Outbound live stream notifications (#1663)
* First pass at browser, discord, twilio notifications * Commit updated Javascript packages * Remove twilio notification support * Email notifications/smtp support * Fix Firefox notification support, remove chrome checks * WIP more email work * Add support for twitter notifications * Add stream title to discord and twitter notifications * Update notification registration modal * Fix hide/show email section * Commit updated API documentation * Commit updated Javascript packages * Fix post-rebase missing var * Remove unused var * Handle unsubscribe errors for browser push * Standardize email config prop names * Allow overriding go live email template * Some notifications cleanup * Commit updated Javascript packages * Remove email/smtp/mailjet support * Remove more references to email notifications Co-authored-by: Owncast <owncast@owncast.online>
This commit is contained in:
BIN
webroot/img/browser-push-notifications-settings.png
Normal file
BIN
webroot/img/browser-push-notifications-settings.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
3
webroot/img/notification-bell.svg
Normal file
3
webroot/img/notification-bell.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="18" viewBox="0 0 16 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.33333 16.0833H9.66667C9.66667 17 8.91667 17.75 8 17.75C7.08333 17.75 6.33333 17 6.33333 16.0833ZM15.5 14.4167V15.25H0.5V14.4167L2.16667 12.75V7.75C2.16667 5.16667 3.83333 2.91667 6.33333 2.16667V1.91667C6.33333 1 7.08333 0.25 8 0.25C8.91667 0.25 9.66667 1 9.66667 1.91667V2.16667C12.1667 2.91667 13.8333 5.16667 13.8333 7.75V12.75L15.5 14.4167ZM12.1667 7.75C12.1667 5.41667 10.3333 3.58333 8 3.58333C5.66667 3.58333 3.83333 5.41667 3.83333 7.75V13.5833H12.1667V7.75Z" fill="#343342"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 600 B |
BIN
webroot/img/owncast-background.png
Normal file
BIN
webroot/img/owncast-background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 MiB |
@@ -25,6 +25,8 @@ import FediverseFollowModal, {
|
||||
FediverseFollowButton,
|
||||
} from './components/fediverse-follow-modal.js';
|
||||
|
||||
import { NotifyButton, NotifyModal } from './components/notification.js';
|
||||
import { isPushNotificationSupported } from './notification/registerWeb.js';
|
||||
import {
|
||||
addNewlines,
|
||||
checkUrlPathForDisplay,
|
||||
@@ -59,8 +61,10 @@ import {
|
||||
URL_STATUS,
|
||||
URL_VIEWER_PING,
|
||||
WIDTH_SINGLE_COL,
|
||||
USER_VISIT_COUNT_KEY,
|
||||
} from './utils/constants.js';
|
||||
import { checkIsModerator } from './utils/chat.js';
|
||||
|
||||
import TabBar from './components/tab-bar.js';
|
||||
|
||||
export default class App extends Component {
|
||||
@@ -138,6 +142,8 @@ export default class App extends Component {
|
||||
this.displayFediverseFollowModal =
|
||||
this.displayFediverseFollowModal.bind(this);
|
||||
this.closeFediverseFollowModal = this.closeFediverseFollowModal.bind(this);
|
||||
this.displayNotificationModal = this.displayNotificationModal.bind(this);
|
||||
this.closeNotificationModal = this.closeNotificationModal.bind(this);
|
||||
|
||||
// player events
|
||||
this.handlePlayerReady = this.handlePlayerReady.bind(this);
|
||||
@@ -179,8 +185,22 @@ export default class App extends Component {
|
||||
});
|
||||
this.player.init();
|
||||
|
||||
this.registerServiceWorker();
|
||||
|
||||
// check routing
|
||||
this.getRoute();
|
||||
|
||||
// Increment the visit counter
|
||||
this.incrementVisitCounter();
|
||||
}
|
||||
|
||||
incrementVisitCounter() {
|
||||
let visits = parseInt(getLocalStorage(USER_VISIT_COUNT_KEY));
|
||||
if (isNaN(visits)) {
|
||||
visits = 0;
|
||||
}
|
||||
|
||||
setLocalStorage(USER_VISIT_COUNT_KEY, visits + 1);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
@@ -248,7 +268,8 @@ export default class App extends Component {
|
||||
}
|
||||
|
||||
setConfigData(data = {}) {
|
||||
const { name, summary, chatDisabled, socketHostOverride } = data;
|
||||
const { name, summary, chatDisabled, socketHostOverride, notifications } =
|
||||
data;
|
||||
window.document.title = name;
|
||||
|
||||
this.socketHostOverride = socketHostOverride;
|
||||
@@ -263,6 +284,7 @@ export default class App extends Component {
|
||||
|
||||
this.setState({
|
||||
canChat: !chatDisabled,
|
||||
notifications,
|
||||
configData: {
|
||||
...data,
|
||||
summary: summary && addNewlines(summary),
|
||||
@@ -579,6 +601,23 @@ export default class App extends Component {
|
||||
this.setState({ fediverseModalData: null });
|
||||
}
|
||||
|
||||
displayNotificationModal(data) {
|
||||
this.setState({ notificationModalData: data });
|
||||
}
|
||||
closeNotificationModal() {
|
||||
this.setState({ notificationModalData: null });
|
||||
}
|
||||
|
||||
async registerServiceWorker() {
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.register('/serviceWorker.js', {
|
||||
scope: '/',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Owncast service worker registration failed!', err);
|
||||
}
|
||||
}
|
||||
|
||||
handleWebsocketMessage(e) {
|
||||
if (e.type === SOCKET_MESSAGE_TYPES.ERROR_USER_DISABLED) {
|
||||
// User has been actively disabled on the backend. Turn off chat for them.
|
||||
@@ -670,6 +709,7 @@ export default class App extends Component {
|
||||
|
||||
render(props, state) {
|
||||
const {
|
||||
accessToken,
|
||||
chatInputEnabled,
|
||||
configData,
|
||||
displayChatPanel,
|
||||
@@ -690,6 +730,8 @@ export default class App extends Component {
|
||||
windowWidth,
|
||||
fediverseModalData,
|
||||
externalActionModalData,
|
||||
notificationModalData,
|
||||
notifications,
|
||||
lastDisconnectTime,
|
||||
section,
|
||||
sectionId,
|
||||
@@ -753,6 +795,14 @@ export default class App extends Component {
|
||||
: html` <${VideoPoster} offlineImage=${logo} active=${streamOnline} /> `;
|
||||
|
||||
// modal buttons
|
||||
const notificationsButton =
|
||||
notifications &&
|
||||
notifications.browser.enabled &&
|
||||
isPushNotificationSupported() &&
|
||||
html`<${NotifyButton}
|
||||
serverName=${name}
|
||||
onClick=${this.displayNotificationModal}
|
||||
/>`;
|
||||
const externalActionButtons = html`<div
|
||||
id="external-actions-container"
|
||||
class="flex flex-row flex-wrap justify-end"
|
||||
@@ -774,6 +824,7 @@ export default class App extends Component {
|
||||
federationInfo=${federation}
|
||||
serverName=${name}
|
||||
/>`}
|
||||
${notificationsButton}
|
||||
</div>`;
|
||||
|
||||
// modal component
|
||||
@@ -800,6 +851,19 @@ export default class App extends Component {
|
||||
/>
|
||||
`;
|
||||
|
||||
const notificationModal =
|
||||
notificationModalData &&
|
||||
html` <${ExternalActionModal}
|
||||
onClose=${this.closeNotificationModal}
|
||||
action=${notificationModalData}
|
||||
useIframe=${false}
|
||||
customContent=${html`<${NotifyModal}
|
||||
notifications=${notifications}
|
||||
streamName=${name}
|
||||
accessToken=${accessToken}
|
||||
/>`}
|
||||
/>`;
|
||||
|
||||
const chat = this.state.websocket
|
||||
? html`
|
||||
<${Chat}
|
||||
@@ -807,7 +871,7 @@ export default class App extends Component {
|
||||
username=${username}
|
||||
chatInputEnabled=${chatInputEnabled && !chatDisabled}
|
||||
instanceTitle=${name}
|
||||
accessToken=${this.state.accessToken}
|
||||
accessToken=${accessToken}
|
||||
inputMaxBytes=${maxSocketPayloadSize - EST_SOCKET_PAYLOAD_BUFFER ||
|
||||
CHAT_MAX_MESSAGE_LENGTH}
|
||||
/>
|
||||
@@ -977,6 +1041,7 @@ export default class App extends Component {
|
||||
</footer>
|
||||
|
||||
${chat} ${externalActionModal} ${fediverseFollowModal}
|
||||
${notificationModal}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -46,11 +46,9 @@ export default class ChatMessageView extends Component {
|
||||
const { message, isModerator, accessToken } = this.props;
|
||||
const { user, timestamp } = message;
|
||||
|
||||
// User is required for this component to render.
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { displayName, displayColor, createdAt, isBot } = user;
|
||||
const isAuthorModerator = checkIsModerator(message);
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ export default class ExternalActionModal extends Component {
|
||||
>
|
||||
<iframe
|
||||
id="external-modal-iframe"
|
||||
style=${iframeStyle}
|
||||
style=${{ iframeStyle }}
|
||||
class="bg-gray-100 bg-center bg-no-repeat"
|
||||
width="100%"
|
||||
allowpaymentrequest="true"
|
||||
|
||||
405
webroot/js/components/notification.js
Normal file
405
webroot/js/components/notification.js
Normal file
@@ -0,0 +1,405 @@
|
||||
import { h } from '/js/web_modules/preact.js';
|
||||
import { useState, useEffect } from '/js/web_modules/preact/hooks.js';
|
||||
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
import { ExternalActionButton } from './external-action-modal.js';
|
||||
import {
|
||||
registerWebPushNotifications,
|
||||
isPushNotificationSupported,
|
||||
} from '../notification/registerWeb.js';
|
||||
import {
|
||||
URL_REGISTER_NOTIFICATION,
|
||||
URL_REGISTER_EMAIL_NOTIFICATION,
|
||||
HAS_DISPLAYED_NOTIFICATION_MODAL_KEY,
|
||||
USER_VISIT_COUNT_KEY,
|
||||
USER_DISMISSED_ANNOYING_NOTIFICATION_POPUP_KEY,
|
||||
} from '../utils/constants.js';
|
||||
import { setLocalStorage, getLocalStorage } from '../utils/helpers.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
export function NotifyModal({ notifications, streamName, accessToken }) {
|
||||
const [error, setError] = useState(null);
|
||||
const [loaderStyle, setLoaderStyle] = useState('none');
|
||||
const [emailNotificationsButtonEnabled, setEmailNotificationsButtonEnabled] =
|
||||
useState(false);
|
||||
const [emailAddress, setEmailAddress] = useState(null);
|
||||
const emailNotificationButtonState = emailNotificationsButtonEnabled
|
||||
? ''
|
||||
: 'cursor-not-allowed opacity-50';
|
||||
const [browserPushPermissionsPending, setBrowserPushPermissionsPending] =
|
||||
useState(false);
|
||||
|
||||
const { browser, email } = notifications;
|
||||
const { publicKey } = browser;
|
||||
|
||||
const browserPushEnabled = browser.enabled && isPushNotificationSupported();
|
||||
let emailEnabled = email.enabled;
|
||||
|
||||
// Store that the user has opened the notifications modal at least once
|
||||
// so we don't ever need to remind them to do it again.
|
||||
useEffect(() => {
|
||||
setLocalStorage(HAS_DISPLAYED_NOTIFICATION_MODAL_KEY, true);
|
||||
}, []);
|
||||
|
||||
async function saveNotificationRegistration(channel, destination) {
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ channel: channel, destination: destination }),
|
||||
};
|
||||
|
||||
try {
|
||||
await fetch(
|
||||
URL_REGISTER_NOTIFICATION + `?accessToken=${accessToken}`,
|
||||
options
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function startBrowserPushRegistration() {
|
||||
// If it's already denied or granted, don't do anything.
|
||||
if (Notification.permission !== 'default') {
|
||||
return;
|
||||
}
|
||||
|
||||
setBrowserPushPermissionsPending(true);
|
||||
try {
|
||||
const subscription = await registerWebPushNotifications(publicKey);
|
||||
saveNotificationRegistration('BROWSER_PUSH_NOTIFICATION', subscription);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(
|
||||
`Error registering for live notifications: ${e.message}. Make sure you're not inside a private browser environment or have previously disabled notifications for this stream.`
|
||||
);
|
||||
}
|
||||
setBrowserPushPermissionsPending(false);
|
||||
}
|
||||
|
||||
async function handlePushToggleChange() {
|
||||
// Nothing can be done if they already denied access.
|
||||
if (Notification.permission === 'denied') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pushEnabled) {
|
||||
startBrowserPushRegistration();
|
||||
}
|
||||
}
|
||||
|
||||
async function registerForEmailButtonPressed() {
|
||||
try {
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ emailAddress: emailAddress }),
|
||||
};
|
||||
|
||||
try {
|
||||
await fetch(
|
||||
URL_REGISTER_EMAIL_NOTIFICATION + `?accessToken=${accessToken}`,
|
||||
options
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(`Error registering for email notifications: ${e.message}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function onEmailInput(e) {
|
||||
const { value } = e.target;
|
||||
|
||||
// TODO: Add validation for email
|
||||
const valid = true;
|
||||
|
||||
setEmailAddress(value);
|
||||
setEmailNotificationsButtonEnabled(valid);
|
||||
}
|
||||
|
||||
function getBrowserPushButtonText() {
|
||||
let pushNotificationButtonText = html`<span id="push-notification-arrow"
|
||||
>←</span
|
||||
>
|
||||
CLICK TO ENABLE`;
|
||||
if (browserPushPermissionsPending) {
|
||||
pushNotificationButtonText = '↑ ACCEPT THE BROWSER PERMISSIONS';
|
||||
} else if (Notification.permission === 'granted') {
|
||||
pushNotificationButtonText = 'ENABLED';
|
||||
} else if (Notification.permission === 'denied') {
|
||||
pushNotificationButtonText = 'DENIED. PLEASE FIX BROWSER PERMISSIONS.';
|
||||
}
|
||||
return pushNotificationButtonText;
|
||||
}
|
||||
|
||||
const pushEnabled = Notification.permission === 'granted';
|
||||
|
||||
return html`
|
||||
<div class="bg-gray-100 bg-center bg-no-repeat p-6">
|
||||
<div
|
||||
style=${{ display: emailEnabled ? 'grid' : 'none' }}
|
||||
class="grid grid-cols-2 gap-10 px-5 py-8"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-slate-600 text-2xl mb-2 font-semibold">
|
||||
Email Notifications
|
||||
</h2>
|
||||
|
||||
<h2>
|
||||
Get notified directly to your email when this stream goes live.
|
||||
</h2>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold">Enter your email address:</div>
|
||||
<input
|
||||
class="border bg-white rounded-l w-8/12 mt-2 mb-1 mr-1 py-2 px-3 text-indigo-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
value=${emailAddress}
|
||||
onInput=${onEmailInput}
|
||||
placeholder="streamlover42@gmail.com"
|
||||
/>
|
||||
<button
|
||||
class="rounded-sm inline px-3 py-2 text-base text-white bg-indigo-700 ${emailNotificationButtonState}"
|
||||
onClick=${registerForEmailButtonPressed}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<div class="text-sm mt-3 text-gray-700">
|
||||
Stop receiving emails any time by clicking the unsubscribe link in
|
||||
the email. <a href="">Learn more.</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr
|
||||
style=${{ display: pushEnabled && emailEnabled ? 'block' : 'none' }}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-2 gap-10 px-5 py-8"
|
||||
style=${{ display: browserPushEnabled ? 'grid' : 'none' }}
|
||||
>
|
||||
<div>
|
||||
<div>
|
||||
<div
|
||||
class="text-sm border-2 p-4 border-red-300"
|
||||
style=${{
|
||||
display:
|
||||
Notification.permission === 'denied' ? 'block' : 'none',
|
||||
}}
|
||||
>
|
||||
Browser notification permissions were denied. Please visit your
|
||||
browser settings to re-enable in order to get notifications.
|
||||
</div>
|
||||
<div
|
||||
class="form-check form-switch"
|
||||
style=${{
|
||||
display:
|
||||
Notification.permission === 'denied' ? 'none' : 'block',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="relative inline-block w-10 align-middle select-none transition duration-200 ease-in"
|
||||
>
|
||||
<input
|
||||
checked=${pushEnabled || browserPushPermissionsPending}
|
||||
disabled=${pushEnabled}
|
||||
type="checkbox"
|
||||
name="toggle"
|
||||
id="toggle"
|
||||
onchange=${handlePushToggleChange}
|
||||
class="toggle-checkbox absolute block w-8 h-8 rounded-full bg-white border-4 appearance-none cursor-pointer"
|
||||
/>
|
||||
<label
|
||||
for="toggle"
|
||||
style=${{ width: '50px' }}
|
||||
class="toggle-label block overflow-hidden h-8 rounded-full bg-gray-300 cursor-pointer"
|
||||
></label>
|
||||
</div>
|
||||
<div class="ml-8 text-xs inline-block text-gray-700">
|
||||
${getBrowserPushButtonText()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="text-slate-600 text-2xl mt-4 mb-2 font-semibold">
|
||||
Browser Notifications
|
||||
</h2>
|
||||
<h2>
|
||||
Get notified right in the browser each time this stream goes live.
|
||||
</h2>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="text-sm mt-3"
|
||||
style=${{ display: !pushEnabled ? 'none' : 'block' }}
|
||||
>
|
||||
To disable push notifications from ${window.location.hostname}
|
||||
${' '} access your browser permissions for this site and turn off
|
||||
notifications.
|
||||
<div style=${{ 'margin-top': '5px' }}>
|
||||
<a href="">Learn more.</a>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="browser-push-preview-box"
|
||||
class="w-full bg-white p-4 m-2 mt-4"
|
||||
style=${{ display: pushEnabled ? 'none' : 'block' }}
|
||||
>
|
||||
<div class="text-lg text-gray-700 ml-2 my-2">
|
||||
${window.location.toString()} wants to
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 my-2">
|
||||
<svg
|
||||
class="mr-3"
|
||||
style=${{ display: 'inline-block' }}
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M14 12.3333V13H2V12.3333L3.33333 11V7C3.33333 4.93333 4.68667 3.11333 6.66667 2.52667C6.66667 2.46 6.66667 2.4 6.66667 2.33333C6.66667 1.97971 6.80714 1.64057 7.05719 1.39052C7.30724 1.14048 7.64638 1 8 1C8.35362 1 8.69276 1.14048 8.94281 1.39052C9.19286 1.64057 9.33333 1.97971 9.33333 2.33333C9.33333 2.4 9.33333 2.46 9.33333 2.52667C11.3133 3.11333 12.6667 4.93333 12.6667 7V11L14 12.3333ZM9.33333 13.6667C9.33333 14.0203 9.19286 14.3594 8.94281 14.6095C8.69276 14.8595 8.35362 15 8 15C7.64638 15 7.30724 14.8595 7.05719 14.6095C6.80714 14.3594 6.66667 14.0203 6.66667 13.6667"
|
||||
fill="#676670"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
Show notifications
|
||||
</div>
|
||||
<div class="flex flex-row justify-end">
|
||||
<button
|
||||
class="bg-blue-500 py-1 px-4 mr-4 rounded-sm text-white"
|
||||
onClick=${startBrowserPushRegistration}
|
||||
>
|
||||
Allow
|
||||
</button>
|
||||
<button
|
||||
class="bg-slate-200 py-1 px-4 rounded-sm text-gray-500 cursor-not-allowed"
|
||||
style=${{
|
||||
'outline-width': 1,
|
||||
'outline-color': '#e2e8f0',
|
||||
'outline-style': 'solid',
|
||||
}}
|
||||
>
|
||||
Block
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
class="text-gray-700 text-sm mt-6"
|
||||
style=${{ display: pushEnabled ? 'none' : 'block' }}
|
||||
>
|
||||
You'll need to allow your browser to receive notifications from
|
||||
${' '} ${streamName}, first.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="follow-loading-spinner-container"
|
||||
style="display: ${loaderStyle}"
|
||||
>
|
||||
<img id="follow-loading-spinner" src="/img/loading.gif" />
|
||||
<p class="text-gray-700 text-lg">Contacting your server.</p>
|
||||
<p class="text-gray-600 text-lg">Please wait...</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function NotifyButton({ serverName, onClick }) {
|
||||
const hasDisplayedNotificationModal = getLocalStorage(
|
||||
HAS_DISPLAYED_NOTIFICATION_MODAL_KEY
|
||||
);
|
||||
|
||||
const hasPreviouslyDismissedAnnoyingPopup = getLocalStorage(
|
||||
USER_DISMISSED_ANNOYING_NOTIFICATION_POPUP_KEY
|
||||
);
|
||||
|
||||
let visits = parseInt(getLocalStorage(USER_VISIT_COUNT_KEY));
|
||||
if (isNaN(visits)) {
|
||||
visits = 0;
|
||||
}
|
||||
|
||||
// Only show the annoying popup if the user has never opened the notification
|
||||
// modal previously _and_ they've visited more than 3 times.
|
||||
const [showPopup, setShowPopup] = useState(
|
||||
!hasPreviouslyDismissedAnnoyingPopup &&
|
||||
!hasDisplayedNotificationModal &&
|
||||
visits > 3
|
||||
);
|
||||
|
||||
const notifyAction = {
|
||||
color: 'rgba(219, 223, 231, 1)',
|
||||
description: `Never miss a stream! Get notified when ${serverName} goes live.`,
|
||||
icon: '/img/notification-bell.svg',
|
||||
openExternally: false,
|
||||
};
|
||||
|
||||
const buttonClicked = (e) => {
|
||||
onClick(e);
|
||||
setShowPopup(false);
|
||||
};
|
||||
|
||||
const notifyPopupDismissedClicked = () => {
|
||||
setShowPopup(false);
|
||||
setLocalStorage(USER_DISMISSED_ANNOYING_NOTIFICATION_POPUP_KEY, true);
|
||||
};
|
||||
|
||||
return html`
|
||||
<span id="notify-button-container" class="relative">
|
||||
<div
|
||||
id="follow-button-popup"
|
||||
style=${{ display: showPopup ? 'block' : 'none' }}
|
||||
>
|
||||
<svg
|
||||
width="192"
|
||||
height="113"
|
||||
viewBox="0 0 192 113"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M8 0C3.58172 0 0 3.58172 0 8V91C0 95.4183 3.58173 99 8 99H172L188.775 112.001C190.089 113.019 192 112.082 192 110.42V99V8C192 3.58172 188.418 0 184 0H8Z"
|
||||
fill="#6965F0"
|
||||
/>
|
||||
<text x="20" y="55" fill="white" font-size="13px">
|
||||
Click and never miss
|
||||
</text>
|
||||
<text x="20" y="75" fill="white" font-size="13px">
|
||||
future streams.
|
||||
</text>
|
||||
</svg>
|
||||
<button
|
||||
class="absolute"
|
||||
style=${{ top: '6px', right: '6px' }}
|
||||
onClick=${notifyPopupDismissedClicked}
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M17.7071 7.70711C18.0976 7.31658 18.0976 6.68342 17.7071 6.29289C17.3166 5.90237 16.6834 5.90237 16.2929 6.29289L12 10.5858L7.70711 6.29289C7.31658 5.90237 6.68342 5.90237 6.29289 6.29289C5.90237 6.68342 5.90237 7.31658 6.29289 7.70711L10.5858 12L6.29289 16.2929C5.90237 16.6834 5.90237 17.3166 6.29289 17.7071C6.68342 18.0976 7.31658 18.0976 7.70711 17.7071L12 13.4142L16.2929 17.7071C16.6834 18.0976 17.3166 18.0976 17.7071 17.7071C18.0976 17.3166 18.0976 16.6834 17.7071 16.2929L13.4142 12L17.7071 7.70711Z"
|
||||
fill="#A5A3F6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<${ExternalActionButton}
|
||||
onClick=${buttonClicked}
|
||||
action=${notifyAction}
|
||||
/>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
30
webroot/js/notification/registerWeb.js
Normal file
30
webroot/js/notification/registerWeb.js
Normal file
@@ -0,0 +1,30 @@
|
||||
export async function registerWebPushNotifications(vapidPublicKey) {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
|
||||
if (!subscription) {
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify(subscription);
|
||||
}
|
||||
|
||||
export function isPushNotificationSupported() {
|
||||
return 'serviceWorker' in navigator && 'PushManager' in window;
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
var padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
var base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/');
|
||||
|
||||
var rawData = window.atob(base64);
|
||||
var outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (var i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
@@ -19,6 +19,9 @@ export const URL_CHAT_REGISTRATION = `/api/chat/register`;
|
||||
export const URL_FOLLOWERS = `/api/followers`;
|
||||
export const URL_PLAYBACK_METRICS = `/api/metrics/playback`;
|
||||
|
||||
export const URL_REGISTER_NOTIFICATION = `/api/notifications/register`;
|
||||
export const URL_REGISTER_EMAIL_NOTIFICATION = `/api/notifications/register/email`;
|
||||
|
||||
export const TIMER_STATUS_UPDATE = 5000; // ms
|
||||
export const TIMER_DISABLE_CHAT_AFTER_OFFLINE = 5 * 60 * 1000; // 5 mins
|
||||
export const TIMER_STREAM_DURATION_COUNTER = 1000;
|
||||
@@ -65,3 +68,10 @@ export const WIDTH_SINGLE_COL = 780;
|
||||
export const HEIGHT_SHORT_WIDE = 500;
|
||||
export const ORIENTATION_PORTRAIT = 'portrait';
|
||||
export const ORIENTATION_LANDSCAPE = 'landscape';
|
||||
|
||||
// localstorage keys
|
||||
export const HAS_DISPLAYED_NOTIFICATION_MODAL_KEY =
|
||||
'HAS_DISPLAYED_NOTIFICATION_MODAL';
|
||||
export const USER_VISIT_COUNT_KEY = 'USER_VISIT_COUNT';
|
||||
export const USER_DISMISSED_ANNOYING_NOTIFICATION_POPUP_KEY =
|
||||
'USER_DISMISSED_ANNOYING_NOTIFICATION_POPUP_KEY';
|
||||
|
||||
1
webroot/js/web_modules/import-map.json
vendored
1
webroot/js/web_modules/import-map.json
vendored
@@ -6,6 +6,7 @@
|
||||
"mark.js/dist/mark.es6.min.js": "./markjs/dist/mark.es6.min.js",
|
||||
"micromodal/dist/micromodal.min.js": "./micromodal/dist/micromodal.min.js",
|
||||
"preact": "./preact.js",
|
||||
"preact/hooks": "./preact/hooks.js",
|
||||
"tailwindcss/dist/tailwind.min.css": "./tailwindcss/dist/tailwind.min.css",
|
||||
"video.js/dist/video-js.min.css": "./videojs/dist/video-js.min.css",
|
||||
"video.js/dist/video.min.js": "./videojs/dist/video.min.js"
|
||||
|
||||
5
webroot/js/web_modules/preact/hooks.js
Normal file
5
webroot/js/web_modules/preact/hooks.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { options as l$1 } from '../preact.js';
|
||||
|
||||
var t,u,r,o=0,i=[],c=l$1.__b,f=l$1.__r,e=l$1.diffed,a=l$1.__c,v=l$1.unmount;function m(t,r){l$1.__h&&l$1.__h(u,t,o||r),o=0;var i=u.__H||(u.__H={__:[],__h:[]});return t>=i.__.length&&i.__.push({}),i.__[t]}function l(n){return o=1,p(w,n)}function p(n,r,o){var i=m(t++,2);return i.t=n,i.__c||(i.__=[o?o(r):w(void 0,r),function(n){var t=i.t(i.__[0],n);i.__[0]!==t&&(i.__=[t,i.__[1]],i.__c.setState({}));}],i.__c=u),i.__}function y(r,o){var i=m(t++,3);!l$1.__s&&k(i.__H,o)&&(i.__=r,i.__H=o,u.__H.__h.push(i));}function h(r,o){var i=m(t++,4);!l$1.__s&&k(i.__H,o)&&(i.__=r,i.__H=o,u.__h.push(i));}function s(n){return o=5,d(function(){return {current:n}},[])}function _(n,t,u){o=6,h(function(){"function"==typeof n?n(t()):n&&(n.current=t());},null==u?u:u.concat(n));}function d(n,u){var r=m(t++,7);return k(r.__H,u)&&(r.__=n(),r.__H=u,r.__h=n),r.__}function A(n,t){return o=8,d(function(){return n},t)}function F(n){var r=u.context[n.__c],o=m(t++,9);return o.c=n,r?(null==o.__&&(o.__=!0,r.sub(u)),r.props.value):n.__}function T(t,u){l$1.useDebugValue&&l$1.useDebugValue(u?u(t):t);}function q(n){var r=m(t++,10),o=l();return r.__=n,u.componentDidCatch||(u.componentDidCatch=function(n){r.__&&r.__(n),o[1](n);}),[o[0],function(){o[1](void 0);}]}function x(){for(var t;t=i.shift();)if(t.__P)try{t.__H.__h.forEach(g),t.__H.__h.forEach(j),t.__H.__h=[];}catch(u){t.__H.__h=[],l$1.__e(u,t.__v);}}l$1.__b=function(n){u=null,c&&c(n);},l$1.__r=function(n){f&&f(n),t=0;var r=(u=n.__c).__H;r&&(r.__h.forEach(g),r.__h.forEach(j),r.__h=[]);},l$1.diffed=function(t){e&&e(t);var o=t.__c;o&&o.__H&&o.__H.__h.length&&(1!==i.push(o)&&r===l$1.requestAnimationFrame||((r=l$1.requestAnimationFrame)||function(n){var t,u=function(){clearTimeout(r),b&&cancelAnimationFrame(t),setTimeout(n);},r=setTimeout(u,100);b&&(t=requestAnimationFrame(u));})(x)),u=null;},l$1.__c=function(t,u){u.some(function(t){try{t.__h.forEach(g),t.__h=t.__h.filter(function(n){return !n.__||j(n)});}catch(r){u.some(function(n){n.__h&&(n.__h=[]);}),u=[],l$1.__e(r,t.__v);}}),a&&a(t,u);},l$1.unmount=function(t){v&&v(t);var u,r=t.__c;r&&r.__H&&(r.__H.__.forEach(function(n){try{g(n);}catch(n){u=n;}}),u&&l$1.__e(u,r.__v));};var b="function"==typeof requestAnimationFrame;function g(n){var t=u,r=n.__c;"function"==typeof r&&(n.__c=void 0,r()),u=t;}function j(n){var t=u;n.__c=n.__(),u=t;}function k(n,t){return !n||n.length!==t.length||t.some(function(t,u){return t!==n[u]})}function w(n,t){return "function"==typeof t?t(n):t}
|
||||
|
||||
export { A as useCallback, F as useContext, T as useDebugValue, y as useEffect, q as useErrorBoundary, _ as useImperativeHandle, h as useLayoutEffect, d as useMemo, p as useReducer, s as useRef, l as useState };
|
||||
24
webroot/serviceWorker.js
Normal file
24
webroot/serviceWorker.js
Normal file
@@ -0,0 +1,24 @@
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('Owncast service worker activated', event);
|
||||
});
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('installing Owncast service worker...', event);
|
||||
});
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
const data = JSON.parse(event.data.text());
|
||||
const { title, body, icon, tag } = data;
|
||||
const options = {
|
||||
title: title || 'Live!',
|
||||
body: body || 'This live stream has started.',
|
||||
icon: icon || '/logo/external',
|
||||
tag: tag,
|
||||
};
|
||||
|
||||
event.waitUntil(self.registration.showNotification(options.title, options));
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
clients.openWindow('/');
|
||||
});
|
||||
@@ -8,7 +8,7 @@ May have overrides for other components with own stylesheets.
|
||||
--header-height: 3.5em;
|
||||
--right-col-width: 24em;
|
||||
--video-container-height: calc((9 / 16) * 100vw);
|
||||
--header-bg-color: rgba(20,0,40,1);
|
||||
--header-bg-color: rgba(20, 0, 40, 1);
|
||||
--user-image-width: 10em;
|
||||
|
||||
--novideo-container-height: 16em;
|
||||
@@ -51,7 +51,7 @@ a:hover {
|
||||
}
|
||||
|
||||
button[disabled] {
|
||||
opacity: .5;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -66,7 +66,6 @@ button[disabled] {
|
||||
white-space: nowrap; /* added line */
|
||||
}
|
||||
|
||||
|
||||
header {
|
||||
height: var(--header-height);
|
||||
background-color: var(--header-bg-color);
|
||||
@@ -94,7 +93,7 @@ header {
|
||||
box-shadow: var(--owncast-purple) 0px 0px 5px;
|
||||
}
|
||||
.external-action-icon {
|
||||
margin: .25em .5em .25em 0;
|
||||
margin: 0.25em 0.5em 0.25em 0;
|
||||
}
|
||||
.external-action-icon img {
|
||||
height: 1.5em;
|
||||
@@ -122,7 +121,7 @@ header {
|
||||
background-size: 30%;
|
||||
}
|
||||
#video-container #video {
|
||||
transition: opacity .5s;
|
||||
transition: opacity 0.5s;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -146,7 +145,7 @@ header {
|
||||
}
|
||||
|
||||
.chat-hidden #chat-toggle {
|
||||
opacity: .75;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* hide chat by default */
|
||||
@@ -182,11 +181,10 @@ header {
|
||||
}
|
||||
|
||||
/* display `send` button on mobile */
|
||||
.touch-screen #send-message-button{
|
||||
.touch-screen #send-message-button {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
|
||||
/* *********** single col layout ***************************** */
|
||||
|
||||
.single-col {
|
||||
@@ -245,7 +243,6 @@ header {
|
||||
max-height: 3em;
|
||||
}
|
||||
|
||||
|
||||
.single-col .user-logo-icons {
|
||||
margin-right: 0;
|
||||
margin-bottom: 1em;
|
||||
@@ -264,7 +261,7 @@ header {
|
||||
margin-left: 1em;
|
||||
}
|
||||
.single-col .follow-icon-list {
|
||||
justify-content: flex-start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.single-col.use-fediverse-follow #fediverse-button-singlecol {
|
||||
display: inline-block;
|
||||
@@ -274,7 +271,6 @@ header {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
|
||||
@media screen and (max-height: 500px) {
|
||||
.single-col.touch-screen:not(.touch-keyboard-active) {
|
||||
--header-height: 0px;
|
||||
@@ -285,14 +281,12 @@ header {
|
||||
}
|
||||
/* ************************************************ */
|
||||
|
||||
|
||||
.no-video #video-container {
|
||||
min-height: var(--video-container-height);
|
||||
}
|
||||
|
||||
/* ************************************************ */
|
||||
|
||||
|
||||
@media screen and (max-width: 860px) {
|
||||
:root {
|
||||
--right-col-width: 20em;
|
||||
@@ -307,7 +301,6 @@ header {
|
||||
|
||||
/* ************************************************ */
|
||||
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
#user-info-change {
|
||||
width: 75vw;
|
||||
@@ -319,12 +312,10 @@ header {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#external-modal-iframe {
|
||||
height: 70vh;
|
||||
}
|
||||
|
||||
|
||||
/**************************
|
||||
Basic Modal Styles
|
||||
**************************/
|
||||
@@ -342,7 +333,7 @@ header {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.75);
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -353,9 +344,8 @@ header {
|
||||
position: relative;
|
||||
background-color: transparent;
|
||||
padding: 0px;
|
||||
max-width: 740px;
|
||||
min-width: 500px;
|
||||
width: 50%;
|
||||
max-width: 780px;
|
||||
width: 60%;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -373,22 +363,25 @@ header {
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
outline: none;
|
||||
cursor: pointer !important;
|
||||
cursor: pointer !important;
|
||||
}
|
||||
.modal__close:before {
|
||||
content: '\2715';
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.modal__close:before { content: "\2715"; font-size: 1.25rem; }
|
||||
|
||||
@supports (display: flex) {
|
||||
.modal__header {
|
||||
.modal__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height:initial;
|
||||
}
|
||||
height: initial;
|
||||
}
|
||||
.modal__title {
|
||||
position: static;
|
||||
position: static;
|
||||
}
|
||||
.modal__close {
|
||||
position: static;
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,23 +390,39 @@ header {
|
||||
**************************/
|
||||
|
||||
@keyframes mmfadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mmfadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mmslideIn {
|
||||
from { transform: translateY(15%); }
|
||||
to { transform: translateY(0); }
|
||||
from {
|
||||
transform: translateY(15%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mmslideOut {
|
||||
from { transform: translateY(0); }
|
||||
to { transform: translateY(-10%); }
|
||||
from {
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
transform: translateY(-10%);
|
||||
}
|
||||
}
|
||||
|
||||
.micromodal-slide {
|
||||
@@ -424,20 +433,20 @@ header {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.micromodal-slide[aria-hidden="false"] .modal__overlay {
|
||||
animation: mmfadeIn .3s cubic-bezier(0.0, 0.0, 0.2, 1);
|
||||
.micromodal-slide[aria-hidden='false'] .modal__overlay {
|
||||
animation: mmfadeIn 0.3s cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.micromodal-slide[aria-hidden="false"] .modal__container {
|
||||
animation: mmslideIn .3s cubic-bezier(0, 0, .2, 1);
|
||||
.micromodal-slide[aria-hidden='false'] .modal__container {
|
||||
animation: mmslideIn 0.3s cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.micromodal-slide[aria-hidden="true"] .modal__overlay {
|
||||
animation: mmfadeOut .3s cubic-bezier(0.0, 0.0, 0.2, 1);
|
||||
.micromodal-slide[aria-hidden='true'] .modal__overlay {
|
||||
animation: mmfadeOut 0.3s cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.micromodal-slide[aria-hidden="true"] .modal__container {
|
||||
animation: mmslideOut .3s cubic-bezier(0, 0, .2, 1);
|
||||
.micromodal-slide[aria-hidden='true'] .modal__container {
|
||||
animation: mmslideOut 0.3s cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.micromodal-slide .modal__container,
|
||||
@@ -446,40 +455,39 @@ header {
|
||||
}
|
||||
|
||||
/* Miromodal mobile styling */
|
||||
@media only screen and (min-device-width : 600px) and (max-device-width : 480px) {
|
||||
.modal__container {
|
||||
width: 90% !important;
|
||||
min-width: 90% !important;
|
||||
}
|
||||
@supports (display: flex) {
|
||||
.modal__container {
|
||||
width: 90% !important;
|
||||
min-width: 90% !important;
|
||||
@media only screen and (min-device-width: 600px) and (max-device-width: 480px) {
|
||||
.modal__container {
|
||||
width: 90% !important;
|
||||
min-width: 90% !important;
|
||||
}
|
||||
@supports (display: flex) {
|
||||
.modal__container {
|
||||
width: 90% !important;
|
||||
min-width: 90% !important;
|
||||
height: 85vh;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.modal__content {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**************************
|
||||
Tab Bar Base Styles
|
||||
**************************/
|
||||
.tab-bar [role="tab"] {
|
||||
padding: .5rem 1rem;
|
||||
border-radius: .25rem .25rem 0 0;
|
||||
.tab-bar [role='tab'] {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.25rem 0.25rem 0 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
.tab-bar [role="tab"]:hover {
|
||||
background-color: rgba(255,255,255,.35);
|
||||
.tab-bar [role='tab']:hover {
|
||||
background-color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
.tab-bar [role="tab"][aria-selected="true"] {
|
||||
.tab-bar [role='tab'][aria-selected='true'] {
|
||||
color: var(--owncast-purple);
|
||||
background-color: white;
|
||||
}
|
||||
.tab-bar [role="tabpanel"] {
|
||||
.tab-bar [role='tabpanel'] {
|
||||
padding: 1rem 0;
|
||||
border-top: 1px solid var(--header-bg-color);
|
||||
min-height: 15rem;
|
||||
@@ -487,7 +495,7 @@ header {
|
||||
|
||||
.follower {
|
||||
width: 20vw;
|
||||
max-width: 300px
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.following-list-follower {
|
||||
@@ -512,3 +520,60 @@ header {
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
/* CHECKBOX TOGGLE SWITCH */
|
||||
/* @apply rules for documentation, these do not work as inline style */
|
||||
.toggle-checkbox:checked {
|
||||
/* @apply: right-0 #5A67D8; */
|
||||
left: 63%;
|
||||
/* border-color:var(--owncast-purple); */
|
||||
}
|
||||
|
||||
#toggle:checked {
|
||||
background-color: var(--owncast-purple);
|
||||
}
|
||||
|
||||
.toggle-checkbox:checked + .toggle-label {
|
||||
outline: 1px solid var(--owncast-purple);
|
||||
}
|
||||
|
||||
#browser-push-preview-box {
|
||||
outline-offset: 10px;
|
||||
outline: 2px dashed #acafb4;
|
||||
box-shadow: 2px 6px 7px 0px #87898d;
|
||||
}
|
||||
|
||||
#push-notification-arrow {
|
||||
position: relative;
|
||||
-webkit-animation: cubic-bezier(0.785, 0.135, 0.15, 0.86) infinite;
|
||||
-webkit-animation-name: left-right-bounce;
|
||||
-webkit-animation-duration: 1.5s;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
@-webkit-keyframes left-right-bounce {
|
||||
0% {
|
||||
left: 0;
|
||||
}
|
||||
50% {
|
||||
left: -6px;
|
||||
}
|
||||
100% {
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
#notify-button-container #follow-button-popup {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
right: 30px;
|
||||
bottom: 42px;
|
||||
}
|
||||
|
||||
#notify-button-container .external-action-icon {
|
||||
margin: 0.25em 0.5em 0.25em 0.5em;
|
||||
}
|
||||
|
||||
#notify-button-container button {
|
||||
border-color: #b8bbc2;
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user