fix(desktop): restore recent channel sorting (#6402)

## Summary

- batch desktop last-message queries into groups of 128 so workspaces
with larger channel counts stay within the relay's explicit-channel
limit
- propagate timestamp query failures instead of replacing the sidebar's
cached recency with all-null data
- advance channel recency from live message events so Recent ordering
updates without waiting for the next refresh

## Root cause

The desktop sent one explicit `#h` filter per channel in a single
`/query`. The relay rejects a request with more than 128 aggregate
explicit channel values. The desktop swallowed that rejection and
returned no timestamps, so Recent correctly fell back to A–Z for every
channel.

## Testing

- `cargo test --manifest-path desktop/src-tauri/Cargo.toml
last_message_filters_stay_within_relay_channel_cap --lib`
- `cd desktop && pnpm test` (5,132 passed)
- `cd desktop && pnpm exec biome check
src/features/channels/useLiveChannelUpdates.ts
src/features/channels/lib/channelRecency.ts
src/features/channels/lib/channelRecency.test.mjs
tests/e2e/channel-sort.spec.ts`
- `cd desktop && pnpm check:file-sizes`
- `cargo fmt --all -- --check`
- `cd desktop && pnpm build:e2e` followed by `pnpm exec playwright test
tests/e2e/channel-sort.spec.ts --project=smoke --workers=1` against a
dedicated static server (4 passed)

## Manual test

In a workspace with more than 128 channels, choose **Channels → Sort →
Recent**. Channels should order by latest message instead of A–Z. While
Recent is selected, a new message in a visible channel should move that
channel to the top immediately.

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
This commit is contained in:
Wes
2026-08-20 17:31:39 -06:00
committed by GitHub
parent b298a175c4
commit 569308c23c
10 changed files with 659 additions and 106 deletions

View File

@@ -9,6 +9,12 @@
use crate::{app_state::AppState, models::ChannelInfo, nostr_convert, relay::query_relay};
pub(super) const DIRECTORY_PAGE_SIZE: usize = 500;
// Keep this aligned with the relay's aggregate explicit-`#h` request bound.
// Each filter carries one channel so the relay can use its channel_id index.
const LAST_MESSAGE_QUERY_CHANNEL_BATCH_SIZE: usize = 128;
// Human-visible channel activity that drives sidebar Recent ordering. Keep this
// aligned with desktop/src/shared/constants/kinds.ts::CHANNEL_MESSAGE_EVENT_KINDS.
const CHANNEL_RECENCY_EVENT_KINDS: [u16; 4] = [9, 40002, 45001, 45003];
pub(super) fn advance_directory_cursor(filter: &mut serde_json::Value, page: &[nostr::Event]) {
let last = page
@@ -129,6 +135,33 @@ pub(super) fn compute_channels_hash(channels: &[ChannelInfo]) -> String {
// ── Core fetch implementation ─────────────────────────────────────────────────
pub(super) fn last_message_filter(channel_id: &str) -> serde_json::Value {
serde_json::json!({
"kinds": CHANNEL_RECENCY_EVENT_KINDS,
"#h": [channel_id],
"limit": 1
})
}
pub(super) fn last_message_filter_batches(
filters: &[serde_json::Value],
) -> Vec<&[serde_json::Value]> {
filters
.chunks(LAST_MESSAGE_QUERY_CHANNEL_BATCH_SIZE)
.collect()
}
async fn query_last_messages(
state: &AppState,
filters: &[serde_json::Value],
) -> Result<Vec<nostr::Event>, String> {
let mut messages = Vec::with_capacity(filters.len());
for batch in last_message_filter_batches(filters) {
messages.extend(query_relay(state, batch).await?);
}
Ok(messages)
}
/// Whether `fetch_channels` includes the unbounded all-open directory scan.
///
/// The 60s channel poll uses [`DirectoryScope::MemberOnly`]: it resolves only
@@ -154,7 +187,10 @@ pub(super) enum DirectoryScope {
/// metadata source (pending-owned ids when member-only, else the all-open
/// kind:39000 scan), and the hidden-DM snapshot (kind:30622).
/// - Phase 2 (parallel): member counts (kind:39002 batch) and last-message
/// timestamps (per-channel kind:9/40002), fanned out over the merged set.
/// timestamps (bounded per-channel human-visible activity batches), fanned
/// out over the merged set. Member-count failures degrade to zero; timestamp
/// failures abort so cached recency is never replaced by a false
/// authoritative empty result.
pub(super) async fn fetch_channels(
state: &AppState,
scope: DirectoryScope,
@@ -336,19 +372,13 @@ pub(super) async fn fetch_channels(
}
// Phase 2 — concurrent: member counts (step 4) and last-message timestamps
// (step 5). Both tolerate failures — empty defaults leave counts at 0 and
// timestamps at None rather than aborting.
// (step 5). Member-count failures degrade to zero. Timestamp failures
// abort this refresh so the frontend keeps its previous Recent ordering.
let all_channel_ids: Vec<String> = channels.iter().map(|c| c.id.clone()).collect();
if !all_channel_ids.is_empty() {
let last_msg_filters: Vec<serde_json::Value> = all_channel_ids
.iter()
.map(|id| {
serde_json::json!({
"kinds": [9, 40002],
"#h": [id],
"limit": 1
})
})
.map(|id| last_message_filter(id))
.collect();
// Bind both filter arrays before the join so their lifetimes cover
@@ -361,10 +391,14 @@ pub(super) async fn fetch_channels(
let (members_result, message_result) = tokio::join!(
// Step 4: batch-fetch kind:39002 for member counts.
query_relay(state, &member_count_filters),
// Step 5: per-channel last-message filter. Uses per-channel `#h`
// so the relay can push each query to its indexed channel_id column.
query_relay(state, &last_msg_filters),
// Step 5: preserve one indexed filter per channel while keeping
// every relay request within its aggregate explicit-channel cap.
query_last_messages(state, &last_msg_filters),
);
// Message timestamps drive the user-selected Recent ordering. Unlike
// member counts, a failed query must not masquerade as an authoritative
// empty result and clear every cached timestamp in the frontend.
let messages = message_result?;
let membership = collect_members_by_channel(&members_result.unwrap_or_default());
for channel in &mut channels {
@@ -376,7 +410,7 @@ pub(super) async fn fetch_channels(
let mut last_message_by_channel: std::collections::HashMap<String, u64> =
std::collections::HashMap::new();
for ev in &message_result.unwrap_or_default() {
for ev in &messages {
if let Some(ch_id) = ev.tags.iter().find_map(|t| {
let s = t.as_slice();
(s.len() >= 2 && s[0] == "h").then(|| s[1].clone())

View File

@@ -461,3 +461,32 @@ fn starter_match_requires_open_unarchived_stream_by_normalized_name() {
channel.archived_at = Some("2026-07-16T00:00:00Z".to_string());
assert!(!is_matching_starter_channel(&channel, spec));
}
#[test]
fn last_message_filter_covers_all_human_visible_activity_kinds() {
let filter = last_message_filter("forum-1");
assert_eq!(
filter,
serde_json::json!({
"kinds": [9, 40002, 45001, 45003],
"#h": ["forum-1"],
"limit": 1
})
);
}
#[test]
fn last_message_filters_stay_within_relay_channel_cap() {
let filters: Vec<serde_json::Value> = (0..257)
.map(|index| serde_json::json!({"#h": [format!("channel-{index}")]}))
.collect();
let batches = last_message_filter_batches(&filters);
assert_eq!(
batches.iter().map(|batch| batch.len()).collect::<Vec<_>>(),
[128, 128, 1]
);
assert_eq!(batches.concat(), filters);
}

View File

@@ -1,10 +1,14 @@
import assert from "node:assert/strict";
import test from "node:test";
import { QueryClient } from "@tanstack/react-query";
import {
applyLastMessages,
canFetchChannelsForIdentity,
channelsQueryKey,
reconcileRefreshedCachedChannel,
refreshChannelsQuery,
requireFullChannelList,
upsertCachedChannel,
upsertCachedChannelMember,
@@ -14,7 +18,7 @@ function makeChannel(
id,
name,
channelType = "stream",
{ participantPubkeys = [], participants = [] } = {},
{ participantPubkeys = [], participants = [], lastMessageAt = null } = {},
) {
return {
id,
@@ -26,7 +30,7 @@ function makeChannel(
purpose: null,
memberCount: participantPubkeys.length,
memberPubkeys: [...participantPubkeys],
lastMessageAt: null,
lastMessageAt,
archivedAt: null,
participants,
participantPubkeys,
@@ -36,6 +40,153 @@ function makeChannel(
};
}
function deferred() {
let resolve;
const promise = new Promise((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function makeRefreshHarness({ cachedHash = "hash-1" } = {}) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const start = makeChannel("general", "General", "stream", {
lastMessageAt: "2026-01-01T00:00:00.000Z",
});
queryClient.setQueryData(channelsQueryKey, [start]);
const request = deferred();
const calls = [];
const fetchChannels = (knownHash) => {
calls.push(knownHash);
return request.promise;
};
const initialSnapshotPair = cachedHash
? { channels: [start], hash: cachedHash }
: null;
return {
calls,
fetchChannels,
initialSnapshotPair,
queryClient,
request,
start,
};
}
function setDisplayedRecency(queryClient, lastMessageAt) {
queryClient.setQueryData(channelsQueryKey, (channels) =>
channels.map((channel) =>
channel.id === "general" ? { ...channel, lastMessageAt } : channel,
),
);
}
function refreshWithHarness(harness, fetchChannels = harness.fetchChannels) {
return harness.queryClient.fetchQuery({
queryKey: channelsQueryKey,
queryFn: () =>
refreshChannelsQuery({
queryClient: harness.queryClient,
initialSnapshotPair: harness.initialSnapshotPair,
relayUrl: null,
ownerPubkey: null,
fetchChannels,
}),
});
}
const T1 = "2026-01-01T00:01:00.000Z";
const T2 = "2026-01-01T00:02:00.000Z";
test("refreshChannelsQuery preserves a live update through matching not-modified settlement", async () => {
const harness = makeRefreshHarness();
const refresh = refreshWithHarness(harness);
assert.deepEqual(harness.calls, ["hash-1"]);
setDisplayedRecency(harness.queryClient, T2);
harness.request.resolve({
hash: "hash-1",
channels: null,
lastMessages: { general: T1 },
});
const result = await refresh;
assert.equal(result[0].lastMessageAt, T2);
assert.equal(
harness.queryClient.getQueryData(channelsQueryKey)[0].lastMessageAt,
T2,
);
});
test("refreshChannelsQuery preserves a live update through authoritative full-list settlement", async () => {
const harness = makeRefreshHarness({ cachedHash: null });
const refresh = refreshWithHarness(harness);
assert.deepEqual(harness.calls, [null]);
setDisplayedRecency(harness.queryClient, T2);
harness.request.resolve({
hash: "hash-2",
channels: [makeChannel("general", "General")],
lastMessages: { general: T1 },
});
const result = await refresh;
assert.equal(result[0].lastMessageAt, T2);
assert.equal(
harness.queryClient.getQueryData(channelsQueryKey)[0].lastMessageAt,
T2,
);
});
test("refreshChannelsQuery preserves a live update through mismatched not-modified retry", async () => {
const harness = makeRefreshHarness();
const retry = deferred();
const fetchChannels = (knownHash) => {
harness.calls.push(knownHash);
return harness.calls.length === 1
? Promise.resolve({
hash: "mismatched-hash",
channels: null,
lastMessages: {},
})
: retry.promise;
};
const refresh = refreshWithHarness(harness, fetchChannels);
await Promise.resolve();
assert.deepEqual(harness.calls, ["hash-1", null]);
setDisplayedRecency(harness.queryClient, T2);
retry.resolve({
hash: "hash-2",
channels: [makeChannel("general", "General")],
lastMessages: { general: T1 },
});
const result = await refresh;
assert.equal(result[0].lastMessageAt, T2);
assert.equal(
harness.queryClient.getQueryData(channelsQueryKey)[0].lastMessageAt,
T2,
);
});
test("refreshChannelsQuery clears unchanged recency on authoritative absence", async () => {
const harness = makeRefreshHarness();
const refresh = refreshWithHarness(harness);
harness.request.resolve({
hash: "hash-1",
channels: null,
lastMessages: {},
});
const result = await refresh;
assert.equal(result[0].lastMessageAt, null);
});
test("upsertCachedChannel_reseedsOpenedDmAfterStaleRefetch", () => {
const staleChannels = [makeChannel("general", "General")];
const openedDm = makeChannel("new-dm", "Alice", "dm");

View File

@@ -1,5 +1,10 @@
import * as React from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
useMutation,
useQuery,
useQueryClient,
type QueryClient,
} from "@tanstack/react-query";
import {
addChannelMembers,
@@ -31,7 +36,11 @@ import type {
SetChannelTopicInput,
UpdateChannelInput,
} from "@/shared/api/types";
import type { OpenDmInput } from "@/shared/api/tauriChannels";
import type {
GetChannelsPayload,
OpenDmInput,
} from "@/shared/api/tauriChannels";
import { mergeConcurrentChannelRecency } from "@/features/channels/lib/channelRecencyMerge";
import { useIdentityQuery } from "@/shared/api/hooks";
import { useFocusedRefetchInterval } from "@/shared/lib/useDocumentVisible";
import { useCommunities } from "@/features/communities/useCommunities";
@@ -312,6 +321,113 @@ export function requireFullChannelList(channels: Channel[] | null): Channel[] {
return channels;
}
export type RefreshChannelsQueryOptions = {
queryClient: QueryClient;
initialSnapshotPair: ChannelSnapshot | null;
relayUrl: string | null;
ownerPubkey: string | null;
fetchChannels?: (knownHash: string | null) => Promise<GetChannelsPayload>;
persistSnapshot?: typeof writeChannelSnapshot;
};
/**
* Revalidates the channel query while preserving live recency updates that land
* during the request. Exported so the production query/cache interleaving can
* be regression-tested without replacing it with a helper-only simulation.
*/
export async function refreshChannelsQuery({
queryClient,
initialSnapshotPair,
relayUrl,
ownerPubkey,
fetchChannels = getChannels,
persistSnapshot = writeChannelSnapshot,
}: RefreshChannelsQueryOptions): Promise<Channel[]> {
// Revalidation uses only an authoritative list/hash pair. The displayed
// channels cache is intentionally ignored because successful mutations
// patch it before the relay's list/hash has necessarily caught up.
const cachedPair =
queryClient.getQueryData<ChannelSnapshot>(channelsSnapshotPairKey) ??
initialSnapshotPair;
const knownHash = cachedPair?.hash ?? null;
const channelsAtRequestStart =
queryClient.getQueryData<Channel[]>(channelsQueryKey);
const payload = await fetchChannels(knownHash);
// A not-modified response is usable only when it echoes the exact hash
// that described the available list. Any other hash/list pairing fails
// slow-never-wrong by retrying without a hash.
const hasMatchingNotModifiedResponse =
payload.channels === null &&
knownHash !== null &&
payload.hash === knownHash;
const pairChannels =
payload.channels ??
(hasMatchingNotModifiedResponse ? cachedPair?.channels : undefined);
if (!pairChannels) {
// Missing cache or a mismatched not-modified response: discard the hash
// and fetch a complete authoritative list before updating persistence.
const full = await fetchChannels(null);
const authoritativeChannels = sortChannels(
applyLastMessages(
requireFullChannelList(full.channels),
full.lastMessages,
),
);
const displayedAtSettlement =
queryClient.getQueryData<Channel[]>(channelsQueryKey);
const sorted = sortChannels(
mergeConcurrentChannelRecency(
authoritativeChannels,
displayedAtSettlement,
channelsAtRequestStart,
),
);
const pair = { channels: authoritativeChannels, hash: full.hash };
queryClient.setQueryData(channelsSnapshotPairKey, pair);
if (relayUrl && ownerPubkey) {
persistSnapshot(relayUrl, ownerPubkey, pair.channels, pair.hash);
}
return sorted;
}
const authoritativeChannels = sortChannels(
applyLastMessages(pairChannels, payload.lastMessages),
);
const pair = {
channels: authoritativeChannels,
hash: payload.hash,
};
queryClient.setQueryData(channelsSnapshotPairKey, pair);
// Merge against the displayed cache at settlement so a newer live
// timestamp cannot be rolled back by an older request result. This is
// required for both full-list and matching not-modified responses.
const displayedAtSettlement =
queryClient.getQueryData<Channel[]>(channelsQueryKey);
const refreshedForDisplay =
payload.channels === null
? sortChannels(
applyLastMessages(
displayedAtSettlement ?? authoritativeChannels,
payload.lastMessages,
),
)
: authoritativeChannels;
const sorted = sortChannels(
mergeConcurrentChannelRecency(
refreshedForDisplay,
displayedAtSettlement,
channelsAtRequestStart,
),
);
if (relayUrl && ownerPubkey) {
persistSnapshot(relayUrl, ownerPubkey, pair.channels, pair.hash);
}
return sorted;
}
export function useChannelsQuery(options?: { enabled?: boolean }) {
const { activeCommunity } = useCommunities();
const relayUrl = activeCommunity?.relayUrl ?? null;
@@ -351,75 +467,13 @@ export function useChannelsQuery(options?: { enabled?: boolean }) {
relayUrl !== null &&
canFetchChannelsForIdentity(ownerPubkey, identityQuery.isError),
queryKey: channelsQueryKey,
queryFn: async () => {
// Revalidation uses only an authoritative list/hash pair. The displayed
// channels cache is intentionally ignored because successful mutations
// patch it before the relay's list/hash has necessarily caught up.
const cachedPair =
queryClient.getQueryData<ChannelSnapshot>(channelsSnapshotPairKey) ??
initialSnapshotPair;
const knownHash = cachedPair?.hash ?? null;
const payload = await getChannels(knownHash);
// A not-modified response is usable only when it echoes the exact hash
// that described the available list. Any other hash/list pairing fails
// slow-never-wrong by retrying without a hash.
const hasMatchingNotModifiedResponse =
payload.channels === null &&
knownHash !== null &&
payload.hash === knownHash;
const pairChannels =
payload.channels ??
(hasMatchingNotModifiedResponse ? cachedPair?.channels : undefined);
if (!pairChannels) {
// Missing cache or a mismatched not-modified response: discard the hash
// and fetch a complete authoritative list before updating persistence.
const full = await getChannels(null);
const sorted = sortChannels(
applyLastMessages(
requireFullChannelList(full.channels),
full.lastMessages,
),
);
const pair = { channels: sorted, hash: full.hash };
queryClient.setQueryData(channelsSnapshotPairKey, pair);
if (relayUrl && ownerPubkey) {
writeChannelSnapshot(relayUrl, ownerPubkey, pair.channels, pair.hash);
}
return sorted;
}
const authoritativeChannels = sortChannels(
applyLastMessages(pairChannels, payload.lastMessages),
);
const pair = {
channels: authoritativeChannels,
hash: payload.hash,
};
queryClient.setQueryData(channelsSnapshotPairKey, pair);
// A matching not-modified result must merge timestamps into whatever is
// displayed at completion time. Reading through setQueryData avoids
// clobbering an optimistic mutation that landed while the request ran.
const sorted =
payload.channels === null
? (queryClient.setQueryData<Channel[]>(
channelsQueryKey,
(displayedChannels) =>
sortChannels(
applyLastMessages(
displayedChannels ?? authoritativeChannels,
payload.lastMessages,
),
),
) ?? authoritativeChannels)
: authoritativeChannels;
if (relayUrl && ownerPubkey) {
writeChannelSnapshot(relayUrl, ownerPubkey, pair.channels, pair.hash);
}
return sorted;
},
queryFn: () =>
refreshChannelsQuery({
queryClient,
initialSnapshotPair,
relayUrl,
ownerPubkey,
}),
// Paint the complete persisted list immediately. `initialDataUpdatedAt: 0`
// deliberately keeps it stale so every boot still validates against the
// relay; queryFn reads the matching hash from the same atomic document.

View File

@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import test from "node:test";
import { applyChannelLastMessageAt } from "./channelRecency.ts";
import { mergeConcurrentChannelRecency } from "./channelRecencyMerge.ts";
function makeChannel(id, lastMessageAt = null) {
return { id, lastMessageAt };
}
test("applyChannelLastMessageAt advances only the matching channel", () => {
const general = makeChannel("general", "2026-01-01T00:00:00.000Z");
const design = makeChannel("design", "2026-01-01T00:00:00.000Z");
const result = applyChannelLastMessageAt(
[general, design],
"design",
1_767_225_660,
);
assert.notStrictEqual(result, undefined);
assert.strictEqual(result[0], general);
assert.notStrictEqual(result[1], design);
assert.equal(result[1].lastMessageAt, "2026-01-01T00:01:00.000Z");
});
test("applyChannelLastMessageAt ignores stale or equal timestamps", () => {
const design = makeChannel("design", "2026-01-01T00:01:00.000Z");
const channels = [design];
assert.strictEqual(
applyChannelLastMessageAt(channels, "design", 1_767_225_600),
channels,
);
assert.strictEqual(
applyChannelLastMessageAt(channels, "design", "2026-01-01T00:01:00.000Z"),
channels,
);
});
test("applyChannelLastMessageAt preserves the list for invalid or unknown updates", () => {
const channels = [makeChannel("design")];
assert.strictEqual(
applyChannelLastMessageAt(channels, "design", "not-a-date"),
channels,
);
assert.strictEqual(
applyChannelLastMessageAt(channels, "unknown", 1_767_225_660),
channels,
);
assert.strictEqual(
applyChannelLastMessageAt(undefined, "design", 1_767_225_660),
undefined,
);
});
function mergeRecency(start, displayed, refreshed) {
return mergeConcurrentChannelRecency(
[makeChannel("general", refreshed)],
[makeChannel("general", displayed)],
[makeChannel("general", start)],
)[0];
}
test("mergeConcurrentChannelRecency preserves a newer live timestamp", () => {
const result = mergeRecency(
"2026-01-01T00:00:00Z",
"2026-01-01T00:02:00Z",
"2026-01-01T00:01:00Z",
);
assert.equal(result.lastMessageAt, "2026-01-01T00:02:00Z");
});
test("mergeConcurrentChannelRecency preserves monotonic and absence semantics", () => {
assert.equal(
mergeRecency(
"2026-01-01T00:02:00Z",
"2026-01-01T00:02:00Z",
"2026-01-01T00:01:00Z",
).lastMessageAt,
"2026-01-01T00:02:00Z",
);
assert.equal(
mergeRecency("2026-01-01T00:01:00Z", "2026-01-01T00:01:00Z", null)
.lastMessageAt,
null,
);
assert.equal(
mergeRecency(
"2026-01-01T00:00:00Z",
"2026-01-01T00:01:00Z",
"2026-01-01T00:02:00Z",
).lastMessageAt,
"2026-01-01T00:02:00Z",
);
});

View File

@@ -0,0 +1,63 @@
import type { QueryClient } from "@tanstack/react-query";
import { channelsQueryKey } from "@/features/channels/hooks";
import type { Channel } from "@/shared/api/types";
function parseTimestamp(value: number | string | null | undefined) {
if (typeof value === "number") {
return Number.isFinite(value) ? value * 1_000 : null;
}
if (!value) {
return null;
}
const timestamp = Date.parse(value);
return Number.isNaN(timestamp) ? null : timestamp;
}
export function applyChannelLastMessageAt(
current: Channel[] | undefined,
channelId: string,
lastMessageAt: number | string | null | undefined,
): Channel[] | undefined {
if (!current) {
return current;
}
const candidateTimestamp = parseTimestamp(lastMessageAt);
if (candidateTimestamp === null) {
return current;
}
let didUpdate = false;
const normalizedLastMessageAt = new Date(candidateTimestamp).toISOString();
const nextChannels = current.map((channel) => {
if (channel.id !== channelId) {
return channel;
}
const currentTimestamp = parseTimestamp(channel.lastMessageAt);
if (currentTimestamp !== null && candidateTimestamp <= currentTimestamp) {
return channel;
}
didUpdate = true;
return {
...channel,
lastMessageAt: normalizedLastMessageAt,
};
});
return didUpdate ? nextChannels : current;
}
export function updateChannelLastMessageAt(
queryClient: QueryClient,
channelId: string,
lastMessageAt: number | string | null | undefined,
) {
queryClient.setQueryData<Channel[]>(channelsQueryKey, (current) =>
applyChannelLastMessageAt(current, channelId, lastMessageAt),
);
}

View File

@@ -0,0 +1,39 @@
export type RecencyChannel = {
id: string;
lastMessageAt: string | null;
};
function timestamp(value: string | null | undefined): number | null {
if (!value) return null;
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? null : parsed;
}
/**
* Keeps recency monotonic when a refresh settles. An authoritative absence may
* clear an unchanged value, but never a live value added during the request.
*/
export function mergeConcurrentChannelRecency<T extends RecencyChannel>(
refreshed: T[],
displayed: T[] | undefined,
atRequestStart: T[] | undefined,
): T[] {
if (!displayed) return refreshed;
const displayedById = new Map(displayed.map((c) => [c.id, c.lastMessageAt]));
const startById = new Map(
atRequestStart?.map((c) => [c.id, c.lastMessageAt]) ?? [],
);
return refreshed.map((channel) => {
const displayedValue = displayedById.get(channel.id);
const displayedAt = timestamp(displayedValue);
const refreshedAt = timestamp(channel.lastMessageAt);
const changed = displayedValue !== startById.get(channel.id);
const keepDisplayed =
displayedAt !== null &&
(refreshedAt !== null ? displayedAt > refreshedAt : changed);
return keepDisplayed
? { ...channel, lastMessageAt: displayedValue ?? null }
: channel;
});
}

View File

@@ -2,6 +2,7 @@ import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { channelsQueryKey } from "@/features/channels/hooks";
import { updateChannelLastMessageAt } from "@/features/channels/lib/channelRecency";
import { mergeTimelineCacheMessages } from "@/features/messages/hooks";
import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys";
import {
@@ -247,6 +248,13 @@ export function useLiveChannelUpdates(
isDmChannel,
);
// Recency is presentation state, not notification state. Every recognized
// message advances Recent ordering, including self-authored and muted
// messages that the notification policy deliberately filters below.
if (isUnreadTriggerKind) {
updateChannelLastMessageAt(queryClient, channelId, event.created_at);
}
// Let the caller observe self-authored trigger events (e.g. to track
// thread participation) before the author-exclusion guard filters them.
if (

View File

@@ -2862,7 +2862,9 @@ const mockChannels: MockChannel[] = [
description: "Casual forum for async discussions",
topic: null,
purpose: null,
last_message_at: null,
// Authoritative forum-post recency returned by get_channels. This must come
// from the native 45001/45003 query, not the stream-only 9/40002 query.
last_message_at: isoMinutesAgo(10),
archived_at: null,
created_by: ALICE_PUBKEY,
topic_set_by: null,
@@ -2873,7 +2875,7 @@ const mockChannels: MockChannel[] = [
max_members: null,
nip29_group_id: null,
created_minutes_ago: 900,
updated_minutes_ago: 900,
updated_minutes_ago: 10,
members: [
createMockMember(ALICE_PUBKEY, "owner", 900),
createMockMember(MOCK_IDENTITY_PUBKEY, "member", 750),

View File

@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test";
import type { Page } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge";
const SHOTS = "test-results/channel-sort";
@@ -28,6 +28,58 @@ async function openApp(page: Page) {
await expect(page.getByTestId("chat-title")).toHaveText("general");
}
async function waitForMockLiveSubscription(page: Page, channelName: string) {
await expect
.poll(() =>
page.evaluate(
(name) =>
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: name,
}) ?? false,
channelName,
),
)
.toBe(true);
}
async function emitMockMessage(
page: Page,
channelName: string,
createdAt: number,
) {
await page.evaluate(
({ channelName, createdAt, pubkey }) => {
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
if (!emit) {
throw new Error("Mock message emitter is not installed");
}
emit({
channelName,
content: "Fresh activity for Recent ordering",
createdAt,
pubkey,
});
},
{ channelName, createdAt, pubkey: TEST_IDENTITIES.alice.pubkey },
);
}
function forumNames(page: Page) {
return page
.getByTestId("forum-list")
.locator("[data-testid^='channel-']")
.evaluateAll((nodes) =>
nodes
.map((n) => n.getAttribute("data-testid") ?? "")
.filter(
(id) =>
!id.startsWith("channel-unread") &&
!id.startsWith("channel-working"),
)
.map((id) => id.replace(/^channel-/, "")),
);
}
function streamNames(page: Page) {
return page
.getByTestId("stream-list")
@@ -61,6 +113,14 @@ test.describe("per-group channel sort", () => {
await installMockBridge(page);
await openApp(page);
// Prime a live subscription while A–Z keeps engineering rendered, then move
// away so the later message exercises sidebar recency rather than routing.
await page.getByTestId("channel-engineering").click();
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
await waitForMockLiveSubscription(page, "engineering");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Hover the Channels header to reveal the action cluster, then open the
// sort dropdown and choose Recent.
const streamList = page.getByTestId("stream-list");
@@ -102,6 +162,18 @@ test.describe("per-group channel sort", () => {
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/03-channels-recent.png` });
// A fresh message must advance the matching channel immediately. The mock
// fixture intentionally pins all-replies in 2999, so use 3000 to prove the
// live cache update outranks even that deterministic sentinel.
await emitMockMessage(
page,
"engineering",
Math.floor(Date.parse("3000-01-01T00:00:00.000Z") / 1_000),
);
await expect
.poll(async () => (await streamNames(page)).slice(0, 3))
.toEqual(["engineering", "all-replies", "deep-history"]);
// Persisted for this identity.
const stored = await page.evaluate((key) => {
return JSON.parse(window.localStorage.getItem(key) ?? "null");
@@ -129,26 +201,30 @@ test.describe("per-group channel sort", () => {
.toEqual(["all-replies", "deep-history"]);
// …while Forums (unset) stays alphabetical.
const forumNames = await page
.getByTestId("forum-list")
.locator("[data-testid^='channel-']")
.evaluateAll((nodes) =>
nodes
.map((n) => n.getAttribute("data-testid") ?? "")
.filter(
(id) =>
!id.startsWith("channel-unread") &&
!id.startsWith("channel-working"),
)
.map((id) => id.replace(/^channel-/, "")),
);
const sortedForums = [...forumNames].sort((a, b) => a.localeCompare(b));
expect(forumNames).toEqual(sortedForums);
const forumOrder = await forumNames(page);
const sortedForums = [...forumOrder].sort((a, b) => a.localeCompare(b));
expect(forumOrder).toEqual(sortedForums);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/04-independent-groups.png` });
});
test("04 — DM group has its own sort trigger", async ({ page }) => {
test("04 — Forum Recent survives authoritative reload", async ({ page }) => {
await seedSortState(page, { forums: "recent" });
await installMockBridge(page);
await openApp(page);
await expect
.poll(async () => (await forumNames(page)).slice(0, 2))
.toEqual(["watercooler", "announcements"]);
await page.reload();
await expect(page.getByTestId("forum-list")).toBeVisible();
await expect
.poll(async () => (await forumNames(page)).slice(0, 2))
.toEqual(["watercooler", "announcements"]);
});
test("05 — DM group has its own sort trigger", async ({ page }) => {
await installMockBridge(page);
await openApp(page);