Skip to content
Merged
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
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export default defineConfig({
"**/animated-avatar-screenshots.spec.ts",
"**/reminders-screenshots.spec.ts",
"**/virtualization-screenshots.spec.ts",
"**/scroll-history.spec.ts",
],
use: {
...devices["Desktop Chrome"],
Expand Down
23 changes: 23 additions & 0 deletions desktop/playwright.perf.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
testDir: "./tests/e2e",
timeout: 60_000,
retries: 0,
workers: 1,
reporter: [["list"]],
use: { baseURL: "http://127.0.0.1:4173" },
projects: [
{
name: "perf",
testMatch: ["**/*.perf.ts"],
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "python3 -m http.server 4173 -d dist",
cwd: ".",
reuseExistingServer: true,
url: "http://127.0.0.1:4173",
},
});
32 changes: 30 additions & 2 deletions desktop/src/app/routes/ChannelRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,46 @@ export function ChannelRouteScreen({
return cachedTarget ? [cachedTarget] : [];
});

// Reset spliced target events when the channel context changes (channel
// switch or entering/leaving a forum post). Tied to channel identity rather
// than the route target so clearing the `messageId` param mid-channel keeps
// the deep-linked row in view. Seeded with the mount key so the initial
// cache-seeded events survive first commit; only a genuine channel change
// clears them. Declared before the fetch effect so a channel switch clears
// stale events before the new target is fetched.
const previousResetKeyRef = React.useRef<string>(
`${channelId}::${selectedPostId ?? ""}`,
);
React.useEffect(() => {
const resetKey = `${channelId}::${selectedPostId ?? ""}`;
if (previousResetKeyRef.current === resetKey) return;
previousResetKeyRef.current = resetKey;
setTargetMessageEvents([]);
}, [channelId, selectedPostId]);

React.useEffect(() => {
let isCancelled = false;

// Don't wipe already-spliced target events just because the route target
// cleared (e.g. `onTargetReached` clears the `messageId` URL param once the
// row is centered). In a channel whose feed doesn't already contain the
// deep-linked message, the spliced event is the only copy — dropping it on
// param-clear blanks the timeline. Resetting on channel / forum-post change
// is handled by the effect below; here we only fetch when there's a target.
if ((!targetMessageId && !targetThreadRootId) || selectedPostId) {
setTargetMessageEvents([]);
return () => {
isCancelled = true;
};
}

const cachedTarget = getCachedSearchHitEvent(targetMessageId);
setTargetMessageEvents(cachedTarget ? [cachedTarget] : []);
if (cachedTarget) {
setTargetMessageEvents((currentEvents) =>
currentEvents.some((event) => event.id === cachedTarget.id)
? currentEvents
: [...currentEvents, cachedTarget],
);
}

const eventIds = [
targetMessageId,
Expand Down
20 changes: 20 additions & 0 deletions desktop/src/features/messages/lib/dateFormatters.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
formatDayHeading,
formatShortMonthDayOrdinal,
formatThreadSummaryLastReplyTime,
startOfLocalDaySeconds,
} from "./dateFormatters.ts";

function localUnixSeconds(year, monthIndex, day) {
Expand Down Expand Up @@ -126,3 +127,22 @@ test("formatDayHeading includes the year for other years", () => {
`${weekday(date)}, May 19th, ${year}`,
);
});

test("startOfLocalDaySeconds collapses a day's timestamps to one value", () => {
const morning = new Date(2026, 5, 14, 8, 30, 15).getTime() / 1_000;
const evening = new Date(2026, 5, 14, 23, 59, 59).getTime() / 1_000;
const midnight = new Date(2026, 5, 14, 0, 0, 0).getTime() / 1_000;

assert.equal(startOfLocalDaySeconds(morning), midnight);
assert.equal(startOfLocalDaySeconds(evening), midnight);
});

test("startOfLocalDaySeconds separates adjacent calendar days", () => {
const lateOn14 = new Date(2026, 5, 14, 23, 0, 0).getTime() / 1_000;
const earlyOn15 = new Date(2026, 5, 15, 1, 0, 0).getTime() / 1_000;

assert.notEqual(
startOfLocalDaySeconds(lateOn14),
startOfLocalDaySeconds(earlyOn15),
);
});
12 changes: 12 additions & 0 deletions desktop/src/features/messages/lib/dateFormatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,18 @@ export function isSameDay(a: number, b: number): boolean {
return isSameDayDate(new Date(a * 1_000), new Date(b * 1_000));
}

/**
* Unix-seconds timestamp of local midnight for the calendar day containing
* `unixSeconds`. Two timestamps on the same calendar day map to the same value,
* so it is a stable identifier for a day group that does not shift when an
* older message is prepended into that day.
*/
export function startOfLocalDaySeconds(unixSeconds: number): number {
const date = new Date(unixSeconds * 1_000);
date.setHours(0, 0, 0, 0);
return Math.floor(date.getTime() / 1_000);
}

/** Short month + ordinal day, e.g. "May 19th". */
export function formatShortMonthDayOrdinal(unixSeconds: number): string {
return formatMonthDayOrdinal(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";

import { formatTimelineMessages } from "./formatTimelineMessages.ts";
import {
countTopLevelTimelineRows,
formatTimelineMessages,
} from "./formatTimelineMessages.ts";

const HEX64_A =
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
Expand Down Expand Up @@ -100,3 +103,94 @@ test("deletion target with non-hex `e` tag value is ignored", () => {
"malformed deletion tag should not match anything",
);
});

// ---------------------------------------------------------------------------
// countTopLevelTimelineRows — the unit fetch-older pages by. Must match the
// rows `buildMainTimelineEntries` would actually render: top-level content
// events, minus deletions, with thread replies collapsed into their parent.
// ---------------------------------------------------------------------------

function hex64(char) {
return char.repeat(64);
}

function message(id, overrides = {}) {
return {
id,
pubkey: PUBKEY_A,
kind: 9,
created_at: 1_700_000_000,
content: "hi",
tags: [["h", CHANNEL_ID]],
sig: "sig",
...overrides,
};
}

function reply(id, parentId, overrides = {}) {
return message(id, {
tags: [
["h", CHANNEL_ID],
["e", parentId, "", "reply"],
],
...overrides,
});
}

test("countTopLevelTimelineRows counts top-level messages", () => {
const events = [
message(hex64("1")),
message(hex64("2")),
message(hex64("3")),
];
assert.equal(countTopLevelTimelineRows(events), 3);
});

test("countTopLevelTimelineRows ignores collapsed thread replies", () => {
const root = hex64("1");
const events = [
message(root),
reply(hex64("2"), root),
reply(hex64("3"), root),
];
// Two replies collapse into the root's summary → one visible row.
assert.equal(countTopLevelTimelineRows(events), 1);
});

test("countTopLevelTimelineRows counts broadcast replies as top-level", () => {
const root = hex64("1");
const broadcast = reply(hex64("2"), root, {
tags: [
["h", CHANNEL_ID],
["e", root, "", "reply"],
["broadcast", "1"],
],
});
assert.equal(countTopLevelTimelineRows([message(root), broadcast]), 2);
});

test("countTopLevelTimelineRows excludes deleted messages", () => {
const target = hex64("1");
const events = [
message(target),
message(hex64("2")),
deletionEvent(9005, target, { id: hex64("9") }),
];
assert.equal(countTopLevelTimelineRows(events), 1);
});

test("countTopLevelTimelineRows ignores non-content kinds (reactions)", () => {
const reaction = {
id: hex64("9"),
pubkey: PUBKEY_B,
kind: 7,
created_at: 1_700_000_001,
content: "+",
tags: [
["h", CHANNEL_ID],
["e", hex64("1")],
],
sig: "sig",
};
assert.equal(countTopLevelTimelineRows([message(hex64("1")), reaction]), 1);
});
46 changes: 45 additions & 1 deletion desktop/src/features/messages/lib/formatTimelineMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import type {
TimelineMessage,
TimelineReaction,
} from "@/features/messages/types";
import { getThreadReference } from "@/features/messages/lib/threading";
import {
getThreadReference,
isBroadcastReply,
} from "@/features/messages/lib/threading";
import {
resolveUserLabel,
type UserProfileLookup,
Expand Down Expand Up @@ -66,6 +69,47 @@ function getDeletionTargets(tags: string[][]) {
.map((tag) => tag[1]);
}

/**
* Count the *visible top-level rows* a raw event window would render in the
* main channel timeline — the same unit `buildMainTimelineEntries` produces.
*
* This is deliberately NOT `events.length`: thread replies collapse into their
* parent's summary row, deleted events disappear, and non-content kinds
* (reactions, edits, deletions) never render as their own row. A history batch
* heavy with replies can add 100 events but only a handful of rows, which is
* why fetch-older counts rows here, not messages, when deciding how far to page.
*
* Mirrors the two filters that bound the rendered list:
* 1. `formatTimelineMessages` keeps content kinds that aren't deletion targets.
* 2. `buildMainTimelineEntries` keeps entries that are top-level
* (`parentId == null`) or broadcast replies.
*/
export function countTopLevelTimelineRows(events: RelayEvent[]): number {
const deletedEventIds = new Set<string>();
for (const event of events) {
if (
event.kind === KIND_DELETION ||
event.kind === KIND_NIP29_DELETE_EVENT
) {
for (const targetId of getDeletionTargets(event.tags)) {
deletedEventIds.add(targetId);
}
}
}

let count = 0;
for (const event of events) {
if (!isTimelineContentEvent(event) || deletedEventIds.has(event.id)) {
continue;
}
const { parentId } = getThreadReference(event.tags);
if (parentId == null || isBroadcastReply(event.tags)) {
count += 1;
}
}
return count;
}

function getReactionTargetId(tags: string[][]) {
for (let index = tags.length - 1; index >= 0; index -= 1) {
const tag = tags[index];
Expand Down
2 changes: 1 addition & 1 deletion desktop/src/features/messages/lib/messageLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export type MessageLinkInput = {
*
* Currently emitted into the URL but not consumed by the click handler
* or deep-link listener — both route via `goChannel(channelId,
* { messageId })` and let `useTimelineScrollManager` resolve the target.
* { messageId })` and let `useAnchoredScroll` resolve the target.
* Reserved for future "open in thread view" routing.
*/
threadRootId?: string | null;
Expand Down
25 changes: 25 additions & 0 deletions desktop/src/features/messages/lib/timelineSnapshot.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,31 @@ test("buildDayGroupBoundaries: group counts always sum to message count", () =>
assert.equal(total, messages.length);
});

test("buildDayGroupBoundaries: same-day group key is stable across a prepend", () => {
// The day section is keyed by this value; if it changes when an older
// message lands on the same calendar day, React remounts the whole section
// on every scroll-up prepend — the timeline flicker. The key must depend on
// the calendar day, not the first message's exact timestamp.
const before = buildDayGroupBoundaries([
message({ id: "b", createdAt: dayAt(2026, 6, 14, 9) }),
message({ id: "c", createdAt: dayAt(2026, 6, 14, 10) }),
]);
const afterPrepend = buildDayGroupBoundaries([
message({ id: "a", createdAt: dayAt(2026, 6, 14, 8) }),
message({ id: "b", createdAt: dayAt(2026, 6, 14, 9) }),
message({ id: "c", createdAt: dayAt(2026, 6, 14, 10) }),
]);
assert.equal(before[0].key, afterPrepend[0].key);
});

test("buildDayGroupBoundaries: distinct calendar days get distinct keys", () => {
const groups = buildDayGroupBoundaries([
message({ id: "a", createdAt: dayAt(2026, 6, 13, 12) }),
message({ id: "b", createdAt: dayAt(2026, 6, 14, 12) }),
]);
assert.notEqual(groups[0].key, groups[1].key);
});

// --- jump-to-message deep links ----------------------------------------------

test("resolveDeepLinkTarget: unresolved with no target", () => {
Expand Down
10 changes: 7 additions & 3 deletions desktop/src/features/messages/lib/timelineSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/

import type { TimelineMessage } from "@/features/messages/types";
import { isSameDay } from "./dateFormatters";
import { isSameDay, startOfLocalDaySeconds } from "./dateFormatters";

/** Distance (px) from the bottom within which the timeline counts as "at bottom". */
export const BOTTOM_THRESHOLD_PX = 72;
Expand Down Expand Up @@ -88,7 +88,11 @@ export function selectLatestMessageAutoScrollBehavior({

/** A single day boundary in the timeline: where it starts and how many messages it covers. */
export type DayGroupBoundary = {
/** Stable key for the day section. */
/**
* Stable key for the day section: the local start-of-day of the messages it
* covers, so prepending an older message into an already-rendered day reuses
* the same key instead of remounting the whole `<section>`.
*/
key: string;
/** Index into `messages` of the first message in this day. */
startIndex: number;
Expand All @@ -114,7 +118,7 @@ export function buildDayGroupBoundaries(

if (!prev || !isSameDay(prev.createdAt, message.createdAt)) {
boundaries.push({
key: `day-${message.createdAt}`,
key: `day-${startOfLocalDaySeconds(message.createdAt)}`,
startIndex: i,
count: 1,
headingTimestamp: message.createdAt,
Expand Down
Loading