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. */}
)
+ * - 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('
')).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.")}