From b59150b6a464265016119acfc975c40dd19429ba Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Fri, 11 Sep 2026 20:28:44 +0100 Subject: [PATCH 01/14] Fix design check paths in workspaces with spaces Signed-off-by: klopez4212 --- scripts/design-system/check-color.mjs | 17 ++++++++++------- scripts/design-system/check-type.mjs | 12 ++++++++---- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/scripts/design-system/check-color.mjs b/scripts/design-system/check-color.mjs index 97c16a53..a869a30e 100644 --- a/scripts/design-system/check-color.mjs +++ b/scripts/design-system/check-color.mjs @@ -34,14 +34,17 @@ import { readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; -const SRC = new URL("../../src/shared/design-system", import.meta.url).pathname; -const VIEWER = new URL("../../tests/fixtures/design-system", import.meta.url) - .pathname; -const TOKENS_FILE = new URL( - "../../src/shared/design-system/styles/tokens.css", - import.meta.url, -).pathname; +const SRC = fileURLToPath( + new URL("../../src/shared/design-system", import.meta.url), +); +const VIEWER = fileURLToPath( + new URL("../../tests/fixtures/design-system", import.meta.url), +); +const TOKENS_FILE = fileURLToPath( + new URL("../../src/shared/design-system/styles/tokens.css", import.meta.url), +); /** * Colour utilities that may not carry an opacity modifier. diff --git a/scripts/design-system/check-type.mjs b/scripts/design-system/check-type.mjs index 111e22af..161e6a47 100644 --- a/scripts/design-system/check-type.mjs +++ b/scripts/design-system/check-type.mjs @@ -28,10 +28,14 @@ import { readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative } from "node:path"; - -const SRC = new URL("../../src/shared/design-system", import.meta.url).pathname; -const VIEWER = new URL("../../tests/fixtures/design-system", import.meta.url) - .pathname; +import { fileURLToPath } from "node:url"; + +const SRC = fileURLToPath( + new URL("../../src/shared/design-system", import.meta.url), +); +const VIEWER = fileURLToPath( + new URL("../../tests/fixtures/design-system", import.meta.url), +); /** Size roles a component may use. Kept in sync with typography.css. */ const SIZE_ROLES = [ From ee673d603c07e73626e9f82474694bfceccca266 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Fri, 11 Sep 2026 20:29:45 +0100 Subject: [PATCH 02/14] Add emoji reactions and polish picker and composer behavior Signed-off-by: klopez4212 --- dev/relay-broker-api.test.mjs | 49 +++ dev/relay-broker.mjs | 13 +- docs/channels.md | 25 ++ docs/relay-queries.md | 7 +- src/bundled/emoji/CustomEmoji.tsx | 2 + src/bundled/emoji/Emoji.module.css | 36 +- src/bundled/emoji/EmojiPicker.tsx | 343 ++++++++++-------- src/bundled/emoji/copy-emoji.ts | 64 ++++ src/bundled/emoji/emoji-mart.ts | 2 +- src/bundled/emoji/index.tsx | 21 ++ src/features/conversation/ReactionTool.tsx | 125 +++++++ src/features/conversation/contracts.ts | 9 + src/features/messages/ChannelTimeline.tsx | 2 + .../messages/MessageComposer.test.tsx | 6 +- src/features/messages/MessageComposer.tsx | 96 ++++- src/features/messages/MessageRow.test.tsx | 32 ++ src/features/messages/MessageRow.tsx | 27 ++ src/features/messages/Messages.module.css | 26 +- src/features/messages/ThreadPanel.test.tsx | 2 +- src/features/messages/ThreadPanel.tsx | 4 + src/features/messages/emoji-selection.ts | 33 ++ src/features/messages/emoji-size.ts | 11 +- src/features/relay/emoji.test.ts | 31 ++ src/features/relay/messages.ts | 14 + tests/browser/emoji.spec.mjs | 117 +++++- tests/browser/gifs.spec.mjs | 12 +- tests/browser/reactions.spec.mjs | 98 +++++ tests/browser/typeahead.spec.mjs | 34 +- tests/fixtures/emoji.tsx | 6 +- 29 files changed, 1031 insertions(+), 216 deletions(-) create mode 100644 src/bundled/emoji/copy-emoji.ts create mode 100644 src/features/conversation/ReactionTool.tsx create mode 100644 src/features/messages/emoji-selection.ts create mode 100644 tests/browser/reactions.spec.mjs diff --git a/dev/relay-broker-api.test.mjs b/dev/relay-broker-api.test.mjs index f594723b..42c0b3fc 100644 --- a/dev/relay-broker-api.test.mjs +++ b/dev/relay-broker-api.test.mjs @@ -361,6 +361,55 @@ test("queued request mints fresh auth at dispatch after wall time advances", asy } }); +test("reaction sign and publish preserve kind 7 and reject malformed targets before upstream I/O", async () => { + const h = await harness((call) => + Response.json({ accepted: true, event_id: call.body.id }), + ); + try { + const template = { + ...h.event, + kind: 7, + content: ":party:", + tags: [ + ["h", "c"], + ["e", "a".repeat(64)], + ["emoji", "party", "https://a.test/party.png"], + ], + }; + const response = await h.post("sign", template); + expect(response.status).toBe(200); + const event = await response.json(); + expect(verifyEvent(event)).toBe(true); + expect(event.kind).toBe(7); + expect(event.tags).toEqual(template.tags); + expect((await h.post("publish", event)).status).toBe(200); + expect(h.calls).toHaveLength(1); + expect(h.calls[0].body).toEqual(JSON.parse(JSON.stringify(event))); + for (const route of ["sign", "publish"]) { + for (const tags of [ + [], + [["e", "bad"]], + [["e", "a".repeat(64), "", "reply"]], + [ + ["e", "a".repeat(64)], + ["e", "b".repeat(64)], + ], + ]) { + expect( + (await h.post(route, { ...event, tags: [["h", "c"], ...tags] })) + .status, + ).toBe(400); + } + expect( + (await h.post(route, { ...event, content: "x".repeat(65) })).status, + ).toBe(400); + } + expect(h.calls).toHaveLength(1); + } finally { + await h.close(); + } +}); + test("both real sign and publish routes admit direct replies but reject arbitrary references before upstream I/O", async () => { const h = await harness((call) => Response.json({ accepted: true, event_id: call.body.id }), diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs index 2027d4a1..97203436 100644 --- a/dev/relay-broker.mjs +++ b/dev/relay-broker.mjs @@ -192,7 +192,7 @@ async function relayAuthority(fetch, relay) { export function validMessageTemplate(event) { return ( event && - event.kind === 9 && + [7, 9].includes(event.kind) && typeof event.content === "string" && event.content.trim().length > 0 && Buffer.byteLength(event.content) <= 32000 && @@ -208,6 +208,13 @@ export function validMessageTemplate(event) { ).length === 1 && (() => { const references = event.tags.filter((tag) => tag[0] === "e"); + if (event.kind === 7) + return ( + [...event.content.trim()].length <= 64 && + references.length === 1 && + references[0].length === 2 && + /^[0-9a-f]{64}$/.test(references[0][1]) + ); if (!references.length) return true; const [reply] = references; // This write surface supports direct-to-root replies, not arbitrary references. @@ -509,7 +516,7 @@ export function relayBrokerPlugin({ viewer, ...(await getAuthority(relay)), relayUrl: relay, - writeKinds: [9], + writeKinds: [7, 9], sidebarPreferences: true, readState: true, agentLibrary: true, @@ -840,7 +847,7 @@ export function relayBrokerPlugin({ const started = performance.now(); const event = finalizeEvent( { - kind: 9, + kind: filters.kind, content: filters.content, created_at: filters.created_at, tags: filters.tags, diff --git a/docs/channels.md b/docs/channels.md index 75f421de..3a97f74b 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -260,6 +260,31 @@ Retry while leaving Unicode available and retaining drafts. Only one picker owns Emoji Mart's global dictionary at a time; scoped custom IDs and disposal prevent old community entries leaking into search or Frequent. Historical messages and existing reactions keep their signed emoji URLs after catalog changes. +Emoji-only messages stay at the large 42px size regardless of count; normal text +returns the message to its usual size. Long runs wrap instead of shrinking. +Selecting and copying custom emoji preserves their `:shortcode:` in plain text, +along with surrounding text and line breaks. Pasting into a community with that +emoji available resolves the shortcode through its existing composer catalog. +In the composer, Shift+Left/Right selects each rendered custom emoji as one unit, +preserving its full shortcode for copying, replacement and deletion. Reversing +direction shrinks the selection by one emoji. Visible shortcode text and emoji +that cannot be rendered retain ordinary text selection. +Custom emoji autocomplete adds no trailing space. The native caret uses the +regular composer text size while the emoji preview remains large. + +Message and thread reaction rows have a Lucide smile-plus button after existing +reactions. Messages without reactions do not show it. The Emoji plugin supplies +the emoji-only picker +through its optional conversation tool `reactionComponent`; the shared message +row owns publication. The picker opens outside the scrolling list, closes on +selection or Escape, and returns focus to the plus button. Failed or unconfirmed +reaction delivery offers Retry reaction through the same outbox. Read-only +connections and archived channels do not expose the action. + +The composer shows its GIF tab as soon as relay support is confirmed. Pickers +without tabs use a search radius equal to the container radius minus the 10px +inset; tabbed pickers keep the smaller 8px search radius. + Emoji uploads and management remain in the existing community workflow. See [the shared catalog/send contract](relay-queries.md#community-emoji). The local diff --git a/docs/relay-queries.md b/docs/relay-queries.md index 62fac93a..4f721bc5 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -81,7 +81,7 @@ running broker needs one coordinated restart to gain the new filter. Reopening the picker reuses its ready catalog; it is not a manual refresh fallback for an older broker. -`session.messages.send`, `reply`, and `edit` resolve referenced `:shortcodes:` +`session.messages.send`, `reply`, `edit`, and `react` resolve referenced `:shortcodes:` into original-URL emoji tags **before** the outbox assigns identity or signs. Text without shortcode candidates does not wait. A cold/unavailable catalog throws synchronously, so a composer retains its draft; plugins can await `ensure()` or @@ -93,7 +93,10 @@ Message and reaction rendering uses only each event's own emoji tags, never the current palette. Tagged edits replace mappings; legacy tagless edits preserve the original message's mappings. All thumbnails use the captured session's media resolver; unsupported or unloadable images fall back to literal shortcodes. -Uploads/management and reaction authoring are outside this slice. +Reaction authoring uses the existing outbox: kind 7, the loaded message's channel +(`h`) and target (`e`), and event-local custom emoji tags. The development broker +admits bounded reactions with exactly one canonical target and preserves kind 7 +through signing. Uploads and emoji management remain outside this slice. ## Thread views diff --git a/src/bundled/emoji/CustomEmoji.tsx b/src/bundled/emoji/CustomEmoji.tsx index c54aec6c..1ca574e6 100644 --- a/src/bundled/emoji/CustomEmoji.tsx +++ b/src/bundled/emoji/CustomEmoji.tsx @@ -16,6 +16,8 @@ export function CustomEmoji({ return src && failed !== src ? ( {literal}("emoji"); @@ -43,23 +46,23 @@ export function EmojiPicker({ supported: boolean | undefined; }>(); const [gifDiscoveryRequested, setGifDiscoveryRequested] = useState(false); - const [showGifTab, setShowGifTab] = useState(false); const [perLine, setPerLine] = useState(0); const [error, setError] = useState(); const [attempt, retry] = useState(0); const trigger = useRef(null); const controls = useRef(null); - const host = useRef(null); + const [host, setHost] = useState(null); const search = useRef(""); const onInsert = useRef(insert); onInsert.current = insert; const id = useId(); - const community = communityFromScope(scope); + const community = reaction ? undefined : communityFromScope(scope); const gifs = community ? gifAvailability?.community === community ? gifAvailability.supported : undefined : false; + const showGifTab = gifs === true; const catalog = useSyncExternalStore( session.emoji.subscribe, session.emoji.snapshot, @@ -68,7 +71,9 @@ export function EmojiPicker({ useEffect(() => { // The popover is positioned against the action row; intermediate tool groups // may be narrower and are not its available width. - const container = controls.current?.offsetParent; + const container = reaction + ? document.documentElement + : controls.current?.offsetParent; if (!open || disabled || !(container instanceof HTMLElement)) return; const resize = () => setPerLine( @@ -86,7 +91,7 @@ export function EmojiPicker({ const observer = new ResizeObserver(resize); observer.observe(container); return () => observer.disconnect(); - }, [open, disabled]); + }, [open, disabled, reaction]); useEffect(() => { if ( !community || @@ -120,7 +125,7 @@ export function EmojiPicker({ return () => controller.abort(); }, [community, gifDiscoveryRequested, gifAvailability]); useEffect(() => { - if (!open || disabled) return; + if (!open || disabled || reaction) return; function outside(event: PointerEvent) { if ( event.target instanceof Node && @@ -130,12 +135,11 @@ export function EmojiPicker({ } document.addEventListener("pointerdown", outside); return () => document.removeEventListener("pointerdown", outside); - }, [open, disabled]); + }, [open, disabled, reaction]); // biome-ignore lint/correctness/useExhaustiveDependencies: attempt explicitly retries a failed lazy import. useLayoutEffect(() => { - if (!open || disabled || tab !== "emoji" || !host.current || !perLine) - return; - const container = host.current; + if (!open || disabled || tab !== "emoji" || !host || !perLine) return; + const container = host; let cancelled = false; let dispose: (() => void) | undefined; setError(undefined); @@ -185,8 +189,153 @@ export function EmojiPicker({ perLine, tab, animateTab, + host, ]); - return ( + const picker = ( +
+
+ ); + const button = ( + + ); + const controlsView = (
- - {open && !disabled && ( -
-
- )} + {reaction ? : button} + {reaction ? ( + + + + + + ) : open && !disabled ? ( + picker + ) : null}
); + return reaction ? ( + + {controlsView} + + ) : ( + controlsView + ); } diff --git a/src/bundled/emoji/copy-emoji.ts b/src/bundled/emoji/copy-emoji.ts new file mode 100644 index 00000000..837f328e --- /dev/null +++ b/src/bundled/emoji/copy-emoji.ts @@ -0,0 +1,64 @@ +/** Preserve event-local shortcodes when copying selected custom emoji images. */ +export function copyEmoji(event: ClipboardEvent) { + if (event.defaultPrevented || !event.clipboardData) return; + // Editable controls already copy their source text, including shortcodes. + if ( + event + .composedPath() + .some( + (node) => + node instanceof HTMLElement && + (node.matches("input, textarea") || node.isContentEditable), + ) + ) + return; + const selection = document.getSelection(); + if (!selection || selection.isCollapsed || !selection.rangeCount) return; + const fragments = Array.from({ length: selection.rangeCount }, (_, index) => + selection.getRangeAt(index).cloneContents(), + ); + if ( + !fragments.some((fragment) => + fragment.querySelector("img[data-copy-emoji]"), + ) + ) + return; + const values = fragments.map((fragment) => { + for (const emoji of fragment.querySelectorAll( + "img[data-copy-emoji]", + )) { + emoji.replaceWith( + document.createTextNode(emoji.dataset.copyEmoji ?? emoji.alt), + ); + } + return selectedText(fragment); + }); + event.clipboardData.setData("text/plain", values.join("\n")); + event.preventDefault(); +} + +// Walk the detached selection only: no hidden DOM insertion, image loads or +// changes to the user's selection. Preserve explicit breaks and block boundaries. +function selectedText(node: Node): string { + if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? ""; + if (node instanceof Element && node.tagName === "BR") return "\n"; + let text = ""; + let previousBlock = false; + for (const child of node.childNodes) { + const block = + child instanceof Element && + /^(DIV|P|LI|OL|UL|SECTION|H[1-6])$/.test(child.tagName); + const value = selectedText(child); + if ( + text && + value && + (block || previousBlock) && + !text.endsWith("\n") && + !value.startsWith("\n") + ) + text += "\n"; + text += value; + previousBlock = block; + } + return text; +} diff --git a/src/bundled/emoji/emoji-mart.ts b/src/bundled/emoji/emoji-mart.ts index bc7c49d1..00d33e63 100644 --- a/src/bundled/emoji/emoji-mart.ts +++ b/src/bundled/emoji/emoji-mart.ts @@ -274,7 +274,7 @@ export function mountEmojiMart({ stroke: var(--picker-search-background); } .spacer { - height: 4px; + height: var(--picker-search-top-space, 4px); } .spacer + .flex.flex-middle { padding-bottom: 4px; diff --git a/src/bundled/emoji/index.tsx b/src/bundled/emoji/index.tsx index 6b7bf2a4..54497eab 100644 --- a/src/bundled/emoji/index.tsx +++ b/src/bundled/emoji/index.tsx @@ -3,11 +3,13 @@ import { emojiQuery } from "./emoji-query"; import type { PluginModule } from "../../plugins/api"; import type { ComposerToolProps, + ReactionToolProps, InlineContent, } from "../../features/conversation/contracts"; import { emojiMatches } from "../../features/relay/emoji"; import { EmojiPicker } from "./EmojiPicker"; import { CustomEmoji } from "./CustomEmoji"; +import { copyEmoji } from "./copy-emoji"; export const inject = ["conversation"]; const entries = (content: InlineContent) => @@ -17,6 +19,11 @@ const entries = (content: InlineContent) => : [] : (content.message.emoji ?? []); export const apply: PluginModule["apply"] = (ctx) => { + ctx.effect(() => { + if (typeof document === "undefined") return () => {}; + document.addEventListener("copy", copyEmoji); + return () => document.removeEventListener("copy", copyEmoji); + }); ctx.conversation.registerCompletion({ id: "typeahead", title: "Emoji", @@ -27,6 +34,20 @@ export const apply: PluginModule["apply"] = (ctx) => { ctx.conversation.registerTool({ id: "picker", title: "Emoji", + reactionComponent: ({ + session, + scope, + disabled, + select, + }: ReactionToolProps) => ( + + ), component: ({ session, scope, diff --git a/src/features/conversation/ReactionTool.tsx b/src/features/conversation/ReactionTool.tsx new file mode 100644 index 00000000..7873044e --- /dev/null +++ b/src/features/conversation/ReactionTool.tsx @@ -0,0 +1,125 @@ +import { useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"; +import type { RelaySession } from "../relay/session"; +import type { ComposerTool, ContributionReader } from "./contracts"; +import type { Contribution } from "../../plugins/contributions"; +import { ContributionBoundary, contributionKey } from "./ContributionBoundary"; +import { messageViewKey } from "../messages/view-key"; +import type { Outbox } from "../relay/outbox"; + +export function ReactionTool({ + registry, + ...props +}: { + registry: ContributionReader; + session: RelaySession; + scope: string; + messageId: string; + disabled: boolean; +}) { + const tools = useSyncExternalStore( + registry.subscribe, + registry.snapshot, + registry.snapshot, + ); + const tool = [...tools] + .sort((a, b) => a.key.localeCompare(b.key)) + .find((item) => item.reactionComponent); + return tool ? ( + Reactions unavailable} + > + + + ) : null; +} + +function OwnedReactionTool({ + registry, + tool, + session, + scope, + messageId, + disabled, +}: { + registry: ContributionReader; + tool: Contribution; + session: RelaySession; + scope: string; + messageId: string; + disabled: boolean; +}) { + const [error, setError] = useState(); + const [sentId, setSentId] = useState(); + const alive = useRef(false); + const unavailable = useRef(disabled); + useLayoutEffect(() => { + unavailable.current = disabled; + }); + useLayoutEffect(() => { + alive.current = true; + return () => { + alive.current = false; + }; + }, []); + const Picker = tool.reactionComponent; + return Picker ? ( + <> + { + if ( + !alive.current || + unavailable.current || + !registry.snapshot().includes(tool) + ) + return false; + try { + setSentId(session.messages.react(messageId, emoji)); + setError(undefined); + return true; + } catch (reason) { + setError( + reason instanceof Error + ? reason.message + : "Could not add reaction. Try again.", + ); + return false; + } + }} + /> + {error && {error}} + {sentId && session.outbox && ( + + )} + + ) : null; +} + +function ReactionDelivery({ outbox, id }: { outbox: Outbox; id: string }) { + const operations = useSyncExternalStore( + outbox.subscribe, + outbox.snapshot, + outbox.snapshot, + ); + const operation = operations.find((item) => item.event.id === id); + if (!operation || !["failed", "unknown"].includes(operation.delivery)) + return null; + return ( + + {operation.delivery === "failed" + ? "Couldn’t add reaction." + : "Reaction delivery not confirmed."}{" "} + + + ); +} diff --git a/src/features/conversation/contracts.ts b/src/features/conversation/contracts.ts index b6bedd04..ac7b47b6 100644 --- a/src/features/conversation/contracts.ts +++ b/src/features/conversation/contracts.ts @@ -18,12 +18,21 @@ export type ComposerToolProps = Readonly<{ insertMention(recipient: Readonly<{ pubkey: string; name: string }>): boolean; focus(): void; }>; +export type ReactionToolProps = Readonly<{ + session: RelaySession; + scope: string; + disabled: boolean; + /** Host-owned reaction intent; revoked when the tool or target is removed. */ + select(emoji: string): boolean; +}>; export type ComposerTool = Readonly<{ id: string; title: string; /** Lower values appear first; defaults to zero. Equal values sort by contribution key. */ order?: number; component: ComponentType; + /** Optional emoji-only chooser for the message reaction row. */ + reactionComponent?: ComponentType; }>; export type InlineContent = Readonly<{ text: string; diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx index 9db2483c..eb723d62 100644 --- a/src/features/messages/ChannelTimeline.tsx +++ b/src/features/messages/ChannelTimeline.tsx @@ -487,6 +487,8 @@ function Timeline({ ({ initial === null ? { focus: vi.fn(), + style: {}, + children: [], addEventListener: vi.fn(), removeEventListener: vi.fn(), ownerDocument: { @@ -263,7 +265,7 @@ it("enlarges Unicode-only drafts and restores normal text presentation", () => { h.type("πŸ˜€ πŸ™ πŸ‘"); expect(h.input().props["data-single-emoji"]).toBe(true); h.type("πŸ˜€ πŸ™ πŸ‘ πŸ˜„"); - expect(h.input().props["data-single-emoji"]).toBeUndefined(); + expect(h.input().props["data-single-emoji"]).toBe(true); h.type("πŸ˜€ πŸ™ hello"); expect(h.input().props["data-single-emoji"]).toBeUndefined(); }); @@ -278,7 +280,7 @@ it("recognizes an exact custom emoji draft without treating shortcode prose as e expect(usesLargeEmojiPresentation(":party: πŸ˜€ :PARTY:", [party])).toBe(true); expect( usesLargeEmojiPresentation(":party: πŸ˜€ :PARTY: :party:", [party]), - ).toBe(false); + ).toBe(true); }); it.each([undefined, "root"])( diff --git a/src/features/messages/MessageComposer.tsx b/src/features/messages/MessageComposer.tsx index 9639d419..30e44ffa 100644 --- a/src/features/messages/MessageComposer.tsx +++ b/src/features/messages/MessageComposer.tsx @@ -11,9 +11,10 @@ import type { RelaySession } from "../relay/session"; import { readView, writeView } from "../../shared/view-state"; import styles from "./Messages.module.css"; import { messageViewKey } from "./view-key"; +import { extendEmojiSelection } from "./emoji-selection"; import { customEmojiOnlySpans, - isUnicodeEmojiOnly, + isEmojiOnly, leadingCustomEmojiSpans, usesLargeEmojiPresentation, } from "./emoji-size"; @@ -115,16 +116,63 @@ function Composer({ customEmojiSources.every( ({ source }) => !!source && source !== failedCustomEmoji, ); - const showCustomEmojiOnly = - showCustomEmoji && - customEmojiOnly.length > 0 && - customEmojiOnly.length <= 3; + const showCustomEmojiOnly = showCustomEmoji && customEmojiOnly.length > 0; const showLeadingCustomEmoji = showCustomEmoji && !customEmojiOnly.length && !!leadingCustomEmoji.spans.length; const input = useRef(null); const customEmojiMirror = useRef(null); + const customEmojiGroup = useRef(null); + const [customEmojiLayout, setCustomEmojiLayout] = useState({ + left: 0, + top: 0, + height: 48, + }); + useLayoutEffect(() => { + if (!showCustomEmojiOnly) return; + const group = customEmojiGroup.current; + const last = group?.children[customEmojiOnly.length - 1]; + if (!group || !last || !(last instanceof HTMLElement)) return; + const resize = () => { + const lineHeight = Number.parseFloat( + getComputedStyle(input.current ?? group).lineHeight, + ); + const next = { + left: Math.min( + last.offsetLeft + last.offsetWidth + 2, + Math.max(0, group.clientWidth - 2), + ), + top: last.offsetTop, + height: last.offsetTop + lineHeight, + }; + setCustomEmojiLayout((current) => + current.left === next.left && + current.top === next.top && + current.height === next.height + ? current + : next, + ); + }; + resize(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(resize); + observer.observe(group); + return () => observer.disconnect(); + }, [showCustomEmojiOnly, customEmojiOnly.length]); + useLayoutEffect(() => { + const element = input.current; + if ( + showCustomEmojiOnly && + element && + element.selectionStart === draft.length && + element.selectionEnd === draft.length + ) + element.scrollTop = Math.max( + 0, + customEmojiLayout.height - element.clientHeight, + ); + }, [showCustomEmojiOnly, customEmojiLayout.height, draft.length]); const inlineEmojiGroup = useRef(null); const inlinePrefixMeasure = useRef(null); const [inlineTextIndent, setInlineTextIndent] = useState(0); @@ -190,10 +238,14 @@ function Composer({ useLayoutEffect(() => { const mirror = customEmojiMirror.current; if (mirror) mirror.scrollTop = input.current?.scrollTop ?? 0; + if (customEmojiGroup.current) + customEmojiGroup.current.style.transform = `translateY(-${input.current?.scrollTop ?? 0}px)`; }); function syncCustomEmojiScroll(element: HTMLTextAreaElement) { if (customEmojiMirror.current) customEmojiMirror.current.scrollTop = element.scrollTop; + if (customEmojiGroup.current) + customEmojiGroup.current.style.transform = `translateY(-${element.scrollTop}px)`; } function insert( text: string, @@ -264,7 +316,7 @@ function Composer({ return ( typeof edit.text === "string" && insert( - `${edit.text}${isUnicodeEmojiOnly(edit.text) ? "" : " "}`, + `${edit.text}${isEmojiOnly(edit.text, emojiCatalog.entries) ? "" : " "}`, undefined, query, ) @@ -338,7 +390,9 @@ function Composer({ style={ showCustomEmojiOnly ? { - paddingLeft: `calc(${customEmojiOnly.length * 44}px * var(--buzz-text-scale, 1))`, + paddingLeft: customEmojiLayout.left, + paddingTop: customEmojiLayout.top, + height: customEmojiLayout.height, } : showLeadingCustomEmoji ? { textIndent: `${inlineTextIndent}px` } @@ -395,6 +449,28 @@ function Composer({ ["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key) ) { completion.invalidate(); + if ( + showCustomEmoji && + !event.altKey && + !event.ctrlKey && + !event.metaKey && + (event.key === "ArrowLeft" || event.key === "ArrowRight") + ) { + const next = extendEmojiSelection( + customEmojiSources, + event.currentTarget, + event.key, + ); + if (next) { + event.preventDefault(); + event.currentTarget.setSelectionRange( + next.start, + next.end, + next.direction, + ); + setSelection({ start: next.start, end: next.end }); + } + } return; } if (completion.keys.current?.(event)) return; @@ -411,7 +487,11 @@ function Composer({ }} /> {showCustomEmojiOnly && ( -