Skip to content
Draft
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",
},
});
16 changes: 16 additions & 0 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ import { relayClient } from "@/shared/api/relayClient";
import { useIdentityQuery } from "@/shared/api/hooks";
import { useRelayAutoHeal } from "@/shared/api/useRelayAutoHeal";
import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup";
import { preloadAgentsScreen } from "@/app/routes/agents";
import { preloadChannelRouteScreen } from "@/app/routes/channels.$channelId";
import { preloadChannelViews } from "@/features/channels/ui/ChannelScreenLazyViews";
import { joinChannel } from "@/shared/api/tauri";
import type { Channel, RelayEvent, SearchHit } from "@/shared/api/types";
import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext";
Expand Down Expand Up @@ -540,6 +543,19 @@ export function AppShell() {
};
}, []);

// Warm the lazy route chunks (channel timeline, forum, agents) once the shell
// is idle, so the FIRST main-nav transition doesn't stall on a cold chunk
// fetch+parse. `startupReady` is the existing idle-or-timeout gate; the chunk
// imports dedupe, so racing an actual navigation is harmless.
React.useEffect(() => {
if (!startupReady) {
return;
}
preloadChannelRouteScreen();
preloadChannelViews();
preloadAgentsScreen();
}, [startupReady]);

React.useEffect(() => {
const numericCount =
highPriorityUnreadChannelIds.size + homeBadgeCountExcludingHighPriority;
Expand Down
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
12 changes: 11 additions & 1 deletion desktop/src/app/routes/agents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,21 @@ import { createFileRoute } from "@tanstack/react-router";

import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";

// The chunk import is hoisted so it can be triggered eagerly (route preload)
// as well as lazily on render — calling it twice is a no-op, the module loader
// dedupes and caches the in-flight promise.
const importAgentsScreen = () => import("@/features/agents/ui/AgentsScreen");

const AgentsScreen = React.lazy(async () => {
const module = await import("@/features/agents/ui/AgentsScreen");
const module = await importAgentsScreen();
return { default: module.AgentsScreen };
});

/** Warms the AgentsScreen route chunk so first navigation doesn't stall. */
export function preloadAgentsScreen(): void {
void importAgentsScreen();
}

export const Route = createFileRoute("/agents")({
component: AgentsRouteComponent,
});
Expand Down
11 changes: 10 additions & 1 deletion desktop/src/app/routes/channels.$channelId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,20 @@ export const Route = createFileRoute("/channels/$channelId")({
component: ChannelRouteComponent,
});

// Hoisted so the chunk can be warmed eagerly (route preload) as well as loaded
// lazily on render; the loader dedupes repeat calls.
const importChannelRouteScreen = () => import("./ChannelRouteScreen");

const ChannelRouteScreen = React.lazy(async () => {
const module = await import("./ChannelRouteScreen");
const module = await importChannelRouteScreen();
return { default: module.ChannelRouteScreen };
});

/** Warms the ChannelRouteScreen chunk so first channel open doesn't stall. */
export function preloadChannelRouteScreen(): void {
void importChannelRouteScreen();
}

function ChannelRouteComponent() {
const { channelId } = Route.useParams();
const search = Route.useSearch();
Expand Down
15 changes: 13 additions & 2 deletions desktop/src/features/channels/ui/ChannelScreenLazyViews.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import * as React from "react";

// Hoisted chunk imports so each view can be warmed eagerly (route preload) as
// well as loaded lazily on render; the module loader dedupes repeat calls.
const importChannelPane = () => import("@/features/channels/ui/ChannelPane");
const importForumView = () => import("@/features/forum/ui/ForumView");

export const ChannelPane = React.lazy(async () => {
const module = await import("@/features/channels/ui/ChannelPane");
const module = await importChannelPane();
return { default: module.ChannelPane };
});

export const ForumView = React.lazy(async () => {
const module = await import("@/features/forum/ui/ForumView");
const module = await importForumView();
return { default: module.ForumView };
});

/** Warms the channel/forum view chunks so first open doesn't stall. */
export function preloadChannelViews(): void {
void importChannelPane();
void importForumView();
}
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
Loading
Loading