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