forked from abner/for-legacy-web
Port sync, queue management and notifs.
This commit is contained in:
@@ -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.
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user