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 1948 additions and 2133 deletions
/**
* This file monitors the message cache to delete any queued messages that have already sent.
*/
import { Message } from "revolt.js";
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 { Typing } from "../../redux/reducers/typing";
import { AppContext } from "./RevoltClient";
type Props = {
messages: QueuedMessage[];
typing: Typing;
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);
}, [props.messages]);
useEffect(() => {
function removeOld() {
if (!props.typing) return;
for (let channel of Object.keys(props.typing)) {
let users = props.typing[channel];
for (let user of users) {
if (+new Date() > user.started + 5000) {
dispatch({
type: "TYPING_STOP",
channel,
user: user.id,
});
}
}
}
}
removeOld();
let interval = setInterval(removeOld, 1000);
return () => clearInterval(interval);
}, [props.typing]);
return null;
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],
typing: state.typing,
};
return {
messages: [...state.queue],
};
});
......@@ -2,145 +2,152 @@
* This file monitors changes to settings and syncs them to the server.
*/
import isEqual from "lodash.isequal";
import { Sync } from "revolt.js/dist/api/objects";
import { UserSettings } from "revolt-api/types/Sync";
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 { Notifications } from "../../redux/reducers/notifications";
import { Settings } from "../../redux/reducers/settings";
import {
DEFAULT_ENABLED_SYNC,
SyncData,
SyncKeys,
SyncOptions,
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;
settings: Settings;
locale: Language;
sync: SyncOptions;
notifications: Notifications;
};
var lastValues: { [key in SyncKeys]?: any } = {};
const lastValues: { [key in SyncKeys]?: unknown } = {};
export function mapSync(
packet: Sync.UserSettings,
revision?: Record<string, number>,
packet: UserSettings,
revision?: Record<string, number>,
) {
let update: { [key in SyncKeys]?: [number, SyncData[key]] } = {};
for (let key of Object.keys(packet)) {
let [timestamp, obj] = packet[key];
if (timestamp < (revision ?? {})[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;
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 }));
}
}, [status]);
function syncChange(key: SyncKeys, data: any) {
let timestamp = +new Date();
dispatch({
type: "SYNC_SET_REVISION",
key,
timestamp,
});
client.syncSetSettings(
{
[key]: data,
},
timestamp,
);
}
let disabled = props.sync.disabled ?? [];
for (let [key, object] of [
["appearance", props.settings.appearance],
["theme", props.settings.theme],
["locale", props.locale],
["notifications", props.notifications],
] as [SyncKeys, any][]) {
useEffect(() => {
if (disabled.indexOf(key) === -1) {
if (typeof lastValues[key] !== "undefined") {
if (!isEqual(lastValues[key], object)) {
syncChange(key, object);
}
}
}
lastValues[key] = object;
}, [disabled, object]);
}
useEffect(() => {
function onPacket(packet: ClientboundNotification) {
if (packet.type === "UserSettingsUpdate") {
let update: { [key in SyncKeys]?: [number, SyncData[key]] } =
mapSync(packet.update, props.sync.revision);
dispatch({
type: "SYNC_UPDATE",
update,
});
}
}
client.addListener("packet", onPacket);
return () => client.removeListener("packet", onPacket);
}, [disabled, props.sync]);
return null;
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,
};
return {
settings: state.settings,
locale: state.locale,
sync: state.sync,
notifications: state.notifications,
};
});
import { Client, Message } from "revolt.js/dist";
import { Client } from "revolt.js/dist";
import { Message } from "revolt.js/dist/maps/Messages";
import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
import { StateUpdater } from "preact/hooks";
......@@ -7,149 +8,114 @@ import { dispatch } from "../../redux";
import { ClientOperations, ClientStatus } from "./RevoltClient";
export var preventReconnect = false;
export let preventReconnect = false;
let preventUntil = 0;
export function setReconnectDisallowed(allowed: boolean) {
preventReconnect = allowed;
preventReconnect = allowed;
}
export function registerEvents(
{ operations }: { operations: ClientOperations },
setStatus: StateUpdater<ClientStatus>,
client: Client,
{ operations }: { operations: ClientOperations },
setStatus: StateUpdater<ClientStatus>,
client: Client,
) {
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();
}
}
let listeners: Record<string, (...args: any[]) => void> = {
connecting: () =>
operations.ready() && setStatus(ClientStatus.CONNECTING),
dropped: () => {
if (operations.ready()) {
setStatus(ClientStatus.DISCONNECTED);
attemptReconnect();
}
},
packet: (packet: ClientboundNotification) => {
switch (packet.type) {
case "ChannelStartTyping": {
if (packet.user === client.user?._id) return;
dispatch({
type: "TYPING_START",
channel: packet.id,
user: packet.user,
});
break;
}
case "ChannelStopTyping": {
if (packet.user === client.user?._id) return;
dispatch({
type: "TYPING_STOP",
channel: packet.id,
user: packet.user,
});
break;
}
case "ChannelAck": {
dispatch({
type: "UNREADS_MARK_READ",
channel: packet.id,
message: packet.message_id,
});
break;
}
}
},
message: (message: Message) => {
if (message.mentions?.includes(client.user!._id)) {
dispatch({
type: "UNREADS_MENTION",
channel: message.channel,
message: message._id,
});
}
},
ready: () => setStatus(ClientStatus.ONLINE),
};
if (import.meta.env.DEV) {
listeners = new Proxy(listeners, {
get:
(target, listener, receiver) =>
(...args: unknown[]) => {
console.debug(`Calling ${listener.toString()} with`, args);
Reflect.get(target, listener)(...args);
},
});
}
// TODO: clean this a bit and properly handle types
for (const listener in listeners) {
client.addListener(listener, listeners[listener]);
}
function logMutation(target: string, key: string) {
console.log("(o) Object mutated", target, "\nChanged:", key);
}
if (import.meta.env.DEV) {
client.users.addListener("mutation", logMutation);
client.servers.addListener("mutation", logMutation);
client.channels.addListener("mutation", logMutation);
client.servers.members.addListener("mutation", logMutation);
}
const online = () => {
if (operations.ready()) {
setStatus(ClientStatus.RECONNECTING);
setReconnectDisallowed(false);
attemptReconnect();
}
};
const offline = () => {
if (operations.ready()) {
setReconnectDisallowed(true);
client.websocket.disconnect();
setStatus(ClientStatus.OFFLINE);
}
};
window.addEventListener("online", online);
window.addEventListener("offline", offline);
return () => {
for (const listener in listeners) {
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("offline", offline);
};
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: () =>
operations.ready() && setStatus(ClientStatus.CONNECTING),
dropped: () => {
if (operations.ready()) {
setStatus(ClientStatus.DISCONNECTED);
attemptReconnect();
}
},
packet: (packet: ClientboundNotification) => {
switch (packet.type) {
case "ChannelAck": {
dispatch({
type: "UNREADS_MARK_READ",
channel: packet.id,
message: packet.message_id,
});
break;
}
}
},
message: (message: Message) => {
if (message.mention_ids?.includes(client.user!._id)) {
dispatch({
type: "UNREADS_MENTION",
channel: message.channel_id,
message: message._id,
});
}
},
ready: () => setStatus(ClientStatus.ONLINE),
};
if (import.meta.env.DEV) {
listeners = new Proxy(listeners, {
get:
(target, listener) =>
(...args: unknown[]) => {
console.debug(`Calling ${listener.toString()} with`, args);
Reflect.get(target, listener)(...args);
},
});
}
// TODO: clean this a bit and properly handle types
for (const listener in listeners) {
client.addListener(listener, listeners[listener]);
}
const online = () => {
if (operations.ready()) {
setStatus(ClientStatus.RECONNECTING);
setReconnectDisallowed(false);
attemptReconnect();
}
};
const offline = () => {
if (operations.ready()) {
setReconnectDisallowed(true);
client.websocket.disconnect();
setStatus(ClientStatus.OFFLINE);
}
};
window.addEventListener("online", online);
window.addEventListener("offline", offline);
return () => {
for (const listener in listeners) {
client.removeListener(
listener,
listeners[listener as keyof typeof listeners],
);
}
window.removeEventListener("online", online);
window.removeEventListener("offline", offline);
};
}
import { Client, PermissionCalculator } from "revolt.js";
import { Channels, Servers, Users } from "revolt.js/dist/api/objects";
import Collection from "revolt.js/dist/maps/Collection";
import { useCallback, useContext, useEffect, useState } from "preact/hooks";
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()) };
}
// TODO: utils.d.ts maybe?
type PickProperties<T, U> = Pick<
T,
{
[K in keyof T]: T[K] extends U ? K : never;
}[keyof T]
>;
// The keys in Client that are an object
// for some reason undefined keeps appearing despite there being no reason to so it's filtered out
type ClientCollectionKey = Exclude<
keyof PickProperties<Client, Collection<any>>,
undefined
>;
function useObject(
type: ClientCollectionKey,
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[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 { Client } from "revolt.js";
import { Channel, Message, User } from "revolt.js/dist/api/objects";
import { Channel } from "revolt.js/dist/maps/Channels";
import { Text } from "preact-i18n";
import { Children } from "../../types/Preact";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function takeError(error: any): string {
const type = error?.response?.data?.type;
let id = type;
if (!type) {
if (error?.response?.status === 403) {
return "Unauthorized";
} else if (error && !!error.isAxiosError && !error.response) {
return "NetworkError";
}
console.error(error);
return "UnknownError";
}
return id;
const type = error?.response?.data?.type;
const id = type;
if (!type) {
if (error?.response?.status === 403) {
return "Unauthorized";
} else if (error && !!error.isAxiosError && !error.response) {
return "NetworkError";
}
console.error(error);
return "UnknownError";
}
return id;
}
export function getChannelName(
client: Client,
channel: Channel,
prefixType?: boolean,
channel: Channel,
prefixType?: boolean,
): Children {
if (channel.channel_type === "SavedMessages")
return <Text id="app.navigation.tabs.saved" />;
if (channel.channel_type === "DirectMessage") {
let uid = client.channels.getRecipient(channel._id);
return (
<>
{prefixType && "@"}
{client.users.get(uid)?.username}
</>
);
}
if (channel.channel_type === "TextChannel" && prefixType) {
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;
if (channel.channel_type === "SavedMessages")
return <Text id="app.navigation.tabs.saved" />;
if (channel.channel_type === "DirectMessage") {
return (
<>
{prefixType && "@"}
{channel.recipient!.username}
</>
);
}
if (channel.channel_type === "TextChannel" && prefixType) {
return <>#{channel.name}</>;
}
return <>{channel.name}</>;
}
interface ImportMetaEnv {
VITE_API_URL: string;
VITE_THEMES_URL: string;
VITE_API_URL: string;
VITE_THEMES_URL: string;
}
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;
};
JSX.HTMLAttributes<HTMLAnchorElement> & {
active: boolean;
};
export default function ConditionalLink(props: Props) {
const { active, ...linkProps } = props;
const { active, ...linkProps } = props;
if (active) {
return <a>{props.children}</a>;
} else {
return <Link {...linkProps} />;
}
if (active) {
return <a>{props.children}</a>;
}
return <Link {...linkProps} />;
}
import {
At,
Bell,
BellOff,
Check,
CheckSquare,
ChevronRight,
Block,
Square,
LeftArrowAlt,
Trash,
At,
Bell,
BellOff,
Check,
CheckSquare,
ChevronRight,
Block,
Square,
LeftArrowAlt,
Trash,
} from "@styled-icons/boxicons-regular";
import { Cog } from "@styled-icons/boxicons-solid";
import { Cog, UserVoice } from "@styled-icons/boxicons-solid";
import { useHistory } from "react-router-dom";
import { Attachment } from "revolt-api/types/Autumn";
import { Presence, RelationshipStatus } from "revolt-api/types/Users";
import {
Attachment,
Channels,
Message,
Servers,
Users,
} from "revolt.js/dist/api/objects";
import {
ChannelPermission,
ServerPermission,
UserPermission,
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 {
ContextMenu,
ContextMenuWithData,
MenuItem,
openContextMenu,
ContextMenuWithData,
MenuItem,
openContextMenu,
} from "preact-context-menu";
import { Text } from "preact-i18n";
import { useContext } from "preact/hooks";
......@@ -37,29 +35,21 @@ import { useContext } from "preact/hooks";
import { dispatch } from "../redux";
import { connectState } from "../redux/connector";
import {
getNotificationState,
Notifications,
NotificationState,
getNotificationState,
Notifications,
NotificationState,
} from "../redux/reducers/notifications";
import { QueuedMessage } from "../redux/reducers/queue";
import { useIntermediate } from "../context/intermediate/Intermediate";
import { Screen, useIntermediate } from "../context/intermediate/Intermediate";
import {
AppContext,
ClientStatus,
StatusContext,
AppContext,
ClientStatus,
StatusContext,
} from "../context/revoltjs/RevoltClient";
import {
useChannel,
useChannelPermission,
useForceUpdate,
useServer,
useServerPermission,
useUser,
useUserPermission,
} from "../context/revoltjs/hooks";
import { takeError } from "../context/revoltjs/util";
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";
......@@ -68,951 +58,976 @@ import { Children } from "../types/Preact";
import { internalEmit } from "./eventEmitter";
interface ContextMenuData {
user?: string;
server?: string;
server_list?: string;
channel?: string;
message?: Message;
unread?: boolean;
queued?: QueuedMessage;
contextualChannel?: string;
user?: string;
server?: string;
server_list?: string;
channel?: string;
message?: Message;
unread?: boolean;
queued?: QueuedMessage;
contextualChannel?: string;
}
type Action =
| { action: "copy_id"; id: string }
| { action: "copy_selection" }
| { action: "copy_text"; content: string }
| { action: "mark_as_read"; channel: Channels.Channel }
| { action: "retry_message"; message: QueuedMessage }
| { action: "cancel_message"; message: QueuedMessage }
| { action: "mention"; user: string }
| { action: "reply_message"; id: string }
| { action: "quote_message"; content: string }
| { action: "edit_message"; id: string }
| { action: "delete_message"; target: Channels.Message }
| { action: "open_file"; attachment: Attachment }
| { action: "save_file"; attachment: Attachment }
| { action: "copy_file_link"; attachment: Attachment }
| { action: "open_link"; link: string }
| { action: "copy_link"; link: string }
| { action: "remove_member"; channel: string; user: string }
| { action: "kick_member"; target: Servers.Server; user: string }
| { action: "ban_member"; target: Servers.Server; user: string }
| { action: "view_profile"; user: string }
| { action: "message_user"; user: string }
| { action: "block_user"; user: Users.User }
| { action: "unblock_user"; user: Users.User }
| { action: "add_friend"; user: Users.User }
| { action: "remove_friend"; user: Users.User }
| { action: "cancel_friend"; user: Users.User }
| { action: "set_presence"; presence: Users.Presence }
| { action: "set_status" }
| { action: "clear_status" }
| { action: "create_channel"; target: Servers.Server }
| {
action: "create_invite";
target:
| Channels.GroupChannel
| Channels.TextChannel
| Channels.VoiceChannel;
}
| { action: "leave_group"; target: Channels.GroupChannel }
| {
action: "delete_channel";
target: Channels.TextChannel | Channels.VoiceChannel;
}
| { action: "close_dm"; target: Channels.DirectMessageChannel }
| { action: "leave_server"; target: Servers.Server }
| { action: "delete_server"; target: Servers.Server }
| { action: "open_notification_options"; channel: Channels.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;
};
| { action: "copy_id"; id: string }
| { action: "copy_selection" }
| { action: "copy_text"; content: string }
| { action: "mark_as_read"; channel: Channel }
| { action: "retry_message"; message: QueuedMessage }
| { action: "cancel_message"; message: QueuedMessage }
| { action: "mention"; user: string }
| { action: "reply_message"; id: string }
| { action: "quote_message"; content: string }
| { action: "edit_message"; id: string }
| { action: "delete_message"; target: Message }
| { action: "open_file"; attachment: Attachment }
| { action: "save_file"; attachment: Attachment }
| { action: "copy_file_link"; attachment: Attachment }
| { action: "open_link"; link: string }
| { action: "copy_link"; link: string }
| { action: "remove_member"; channel: Channel; user: User }
| { action: "kick_member"; target: Server; user: User }
| { action: "ban_member"; target: Server; user: User }
| { action: "view_profile"; user: User }
| { action: "message_user"; user: User }
| { action: "block_user"; user: User }
| { action: "unblock_user"; user: User }
| { action: "add_friend"; user: User }
| { action: "remove_friend"; user: User }
| { action: "cancel_friend"; user: User }
| { action: "set_presence"; presence: Presence }
| { action: "set_status" }
| { action: "clear_status" }
| { action: "create_channel"; target: Server }
| {
action: "create_invite";
target: Channel;
}
| { action: "leave_group"; target: 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_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;
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 client = useContext(AppContext);
const userId = client.user!._id;
const status = useContext(StatusContext);
const isOnline = status === ClientStatus.ONLINE;
const history = useHistory();
function contextClick(data?: Action) {
if (typeof data === "undefined") return;
(async () => {
switch (data.action) {
case "copy_id":
writeClipboard(data.id);
break;
case "copy_selection":
writeClipboard(document.getSelection()?.toString() ?? "");
break;
case "mark_as_read":
{
if (
data.channel.channel_type === "SavedMessages" ||
data.channel.channel_type === "VoiceChannel"
)
return;
let message =
data.channel.channel_type === "TextChannel"
? 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;
case "retry_message":
{
const nonce = data.message.id;
const fail = (error: any) =>
dispatch({
type: "QUEUE_FAIL",
nonce,
error,
});
client.channels
.sendMessage(data.message.channel, {
nonce: data.message.id,
content: data.message.data.content as string,
replies: data.message.data.replies,
})
.catch(fail);
dispatch({
type: "QUEUE_START",
nonce,
});
}
break;
case "cancel_message":
{
dispatch({
type: "QUEUE_REMOVE",
nonce: data.message.id,
});
}
break;
case "mention":
{
internalEmit(
"MessageBox",
"append",
`<@${data.user}>`,
"mention",
);
}
break;
case "copy_text":
writeClipboard(data.content);
break;
case "reply_message":
{
internalEmit("ReplyBar", "add", data.id);
}
break;
case "quote_message":
{
internalEmit(
"MessageBox",
"append",
data.content,
"quote",
);
}
break;
case "edit_message":
{
internalEmit(
"MessageRenderer",
"edit_message",
data.id,
);
}
break;
case "open_file":
{
window
.open(
client.generateFileURL(data.attachment),
"_blank",
)
?.focus();
}
break;
case "save_file":
{
window.open(
// ! FIXME: do this from revolt.js
client
.generateFileURL(data.attachment)
?.replace(
"attachments",
"attachments/download",
),
"_blank",
);
}
break;
case "copy_file_link":
{
const { filename } = data.attachment;
writeClipboard(
// ! FIXME: do from r.js
client.generateFileURL(data.attachment) +
`/${encodeURI(filename)}`,
);
}
break;
case "open_link":
{
window.open(data.link, "_blank")?.focus();
}
break;
case "copy_link":
{
writeClipboard(data.link);
}
break;
case "remove_member":
{
client.channels.removeMember(data.channel, data.user);
}
break;
case "view_profile":
openScreen({ id: "profile", user_id: data.user });
break;
case "message_user":
{
const channel = await client.users.openDM(data.user);
if (channel) {
history.push(`/channel/${channel._id}`);
}
}
break;
case "add_friend":
{
await client.users.addFriend(data.user.username);
}
break;
case "block_user":
openScreen({
id: "special_prompt",
type: "block_user",
target: data.user,
});
break;
case "unblock_user":
await client.users.unblockUser(data.user._id);
break;
case "remove_friend":
openScreen({
id: "special_prompt",
type: "unfriend_user",
target: data.user,
});
break;
case "cancel_friend":
await client.users.removeFriend(data.user._id);
break;
case "set_presence":
{
await client.users.editUser({
status: {
...client.user?.status,
presence: data.presence,
},
});
}
break;
case "set_status":
openScreen({
id: "special_input",
type: "set_custom_status",
});
break;
case "clear_status":
{
let { text, ...status } = client.user?.status ?? {};
await client.users.editUser({ status });
}
break;
case "leave_group":
case "close_dm":
case "leave_server":
case "delete_channel":
case "delete_server":
case "delete_message":
case "create_channel":
case "create_invite":
// The any here is because typescript flattens the case types into a single type and type structure and specifity is lost or whatever
openScreen({
id: "special_prompt",
type: data.action,
target: data.target as any,
});
break;
case "ban_member":
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 "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 "set_notification_state": {
const { key, state } = data;
if (state) {
dispatch({ type: "NOTIFICATIONS_SET", key, state });
} else {
dispatch({ type: "NOTIFICATIONS_REMOVE", key });
}
break;
}
}
})().catch((err) => {
openScreen({ id: "error", error: takeError(err) });
});
}
return (
<>
<ContextMenuWithData id="Menu" onClose={contextClick}>
{({
user: uid,
channel: cid,
server: sid,
message,
server_list,
queued,
unread,
contextualChannel: cxid,
}: ContextMenuData) => {
const forceUpdate = useForceUpdate();
const elements: Children[] = [];
var lastDivider = false;
function generateAction(
action: Action,
locale?: string,
disabled?: boolean,
tip?: Children,
) {
lastDivider = false;
elements.push(
<MenuItem data={action} disabled={disabled}>
<Text
id={`app.context_menu.${
locale ?? action.action
}`}
/>
{tip && <div className="tip">{tip}</div>}
</MenuItem>,
);
}
function pushDivider() {
if (lastDivider || elements.length === 0) return;
lastDivider = true;
elements.push(<LineDivider />);
}
if (server_list) {
let server = useServer(server_list, forceUpdate);
let permissions = useServerPermission(
server_list,
forceUpdate,
);
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;
}
if (document.getSelection()?.toString().length ?? 0 > 0) {
generateAction(
{ action: "copy_selection" },
undefined,
undefined,
<Text id="shortcuts.ctrlc" />,
);
pushDivider();
}
const channel = useChannel(cid, forceUpdate);
const contextualChannel = useChannel(cxid, forceUpdate);
const targetChannel = channel ?? contextualChannel;
const user = useUser(uid, forceUpdate);
const serverChannel =
targetChannel &&
(targetChannel.channel_type === "TextChannel" ||
targetChannel.channel_type === "VoiceChannel")
? targetChannel
: undefined;
const server = useServer(
serverChannel ? serverChannel.server : sid,
forceUpdate,
);
const channelPermissions = targetChannel
? useChannelPermission(targetChannel._id, forceUpdate)
: 0;
const serverPermissions = server
? useServerPermission(server._id, forceUpdate)
: serverChannel
? useServerPermission(serverChannel.server, forceUpdate)
: 0;
const userPermissions = user
? useUserPermission(user._id, forceUpdate)
: 0;
if (channel && unread) {
generateAction({ action: "mark_as_read", channel });
}
if (contextualChannel) {
if (user && user._id !== userId) {
generateAction({
action: "mention",
user: user._id,
});
pushDivider();
}
}
if (user) {
let actions: Action["action"][];
switch (user.relationship) {
case Users.Relationship.User:
actions = [];
break;
case Users.Relationship.Friend:
actions = ["remove_friend", "block_user"];
break;
case Users.Relationship.Incoming:
actions = [
"add_friend",
"cancel_friend",
"block_user",
];
break;
case Users.Relationship.Outgoing:
actions = ["cancel_friend", "block_user"];
break;
case Users.Relationship.Blocked:
actions = ["unblock_user"];
break;
case Users.Relationship.BlockedOther:
actions = ["block_user"];
break;
case Users.Relationship.None:
default:
actions = ["add_friend", "block_user"];
}
if (userPermissions & UserPermission.ViewProfile) {
generateAction({
action: "view_profile",
user: user._id,
});
}
if (
user._id !== userId &&
userPermissions & UserPermission.SendMessage
) {
generateAction({
action: "message_user",
user: user._id,
});
}
for (let i = 0; i < actions.length; i++) {
// The any here is because typescript can't determine that user the actions are linked together correctly
generateAction({ action: actions[i] as any, user });
}
}
if (contextualChannel) {
if (contextualChannel.channel_type === "Group" && uid) {
if (
contextualChannel.owner === userId &&
userId !== uid
) {
generateAction({
action: "remove_member",
channel: contextualChannel._id,
user: uid,
});
}
}
if (
server &&
uid &&
userId !== uid &&
uid !== server.owner
) {
if (
serverPermissions & ServerPermission.KickMembers
)
generateAction({
action: "kick_member",
target: server,
user: uid,
});
if (serverPermissions & ServerPermission.BanMembers)
generateAction({
action: "ban_member",
target: server,
user: uid,
});
}
}
if (queued) {
generateAction({
action: "retry_message",
message: queued,
});
generateAction({
action: "cancel_message",
message: queued,
});
}
if (message && !queued) {
generateAction({
action: "reply_message",
id: message._id,
});
if (
typeof message.content === "string" &&
message.content.length > 0
) {
generateAction({
action: "quote_message",
content: message.content,
});
generateAction({
action: "copy_text",
content: message.content,
});
}
if (message.author === userId) {
generateAction({
action: "edit_message",
id: message._id,
});
}
if (
message.author === userId ||
channelPermissions &
ChannelPermission.ManageMessages
) {
generateAction({
action: "delete_message",
target: message,
});
}
if (message.attachments) {
pushDivider();
const { metadata } = message.attachments[0];
const { type } = metadata;
generateAction(
{
action: "open_file",
attachment: message.attachments[0],
},
type === "Image"
? "open_image"
: type === "Video"
? "open_video"
: "open_file",
);
generateAction(
{
action: "save_file",
attachment: message.attachments[0],
},
type === "Image"
? "save_image"
: type === "Video"
? "save_video"
: "save_file",
);
generateAction(
{
action: "copy_file_link",
attachment: message.attachments[0],
},
"copy_link",
);
}
if (document.activeElement?.tagName === "A") {
let link =
document.activeElement.getAttribute("href");
if (link) {
pushDivider();
generateAction({ action: "open_link", link });
generateAction({ action: "copy_link", link });
}
}
}
let id = sid ?? cid ?? uid ?? message?._id;
if (id) {
pushDivider();
if (channel) {
if (channel.channel_type !== "VoiceChannel") {
generateAction(
{
action: "open_notification_options",
channel,
},
undefined,
undefined,
<ChevronRight size={24} />,
);
}
switch (channel.channel_type) {
case "Group":
// ! generateAction({ action: "create_invite", target: channel }); FIXME: add support for group invites
generateAction(
{
action: "open_channel_settings",
id: channel._id,
},
"open_group_settings",
);
generateAction(
{
action: "leave_group",
target: channel,
},
"leave_group",
);
break;
case "DirectMessage":
generateAction({
action: "close_dm",
target: channel,
});
break;
case "TextChannel":
case "VoiceChannel":
// ! FIXME: add permission for invites
generateAction({
action: "create_invite",
target: channel,
});
if (
serverPermissions &
ServerPermission.ManageServer
)
generateAction(
{
action: "open_server_channel_settings",
server: channel.server,
id: channel._id,
},
"open_channel_settings",
);
if (
serverPermissions &
ServerPermission.ManageChannels
)
generateAction({
action: "delete_channel",
target: channel,
});
break;
}
}
if (sid && server) {
if (
serverPermissions &
ServerPermission.ManageServer
)
generateAction(
{
action: "open_server_settings",
id: server._id,
},
"open_server_settings",
);
if (userId === server.owner) {
generateAction(
{ action: "delete_server", target: server },
"delete_server",
);
} else {
generateAction(
{ action: "leave_server", target: server },
"leave_server",
);
}
}
generateAction(
{ action: "copy_id", id },
sid
? "copy_sid"
: cid
? "copy_cid"
: message
? "copy_mid"
: "copy_uid",
);
}
return elements;
}}
</ContextMenuWithData>
<ContextMenuWithData
id="Status"
onClose={contextClick}
className="Status">
{() => (
<>
<div className="header">
<div className="main">
<div>@{client.user!.username}</div>
<div className="status">
<UserStatus user={client.user!} />
</div>
</div>
<IconButton>
<MenuItem data={{ action: "open_settings" }}>
<Cog size={18} />
</MenuItem>
</IconButton>
</div>
<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 />
<div className="header">
<div className="main">
<MenuItem
data={{ action: "set_status" }}
disabled={!isOnline}>
<Text
id={`app.context_menu.custom_status`}
/>
</MenuItem>
</div>
{client.user!.status?.text && (
<IconButton>
<MenuItem data={{ action: "clear_status" }}>
<Trash size={18} />
</MenuItem>
</IconButton>
)}
</div>
</>
)}
</ContextMenuWithData>
<ContextMenuWithData
id="NotificationOptions"
onClose={contextClick}>
{({ channel }: { channel: Channels.Channel }) => {
const state = props.notifications[channel._id];
const actual = getNotificationState(
props.notifications,
channel,
);
let elements: Children[] = [
<MenuItem
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
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;
}}
</ContextMenuWithData>
</>
);
const { openScreen, writeClipboard } = useIntermediate();
const client = useContext(AppContext);
const userId = client.user!._id;
const status = useContext(StatusContext);
const isOnline = status === ClientStatus.ONLINE;
const history = useHistory();
function contextClick(data?: Action) {
if (typeof data === "undefined") return;
(async () => {
switch (data.action) {
case "copy_id":
writeClipboard(data.id);
break;
case "copy_selection":
writeClipboard(document.getSelection()?.toString() ?? "");
break;
case "mark_as_read":
{
if (
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;
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;
case "retry_message":
{
const nonce = data.message.id;
const fail = (error: string) =>
dispatch({
type: "QUEUE_FAIL",
nonce,
error,
});
client.channels
.get(data.message.channel)!
.sendMessage({
nonce: data.message.id,
content: data.message.data.content as string,
replies: data.message.data.replies,
})
.catch(fail);
dispatch({
type: "QUEUE_START",
nonce,
});
}
break;
case "cancel_message":
{
dispatch({
type: "QUEUE_REMOVE",
nonce: data.message.id,
});
}
break;
case "mention":
{
internalEmit(
"MessageBox",
"append",
`<@${data.user}>`,
"mention",
);
}
break;
case "copy_text":
writeClipboard(data.content);
break;
case "reply_message":
{
internalEmit("ReplyBar", "add", data.id);
}
break;
case "quote_message":
{
internalEmit(
"MessageBox",
"append",
data.content,
"quote",
);
}
break;
case "edit_message":
{
internalEmit(
"MessageRenderer",
"edit_message",
data.id,
);
}
break;
case "open_file":
{
window
.open(
client.generateFileURL(data.attachment),
"_blank",
)
?.focus();
}
break;
case "save_file":
{
window.open(
// ! FIXME: do this from revolt.js
client
.generateFileURL(data.attachment)
?.replace(
"attachments",
"attachments/download",
),
"_blank",
);
}
break;
case "copy_file_link":
{
const { filename } = data.attachment;
writeClipboard(
// ! FIXME: do from r.js
`${client.generateFileURL(
data.attachment,
)}/${encodeURI(filename)}`,
);
}
break;
case "open_link":
{
window.open(data.link, "_blank")?.focus();
}
break;
case "copy_link":
{
writeClipboard(data.link);
}
break;
case "remove_member":
{
data.channel.removeMember(data.user._id);
}
break;
case "view_profile":
openScreen({ id: "profile", user_id: data.user._id });
break;
case "message_user":
{
const channel = await data.user.openDM();
if (channel) {
history.push(`/channel/${channel._id}`);
}
}
break;
case "add_friend":
{
await data.user.addFriend();
}
break;
case "block_user":
openScreen({
id: "special_prompt",
type: "block_user",
target: data.user,
});
break;
case "unblock_user":
await data.user.unblockUser();
break;
case "remove_friend":
openScreen({
id: "special_prompt",
type: "unfriend_user",
target: data.user,
});
break;
case "cancel_friend":
await data.user.removeFriend();
break;
case "set_presence":
{
await client.users.edit({
status: {
...client.user?.status,
presence: data.presence,
},
});
}
break;
case "set_status":
openScreen({
id: "special_input",
type: "set_custom_status",
});
break;
case "clear_status":
{
const { text: _text, ...status } =
client.user?.status ?? {};
await client.users.edit({ status });
}
break;
case "leave_group":
case "close_dm":
case "leave_server":
case "delete_channel":
case "delete_server":
case "delete_message":
case "create_channel":
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 "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 "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 "set_notification_state": {
const { key, state } = data;
if (state) {
dispatch({ type: "NOTIFICATIONS_SET", key, state });
} else {
dispatch({ type: "NOTIFICATIONS_REMOVE", key });
}
break;
}
}
})().catch((err) => {
openScreen({ id: "error", error: takeError(err) });
});
}
return (
<>
<ContextMenuWithData id="Menu" onClose={contextClick}>
{({
user: uid,
channel: cid,
server: sid,
message,
server_list,
queued,
unread,
contextualChannel: cxid,
}: ContextMenuData) => {
const elements: Children[] = [];
let lastDivider = false;
function generateAction(
action: Action,
locale?: string,
disabled?: boolean,
tip?: Children,
) {
lastDivider = false;
elements.push(
<MenuItem data={action} disabled={disabled}>
<Text
id={`app.context_menu.${
locale ?? action.action
}`}
/>
{tip && <div className="tip">{tip}</div>}
</MenuItem>,
);
}
function pushDivider() {
if (lastDivider || elements.length === 0) return;
lastDivider = true;
elements.push(<LineDivider />);
}
if (server_list) {
const server = client.servers.get(server_list)!;
const permissions = server.permission;
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;
}
if (document.getSelection()?.toString().length ?? 0 > 0) {
generateAction(
{ action: "copy_selection" },
undefined,
undefined,
<Text id="shortcuts.ctrlc" />,
);
pushDivider();
}
const channel = cid ? client.channels.get(cid) : undefined;
const contextualChannel = cxid
? client.channels.get(cxid)
: undefined;
const targetChannel = channel ?? contextualChannel;
const user = uid ? client.users.get(uid) : undefined;
const serverChannel =
targetChannel &&
(targetChannel.channel_type === "TextChannel" ||
targetChannel.channel_type === "VoiceChannel")
? targetChannel
: undefined;
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) {
generateAction({ action: "mark_as_read", channel });
}
if (contextualChannel) {
if (user && user._id !== userId) {
generateAction({
action: "mention",
user: user._id,
});
pushDivider();
}
}
if (user) {
let actions: Action["action"][];
switch (user.relationship) {
case RelationshipStatus.User:
actions = [];
break;
case RelationshipStatus.Friend:
actions = ["remove_friend", "block_user"];
break;
case RelationshipStatus.Incoming:
actions = [
"add_friend",
"cancel_friend",
"block_user",
];
break;
case RelationshipStatus.Outgoing:
actions = ["cancel_friend", "block_user"];
break;
case RelationshipStatus.Blocked:
actions = ["unblock_user"];
break;
case RelationshipStatus.BlockedOther:
actions = ["block_user"];
break;
case RelationshipStatus.None:
default:
actions = ["add_friend", "block_user"];
}
if (userPermissions & UserPermission.ViewProfile) {
generateAction({
action: "view_profile",
user,
});
}
if (
user._id !== userId &&
userPermissions & UserPermission.SendMessage
) {
generateAction({
action: "message_user",
user,
});
}
for (let i = 0; i < actions.length; i++) {
// Typescript can't determine that user the actions are linked together correctly
generateAction({
action: actions[i],
user,
} as unknown as Action);
}
}
if (contextualChannel) {
if (contextualChannel.channel_type === "Group" && uid) {
if (
contextualChannel.owner_id === userId &&
userId !== uid
) {
generateAction({
action: "remove_member",
channel: contextualChannel,
user: user!,
});
}
}
if (
server &&
uid &&
userId !== uid &&
uid !== server.owner
) {
if (
serverPermissions & ServerPermission.KickMembers
)
generateAction({
action: "kick_member",
target: server,
user: user!,
});
if (serverPermissions & ServerPermission.BanMembers)
generateAction({
action: "ban_member",
target: server,
user: user!,
});
}
}
if (queued) {
generateAction({
action: "retry_message",
message: queued,
});
generateAction({
action: "cancel_message",
message: queued,
});
}
if (message && !queued) {
generateAction({
action: "reply_message",
id: message._id,
});
if (
typeof message.content === "string" &&
message.content.length > 0
) {
generateAction({
action: "quote_message",
content: message.content,
});
generateAction({
action: "copy_text",
content: message.content,
});
}
if (message.author_id === userId) {
generateAction({
action: "edit_message",
id: message._id,
});
}
if (
message.author_id === userId ||
channelPermissions &
ChannelPermission.ManageMessages
) {
generateAction({
action: "delete_message",
target: message,
});
}
if (message.attachments) {
pushDivider();
const { metadata } = message.attachments[0];
const { type } = metadata;
generateAction(
{
action: "open_file",
attachment: message.attachments[0],
},
type === "Image"
? "open_image"
: type === "Video"
? "open_video"
: "open_file",
);
generateAction(
{
action: "save_file",
attachment: message.attachments[0],
},
type === "Image"
? "save_image"
: type === "Video"
? "save_video"
: "save_file",
);
generateAction(
{
action: "copy_file_link",
attachment: message.attachments[0],
},
"copy_link",
);
}
if (document.activeElement?.tagName === "A") {
const link =
document.activeElement.getAttribute("href");
if (link) {
pushDivider();
generateAction({ action: "open_link", link });
generateAction({ action: "copy_link", link });
}
}
}
const id = sid ?? cid ?? uid ?? message?._id;
if (id) {
pushDivider();
if (channel) {
if (channel.channel_type !== "VoiceChannel") {
generateAction(
{
action: "open_notification_options",
channel,
},
undefined,
undefined,
<ChevronRight size={24} />,
);
}
switch (channel.channel_type) {
case "Group":
// ! generateAction({ action: "create_invite", target: channel }); FIXME: add support for group invites
generateAction(
{
action: "open_channel_settings",
id: channel._id,
},
"open_group_settings",
);
generateAction(
{
action: "leave_group",
target: channel,
},
"leave_group",
);
break;
case "DirectMessage":
generateAction({
action: "close_dm",
target: channel,
});
break;
case "TextChannel":
case "VoiceChannel":
if (
channelPermissions &
ChannelPermission.InviteOthers
) {
generateAction({
action: "create_invite",
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;
}
}
if (sid && server) {
if (
serverPermissions &
ServerPermission.ManageServer
)
generateAction(
{
action: "open_server_settings",
id: server._id,
},
"open_server_settings",
);
if (userId === server.owner) {
generateAction(
{ action: "delete_server", target: server },
"delete_server",
);
} else {
generateAction(
{ action: "leave_server", target: server },
"leave_server",
);
}
}
generateAction(
{ action: "copy_id", id },
sid
? "copy_sid"
: 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;
}}
</ContextMenuWithData>
</>
);
}
export default connectState(ContextMenus, (state) => {
return {
notifications: state.notifications,
};
return {
notifications: state.notifications,
};
});
/* eslint-disable react-hooks/rules-of-hooks */
import { useState } from "preact/hooks";
const counts: { [key: string]: number } = {};
export default function PaintCounter({
small,
always,
small,
always,
}: {
small?: boolean;
always?: boolean;
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 count = counts[uniqueId] ?? 0;
counts[uniqueId] = count + 1;
return (
<div style={{ textAlign: "center", fontSize: "0.8em" }}>
{small ? <>P: {count + 1}</> : <>Painted {count + 1} time(s).</>}
</div>
);
const [uniqueId] = useState(`${Math.random()}`);
const count = counts[uniqueId] ?? 0;
counts[uniqueId] = count + 1;
return (
<div style={{ textAlign: "center", fontSize: "0.8em" }}>
{small ? <>P: {count + 1}</> : <>Painted {count + 1} time(s).</>}
</div>
);
}
import { useEffect, useRef } from "preact/hooks";
import styled from "styled-components";
import TextArea, {
DEFAULT_LINE_HEIGHT,
DEFAULT_TEXT_AREA_PADDING,
TextAreaProps,
TEXT_AREA_BORDER_WIDTH,
} from "../components/ui/TextArea";
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"
JSX.HTMLAttributes<HTMLTextAreaElement>,
"style" | "value" | "onChange" | "children" | "as"
> &
TextAreaProps & {
forceFocus?: boolean;
autoFocus?: boolean;
minHeight?: number;
maxRows?: number;
value: string;
id?: string;
};
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,
children,
as,
...textAreaProps
} = props;
const line = lineHeight ?? DEFAULT_LINE_HEIGHT;
const heightPadding =
((padding ?? DEFAULT_TEXT_AREA_PADDING) +
(hideBorder ? 0 : TEXT_AREA_BORDER_WIDTH)) *
2;
const height = Math.max(
Math.min(value.split("\n").length, maxRows ?? Infinity) * line +
heightPadding,
minHeight ?? 0,
);
const ref = useRef<HTMLTextAreaElement>();
useEffect(() => {
if (isTouchscreenDevice) return;
autoFocus && ref.current.focus();
}, [value]);
const inputSelected = () =>
["TEXTAREA", "INPUT"].includes(document.activeElement?.nodeName ?? "");
useEffect(() => {
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]);
useEffect(() => {
function focus(id: string) {
if (id === props.id) {
ref.current.focus();
}
}
return internalSubscribe("TextArea", "focus", focus);
}, [ref]);
return (
<TextArea
ref={ref}
value={value}
padding={padding}
style={{ height }}
hideBorder={hideBorder}
lineHeight={lineHeight}
{...textAreaProps}
/>
);
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);
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)));
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}
export function debounce(cb: Function, duration: number) {
// Store the timer variable.
let timer: NodeJS.Timeout;
// This function is given to React.
return (...args: any[]) => {
// 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 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);
};
}
......@@ -3,21 +3,22 @@ import EventEmitter from "eventemitter3";
export const InternalEvent = new EventEmitter();
export function internalSubscribe(
ns: string,
event: string,
fn: (...args: any[]) => void,
ns: string,
event: string,
fn: (...args: unknown[]) => void,
) {
InternalEvent.addListener(ns + "/" + event, fn);
return () => InternalEvent.removeListener(ns + "/" + event, fn);
InternalEvent.addListener(`${ns}/${event}`, fn);
return () => InternalEvent.removeListener(`${ns}/${event}`, fn);
}
export function internalEmit(ns: string, event: string, ...args: any[]) {
InternalEvent.emit(ns + "/" + event, ...args);
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
......@@ -25,4 +26,5 @@ export function internalEmit(ns: string, event: string, ...args: any[]) {
// - 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`;
}
if (size > 1e6) {
return `${(size / 1e6).toFixed(2)} MB`;
} else if (size > 1e3) {
return `${(size / 1e3).toFixed(2)} KB`;
}
return `${size} B`;
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;
[key: string]: Children;
}
interface Props {
id: string;
fields: Fields;
id: string;
fields: Fields;
}
export interface IntlType {
intl: {
dictionary: {
[key: string]: Object | string;
};
};
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();
} else {
// base case
return [input];
}
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 { intl } = useContext(IntlContext) as unknown as IntlType;
const path = id.split(".");
let entry = intl.dictionary[path.shift()!];
for (let key of path) {
// @ts-expect-error
entry = entry[key];
}
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)}</>;
return <>{recursiveReplaceFields(entry as string, fields)}</>;
}
export function useTranslation() {
const { intl } = useContext(IntlContext) as unknown as IntlType;
return (id: string, fields?: Object, plural?: number, fallback?: string) =>
translate(id, "", intl.dictionary, fields, plural, fallback);
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";
export const isTouchscreenDevice =
isDesktop && !isTablet
? false
: (typeof window !== "undefined"
? navigator.maxTouchPoints > 0
: false) || isMobile;
isDesktop || isTablet
? false
: (typeof window !== "undefined"
? navigator.maxTouchPoints > 0
: false) || isMobile;
/* eslint-disable @typescript-eslint/no-empty-function */
export const noop = () => {};
export const noopAsync = async () => {};
/* eslint-enable @typescript-eslint/no-empty-function */
/* eslint-disable react-hooks/rules-of-hooks */
import EventEmitter3 from "eventemitter3";
import { Client, Message } from "revolt.js";
import { Client } from "revolt.js";
import { Message } from "revolt.js/dist/maps/Messages";
import { useEffect, useState } from "preact/hooks";
......@@ -9,198 +11,213 @@ import { RendererRoutines, RenderState, ScrollState } from "./types";
export const SMOOTH_SCROLL_ON_RECEIVE = false;
export class SingletonRenderer extends EventEmitter3 {
client?: Client;
channel?: string;
state: RenderState;
currentRenderer: RendererRoutines;
stale = false;
fetchingTop = false;
fetchingBottom = false;
constructor() {
super();
this.receive = this.receive.bind(this);
this.edit = this.edit.bind(this);
this.delete = this.delete.bind(this);
this.state = { type: "LOADING" };
this.currentRenderer = SimpleRenderer;
}
private receive(message: Message) {
this.currentRenderer.receive(this, message);
}
private edit(id: string, patch: Partial<Message>) {
this.currentRenderer.edit(this, id, patch);
}
private delete(id: string) {
this.currentRenderer.delete(this, id);
}
subscribe(client: Client) {
if (this.client) {
this.client.removeListener("message", this.receive);
this.client.removeListener("message/update", this.edit);
this.client.removeListener("message/delete", this.delete);
}
this.client = client;
client.addListener("message", this.receive);
client.addListener("message/update", this.edit);
client.addListener("message/delete", this.delete);
}
private setStateUnguarded(state: RenderState, scroll?: ScrollState) {
this.state = state;
this.emit("state", state);
if (scroll) {
this.emit("scroll", scroll);
}
}
setState(id: string, state: RenderState, scroll?: ScrollState) {
if (id !== this.channel) return;
this.setStateUnguarded(state, scroll);
}
markStale() {
this.stale = true;
}
async init(id: string) {
this.channel = id;
this.stale = false;
this.setStateUnguarded({ type: "LOADING" });
await this.currentRenderer.init(this, id);
}
async reloadStale(id: string) {
if (this.stale) {
this.stale = false;
await this.init(id);
}
}
async loadTop(ref?: HTMLDivElement) {
if (this.fetchingTop) return;
this.fetchingTop = true;
function generateScroll(end: string): ScrollState {
if (ref) {
let heightRemoved = 0;
let messageContainer = ref.children[0];
if (messageContainer) {
for (let child of Array.from(messageContainer.children)) {
// If this child has a ulid.
if (child.id?.length === 26) {
// Check whether it was removed.
if (child.id.localeCompare(end) === 1) {
heightRemoved +=
child.clientHeight +
// We also need to take into account the top margin of the container.
parseInt(
window
.getComputedStyle(child)
.marginTop.slice(0, -2),
);
}
}
}
}
return {
type: "OffsetTop",
previousHeight: ref.scrollHeight - heightRemoved,
};
} else {
return {
type: "OffsetTop",
previousHeight: 0,
};
}
}
await this.currentRenderer.loadTop(this, generateScroll);
// Allow state updates to propagate.
setTimeout(() => (this.fetchingTop = false), 0);
}
async loadBottom(ref?: HTMLDivElement) {
if (this.fetchingBottom) return;
this.fetchingBottom = true;
function generateScroll(start: string): ScrollState {
if (ref) {
let heightRemoved = 0;
let messageContainer = ref.children[0];
if (messageContainer) {
for (let child of Array.from(messageContainer.children)) {
// If this child has a ulid.
if (child.id?.length === 26) {
// Check whether it was removed.
if (child.id.localeCompare(start) === -1) {
heightRemoved +=
child.clientHeight +
// We also need to take into account the top margin of the container.
parseInt(
window
.getComputedStyle(child)
.marginTop.slice(0, -2),
);
}
}
}
}
return {
type: "ScrollTop",
y: ref.scrollTop - heightRemoved,
};
} else {
return {
type: "ScrollToBottom",
};
}
}
await this.currentRenderer.loadBottom(this, generateScroll);
// Allow state updates to propagate.
setTimeout(() => (this.fetchingBottom = false), 0);
}
async jumpToBottom(id: string, smooth: boolean) {
if (id !== this.channel) return;
if (this.state.type === "RENDER" && this.state.atBottom) {
this.emit("scroll", { type: "ScrollToBottom", smooth });
} else {
await this.currentRenderer.init(this, id, true);
}
}
client?: Client;
channel?: string;
state: RenderState;
currentRenderer: RendererRoutines;
stale = false;
fetchingTop = false;
fetchingBottom = false;
constructor() {
super();
this.receive = this.receive.bind(this);
this.edit = this.edit.bind(this);
this.delete = this.delete.bind(this);
this.state = { type: "LOADING" };
this.currentRenderer = SimpleRenderer;
}
private receive(message: Message) {
this.currentRenderer.receive(this, message);
}
private edit(id: string, patch: Partial<Message>) {
this.currentRenderer.edit(this, id, patch);
}
private delete(id: string) {
this.currentRenderer.delete(this, id);
}
subscribe(client: Client) {
if (this.client) {
this.client.removeListener("message", this.receive);
this.client.removeListener("message/update", this.edit);
this.client.removeListener("message/delete", this.delete);
}
this.client = client;
client.addListener("message", this.receive);
client.addListener("message/update", this.edit);
client.addListener("message/delete", this.delete);
}
private setStateUnguarded(state: RenderState, scroll?: ScrollState) {
this.state = state;
this.emit("state", state);
if (scroll) {
this.emit("scroll", scroll);
}
}
setState(id: string, state: RenderState, scroll?: ScrollState) {
if (id !== this.channel) return;
this.setStateUnguarded(state, scroll);
}
markStale() {
this.stale = true;
}
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.stale = false;
this.setStateUnguarded({ type: "LOADING" });
await this.currentRenderer.init(this, id, message_id);
}
async reloadStale(id: string) {
if (this.stale) {
this.stale = false;
await this.init(id);
}
}
async loadTop(ref?: HTMLDivElement) {
if (this.fetchingTop) return;
this.fetchingTop = true;
function generateScroll(end: string): ScrollState {
if (ref) {
let heightRemoved = 0;
const messageContainer = ref.children[0];
if (messageContainer) {
for (const child of Array.from(messageContainer.children)) {
// If this child has a ulid.
if (child.id?.length === 26) {
// Check whether it was removed.
if (child.id.localeCompare(end) === 1) {
heightRemoved +=
child.clientHeight +
// We also need to take into account the top margin of the container.
parseInt(
window
.getComputedStyle(child)
.marginTop.slice(0, -2),
10,
);
}
}
}
}
return {
type: "OffsetTop",
previousHeight: ref.scrollHeight - heightRemoved,
};
}
return {
type: "OffsetTop",
previousHeight: 0,
};
}
await this.currentRenderer.loadTop(this, generateScroll);
// Allow state updates to propagate.
setTimeout(() => (this.fetchingTop = false), 0);
}
async loadBottom(ref?: HTMLDivElement) {
if (this.fetchingBottom) return;
this.fetchingBottom = true;
function generateScroll(start: string): ScrollState {
if (ref) {
let heightRemoved = 0;
const messageContainer = ref.children[0];
if (messageContainer) {
for (const child of Array.from(messageContainer.children)) {
// If this child has a ulid.
if (child.id?.length === 26) {
// Check whether it was removed.
if (child.id.localeCompare(start) === -1) {
heightRemoved +=
child.clientHeight +
// We also need to take into account the top margin of the container.
parseInt(
window
.getComputedStyle(child)
.marginTop.slice(0, -2),
10,
);
}
}
}
}
return {
type: "ScrollTop",
y: ref.scrollTop - heightRemoved,
};
}
return {
type: "ScrollToBottom",
};
}
await this.currentRenderer.loadBottom(this, generateScroll);
// Allow state updates to propagate.
setTimeout(() => (this.fetchingBottom = false), 0);
}
async jumpToBottom(id: string, smooth: boolean) {
if (id !== this.channel) return;
if (this.state.type === "RENDER" && this.state.atBottom) {
this.emit("scroll", { type: "ScrollToBottom", smooth });
} else {
await this.currentRenderer.init(this, id, undefined, true);
}
}
}
export const SingletonMessageRenderer = new SingletonRenderer();
export function useRenderState(id: string) {
const [state, setState] = useState<Readonly<RenderState>>(
SingletonMessageRenderer.state,
);
if (typeof id === "undefined") return;
const [state, setState] = useState<Readonly<RenderState>>(
SingletonMessageRenderer.state,
);
if (typeof id === "undefined") return;
function render(state: RenderState) {
setState(state);
}
function render(state: RenderState) {
setState(state);
}
useEffect(() => {
SingletonMessageRenderer.addListener("state", render);
return () => SingletonMessageRenderer.removeListener("state", render);
}, [id]);
useEffect(() => {
SingletonMessageRenderer.addListener("state", render);
return () => SingletonMessageRenderer.removeListener("state", render);
}, [id]);
return state;
return state;
}
import { mapMessage } from "../../../context/revoltjs/util";
import { noopAsync } from "../../js";
import { SMOOTH_SCROLL_ON_RECEIVE } from "../Singleton";
import { RendererRoutines } from "../types";
export const SimpleRenderer: RendererRoutines = {
init: async (renderer, id, smooth) => {
if (renderer.client!.websocket.connected) {
renderer
.client!.channels.fetchMessagesWithUsers(id, {}, true)
.then(({ messages: data }) => {
data.reverse();
let messages = data.map((x) => mapMessage(x));
renderer.setState(
id,
{
type: "RENDER",
messages,
atTop: data.length < 50,
atBottom: true,
},
{ type: "ScrollToBottom", smooth },
);
});
} else {
renderer.setState(id, { type: "WAITING_FOR_NETWORK" });
}
},
receive: async (renderer, message) => {
if (message.channel !== renderer.channel) return;
if (renderer.state.type !== "RENDER") return;
if (renderer.state.messages.find((x) => x._id === message._id)) return;
if (!renderer.state.atBottom) return;
let messages = [...renderer.state.messages, mapMessage(message)];
let atTop = renderer.state.atTop;
if (messages.length > 150) {
messages = messages.slice(messages.length - 150);
atTop = false;
}
renderer.setState(
message.channel,
{
...renderer.state,
messages,
atTop,
},
{ type: "StayAtBottom", smooth: SMOOTH_SCROLL_ON_RECEIVE },
);
},
edit: async (renderer, id, patch) => {
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) => {
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) {
messages.splice(index, 1);
renderer.setState(
channel,
{
...renderer.state,
messages,
},
{ type: "StayAtBottom" },
);
}
},
loadTop: async (renderer, generateScroll) => {
const channel = renderer.channel;
if (!channel) return;
const state = renderer.state;
if (state.type !== "RENDER") return;
if (state.atTop) return;
const { messages: data } =
await renderer.client!.channels.fetchMessagesWithUsers(
channel,
{
before: state.messages[0]._id,
},
true,
);
if (data.length === 0) {
return renderer.setState(channel, {
...state,
atTop: true,
});
}
data.reverse();
let messages = [...data.map((x) => mapMessage(x)), ...state.messages];
let atTop = false;
if (data.length < 50) {
atTop = true;
}
let atBottom = state.atBottom;
if (messages.length > 150) {
messages = messages.slice(0, 150);
atBottom = false;
}
renderer.setState(
channel,
{ ...state, atTop, atBottom, messages },
generateScroll(messages[messages.length - 1]._id),
);
},
loadBottom: async (renderer, generateScroll) => {
const channel = renderer.channel;
if (!channel) return;
const state = renderer.state;
if (state.type !== "RENDER") return;
if (state.atBottom) return;
const { messages: data } =
await renderer.client!.channels.fetchMessagesWithUsers(
channel,
{
after: state.messages[state.messages.length - 1]._id,
sort: "Oldest",
},
true,
);
if (data.length === 0) {
return renderer.setState(channel, {
...state,
atBottom: true,
});
}
let messages = [...state.messages, ...data.map((x) => mapMessage(x))];
let atBottom = false;
if (data.length < 50) {
atBottom = true;
}
let atTop = state.atTop;
if (messages.length > 150) {
messages = messages.slice(messages.length - 150);
atTop = false;
}
renderer.setState(
channel,
{ ...state, atTop, atBottom, messages },
generateScroll(messages[0]._id),
);
},
init: async (renderer, id, nearby, smooth) => {
if (renderer.client!.websocket.connected) {
if (nearby)
renderer
.client!.channels.get(id)!
.fetchMessagesWithUsers({ nearby, limit: 100 })
.then(({ messages }) => {
messages.sort((a, b) => a._id.localeCompare(b._id));
renderer.setState(
id,
{
type: "RENDER",
messages,
atTop: false,
atBottom: false,
},
{ 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 {
renderer.setState(id, { type: "WAITING_FOR_NETWORK" });
}
},
receive: async (renderer, message) => {
if (message.channel_id !== renderer.channel) return;
if (renderer.state.type !== "RENDER") return;
if (renderer.state.messages.find((x) => x._id === message._id)) return;
if (!renderer.state.atBottom) return;
let messages = [...renderer.state.messages, message];
let atTop = renderer.state.atTop;
if (messages.length > 150) {
messages = messages.slice(messages.length - 150);
atTop = false;
}
renderer.setState(
message.channel_id,
{
...renderer.state,
messages,
atTop,
},
{ type: "StayAtBottom", smooth: SMOOTH_SCROLL_ON_RECEIVE },
);
},
edit: noopAsync,
delete: async (renderer, id) => {
const channel = renderer.channel;
if (!channel) return;
if (renderer.state.type !== "RENDER") return;
const messages = [...renderer.state.messages];
const index = messages.findIndex((x) => x._id === id);
if (index > -1) {
messages.splice(index, 1);
renderer.setState(
channel,
{
...renderer.state,
messages,
},
{ type: "StayAtBottom" },
);
}
},
loadTop: async (renderer, generateScroll) => {
const channel = renderer.channel;
if (!channel) return;
const state = renderer.state;
if (state.type !== "RENDER") return;
if (state.atTop) return;
const { messages: data } = await renderer
.client!.channels.get(channel)!
.fetchMessagesWithUsers({
before: state.messages[0]._id,
});
if (data.length === 0) {
return renderer.setState(channel, {
...state,
atTop: true,
});
}
data.reverse();
let messages = [...data, ...state.messages];
let atTop = false;
if (data.length < 50) {
atTop = true;
}
let atBottom = state.atBottom;
if (messages.length > 150) {
messages = messages.slice(0, 150);
atBottom = false;
}
renderer.setState(
channel,
{ ...state, atTop, atBottom, messages },
generateScroll(messages[messages.length - 1]._id),
);
},
loadBottom: async (renderer, generateScroll) => {
const channel = renderer.channel;
if (!channel) return;
const state = renderer.state;
if (state.type !== "RENDER") return;
if (state.atBottom) return;
const { messages: data } = await renderer
.client!.channels.get(channel)!
.fetchMessagesWithUsers({
after: state.messages[state.messages.length - 1]._id,
sort: "Oldest",
});
if (data.length === 0) {
return renderer.setState(channel, {
...state,
atBottom: true,
});
}
let messages = [...state.messages, ...data];
let atBottom = false;
if (data.length < 50) {
atBottom = true;
}
let atTop = state.atTop;
if (messages.length > 150) {
messages = messages.slice(messages.length - 150);
atTop = false;
}
renderer.setState(
channel,
{ ...state, atTop, atBottom, messages },
generateScroll(messages[0]._id),
);
},
};