Port sync, queue management and notifs.

This commit is contained in:
Paul
2021-06-21 13:28:26 +01:00
parent 3555e9a7bf
commit 0115ace3fa
20 changed files with 521 additions and 35 deletions

View File

@@ -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);
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -33,7 +33,7 @@ export default function Message({ attachContext, message, contrast, content: rep
</MessageInfo>
<MessageContent>
{ head && <Username user={user} /> }
{ content ?? <Markdown content={content} /> }
{ replacement ?? <Markdown content={content} /> }
{ message.attachments?.map((attachment, index) =>
<Attachment key={index} attachment={attachment} hasContent={ index > 0 || content.length > 0 } />) }
{ message.embeds?.map((embed, index) =>

View File

@@ -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;

View File

@@ -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 (
<Switch>
<Route path="/channel/:channel">
<NotifierComponent />
</Route>
<Route path="/">
<NotifierComponent />
</Route>
</Switch>
);
}

View File

@@ -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<Client>(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 {

View File

@@ -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
);

View File

@@ -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
);

View File

@@ -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() {
</Routes>
<ContextMenus />
<Popovers />
<Notifications />
<StateMonitor />
<SyncManager />
</OverlappingPanels>
);
};