diff --git a/ROADMAP.md b/ROADMAP.md index d1a0ca2..64da683 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -22,7 +22,7 @@ ## Phase 1 — Core UX Gaps - [x] File/image attachment uploads (R2) -- [ ] Message deletion +- [x] Message deletion - [ ] Message editing UI - [ ] User profiles (bio, custom status, avatar upload) - [ ] Channel edit/delete diff --git a/apps/realtime/src/index.ts b/apps/realtime/src/index.ts index 463184e..b1b6af3 100644 --- a/apps/realtime/src/index.ts +++ b/apps/realtime/src/index.ts @@ -10,6 +10,8 @@ import type { import { channelRoom, channelRoomPayloadSchema, + deleteMessagePayloadSchema, + editMessagePayloadSchema, guildRoom, markChannelReadPayloadSchema, presenceSubscribePayloadSchema, @@ -25,7 +27,12 @@ import { createClient } from "redis" import { Server, type Socket } from "socket.io" import { toErrorMessage } from "@/lib/errors" import { assertUserCanAccessChannel } from "@/services/channel-access" -import { createMessage, toggleMessageReaction } from "@/services/messages" +import { + createMessage, + deleteMessage, + editMessage, + toggleMessageReaction, +} from "@/services/messages" import { buildMessageFanout } from "@/services/notifications" import { listOnlineUserIds, @@ -353,6 +360,46 @@ io.on("connection", (socket) => { } }) + socket.on("message:delete", async (payload, ack) => { + try { + const parsed = deleteMessagePayloadSchema.parse(payload) + const result = await deleteMessage({ + userId: socket.data.user.id, + payload: parsed, + }) + + socket.to(channelRoom(parsed.channelId)).emit("message:deleted", { + channelId: result.channelId, + messageId: result.messageId, + }) + + ack?.({ ok: true }) + } catch (error) { + ack?.({ ok: false, error: toErrorMessage(error) }) + } + }) + + socket.on("message:edit", async (payload, ack) => { + try { + const parsed = editMessagePayloadSchema.parse(payload) + const result = await editMessage({ + userId: socket.data.user.id, + payload: parsed, + }) + + socket.to(channelRoom(parsed.channelId)).emit("message:updated", { + channelId: result.channelId, + messageId: result.messageId, + content: result.content, + editedAt: result.editedAt, + }) + + ack?.({ ok: true }) + } catch (error) { + ack?.({ ok: false, error: toErrorMessage(error) }) + } + }) + socket.on("message:reaction:toggle", async (payload, ack) => { try { const parsed = toggleMessageReactionPayloadSchema.parse(payload) diff --git a/apps/realtime/src/services/messages.ts b/apps/realtime/src/services/messages.ts index 5bdcf0f..011860e 100644 --- a/apps/realtime/src/services/messages.ts +++ b/apps/realtime/src/services/messages.ts @@ -1,5 +1,7 @@ import { and, count, db, eq, schema } from "@repo/db" import type { + DeleteMessagePayload, + EditMessagePayload, RealtimeMessage, RealtimeMessageReactionUpdated, SendMessagePayload, @@ -15,6 +17,16 @@ type CreateMessageInput = { payload: SendMessagePayload } +type DeleteMessageInput = { + userId: string + payload: DeleteMessagePayload +} + +type EditMessageInput = { + userId: string + payload: EditMessagePayload +} + type ToggleMessageReactionInput = { userId: string payload: ToggleMessageReactionPayload @@ -25,6 +37,12 @@ export type CreateMessageResult = { channel: AccessibleChannel } +export type DeleteMessageResult = { + channelId: string + messageId: string + channel: AccessibleChannel +} + export type ToggleMessageReactionResult = { update: RealtimeMessageReactionUpdated channel: AccessibleChannel @@ -173,6 +191,103 @@ export async function createMessage(input: CreateMessageInput) { } satisfies CreateMessageResult } +export async function deleteMessage( + input: DeleteMessageInput +): Promise { + const channelRecord = await assertUserCanAccessChannel( + input.userId, + input.payload.channelId + ) + + const messageRecord = await db + .select({ + id: schema.message.id, + authorId: schema.message.authorId, + }) + .from(schema.message) + .where( + and( + eq(schema.message.id, input.payload.messageId), + eq(schema.message.channelId, input.payload.channelId) + ) + ) + .limit(1) + .then((rows) => rows[0]) + + if (!messageRecord) { + throw new Error("Message not found") + } + + if (messageRecord.authorId !== input.userId) { + throw new Error("You can only delete your own messages") + } + + await db + .delete(schema.message) + .where(eq(schema.message.id, input.payload.messageId)) + + return { + channelId: input.payload.channelId, + messageId: input.payload.messageId, + channel: channelRecord, + } +} + +export type EditMessageResult = { + channelId: string + messageId: string + content: string + editedAt: string + channel: AccessibleChannel +} + +export async function editMessage( + input: EditMessageInput +): Promise { + const channelRecord = await assertUserCanAccessChannel( + input.userId, + input.payload.channelId + ) + + const messageRecord = await db + .select({ + id: schema.message.id, + authorId: schema.message.authorId, + }) + .from(schema.message) + .where( + and( + eq(schema.message.id, input.payload.messageId), + eq(schema.message.channelId, input.payload.channelId) + ) + ) + .limit(1) + .then((rows) => rows[0]) + + if (!messageRecord) { + throw new Error("Message not found") + } + + if (messageRecord.authorId !== input.userId) { + throw new Error("You can only edit your own messages") + } + + const editedAt = new Date() + + await db + .update(schema.message) + .set({ content: input.payload.content, editedAt }) + .where(eq(schema.message.id, input.payload.messageId)) + + return { + channelId: input.payload.channelId, + messageId: input.payload.messageId, + content: input.payload.content, + editedAt: editedAt.toISOString(), + channel: channelRecord, + } +} + export async function toggleMessageReaction(input: ToggleMessageReactionInput) { const channelRecord = await assertUserCanAccessChannel( input.userId, diff --git a/apps/web/src/components/chat/composer/message-input.tsx b/apps/web/src/components/chat/composer/message-input.tsx index b71fa32..5e90a02 100644 --- a/apps/web/src/components/chat/composer/message-input.tsx +++ b/apps/web/src/components/chat/composer/message-input.tsx @@ -44,6 +44,7 @@ import { } from "react" import type { PendingAttachment } from "@/hooks/use-file-upload" import type { Message } from "@/lib/api-types" +import { extractMentionIds, toStoredMarkdown } from "@/lib/editor-utils" import type { ChatContext } from "../header" import { AttachmentPreview } from "./attachment-preview" import { @@ -65,13 +66,9 @@ const MAX_MESSAGE_LENGTH = 2000 const POPUP_HORIZONTAL_PADDING = 8 const POPUP_VERTICAL_PADDING = 8 const POPUP_GAP = 6 -const SUGGESTION_MENU_SELECTOR = +export const SUGGESTION_MENU_SELECTOR = "[data-suggestion-open='true'], [data-mention-suggestion-open='true'], [data-slash-suggestion-open='true'], [data-slash-command-open='true']" -const EVERYONE_MENTION_ID = "everyone" const SLASH_COMMAND_PLUGIN_KEY = new PluginKey("slash-command") -const TIPTAP_MARKDOWN_MENTION_REGEX = /\[@[^\]]*?\bid="([^"]+)"[^\]]*]/g -const STORED_MENTION_REGEX = - /<@([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})>/gi const DEFAULT_CODE_BLOCK_LANGUAGE = "plaintext" const CODE_BLOCK_LANGUAGE_OPTIONS = [ { value: "plaintext", label: "Plain Text" }, @@ -113,37 +110,6 @@ interface MessageInputProps { isUploading: boolean } -function toStoredMarkdown(markdown: string) { - return ( - markdown - .replace(/\u00A0/g, " ") - // Strip ++…++ wrappers the Markdown extension generates for unrecognised marks (e.g. Link) - // TipTap outputs either ++[url](url)++ or ++bareUrl++ - .replace(/\+\+\[([^\]]+)\]\([^)]+\)\+\+/g, "$1") - .replace(/\+\+([\s\S]+?)\+\+/g, "$1") - .replace(TIPTAP_MARKDOWN_MENTION_REGEX, (_match, mentionId: string) => { - if (mentionId.toLowerCase() === EVERYONE_MENTION_ID) { - return "@everyone" - } - - return `<@${mentionId}>` - }) - ) -} - -function extractMentionIds(content: string) { - const mentionIds = new Set() - - for (const match of content.matchAll(STORED_MENTION_REGEX)) { - const mentionId = match[1] - if (mentionId) { - mentionIds.add(mentionId) - } - } - - return Array.from(mentionIds) -} - interface SuggestionPopupListRef { onKeyDown: (props: SuggestionKeyDownProps) => boolean } @@ -253,7 +219,7 @@ function createSuggestionPopupManager< } } -function createMentionSuggestion( +export function createMentionSuggestion( getMentionCandidates: () => MentionCandidate[] ): MentionOptions["suggestion"] { return { diff --git a/apps/web/src/components/chat/message-action-bar.tsx b/apps/web/src/components/chat/message-action-bar.tsx index e61a88c..52e3a19 100644 --- a/apps/web/src/components/chat/message-action-bar.tsx +++ b/apps/web/src/components/chat/message-action-bar.tsx @@ -80,18 +80,24 @@ export function MessageActionBar({ + {canManageMessage && ( + + Edit message + + )} + Copy text {canManageMessage && ( <> - - Edit message - - + + Delete message - )} - Copy text diff --git a/apps/web/src/components/chat/message-edit-input.tsx b/apps/web/src/components/chat/message-edit-input.tsx new file mode 100644 index 0000000..aff7537 --- /dev/null +++ b/apps/web/src/components/chat/message-edit-input.tsx @@ -0,0 +1,157 @@ +import { cn } from "@repo/ui/lib/utils" +import Link from "@tiptap/extension-link" +import Mention from "@tiptap/extension-mention" +import { Markdown } from "@tiptap/markdown" +import { EditorContent, useEditor } from "@tiptap/react" +import StarterKit from "@tiptap/starter-kit" +import { useCallback, useEffect, useMemo, useRef } from "react" +import { toStoredMarkdown } from "@/lib/editor-utils" +import type { MentionCandidate } from "./composer/mention-types" +import { + createMentionSuggestion, + SUGGESTION_MENU_SELECTOR, +} from "./composer/message-input" + +const MAX_MESSAGE_LENGTH = 2000 + +interface MessageEditInputProps { + initialContent: string + onSave: (content: string) => void + onCancel: () => void + mentionCandidates?: MentionCandidate[] +} + +export function MessageEditInput({ + initialContent, + onSave, + onCancel, + mentionCandidates = [], +}: MessageEditInputProps) { + const mentionCandidatesRef = useRef(mentionCandidates) + + useEffect(() => { + mentionCandidatesRef.current = mentionCandidates + }, [mentionCandidates]) + + const mentionSuggestion = useMemo( + () => createMentionSuggestion(() => mentionCandidatesRef.current), + [] + ) + + const editor = useEditor( + { + extensions: [ + StarterKit.configure({ + heading: false, + blockquote: false, + horizontalRule: false, + }), + Markdown, + Link.configure({ + openOnClick: false, + autolink: true, + linkOnPaste: true, + HTMLAttributes: { + class: + "text-primary underline-offset-2 hover:underline cursor-pointer", + rel: "noreferrer noopener", + target: "_blank", + }, + }), + Mention.configure({ + HTMLAttributes: { + class: "rounded bg-primary/15 px-1 py-0.5 font-medium text-primary", + }, + renderText: ({ options, node }) => + `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`, + suggestion: mentionSuggestion, + }), + ], + content: initialContent, + editorProps: { + attributes: { + class: + "min-h-[24px] max-h-[200px] overflow-y-auto whitespace-pre-wrap break-words text-sm leading-6 text-foreground/90 outline-none [&_code]:rounded-[4px] [&_code]:border [&_code]:border-border/70 [&_code]:bg-primary/10 [&_code]:px-0.75 [&_code]:py-0.25 [&_code]:font-mono [&_code]:text-[0.92em] [&_code]:text-foreground [&_pre]:mt-1 [&_pre]:mb-0 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:border [&_pre]:border-border/70 [&_pre]:bg-muted/50 [&_pre]:px-2 [&_pre]:py-1.5 [&_pre]:font-mono [&_pre]:text-[0.92em] [&_pre]:leading-6 [&_pre_code]:rounded-none [&_pre_code]:border-0 [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-foreground", + }, + }, + autofocus: "end", + }, + [] + ) + + const handleSave = useCallback(() => { + if (!editor) return + + const rawMarkdown = editor.getMarkdown() + const markdown = toStoredMarkdown(rawMarkdown) + const trimmed = markdown.trim() + + if (trimmed.length === 0 || trimmed.length > MAX_MESSAGE_LENGTH) return + + // Don't save if content hasn't changed + if (trimmed === initialContent) { + onCancel() + return + } + + onSave(trimmed) + }, [editor, initialContent, onSave, onCancel]) + + useEffect(() => { + if (!editor?.view?.dom) return + + const isSuggestionMenuOpen = () => + Boolean(document.querySelector(SUGGESTION_MENU_SELECTOR)) + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault() + event.stopPropagation() + onCancel() + return + } + + if (event.key === "Enter" && !event.shiftKey) { + if (event.isComposing) return + if (isSuggestionMenuOpen()) return + event.preventDefault() + event.stopPropagation() + handleSave() + } + } + + const domNode = editor.view.dom + domNode.addEventListener("keydown", handleKeyDown, { capture: true }) + return () => { + domNode.removeEventListener("keydown", handleKeyDown, { capture: true }) + } + }, [editor, handleSave, onCancel]) + + return ( +
+
+ +
+
+ escape to{" "} + {" "} + • enter to{" "} + +
+
+ ) +} diff --git a/apps/web/src/components/chat/message-item.tsx b/apps/web/src/components/chat/message-item.tsx index 8a7d064..d5b9cc4 100644 --- a/apps/web/src/components/chat/message-item.tsx +++ b/apps/web/src/components/chat/message-item.tsx @@ -1,11 +1,23 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@repo/ui/components/alert-dialog" import { Avatar, AvatarFallback, AvatarImage } from "@repo/ui/components/avatar" import { cn } from "@repo/ui/lib/utils" import { formatTime } from "@repo/utils/date" import { useCallback, useState } from "react" import type { Message } from "@/lib/api-types" +import type { MentionCandidate } from "./composer/mention-types" import { EmbedCard } from "./embed-card" import { MessageActionBar } from "./message-action-bar" import { AttachmentGrid } from "./message-attachment" +import { MessageEditInput } from "./message-edit-input" import { MessageMarkdown } from "./message-markdown" interface MessageItemProps { @@ -14,6 +26,9 @@ interface MessageItemProps { currentUserId?: string onReact?: (messageId: string, emoji: string) => void onReply?: (message: Message) => void + onDelete?: (messageId: string) => void + onEdit?: (messageId: string, content: string) => void + mentionCandidates?: MentionCandidate[] } function nameInitial(name: string) { @@ -105,9 +120,14 @@ export function MessageItem({ currentUserId, onReact, onReply, + onDelete, + onEdit, + mentionCandidates, }: MessageItemProps) { const author = message.author const [isActionBarPinned, setIsActionBarPinned] = useState(false) + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) + const [isEditing, setIsEditing] = useState(false) const isOwnMessage = !!currentUserId && currentUserId === message.authorId const isReply = message.type === "reply" @@ -131,6 +151,31 @@ export function MessageItem({ onReply?.(message) }, [message, onReply]) + const handleDeleteRequest = useCallback(() => { + setIsDeleteDialogOpen(true) + }, []) + + const handleDeleteConfirm = useCallback(() => { + onDelete?.(message.id) + setIsDeleteDialogOpen(false) + }, [message.id, onDelete]) + + const handleEditRequest = useCallback(() => { + setIsEditing(true) + }, []) + + const handleEditSave = useCallback( + (content: string) => { + onEdit?.(message.id, content) + setIsEditing(false) + }, + [message.id, onEdit] + ) + + const handleEditCancel = useCallback(() => { + setIsEditing(false) + }, []) + return (
@@ -182,10 +229,20 @@ export function MessageItem({
)} - + {isEditing ? ( + + ) : ( + + )} {message.attachments && message.attachments.length > 0 && ( )} @@ -219,6 +276,29 @@ export function MessageItem({ )} + + + + Delete message + + Are you sure you want to delete this message? This action cannot + be undone. + + + + Cancel + + Delete + + + + ) } diff --git a/apps/web/src/components/chat/message-list.tsx b/apps/web/src/components/chat/message-list.tsx index 98c0408..9052561 100644 --- a/apps/web/src/components/chat/message-list.tsx +++ b/apps/web/src/components/chat/message-list.tsx @@ -4,6 +4,7 @@ import { differenceInMinutes, isSameDay } from "@repo/utils/date" import { Hash, User, Users } from "lucide-react" import { useCallback, useEffect, useRef, useState } from "react" import type { Message } from "@/lib/api-types" +import type { MentionCandidate } from "./composer/mention-types" import { DateDivider } from "./date-divider" import type { ChatContext } from "./header" import { MessageItem } from "./message-item" @@ -14,6 +15,9 @@ interface MessageListProps { currentUserId?: string onReact?: (messageId: string, emoji: string) => void onReply?: (message: Message) => void + onDelete?: (messageId: string) => void + onEdit?: (messageId: string, content: string) => void + mentionCandidates?: MentionCandidate[] isLoading?: boolean hasMore?: boolean onLoadMore?: () => void @@ -64,6 +68,9 @@ export function MessageList({ currentUserId, onReact, onReply, + onDelete, + onEdit, + mentionCandidates, isLoading, hasMore, onLoadMore, @@ -178,6 +185,9 @@ export function MessageList({ currentUserId={currentUserId} onReact={onReact} onReply={onReply} + onDelete={onDelete} + onEdit={onEdit} + mentionCandidates={mentionCandidates} /> ) diff --git a/apps/web/src/components/chat/message-markdown.tsx b/apps/web/src/components/chat/message-markdown.tsx index ed3c96b..189dbb8 100644 --- a/apps/web/src/components/chat/message-markdown.tsx +++ b/apps/web/src/components/chat/message-markdown.tsx @@ -180,12 +180,14 @@ interface MessageMarkdownProps { content: string | null mentions: Message["mentions"] className?: string + editedAt?: string | null } export function MessageMarkdown({ content, mentions, className, + editedAt, }: MessageMarkdownProps) { const mentionById = useMemo( () => new Map(mentions.map((mention) => [mention.id, mention])), @@ -206,6 +208,7 @@ export function MessageMarkdown({ className={cn( "break-words text-sm leading-snug text-foreground/90", "[&_p]:my-0 [&_a]:break-words [&_code]:rounded-[4px] [&_code]:border [&_code]:border-border/70 [&_code]:bg-primary/10 [&_code]:px-0.75 [&_code]:py-0.25 [&_code]:font-mono [&_code]:text-[0.92em] [&_code]:text-foreground [&_pre_code]:rounded-none [&_pre_code]:border-0 [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code.hljs]:bg-transparent [&_ul]:my-1 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-1 [&_ol]:list-decimal [&_ol]:pl-5", + editedAt && "[&>p:last-of-type]:inline", className )} > @@ -297,6 +300,11 @@ export function MessageMarkdown({ > {markdown} + {editedAt && ( + + (edited) + + )} ) } diff --git a/apps/web/src/hooks/use-message-deletion.ts b/apps/web/src/hooks/use-message-deletion.ts new file mode 100644 index 0000000..bc92cae --- /dev/null +++ b/apps/web/src/hooks/use-message-deletion.ts @@ -0,0 +1,72 @@ +import type { QueryClient } from "@tanstack/react-query" +import { useCallback, useEffect } from "react" +import type { AppSocket } from "@/lib/socket" + +interface MessagesQueryData { + data: { id: string }[] +} + +interface UseMessageDeletionOptions { + socket: AppSocket | null + queryClient: QueryClient + channelId: string +} + +export function useMessageDeletion({ + socket, + queryClient, + channelId, +}: UseMessageDeletionOptions) { + const removeMessageFromCache = useCallback( + (messageId: string) => { + queryClient.setQueryData(["messages", channelId], (old) => { + if (!old) return old + return { + ...old, + data: old.data.filter((m) => m.id !== messageId), + } as TData + }) + }, + [queryClient, channelId] + ) + + // Listen for message:deleted from other clients + useEffect(() => { + if (!socket) return + + const handleDeleted = (payload: { + channelId: string + messageId: string + }) => { + if (payload.channelId !== channelId) return + removeMessageFromCache(payload.messageId) + } + + socket.on("message:deleted", handleDeleted) + return () => { + socket.off("message:deleted", handleDeleted) + } + }, [socket, channelId, removeMessageFromCache]) + + const handleDelete = useCallback( + (messageId: string) => { + if (!socket?.connected) return + + // Optimistically remove from cache + removeMessageFromCache(messageId) + + socket.emit("message:delete", { channelId, messageId }, (result) => { + if (!result.ok) { + console.error("[chat] delete message failed:", result.error) + // Re-fetch to restore state + void queryClient.invalidateQueries({ + queryKey: ["messages", channelId], + }) + } + }) + }, + [socket, channelId, removeMessageFromCache, queryClient] + ) + + return { handleDelete } +} diff --git a/apps/web/src/hooks/use-message-editing.ts b/apps/web/src/hooks/use-message-editing.ts new file mode 100644 index 0000000..cb5caac --- /dev/null +++ b/apps/web/src/hooks/use-message-editing.ts @@ -0,0 +1,80 @@ +import type { QueryClient } from "@tanstack/react-query" +import { useCallback, useEffect } from "react" +import type { AppSocket } from "@/lib/socket" + +interface MessagesQueryData { + data: { id: string; content: string | null; editedAt: string | null }[] +} + +interface UseMessageEditingOptions { + socket: AppSocket | null + queryClient: QueryClient + channelId: string +} + +export function useMessageEditing({ + socket, + queryClient, + channelId, +}: UseMessageEditingOptions) { + const updateMessageInCache = useCallback( + (messageId: string, content: string, editedAt: string) => { + queryClient.setQueryData(["messages", channelId], (old) => { + if (!old) return old + return { + ...old, + data: old.data.map((m) => + m.id === messageId ? { ...m, content, editedAt } : m + ), + } as TData + }) + }, + [queryClient, channelId] + ) + + // Listen for message:updated from other clients + useEffect(() => { + if (!socket) return + + const handleUpdated = (payload: { + channelId: string + messageId: string + content: string + editedAt: string + }) => { + if (payload.channelId !== channelId) return + updateMessageInCache(payload.messageId, payload.content, payload.editedAt) + } + + socket.on("message:updated", handleUpdated) + return () => { + socket.off("message:updated", handleUpdated) + } + }, [socket, channelId, updateMessageInCache]) + + const handleEdit = useCallback( + (messageId: string, content: string) => { + if (!socket?.connected) return + + // Optimistically update + const editedAt = new Date().toISOString() + updateMessageInCache(messageId, content, editedAt) + + socket.emit( + "message:edit", + { channelId, messageId, content }, + (result) => { + if (!result.ok) { + console.error("[chat] edit message failed:", result.error) + void queryClient.invalidateQueries({ + queryKey: ["messages", channelId], + }) + } + } + ) + }, + [socket, channelId, updateMessageInCache, queryClient] + ) + + return { handleEdit } +} diff --git a/apps/web/src/lib/editor-utils.ts b/apps/web/src/lib/editor-utils.ts new file mode 100644 index 0000000..fdfffdf --- /dev/null +++ b/apps/web/src/lib/editor-utils.ts @@ -0,0 +1,36 @@ +const EVERYONE_MENTION_ID = "everyone" + +export const TIPTAP_MARKDOWN_MENTION_REGEX = /\[@[^\]]*?\bid="([^"]+)"[^\]]*]/g +export const STORED_MENTION_REGEX = + /<@([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})>/gi + +export function toStoredMarkdown(markdown: string) { + return ( + markdown + .replace(/\u00A0/g, " ") + // Strip ++…++ wrappers the Markdown extension generates for unrecognised marks (e.g. Link) + // TipTap outputs either ++[url](url)++ or ++bareUrl++ + .replace(/\+\+\[([^\]]+)\]\([^)]+\)\+\+/g, "$1") + .replace(/\+\+([\s\S]+?)\+\+/g, "$1") + .replace(TIPTAP_MARKDOWN_MENTION_REGEX, (_match, mentionId: string) => { + if (mentionId.toLowerCase() === EVERYONE_MENTION_ID) { + return "@everyone" + } + + return `<@${mentionId}>` + }) + ) +} + +export function extractMentionIds(content: string) { + const mentionIds = new Set() + + for (const match of content.matchAll(STORED_MENTION_REGEX)) { + const mentionId = match[1] + if (mentionId) { + mentionIds.add(mentionId) + } + } + + return Array.from(mentionIds) +} diff --git a/apps/web/src/lib/realtime-adapter.ts b/apps/web/src/lib/realtime-adapter.ts index b9bb615..3cca7fb 100644 --- a/apps/web/src/lib/realtime-adapter.ts +++ b/apps/web/src/lib/realtime-adapter.ts @@ -18,7 +18,7 @@ export function realtimeMessageToMessage(rm: RealtimeMessage): Message { attachments: rm.attachments ?? [], embeds: rm.embeds ?? [], pinned: false, - editedAt: null, + editedAt: rm.editedAt ?? null, mentions: rm.mentions, reactions: rm.reactions, } diff --git a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx index cd1db78..1c85cd9 100644 --- a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx +++ b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx @@ -11,6 +11,8 @@ import { MessageList } from "@/components/chat/message-list" import { useRightSidebar } from "@/components/sidebar/right-panel/right-sidebar-context" import { useSocket } from "@/context/socket-context" import { useFileUpload } from "@/hooks/use-file-upload" +import { useMessageDeletion } from "@/hooks/use-message-deletion" +import { useMessageEditing } from "@/hooks/use-message-editing" import { useMessageReactions } from "@/hooks/use-message-reactions" import { useMessageSending } from "@/hooks/use-message-sending" import { useReplyState } from "@/hooks/use-reply-state" @@ -97,6 +99,18 @@ function ChannelView() { currentUserId, }) + const { handleDelete } = useMessageDeletion({ + socket, + queryClient, + channelId, + }) + + const { handleEdit } = useMessageEditing({ + socket, + queryClient, + channelId, + }) + const { handleSend } = useMessageSending({ socket, queryClient, @@ -189,6 +203,9 @@ function ChannelView() { currentUserId={currentUserId} onReact={handleReact} onReply={setReplyingTo} + onDelete={handleDelete} + onEdit={handleEdit} + mentionCandidates={mentionCandidates} isLoading={messagesLoading} /> ({ + socket, + queryClient, + channelId: dmId, + }) + + const { handleEdit } = useMessageEditing({ + socket, + queryClient, + channelId: dmId, + }) + const { handleSend } = useMessageSending({ socket, queryClient, @@ -156,6 +170,9 @@ function DMConversation() { currentUserId={currentUserId} onReact={handleReact} onReply={setReplyingTo} + onDelete={handleDelete} + onEdit={handleEdit} + mentionCandidates={mentionCandidates} isLoading={messagesLoading} /> export type ToggleMessageReactionPayload = z.infer< typeof toggleMessageReactionPayloadSchema > +export type DeleteMessagePayload = z.infer +export type EditMessagePayload = z.infer export type MarkChannelReadPayload = z.infer< typeof markChannelReadPayloadSchema > @@ -125,6 +138,7 @@ export type RealtimeMessage = { attachments: RealtimeAttachment[] embeds: RealtimeEmbed[] referencedMessage: RealtimeReferencedMessage | null + editedAt?: string nonce?: string } @@ -147,6 +161,9 @@ export type SendMessageAck = ( result: { ok: true; message: RealtimeMessage } | ErrorResult ) => void +export type DeleteMessageAck = (result: OkResult | ErrorResult) => void +export type EditMessageAck = (result: OkResult | ErrorResult) => void + export type ToggleMessageReactionAck = ( result: { ok: true; update: RealtimeMessageReactionUpdated } | ErrorResult ) => void @@ -202,6 +219,11 @@ export interface ClientToServerEvents { "channel:join": (payload: ChannelRoomPayload, ack?: JoinLeaveAck) => void "channel:leave": (payload: ChannelRoomPayload, ack?: JoinLeaveAck) => void "message:send": (payload: SendMessagePayload, ack?: SendMessageAck) => void + "message:delete": ( + payload: DeleteMessagePayload, + ack?: DeleteMessageAck + ) => void + "message:edit": (payload: EditMessagePayload, ack?: EditMessageAck) => void "message:reaction:toggle": ( payload: ToggleMessageReactionPayload, ack?: ToggleMessageReactionAck @@ -222,6 +244,13 @@ export interface ServerToClientEvents { }) => void "presence:user:update": (payload: PresenceUserUpdate) => void "message:created": (payload: RealtimeMessage) => void + "message:deleted": (payload: { channelId: string; messageId: string }) => void + "message:updated": (payload: { + channelId: string + messageId: string + content: string + editedAt: string + }) => void "message:reaction:updated": (payload: RealtimeMessageReactionUpdated) => void "message:embeds:updated": (payload: RealtimeMessageEmbedsUpdated) => void "notification:unread": (payload: UnreadNotification) => void diff --git a/packages/ui/src/components/alert-dialog.tsx b/packages/ui/src/components/alert-dialog.tsx new file mode 100644 index 0000000..e819882 --- /dev/null +++ b/packages/ui/src/components/alert-dialog.tsx @@ -0,0 +1,195 @@ +"use client" + +import { Button } from "@repo/ui/components/button" +import { cn } from "@repo/ui/lib/utils" +import { AlertDialog as AlertDialogPrimitive } from "radix-ui" +import type * as React from "react" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" +}) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogAction({ + className, + variant = "default", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + variant = "outline", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +}