mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
97
desktop/src/features/channels/lib/channelRecency.test.mjs
Normal file
97
desktop/src/features/channels/lib/channelRecency.test.mjs
Normal 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",
|
||||
);
|
||||
});
|
||||
63
desktop/src/features/channels/lib/channelRecency.ts
Normal file
63
desktop/src/features/channels/lib/channelRecency.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
39
desktop/src/features/channels/lib/channelRecencyMerge.ts
Normal file
39
desktop/src/features/channels/lib/channelRecencyMerge.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user