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 1677 additions and 468 deletions
......@@ -2,4 +2,3 @@ interface ImportMetaEnv {
VITE_API_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 { useContext } from "preact/hooks";
import {
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 { 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 {
ContextMenu,
ContextMenuWithData,
MenuItem
MenuItem,
openContextMenu,
} 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 { WithDispatcher } from "../redux/reducers";
import { useIntermediate } from "../context/intermediate/Intermediate";
import { AppContext, ClientStatus, StatusContext } from "../context/revoltjs/RevoltClient";
import { Screen, useIntermediate } from "../context/intermediate/Intermediate";
import {
AppContext,
ClientStatus,
StatusContext,
} from "../context/revoltjs/RevoltClient";
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 { connectState } from "../redux/connector";
import { Children } from "../types/Preact";
import { internalEmit } from "./eventEmitter";
interface ContextMenuData {
......@@ -35,43 +73,63 @@ type Action =
| { action: "copy_id"; id: string }
| { action: "copy_selection" }
| { action: "copy_text"; content: string }
| { action: "mark_as_read"; channel: Channels.Channel }
| { 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: Channels.Message }
| { 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: 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: string }
| { action: "unblock_user"; user: string }
| { action: "add_friend"; user: string }
| { action: "remove_friend"; user: string }
| { action: "cancel_friend"; user: string }
| { action: "set_presence"; presence: Users.Presence }
| { 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"; server: string }
| { action: "create_invite"; target: Channels.GroupChannel | Channels.TextChannel }
| { action: "leave_group"; target: Channels.GroupChannel }
| { action: "delete_channel"; target: Channels.TextChannel }
| { action: "close_dm"; target: Channels.DirectMessageChannel }
| { action: "leave_server"; target: Servers.Server }
| { action: "delete_server"; target: Servers.Server }
| { action: "open_channel_settings", id: string }
| { action: "open_server_settings", id: string }
| { action: "open_server_channel_settings", server: string, id: string };
function ContextMenus(props: WithDispatcher) {
| { 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;
};
// ! 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;
......@@ -88,50 +146,65 @@ function ContextMenus(props: WithDispatcher) {
writeClipboard(data.id);
break;
case "copy_selection":
writeClipboard(document.getSelection()?.toString() ?? '');
writeClipboard(document.getSelection()?.toString() ?? "");
break;
case "mark_as_read":
if (data.channel.channel_type === 'SavedMessages') return;
props.dispatcher({
type: "UNREADS_MARK_READ",
channel: data.channel._id,
message: data.channel.channel_type === 'TextChannel' ? data.channel.last_message : data.channel.last_message._id,
request: true
});
{
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: any) =>
props.dispatcher({
const fail = (error: string) =>
dispatch({
type: "QUEUE_FAIL",
nonce,
error
error,
});
client.channels
.sendMessage(
data.message.channel,
{
content: data.message.data.content as string,
nonce
}
)
.get(data.message.channel)!
.sendMessage({
nonce: data.message.id,
content: data.message.data.content as string,
replies: data.message.data.replies,
})
.catch(fail);
props.dispatcher({
dispatch({
type: "QUEUE_START",
nonce
nonce,
});
}
break;
case "cancel_message":
{
props.dispatcher({
dispatch({
type: "QUEUE_REMOVE",
nonce: data.message.id
nonce: data.message.id,
});
}
break;
......@@ -142,7 +215,7 @@ function ContextMenus(props: WithDispatcher) {
"MessageBox",
"append",
`<@${data.user}>`,
"mention"
"mention",
);
}
break;
......@@ -150,20 +223,31 @@ function ContextMenus(props: WithDispatcher) {
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"
"quote",
);
}
break;
case "edit_message":
{
internalEmit("MessageRenderer", "edit_message", data.id);
internalEmit(
"MessageRenderer",
"edit_message",
data.id,
);
}
break;
......@@ -172,7 +256,7 @@ function ContextMenus(props: WithDispatcher) {
window
.open(
client.generateFileURL(data.attachment),
"_blank"
"_blank",
)
?.focus();
}
......@@ -182,8 +266,13 @@ function ContextMenus(props: WithDispatcher) {
{
window.open(
// ! FIXME: do this from revolt.js
client.generateFileURL(data.attachment)?.replace('attachments', 'attachments/download'),
"_blank"
client
.generateFileURL(data.attachment)
?.replace(
"attachments",
"attachments/download",
),
"_blank",
);
}
break;
......@@ -193,7 +282,9 @@ function ContextMenus(props: WithDispatcher) {
const { filename } = data.attachment;
writeClipboard(
// ! FIXME: do from r.js
client.generateFileURL(data.attachment) + `/${encodeURI(filename)}`,
`${client.generateFileURL(
data.attachment,
)}/${encodeURI(filename)}`,
);
}
break;
......@@ -212,17 +303,17 @@ function ContextMenus(props: WithDispatcher) {
case "remove_member":
{
client.channels.removeMember(data.channel, data.user);
data.channel.removeMember(data.user._id);
}
break;
case "view_profile":
openScreen({ id: 'profile', user_id: data.user });
openScreen({ id: "profile", user_id: data.user._id });
break;
case "message_user":
{
const channel = await client.users.openDM(data.user);
const channel = await data.user.openDM();
if (channel) {
history.push(`/channel/${channel._id}`);
}
......@@ -231,63 +322,116 @@ function ContextMenus(props: WithDispatcher) {
case "add_friend":
{
let user = client.users.get(data.user);
if (user) {
await client.users.addFriend(user.username);
}
await data.user.addFriend();
}
break;
case "block_user":
await client.users.blockUser(data.user);
openScreen({
id: "special_prompt",
type: "block_user",
target: data.user,
});
break;
case "unblock_user":
await client.users.unblockUser(data.user);
await data.user.unblockUser();
break;
case "remove_friend":
openScreen({
id: "special_prompt",
type: "unfriend_user",
target: data.user,
});
break;
case "cancel_friend":
await client.users.removeFriend(data.user);
await data.user.removeFriend();
break;
case "set_presence":
{
await client.users.editUser({
await client.users.edit({
status: {
...client.user?.status,
presence: data.presence
}
presence: data.presence,
},
});
}
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":
{
let { text, ...status } = client.user?.status ?? {};
await client.users.editUser({ 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":
// @ts-expect-error
case "create_invite": openScreen({ id: "special_prompt", type: data.action, target: data.target }); break;
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 "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 "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 => {
})().catch((err) => {
openScreen({ id: "error", error: takeError(err) });
});
}
......@@ -303,29 +447,27 @@ function ContextMenus(props: WithDispatcher) {
server_list,
queued,
unread,
contextualChannel: cxid
contextualChannel: cxid,
}: ContextMenuData) => {
const forceUpdate = useForceUpdate();
const elements: Children[] = [];
var lastDivider = false;
let lastDivider = false;
function generateAction(
action: Action,
locale?: string,
disabled?: boolean,
tip?: Children
tip?: Children,
) {
lastDivider = false;
elements.push(
<MenuItem data={action} disabled={disabled}>
<Text
id={`app.context_menu.${locale ??
action.action}`}
id={`app.context_menu.${
locale ?? action.action
}`}
/>
{ tip && <div className="tip">
{ tip }
</div> }
</MenuItem>
{tip && <div className="tip">{tip}</div>}
</MenuItem>,
);
}
......@@ -336,44 +478,69 @@ function ContextMenus(props: WithDispatcher) {
}
if (server_list) {
let permissions = useServerPermission(server_list, forceUpdate);
if (permissions & ServerPermission.ManageChannels) generateAction({ action: 'create_channel', server: server_list });
if (permissions & ServerPermission.ManageServer) generateAction({ action: 'open_server_settings', id: 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" />);
generateAction(
{ action: "copy_selection" },
undefined,
undefined,
<Text id="shortcuts.ctrlc" />,
);
pushDivider();
}
const channel = useChannel(cid, forceUpdate);
const contextualChannel = useChannel(cxid, forceUpdate);
const channel = cid ? client.channels.get(cid) : undefined;
const contextualChannel = cxid
? client.channels.get(cxid)
: undefined;
const targetChannel = channel ?? contextualChannel;
const user = useUser(uid, forceUpdate);
const server = useServer(targetChannel?.channel_type === 'TextChannel' ? targetChannel.server : sid, forceUpdate);
const channelPermissions = targetChannel ? useChannelPermission(targetChannel._id, forceUpdate) : 0;
const serverPermissions = server ? useServerPermission(server._id, forceUpdate) : (
targetChannel?.channel_type === 'TextChannel' ? useServerPermission(targetChannel.server, forceUpdate) : 0
);
const userPermissions = user ? useUserPermission(user._id, forceUpdate) : 0;
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 },
undefined,
true
);
generateAction({ action: "mark_as_read", channel });
}
if (contextualChannel) {
if (user && user._id !== userId) {
generateAction({
action: "mention",
user: user._id
user: user._id,
});
pushDivider();
......@@ -381,110 +548,147 @@ function ContextMenus(props: WithDispatcher) {
}
if (user) {
let actions: string[];
let actions: Action["action"][];
switch (user.relationship) {
case Users.Relationship.User: actions = []; break;
case Users.Relationship.Friend:
case RelationshipStatus.User:
actions = [];
break;
case RelationshipStatus.Friend:
actions = ["remove_friend", "block_user"];
break;
case RelationshipStatus.Incoming:
actions = [
"remove_friend",
"block_user"
"add_friend",
"cancel_friend",
"block_user",
];
break;
case Users.Relationship.Incoming:
actions = ["add_friend", "block_user"];
break;
case Users.Relationship.Outgoing:
case RelationshipStatus.Outgoing:
actions = ["cancel_friend", "block_user"];
break;
case Users.Relationship.Blocked:
case RelationshipStatus.Blocked:
actions = ["unblock_user"];
break;
case Users.Relationship.BlockedOther:
case RelationshipStatus.BlockedOther:
actions = ["block_user"];
break;
case Users.Relationship.None:
case RelationshipStatus.None:
default:
actions = ["add_friend", "block_user"];
}
if (userPermissions & UserPermission.ViewProfile) {
generateAction({ action: 'view_profile', user: user._id });
generateAction({
action: "view_profile",
user,
});
}
if (user._id !== userId && userPermissions & UserPermission.SendMessage) {
generateAction({ action: 'message_user', user: user._id });
if (
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({
action: action as any,
user: user._id
});
action: actions[i],
user,
} as unknown as Action);
}
}
if (contextualChannel) {
if (contextualChannel.channel_type === "Group" && uid) {
if (
contextualChannel.owner === userId &&
contextualChannel.owner_id === userId &&
userId !== uid
) {
generateAction({
action: "remove_member",
channel: contextualChannel._id,
user: uid
channel: contextualChannel,
user: user!,
});
}
}
if (server && uid && userId !== uid && uid !== server.owner) {
if (serverPermissions & ServerPermission.KickMembers)
generateAction({ action: "kick_member", target: server, user: uid });
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: uid });
generateAction({
action: "ban_member",
target: server,
user: user!,
});
}
}
if (queued) {
generateAction({
action: "retry_message",
message: queued
message: queued,
});
generateAction({
action: "cancel_message",
message: queued
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
content: message.content,
});
generateAction({
action: "copy_text",
content: message.content
content: message.content,
});
}
if (message.author === userId) {
if (message.author_id === userId) {
generateAction({
action: "edit_message",
id: message._id
id: message._id,
});
}
if (message.author === userId ||
channelPermissions & ChannelPermission.ManageMessages) {
if (
message.author_id === userId ||
channelPermissions &
ChannelPermission.ManageMessages
) {
generateAction({
action: "delete_message",
target: message
target: message,
});
}
......@@ -496,40 +700,39 @@ function ContextMenus(props: WithDispatcher) {
generateAction(
{
action: "open_file",
attachment: message.attachments[0]
attachment: message.attachments[0],
},
type === "Image"
? "open_image"
: type === "Video"
? "open_video"
: "open_file"
: "open_file",
);
generateAction(
{
action: "save_file",
attachment: message.attachments[0]
attachment: message.attachments[0],
},
type === "Image"
? "save_image"
: type === "Video"
? "save_video"
: "save_file"
: "save_file",
);
generateAction(
{
action: "copy_file_link",
attachment: message.attachments[0]
attachment: message.attachments[0],
},
"copy_link"
"copy_link",
);
}
if (document.activeElement?.tagName === "A") {
let link = document.activeElement.getAttribute(
"href"
);
const link =
document.activeElement.getAttribute("href");
if (link) {
pushDivider();
generateAction({ action: "open_link", link });
......@@ -538,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) {
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':
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");
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 });
case "DirectMessage":
generateAction({
action: "close_dm",
target: channel,
});
break;
case 'TextChannel':
// ! 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 });
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 (
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");
generateAction(
{ action: "delete_server", target: server },
"delete_server",
);
} else {
generateAction({ action: "leave_server", target: server }, "leave_server");
generateAction(
{ action: "leave_server", target: server },
"leave_server",
);
}
}
generateAction(
{ action: "copy_id", id },
sid ? "copy_sid" :
cid ? "copy_cid" :
message ? "copy_mid" : "copy_uid"
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>
<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(
ContextMenus,
() => {
return {};
},
true
);
export default connectState(ContextMenus, (state) => {
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?: boolean, always?: boolean }) {
export default function PaintCounter({
small,
always,
}: {
small?: boolean;
always?: boolean;
}) {
if (import.meta.env.PROD && !always) return null;
const [uniqueId] = useState('' + Math.random());
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 style={{ textAlign: "center", fontSize: "0.8em" }}>
{small ? <>P: {count + 1}</> : <>Painted {count + 1} time(s).</>}
</div>
)
);
}
import TextArea, { DEFAULT_LINE_HEIGHT, DEFAULT_TEXT_AREA_PADDING, TextAreaProps, TEXT_AREA_BORDER_WIDTH } from "../components/ui/TextArea";
import { useEffect, useRef } from "preact/hooks";
import styled from "styled-components";
import { RefObject } from "preact";
import { useEffect, useLayoutEffect, useRef } from "preact/hooks";
import TextArea, { TextAreaProps } from "../components/ui/TextArea";
import { internalSubscribe } from "./eventEmitter";
import { 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;
};
type TextAreaAutoSizeProps = Omit<JSX.HTMLAttributes<HTMLTextAreaElement>, 'style' | 'value'> & TextAreaProps & {
forceFocus?: boolean
autoFocus?: boolean
minHeight?: number
maxRows?: number
value: string
const Container = styled.div`
flex-grow: 1;
display: flex;
flex-direction: column;
`;
id?: string
};
const Ghost = styled.div<{ lineHeight: string; maxRows: number }>`
flex: 0;
width: 100%;
overflow: hidden;
visibility: hidden;
position: relative;
> div {
width: 100%;
white-space: pre-wrap;
word-break: break-all;
top: 0;
position: absolute;
font-size: var(--text-size);
line-height: ${(props) => props.lineHeight};
max-height: calc(
calc(${(props) => props.lineHeight} * ${(props) => props.maxRows})
);
}
`;
export default function TextAreaAutoSize(props: TextAreaAutoSizeProps) {
const { autoFocus, minHeight, maxRows, value, padding, lineHeight, hideBorder, forceFocus, children, as, ...textAreaProps } = props;
const line = lineHeight ?? DEFAULT_LINE_HEIGHT;
const {
autoFocus,
minHeight,
maxRows,
value,
padding,
lineHeight,
hideBorder,
forceFocus,
onChange,
...textAreaProps
} = props;
const heightPadding = ((padding ?? DEFAULT_TEXT_AREA_PADDING) + (hideBorder ? 0 : TEXT_AREA_BORDER_WIDTH)) * 2;
const height = Math.max(Math.min(value.split('\n').length, maxRows ?? Infinity) * line + heightPadding, minHeight ?? 0);
const ref = useRef<HTMLTextAreaElement>() as RefObject<HTMLTextAreaElement>;
const ghost = useRef<HTMLDivElement>() as RefObject<HTMLDivElement>;
const ref = useRef<HTMLTextAreaElement>();
useLayoutEffect(() => {
if (ref.current && ghost.current) {
ref.current.style.height = `${ghost.current.clientHeight}px`;
}
}, [ghost, props.value]);
useEffect(() => {
autoFocus && ref.current.focus();
}, [value]);
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();
}
......@@ -48,30 +106,55 @@ export default function TextAreaAutoSize(props: TextAreaAutoSizeProps) {
if ((e.ctrlKey && e.key !== "v") || e.altKey || e.metaKey) return;
if (e.key.length !== 1) return;
if (ref && !inputSelected()) {
ref.current.focus();
ref.current!.focus();
}
}
document.body.addEventListener("keydown", keyDown);
return () => document.body.removeEventListener("keydown", keyDown);
}, [ref]);
}, [ref, autoFocus, forceFocus, value]);
useEffect(() => {
if (!ref.current) return;
function focus(id: string) {
if (id === props.id) {
ref.current.focus();
ref.current!.focus();
}
}
return internalSubscribe("TextArea", "focus", focus);
}, [ref]);
return <TextArea
ref={ref}
value={value}
padding={padding}
style={{ height }}
hideBorder={hideBorder}
lineHeight={lineHeight}
{...textAreaProps} />;
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, "+")
.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) {
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: any[]) => {
return (...args: unknown[]) => {
// Get rid of the old timer.
clearTimeout(timer);
// Set a new timer.
......
export function defer(cb: () => void) {
setTimeout(cb, 0);
}
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: any[]) => void) {
InternalEvent.addListener(ns + '/' + event, fn);
return () => InternalEvent.removeListener(ns + '/' + event, fn);
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: 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
// - Intermediate/navigate
// - MessageBox/append
// - TextArea/focus
// - ReplyBar/add
// - Modal/close
// - PWA/update
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
fields: Fields;
}
export interface IntlType {
intl: {
dictionary: {
[key: string]: Object | string
}
}
dictionary: Dictionary;
};
}
// This will exhibit O(2^n) behaviour.
......@@ -24,36 +25,46 @@ 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 ];
if (typeof field === "undefined") return [input];
const values: (Children | string[])[] = input.split(`{{${key}}}`)
.map(v => recursiveReplaceFields(v, restOfFields));
const values: (Children | string[])[] = input
.split(`{{${key}}}`)
.map((v) => recursiveReplaceFields(v, restOfFields));
for (let i=values.length - 1;i>0;i-=2) {
for (let i = values.length - 1; i > 0; i -= 2) {
values.splice(i, 0, field);
}
return values.flat();
} else {
// base case
return [ input ];
}
// base case
return [input];
}
export function TextReact({ id, fields }: Props) {
const { intl } = useContext(IntlContext) as unknown as IntlType;
const path = id.split('.');
const path = id.split(".");
let entry = intl.dictionary[path.shift()!];
for (let key of path) {
// @ts-expect-error
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);
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
isDesktop || isTablet
? false
: (typeof window !== "undefined"
? navigator.maxTouchPoints > 0
......
/* eslint-disable @typescript-eslint/no-empty-function */
export const noop = () => {};
export const noopAsync = async () => {};
/* eslint-enable @typescript-eslint/no-empty-function */
import { RendererRoutines, RenderState, ScrollState } from "./types";
import { SimpleRenderer } from "./simple/SimpleRenderer";
/* eslint-disable react-hooks/rules-of-hooks */
import EventEmitter3 from "eventemitter3";
import { Client } from "revolt.js";
import { Message } from "revolt.js/dist/maps/Messages";
import { useEffect, useState } from "preact/hooks";
import EventEmitter3 from 'eventemitter3';
import { Client, Message } from "revolt.js";
import { SimpleRenderer } from "./simple/SimpleRenderer";
import { RendererRoutines, RenderState, ScrollState } from "./types";
export const SMOOTH_SCROLL_ON_RECEIVE = false;
......@@ -23,7 +27,7 @@ export class SingletonRenderer extends EventEmitter3 {
this.edit = this.edit.bind(this);
this.delete = this.delete.bind(this);
this.state = { type: 'LOADING' };
this.state = { type: "LOADING" };
this.currentRenderer = SimpleRenderer;
}
......@@ -41,23 +45,23 @@ export class SingletonRenderer extends EventEmitter3 {
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.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);
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);
this.emit("state", state);
if (scroll) {
this.emit('scroll', scroll);
this.emit("scroll", scroll);
}
}
......@@ -67,14 +71,29 @@ export class SingletonRenderer extends EventEmitter3 {
}
markStale() {
this.stale = true;
this.stale = true;
}
async init(id: string) {
async init(id: string, message_id?: string) {
if (message_id) {
if (this.state.type === "RENDER") {
const message = this.state.messages.find(
(x) => x._id === message_id,
);
if (message) {
this.emit("scroll", {
type: "ScrollToView",
id: message_id,
});
return;
}
}
}
this.channel = id;
this.stale = false;
this.setStateUnguarded({ type: 'LOADING' });
await this.currentRenderer.init(this, id);
this.setStateUnguarded({ type: "LOADING" });
await this.currentRenderer.init(this, id, message_id);
}
async reloadStale(id: string) {
......@@ -91,37 +110,42 @@ export class SingletonRenderer extends EventEmitter3 {
function generateScroll(end: string): ScrollState {
if (ref) {
let heightRemoved = 0;
let messageContainer = ref.children[0];
const messageContainer = ref.children[0];
if (messageContainer) {
for (let child of Array.from(messageContainer.children)) {
for (const child of Array.from(messageContainer.children)) {
// If this child has a ulid.
if (child.id?.length === 26) {
// Check whether it was removed.
if (child.id.localeCompare(end) === 1) {
heightRemoved += child.clientHeight +
heightRemoved +=
child.clientHeight +
// We also need to take into account the top margin of the container.
parseInt(window.getComputedStyle(child).marginTop.slice(0, -2));
parseInt(
window
.getComputedStyle(child)
.marginTop.slice(0, -2),
10,
);
}
}
}
}
return {
type: 'OffsetTop',
previousHeight: ref.scrollHeight - heightRemoved
}
} else {
return {
type: 'OffsetTop',
previousHeight: 0
}
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);
setTimeout(() => (this.fetchingTop = false), 0);
}
async loadBottom(ref?: HTMLDivElement) {
......@@ -131,44 +155,49 @@ export class SingletonRenderer extends EventEmitter3 {
function generateScroll(start: string): ScrollState {
if (ref) {
let heightRemoved = 0;
let messageContainer = ref.children[0];
const messageContainer = ref.children[0];
if (messageContainer) {
for (let child of Array.from(messageContainer.children)) {
for (const child of Array.from(messageContainer.children)) {
// If this child has a ulid.
if (child.id?.length === 26) {
// Check whether it was removed.
if (child.id.localeCompare(start) === -1) {
heightRemoved += child.clientHeight +
heightRemoved +=
child.clientHeight +
// We also need to take into account the top margin of the container.
parseInt(window.getComputedStyle(child).marginTop.slice(0, -2));
parseInt(
window
.getComputedStyle(child)
.marginTop.slice(0, -2),
10,
);
}
}
}
}
return {
type: 'ScrollTop',
y: ref.scrollTop - heightRemoved
}
} else {
return {
type: 'ScrollToBottom'
}
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);
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 });
if (this.state.type === "RENDER" && this.state.atBottom) {
this.emit("scroll", { type: "ScrollToBottom", smooth });
} else {
await this.currentRenderer.init(this, id, true);
await this.currentRenderer.init(this, id, undefined, true);
}
}
}
......@@ -176,7 +205,9 @@ export class SingletonRenderer extends EventEmitter3 {
export const SingletonMessageRenderer = new SingletonRenderer();
export function useRenderState(id: string) {
const [state, setState] = useState<Readonly<RenderState>>(SingletonMessageRenderer.state);
const [state, setState] = useState<Readonly<RenderState>>(
SingletonMessageRenderer.state,
);
if (typeof id === "undefined") return;
function render(state: RenderState) {
......
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) => {
init: async (renderer, id, nearby, 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 }
);
});
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' });
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 (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, mapMessage(message) ];
let messages = [...renderer.state.messages, message];
let atTop = renderer.state.atTop;
if (messages.length > 150) {
messages = messages.slice(messages.length - 150);
......@@ -39,44 +57,23 @@ export const SimpleRenderer: RendererRoutines = {
}
renderer.setState(
message.channel,
message.channel_id,
{
...renderer.state,
messages,
atTop
atTop,
},
{ type: 'StayAtBottom', smooth: SMOOTH_SCROLL_ON_RECEIVE }
{ 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' }
);
}
},
edit: noopAsync,
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 (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);
......@@ -85,9 +82,9 @@ export const SimpleRenderer: RendererRoutines = {
channel,
{
...renderer.state,
messages
messages,
},
{ type: 'StayAtBottom' }
{ type: "StayAtBottom" },
);
}
},
......@@ -96,25 +93,24 @@ export const SimpleRenderer: RendererRoutines = {
if (!channel) return;
const state = renderer.state;
if (state.type !== 'RENDER') return;
if (state.type !== "RENDER") return;
if (state.atTop) return;
const { messages: data } = await renderer.client!.channels.fetchMessagesWithUsers(channel, {
before: state.messages[0]._id
}, true);
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
}
);
return renderer.setState(channel, {
...state,
atTop: true,
});
}
data.reverse();
let messages = [ ...data.map(x => mapMessage(x)), ...state.messages ];
let messages = [...data, ...state.messages];
let atTop = false;
if (data.length < 50) {
......@@ -130,7 +126,7 @@ export const SimpleRenderer: RendererRoutines = {
renderer.setState(
channel,
{ ...state, atTop, atBottom, messages },
generateScroll(messages[messages.length - 1]._id)
generateScroll(messages[messages.length - 1]._id),
);
},
loadBottom: async (renderer, generateScroll) => {
......@@ -138,25 +134,24 @@ export const SimpleRenderer: RendererRoutines = {
if (!channel) return;
const state = renderer.state;
if (state.type !== 'RENDER') return;
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);
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
}
);
return renderer.setState(channel, {
...state,
atBottom: true,
});
}
let messages = [ ...state.messages, ...data.map(x => mapMessage(x)) ];
let messages = [...state.messages, ...data];
let atBottom = false;
if (data.length < 50) {
......@@ -172,7 +167,7 @@ export const SimpleRenderer: RendererRoutines = {
renderer.setState(
channel,
{ ...state, atTop, atBottom, messages },
generateScroll(messages[0]._id)
generateScroll(messages[0]._id),
);
}
},
};
import { Message } from "revolt.js";
import { Message } from "revolt.js/dist/maps/Messages";
import { SingletonRenderer } from "./Singleton";
import { MessageObject } from "../../context/revoltjs/util";
export type ScrollState =
| { type: "Free" }
| { type: "Bottom", scrollingUntil?: number }
| { type: "ScrollToBottom" | "StayAtBottom", smooth?: boolean }
| { type: "Bottom"; scrollingUntil?: number }
| { type: "ScrollToBottom" | "StayAtBottom"; smooth?: boolean }
| { type: "ScrollToView"; id: string }
| { type: "OffsetTop"; previousHeight: number }
| { type: "ScrollTop"; y: number };
export type RenderState =
| {
type: "LOADING" | "WAITING_FOR_NETWORK" | "EMPTY";
}
type: "LOADING" | "WAITING_FOR_NETWORK" | "EMPTY";
}
| {
type: "RENDER";
atTop: boolean;
atBottom: boolean;
messages: MessageObject[];
};
type: "RENDER";
atTop: boolean;
atBottom: boolean;
messages: Message[];
};
export interface RendererRoutines {
init: (renderer: SingletonRenderer, id: string, smooth?: boolean) => Promise<void>
init: (
renderer: SingletonRenderer,
id: string,
message?: string,
smooth?: boolean,
) => Promise<void>;
receive: (renderer: SingletonRenderer, message: Message) => Promise<void>;
edit: (renderer: SingletonRenderer, id: string, partial: Partial<Message>) => Promise<void>;
edit: (
renderer: SingletonRenderer,
id: string,
partial: Partial<Message>,
) => Promise<void>;
delete: (renderer: SingletonRenderer, id: string) => Promise<void>;
loadTop: (renderer: SingletonRenderer, generateScroll: (end: string) => ScrollState) => Promise<void>;
loadBottom: (renderer: SingletonRenderer, generateScroll: (start: string) => ScrollState) => Promise<void>;
loadTop: (
renderer: SingletonRenderer,
generateScroll: (end: string) => ScrollState,
) => Promise<void>;
loadBottom: (
renderer: SingletonRenderer,
generateScroll: (start: string) => ScrollState,
) => Promise<void>;
}
export const stopPropagation = (ev: JSX.TargetedMouseEvent<HTMLDivElement>, _consume?: any) => {
export const stopPropagation = (
ev: JSX.TargetedMouseEvent<HTMLElement>,
// eslint-disable-next-line
_consume?: unknown,
) => {
ev.preventDefault();
ev.stopPropagation();
return true;
};
import EventEmitter from "eventemitter3";
import {
RtpCapabilities,
RtpParameters,
} from "mediasoup-client/lib/RtpParameters";
import { DtlsParameters } from "mediasoup-client/lib/Transport";
import {
AuthenticationResult,
Room,
TransportInitDataTuple,
WSCommandType,
WSErrorCode,
ProduceType,
ConsumerData,
} from "./Types";
interface SignalingEvents {
open: (event: Event) => void;
close: (event: CloseEvent) => void;
error: (event: Event) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: (data: any) => void;
}
export default class Signaling extends EventEmitter<SignalingEvents> {
ws?: WebSocket;
index: number;
pending: Map<number, (data: unknown) => void>;
constructor() {
super();
this.index = 0;
this.pending = new Map();
}
connected(): boolean {
return (
this.ws !== undefined &&
this.ws.readyState !== WebSocket.CLOSING &&
this.ws.readyState !== WebSocket.CLOSED
);
}
connect(address: string): Promise<void> {
this.disconnect();
this.ws = new WebSocket(address);
this.ws.onopen = (e) => this.emit("open", e);
this.ws.onclose = (e) => this.emit("close", e);
this.ws.onerror = (e) => this.emit("error", e);
this.ws.onmessage = (e) => this.parseData(e);
let finished = false;
return new Promise((resolve, reject) => {
this.once("open", () => {
if (finished) return;
finished = true;
resolve();
});
this.once("error", () => {
if (finished) return;
finished = true;
reject();
});
});
}
disconnect() {
if (
this.ws !== undefined &&
this.ws.readyState !== WebSocket.CLOSED &&
this.ws.readyState !== WebSocket.CLOSING
)
this.ws.close(1000);
}
private parseData(event: MessageEvent) {
if (typeof event.data !== "string") return;
const json = JSON.parse(event.data);
const entry = this.pending.get(json.id);
if (entry === undefined) {
this.emit("data", json);
return;
}
entry(json);
}
/* eslint-disable @typescript-eslint/no-explicit-any */
sendRequest(type: string, data?: any): Promise<any> {
if (this.ws === undefined || this.ws.readyState !== WebSocket.OPEN)
return Promise.reject({ error: WSErrorCode.NotConnected });
const ws = this.ws;
return new Promise((resolve, reject) => {
if (this.index >= 2 ** 32) this.index = 0;
while (this.pending.has(this.index)) this.index++;
const onClose = (e: CloseEvent) => {
reject({
error: e.code,
message: e.reason,
});
};
const finishedFn = (data: any) => {
this.removeListener("close", onClose);
if (data.error)
reject({
error: data.error,
message: data.message,
data: data.data,
});
resolve(data.data);
};
this.pending.set(this.index, finishedFn);
this.once("close", onClose);
const json = {
id: this.index,
type,
data,
};
ws.send(`${JSON.stringify(json)}\n`);
this.index++;
});
}
/* eslint-enable @typescript-eslint/no-explicit-any */
authenticate(token: string, roomId: string): Promise<AuthenticationResult> {
return this.sendRequest(WSCommandType.Authenticate, { token, roomId });
}
async roomInfo(): Promise<Room> {
const room = await this.sendRequest(WSCommandType.RoomInfo);
return {
id: room.id,
videoAllowed: room.videoAllowed,
users: new Map(Object.entries(room.users)),
};
}
initializeTransports(
rtpCapabilities: RtpCapabilities,
): Promise<TransportInitDataTuple> {
return this.sendRequest(WSCommandType.InitializeTransports, {
mode: "SplitWebRTC",
rtpCapabilities,
});
}
connectTransport(
id: string,
dtlsParameters: DtlsParameters,
): Promise<void> {
return this.sendRequest(WSCommandType.ConnectTransport, {
id,
dtlsParameters,
});
}
async startProduce(
type: ProduceType,
rtpParameters: RtpParameters,
): Promise<string> {
const result = await this.sendRequest(WSCommandType.StartProduce, {
type,
rtpParameters,
});
return result.producerId;
}
stopProduce(type: ProduceType): Promise<void> {
return this.sendRequest(WSCommandType.StopProduce, { type });
}
startConsume(userId: string, type: ProduceType): Promise<ConsumerData> {
return this.sendRequest(WSCommandType.StartConsume, { type, userId });
}
stopConsume(consumerId: string): Promise<void> {
return this.sendRequest(WSCommandType.StopConsume, { id: consumerId });
}
setConsumerPause(consumerId: string, paused: boolean): Promise<void> {
return this.sendRequest(WSCommandType.SetConsumerPause, {
id: consumerId,
paused,
});
}
}
import { Consumer } from "mediasoup-client/lib/Consumer";
import {
MediaKind,
RtpCapabilities,
RtpParameters,
} from "mediasoup-client/lib/RtpParameters";
import { SctpParameters } from "mediasoup-client/lib/SctpParameters";
import {
DtlsParameters,
IceCandidate,
IceParameters,
} from "mediasoup-client/lib/Transport";
export enum WSEventType {
UserJoined = "UserJoined",
UserLeft = "UserLeft",
UserStartProduce = "UserStartProduce",
UserStopProduce = "UserStopProduce",
}
export enum WSCommandType {
Authenticate = "Authenticate",
RoomInfo = "RoomInfo",
InitializeTransports = "InitializeTransports",
ConnectTransport = "ConnectTransport",
StartProduce = "StartProduce",
StopProduce = "StopProduce",
StartConsume = "StartConsume",
StopConsume = "StopConsume",
SetConsumerPause = "SetConsumerPause",
}
export enum WSErrorCode {
NotConnected = 0,
NotFound = 404,
TransportConnectionFailure = 601,
ProducerFailure = 611,
ProducerNotFound = 614,
ConsumerFailure = 621,
ConsumerNotFound = 624,
}
export enum WSCloseCode {
// Sent when the received data is not a string, or is unparseable
InvalidData = 1003,
Unauthorized = 4001,
RoomClosed = 4004,
// Sent when a client tries to send an opcode in the wrong state
InvalidState = 1002,
ServerError = 1011,
}
export interface VoiceError {
error: WSErrorCode | WSCloseCode;
message: string;
}
export type ProduceType = "audio"; //| "video" | "saudio" | "svideo";
export interface AuthenticationResult {
userId: string;
roomId: string;
rtpCapabilities: RtpCapabilities;
}
export interface Room {
id: string;
videoAllowed: boolean;
users: Map<string, VoiceUser>;
}
export interface VoiceUser {
audio?: boolean;
//video?: boolean,
//saudio?: boolean,
//svideo?: boolean,
}
export interface ConsumerList {
audio?: Consumer;
//video?: Consumer,
//saudio?: Consumer,
//svideo?: Consumer,
}
export interface TransportInitData {
id: string;
iceParameters: IceParameters;
iceCandidates: IceCandidate[];
dtlsParameters: DtlsParameters;
sctpParameters: SctpParameters | undefined;
}
export interface TransportInitDataTuple {
sendTransport: TransportInitData;
recvTransport: TransportInitData;
}
export interface ConsumerData {
id: string;
producerId: string;
kind: MediaKind;
rtpParameters: RtpParameters;
}
import EventEmitter from "eventemitter3";
import * as mediasoupClient from "mediasoup-client";
import { types } from "mediasoup-client";
import { Device, Producer, Transport } from "mediasoup-client/lib/types";
import Signaling from "./Signaling";
import {
ProduceType,
WSEventType,
VoiceError,
VoiceUser,
ConsumerList,
WSErrorCode,
} from "./Types";
const UnsupportedError = types.UnsupportedError;
interface VoiceEvents {
ready: () => void;
error: (error: Error) => void;
close: (error?: VoiceError) => void;
startProduce: (type: ProduceType) => void;
stopProduce: (type: ProduceType) => void;
userJoined: (userId: string) => void;
userLeft: (userId: string) => void;
userStartProduce: (userId: string, type: ProduceType) => void;
userStopProduce: (userId: string, type: ProduceType) => void;
}
export default class VoiceClient extends EventEmitter<VoiceEvents> {
private _supported: boolean;
device?: Device;
signaling: Signaling;
sendTransport?: Transport;
recvTransport?: Transport;
userId?: string;
roomId?: string;
participants: Map<string, VoiceUser>;
consumers: Map<string, ConsumerList>;
audioProducer?: Producer;
constructor() {
super();
this._supported = mediasoupClient.detectDevice() !== undefined;
this.signaling = new Signaling();
this.participants = new Map();
this.consumers = new Map();
this.signaling.on(
"data",
(json) => {
const data = json.data;
switch (json.type) {
case WSEventType.UserJoined: {
this.participants.set(data.id, {});
this.emit("userJoined", data.id);
break;
}
case WSEventType.UserLeft: {
this.participants.delete(data.id);
this.emit("userLeft", data.id);
if (this.recvTransport) this.stopConsume(data.id);
break;
}
case WSEventType.UserStartProduce: {
const user = this.participants.get(data.id);
if (user === undefined) return;
switch (data.type) {
case "audio":
user.audio = true;
break;
default:
throw new Error(
`Invalid produce type ${data.type}`,
);
}
if (this.recvTransport)
this.startConsume(data.id, data.type);
this.emit("userStartProduce", data.id, data.type);
break;
}
case WSEventType.UserStopProduce: {
const user = this.participants.get(data.id);
if (user === undefined) return;
switch (data.type) {
case "audio":
user.audio = false;
break;
default:
throw new Error(
`Invalid produce type ${data.type}`,
);
}
if (this.recvTransport)
this.stopConsume(data.id, data.type);
this.emit("userStopProduce", data.id, data.type);
break;
}
}
},
this,
);
this.signaling.on(
"error",
() => {
this.emit("error", new Error("Signaling error"));
},
this,
);
this.signaling.on(
"close",
(error) => {
this.disconnect(
{
error: error.code,
message: error.reason,
},
true,
);
},
this,
);
}
supported() {
return this._supported;
}
throwIfUnsupported() {
if (!this._supported) throw new UnsupportedError("RTC not supported");
}
connect(address: string, roomId: string) {
this.throwIfUnsupported();
this.device = new Device();
this.roomId = roomId;
return this.signaling.connect(address);
}
disconnect(error?: VoiceError, ignoreDisconnected?: boolean) {
if (!this.signaling.connected() && !ignoreDisconnected) return;
this.signaling.disconnect();
this.participants = new Map();
this.consumers = new Map();
this.userId = undefined;
this.roomId = undefined;
this.audioProducer = undefined;
if (this.sendTransport) this.sendTransport.close();
if (this.recvTransport) this.recvTransport.close();
this.sendTransport = undefined;
this.recvTransport = undefined;
this.emit("close", error);
}
async authenticate(token: string) {
this.throwIfUnsupported();
if (this.device === undefined || this.roomId === undefined)
throw new ReferenceError("Voice Client is in an invalid state");
const result = await this.signaling.authenticate(token, this.roomId);
const [room] = await Promise.all([
this.signaling.roomInfo(),
this.device.load({ routerRtpCapabilities: result.rtpCapabilities }),
]);
this.userId = result.userId;
this.participants = room.users;
}
async initializeTransports() {
this.throwIfUnsupported();
if (this.device === undefined)
throw new ReferenceError("Voice Client is in an invalid state");
const initData = await this.signaling.initializeTransports(
this.device.rtpCapabilities,
);
this.sendTransport = this.device.createSendTransport(
initData.sendTransport,
);
this.recvTransport = this.device.createRecvTransport(
initData.recvTransport,
);
const connectTransport = (transport: Transport) => {
transport.on("connect", ({ dtlsParameters }, callback, errback) => {
this.signaling
.connectTransport(transport.id, dtlsParameters)
.then(callback)
.catch(errback);
});
};
connectTransport(this.sendTransport);
connectTransport(this.recvTransport);
this.sendTransport.on("produce", (parameters, callback, errback) => {
const type = parameters.appData.type;
if (
parameters.kind === "audio" &&
type !== "audio" &&
type !== "saudio"
)
return errback();
if (
parameters.kind === "video" &&
type !== "video" &&
type !== "svideo"
)
return errback();
this.signaling
.startProduce(type, parameters.rtpParameters)
.then((id) => callback({ id }))
.catch(errback);
});
this.emit("ready");
for (const user of this.participants) {
if (user[1].audio && user[0] !== this.userId)
this.startConsume(user[0], "audio");
}
}
private async startConsume(userId: string, type: ProduceType) {
if (this.recvTransport === undefined)
throw new Error("Receive transport undefined");
const consumers = this.consumers.get(userId) || {};
const consumerParams = await this.signaling.startConsume(userId, type);
const consumer = await this.recvTransport.consume(consumerParams);
switch (type) {
case "audio":
consumers.audio = consumer;
}
const mediaStream = new MediaStream([consumer.track]);
const audio = new Audio();
audio.srcObject = mediaStream;
await this.signaling.setConsumerPause(consumer.id, false);
audio.play();
this.consumers.set(userId, consumers);
}
private async stopConsume(userId: string, type?: ProduceType) {
const consumers = this.consumers.get(userId);
if (consumers === undefined) return;
if (type === undefined) {
if (consumers.audio !== undefined) consumers.audio.close();
this.consumers.delete(userId);
} else {
switch (type) {
case "audio": {
if (consumers.audio !== undefined) {
consumers.audio.close();
this.signaling.stopConsume(consumers.audio.id);
}
consumers.audio = undefined;
break;
}
}
this.consumers.set(userId, consumers);
}
}
async startProduce(track: MediaStreamTrack, type: ProduceType) {
if (this.sendTransport === undefined)
throw new Error("Send transport undefined");
const producer = await this.sendTransport.produce({
track,
appData: { type },
});
switch (type) {
case "audio":
this.audioProducer = producer;
break;
}
const participant = this.participants.get(this.userId || "");
if (participant !== undefined) {
participant[type] = true;
this.participants.set(this.userId || "", participant);
}
this.emit("startProduce", type);
}
async stopProduce(type: ProduceType) {
let producer;
switch (type) {
case "audio":
producer = this.audioProducer;
this.audioProducer = undefined;
break;
}
if (producer !== undefined) {
producer.close();
this.emit("stopProduce", type);
}
const participant = this.participants.get(this.userId || "");
if (participant !== undefined) {
participant[type] = false;
this.participants.set(this.userId || "", participant);
}
try {
await this.signaling.stopProduce(type);
} catch (error) {
if (error.error === WSErrorCode.ProducerNotFound) return;
throw error;
}
}
}