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
66 changes: 0 additions & 66 deletions desktop/src/features/channels/lib/channelCache.ts

This file was deleted.

44 changes: 36 additions & 8 deletions desktop/src/features/channels/useLiveChannelUpdates.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,45 @@
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";

import { updateChannelLastMessageAt } from "@/features/channels/lib/channelCache";
import { channelsQueryKey } from "@/features/channels/hooks";
import { mergeTimelineCacheMessages } from "@/features/messages/hooks";
import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys";
import { getChannelIdFromTags } from "@/features/messages/lib/threading";
import { relayClient } from "@/shared/api/relayClient";
import { CHANNEL_EVENT_KINDS } from "@/shared/constants/kinds";
import {
CHANNEL_EVENT_KINDS,
KIND_FORUM_COMMENT,
KIND_FORUM_POST,
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_V2,
} from "@/shared/constants/kinds";
import type { Channel, RelayEvent } from "@/shared/api/types";

export type UseLiveChannelUpdatesOptions = {
currentPubkey?: string;
onDmMessage?: (event: RelayEvent, channel: Channel) => void;
onLiveMention?: () => void;
/**
* Fired for live "new content" events in a member channel (chat messages,
* forum posts/comments) authored by someone other than the current user.
* Used to drive the in-session "latest message at" map that powers sidebar
* unread badges. See `UNREAD_TRIGGER_KINDS` for the exact kind set.
*/
onChannelMessage?: (channelId: string, event: RelayEvent) => void;
};

const LIVE_SUBSCRIPTION_RETRY_BASE_MS = 1_000;
const LIVE_SUBSCRIPTION_RETRY_MAX_MS = 30_000;

function getMessageTimestamp(event: RelayEvent) {
return new Date(event.created_at * 1_000).toISOString();
}
// Only "new content" kinds should bump unread state. Reactions, edits,
// diffs, deletions, and system messages can all arrive after the last
// human-visible message and would otherwise create phantom unreads.
const UNREAD_TRIGGER_KINDS = new Set<number>([
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_V2,
KIND_FORUM_POST,
KIND_FORUM_COMMENT,
]);

function isExternalMentionEvent(event: RelayEvent, currentPubkey: string) {
return (
Expand Down Expand Up @@ -135,12 +153,22 @@ export function useLiveChannelUpdates(
return;
}

// Always update the cache — even for the active channel.
// Notify the unread tracker. Restricted to human-visible message kinds
// and to events authored by someone other than the current user — your
// own outgoing messages should never make a channel unread, and
// reactions / edits / system messages aren't "new content".
if (
UNREAD_TRIGGER_KINDS.has(event.kind) &&
(normalizedCurrentPubkey.length === 0 ||
event.pubkey.toLowerCase() !== normalizedCurrentPubkey)
) {
options.onChannelMessage?.(channelId, event);
}

// Merge into the timeline cache for the active channel.
// useChannelSubscription also writes to this cache, but there's a
// race window where it hasn't connected yet. Writes are idempotent
// (mergeTimelineCacheMessages deduplicates by event ID).
const messageTimestamp = getMessageTimestamp(event);
updateChannelLastMessageAt(queryClient, channelId, messageTimestamp);
queryClient.setQueryData<RelayEvent[]>(
channelMessagesKey(channelId),
(current) => {
Expand Down
129 changes: 106 additions & 23 deletions desktop/src/features/channels/useUnreadChannels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
} from "@/features/channels/useLiveChannelUpdates";
import { useReadState } from "@/features/channels/readState/useReadState";
import type { RelayClient } from "@/shared/api/relayClientSession";
import type { Channel } from "@/shared/api/types";
import type { Channel, RelayEvent } from "@/shared/api/types";

type UseUnreadChannelsOptions = UseLiveChannelUpdatesOptions & {
pubkey?: string;
Expand Down Expand Up @@ -50,51 +50,115 @@ export function useUnreadChannels(
seedContextRead,
} = useReadState(pubkey, relayClient);

// In-session "latest message at" per channel (unix seconds), driven by the
// live subscription. The backend doesn't populate Channel.lastMessageAt, so
// unread state cannot rely on it; this map is the authoritative source for
// sidebar badges. Monotonic: only advances. Reset when the identity or
// relay changes. Stale entries for channels the user has left are silently
// ignored by the memo (it iterates the current channels list, not the map).
const latestByChannelRef = React.useRef(new Map<string, number>());

// Channels manually marked unread this session (e.g., right-click → "mark
// unread"). Tracked separately from latestByChannelRef so we don't have to
// synthesise a "latest message" timestamp and risk the corresponding read
// marker becoming sticky. Cleared when the user opens the channel.
const forcedUnreadRef = React.useRef(new Set<string>());

const [latestVersion, bumpLatestVersion] = React.useReducer(
(x: number) => x + 1,
0,
);

// Track whether channels have been initialized (for first-load seeding)
const hasInitializedChannelsRef = React.useRef(false);

// Reset the in-session state when the identity or relay changes.
// biome-ignore lint/correctness/useExhaustiveDependencies: pubkey/relayClient are intentional reset signals
React.useEffect(() => {
latestByChannelRef.current = new Map();
forcedUnreadRef.current = new Set();
hasInitializedChannelsRef.current = false;
bumpLatestVersion();
}, [pubkey, relayClient]);

const markChannelRead = React.useCallback(
(channelId: string, readAt: string | null | undefined) => {
const unixSeconds = toUnixSeconds(readAt);
if (unixSeconds === null) return;
// Reading clears any prior manual mark-unread.
if (forcedUnreadRef.current.delete(channelId)) {
bumpLatestVersion();
}
markContextRead(channelId, unixSeconds);
},
[markContextRead],
);

// Manually mark a channel unread (e.g., right-click → "mark unread"). Sets
// the in-session forced-unread flag so the sidebar badge appears immediately
// without us inventing a synthetic latest-message timestamp, and rolls the
// NIP-RS read marker back so the unread state syncs across devices. The
// forced flag is cleared when the user opens the channel (markChannelRead).
const markChannelUnread = React.useCallback(
(channelId: string, lastMessageAt: string | null | undefined) => {
if (!forcedUnreadRef.current.has(channelId)) {
forcedUnreadRef.current.add(channelId);
bumpLatestVersion();
}
const unixSeconds = toUnixSeconds(lastMessageAt);
if (unixSeconds === null) return;
markContextUnread(channelId, unixSeconds);
if (unixSeconds !== null) {
markContextUnread(channelId, unixSeconds);
}
},
[markContextUnread],
);

// Seed new channels so they don't flash as unread on first load.
// For channels the user hasn't read yet, initialize read-at to the
// channel's current lastMessageAt so they appear as "read."
// Opportunistic backfill: if Channel.lastMessageAt is ever populated by the
// backend (today it isn't), seed the in-session map. Strict max — never
// overwrites a more recent live value.
React.useEffect(() => {
if (channels.length === 0) return;
let didAdvance = false;
for (const channel of channels) {
const seedUnix = toUnixSeconds(channel.lastMessageAt);
if (seedUnix === null) continue;
const current = latestByChannelRef.current.get(channel.id) ?? 0;
if (seedUnix > current) {
latestByChannelRef.current.set(channel.id, seedUnix);
didAdvance = true;
}
}
if (didAdvance) bumpLatestVersion();
}, [channels]);

// Seed read state on first load so existing channels don't flash as unread
// when the backend reports a non-null Channel.lastMessageAt. We deliberately
// seed from the backend-provided value (not from the live map), so a live
// event that races ahead of NIP-RS readiness can't be silently swallowed as
// already-read. Today the backend always returns null and this is a no-op;
// it becomes meaningful once last_message_at is wired up server-side.
React.useEffect(() => {
if (!isReadStateReady) return;
if (channels.length === 0) return;
if (hasInitializedChannelsRef.current) return;

for (const channel of channels) {
const existing = getEffectiveTimestamp(channel.id);
if (existing !== null) continue;

// Only seed on first initialization, not when new channels appear later
if (hasInitializedChannelsRef.current) continue;

const lastMsgUnix = toUnixSeconds(channel.lastMessageAt);
if (lastMsgUnix !== null) {
seedContextRead(channel.id, lastMsgUnix);
const seedUnix = toUnixSeconds(channel.lastMessageAt);
if (seedUnix !== null) {
seedContextRead(channel.id, seedUnix);
}
}

hasInitializedChannelsRef.current = true;
}, [channels, getEffectiveTimestamp, isReadStateReady, seedContextRead]);

// Mark the active channel as read when it changes or new messages arrive
// Mark the active channel as read when it changes or new messages arrive.
// Honours the caller's contract that a null activeReadAt suppresses
// read-marking until the timeline reports a real position. Manual
// mark-unread state is cleared inside markChannelRead, not here.
React.useEffect(() => {
if (!isReadStateReady) return;
if (!activeChannelId) return;
Expand All @@ -106,14 +170,31 @@ export function useUnreadChannels(
markChannelRead,
]);

// Keep live channel updates (drives channel.lastMessageAt cache updates)
useLiveChannelUpdates(channels, activeChannelId, liveUpdateOptions);
// Feed the in-session "latest message at" map from live channel events.
// Composes with any caller-supplied onChannelMessage handler.
const callerOnChannelMessage = liveUpdateOptions.onChannelMessage;
const handleChannelMessage = React.useCallback(
(channelId: string, event: RelayEvent) => {
const current = latestByChannelRef.current.get(channelId) ?? 0;
if (event.created_at > current) {
latestByChannelRef.current.set(channelId, event.created_at);
bumpLatestVersion();
}
callerOnChannelMessage?.(channelId, event);
},
[callerOnChannelMessage],
);

useLiveChannelUpdates(channels, activeChannelId, {
...liveUpdateOptions,
onChannelMessage: handleChannelMessage,
});

// Compute unread channel IDs by comparing channel.lastMessageAt against
// the NIP-RS effective timestamp.
// readStateVersion is intentionally included to force recomputation when
// cross-device state arrives (getEffectiveTimestamp is referentially stable).
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion is an intentional invalidation signal
// Unread = channels (excluding active) that have either been manually
// marked unread this session, or whose in-session latest message timestamp
// is strictly newer than their NIP-RS read marker.
// readStateVersion and latestVersion are intentional invalidation signals.
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion and latestVersion are intentional invalidation signals
const unreadChannelIds = React.useMemo(() => {
if (!isReadStateReady) {
return new Set<string>();
Expand All @@ -123,11 +204,12 @@ export function useUnreadChannels(
channels
.filter((channel) => channel.id !== activeChannelId)
.filter((channel) => {
const lastMsgUnix = toUnixSeconds(channel.lastMessageAt);
if (lastMsgUnix === null) return false;
if (forcedUnreadRef.current.has(channel.id)) return true;
const latest = latestByChannelRef.current.get(channel.id);
if (latest === undefined) return false;

const readAt = getEffectiveTimestamp(channel.id);
return readAt === null || lastMsgUnix > readAt;
return readAt === null || latest > readAt;
})
.map((channel) => channel.id),
);
Expand All @@ -136,6 +218,7 @@ export function useUnreadChannels(
channels,
getEffectiveTimestamp,
isReadStateReady,
latestVersion,
readStateVersion,
]);

Expand Down
14 changes: 0 additions & 14 deletions desktop/src/features/messages/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useEffect, useEffectEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";

import { updateChannelLastMessageAt } from "@/features/channels/lib/channelCache";
import {
channelMessagesKey,
dedupeMessagesById,
Expand Down Expand Up @@ -179,11 +178,6 @@ export function useChannelSubscription(channel: Channel | null) {
return;
}

updateChannelLastMessageAt(
queryClient,
channelId,
new Date(event.created_at * 1_000).toISOString(),
);
queryClient.setQueryData<RelayEvent[]>(
channelMessagesKey(channelId),
(current = []) => mergeTimelineCacheMessages(current, event),
Expand Down Expand Up @@ -400,14 +394,6 @@ export function useSendMessageMutation(
queryClient.setQueryData(context.queryKey, context.previousMessages);
},
onSuccess: (message, _variables, context) => {
if (channel) {
updateChannelLastMessageAt(
queryClient,
channel.id,
new Date(message.created_at * 1_000).toISOString(),
);
}

if (!context) {
return;
}
Expand Down
Loading
Loading