diff --git a/apps/web/src/components/common/BaseThreadMessage.tsx b/apps/web/src/components/common/BaseThreadMessage.tsx index a834c5b50..e19bb2762 100644 --- a/apps/web/src/components/common/BaseThreadMessage.tsx +++ b/apps/web/src/components/common/BaseThreadMessage.tsx @@ -48,7 +48,7 @@ export const BaseThreadMessage = ({ went inert). Desktop keeps hover cards: hover never conflicts with click navigation. Same rule as MessageResultBlock. */}
- +
{/* List rows stay compact: the doctype + id line, not the full card. Darker ink than the default — this line is part of the row's diff --git a/apps/web/src/components/common/EmojiFace.tsx b/apps/web/src/components/common/EmojiFace.tsx new file mode 100644 index 000000000..d6b0ba99e --- /dev/null +++ b/apps/web/src/components/common/EmojiFace.tsx @@ -0,0 +1,16 @@ +import type { QuickEmoji } from "@utils/preferences" + +/** + * A QuickEmoji face: custom emojis are plain images; native ones render via + * em-emoji from the Apple set (initialized in App.tsx) so reactions look the + * same on every platform. Sized relative to the host's font size — put + * text-2xl (or similar) on the wrapping button to scale it. + */ +export const EmojiFace = ({ emoji }: { emoji: QuickEmoji }) => + emoji.src ? ( + + ) : ( + + ) diff --git a/apps/web/src/components/common/MessageResultBlock/MessageResultBlock.tsx b/apps/web/src/components/common/MessageResultBlock/MessageResultBlock.tsx index 099b09c24..26f0dc7e7 100644 --- a/apps/web/src/components/common/MessageResultBlock/MessageResultBlock.tsx +++ b/apps/web/src/components/common/MessageResultBlock/MessageResultBlock.tsx @@ -97,7 +97,7 @@ const MessageResultBlockInner = ({ message, user, channel, dmChannel, peer, work search list would fire get_poll per row and drop the highlight. */} {message.message_type === "Poll" ? - : } + : } diff --git a/apps/web/src/components/features/message/ThreadRootMessage.tsx b/apps/web/src/components/features/message/ThreadRootMessage.tsx index 1b65b801d..5531c1e0d 100644 --- a/apps/web/src/components/features/message/ThreadRootMessage.tsx +++ b/apps/web/src/components/features/message/ThreadRootMessage.tsx @@ -136,18 +136,8 @@ export const ThreadRootMessage = ({ threadID, parentID }: { threadID: string; pa )} ) : ( - <> - - {/* MessageContent doesn't know about linked documents — - MessageItem renders the card in the stream, so this - surface must too. */} - {linkedDocMember && ( - - )} - + // MessageContent renders the linked-document card itself. + ) ) : ( // The whole collapsed preview is a click target for diff --git a/apps/web/src/components/features/message/actions/MessageActionMenu.tsx b/apps/web/src/components/features/message/actions/MessageActionMenu.tsx index 87da1f30f..642f1e9de 100644 --- a/apps/web/src/components/features/message/actions/MessageActionMenu.tsx +++ b/apps/web/src/components/features/message/actions/MessageActionMenu.tsx @@ -109,8 +109,9 @@ export const MessageActionMenu = ({ const lastTapRef = useRef({ messageID: "", time: 0 }) const menuOpenedAtRef = useRef(0) const wrapperRef = useRef(null) - /** Hovered message + its toolbar position; null hides the toolbar. */ - const [hovered, setHovered] = useState<{ message: Message; top: number } | null>(null) + /** Hovered message + its toolbar position (top; one of left/right anchors + * it); null hides the toolbar. */ + const [hovered, setHovered] = useState<{ message: Message; top: number; left?: number; right?: number } | null>(null) /** While the toolbar's ellipsis menu is open, hover-clearing is suspended. */ const toolbarMenuOpenRef = useRef(false) @@ -157,8 +158,38 @@ export const MessageActionMenu = ({ const showToolbarFor = (message: Message, element: HTMLElement) => { if (!wrapperRef.current) return - const top = element.getBoundingClientRect().top - wrapperRef.current.getBoundingClientRect().top - 14 - setHovered({ message, top: Math.max(top, 2) }) + const wrapperRect = wrapperRef.current.getBoundingClientRect() + // Resolve the ROW SHELL — `element` can be an inner node (image tile) + // or an outer wrapper (batch root). + const row = + (element.closest("[data-message-row]") as HTMLElement | null) ?? + (element.querySelector("[data-message-row]") as HTMLElement | null) ?? + element + // The row says how it's aligned (data-message-row, set by MessageRow) — + // no class sniffing. Rows are full width in every mode, and the toolbar + // sits 24px above the row in the corner OPPOSITE the message's side, so + // its overlap always lands on empty row space: + // - "own" content hugs the right → toolbar top-LEFT. + // - everyone else's hugs the left → toolbar top-RIGHT, which is the + // same corner Simple mode has always used. + // The lower half overlaps the row, so the pointer reaches the toolbar + // without leaving the row — it can't flicker away en route. + const rect = row.getBoundingClientRect() + const top = Math.max(rect.top - wrapperRect.top - 24, 2) + const mode = row.getAttribute("data-message-row") + if (mode === "own") { + setHovered({ + message, + top, + left: Math.max(rect.left - wrapperRect.left + 16, 16), + }) + return + } + setHovered({ + message, + top, + right: Math.max(wrapperRect.right - rect.right + 16, 16), + }) } /** Desktop: tracks which message the pointer is over and positions the floating toolbar. */ @@ -578,6 +609,8 @@ export const MessageActionMenu = ({ diff --git a/apps/web/src/components/features/message/actions/MessageHoverToolbar.tsx b/apps/web/src/components/features/message/actions/MessageHoverToolbar.tsx index 7f6efe793..f4211c97b 100644 --- a/apps/web/src/components/features/message/actions/MessageHoverToolbar.tsx +++ b/apps/web/src/components/features/message/actions/MessageHoverToolbar.tsx @@ -34,13 +34,19 @@ import { useIsMobile } from "@hooks/use-mobile" export const MessageHoverToolbar = ({ message, top, + left, + right, canInteract = true, onMenuOpenChange, onOpenFullMenu, }: { message: Message - /** Offset from the stream wrapper's top, computed at hover time. */ + /** The toolbar's TOP, offset from the stream wrapper's top. */ top: number + /** Horizontal anchor, offset from the wrapper's edge. One of the two is + * set: `left` for own Left-Right rows, `right` for everything else. */ + left?: number + right?: number /** From the host's composer gate — see MessageActionMenu. */ canInteract?: boolean /** Lets the hover tracker keep the toolbar mounted while the menu is open. */ @@ -86,8 +92,8 @@ export const MessageHoverToolbar = ({ data-message-id={message.name} data-hover-toolbar // z-30: must float above the sticky date separators (z-20) - className="absolute right-4 z-40 flex items-center gap-0.5 rounded-md border border-outline-gray-2 bg-surface-base p-0.5 shadow-xs" - style={{ top }} + className="absolute z-40 flex items-center gap-0.5 rounded-md border border-outline-gray-2 bg-surface-base p-0.5 shadow-xs" + style={{ top, left, right }} > {/* One provider for the whole toolbar: after the first tooltip shows, moving across the icons shows the next ones instantly (skip delay) — diff --git a/apps/web/src/components/features/message/renderers/BatchMessageItem.tsx b/apps/web/src/components/features/message/renderers/BatchMessageItem.tsx index e0438efdb..fa2366646 100644 --- a/apps/web/src/components/features/message/renderers/BatchMessageItem.tsx +++ b/apps/web/src/components/features/message/renderers/BatchMessageItem.tsx @@ -9,11 +9,13 @@ import { EditableMessageBody, MessageAttributes } from "./MessageContent" import { MessageLinkPreview } from "./LinkPreview" import { MessageReactionsRow } from "./MessageReactions" import { MessageRow, MessageSenderLayout } from "./MessageRow" -import { MessageThreadPill } from "./ThreadMessage" +import { cn } from "@lib/utils" +import { MessageThreadPill, ThreadConnector } from "./ThreadMessage" import ReplyMessage from "./ReplyMessage" -import { OptimisticStatus, optimisticRowClass } from "./OptimisticStatus" +import { FailedSendIndicator, OptimisticStatus, optimisticRowClass, sendingDimClass } from "./OptimisticStatus" import { getAttachmentKind, messagesToAttachments } from "@utils/attachmentPreview" import { isThreadParent, parseRepliedMessageDetails } from "@utils/messageUtils" +import { useMessageAlignment } from "@hooks/useChatStyle" import type { RepliedMessageDetails } from "./RepliedMessagePreview" import type { MessageBatchBlock } from "@stores/messages/types" import type { Message } from "@raven/types/common/Message" @@ -78,6 +80,10 @@ export const BatchMessageItem = ({ const head = block.messages[0] const newest = block.messages[block.messages.length - 1] + const owner = head.is_bot_message ? head.bot || '' : head.owner + // See MessageItem — own rows have no avatar column for the connector. + const { isLeftRight, isOwn } = useMessageAlignment(owner) + // The selector keeps at most one thread parent in a batch (it splits 2+ into individual // messages), so find that member wherever it sits and show its pill + connector. v3 batch // actions create the thread on the NEWEST member (see blockFromEvent), but finding it by @@ -129,7 +135,18 @@ export const BatchMessageItem = ({ )) const content = ( -
+ // Bubble mode is a flex column: media, caption bubble and cards each + // keep their own width and align to the message's side. +
{/* Badges sit above everything, same as a single message — the flags describe the whole block (a forwarded batch arrives all-forwarded). */} @@ -141,7 +158,7 @@ export const BatchMessageItem = ({ /> )} - {captionMember && } + {captionMember && } {/* Links live on the caption member (the server extracts them from its text), so that's where the first-link preview hangs off a batch too. */} {captionMember && } @@ -149,25 +166,36 @@ export const BatchMessageItem = ({ )} - {memberReactions} + {!isLeftRight && memberReactions}
) return ( - - {threadMember &&
} + + {threadMember && } {memberReactions} : undefined} + footer={threadMember && isOwn ? : undefined} + statusIcon={isOwn ? : undefined} > {content} - {threadMember && } + {threadMember && !isOwn && } ) } diff --git a/apps/web/src/components/features/message/renderers/DocumentLinkRenderer.tsx b/apps/web/src/components/features/message/renderers/DocumentLinkRenderer.tsx index 48703d03b..c7135d5c5 100644 --- a/apps/web/src/components/features/message/renderers/DocumentLinkRenderer.tsx +++ b/apps/web/src/components/features/message/renderers/DocumentLinkRenderer.tsx @@ -185,12 +185,15 @@ export const documentPreviewSwrKey = (doctype: string, docname: string) => * image box). The preview DATA waits for visibility, poll-style, so a channel * full of linked documents only fetches what the user actually sees. */ -export const DocumentLinkRenderer = ({ doctype, docname }: { doctype: string; docname: string }) => { +export const DocumentLinkRenderer = ({ doctype, docname, className }: { doctype: string; docname: string; className?: string }) => { const { meta, workflowDoc } = useDoctypeMeta(doctype) const { ref, hasBeenInView } = useHasBeenInView() + // w-full stretches the card in the classic full-width row. Inside a + // fit-content column (Left-Right mode) w-full collapses to the content, + // so those callers pass a fixed width via className instead. return ( -
+
{hasBeenInView ? ( ) : ( diff --git a/apps/web/src/components/features/message/renderers/MessageContent.tsx b/apps/web/src/components/features/message/renderers/MessageContent.tsx index 0bb3c9bb2..9afeca919 100644 --- a/apps/web/src/components/features/message/renderers/MessageContent.tsx +++ b/apps/web/src/components/features/message/renderers/MessageContent.tsx @@ -10,11 +10,14 @@ import { MessageImages } from "./MessageImages" import { MessageFiles } from "./MessageFiles" import { MessageVideo } from "./MessageVideo" import { MessageAudio } from "./MessageAudio" -import RichTextRenderer, { isJumbomojiHtml } from "./RichTextRenderer" +import RichTextRenderer, { isJumbomojiHtml, parseBodySegments } from "./RichTextRenderer" +import { messageBubbleClass } from "./MessageRow" +import { cn } from "@lib/utils" import { MessageLinkPreview } from "./LinkPreview" import { PollMessageContent } from "./PollMessageContent" import SearchTextRenderer from "./SearchTextRenderer" import { MessageReactionsRow } from "./MessageReactions" +import { DocumentLinkRenderer } from "./DocumentLinkRenderer" import { getAttachmentKind } from "@utils/attachmentPreview" import { parseRepliedMessageDetails } from "@utils/messageUtils" import type { RepliedMessageDetails } from "./RepliedMessagePreview" @@ -27,23 +30,53 @@ import { Badge } from "@components/ui/badge" * - sqlite FTS search snippets are plain text, optionally with `` * highlights (which would begin with ` { +export const MessageBody = ({ content, bubble = false }: { content?: string | null; bubble?: boolean }) => { if (!content) return null const trimmed = content.trim() if (!trimmed) return null // jumbomoji: emoji-only messages render big in the stream (not in compact // contexts like notifications, which use RichTextRenderer directly). - if (trimmed.startsWith('<') && !trimmed.startsWith(' + if (trimmed.startsWith('<') && !trimmed.startsWith(' + return + } + if (bubble) return
return } +/** + * The Left-Right text body: iMessage style. Text runs get a bubble each, + * code blocks and lone GIFs render bare between them, and an emoji-only + * message renders big with no bubble at all. The parent column (see + * MessageContent's bubble mode) aligns the pieces left or right. + */ +const BubbledBody = ({ html }: { html: string }) => { + const segments = useMemo(() => parseBodySegments(html), [html]) + return ( + <> + {segments.map((segment, index) => ( +
+ {segment.node} +
+ ))} + + ) +} + /** * A message's text body that swaps to the inline editor while this message is the * channel's edit target (`editingMessageAtom`). Used for a standalone text/caption * message and for a batch's caption-bearing member, so editing works the same way * everywhere the body is shown. */ -export const EditableMessageBody = ({ message }: { message: Message }) => { +export const EditableMessageBody = ({ message, bubble = false }: { message: Message; bubble?: boolean }) => { // Subscribe to a derived boolean (is THIS message being edited?) rather than the // raw id, so toggling an edit only re-renders the affected body — not every body // sharing the channel's editing atom. @@ -53,9 +86,10 @@ export const EditableMessageBody = ({ message }: { message: Message }) => { [message.channel_id, message.name], ), ) + // The editor is never bubbled — it takes the full width while editing. if (isEditing) return - if (message.is_edited === 1 && message.text?.trim()) return - return + if (message.is_edited === 1 && message.text?.trim()) return + return } /** Escape the translated label before it goes into the message HTML. */ @@ -71,7 +105,7 @@ const escapeHtml = (value: string) => * rendered inside them. A jumbomoji paragraph also takes the separate line: * added text would fail the emoji-only check and shrink the emojis. */ -const EditedMessageBody = ({ text }: { text: string }) => { +const EditedMessageBody = ({ text, bubble = false }: { text: string; bubble?: boolean }) => { const label = `(${_("edited")})` const injected = useMemo(() => { const trimmed = text.trim() @@ -79,10 +113,10 @@ const EditedMessageBody = ({ text }: { text: string }) => { return `${trimmed.slice(0, -"

".length)}${escapeHtml(label)}

` }, [text, label]) - if (injected) return + if (injected) return return ( <> - +
{label}
) @@ -128,7 +162,14 @@ const MessageMedia = ({ message, fileUrl }: { message: Message; fileUrl: string } } -export const MessageContent = ({ message, showLinkPreview = true }: { message: Message, showLinkPreview?: boolean }) => { +/** `showLinkedDocument` off for compact surfaces (thread lists, result blocks) + * that render their own inline doc link or want no card. `showReactions` off + * when the caller renders the reactions row outside the content (Left-Right). + * + * `bubble` turns on the iMessage layout: only TEXT gets a bubble; media, + * polls, cards, code blocks and GIFs render bare, stacked in a column that + * aligns "start" (others) or "end" (own messages). */ +export const MessageContent = ({ message, showLinkPreview = true, showLinkedDocument = true, showReactions = true, bubble }: { message: Message, showLinkPreview?: boolean, showLinkedDocument?: boolean, showReactions?: boolean, bubble?: "start" | "end" }) => { const messageFile = "file" in message ? (message.file as string | undefined) : undefined // String from fetches, OBJECT from realtime/ack payloads — the shared @@ -139,9 +180,22 @@ export const MessageContent = ({ message, showLinkPreview = true }: { message: M ) // min-w-0: without it this flex column can't shrink below its content, so - // fixed-width media overflows narrow (mobile) columns and gets clipped + // fixed-width media overflows narrow (mobile) columns and gets clipped. + // Bubble mode is a flex column so each piece (bubble, card, media) keeps + // its own width and the column aligns them to the message's side. The + // editor escape lets the inline edit box take the full width back. return ( -
+
{message.linked_message && repliedMessageDetails && ( @@ -160,18 +214,29 @@ export const MessageContent = ({ message, showLinkPreview = true }: { message: M <> {/* Caption (editable inline). Hidden when empty unless being edited. */} - {(message.text || undefined) && } + {(message.text || undefined) && } ) : ( // Render the HTML body (message.text), NOT message.content — the // latter is the backend's derived plain-text (search/teasers). - + )} {/* Preview for the first link in the body (YouTube embed for now) */} {showLinkPreview && } - + {/* Linked document card sits ABOVE the reactions — reactions are always last. + In bubble mode the column is fit-content, so the card asks for a fixed + width (capped to the column on narrow screens) instead of stretching. */} + {showLinkedDocument && message.link_doctype && message.link_document && ( + + )} + + {showReactions && }
) } diff --git a/apps/web/src/components/features/message/renderers/MessageItem.tsx b/apps/web/src/components/features/message/renderers/MessageItem.tsx index bf9bd2c1c..124927cc3 100644 --- a/apps/web/src/components/features/message/renderers/MessageItem.tsx +++ b/apps/web/src/components/features/message/renderers/MessageItem.tsx @@ -1,11 +1,12 @@ import { Message } from "@raven/types/common/Message" -import { MessageThreadPill } from "./ThreadMessage" +import { MessageThreadPill, ThreadConnector } from "./ThreadMessage" import { useIntersectionObserver } from "usehooks-ts" -import { DocumentLinkRenderer } from "./DocumentLinkRenderer" import { MessageContent } from "./MessageContent" +import { MessageReactionsRow } from "./MessageReactions" import { MessageRow, MessageSenderLayout } from "./MessageRow" -import { OptimisticStatus, optimisticRowClass } from "./OptimisticStatus" +import { FailedSendIndicator, OptimisticStatus, optimisticRowClass, sendingDimClass } from "./OptimisticStatus" import { isThreadParent } from "@utils/messageUtils" +import { useMessageAlignment } from "@hooks/useChatStyle" /** * Anatomy of a message @@ -56,6 +57,11 @@ export const MessageItem = ({ message, onInView }: { message: Message; onInView? const showThread = isThreadParent(message) + const owner = message.is_bot_message ? message.bot || '' : message.owner + // Own rows have no avatar column for the thread connector; their pill + // renders as the bubble's footer instead. + const { isLeftRight, isOwn } = useMessageAlignment(owner) + const { ref } = useIntersectionObserver({ onChange: (isIntersecting) => { if (onInView && isIntersecting) { @@ -68,21 +74,29 @@ export const MessageItem = ({ message, onInView }: { message: Message; onInView? // the stream level via event delegation on the data-message-id wrapper. // A thread parent is never a continuation (the selector enforces this), so // the connector always anchors to the full header — no is_continuation branch. - return - {showThread &&
} + // Left-Right rows show a failed send as an icon beside the message + // (iMessage-style) instead of the red row wash. + return + {showThread && } : undefined} + footer={showThread && isOwn ? : undefined} + statusIcon={isOwn ? : undefined} > - - {message.link_doctype && message.link_document && ( - - )} + - {showThread ? : null} + {showThread && !isOwn ? : null} } diff --git a/apps/web/src/components/features/message/renderers/MessageRow.tsx b/apps/web/src/components/features/message/renderers/MessageRow.tsx index ad519d12f..a20c3f891 100644 --- a/apps/web/src/components/features/message/renderers/MessageRow.tsx +++ b/apps/web/src/components/features/message/renderers/MessageRow.tsx @@ -38,18 +38,32 @@ export const useMessageTimes = (creation: string) => { }, [creation, timeFormat]) } +/** How a row sits in the stream. "simple" is the classic layout. In + * Left-Right mode, "own" content hugs the right edge and everyone else's + * ("left-right") hugs the left. The ROW is full width in every mode — the + * hover wash and highlights span the row, and the toolbar anchors to its + * corners. Only the CONTENT inside is aligned and width-capped. */ +export type MessageRowAlignment = "simple" | "left-right" | "own" + /** The hoverable row shell every stream row shares. */ export const MessageRow = ({ children, ref, className, + alignment = "simple", }: { children: React.ReactNode ref?: React.Ref className?: string + alignment?: MessageRowAlignment }) => (
) +// Width cap for a Left-Right message's content — the row stays full width, +// the content inside stops short of the other edge: 85% on mobile (the gap +// is what makes the side alignment readable on a narrow screen), 75% on +// desktop. Dropped while the inline editor is open, so editing gets the +// whole row back. +const contentCapClass = "max-w-[85%] md:max-w-[75%] has-[[data-raven-editor]]:max-w-full" + +// The text bubble, iMessage-like: tight padding, round corners, gray fill. +// The bubble has no hover style of its own — the row's wash is the hover +// feedback. ONLY text lives in bubbles: media, polls, cards, code blocks and +// GIFs render bare beside them (see MessageContent). +// The radius is CONSTANT for every bubble height — that's what iMessage does. +// 18px is half of a one-line bubble (24px line + 12px padding), so short +// bubbles come out as true pills and tall ones keep the same corners. +export const messageBubbleClass = + "w-fit min-w-0 max-w-full rounded-[18px] bg-surface-gray-1 px-3 py-1.5 md:py-2" + +// Alignment context for an own message's content column (thread pill footer, +// reactions). The editor escape keeps the inline edit box full width inside +// the w-fit chain. +const bubbleColumnClass = "flex w-fit max-w-full flex-col has-[[data-raven-editor]]:w-full" + /** * The sender layout inside a row: avatar + name + time header for the first * message of a group, the empty gutter for continuations. `children` render * in the (min-w-0) content column either way. + * + * Left-Right mode aligns the content: own messages sit right with no + * avatar/name (just a time label), others keep the avatar and name · time + * header. The content itself decides what gets a bubble (see MessageContent). */ export const MessageSenderLayout = ({ owner, creation, isContinuation, + isLeftRight = false, + isOwn = false, + reactions, + footer, + statusIcon, children, }: { owner: string creation: string isContinuation: boolean + /** Left-Right mode: others keep avatar + header above their content. */ + isLeftRight?: boolean + /** Left-Right mode, current user's message: right-aligned, no avatar/name. */ + isOwn?: boolean + /** Left-Right mode: the reactions row, rendered below the content. */ + reactions?: React.ReactNode + /** Rendered under an OWN message's content, aligned to it (thread pill). + * Other/simple layouts render their footer at the row level instead. */ + footer?: React.ReactNode + /** Shown to the LEFT of an own message's content — the failed-send icon. */ + statusIcon?: React.ReactNode children: React.ReactNode }) => { const user = useUser(owner) const displayName = user?.full_name || user?.name || owner || _("User") const { shortTime, longTime } = useMessageTimes(creation) + if (isOwn) { + return ( +
+ {!isContinuation && ( + + + {shortTime} + + {longTime} + + )} + {/* Content hugs the right edge; the status icon (failed send) + sits on its left, centered like iMessage's error mark. */} +
+ {statusIcon} +
+ {children} + {reactions} + {footer} +
+
+
+ ) + } + if (isContinuation) { return (
-
{children}
+
+ {children} + {isLeftRight && reactions} +
) } @@ -107,7 +191,7 @@ export const MessageSenderLayout = ({ )}
-
+
{/* Same profile card as hovering a mention — a person's name opens the same thing wherever it appears in the stream. */} @@ -130,6 +214,7 @@ export const MessageSenderLayout = ({ root leading the content gets a nudge more. */}
{children} + {isLeftRight && reactions}
diff --git a/apps/web/src/components/features/message/renderers/OptimisticStatus.tsx b/apps/web/src/components/features/message/renderers/OptimisticStatus.tsx index f9a0426c0..3babecee9 100644 --- a/apps/web/src/components/features/message/renderers/OptimisticStatus.tsx +++ b/apps/web/src/components/features/message/renderers/OptimisticStatus.tsx @@ -1,5 +1,6 @@ import { useContext } from "react" import { FrappeConfig, FrappeContext } from "frappe-react-sdk" +import { CircleAlertIcon } from "lucide-react" import type { Message } from "@raven/types/common/Message" import { retrySend, discardSend } from "@stores/messages/messageSender" import { isOptimistic } from "@stores/messages/types" @@ -20,6 +21,26 @@ export const optimisticRowClass = (message: Message): string => { return "opacity-50" } +/** + * The Left-Right variant of the row treatment: dim while sending, nothing when + * failed. A red row wash doesn't work there — the bubble paints over it — so a + * failed send shows as an icon beside the bubble (FailedSendIndicator) instead. + */ +export const sendingDimClass = (message: Message): string => + isOptimistic(message) && message._status !== "failed" ? "opacity-50" : "" + +/** Red mark beside an own message whose send failed — like iMessage's error + * icon. Retry/Discard live in the OptimisticStatus footer below the message. */ +export const FailedSendIndicator = ({ message }: { message: Message }) => { + if (!isOptimistic(message) || message._status !== "failed") return null + return ( + + ) +} + /** Quiet inline footer for a failed send: retry (re-sends the same batch) or discard. */ export const OptimisticStatus = ({ message }: { message: Message }) => { const { call } = useContext(FrappeContext) as FrappeConfig diff --git a/apps/web/src/components/features/message/renderers/RichTextRenderer.tsx b/apps/web/src/components/features/message/renderers/RichTextRenderer.tsx index 94ac1a34d..d2afeee40 100644 --- a/apps/web/src/components/features/message/renderers/RichTextRenderer.tsx +++ b/apps/web/src/components/features/message/renderers/RichTextRenderer.tsx @@ -231,6 +231,77 @@ export const isJumbomojiHtml = (html: string): boolean => html.length <= JUMBOMOJI_HTML_MAX_LENGTH && isJumbomoji(html, htmlToDOM(html, { lowerCaseAttributeNames: false })) +/* ---------------------------- Body segments ---------------------------- */ + +/** + * One piece of a message body, for the Left-Right layout. + * Text pieces go inside a bubble. Standalone pieces (code blocks, a GIF on + * its own line, emoji-only messages) render bare, iMessage style. + */ +export type BodySegment = { + standalone: boolean + /** Emoji-only message — rendered big and bare. */ + jumbo: boolean + node: React.ReactNode +} + +/** + * A block that should NOT live inside a text bubble: + * - a code block (
)
+ * - a paragraph that holds only one image (a GIF). Custom emojis don't
+ *   count — they are inline text.
+ */
+const isStandaloneBlock = (node: DOMNode): boolean => {
+    if (!(node instanceof Element)) return false
+    if (node.name === "pre") return true
+    if (node.name === "p") {
+        const children = (node.children as DOMNode[]).filter(
+            (child) => !(child instanceof Text && !child.data.trim()),
+        )
+        const only = children.length === 1 ? children[0] : null
+        return (
+            only instanceof Element &&
+            only.name === "img" &&
+            only.attribs?.["data-type"] !== "customEmoji"
+        )
+    }
+    return false
+}
+
+/**
+ * Split a message body into segments for the Left-Right layout.
+ * Consecutive text blocks group into one segment (one bubble). Standalone
+ * blocks break the run and come back as their own bare segment. An
+ * emoji-only message is one bare jumbo segment.
+ */
+export const parseBodySegments = (html: string): BodySegment[] => {
+    const dom = htmlToDOM(html, { lowerCaseAttributeNames: false })
+    if (isJumbomoji(html, dom)) {
+        return [{ standalone: true, jumbo: true, node: domToReact(dom, options) }]
+    }
+
+    const segments: BodySegment[] = []
+    let run: DOMNode[] = []
+    const flushRun = () => {
+        if (run.length === 0) return
+        segments.push({ standalone: false, jumbo: false, node: domToReact(run, options) })
+        run = []
+    }
+
+    for (const node of dom) {
+        // Whitespace between blocks belongs to no segment.
+        if (node instanceof Text && !node.data.trim()) continue
+        if (isStandaloneBlock(node)) {
+            flushRun()
+            segments.push({ standalone: true, jumbo: false, node: domToReact([node], options) })
+        } else {
+            run.push(node)
+        }
+    }
+    flushRun()
+    return segments
+}
+
 export const RichTextRenderer = ({ html, jumbomoji = false }: { html: string; jumbomoji?: boolean }) => {
     const { tree, jumbo } = useMemo(() => {
         // Same two steps parse() runs internally, split so ONE parsed DOM feeds
diff --git a/apps/web/src/components/features/message/renderers/ThreadMessage.tsx b/apps/web/src/components/features/message/renderers/ThreadMessage.tsx
index 7aef30552..b69a95b0c 100644
--- a/apps/web/src/components/features/message/renderers/ThreadMessage.tsx
+++ b/apps/web/src/components/features/message/renderers/ThreadMessage.tsx
@@ -13,6 +13,22 @@ import { NavLink, useLocation } from "react-router-dom"
 import { cn } from "@lib/utils"
 import _ from "@lib/translate"
 
+/** Curved line joining a thread parent to its reply pill: full height from the
+ *  avatar column for others, a short bottom elbow beside an own message's pill. */
+export const ThreadConnector = ({ side }: { side: "left" | "right" }) =>
+    side === "left" ? (
+        
+ ) : ( +
+ ) + +/** Where the thread pill sits. "start": indented past the avatar gutter, for + * left-aligned messages. "end": under an own message in Left-Right mode — + * right-aligned, with its content mirrored to match. */ +export type ThreadPillAlign = "start" | "end" + +const pillAlignClass = (align: ThreadPillAlign) => (align === "end" ? "mt-2 mr-11 flex-row-reverse" : "mt-2 ml-11") + interface ThreadButtonProps { participants: UserData[] messageCount: number @@ -21,9 +37,10 @@ interface ThreadButtonProps { * real channel thread route even when the chat is rendered in a pane * (notifications/search/saved), where the URL carries no channel. */ channelID: string + align?: ThreadPillAlign } -export const ThreadButton = ({ participants, messageCount, threadID, channelID }: ThreadButtonProps) => { +export const ThreadButton = ({ participants, messageCount, threadID, channelID, align = "start" }: ThreadButtonProps) => { const location = useLocation() const drawerChannelID = channelID const setDrawerType = useSetAtom(channelDrawerAtom(drawerChannelID)) @@ -44,10 +61,10 @@ export const ThreadButton = ({ participants, messageCount, threadID, channelID } {messageCount === 1 ? _("1 reply") : _("{0} replies", [String(messageCount)])} ) - const className = "flex w-fit ml-11 mt-2 items-center gap-2 text-ink-gray-6 transition-colors duration-200 hover:text-ink-gray-8" + const className = cn("flex w-fit items-center gap-2 text-ink-gray-6 transition-colors duration-200 hover:text-ink-gray-8", pillAlignClass(align)) // No threadID → render non-interactive (shouldn't happen for a real pill). - if (!threadID) return
{content}
+ if (!threadID) return
{content}
// Destination: the thread route under its REAL parent channel, resolved from the // channel store — so the pill works from anywhere, including the notification/ @@ -82,8 +99,8 @@ export const ThreadButton = ({ participants, messageCount, threadID, channelID } } /** Placeholder pill (reserves the row's height) shown until the thread details load. */ -const ThreadPillSkeleton = () => ( -
+const ThreadPillSkeleton = ({ align = "start" }: { align?: ThreadPillAlign }) => ( +
@@ -92,7 +109,7 @@ const ThreadPillSkeleton = () => (
) -const LoadedThreadPill = ({ threadID, channelID, isInView }: { threadID: string; channelID: string; isInView: boolean }) => { +const LoadedThreadPill = ({ threadID, channelID, isInView, align }: { threadID: string; channelID: string; isInView: boolean; align?: ThreadPillAlign }) => { const { call } = useContext(FrappeContext) as FrappeConfig // Fetch each time the pill comes on screen. The first time seeds the count + @@ -120,9 +137,9 @@ const LoadedThreadPill = ({ threadID, channelID, isInView }: { threadID: string; const replyCount = useThreadReplyCount(threadID) // Undefined until the seed lands → keep the skeleton (members arrive in the same seed). - if (replyCount === undefined) return + if (replyCount === undefined) return - return + return } /** @@ -133,7 +150,7 @@ const LoadedThreadPill = ({ threadID, channelID, isInView }: { threadID: string; * the pill is actually on screen. * `channelID` = the message's channel (the thread's parent) — see ThreadButtonProps. */ -export const MessageThreadPill = ({ threadID, channelID }: { threadID: string; channelID: string }) => { +export const MessageThreadPill = ({ threadID, channelID, align }: { threadID: string; channelID: string; align?: ThreadPillAlign }) => { const { ref, isInView, hasBeenInView } = useInView() - return
{hasBeenInView ? : }
+ return
{hasBeenInView ? : }
} diff --git a/apps/web/src/components/features/message/renderers/bodySegments.test.ts b/apps/web/src/components/features/message/renderers/bodySegments.test.ts new file mode 100644 index 000000000..a74c5fd61 --- /dev/null +++ b/apps/web/src/components/features/message/renderers/bodySegments.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest" +import { parseBodySegments } from "./RichTextRenderer" + +// Shapes only — flags and counts. The React nodes themselves are covered by +// rendering in the app. +const shape = (html: string) => + parseBodySegments(html).map((segment) => ({ standalone: segment.standalone, jumbo: segment.jumbo })) + +describe("parseBodySegments", () => { + it("keeps plain paragraphs as one bubbled segment", () => { + expect(shape("

hello

world

")).toEqual([{ standalone: false, jumbo: false }]) + }) + + it("breaks a code block out of the text run", () => { + expect(shape('

before

x()

after

')).toEqual([ + { standalone: false, jumbo: false }, + { standalone: true, jumbo: false }, + { standalone: false, jumbo: false }, + ]) + }) + + it("renders a code-only message as one bare segment", () => { + expect(shape("
x()
")).toEqual([{ standalone: true, jumbo: false }]) + }) + + it("breaks a lone GIF paragraph out as bare", () => { + expect(shape('

look

')).toEqual([ + { standalone: false, jumbo: false }, + { standalone: true, jumbo: false }, + ]) + }) + + it("keeps an inline GIF (text around it) inside the bubble", () => { + expect(shape('

look at this

')).toEqual([ + { standalone: false, jumbo: false }, + ]) + }) + + it("keeps a lone custom emoji as a bare jumbo segment", () => { + expect(shape('

:party:

')).toEqual([ + { standalone: true, jumbo: true }, + ]) + }) + + it("renders an emoji-only message as one bare jumbo segment", () => { + expect(shape("

\u{1F600}\u{1F389}

")).toEqual([{ standalone: true, jumbo: true }]) + }) + + it("does not treat an emoji message with text as jumbo", () => { + expect(shape("

nice \u{1F600}

")).toEqual([{ standalone: false, jumbo: false }]) + }) +}) diff --git a/apps/web/src/components/features/profile/PreferencesDrawer.tsx b/apps/web/src/components/features/profile/PreferencesDrawer.tsx index ba4f0736c..70a3312f3 100644 --- a/apps/web/src/components/features/profile/PreferencesDrawer.tsx +++ b/apps/web/src/components/features/profile/PreferencesDrawer.tsx @@ -11,6 +11,8 @@ import { Button } from "@components/ui/button" import { useTheme } from "@components/theme-provider" import { customEmojiCategoriesAtom } from "@lib/emojiMart" import { DoubleTapReactionAtom, QuickEmojisAtom, type QuickEmoji, type TimeFormat, timeFormatAtom, imageGroupingLayoutAtom } from "@utils/preferences" +import { useQuickEmojiSuggestions } from "@utils/reactionUsage" +import { EmojiFace } from "@components/common/EmojiFace" import { errorResponseToast } from "@components/ui/error-banner" import { PrefRow, PrefSection } from "./PrefRows" import _ from "@lib/translate" @@ -46,6 +48,11 @@ export const PreferencesDrawer = ({ open, onOpenChange }: { open: boolean; onOpe const [pickingSlot, setPickingSlot] = useState(null) const [imageGrouping, setImageGrouping] = useAtom(imageGroupingLayoutAtom) + // Shared with the desktop panel (one hook, one behavior): the most-used + // reactions of recent months, applied to the slots with one tap. The + // fetch waits for the drawer to actually open — this component mounts + // with the Profile page, closed. + const { suggestions, showSuggestions, apply: applySuggestions } = useQuickEmojiSuggestions(6, { enabled: open }) const updateValue = (fieldname: string, value: string | number) => { if (!myProfile?.name) return @@ -195,6 +202,24 @@ export const PreferencesDrawer = ({ open, onOpenChange }: { open: boolean; onOpe ))}
{_("Tap a slot to change its emoji - these are your one-tap reactions.")} + {/* Full-width like the slots row above — label left, emojis + spread; one tap applies the whole set. */} + {showSuggestions && ( +
+ {_("Suggested:")} + +
+ )}
- emoji.src ? ( - - ) : ( - - ) diff --git a/apps/web/src/components/features/settings/panels/Preferences.tsx b/apps/web/src/components/features/settings/panels/Preferences.tsx index 2c87834d9..16a3dd7f9 100644 --- a/apps/web/src/components/features/settings/panels/Preferences.tsx +++ b/apps/web/src/components/features/settings/panels/Preferences.tsx @@ -4,6 +4,8 @@ import { SettingsPanelDescription, SettingsPanelHeader, SettingsPanelTitle, Sett import { Switch } from "@components/ui/switch" import { useAtom, useAtomValue } from "jotai" import { EnterKeyBehaviourAtom, QuickEmojisAtom, QuietHoursNudge, hideReadReceiptsAtom, quietHoursConfigAtom, quietHoursNudgeAtom, timeFormatAtom } from "@utils/preferences" +import { useQuickEmojiSuggestions } from "@utils/reactionUsage" +import { EmojiFace } from "@components/common/EmojiFace" import { formatWorkingHoursRange } from "@utils/quietHours" import { hasRole } from "@lib/permissions" import _ from "@lib/translate" @@ -253,6 +255,12 @@ const QuickEmojis = () => { const { themeValue } = useTheme() + // Shared with the mobile drawer (one hook, one behavior): the four + // most-used reactions of recent months, applied to the visible slots + // with one click. Hidden until a full set exists, or when it matches + // what's pinned. + const { suggestions, showSuggestions, apply: applySuggestions } = useQuickEmojiSuggestions(4) + const handleEmojiSelect = (index: number, emoji: any) => { const newEmojis = [...quickEmojis] newEmojis[index] = { @@ -270,43 +278,50 @@ const QuickEmojis = () => { {_("Set your favorite emojis for quick reactions.")}
-
- {quickEmojis.slice(0, 4).map((emoji, index) => ( - - - - + + + {/* Dialog scroll lock preventDefaults wheel it can't inspect — emoji-mart + scrolls inside shadow DOM. This fixes it. */} +
event.stopPropagation()}> + handleEmojiSelect(index, emoji)} theme={themeValue} set="native" custom={customEmojis} previewPosition="none" /> - ) : ( - // em-emoji renders from the Apple set (initialized in - // App.tsx) so reactions look the same on every platform - - )} - - - - handleEmojiSelect(index, emoji)} theme={themeValue} set="native" custom={customEmojis} previewPosition="none" - /> - - - - ))} +
+
+
+
+ ))} +
+ {showSuggestions && ( +
+ {_("Suggested:")} + +
+ )}
} -export default Preferences \ No newline at end of file +export default Preferences diff --git a/apps/web/src/hooks/useChatStyle.ts b/apps/web/src/hooks/useChatStyle.ts index f53695900..224c0cf05 100644 --- a/apps/web/src/hooks/useChatStyle.ts +++ b/apps/web/src/hooks/useChatStyle.ts @@ -1,15 +1,17 @@ import { useAtomValue } from "jotai" import { chatStyleAtom } from "@utils/preferences" -import { useUserCookieData } from "./useUserCookieData" +import { isCurrentUser } from "@utils/userDisplay" /** - * Per-row message layout flags. `isLeftRight` turns on bubble styling for everyone; - * `isOwn` is true only for the current user's messages in Left-Right mode — those render - * right-aligned with no avatar (iMessage-style). Cheap: a primitive atom + a cookie read, - * so it's fine to call per message row. + * Per-row message layout flags. `isLeftRight` turns on the iMessage layout for + * everyone; `isOwn` is true only for the current user's messages in that mode — + * those render right-aligned with no avatar. + * + * This runs once per message row, so the owner check uses isCurrentUser (a + * cached string compare). useUserCookieData would re-parse document.cookie on + * every row — see its own doc comment. */ export const useMessageAlignment = (owner: string) => { const isLeftRight = useAtomValue(chatStyleAtom) === "Left-Right" - const { name } = useUserCookieData() - return { isLeftRight, isOwn: isLeftRight && owner === name } + return { isLeftRight, isOwn: isLeftRight && isCurrentUser(owner) } } diff --git a/apps/web/src/utils/preferences.ts b/apps/web/src/utils/preferences.ts index 616220a03..4a79d1936 100644 --- a/apps/web/src/utils/preferences.ts +++ b/apps/web/src/utils/preferences.ts @@ -1,5 +1,5 @@ -import { atom, getDefaultStore } from "jotai" -import { atomWithStorage } from "jotai/utils" +import { getDefaultStore } from "jotai" +import { atomWithLazy, atomWithStorage } from "jotai/utils" export type ChatStyle = "Simple" | "Left-Right" export type TimeFormat = "12-hour" | "24-hour" @@ -10,13 +10,18 @@ export type TimeFormat = "12-hour" | "24-hour" * client one — so it's seeded from boot (correct on first paint) rather than localStorage, and * the Appearance switcher sets it for a live change without reload. Read it with a single * useAtomValue in the message rows. + * + * All boot-seeded atoms here use atomWithLazy. Why: this module can be + * imported before `window.frappe.boot` exists (dev loads boot async, and + * offline shells recover it in main.tsx). An eager read at import time would + * seed the defaults. Lazy init reads boot on first use instead. */ -export const chatStyleAtom = atom((window.frappe?.boot?.chat_style as ChatStyle | undefined) ?? "Simple") +export const chatStyleAtom = atomWithLazy(() => (window.frappe?.boot?.chat_style as ChatStyle | undefined) ?? "Simple") /** * Time format: "12-hour" displays times like "12:00 PM"; "24-hour" displays times like "12:00" in all messages. */ -export const timeFormatAtom = atom((window.frappe?.boot?.raven_time_format as TimeFormat | undefined) ?? "12-hour") +export const timeFormatAtom = atomWithLazy(() => (window.frappe?.boot?.raven_time_format as TimeFormat | undefined) ?? "12-hour") /** * Whether the user hides read receipts (two-way: theirs are invisible AND @@ -25,7 +30,7 @@ export const timeFormatAtom = atom((window.frappe?.boot?.raven_time_ * paths (the message action menu) read a plain atom instead of subscribing * to the profile SWR cache. Stored on Raven User as `hide_read_receipts`. */ -export const hideReadReceiptsAtom = atom(Boolean(window.frappe?.boot?.raven_hide_read_receipts)) +export const hideReadReceiptsAtom = atomWithLazy(() => Boolean(window.frappe?.boot?.raven_hide_read_receipts)) export type QuietHoursNudge = "Nudge" | "No Nudge" | "Auto Silent" @@ -37,8 +42,8 @@ export type QuietHoursNudge = "Nudge" | "No Nudge" | "Auto Silent" * Raven User as `quiet_hours_nudge`; seeded from boot and written by the * Preferences panel, so the send path reads a plain atom. */ -export const quietHoursNudgeAtom = atom( - (window.frappe?.boot?.raven_quiet_hours_nudge as QuietHoursNudge | undefined) ?? "Nudge", +export const quietHoursNudgeAtom = atomWithLazy( + () => (window.frappe?.boot?.raven_quiet_hours_nudge as QuietHoursNudge | undefined) ?? "Nudge", ) export type QuietHoursConfig = { @@ -53,8 +58,8 @@ export type QuietHoursConfig = { * session applies the change live (banner, send default, preferences row) * without a reload. Other members pick it up on their next boot. */ -export const quietHoursConfigAtom = atom( - (window.frappe?.boot?.quiet_hours as QuietHoursConfig | undefined) ?? null, +export const quietHoursConfigAtom = atomWithLazy( + () => (window.frappe?.boot?.quiet_hours as QuietHoursConfig | undefined) ?? null, ) /** Non-hook reader for the plain evaluator functions (utils/quietHours.ts). diff --git a/apps/web/src/utils/reactionUsage.test.ts b/apps/web/src/utils/reactionUsage.test.ts new file mode 100644 index 000000000..3afef8324 --- /dev/null +++ b/apps/web/src/utils/reactionUsage.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest" +import { mergeSuggestions, sameEmojiSet, toSuggestedSet, type ReactionUsageRow } from "./reactionUsage" +import type { QuickEmoji } from "./preferences" + +const row = (reaction: string, uses: number, custom = false, name?: string): ReactionUsageRow => ({ + reaction, + is_custom: custom ? 1 : 0, + reaction_escaped: name, + uses, +}) + +describe("toSuggestedSet", () => { + it("maps native and custom rows, keeping server order", () => { + const set = toSuggestedSet([row("👍", 5), row("/files/party.png", 3, true, "party-blob")], 2) + expect(set).toEqual([ + { id: "👍", native: "👍" }, + { id: "party-blob", src: "/files/party.png" }, + ]) + }) + + it("returns nothing unless a FULL set exists (applied as one unit)", () => { + expect(toSuggestedSet([row("👍", 5)], 2)).toEqual([]) + expect(toSuggestedSet(undefined, 2)).toEqual([]) + }) + + it("caps at n", () => { + expect(toSuggestedSet([row("a", 3), row("b", 2), row("c", 1)], 2)).toHaveLength(2) + }) +}) + +describe("sameEmojiSet", () => { + const set = (...ids: string[]): QuickEmoji[] => ids.map((id) => ({ id })) + + it("matches regardless of order", () => { + expect(sameEmojiSet(set("a", "b"), set("b", "a"))).toBe(true) + }) + + it("matches a picker slug id against a char id via native", () => { + expect(sameEmojiSet([{ id: "❤️", native: "❤️" }], [{ id: "heart", native: "❤️" }])).toBe(true) + }) + + it("differs on any member or length", () => { + expect(sameEmojiSet(set("a", "b"), set("a", "c"))).toBe(false) + expect(sameEmojiSet(set("a"), set("a", "b"))).toBe(false) + }) + + it("compares as a multiset — duplicates must match too", () => { + expect(sameEmojiSet(set("a", "a", "b"), set("a", "b", "b"))).toBe(false) + expect(sameEmojiSet(set("a", "a", "b"), set("b", "a", "a"))).toBe(true) + }) +}) + +describe("mergeSuggestions", () => { + const set = (...ids: string[]): QuickEmoji[] => ids.map((id) => ({ id })) + const ids = (emojis: QuickEmoji[]) => emojis.map((emoji) => emoji.id) + + it("replaces the first n slots and keeps the rest", () => { + const next = mergeSuggestions(set("a", "b", "c", "d", "e", "f"), set("w", "x", "y", "z"), 4) + expect(ids(next)).toEqual(["w", "x", "y", "z", "e", "f"]) + }) + + it("replaces everything when n covers all slots", () => { + const next = mergeSuggestions(set("a", "b", "c"), set("x", "y", "z"), 3) + expect(ids(next)).toEqual(["x", "y", "z"]) + }) + + it("refills a kept slot that would duplicate a suggestion", () => { + // "e" is now suggested; its old slot 5 gets a replaced head emoji. + const next = mergeSuggestions(set("a", "b", "c", "d", "e", "f"), set("e", "x", "y", "z"), 4) + expect(ids(next)).toEqual(["e", "x", "y", "z", "a", "f"]) + }) + + it("drops a duplicate kept slot when no refill is free", () => { + // Every old head emoji is suggested, so nothing can refill slot 5. + const next = mergeSuggestions(set("a", "b", "a", "x"), set("a", "b", "x"), 3) + expect(ids(next)).toEqual(["a", "b", "x"]) + }) +}) diff --git a/apps/web/src/utils/reactionUsage.ts b/apps/web/src/utils/reactionUsage.ts new file mode 100644 index 000000000..ed321bbd9 --- /dev/null +++ b/apps/web/src/utils/reactionUsage.ts @@ -0,0 +1,83 @@ +import { useAtom } from "jotai" +import { useFrappeGetCall } from "frappe-react-sdk" +import { QuickEmojisAtom, type QuickEmoji } from "./preferences" + +/** A row of raven.api.reactions.most_used_reactions — the user's most-used + * reactions of the past 3 months, counted server-side across devices. + * Removed reactions don't count (un-reacting deletes the row). */ +export type ReactionUsageRow = { reaction: string; is_custom: 0 | 1; reaction_escaped?: string | null; uses: number } + +// Every surface fetches the same 6 rows under ONE SWR key and slices to what +// it shows (desktop 4, mobile 6) — instead of one server query per surface. +const FETCH_LIMIT = 6 + +/** Server rows → QuickEmoji list; empty unless a FULL set of `n` exists (the + * suggestion row applies as one unit). Custom emojis carry the image URL in + * `reaction` and their name in `reaction_escaped`. */ +export const toSuggestedSet = (rows: ReactionUsageRow[] | undefined, n: number): QuickEmoji[] => { + const mapped = (rows ?? []).slice(0, n).map((row): QuickEmoji => + row.is_custom + ? { id: row.reaction_escaped || row.reaction, src: row.reaction } + : { id: row.reaction, native: row.reaction }, + ) + return mapped.length === n ? mapped : [] +} + +/** Compare by native char (falling back to id): picker entries carry slug ids + * ("heart") while server rows carry the char, so ids alone can't match. */ +const identity = (emoji: QuickEmoji) => emoji.native ?? emoji.id + +/** Same emojis regardless of order — an equal suggestion has nothing to + * offer. A multiset compare, so duplicate entries also have to match. */ +export const sameEmojiSet = (a: QuickEmoji[], b: QuickEmoji[]): boolean => { + if (a.length !== b.length) return false + const sortedA = a.map(identity).sort() + const sortedB = b.map(identity).sort() + return sortedA.every((value, index) => value === sortedB[index]) +} + +/** + * Apply suggestions to the pinned slots: the first `n` slots become the + * suggestions, later slots are kept. No emoji ends up pinned twice — a kept + * slot that now duplicates a suggestion is refilled with one of the replaced + * head emojis (dropped only when none is free), so the slot count holds. + */ +export const mergeSuggestions = (current: QuickEmoji[], suggestions: QuickEmoji[], n: number): QuickEmoji[] => { + const seen = new Set(suggestions.map(identity)) + const refills = current.slice(0, n).filter((emoji) => !seen.has(identity(emoji))) + const kept: QuickEmoji[] = [] + for (const slot of current.slice(n)) { + const emoji = seen.has(identity(slot)) ? refills.shift() : slot + if (!emoji || seen.has(identity(emoji))) continue + seen.add(identity(emoji)) + kept.push(emoji) + } + return [...suggestions, ...kept] +} + +/** The user's `n` most-used reactions, SWR-cached (no focus revalidation — + * this changes slowly). `enabled` gates the fetch: pass false while the + * hosting surface is closed, so a mounted-but-shut drawer costs nothing. */ +export const useSuggestedReactions = (n: number, enabled = true): QuickEmoji[] => { + const { data } = useFrappeGetCall<{ message: ReactionUsageRow[] }>( + "raven.api.reactions.most_used_reactions", + { limit: FETCH_LIMIT }, + enabled ? "most_used_reactions" : null, + { revalidateOnFocus: false }, + ) + return toSuggestedSet(data?.message, n) +} + +/** + * Everything a preferences surface needs for its "Suggested" strip: the + * suggestion set, whether to show it (hidden until a full set exists, or when + * it already matches the first `n` pinned slots), and apply(). One hook for + * desktop and mobile, so the two surfaces can't drift apart. + */ +export const useQuickEmojiSuggestions = (n: number, options?: { enabled?: boolean }) => { + const [quickEmojis, setQuickEmojis] = useAtom(QuickEmojisAtom) + const suggestions = useSuggestedReactions(n, options?.enabled ?? true) + const showSuggestions = suggestions.length > 0 && !sameEmojiSet(suggestions, quickEmojis.slice(0, n)) + const apply = () => setQuickEmojis(mergeSuggestions(quickEmojis, suggestions, n)) + return { suggestions, showSuggestions, apply } +} diff --git a/raven/api/raven_message.py b/raven/api/raven_message.py index 57ac958e3..a0e008177 100644 --- a/raven/api/raven_message.py +++ b/raven/api/raven_message.py @@ -982,7 +982,9 @@ def _forward_payloads(message_id: str) -> list[dict]: payloads = [] for member in members: - payload = {field: member.get(field) for field in FORWARDABLE_FIELDS if member.get(field) is not None} + payload = { + field: member.get(field) for field in FORWARDABLE_FIELDS if member.get(field) is not None + } # Forwarding drops the reply link, so inline the quoted message into `text` up # front. `json` goes with it: it still holds the unquoted body, and the copy # should have one body that carries the quote. @@ -1003,7 +1005,9 @@ def _forward_payloads(message_id: str) -> list[dict]: @frappe.whitelist(methods=["POST"]) def forward_message( - message_receivers: list[dict], forwarded_message: dict | None = None, message_id: str | None = None + message_receivers: list[dict], + forwarded_message: dict | None = None, + message_id: str | None = None, ): """ Forward a message to multiple users/ or in multiple channels diff --git a/raven/api/reactions.py b/raven/api/reactions.py index 059416ab1..44c01d53d 100644 --- a/raven/api/reactions.py +++ b/raven/api/reactions.py @@ -131,3 +131,32 @@ def calculate_message_reaction(message_id, channel_id: str = None, do_not_publis docname=channel_id, # Adding this to automatically add the room for the event via Frappe after_commit=False, ) + + +@frappe.whitelist(methods=["GET"]) +def most_used_reactions(limit: int = 6): + """ + The current user's most-used reactions over the past 3 months — feeds the + quick-emoji suggestions in preferences. Removed reactions don't count + (un-reacting deletes the row), which is the better "your emojis" signal. + """ + from frappe.query_builder import Order + from frappe.query_builder.functions import Count + + reaction = frappe.qb.DocType("Raven Message Reaction") + return ( + frappe.qb.from_(reaction) + .select( + reaction.reaction, + reaction.is_custom, + reaction.reaction_escaped, + Count(reaction.name).as_("uses"), + ) + .where(reaction.owner == frappe.session.user) + .where(reaction.creation > frappe.utils.add_to_date(frappe.utils.now_datetime(), months=-3)) + .groupby(reaction.reaction, reaction.is_custom, reaction.reaction_escaped) + .orderby(Count(reaction.name), order=Order.desc) + # Clamp to 1..6 — a zero or negative limit would reach SQL as-is and + # either return nothing or error. + .limit(max(min(frappe.utils.cint(limit), 6), 1)) + ).run(as_dict=True) diff --git a/raven/raven/doctype/raven_settings/raven_settings.py b/raven/raven/doctype/raven_settings/raven_settings.py index 85cf5df6e..0819cf30e 100644 --- a/raven/raven/doctype/raven_settings/raven_settings.py +++ b/raven/raven/doctype/raven_settings/raven_settings.py @@ -14,8 +14,11 @@ class RavenSettings(Document): if TYPE_CHECKING: from frappe.types import DF + from raven.raven.doctype.raven_blocked_links.raven_blocked_links import RavenBlockedLinks - from raven.raven_integrations.doctype.raven_hr_company_workspace.raven_hr_company_workspace import RavenHRCompanyWorkspace + from raven.raven_integrations.doctype.raven_hr_company_workspace.raven_hr_company_workspace import ( + RavenHRCompanyWorkspace, + ) auto_add_system_users: DF.Check auto_create_department_channel: DF.Check diff --git a/raven/raven/doctype/raven_user/raven_user.py b/raven/raven/doctype/raven_user/raven_user.py index b93f0b397..03b13496c 100644 --- a/raven/raven/doctype/raven_user/raven_user.py +++ b/raven/raven/doctype/raven_user/raven_user.py @@ -15,10 +15,17 @@ class RavenUser(Document): if TYPE_CHECKING: from frappe.types import DF - from raven.raven.doctype.raven_grouped_channels.raven_grouped_channels import RavenGroupedChannels + + from raven.raven.doctype.raven_grouped_channels.raven_grouped_channels import ( + RavenGroupedChannels, + ) from raven.raven.doctype.raven_pinned_channels.raven_pinned_channels import RavenPinnedChannels - from raven.raven.doctype.raven_user_pinned_workspaces.raven_user_pinned_workspaces import RavenUserPinnedWorkspaces - from raven.raven_channel_management.doctype.raven_channel_groups.raven_channel_groups import RavenChannelGroups + from raven.raven.doctype.raven_user_pinned_workspaces.raven_user_pinned_workspaces import ( + RavenUserPinnedWorkspaces, + ) + from raven.raven_channel_management.doctype.raven_channel_groups.raven_channel_groups import ( + RavenChannelGroups, + ) availability_status: DF.Literal["", "Available", "Away", "Do not disturb", "Invisible"] bot: DF.Link | None