diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs index 3118538b5f..16a9968bfa 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs @@ -312,3 +312,23 @@ test("shouldShowTranscriptRowTimestamp: compact preview stays dense", () => { false, ); }); + +test("an assistant headline never ends on half a surrogate pair", () => { + // The 69th code unit lands inside the emoji, where `slice` used to cut. + const text = `${"x".repeat(68)}🎉 and more words to push it past the limit`; + const headline = getActivityHeadline(makeMessage({ text })); + const lastBeforeEllipsis = headline.slice(0, -1); + const lastUnit = lastBeforeEllipsis.charCodeAt(lastBeforeEllipsis.length - 1); + assert.ok( + lastUnit < 0xd800 || lastUnit > 0xdbff, + `ends on a lone high surrogate: ${JSON.stringify(headline.slice(-3))}`, + ); + assert.equal(headline, `${"x".repeat(68)}🎉…`); +}); + +test("an emoji-heavy headline is not falsely ellipsised", () => { + // 60 characters, 120 code units — under the 72-character limit, so it must + // come back whole. The old code-unit guard shortened it. + const text = "🎉".repeat(60); + assert.equal(getActivityHeadline(makeMessage({ text })), text); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts index a77a21ff89..b404fc773c 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts @@ -1,5 +1,9 @@ import type { TranscriptItem } from "./agentSessionTypes"; import { buildCompactToolSummary } from "./agentSessionToolSummary"; +import { + countCharacters, + truncateByCharacters, +} from "@/shared/lib/truncateByCharacters"; /** * Whether a polished activity row should render the opt-in timestamp footer. @@ -39,8 +43,8 @@ export function getActivityHeadline(item: TranscriptItem): string | null { if (trimmed.length > 0) { const firstLine = trimmed.split("\n")[0]?.trim() ?? ""; if (firstLine.length > 0) { - return firstLine.length > 72 - ? `${firstLine.slice(0, 69)}…` + return countCharacters(firstLine) > 72 + ? `${truncateByCharacters(firstLine, 69)}…` : firstLine; } } diff --git a/desktop/src/features/forum/ui/ForumPostCard.tsx b/desktop/src/features/forum/ui/ForumPostCard.tsx index 8cbbdff2b4..ecd85d0c8a 100644 --- a/desktop/src/features/forum/ui/ForumPostCard.tsx +++ b/desktop/src/features/forum/ui/ForumPostCard.tsx @@ -16,6 +16,10 @@ import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; import { formatRelativeTime } from "../lib/time"; import { DeleteActionMenu } from "./DeleteActionMenu"; +import { + countCharacters, + truncateByCharacters, +} from "@/shared/lib/truncateByCharacters"; type ForumPostCardProps = { post: ForumPost; @@ -58,8 +62,8 @@ export function ForumPostCard({ const imetaByUrl = useMemo(() => parseImetaTags(post.tags), [post.tags]); const summary = post.threadSummary; const previewContent = - post.content.length > 200 - ? `${post.content.slice(0, 200)}...` + countCharacters(post.content) > 200 + ? `${truncateByCharacters(post.content, 200)}...` : post.content; return ( diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 893b3c309c..cad4e498f7 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -73,7 +73,7 @@ import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState"; import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; import { Button } from "@/shared/ui/button"; import { HomeMembersSidebarOverlay } from "./HomeMembersSidebarOverlay"; - +import { truncateByCharacters } from "@/shared/lib/truncateByCharacters"; const INBOX_SEARCH_KEYS = [ "item", "profile", @@ -720,7 +720,7 @@ export function HomeView({ authorPubkey: item.item.pubkey, channelId, eventId: item.id, - preview: item.preview.slice(0, 100), + preview: truncateByCharacters(item.preview, 100), }); }} onSelect={(itemId) => { diff --git a/desktop/src/features/huddle/components/HuddleBar.tsx b/desktop/src/features/huddle/components/HuddleBar.tsx index 16641f4cc3..6947c88fca 100644 --- a/desktop/src/features/huddle/components/HuddleBar.tsx +++ b/desktop/src/features/huddle/components/HuddleBar.tsx @@ -33,6 +33,10 @@ import type { HuddleAgentVoiceSettings } from "./AgentVoiceMenu"; import { MicControls, SpeakerControls } from "./MicControls"; import { HuddleParticipantsControl } from "./ParticipantList"; import { truncatePubkey } from "@/shared/lib/pubkey"; +import { + countCharacters, + truncateByCharacters, +} from "@/shared/lib/truncateByCharacters"; // Mirrors HuddleState in src-tauri/src/huddle/mod.rs. type HuddleState = { @@ -92,8 +96,8 @@ function customEmojiShortcode(emoji: string): string | null { function clampReactionName(name: string): string { const trimmed = name.trim(); - if (trimmed.length <= HUDDLE_REACTION_NAME_MAX) return trimmed; - return `${trimmed.slice(0, HUDDLE_REACTION_NAME_MAX - 1).trimEnd()}…`; + if (countCharacters(trimmed) <= HUDDLE_REACTION_NAME_MAX) return trimmed; + return `${truncateByCharacters(trimmed, HUDDLE_REACTION_NAME_MAX - 1).trimEnd()}…`; } function fallbackNameForPubkey(pubkey?: string | null): string { diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index de496836c1..4563209fdd 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -58,6 +58,7 @@ import { MessageTimestamp } from "./MessageTimestamp"; import { SentFromThreadLine } from "./SentFromThreadLine"; import { WaveMessageAttachment } from "./WaveMessageAttachment"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { truncateByCharacters } from "@/shared/lib/truncateByCharacters"; const DiffMessage = React.lazy(() => import("./DiffMessage")); const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded")); @@ -221,7 +222,7 @@ export const MessageRow = React.memo( openReminder({ eventId: msg.id, channelId: channelId ?? "", - preview: msg.body.slice(0, 100), + preview: truncateByCharacters(msg.body, 100), authorPubkey: msg.pubkey ?? "", }); }, diff --git a/desktop/src/features/notifications/lib/notificationFormat.ts b/desktop/src/features/notifications/lib/notificationFormat.ts index c25270123a..f7cbefc7c6 100644 --- a/desktop/src/features/notifications/lib/notificationFormat.ts +++ b/desktop/src/features/notifications/lib/notificationFormat.ts @@ -1,3 +1,8 @@ +import { + countCharacters, + truncateByCharacters, +} from "@/shared/lib/truncateByCharacters"; + const NOTIFICATION_BODY_MAX_LENGTH = 140; /** @@ -29,8 +34,8 @@ export function truncateNotificationBody( ): string { const trimmed = content.trim(); if (trimmed.length === 0) return fallback; - if (trimmed.length <= NOTIFICATION_BODY_MAX_LENGTH) return trimmed; - return `${trimmed.slice(0, NOTIFICATION_BODY_MAX_LENGTH - 3).trimEnd()}...`; + if (countCharacters(trimmed) <= NOTIFICATION_BODY_MAX_LENGTH) return trimmed; + return `${truncateByCharacters(trimmed, NOTIFICATION_BODY_MAX_LENGTH - 3).trimEnd()}...`; } /** diff --git a/desktop/src/features/projects/lib/discussionChannels.test.mjs b/desktop/src/features/projects/lib/discussionChannels.test.mjs index 95f73f6373..71d0edfcaa 100644 --- a/desktop/src/features/projects/lib/discussionChannels.test.mjs +++ b/desktop/src/features/projects/lib/discussionChannels.test.mjs @@ -130,3 +130,20 @@ test("discussionSnippet truncates long content on an ellipsis", () => { assert.ok(snippet.length <= 400); assert.ok(snippet.endsWith("…")); }); + +test("a discussion snippet never ends on half a surrogate pair", () => { + const content = `${"x".repeat(398)}🎉 trailing words past the limit`; + const snippet = discussionSnippet(content); + const body = snippet.slice(0, -1); + const lastUnit = body.charCodeAt(body.length - 1); + assert.ok( + lastUnit < 0xd800 || lastUnit > 0xdbff, + `ends on a lone high surrogate: ${JSON.stringify(snippet.slice(-3))}`, + ); +}); + +test("an emoji-heavy snippet is not falsely ellipsised", () => { + // 300 characters, 600 code units — under the 400-character limit. + const content = "🎉".repeat(300); + assert.equal(discussionSnippet(content), content); +}); diff --git a/desktop/src/features/projects/lib/discussionChannels.ts b/desktop/src/features/projects/lib/discussionChannels.ts index 3776467248..29ffa57e03 100644 --- a/desktop/src/features/projects/lib/discussionChannels.ts +++ b/desktop/src/features/projects/lib/discussionChannels.ts @@ -16,6 +16,10 @@ */ import type { SearchHit } from "@/shared/api/searchTypes"; +import { + countCharacters, + truncateByCharacters, +} from "@/shared/lib/truncateByCharacters"; export type DiscussionChannel = { id: string; @@ -145,8 +149,8 @@ export function discussionSnippet(content: string): string { if (cleaned.length === 0) { return "Shared a link to this."; } - if (cleaned.length <= SNIPPET_MAX_CHARS) { + if (countCharacters(cleaned) <= SNIPPET_MAX_CHARS) { return cleaned; } - return `${cleaned.slice(0, SNIPPET_MAX_CHARS - 1).trimEnd()}…`; + return `${truncateByCharacters(cleaned, SNIPPET_MAX_CHARS - 1).trimEnd()}…`; } diff --git a/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx b/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx index dc317ab03c..b3ee71423c 100644 --- a/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx +++ b/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx @@ -29,6 +29,7 @@ import { PROJECT_EVENT_VISUALS, type ProjectEventKind, } from "./ProjectEventTypeIcon"; +import { truncateByCharacters } from "@/shared/lib/truncateByCharacters"; type ActivityKind = ProjectEventKind; @@ -86,7 +87,10 @@ type ProjectsActivityFeedProps = { const ACTIVITY_LIMIT = 30; function contentPreview(content: string) { - return markdownToPlainText(content).replace(/\s+/g, " ").trim().slice(0, 280); + return truncateByCharacters( + markdownToPlainText(content).replace(/\s+/g, " ").trim(), + 280, + ); } function buildActivityItems({ diff --git a/desktop/src/features/pulse/lib/replies.ts b/desktop/src/features/pulse/lib/replies.ts index eecb18632e..789d9f6d2f 100644 --- a/desktop/src/features/pulse/lib/replies.ts +++ b/desktop/src/features/pulse/lib/replies.ts @@ -1,4 +1,5 @@ import type { UserNote } from "@/shared/api/socialTypes"; +import { truncateByCharacters } from "@/shared/lib/truncateByCharacters"; export function getReplyParent(note: UserNote): string | null { const eTags = note.tags.filter((tag) => tag[0] === "e" && tag[1]); @@ -27,5 +28,5 @@ export function getReplyParent(note: UserNote): string | null { } export function noteSnippet(content: string) { - return content.trim().replace(/\s+/g, " ").slice(0, 120); + return truncateByCharacters(content.trim().replace(/\s+/g, " "), 120); } diff --git a/desktop/src/features/search/ui/SearchResultItem.tsx b/desktop/src/features/search/ui/SearchResultItem.tsx index 6eeb33801e..901506ee51 100644 --- a/desktop/src/features/search/ui/SearchResultItem.tsx +++ b/desktop/src/features/search/ui/SearchResultItem.tsx @@ -19,6 +19,10 @@ import { import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types"; import { Badge } from "@/shared/ui/badge"; import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { + countCharacters, + truncateByCharacters, +} from "@/shared/lib/truncateByCharacters"; export type SearchResult = | { @@ -182,11 +186,11 @@ function truncateContent(content: string) { return "No message body."; } - if (trimmed.length <= 180) { + if (countCharacters(trimmed) <= 180) { return trimmed; } - return `${trimmed.slice(0, 177)}...`; + return `${truncateByCharacters(trimmed, 177)}...`; } function formatRelativeTime(unixSeconds: number) { diff --git a/desktop/src/features/search/ui/TopbarSearch.tsx b/desktop/src/features/search/ui/TopbarSearch.tsx index 963ae6cf42..d2be050bd7 100644 --- a/desktop/src/features/search/ui/TopbarSearch.tsx +++ b/desktop/src/features/search/ui/TopbarSearch.tsx @@ -27,6 +27,7 @@ import { } from "@/shared/ui/mentionChip"; import { Skeleton } from "@/shared/ui/skeleton"; import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { truncateByCharacters } from "@/shared/lib/truncateByCharacters"; type TopbarSearchProps = { channelLabels?: Record; @@ -77,12 +78,11 @@ function truncateResultText(content: string, maxLength = 96) { if (trimmed.length === 0) { return "No message body."; } - - if (trimmed.length <= maxLength) { + const kept = truncateByCharacters(trimmed, maxLength); + if (kept.length === trimmed.length) { return trimmed; } - - return `${trimmed.slice(0, maxLength - 3).trimEnd()}...`; + return `${truncateByCharacters(trimmed, maxLength - 3).trimEnd()}...`; } function formatRelativeTime(unixSeconds: number) { diff --git a/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx index 1c86ac4c36..0be5352b8f 100644 --- a/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx +++ b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx @@ -18,6 +18,7 @@ import { useNow } from "@/shared/lib/useNow"; import { Markdown } from "@/shared/ui/markdown"; import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { truncateByCharacters } from "@/shared/lib/truncateByCharacters"; const HOVER_OPEN_DELAY_MS = 250; const HOVER_CLOSE_DELAY_MS = 180; @@ -437,7 +438,7 @@ export function ChannelActivityPopover({ authorPubkey: item.item.pubkey, channelId: channel.id, eventId: item.id, - preview: item.preview.slice(0, 100), + preview: truncateByCharacters(item.preview, 100), }); }} /> diff --git a/desktop/src/shared/lib/truncateByCharacters.test.mjs b/desktop/src/shared/lib/truncateByCharacters.test.mjs new file mode 100644 index 0000000000..129ecfca17 --- /dev/null +++ b/desktop/src/shared/lib/truncateByCharacters.test.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + countCharacters, + truncateByCharacters, +} from "./truncateByCharacters.ts"; + +test("leaves a short string alone", () => { + assert.equal(truncateByCharacters("hello", 100), "hello"); + assert.equal(truncateByCharacters("hello", 5), "hello"); +}); + +test("never ends on half a surrogate pair", () => { + // The 100th code unit lands inside the emoji, which is where `slice` bit. + const body = `${"x".repeat(99)}🎉 more text`; + const cut = truncateByCharacters(body, 100); + const lastUnit = cut.charCodeAt(cut.length - 1); + assert.ok( + lastUnit < 0xd800 || lastUnit > 0xdbff, + `ends on a lone high surrogate: ${JSON.stringify(cut.slice(-2))}`, + ); + assert.equal(cut, `${"x".repeat(99)}🎉`); +}); + +test("counts characters, not code units", () => { + assert.equal(truncateByCharacters("🎉🎉🎉", 2), "🎉🎉"); + assert.equal([...truncateByCharacters("🎉🎉🎉", 2)].length, 2); +}); + +test("handles the degenerate limits", () => { + assert.equal(truncateByCharacters("hello", 0), ""); + assert.equal(truncateByCharacters("", 10), ""); +}); + +test("counts characters so a guard cannot disagree with the cut", () => { + // 150 emoji: 150 characters, 300 code units. A `.length > 200` guard fires + // while the cut returns the whole string, so the caller appends an ellipsis + // to text that was never shortened. + const emoji = "🎉".repeat(150); + assert.equal(emoji.length, 300); + assert.equal(countCharacters(emoji), 150); + assert.equal(truncateByCharacters(emoji, 200), emoji); + assert.equal(countCharacters(emoji) > 200, false); +}); + +test("counts a plain string the same as .length", () => { + assert.equal(countCharacters("hello"), 5); + assert.equal(countCharacters(""), 0); +}); diff --git a/desktop/src/shared/lib/truncateByCharacters.ts b/desktop/src/shared/lib/truncateByCharacters.ts new file mode 100644 index 0000000000..a09c3d422e --- /dev/null +++ b/desktop/src/shared/lib/truncateByCharacters.ts @@ -0,0 +1,33 @@ +/** How many characters `text` has, counting code points rather than units. */ +export function countCharacters(text: string): number { + return [...text].length; +} + +/** + * Truncate `text` to at most `maxCharacters`, cutting between characters + * rather than between the UTF-16 code units a character is stored in. + * + * `String.prototype.slice` counts code units. Most emoji, and plenty of CJK, + * live outside the Basic Multilingual Plane and are stored as a surrogate + * pair, so a cut that lands inside one leaves a lone surrogate: not a + * character, and rendered as `�` at the end of the preview. + * + * Guard with `countCharacters`, not `.length`. Deciding in code units and + * cutting in code points disagree on emoji-heavy text: a 150-emoji string is + * 300 units long, so a `length > 200` guard fires while this returns the + * string untouched — and the caller appends an ellipsis to complete text. + * + * Characters here means code points, not grapheme clusters. A cut can still + * land between the parts of a ZWJ sequence (a family emoji becoming one + * person), which is a different picture but a valid string — unlike the lone + * surrogate, which is not text at all. + */ +export function truncateByCharacters( + text: string, + maxCharacters: number, +): string { + if (maxCharacters <= 0) return ""; + const characters = [...text]; + if (characters.length <= maxCharacters) return text; + return characters.slice(0, maxCharacters).join(""); +}