From 61db83cf2c2a70787a36581cfa13dfb32a81ff60 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 29 Oct 2025 11:05:30 +0200 Subject: [PATCH 01/20] wip Signed-off-by: Simo --- codex/STATE.md | 2 +- codex/tickets/TKT-0010.md | 1 + components/waves/drops/WaveDrop.tsx | 2 + .../hooks/useWaveDropsClipboard.ts | 470 ++++++++++++++++++ .../waves/drops/wave-drops-all/index.tsx | 18 +- 5 files changed, 490 insertions(+), 3 deletions(-) create mode 100644 components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts diff --git a/codex/STATE.md b/codex/STATE.md index 87fcc94abf..d9e4a3d91a 100644 --- a/codex/STATE.md +++ b/codex/STATE.md @@ -13,7 +13,7 @@ This table is the single source of truth for active and historical tickets. Keep | TKT-0007 | Stabilize group name search input | In-Progress | P0 | simo6529 | [#1540](https://github.com/6529-Collections/6529seize-frontend/pull/1540) | 2025-10-14 | | TKT-0008 | Reconcile Codex board merge conflicts | In-Progress | P1 | openai-assistant | [#1539](https://github.com/6529-Collections/6529seize-frontend/pull/1539) | 2025-10-14 | | TKT-0009 | Refactor Brain notifications shell for modular clarity | In-Progress | P1 | simo6529 | [#1545](https://github.com/6529-Collections/6529seize-frontend/pull/1545) | 2025-10-15 | -| TKT-0010 | Refactor WaveDropsAll component for modular clarity | In-Progress | P1 | openai-assistant | [#1560](https://github.com/6529-Collections/6529seize-frontend/pull/1560) | 2025-10-22 | +| TKT-0010 | Refactor WaveDropsAll component for modular clarity | In-Progress | P1 | openai-assistant | [#1560](https://github.com/6529-Collections/6529seize-frontend/pull/1560) | 2025-10-29 | | TKT-0011 | Restore identity search keyboard navigation | Done | P1 | simo6529 | Pending (branch block-add-identity-to-wave) | 2025-10-26 | | TKT-0012 | Refactor wave group edit buttons for modular clarity | In-Progress | P1 | openai-assistant | [#1544](https://github.com/6529-Collections/6529seize-frontend/pull/1544) | 2025-10-26 | | TKT-0013 | Respect unstyled flag in compact menu button | In-Progress | P1 | openai-assistant | — | 2025-10-23 | diff --git a/codex/tickets/TKT-0010.md b/codex/tickets/TKT-0010.md index bcfa55ada3..7ff1269171 100644 --- a/codex/tickets/TKT-0010.md +++ b/codex/tickets/TKT-0010.md @@ -42,3 +42,4 @@ title: Refactor WaveDropsAll component for modular clarity - 2025-10-22T11:18:04Z – Updated `parseSeizeQuoteLink` to accept both serial number and drop-based quote URLs and expanded unit tests (`npm run test -- --runTestsByPath __tests__/helpers/SeizeLinkParser.test.ts`) to cover the additional cases. - 2025-10-22T11:21:14Z – Removed placeholder origin fallback from Seize quote parsing and documented the new requirement in `AGENTS.md`; `npm run test -- --runTestsByPath __tests__/helpers/SeizeLinkParser.test.ts` remains green. - 2025-10-22T11:24:22Z – Normalized Seize quote parsing to trim trailing slashes from query values (wave, serial, drop) and added regression tests (`npm run test -- --runTestsByPath __tests__/helpers/SeizeLinkParser.test.ts`). +- 2025-10-29T09:00:30Z – Added data-driven clipboard handling for WaveDrops notes, including custom copy formatting, modifier-based Markdown export, and toast feedback aligned with the copy spec (manual verification only; no commands executed per policy). diff --git a/components/waves/drops/WaveDrop.tsx b/components/waves/drops/WaveDrop.tsx index ba68a4bce0..9897c462a2 100644 --- a/components/waves/drops/WaveDrop.tsx +++ b/components/waves/drops/WaveDrop.tsx @@ -322,6 +322,8 @@ const WaveDrop = ({ } ${isProfileView ? "tw-mb-3" : ""} tw-w-full`}>
diff --git a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts new file mode 100644 index 0000000000..b554da9419 --- /dev/null +++ b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts @@ -0,0 +1,470 @@ +"use client"; + +import { RefObject, useEffect, useMemo, useRef } from "react"; +import { toast } from "react-toastify"; +import { + Drop, + DropSize, + ExtendedDrop, +} from "@/helpers/waves/drop.helpers"; +import { ApiDropType } from "@/generated/models/ApiDropType"; +import { ApiDropMetadata } from "@/generated/models/ApiDropMetadata"; + +type ClipboardFormat = "plain" | "markdown"; + +interface ClipboardMessage { + readonly id: string; + readonly author: string; + readonly timestamp: number; + readonly plainContent: string; + readonly markdownContent: string; + readonly embedPlainLines: string[]; + readonly embedMarkdownLines: string[]; + readonly attachmentPlainLines: string[]; + readonly attachmentMarkdownLines: string[]; +} + +interface UseWaveDropsClipboardOptions { + readonly containerRef: RefObject; + readonly drops: Drop[] | undefined; +} + +const isHTMLElement = (node: Node | null): node is HTMLElement => + !!node && node instanceof HTMLElement; + +const nodeIsEditable = (node: Node | null): boolean => { + if (!node) { + return false; + } + + if (isHTMLElement(node)) { + if (node.isContentEditable) { + return true; + } + if ( + node instanceof HTMLInputElement || + node instanceof HTMLTextAreaElement + ) { + return true; + } + if (node.dataset?.waveClipboardAllowDefault === "true") { + return true; + } + } + + const parent = isHTMLElement(node) ? node.parentElement : node.parentElement; + if (!parent) { + return false; + } + + if (parent.closest("[contenteditable=\"true\"]")) { + return true; + } + + if (parent instanceof HTMLInputElement || parent instanceof HTMLTextAreaElement) { + return true; + } + + return ( + parent.closest("[data-wave-clipboard-allow-default=\"true\"]") !== null + ); +}; + +const toPlainText = (markdown: string): string => + markdown + .replace(/```([\s\S]*?)```/g, (_, code) => code.trim()) + .replace(/`([^`]+)`/g, "$1") + .replace(/!\[.*?]\((.*?)\)/g, (_, url: string) => url) + .replace(/\[(.*?)]\((.*?)\)/g, "$1 ($2)") + .replace(/(?:\*\*|__)(.*?)\1/g, "$1") + .replace(/[_*~>#-]/g, "") + .replace(/\n{3,}/g, "\n\n") + .trim(); + +type EmbedInfo = { + readonly title?: string; + readonly url?: string; + readonly description?: string; + readonly extras: string[]; +}; + +const EMBED_KEY_SEPARATORS = [":", "::", ".", "-", "_"] as const; + +const extractEmbeds = (metadata: ApiDropMetadata[]): EmbedInfo[] => { + if (!metadata.length) { + return []; + } + + const groups = new Map(); + + metadata.forEach(({ data_key, data_value }) => { + if (!data_value) { + return; + } + + const normalizedKey = data_key?.toLowerCase?.() ?? ""; + let groupKey = "default"; + let fieldKey = normalizedKey; + + for (const separator of EMBED_KEY_SEPARATORS) { + const index = normalizedKey.lastIndexOf(separator); + if (index > -1) { + groupKey = normalizedKey.slice(0, index) || "default"; + fieldKey = normalizedKey.slice(index + separator.length); + break; + } + } + + const group = groups.get(groupKey) ?? { extras: [] as string[] }; + + if (fieldKey.includes("title")) { + if (!group.title) { + group.title = data_value; + } + } else if (fieldKey.includes("url")) { + if (!group.url) { + group.url = data_value; + } + } else if (fieldKey.includes("description")) { + if (!group.description) { + group.description = data_value; + } + } else if (data_value.startsWith("http")) { + if (!group.url) { + group.url = data_value; + } else if (!group.extras.includes(data_value)) { + group.extras.push(data_value); + } + } else { + group.extras.push(`${data_key}: ${data_value}`); + } + + groups.set(groupKey, group); + }); + + return Array.from(groups.values()).filter( + (group) => + !!group.title || + !!group.url || + !!group.description || + group.extras.length > 0 + ); +}; + +const buildClipboardMessage = (drop: ExtendedDrop): ClipboardMessage => { + const markdownContent = drop.parts + .map((part) => part.content ?? "") + .filter((value) => (value ?? "").trim().length > 0) + .join("\n\n") + .trim(); + + const plainContent = markdownContent ? toPlainText(markdownContent) : ""; + + const embedInfos = extractEmbeds(drop.metadata ?? []); + + const embedPlainLines = embedInfos.flatMap((embed) => { + const lines: string[] = []; + if (embed.title && embed.url) { + lines.push(`${embed.title} — ${embed.url}`); + } else if (embed.title) { + lines.push(embed.title); + } else if (embed.url) { + lines.push(embed.url); + } + if (embed.description) { + lines.push(embed.description); + } + lines.push(...embed.extras); + return lines; + }); + + const embedMarkdownLines = embedInfos.flatMap((embed) => { + const lines: string[] = []; + if (embed.title && embed.url) { + lines.push(`[${embed.title}](${embed.url})`); + } else if (embed.title) { + lines.push(`**${embed.title}**`); + } else if (embed.url) { + lines.push(embed.url); + } + if (embed.description) { + lines.push(embed.description); + } + lines.push(...embed.extras); + return lines; + }); + + const mediaUrls = drop.parts.flatMap((part) => + part.media + .map((media) => media.url) + .filter((url): url is string => !!url && url.length > 0) + ); + + const uniqueAttachments = Array.from(new Set(mediaUrls)); + + return { + id: drop.stableKey ?? drop.id, + author: drop.author?.handle ?? "Unknown", + timestamp: drop.created_at, + markdownContent, + plainContent, + embedPlainLines: embedPlainLines.filter(Boolean), + embedMarkdownLines: embedMarkdownLines.filter(Boolean), + attachmentPlainLines: uniqueAttachments, + attachmentMarkdownLines: uniqueAttachments, + }; +}; + +const formatTimestamp = (timestamp: number): string => { + try { + return new Intl.DateTimeFormat(undefined, { + hour: "2-digit", + minute: "2-digit", + }).format(new Date(timestamp)); + } catch { + return ""; + } +}; + +const formatMessage = ( + message: ClipboardMessage, + format: ClipboardFormat, + isSingle: boolean +): string => { + const timeLabel = formatTimestamp(message.timestamp); + const authorLabel = message.author || "Unknown"; + const heading = + format === "markdown" + ? isSingle + ? `**${authorLabel}**${timeLabel ? ` (${timeLabel})` : ""}:` + : `${timeLabel ? `**${timeLabel}** ` : ""}**${authorLabel}**:` + : isSingle + ? `${authorLabel}${timeLabel ? ` (${timeLabel})` : ""}:` + : `${timeLabel ? `${timeLabel} ` : ""}${authorLabel}:`; + + const primaryContent = + format === "markdown" + ? message.markdownContent.trim() + : message.plainContent.trim(); + + const embedLines = + format === "markdown" + ? message.embedMarkdownLines + : message.embedPlainLines; + const attachmentLines = + format === "markdown" + ? message.attachmentMarkdownLines + : message.attachmentPlainLines; + + const additionalSections = [...embedLines, ...attachmentLines].filter( + (line) => line && line.length > 0 + ); + + if (!primaryContent && additionalSections.length === 0) { + return heading; + } + + const [firstSection, ...rest] = [ + primaryContent, + ...additionalSections, + ].filter((section) => section && section.length > 0); + + if (!firstSection) { + return heading; + } + + let block = `${heading} ${firstSection}`.trimEnd(); + if (rest.length > 0) { + block += `\n\n${rest.join("\n\n")}`; + } + + return block; +}; + +const formatMessages = ( + messages: ClipboardMessage[], + format: ClipboardFormat +): string => { + if (!messages.length) { + return ""; + } + + const isSingle = messages.length === 1; + const segments = messages.map((message) => + formatMessage(message, format, isSingle) + ); + + return segments.join("\n\n"); +}; + +const gatherSelectedMessageIds = ( + selection: Selection, + container: HTMLElement +): string[] => { + const dropElements = Array.from( + container.querySelectorAll("[data-wave-drop-id]") + ); + + const ids: string[] = []; + const seen = new Set(); + + for (const element of dropElements) { + const dropId = element.dataset.waveDropId; + if (!dropId || seen.has(dropId)) { + continue; + } + + let intersects = false; + for (let i = 0; i < selection.rangeCount; i += 1) { + const range = selection.getRangeAt(i); + try { + if (range.intersectsNode(element)) { + intersects = true; + break; + } + } catch { + continue; + } + } + + if (intersects) { + ids.push(dropId); + seen.add(dropId); + } + } + + return ids; +}; + +export const useWaveDropsClipboard = ({ + containerRef, + drops, +}: UseWaveDropsClipboardOptions): void => { + const chatDrops = useMemo( + () => + (drops ?? []).filter( + (drop): drop is ExtendedDrop => + drop.type === DropSize.FULL && drop.drop_type === ApiDropType.Chat + ), + [drops] + ); + + const clipboardMessages = useMemo(() => { + return new Map( + chatDrops.map((drop) => [drop.stableKey ?? drop.id, buildClipboardMessage(drop)]) + ); + }, [chatDrops]); + + const formatRef = useRef("plain"); + + useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key.toLowerCase() !== "c") { + return; + } + + if (!event.metaKey && !event.ctrlKey) { + return; + } + + formatRef.current = event.shiftKey ? "markdown" : "plain"; + }; + + const handleCopy = (event: ClipboardEvent) => { + const selection = globalThis.getSelection?.(); + + if (!selection || selection.isCollapsed) { + formatRef.current = "plain"; + return; + } + + const anchorNodeParent = selection.anchorNode; + const focusNodeParent = selection.focusNode; + + if ( + !container.contains(anchorNodeParent) && + !container.contains(focusNodeParent) + ) { + formatRef.current = "plain"; + return; + } + + if (nodeIsEditable(anchorNodeParent) || nodeIsEditable(focusNodeParent)) { + formatRef.current = "plain"; + return; + } + + const selectedIds = gatherSelectedMessageIds(selection, container); + + if (!selectedIds.length) { + formatRef.current = "plain"; + return; + } + + const messages = selectedIds + .map((id) => clipboardMessages.get(id)) + .filter((message): message is ClipboardMessage => !!message); + + if (!messages.length) { + formatRef.current = "plain"; + return; + } + + messages.sort((a, b) => { + if (a.timestamp === b.timestamp) { + return a.id.localeCompare(b.id); + } + return a.timestamp - b.timestamp; + }); + + const payload = formatMessages(messages, formatRef.current); + + if (!payload) { + formatRef.current = "plain"; + return; + } + + event.preventDefault(); + + if (event.clipboardData) { + event.clipboardData.setData("text/plain", payload); + } + + if (navigator?.clipboard?.writeText) { + void navigator.clipboard + .writeText(payload) + .catch(() => { + // Silently ignore clipboard promise failures – the event clipboard fallback already ran. + }); + } + + const count = messages.length; + toast.success( + count === 1 ? "Message copied" : `Copied ${count} messages`, + { + autoClose: 1000, + hideProgressBar: true, + pauseOnHover: false, + closeOnClick: true, + draggable: false, + } + ); + + formatRef.current = "plain"; + }; + + window.addEventListener("keydown", handleKeyDown); + container.addEventListener("copy", handleCopy); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + container.removeEventListener("copy", handleCopy); + }; + }, [clipboardMessages, containerRef]); +}; + diff --git a/components/waves/drops/wave-drops-all/index.tsx b/components/waves/drops/wave-drops-all/index.tsx index 4225f1de4f..cf218e2d65 100644 --- a/components/waves/drops/wave-drops-all/index.tsx +++ b/components/waves/drops/wave-drops-all/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback } from "react"; +import { useCallback, useMemo, useRef } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/components/auth/Auth"; import { useNotificationsContext } from "@/components/notifications/NotificationsContext"; @@ -15,6 +15,7 @@ import { ActiveDropState } from "@/types/dropInteractionTypes"; import WaveDropsScrollingOverlay from "@/components/waves/drops/WaveDropsScrollingOverlay"; import { useWaveDropsNotificationRead } from "./hooks/useWaveDropsNotificationRead"; import { useWaveDropsSerialScroll } from "./hooks/useWaveDropsSerialScroll"; +import { useWaveDropsClipboard } from "./hooks/useWaveDropsClipboard"; import { WaveDropsContent } from "./subcomponents/WaveDropsContent"; interface WaveDropsAllProps { @@ -51,6 +52,7 @@ const WaveDropsAll: React.FC = ({ const router = useRouter(); const { removeWaveDeliveredNotifications } = useNotificationsContext(); const { connectedProfile } = useAuth(); + const containerRef = useRef(null); const { waveMessages, fetchNextPage, waitAndRevealDrop } = useVirtualizedWaveDrops(waveId, dropId); @@ -67,6 +69,16 @@ const WaveDropsAll: React.FC = ({ removeWaveDeliveredNotifications, }); + const dropsForClipboard = useMemo( + () => waveMessages?.drops ?? [], + [waveMessages?.drops] + ); + + useWaveDropsClipboard({ + containerRef, + drops: dropsForClipboard, + }); + const { serialTarget, queueSerialTarget, @@ -133,7 +145,9 @@ const WaveDropsAll: React.FC = ({ ); return ( -
+
Date: Wed, 29 Oct 2025 11:46:13 +0200 Subject: [PATCH 02/20] wip Signed-off-by: Simo --- .../hooks/useWaveDropsClipboard.ts | 160 +++++++++++++++++- 1 file changed, 158 insertions(+), 2 deletions(-) diff --git a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts index b554da9419..ecf791ad45 100644 --- a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts +++ b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts @@ -70,6 +70,66 @@ const nodeIsEditable = (node: Node | null): boolean => { ); }; +const escapeAttributeValue = (value: string): string => { + if (typeof CSS !== "undefined" && typeof CSS.escape === "function") { + return CSS.escape(value); + } + + return value.replace(/["\\]/g, "\\$&"); +}; + +const findDropElement = (node: Node | null): HTMLElement | null => { + if (!node) { + return null; + } + + let current: HTMLElement | null = + node instanceof HTMLElement ? node : (node.parentElement ?? null); + + while (current) { + if (current.dataset?.waveDropId) { + return current; + } + current = current.parentElement; + } + + return null; +}; + +const isRangeFullyCoveringElement = (range: Range, element: HTMLElement): boolean => { + const elementRange = document.createRange(); + elementRange.selectNodeContents(element); + + const coversStart = range.compareBoundaryPoints(Range.START_TO_START, elementRange) <= 0; + const coversEnd = range.compareBoundaryPoints(Range.END_TO_END, elementRange) >= 0; + + elementRange.detach?.(); + + return coversStart && coversEnd; +}; + +const getSelectedTextForElement = (range: Range, element: HTMLElement): string => { + const elementRange = document.createRange(); + elementRange.selectNodeContents(element); + + const clipped = range.cloneRange(); + + if (clipped.compareBoundaryPoints(Range.START_TO_START, elementRange) < 0) { + clipped.setStart(elementRange.startContainer, elementRange.startOffset); + } + + if (clipped.compareBoundaryPoints(Range.END_TO_END, elementRange) > 0) { + clipped.setEnd(elementRange.endContainer, elementRange.endOffset); + } + + const text = clipped.toString(); + + clipped.detach?.(); + elementRange.detach?.(); + + return text; +}; + const toPlainText = (markdown: string): string => markdown .replace(/```([\s\S]*?)```/g, (_, code) => code.trim()) @@ -422,7 +482,104 @@ export const useWaveDropsClipboard = ({ return a.timestamp - b.timestamp; }); - const payload = formatMessages(messages, formatRef.current); + const messagesById = new Map( + messages.map((message) => [message.id, message] as const) + ); + + const selectionRange = + selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + + let payload: string | undefined; + + if (selectionRange) { + const dropElements = new Map(); + + for (const id of selectedIds) { + const escapedId = escapeAttributeValue(id); + const element = container.querySelector( + `[data-wave-drop-id="${escapedId}"]` + ); + if (element) { + dropElements.set(id, element); + } + } + + const startElement = findDropElement(selectionRange.startContainer); + const endElement = findDropElement(selectionRange.endContainer); + const startDropId = startElement?.dataset?.waveDropId ?? null; + const endDropId = endElement?.dataset?.waveDropId ?? null; + + const startElementForRange = + (startDropId && dropElements.get(startDropId)) ?? startElement ?? null; + const endElementForRange = + (endDropId && dropElements.get(endDropId)) ?? endElement ?? null; + + const startFullySelected = + !!( + startDropId && + startElementForRange && + isRangeFullyCoveringElement(selectionRange, startElementForRange) + ); + const endFullySelected = + !!( + endDropId && + endElementForRange && + isRangeFullyCoveringElement(selectionRange, endElementForRange) + ); + + const partialSegments = new Map(); + let usedPartialHandling = false; + + if (startDropId && startElementForRange && !startFullySelected) { + usedPartialHandling = true; + const text = getSelectedTextForElement(selectionRange, startElementForRange); + partialSegments.set(startDropId, text); + } + + if ( + endDropId && + endElementForRange && + (!endFullySelected || (startDropId === endDropId && !startFullySelected)) + ) { + usedPartialHandling = true; + const text = getSelectedTextForElement(selectionRange, endElementForRange); + partialSegments.set(endDropId, text); + } + + const fullMessageIds = selectedIds.filter((id) => !partialSegments.has(id)); + const totalFullMessages = fullMessageIds.length; + + const segments: string[] = []; + + for (const id of selectedIds) { + if (partialSegments.has(id)) { + const partial = partialSegments.get(id) ?? ""; + segments.push(partial); + continue; + } + + const message = messagesById.get(id); + if (!message) { + continue; + } + + const segment = formatMessage( + message, + formatRef.current, + totalFullMessages === 1 + ); + + segments.push(segment); + } + + if (segments.length > 0 || usedPartialHandling) { + payload = segments.join("\n\n"); + } + } + + if (!payload) { + payload = formatMessages(messages, formatRef.current); + } if (!payload) { formatRef.current = "plain"; @@ -467,4 +624,3 @@ export const useWaveDropsClipboard = ({ }; }, [clipboardMessages, containerRef]); }; - From 8cb91701aea59b3a0a0a5123e91f6d2963ccac97 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 29 Oct 2025 11:52:57 +0200 Subject: [PATCH 03/20] wip Signed-off-by: Simo --- .../wave-drops-all/hooks/useWaveDropsClipboard.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts index ecf791ad45..7fd27354db 100644 --- a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts +++ b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts @@ -1,7 +1,6 @@ "use client"; import { RefObject, useEffect, useMemo, useRef } from "react"; -import { toast } from "react-toastify"; import { Drop, DropSize, @@ -600,18 +599,6 @@ export const useWaveDropsClipboard = ({ }); } - const count = messages.length; - toast.success( - count === 1 ? "Message copied" : `Copied ${count} messages`, - { - autoClose: 1000, - hideProgressBar: true, - pauseOnHover: false, - closeOnClick: true, - draggable: false, - } - ); - formatRef.current = "plain"; }; From 980bd70e1f471ed2a09686ce5847748b2b065e51 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 29 Oct 2025 12:04:24 +0200 Subject: [PATCH 04/20] wip Signed-off-by: Simo --- .../waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts index 7fd27354db..e8908533c4 100644 --- a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts +++ b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts @@ -136,7 +136,7 @@ const toPlainText = (markdown: string): string => .replace(/!\[.*?]\((.*?)\)/g, (_, url: string) => url) .replace(/\[(.*?)]\((.*?)\)/g, "$1 ($2)") .replace(/(?:\*\*|__)(.*?)\1/g, "$1") - .replace(/[_*~>#-]/g, "") + .replace(/[_*~>]/g, "") .replace(/\n{3,}/g, "\n\n") .trim(); From d93bd8572829d0b96c4c3c3bb88fe6182ee331a4 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 29 Oct 2025 12:18:15 +0200 Subject: [PATCH 05/20] wip Signed-off-by: Simo --- .../drops/wave-drops-all/hooks/useWaveDropsClipboard.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts index e8908533c4..59e2e7ca61 100644 --- a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts +++ b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts @@ -136,7 +136,10 @@ const toPlainText = (markdown: string): string => .replace(/!\[.*?]\((.*?)\)/g, (_, url: string) => url) .replace(/\[(.*?)]\((.*?)\)/g, "$1 ($2)") .replace(/(?:\*\*|__)(.*?)\1/g, "$1") - .replace(/[_*~>]/g, "") + .replace(/(^|[^\w])\*([^*\s][^*]*?)\*(?=$|[^\w])/g, (_, prefix: string, content: string) => `${prefix}${content}`) + .replace(/(^|[^\w])_([^_\s][^_]*?)_(?=$|[^\w])/g, (_, prefix: string, content: string) => `${prefix}${content}`) + .replace(/~~([^~]+)~~/g, "$1") + .replace(/^\s{0,3}>\s?/gm, "") .replace(/\n{3,}/g, "\n\n") .trim(); From 33c0b6ed30a00d2a21ee21f2ed349fb93a8393af Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 29 Oct 2025 12:26:31 +0200 Subject: [PATCH 06/20] wip Signed-off-by: Simo --- .../hooks/useWaveDropsClipboard.ts | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts index 59e2e7ca61..3620c421fa 100644 --- a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts +++ b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts @@ -178,30 +178,37 @@ const extractEmbeds = (metadata: ApiDropMetadata[]): EmbedInfo[] => { } const group = groups.get(groupKey) ?? { extras: [] as string[] }; + let nextGroup = group; if (fieldKey.includes("title")) { - if (!group.title) { - group.title = data_value; + if (!nextGroup.title) { + nextGroup = { ...nextGroup, title: data_value }; } } else if (fieldKey.includes("url")) { - if (!group.url) { - group.url = data_value; + if (!nextGroup.url) { + nextGroup = { ...nextGroup, url: data_value }; } } else if (fieldKey.includes("description")) { - if (!group.description) { - group.description = data_value; + if (!nextGroup.description) { + nextGroup = { ...nextGroup, description: data_value }; } } else if (data_value.startsWith("http")) { - if (!group.url) { - group.url = data_value; - } else if (!group.extras.includes(data_value)) { - group.extras.push(data_value); + if (!nextGroup.url) { + nextGroup = { ...nextGroup, url: data_value }; + } else if (!nextGroup.extras.includes(data_value)) { + nextGroup = { + ...nextGroup, + extras: [...nextGroup.extras, data_value], + }; } } else { - group.extras.push(`${data_key}: ${data_value}`); + nextGroup = { + ...nextGroup, + extras: [...nextGroup.extras, `${data_key}: ${data_value}`], + }; } - groups.set(groupKey, group); + groups.set(groupKey, nextGroup); }); return Array.from(groups.values()).filter( From 2b92e59a9a50214c4f5c89996d435f16a4ddaa21 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 29 Oct 2025 12:31:56 +0200 Subject: [PATCH 07/20] wip Signed-off-by: Simo --- .../meme-calendar.helpers.timezone.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/__tests__/components/meme-calendar/meme-calendar.helpers.timezone.test.ts b/__tests__/components/meme-calendar/meme-calendar.helpers.timezone.test.ts index 35a8072107..6276b3bf1a 100644 --- a/__tests__/components/meme-calendar/meme-calendar.helpers.timezone.test.ts +++ b/__tests__/components/meme-calendar/meme-calendar.helpers.timezone.test.ts @@ -1,5 +1,4 @@ import { - mintEndInstantUtcForMintDay, mintStartInstantUtcForMintDay, nextMintDateOnOrAfter, wallTimeToUtcInstantInZone, @@ -16,6 +15,17 @@ const formatAthensTime = (date: Date): string => const isoDate = (y: number, m: number, d: number): Date => new Date(Date.UTC(y, m, d)); +const mintEndInstantUtcForMintDay = (mintDay: Date): Date => { + const nextDay = new Date( + Date.UTC( + mintDay.getUTCFullYear(), + mintDay.getUTCMonth(), + mintDay.getUTCDate() + 1 + ) + ); + return wallTimeToUtcInstantInZone(nextDay, 17, 0); +}; + describe("meme calendar timezone handling", () => { it("keeps mint start anchored to 17:40 Athens time across 2024", () => { const months = Array.from({ length: 12 }, (_, idx) => idx); From 522238a3fdd612354bad8b5a798220e17bd21dcb Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 29 Oct 2025 14:44:22 +0200 Subject: [PATCH 08/20] wip Signed-off-by: Simo --- .../hooks/useWaveDropsClipboard.ts | 588 +++++++++++------- .../waves/drops/wave-drops-all/index.tsx | 6 +- 2 files changed, 382 insertions(+), 212 deletions(-) diff --git a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts index 3620c421fa..b74602586c 100644 --- a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts +++ b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts @@ -24,7 +24,7 @@ interface ClipboardMessage { } interface UseWaveDropsClipboardOptions { - readonly containerRef: RefObject; + readonly containerRef: RefObject; readonly drops: Drop[] | undefined; } @@ -51,12 +51,12 @@ const nodeIsEditable = (node: Node | null): boolean => { } } - const parent = isHTMLElement(node) ? node.parentElement : node.parentElement; + const parent = node.parentElement; if (!parent) { return false; } - if (parent.closest("[contenteditable=\"true\"]")) { + if (parent.isContentEditable) { return true; } @@ -64,9 +64,11 @@ const nodeIsEditable = (node: Node | null): boolean => { return true; } - return ( - parent.closest("[data-wave-clipboard-allow-default=\"true\"]") !== null + const allowDefaultElement = parent.closest( + "[data-wave-clipboard-allow-default=\"true\"]" ); + + return allowDefaultElement !== null; }; const escapeAttributeValue = (value: string): string => { @@ -74,7 +76,7 @@ const escapeAttributeValue = (value: string): string => { return CSS.escape(value); } - return value.replace(/["\\]/g, "\\$&"); + return value.replaceAll(/[\0-\x1F\x7F"\\\[\]]/g, String.raw`\$&`); }; const findDropElement = (node: Node | null): HTMLElement | null => { @@ -82,8 +84,13 @@ const findDropElement = (node: Node | null): HTMLElement | null => { return null; } - let current: HTMLElement | null = - node instanceof HTMLElement ? node : (node.parentElement ?? null); + let current: HTMLElement | null = null; + + if (node instanceof HTMLElement) { + current = node; + } else if (node.parentElement) { + current = node.parentElement; + } while (current) { if (current.dataset?.waveDropId) { @@ -102,8 +109,6 @@ const isRangeFullyCoveringElement = (range: Range, element: HTMLElement): boolea const coversStart = range.compareBoundaryPoints(Range.START_TO_START, elementRange) <= 0; const coversEnd = range.compareBoundaryPoints(Range.END_TO_END, elementRange) >= 0; - elementRange.detach?.(); - return coversStart && coversEnd; }; @@ -123,24 +128,22 @@ const getSelectedTextForElement = (range: Range, element: HTMLElement): string = const text = clipped.toString(); - clipped.detach?.(); - elementRange.detach?.(); - return text; }; const toPlainText = (markdown: string): string => markdown - .replace(/```([\s\S]*?)```/g, (_, code) => code.trim()) - .replace(/`([^`]+)`/g, "$1") - .replace(/!\[.*?]\((.*?)\)/g, (_, url: string) => url) - .replace(/\[(.*?)]\((.*?)\)/g, "$1 ($2)") - .replace(/(?:\*\*|__)(.*?)\1/g, "$1") - .replace(/(^|[^\w])\*([^*\s][^*]*?)\*(?=$|[^\w])/g, (_, prefix: string, content: string) => `${prefix}${content}`) - .replace(/(^|[^\w])_([^_\s][^_]*?)_(?=$|[^\w])/g, (_, prefix: string, content: string) => `${prefix}${content}`) - .replace(/~~([^~]+)~~/g, "$1") - .replace(/^\s{0,3}>\s?/gm, "") - .replace(/\n{3,}/g, "\n\n") + .replaceAll(/```([\s\S]*?)```/g, (_, code) => code.trim()) + .replaceAll(/`([^`]+)`/g, "$1") + .replaceAll(/!\[[^\]]*]\(([^)]+)\)/g, "$1") + .replaceAll(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)") + .replaceAll(/(\*\*|__)(.*?)\1/g, "$2") + .replaceAll(/(\*|_)(.*?)\1/g, "$2") + .replaceAll(/~~(.*?)~~/g, "$1") + .replaceAll(/(^|\n)#{1,6}\s+/g, "$1") + .replaceAll(/(^|\n)\s*[-*+]\s+/g, "$1") + .replaceAll(/(^|\n)>\s?/g, "$1") + .replaceAll(/\n{3,}/g, "\n\n") .trim(); type EmbedInfo = { @@ -159,9 +162,9 @@ const extractEmbeds = (metadata: ApiDropMetadata[]): EmbedInfo[] => { const groups = new Map(); - metadata.forEach(({ data_key, data_value }) => { + for (const { data_key, data_value } of metadata) { if (!data_value) { - return; + continue; } const normalizedKey = data_key?.toLowerCase?.() ?? ""; @@ -177,7 +180,7 @@ const extractEmbeds = (metadata: ApiDropMetadata[]): EmbedInfo[] => { } } - const group = groups.get(groupKey) ?? { extras: [] as string[] }; + const group = groups.get(groupKey) ?? { extras: [] }; let nextGroup = group; if (fieldKey.includes("title")) { @@ -209,7 +212,7 @@ const extractEmbeds = (metadata: ApiDropMetadata[]): EmbedInfo[] => { } groups.set(groupKey, nextGroup); - }); + } return Array.from(groups.values()).filter( (group) => @@ -280,7 +283,9 @@ const buildClipboardMessage = (drop: ExtendedDrop): ClipboardMessage => { embedPlainLines: embedPlainLines.filter(Boolean), embedMarkdownLines: embedMarkdownLines.filter(Boolean), attachmentPlainLines: uniqueAttachments, - attachmentMarkdownLines: uniqueAttachments, + attachmentMarkdownLines: uniqueAttachments.map( + (url) => `[attachment](${url})` + ), }; }; @@ -295,6 +300,68 @@ const formatTimestamp = (timestamp: number): string => { } }; +type FormatContent = { + primaryContent: string; + embedLines: string[]; + attachmentLines: string[]; +}; + +const resolveFormatContent = ( + message: ClipboardMessage, + format: ClipboardFormat +): FormatContent => { + const isMarkdown = format === "markdown"; + let primarySource: string | null | undefined; + let embedSource: string[] | null | undefined; + let attachmentSource: string[] | null | undefined; + + if (isMarkdown) { + primarySource = message.markdownContent; + embedSource = message.embedMarkdownLines; + attachmentSource = message.attachmentMarkdownLines; + } else { + primarySource = message.plainContent; + embedSource = message.embedPlainLines; + attachmentSource = message.attachmentPlainLines; + } + + const primaryContent = (primarySource ?? "").trim(); + const embedLines = embedSource ?? []; + const attachmentLines = attachmentSource ?? []; + + return { + primaryContent, + embedLines, + attachmentLines, + }; +}; + +const createHeading = ( + authorLabel: string, + timeLabel: string, + format: ClipboardFormat, + isSingle: boolean +): string => { + const timeSuffix = timeLabel ? " (" + timeLabel + ")" : ""; + const markdownTimePrefix = timeLabel ? "**" + timeLabel + "** " : ""; + const plainTimePrefix = timeLabel ? timeLabel + " " : ""; + const markdownAuthorLabel = "**" + authorLabel + "**"; + + if (format === "markdown") { + if (isSingle) { + return markdownAuthorLabel + timeSuffix + ":"; + } + + return markdownTimePrefix + markdownAuthorLabel + ":"; + } + + if (isSingle) { + return authorLabel + timeSuffix + ":"; + } + + return plainTimePrefix + authorLabel + ":"; +}; + const formatMessage = ( message: ClipboardMessage, format: ClipboardFormat, @@ -302,49 +369,24 @@ const formatMessage = ( ): string => { const timeLabel = formatTimestamp(message.timestamp); const authorLabel = message.author || "Unknown"; - const heading = - format === "markdown" - ? isSingle - ? `**${authorLabel}**${timeLabel ? ` (${timeLabel})` : ""}:` - : `${timeLabel ? `**${timeLabel}** ` : ""}**${authorLabel}**:` - : isSingle - ? `${authorLabel}${timeLabel ? ` (${timeLabel})` : ""}:` - : `${timeLabel ? `${timeLabel} ` : ""}${authorLabel}:`; - - const primaryContent = - format === "markdown" - ? message.markdownContent.trim() - : message.plainContent.trim(); - - const embedLines = - format === "markdown" - ? message.embedMarkdownLines - : message.embedPlainLines; - const attachmentLines = - format === "markdown" - ? message.attachmentMarkdownLines - : message.attachmentPlainLines; - - const additionalSections = [...embedLines, ...attachmentLines].filter( - (line) => line && line.length > 0 - ); + const formatContent = resolveFormatContent(message, format); + const heading = createHeading(authorLabel, timeLabel, format, isSingle); - if (!primaryContent && additionalSections.length === 0) { - return heading; - } - - const [firstSection, ...rest] = [ - primaryContent, - ...additionalSections, - ].filter((section) => section && section.length > 0); + const sections = [ + formatContent.primaryContent, + ...formatContent.embedLines, + ...formatContent.attachmentLines, + ].filter((section) => section.length > 0); - if (!firstSection) { + if (sections.length === 0) { return heading; } - let block = `${heading} ${firstSection}`.trimEnd(); + const [firstSection, ...rest] = sections; + let block = (heading + " " + firstSection).trimEnd(); + if (rest.length > 0) { - block += `\n\n${rest.join("\n\n")}`; + block += "\n\n" + rest.join("\n\n"); } return block; @@ -405,6 +447,253 @@ const gatherSelectedMessageIds = ( return ids; }; +type SelectionContext = { + messages: ClipboardMessage[]; + selectedIds: string[]; + selectionRange: Range | null; +}; + +type RangeBoundaryContext = { + startDropId: string | null; + endDropId: string | null; + startElementForRange: HTMLElement | null; + endElementForRange: HTMLElement | null; + startFullySelected: boolean; + endFullySelected: boolean; +}; + +type PartialSegmentsResult = { + partialSegments: Map; + usedPartialHandling: boolean; +}; + +const resolveSelectionContext = ( + selection: Selection | null, + container: HTMLElement, + clipboardMessages: Map +): SelectionContext | null => { + if (!selection || selection.isCollapsed) { + return null; + } + + const anchorNodeParent = selection.anchorNode; + const focusNodeParent = selection.focusNode; + + const anchorInside = anchorNodeParent + ? container.contains(anchorNodeParent) + : false; + const focusInside = focusNodeParent + ? container.contains(focusNodeParent) + : false; + + if (!anchorInside && !focusInside) { + return null; + } + + if (nodeIsEditable(anchorNodeParent) || nodeIsEditable(focusNodeParent)) { + return null; + } + + const selectedIds = gatherSelectedMessageIds(selection, container); + if (!selectedIds.length) { + return null; + } + + const messages = selectedIds + .map((id) => clipboardMessages.get(id)) + .filter((message): message is ClipboardMessage => !!message); + + if (!messages.length) { + return null; + } + + messages.sort((a, b) => { + if (a.timestamp === b.timestamp) { + return a.id.localeCompare(b.id); + } + return a.timestamp - b.timestamp; + }); + + const selectionRange = + selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + + return { + messages, + selectedIds, + selectionRange, + }; +}; + +const buildRangePayload = ( + selectionRange: Range, + selectedIds: string[], + container: HTMLElement, + messages: ClipboardMessage[], + format: ClipboardFormat +): string | undefined => { + const messagesById = new Map( + messages.map((message) => [message.id, message] as const) + ); + + const dropElements = collectDropElements(container, selectedIds); + const boundaries = resolveRangeBoundaries(selectionRange, dropElements); + const { partialSegments, usedPartialHandling } = collectPartialSegments( + selectionRange, + boundaries + ); + + const segments = buildSegmentsFromSelection({ + selectedIds, + partialSegments, + messagesById, + format, + }); + + if (segments.length > 0 || usedPartialHandling) { + return segments.join("\n\n"); + } + + return undefined; +}; + +const collectDropElements = ( + container: HTMLElement, + selectedIds: string[] +): Map => { + const dropElements = new Map(); + + for (const id of selectedIds) { + const escapedId = escapeAttributeValue(id); + const element = container.querySelector( + `[data-wave-drop-id="${escapedId}"]` + ); + + if (element) { + dropElements.set(id, element); + } + } + + return dropElements; +}; + +const resolveRangeBoundaries = ( + selectionRange: Range, + dropElements: Map +): RangeBoundaryContext => { + const startElement = findDropElement(selectionRange.startContainer); + const endElement = findDropElement(selectionRange.endContainer); + const startDropId = startElement?.dataset?.waveDropId ?? null; + const endDropId = endElement?.dataset?.waveDropId ?? null; + + const startElementForRange = + startDropId != null + ? dropElements.get(startDropId) ?? startElement ?? null + : startElement ?? null; + const endElementForRange = + endDropId != null + ? dropElements.get(endDropId) ?? endElement ?? null + : endElement ?? null; + + const startFullySelected = Boolean( + startDropId && + startElementForRange && + isRangeFullyCoveringElement(selectionRange, startElementForRange) + ); + const endFullySelected = Boolean( + endDropId && + endElementForRange && + isRangeFullyCoveringElement(selectionRange, endElementForRange) + ); + + return { + startDropId, + endDropId, + startElementForRange, + endElementForRange, + startFullySelected, + endFullySelected, + }; +}; + +const collectPartialSegments = ( + selectionRange: Range, + boundaries: RangeBoundaryContext +): PartialSegmentsResult => { + const partialSegments = new Map(); + let usedPartialHandling = false; + + if ( + boundaries.startDropId && + boundaries.startElementForRange && + !boundaries.startFullySelected + ) { + usedPartialHandling = true; + const text = getSelectedTextForElement( + selectionRange, + boundaries.startElementForRange + ); + partialSegments.set(boundaries.startDropId, text); + } + + if ( + boundaries.endDropId && + boundaries.endElementForRange && + (!boundaries.endFullySelected || + (boundaries.startDropId === boundaries.endDropId && + !boundaries.startFullySelected)) + ) { + usedPartialHandling = true; + const text = getSelectedTextForElement( + selectionRange, + boundaries.endElementForRange + ); + partialSegments.set(boundaries.endDropId, text); + } + + return { partialSegments, usedPartialHandling }; +}; + +type BuildSegmentsOptions = { + selectedIds: string[]; + partialSegments: Map; + messagesById: Map; + format: ClipboardFormat; +}; + +const buildSegmentsFromSelection = ({ + selectedIds, + partialSegments, + messagesById, + format, +}: BuildSegmentsOptions): string[] => { + const fullMessageIds = selectedIds.filter((id) => !partialSegments.has(id)); + const totalFullMessages = fullMessageIds.length; + const segments: string[] = []; + + for (const id of selectedIds) { + const partial = partialSegments.get(id); + if (partial !== undefined) { + segments.push(partial); + continue; + } + + const message = messagesById.get(id); + if (!message) { + continue; + } + + const segment = formatMessage( + message, + format, + totalFullMessages === 1 + ); + + segments.push(segment); + } + + return segments; +}; + export const useWaveDropsClipboard = ({ containerRef, drops, @@ -445,150 +734,30 @@ export const useWaveDropsClipboard = ({ }; const handleCopy = (event: ClipboardEvent) => { - const selection = globalThis.getSelection?.(); - - if (!selection || selection.isCollapsed) { - formatRef.current = "plain"; - return; - } - - const anchorNodeParent = selection.anchorNode; - const focusNodeParent = selection.focusNode; - - if ( - !container.contains(anchorNodeParent) && - !container.contains(focusNodeParent) - ) { - formatRef.current = "plain"; - return; - } - - if (nodeIsEditable(anchorNodeParent) || nodeIsEditable(focusNodeParent)) { - formatRef.current = "plain"; - return; - } - - const selectedIds = gatherSelectedMessageIds(selection, container); - - if (!selectedIds.length) { - formatRef.current = "plain"; - return; - } - - const messages = selectedIds - .map((id) => clipboardMessages.get(id)) - .filter((message): message is ClipboardMessage => !!message); + const selection = globalThis.getSelection?.() ?? null; + const context = resolveSelectionContext( + selection, + container, + clipboardMessages + ); - if (!messages.length) { + if (!context) { formatRef.current = "plain"; return; } - messages.sort((a, b) => { - if (a.timestamp === b.timestamp) { - return a.id.localeCompare(b.id); - } - return a.timestamp - b.timestamp; - }); - - const messagesById = new Map( - messages.map((message) => [message.id, message] as const) - ); - - const selectionRange = - selection.rangeCount > 0 ? selection.getRangeAt(0) : null; - - let payload: string | undefined; + const rangePayload = context.selectionRange + ? buildRangePayload( + context.selectionRange, + context.selectedIds, + container, + context.messages, + formatRef.current + ) + : undefined; - if (selectionRange) { - const dropElements = new Map(); - - for (const id of selectedIds) { - const escapedId = escapeAttributeValue(id); - const element = container.querySelector( - `[data-wave-drop-id="${escapedId}"]` - ); - if (element) { - dropElements.set(id, element); - } - } - - const startElement = findDropElement(selectionRange.startContainer); - const endElement = findDropElement(selectionRange.endContainer); - const startDropId = startElement?.dataset?.waveDropId ?? null; - const endDropId = endElement?.dataset?.waveDropId ?? null; - - const startElementForRange = - (startDropId && dropElements.get(startDropId)) ?? startElement ?? null; - const endElementForRange = - (endDropId && dropElements.get(endDropId)) ?? endElement ?? null; - - const startFullySelected = - !!( - startDropId && - startElementForRange && - isRangeFullyCoveringElement(selectionRange, startElementForRange) - ); - const endFullySelected = - !!( - endDropId && - endElementForRange && - isRangeFullyCoveringElement(selectionRange, endElementForRange) - ); - - const partialSegments = new Map(); - let usedPartialHandling = false; - - if (startDropId && startElementForRange && !startFullySelected) { - usedPartialHandling = true; - const text = getSelectedTextForElement(selectionRange, startElementForRange); - partialSegments.set(startDropId, text); - } - - if ( - endDropId && - endElementForRange && - (!endFullySelected || (startDropId === endDropId && !startFullySelected)) - ) { - usedPartialHandling = true; - const text = getSelectedTextForElement(selectionRange, endElementForRange); - partialSegments.set(endDropId, text); - } - - const fullMessageIds = selectedIds.filter((id) => !partialSegments.has(id)); - const totalFullMessages = fullMessageIds.length; - - const segments: string[] = []; - - for (const id of selectedIds) { - if (partialSegments.has(id)) { - const partial = partialSegments.get(id) ?? ""; - segments.push(partial); - continue; - } - - const message = messagesById.get(id); - if (!message) { - continue; - } - - const segment = formatMessage( - message, - formatRef.current, - totalFullMessages === 1 - ); - - segments.push(segment); - } - - if (segments.length > 0 || usedPartialHandling) { - payload = segments.join("\n\n"); - } - } - - if (!payload) { - payload = formatMessages(messages, formatRef.current); - } + const payload = + rangePayload ?? formatMessages(context.messages, formatRef.current); if (!payload) { formatRef.current = "plain"; @@ -599,24 +768,23 @@ export const useWaveDropsClipboard = ({ if (event.clipboardData) { event.clipboardData.setData("text/plain", payload); + if (formatRef.current === "markdown") { + event.clipboardData.setData("text/markdown", payload); + } } if (navigator?.clipboard?.writeText) { - void navigator.clipboard - .writeText(payload) - .catch(() => { - // Silently ignore clipboard promise failures – the event clipboard fallback already ran. - }); + void navigator.clipboard.writeText(payload).catch(() => {}); } formatRef.current = "plain"; }; - window.addEventListener("keydown", handleKeyDown); + globalThis.addEventListener?.("keydown", handleKeyDown); container.addEventListener("copy", handleCopy); return () => { - window.removeEventListener("keydown", handleKeyDown); + globalThis.removeEventListener?.("keydown", handleKeyDown); container.removeEventListener("copy", handleCopy); }; }, [clipboardMessages, containerRef]); diff --git a/components/waves/drops/wave-drops-all/index.tsx b/components/waves/drops/wave-drops-all/index.tsx index cf218e2d65..851e9db1c1 100644 --- a/components/waves/drops/wave-drops-all/index.tsx +++ b/components/waves/drops/wave-drops-all/index.tsx @@ -5,7 +5,7 @@ import { useRouter } from "next/navigation"; import { useAuth } from "@/components/auth/Auth"; import { useNotificationsContext } from "@/components/notifications/NotificationsContext"; import { getWaveRoute } from "@/helpers/navigation.helpers"; -import { DropSize, ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import { Drop, DropSize, ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { isWaveDirectMessage } from "@/helpers/waves/wave.helpers"; import { useScrollBehavior } from "@/hooks/useScrollBehavior"; import { useVirtualizedWaveDrops } from "@/hooks/useVirtualizedWaveDrops"; @@ -18,6 +18,8 @@ import { useWaveDropsSerialScroll } from "./hooks/useWaveDropsSerialScroll"; import { useWaveDropsClipboard } from "./hooks/useWaveDropsClipboard"; import { WaveDropsContent } from "./subcomponents/WaveDropsContent"; +const EMPTY_DROPS: Drop[] = []; + interface WaveDropsAllProps { readonly waveId: string; readonly dropId: string | null; @@ -70,7 +72,7 @@ const WaveDropsAll: React.FC = ({ }); const dropsForClipboard = useMemo( - () => waveMessages?.drops ?? [], + () => waveMessages?.drops ?? EMPTY_DROPS, [waveMessages?.drops] ); From 5e3a6100256338e2844463f5d2014808c2205fc4 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 29 Oct 2025 15:03:01 +0200 Subject: [PATCH 09/20] wip Signed-off-by: Simo --- .../wave-drops-all/hooks/useWaveDropsClipboard.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts index b74602586c..2f312807bf 100644 --- a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts +++ b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts @@ -499,9 +499,14 @@ const resolveSelectionContext = ( return null; } - const messages = selectedIds - .map((id) => clipboardMessages.get(id)) - .filter((message): message is ClipboardMessage => !!message); + const messages: ClipboardMessage[] = []; + for (const id of selectedIds) { + const message = clipboardMessages.get(id); + if (!message) { + return null; + } + messages.push(message); + } if (!messages.length) { return null; From 7f2475381a622c2f10e3eeba34bc6826f504bf58 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 29 Oct 2025 15:20:29 +0200 Subject: [PATCH 10/20] wip Signed-off-by: Simo --- components/waves/drops/WaveDrop.tsx | 2 +- .../waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/components/waves/drops/WaveDrop.tsx b/components/waves/drops/WaveDrop.tsx index 9897c462a2..ea038fbfe2 100644 --- a/components/waves/drops/WaveDrop.tsx +++ b/components/waves/drops/WaveDrop.tsx @@ -322,7 +322,7 @@ const WaveDrop = ({ } ${isProfileView ? "tw-mb-3" : ""} tw-w-full`}>
{ const uniqueAttachments = Array.from(new Set(mediaUrls)); return { - id: drop.stableKey ?? drop.id, + id: drop.stableHash ?? drop.id, author: drop.author?.handle ?? "Unknown", timestamp: drop.created_at, markdownContent, @@ -714,7 +714,7 @@ export const useWaveDropsClipboard = ({ const clipboardMessages = useMemo(() => { return new Map( - chatDrops.map((drop) => [drop.stableKey ?? drop.id, buildClipboardMessage(drop)]) + chatDrops.map((drop) => [drop.stableHash ?? drop.id, buildClipboardMessage(drop)]) ); }, [chatDrops]); From 439e8f6a160a8aff664bb894e3e1330fc5ba4637 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 29 Oct 2025 15:53:44 +0200 Subject: [PATCH 11/20] wip Signed-off-by: Simo --- .../hooks/useWaveDropsClipboard.ts | 241 ++++++++++++++++-- 1 file changed, 224 insertions(+), 17 deletions(-) diff --git a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts index 2bb13232d7..ba0800e315 100644 --- a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts +++ b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts @@ -223,18 +223,178 @@ const extractEmbeds = (metadata: ApiDropMetadata[]): EmbedInfo[] => { ); }; -const buildClipboardMessage = (drop: ExtendedDrop): ClipboardMessage => { - const markdownContent = drop.parts - .map((part) => part.content ?? "") - .filter((value) => (value ?? "").trim().length > 0) - .join("\n\n") - .trim(); +type QuoteDropSource = { + readonly author?: { readonly handle?: string | null }; + readonly parts?: ReadonlyArray<{ + readonly part_id: number; + readonly content: string | null; + }>; + readonly created_at?: number | null; + readonly wave?: { readonly name?: string | null } | null; +}; + +const mergeQuoteDropSources = ( + primary?: QuoteDropSource | null, + secondary?: QuoteDropSource | null +): QuoteDropSource | undefined => { + if (!primary && !secondary) { + return undefined; + } + + if (!primary) { + return secondary ?? undefined; + } + + if (!secondary) { + return primary ?? undefined; + } + + return { + author: primary.author ?? secondary.author, + parts: primary.parts ?? secondary.parts, + created_at: primary.created_at ?? secondary.created_at, + wave: primary.wave ?? secondary.wave, + }; +}; + +const registerQuoteDropSource = ( + registry: Map, + dropId: string, + source: QuoteDropSource | null | undefined +): void => { + if (!source) { + return; + } + + const merged = mergeQuoteDropSources(registry.get(dropId), source); + if (merged) { + registry.set(dropId, merged); + } +}; + +type DropReferenceDescriptor = { + readonly label: string; + readonly dropId: string; + readonly dropPartId: number; + readonly isDeleted?: boolean; + readonly drop?: QuoteDropSource | null; +}; + +const buildClipboardMessage = ( + drop: ExtendedDrop, + quoteLookup: Map +): ClipboardMessage => { + const formatReference = ( + descriptor: DropReferenceDescriptor + ): string | null => { + if (descriptor.isDeleted) { + return `> **${descriptor.label}** (original message deleted: ${descriptor.dropId}#${descriptor.dropPartId})`; + } + + const referencedDrop = mergeQuoteDropSources( + descriptor.drop ?? undefined, + quoteLookup.get(descriptor.dropId) + ); + + const authorHandle = referencedDrop?.author?.handle?.trim(); + const referencedPartContent = + referencedDrop?.parts?.find( + (part) => part.part_id === descriptor.dropPartId + )?.content ?? ""; + const normalizedContent = + typeof referencedPartContent === "string" + ? referencedPartContent.trim() + : ""; + const waveName = referencedDrop?.wave?.name?.trim(); + + if (!authorHandle && !normalizedContent && !waveName) { + return `> **${descriptor.label}** (${descriptor.dropId}#${descriptor.dropPartId})`; + } + + const headingParts = [descriptor.label, authorHandle] + .filter((part) => part && part.length > 0) + .join(" ") + .trim(); + + const headingLine = `> **${headingParts}${ + normalizedContent.length > 0 ? ":" : "" + }**`; + + const lines: string[] = [headingLine]; + + if (normalizedContent.length > 0) { + const contentLines = normalizedContent.split("\n"); + for (const line of contentLines) { + lines.push(line.trim().length > 0 ? `> ${line}` : ">"); + } + } + + if (waveName) { + lines.push(`> in ${waveName}`); + } + + return lines.join("\n"); + }; + + const markdownSegments: string[] = []; + + const replySegment = drop.reply_to + ? formatReference({ + label: "Replying to", + dropId: drop.reply_to.drop_id, + dropPartId: drop.reply_to.drop_part_id, + isDeleted: drop.reply_to.is_deleted, + drop: drop.reply_to.drop, + }) + : null; + + if (replySegment) { + markdownSegments.push(replySegment); + } + + for (const part of drop.parts) { + const content = (part.content ?? "").trim(); + if (content.length > 0) { + markdownSegments.push(content); + } + + if (part.quoted_drop) { + const quoteSegment = formatReference({ + label: "Quote from", + dropId: part.quoted_drop.drop_id, + dropPartId: part.quoted_drop.drop_part_id, + drop: part.quoted_drop.drop, + }); + + if (quoteSegment) { + markdownSegments.push(quoteSegment); + } + } + } + + const markdownContent = markdownSegments.join("\n\n").trim(); const plainContent = markdownContent ? toPlainText(markdownContent) : ""; const embedInfos = extractEmbeds(drop.metadata ?? []); - const embedPlainLines = embedInfos.flatMap((embed) => { + const summaryPlainLines: string[] = []; + const summaryMarkdownLines: string[] = []; + + if (drop.drop_type !== ApiDropType.Chat) { + summaryPlainLines.push(`Type: ${drop.drop_type}`); + summaryMarkdownLines.push(`**Type:** ${drop.drop_type}`); + } + + if (drop.drop_type === ApiDropType.Winner) { + const winnerRank = drop.winning_context?.place ?? drop.rank; + if (winnerRank !== null && winnerRank !== undefined) { + summaryPlainLines.push(`Rank: ${winnerRank}`); + summaryMarkdownLines.push(`**Rank:** ${winnerRank}`); + } + } + + const embedPlainDetails = embedInfos.flatMap((embed) => { const lines: string[] = []; if (embed.title && embed.url) { lines.push(`${embed.title} — ${embed.url}`); @@ -250,7 +410,7 @@ const buildClipboardMessage = (drop: ExtendedDrop): ClipboardMessage => { return lines; }); - const embedMarkdownLines = embedInfos.flatMap((embed) => { + const embedMarkdownDetails = embedInfos.flatMap((embed) => { const lines: string[] = []; if (embed.title && embed.url) { lines.push(`[${embed.title}](${embed.url})`); @@ -266,6 +426,14 @@ const buildClipboardMessage = (drop: ExtendedDrop): ClipboardMessage => { return lines; }); + const embedPlainLines = [...summaryPlainLines, ...embedPlainDetails].filter( + Boolean + ); + const embedMarkdownLines = [ + ...summaryMarkdownLines, + ...embedMarkdownDetails, + ].filter(Boolean); + const mediaUrls = drop.parts.flatMap((part) => part.media .map((media) => media.url) @@ -277,11 +445,11 @@ const buildClipboardMessage = (drop: ExtendedDrop): ClipboardMessage => { return { id: drop.stableHash ?? drop.id, author: drop.author?.handle ?? "Unknown", - timestamp: drop.created_at, - markdownContent, - plainContent, - embedPlainLines: embedPlainLines.filter(Boolean), - embedMarkdownLines: embedMarkdownLines.filter(Boolean), + timestamp: drop.created_at, + markdownContent, + plainContent, + embedPlainLines, + embedMarkdownLines, attachmentPlainLines: uniqueAttachments, attachmentMarkdownLines: uniqueAttachments.map( (url) => `[attachment](${url})` @@ -703,20 +871,59 @@ export const useWaveDropsClipboard = ({ containerRef, drops, }: UseWaveDropsClipboardOptions): void => { - const chatDrops = useMemo( + const fullDrops = useMemo( () => (drops ?? []).filter( (drop): drop is ExtendedDrop => - drop.type === DropSize.FULL && drop.drop_type === ApiDropType.Chat + drop.type === DropSize.FULL ), [drops] ); + const quoteDropLookup = useMemo(() => { + const registry = new Map(); + + for (const drop of fullDrops) { + registerQuoteDropSource(registry, drop.id, { + author: drop.author, + parts: drop.parts, + created_at: drop.created_at, + wave: drop.wave, + }); + + if (drop.reply_to?.drop) { + registerQuoteDropSource(registry, drop.reply_to.drop_id, { + author: drop.reply_to.drop.author, + parts: drop.reply_to.drop.parts, + created_at: drop.reply_to.drop.created_at, + wave: drop.wave, + }); + } + + for (const part of drop.parts) { + const quoted = part.quoted_drop; + if (quoted?.drop) { + registerQuoteDropSource(registry, quoted.drop_id, { + author: quoted.drop.author, + parts: quoted.drop.parts, + created_at: quoted.drop.created_at, + wave: drop.wave, + }); + } + } + } + + return registry; + }, [fullDrops]); + const clipboardMessages = useMemo(() => { return new Map( - chatDrops.map((drop) => [drop.stableHash ?? drop.id, buildClipboardMessage(drop)]) + fullDrops.map((drop) => [ + drop.stableHash ?? drop.id, + buildClipboardMessage(drop, quoteDropLookup), + ]) ); - }, [chatDrops]); + }, [fullDrops, quoteDropLookup]); const formatRef = useRef("plain"); From 6b7d421d7c10d0529fd8912cfd5755939b108d61 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 29 Oct 2025 16:06:45 +0200 Subject: [PATCH 12/20] wip Signed-off-by: Simo --- components/drops/view/DropsList.tsx | 5 +++++ components/drops/view/HighlightDropWrapper.tsx | 10 +++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/components/drops/view/DropsList.tsx b/components/drops/view/DropsList.tsx index 10b9aad85f..17a48aef78 100644 --- a/components/drops/view/DropsList.tsx +++ b/components/drops/view/DropsList.tsx @@ -124,6 +124,11 @@ const DropsList = memo(function DropsList({ { @@ -242,7 +244,13 @@ const HighlightDropWrapper = forwardRef< ); return ( -
+
{children}
); From 393c63b0923d9ea5d1814180b3fa0cfcd34f833e Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 29 Oct 2025 16:22:46 +0200 Subject: [PATCH 13/20] wip Signed-off-by: Simo --- .../hooks/useWaveDropsClipboard.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts index ba0800e315..ecaa575a73 100644 --- a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts +++ b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts @@ -942,6 +942,22 @@ export const useWaveDropsClipboard = ({ return; } + const selection = globalThis.getSelection?.() ?? null; + const targetNode = event.target instanceof Node ? event.target : null; + const anchorNode = selection?.anchorNode ?? null; + const focusNode = selection?.focusNode ?? null; + const isInContainer = (node: Node | null) => + !!node && container.contains(node); + + if ( + !isInContainer(targetNode) && + !isInContainer(anchorNode) && + !isInContainer(focusNode) + ) { + formatRef.current = "plain"; + return; + } + formatRef.current = event.shiftKey ? "markdown" : "plain"; }; From 3a96a2a222cb57217e44a7cfad65039780a44055 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 29 Oct 2025 16:47:49 +0200 Subject: [PATCH 14/20] wip --- .../hooks/useWaveDropsClipboard.ts | 266 +++++++++++------- 1 file changed, 160 insertions(+), 106 deletions(-) diff --git a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts index ecaa575a73..be18db7781 100644 --- a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts +++ b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts @@ -76,7 +76,7 @@ const escapeAttributeValue = (value: string): string => { return CSS.escape(value); } - return value.replaceAll(/[\0-\x1F\x7F"\\\[\]]/g, String.raw`\$&`); + return value.replaceAll(/[\u0000-\x1F\x7F"\\[\]]/g, String.raw`\$&`); }; const findDropElement = (node: Node | null): HTMLElement | null => { @@ -138,7 +138,7 @@ const toPlainText = (markdown: string): string => .replaceAll(/!\[[^\]]*]\(([^)]+)\)/g, "$1") .replaceAll(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)") .replaceAll(/(\*\*|__)(.*?)\1/g, "$2") - .replaceAll(/(\*|_)(.*?)\1/g, "$2") + .replaceAll(/([*_])(.*?)\1/g, "$2") .replaceAll(/~~(.*?)~~/g, "$1") .replaceAll(/(^|\n)#{1,6}\s+/g, "$1") .replaceAll(/(^|\n)\s*[-*+]\s+/g, "$1") @@ -280,72 +280,154 @@ type DropReferenceDescriptor = { readonly drop?: QuoteDropSource | null; }; -const buildClipboardMessage = ( - drop: ExtendedDrop, +const formatDeletedReference = ( + descriptor: DropReferenceDescriptor +): string => + `> **${descriptor.label}** (original message deleted: ${descriptor.dropId}#${descriptor.dropPartId})`; + +const formatFallbackReference = ( + descriptor: DropReferenceDescriptor +): string => + `> **${descriptor.label}** (${descriptor.dropId}#${descriptor.dropPartId})`; + +const resolveReferenceDetails = ( + descriptor: DropReferenceDescriptor, quoteLookup: Map -): ClipboardMessage => { - const formatReference = ( - descriptor: DropReferenceDescriptor - ): string | null => { - if (descriptor.isDeleted) { - return `> **${descriptor.label}** (original message deleted: ${descriptor.dropId}#${descriptor.dropPartId})`; - } +) => { + const mergedDrop = mergeQuoteDropSources( + descriptor.drop ?? undefined, + quoteLookup.get(descriptor.dropId) + ); - const referencedDrop = mergeQuoteDropSources( - descriptor.drop ?? undefined, - quoteLookup.get(descriptor.dropId) - ); + const authorHandle = mergedDrop?.author?.handle?.trim() ?? ""; + const referencedPartContent = mergedDrop?.parts?.find( + (part) => part.part_id === descriptor.dropPartId + )?.content; + const normalizedContent = + typeof referencedPartContent === "string" + ? referencedPartContent.trim() + : ""; + const waveName = mergedDrop?.wave?.name?.trim() ?? ""; + + return { authorHandle, normalizedContent, waveName }; +}; - const authorHandle = referencedDrop?.author?.handle?.trim(); - const referencedPartContent = - referencedDrop?.parts?.find( - (part) => part.part_id === descriptor.dropPartId - )?.content ?? ""; - const normalizedContent = - typeof referencedPartContent === "string" - ? referencedPartContent.trim() - : ""; - const waveName = referencedDrop?.wave?.name?.trim(); - - if (!authorHandle && !normalizedContent && !waveName) { - return `> **${descriptor.label}** (${descriptor.dropId}#${descriptor.dropPartId})`; - } +const buildReferenceContentLines = (content: string): string[] => { + if (!content) { + return []; + } - const headingParts = [descriptor.label, authorHandle] - .filter((part) => part && part.length > 0) - .join(" ") - .trim(); + const contentLines = content.split("\n"); + return contentLines.map((line) => { + const trimmed = line.trim(); + return trimmed.length > 0 ? `> ${line}` : ">"; + }); +}; - const headingLine = `> **${headingParts}${ - normalizedContent.length > 0 ? ":" : "" - }**`; +const buildReferenceHeading = ( + label: string, + authorHandle: string, + hasContent: boolean +): string => { + const headingParts = [label, authorHandle] + .filter((part) => part && part.length > 0) + .join(" ") + .trim(); - const lines: string[] = [headingLine]; + const suffix = hasContent ? ":" : ""; - if (normalizedContent.length > 0) { - const contentLines = normalizedContent.split("\n"); - for (const line of contentLines) { - lines.push(line.trim().length > 0 ? `> ${line}` : ">"); - } - } + return `> **${headingParts}${suffix}**`; +}; - if (waveName) { - lines.push(`> in ${waveName}`); - } +const formatReference = ( + descriptor: DropReferenceDescriptor, + quoteLookup: Map +): string | null => { + if (descriptor.isDeleted) { + return formatDeletedReference(descriptor); + } - return lines.join("\n"); - }; + const { authorHandle, normalizedContent, waveName } = + resolveReferenceDetails(descriptor, quoteLookup); + + if (!authorHandle && !normalizedContent && !waveName) { + return formatFallbackReference(descriptor); + } + + const lines = [ + buildReferenceHeading(descriptor.label, authorHandle, normalizedContent.length > 0), + ...buildReferenceContentLines(normalizedContent), + ]; + + if (waveName) { + lines.push(`> in ${waveName}`); + } + + return lines.join("\n"); +}; + +type EmbedLineBuilder = (embed: EmbedInfo) => string[]; + +const buildEmbedLines = ( + embedInfos: EmbedInfo[], + builder: EmbedLineBuilder +): string[] => embedInfos.flatMap(builder); + +const createPlainEmbedLines: EmbedLineBuilder = (embed) => { + const lines: string[] = []; + if (embed.title && embed.url) { + lines.push(`${embed.title} — ${embed.url}`); + } else if (embed.title) { + lines.push(embed.title); + } else if (embed.url) { + lines.push(embed.url); + } + + if (embed.description) { + lines.push(embed.description); + } + lines.push(...embed.extras); + + return lines; +}; + +const createMarkdownEmbedLines: EmbedLineBuilder = (embed) => { + const lines: string[] = []; + if (embed.title && embed.url) { + lines.push(`[${embed.title}](${embed.url})`); + } else if (embed.title) { + lines.push(`**${embed.title}**`); + } else if (embed.url) { + lines.push(embed.url); + } + + if (embed.description) { + lines.push(embed.description); + } + + lines.push(...embed.extras); + + return lines; +}; + +const buildClipboardMessage = ( + drop: ExtendedDrop, + quoteLookup: Map +): ClipboardMessage => { const markdownSegments: string[] = []; const replySegment = drop.reply_to - ? formatReference({ - label: "Replying to", - dropId: drop.reply_to.drop_id, - dropPartId: drop.reply_to.drop_part_id, - isDeleted: drop.reply_to.is_deleted, - drop: drop.reply_to.drop, - }) + ? formatReference( + { + label: "Replying to", + dropId: drop.reply_to.drop_id, + dropPartId: drop.reply_to.drop_part_id, + isDeleted: drop.reply_to.is_deleted, + drop: drop.reply_to.drop, + }, + quoteLookup + ) : null; if (replySegment) { @@ -359,12 +441,15 @@ const buildClipboardMessage = ( } if (part.quoted_drop) { - const quoteSegment = formatReference({ - label: "Quote from", - dropId: part.quoted_drop.drop_id, - dropPartId: part.quoted_drop.drop_part_id, - drop: part.quoted_drop.drop, - }); + const quoteSegment = formatReference( + { + label: "Quote from", + dropId: part.quoted_drop.drop_id, + dropPartId: part.quoted_drop.drop_part_id, + drop: part.quoted_drop.drop, + }, + quoteLookup + ); if (quoteSegment) { markdownSegments.push(quoteSegment); @@ -394,44 +479,13 @@ const buildClipboardMessage = ( } } - const embedPlainDetails = embedInfos.flatMap((embed) => { - const lines: string[] = []; - if (embed.title && embed.url) { - lines.push(`${embed.title} — ${embed.url}`); - } else if (embed.title) { - lines.push(embed.title); - } else if (embed.url) { - lines.push(embed.url); - } - if (embed.description) { - lines.push(embed.description); - } - lines.push(...embed.extras); - return lines; - }); - - const embedMarkdownDetails = embedInfos.flatMap((embed) => { - const lines: string[] = []; - if (embed.title && embed.url) { - lines.push(`[${embed.title}](${embed.url})`); - } else if (embed.title) { - lines.push(`**${embed.title}**`); - } else if (embed.url) { - lines.push(embed.url); - } - if (embed.description) { - lines.push(embed.description); - } - lines.push(...embed.extras); - return lines; - }); - - const embedPlainLines = [...summaryPlainLines, ...embedPlainDetails].filter( - Boolean - ); + const embedPlainLines = [ + ...summaryPlainLines, + ...buildEmbedLines(embedInfos, createPlainEmbedLines), + ].filter(Boolean); const embedMarkdownLines = [ ...summaryMarkdownLines, - ...embedMarkdownDetails, + ...buildEmbedLines(embedInfos, createMarkdownEmbedLines), ].filter(Boolean); const mediaUrls = drop.parts.flatMap((part) => @@ -445,9 +499,9 @@ const buildClipboardMessage = ( return { id: drop.stableHash ?? drop.id, author: drop.author?.handle ?? "Unknown", - timestamp: drop.created_at, - markdownContent, - plainContent, + timestamp: drop.created_at, + markdownContent, + plainContent, embedPlainLines, embedMarkdownLines, attachmentPlainLines: uniqueAttachments, @@ -640,7 +694,11 @@ const resolveSelectionContext = ( container: HTMLElement, clipboardMessages: Map ): SelectionContext | null => { - if (!selection || selection.isCollapsed) { + if (selection === null) { + return null; + } + + if (selection.isCollapsed) { return null; } @@ -663,7 +721,7 @@ const resolveSelectionContext = ( } const selectedIds = gatherSelectedMessageIds(selection, container); - if (!selectedIds.length) { + if (selectedIds.length === 0) { return null; } @@ -676,10 +734,6 @@ const resolveSelectionContext = ( messages.push(message); } - if (!messages.length) { - return null; - } - messages.sort((a, b) => { if (a.timestamp === b.timestamp) { return a.id.localeCompare(b.id); From 0b080aad67175d63c52e1ef0e62e4b931913cb73 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 29 Oct 2025 17:14:30 +0200 Subject: [PATCH 15/20] wip --- .../hooks/useWaveDropsClipboard.ts | 209 +++++++++++------- 1 file changed, 135 insertions(+), 74 deletions(-) diff --git a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts index be18db7781..fbe29e2dd6 100644 --- a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts +++ b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts @@ -76,7 +76,16 @@ const escapeAttributeValue = (value: string): string => { return CSS.escape(value); } - return value.replaceAll(/[\u0000-\x1F\x7F"\\[\]]/g, String.raw`\$&`); + let out = ""; + for (const ch of value) { + const code = ch.codePointAt(0)!; + if (code <= 0x1f || code === 0x7f || ch === "\"" || ch === "\\" || ch === "[" || ch === "]") { + out += "\\" + ch; + } else { + out += ch; + } + } + return out; }; const findDropElement = (node: Node | null): HTMLElement | null => { @@ -142,6 +151,7 @@ const toPlainText = (markdown: string): string => .replaceAll(/~~(.*?)~~/g, "$1") .replaceAll(/(^|\n)#{1,6}\s+/g, "$1") .replaceAll(/(^|\n)\s*[-*+]\s+/g, "$1") + .replaceAll(/(^|\n)\s*\d+[.)]\s+/g, "$1") .replaceAll(/(^|\n)>\s?/g, "$1") .replaceAll(/\n{3,}/g, "\n\n") .trim(); @@ -155,6 +165,69 @@ type EmbedInfo = { const EMBED_KEY_SEPARATORS = [":", "::", ".", "-", "_"] as const; +const createEmptyEmbed = (): EmbedInfo => ({ extras: [] }); + +const ensureFieldValue = ( + group: EmbedInfo, + field: K, + value: string +): EmbedInfo => { + if (group[field]) { + return group; + } + return { ...group, [field]: value }; +}; + +const appendUniqueExtra = (group: EmbedInfo, value: string): EmbedInfo => { + if (group.extras.includes(value)) { + return group; + } + return { ...group, extras: [...group.extras, value] }; +}; + +const splitEmbedKey = (normalizedKey: string) => { + for (const separator of EMBED_KEY_SEPARATORS) { + const index = normalizedKey.lastIndexOf(separator); + if (index > -1) { + return { + groupKey: normalizedKey.slice(0, index) || "default", + fieldKey: normalizedKey.slice(index + separator.length), + }; + } + } + + return { groupKey: "default", fieldKey: normalizedKey }; +}; + +const applyMetadataToGroup = ( + group: EmbedInfo, + fieldKey: string, + dataKey: string | undefined, + dataValue: string +): EmbedInfo => { + if (fieldKey.includes("title")) { + return ensureFieldValue(group, "title", dataValue); + } + + if (fieldKey.includes("url")) { + return ensureFieldValue(group, "url", dataValue); + } + + if (fieldKey.includes("description")) { + return ensureFieldValue(group, "description", dataValue); + } + + if (dataValue.startsWith("http")) { + if (!group.url) { + return ensureFieldValue(group, "url", dataValue); + } + return appendUniqueExtra(group, dataValue); + } + + const extraLabel = `${dataKey}: ${dataValue}`; + return appendUniqueExtra(group, extraLabel); +}; + const extractEmbeds = (metadata: ApiDropMetadata[]): EmbedInfo[] => { if (!metadata.length) { return []; @@ -168,50 +241,13 @@ const extractEmbeds = (metadata: ApiDropMetadata[]): EmbedInfo[] => { } const normalizedKey = data_key?.toLowerCase?.() ?? ""; - let groupKey = "default"; - let fieldKey = normalizedKey; - - for (const separator of EMBED_KEY_SEPARATORS) { - const index = normalizedKey.lastIndexOf(separator); - if (index > -1) { - groupKey = normalizedKey.slice(0, index) || "default"; - fieldKey = normalizedKey.slice(index + separator.length); - break; - } - } + const { groupKey, fieldKey } = splitEmbedKey(normalizedKey); + const group = groups.get(groupKey) ?? createEmptyEmbed(); - const group = groups.get(groupKey) ?? { extras: [] }; - let nextGroup = group; - - if (fieldKey.includes("title")) { - if (!nextGroup.title) { - nextGroup = { ...nextGroup, title: data_value }; - } - } else if (fieldKey.includes("url")) { - if (!nextGroup.url) { - nextGroup = { ...nextGroup, url: data_value }; - } - } else if (fieldKey.includes("description")) { - if (!nextGroup.description) { - nextGroup = { ...nextGroup, description: data_value }; - } - } else if (data_value.startsWith("http")) { - if (!nextGroup.url) { - nextGroup = { ...nextGroup, url: data_value }; - } else if (!nextGroup.extras.includes(data_value)) { - nextGroup = { - ...nextGroup, - extras: [...nextGroup.extras, data_value], - }; - } - } else { - nextGroup = { - ...nextGroup, - extras: [...nextGroup.extras, `${data_key}: ${data_value}`], - }; - } - - groups.set(groupKey, nextGroup); + groups.set( + groupKey, + applyMetadataToGroup(group, fieldKey, data_key, data_value) + ); } return Array.from(groups.values()).filter( @@ -411,6 +447,41 @@ const createMarkdownEmbedLines: EmbedLineBuilder = (embed) => { return lines; }; +type DropPart = ExtendedDrop["parts"][number]; + +const buildPartSegments = ( + part: DropPart, + quoteLookup: Map +): string[] => { + const segments: string[] = []; + const content = (part.content ?? "").trim(); + + if (content.length > 0) { + segments.push(content); + } + + const quoted = part.quoted_drop; + if (!quoted) { + return segments; + } + + const quoteSegment = formatReference( + { + label: "Quote from", + dropId: quoted.drop_id, + dropPartId: quoted.drop_part_id, + drop: quoted.drop, + }, + quoteLookup + ); + + if (quoteSegment) { + segments.push(quoteSegment); + } + + return segments; +}; + const buildClipboardMessage = ( drop: ExtendedDrop, quoteLookup: Map @@ -435,26 +506,7 @@ const buildClipboardMessage = ( } for (const part of drop.parts) { - const content = (part.content ?? "").trim(); - if (content.length > 0) { - markdownSegments.push(content); - } - - if (part.quoted_drop) { - const quoteSegment = formatReference( - { - label: "Quote from", - dropId: part.quoted_drop.drop_id, - dropPartId: part.quoted_drop.drop_part_id, - drop: part.quoted_drop.drop, - }, - quoteLookup - ); - - if (quoteSegment) { - markdownSegments.push(quoteSegment); - } - } + markdownSegments.push(...buildPartSegments(part, quoteLookup)); } const markdownContent = markdownSegments.join("\n\n").trim(); @@ -618,7 +670,7 @@ const formatMessages = ( messages: ClipboardMessage[], format: ClipboardFormat ): string => { - if (!messages.length) { + if (messages.length === 0) { return ""; } @@ -728,10 +780,13 @@ const resolveSelectionContext = ( const messages: ClipboardMessage[] = []; for (const id of selectedIds) { const message = clipboardMessages.get(id); - if (!message) { - return null; + if (message) { + messages.push(message); } - messages.push(message); + } + + if (messages.length === 0) { + return null; } messages.sort((a, b) => { @@ -852,7 +907,7 @@ const collectPartialSegments = ( if ( boundaries.startDropId && boundaries.startElementForRange && - !boundaries.startFullySelected + boundaries.startFullySelected === false ) { usedPartialHandling = true; const text = getSelectedTextForElement( @@ -862,12 +917,17 @@ const collectPartialSegments = ( partialSegments.set(boundaries.startDropId, text); } + const isSameDrop = + boundaries.startDropId !== null && + boundaries.startDropId === boundaries.endDropId; + const endPartiallySelected = + boundaries.endFullySelected === false || + (isSameDrop && boundaries.startFullySelected === false); + if ( boundaries.endDropId && boundaries.endElementForRange && - (!boundaries.endFullySelected || - (boundaries.startDropId === boundaries.endDropId && - !boundaries.startFullySelected)) + endPartiallySelected ) { usedPartialHandling = true; const text = getSelectedTextForElement( @@ -980,9 +1040,10 @@ export const useWaveDropsClipboard = ({ }, [fullDrops, quoteDropLookup]); const formatRef = useRef("plain"); + const containerNode = containerRef.current; useEffect(() => { - const container = containerRef.current; + const container = containerNode; if (!container) { return; } @@ -1069,5 +1130,5 @@ export const useWaveDropsClipboard = ({ globalThis.removeEventListener?.("keydown", handleKeyDown); container.removeEventListener("copy", handleCopy); }; - }, [clipboardMessages, containerRef]); + }, [clipboardMessages, containerNode]); }; From b61642b44485522dce0bf268820b31005aeca959 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 29 Oct 2025 17:24:54 +0200 Subject: [PATCH 16/20] wip --- .../drops/wave-drops-all/hooks/useWaveDropsClipboard.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts index fbe29e2dd6..1d87acf4ff 100644 --- a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts +++ b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts @@ -868,9 +868,9 @@ const resolveRangeBoundaries = ( const endDropId = endElement?.dataset?.waveDropId ?? null; const startElementForRange = - startDropId != null - ? dropElements.get(startDropId) ?? startElement ?? null - : startElement ?? null; + startDropId == null + ? startElement ?? null + : dropElements.get(startDropId) ?? startElement ?? null; const endElementForRange = endDropId != null ? dropElements.get(endDropId) ?? endElement ?? null From c29a90ed62ddd7df9ace4dfb447555dd0bfc8e2d Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 29 Oct 2025 17:33:24 +0200 Subject: [PATCH 17/20] wip --- .../wave-drops-all/hooks/useWaveDropsClipboard.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts index 1d87acf4ff..513c0cd2d1 100644 --- a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts +++ b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts @@ -79,7 +79,7 @@ const escapeAttributeValue = (value: string): string => { let out = ""; for (const ch of value) { const code = ch.codePointAt(0)!; - if (code <= 0x1f || code === 0x7f || ch === "\"" || ch === "\\" || ch === "[" || ch === "]") { + if (code <= 0x1F || code === 0x7F || ch === "\"" || ch === "\\" || ch === "[" || ch === "]") { out += "\\" + ch; } else { out += ch; @@ -941,10 +941,10 @@ const collectPartialSegments = ( }; type BuildSegmentsOptions = { - selectedIds: string[]; - partialSegments: Map; - messagesById: Map; - format: ClipboardFormat; + readonly selectedIds: string[]; + readonly partialSegments: Map; + readonly messagesById: Map; + readonly format: ClipboardFormat; }; const buildSegmentsFromSelection = ({ From e41067d7535a4bbb2997536833cc51ea5de8865f Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 29 Oct 2025 17:43:53 +0200 Subject: [PATCH 18/20] wip --- .../drops/wave-drops-all/hooks/useWaveDropsClipboard.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts index 513c0cd2d1..d907b7dc4e 100644 --- a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts +++ b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts @@ -989,7 +989,11 @@ export const useWaveDropsClipboard = ({ () => (drops ?? []).filter( (drop): drop is ExtendedDrop => - drop.type === DropSize.FULL + drop.type === DropSize.FULL && + typeof drop.stableKey === "string" && + drop.stableKey.length > 0 && + typeof drop.stableHash === "string" && + drop.stableHash.length > 0 ), [drops] ); From 30e1312ccc07a390e1471411e9dc7c2a69d51113 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 29 Oct 2025 17:54:39 +0200 Subject: [PATCH 19/20] wip Signed-off-by: Simo --- .../hooks/useWaveDropsClipboard.ts | 78 ++++++++++++++++++- 1 file changed, 74 insertions(+), 4 deletions(-) diff --git a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts index d907b7dc4e..83574c5fe0 100644 --- a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts +++ b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts @@ -140,12 +140,81 @@ const getSelectedTextForElement = (range: Range, element: HTMLElement): string = return text; }; -const toPlainText = (markdown: string): string => - markdown +const replaceMarkdownLinks = (input: string): string => { + if (!input.includes("[")) { + return input; + } + + let result = ""; + let cursor = 0; + + while (cursor < input.length) { + const openBracket = input.indexOf("[", cursor); + if (openBracket === -1) { + result += input.slice(cursor); + break; + } + + const isImage = openBracket > cursor && input[openBracket - 1] === "!"; + const segmentEnd = isImage ? openBracket - 1 : openBracket; + + result += input.slice(cursor, segmentEnd); + + const closeBracket = input.indexOf("]", openBracket + 1); + if (closeBracket === -1) { + result += input.slice(segmentEnd); + break; + } + + if (closeBracket + 1 >= input.length || input[closeBracket + 1] !== "(") { + result += input.slice(segmentEnd, closeBracket + 1); + cursor = closeBracket + 1; + continue; + } + + let depth = 1; + let urlCursor = closeBracket + 2; + + while (urlCursor < input.length && depth > 0) { + const char = input[urlCursor]; + if (char === "(") { + depth += 1; + } else if (char === ")") { + depth -= 1; + } + urlCursor += 1; + } + + if (depth !== 0) { + result += input.slice(segmentEnd, urlCursor); + cursor = urlCursor; + continue; + } + + const urlEnd = urlCursor - 1; + const label = input.slice(openBracket + 1, closeBracket); + const url = input.slice(closeBracket + 2, urlEnd); + + if (isImage) { + result += url; + } else if (label && url) { + result += `${label} (${url})`; + } else { + result += label || url; + } + + cursor = urlCursor; + } + + return result; +}; + +const toPlainText = (markdown: string): string => { + const withoutLinkMarkup = replaceMarkdownLinks(markdown); + + return withoutLinkMarkup .replaceAll(/```([\s\S]*?)```/g, (_, code) => code.trim()) .replaceAll(/`([^`]+)`/g, "$1") - .replaceAll(/!\[[^\]]*]\(([^)]+)\)/g, "$1") - .replaceAll(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)") .replaceAll(/(\*\*|__)(.*?)\1/g, "$2") .replaceAll(/([*_])(.*?)\1/g, "$2") .replaceAll(/~~(.*?)~~/g, "$1") @@ -155,6 +224,7 @@ const toPlainText = (markdown: string): string => .replaceAll(/(^|\n)>\s?/g, "$1") .replaceAll(/\n{3,}/g, "\n\n") .trim(); +}; type EmbedInfo = { readonly title?: string; From 44ac6481f30404aa2e8e1863b6ea9313fcef4dd2 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 29 Oct 2025 18:09:26 +0200 Subject: [PATCH 20/20] wip Signed-off-by: Simo --- .../drops/wave-drops-all/hooks/useWaveDropsClipboard.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts index 83574c5fe0..4e090676f4 100644 --- a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts +++ b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts @@ -1114,10 +1114,9 @@ export const useWaveDropsClipboard = ({ }, [fullDrops, quoteDropLookup]); const formatRef = useRef("plain"); - const containerNode = containerRef.current; useEffect(() => { - const container = containerNode; + const container = containerRef.current; if (!container) { return; } @@ -1204,5 +1203,5 @@ export const useWaveDropsClipboard = ({ globalThis.removeEventListener?.("keydown", handleKeyDown); container.removeEventListener("copy", handleCopy); }; - }, [clipboardMessages, containerNode]); + }, [clipboardMessages, containerRef]); };