From 52f3c793c061277b50488f061fca44d0312143db Mon Sep 17 00:00:00 2001 From: Asraye Date: Sat, 6 Sep 2025 04:51:22 +1000 Subject: [PATCH 1/2] feat: message edit hint Also ran files through Prettier for better formatting. --- src/components/messaging/Chat.tsx | 1874 ++++++++++++++++++----------- 1 file changed, 1140 insertions(+), 734 deletions(-) diff --git a/src/components/messaging/Chat.tsx b/src/components/messaging/Chat.tsx index cab2b3b..3140ff2 100644 --- a/src/components/messaging/Chat.tsx +++ b/src/components/messaging/Chat.tsx @@ -11,11 +11,15 @@ import { ParentProps, Show, splitProps, - Switch + Switch, } from "solid-js"; -import type {Message, MessageReference} from "../../types/message"; -import {getApi} from "../../api/Api"; -import MessageGrouper, {authorDefault, type MessageGroup, type MessageDivider} from "../../api/MessageGrouper"; +import type { Message, MessageReference } from "../../types/message"; +import { getApi } from "../../api/Api"; +import MessageGrouper, { + authorDefault, + type MessageGroup, + type MessageDivider, +} from "../../api/MessageGrouper"; import { displayName, extendedColor, @@ -28,37 +32,40 @@ import { humanizeTimestamp, mapIterator, snowflakes, - uuid + uuid, } from "../../utils"; import TypingKeepAlive from "../../api/TypingKeepAlive"; import tooltip from "../../directives/tooltip"; -import Icon, {IconElement} from "../icons/Icon"; +import Icon, { IconElement } from "../icons/Icon"; import Clipboard from "../icons/svg/Clipboard"; import PaperPlaneTop from "../icons/svg/PaperPlaneTop"; -import {DynamicMarkdown} from "./Markdown"; -import type {DmChannel, GuildChannel} from "../../types/channel"; +import { DynamicMarkdown } from "./Markdown"; +import type { DmChannel, GuildChannel } from "../../types/channel"; import Fuse from "fuse.js"; -import {gemoji} from 'gemoji' -import {User} from "../../types/user"; +import { gemoji } from "gemoji"; +import { User } from "../../types/user"; import Plus from "../icons/svg/Plus"; import Hashtag from "../icons/svg/Hashtag"; import Trash from "../icons/svg/Trash"; import useContextMenu from "../../hooks/useContextMenu"; -import ContextMenu, {ContextMenuButton, DangerContextMenuButton} from "../ui/ContextMenu"; -import {toast} from "solid-toast"; +import ContextMenu, { + ContextMenuButton, + DangerContextMenuButton, +} from "../ui/ContextMenu"; +import { toast } from "solid-toast"; import Code from "../icons/svg/Code"; -import {ExtendedColor, Invite} from "../../types/guild"; +import { ExtendedColor, Invite } from "../../types/guild"; import GuildIcon from "../guilds/GuildIcon"; import UserPlus from "../icons/svg/UserPlus"; -import {joinGuild} from "../../pages/guilds/Invite"; -import {A, useNavigate, useParams} from "@solidjs/router"; +import { joinGuild } from "../../pages/guilds/Invite"; +import { A, useNavigate, useParams } from "@solidjs/router"; import BookmarkFilled from "../icons/svg/BookmarkFilled"; -import {UserFlags} from "../../api/Bitflags"; -import {ReactiveSet} from "@solid-primitives/set"; +import { UserFlags } from "../../api/Bitflags"; +import { ReactiveSet } from "@solid-primitives/set"; import PenToSquare from "../icons/svg/PenToSquare"; -import {getUnicodeEmojiUrl} from "./Emoji"; +import { getUnicodeEmojiUrl } from "./Emoji"; import Users from "../icons/svg/Users"; -import {ModalId, useModal} from "../ui/Modal"; +import { ModalId, useModal } from "../ui/Modal"; import EllipsisVertical from "../icons/svg/EllipsisVertical"; import FaceSmile from "../icons/svg/FaceSmile"; import Reply from "../icons/svg/Reply"; @@ -67,46 +74,50 @@ import EmojiPicker from "./EmojiPicker"; import Spinner from "../icons/svg/Spinner"; import Link from "../icons/svg/Link"; import ArrowDown from "../icons/svg/ArrowDown"; -import {stringifyJSON} from "../../api/parseJSON"; +import { stringifyJSON } from "../../api/parseJSON"; import Reference from "../icons/svg/Reference"; import At from "../icons/svg/At"; -void tooltip +void tooltip; -const CONVEY = 'https://convey.adapt.chat' +const CONVEY = "https://convey.adapt.chat"; type SkeletalData = { - headerWidth: string, - contentLines: string[], -} + headerWidth: string; + contentLines: string[]; +}; function generateSkeletalData(n: number = 10): SkeletalData[] { - const data: SkeletalData[] = [] + const data: SkeletalData[] = []; for (let i = 0; i < n; i++) { - const headerWidth = `${Math.random() * 25 + 25}%` - const contentLines = [] + const headerWidth = `${Math.random() * 25 + 25}%`; + const contentLines = []; - const lines = Math.random() * ( - Math.random() < 0.2 ? 5 : 2 - ) + const lines = Math.random() * (Math.random() < 0.2 ? 5 : 2); for (let j = 0; j < lines; j++) { - contentLines.push(`${Math.random() * 60 + 20}%`) + contentLines.push(`${Math.random() * 60 + 20}%`); } - data.push({ headerWidth, contentLines }) + data.push({ headerWidth, contentLines }); } - return data + return data; } function MessageLoadingSkeleton() { - const skeletalData = generateSkeletalData() + const skeletalData = generateSkeletalData(); return (
{(data: SkeletalData, i) => ( -
+
-
+
{data.contentLines.map((width) => (
))} @@ -115,40 +126,51 @@ function MessageLoadingSkeleton() { )}
- ) + ); } function shouldDisplayImage(filename: string): boolean { - return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].some((ext) => filename.endsWith(ext)) + return ["png", "jpg", "jpeg", "gif", "webp", "svg"].some((ext) => + filename.endsWith(ext), + ); } -function MessageReferencePreview(props: { reference: MessageReference, grouper?: MessageGrouper }) { - const api = getApi()! - const [refMsg, setRefMsg] = createSignal(null) +function MessageReferencePreview(props: { + reference: MessageReference; + grouper?: MessageGrouper; +}) { + const api = getApi()!; + const [refMsg, setRefMsg] = createSignal(null); onMount(async () => { if (props.grouper) { - const loc = await props.grouper.findMessage(props.reference.message_id) + const loc = await props.grouper.findMessage(props.reference.message_id); if (loc) { - const group = props.grouper.groups[loc[0]] + const group = props.grouper.groups[loc[0]]; if (!group.isDivider) { // @ts-ignore - setRefMsg((group as MessageGroup)[loc[1]]) - return + setRefMsg((group as MessageGroup)[loc[1]]); + return; } } } - const response = await api.request('GET', `/channels/${props.reference.channel_id}/messages/${props.reference.message_id}`) - if (response.ok) setRefMsg(response.jsonOrThrow()) - }) - - const author = createMemo(() => - refMsg()?.author ?? api.cache!.users.get(refMsg()?.author_id!) ?? authorDefault() - ) - const avatar = createMemo(() => api.cache!.avatarOf(refMsg()?.author_id!)) + const response = await api.request( + "GET", + `/channels/${props.reference.channel_id}/messages/${props.reference.message_id}`, + ); + if (response.ok) setRefMsg(response.jsonOrThrow()); + }); + + const author = createMemo( + () => + refMsg()?.author ?? + api.cache!.users.get(refMsg()?.author_id!) ?? + authorDefault(), + ); + const avatar = createMemo(() => api.cache!.avatarOf(refMsg()?.author_id!)); const href = props.reference.guild_id ? `/guilds/${props.reference.guild_id}/${props.reference.channel_id}/${props.reference.message_id}` - : `/dms/${props.reference.channel_id}/${props.reference.message_id}` + : `/dms/${props.reference.channel_id}/${props.reference.message_id}`; return ( Unknown message}> - + {displayName(author())} - {refMsg()!.content ? `: ${refMsg()!.content}` : ''} + {refMsg()!.content ? `: ${refMsg()!.content}` : ""} - ) + ); } -const INVITE_REGEX = /https:\/\/adapt\.chat\/invite\/([a-zA-Z0-9]+)/g +const INVITE_REGEX = /https:\/\/adapt\.chat\/invite\/([a-zA-Z0-9]+)/g; export type MessageContentProps = { - message: Message, - grouper?: MessageGrouper, - editing?: ReactiveSet, - largePadding?: boolean -} + message: Message; + grouper?: MessageGrouper; + editing?: ReactiveSet; + largePadding?: boolean; +}; export function MessageContent(props: MessageContentProps) { - const message = () => props.message - const largePadding = () => props.largePadding - const navigate = useNavigate() + const message = () => props.message; + const largePadding = () => props.largePadding; + const navigate = useNavigate(); - const api = getApi()! - const [invites, setInvites] = createSignal([]) + const api = getApi()!; + const [invites, setInvites] = createSignal([]); onMount(() => { const codes = new Set( - mapIterator(message().content?.matchAll(INVITE_REGEX) ?? [], (match) => match[1]) - ) - if (codes.size == 0) return + mapIterator( + message().content?.matchAll(INVITE_REGEX) ?? [], + (match) => match[1], + ), + ); + if (codes.size == 0) return; let tasks = [...codes].slice(0, 5).map(async (code) => { - const cached = api.cache!.invites.get(code) - if (cached) return cached + const cached = api.cache!.invites.get(code); + if (cached) return cached; - const response = await api.request('GET', `/invites/${code}`) - if (!response.ok) return null + const response = await api.request("GET", `/invites/${code}`); + if (!response.ok) return null; - const invite: Invite = response.jsonOrThrow() - api.cache!.invites.set(code, invite) - return invite - }) + const invite: Invite = response.jsonOrThrow(); + api.cache!.invites.set(code, invite); + return invite; + }); Promise.all(tasks).then((invites) => { - setInvites(invites.filter((invite): invite is Invite => !!invite)) - }) - }) + setInvites(invites.filter((invite): invite is Invite => !!invite)); + }); + }); - let editAreaRef: HTMLDivElement | null = null + let editAreaRef: HTMLDivElement | null = null; const editMessage = async () => { - const editedContent = editAreaRef!.innerText!.trim() - if (!editedContent) return + const editedContent = editAreaRef!.innerText!.trim(); + if (!editedContent) return; const msg = { ...message(), content: editedContent, - _nonceState: 'pending', + _nonceState: "pending", } satisfies Message; - props.grouper?.editMessage(msg.id, msg) - const response = await api.request('PATCH', `/channels/${message().channel_id}/messages/${message().id}`, { - json: { content: editedContent } - }) + props.grouper?.editMessage(msg.id, msg); + + const response = await api.request( + "PATCH", + `/channels/${message().channel_id}/messages/${message().id}`, + { + json: { content: editedContent }, + }, + ); + if (!response.ok) { - toast.error(`Failed to edit message: ${response.errorJsonOrThrow().message}`) + toast.error( + `Failed to edit message: ${response.errorJsonOrThrow().message}`, + ); + } else { + // remove from editing set once saved + props.editing?.delete(message().id); } - } + }; + + // populate edit area when entering edit mode createEffect(() => { if (props.editing?.has(message().id)) { - editAreaRef!.innerText = message().content! - editAreaRef!.focus() + editAreaRef!.innerText = message().content!; + editAreaRef!.focus(); } - }) + }); return ( - -
*:nth-last-child(2)]:inline-block message-content-root": !!message().edited_at }} - > - - - - (edited) - - -
-
- }> + +
*:nth-last-child(2)]:inline-block message-content-root": + !!message().edited_at, + }} + > + + + + (edited) + + +
+
+ } + >
{ - if (e.key == 'Enter' && !e.shiftKey || e.key == 'Escape') { - e.preventDefault() - props.editing?.delete(message().id) - if (e.key == 'Enter') await editMessage() + if ((e.key === "Enter" && !e.shiftKey) || e.key === "Escape") { + e.preventDefault(); + if (e.key === "Enter") await editMessage(); + props.editing?.delete(message().id); } }} /> + + +
+ Escape to{" "} + props.editing?.delete(message().id)} + > + Cancel + {" "} + • Enter to{" "} + + Save + +
+
+ {(embed) => (
-
+
- + @@ -329,7 +405,11 @@ export function MessageContent(props: MessageContentProps) {
- +
@@ -341,13 +421,18 @@ export function MessageContent(props: MessageContentProps) { {/* Attachments */} {(attachment) => ( -
+
{(() => { - const url = attachment.id && CONVEY + `/attachments/compr/${uuid(attachment.id)}/${attachment.filename}` + const url = + attachment.id && + CONVEY + + `/attachments/compr/${uuid(attachment.id)}/${attachment.filename}`; return shouldDisplayImage(attachment.filename) ? ( {attachment.filename} -
{humanizeSize(attachment.size)}
+
+ {humanizeSize(attachment.size)} +
- ) + ); })()}
)} @@ -378,24 +465,39 @@ export function MessageContent(props: MessageContentProps) { {(invite) => (
- +

You've been invited to join a server!

- +
-

{invite.guild?.name}

+

+ {invite.guild?.name} +

{invite.guild?.description}

- {invite.guild?.member_count?.total} Member{invite.guild?.member_count?.total === 1 ? '' : 's'} + {invite.guild?.member_count?.total} Member + {invite.guild?.member_count?.total === 1 ? "" : "s"}

- @@ -412,14 +514,17 @@ export function MessageContent(props: MessageContentProps) {

- ) + ); } function getWordAt(str: string, pos: number) { - const left = str.slice(0, pos + 1).search(/\S+$/) - const right = str.slice(pos).search(/\s/) + const left = str.slice(0, pos + 1).search(/\S+$/); + const right = str.slice(pos).search(/\s/); - return [right < 0 ? str.slice(left) : str.slice(left, right + pos), left] as const + return [ + right < 0 ? str.slice(left) : str.slice(left, right + pos), + left, + ] as const; } export enum AutocompleteType { @@ -429,74 +534,97 @@ export enum AutocompleteType { } export interface AutocompleteState { - type: AutocompleteType, - value: string, - selected: number, - data?: any, + type: AutocompleteType; + value: string; + selected: number; + data?: any; } function trueModulo(n: number, m: number) { - return ((n % m) + m) % m + return ((n % m) + m) % m; } -function setSelectionRange(element: HTMLDivElement, selectionStart: number, selectionEnd: number = selectionStart) { - const range = document.createRange() - const selection = window.getSelection() +function setSelectionRange( + element: HTMLDivElement, + selectionStart: number, + selectionEnd: number = selectionStart, +) { + const range = document.createRange(); + const selection = window.getSelection(); - range.setStart(element.childNodes[0], selectionStart) - range.setEnd(element.childNodes[0], selectionEnd) - range.collapse(true) + range.setStart(element.childNodes[0], selectionStart); + range.setEnd(element.childNodes[0], selectionEnd); + range.collapse(true); - selection?.removeAllRanges() - selection?.addRange(range) + selection?.removeAllRanges(); + selection?.addRange(range); } type MessageContextMenuProps = { - message: Message, - guildId?: bigint, - editing?: ReactiveSet, - onReply?: (message: Message) => void, -} + message: Message; + guildId?: bigint; + editing?: ReactiveSet; + onReply?: (message: Message) => void; +}; -function MessageContextMenu({ message, guildId, editing, onReply }: MessageContextMenuProps) { - const api = getApi()! - const {showModal} = useModal() - const navigate = useNavigate() +function MessageContextMenu({ + message, + guildId, + editing, + onReply, +}: MessageContextMenuProps) { + const api = getApi()!; + const { showModal } = useModal(); + const navigate = useNavigate(); const getMessageLink = () => { if (guildId) { - return `https://app.adapt.chat/guilds/${guildId}/${message.channel_id}/${message.id}` + return `https://app.adapt.chat/guilds/${guildId}/${message.channel_id}/${message.id}`; } else { - return `https://app.adapt.chat/dms/${message.channel_id}/${message.id}` + return `https://app.adapt.chat/dms/${message.channel_id}/${message.id}`; } - } + }; - const perms = () => guildId ? api.cache!.getClientPermissions(guildId, message.channel_id) : null + const perms = () => + guildId + ? api.cache!.getClientPermissions(guildId, message.channel_id) + : null; return ( - - onReply!(message)} /> + + onReply!(message)} + /> api.request('PUT', `/channels/${message.channel_id}/ack/${message.id - BigInt(1)}`)} + onClick={() => + api.request( + "PUT", + `/channels/${message.channel_id}/ack/${message.id - BigInt(1)}`, + ) + } /> toast.promise( - navigator.clipboard.writeText(message.content!), - { + onClick={() => + toast.promise(navigator.clipboard.writeText(message.content!), { loading: "Copying message text...", success: "Copied to your clipboard!", error: "Failed to copy message text, try again later.", - } - )} + }) + } /> toast.promise( - navigator.clipboard.writeText(getMessageLink()), - { + onClick={() => + toast.promise(navigator.clipboard.writeText(getMessageLink()), { loading: "Copying message link...", success: "Copied to your clipboard!", error: "Failed to copy message link, try again later.", - } - )} + }) + } /> editing!.add(message.id)} /> - + { if (!event.shiftKey) - return showModal(ModalId.DeleteMessage, message) + return showModal(ModalId.DeleteMessage, message); - const resp = await api.deleteMessage(message.channel_id, message.id) + const resp = await api.deleteMessage( + message.channel_id, + message.id, + ); if (!resp.ok) { - toast.error(`Failed to delete message: ${resp.errorJsonOrThrow().message}`) + toast.error( + `Failed to delete message: ${resp.errorJsonOrThrow().message}`, + ); } }} /> - ) + ); } interface UploadedAttachment { - filename: string - alt?: string - file: File - type: string, - preview?: string, + filename: string; + alt?: string; + file: File; + type: string; + preview?: string; } const timestampTooltip = (timestamp: number | Date) => ({ content: humanizeFullTimestamp(timestamp), delay: [1000, null] as [number, null], - interactive: true -}) + interactive: true, +}); export type MessageHeaderProps = { - mentioned?: boolean, - onContextMenu?: (e: MouseEvent) => any, - authorAvatar?: string, - authorColor?: ExtendedColor | null, - authorName: string, - badge?: string, - timestamp: number | Date, - class?: string, - classList?: Record, - noHoverEffects?: boolean, - quickActions?: ReturnType, + mentioned?: boolean; + onContextMenu?: (e: MouseEvent) => any; + authorAvatar?: string; + authorColor?: ExtendedColor | null; + authorName: string; + badge?: string; + timestamp: number | Date; + class?: string; + classList?: Record; + noHoverEffects?: boolean; + quickActions?: ReturnType; referencesProvider?: { - references: MessageReference[], - grouper: MessageGrouper, - }, -} + references: MessageReference[]; + grouper: MessageGrouper; + }; +}; export function MessageHeader(props: ParentProps) { return (
) {
{props.quickActions} ) { alt="" />
- + {props.authorName} - {props.badge} + + {props.badge} + ) { {props.children}
- ) + ); } export function MessagePreview( - props: { message: Message, guildId?: bigint, onReply?: (message: Message) => void } & Partial & MessageContentProps + props: { + message: Message; + guildId?: bigint; + onReply?: (message: Message) => void; + } & Partial & + MessageContentProps, ) { - const api = getApi()! - const contextMenu = useContextMenu()! - const message = () => props.message + const api = getApi()!; + const contextMenu = useContextMenu()!; + const message = () => props.message; // if no guild id is provided, try resolving one - const guildId = createMemo(() => - props.guildId ?? (api.cache!.channels.get(message().channel_id) as GuildChannel)?.guild_id - ) - - const author = createMemo(() => - message().author ?? api.cache!.users.get(message().author_id!) ?? authorDefault() - ) - const authorColor = guildId() && message().author_id - ? api.cache!.getMemberColor(guildId(), message().author_id!) - : undefined - - const [contentProps, rest] = splitProps(props, ['message', 'grouper', 'editing', 'largePadding']) - const [, headerProps] = splitProps(rest, ['guildId']) + const guildId = createMemo( + () => + props.guildId ?? + (api.cache!.channels.get(message().channel_id) as GuildChannel)?.guild_id, + ); + + const author = createMemo( + () => + message().author ?? + api.cache!.users.get(message().author_id!) ?? + authorDefault(), + ); + const authorColor = + guildId() && message().author_id + ? api.cache!.getMemberColor(guildId(), message().author_id!) + : undefined; + + const [contentProps, rest] = splitProps(props, [ + "message", + "grouper", + "editing", + "largePadding", + ]); + const [, headerProps] = splitProps(rest, ["guildId"]); return ( + , )} authorAvatar={api.cache!.avatarOf(message().author_id!)} authorColor={authorColor} authorName={displayName(author())} - badge={UserFlags.fromValue(author().flags).has('BOT') ? 'BOT' : undefined} + badge={UserFlags.fromValue(author().flags).has("BOT") ? "BOT" : undefined} timestamp={snowflakes.timestamp(message().id)} - referencesProvider={message().references - && contentProps.grouper - && { references: message().references, grouper: contentProps.grouper } + referencesProvider={ + message().references && + contentProps.grouper && { + references: message().references, + grouper: contentProps.grouper, + } } {...headerProps} > - ) + ); } -function QuickActionButton( - { icon, tooltip: tt, ...props }: { icon: IconElement, tooltip: string } & JSX.ButtonHTMLAttributes -) { +function QuickActionButton({ + icon, + tooltip: tt, + ...props +}: { + icon: IconElement; + tooltip: string; +} & JSX.ButtonHTMLAttributes) { return ( - ) + ); } type QuickActionsProps = { - message: Message, - offset?: number, - guildId?: bigint, - grouper: MessageGrouper, - editing?: ReactiveSet, - onReply: (message: Message) => void, -} + message: Message; + offset?: number; + guildId?: bigint; + grouper: MessageGrouper; + editing?: ReactiveSet; + onReply: (message: Message) => void; +}; function QuickActions(props: QuickActionsProps) { - const contextMenu = useContextMenu() + const contextMenu = useContextMenu(); const permissions = createMemo(() => - props.guildId ? getApi()?.cache?.getClientPermissions(props.guildId, props.message.channel_id) : null - ) + props.guildId + ? getApi()?.cache?.getClientPermissions( + props.guildId, + props.message.channel_id, + ) + : null, + ); return ( - ) + ); } type SubsequentMessageProps = { - message: Message, - guildId?: bigint, - grouper: MessageGrouper, - onReply: (message: Message) => void, - editing?: ReactiveSet, - contentProps: Partial, + message: Message; + guildId?: bigint; + grouper: MessageGrouper; + onReply: (message: Message) => void; + editing?: ReactiveSet; + contentProps: Partial; }; function SubsequentMessage(props: SubsequentMessageProps) { - const api = getApi()! - const contextMenu = useContextMenu() + const api = getApi()!; + const contextMenu = useContextMenu(); return (
+ , )} > - +
- ) + ); } type MessageGroupViewProps = { - group: MessageGroup, - guildId?: bigint, - grouper: MessageGrouper, - onReply: (message: Message) => void, - editing: ReactiveSet, - contentProps: Partial, -} + group: MessageGroup; + guildId?: bigint; + grouper: MessageGrouper; + onReply: (message: Message) => void; + editing: ReactiveSet; + contentProps: Partial; +}; function MessageGroupView(props: MessageGroupViewProps) { - const group = () => props.group - if (group().isDivider) return ( - - ) + const group = () => props.group; + if (group().isDivider) + return ( + + ); return (
- - {(message, i) => i() === 0 ? ( - - } - {...props.contentProps} - /> - ) : ( - - )} + + {(message, i) => + i() === 0 ? ( + + } + {...props.contentProps} + /> + ) : ( + + ) + }
- ) + ); } -export default function Chat(props: { channelId: bigint, guildId?: bigint, title: string, startMessage: JSX.Element }) { - const api = getApi()! - const contextMenu = useContextMenu()! - const params = useParams() - const navigate = useNavigate() - const messageId = createMemo(() => params.messageId ? BigInt(params.messageId) : null) - - const [messageInputFocused, setMessageInputFocused] = createSignal(false) - const [messageInputFocusTimeout, setMessageInputFocusTimeout] = createSignal(null) - const [loading, setLoading] = createSignal(true) - const [autocompleteState, setAutocompleteState] = createSignal(null) - const [uploadedAttachments, setUploadedAttachments] = createSignal([]) - const [replyingTo, setReplyingTo] = createSignal<{message: Message, mentionAuthor: boolean}[]>([]) - const [sendable, setSendable] = createSignal(false) - const [emojiPickerVisible, setEmojiPickerVisible] = createSignal(false) - let emojiPickerRef: HTMLDivElement | undefined - let emojiToggleRef: HTMLButtonElement | undefined - let messageInputRef: HTMLDivElement | undefined - let messageAreaRef: HTMLDivElement | undefined +export default function Chat(props: { + channelId: bigint; + guildId?: bigint; + title: string; + startMessage: JSX.Element; +}) { + const api = getApi()!; + const contextMenu = useContextMenu()!; + const params = useParams(); + const navigate = useNavigate(); + const messageId = createMemo(() => + params.messageId ? BigInt(params.messageId) : null, + ); + + const [messageInputFocused, setMessageInputFocused] = createSignal(false); + const [messageInputFocusTimeout, setMessageInputFocusTimeout] = createSignal< + number | null + >(null); + const [loading, setLoading] = createSignal(true); + const [autocompleteState, setAutocompleteState] = + createSignal(null); + const [uploadedAttachments, setUploadedAttachments] = createSignal< + UploadedAttachment[] + >([]); + const [replyingTo, setReplyingTo] = createSignal< + { message: Message; mentionAuthor: boolean }[] + >([]); + const [sendable, setSendable] = createSignal(false); + const [emojiPickerVisible, setEmojiPickerVisible] = createSignal(false); + let emojiPickerRef: HTMLDivElement | undefined; + let emojiToggleRef: HTMLButtonElement | undefined; + let messageInputRef: HTMLDivElement | undefined; + let messageAreaRef: HTMLDivElement | undefined; const [showViewNewerMessages, setShowViewNewerMessages] = createSignal(false); const [scrollingToBottom, setScrollingToBottom] = createSignal(false); const [messageContextDistance, setMessageContextDistance] = createSignal(0); const [lastScrollPosition, setLastScrollPosition] = createSignal(0); - const updateSendable = () => setSendable(!!messageInputRef?.innerText?.trim() || uploadedAttachments().length > 0) + const updateSendable = () => + setSendable( + !!messageInputRef?.innerText?.trim() || uploadedAttachments().length > 0, + ); const addReply = (message: Message) => { - setReplyingTo(prev => prev.some(({ message: m }) => m.id === message.id) ? prev : [...prev, {message, mentionAuthor: false}]) - messageInputRef?.focus() - } - const removeReply = (id: bigint) => setReplyingTo(prev => prev.filter(({ message: m }) => m.id !== id)) + setReplyingTo((prev) => + prev.some(({ message: m }) => m.id === message.id) + ? prev + : [...prev, { message, mentionAuthor: false }], + ); + messageInputRef?.focus(); + }; + const removeReply = (id: bigint) => + setReplyingTo((prev) => prev.filter(({ message: m }) => m.id !== id)); const setMentionAuthor = (message: Message, mentionAuthor: boolean) => { - setReplyingTo(prev => prev.map(({ message: m, mentionAuthor: ma }) => - m.id === message.id ? { message: m, mentionAuthor } : { message: m, mentionAuthor: ma } - )) - } + setReplyingTo((prev) => + prev.map(({ message: m, mentionAuthor: ma }) => + m.id === message.id + ? { message: m, mentionAuthor } + : { message: m, mentionAuthor: ma }, + ), + ); + }; - const mobile = /Android|webOS|iPhone|iP[ao]d|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) + const mobile = + /Android|webOS|iPhone|iP[ao]d|BlackBerry|IEMobile|Opera Mini/i.test( + navigator.userAgent, + ); const grouper = createMemo(() => { - const { grouper, cached } = getApi()!.cache!.useChannelMessages(props.channelId) - setLoading(!cached) + const { grouper, cached } = getApi()!.cache!.useChannelMessages( + props.channelId, + ); + setLoading(!cached); - if (!cached) - grouper.fetchMessages().then(() => setLoading(false)) - return grouper - }) + if (!cached) grouper.fetchMessages().then(() => setLoading(false)); + return grouper; + }); - const typing = createMemo(() => api.cache!.useTyping(props.channelId)) - const typingKeepAlive = new TypingKeepAlive(api, props.channelId) - const editing = new ReactiveSet() + const typing = createMemo(() => api.cache!.useTyping(props.channelId)); + const typingKeepAlive = new TypingKeepAlive(api, props.channelId); + const editing = new ReactiveSet(); const focusListener = (e: KeyboardEvent) => { - const charCode = e.key.charCodeAt(0) - if (document.activeElement == document.body && ( - e.key.length == 1 - && charCode >= 32 && charCode <= 126 - && !e.ctrlKey && !e.altKey && !e.metaKey - || (e.ctrlKey || e.metaKey) && e.key == 'v' - )) { - messageInputRef!.focus() + const charCode = e.key.charCodeAt(0); + if ( + document.activeElement == document.body && + ((e.key.length == 1 && + charCode >= 32 && + charCode <= 126 && + !e.ctrlKey && + !e.altKey && + !e.metaKey) || + ((e.ctrlKey || e.metaKey) && e.key == "v")) + ) { + messageInputRef!.focus(); } - } + }; const hideEmojiPicker = (e: MouseEvent) => { if ( - emojiPickerVisible() - && emojiPickerRef - && !emojiPickerRef.contains(e.target as Node) - && !emojiToggleRef?.contains(e.target as Node) + emojiPickerVisible() && + emojiPickerRef && + !emojiPickerRef.contains(e.target as Node) && + !emojiToggleRef?.contains(e.target as Node) ) { - setEmojiPickerVisible(false) + setEmojiPickerVisible(false); } - } + }; onMount(() => { - document.addEventListener('keydown', focusListener) - document.addEventListener('click', hideEmojiPicker) - }) + document.addEventListener("keydown", focusListener); + document.addEventListener("click", hideEmojiPicker); + }); onCleanup(async () => { - document.removeEventListener('keydown', focusListener) - document.removeEventListener('click', hideEmojiPicker) - await typingKeepAlive.stop() - }) + document.removeEventListener("keydown", focusListener); + document.removeEventListener("click", hideEmojiPicker); + await typingKeepAlive.stop(); + }); const createMessage = async () => { if (!sendable()) return; - const content = messageInputRef!.innerText!.trim() - const attachments = uploadedAttachments() - const references = replyingTo() + const content = messageInputRef!.innerText!.trim(); + const attachments = uploadedAttachments(); + const references = replyingTo(); - setUploadedAttachments([]) - setReplyingTo([]) - setSendable(false) + setUploadedAttachments([]); + setReplyingTo([]); + setSendable(false); // Clear the message input without invalidating undo history - messageInputRef!.focus() - document.execCommand('selectAll', false) - document.execCommand('insertHTML', false, '') + messageInputRef!.focus(); + document.execCommand("selectAll", false); + document.execCommand("insertHTML", false, ""); const refs = references.map(({ message: r, mentionAuthor }) => ({ channel_id: r.channel_id, - guild_id: (api.cache?.channels.get(r.channel_id) as GuildChannel | undefined)?.guild_id ?? null, + guild_id: + (api.cache?.channels.get(r.channel_id) as GuildChannel | undefined) + ?.guild_id ?? null, mention_author: mentionAuthor, message_id: r.id, - })) + })); let mockMessage = { id: snowflakes.fromTimestamp(Date.now()), - type: 'default', + type: "default", content, author_id: api.cache!.clientUser!.id, - attachments: attachments.map(attachment => ({ + attachments: attachments.map((attachment) => ({ _imageOverride: attachment.preview, filename: attachment.filename, alt: attachment.alt, size: attachment.file.size, })), - _nonceState: 'pending', + _nonceState: "pending", ...grouper().nonceDefault, references: refs, - } as Message + } as Message; - const nonce = mockMessage.id.toString() - const idx = grouper().pushMessage(mockMessage) - grouper().nonced.set(nonce, idx) - messageAreaRef!.scrollTo(0, messageAreaRef!.scrollHeight) + const nonce = mockMessage.id.toString(); + const idx = grouper().pushMessage(mockMessage); + grouper().nonced.set(nonce, idx); + messageAreaRef!.scrollTo(0, messageAreaRef!.scrollHeight); try { - const json = { content, nonce, references: refs } + const json = { content, nonce, references: refs }; let options; if (attachments.length > 0) { - const formData = new FormData() - formData.append('json', stringifyJSON(json)); + const formData = new FormData(); + formData.append("json", stringifyJSON(json)); for (const [i, attachment] of Object.entries(attachments)) { - formData.append('file' + i, attachment.file, attachment.filename) + formData.append("file" + i, attachment.file, attachment.filename); } - options = { multipart: formData } + options = { multipart: formData }; } else { - options = { json } + options = { json }; } - const response = await api.request('POST', `/channels/${props.channelId}/messages`, options) - void typingKeepAlive.stop() + const response = await api.request( + "POST", + `/channels/${props.channelId}/messages`, + options, + ); + void typingKeepAlive.stop(); if (!response.ok) - grouper().ackNonceError(nonce, mockMessage, response.errorJsonOrThrow().message) + grouper().ackNonceError( + nonce, + mockMessage, + response.errorJsonOrThrow().message, + ); } catch (e: any) { - grouper().ackNonceError(nonce, mockMessage, e) - throw e + grouper().ackNonceError(nonce, mockMessage, e); + throw e; } - } + }; const MAPPING = [ - ['@', AutocompleteType.UserMention], - ['#', AutocompleteType.ChannelMention], - [':', AutocompleteType.Emoji], - ] as const - let caretPosition = 0 + ["@", AutocompleteType.UserMention], + ["#", AutocompleteType.ChannelMention], + [":", AutocompleteType.Emoji], + ] as const; + let caretPosition = 0; const cacheCaretPosition = () => { - const sel = window.getSelection() - if (sel?.rangeCount && messageInputRef && messageInputRef.contains(sel.anchorNode)) { - const range = sel.getRangeAt(0) - const preRange = range.cloneRange() - preRange.selectNodeContents(messageInputRef) - preRange.setEnd(range.endContainer, range.endOffset) - caretPosition = preRange.toString().length + const sel = window.getSelection(); + if ( + sel?.rangeCount && + messageInputRef && + messageInputRef.contains(sel.anchorNode) + ) { + const range = sel.getRangeAt(0); + const preRange = range.cloneRange(); + preRange.selectNodeContents(messageInputRef); + preRange.setEnd(range.endContainer, range.endOffset); + caretPosition = preRange.toString().length; } - } - let autocompleteTimeout: number | undefined + }; + let autocompleteTimeout: number | undefined; const updateAutocompleteState = () => { - const [currentWord, index] = getWordAt(messageInputRef?.innerText!, caretPosition - 1) + const [currentWord, index] = getWordAt( + messageInputRef?.innerText!, + caretPosition - 1, + ); for (const [char, type] of MAPPING) { if (currentWord.startsWith(char)) { setAutocompleteState({ @@ -1003,153 +1259,187 @@ export default function Chat(props: { channelId: bigint, guildId?: bigint, title value: currentWord.slice(1), selected: 0, data: { index }, - }) - return + }); + return; } } - setAutocompleteState(null) - } + setAutocompleteState(null); + }; const scheduleAutocompleteUpdate = () => { - clearTimeout(autocompleteTimeout) - autocompleteTimeout = window.setTimeout(updateAutocompleteState, 50) - } + clearTimeout(autocompleteTimeout); + autocompleteTimeout = window.setTimeout(updateAutocompleteState, 50); + }; const handleCaretUpdate = () => { - cacheCaretPosition() - scheduleAutocompleteUpdate() - } + cacheCaretPosition(); + scheduleAutocompleteUpdate(); + }; const members = createMemo(() => { - const cache = api.cache - if (!cache) return [] - - const m = ( - props.guildId - ? cache.memberReactor.get(props.guildId)?.map(u => cache.users.get(u)) - : (cache.channels.get(props.channelId) as DmChannel | null)?.recipient_ids.map(u => cache.users.get(u)) - ) ?? [] - return m.filter((u): u is User => !!u) - }) - const fuseMemberIndex = createMemo(() => new Fuse(members()!, { - keys: ['username', 'display_name'], // TODO: nickname - })) - - const permissions = createMemo(() => props.guildId ? api.cache!.getClientPermissions(props.guildId, props.channelId) : null) - const canSendMessages = createMemo(() => !permissions() || permissions()!.has('SEND_MESSAGES')) + const cache = api.cache; + if (!cache) return []; + + const m = + (props.guildId + ? cache.memberReactor.get(props.guildId)?.map((u) => cache.users.get(u)) + : ( + cache.channels.get(props.channelId) as DmChannel | null + )?.recipient_ids.map((u) => cache.users.get(u))) ?? []; + return m.filter((u): u is User => !!u); + }); + const fuseMemberIndex = createMemo( + () => + new Fuse(members()!, { + keys: ["username", "display_name"], // TODO: nickname + }), + ); + + const permissions = createMemo(() => + props.guildId + ? api.cache!.getClientPermissions(props.guildId, props.channelId) + : null, + ); + const canSendMessages = createMemo( + () => !permissions() || permissions()!.has("SEND_MESSAGES"), + ); const recipientId = createMemo(() => { - if (props.guildId) return null - const ids = (api?.cache?.channels.get(props.channelId) as DmChannel | null)?.recipient_ids as any - return ids[0] == api?.cache?.clientId ? ids[1] : ids[0] - }) + if (props.guildId) return null; + const ids = (api?.cache?.channels.get(props.channelId) as DmChannel | null) + ?.recipient_ids as any; + return ids[0] == api?.cache?.clientId ? ids[1] : ids[0]; + }); const channels = createMemo(() => { - const cache = api.cache - if (!cache) return [] + const cache = api.cache; + if (!cache) return []; // TODO: filter by permissions if (props.guildId) { - return [...cache.guilds.get(props.guildId)?.channels?.values() ?? []] + return [...(cache.guilds.get(props.guildId)?.channels?.values() ?? [])]; } else { - const mutualGuildIds = cache.userGuilds.get(recipientId()!) ?? [] + const mutualGuildIds = cache.userGuilds.get(recipientId()!) ?? []; return [ - ...flatMapIterator( - mutualGuildIds, - (guildId) => { - const guild = cache.guilds.get(guildId) - return ( - guild?.channels - ?.map(channel => ({...channel, key: `${channel.name}:${guild.name}`}) as unknown as GuildChannel) ?? [] - ) - } - ), - ] + ...flatMapIterator(mutualGuildIds, (guildId) => { + const guild = cache.guilds.get(guildId); + return ( + guild?.channels?.map( + (channel) => + ({ + ...channel, + key: `${channel.name}:${guild.name}`, + }) as unknown as GuildChannel, + ) ?? [] + ); + }), + ]; } - }) - const fuseChannelIndex = createMemo(() => new Fuse(channels()!, { - keys: ['key', 'name'], - })) + }); + const fuseChannelIndex = createMemo( + () => + new Fuse(channels()!, { + keys: ["key", "name"], + }), + ); const externalAllowedFrom = createMemo(() => { - if (!props.guildId || permissions()?.has('USE_EXTERNAL_EMOJIS')) - return api.cache?.guildList ?? [] + if (!props.guildId || permissions()?.has("USE_EXTERNAL_EMOJIS")) + return api.cache?.guildList ?? []; - return [props.guildId] - }) + return [props.guildId]; + }); const emojis = createMemo(() => { - const unicode = gemoji.flatMap( - ({names, emoji, category}) => names.map(name => ({ - name, emoji, url: getUnicodeEmojiUrl(emoji), category - })) + const unicode = gemoji.flatMap(({ names, emoji, category }) => + names.map((name) => ({ + name, + emoji, + url: getUnicodeEmojiUrl(emoji), + category, + })), ); - const allowed = externalAllowedFrom() + const allowed = externalAllowedFrom(); const custom = filterMapIterator( api.cache!.customEmojis.values(), - (emoji) => allowed.includes(emoji.guild_id) ? { - name: emoji.name, - emoji: `:${emoji.id}:`, - url: `https://convey.adapt.chat/emojis/${emoji.id}`, - category: api.cache!.guilds.get(emoji.guild_id)?.name ?? 'Custom', - data: emoji, - } : null - ) - return [...unicode, ...custom] - }) - const fuseEmojiIndex = createMemo(() => new Fuse(emojis(), { keys: ['name'] })) + (emoji) => + allowed.includes(emoji.guild_id) + ? { + name: emoji.name, + emoji: `:${emoji.id}:`, + url: `https://convey.adapt.chat/emojis/${emoji.id}`, + category: api.cache!.guilds.get(emoji.guild_id)?.name ?? "Custom", + data: emoji, + } + : null, + ); + return [...unicode, ...custom]; + }); + const fuseEmojiIndex = createMemo( + () => new Fuse(emojis(), { keys: ["name"] }), + ); const setAutocompleteSelection = (index: number) => { - setAutocompleteState(prev => ({ + setAutocompleteState((prev) => ({ ...prev!, selected: trueModulo(index, autocompleteResult()?.length || 1), - })) - } - const fuse = function(value: string, index: Accessor>, fallback: Accessor) { + })); + }; + const fuse = function ( + value: string, + index: Accessor>, + fallback: Accessor, + ) { return value - ? index()?.search(value).slice(0, 5).map(result => result.item) - : fallback().slice(0, 5) - } + ? index() + ?.search(value) + .slice(0, 5) + .map((result) => result.item) + : fallback().slice(0, 5); + }; const autocompleteResult = createMemo(() => { - const state = autocompleteState() - if (!state) return + const state = autocompleteState(); + if (!state) return; - const { type, value } = state + const { type, value } = state; switch (type) { - case AutocompleteType.UserMention: return fuse(value, fuseMemberIndex, members) - case AutocompleteType.ChannelMention: return fuse(value, fuseChannelIndex, channels) - case AutocompleteType.Emoji: return fuse(value, fuseEmojiIndex, () => []) + case AutocompleteType.UserMention: + return fuse(value, fuseMemberIndex, members); + case AutocompleteType.ChannelMention: + return fuse(value, fuseChannelIndex, channels); + case AutocompleteType.Emoji: + return fuse(value, fuseEmojiIndex, () => []); } - }) + }); const executeAutocomplete = (index?: number) => { - const result = autocompleteResult() + const result = autocompleteResult(); if (!result?.length) { - return void setAutocompleteState(null) + return void setAutocompleteState(null); } - const { type, value, selected } = autocompleteState()! + const { type, value, selected } = autocompleteState()!; const replace = (repl: string) => { - const { index: wordIndex } = autocompleteState()!.data! + const { index: wordIndex } = autocompleteState()!.data!; - const text = messageInputRef!.innerText! - const before = text.slice(0, wordIndex) + repl - const after = text.slice(wordIndex + value.length + 1) - messageInputRef!.innerText = before + after + const text = messageInputRef!.innerText!; + const before = text.slice(0, wordIndex) + repl; + const after = text.slice(wordIndex + value.length + 1); + messageInputRef!.innerText = before + after; - messageInputRef!.focus() - setSelectionRange(messageInputRef!, before.length) - } + messageInputRef!.focus(); + setSelectionRange(messageInputRef!, before.length); + }; switch (type) { case AutocompleteType.UserMention: case AutocompleteType.ChannelMention: { - const target = result[index ?? selected] as User | GuildChannel - const symbol = MAPPING.find(([_, ty]) => type === ty)![0] - replace(`<${symbol}${target.id}>`) - break + const target = result[index ?? selected] as User | GuildChannel; + const symbol = MAPPING.find(([_, ty]) => type === ty)![0]; + replace(`<${symbol}${target.id}>`); + break; } case AutocompleteType.Emoji: - replace((result[index ?? selected] as { emoji: string }).emoji) - break + replace((result[index ?? selected] as { emoji: string }).emoji); + break; } - setAutocompleteState(null) - } + setAutocompleteState(null); + }; const StandardAutocompleteEntry = (props: ParentProps<{ idx: number }>) => (
{props.children}
- ) + ); - let [lastAckedId, setLastAckedId] = createSignal(null) + let [lastAckedId, setLastAckedId] = createSignal(null); const ack = async () => { - const last = api.cache?.lastMessages.get(props.channelId) - if (!last || lastAckedId() == last?.id) return + const last = api.cache?.lastMessages.get(props.channelId); + if (!last || lastAckedId() == last?.id) return; - let { id, author_id: authorId } = last as Message - if (authorId == api.cache?.clientId) return + let { id, author_id: authorId } = last as Message; + if (authorId == api.cache?.clientId) return; - setLastAckedId(id) - await api.request('PUT', `/channels/${props.channelId}/ack/${id}`) - } + setLastAckedId(id); + await api.request("PUT", `/channels/${props.channelId}/ack/${id}`); + }; - const contentProps = () => ({ grouper: grouper(), editing }) + const contentProps = () => ({ grouper: grouper(), editing }); // message jump handler createEffect(async () => { - const targetId = messageId() - if (!targetId || !messageAreaRef) return - if (loading()) return + const targetId = messageId(); + if (!targetId || !messageAreaRef) return; + if (loading()) return; + + let messageElement = messageAreaRef.querySelector( + `[data-message-id="${targetId}"]`, + ); - let messageElement = messageAreaRef.querySelector(`[data-message-id="${targetId}"]`) - // If not found in DOM and grouper is available, try to find and load the message if (!messageElement) { - const indices = await grouper().findMessage(targetId) - if (!indices) return - + const indices = await grouper().findMessage(targetId); + if (!indices) return; + // Check again after finding the message - messageElement = messageAreaRef.querySelector(`[data-message-id="${targetId}"]`) - if (!messageElement) return + messageElement = messageAreaRef.querySelector( + `[data-message-id="${targetId}"]`, + ); + if (!messageElement) return; } - messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' }) - + messageElement.scrollIntoView({ behavior: "smooth", block: "center" }); + // highlight the message - const messageContainer = messageElement.closest('.group') + const messageContainer = messageElement.closest(".group"); if (messageContainer) { - messageContainer.classList.add('bg-accent/10') + messageContainer.classList.add("bg-accent/10"); setTimeout(() => { - messageContainer.classList.remove('bg-accent/10') - - if (props.guildId) - navigate(`/guilds/${props.guildId}/${props.channelId}`, { replace: true }) - else - navigate(`/dms/${props.channelId}`, { replace: true }) - }, 3000) + messageContainer.classList.remove("bg-accent/10"); + + if (props.guildId) + navigate(`/guilds/${props.guildId}/${props.channelId}`, { + replace: true, + }); + else navigate(`/dms/${props.channelId}`, { replace: true }); + }, 3000); } - }) + }); const handleEmojiSelect = (emoji: string) => { - if (!messageInputRef) return + if (!messageInputRef) return; if (document.activeElement !== messageInputRef) { - messageInputRef.focus() + messageInputRef.focus(); } // Insert emoji at current cursor position - const selection = window.getSelection() - const range = selection?.getRangeAt(0) - + const selection = window.getSelection(); + const range = selection?.getRangeAt(0); + if (range && messageInputRef) { // Create a text node with the emoji - const textNode = document.createTextNode(emoji) - range.insertNode(textNode) - + const textNode = document.createTextNode(emoji); + range.insertNode(textNode); + // Move cursor after the inserted emoji - range.setStartAfter(textNode) - range.setEndAfter(textNode) - selection?.removeAllRanges() - selection?.addRange(range) - - messageInputRef.focus() - updateSendable() + range.setStartAfter(textNode); + range.setEndAfter(textNode); + selection?.removeAllRanges(); + selection?.addRange(range); + + messageInputRef.focus(); + updateSendable(); // Close the emoji picker if shift is not held - const event = window.event as MouseEvent + const event = window.event as MouseEvent; if (!event || !event.shiftKey) { - setEmojiPickerVisible(false) + setEmojiPickerVisible(false); } } - } + }; return ( - }> + + You do not have permission to send messages in this channel. +
+ } + >
-
@@ -1480,35 +1818,41 @@ export default function Chat(props: { channelId: bigint, guildId?: bigint, title class="w-9 h-9 flex flex-shrink-0 items-center justify-center rounded-full bg-3 mr-2 transition-all duration-200 hover:bg-accent" onClick={() => { // Upload attachment - const input = document.createElement('input') - input.type = 'file' - input.multiple = true - input.accept = '*' - input.addEventListener('change', async () => { - const files = input.files - if (!files) return - - const uploaded = await Promise.all(Array.from(files, async (file) => { - const attachment: UploadedAttachment = { - filename: file.name, - type: file.type, - file, - } - if (file.type.startsWith('image/')) { - // show a preview of this image - attachment.preview = URL.createObjectURL(file) - } - return attachment - })) - setUploadedAttachments(prev => [...prev, ...uploaded]) - updateSendable() - }) - input.click() - messageInputRef?.focus() + const input = document.createElement("input"); + input.type = "file"; + input.multiple = true; + input.accept = "*"; + input.addEventListener("change", async () => { + const files = input.files; + if (!files) return; + + const uploaded = await Promise.all( + Array.from(files, async (file) => { + const attachment: UploadedAttachment = { + filename: file.name, + type: file.type, + file, + }; + if (file.type.startsWith("image/")) { + // show a preview of this image + attachment.preview = URL.createObjectURL(file); + } + return attachment; + }), + ); + setUploadedAttachments((prev) => [...prev, ...uploaded]); + updateSendable(); + }); + input.click(); + messageInputRef?.focus(); }} use:tooltip="Upload" > - +
{({ message: msg, mentionAuthor }) => { - const icon = msg.author?.avatar ?? api.cache!.avatarOf(msg.author_id!) - const name = displayName(msg.author ?? api.cache!.users.get(msg.author_id!) ?? authorDefault()) + const icon = + msg.author?.avatar ?? api.cache!.avatarOf(msg.author_id!); + const name = displayName( + msg.author ?? + api.cache!.users.get(msg.author_id!) ?? + authorDefault(), + ); return (
- {name} + {name} {name} - {msg.content ? `: ${msg.content}` : ''} + {msg.content ? `: ${msg.content}` : ""} -
- ) + ); }}
@@ -1560,8 +1927,10 @@ export default function Chat(props: { channelId: bigint, guildId?: bigint, title
{attachment.preview ? ( - {attachment.filename} + {attachment.filename} ) : ( {attachment.type || attachment.filename} @@ -1577,9 +1950,12 @@ export default function Chat(props: { channelId: bigint, guildId?: bigint, title )}
-

{attachment.filename}

+

+ {attachment.filename} +

- {humanizeSize(attachment.file.size)} {attachment.alt && <> - {attachment.alt}} + {humanizeSize(attachment.file.size)}{" "} + {attachment.alt && <> - {attachment.alt}}
@@ -1587,7 +1963,13 @@ export default function Chat(props: { channelId: bigint, guildId?: bigint, title 1} keyed={false}>
- = {humanizeSize(uploadedAttachments().reduce((acc, cur) => acc + cur.file.size, 0))} + ={" "} + {humanizeSize( + uploadedAttachments().reduce( + (acc, cur) => acc + cur.file.size, + 0, + ), + )}
@@ -1601,90 +1983,105 @@ export default function Chat(props: { channelId: bigint, guildId?: bigint, title spellcheck={false} // Paste listener for attachments onPaste={async (event) => { - event.preventDefault() - - const types = event.clipboardData?.types - if (types?.includes('Files')) { - const files = event.clipboardData?.files - if (!files) return - - const uploaded = await Promise.all(Array.from(files, async (file) => { - const attachment: UploadedAttachment = { - filename: file.name, - type: file.type, - file, - } - if (file.type.startsWith('image/')) { - // show a preview of this image - attachment.preview = URL.createObjectURL(file) - } - return attachment - })) - setUploadedAttachments(prev => [...prev, ...uploaded]) + event.preventDefault(); + + const types = event.clipboardData?.types; + if (types?.includes("Files")) { + const files = event.clipboardData?.files; + if (!files) return; + + const uploaded = await Promise.all( + Array.from(files, async (file) => { + const attachment: UploadedAttachment = { + filename: file.name, + type: file.type, + file, + }; + if (file.type.startsWith("image/")) { + // show a preview of this image + attachment.preview = URL.createObjectURL(file); + } + return attachment; + }), + ); + setUploadedAttachments((prev) => [...prev, ...uploaded]); } - if (types?.includes('text/plain')) { - const text = event.clipboardData?.getData('text/plain') - if (!text) return + if (types?.includes("text/plain")) { + const text = event.clipboardData?.getData("text/plain"); + if (!text) return; - document.execCommand('insertText', false, text) + document.execCommand("insertText", false, text); } - updateSendable() + updateSendable(); }} onKeyUp={(event) => { - const oldState = autocompleteState() + const oldState = autocompleteState(); if (oldState) - if (event.key === 'ArrowUp') { - return setAutocompleteSelection(oldState.selected - 1) - } else if (event.key === 'ArrowDown') { - return setAutocompleteSelection(oldState.selected + 1) + if (event.key === "ArrowUp") { + return setAutocompleteSelection(oldState.selected - 1); + } else if (event.key === "ArrowDown") { + return setAutocompleteSelection(oldState.selected + 1); } - handleCaretUpdate() + handleCaretUpdate(); }} onKeyDown={(event) => { - const oldState = autocompleteState() - if (oldState && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) - event.preventDefault() - - else if (event.key === 'ArrowUp' && !event.currentTarget.innerText?.trim()) { - event.preventDefault() - const lastMessage = grouper().latestMessageWhere(m => m.author_id === api.cache?.clientId) - if (lastMessage && lastMessage.author_id == api.cache?.clientId) - editing.add(lastMessage.id) + const oldState = autocompleteState(); + if ( + oldState && + (event.key === "ArrowUp" || event.key === "ArrowDown") + ) + event.preventDefault(); + else if ( + event.key === "ArrowUp" && + !event.currentTarget.innerText?.trim() + ) { + event.preventDefault(); + const lastMessage = grouper().latestMessageWhere( + (m) => m.author_id === api.cache?.clientId, + ); + if ( + lastMessage && + lastMessage.author_id == api.cache?.clientId + ) + editing.add(lastMessage.id); } }} onKeyPress={async (event) => { - if (event.shiftKey) - return + if (event.shiftKey) return; - if (event.key === 'Enter' && (!mobile || event.ctrlKey || event.metaKey)) { - event.preventDefault() + if ( + event.key === "Enter" && + (!mobile || event.ctrlKey || event.metaKey) + ) { + event.preventDefault(); if (autocompleteState() && autocompleteResult()?.length) - return executeAutocomplete() + return executeAutocomplete(); - await createMessage() + await createMessage(); } }} onMouseUp={handleCaretUpdate} onTouchStart={handleCaretUpdate} onSelect={handleCaretUpdate} onInput={() => { - void typingKeepAlive.ackTyping() - updateSendable() - handleCaretUpdate() + void typingKeepAlive.ackTyping(); + updateSendable(); + handleCaretUpdate(); }} onFocus={() => { - const timeout = messageInputFocusTimeout() - if (timeout) - clearTimeout(timeout) + const timeout = messageInputFocusTimeout(); + if (timeout) clearTimeout(timeout); - setMessageInputFocused(true) - void ack() + setMessageInputFocused(true); + void ack(); }} - onBlur={() => setMessageInputFocusTimeout( - setTimeout(() => setMessageInputFocused(false), 100) as any - )} + onBlur={() => + setMessageInputFocusTimeout( + setTimeout(() => setMessageInputFocused(false), 100) as any, + ) + } />
0}> - api.cache?.users.get(id)?.username).filter((u): u is string => !!u)}> + api.cache?.users.get(id)?.username) + .filter((u): u is string => !!u)} + > {(username, index) => ( <> {username} - {index() < typing().users.size - 1 && typing().users.size > 2 && ( - , - )} + {index() < typing().users.size - 1 && + typing().users.size > 2 && , } {index() === typing().users.size - 2 && ( and )} )} - {typing().users.size === 1 ? 'is' : 'are'} typing... + + {" "} + {typing().users.size === 1 ? "is" : "are"} typing... +
@@ -1746,5 +2152,5 @@ export default function Chat(props: { channelId: bigint, guildId?: bigint, title
- ) + ); } From 20b52be1aefb67d1a06da626e887a8cb9b9689bf Mon Sep 17 00:00:00 2001 From: Asraye Date: Thu, 18 Sep 2025 00:53:30 +1000 Subject: [PATCH 2/2] fix: modal works now lol feat: delete modal if empty content --- src/components/messaging/Chat.tsx | 1848 ++++++++++++----------------- 1 file changed, 733 insertions(+), 1115 deletions(-) diff --git a/src/components/messaging/Chat.tsx b/src/components/messaging/Chat.tsx index 3140ff2..d44e2de 100644 --- a/src/components/messaging/Chat.tsx +++ b/src/components/messaging/Chat.tsx @@ -11,15 +11,11 @@ import { ParentProps, Show, splitProps, - Switch, + Switch } from "solid-js"; -import type { Message, MessageReference } from "../../types/message"; -import { getApi } from "../../api/Api"; -import MessageGrouper, { - authorDefault, - type MessageGroup, - type MessageDivider, -} from "../../api/MessageGrouper"; +import type {Message, MessageReference} from "../../types/message"; +import {getApi} from "../../api/Api"; +import MessageGrouper, {authorDefault, type MessageGroup, type MessageDivider} from "../../api/MessageGrouper"; import { displayName, extendedColor, @@ -32,40 +28,37 @@ import { humanizeTimestamp, mapIterator, snowflakes, - uuid, + uuid } from "../../utils"; import TypingKeepAlive from "../../api/TypingKeepAlive"; import tooltip from "../../directives/tooltip"; -import Icon, { IconElement } from "../icons/Icon"; +import Icon, {IconElement} from "../icons/Icon"; import Clipboard from "../icons/svg/Clipboard"; import PaperPlaneTop from "../icons/svg/PaperPlaneTop"; -import { DynamicMarkdown } from "./Markdown"; -import type { DmChannel, GuildChannel } from "../../types/channel"; +import {DynamicMarkdown} from "./Markdown"; +import type {DmChannel, GuildChannel} from "../../types/channel"; import Fuse from "fuse.js"; -import { gemoji } from "gemoji"; -import { User } from "../../types/user"; +import {gemoji} from 'gemoji' +import {User} from "../../types/user"; import Plus from "../icons/svg/Plus"; import Hashtag from "../icons/svg/Hashtag"; import Trash from "../icons/svg/Trash"; import useContextMenu from "../../hooks/useContextMenu"; -import ContextMenu, { - ContextMenuButton, - DangerContextMenuButton, -} from "../ui/ContextMenu"; -import { toast } from "solid-toast"; +import ContextMenu, {ContextMenuButton, DangerContextMenuButton} from "../ui/ContextMenu"; +import {toast} from "solid-toast"; import Code from "../icons/svg/Code"; -import { ExtendedColor, Invite } from "../../types/guild"; +import {ExtendedColor, Invite} from "../../types/guild"; import GuildIcon from "../guilds/GuildIcon"; import UserPlus from "../icons/svg/UserPlus"; -import { joinGuild } from "../../pages/guilds/Invite"; -import { A, useNavigate, useParams } from "@solidjs/router"; +import {joinGuild} from "../../pages/guilds/Invite"; +import {A, useNavigate, useParams} from "@solidjs/router"; import BookmarkFilled from "../icons/svg/BookmarkFilled"; -import { UserFlags } from "../../api/Bitflags"; -import { ReactiveSet } from "@solid-primitives/set"; +import {UserFlags} from "../../api/Bitflags"; +import {ReactiveSet} from "@solid-primitives/set"; import PenToSquare from "../icons/svg/PenToSquare"; -import { getUnicodeEmojiUrl } from "./Emoji"; +import {getUnicodeEmojiUrl} from "./Emoji"; import Users from "../icons/svg/Users"; -import { ModalId, useModal } from "../ui/Modal"; +import {ModalId, useModal} from "../ui/Modal"; import EllipsisVertical from "../icons/svg/EllipsisVertical"; import FaceSmile from "../icons/svg/FaceSmile"; import Reply from "../icons/svg/Reply"; @@ -74,50 +67,46 @@ import EmojiPicker from "./EmojiPicker"; import Spinner from "../icons/svg/Spinner"; import Link from "../icons/svg/Link"; import ArrowDown from "../icons/svg/ArrowDown"; -import { stringifyJSON } from "../../api/parseJSON"; +import {stringifyJSON} from "../../api/parseJSON"; import Reference from "../icons/svg/Reference"; import At from "../icons/svg/At"; -void tooltip; +void tooltip -const CONVEY = "https://convey.adapt.chat"; +const CONVEY = 'https://convey.adapt.chat' type SkeletalData = { - headerWidth: string; - contentLines: string[]; -}; + headerWidth: string, + contentLines: string[], +} function generateSkeletalData(n: number = 10): SkeletalData[] { - const data: SkeletalData[] = []; + const data: SkeletalData[] = [] for (let i = 0; i < n; i++) { - const headerWidth = `${Math.random() * 25 + 25}%`; - const contentLines = []; + const headerWidth = `${Math.random() * 25 + 25}%` + const contentLines = [] - const lines = Math.random() * (Math.random() < 0.2 ? 5 : 2); + const lines = Math.random() * ( + Math.random() < 0.2 ? 5 : 2 + ) for (let j = 0; j < lines; j++) { - contentLines.push(`${Math.random() * 60 + 20}%`); + contentLines.push(`${Math.random() * 60 + 20}%`) } - data.push({ headerWidth, contentLines }); + data.push({ headerWidth, contentLines }) } - return data; + return data } function MessageLoadingSkeleton() { - const skeletalData = generateSkeletalData(); + const skeletalData = generateSkeletalData() return (
{(data: SkeletalData, i) => ( -
+
-
+
{data.contentLines.map((width) => (
))} @@ -126,51 +115,40 @@ function MessageLoadingSkeleton() { )}
- ); + ) } function shouldDisplayImage(filename: string): boolean { - return ["png", "jpg", "jpeg", "gif", "webp", "svg"].some((ext) => - filename.endsWith(ext), - ); + return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].some((ext) => filename.endsWith(ext)) } -function MessageReferencePreview(props: { - reference: MessageReference; - grouper?: MessageGrouper; -}) { - const api = getApi()!; - const [refMsg, setRefMsg] = createSignal(null); +function MessageReferencePreview(props: { reference: MessageReference, grouper?: MessageGrouper }) { + const api = getApi()! + const [refMsg, setRefMsg] = createSignal(null) onMount(async () => { if (props.grouper) { - const loc = await props.grouper.findMessage(props.reference.message_id); + const loc = await props.grouper.findMessage(props.reference.message_id) if (loc) { - const group = props.grouper.groups[loc[0]]; + const group = props.grouper.groups[loc[0]] if (!group.isDivider) { // @ts-ignore - setRefMsg((group as MessageGroup)[loc[1]]); - return; + setRefMsg((group as MessageGroup)[loc[1]]) + return } } } - const response = await api.request( - "GET", - `/channels/${props.reference.channel_id}/messages/${props.reference.message_id}`, - ); - if (response.ok) setRefMsg(response.jsonOrThrow()); - }); - - const author = createMemo( - () => - refMsg()?.author ?? - api.cache!.users.get(refMsg()?.author_id!) ?? - authorDefault(), - ); - const avatar = createMemo(() => api.cache!.avatarOf(refMsg()?.author_id!)); + const response = await api.request('GET', `/channels/${props.reference.channel_id}/messages/${props.reference.message_id}`) + if (response.ok) setRefMsg(response.jsonOrThrow()) + }) + + const author = createMemo(() => + refMsg()?.author ?? api.cache!.users.get(refMsg()?.author_id!) ?? authorDefault() + ) + const avatar = createMemo(() => api.cache!.avatarOf(refMsg()?.author_id!)) const href = props.reference.guild_id ? `/guilds/${props.reference.guild_id}/${props.reference.channel_id}/${props.reference.message_id}` - : `/dms/${props.reference.channel_id}/${props.reference.message_id}`; + : `/dms/${props.reference.channel_id}/${props.reference.message_id}` return ( Unknown message}> - + {displayName(author())} - {refMsg()!.content ? `: ${refMsg()!.content}` : ""} + {refMsg()!.content ? `: ${refMsg()!.content}` : ''} - ); + ) } -const INVITE_REGEX = /https:\/\/adapt\.chat\/invite\/([a-zA-Z0-9]+)/g; +const INVITE_REGEX = /https:\/\/adapt\.chat\/invite\/([a-zA-Z0-9]+)/g export type MessageContentProps = { - message: Message; - grouper?: MessageGrouper; - editing?: ReactiveSet; - largePadding?: boolean; -}; + message: Message, + grouper?: MessageGrouper, + editing?: ReactiveSet, + largePadding?: boolean +} export function MessageContent(props: MessageContentProps) { - const message = () => props.message; - const largePadding = () => props.largePadding; - const navigate = useNavigate(); + const { showModal } = useModal(); + const message = () => props.message + const largePadding = () => props.largePadding + const navigate = useNavigate() - const api = getApi()!; - const [invites, setInvites] = createSignal([]); + const api = getApi()! + const [invites, setInvites] = createSignal([]) onMount(() => { const codes = new Set( - mapIterator( - message().content?.matchAll(INVITE_REGEX) ?? [], - (match) => match[1], - ), - ); - if (codes.size == 0) return; + mapIterator(message().content?.matchAll(INVITE_REGEX) ?? [], (match) => match[1]) + ) + if (codes.size == 0) return let tasks = [...codes].slice(0, 5).map(async (code) => { - const cached = api.cache!.invites.get(code); - if (cached) return cached; + const cached = api.cache!.invites.get(code) + if (cached) return cached - const response = await api.request("GET", `/invites/${code}`); - if (!response.ok) return null; + const response = await api.request('GET', `/invites/${code}`) + if (!response.ok) return null - const invite: Invite = response.jsonOrThrow(); - api.cache!.invites.set(code, invite); - return invite; - }); + const invite: Invite = response.jsonOrThrow() + api.cache!.invites.set(code, invite) + return invite + }) Promise.all(tasks).then((invites) => { - setInvites(invites.filter((invite): invite is Invite => !!invite)); - }); - }); + setInvites(invites.filter((invite): invite is Invite => !!invite)) + }) + }) - let editAreaRef: HTMLDivElement | null = null; + let editAreaRef: HTMLDivElement | null = null const editMessage = async () => { - const editedContent = editAreaRef!.innerText!.trim(); - if (!editedContent) return; + const editedContent = editAreaRef!.innerText!.trim() + if (!editedContent) { + showModal(ModalId.DeleteMessage, message()); + return; + } const msg = { ...message(), content: editedContent, - _nonceState: "pending", + _nonceState: 'pending', } satisfies Message; + props.grouper?.editMessage(msg.id, msg) - props.grouper?.editMessage(msg.id, msg); - - const response = await api.request( - "PATCH", - `/channels/${message().channel_id}/messages/${message().id}`, - { - json: { content: editedContent }, - }, - ); - + const response = await api.request('PATCH', `/channels/${message().channel_id}/messages/${message().id}`, { + json: { content: editedContent } + }) if (!response.ok) { - toast.error( - `Failed to edit message: ${response.errorJsonOrThrow().message}`, - ); - } else { - // remove from editing set once saved - props.editing?.delete(message().id); + toast.error(`Failed to edit message: ${response.errorJsonOrThrow().message}`) } - }; - - // populate edit area when entering edit mode + } createEffect(() => { if (props.editing?.has(message().id)) { - editAreaRef!.innerText = message().content!; - editAreaRef!.focus(); + editAreaRef!.innerText = message().content! + editAreaRef!.focus() } - }); + }) return ( - -
*:nth-last-child(2)]:inline-block message-content-root": - !!message().edited_at, - }} - > - - - - (edited) - - -
-
- } - > + +
*:nth-last-child(2)]:inline-block message-content-root": !!message().edited_at }} + > + + + + (edited) + + +
+
+ }>
- + {(embed) => (
-
+
- + @@ -405,11 +353,7 @@ export function MessageContent(props: MessageContentProps) {
- +
@@ -421,18 +365,13 @@ export function MessageContent(props: MessageContentProps) { {/* Attachments */} {(attachment) => ( -
+
{(() => { - const url = - attachment.id && - CONVEY + - `/attachments/compr/${uuid(attachment.id)}/${attachment.filename}`; + const url = attachment.id && CONVEY + `/attachments/compr/${uuid(attachment.id)}/${attachment.filename}` return shouldDisplayImage(attachment.filename) ? ( {attachment.filename} -
- {humanizeSize(attachment.size)} -
+
{humanizeSize(attachment.size)}
- ); + ) })()}
)} @@ -465,39 +402,24 @@ export function MessageContent(props: MessageContentProps) { {(invite) => (
- +

You've been invited to join a server!

- +
-

- {invite.guild?.name} -

+

{invite.guild?.name}

{invite.guild?.description}

- {invite.guild?.member_count?.total} Member - {invite.guild?.member_count?.total === 1 ? "" : "s"} + {invite.guild?.member_count?.total} Member{invite.guild?.member_count?.total === 1 ? '' : 's'}

- @@ -514,17 +436,14 @@ export function MessageContent(props: MessageContentProps) {

- ); + ) } function getWordAt(str: string, pos: number) { - const left = str.slice(0, pos + 1).search(/\S+$/); - const right = str.slice(pos).search(/\s/); + const left = str.slice(0, pos + 1).search(/\S+$/) + const right = str.slice(pos).search(/\s/) - return [ - right < 0 ? str.slice(left) : str.slice(left, right + pos), - left, - ] as const; + return [right < 0 ? str.slice(left) : str.slice(left, right + pos), left] as const } export enum AutocompleteType { @@ -534,97 +453,74 @@ export enum AutocompleteType { } export interface AutocompleteState { - type: AutocompleteType; - value: string; - selected: number; - data?: any; + type: AutocompleteType, + value: string, + selected: number, + data?: any, } function trueModulo(n: number, m: number) { - return ((n % m) + m) % m; + return ((n % m) + m) % m } -function setSelectionRange( - element: HTMLDivElement, - selectionStart: number, - selectionEnd: number = selectionStart, -) { - const range = document.createRange(); - const selection = window.getSelection(); +function setSelectionRange(element: HTMLDivElement, selectionStart: number, selectionEnd: number = selectionStart) { + const range = document.createRange() + const selection = window.getSelection() - range.setStart(element.childNodes[0], selectionStart); - range.setEnd(element.childNodes[0], selectionEnd); - range.collapse(true); + range.setStart(element.childNodes[0], selectionStart) + range.setEnd(element.childNodes[0], selectionEnd) + range.collapse(true) - selection?.removeAllRanges(); - selection?.addRange(range); + selection?.removeAllRanges() + selection?.addRange(range) } type MessageContextMenuProps = { - message: Message; - guildId?: bigint; - editing?: ReactiveSet; - onReply?: (message: Message) => void; -}; + message: Message, + guildId?: bigint, + editing?: ReactiveSet, + onReply?: (message: Message) => void, +} -function MessageContextMenu({ - message, - guildId, - editing, - onReply, -}: MessageContextMenuProps) { - const api = getApi()!; - const { showModal } = useModal(); - const navigate = useNavigate(); +function MessageContextMenu({ message, guildId, editing, onReply }: MessageContextMenuProps) { + const api = getApi()! + const {showModal} = useModal() + const navigate = useNavigate() const getMessageLink = () => { if (guildId) { - return `https://app.adapt.chat/guilds/${guildId}/${message.channel_id}/${message.id}`; + return `https://app.adapt.chat/guilds/${guildId}/${message.channel_id}/${message.id}` } else { - return `https://app.adapt.chat/dms/${message.channel_id}/${message.id}`; + return `https://app.adapt.chat/dms/${message.channel_id}/${message.id}` } - }; + } - const perms = () => - guildId - ? api.cache!.getClientPermissions(guildId, message.channel_id) - : null; + const perms = () => guildId ? api.cache!.getClientPermissions(guildId, message.channel_id) : null return ( - - onReply!(message)} - /> + + onReply!(message)} /> - api.request( - "PUT", - `/channels/${message.channel_id}/ack/${message.id - BigInt(1)}`, - ) - } + onClick={() => api.request('PUT', `/channels/${message.channel_id}/ack/${message.id - BigInt(1)}`)} /> - toast.promise(navigator.clipboard.writeText(message.content!), { + onClick={() => toast.promise( + navigator.clipboard.writeText(message.content!), + { loading: "Copying message text...", success: "Copied to your clipboard!", error: "Failed to copy message text, try again later.", - }) - } + } + )} /> - toast.promise(navigator.clipboard.writeText(getMessageLink()), { + onClick={() => toast.promise( + navigator.clipboard.writeText(getMessageLink()), + { loading: "Copying message link...", success: "Copied to your clipboard!", error: "Failed to copy message link, try again later.", - }) - } + } + )} /> editing!.add(message.id)} /> - + { if (!event.shiftKey) - return showModal(ModalId.DeleteMessage, message); + return showModal(ModalId.DeleteMessage, message) - const resp = await api.deleteMessage( - message.channel_id, - message.id, - ); + const resp = await api.deleteMessage(message.channel_id, message.id) if (!resp.ok) { - toast.error( - `Failed to delete message: ${resp.errorJsonOrThrow().message}`, - ); + toast.error(`Failed to delete message: ${resp.errorJsonOrThrow().message}`) } }} /> - ); + ) } interface UploadedAttachment { - filename: string; - alt?: string; - file: File; - type: string; - preview?: string; + filename: string + alt?: string + file: File + type: string, + preview?: string, } const timestampTooltip = (timestamp: number | Date) => ({ content: humanizeFullTimestamp(timestamp), delay: [1000, null] as [number, null], - interactive: true, -}); + interactive: true +}) export type MessageHeaderProps = { - mentioned?: boolean; - onContextMenu?: (e: MouseEvent) => any; - authorAvatar?: string; - authorColor?: ExtendedColor | null; - authorName: string; - badge?: string; - timestamp: number | Date; - class?: string; - classList?: Record; - noHoverEffects?: boolean; - quickActions?: ReturnType; + mentioned?: boolean, + onContextMenu?: (e: MouseEvent) => any, + authorAvatar?: string, + authorColor?: ExtendedColor | null, + authorName: string, + badge?: string, + timestamp: number | Date, + class?: string, + classList?: Record, + noHoverEffects?: boolean, + quickActions?: ReturnType, referencesProvider?: { - references: MessageReference[]; - grouper: MessageGrouper; - }; -}; + references: MessageReference[], + grouper: MessageGrouper, + }, +} export function MessageHeader(props: ParentProps) { return (
) {
{props.quickActions} ) { alt="" />
- + {props.authorName} - - {props.badge} - + {props.badge} ) { {props.children}
- ); + ) } export function MessagePreview( - props: { - message: Message; - guildId?: bigint; - onReply?: (message: Message) => void; - } & Partial & - MessageContentProps, + props: { message: Message, guildId?: bigint, onReply?: (message: Message) => void } & Partial & MessageContentProps ) { - const api = getApi()!; - const contextMenu = useContextMenu()!; - const message = () => props.message; + const api = getApi()! + const contextMenu = useContextMenu()! + const message = () => props.message // if no guild id is provided, try resolving one - const guildId = createMemo( - () => - props.guildId ?? - (api.cache!.channels.get(message().channel_id) as GuildChannel)?.guild_id, - ); - - const author = createMemo( - () => - message().author ?? - api.cache!.users.get(message().author_id!) ?? - authorDefault(), - ); - const authorColor = - guildId() && message().author_id - ? api.cache!.getMemberColor(guildId(), message().author_id!) - : undefined; - - const [contentProps, rest] = splitProps(props, [ - "message", - "grouper", - "editing", - "largePadding", - ]); - const [, headerProps] = splitProps(rest, ["guildId"]); + const guildId = createMemo(() => + props.guildId ?? (api.cache!.channels.get(message().channel_id) as GuildChannel)?.guild_id + ) + + const author = createMemo(() => + message().author ?? api.cache!.users.get(message().author_id!) ?? authorDefault() + ) + const authorColor = guildId() && message().author_id + ? api.cache!.getMemberColor(guildId(), message().author_id!) + : undefined + + const [contentProps, rest] = splitProps(props, ['message', 'grouper', 'editing', 'largePadding']) + const [, headerProps] = splitProps(rest, ['guildId']) return ( , + )} authorAvatar={api.cache!.avatarOf(message().author_id!)} authorColor={authorColor} authorName={displayName(author())} - badge={UserFlags.fromValue(author().flags).has("BOT") ? "BOT" : undefined} + badge={UserFlags.fromValue(author().flags).has('BOT') ? 'BOT' : undefined} timestamp={snowflakes.timestamp(message().id)} - referencesProvider={ - message().references && - contentProps.grouper && { - references: message().references, - grouper: contentProps.grouper, - } + referencesProvider={message().references + && contentProps.grouper + && { references: message().references, grouper: contentProps.grouper } } {...headerProps} > - ); + ) } -function QuickActionButton({ - icon, - tooltip: tt, - ...props -}: { - icon: IconElement; - tooltip: string; -} & JSX.ButtonHTMLAttributes) { +function QuickActionButton( + { icon, tooltip: tt, ...props }: { icon: IconElement, tooltip: string } & JSX.ButtonHTMLAttributes +) { return ( - ); + ) } type QuickActionsProps = { - message: Message; - offset?: number; - guildId?: bigint; - grouper: MessageGrouper; - editing?: ReactiveSet; - onReply: (message: Message) => void; -}; + message: Message, + offset?: number, + guildId?: bigint, + grouper: MessageGrouper, + editing?: ReactiveSet, + onReply: (message: Message) => void, +} function QuickActions(props: QuickActionsProps) { - const contextMenu = useContextMenu(); + const contextMenu = useContextMenu() const permissions = createMemo(() => - props.guildId - ? getApi()?.cache?.getClientPermissions( - props.guildId, - props.message.channel_id, - ) - : null, - ); + props.guildId ? getApi()?.cache?.getClientPermissions(props.guildId, props.message.channel_id) : null + ) return ( - ); + ) } type SubsequentMessageProps = { - message: Message; - guildId?: bigint; - grouper: MessageGrouper; - onReply: (message: Message) => void; - editing?: ReactiveSet; - contentProps: Partial; + message: Message, + guildId?: bigint, + grouper: MessageGrouper, + onReply: (message: Message) => void, + editing?: ReactiveSet, + contentProps: Partial, }; function SubsequentMessage(props: SubsequentMessageProps) { - const api = getApi()!; - const contextMenu = useContextMenu(); + const api = getApi()! + const contextMenu = useContextMenu() return (
, + )} > - +
- ); + ) } type MessageGroupViewProps = { - group: MessageGroup; - guildId?: bigint; - grouper: MessageGrouper; - onReply: (message: Message) => void; - editing: ReactiveSet; - contentProps: Partial; -}; + group: MessageGroup, + guildId?: bigint, + grouper: MessageGrouper, + onReply: (message: Message) => void, + editing: ReactiveSet, + contentProps: Partial, +} function MessageGroupView(props: MessageGroupViewProps) { - const group = () => props.group; - if (group().isDivider) - return ( - - ); + const group = () => props.group + if (group().isDivider) return ( + + ) return (
- - {(message, i) => - i() === 0 ? ( - - } - {...props.contentProps} - /> - ) : ( - - ) - } + + {(message, i) => i() === 0 ? ( + + } + {...props.contentProps} + /> + ) : ( + + )}
- ); + ) } -export default function Chat(props: { - channelId: bigint; - guildId?: bigint; - title: string; - startMessage: JSX.Element; -}) { - const api = getApi()!; - const contextMenu = useContextMenu()!; - const params = useParams(); - const navigate = useNavigate(); - const messageId = createMemo(() => - params.messageId ? BigInt(params.messageId) : null, - ); - - const [messageInputFocused, setMessageInputFocused] = createSignal(false); - const [messageInputFocusTimeout, setMessageInputFocusTimeout] = createSignal< - number | null - >(null); - const [loading, setLoading] = createSignal(true); - const [autocompleteState, setAutocompleteState] = - createSignal(null); - const [uploadedAttachments, setUploadedAttachments] = createSignal< - UploadedAttachment[] - >([]); - const [replyingTo, setReplyingTo] = createSignal< - { message: Message; mentionAuthor: boolean }[] - >([]); - const [sendable, setSendable] = createSignal(false); - const [emojiPickerVisible, setEmojiPickerVisible] = createSignal(false); - let emojiPickerRef: HTMLDivElement | undefined; - let emojiToggleRef: HTMLButtonElement | undefined; - let messageInputRef: HTMLDivElement | undefined; - let messageAreaRef: HTMLDivElement | undefined; +export default function Chat(props: { channelId: bigint, guildId?: bigint, title: string, startMessage: JSX.Element }) { + const api = getApi()! + const contextMenu = useContextMenu()! + const params = useParams() + const navigate = useNavigate() + const messageId = createMemo(() => params.messageId ? BigInt(params.messageId) : null) + + const [messageInputFocused, setMessageInputFocused] = createSignal(false) + const [messageInputFocusTimeout, setMessageInputFocusTimeout] = createSignal(null) + const [loading, setLoading] = createSignal(true) + const [autocompleteState, setAutocompleteState] = createSignal(null) + const [uploadedAttachments, setUploadedAttachments] = createSignal([]) + const [replyingTo, setReplyingTo] = createSignal<{message: Message, mentionAuthor: boolean}[]>([]) + const [sendable, setSendable] = createSignal(false) + const [emojiPickerVisible, setEmojiPickerVisible] = createSignal(false) + let emojiPickerRef: HTMLDivElement | undefined + let emojiToggleRef: HTMLButtonElement | undefined + let messageInputRef: HTMLDivElement | undefined + let messageAreaRef: HTMLDivElement | undefined const [showViewNewerMessages, setShowViewNewerMessages] = createSignal(false); const [scrollingToBottom, setScrollingToBottom] = createSignal(false); const [messageContextDistance, setMessageContextDistance] = createSignal(0); const [lastScrollPosition, setLastScrollPosition] = createSignal(0); - const updateSendable = () => - setSendable( - !!messageInputRef?.innerText?.trim() || uploadedAttachments().length > 0, - ); + const updateSendable = () => setSendable(!!messageInputRef?.innerText?.trim() || uploadedAttachments().length > 0) const addReply = (message: Message) => { - setReplyingTo((prev) => - prev.some(({ message: m }) => m.id === message.id) - ? prev - : [...prev, { message, mentionAuthor: false }], - ); - messageInputRef?.focus(); - }; - const removeReply = (id: bigint) => - setReplyingTo((prev) => prev.filter(({ message: m }) => m.id !== id)); + setReplyingTo(prev => prev.some(({ message: m }) => m.id === message.id) ? prev : [...prev, {message, mentionAuthor: false}]) + messageInputRef?.focus() + } + const removeReply = (id: bigint) => setReplyingTo(prev => prev.filter(({ message: m }) => m.id !== id)) const setMentionAuthor = (message: Message, mentionAuthor: boolean) => { - setReplyingTo((prev) => - prev.map(({ message: m, mentionAuthor: ma }) => - m.id === message.id - ? { message: m, mentionAuthor } - : { message: m, mentionAuthor: ma }, - ), - ); - }; + setReplyingTo(prev => prev.map(({ message: m, mentionAuthor: ma }) => + m.id === message.id ? { message: m, mentionAuthor } : { message: m, mentionAuthor: ma } + )) + } - const mobile = - /Android|webOS|iPhone|iP[ao]d|BlackBerry|IEMobile|Opera Mini/i.test( - navigator.userAgent, - ); + const mobile = /Android|webOS|iPhone|iP[ao]d|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) const grouper = createMemo(() => { - const { grouper, cached } = getApi()!.cache!.useChannelMessages( - props.channelId, - ); - setLoading(!cached); + const { grouper, cached } = getApi()!.cache!.useChannelMessages(props.channelId) + setLoading(!cached) - if (!cached) grouper.fetchMessages().then(() => setLoading(false)); - return grouper; - }); + if (!cached) + grouper.fetchMessages().then(() => setLoading(false)) + return grouper + }) - const typing = createMemo(() => api.cache!.useTyping(props.channelId)); - const typingKeepAlive = new TypingKeepAlive(api, props.channelId); - const editing = new ReactiveSet(); + const typing = createMemo(() => api.cache!.useTyping(props.channelId)) + const typingKeepAlive = new TypingKeepAlive(api, props.channelId) + const editing = new ReactiveSet() const focusListener = (e: KeyboardEvent) => { - const charCode = e.key.charCodeAt(0); - if ( - document.activeElement == document.body && - ((e.key.length == 1 && - charCode >= 32 && - charCode <= 126 && - !e.ctrlKey && - !e.altKey && - !e.metaKey) || - ((e.ctrlKey || e.metaKey) && e.key == "v")) - ) { - messageInputRef!.focus(); + const charCode = e.key.charCodeAt(0) + if (document.activeElement == document.body && ( + e.key.length == 1 + && charCode >= 32 && charCode <= 126 + && !e.ctrlKey && !e.altKey && !e.metaKey + || (e.ctrlKey || e.metaKey) && e.key == 'v' + )) { + messageInputRef!.focus() } - }; + } const hideEmojiPicker = (e: MouseEvent) => { if ( - emojiPickerVisible() && - emojiPickerRef && - !emojiPickerRef.contains(e.target as Node) && - !emojiToggleRef?.contains(e.target as Node) + emojiPickerVisible() + && emojiPickerRef + && !emojiPickerRef.contains(e.target as Node) + && !emojiToggleRef?.contains(e.target as Node) ) { - setEmojiPickerVisible(false); + setEmojiPickerVisible(false) } - }; + } onMount(() => { - document.addEventListener("keydown", focusListener); - document.addEventListener("click", hideEmojiPicker); - }); + document.addEventListener('keydown', focusListener) + document.addEventListener('click', hideEmojiPicker) + }) onCleanup(async () => { - document.removeEventListener("keydown", focusListener); - document.removeEventListener("click", hideEmojiPicker); - await typingKeepAlive.stop(); - }); + document.removeEventListener('keydown', focusListener) + document.removeEventListener('click', hideEmojiPicker) + await typingKeepAlive.stop() + }) const createMessage = async () => { if (!sendable()) return; - const content = messageInputRef!.innerText!.trim(); - const attachments = uploadedAttachments(); - const references = replyingTo(); + const content = messageInputRef!.innerText!.trim() + const attachments = uploadedAttachments() + const references = replyingTo() - setUploadedAttachments([]); - setReplyingTo([]); - setSendable(false); + setUploadedAttachments([]) + setReplyingTo([]) + setSendable(false) // Clear the message input without invalidating undo history - messageInputRef!.focus(); - document.execCommand("selectAll", false); - document.execCommand("insertHTML", false, ""); + messageInputRef!.focus() + document.execCommand('selectAll', false) + document.execCommand('insertHTML', false, '') const refs = references.map(({ message: r, mentionAuthor }) => ({ channel_id: r.channel_id, - guild_id: - (api.cache?.channels.get(r.channel_id) as GuildChannel | undefined) - ?.guild_id ?? null, + guild_id: (api.cache?.channels.get(r.channel_id) as GuildChannel | undefined)?.guild_id ?? null, mention_author: mentionAuthor, message_id: r.id, - })); + })) let mockMessage = { id: snowflakes.fromTimestamp(Date.now()), - type: "default", + type: 'default', content, author_id: api.cache!.clientUser!.id, - attachments: attachments.map((attachment) => ({ + attachments: attachments.map(attachment => ({ _imageOverride: attachment.preview, filename: attachment.filename, alt: attachment.alt, size: attachment.file.size, })), - _nonceState: "pending", + _nonceState: 'pending', ...grouper().nonceDefault, references: refs, - } as Message; + } as Message - const nonce = mockMessage.id.toString(); - const idx = grouper().pushMessage(mockMessage); - grouper().nonced.set(nonce, idx); - messageAreaRef!.scrollTo(0, messageAreaRef!.scrollHeight); + const nonce = mockMessage.id.toString() + const idx = grouper().pushMessage(mockMessage) + grouper().nonced.set(nonce, idx) + messageAreaRef!.scrollTo(0, messageAreaRef!.scrollHeight) try { - const json = { content, nonce, references: refs }; + const json = { content, nonce, references: refs } let options; if (attachments.length > 0) { - const formData = new FormData(); - formData.append("json", stringifyJSON(json)); + const formData = new FormData() + formData.append('json', stringifyJSON(json)); for (const [i, attachment] of Object.entries(attachments)) { - formData.append("file" + i, attachment.file, attachment.filename); + formData.append('file' + i, attachment.file, attachment.filename) } - options = { multipart: formData }; + options = { multipart: formData } } else { - options = { json }; + options = { json } } - const response = await api.request( - "POST", - `/channels/${props.channelId}/messages`, - options, - ); - void typingKeepAlive.stop(); + const response = await api.request('POST', `/channels/${props.channelId}/messages`, options) + void typingKeepAlive.stop() if (!response.ok) - grouper().ackNonceError( - nonce, - mockMessage, - response.errorJsonOrThrow().message, - ); + grouper().ackNonceError(nonce, mockMessage, response.errorJsonOrThrow().message) } catch (e: any) { - grouper().ackNonceError(nonce, mockMessage, e); - throw e; + grouper().ackNonceError(nonce, mockMessage, e) + throw e } - }; + } const MAPPING = [ - ["@", AutocompleteType.UserMention], - ["#", AutocompleteType.ChannelMention], - [":", AutocompleteType.Emoji], - ] as const; - let caretPosition = 0; + ['@', AutocompleteType.UserMention], + ['#', AutocompleteType.ChannelMention], + [':', AutocompleteType.Emoji], + ] as const + let caretPosition = 0 const cacheCaretPosition = () => { - const sel = window.getSelection(); - if ( - sel?.rangeCount && - messageInputRef && - messageInputRef.contains(sel.anchorNode) - ) { - const range = sel.getRangeAt(0); - const preRange = range.cloneRange(); - preRange.selectNodeContents(messageInputRef); - preRange.setEnd(range.endContainer, range.endOffset); - caretPosition = preRange.toString().length; + const sel = window.getSelection() + if (sel?.rangeCount && messageInputRef && messageInputRef.contains(sel.anchorNode)) { + const range = sel.getRangeAt(0) + const preRange = range.cloneRange() + preRange.selectNodeContents(messageInputRef) + preRange.setEnd(range.endContainer, range.endOffset) + caretPosition = preRange.toString().length } - }; - let autocompleteTimeout: number | undefined; + } + let autocompleteTimeout: number | undefined const updateAutocompleteState = () => { - const [currentWord, index] = getWordAt( - messageInputRef?.innerText!, - caretPosition - 1, - ); + const [currentWord, index] = getWordAt(messageInputRef?.innerText!, caretPosition - 1) for (const [char, type] of MAPPING) { if (currentWord.startsWith(char)) { setAutocompleteState({ @@ -1259,187 +1027,153 @@ export default function Chat(props: { value: currentWord.slice(1), selected: 0, data: { index }, - }); - return; + }) + return } } - setAutocompleteState(null); - }; + setAutocompleteState(null) + } const scheduleAutocompleteUpdate = () => { - clearTimeout(autocompleteTimeout); - autocompleteTimeout = window.setTimeout(updateAutocompleteState, 50); - }; + clearTimeout(autocompleteTimeout) + autocompleteTimeout = window.setTimeout(updateAutocompleteState, 50) + } const handleCaretUpdate = () => { - cacheCaretPosition(); - scheduleAutocompleteUpdate(); - }; + cacheCaretPosition() + scheduleAutocompleteUpdate() + } const members = createMemo(() => { - const cache = api.cache; - if (!cache) return []; - - const m = - (props.guildId - ? cache.memberReactor.get(props.guildId)?.map((u) => cache.users.get(u)) - : ( - cache.channels.get(props.channelId) as DmChannel | null - )?.recipient_ids.map((u) => cache.users.get(u))) ?? []; - return m.filter((u): u is User => !!u); - }); - const fuseMemberIndex = createMemo( - () => - new Fuse(members()!, { - keys: ["username", "display_name"], // TODO: nickname - }), - ); - - const permissions = createMemo(() => - props.guildId - ? api.cache!.getClientPermissions(props.guildId, props.channelId) - : null, - ); - const canSendMessages = createMemo( - () => !permissions() || permissions()!.has("SEND_MESSAGES"), - ); + const cache = api.cache + if (!cache) return [] + + const m = ( + props.guildId + ? cache.memberReactor.get(props.guildId)?.map(u => cache.users.get(u)) + : (cache.channels.get(props.channelId) as DmChannel | null)?.recipient_ids.map(u => cache.users.get(u)) + ) ?? [] + return m.filter((u): u is User => !!u) + }) + const fuseMemberIndex = createMemo(() => new Fuse(members()!, { + keys: ['username', 'display_name'], // TODO: nickname + })) + + const permissions = createMemo(() => props.guildId ? api.cache!.getClientPermissions(props.guildId, props.channelId) : null) + const canSendMessages = createMemo(() => !permissions() || permissions()!.has('SEND_MESSAGES')) const recipientId = createMemo(() => { - if (props.guildId) return null; - const ids = (api?.cache?.channels.get(props.channelId) as DmChannel | null) - ?.recipient_ids as any; - return ids[0] == api?.cache?.clientId ? ids[1] : ids[0]; - }); + if (props.guildId) return null + const ids = (api?.cache?.channels.get(props.channelId) as DmChannel | null)?.recipient_ids as any + return ids[0] == api?.cache?.clientId ? ids[1] : ids[0] + }) const channels = createMemo(() => { - const cache = api.cache; - if (!cache) return []; + const cache = api.cache + if (!cache) return [] // TODO: filter by permissions if (props.guildId) { - return [...(cache.guilds.get(props.guildId)?.channels?.values() ?? [])]; + return [...cache.guilds.get(props.guildId)?.channels?.values() ?? []] } else { - const mutualGuildIds = cache.userGuilds.get(recipientId()!) ?? []; + const mutualGuildIds = cache.userGuilds.get(recipientId()!) ?? [] return [ - ...flatMapIterator(mutualGuildIds, (guildId) => { - const guild = cache.guilds.get(guildId); - return ( - guild?.channels?.map( - (channel) => - ({ - ...channel, - key: `${channel.name}:${guild.name}`, - }) as unknown as GuildChannel, - ) ?? [] - ); - }), - ]; + ...flatMapIterator( + mutualGuildIds, + (guildId) => { + const guild = cache.guilds.get(guildId) + return ( + guild?.channels + ?.map(channel => ({...channel, key: `${channel.name}:${guild.name}`}) as unknown as GuildChannel) ?? [] + ) + } + ), + ] } - }); - const fuseChannelIndex = createMemo( - () => - new Fuse(channels()!, { - keys: ["key", "name"], - }), - ); + }) + const fuseChannelIndex = createMemo(() => new Fuse(channels()!, { + keys: ['key', 'name'], + })) const externalAllowedFrom = createMemo(() => { - if (!props.guildId || permissions()?.has("USE_EXTERNAL_EMOJIS")) - return api.cache?.guildList ?? []; + if (!props.guildId || permissions()?.has('USE_EXTERNAL_EMOJIS')) + return api.cache?.guildList ?? [] - return [props.guildId]; - }); + return [props.guildId] + }) const emojis = createMemo(() => { - const unicode = gemoji.flatMap(({ names, emoji, category }) => - names.map((name) => ({ - name, - emoji, - url: getUnicodeEmojiUrl(emoji), - category, - })), + const unicode = gemoji.flatMap( + ({names, emoji, category}) => names.map(name => ({ + name, emoji, url: getUnicodeEmojiUrl(emoji), category + })) ); - const allowed = externalAllowedFrom(); + const allowed = externalAllowedFrom() const custom = filterMapIterator( api.cache!.customEmojis.values(), - (emoji) => - allowed.includes(emoji.guild_id) - ? { - name: emoji.name, - emoji: `:${emoji.id}:`, - url: `https://convey.adapt.chat/emojis/${emoji.id}`, - category: api.cache!.guilds.get(emoji.guild_id)?.name ?? "Custom", - data: emoji, - } - : null, - ); - return [...unicode, ...custom]; - }); - const fuseEmojiIndex = createMemo( - () => new Fuse(emojis(), { keys: ["name"] }), - ); + (emoji) => allowed.includes(emoji.guild_id) ? { + name: emoji.name, + emoji: `:${emoji.id}:`, + url: `https://convey.adapt.chat/emojis/${emoji.id}`, + category: api.cache!.guilds.get(emoji.guild_id)?.name ?? 'Custom', + data: emoji, + } : null + ) + return [...unicode, ...custom] + }) + const fuseEmojiIndex = createMemo(() => new Fuse(emojis(), { keys: ['name'] })) const setAutocompleteSelection = (index: number) => { - setAutocompleteState((prev) => ({ + setAutocompleteState(prev => ({ ...prev!, selected: trueModulo(index, autocompleteResult()?.length || 1), - })); - }; - const fuse = function ( - value: string, - index: Accessor>, - fallback: Accessor, - ) { + })) + } + const fuse = function(value: string, index: Accessor>, fallback: Accessor) { return value - ? index() - ?.search(value) - .slice(0, 5) - .map((result) => result.item) - : fallback().slice(0, 5); - }; + ? index()?.search(value).slice(0, 5).map(result => result.item) + : fallback().slice(0, 5) + } const autocompleteResult = createMemo(() => { - const state = autocompleteState(); - if (!state) return; + const state = autocompleteState() + if (!state) return - const { type, value } = state; + const { type, value } = state switch (type) { - case AutocompleteType.UserMention: - return fuse(value, fuseMemberIndex, members); - case AutocompleteType.ChannelMention: - return fuse(value, fuseChannelIndex, channels); - case AutocompleteType.Emoji: - return fuse(value, fuseEmojiIndex, () => []); + case AutocompleteType.UserMention: return fuse(value, fuseMemberIndex, members) + case AutocompleteType.ChannelMention: return fuse(value, fuseChannelIndex, channels) + case AutocompleteType.Emoji: return fuse(value, fuseEmojiIndex, () => []) } - }); + }) const executeAutocomplete = (index?: number) => { - const result = autocompleteResult(); + const result = autocompleteResult() if (!result?.length) { - return void setAutocompleteState(null); + return void setAutocompleteState(null) } - const { type, value, selected } = autocompleteState()!; + const { type, value, selected } = autocompleteState()! const replace = (repl: string) => { - const { index: wordIndex } = autocompleteState()!.data!; + const { index: wordIndex } = autocompleteState()!.data! - const text = messageInputRef!.innerText!; - const before = text.slice(0, wordIndex) + repl; - const after = text.slice(wordIndex + value.length + 1); - messageInputRef!.innerText = before + after; + const text = messageInputRef!.innerText! + const before = text.slice(0, wordIndex) + repl + const after = text.slice(wordIndex + value.length + 1) + messageInputRef!.innerText = before + after - messageInputRef!.focus(); - setSelectionRange(messageInputRef!, before.length); - }; + messageInputRef!.focus() + setSelectionRange(messageInputRef!, before.length) + } switch (type) { case AutocompleteType.UserMention: case AutocompleteType.ChannelMention: { - const target = result[index ?? selected] as User | GuildChannel; - const symbol = MAPPING.find(([_, ty]) => type === ty)![0]; - replace(`<${symbol}${target.id}>`); - break; + const target = result[index ?? selected] as User | GuildChannel + const symbol = MAPPING.find(([_, ty]) => type === ty)![0] + replace(`<${symbol}${target.id}>`) + break } case AutocompleteType.Emoji: - replace((result[index ?? selected] as { emoji: string }).emoji); - break; + replace((result[index ?? selected] as { emoji: string }).emoji) + break } - setAutocompleteState(null); - }; + setAutocompleteState(null) + } const StandardAutocompleteEntry = (props: ParentProps<{ idx: number }>) => (
{props.children}
- ); + ) - let [lastAckedId, setLastAckedId] = createSignal(null); + let [lastAckedId, setLastAckedId] = createSignal(null) const ack = async () => { - const last = api.cache?.lastMessages.get(props.channelId); - if (!last || lastAckedId() == last?.id) return; + const last = api.cache?.lastMessages.get(props.channelId) + if (!last || lastAckedId() == last?.id) return - let { id, author_id: authorId } = last as Message; - if (authorId == api.cache?.clientId) return; + let { id, author_id: authorId } = last as Message + if (authorId == api.cache?.clientId) return - setLastAckedId(id); - await api.request("PUT", `/channels/${props.channelId}/ack/${id}`); - }; + setLastAckedId(id) + await api.request('PUT', `/channels/${props.channelId}/ack/${id}`) + } - const contentProps = () => ({ grouper: grouper(), editing }); + const contentProps = () => ({ grouper: grouper(), editing }) // message jump handler createEffect(async () => { - const targetId = messageId(); - if (!targetId || !messageAreaRef) return; - if (loading()) return; - - let messageElement = messageAreaRef.querySelector( - `[data-message-id="${targetId}"]`, - ); + const targetId = messageId() + if (!targetId || !messageAreaRef) return + if (loading()) return + let messageElement = messageAreaRef.querySelector(`[data-message-id="${targetId}"]`) + // If not found in DOM and grouper is available, try to find and load the message if (!messageElement) { - const indices = await grouper().findMessage(targetId); - if (!indices) return; - + const indices = await grouper().findMessage(targetId) + if (!indices) return + // Check again after finding the message - messageElement = messageAreaRef.querySelector( - `[data-message-id="${targetId}"]`, - ); - if (!messageElement) return; + messageElement = messageAreaRef.querySelector(`[data-message-id="${targetId}"]`) + if (!messageElement) return } - messageElement.scrollIntoView({ behavior: "smooth", block: "center" }); - + messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' }) + // highlight the message - const messageContainer = messageElement.closest(".group"); + const messageContainer = messageElement.closest('.group') if (messageContainer) { - messageContainer.classList.add("bg-accent/10"); + messageContainer.classList.add('bg-accent/10') setTimeout(() => { - messageContainer.classList.remove("bg-accent/10"); - - if (props.guildId) - navigate(`/guilds/${props.guildId}/${props.channelId}`, { - replace: true, - }); - else navigate(`/dms/${props.channelId}`, { replace: true }); - }, 3000); + messageContainer.classList.remove('bg-accent/10') + + if (props.guildId) + navigate(`/guilds/${props.guildId}/${props.channelId}`, { replace: true }) + else + navigate(`/dms/${props.channelId}`, { replace: true }) + }, 3000) } - }); + }) const handleEmojiSelect = (emoji: string) => { - if (!messageInputRef) return; + if (!messageInputRef) return if (document.activeElement !== messageInputRef) { - messageInputRef.focus(); + messageInputRef.focus() } // Insert emoji at current cursor position - const selection = window.getSelection(); - const range = selection?.getRangeAt(0); - + const selection = window.getSelection() + const range = selection?.getRangeAt(0) + if (range && messageInputRef) { // Create a text node with the emoji - const textNode = document.createTextNode(emoji); - range.insertNode(textNode); - + const textNode = document.createTextNode(emoji) + range.insertNode(textNode) + // Move cursor after the inserted emoji - range.setStartAfter(textNode); - range.setEndAfter(textNode); - selection?.removeAllRanges(); - selection?.addRange(range); - - messageInputRef.focus(); - updateSendable(); + range.setStartAfter(textNode) + range.setEndAfter(textNode) + selection?.removeAllRanges() + selection?.addRange(range) + + messageInputRef.focus() + updateSendable() // Close the emoji picker if shift is not held - const event = window.event as MouseEvent; + const event = window.event as MouseEvent if (!event || !event.shiftKey) { - setEmojiPickerVisible(false); + setEmojiPickerVisible(false) } } - }; + } return ( - } - > + + You do not have permission to send messages in this channel. +
+ }>
-
@@ -1818,41 +1504,35 @@ export default function Chat(props: { class="w-9 h-9 flex flex-shrink-0 items-center justify-center rounded-full bg-3 mr-2 transition-all duration-200 hover:bg-accent" onClick={() => { // Upload attachment - const input = document.createElement("input"); - input.type = "file"; - input.multiple = true; - input.accept = "*"; - input.addEventListener("change", async () => { - const files = input.files; - if (!files) return; - - const uploaded = await Promise.all( - Array.from(files, async (file) => { - const attachment: UploadedAttachment = { - filename: file.name, - type: file.type, - file, - }; - if (file.type.startsWith("image/")) { - // show a preview of this image - attachment.preview = URL.createObjectURL(file); - } - return attachment; - }), - ); - setUploadedAttachments((prev) => [...prev, ...uploaded]); - updateSendable(); - }); - input.click(); - messageInputRef?.focus(); + const input = document.createElement('input') + input.type = 'file' + input.multiple = true + input.accept = '*' + input.addEventListener('change', async () => { + const files = input.files + if (!files) return + + const uploaded = await Promise.all(Array.from(files, async (file) => { + const attachment: UploadedAttachment = { + filename: file.name, + type: file.type, + file, + } + if (file.type.startsWith('image/')) { + // show a preview of this image + attachment.preview = URL.createObjectURL(file) + } + return attachment + })) + setUploadedAttachments(prev => [...prev, ...uploaded]) + updateSendable() + }) + input.click() + messageInputRef?.focus() }} use:tooltip="Upload" > - +
{({ message: msg, mentionAuthor }) => { - const icon = - msg.author?.avatar ?? api.cache!.avatarOf(msg.author_id!); - const name = displayName( - msg.author ?? - api.cache!.users.get(msg.author_id!) ?? - authorDefault(), - ); + const icon = msg.author?.avatar ?? api.cache!.avatarOf(msg.author_id!) + const name = displayName(msg.author ?? api.cache!.users.get(msg.author_id!) ?? authorDefault()) return (
- {name} + {name} {name} - {msg.content ? `: ${msg.content}` : ""} + {msg.content ? `: ${msg.content}` : ''} -
- ); + ) }}
@@ -1927,10 +1584,8 @@ export default function Chat(props: {
{attachment.preview ? ( - {attachment.filename} + {attachment.filename} ) : ( {attachment.type || attachment.filename} @@ -1950,12 +1601,9 @@ export default function Chat(props: { )}
-

- {attachment.filename} -

+

{attachment.filename}

- {humanizeSize(attachment.file.size)}{" "} - {attachment.alt && <> - {attachment.alt}} + {humanizeSize(attachment.file.size)} {attachment.alt && <> - {attachment.alt}}
@@ -1963,13 +1611,7 @@ export default function Chat(props: { 1} keyed={false}>
- ={" "} - {humanizeSize( - uploadedAttachments().reduce( - (acc, cur) => acc + cur.file.size, - 0, - ), - )} + = {humanizeSize(uploadedAttachments().reduce((acc, cur) => acc + cur.file.size, 0))}
@@ -1983,105 +1625,90 @@ export default function Chat(props: { spellcheck={false} // Paste listener for attachments onPaste={async (event) => { - event.preventDefault(); - - const types = event.clipboardData?.types; - if (types?.includes("Files")) { - const files = event.clipboardData?.files; - if (!files) return; - - const uploaded = await Promise.all( - Array.from(files, async (file) => { - const attachment: UploadedAttachment = { - filename: file.name, - type: file.type, - file, - }; - if (file.type.startsWith("image/")) { - // show a preview of this image - attachment.preview = URL.createObjectURL(file); - } - return attachment; - }), - ); - setUploadedAttachments((prev) => [...prev, ...uploaded]); + event.preventDefault() + + const types = event.clipboardData?.types + if (types?.includes('Files')) { + const files = event.clipboardData?.files + if (!files) return + + const uploaded = await Promise.all(Array.from(files, async (file) => { + const attachment: UploadedAttachment = { + filename: file.name, + type: file.type, + file, + } + if (file.type.startsWith('image/')) { + // show a preview of this image + attachment.preview = URL.createObjectURL(file) + } + return attachment + })) + setUploadedAttachments(prev => [...prev, ...uploaded]) } - if (types?.includes("text/plain")) { - const text = event.clipboardData?.getData("text/plain"); - if (!text) return; + if (types?.includes('text/plain')) { + const text = event.clipboardData?.getData('text/plain') + if (!text) return - document.execCommand("insertText", false, text); + document.execCommand('insertText', false, text) } - updateSendable(); + updateSendable() }} onKeyUp={(event) => { - const oldState = autocompleteState(); + const oldState = autocompleteState() if (oldState) - if (event.key === "ArrowUp") { - return setAutocompleteSelection(oldState.selected - 1); - } else if (event.key === "ArrowDown") { - return setAutocompleteSelection(oldState.selected + 1); + if (event.key === 'ArrowUp') { + return setAutocompleteSelection(oldState.selected - 1) + } else if (event.key === 'ArrowDown') { + return setAutocompleteSelection(oldState.selected + 1) } - handleCaretUpdate(); + handleCaretUpdate() }} onKeyDown={(event) => { - const oldState = autocompleteState(); - if ( - oldState && - (event.key === "ArrowUp" || event.key === "ArrowDown") - ) - event.preventDefault(); - else if ( - event.key === "ArrowUp" && - !event.currentTarget.innerText?.trim() - ) { - event.preventDefault(); - const lastMessage = grouper().latestMessageWhere( - (m) => m.author_id === api.cache?.clientId, - ); - if ( - lastMessage && - lastMessage.author_id == api.cache?.clientId - ) - editing.add(lastMessage.id); + const oldState = autocompleteState() + if (oldState && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) + event.preventDefault() + + else if (event.key === 'ArrowUp' && !event.currentTarget.innerText?.trim()) { + event.preventDefault() + const lastMessage = grouper().latestMessageWhere(m => m.author_id === api.cache?.clientId) + if (lastMessage && lastMessage.author_id == api.cache?.clientId) + editing.add(lastMessage.id) } }} onKeyPress={async (event) => { - if (event.shiftKey) return; + if (event.shiftKey) + return - if ( - event.key === "Enter" && - (!mobile || event.ctrlKey || event.metaKey) - ) { - event.preventDefault(); + if (event.key === 'Enter' && (!mobile || event.ctrlKey || event.metaKey)) { + event.preventDefault() if (autocompleteState() && autocompleteResult()?.length) - return executeAutocomplete(); + return executeAutocomplete() - await createMessage(); + await createMessage() } }} onMouseUp={handleCaretUpdate} onTouchStart={handleCaretUpdate} onSelect={handleCaretUpdate} onInput={() => { - void typingKeepAlive.ackTyping(); - updateSendable(); - handleCaretUpdate(); + void typingKeepAlive.ackTyping() + updateSendable() + handleCaretUpdate() }} onFocus={() => { - const timeout = messageInputFocusTimeout(); - if (timeout) clearTimeout(timeout); + const timeout = messageInputFocusTimeout() + if (timeout) + clearTimeout(timeout) - setMessageInputFocused(true); - void ack(); + setMessageInputFocused(true) + void ack() }} - onBlur={() => - setMessageInputFocusTimeout( - setTimeout(() => setMessageInputFocused(false), 100) as any, - ) - } + onBlur={() => setMessageInputFocusTimeout( + setTimeout(() => setMessageInputFocused(false), 100) as any + )} />
0}> - api.cache?.users.get(id)?.username) - .filter((u): u is string => !!u)} - > + api.cache?.users.get(id)?.username).filter((u): u is string => !!u)}> {(username, index) => ( <> {username} - {index() < typing().users.size - 1 && - typing().users.size > 2 && , } + {index() < typing().users.size - 1 && typing().users.size > 2 && ( + , + )} {index() === typing().users.size - 2 && ( and )} )} - - {" "} - {typing().users.size === 1 ? "is" : "are"} typing... - + {typing().users.size === 1 ? 'is' : 'are'} typing...
@@ -2152,5 +1770,5 @@ export default function Chat(props: {
- ); + ) }