Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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;
}
}
Expand Down
8 changes: 6 additions & 2 deletions desktop/src/features/forum/ui/ForumPostCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
Expand Down
4 changes: 2 additions & 2 deletions desktop/src/features/home/ui/HomeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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) => {
Expand Down
8 changes: 6 additions & 2 deletions desktop/src/features/huddle/components/HuddleBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion desktop/src/features/messages/ui/MessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -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 ?? "",
});
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import {
countCharacters,
truncateByCharacters,
} from "@/shared/lib/truncateByCharacters";

const NOTIFICATION_BODY_MAX_LENGTH = 140;

/**
Expand Down Expand Up @@ -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()}...`;
}

/**
Expand Down
17 changes: 17 additions & 0 deletions desktop/src/features/projects/lib/discussionChannels.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
8 changes: 6 additions & 2 deletions desktop/src/features/projects/lib/discussionChannels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
*/

import type { SearchHit } from "@/shared/api/searchTypes";
import {
countCharacters,
truncateByCharacters,
} from "@/shared/lib/truncateByCharacters";

export type DiscussionChannel = {
id: string;
Expand Down Expand Up @@ -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()}…`;
}
6 changes: 5 additions & 1 deletion desktop/src/features/projects/ui/ProjectsActivityFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
PROJECT_EVENT_VISUALS,
type ProjectEventKind,
} from "./ProjectEventTypeIcon";
import { truncateByCharacters } from "@/shared/lib/truncateByCharacters";

type ActivityKind = ProjectEventKind;

Expand Down Expand Up @@ -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({
Expand Down
3 changes: 2 additions & 1 deletion desktop/src/features/pulse/lib/replies.ts
Original file line number Diff line number Diff line change
@@ -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]);
Expand Down Expand Up @@ -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);
}
8 changes: 6 additions & 2 deletions desktop/src/features/search/ui/SearchResultItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
| {
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 4 additions & 4 deletions desktop/src/features/search/ui/TopbarSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
});
}}
/>
Expand Down
50 changes: 50 additions & 0 deletions desktop/src/shared/lib/truncateByCharacters.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
33 changes: 33 additions & 0 deletions desktop/src/shared/lib/truncateByCharacters.ts
Original file line number Diff line number Diff line change
@@ -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("");
}