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 1355 additions and 566 deletions
import { openDB } from 'idb'; /* eslint-disable react-hooks/rules-of-hooks */
import { Client } from "revolt.js"; import { Client } from "revolt.js";
import { takeError } from "./util";
import { createContext } from "preact";
import { Children } from "../../types/Preact";
import { Route } from "revolt.js/dist/api/routes"; import { Route } from "revolt.js/dist/api/routes";
import { useEffect, useMemo, useState } from "preact/hooks";
import { createContext } from "preact";
import { useContext, useEffect, useMemo, useState } from "preact/hooks";
import { SingletonMessageRenderer } from "../../lib/renderer/Singleton";
import { dispatch } from "../../redux";
import { connectState } from "../../redux/connector"; import { connectState } from "../../redux/connector";
import Preloader from "../../components/ui/Preloader";
import { WithDispatcher } from "../../redux/reducers";
import { AuthState } from "../../redux/reducers/auth"; import { AuthState } from "../../redux/reducers/auth";
import { SyncOptions } from "../../redux/reducers/sync";
import Preloader from "../../components/ui/Preloader";
import { Children } from "../../types/Preact";
import { useIntermediate } from "../intermediate/Intermediate";
import { registerEvents, setReconnectDisallowed } from "./events"; import { registerEvents, setReconnectDisallowed } from "./events";
import { takeError } from "./util";
export enum ClientStatus { export enum ClientStatus {
INIT, INIT,
...@@ -30,72 +36,61 @@ export interface ClientOperations { ...@@ -30,72 +36,61 @@ export interface ClientOperations {
ready: () => boolean; ready: () => boolean;
} }
export const AppContext = createContext<Client>(undefined as any); // By the time they are used, they should all be initialized.
export const StatusContext = createContext<ClientStatus>(undefined as any); // Currently the app does not render until a client is built and the other two are always initialized on first render.
export const OperationsContext = createContext<ClientOperations>(undefined as any); // - insert's words
export const AppContext = createContext<Client>(null!);
export const StatusContext = createContext<ClientStatus>(null!);
export const OperationsContext = createContext<ClientOperations>(null!);
type Props = WithDispatcher & { type Props = {
auth: AuthState; auth: AuthState;
sync: SyncOptions;
children: Children; children: Children;
}; };
function Context({ auth, sync, children, dispatcher }: Props) { function Context({ auth, children }: Props) {
const { openScreen } = useIntermediate();
const [status, setStatus] = useState(ClientStatus.INIT); const [status, setStatus] = useState(ClientStatus.INIT);
const [client, setClient] = useState<Client>(undefined as unknown as Client); const [client, setClient] = useState<Client>(
undefined as unknown as Client,
);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
let db; const client = new Client({
try {
db = await openDB('state', 3, {
upgrade(db) {
for (let store of [ "channels", "servers", "users", "members" ]) {
db.createObjectStore(store, {
keyPath: '_id'
});
}
},
});
} catch (err) {
console.error('Failed to open IndexedDB store, continuing without.');
}
setClient(new Client({
autoReconnect: false, autoReconnect: false,
apiURL: import.meta.env.VITE_API_URL, apiURL: import.meta.env.VITE_API_URL,
debug: import.meta.env.DEV, debug: import.meta.env.DEV,
db });
}));
setClient(client);
SingletonMessageRenderer.subscribe(client);
setStatus(ClientStatus.LOADING); setStatus(ClientStatus.LOADING);
})(); })();
}, [ ]); }, []);
if (status === ClientStatus.INIT) return null; if (status === ClientStatus.INIT) return null;
const operations: ClientOperations = useMemo(() => { const operations: ClientOperations = useMemo(() => {
return { return {
login: async data => { login: async (data) => {
setReconnectDisallowed(true); setReconnectDisallowed(true);
try { try {
const onboarding = await client.login(data); const onboarding = await client.login(data);
setReconnectDisallowed(false); setReconnectDisallowed(false);
const login = () => const login = () =>
dispatcher({ dispatch({
type: "LOGIN", type: "LOGIN",
session: client.session as any session: client.session!, // This [null assertion] is ok, we should have a session by now. - insert's words
}); });
if (onboarding) { if (onboarding) {
/*openScreen({ openScreen({
id: "onboarding", id: "onboarding",
callback: async (username: string) => { callback: async (username: string) =>
await (onboarding as any)(username, true); onboarding(username, true).then(login),
login(); });
}
});*/
} else { } else {
login(); login();
} }
...@@ -104,13 +99,13 @@ function Context({ auth, sync, children, dispatcher }: Props) { ...@@ -104,13 +99,13 @@ function Context({ auth, sync, children, dispatcher }: Props) {
throw err; throw err;
} }
}, },
logout: async shouldRequest => { logout: async (shouldRequest) => {
dispatcher({ type: "LOGOUT" }); dispatch({ type: "LOGOUT" });
delete client.user; client.reset();
dispatcher({ type: "RESET" }); dispatch({ type: "RESET" });
// openScreen({ id: "none" }); openScreen({ id: "none" });
setStatus(ClientStatus.READY); setStatus(ClientStatus.READY);
client.websocket.disconnect(); client.websocket.disconnect();
...@@ -124,24 +119,20 @@ function Context({ auth, sync, children, dispatcher }: Props) { ...@@ -124,24 +119,20 @@ function Context({ auth, sync, children, dispatcher }: Props) {
} }
}, },
loggedIn: () => typeof auth.active !== "undefined", loggedIn: () => typeof auth.active !== "undefined",
ready: () => ( ready: () =>
operations.loggedIn() && operations.loggedIn() && typeof client.user !== "undefined",
typeof client.user !== "undefined" };
) }, [client, auth.active, openScreen]);
}
}, [ client, auth.active ]);
useEffect( useEffect(
() => registerEvents({ operations, dispatcher }, setStatus, client), () => registerEvents({ operations }, setStatus, client),
[ client ] [client, operations],
); );
useEffect(() => { useEffect(() => {
(async () => { (async () => {
await client.restore();
if (auth.active) { if (auth.active) {
dispatcher({ type: "QUEUE_FAIL_ALL" }); dispatch({ type: "QUEUE_FAIL_ALL" });
const active = auth.accounts[auth.active]; const active = auth.accounts[auth.active];
client.user = client.users.get(active.session.user_id); client.user = client.users.get(active.session.user_id);
...@@ -149,54 +140,38 @@ function Context({ auth, sync, children, dispatcher }: Props) { ...@@ -149,54 +140,38 @@ function Context({ auth, sync, children, dispatcher }: Props) {
return setStatus(ClientStatus.OFFLINE); return setStatus(ClientStatus.OFFLINE);
} }
if (operations.ready()) if (operations.ready()) setStatus(ClientStatus.CONNECTING);
setStatus(ClientStatus.CONNECTING);
if (navigator.onLine) { if (navigator.onLine) {
await client await client
.fetchConfiguration() .fetchConfiguration()
.catch(() => .catch(() =>
console.error("Failed to connect to API server.") console.error("Failed to connect to API server."),
); );
} }
try { try {
await client.fetchConfiguration(); await client.fetchConfiguration();
const callback = await client.useExistingSession( const callback = await client.useExistingSession(
active.session active.session,
); );
//if (callback) { if (callback) {
/*openScreen({ id: "onboarding", 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() });*/
//}
} catch (err) { } catch (err) {
setStatus(ClientStatus.DISCONNECTED); setStatus(ClientStatus.DISCONNECTED);
const error = takeError(err); const error = takeError(err);
if (error === "Forbidden") { if (error === "Forbidden" || error === "Unauthorized") {
operations.logout(true); operations.logout(true);
// openScreen({ id: "signed_out" }); openScreen({ id: "signed_out" });
} else { } else {
// openScreen({ id: "error", error }); openScreen({ id: "error", error });
} }
} }
} else { } else {
try { try {
await client.fetchConfiguration() await client.fetchConfiguration();
} catch (err) { } catch (err) {
console.error("Failed to connect to API server."); console.error("Failed to connect to API server.");
} }
...@@ -204,30 +179,29 @@ function Context({ auth, sync, children, dispatcher }: Props) { ...@@ -204,30 +179,29 @@ function Context({ auth, sync, children, dispatcher }: Props) {
setStatus(ClientStatus.READY); setStatus(ClientStatus.READY);
} }
})(); })();
// eslint-disable-next-line
}, []); }, []);
if (status === ClientStatus.LOADING) { if (status === ClientStatus.LOADING) {
return <Preloader />; return <Preloader type="spinner" />;
} }
return ( return (
<AppContext.Provider value={client}> <AppContext.Provider value={client}>
<StatusContext.Provider value={status}> <StatusContext.Provider value={status}>
<OperationsContext.Provider value={operations}> <OperationsContext.Provider value={operations}>
{ children } {children}
</OperationsContext.Provider> </OperationsContext.Provider>
</StatusContext.Provider> </StatusContext.Provider>
</AppContext.Provider> </AppContext.Provider>
); );
} }
export default connectState<{ children: Children }>( export default connectState<{ children: Children }>(Context, (state) => {
Context, return {
state => { auth: state.auth,
return { sync: state.sync,
auth: state.auth, };
sync: state.sync });
};
}, export const useClient = () => useContext(AppContext);
true
);
/**
* This file monitors the message cache to delete any queued messages that have already sent.
*/
import { Message } from "revolt.js/dist/maps/Messages";
import { useContext, useEffect } from "preact/hooks";
import { dispatch } from "../../redux";
import { connectState } from "../../redux/connector";
import { QueuedMessage } from "../../redux/reducers/queue";
import { AppContext } from "./RevoltClient";
type Props = {
messages: QueuedMessage[];
};
function StateMonitor(props: Props) {
const client = useContext(AppContext);
useEffect(() => {
dispatch({
type: "QUEUE_DROP_ALL",
});
}, []);
useEffect(() => {
function add(msg: Message) {
if (!msg.nonce) return;
if (!props.messages.find((x) => x.id === msg.nonce)) return;
dispatch({
type: "QUEUE_REMOVE",
nonce: msg.nonce,
});
}
client.addListener("message", add);
return () => client.removeListener("message", add);
}, [client, props.messages]);
return null;
}
export default connectState(StateMonitor, (state) => {
return {
messages: [...state.queue],
};
});
/**
* This file monitors changes to settings and syncs them to the server.
*/
import isEqual from "lodash.isequal";
import { UserSettings } from "revolt-api/types/Sync";
import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
import { useCallback, useContext, useEffect, useMemo } from "preact/hooks";
import { dispatch } from "../../redux";
import { connectState } from "../../redux/connector";
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";
type Props = {
settings: Settings;
locale: Language;
sync: SyncOptions;
notifications: Notifications;
};
const lastValues: { [key in SyncKeys]?: unknown } = {};
export function mapSync(
packet: UserSettings,
revision?: Record<string, number>,
) {
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;
}
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) => {
dispatch({
type: "SYNC_UPDATE",
update: mapSync(data),
});
});
client
.syncFetchUnreads()
.then((unreads) => dispatch({ type: "UNREADS_SET", unreads }));
}
}, [client, props.sync?.disabled, status]);
const syncChange = useCallback(
(key: SyncKeys, data: unknown) => {
const timestamp = +new Date();
dispatch({
type: "SYNC_SET_REVISION",
key,
timestamp,
});
client.syncSetSettings(
{
[key]: data as string,
},
timestamp,
);
},
[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(() => {
if (disabled.indexOf(key) === -1) {
if (typeof lastValues[key] !== "undefined") {
if (!isEqual(lastValues[key], object)) {
syncChange(key, object);
}
}
}
lastValues[key] = object;
}, [key, syncChange, disabled, object]);
}
useEffect(() => {
function onPacket(packet: ClientboundNotification) {
if (packet.type === "UserSettingsUpdate") {
const update: { [key in SyncKeys]?: [number, SyncData[key]] } =
mapSync(packet.update, props.sync.revision);
dispatch({
type: "SYNC_UPDATE",
update,
});
}
}
client.addListener("packet", onPacket);
return () => client.removeListener("packet", onPacket);
}, [client, disabled, props.sync]);
return null;
}
export default connectState(SyncManager, (state) => {
return {
settings: state.settings,
locale: state.locale,
sync: state.sync,
notifications: state.notifications,
};
});
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,
const listeners = { ) {
function attemptReconnect() {
if (preventReconnect) return;
function reconnect() {
preventUntil = +new Date() + 2000;
client.websocket.connect().catch((err) => console.error(err));
}
if (+new Date() > preventUntil) {
setTimeout(reconnect, 2000);
} else {
reconnect();
}
}
// 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),
dropped: () => { dropped: () => {
operations.ready() && setStatus(ClientStatus.DISCONNECTED); if (operations.ready()) {
setStatus(ClientStatus.DISCONNECTED);
if (preventReconnect) return; attemptReconnect();
function reconnect() {
preventUntil = +new Date() + 2000;
client.websocket.connect().catch(err => console.error(err));
}
if (+new Date() > preventUntil) {
setTimeout(reconnect, 2000);
} else {
reconnect();
} }
}, },
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,
request: false
}); });
break; break;
} }
...@@ -71,51 +60,62 @@ export function registerEvents({ ...@@ -71,51 +60,62 @@ 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: () => { ready: () => setStatus(ClientStatus.ONLINE),
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)) { // TODO: clean this a bit and properly handle types
client.addListener(listener, (listenerFunc as any)[listener]); for (const listener in listeners) {
client.addListener(listener, listeners[listener]);
} }
/*const online = () => const online = () => {
operations.ready() && setStatus(ClientStatus.RECONNECTING); if (operations.ready()) {
const offline = () => setStatus(ClientStatus.RECONNECTING);
operations.ready() && setStatus(ClientStatus.OFFLINE); setReconnectDisallowed(false);
attemptReconnect();
}
};
const offline = () => {
if (operations.ready()) {
setReconnectDisallowed(true);
client.websocket.disconnect();
setStatus(ClientStatus.OFFLINE);
}
};
window.addEventListener("online", online); window.addEventListener("online", online);
window.addEventListener("offline", offline); window.addEventListener("offline", offline);
return () => { return () => {
for (const listener of Object.keys(listenerFunc)) { for (const listener in listeners) {
RevoltClient.removeListener(listener, (listenerFunc as any)[listener]); client.removeListener(
listener,
listeners[listener as keyof typeof listeners],
);
} }
window.removeEventListener("online", online); window.removeEventListener("online", online);
window.removeEventListener("offline", offline); window.removeEventListener("offline", offline);
};*/ };
} }
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 [, updateState] = useState({});
return { client, forceUpdate: useCallback(() => updateState({}), []) };
}
function useObject(type: string, id?: string | string[], context?: HookContext) {
const ctx = useForceUpdate(context);
function mutation(target: string) {
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", mutation);
return () => map.removeListener("update", mutation);
}, [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 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 mutation = (target: string) => (target === id) && ctx.forceUpdate();
useEffect(() => {
ctx.client.channels.addListener("update", mutation);
return () => ctx.client.channels.removeListener("update", mutation);
}, [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();
useEffect(() => {
ctx.client.servers.addListener("update", mutation);
return () => ctx.client.servers.removeListener("update", mutation);
}, [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,14 +22,20 @@ export function takeError( ...@@ -22,14 +22,20 @@ export function takeError(
return id; return id;
} }
export function getChannelName(client: Client, channel: Channel, users: User[], 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 && "@"}{users.find(x => x._id === uid)?.username}</>; {prefixType && "@"}
{channel.recipient!.username}
</>
);
} }
if (channel.channel_type === "TextChannel" && prefixType) { if (channel.channel_type === "TextChannel" && prefixType) {
...@@ -38,12 +44,3 @@ export function getChannelName(client: Client, channel: Channel, users: User[], ...@@ -38,12 +44,3 @@ export function getChannelName(client: Client, channel: Channel, users: User[],
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";
type Props = LinkProps &
JSX.HTMLAttributes<HTMLAnchorElement> & {
active: boolean;
};
export default function ConditionalLink(props: Props) {
const { active, ...linkProps } = props;
if (active) {
return <a>{props.children}</a>;
}
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,
} 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 UserStatus from "../components/common/user/UserStatus";
import IconButton from "../components/ui/IconButton";
import LineDivider from "../components/ui/LineDivider"; import LineDivider from "../components/ui/LineDivider";
import { connectState } from "../redux/connector";
import { Children } from "../types/Preact";
import { internalEmit } from "./eventEmitter";
interface ContextMenuData { interface ContextMenuData {
user?: string; user?: string;
...@@ -34,43 +73,63 @@ type Action = ...@@ -34,43 +73,63 @@ 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: "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: string } | { action: "block_user"; user: User }
| { action: "unblock_user"; user: string } | { action: "unblock_user"; user: User }
| { action: "add_friend"; user: string } | { action: "add_friend"; user: User }
| { action: "remove_friend"; user: string } | { action: "remove_friend"; user: User }
| { action: "cancel_friend"; user: string } | { 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"; server: string } | { action: "create_channel"; target: Server }
| { action: "create_invite"; target: Channels.GroupChannel | Channels.TextChannel } | {
| { action: "leave_group"; target: Channels.GroupChannel } action: "create_invite";
| { action: "delete_channel"; target: Channels.TextChannel } 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_channel_settings", id: string } action: "delete_channel";
| { action: "open_server_settings", id: string } target: Channel;
| { action: "open_server_channel_settings", server: string, id: string }; }
| { action: "close_dm"; target: Channel }
function ContextMenus(props: WithDispatcher) { | { action: "leave_server"; target: Server }
| { action: "delete_server"; target: Server }
| { action: "open_notification_options"; channel: Channel }
| { action: "open_settings" }
| { action: "open_channel_settings"; id: string }
| { action: "open_server_settings"; id: string }
| { action: "open_server_channel_settings"; server: string; id: string }
| {
action: "set_notification_state";
key: string;
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) {
const { openScreen, writeClipboard } = useIntermediate(); const { openScreen, writeClipboard } = useIntermediate();
const client = useContext(AppContext); const client = useContext(AppContext);
const userId = client.user!._id; const userId = client.user!._id;
...@@ -87,82 +146,108 @@ function ContextMenus(props: WithDispatcher) { ...@@ -87,82 +146,108 @@ function ContextMenus(props: WithDispatcher) {
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') return; {
props.dispatcher({ if (
type: "UNREADS_MARK_READ", data.channel.channel_type === "SavedMessages" ||
channel: data.channel._id, data.channel.channel_type === "VoiceChannel"
message: data.channel.channel_type === 'TextChannel' ? data.channel.last_message : data.channel.last_message._id, )
request: true return;
});
const message =
typeof data.channel.last_message === "string"
? data.channel.last_message
: data.channel.last_message!._id;
dispatch({
type: "UNREADS_MARK_READ",
channel: data.channel._id,
message,
});
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,
content: data.message.data.content as string, content: data.message.data.content as string,
nonce 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;
case "mention": case "mention":
{ {
// edit draft internalEmit(
/*InternalEventEmitter.emit( "MessageBox",
"append_messagebox", "append",
`<@${data.user}>`, `<@${data.user}>`,
"mention" "mention",
);*/ );
} }
break; break;
case "copy_text": case "copy_text":
writeClipboard(data.content); writeClipboard(data.content);
break; break;
case "reply_message":
{
internalEmit("ReplyBar", "add", data.id);
}
break;
case "quote_message": case "quote_message":
{ {
// edit draft internalEmit(
/*InternalEventEmitter.emit( "MessageBox",
"append_messagebox", "append",
data.content, data.content,
"quote" "quote",
);*/ );
} }
break; break;
case "edit_message": case "edit_message":
{ {
// InternalEventEmitter.emit("edit_message", data.id); internalEmit(
"MessageRenderer",
"edit_message",
data.id,
);
} }
break; break;
...@@ -171,7 +256,7 @@ function ContextMenus(props: WithDispatcher) { ...@@ -171,7 +256,7 @@ function ContextMenus(props: WithDispatcher) {
window window
.open( .open(
client.generateFileURL(data.attachment), client.generateFileURL(data.attachment),
"_blank" "_blank",
) )
?.focus(); ?.focus();
} }
...@@ -181,18 +266,25 @@ function ContextMenus(props: WithDispatcher) { ...@@ -181,18 +266,25 @@ function ContextMenus(props: WithDispatcher) {
{ {
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;
case "copy_file_link": case "copy_file_link":
{ {
const { _id, 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;
...@@ -211,17 +303,17 @@ function ContextMenus(props: WithDispatcher) { ...@@ -211,17 +303,17 @@ function ContextMenus(props: WithDispatcher) {
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}`);
} }
...@@ -230,63 +322,116 @@ function ContextMenus(props: WithDispatcher) { ...@@ -230,63 +322,116 @@ function ContextMenus(props: WithDispatcher) {
case "add_friend": case "add_friend":
{ {
let user = client.users.get(data.user); await data.user.addFriend();
if (user) {
await client.users.addFriend(user.username);
}
} }
break; break;
case "block_user": case "block_user":
await client.users.blockUser(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); await data.user.unblockUser();
break; break;
case "remove_friend": case "remove_friend":
openScreen({
id: "special_prompt",
type: "unfriend_user",
target: data.user,
});
break;
case "cancel_friend": case "cancel_friend":
await client.users.removeFriend(data.user); 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":
case "delete_channel": case "delete_channel":
case "delete_server": case "delete_server":
case "delete_message": case "delete_message":
// @ts-expect-error case "create_channel":
case "create_invite": openScreen({ id: "special_prompt", type: data.action, target: data.target }); break; case "create_invite":
// 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": {
openContextMenu("NotificationOptions", {
channel: data.channel,
});
break;
}
case "create_channel": openScreen({ id: "special_input", type: "create_channel", server: data.server }); break; case "open_settings":
history.push("/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 "open_channel_settings": history.push(`/channel/${data.id}/settings`); break; case "set_notification_state": {
case "open_server_channel_settings": history.push(`/server/${data.server}/channel/${data.id}/settings`); break; const { key, state } = data;
case "open_server_settings": history.push(`/server/${data.id}/settings`); break; if (state) {
dispatch({ type: "NOTIFICATIONS_SET", key, state });
} else {
dispatch({ type: "NOTIFICATIONS_REMOVE", key });
}
break;
}
} }
})().catch(err => { })().catch((err) => {
openScreen({ id: "error", error: takeError(err) }); openScreen({ id: "error", error: takeError(err) });
}); });
} }
...@@ -302,29 +447,27 @@ function ContextMenus(props: WithDispatcher) { ...@@ -302,29 +447,27 @@ function ContextMenus(props: WithDispatcher) {
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>
); );
} }
...@@ -335,44 +478,69 @@ function ContextMenus(props: WithDispatcher) { ...@@ -335,44 +478,69 @@ function ContextMenus(props: WithDispatcher) {
} }
if (server_list) { if (server_list) {
let permissions = useServerPermission(server_list, forceUpdate); const server = client.servers.get(server_list)!;
if (permissions & ServerPermission.ManageChannels) generateAction({ action: 'create_channel', server: server_list }); const permissions = server.permission;
if (permissions & ServerPermission.ManageServer) generateAction({ action: 'open_server_settings', id: server_list }); if (server) {
if (permissions & ServerPermission.ManageChannels)
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 server = useServer(targetChannel?.channel_type === 'TextChannel' ? targetChannel.server : sid, forceUpdate); const serverChannel =
targetChannel &&
const channelPermissions = targetChannel ? useChannelPermission(targetChannel._id, forceUpdate) : 0; (targetChannel.channel_type === "TextChannel" ||
const serverPermissions = server ? useServerPermission(server._id, forceUpdate) : ( targetChannel.channel_type === "VoiceChannel")
targetChannel?.channel_type === 'TextChannel' ? useServerPermission(targetChannel.server, forceUpdate) : 0 ? targetChannel
); : 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( generateAction({ action: "mark_as_read", channel });
{ action: "mark_as_read", channel },
undefined,
true
);
} }
if (contextualChannel) { if (contextualChannel) {
if (user && user._id !== userId) { if (user && user._id !== userId) {
generateAction({ generateAction({
action: "mention", action: "mention",
user: user._id user: user._id,
}); });
pushDivider(); pushDivider();
...@@ -380,110 +548,147 @@ function ContextMenus(props: WithDispatcher) { ...@@ -380,110 +548,147 @@ function ContextMenus(props: WithDispatcher) {
} }
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", "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._id 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({
action: "reply_message",
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,
}); });
} }
...@@ -495,40 +700,39 @@ function ContextMenus(props: WithDispatcher) { ...@@ -495,40 +700,39 @@ function ContextMenus(props: WithDispatcher) {
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 });
...@@ -537,120 +741,293 @@ function ContextMenus(props: WithDispatcher) { ...@@ -537,120 +741,293 @@ function ContextMenus(props: WithDispatcher) {
} }
} }
let id = server?._id ?? channel?._id ?? user?._id ?? message?._id; const id = sid ?? cid ?? uid ?? message?._id;
if (id) { if (id) {
pushDivider(); pushDivider();
if (channel) { if (channel) {
if (channel.channel_type !== "VoiceChannel") {
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":
// ! FIXME: add permission for invites case "VoiceChannel":
generateAction({ action: "create_invite", target: channel }); if (
channelPermissions &
if (serverPermissions & ServerPermission.ManageServer) ChannelPermission.InviteOthers
generateAction({ action: "open_server_channel_settings", server: channel.server, id: channel._id }, "open_channel_settings"); ) {
generateAction({
if (serverPermissions & ServerPermission.ManageChannels) action: "create_invite",
generateAction({ action: "delete_channel", target: 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;
}}
</ContextMenuWithData>
<ContextMenuWithData
id="Status"
onClose={contextClick}
className="Status">
{() => {
const user = client.user!;
return (
<>
<div className="header">
<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
data={{
action: "set_presence",
presence: Presence.Idle,
}}
disabled={!isOnline}>
<div className="indicator idle" />
<Text id={`app.status.idle`} />
</MenuItem>
<MenuItem
data={{
action: "set_presence",
presence: Presence.Busy,
}}
disabled={!isOnline}>
<div className="indicator busy" />
<Text id={`app.status.busy`} />
</MenuItem>
<MenuItem
data={{
action: "set_presence",
presence: Presence.Invisible,
}}
disabled={!isOnline}>
<div className="indicator invisible" />
<Text id={`app.status.invisible`} />
</MenuItem>
<LineDivider />
<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
id="NotificationOptions"
onClose={contextClick}>
{({ channel }: { channel: Channel }) => {
const state = props.notifications[channel._id];
const actual = getNotificationState(
props.notifications,
channel,
);
const elements: Children[] = [
<MenuItem
key="notif"
data={{
action: "set_notification_state",
key: channel._id,
}}>
<Text
id={`app.main.channel.notifications.default`}
/>
<div className="tip">
{state !== undefined && <Square size={20} />}
{state === undefined && (
<CheckSquare size={20} />
)}
</div>
</MenuItem>,
];
function generate(key: string, icon: Children) {
elements.push(
<MenuItem
key={key}
data={{
action: "set_notification_state",
key: channel._id,
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("mention", <At size={24} />);
generate("muted", <BellOff size={24} />);
generate("none", <Block size={24} />);
return elements; return elements;
}} }}
</ContextMenuWithData> </ContextMenuWithData>
<ContextMenu id="Status" onClose={contextClick}>
<span data-disabled={true}>@{client.user?.username}</span>
<LineDivider />
<MenuItem
data={{
action: "set_presence",
presence: Users.Presence.Online
}}
disabled={!isOnline}
>
<div className="indicator online" />
<Text id={`app.status.online`} />
</MenuItem>
<MenuItem
data={{
action: "set_presence",
presence: Users.Presence.Idle
}}
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 />
<MenuItem data={{ action: "set_status" }} disabled={!isOnline}>
<Text id={`app.context_menu.custom_status`} />
</MenuItem>
{client.user?.status?.text && (
<MenuItem
data={{ action: "clear_status" }}
disabled={!isOnline}
>
<Text id={`app.context_menu.clear_status`} />
</MenuItem>
)}
</ContextMenu>
</> </>
); );
} }
export default connectState( export default connectState(ContextMenus, (state) => {
ContextMenus, return {
() => { notifications: state.notifications,
return {}; };
}, });
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 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 { isTouchscreenDevice } from "./isTouchscreenDevice";
type TextAreaAutoSizeProps = Omit<
JSX.HTMLAttributes<HTMLTextAreaElement>,
"style" | "value" | "onChange" | "children" | "as"
> &
TextAreaProps & {
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;
`;
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) {
const {
autoFocus,
minHeight,
maxRows,
value,
padding,
lineHeight,
hideBorder,
forceFocus,
onChange,
...textAreaProps
} = props;
const ref = useRef<HTMLTextAreaElement>() as RefObject<HTMLTextAreaElement>;
const ghost = useRef<HTMLDivElement>() as RefObject<HTMLDivElement>;
useLayoutEffect(() => {
if (ref.current && ghost.current) {
ref.current.style.height = `${ghost.current.clientHeight}px`;
}
}, [ghost, props.value]);
useEffect(() => {
if (isTouchscreenDevice) return;
autoFocus && ref.current && ref.current.focus();
}, [value, autoFocus]);
const inputSelected = () =>
["TEXTAREA", "INPUT"].includes(document.activeElement?.nodeName ?? "");
useEffect(() => {
if (!ref.current) return;
if (forceFocus) {
ref.current.focus();
}
if (isTouchscreenDevice) return;
if (autoFocus && !inputSelected()) {
ref.current.focus();
}
// ? if you are wondering what this is
// ? it is a quick and dirty hack to fix
// ? value not setting correctly
// ? I have no clue what's going on
ref.current.value = value;
if (!autoFocus) return;
function keyDown(e: KeyboardEvent) {
if ((e.ctrlKey && e.key !== "v") || e.altKey || e.metaKey) return;
if (e.key.length !== 1) return;
if (ref && !inputSelected()) {
ref.current!.focus();
}
}
document.body.addEventListener("keydown", keyDown);
return () => document.body.removeEventListener("keydown", keyDown);
}, [ref, autoFocus, forceFocus, value]);
useEffect(() => {
if (!ref.current) return;
function focus(id: string) {
if (id === props.id) {
ref.current!.focus();
}
}
return internalSubscribe(
"TextArea",
"focus",
focus as (...args: unknown[]) => void,
);
}, [props.id, ref]);
return (
<Container>
<TextArea
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) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding)
.replace(/-/g, "+")
.replace(/_/g, "/");
const rawData = window.atob(base64);
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}
export function debounce(cb: (...args: unknown[]) => void, duration: number) {
// Store the timer variable.
let timer: NodeJS.Timeout;
// This function is given to React.
return (...args: unknown[]) => {
// Get rid of the old timer.
clearTimeout(timer);
// Set a new timer.
timer = setTimeout(() => {
// Instead calling the new function.
// (with the newer data)
cb(...args);
}, duration);
};
}
export const defer = (cb: () => void) => setTimeout(cb, 0);
import EventEmitter from "eventemitter3";
export const InternalEvent = new EventEmitter();
export function internalSubscribe(
ns: string,
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: unknown[]) {
InternalEvent.emit(`${ns}/${event}`, ...args);
}
// Event structure: namespace/event
/// Event List
// - MessageArea/jump_to_bottom
// - MessageRenderer/edit_last
// - MessageRenderer/edit_message
// - Intermediate/open_profile
// - Intermediate/navigate
// - MessageBox/append
// - TextArea/focus
// - ReplyBar/add
// - Modal/close
// - PWA/update
export function determineFileSize(size: number) {
if (size > 1e6) {
return `${(size / 1e6).toFixed(2)} MB`;
} else if (size > 1e3) {
return `${(size / 1e3).toFixed(2)} KB`;
}
return `${size} B`;
}
import { IntlContext, translate } from "preact-i18n";
import { useContext } from "preact/hooks";
import { Dictionary } from "../context/Locale";
import { Children } from "../types/Preact";
interface Fields {
[key: string]: Children;
}
interface Props {
id: string;
fields: Fields;
}
export interface IntlType {
intl: {
dictionary: Dictionary;
};
}
// This will exhibit O(2^n) behaviour.
function recursiveReplaceFields(input: string, fields: Fields) {
const key = Object.keys(fields)[0];
if (key) {
const { [key]: field, ...restOfFields } = fields;
if (typeof field === "undefined") return [input];
const values: (Children | string[])[] = input
.split(`{{${key}}}`)
.map((v) => recursiveReplaceFields(v, restOfFields));
for (let i = values.length - 1; i > 0; i -= 2) {
values.splice(i, 0, field);
}
return values.flat();
}
// base case
return [input];
}
export function TextReact({ id, fields }: Props) {
const { intl } = useContext(IntlContext) as unknown as IntlType;
const path = id.split(".");
let entry = intl.dictionary[path.shift()!];
for (const key of path) {
// @ts-expect-error TODO: lazy
entry = entry[key];
}
return <>{recursiveReplaceFields(entry as string, fields)}</>;
}
export function useTranslation() {
const { intl } = useContext(IntlContext) as unknown as IntlType;
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 */