Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
No results found
Show changes
Showing
with 1121 additions and 843 deletions
/** /**
* This file monitors changes to settings and syncs them to the server. * This file monitors changes to settings and syncs them to the server.
*/ */
import isEqual from "lodash.isequal"; import isEqual from "lodash.isequal";
import { Language } from "../Locale"; import { UserSettings } from "revolt-api/types/Sync";
import { Sync } from "revolt.js/dist/api/objects"; import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
import { useContext, useEffect } from "preact/hooks";
import { useCallback, useContext, useEffect, useMemo } from "preact/hooks";
import { dispatch } from "../../redux";
import { connectState } from "../../redux/connector"; import { connectState } from "../../redux/connector";
import { WithDispatcher } from "../../redux/reducers";
import { Settings } from "../../redux/reducers/settings";
import { Notifications } from "../../redux/reducers/notifications"; import { Notifications } from "../../redux/reducers/notifications";
import { Settings } from "../../redux/reducers/settings";
import {
DEFAULT_ENABLED_SYNC,
SyncData,
SyncKeys,
SyncOptions,
} from "../../redux/reducers/sync";
import { Language } from "../Locale";
import { AppContext, ClientStatus, StatusContext } from "./RevoltClient"; 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 & { type Props = {
settings: Settings, settings: Settings;
locale: Language, locale: Language;
sync: SyncOptions, sync: SyncOptions;
notifications: Notifications notifications: Notifications;
}; };
var lastValues: { [key in SyncKeys]?: any } = { }; const lastValues: { [key in SyncKeys]?: unknown } = {};
export function mapSync(packet: Sync.UserSettings, revision?: { [key: string]: number }) { export function mapSync(
let update: { [key in SyncKeys]?: [ number, SyncData[key] ] } = {}; packet: UserSettings,
for (let key of Object.keys(packet)) { revision?: Record<string, number>,
let [ timestamp, obj ] = packet[key]; ) {
if (timestamp < (revision ?? {} as any)[key] ?? 0) { const update: { [key in SyncKeys]?: [number, SyncData[key]] } = {};
for (const key of Object.keys(packet)) {
const [timestamp, obj] = packet[key];
if (timestamp < (revision ?? {})[key] ?? 0) {
continue; continue;
} }
let object; let object;
if (obj[0] === '{') { if (obj[0] === "{") {
object = JSON.parse(obj) object = JSON.parse(obj);
} else { } else {
object = obj; object = obj;
} }
lastValues[key as SyncKeys] = object; lastValues[key as SyncKeys] = object;
update[key as SyncKeys] = [ timestamp, object ]; update[key as SyncKeys] = [timestamp, object];
} }
return update; return update;
...@@ -52,38 +62,57 @@ function SyncManager(props: Props) { ...@@ -52,38 +62,57 @@ function SyncManager(props: Props) {
useEffect(() => { useEffect(() => {
if (status === ClientStatus.ONLINE) { if (status === ClientStatus.ONLINE) {
client client
.syncFetchSettings(DEFAULT_ENABLED_SYNC.filter(x => !props.sync?.disabled?.includes(x))) .syncFetchSettings(
.then(data => { DEFAULT_ENABLED_SYNC.filter(
props.dispatcher({ (x) => !props.sync?.disabled?.includes(x),
type: 'SYNC_UPDATE', ),
update: mapSync(data) )
.then((data) => {
dispatch({
type: "SYNC_UPDATE",
update: mapSync(data),
}); });
}); });
client client
.syncFetchUnreads() .syncFetchUnreads()
.then(unreads => props.dispatcher({ type: 'UNREADS_SET', unreads })); .then((unreads) => dispatch({ type: "UNREADS_SET", unreads }));
} }
}, [ status ]); }, [client, props.sync?.disabled, status]);
function syncChange(key: SyncKeys, data: any) { const syncChange = useCallback(
let timestamp = + new Date(); (key: SyncKeys, data: unknown) => {
props.dispatcher({ const timestamp = +new Date();
type: 'SYNC_SET_REVISION', dispatch({
key, type: "SYNC_SET_REVISION",
timestamp key,
}); timestamp,
});
client.syncSetSettings({
[key]: data client.syncSetSettings(
}, timestamp); {
} [key]: data as string,
},
let disabled = props.sync.disabled ?? []; timestamp,
for (let [key, object] of [ ['appearance', props.settings.appearance], ['theme', props.settings.theme], ['locale', props.locale], ['notifications', props.notifications] ] as [SyncKeys, any][]) { );
},
[client],
);
const disabled = useMemo(
() => props.sync.disabled ?? [],
[props.sync.disabled],
);
for (const [key, object] of [
["appearance", props.settings.appearance],
["theme", props.settings.theme],
["locale", props.locale],
["notifications", props.notifications],
] as [SyncKeys, unknown][]) {
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => { useEffect(() => {
if (disabled.indexOf(key) === -1) { if (disabled.indexOf(key) === -1) {
if (typeof lastValues[key] !== 'undefined') { if (typeof lastValues[key] !== "undefined") {
if (!isEqual(lastValues[key], object)) { if (!isEqual(lastValues[key], object)) {
syncChange(key, object); syncChange(key, object);
} }
...@@ -91,37 +120,34 @@ function SyncManager(props: Props) { ...@@ -91,37 +120,34 @@ function SyncManager(props: Props) {
} }
lastValues[key] = object; lastValues[key] = object;
}, [ disabled, object ]); }, [key, syncChange, disabled, object]);
} }
useEffect(() => { useEffect(() => {
function onPacket(packet: ClientboundNotification) { function onPacket(packet: ClientboundNotification) {
if (packet.type === 'UserSettingsUpdate') { if (packet.type === "UserSettingsUpdate") {
let update: { [key in SyncKeys]?: [ number, SyncData[key] ] } = mapSync(packet.update, props.sync.revision); const update: { [key in SyncKeys]?: [number, SyncData[key]] } =
mapSync(packet.update, props.sync.revision);
props.dispatcher({ dispatch({
type: 'SYNC_UPDATE', type: "SYNC_UPDATE",
update update,
}); });
} }
} }
client.addListener('packet', onPacket); client.addListener("packet", onPacket);
return () => client.removeListener('packet', onPacket); return () => client.removeListener("packet", onPacket);
}, [ disabled, props.sync ]); }, [client, disabled, props.sync]);
return <></>; return null;
} }
export default connectState( export default connectState(SyncManager, (state) => {
SyncManager, return {
state => { settings: state.settings,
return { locale: state.locale,
settings: state.settings, sync: state.sync,
locale: state.locale, notifications: state.notifications,
sync: state.sync, };
notifications: state.notifications });
};
},
true
);
import { Client } from "revolt.js/dist";
import { Message } from "revolt.js/dist/maps/Messages";
import { ClientboundNotification } from "revolt.js/dist/websocket/notifications"; import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
import { WithDispatcher } from "../../redux/reducers";
import { Client, Message } from "revolt.js/dist";
import {
ClientOperations,
ClientStatus
} from "./RevoltClient";
import { StateUpdater } from "preact/hooks"; import { StateUpdater } from "preact/hooks";
export var preventReconnect = false; import { dispatch } from "../../redux";
import { ClientOperations, ClientStatus } from "./RevoltClient";
export let preventReconnect = false;
let preventUntil = 0; let preventUntil = 0;
export function setReconnectDisallowed(allowed: boolean) { export function setReconnectDisallowed(allowed: boolean) {
preventReconnect = allowed; preventReconnect = allowed;
} }
export function registerEvents({ export function registerEvents(
operations, { operations }: { operations: ClientOperations },
dispatcher setStatus: StateUpdater<ClientStatus>,
}: { operations: ClientOperations } & WithDispatcher, setStatus: StateUpdater<ClientStatus>, client: Client) { client: Client,
) {
function attemptReconnect() { function attemptReconnect() {
if (preventReconnect) return; if (preventReconnect) return;
function reconnect() { function reconnect() {
preventUntil = +new Date() + 2000; preventUntil = +new Date() + 2000;
client.websocket.connect().catch(err => console.error(err)); client.websocket.connect().catch((err) => console.error(err));
} }
if (+new Date() > preventUntil) { if (+new Date() > preventUntil) {
...@@ -32,7 +34,8 @@ export function registerEvents({ ...@@ -32,7 +34,8 @@ export function registerEvents({
} }
} }
const listeners = { // eslint-disable-next-line @typescript-eslint/no-explicit-any
let listeners: Record<string, (...args: any[]) => void> = {
connecting: () => connecting: () =>
operations.ready() && setStatus(ClientStatus.CONNECTING), operations.ready() && setStatus(ClientStatus.CONNECTING),
...@@ -45,29 +48,11 @@ export function registerEvents({ ...@@ -45,29 +48,11 @@ export function registerEvents({
packet: (packet: ClientboundNotification) => { packet: (packet: ClientboundNotification) => {
switch (packet.type) { switch (packet.type) {
case "ChannelStartTyping": {
if (packet.user === client.user?._id) return;
dispatcher({
type: "TYPING_START",
channel: packet.id,
user: packet.user
});
break;
}
case "ChannelStopTyping": {
if (packet.user === client.user?._id) return;
dispatcher({
type: "TYPING_STOP",
channel: packet.id,
user: packet.user
});
break;
}
case "ChannelAck": { case "ChannelAck": {
dispatcher({ dispatch({
type: "UNREADS_MARK_READ", type: "UNREADS_MARK_READ",
channel: packet.id, channel: packet.id,
message: packet.message_id message: packet.message_id,
}); });
break; break;
} }
...@@ -75,44 +60,32 @@ export function registerEvents({ ...@@ -75,44 +60,32 @@ export function registerEvents({
}, },
message: (message: Message) => { message: (message: Message) => {
if (message.mentions?.includes(client.user!._id)) { if (message.mention_ids?.includes(client.user!._id)) {
dispatcher({ dispatch({
type: "UNREADS_MENTION", type: "UNREADS_MENTION",
channel: message.channel, channel: message.channel_id,
message: message._id message: message._id,
}); });
} }
}, },
ready: () => setStatus(ClientStatus.ONLINE) ready: () => setStatus(ClientStatus.ONLINE),
}; };
let listenerFunc: { [key: string]: Function };
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
listenerFunc = {}; listeners = new Proxy(listeners, {
for (const listener of Object.keys(listeners)) { get:
listenerFunc[listener] = (...args: any[]) => { (target, listener) =>
console.debug(`Calling ${listener} with`, args); (...args: unknown[]) => {
(listeners as any)[listener](...args); console.debug(`Calling ${listener.toString()} with`, args);
}; Reflect.get(target, listener)(...args);
} },
} else { });
listenerFunc = listeners;
}
for (const listener of Object.keys(listenerFunc)) {
client.addListener(listener, (listenerFunc as any)[listener]);
}
function logMutation(target: string, key: string) {
console.log('(o) Object mutated', target, '\nChanged:', key);
} }
if (import.meta.env.DEV) { // TODO: clean this a bit and properly handle types
client.users.addListener('mutation', logMutation); for (const listener in listeners) {
client.servers.addListener('mutation', logMutation); client.addListener(listener, listeners[listener]);
client.channels.addListener('mutation', logMutation);
client.servers.members.addListener('mutation', logMutation);
} }
const online = () => { const online = () => {
...@@ -122,7 +95,7 @@ export function registerEvents({ ...@@ -122,7 +95,7 @@ export function registerEvents({
attemptReconnect(); attemptReconnect();
} }
}; };
const offline = () => { const offline = () => {
if (operations.ready()) { if (operations.ready()) {
setReconnectDisallowed(true); setReconnectDisallowed(true);
...@@ -135,15 +108,11 @@ export function registerEvents({ ...@@ -135,15 +108,11 @@ export function registerEvents({
window.addEventListener("offline", offline); window.addEventListener("offline", offline);
return () => { return () => {
for (const listener of Object.keys(listenerFunc)) { for (const listener in listeners) {
client.removeListener(listener, (listenerFunc as any)[listener]); client.removeListener(
} listener,
listeners[listener as keyof typeof listeners],
if (import.meta.env.DEV) { );
client.users.removeListener('mutation', logMutation);
client.servers.removeListener('mutation', logMutation);
client.channels.removeListener('mutation', logMutation);
client.servers.members.removeListener('mutation', logMutation);
} }
window.removeEventListener("online", online); window.removeEventListener("online", online);
......
import { useCallback, useContext, useEffect, useState } from "preact/hooks";
import { Channels, Servers, Users } from "revolt.js/dist/api/objects";
import { Client, PermissionCalculator } from 'revolt.js';
import { AppContext } from "./RevoltClient";
export interface HookContext {
client: Client,
forceUpdate: () => void
}
export function useForceUpdate(context?: HookContext): HookContext {
const client = useContext(AppContext);
if (context) return context;
const H = useState(0);
var updateState: (_: number) => void;
if (Array.isArray(H)) {
let [, u] = H;
updateState = u;
} else {
console.warn('Failed to construct using useState.');
updateState = ()=>{};
}
return { client, forceUpdate: () => updateState(Math.random()) };
}
function useObject(type: string, id?: string | string[], context?: HookContext) {
const ctx = useForceUpdate(context);
function update(target: any) {
if (typeof id === 'string' ? target === id :
Array.isArray(id) ? id.includes(target) : true) {
ctx.forceUpdate();
}
}
const map = (ctx.client as any)[type];
useEffect(() => {
map.addListener("update", update);
return () => map.removeListener("update", update);
}, [id]);
return typeof id === 'string' ? map.get(id)
: Array.isArray(id) ? id.map(x => map.get(x))
: map.toArray();
}
export function useUser(id?: string, context?: HookContext) {
if (typeof id === "undefined") return;
return useObject('users', id, context) as Readonly<Users.User> | undefined;
}
export function useSelf(context?: HookContext) {
const ctx = useForceUpdate(context);
return useUser(ctx.client.user!._id, ctx);
}
export function useUsers(ids?: string[], context?: HookContext) {
return useObject('users', ids, context) as (Readonly<Users.User> | undefined)[];
}
export function useChannel(id?: string, context?: HookContext) {
if (typeof id === "undefined") return;
return useObject('channels', id, context) as Readonly<Channels.Channel> | undefined;
}
export function useChannels(ids?: string[], context?: HookContext) {
return useObject('channels', ids, context) as (Readonly<Channels.Channel> | undefined)[];
}
export function useServer(id?: string, context?: HookContext) {
if (typeof id === "undefined") return;
return useObject('servers', id, context) as Readonly<Servers.Server> | undefined;
}
export function useServers(ids?: string[], context?: HookContext) {
return useObject('servers', ids, context) as (Readonly<Servers.Server> | undefined)[];
}
export function useDMs(context?: HookContext) {
const ctx = useForceUpdate(context);
function mutation(target: string) {
let channel = ctx.client.channels.get(target);
if (channel) {
if (channel.channel_type === 'DirectMessage' || channel.channel_type === 'Group') {
ctx.forceUpdate();
}
}
}
const map = ctx.client.channels;
useEffect(() => {
map.addListener("update", mutation);
return () => map.removeListener("update", mutation);
}, []);
return map
.toArray()
.filter(x => x.channel_type === 'DirectMessage' || x.channel_type === 'Group' || x.channel_type === 'SavedMessages') as (Channels.GroupChannel | Channels.DirectMessageChannel | Channels.SavedMessagesChannel)[];
}
export function useUserPermission(id: string, context?: HookContext) {
const ctx = useForceUpdate(context);
const mutation = (target: string) => (target === id) && ctx.forceUpdate();
useEffect(() => {
ctx.client.users.addListener("update", mutation);
return () => ctx.client.users.removeListener("update", mutation);
}, [id]);
let calculator = new PermissionCalculator(ctx.client);
return calculator.forUser(id);
}
export function useChannelPermission(id: string, context?: HookContext) {
const ctx = useForceUpdate(context);
const channel = ctx.client.channels.get(id);
const server = (channel && (channel.channel_type === 'TextChannel' || channel.channel_type === 'VoiceChannel')) ? channel.server : undefined;
const mutation = (target: string) => (target === id) && ctx.forceUpdate();
const mutationServer = (target: string) => (target === server) && ctx.forceUpdate();
const mutationMember = (target: string) => (target.substr(26) === ctx.client.user!._id) && ctx.forceUpdate();
useEffect(() => {
ctx.client.channels.addListener("update", mutation);
if (server) {
ctx.client.servers.addListener("update", mutationServer);
ctx.client.servers.members.addListener("update", mutationMember);
}
return () => {
ctx.client.channels.removeListener("update", mutation);
if (server) {
ctx.client.servers.removeListener("update", mutationServer);
ctx.client.servers.members.removeListener("update", mutationMember);
}
}
}, [id]);
let calculator = new PermissionCalculator(ctx.client);
return calculator.forChannel(id);
}
export function useServerPermission(id: string, context?: HookContext) {
const ctx = useForceUpdate(context);
const mutation = (target: string) => (target === id) && ctx.forceUpdate();
const mutationMember = (target: string) => (target.substr(26) === ctx.client.user!._id) && ctx.forceUpdate();
useEffect(() => {
ctx.client.servers.addListener("update", mutation);
ctx.client.servers.members.addListener("update", mutationMember);
return () => {
ctx.client.servers.removeListener("update", mutation);
ctx.client.servers.members.removeListener("update", mutationMember);
}
}, [id]);
let calculator = new PermissionCalculator(ctx.client);
return calculator.forServer(id);
}
import { Channel, Message, User } from "revolt.js/dist/api/objects"; import { Channel } from "revolt.js/dist/maps/Channels";
import { Children } from "../../types/Preact";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { Client } from "revolt.js";
export function takeError( import { Children } from "../../types/Preact";
error: any
): string { // eslint-disable-next-line @typescript-eslint/no-explicit-any
export function takeError(error: any): string {
const type = error?.response?.data?.type; const type = error?.response?.data?.type;
let id = type; const id = type;
if (!type) { if (!type) {
if (error?.response?.status === 403) { if (error?.response?.status === 403) {
return "Unauthorized"; return "Unauthorized";
} else if (error && (!!error.isAxiosError && !error.response)) { } else if (error && !!error.isAxiosError && !error.response) {
return "NetworkError"; return "NetworkError";
} }
...@@ -22,13 +22,20 @@ export function takeError( ...@@ -22,13 +22,20 @@ export function takeError(
return id; return id;
} }
export function getChannelName(client: Client, channel: Channel, prefixType?: boolean): Children { export function getChannelName(
channel: Channel,
prefixType?: boolean,
): Children {
if (channel.channel_type === "SavedMessages") if (channel.channel_type === "SavedMessages")
return <Text id="app.navigation.tabs.saved" />; return <Text id="app.navigation.tabs.saved" />;
if (channel.channel_type === "DirectMessage") { if (channel.channel_type === "DirectMessage") {
let uid = client.channels.getRecipient(channel._id); return (
return <>{prefixType && "@"}{client.users.get(uid)?.username}</>; <>
{prefixType && "@"}
{channel.recipient!.username}
</>
);
} }
if (channel.channel_type === "TextChannel" && prefixType) { if (channel.channel_type === "TextChannel" && prefixType) {
...@@ -37,12 +44,3 @@ export function getChannelName(client: Client, channel: Channel, prefixType?: bo ...@@ -37,12 +44,3 @@ export function getChannelName(client: Client, channel: Channel, prefixType?: bo
return <>{channel.name}</>; return <>{channel.name}</>;
} }
export type MessageObject = Omit<Message, "edited"> & { edited?: string };
export function mapMessage(message: Partial<Message>) {
const { edited, ...msg } = message;
return {
...msg,
edited: edited?.$date,
} as MessageObject;
}
...@@ -2,4 +2,3 @@ interface ImportMetaEnv { ...@@ -2,4 +2,3 @@ interface ImportMetaEnv {
VITE_API_URL: string; VITE_API_URL: string;
VITE_THEMES_URL: string; VITE_THEMES_URL: string;
} }
\ No newline at end of file
type Build = "stable" | "nightly" | "dev";
type NativeConfig = {
frame: boolean;
build: Build;
discordRPC: boolean;
hardwareAcceleration: boolean;
};
declare interface Window {
isNative?: boolean;
nativeVersion: string;
native: {
min();
max();
close();
reload();
relaunch();
getConfig(): NativeConfig;
set(key: keyof NativeConfig, value: unknown);
getAutoStart(): Promise<boolean>;
enableAutoStart(): Promise<void>;
disableAutoStart(): Promise<void>;
};
}
declare const Fragment = preact.Fragment;
import { Link, LinkProps } from "react-router-dom"; import { Link, LinkProps } from "react-router-dom";
type Props = LinkProps & JSX.HTMLAttributes<HTMLAnchorElement> & { type Props = LinkProps &
active: boolean JSX.HTMLAttributes<HTMLAnchorElement> & {
}; active: boolean;
};
export default function ConditionalLink(props: Props) { export default function ConditionalLink(props: Props) {
const { active, ...linkProps } = props; const { active, ...linkProps } = props;
if (active) { if (active) {
return <a>{ props.children }</a>; return <a>{props.children}</a>;
} else {
return <Link {...linkProps} />;
} }
return <Link {...linkProps} />;
} }
import { Text } from "preact-i18n"; import {
import { useContext } from "preact/hooks"; At,
Bell,
BellOff,
Check,
CheckSquare,
ChevronRight,
Block,
Square,
LeftArrowAlt,
Trash,
} from "@styled-icons/boxicons-regular";
import { Cog, UserVoice } from "@styled-icons/boxicons-solid";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { Attachment, Channels, Message, Servers, Users } from "revolt.js/dist/api/objects"; import { Attachment } from "revolt-api/types/Autumn";
import { Presence, RelationshipStatus } from "revolt-api/types/Users";
import {
ChannelPermission,
ServerPermission,
UserPermission,
} from "revolt.js/dist/api/permissions";
import { Channel } from "revolt.js/dist/maps/Channels";
import { Message } from "revolt.js/dist/maps/Messages";
import { Server } from "revolt.js/dist/maps/Servers";
import { User } from "revolt.js/dist/maps/Users";
import { import {
ContextMenu,
ContextMenuWithData, ContextMenuWithData,
MenuItem, MenuItem,
openContextMenu openContextMenu,
} from "preact-context-menu"; } from "preact-context-menu";
import { ChannelPermission, ServerPermission, UserPermission } from "revolt.js/dist/api/permissions"; import { Text } from "preact-i18n";
import { useContext } from "preact/hooks";
import { dispatch } from "../redux";
import { connectState } from "../redux/connector";
import {
getNotificationState,
Notifications,
NotificationState,
} from "../redux/reducers/notifications";
import { QueuedMessage } from "../redux/reducers/queue"; import { QueuedMessage } from "../redux/reducers/queue";
import { WithDispatcher } from "../redux/reducers";
import { useIntermediate } from "../context/intermediate/Intermediate"; import { Screen, useIntermediate } from "../context/intermediate/Intermediate";
import { AppContext, ClientStatus, StatusContext } from "../context/revoltjs/RevoltClient"; import {
AppContext,
ClientStatus,
StatusContext,
} from "../context/revoltjs/RevoltClient";
import { takeError } from "../context/revoltjs/util"; import { takeError } from "../context/revoltjs/util";
import { useChannel, useChannelPermission, useForceUpdate, useServer, useServerPermission, useUser, useUserPermission } from "../context/revoltjs/hooks";
import { Children } from "../types/Preact"; import Tooltip from "../components/common/Tooltip";
import LineDivider from "../components/ui/LineDivider";
import { connectState } from "../redux/connector";
import { internalEmit } from "./eventEmitter";
import { At, Bell, BellOff, Check, CheckSquare, ChevronRight, Block, Square, LeftArrowAlt, Trash } from "@styled-icons/boxicons-regular";
import { Cog } from "@styled-icons/boxicons-solid";
import { getNotificationState, Notifications, NotificationState } from "../redux/reducers/notifications";
import UserStatus from "../components/common/user/UserStatus"; import UserStatus from "../components/common/user/UserStatus";
import IconButton from "../components/ui/IconButton"; import IconButton from "../components/ui/IconButton";
import LineDivider from "../components/ui/LineDivider";
import { Children } from "../types/Preact";
import { internalEmit } from "./eventEmitter";
interface ContextMenuData { interface ContextMenuData {
user?: string; user?: string;
...@@ -41,50 +73,62 @@ type Action = ...@@ -41,50 +73,62 @@ type Action =
| { action: "copy_id"; id: string } | { action: "copy_id"; id: string }
| { action: "copy_selection" } | { action: "copy_selection" }
| { action: "copy_text"; content: string } | { action: "copy_text"; content: string }
| { action: "mark_as_read"; channel: Channels.Channel } | { action: "mark_as_read"; channel: Channel }
| { action: "retry_message"; message: QueuedMessage } | { action: "retry_message"; message: QueuedMessage }
| { action: "cancel_message"; message: QueuedMessage } | { action: "cancel_message"; message: QueuedMessage }
| { action: "mention"; user: string } | { action: "mention"; user: string }
| { action: "reply_message"; id: string } | { action: "reply_message"; id: string }
| { action: "quote_message"; content: string } | { action: "quote_message"; content: string }
| { action: "edit_message"; id: string } | { action: "edit_message"; id: string }
| { action: "delete_message"; target: Channels.Message } | { action: "delete_message"; target: Message }
| { action: "open_file"; attachment: Attachment } | { action: "open_file"; attachment: Attachment }
| { action: "save_file"; attachment: Attachment } | { action: "save_file"; attachment: Attachment }
| { action: "copy_file_link"; attachment: Attachment } | { action: "copy_file_link"; attachment: Attachment }
| { action: "open_link"; link: string } | { action: "open_link"; link: string }
| { action: "copy_link"; link: string } | { action: "copy_link"; link: string }
| { action: "remove_member"; channel: string; user: string } | { action: "remove_member"; channel: Channel; user: User }
| { action: "kick_member"; target: Servers.Server; user: string } | { action: "kick_member"; target: Server; user: User }
| { action: "ban_member"; target: Servers.Server; user: string } | { action: "ban_member"; target: Server; user: User }
| { action: "view_profile"; user: string } | { action: "view_profile"; user: User }
| { action: "message_user"; user: string } | { action: "message_user"; user: User }
| { action: "block_user"; user: Users.User } | { action: "block_user"; user: User }
| { action: "unblock_user"; user: Users.User } | { action: "unblock_user"; user: User }
| { action: "add_friend"; user: Users.User } | { action: "add_friend"; user: User }
| { action: "remove_friend"; user: Users.User } | { action: "remove_friend"; user: User }
| { action: "cancel_friend"; user: Users.User } | { action: "cancel_friend"; user: User }
| { action: "set_presence"; presence: Users.Presence } | { action: "set_presence"; presence: Presence }
| { action: "set_status" } | { action: "set_status" }
| { action: "clear_status" } | { action: "clear_status" }
| { action: "create_channel"; target: Servers.Server } | { action: "create_channel"; target: Server }
| { action: "create_invite"; target: Channels.GroupChannel | Channels.TextChannel | Channels.VoiceChannel } | {
| { action: "leave_group"; target: Channels.GroupChannel } action: "create_invite";
| { action: "delete_channel"; target: Channels.TextChannel | Channels.VoiceChannel } target: Channel;
| { action: "close_dm"; target: Channels.DirectMessageChannel } }
| { action: "leave_server"; target: Servers.Server } | { action: "leave_group"; target: Channel }
| { action: "delete_server"; target: Servers.Server } | {
| { action: "open_notification_options", channel: Channels.Channel } action: "delete_channel";
target: Channel;
}
| { action: "close_dm"; target: Channel }
| { action: "leave_server"; target: Server }
| { action: "delete_server"; target: Server }
| { action: "open_notification_options"; channel: Channel }
| { action: "open_settings" } | { action: "open_settings" }
| { action: "open_channel_settings", id: string } | { action: "open_channel_settings"; id: string }
| { action: "open_server_settings", id: string } | { action: "open_server_settings"; id: string }
| { action: "open_server_channel_settings", server: string, id: string } | { action: "open_server_channel_settings"; server: string; id: string }
| { action: "set_notification_state", key: string, state?: NotificationState }; | {
action: "set_notification_state";
type Props = WithDispatcher & { key: string;
notifications: Notifications state?: NotificationState;
};
type Props = {
notifications: Notifications;
}; };
// ! FIXME: I dare someone to re-write this
// Tip: This should just be split into separate context menus per logical area.
function ContextMenus(props: Props) { function ContextMenus(props: Props) {
const { openScreen, writeClipboard } = useIntermediate(); const { openScreen, writeClipboard } = useIntermediate();
const client = useContext(AppContext); const client = useContext(AppContext);
...@@ -102,57 +146,65 @@ function ContextMenus(props: Props) { ...@@ -102,57 +146,65 @@ function ContextMenus(props: Props) {
writeClipboard(data.id); writeClipboard(data.id);
break; break;
case "copy_selection": case "copy_selection":
writeClipboard(document.getSelection()?.toString() ?? ''); writeClipboard(document.getSelection()?.toString() ?? "");
break; break;
case "mark_as_read": case "mark_as_read":
{ {
if (data.channel.channel_type === 'SavedMessages' || if (
data.channel.channel_type === 'VoiceChannel') return; data.channel.channel_type === "SavedMessages" ||
data.channel.channel_type === "VoiceChannel"
)
return;
const message =
typeof data.channel.last_message === "string"
? data.channel.last_message
: data.channel.last_message!._id;
let message = data.channel.channel_type === 'TextChannel' ? data.channel.last_message : data.channel.last_message._id; dispatch({
props.dispatcher({
type: "UNREADS_MARK_READ", type: "UNREADS_MARK_READ",
channel: data.channel._id, channel: data.channel._id,
message message,
}); });
client.req('PUT', `/channels/${data.channel._id}/ack/${message}` as '/channels/id/ack/id'); client.req(
"PUT",
`/channels/${data.channel._id}/ack/${message}` as "/channels/id/ack/id",
);
} }
break; break;
case "retry_message": case "retry_message":
{ {
const nonce = data.message.id; const nonce = data.message.id;
const fail = (error: any) => const fail = (error: string) =>
props.dispatcher({ dispatch({
type: "QUEUE_FAIL", type: "QUEUE_FAIL",
nonce, nonce,
error error,
}); });
client.channels client.channels
.sendMessage( .get(data.message.channel)!
data.message.channel, .sendMessage({
{ nonce: data.message.id,
nonce: data.message.id, content: data.message.data.content as string,
content: data.message.data.content as string, replies: data.message.data.replies,
replies: data.message.data.replies })
}
)
.catch(fail); .catch(fail);
props.dispatcher({ dispatch({
type: "QUEUE_START", type: "QUEUE_START",
nonce nonce,
}); });
} }
break; break;
case "cancel_message": case "cancel_message":
{ {
props.dispatcher({ dispatch({
type: "QUEUE_REMOVE", type: "QUEUE_REMOVE",
nonce: data.message.id nonce: data.message.id,
}); });
} }
break; break;
...@@ -163,7 +215,7 @@ function ContextMenus(props: Props) { ...@@ -163,7 +215,7 @@ function ContextMenus(props: Props) {
"MessageBox", "MessageBox",
"append", "append",
`<@${data.user}>`, `<@${data.user}>`,
"mention" "mention",
); );
} }
break; break;
...@@ -174,11 +226,7 @@ function ContextMenus(props: Props) { ...@@ -174,11 +226,7 @@ function ContextMenus(props: Props) {
case "reply_message": case "reply_message":
{ {
internalEmit( internalEmit("ReplyBar", "add", data.id);
"ReplyBar",
"add",
data.id
);
} }
break; break;
...@@ -188,14 +236,18 @@ function ContextMenus(props: Props) { ...@@ -188,14 +236,18 @@ function ContextMenus(props: Props) {
"MessageBox", "MessageBox",
"append", "append",
data.content, data.content,
"quote" "quote",
); );
} }
break; break;
case "edit_message": case "edit_message":
{ {
internalEmit("MessageRenderer", "edit_message", data.id); internalEmit(
"MessageRenderer",
"edit_message",
data.id,
);
} }
break; break;
...@@ -204,7 +256,7 @@ function ContextMenus(props: Props) { ...@@ -204,7 +256,7 @@ function ContextMenus(props: Props) {
window window
.open( .open(
client.generateFileURL(data.attachment), client.generateFileURL(data.attachment),
"_blank" "_blank",
) )
?.focus(); ?.focus();
} }
...@@ -214,8 +266,13 @@ function ContextMenus(props: Props) { ...@@ -214,8 +266,13 @@ function ContextMenus(props: Props) {
{ {
window.open( window.open(
// ! FIXME: do this from revolt.js // ! FIXME: do this from revolt.js
client.generateFileURL(data.attachment)?.replace('attachments', 'attachments/download'), client
"_blank" .generateFileURL(data.attachment)
?.replace(
"attachments",
"attachments/download",
),
"_blank",
); );
} }
break; break;
...@@ -225,7 +282,9 @@ function ContextMenus(props: Props) { ...@@ -225,7 +282,9 @@ function ContextMenus(props: Props) {
const { filename } = data.attachment; const { filename } = data.attachment;
writeClipboard( writeClipboard(
// ! FIXME: do from r.js // ! FIXME: do from r.js
client.generateFileURL(data.attachment) + `/${encodeURI(filename)}`, `${client.generateFileURL(
data.attachment,
)}/${encodeURI(filename)}`,
); );
} }
break; break;
...@@ -244,17 +303,17 @@ function ContextMenus(props: Props) { ...@@ -244,17 +303,17 @@ function ContextMenus(props: Props) {
case "remove_member": case "remove_member":
{ {
client.channels.removeMember(data.channel, data.user); data.channel.removeMember(data.user._id);
} }
break; break;
case "view_profile": case "view_profile":
openScreen({ id: 'profile', user_id: data.user }); openScreen({ id: "profile", user_id: data.user._id });
break; break;
case "message_user": case "message_user":
{ {
const channel = await client.users.openDM(data.user); const channel = await data.user.openDM();
if (channel) { if (channel) {
history.push(`/channel/${channel._id}`); history.push(`/channel/${channel._id}`);
} }
...@@ -263,43 +322,57 @@ function ContextMenus(props: Props) { ...@@ -263,43 +322,57 @@ function ContextMenus(props: Props) {
case "add_friend": case "add_friend":
{ {
await client.users.addFriend(data.user.username); await data.user.addFriend();
} }
break; break;
case "block_user": case "block_user":
openScreen({ id: 'special_prompt', type: 'block_user', target: data.user }); openScreen({
id: "special_prompt",
type: "block_user",
target: data.user,
});
break; break;
case "unblock_user": case "unblock_user":
await client.users.unblockUser(data.user._id); await data.user.unblockUser();
break; break;
case "remove_friend": case "remove_friend":
openScreen({ id: 'special_prompt', type: 'unfriend_user', target: data.user }); openScreen({
id: "special_prompt",
type: "unfriend_user",
target: data.user,
});
break; break;
case "cancel_friend": case "cancel_friend":
await client.users.removeFriend(data.user._id); await data.user.removeFriend();
break; break;
case "set_presence": case "set_presence":
{ {
await client.users.editUser({ await client.users.edit({
status: { status: {
...client.user?.status, ...client.user?.status,
presence: data.presence presence: data.presence,
} },
}); });
} }
break; break;
case "set_status": openScreen({ id: "special_input", type: "set_custom_status" }); break; case "set_status":
openScreen({
id: "special_input",
type: "set_custom_status",
});
break;
case "clear_status": case "clear_status":
{ {
let { text, ...status } = client.user?.status ?? {}; const { text: _text, ...status } =
await client.users.editUser({ status }); client.user?.status ?? {};
await client.users.edit({ status });
} }
break; break;
case "leave_group": case "leave_group":
case "close_dm": case "close_dm":
case "leave_server": case "leave_server":
...@@ -307,33 +380,58 @@ function ContextMenus(props: Props) { ...@@ -307,33 +380,58 @@ function ContextMenus(props: Props) {
case "delete_server": case "delete_server":
case "delete_message": case "delete_message":
case "create_channel": case "create_channel":
// @ts-expect-error case "create_invite":
case "create_invite": openScreen({ id: "special_prompt", type: data.action, target: data.target }); break; // Typescript flattens the case types into a single type and type structure and specifity is lost
openScreen({
id: "special_prompt",
type: data.action,
target: data.target,
} as unknown as Screen);
break;
case "ban_member": case "ban_member":
case "kick_member": openScreen({ id: "special_prompt", type: data.action, target: data.target, user: data.user }); break; case "kick_member":
openScreen({
id: "special_prompt",
type: data.action,
target: data.target,
user: data.user,
});
break;
case "open_notification_options": { case "open_notification_options": {
openContextMenu("NotificationOptions", { channel: data.channel }); openContextMenu("NotificationOptions", {
channel: data.channel,
});
break; break;
} }
case "open_settings": history.push('/settings'); break; case "open_settings":
case "open_channel_settings": history.push(`/channel/${data.id}/settings`); break; history.push("/settings");
case "open_server_channel_settings": history.push(`/server/${data.server}/channel/${data.id}/settings`); break; break;
case "open_server_settings": history.push(`/server/${data.id}/settings`); break; case "open_channel_settings":
history.push(`/channel/${data.id}/settings`);
break;
case "open_server_channel_settings":
history.push(
`/server/${data.server}/channel/${data.id}/settings`,
);
break;
case "open_server_settings":
history.push(`/server/${data.id}/settings`);
break;
case "set_notification_state": { case "set_notification_state": {
const { key, state } = data; const { key, state } = data;
if (state) { if (state) {
props.dispatcher({ type: "NOTIFICATIONS_SET", key, state }); dispatch({ type: "NOTIFICATIONS_SET", key, state });
} else { } else {
props.dispatcher({ type: "NOTIFICATIONS_REMOVE", key }); dispatch({ type: "NOTIFICATIONS_REMOVE", key });
} }
break; break;
} }
} }
})().catch(err => { })().catch((err) => {
openScreen({ id: "error", error: takeError(err) }); openScreen({ id: "error", error: takeError(err) });
}); });
} }
...@@ -349,29 +447,27 @@ function ContextMenus(props: Props) { ...@@ -349,29 +447,27 @@ function ContextMenus(props: Props) {
server_list, server_list,
queued, queued,
unread, unread,
contextualChannel: cxid contextualChannel: cxid,
}: ContextMenuData) => { }: ContextMenuData) => {
const forceUpdate = useForceUpdate();
const elements: Children[] = []; const elements: Children[] = [];
var lastDivider = false; let lastDivider = false;
function generateAction( function generateAction(
action: Action, action: Action,
locale?: string, locale?: string,
disabled?: boolean, disabled?: boolean,
tip?: Children tip?: Children,
) { ) {
lastDivider = false; lastDivider = false;
elements.push( elements.push(
<MenuItem data={action} disabled={disabled}> <MenuItem data={action} disabled={disabled}>
<Text <Text
id={`app.context_menu.${locale ?? id={`app.context_menu.${
action.action}`} locale ?? action.action
}`}
/> />
{ tip && <div className="tip"> {tip && <div className="tip">{tip}</div>}
{ tip } </MenuItem>,
</div> }
</MenuItem>
); );
} }
...@@ -382,34 +478,59 @@ function ContextMenus(props: Props) { ...@@ -382,34 +478,59 @@ function ContextMenus(props: Props) {
} }
if (server_list) { if (server_list) {
let server = useServer(server_list, forceUpdate); const server = client.servers.get(server_list)!;
let permissions = useServerPermission(server_list, forceUpdate); const permissions = server.permission;
if (server) { if (server) {
if (permissions & ServerPermission.ManageChannels) generateAction({ action: 'create_channel', target: server }); if (permissions & ServerPermission.ManageChannels)
if (permissions & ServerPermission.ManageServer) generateAction({ action: 'open_server_settings', id: server_list }); generateAction({
action: "create_channel",
target: server,
});
if (permissions & ServerPermission.ManageServer)
generateAction({
action: "open_server_settings",
id: server_list,
});
} }
return elements; return elements;
} }
if (document.getSelection()?.toString().length ?? 0 > 0) { if (document.getSelection()?.toString().length ?? 0 > 0) {
generateAction({ action: "copy_selection" }, undefined, undefined, <Text id="shortcuts.ctrlc" />); generateAction(
{ action: "copy_selection" },
undefined,
undefined,
<Text id="shortcuts.ctrlc" />,
);
pushDivider(); pushDivider();
} }
const channel = useChannel(cid, forceUpdate); const channel = cid ? client.channels.get(cid) : undefined;
const contextualChannel = useChannel(cxid, forceUpdate); const contextualChannel = cxid
? client.channels.get(cxid)
: undefined;
const targetChannel = channel ?? contextualChannel; const targetChannel = channel ?? contextualChannel;
const user = useUser(uid, forceUpdate); const user = uid ? client.users.get(uid) : undefined;
const serverChannel = targetChannel && (targetChannel.channel_type === 'TextChannel' || targetChannel.channel_type === 'VoiceChannel') ? targetChannel : undefined; const serverChannel =
const server = useServer(serverChannel ? serverChannel.server : sid, forceUpdate); targetChannel &&
(targetChannel.channel_type === "TextChannel" ||
const channelPermissions = targetChannel ? useChannelPermission(targetChannel._id, forceUpdate) : 0; targetChannel.channel_type === "VoiceChannel")
const serverPermissions = server ? useServerPermission(server._id, forceUpdate) : ( ? targetChannel
serverChannel ? useServerPermission(serverChannel.server, forceUpdate) : 0 : undefined;
);
const userPermissions = user ? useUserPermission(user._id, forceUpdate) : 0; const s = serverChannel ? serverChannel.server_id! : sid;
const server = s ? client.servers.get(s) : undefined;
const channelPermissions = targetChannel?.permission || 0;
const serverPermissions =
(server
? server.permission
: serverChannel
? serverChannel.server?.permission
: 0) || 0;
const userPermissions = (user ? user.permission : 0) || 0;
if (channel && unread) { if (channel && unread) {
generateAction({ action: "mark_as_read", channel }); generateAction({ action: "mark_as_read", channel });
...@@ -419,7 +540,7 @@ function ContextMenus(props: Props) { ...@@ -419,7 +540,7 @@ function ContextMenus(props: Props) {
if (user && user._id !== userId) { if (user && user._id !== userId) {
generateAction({ generateAction({
action: "mention", action: "mention",
user: user._id user: user._id,
}); });
pushDivider(); pushDivider();
...@@ -427,116 +548,147 @@ function ContextMenus(props: Props) { ...@@ -427,116 +548,147 @@ function ContextMenus(props: Props) {
} }
if (user) { if (user) {
let actions: string[]; let actions: Action["action"][];
switch (user.relationship) { switch (user.relationship) {
case Users.Relationship.User: actions = []; break; case RelationshipStatus.User:
case Users.Relationship.Friend: actions = [];
break;
case RelationshipStatus.Friend:
actions = ["remove_friend", "block_user"];
break;
case RelationshipStatus.Incoming:
actions = [ actions = [
"remove_friend", "add_friend",
"block_user" "cancel_friend",
"block_user",
]; ];
break; break;
case Users.Relationship.Incoming: case RelationshipStatus.Outgoing:
actions = ["add_friend", "cancel_friend", "block_user"];
break;
case Users.Relationship.Outgoing:
actions = ["cancel_friend", "block_user"]; actions = ["cancel_friend", "block_user"];
break; break;
case Users.Relationship.Blocked: case RelationshipStatus.Blocked:
actions = ["unblock_user"]; actions = ["unblock_user"];
break; break;
case Users.Relationship.BlockedOther: case RelationshipStatus.BlockedOther:
actions = ["block_user"]; actions = ["block_user"];
break; break;
case Users.Relationship.None: case RelationshipStatus.None:
default: default:
actions = ["add_friend", "block_user"]; actions = ["add_friend", "block_user"];
} }
if (userPermissions & UserPermission.ViewProfile) { if (userPermissions & UserPermission.ViewProfile) {
generateAction({ action: 'view_profile', user: user._id }); generateAction({
action: "view_profile",
user,
});
} }
if (user._id !== userId && userPermissions & UserPermission.SendMessage) { if (
generateAction({ action: 'message_user', user: user._id }); user._id !== userId &&
userPermissions & UserPermission.SendMessage
) {
generateAction({
action: "message_user",
user,
});
} }
for (const action of actions) { for (let i = 0; i < actions.length; i++) {
// Typescript can't determine that user the actions are linked together correctly
generateAction({ generateAction({
action: action as any, action: actions[i],
user user,
}); } as unknown as Action);
} }
} }
if (contextualChannel) { if (contextualChannel) {
if (contextualChannel.channel_type === "Group" && uid) { if (contextualChannel.channel_type === "Group" && uid) {
if ( if (
contextualChannel.owner === userId && contextualChannel.owner_id === userId &&
userId !== uid userId !== uid
) { ) {
generateAction({ generateAction({
action: "remove_member", action: "remove_member",
channel: contextualChannel._id, channel: contextualChannel,
user: uid user: user!,
}); });
} }
} }
if (server && uid && userId !== uid && uid !== server.owner) { if (
if (serverPermissions & ServerPermission.KickMembers) server &&
generateAction({ action: "kick_member", target: server, user: uid }); uid &&
userId !== uid &&
uid !== server.owner
) {
if (
serverPermissions & ServerPermission.KickMembers
)
generateAction({
action: "kick_member",
target: server,
user: user!,
});
if (serverPermissions & ServerPermission.BanMembers) if (serverPermissions & ServerPermission.BanMembers)
generateAction({ action: "ban_member", target: server, user: uid }); generateAction({
action: "ban_member",
target: server,
user: user!,
});
} }
} }
if (queued) { if (queued) {
generateAction({ generateAction({
action: "retry_message", action: "retry_message",
message: queued message: queued,
}); });
generateAction({ generateAction({
action: "cancel_message", action: "cancel_message",
message: queued message: queued,
}); });
} }
if (message && !queued) { if (message && !queued) {
generateAction({ generateAction({
action: "reply_message", action: "reply_message",
id: message._id id: message._id,
}); });
if ( if (
typeof message.content === "string" && typeof message.content === "string" &&
message.content.length > 0 message.content.length > 0
) { ) {
generateAction({ generateAction({
action: "quote_message", action: "quote_message",
content: message.content content: message.content,
}); });
generateAction({ generateAction({
action: "copy_text", action: "copy_text",
content: message.content content: message.content,
}); });
} }
if (message.author === userId) { if (message.author_id === userId) {
generateAction({ generateAction({
action: "edit_message", action: "edit_message",
id: message._id id: message._id,
}); });
} }
if (message.author === userId || if (
channelPermissions & ChannelPermission.ManageMessages) { message.author_id === userId ||
channelPermissions &
ChannelPermission.ManageMessages
) {
generateAction({ generateAction({
action: "delete_message", action: "delete_message",
target: message target: message,
}); });
} }
...@@ -548,40 +700,39 @@ function ContextMenus(props: Props) { ...@@ -548,40 +700,39 @@ function ContextMenus(props: Props) {
generateAction( generateAction(
{ {
action: "open_file", action: "open_file",
attachment: message.attachments[0] attachment: message.attachments[0],
}, },
type === "Image" type === "Image"
? "open_image" ? "open_image"
: type === "Video" : type === "Video"
? "open_video" ? "open_video"
: "open_file" : "open_file",
); );
generateAction( generateAction(
{ {
action: "save_file", action: "save_file",
attachment: message.attachments[0] attachment: message.attachments[0],
}, },
type === "Image" type === "Image"
? "save_image" ? "save_image"
: type === "Video" : type === "Video"
? "save_video" ? "save_video"
: "save_file" : "save_file",
); );
generateAction( generateAction(
{ {
action: "copy_file_link", action: "copy_file_link",
attachment: message.attachments[0] attachment: message.attachments[0],
}, },
"copy_link" "copy_link",
); );
} }
if (document.activeElement?.tagName === "A") { if (document.activeElement?.tagName === "A") {
let link = document.activeElement.getAttribute( const link =
"href" document.activeElement.getAttribute("href");
);
if (link) { if (link) {
pushDivider(); pushDivider();
generateAction({ action: "open_link", link }); generateAction({ action: "open_link", link });
...@@ -590,160 +741,283 @@ function ContextMenus(props: Props) { ...@@ -590,160 +741,283 @@ function ContextMenus(props: Props) {
} }
} }
let id = sid ?? cid ?? uid ?? message?._id; const id = sid ?? cid ?? uid ?? message?._id;
if (id) { if (id) {
pushDivider(); pushDivider();
if (channel) { if (channel) {
if (channel.channel_type !== 'VoiceChannel') { if (channel.channel_type !== "VoiceChannel") {
generateAction({ action: "open_notification_options", channel }, undefined, undefined, <ChevronRight size={24} />); generateAction(
{
action: "open_notification_options",
channel,
},
undefined,
undefined,
<ChevronRight size={24} />,
);
} }
switch (channel.channel_type) { switch (channel.channel_type) {
case 'Group': case "Group":
// ! generateAction({ action: "create_invite", target: channel }); FIXME: add support for group invites // ! generateAction({ action: "create_invite", target: channel }); FIXME: add support for group invites
generateAction({ action: "open_channel_settings", id: channel._id }, "open_group_settings"); generateAction(
generateAction({ action: "leave_group", target: channel }, "leave_group"); {
action: "open_channel_settings",
id: channel._id,
},
"open_group_settings",
);
generateAction(
{
action: "leave_group",
target: channel,
},
"leave_group",
);
break; break;
case 'DirectMessage': case "DirectMessage":
generateAction({ action: "close_dm", target: channel }); generateAction({
action: "close_dm",
target: channel,
});
break; break;
case 'TextChannel': case "TextChannel":
case 'VoiceChannel': case "VoiceChannel":
// ! FIXME: add permission for invites if (
generateAction({ action: "create_invite", target: channel }); channelPermissions &
ChannelPermission.InviteOthers
if (serverPermissions & ServerPermission.ManageServer) ) {
generateAction({ action: "open_server_channel_settings", server: channel.server, id: channel._id }, "open_channel_settings"); generateAction({
action: "create_invite",
if (serverPermissions & ServerPermission.ManageChannels) target: channel,
generateAction({ action: "delete_channel", target: channel }); });
}
if (
serverPermissions &
ServerPermission.ManageServer
)
generateAction(
{
action: "open_server_channel_settings",
server: channel.server_id!,
id: channel._id,
},
"open_channel_settings",
);
if (
serverPermissions &
ServerPermission.ManageChannels
)
generateAction({
action: "delete_channel",
target: channel,
});
break; break;
} }
} }
if (sid && server) { if (sid && server) {
if (serverPermissions & ServerPermission.ManageServer) if (
generateAction({ action: "open_server_settings", id: server._id }, "open_server_settings"); serverPermissions &
ServerPermission.ManageServer
)
generateAction(
{
action: "open_server_settings",
id: server._id,
},
"open_server_settings",
);
if (userId === server.owner) { if (userId === server.owner) {
generateAction({ action: "delete_server", target: server }, "delete_server"); generateAction(
{ action: "delete_server", target: server },
"delete_server",
);
} else { } else {
generateAction({ action: "leave_server", target: server }, "leave_server"); generateAction(
{ action: "leave_server", target: server },
"leave_server",
);
} }
} }
generateAction( generateAction(
{ action: "copy_id", id }, { action: "copy_id", id },
sid ? "copy_sid" : sid
cid ? "copy_cid" : ? "copy_sid"
message ? "copy_mid" : "copy_uid" : cid
? "copy_cid"
: message
? "copy_mid"
: "copy_uid",
); );
} }
return elements; return elements;
}} }}
</ContextMenuWithData> </ContextMenuWithData>
<ContextMenuWithData id="Status" onClose={contextClick} className="Status"> <ContextMenuWithData
{() => <> id="Status"
<div className="header"> onClose={contextClick}
<div className="main"> className="Status">
<div>@{client.user!.username}</div> {() => {
<div className="status"><UserStatus user={client.user!} /></div> const user = client.user!;
</div> return (
<IconButton> <>
<MenuItem data={{ action: "open_settings" }}> <div className="header">
<Cog size={18} /> <div className="main">
<div
className="username"
onClick={() =>
writeClipboard(
client.user!.username,
)
}>
<Tooltip
content={
<Text id="app.special.copy_username" />
}>
@{user.username}
</Tooltip>
</div>
<div
className="status"
onClick={() =>
contextClick({
action: "set_status",
})
}>
<UserStatus user={user} />
</div>
</div>
<IconButton>
<MenuItem
data={{ action: "open_settings" }}>
<Cog size={22} />
</MenuItem>
</IconButton>
</div>
<LineDivider />
<MenuItem
data={{
action: "set_presence",
presence: Presence.Online,
}}
disabled={!isOnline}>
<div className="indicator online" />
<Text id={`app.status.online`} />
</MenuItem> </MenuItem>
</IconButton> <MenuItem
</div> data={{
<LineDivider /> action: "set_presence",
<MenuItem presence: Presence.Idle,
data={{ }}
action: "set_presence", disabled={!isOnline}>
presence: Users.Presence.Online <div className="indicator idle" />
}} <Text id={`app.status.idle`} />
disabled={!isOnline} </MenuItem>
> <MenuItem
<div className="indicator online" /> data={{
<Text id={`app.status.online`} /> action: "set_presence",
</MenuItem> presence: Presence.Busy,
<MenuItem }}
data={{ disabled={!isOnline}>
action: "set_presence", <div className="indicator busy" />
presence: Users.Presence.Idle <Text id={`app.status.busy`} />
}}
disabled={!isOnline}
>
<div className="indicator idle" />
<Text id={`app.status.idle`} />
</MenuItem>
<MenuItem
data={{
action: "set_presence",
presence: Users.Presence.Busy
}}
disabled={!isOnline}
>
<div className="indicator busy" />
<Text id={`app.status.busy`} />
</MenuItem>
<MenuItem
data={{
action: "set_presence",
presence: Users.Presence.Invisible
}}
disabled={!isOnline}
>
<div className="indicator invisible" />
<Text id={`app.status.invisible`} />
</MenuItem>
<LineDivider />
<div className="header">
<div className="main">
<MenuItem data={{ action: "set_status" }} disabled={!isOnline}>
<Text id={`app.context_menu.custom_status`} />
</MenuItem> </MenuItem>
</div> <MenuItem
{ client.user!.status?.text && <IconButton> data={{
<MenuItem data={{ action: "clear_status" }}> action: "set_presence",
<Trash size={18} /> presence: Presence.Invisible,
}}
disabled={!isOnline}>
<div className="indicator invisible" />
<Text id={`app.status.invisible`} />
</MenuItem> </MenuItem>
</IconButton> } <LineDivider />
</div> <MenuItem
</>} data={{ action: "set_status" }}
disabled={!isOnline}>
<UserVoice size={18} />
<Text id={`app.context_menu.custom_status`} />
{client.user!.status?.text && (
<IconButton>
<MenuItem
data={{ action: "clear_status" }}>
<Trash size={18} />
</MenuItem>
</IconButton>
)}
</MenuItem>
</>
);
}}
</ContextMenuWithData> </ContextMenuWithData>
<ContextMenuWithData id="NotificationOptions" onClose={contextClick}> <ContextMenuWithData
{({ channel }: { channel: Channels.Channel }) => { id="NotificationOptions"
onClose={contextClick}>
{({ channel }: { channel: Channel }) => {
const state = props.notifications[channel._id]; const state = props.notifications[channel._id];
const actual = getNotificationState(props.notifications, channel); const actual = getNotificationState(
props.notifications,
channel,
);
let elements: Children[] = [ const elements: Children[] = [
<MenuItem data={{ action: "set_notification_state", key: channel._id }}> <MenuItem
<Text id={`app.main.channel.notifications.default`} /> key="notif"
data={{
action: "set_notification_state",
key: channel._id,
}}>
<Text
id={`app.main.channel.notifications.default`}
/>
<div className="tip"> <div className="tip">
{ (state !== undefined) && <Square size={20} /> } {state !== undefined && <Square size={20} />}
{ (state === undefined) && <CheckSquare size={20} /> } {state === undefined && (
<CheckSquare size={20} />
)}
</div> </div>
</MenuItem> </MenuItem>,
]; ];
function generate(key: string, icon: Children) { function generate(key: string, icon: Children) {
elements.push( elements.push(
<MenuItem data={{ action: "set_notification_state", key: channel._id, state: key }}> <MenuItem
{ icon } key={key}
<Text id={`app.main.channel.notifications.${key}`} /> data={{
{ (state === undefined && actual === key) && <div className="tip"><LeftArrowAlt size={20} /></div> } action: "set_notification_state",
{ (state === key) && <div className="tip"><Check size={20} /></div> } key: channel._id,
</MenuItem> state: key,
}}>
{icon}
<Text
id={`app.main.channel.notifications.${key}`}
/>
{state === undefined && actual === key && (
<div className="tip">
<LeftArrowAlt size={20} />
</div>
)}
{state === key && (
<div className="tip">
<Check size={20} />
</div>
)}
</MenuItem>,
); );
} }
generate('all', <Bell size={24} />); generate("all", <Bell size={24} />);
generate('mention', <At size={24} />); generate("mention", <At size={24} />);
generate('muted', <BellOff size={24} />); generate("muted", <BellOff size={24} />);
generate('none', <Block size={24} />); generate("none", <Block size={24} />);
return elements; return elements;
}} }}
...@@ -752,12 +1026,8 @@ function ContextMenus(props: Props) { ...@@ -752,12 +1026,8 @@ function ContextMenus(props: Props) {
); );
} }
export default connectState( export default connectState(ContextMenus, (state) => {
ContextMenus, return {
state => { notifications: state.notifications,
return { };
notifications: state.notifications });
};
},
true
);
/* eslint-disable react-hooks/rules-of-hooks */
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
const counts: { [key: string]: number } = {}; const counts: { [key: string]: number } = {};
export default function PaintCounter({ small, always }: { small?: boolean, always?: boolean }) { export default function PaintCounter({
small,
always,
}: {
small?: boolean;
always?: boolean;
}) {
if (import.meta.env.PROD && !always) return null; if (import.meta.env.PROD && !always) return null;
const [uniqueId] = useState('' + Math.random()); const [uniqueId] = useState(`${Math.random()}`);
const count = counts[uniqueId] ?? 0; const count = counts[uniqueId] ?? 0;
counts[uniqueId] = count + 1; counts[uniqueId] = count + 1;
return ( return (
<div style={{ textAlign: 'center', fontSize: '0.8em' }}> <div style={{ textAlign: "center", fontSize: "0.8em" }}>
{ small ? <>P: { count + 1 }</> : <> {small ? <>P: {count + 1}</> : <>Painted {count + 1} time(s).</>}
Painted {count + 1} time(s).
</> }
</div> </div>
) );
} }
import { useEffect, useRef } from "preact/hooks"; import styled from "styled-components";
import { RefObject } from "preact";
import { useEffect, useLayoutEffect, useRef } from "preact/hooks";
import TextArea, { TextAreaProps } from "../components/ui/TextArea";
import { internalSubscribe } from "./eventEmitter"; import { internalSubscribe } from "./eventEmitter";
import { isTouchscreenDevice } from "./isTouchscreenDevice"; import { isTouchscreenDevice } from "./isTouchscreenDevice";
import TextArea, { DEFAULT_LINE_HEIGHT, DEFAULT_TEXT_AREA_PADDING, TextAreaProps, TEXT_AREA_BORDER_WIDTH } from "../components/ui/TextArea";
type TextAreaAutoSizeProps = Omit<JSX.HTMLAttributes<HTMLTextAreaElement>, 'style' | 'value'> & TextAreaProps & { type TextAreaAutoSizeProps = Omit<
forceFocus?: boolean JSX.HTMLAttributes<HTMLTextAreaElement>,
autoFocus?: boolean "style" | "value" | "onChange" | "children" | "as"
minHeight?: number > &
maxRows?: number TextAreaProps & {
value: string forceFocus?: boolean;
autoFocus?: boolean;
minHeight?: number;
maxRows?: number;
value: string;
id?: string;
onChange?: (ev: JSX.TargetedEvent<HTMLTextAreaElement, Event>) => void;
};
const Container = styled.div`
flex-grow: 1;
display: flex;
flex-direction: column;
`;
id?: string const Ghost = styled.div<{ lineHeight: string; maxRows: number }>`
}; flex: 0;
width: 100%;
overflow: hidden;
visibility: hidden;
position: relative;
> div {
width: 100%;
white-space: pre-wrap;
word-break: break-all;
top: 0;
position: absolute;
font-size: var(--text-size);
line-height: ${(props) => props.lineHeight};
max-height: calc(
calc(${(props) => props.lineHeight} * ${(props) => props.maxRows})
);
}
`;
export default function TextAreaAutoSize(props: TextAreaAutoSizeProps) { export default function TextAreaAutoSize(props: TextAreaAutoSizeProps) {
const { autoFocus, minHeight, maxRows, value, padding, lineHeight, hideBorder, forceFocus, children, as, ...textAreaProps } = props; const {
const line = lineHeight ?? DEFAULT_LINE_HEIGHT; autoFocus,
minHeight,
maxRows,
value,
padding,
lineHeight,
hideBorder,
forceFocus,
onChange,
...textAreaProps
} = props;
const heightPadding = ((padding ?? DEFAULT_TEXT_AREA_PADDING) + (hideBorder ? 0 : TEXT_AREA_BORDER_WIDTH)) * 2; const ref = useRef<HTMLTextAreaElement>() as RefObject<HTMLTextAreaElement>;
const height = Math.max(Math.min(value.split('\n').length, maxRows ?? Infinity) * line + heightPadding, minHeight ?? 0); const ghost = useRef<HTMLDivElement>() as RefObject<HTMLDivElement>;
const ref = useRef<HTMLTextAreaElement>(); useLayoutEffect(() => {
if (ref.current && ghost.current) {
ref.current.style.height = `${ghost.current.clientHeight}px`;
}
}, [ghost, props.value]);
useEffect(() => { useEffect(() => {
if (isTouchscreenDevice) return; if (isTouchscreenDevice) return;
autoFocus && ref.current.focus(); autoFocus && ref.current && ref.current.focus();
}, [value]); }, [value, autoFocus]);
const inputSelected = () => const inputSelected = () =>
["TEXTAREA", "INPUT"].includes(document.activeElement?.nodeName ?? ""); ["TEXTAREA", "INPUT"].includes(document.activeElement?.nodeName ?? "");
useEffect(() => { useEffect(() => {
if (!ref.current) return;
if (forceFocus) { if (forceFocus) {
ref.current.focus(); ref.current.focus();
} }
...@@ -51,30 +106,55 @@ export default function TextAreaAutoSize(props: TextAreaAutoSizeProps) { ...@@ -51,30 +106,55 @@ export default function TextAreaAutoSize(props: TextAreaAutoSizeProps) {
if ((e.ctrlKey && e.key !== "v") || e.altKey || e.metaKey) return; if ((e.ctrlKey && e.key !== "v") || e.altKey || e.metaKey) return;
if (e.key.length !== 1) return; if (e.key.length !== 1) return;
if (ref && !inputSelected()) { if (ref && !inputSelected()) {
ref.current.focus(); ref.current!.focus();
} }
} }
document.body.addEventListener("keydown", keyDown); document.body.addEventListener("keydown", keyDown);
return () => document.body.removeEventListener("keydown", keyDown); return () => document.body.removeEventListener("keydown", keyDown);
}, [ref]); }, [ref, autoFocus, forceFocus, value]);
useEffect(() => { useEffect(() => {
if (!ref.current) return;
function focus(id: string) { function focus(id: string) {
if (id === props.id) { if (id === props.id) {
ref.current.focus(); ref.current!.focus();
} }
} }
return internalSubscribe("TextArea", "focus", focus); return internalSubscribe(
}, [ref]); "TextArea",
"focus",
return <TextArea focus as (...args: unknown[]) => void,
ref={ref} );
value={value} }, [props.id, ref]);
padding={padding}
style={{ height }} return (
hideBorder={hideBorder} <Container>
lineHeight={lineHeight} <TextArea
{...textAreaProps} />; ref={ref}
value={value}
padding={padding}
style={{ minHeight }}
hideBorder={hideBorder}
lineHeight={lineHeight}
onChange={(ev) => {
onChange && onChange(ev);
}}
{...textAreaProps}
/>
<Ghost
lineHeight={lineHeight ?? "var(--textarea-line-height)"}
maxRows={maxRows ?? 5}>
<div ref={ghost} style={{ padding }}>
{props.value
? props.value
.split("\n")
.map((x) => `\u200e${x}`)
.join("\n")
: undefined ?? "\n"}
</div>
</Ghost>
</Container>
);
} }
export function urlBase64ToUint8Array(base64String: string) { export function urlBase64ToUint8Array(base64String: string) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4); const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding) const base64 = (base64String + padding)
.replace(/\-/g, "+") .replace(/-/g, "+")
.replace(/_/g, "/"); .replace(/_/g, "/");
const rawData = window.atob(base64); const rawData = window.atob(base64);
return Uint8Array.from([...rawData].map(char => char.charCodeAt(0))); return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
} }
export function debounce(cb: Function, duration: number) { export function debounce(cb: (...args: unknown[]) => void, duration: number) {
// Store the timer variable. // Store the timer variable.
let timer: NodeJS.Timeout; let timer: NodeJS.Timeout;
// This function is given to React. // This function is given to React.
return (...args: any[]) => { return (...args: unknown[]) => {
// Get rid of the old timer. // Get rid of the old timer.
clearTimeout(timer); clearTimeout(timer);
// Set a new timer. // Set a new timer.
......
import EventEmitter from "eventemitter3"; import EventEmitter from "eventemitter3";
export const InternalEvent = new EventEmitter(); export const InternalEvent = new EventEmitter();
export function internalSubscribe(ns: string, event: string, fn: (...args: any[]) => void) { export function internalSubscribe(
InternalEvent.addListener(ns + '/' + event, fn); ns: string,
return () => InternalEvent.removeListener(ns + '/' + event, fn); event: string,
fn: (...args: unknown[]) => void,
) {
InternalEvent.addListener(`${ns}/${event}`, fn);
return () => InternalEvent.removeListener(`${ns}/${event}`, fn);
} }
export function internalEmit(ns: string, event: string, ...args: any[]) { export function internalEmit(ns: string, event: string, ...args: unknown[]) {
InternalEvent.emit(ns + '/' + event, ...args); InternalEvent.emit(`${ns}/${event}`, ...args);
} }
// Event structure: namespace/event // Event structure: namespace/event
/// Event List /// Event List
// - MessageArea/jump_to_bottom
// - MessageRenderer/edit_last // - MessageRenderer/edit_last
// - MessageRenderer/edit_message // - MessageRenderer/edit_message
// - Intermediate/open_profile // - Intermediate/open_profile
...@@ -20,4 +26,5 @@ export function internalEmit(ns: string, event: string, ...args: any[]) { ...@@ -20,4 +26,5 @@ export function internalEmit(ns: string, event: string, ...args: any[]) {
// - MessageBox/append // - MessageBox/append
// - TextArea/focus // - TextArea/focus
// - ReplyBar/add // - ReplyBar/add
// - Modal/close
// - PWA/update // - PWA/update
import { IntlContext, translate } from "preact-i18n"; import { IntlContext, translate } from "preact-i18n";
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
import { Dictionary } from "../context/Locale";
import { Children } from "../types/Preact"; import { Children } from "../types/Preact";
interface Fields { interface Fields {
[key: string]: Children [key: string]: Children;
} }
interface Props { interface Props {
id: string; id: string;
fields: Fields fields: Fields;
} }
export interface IntlType { export interface IntlType {
intl: { intl: {
dictionary: { dictionary: Dictionary;
[key: string]: Object | string };
}
}
} }
// This will exhibit O(2^n) behaviour. // This will exhibit O(2^n) behaviour.
...@@ -24,36 +25,46 @@ function recursiveReplaceFields(input: string, fields: Fields) { ...@@ -24,36 +25,46 @@ function recursiveReplaceFields(input: string, fields: Fields) {
const key = Object.keys(fields)[0]; const key = Object.keys(fields)[0];
if (key) { if (key) {
const { [key]: field, ...restOfFields } = fields; const { [key]: field, ...restOfFields } = fields;
if (typeof field === 'undefined') return [ input ]; if (typeof field === "undefined") return [input];
const values: (Children | string[])[] = input.split(`{{${key}}}`) const values: (Children | string[])[] = input
.map(v => recursiveReplaceFields(v, restOfFields)); .split(`{{${key}}}`)
.map((v) => recursiveReplaceFields(v, restOfFields));
for (let i=values.length - 1;i>0;i-=2) { for (let i = values.length - 1; i > 0; i -= 2) {
values.splice(i, 0, field); values.splice(i, 0, field);
} }
return values.flat(); return values.flat();
} else {
// base case
return [ input ];
} }
// base case
return [input];
} }
export function TextReact({ id, fields }: Props) { export function TextReact({ id, fields }: Props) {
const { intl } = useContext(IntlContext) as unknown as IntlType; const { intl } = useContext(IntlContext) as unknown as IntlType;
const path = id.split('.'); const path = id.split(".");
let entry = intl.dictionary[path.shift()!]; let entry = intl.dictionary[path.shift()!];
for (let key of path) { for (const key of path) {
// @ts-expect-error // @ts-expect-error TODO: lazy
entry = entry[key]; entry = entry[key];
} }
return <>{ recursiveReplaceFields(entry as string, fields) }</>; return <>{recursiveReplaceFields(entry as string, fields)}</>;
} }
export function useTranslation() { export function useTranslation() {
const { intl } = useContext(IntlContext) as unknown as IntlType; const { intl } = useContext(IntlContext) as unknown as IntlType;
return (id: string, fields?: Object, plural?: number, fallback?: string) => translate(id, "", intl.dictionary, fields, plural, fallback); return (
id: string,
fields?: Record<string, string | undefined>,
plural?: number,
fallback?: string,
) => translate(id, "", intl.dictionary, fields, plural, fallback);
}
export function useDictionary() {
const { intl } = useContext(IntlContext) as unknown as IntlType;
return intl.dictionary;
} }
import { isDesktop, isMobile, isTablet } from "react-device-detect"; import { isDesktop, isMobile, isTablet } from "react-device-detect";
export const isTouchscreenDevice = export const isTouchscreenDevice =
isDesktop && !isTablet isDesktop || isTablet
? false ? false
: (typeof window !== "undefined" : (typeof window !== "undefined"
? navigator.maxTouchPoints > 0 ? navigator.maxTouchPoints > 0
......
/* eslint-disable @typescript-eslint/no-empty-function */
export const noop = () => {};
export const noopAsync = async () => {};
/* eslint-enable @typescript-eslint/no-empty-function */
import { RendererRoutines, RenderState, ScrollState } from "./types"; /* eslint-disable react-hooks/rules-of-hooks */
import { SimpleRenderer } from "./simple/SimpleRenderer"; import EventEmitter3 from "eventemitter3";
import { Client } from "revolt.js";
import { Message } from "revolt.js/dist/maps/Messages";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import EventEmitter3 from 'eventemitter3';
import { Client, Message } from "revolt.js"; import { SimpleRenderer } from "./simple/SimpleRenderer";
import { RendererRoutines, RenderState, ScrollState } from "./types";
export const SMOOTH_SCROLL_ON_RECEIVE = false; export const SMOOTH_SCROLL_ON_RECEIVE = false;
...@@ -23,7 +27,7 @@ export class SingletonRenderer extends EventEmitter3 { ...@@ -23,7 +27,7 @@ export class SingletonRenderer extends EventEmitter3 {
this.edit = this.edit.bind(this); this.edit = this.edit.bind(this);
this.delete = this.delete.bind(this); this.delete = this.delete.bind(this);
this.state = { type: 'LOADING' }; this.state = { type: "LOADING" };
this.currentRenderer = SimpleRenderer; this.currentRenderer = SimpleRenderer;
} }
...@@ -41,23 +45,23 @@ export class SingletonRenderer extends EventEmitter3 { ...@@ -41,23 +45,23 @@ export class SingletonRenderer extends EventEmitter3 {
subscribe(client: Client) { subscribe(client: Client) {
if (this.client) { if (this.client) {
this.client.removeListener('message', this.receive); this.client.removeListener("message", this.receive);
this.client.removeListener('message/update', this.edit); this.client.removeListener("message/update", this.edit);
this.client.removeListener('message/delete', this.delete); this.client.removeListener("message/delete", this.delete);
} }
this.client = client; this.client = client;
client.addListener('message', this.receive); client.addListener("message", this.receive);
client.addListener('message/update', this.edit); client.addListener("message/update", this.edit);
client.addListener('message/delete', this.delete); client.addListener("message/delete", this.delete);
} }
private setStateUnguarded(state: RenderState, scroll?: ScrollState) { private setStateUnguarded(state: RenderState, scroll?: ScrollState) {
this.state = state; this.state = state;
this.emit('state', state); this.emit("state", state);
if (scroll) { if (scroll) {
this.emit('scroll', scroll); this.emit("scroll", scroll);
} }
} }
...@@ -67,14 +71,29 @@ export class SingletonRenderer extends EventEmitter3 { ...@@ -67,14 +71,29 @@ export class SingletonRenderer extends EventEmitter3 {
} }
markStale() { markStale() {
this.stale = true; this.stale = true;
} }
async init(id: string) { async init(id: string, message_id?: string) {
if (message_id) {
if (this.state.type === "RENDER") {
const message = this.state.messages.find(
(x) => x._id === message_id,
);
if (message) {
this.emit("scroll", {
type: "ScrollToView",
id: message_id,
});
return;
}
}
}
this.channel = id; this.channel = id;
this.stale = false; this.stale = false;
this.setStateUnguarded({ type: 'LOADING' }); this.setStateUnguarded({ type: "LOADING" });
await this.currentRenderer.init(this, id); await this.currentRenderer.init(this, id, message_id);
} }
async reloadStale(id: string) { async reloadStale(id: string) {
...@@ -91,37 +110,42 @@ export class SingletonRenderer extends EventEmitter3 { ...@@ -91,37 +110,42 @@ export class SingletonRenderer extends EventEmitter3 {
function generateScroll(end: string): ScrollState { function generateScroll(end: string): ScrollState {
if (ref) { if (ref) {
let heightRemoved = 0; let heightRemoved = 0;
let messageContainer = ref.children[0]; const messageContainer = ref.children[0];
if (messageContainer) { if (messageContainer) {
for (let child of Array.from(messageContainer.children)) { for (const child of Array.from(messageContainer.children)) {
// If this child has a ulid. // If this child has a ulid.
if (child.id?.length === 26) { if (child.id?.length === 26) {
// Check whether it was removed. // Check whether it was removed.
if (child.id.localeCompare(end) === 1) { if (child.id.localeCompare(end) === 1) {
heightRemoved += child.clientHeight + heightRemoved +=
child.clientHeight +
// We also need to take into account the top margin of the container. // We also need to take into account the top margin of the container.
parseInt(window.getComputedStyle(child).marginTop.slice(0, -2)); parseInt(
window
.getComputedStyle(child)
.marginTop.slice(0, -2),
10,
);
} }
} }
} }
} }
return { return {
type: 'OffsetTop', type: "OffsetTop",
previousHeight: ref.scrollHeight - heightRemoved previousHeight: ref.scrollHeight - heightRemoved,
} };
} else {
return {
type: 'OffsetTop',
previousHeight: 0
}
} }
return {
type: "OffsetTop",
previousHeight: 0,
};
} }
await this.currentRenderer.loadTop(this, generateScroll); await this.currentRenderer.loadTop(this, generateScroll);
// Allow state updates to propagate. // Allow state updates to propagate.
setTimeout(() => this.fetchingTop = false, 0); setTimeout(() => (this.fetchingTop = false), 0);
} }
async loadBottom(ref?: HTMLDivElement) { async loadBottom(ref?: HTMLDivElement) {
...@@ -131,44 +155,49 @@ export class SingletonRenderer extends EventEmitter3 { ...@@ -131,44 +155,49 @@ export class SingletonRenderer extends EventEmitter3 {
function generateScroll(start: string): ScrollState { function generateScroll(start: string): ScrollState {
if (ref) { if (ref) {
let heightRemoved = 0; let heightRemoved = 0;
let messageContainer = ref.children[0]; const messageContainer = ref.children[0];
if (messageContainer) { if (messageContainer) {
for (let child of Array.from(messageContainer.children)) { for (const child of Array.from(messageContainer.children)) {
// If this child has a ulid. // If this child has a ulid.
if (child.id?.length === 26) { if (child.id?.length === 26) {
// Check whether it was removed. // Check whether it was removed.
if (child.id.localeCompare(start) === -1) { if (child.id.localeCompare(start) === -1) {
heightRemoved += child.clientHeight + heightRemoved +=
child.clientHeight +
// We also need to take into account the top margin of the container. // We also need to take into account the top margin of the container.
parseInt(window.getComputedStyle(child).marginTop.slice(0, -2)); parseInt(
window
.getComputedStyle(child)
.marginTop.slice(0, -2),
10,
);
} }
} }
} }
} }
return { return {
type: 'ScrollTop', type: "ScrollTop",
y: ref.scrollTop - heightRemoved y: ref.scrollTop - heightRemoved,
} };
} else {
return {
type: 'ScrollToBottom'
}
} }
return {
type: "ScrollToBottom",
};
} }
await this.currentRenderer.loadBottom(this, generateScroll); await this.currentRenderer.loadBottom(this, generateScroll);
// Allow state updates to propagate. // Allow state updates to propagate.
setTimeout(() => this.fetchingBottom = false, 0); setTimeout(() => (this.fetchingBottom = false), 0);
} }
async jumpToBottom(id: string, smooth: boolean) { async jumpToBottom(id: string, smooth: boolean) {
if (id !== this.channel) return; if (id !== this.channel) return;
if (this.state.type === 'RENDER' && this.state.atBottom) { if (this.state.type === "RENDER" && this.state.atBottom) {
this.emit('scroll', { type: 'ScrollToBottom', smooth }); this.emit("scroll", { type: "ScrollToBottom", smooth });
} else { } else {
await this.currentRenderer.init(this, id, true); await this.currentRenderer.init(this, id, undefined, true);
} }
} }
} }
...@@ -176,7 +205,9 @@ export class SingletonRenderer extends EventEmitter3 { ...@@ -176,7 +205,9 @@ export class SingletonRenderer extends EventEmitter3 {
export const SingletonMessageRenderer = new SingletonRenderer(); export const SingletonMessageRenderer = new SingletonRenderer();
export function useRenderState(id: string) { export function useRenderState(id: string) {
const [state, setState] = useState<Readonly<RenderState>>(SingletonMessageRenderer.state); const [state, setState] = useState<Readonly<RenderState>>(
SingletonMessageRenderer.state,
);
if (typeof id === "undefined") return; if (typeof id === "undefined") return;
function render(state: RenderState) { function render(state: RenderState) {
......
import { mapMessage } from "../../../context/revoltjs/util"; import { noopAsync } from "../../js";
import { SMOOTH_SCROLL_ON_RECEIVE } from "../Singleton"; import { SMOOTH_SCROLL_ON_RECEIVE } from "../Singleton";
import { RendererRoutines } from "../types"; import { RendererRoutines } from "../types";
export const SimpleRenderer: RendererRoutines = { export const SimpleRenderer: RendererRoutines = {
init: async (renderer, id, smooth) => { init: async (renderer, id, nearby, smooth) => {
if (renderer.client!.websocket.connected) { if (renderer.client!.websocket.connected) {
renderer.client!.channels if (nearby)
.fetchMessagesWithUsers(id, { }, true) renderer
.then(({ messages: data }) => { .client!.channels.get(id)!
data.reverse(); .fetchMessagesWithUsers({ nearby, limit: 100 })
let messages = data.map(x => mapMessage(x)); .then(({ messages }) => {
renderer.setState( messages.sort((a, b) => a._id.localeCompare(b._id));
id, renderer.setState(
{ id,
type: 'RENDER', {
messages, type: "RENDER",
atTop: data.length < 50, messages,
atBottom: true atTop: false,
}, atBottom: false,
{ type: 'ScrollToBottom', smooth } },
); { type: "ScrollToView", id: nearby },
}); );
});
else
renderer
.client!.channels.get(id)!
.fetchMessagesWithUsers({})
.then(({ messages }) => {
messages.reverse();
renderer.setState(
id,
{
type: "RENDER",
messages,
atTop: messages.length < 50,
atBottom: true,
},
{ type: "ScrollToBottom", smooth },
);
});
} else { } else {
renderer.setState(id, { type: 'WAITING_FOR_NETWORK' }); renderer.setState(id, { type: "WAITING_FOR_NETWORK" });
} }
}, },
receive: async (renderer, message) => { receive: async (renderer, message) => {
if (message.channel !== renderer.channel) return; if (message.channel_id !== renderer.channel) return;
if (renderer.state.type !== 'RENDER') return; if (renderer.state.type !== "RENDER") return;
if (renderer.state.messages.find(x => x._id === message._id)) return; if (renderer.state.messages.find((x) => x._id === message._id)) return;
if (!renderer.state.atBottom) return; if (!renderer.state.atBottom) return;
let messages = [ ...renderer.state.messages, mapMessage(message) ]; let messages = [...renderer.state.messages, message];
let atTop = renderer.state.atTop; let atTop = renderer.state.atTop;
if (messages.length > 150) { if (messages.length > 150) {
messages = messages.slice(messages.length - 150); messages = messages.slice(messages.length - 150);
...@@ -39,44 +57,23 @@ export const SimpleRenderer: RendererRoutines = { ...@@ -39,44 +57,23 @@ export const SimpleRenderer: RendererRoutines = {
} }
renderer.setState( renderer.setState(
message.channel, message.channel_id,
{ {
...renderer.state, ...renderer.state,
messages, messages,
atTop atTop,
}, },
{ type: 'StayAtBottom', smooth: SMOOTH_SCROLL_ON_RECEIVE } { type: "StayAtBottom", smooth: SMOOTH_SCROLL_ON_RECEIVE },
); );
}, },
edit: async (renderer, id, patch) => { edit: noopAsync,
const channel = renderer.channel;
if (!channel) return;
if (renderer.state.type !== 'RENDER') return;
let messages = [ ...renderer.state.messages ];
let index = messages.findIndex(x => x._id === id);
if (index > -1) {
let message = { ...messages[index], ...mapMessage(patch) };
messages.splice(index, 1, message);
renderer.setState(
channel,
{
...renderer.state,
messages
},
{ type: 'StayAtBottom' }
);
}
},
delete: async (renderer, id) => { delete: async (renderer, id) => {
const channel = renderer.channel; const channel = renderer.channel;
if (!channel) return; if (!channel) return;
if (renderer.state.type !== 'RENDER') return; if (renderer.state.type !== "RENDER") return;
let messages = [ ...renderer.state.messages ]; const messages = [...renderer.state.messages];
let index = messages.findIndex(x => x._id === id); const index = messages.findIndex((x) => x._id === id);
if (index > -1) { if (index > -1) {
messages.splice(index, 1); messages.splice(index, 1);
...@@ -85,9 +82,9 @@ export const SimpleRenderer: RendererRoutines = { ...@@ -85,9 +82,9 @@ export const SimpleRenderer: RendererRoutines = {
channel, channel,
{ {
...renderer.state, ...renderer.state,
messages messages,
}, },
{ type: 'StayAtBottom' } { type: "StayAtBottom" },
); );
} }
}, },
...@@ -96,25 +93,24 @@ export const SimpleRenderer: RendererRoutines = { ...@@ -96,25 +93,24 @@ export const SimpleRenderer: RendererRoutines = {
if (!channel) return; if (!channel) return;
const state = renderer.state; const state = renderer.state;
if (state.type !== 'RENDER') return; if (state.type !== "RENDER") return;
if (state.atTop) return; if (state.atTop) return;
const { messages: data } = await renderer.client!.channels.fetchMessagesWithUsers(channel, { const { messages: data } = await renderer
before: state.messages[0]._id .client!.channels.get(channel)!
}, true); .fetchMessagesWithUsers({
before: state.messages[0]._id,
});
if (data.length === 0) { if (data.length === 0) {
return renderer.setState( return renderer.setState(channel, {
channel, ...state,
{ atTop: true,
...state, });
atTop: true
}
);
} }
data.reverse(); data.reverse();
let messages = [ ...data.map(x => mapMessage(x)), ...state.messages ]; let messages = [...data, ...state.messages];
let atTop = false; let atTop = false;
if (data.length < 50) { if (data.length < 50) {
...@@ -130,7 +126,7 @@ export const SimpleRenderer: RendererRoutines = { ...@@ -130,7 +126,7 @@ export const SimpleRenderer: RendererRoutines = {
renderer.setState( renderer.setState(
channel, channel,
{ ...state, atTop, atBottom, messages }, { ...state, atTop, atBottom, messages },
generateScroll(messages[messages.length - 1]._id) generateScroll(messages[messages.length - 1]._id),
); );
}, },
loadBottom: async (renderer, generateScroll) => { loadBottom: async (renderer, generateScroll) => {
...@@ -138,25 +134,24 @@ export const SimpleRenderer: RendererRoutines = { ...@@ -138,25 +134,24 @@ export const SimpleRenderer: RendererRoutines = {
if (!channel) return; if (!channel) return;
const state = renderer.state; const state = renderer.state;
if (state.type !== 'RENDER') return; if (state.type !== "RENDER") return;
if (state.atBottom) return; if (state.atBottom) return;
const { messages: data } = await renderer.client!.channels.fetchMessagesWithUsers(channel, { const { messages: data } = await renderer
after: state.messages[state.messages.length - 1]._id, .client!.channels.get(channel)!
sort: 'Oldest' .fetchMessagesWithUsers({
}, true); after: state.messages[state.messages.length - 1]._id,
sort: "Oldest",
});
if (data.length === 0) { if (data.length === 0) {
return renderer.setState( return renderer.setState(channel, {
channel, ...state,
{ atBottom: true,
...state, });
atBottom: true
}
);
} }
let messages = [ ...state.messages, ...data.map(x => mapMessage(x)) ]; let messages = [...state.messages, ...data];
let atBottom = false; let atBottom = false;
if (data.length < 50) { if (data.length < 50) {
...@@ -172,7 +167,7 @@ export const SimpleRenderer: RendererRoutines = { ...@@ -172,7 +167,7 @@ export const SimpleRenderer: RendererRoutines = {
renderer.setState( renderer.setState(
channel, channel,
{ ...state, atTop, atBottom, messages }, { ...state, atTop, atBottom, messages },
generateScroll(messages[0]._id) generateScroll(messages[0]._id),
); );
} },
}; };
import { Message } from "revolt.js"; import { Message } from "revolt.js/dist/maps/Messages";
import { SingletonRenderer } from "./Singleton"; import { SingletonRenderer } from "./Singleton";
import { MessageObject } from "../../context/revoltjs/util";
export type ScrollState = export type ScrollState =
| { type: "Free" } | { type: "Free" }
| { type: "Bottom", scrollingUntil?: number } | { type: "Bottom"; scrollingUntil?: number }
| { type: "ScrollToBottom" | "StayAtBottom", smooth?: boolean } | { type: "ScrollToBottom" | "StayAtBottom"; smooth?: boolean }
| { type: "ScrollToView"; id: string }
| { type: "OffsetTop"; previousHeight: number } | { type: "OffsetTop"; previousHeight: number }
| { type: "ScrollTop"; y: number }; | { type: "ScrollTop"; y: number };
export type RenderState = export type RenderState =
| { | {
type: "LOADING" | "WAITING_FOR_NETWORK" | "EMPTY"; type: "LOADING" | "WAITING_FOR_NETWORK" | "EMPTY";
} }
| { | {
type: "RENDER"; type: "RENDER";
atTop: boolean; atTop: boolean;
atBottom: boolean; atBottom: boolean;
messages: MessageObject[]; messages: Message[];
}; };
export interface RendererRoutines { export interface RendererRoutines {
init: (renderer: SingletonRenderer, id: string, smooth?: boolean) => Promise<void> init: (
renderer: SingletonRenderer,
id: string,
message?: string,
smooth?: boolean,
) => Promise<void>;
receive: (renderer: SingletonRenderer, message: Message) => Promise<void>; receive: (renderer: SingletonRenderer, message: Message) => Promise<void>;
edit: (renderer: SingletonRenderer, id: string, partial: Partial<Message>) => Promise<void>; edit: (
renderer: SingletonRenderer,
id: string,
partial: Partial<Message>,
) => Promise<void>;
delete: (renderer: SingletonRenderer, id: string) => Promise<void>; delete: (renderer: SingletonRenderer, id: string) => Promise<void>;
loadTop: (renderer: SingletonRenderer, generateScroll: (end: string) => ScrollState) => Promise<void>; loadTop: (
loadBottom: (renderer: SingletonRenderer, generateScroll: (start: string) => ScrollState) => Promise<void>; renderer: SingletonRenderer,
generateScroll: (end: string) => ScrollState,
) => Promise<void>;
loadBottom: (
renderer: SingletonRenderer,
generateScroll: (start: string) => ScrollState,
) => Promise<void>;
} }
export const stopPropagation = (ev: JSX.TargetedMouseEvent<HTMLDivElement>, _consume?: any) => { export const stopPropagation = (
ev: JSX.TargetedMouseEvent<HTMLElement>,
// eslint-disable-next-line
_consume?: unknown,
) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
return true; return true;
......