diff --git a/apps/web/package.json b/apps/web/package.json index 424f953..f42a0ca 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -30,6 +30,7 @@ "@tiptap/suggestion": "^3.20.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "highlight.js": "^11.11.1", "lucide-react": "^0.563.0", "motion": "^12.34.0", "next-themes": "^0.4.6", @@ -38,6 +39,7 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-markdown": "^10.1.0", + "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", "socket.io-client": "^4.8.3", "tailwind-merge": "^3.4.0", diff --git a/apps/web/src/components/chat/composer/message-input.tsx b/apps/web/src/components/chat/composer/message-input.tsx index fe403b7..5cdce51 100644 --- a/apps/web/src/components/chat/composer/message-input.tsx +++ b/apps/web/src/components/chat/composer/message-input.tsx @@ -7,11 +7,41 @@ import { import { cn } from "@repo/ui/lib/utils" import Mention, { type MentionOptions } from "@tiptap/extension-mention" import { Markdown } from "@tiptap/markdown" -import { EditorContent, ReactRenderer, useEditor } from "@tiptap/react" +import { PluginKey } from "@tiptap/pm/state" +import { + EditorContent, + Extension, + ReactRenderer, + useEditor, + useEditorState, +} from "@tiptap/react" +import { BubbleMenu } from "@tiptap/react/menus" import StarterKit from "@tiptap/starter-kit" -import type { SuggestionProps } from "@tiptap/suggestion" -import { FileUp, ImagePlus, Link2, Plus, Send, Smile } from "lucide-react" -import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import Suggestion, { + type SuggestionKeyDownProps, + type SuggestionOptions, + type SuggestionProps, +} from "@tiptap/suggestion" +import { + Bold, + Code, + FileUp, + ImagePlus, + Italic, + Link2, + Plus, + Send, + Smile, + Strikethrough, +} from "lucide-react" +import { + type ComponentType, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react" import type { Message } from "@/lib/api-types" import type { ChatContext } from "../header" import { @@ -20,12 +50,23 @@ import { type MentionSuggestionListRef, } from "./mention-suggestion-list" import type { MentionCandidate } from "./mention-types" +import { + type SlashCommandItem, + SlashCommandList, + type SlashCommandListProps, + type SlashCommandListRef, +} from "./slash-command-list" const MAX_MENTION_RESULTS = 8 +const MAX_SLASH_RESULTS = 8 const MAX_MESSAGE_LENGTH = 2000 const POPUP_HORIZONTAL_PADDING = 8 const POPUP_VERTICAL_PADDING = 8 const POPUP_GAP = 6 +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 @@ -34,6 +75,23 @@ const ATTACHMENT_ACTIONS = [ { id: "upload-image", label: "Upload Image", icon: ImagePlus }, { id: "attach-link", label: "Attach Link", icon: Link2 }, ] as const +const DEFAULT_CODE_BLOCK_LANGUAGE = "plaintext" +const CODE_BLOCK_LANGUAGE_OPTIONS = [ + { value: "plaintext", label: "Plain Text" }, + { value: "typescript", label: "TypeScript" }, + { value: "javascript", label: "JavaScript" }, + { value: "python", label: "Python" }, + { value: "json", label: "JSON" }, + { value: "bash", label: "Bash" }, +] as const +const SLASH_COMMANDS: SlashCommandItem[] = [ + { + id: "code-block", + label: "Code Block", + description: "Insert a code block", + search: "code snippet block fence", + }, +] interface MessageInputProps { context: ChatContext @@ -46,7 +104,13 @@ interface MessageInputProps { function toStoredMarkdown(markdown: string) { return markdown .replace(/\u00A0/g, " ") - .replace(TIPTAP_MARKDOWN_MENTION_REGEX, "<@$1>") + .replace(TIPTAP_MARKDOWN_MENTION_REGEX, (_match, mentionId: string) => { + if (mentionId.toLowerCase() === EVERYONE_MENTION_ID) { + return "@everyone" + } + + return `<@${mentionId}>` + }) } function extractMentionIds(content: string) { @@ -62,6 +126,115 @@ function extractMentionIds(content: string) { return Array.from(mentionIds) } +interface SuggestionPopupListRef { + onKeyDown: (props: SuggestionKeyDownProps) => boolean +} + +interface SuggestionPopupManagerOptions< + TItem, + TListProps extends { items: TItem[]; command: (item: TItem) => void }, +> { + rendererComponent: ComponentType + popupClassName: string + popupDataAttribute: string + popupFallbackWidth: number + popupFallbackHeight: number +} + +function createSuggestionPopupManager< + TItem, + TListRef extends SuggestionPopupListRef, + TListProps extends { items: TItem[]; command: (item: TItem) => void }, +>({ + rendererComponent, + popupClassName, + popupDataAttribute, + popupFallbackWidth, + popupFallbackHeight, +}: SuggestionPopupManagerOptions) { + let popup: HTMLDivElement | null = null + let currentProps: SuggestionProps | null = null + let reactRenderer: ReactRenderer | null = null + + const positionPopup = () => { + const clientRect = currentProps?.clientRect?.() + if (!popup || !clientRect) return + + const popupWidth = popup.offsetWidth || popupFallbackWidth + const popupHeight = popup.offsetHeight || popupFallbackHeight + const maxLeft = Math.max( + POPUP_HORIZONTAL_PADDING, + window.innerWidth - popupWidth - POPUP_HORIZONTAL_PADDING + ) + const left = Math.min( + Math.max(POPUP_HORIZONTAL_PADDING, clientRect.left), + maxLeft + ) + const top = Math.max( + POPUP_VERTICAL_PADDING, + clientRect.top - popupHeight - POPUP_GAP + ) + + popup.style.left = `${left}px` + popup.style.top = `${top}px` + } + + const cleanup = () => { + window.removeEventListener("resize", positionPopup) + window.removeEventListener("scroll", positionPopup, true) + reactRenderer?.destroy() + reactRenderer = null + popup?.remove() + popup = null + currentProps = null + } + + return { + onStart: (props: SuggestionProps) => { + cleanup() + currentProps = props + + popup = document.createElement("div") + popup.className = popupClassName + popup.dataset[popupDataAttribute] = "true" + popup.dataset.suggestionOpen = "true" + document.body.append(popup) + + reactRenderer = new ReactRenderer(rendererComponent, { + editor: props.editor, + props: { + items: props.items, + command: props.command, + } as TListProps, + }) + + popup.append(reactRenderer.element) + window.addEventListener("resize", positionPopup) + window.addEventListener("scroll", positionPopup, true) + positionPopup() + }, + onUpdate: (props: SuggestionProps) => { + currentProps = props + reactRenderer?.updateProps({ + items: props.items, + command: props.command, + } as TListProps) + positionPopup() + }, + onKeyDown: (props: SuggestionKeyDownProps) => { + if (props.event.key === "Escape") { + props.event.preventDefault() + cleanup() + return true + } + + const suggestionList = reactRenderer?.ref as TListRef | null + return suggestionList?.onKeyDown(props) ?? false + }, + onExit: cleanup, + } +} + function createMentionSuggestion( getMentionCandidates: () => MentionCandidate[] ): MentionOptions["suggestion"] { @@ -79,96 +252,167 @@ function createMentionSuggestion( return results.slice(0, MAX_MENTION_RESULTS) }, render: () => { - let popup: HTMLDivElement | null = null - let currentProps: SuggestionProps< + return createSuggestionPopupManager< MentionCandidate, - MentionCandidate - > | null = null - let reactRenderer: ReactRenderer< MentionSuggestionListRef, MentionSuggestionListProps - > | null = null - - const positionPopup = () => { - const clientRect = currentProps?.clientRect?.() - if (!popup || !clientRect) return + >({ + rendererComponent: MentionSuggestionList, + popupClassName: + "fixed z-50 w-60 overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md", + popupDataAttribute: "mentionSuggestionOpen", + popupFallbackWidth: 240, + popupFallbackHeight: 240, + }) + }, + } +} - const popupWidth = popup.offsetWidth || 240 - const popupHeight = popup.offsetHeight || 240 - const maxLeft = Math.max( - POPUP_HORIZONTAL_PADDING, - window.innerWidth - popupWidth - POPUP_HORIZONTAL_PADDING - ) - const left = Math.min( - Math.max(POPUP_HORIZONTAL_PADDING, clientRect.left), - maxLeft - ) - const top = Math.max( - POPUP_VERTICAL_PADDING, - clientRect.top - popupHeight - POPUP_GAP +function createSlashCommandSuggestion(): Omit< + SuggestionOptions, + "editor" +> { + return { + pluginKey: SLASH_COMMAND_PLUGIN_KEY, + char: "/", + items: ({ query }) => { + const normalized = query.trim().toLowerCase() + const results = SLASH_COMMANDS.filter((command) => { + if (!normalized) return true + return ( + command.label.toLowerCase().includes(normalized) || + command.search?.toLowerCase().includes(normalized) ) - - popup.style.left = `${left}px` - popup.style.top = `${top}px` + }) + return results.slice(0, MAX_SLASH_RESULTS) + }, + command: ({ editor, range, props }) => { + if (props.id !== "code-block") { + return } - const cleanup = () => { - window.removeEventListener("resize", positionPopup) - window.removeEventListener("scroll", positionPopup, true) - reactRenderer?.destroy() - reactRenderer = null - popup?.remove() - popup = null - currentProps = null + editor + .chain() + .focus() + .insertContentAt(range, { + type: "codeBlock", + attrs: { language: DEFAULT_CODE_BLOCK_LANGUAGE }, + }) + .run() + }, + allow: ({ editor }) => { + if (!editor.isEditable) return false + return !editor.isActive("codeBlock") + }, + render: () => { + return createSuggestionPopupManager< + SlashCommandItem, + SlashCommandListRef, + SlashCommandListProps + >({ + rendererComponent: SlashCommandList, + popupClassName: + "fixed z-50 w-72 overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md", + popupDataAttribute: "slashSuggestionOpen", + popupFallbackWidth: 288, + popupFallbackHeight: 240, + }) + }, + } +} + +function createSlashCommandExtension( + suggestion: Omit< + SuggestionOptions, + "editor" + > +) { + return Extension.create({ + name: "slashCommand", + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + ...suggestion, + }), + ] + }, + }) +} + +function getCodeBlockLanguageValue(language: unknown) { + if (typeof language !== "string") { + return DEFAULT_CODE_BLOCK_LANGUAGE + } + + const normalized = language.trim().toLowerCase() + if (!normalized) { + return DEFAULT_CODE_BLOCK_LANGUAGE + } + + const supported = CODE_BLOCK_LANGUAGE_OPTIONS.some( + (option) => option.value === normalized + ) + + if (supported) { + return normalized + } + + return DEFAULT_CODE_BLOCK_LANGUAGE +} + +function getActiveCodeBlockRect(editor: { + state: { + doc: { + nodeAt: (pos: number) => { attrs?: { language?: unknown } } | null + } + selection: { + $from: { + depth: number + node: (depth: number) => { type: { name: string } } + before: (depth: number) => number } + } + } + view: { + nodeDOM: (pos: number) => Node | null + } +}) { + const { $from } = editor.state.selection - return { - onStart: (props) => { - cleanup() - currentProps = props - - popup = document.createElement("div") - popup.className = - "fixed z-50 w-60 overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md" - popup.dataset.mentionSuggestionOpen = "true" - document.body.append(popup) - - reactRenderer = new ReactRenderer(MentionSuggestionList, { - editor: props.editor, - props: { - items: props.items, - command: props.command, - }, - }) - - popup.append(reactRenderer.element) - window.addEventListener("resize", positionPopup) - window.addEventListener("scroll", positionPopup, true) - positionPopup() - }, - onUpdate: (props) => { - currentProps = props - reactRenderer?.updateProps({ - items: props.items, - command: props.command, - }) - positionPopup() - }, - onKeyDown: (props) => { - if (props.event.key === "Escape") { - props.event.preventDefault() - cleanup() - return true - } - - const suggestionList = - reactRenderer?.ref as MentionSuggestionListRef | null - return suggestionList?.onKeyDown(props) ?? false - }, - onExit: cleanup, + for (let depth = $from.depth; depth > 0; depth -= 1) { + if ($from.node(depth).type.name !== "codeBlock") continue + + const domNode = editor.view.nodeDOM($from.before(depth)) + if (!(domNode instanceof HTMLElement)) continue + + const rect = domNode.getBoundingClientRect() + return new DOMRect(rect.left + 8, rect.top + 8, 1, 1) + } + + return null +} + +function getActiveCodeBlockPos(editor: { + state: { + selection: { + $from: { + depth: number + node: (depth: number) => { type: { name: string } } + before: (depth: number) => number } - }, + } } +}) { + const { $from } = editor.state.selection + + for (let depth = $from.depth; depth > 0; depth -= 1) { + if ($from.node(depth).type.name === "codeBlock") { + return $from.before(depth) + } + } + + return null } export function MessageInput({ @@ -217,6 +461,15 @@ export function MessageInput({ () => createMentionSuggestion(() => mentionCandidatesRef.current), [] ) + // Slash commands temporarily disabled. + // const slashCommandSuggestion = useMemo( + // () => createSlashCommandSuggestion(), + // [] + // ) + // const slashCommandExtension = useMemo( + // () => createSlashCommandExtension(slashCommandSuggestion), + // [slashCommandSuggestion] + // ) const editor = useEditor( { @@ -224,7 +477,6 @@ export function MessageInput({ StarterKit.configure({ heading: false, blockquote: false, - codeBlock: false, horizontalRule: false, }), Markdown, @@ -236,11 +488,12 @@ export function MessageInput({ `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`, suggestion: mentionSuggestion, }), + // slashCommandExtension, ], 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", + "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", }, }, onCreate: ({ editor: tiptapEditor }) => { @@ -292,15 +545,15 @@ export function MessageInput({ return } - const isMentionSuggestionOpen = () => - Boolean(document.querySelector("[data-mention-suggestion-open='true']")) + const isSuggestionMenuOpen = () => + Boolean(document.querySelector(SUGGESTION_MENU_SELECTOR)) const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Enter" && !event.shiftKey) { if (event.isComposing) { return } - if (isMentionSuggestionOpen()) { + if (isSuggestionMenuOpen()) { return } event.preventDefault() @@ -322,6 +575,68 @@ export function MessageInput({ trimmedValue.length <= MAX_MESSAGE_LENGTH && !isSending const isEmpty = trimmedValue.length === 0 + const markState = useEditorState({ + editor, + selector: ({ editor: tiptapEditor }) => { + if (!tiptapEditor) { + return { + isBoldActive: false, + isCodeBlockActive: false, + codeBlockPos: null, + isCodeActive: false, + codeBlockLanguage: DEFAULT_CODE_BLOCK_LANGUAGE, + isItalicActive: false, + isStrikeActive: false, + } + } + + return { + isBoldActive: tiptapEditor.isActive("bold"), + isCodeBlockActive: tiptapEditor.isActive("codeBlock"), + codeBlockPos: getActiveCodeBlockPos(tiptapEditor), + isCodeActive: tiptapEditor.isActive("code"), + codeBlockLanguage: getCodeBlockLanguageValue( + tiptapEditor.getAttributes("codeBlock").language + ), + isItalicActive: tiptapEditor.isActive("italic"), + isStrikeActive: tiptapEditor.isActive("strike"), + } + }, + }) + const isBoldActive = markState?.isBoldActive ?? false + const isCodeBlockActive = markState?.isCodeBlockActive ?? false + const codeBlockPos = markState?.codeBlockPos ?? null + const isCodeActive = markState?.isCodeActive ?? false + const codeBlockLanguage = + markState?.codeBlockLanguage ?? DEFAULT_CODE_BLOCK_LANGUAGE + const isItalicActive = markState?.isItalicActive ?? false + const isStrikeActive = markState?.isStrikeActive ?? false + + const getMarkButtonClassName = (isActive: boolean) => + cn( + "size-7 border border-transparent text-muted-foreground", + "hover:text-foreground", + isActive && + "border-primary/30 bg-primary/15 text-primary hover:bg-primary/20 hover:text-primary" + ) + + const handleCodeBlockLanguageChange = useCallback( + (language: string) => { + if (!editor || codeBlockPos === null) return + + const codeBlockNode = editor.state.doc.nodeAt(codeBlockPos) + if (!codeBlockNode || codeBlockNode.type.name !== "codeBlock") return + + editor.view.dispatch( + editor.state.tr.setNodeMarkup(codeBlockPos, undefined, { + ...codeBlockNode.attrs, + language, + }) + ) + editor.commands.focus() + }, + [codeBlockPos, editor] + ) return (
@@ -377,6 +692,182 @@ export function MessageInput({ {placeholder} )} + {editor && ( + document.body} + shouldShow={({ editor: tiptapEditor, element }) => { + const activeElement = + typeof document !== "undefined" + ? document.activeElement + : null + + return ( + tiptapEditor.isEditable && + (tiptapEditor.isActive("codeBlock") || + (activeElement ? element.contains(activeElement) : false)) + ) + }} + getReferencedVirtualElement={() => { + const rect = getActiveCodeBlockRect(editor) + if (!rect) return null + + return { + getBoundingClientRect: () => rect, + } + }} + options={{ + strategy: "fixed", + placement: "bottom-start", + offset: 0, + flip: true, + shift: true, + }} + className="z-50 rounded-md border border-border/70 bg-background/95 p-1 shadow-sm backdrop-blur" + > +
+ + Lang + + +
+
+ )} + {editor && ( + document.body} + shouldShow={({ editor: tiptapEditor, state, from, to }) => { + if (!tiptapEditor.isEditable) return false + if (state.selection.empty || from === to) return false + if (tiptapEditor.isActive("codeBlock")) return false + return state.doc.textBetween(from, to).trim().length > 0 + }} + getReferencedVirtualElement={() => { + const { selection } = editor.state + if (selection.empty) return null + + const { left, right, top, bottom } = editor.view.coordsAtPos( + selection.head + ) + + return { + getBoundingClientRect: () => + new DOMRect( + left, + top, + Math.max(1, right - left), + Math.max(1, bottom - top) + ), + } + }} + options={{ + strategy: "fixed", + placement: "top", + offset: 8, + flip: true, + shift: true, + }} + className="z-50 flex items-center gap-1 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md" + > + + + + + + )}
diff --git a/apps/web/src/components/chat/composer/slash-command-list.tsx b/apps/web/src/components/chat/composer/slash-command-list.tsx new file mode 100644 index 0000000..a241cef --- /dev/null +++ b/apps/web/src/components/chat/composer/slash-command-list.tsx @@ -0,0 +1,126 @@ +import { cn } from "@repo/ui/lib/utils" +import type { SuggestionKeyDownProps } from "@tiptap/suggestion" +import { Code2 } from "lucide-react" +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useState, +} from "react" + +export interface SlashCommandItem { + id: string + label: string + description: string + language?: string + search?: string +} + +export interface SlashCommandListRef { + onKeyDown: (props: SuggestionKeyDownProps) => boolean +} + +export interface SlashCommandListProps { + items: SlashCommandItem[] + command: (item: SlashCommandItem) => void +} + +function CommandIcon({ id }: { id: SlashCommandItem["id"] }) { + if (id.startsWith("code-block")) { + return + } + + return null +} + +export const SlashCommandList = forwardRef< + SlashCommandListRef, + SlashCommandListProps +>(function SlashCommandList({ items, command }, ref) { + const [selectedIndex, setSelectedIndex] = useState(0) + + const selectItem = useCallback( + (index: number) => { + const item = items[index] + if (!item) return false + command(item) + return true + }, + [items, command] + ) + + useEffect(() => { + setSelectedIndex(0) + }, [items]) + + useImperativeHandle( + ref, + () => ({ + onKeyDown: ({ event }) => { + if (items.length === 0) return false + + if (event.key === "ArrowDown") { + event.preventDefault() + setSelectedIndex((currentIndex) => (currentIndex + 1) % items.length) + return true + } + + if (event.key === "ArrowUp") { + event.preventDefault() + setSelectedIndex( + (currentIndex) => (currentIndex + items.length - 1) % items.length + ) + return true + } + + if (event.key === "Enter") { + event.preventDefault() + return selectItem(selectedIndex) + } + + return false + }, + }), + [items.length, selectedIndex, selectItem] + ) + + if (items.length === 0) { + return ( +
+ No commands +
+ ) + } + + return ( +
+ {items.map((item, index) => ( + + ))} +
+ ) +}) diff --git a/apps/web/src/components/chat/date-divider.tsx b/apps/web/src/components/chat/date-divider.tsx new file mode 100644 index 0000000..ca35af0 --- /dev/null +++ b/apps/web/src/components/chat/date-divider.tsx @@ -0,0 +1,18 @@ +import { formatDateDivider } from "@repo/utils/date" + +interface DateDividerProps { + date: Date | string +} + +export function DateDivider({ date }: DateDividerProps) { + return ( +
+
+
+ + {formatDateDivider(date)} + +
+
+ ) +} diff --git a/apps/web/src/components/chat/emoji-reaction-picker.tsx b/apps/web/src/components/chat/emoji-reaction-picker.tsx new file mode 100644 index 0000000..7191f2d --- /dev/null +++ b/apps/web/src/components/chat/emoji-reaction-picker.tsx @@ -0,0 +1,147 @@ +import { Button } from "@repo/ui/components/button" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@repo/ui/components/popover" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@repo/ui/components/tooltip" +import { Plus, SmilePlus } from "lucide-react" +import { useState } from "react" + +const QUICK_EMOJIS = ["👍", "❤️", "😂", "😮", "😢", "🙏", "🎉", "👀"] + +const EXTENDED_EMOJIS = [ + "😀", + "😄", + "😁", + "😆", + "😊", + "😉", + "😍", + "🥰", + "😘", + "😎", + "🤓", + "🤩", + "😮", + "😢", + "😭", + "😡", + "🤯", + "😱", + "🥳", + "👏", + "🙌", + "🙏", + "💪", + "👀", + "🔥", + "✨", + "⭐", + "💯", + "❤️", + "💔", + "👍", + "👎", + "👌", + "✌️", + "👋", + "🎉", + "✅", + "❌", + "🚀", + "💡", +] + +interface EmojiReactionPickerProps { + onSelect?: (emoji: string) => void +} + +export function EmojiReactionPicker({ onSelect }: EmojiReactionPickerProps) { + const [open, setOpen] = useState(false) + const [showExtended, setShowExtended] = useState(false) + + const handleSelect = (emoji: string) => { + onSelect?.(emoji) + setOpen(false) + setShowExtended(false) + } + + return ( + { + setOpen(nextOpen) + if (!nextOpen) { + setShowExtended(false) + } + }} + > + + + + + + + {!open && Add reaction} + + + event.preventDefault()} + > + {showExtended ? ( +
+ {EXTENDED_EMOJIS.map((emoji) => ( + + ))} +
+ ) : ( +
+ {QUICK_EMOJIS.map((emoji) => ( + + ))} + +
+ )} +
+
+ ) +} diff --git a/apps/web/src/components/chat/message-action-bar.tsx b/apps/web/src/components/chat/message-action-bar.tsx new file mode 100644 index 0000000..1ebf404 --- /dev/null +++ b/apps/web/src/components/chat/message-action-bar.tsx @@ -0,0 +1,74 @@ +import { Button } from "@repo/ui/components/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@repo/ui/components/dropdown-menu" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@repo/ui/components/tooltip" +import { MessageSquarePlus, MoreHorizontal } from "lucide-react" +import { EmojiReactionPicker } from "./emoji-reaction-picker" + +interface MessageActionBarProps { + onReact?: (emoji: string) => void + onReply?: () => void + onCopyText?: () => void +} + +export function MessageActionBar({ + onReact, + onReply, + onCopyText, +}: MessageActionBarProps) { + return ( +
+ + + + + + + Reply + + + + + + + + + + More actions + + + + Copy text + + More actions soon + + +
+ ) +} diff --git a/apps/web/src/components/chat/message-item.tsx b/apps/web/src/components/chat/message-item.tsx index 0c6e963..f997382 100644 --- a/apps/web/src/components/chat/message-item.tsx +++ b/apps/web/src/components/chat/message-item.tsx @@ -1,6 +1,8 @@ import { Avatar, AvatarFallback, AvatarImage } from "@repo/ui/components/avatar" import { formatTime } from "@repo/utils/date" +import { useCallback } from "react" import type { Message } from "@/lib/api-types" +import { MessageActionBar } from "./message-action-bar" import { MessageMarkdown } from "./message-markdown" interface MessageItemProps { @@ -15,9 +17,17 @@ function nameInitial(name: string) { export function MessageItem({ message, showHeader }: MessageItemProps) { const author = message.author + const handleCopyText = useCallback(() => { + if (!message.content) return + + void navigator.clipboard.writeText(message.content).catch(() => {}) + }, [message.content]) return ( -
+
+
+ +
{showHeader ? ( {author.image && } @@ -26,7 +36,11 @@ export function MessageItem({ message, showHeader }: MessageItemProps) { ) : ( -
+
+ + {formatTime(message.createdAt)} + +
)}
{showHeader && ( diff --git a/apps/web/src/components/chat/message-list.tsx b/apps/web/src/components/chat/message-list.tsx index 0e5cea1..ebb6502 100644 --- a/apps/web/src/components/chat/message-list.tsx +++ b/apps/web/src/components/chat/message-list.tsx @@ -1,7 +1,9 @@ import { Skeleton } from "@repo/ui/components/skeleton" +import { differenceInMinutes, isSameDay } from "@repo/utils/date" import { Hash, User, Users } from "lucide-react" import { useCallback, useEffect, useRef } from "react" import type { Message } from "@/lib/api-types" +import { DateDivider } from "./date-divider" import type { ChatContext } from "./header" import { MessageItem } from "./message-item" @@ -50,6 +52,7 @@ const MESSAGE_SKELETON_GROUPS = [ { key: "f", nameWidth: "4.5rem", lines: ["65%"] }, { key: "g", nameWidth: "6rem", lines: ["85%", "40%"] }, ] +const MESSAGE_GROUP_WINDOW_MINUTES = 5 export function MessageList({ context, @@ -115,13 +118,27 @@ export function MessageList({
{messages.map((msg, i) => { const next = messages[i + 1] - const showHeader = !next || next.authorId !== msg.authorId + const isDateBoundary = + !next || !isSameDay(msg.createdAt, next.createdAt) + const isWithinGroupWindow = + !!next && + differenceInMinutes(msg.createdAt, next.createdAt) <= + MESSAGE_GROUP_WINDOW_MINUTES + const showHeader = + isDateBoundary || + !next || + next.authorId !== msg.authorId || + !isWithinGroupWindow + return ( - +
+ {isDateBoundary && } + +
) })} {hasMore && ( diff --git a/apps/web/src/components/chat/message-markdown.tsx b/apps/web/src/components/chat/message-markdown.tsx index b542314..ed3c96b 100644 --- a/apps/web/src/components/chat/message-markdown.tsx +++ b/apps/web/src/components/chat/message-markdown.tsx @@ -6,15 +6,139 @@ import { HoverCardTrigger, } from "@repo/ui/components/hover-card" import { cn } from "@repo/ui/lib/utils" -import { useMemo } from "react" +import { Check, Code2, Copy } from "lucide-react" +import { + Children, + isValidElement, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react" import ReactMarkdown, { defaultUrlTransform } from "react-markdown" +import rehypeHighlight from "rehype-highlight" import remarkGfm from "remark-gfm" +import "highlight.js/styles/github-dark-dimmed.css" import type { Message } from "@/lib/api-types" const USER_MENTION_TOKEN_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 TIPTAP_MENTION_REGEX = /\[@[^\]]*?\bid="([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 EVERYONE_MENTION_REGEX = /(^|\s)@everyone\b/gi +const LANGUAGE_CLASS_REGEX = /language-([a-z0-9-]+)/i + +function getTextContent(node: ReactNode): string { + if (typeof node === "string") return node + if (typeof node === "number") return String(node) + if (!node) return "" + + if (Array.isArray(node)) { + return node.map((child) => getTextContent(child)).join("") + } + + if (isValidElement<{ children?: ReactNode }>(node)) { + return getTextContent(node.props.children) + } + + return "" +} + +function parseCodeBlock(children: ReactNode) { + const firstChild = Children.toArray(children).find((child) => + isValidElement<{ className?: string; children?: ReactNode }>(child) + ) + + if ( + !firstChild || + !isValidElement<{ className?: string; children?: ReactNode }>(firstChild) + ) { + return { + code: getTextContent(children).replace(/\n$/, ""), + language: "text", + } + } + + const className = + typeof firstChild.props.className === "string" + ? firstChild.props.className + : "" + const language = className.match(LANGUAGE_CLASS_REGEX)?.[1] ?? "text" + const code = getTextContent(firstChild.props.children).replace(/\n$/, "") + + return { code, language } +} + +function CodeBlock({ children }: { children?: ReactNode }) { + const [copied, setCopied] = useState(false) + const clearCopiedTimerRef = useRef(null) + + const { code, language } = useMemo( + () => parseCodeBlock(children ?? null), + [children] + ) + + useEffect(() => { + return () => { + if (clearCopiedTimerRef.current !== null) { + window.clearTimeout(clearCopiedTimerRef.current) + } + } + }, []) + + const handleCopy = useCallback(async () => { + if (!code) return + + try { + await navigator.clipboard.writeText(code) + setCopied(true) + + if (clearCopiedTimerRef.current !== null) { + window.clearTimeout(clearCopiedTimerRef.current) + } + clearCopiedTimerRef.current = window.setTimeout(() => { + setCopied(false) + }, 1200) + } catch { + // No-op if clipboard access is unavailable. + } + }, [code]) + + const languageLabel = + language.toLowerCase() === "plaintext" ? "plain text" : language + + return ( +
+
+ + + {languageLabel} + + +
+
{children}
+
+ ) +} function escapeMarkdownText(value: string) { return value.replace(/\\/g, "\\\\").replace(/\[/g, "\\[").replace(/]/g, "\\]") @@ -40,14 +164,16 @@ function toRenderableMarkdown( (_match, userId: string) => `<@${userId}>` ) - return normalizedContent.replace( - USER_MENTION_TOKEN_REGEX, - (_match, userId: string) => { + return normalizedContent + .replace( + EVERYONE_MENTION_REGEX, + (_match, prefix: string) => `${prefix}[@everyone](mention:everyone)` + ) + .replace(USER_MENTION_TOKEN_REGEX, (_match, userId: string) => { const mention = mentionById.get(userId) const label = mention ? getMentionLabel(mention) : "unknown-user" return `[@${escapeMarkdownText(label)}](mention:${userId})` - } - ) + }) } interface MessageMarkdownProps { @@ -79,12 +205,13 @@ export function MessageMarkdown({
{ if (url.startsWith("mention:")) { return url @@ -96,6 +223,14 @@ export function MessageMarkdown({ a: ({ href, children }) => { if (href?.startsWith("mention:")) { const mentionId = href.slice("mention:".length) + if (mentionId === "everyone") { + return ( + + {children} + + ) + } + const mention = mentionById.get(mentionId) const displayName = mention ? getMentionLabel(mention) @@ -157,6 +292,7 @@ export function MessageMarkdown({ ) }, + pre: ({ children }) => {children}, }} > {markdown} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 6fe9759..82c14d2 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,3 +1,4 @@ +import { TooltipProvider } from "@repo/ui/components/tooltip" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { ReactQueryDevtools } from "@tanstack/react-query-devtools" import { createRouter, RouterProvider } from "@tanstack/react-router" @@ -36,7 +37,9 @@ createRoot(rootElement).render( enableSystem disableTransitionOnChange > - + + + diff --git a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx index 090b823..45fa2ef 100644 --- a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx +++ b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx @@ -202,15 +202,22 @@ function ChannelView() { ) const mentionCandidates = useMemo( - () => - guildMembersData?.members.map((member) => ({ + () => [ + { + id: "everyone", + label: "everyone", + name: "everyone", + search: "everyone all members", + }, + ...(guildMembersData?.members.map((member) => ({ id: member.userId, label: member.displayUsername ?? member.username ?? member.name, name: member.name, username: member.username, displayUsername: member.displayUsername, image: member.image, - })) ?? [], + })) ?? []), + ], [guildMembersData?.members] ) diff --git a/packages/utils/src/date.ts b/packages/utils/src/date.ts index 92e3842..844a9c6 100644 --- a/packages/utils/src/date.ts +++ b/packages/utils/src/date.ts @@ -46,6 +46,43 @@ export function formatTime(date: Date | string): string { return dt.toLocaleString(DateTime.TIME_SIMPLE) } +/** + * Returns absolute difference between two timestamps in minutes. + */ +export function differenceInMinutes( + date1: Date | string, + date2: Date | string +): number { + const dt1 = toDateTime(date1) + const dt2 = toDateTime(date2) + + return Math.abs(dt1.diff(dt2, "minutes").minutes) +} + +/** + * Check if two dates are on the same local day. + */ +export function isSameDay(date1: Date | string, date2: Date | string): boolean { + const dt1 = toDateTime(date1).toLocal() + const dt2 = toDateTime(date2).toLocal() + + return dt1.hasSame(dt2, "day") +} + +/** + * Formats a date label for message list dividers. + * Returns values like "Today", "Yesterday", or "Monday, December 20, 2024". + */ +export function formatDateDivider(date: Date | string): string { + const dt = toDateTime(date).toLocal() + const now = DateTime.now().toLocal() + + if (dt.hasSame(now, "day")) return "Today" + if (dt.hasSame(now.minus({ days: 1 }), "day")) return "Yesterday" + + return dt.toFormat("cccc, LLLL d, yyyy") +} + /** * Groups timestamps by day label ("Today", "Yesterday", "Jan 1, 2024"). */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36edb4d..9a2c9a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -178,6 +178,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + highlight.js: + specifier: ^11.11.1 + version: 11.11.1 lucide-react: specifier: ^0.563.0 version: 0.563.0(react@19.2.4) @@ -202,6 +205,9 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.13)(react@19.2.4) + rehype-highlight: + specifier: ^7.0.2 + version: 7.0.2 remark-gfm: specifier: ^4.0.1 version: 4.0.1 @@ -3729,9 +3735,15 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} @@ -3741,6 +3753,10 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + hono-pino@0.8.0: resolution: {integrity: sha512-JFQcOIHApa22NUqZpyNCYf/ni2kwnn85vABjpAzssiQeEt7rkN/sRW19Z+WQzdHDluZVSNnF6Mk7nY13MJzJDA==} engines: {node: '>=18'} @@ -4050,6 +4066,9 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + lowlight@3.3.0: + resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -4749,6 +4768,9 @@ packages: redis@4.7.1: resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==} + rehype-highlight@7.0.2: + resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -5198,6 +5220,9 @@ packages: unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -8380,6 +8405,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -8400,6 +8429,13 @@ snapshots: transitivePeerDependencies: - supports-color + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 @@ -8408,6 +8444,8 @@ snapshots: help-me@5.0.0: {} + highlight.js@11.11.1: {} + hono-pino@0.8.0(hono@4.11.9)(pino@9.14.0): dependencies: defu: 6.1.4 @@ -8625,6 +8663,12 @@ snapshots: longest-streak@3.1.0: {} + lowlight@3.3.0: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + highlight.js: 11.11.1 + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -9635,6 +9679,14 @@ snapshots: '@redis/search': 1.2.0(@redis/client@1.6.1) '@redis/time-series': 1.1.0(@redis/client@1.6.1) + rehype-highlight@7.0.2: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-text: 4.0.2 + lowlight: 3.3.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -10203,6 +10255,11 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3