diff --git a/package.json b/package.json index 102e8282..226fa5dd 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@styled-icons/simple-icons": "^10.33.0", "@traptitech/markdown-it-katex": "^3.4.3", "@traptitech/markdown-it-spoiler": "^1.1.6", + "@types/lodash.isequal": "^4.5.5", "@types/markdown-it": "^12.0.2", "@types/node": "^15.12.4", "@types/preact-i18n": "^2.3.0", @@ -53,6 +54,7 @@ "highlight.js": "^11.0.1", "idb": "^6.1.2", "localforage": "^1.9.0", + "lodash.isequal": "^4.5.0", "markdown-it": "^12.0.6", "markdown-it-emoji": "^2.0.0", "markdown-it-sub": "^1.0.0", diff --git a/src/assets/sounds/Audio.ts b/src/assets/sounds/Audio.ts new file mode 100644 index 00000000..37da121e --- /dev/null +++ b/src/assets/sounds/Audio.ts @@ -0,0 +1,21 @@ +import message from './message.mp3'; +import call_join from './call_join.mp3'; +import call_leave from './call_leave.mp3'; + +const SoundMap: { [key in Sounds]: string } = { + message, + call_join, + call_leave +} + +export type Sounds = 'message' | 'call_join' | 'call_leave'; + +export function playSound(sound: Sounds) { + let file = SoundMap[sound]; + let el = new Audio(file); + try { + el.play(); + } catch (err) { + console.error('Failed to play audio file', file, err); + } +} diff --git a/public/assets/sounds/call_join.mp3 b/src/assets/sounds/call_join.mp3 similarity index 100% rename from public/assets/sounds/call_join.mp3 rename to src/assets/sounds/call_join.mp3 diff --git a/public/assets/sounds/call_join.ogg b/src/assets/sounds/call_join.ogg similarity index 100% rename from public/assets/sounds/call_join.ogg rename to src/assets/sounds/call_join.ogg diff --git a/public/assets/sounds/call_leave.mp3 b/src/assets/sounds/call_leave.mp3 similarity index 100% rename from public/assets/sounds/call_leave.mp3 rename to src/assets/sounds/call_leave.mp3 diff --git a/public/assets/sounds/call_leave.ogg b/src/assets/sounds/call_leave.ogg similarity index 100% rename from public/assets/sounds/call_leave.ogg rename to src/assets/sounds/call_leave.ogg diff --git a/public/assets/sounds/inbound.mp3 b/src/assets/sounds/inbound.mp3 similarity index 100% rename from public/assets/sounds/inbound.mp3 rename to src/assets/sounds/inbound.mp3 diff --git a/public/assets/sounds/inbound.ogg b/src/assets/sounds/inbound.ogg similarity index 100% rename from public/assets/sounds/inbound.ogg rename to src/assets/sounds/inbound.ogg diff --git a/public/assets/sounds/message.mp3 b/src/assets/sounds/message.mp3 similarity index 100% rename from public/assets/sounds/message.mp3 rename to src/assets/sounds/message.mp3 diff --git a/public/assets/sounds/message.ogg b/src/assets/sounds/message.ogg similarity index 100% rename from public/assets/sounds/message.ogg rename to src/assets/sounds/message.ogg diff --git a/public/assets/sounds/outbound.mp3 b/src/assets/sounds/outbound.mp3 similarity index 100% rename from public/assets/sounds/outbound.mp3 rename to src/assets/sounds/outbound.mp3 diff --git a/public/assets/sounds/outbound.ogg b/src/assets/sounds/outbound.ogg similarity index 100% rename from public/assets/sounds/outbound.ogg rename to src/assets/sounds/outbound.ogg diff --git a/src/components/common/messaging/Message.tsx b/src/components/common/messaging/Message.tsx index 82cd15ed..4de938ea 100644 --- a/src/components/common/messaging/Message.tsx +++ b/src/components/common/messaging/Message.tsx @@ -33,7 +33,7 @@ export default function Message({ attachContext, message, contrast, content: rep { head && } - { content ?? } + { replacement ?? } { message.attachments?.map((attachment, index) => 0 || content.length > 0 } />) } { message.embeds?.map((embed, index) => diff --git a/src/components/common/messaging/MessageBox.tsx b/src/components/common/messaging/MessageBox.tsx index 26825ca8..32b4e347 100644 --- a/src/components/common/messaging/MessageBox.tsx +++ b/src/components/common/messaging/MessageBox.tsx @@ -1,16 +1,16 @@ -import { useContext } from "preact/hooks"; -import { Channel } from "revolt.js"; import { ulid } from "ulid"; -import { AppContext } from "../../../context/revoltjs/RevoltClient"; -import { takeError } from "../../../context/revoltjs/util"; +import { Channel } from "revolt.js"; +import TextArea from "../../ui/TextArea"; +import { useContext } from "preact/hooks"; import { defer } from "../../../lib/defer"; -import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; -import { SingletonMessageRenderer, SMOOTH_SCROLL_ON_RECEIVE } from "../../../lib/renderer/Singleton"; +import IconButton from "../../ui/IconButton"; +import { Send } from '@styled-icons/feather'; import { connectState } from "../../../redux/connector"; import { WithDispatcher } from "../../../redux/reducers"; -import IconButton from "../../ui/IconButton"; -import TextArea from "../../ui/TextArea"; -import { Send } from '@styled-icons/feather'; +import { takeError } from "../../../context/revoltjs/util"; +import { AppContext } from "../../../context/revoltjs/RevoltClient"; +import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; +import { SingletonMessageRenderer, SMOOTH_SCROLL_ON_RECEIVE } from "../../../lib/renderer/Singleton"; type Props = WithDispatcher & { channel: Channel; diff --git a/src/context/revoltjs/Notifications.tsx b/src/context/revoltjs/Notifications.tsx new file mode 100644 index 00000000..09b8eaf1 --- /dev/null +++ b/src/context/revoltjs/Notifications.tsx @@ -0,0 +1,256 @@ +import { decodeTime } from "ulid"; +import { AppContext } from "./RevoltClient"; +import { Users } from "revolt.js/dist/api/objects"; +import { useContext, useEffect } from "preact/hooks"; +import { IntlContext, translate } from "preact-i18n"; +import { connectState } from "../../redux/connector"; +import { playSound } from "../../assets/sounds/Audio"; +import { Message, SYSTEM_USER_ID, User } from "revolt.js"; +import { NotificationOptions } from "../../redux/reducers/settings"; +import { Route, Switch, useHistory, useParams } from "react-router-dom"; + +interface Props { + options?: NotificationOptions; +} + +const notifications: { [key: string]: Notification } = {}; + +async function createNotification(title: string, options: globalThis.NotificationOptions) { + try { + return new Notification(title, options); + } catch (err) { + let sw = await navigator.serviceWorker.getRegistration(); + sw?.showNotification(title, options); + } +} + +function Notifier(props: Props) { + const { intl } = useContext(IntlContext) as any; + const showNotification = props.options?.desktopEnabled ?? false; + // const playIncoming = props.options?.soundEnabled ?? true; + // const playOutgoing = props.options?.outgoingSoundEnabled ?? true; + + const client = useContext(AppContext); + const { guild: guild_id, channel: channel_id } = useParams<{ + guild: string; + channel: string; + }>(); + const history = useHistory(); + + async function message(msg: Message) { + if (msg.author === client.user!._id) return; + if (msg.channel === channel_id && document.hasFocus()) return; + if (client.user?.status?.presence === Users.Presence.Busy) return; + + // Sounds.playInbound(); + playSound('message'); + if (!showNotification) return; + + const channel = client.channels.get(msg.channel); + const author = client.users.get(msg.author); + if (author?.relationship === Users.Relationship.Blocked) return; + + let title; + switch (channel?.channel_type) { + case "SavedMessages": + return; + case "DirectMessage": + title = `@${author?.username}`; + break; + case "Group": + if (author?._id === SYSTEM_USER_ID) { + title = channel.name; + } else { + title = `@${author?.username} - ${channel.name}`; + } + break; + case "TextChannel": + const server = client.servers.get(channel.server); + title = `@${author?.username} (#${channel.name}, ${server?.name})`; + break; + default: + title = msg.channel; + break; + } + + let image; + if (msg.attachments) { + let imageAttachment = msg.attachments.find(x => x.metadata.type === 'Image'); + if (imageAttachment) { + image = client.generateFileURL(imageAttachment, { max_side: 720 }); + } + } + + let body, icon; + if (typeof msg.content === "string") { + body = client.markdownToText(msg.content); + icon = client.users.getAvatarURL(msg.author, { max_side: 256 }); + } else { + let users = client.users; + switch (msg.content.type) { + // ! FIXME: update to support new replacements + case "user_added": + body = `${users.get(msg.content.id)?.username} ${translate( + "app.main.channel.system.user_joined", + "", + intl.dictionary + )} (${translate( + "app.main.channel.system.added_by", + "", + intl.dictionary + )} ${users.get(msg.content.by)?.username})`; + icon = client.users.getAvatarURL(msg.content.id, { max_side: 256 }); + break; + case "user_remove": + body = `${users.get(msg.content.id)?.username} ${translate( + "app.main.channel.system.user_left", + "", + intl.dictionary + )} (${translate( + "app.main.channel.system.added_by", + "", + intl.dictionary + )} ${users.get(msg.content.by)?.username})`; + icon = client.users.getAvatarURL(msg.content.id, { max_side: 256 }); + break; + case "user_left": + body = `${users.get(msg.content.id)?.username} ${translate( + "app.main.channel.system.user_left", + "", + intl.dictionary + )}`; + icon = client.users.getAvatarURL(msg.content.id, { max_side: 256 }); + break; + case "channel_renamed": + body = `${users.get(msg.content.by)?.username} ${translate( + "app.main.channel.system.channel_renamed", + "", + intl.dictionary + )} ${msg.content.name}`; + icon = client.users.getAvatarURL(msg.content.by, { max_side: 256 }); + break; + } + } + + let notif = await createNotification(title, { + icon, + image, + body, + timestamp: decodeTime(msg._id), + tag: msg.channel, + badge: '/assets/icons/android-chrome-512x512.png', + silent: true + }); + + if (notif) { + notif.addEventListener("click", () => { + const id = msg.channel; + if (id !== channel_id) { + let channel = client.channels.get(id); + if (channel) { + if (channel.channel_type === 'TextChannel') { + history.push(`/server/${channel.server}/channel/${id}`); + } else { + history.push(`/channel/${id}`); + } + } + } + }); + + notifications[msg.channel] = notif; + notif.addEventListener( + "close", + () => delete notifications[msg.channel] + ); + } + } + + async function relationship(user: User, property: string) { + if (client.user?.status?.presence === Users.Presence.Busy) return; + if (property !== "relationship") return; + if (!showNotification) return; + + let event; + switch (user.relationship) { + case Users.Relationship.Incoming: + event = translate( + "notifications.sent_request", + "", + intl.dictionary, + { person: user.username } + ); + break; + case Users.Relationship.Friend: + event = translate( + "notifications.now_friends", + "", + intl.dictionary, + { person: user.username } + ); + break; + default: + return; + } + + let notif = await createNotification(event, { + icon: client.users.getAvatarURL(user._id, { max_side: 256 }), + badge: '/assets/icons/android-chrome-512x512.png', + timestamp: +new Date() + }); + + notif?.addEventListener("click", () => { + history.push(`/friends`); + }); + } + + useEffect(() => { + client.addListener("message", message); + client.users.addListener("mutation", relationship); + + return () => { + client.removeListener("message", message); + client.users.removeListener("mutation", relationship); + }; + }, [client, guild_id, channel_id, showNotification]); + + useEffect(() => { + function visChange() { + if (document.visibilityState === "visible") { + if (notifications[channel_id]) { + notifications[channel_id].close(); + } + } + } + + visChange(); + + document.addEventListener("visibilitychange", visChange); + return () => + document.removeEventListener("visibilitychange", visChange); + }, [guild_id, channel_id]); + + return <>; +} + +const NotifierComponent = connectState( + Notifier, + state => { + return { + options: state.settings.notification + }; + }, + true +); + +export default function Notifications() { + return ( + + + + + + + + + ); +} diff --git a/src/context/revoltjs/RevoltClient.tsx b/src/context/revoltjs/RevoltClient.tsx index 5c2ee715..da9c1571 100644 --- a/src/context/revoltjs/RevoltClient.tsx +++ b/src/context/revoltjs/RevoltClient.tsx @@ -10,6 +10,7 @@ import { WithDispatcher } from "../../redux/reducers"; import { AuthState } from "../../redux/reducers/auth"; import { SyncOptions } from "../../redux/reducers/sync"; import { useEffect, useMemo, useState } from "preact/hooks"; +import { useIntermediate } from '../intermediate/Intermediate'; import { registerEvents, setReconnectDisallowed } from "./events"; import { SingletonMessageRenderer } from '../../lib/renderer/Singleton'; @@ -42,6 +43,7 @@ type Props = WithDispatcher & { }; function Context({ auth, sync, children, dispatcher }: Props) { + const { openScreen } = useIntermediate(); const [status, setStatus] = useState(ClientStatus.INIT); const [client, setClient] = useState(undefined as unknown as Client); @@ -92,13 +94,13 @@ function Context({ auth, sync, children, dispatcher }: Props) { }); if (onboarding) { - /*openScreen({ + openScreen({ id: "onboarding", callback: async (username: string) => { await (onboarding as any)(username, true); login(); } - });*/ + }); } else { login(); } @@ -113,7 +115,7 @@ function Context({ auth, sync, children, dispatcher }: Props) { delete client.user; dispatcher({ type: "RESET" }); - // openScreen({ id: "none" }); + openScreen({ id: "none" }); setStatus(ClientStatus.READY); client.websocket.disconnect(); @@ -168,32 +170,17 @@ function Context({ auth, sync, children, dispatcher }: Props) { active.session ); - //if (callback) { - /*openScreen({ id: "onboarding", callback });*/ - //} else { - /* - // ! FIXME: all this code needs to be re-written - (async () => { - // ! FIXME: should be included in Ready payload - props.dispatcher({ - type: 'SYNC_UPDATE', - // ! FIXME: write a procedure to resolve merge conflicts - update: mapSync( - await client.syncFetchSettings(DEFAULT_ENABLED_SYNC.filter(x => !props.sync?.disabled?.includes(x))) - ) - }); - })() - - props.dispatcher({ type: 'UNREADS_SET', unreads: await client.syncFetchUnreads() });*/ - //} + if (callback) { + openScreen({ id: "onboarding", callback }); + } } catch (err) { setStatus(ClientStatus.DISCONNECTED); const error = takeError(err); - if (error === "Forbidden") { + if (error === "Forbidden" || error === "Unauthorized") { operations.logout(true); - // openScreen({ id: "signed_out" }); + openScreen({ id: "signed_out" }); } else { - // openScreen({ id: "error", error }); + openScreen({ id: "error", error }); } } } else { diff --git a/src/context/revoltjs/StateMonitor.tsx b/src/context/revoltjs/StateMonitor.tsx new file mode 100644 index 00000000..1269acc3 --- /dev/null +++ b/src/context/revoltjs/StateMonitor.tsx @@ -0,0 +1,78 @@ +/** + * This file monitors the message cache to delete any queued messages that have already sent. + */ + +import { Message } from "revolt.js"; +import { AppContext } from "./RevoltClient"; +import { Typing } from "../../redux/reducers/typing"; +import { useContext, useEffect } from "preact/hooks"; +import { connectState } from "../../redux/connector"; +import { WithDispatcher } from "../../redux/reducers"; +import { QueuedMessage } from "../../redux/reducers/queue"; + +type Props = WithDispatcher & { + messages: QueuedMessage[]; + typing: Typing +}; + +function StateMonitor(props: Props) { + const client = useContext(AppContext); + + useEffect(() => { + props.dispatcher({ + type: 'QUEUE_DROP_ALL' + }); + }, [ ]); + + useEffect(() => { + function add(msg: Message) { + if (!msg.nonce) return; + if (!props.messages.find(x => x.id === msg.nonce)) return; + + props.dispatcher({ + type: 'QUEUE_REMOVE', + nonce: msg.nonce + }); + } + + client.addListener('message', add); + return () => client.removeListener('message', add); + }, [ props.messages ]); + + useEffect(() => { + function removeOld() { + if (!props.typing) return; + for (let channel of Object.keys(props.typing)) { + let users = props.typing[channel]; + + for (let user of users) { + if (+ new Date() > user.started + 5000) { + props.dispatcher({ + type: 'TYPING_STOP', + channel, + user: user.id + }); + } + } + } + } + + removeOld(); + + let interval = setInterval(removeOld, 1000); + return () => clearInterval(interval); + }, [ props.typing ]); + + return <>; +} + +export default connectState( + StateMonitor, + state => { + return { + messages: [...state.queue], + typing: state.typing + }; + }, + true +); diff --git a/src/context/revoltjs/SyncManager.tsx b/src/context/revoltjs/SyncManager.tsx new file mode 100644 index 00000000..95361cc5 --- /dev/null +++ b/src/context/revoltjs/SyncManager.tsx @@ -0,0 +1,124 @@ +/** + * This file monitors changes to settings and syncs them to the server. + */ + +import isEqual from "lodash.isequal"; +import { Language } from "../Locale"; +import { Sync } from "revolt.js/dist/api/objects"; +import { useContext, useEffect } from "preact/hooks"; +import { connectState } from "../../redux/connector"; +import { WithDispatcher } from "../../redux/reducers"; +import { Settings } from "../../redux/reducers/settings"; +import { AppContext, ClientStatus, StatusContext } from "./RevoltClient"; +import { ClientboundNotification } from "revolt.js/dist/websocket/notifications"; +import { DEFAULT_ENABLED_SYNC, SyncData, SyncKeys, SyncOptions } from "../../redux/reducers/sync"; + +type Props = WithDispatcher & { + settings: Settings, + locale: Language, + sync: SyncOptions +}; + +var lastValues: { [key in SyncKeys]?: any } = { }; + +export function mapSync(packet: Sync.UserSettings, revision?: { [key: string]: number }) { + let update: { [key in SyncKeys]?: [ number, SyncData[key] ] } = {}; + for (let key of Object.keys(packet)) { + let [ timestamp, obj ] = packet[key]; + if (timestamp < (revision ?? {} as any)[key] ?? 0) { + continue; + } + + let object; + if (obj[0] === '{') { + object = JSON.parse(obj) + } else { + object = obj; + } + + lastValues[key as SyncKeys] = object; + update[key as SyncKeys] = [ timestamp, object ]; + } + + return update; +} + +function SyncManager(props: Props) { + const client = useContext(AppContext); + const status = useContext(StatusContext); + + useEffect(() => { + if (status === ClientStatus.ONLINE) { + client + .syncFetchSettings(DEFAULT_ENABLED_SYNC.filter(x => !props.sync?.disabled?.includes(x))) + .then(data => { + props.dispatcher({ + type: 'SYNC_UPDATE', + update: mapSync(data) + }); + }); + + client + .syncFetchUnreads() + .then(unreads => props.dispatcher({ type: 'UNREADS_SET', unreads })); + } + }, [ status ]); + + function syncChange(key: SyncKeys, data: any) { + let timestamp = + new Date(); + props.dispatcher({ + type: 'SYNC_SET_REVISION', + key, + timestamp + }); + + client.syncSetSettings({ + [key]: data + }, timestamp); + } + + let disabled = props.sync.disabled ?? []; + for (let [key, object] of [ ['appearance', props.settings.appearance], ['theme', props.settings.theme], ['locale', props.locale] ] as [SyncKeys, any][]) { + useEffect(() => { + if (disabled.indexOf(key) === -1) { + if (typeof lastValues[key] !== 'undefined') { + if (!isEqual(lastValues[key], object)) { + syncChange(key, object); + } + } + } + + lastValues[key] = object; + }, [ disabled, object ]); + } + + useEffect(() => { + function onPacket(packet: ClientboundNotification) { + if (packet.type === 'UserSettingsUpdate') { + let update: { [key in SyncKeys]?: [ number, SyncData[key] ] } = mapSync(packet.update, props.sync.revision); + + props.dispatcher({ + type: 'SYNC_UPDATE', + update + }); + } + } + + client.addListener('packet', onPacket); + return () => client.removeListener('packet', onPacket); + }, [ disabled, props.sync ]); + + return <>; +} + +export default connectState( + SyncManager, + state => { + return { + settings: state.settings, + locale: state.locale, + sync: state.sync + }; + }, + true +); diff --git a/src/pages/RevoltApp.tsx b/src/pages/RevoltApp.tsx index ab74d14b..5b89edd2 100644 --- a/src/pages/RevoltApp.tsx +++ b/src/pages/RevoltApp.tsx @@ -3,8 +3,11 @@ import { isTouchscreenDevice } from "../lib/isTouchscreenDevice"; import { Switch, Route } from "react-router-dom"; import styled from "styled-components"; -import Popovers from "../context/intermediate/Popovers"; import ContextMenus from "../lib/ContextMenus"; +import Popovers from "../context/intermediate/Popovers"; +import SyncManager from "../context/revoltjs/SyncManager"; +import StateMonitor from "../context/revoltjs/StateMonitor"; +import Notifications from "../context/revoltjs/Notifications"; import LeftSidebar from "../components/navigation/LeftSidebar"; import RightSidebar from "../components/navigation/RightSidebar"; @@ -57,6 +60,9 @@ export default function App() { + + + ); }; diff --git a/yarn.lock b/yarn.lock index ce8812d6..a08e2b04 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1167,6 +1167,18 @@ resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-3.0.1.tgz#4d26a9efe3aa2caf829234ec5a39580fc88b6001" integrity sha512-pQv3Sygwxxh6jYQzXaiyWDAHevJqWtqDUv6t11Sa9CPGiXny66II7Pl6PR8QO5OVysD6HYOkHMeBgIjLnk9SkQ== +"@types/lodash.isequal@^4.5.5": + version "4.5.5" + resolved "https://registry.yarnpkg.com/@types/lodash.isequal/-/lodash.isequal-4.5.5.tgz#4fed1b1b00bef79e305de0352d797e9bb816c8ff" + integrity sha512-4IKbinG7MGP131wRfceK6W4E/Qt3qssEFLF30LnJbjYiSfHGGRU/Io8YxXrZX109ir+iDETC8hw8QsDijukUVg== + dependencies: + "@types/lodash" "*" + +"@types/lodash@*": + version "4.14.170" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.170.tgz#0d67711d4bf7f4ca5147e9091b847479b87925d6" + integrity sha512-bpcvu/MKHHeYX+qeEN8GE7DIravODWdACVA1ctevD8CN24RhPZIKMn9ntfAsrvLfSX3cR5RrBKAbYm9bGs0A+Q== + "@types/markdown-it@^12.0.2": version "12.0.2" resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-12.0.2.tgz#153e5477970ed2a47b2f619ed4ab66f870de8a04"