feat(mobile+desktop): two-tier Slack-style app icon badge (#802)

This commit is contained in:
Will Pfleger
2026-06-03 21:16:49 -04:00
committed by GitHub
parent 0c225f4d7d
commit 3b78dc5690
40 changed files with 1703 additions and 246 deletions

View File

@@ -235,26 +235,60 @@ just desktop-screenshot --name search --click open-search
just desktop-screenshot --name settings --click open-settings
```
Options: `--name` (filename), `--route` (client route), `--click` (data-testid
or CSS selector), `--wait` (ms, default 2000), `--viewport` (WxH, default
1280x720), `--outdir` (default `test-results/screenshots`),
`--messages` (JSON file path). Output is a PNG path on stdout.
Options: `--name` (filename), `--route` (client route), `--active-channel`
(channel to view), `--click` (left-click data-testid or CSS selector),
`--right-click` (right-click for context menus), `--hover` (hover before
capture), `--clip` (crop region as `x,y,w,h` — e.g. `0,0,256,720` for sidebar
only), `--wait` (ms, default 2000), `--viewport` (WxH, default 1280x720),
`--outdir` (default `test-results/screenshots`), `--messages` (JSON file path).
Output is a PNG path on stdout.
Use `--messages` to inject content into a channel before capture. The JSON file
is an array of `{ channelName, content, pubkey?, kind? }` objects — all must
target the same channel (`[a-z0-9-]+`). When provided, `--route` is ignored.
```bash
just desktop-screenshot --name code-blocks --messages /tmp/msgs.json
```
is an array of objects — `channelName` and `content` are required, all other
fields are optional and passed through to `__SPROUT_E2E_EMIT_MOCK_MESSAGE__`:
```json
[
{ "channelName": "general", "content": "```typescript\nconst x = 42;\n```" },
{ "channelName": "general", "content": "plain text message" }
{
"channelName": "random",
"content": "Hey @tyler check this out",
"pubkey": "953d...",
"kind": 40002,
"mentionPubkeys": ["deadbeef..."],
"extraTags": [["broadcast", "1"], ["e", "some-root-id"]],
"parentEventId": "abc123"
}
]
```
Without `--active-channel`, all messages must target the same channel and the
helper navigates to that channel (useful for showing message content). With
`--active-channel`, messages can target multiple channels while the "camera"
stays on the specified channel (useful for unread indicators, badges, etc.).
```bash
# Messages in the channel you're viewing (code blocks, formatting, etc.)
just desktop-screenshot --name code-blocks --messages /tmp/msgs.json
# Messages in OTHER channels to trigger unread state
just desktop-screenshot --name unread-dot \
--active-channel general --messages /tmp/badge-msgs.json
# Cropped to sidebar only (256px wide)
just desktop-screenshot --name sidebar-unread \
--active-channel general --messages /tmp/badge-msgs.json \
--clip 0,0,256,720
# Context menu on an unread channel (wider crop to include popup)
just desktop-screenshot --name ctx-mark-read \
--active-channel general --messages /tmp/badge-msgs.json \
--right-click channel-random --clip 0,200,320,300
# Hover state (e.g. copy button reveal)
just desktop-screenshot --name copy-hover \
--messages /tmp/code-msgs.json --hover "[data-testid='copy-code']"
```
Available mock channels: `general`, `random`, `design`, `sales`, `engineering`,
`agents`, `watercooler`, `announcements`, `alice-tyler`, `bob-tyler`.
@@ -266,6 +300,23 @@ Available mock channels: `general`, `random`, `design`, `sales`, `engineering`,
./scripts/post-screenshots.sh 803 test-results/screenshots body.md # custom body prepended
```
The body file supports `{{filename}}` placeholders (without `.png`) to inline
images at specific positions. Images not referenced by any placeholder are
appended at the end. Without placeholders, all images are appended (backward
compatible).
```markdown
### Unread dot
A message arrives in `#random`.
{{01-unread-dot}}
### Context menu
Right-click shows "Mark as read".
{{02-context-menu}}
```
Re-runs for the same PR overwrite previous images. Cleanup:
`git push origin --delete agent-screenshots/<username>`.

View File

@@ -56,14 +56,17 @@ pub(crate) async fn dispatch_persistent_event(
) -> usize {
let event_id_hex = stored_event.event.id.to_hex();
if let Some(ch_id) = stored_event.channel_id {
state.mark_local_event(&stored_event.event.id);
if let Err(e) = state.pubsub.publish_event(ch_id, &stored_event.event).await {
state
.local_event_ids
.invalidate(&stored_event.event.id.to_bytes());
warn!(event_id = %event_id_hex, "Redis publish failed: {e}");
}
let pubsub_channel = stored_event.channel_id.unwrap_or(uuid::Uuid::nil());
state.mark_local_event(&stored_event.event.id);
if let Err(e) = state
.pubsub
.publish_event(pubsub_channel, &stored_event.event)
.await
{
state
.local_event_ids
.invalidate(&stored_event.event.id.to_bytes());
warn!(event_id = %event_id_hex, "Redis publish failed: {e}");
}
let matches = state.sub_registry.fan_out(stored_event);

View File

@@ -21,6 +21,7 @@ export default defineConfig({
testMatch: [
"**/smoke.spec.ts",
"**/channels.spec.ts",
"**/badge.spec.ts",
"**/channel-browser.spec.ts",
"**/messaging.spec.ts",
"**/custom-emoji.spec.ts",

View File

@@ -7,6 +7,7 @@
"core:default",
"core:webview:allow-set-webview-zoom",
"core:window:allow-set-badge-count",
"core:window:allow-set-badge-label",
"core:window:allow-request-user-attention",
"core:window:allow-set-focus",
"core:window:allow-start-dragging",

View File

@@ -19,15 +19,26 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result<Vec<ChannelInfo>
// Step 1: find all kind:39002 (members) events that mention me, then
// pull the channel ids out of their `d` tags.
let member_events = query_relay(
&state,
&[serde_json::json!({
"kinds": [39002],
"#p": [my_pubkey],
"limit": 1000,
})],
)
.await?;
let member_events = {
let mut all = Vec::new();
let mut until: Option<u64> = None;
loop {
let mut f = serde_json::json!({"kinds": [39002], "#p": [&my_pubkey], "limit": 500});
if let Some(u) = until {
f["until"] = serde_json::json!(u);
}
let page = query_relay(&state, &[f]).await?;
let done = page.len() < 500;
if let Some(t) = page.iter().map(|e| e.created_at.as_secs()).min() {
until = Some(t.saturating_sub(1));
}
all.extend(page);
if done {
break;
}
}
all
};
let mut channel_ids: Vec<String> = member_events
.iter()

View File

@@ -32,7 +32,7 @@ import {
requestDockBounce,
revealDesktopAppWindow,
sendDesktopNotification,
setDesktopAppBadgeCount,
setDesktopAppBadge,
type DesktopNotificationTarget,
} from "@/features/notifications/lib/desktop";
import { playNotificationSound } from "@/features/notifications/lib/sound";
@@ -289,6 +289,7 @@ export function AppShell() {
markChannelRead,
markChannelUnread,
unreadChannelIds,
highPriorityUnreadChannelIds,
getEffectiveTimestamp: getChannelReadAt,
readStateVersion,
participatedRootIds,
@@ -319,16 +320,18 @@ export function AppShell() {
// ReadStateManager mounted via useUnreadChannels above. Channel-backed
// feed items contribute to the badge iff strictly newer than that
// channel's read marker; non-channel items keep their seen-set fallback.
const homeBadgeCount = useHomeFeedNotificationState(
homeFeedQuery.data,
identityQuery.data?.pubkey,
notificationSettings.settings,
notificationSettings.setDesktopEnabled,
selectedView === "home",
getChannelReadAt,
readStateVersion,
feedProfilesQuery.data?.profiles,
);
const { homeBadgeCount, homeBadgeCountExcludingHighPriority } =
useHomeFeedNotificationState(
homeFeedQuery.data,
identityQuery.data?.pubkey,
notificationSettings.settings,
notificationSettings.setDesktopEnabled,
selectedView === "home",
getChannelReadAt,
readStateVersion,
highPriorityUnreadChannelIds,
feedProfilesQuery.data?.profiles,
);
const isNotifiedForThread = React.useCallback(
(rootId: string) =>
@@ -492,8 +495,20 @@ export function AppShell() {
}, []);
React.useEffect(() => {
void setDesktopAppBadgeCount(unreadChannelIds.size + homeBadgeCount);
}, [homeBadgeCount, unreadChannelIds.size]);
const numericCount =
highPriorityUnreadChannelIds.size + homeBadgeCountExcludingHighPriority;
if (numericCount > 0) {
void setDesktopAppBadge({ kind: "count", count: numericCount });
} else if (unreadChannelIds.size > 0) {
void setDesktopAppBadge({ kind: "dot" });
} else {
void setDesktopAppBadge({ kind: "none" });
}
}, [
homeBadgeCountExcludingHighPriority,
highPriorityUnreadChannelIds.size,
unreadChannelIds.size,
]);
// Dispatch `sprout://message` deep links into the router.
useMessageDeepLinks();

View File

@@ -18,6 +18,10 @@ export function localPublishableContextKey(pubkey: string): string {
return `sprout.channel-read-state.publishable.v1:${pubkey}`;
}
export function localSourceCreatedAtKey(pubkey: string): string {
return `sprout.channel-read-state.source-created-at.v1:${pubkey}`;
}
export function isPlainRecord(
value: unknown,
): value is Record<string, unknown> {

View File

@@ -52,27 +52,16 @@ export class ReadStateManager {
private relayClient: RelayClient;
private clientId: string;
private slotId: string;
// Effective merged state across all blobs
private effectiveState = new Map<string, number>();
// Contexts this client is allowed to include in its published blob. This
// excludes first-load local baselines for channels the user has not opened.
private publishableContextIds = new Set<string>();
// Last-published blob content (for diff to suppress no-op publishes)
private lastPublishedContexts: Record<string, number> = {};
private debounceTimer: number | null = null;
private listeners = new Set<() => void>();
private unsubscribeLive: (() => void) | null = null;
private initialized = false;
// Track the max created_at seen from fetched blobs so publishes can satisfy
// NIP-RS clock-skew monotonicity for our d-tag coordinate.
private maxFetchedCreatedAt = 0;
// Contexts rolled back by mark-unread, protected from merge until next publish.
private forcedContexts = new Set<string>();
private contextSourceCreatedAt = new Map<string, number>();
constructor(pubkey: string, relayClient: RelayClient) {
this.pubkey = pubkey;
@@ -103,6 +92,10 @@ export class ReadStateManager {
markContextRead(contextId: string, unixTimestamp: number): void {
this.forcedContexts.delete(contextId);
this.advanceContext(contextId, unixTimestamp, { publishable: true });
this.contextSourceCreatedAt.set(
contextId,
Math.max(Math.floor(Date.now() / 1_000), this.maxFetchedCreatedAt + 1),
);
}
seedContextRead(contextId: string, unixTimestamp: number): void {
@@ -110,9 +103,6 @@ export class ReadStateManager {
}
markContextUnread(contextId: string, lastMessageUnix: number): void {
// Roll back the read timestamp to just before the last message so the
// channel appears unread. This is published via NIP-RS and syncs across
// devices.
const rollbackTo = lastMessageUnix - 1;
this.effectiveState.set(contextId, rollbackTo);
this.publishableContextIds.add(contextId);
@@ -128,7 +118,6 @@ export class ReadStateManager {
options: { publishable: boolean },
): void {
const current = this.effectiveState.get(contextId) ?? 0;
// Monotonic: only advance, never lower
if (unixTimestamp <= current) {
if (!options.publishable || this.publishableContextIds.has(contextId)) {
return;
@@ -203,16 +192,13 @@ export class ReadStateManager {
let ownBlobCreatedAt = 0;
for (const event of events) {
// Only process our own events
if (event.pubkey !== this.pubkey) continue;
// Validate d tag (exactly one, with correct prefix)
const dTags = event.tags.filter((t) => t[0] === "d");
if (dTags.length !== 1) continue;
const dTag = dTags[0];
if (!isValidReadStateDTag(dTag[1])) continue;
// Validate t tag (exactly one)
const tTags = event.tags.filter(
(t) => t[0] === "t" && t[1] === "read-state",
);
@@ -237,17 +223,19 @@ export class ReadStateManager {
continue;
}
// Merge into effective state
for (const [ctx, ts] of Object.entries(blob.contexts)) {
if (this.forcedContexts.has(ctx)) continue;
const sourceCreatedAt = this.contextSourceCreatedAt.get(ctx) ?? 0;
const current = this.effectiveState.get(ctx) ?? 0;
if (ts > current) {
if (event.created_at > sourceCreatedAt) {
this.effectiveState.set(ctx, ts);
this.contextSourceCreatedAt.set(ctx, event.created_at);
} else if (event.created_at === sourceCreatedAt && ts !== current) {
this.effectiveState.set(ctx, ts);
}
this.publishableContextIds.add(ctx);
}
// Track own blob (most recent event for our client_id)
if (blob.client_id === this.clientId) {
if (event.created_at > ownBlobCreatedAt) {
ownBlob = blob;
@@ -307,13 +295,11 @@ export class ReadStateManager {
private async handleIncomingEvent(event: RelayEvent): Promise<void> {
if (event.pubkey !== this.pubkey) return;
// Validate d tag (exactly one, with correct prefix)
const dTags = event.tags.filter((t) => t[0] === "d");
if (dTags.length !== 1) return;
const dTag = dTags[0];
if (!isValidReadStateDTag(dTag[1])) return;
// Validate t tag (exactly one)
const tTags = event.tags.filter(
(t) => t[0] === "t" && t[1] === "read-state",
);
@@ -338,16 +324,25 @@ export class ReadStateManager {
return;
}
// Merge into effective state
let anyAdvanced = false;
for (const [ctx, ts] of Object.entries(blob.contexts)) {
if (this.forcedContexts.has(ctx)) continue;
const sourceCreatedAt = this.contextSourceCreatedAt.get(ctx) ?? 0;
const current = this.effectiveState.get(ctx) ?? 0;
if (ts > current) {
if (event.created_at > sourceCreatedAt) {
if (this.effectiveState.get(ctx) !== ts) {
this.effectiveState.set(ctx, ts);
anyAdvanced = true;
}
this.contextSourceCreatedAt.set(ctx, event.created_at);
} else if (event.created_at === sourceCreatedAt && ts !== current) {
this.effectiveState.set(ctx, ts);
anyAdvanced = true;
}
this.publishableContextIds.add(ctx);
if (!this.publishableContextIds.has(ctx)) {
this.publishableContextIds.add(ctx);
anyAdvanced = true;
}
}
if (anyAdvanced) {
@@ -416,6 +411,9 @@ export class ReadStateManager {
this.lastPublishedContexts = contexts;
this.forcedContexts.clear();
for (const key of Object.keys(contexts)) {
this.contextSourceCreatedAt.set(key, createdAt);
}
this.maxFetchedCreatedAt = Math.max(
this.maxFetchedCreatedAt,
event.created_at,
@@ -473,6 +471,9 @@ export class ReadStateManager {
for (const contextId of stored.publishableContextIds) {
this.publishableContextIds.add(contextId);
}
for (const [contextId, createdAt] of stored.contextSourceCreatedAt) {
this.contextSourceCreatedAt.set(contextId, createdAt);
}
this.persistLocalState();
}
@@ -481,6 +482,7 @@ export class ReadStateManager {
this.pubkey,
this.effectiveState,
this.publishableContextIds,
this.contextSourceCreatedAt,
);
}

View File

@@ -3,11 +3,13 @@ import {
localIsoToUnixSeconds,
localPublishableContextKey,
localReadStateKey,
localSourceCreatedAtKey,
} from "@/features/channels/readState/readStateFormat";
export type StoredReadState = {
contexts: Map<string, number>;
publishableContextIds: Set<string>;
contextSourceCreatedAt: Map<string, number>;
};
function mergeLocalStorageKey(
@@ -55,6 +57,27 @@ function readPublishableContextIds(pubkey: string): Set<string> {
return result;
}
function readContextSourceCreatedAt(pubkey: string): Map<string, number> {
const result = new Map<string, number>();
const raw = localStorage.getItem(localSourceCreatedAtKey(pubkey));
if (!raw) return result;
try {
const parsed = JSON.parse(raw);
if (!isPlainRecord(parsed)) return result;
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === "number" && Number.isInteger(value) && value >= 0) {
result.set(key, value);
}
}
} catch {
// Corrupt localStorage, ignore.
}
return result;
}
export function readStoredReadState(pubkey: string): StoredReadState {
const contexts = new Map<string, number>();
mergeLocalStorageKey(contexts, localReadStateKey(pubkey));
@@ -62,6 +85,7 @@ export function readStoredReadState(pubkey: string): StoredReadState {
return {
contexts,
publishableContextIds: readPublishableContextIds(pubkey),
contextSourceCreatedAt: readContextSourceCreatedAt(pubkey),
};
}
@@ -69,6 +93,7 @@ export function writeStoredReadState(
pubkey: string,
contexts: ReadonlyMap<string, number>,
publishableContextIds: ReadonlySet<string>,
contextSourceCreatedAt: ReadonlyMap<string, number>,
): void {
const state: Record<string, string> = {};
for (const [contextId, timestamp] of contexts) {
@@ -80,4 +105,13 @@ export function writeStoredReadState(
localPublishableContextKey(pubkey),
JSON.stringify([...publishableContextIds]),
);
const sourceState: Record<string, number> = {};
for (const [contextId, createdAt] of contextSourceCreatedAt) {
sourceState[contextId] = createdAt;
}
localStorage.setItem(
localSourceCreatedAtKey(pubkey),
JSON.stringify(sourceState),
);
}

View File

@@ -173,7 +173,6 @@ export function useLiveChannelUpdates(
// reactions / edits / system messages aren't "new content".
if (
UNREAD_TRIGGER_KINDS.has(event.kind) &&
channelId !== activeChannelId &&
(normalizedCurrentPubkey.length === 0 ||
event.pubkey.toLowerCase() !== normalizedCurrentPubkey) &&
shouldNotifyForEvent(
@@ -186,11 +185,13 @@ export function useLiveChannelUpdates(
)
) {
options.onChannelMessage?.(channelId, event);
const ref = getThreadReference(event.tags);
const isThreadReply =
ref.parentId !== null && !isBroadcastReply(event.tags);
if (isThreadReply) {
options.onThreadReplyNotification?.(channelId, event);
if (channelId !== activeChannelId) {
const ref = getThreadReference(event.tags);
const isThreadReply =
ref.parentId !== null && !isBroadcastReply(event.tags);
if (isThreadReply) {
options.onThreadReplyNotification?.(channelId, event);
}
}
}

View File

@@ -9,7 +9,10 @@ import {
getThreadReference,
isBroadcastReply,
} from "@/features/messages/lib/threading";
import { shouldNotifyForEvent } from "@/features/notifications/lib/shouldNotify";
import {
shouldNotifyForEvent,
isHighPriorityEventForUser,
} from "@/features/notifications/lib/shouldNotify";
import type { RelayClient } from "@/shared/api/relayClientSession";
import type { Channel, RelayEvent } from "@/shared/api/types";
import { CHANNEL_MESSAGE_EVENT_KINDS } from "@/shared/constants/kinds";
@@ -209,6 +212,14 @@ function toUnixSeconds(isoOrMs: string | null | undefined): number | null {
return ms === null ? null : Math.floor(ms / 1_000);
}
function setsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
if (a.size !== b.size) return false;
for (const item of a) {
if (!b.has(item)) return false;
}
return true;
}
export function useUnreadChannels(
channels: Channel[],
activeChannel: Channel | null,
@@ -241,6 +252,12 @@ export function useUnreadChannels(
// change. 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>());
const latestHighPriorityByChannelRef = React.useRef(
new Map<string, number>(),
);
const channelsRef = React.useRef(channels);
channelsRef.current = channels;
// Channels manually marked unread this session (e.g., right-click → "mark
// unread"). The NIP-RS rollback (markContextUnread) is the cross-device
@@ -281,6 +298,7 @@ export function useUnreadChannels(
// biome-ignore lint/correctness/useExhaustiveDependencies: pubkey/relayClient are intentional reset signals
React.useEffect(() => {
latestByChannelRef.current = new Map();
latestHighPriorityByChannelRef.current = new Map();
forcedUnreadRef.current = new Set();
caughtUpChannelsRef.current = new Set();
participatedRootIdsRef.current = pubkey
@@ -296,16 +314,25 @@ export function useUnreadChannels(
const markChannelRead = React.useCallback(
(channelId: string, readAt: string | null | undefined) => {
const unixSeconds =
toUnixSeconds(readAt) ??
latestByChannelRef.current.get(channelId) ??
null;
if (unixSeconds === null) return;
// Reading clears any prior manual mark-unread.
if (forcedUnreadRef.current.delete(channelId)) {
bumpLatestVersion();
}
const callerUnix = toUnixSeconds(readAt);
const observedLatest = latestByChannelRef.current.get(channelId);
const unixSeconds =
Math.max(callerUnix ?? 0, observedLatest ?? 0) || null;
if (unixSeconds === null) return;
markContextRead(channelId, unixSeconds);
// Clear observed-latest refs when the read marker covers them so the
// unread memo sees `latest === undefined` until a genuinely new event
// arrives. Without this, `latest > readAt` resolves to `T > T` (false)
// but the channel lingers in the set when advanceContext's monotonic
// guard suppresses the readStateVersion bump.
if (observedLatest !== undefined && observedLatest <= unixSeconds) {
latestByChannelRef.current.delete(channelId);
latestHighPriorityByChannelRef.current.delete(channelId);
bumpLatestVersion();
}
},
[markContextRead],
);
@@ -362,9 +389,28 @@ export function useUnreadChannels(
latestByChannelRef.current.set(channelId, event.created_at);
bumpLatestVersion();
}
// Track high-priority events (DMs, mentions, broadcasts) separately.
const channel = channelsRef.current.find((ch) => ch.id === channelId);
if (
channel?.channelType === "dm" ||
(normalizedPubkey !== null &&
isHighPriorityEventForUser(event, normalizedPubkey))
) {
const currentHigh =
latestHighPriorityByChannelRef.current.get(channelId) ?? 0;
if (event.created_at > currentHigh) {
latestHighPriorityByChannelRef.current.set(
channelId,
event.created_at,
);
bumpLatestVersion();
}
}
callerOnChannelMessage?.(channelId, event);
},
[callerOnChannelMessage],
[callerOnChannelMessage, normalizedPubkey],
);
const handleSelfChannelMessage = React.useCallback(
@@ -492,6 +538,7 @@ export function useUnreadChannels(
channelId: string;
ok: true;
maxExternal: number;
maxHighPriority: number;
threadReplies: ThreadActivityItem[];
}
| { channelId: string; ok: false };
@@ -542,8 +589,11 @@ export function useUnreadChannels(
// Pass 2: compute maxExternal and collect thread reply activity,
// applying the notification filter to both.
let maxExternal = 0;
let maxHighPriority = 0;
const threadReplies: ThreadActivityItem[] = [];
const chName = channels.find((ch) => ch.id === channelId)?.name ?? "";
const ch = channels.find((c) => c.id === channelId);
const chType = ch?.channelType;
const chName = ch?.name ?? "";
for (const event of events) {
if (
normalizedPubkey !== null &&
@@ -567,6 +617,15 @@ export function useUnreadChannels(
if (event.created_at > maxExternal) {
maxExternal = event.created_at;
}
if (
chType === "dm" ||
(normalizedPubkey !== null &&
isHighPriorityEventForUser(event, normalizedPubkey))
) {
if (event.created_at > maxHighPriority) {
maxHighPriority = event.created_at;
}
}
const evtRef = getThreadReference(event.tags);
if (evtRef.parentId !== null && !isBroadcastReply(event.tags)) {
threadReplies.push({
@@ -582,7 +641,13 @@ export function useUnreadChannels(
}
}
return { channelId, ok: true, maxExternal, threadReplies };
return {
channelId,
ok: true,
maxExternal,
maxHighPriority,
threadReplies,
};
} catch {
// Transient relay failure for this channel — release the claim
// so we retry on the next effect run instead of staying stuck
@@ -599,13 +664,32 @@ export function useUnreadChannels(
caughtUpChannelsRef.current.delete(result.channelId);
continue;
}
const { channelId, maxExternal, threadReplies } = result;
const { channelId, maxExternal, maxHighPriority, threadReplies } =
result;
allThreadReplies.push(...threadReplies);
if (maxExternal === 0) continue;
const current = latestByChannelRef.current.get(channelId) ?? 0;
if (maxExternal > current) {
latestByChannelRef.current.set(channelId, maxExternal);
didAdvance = true;
if (maxExternal > 0) {
const readAtNow = getEffectiveTimestamp(channelId) ?? 0;
if (maxExternal > readAtNow) {
const current = latestByChannelRef.current.get(channelId) ?? 0;
if (maxExternal > current) {
latestByChannelRef.current.set(channelId, maxExternal);
didAdvance = true;
}
}
}
if (maxHighPriority > 0) {
const readAtNow = getEffectiveTimestamp(channelId) ?? 0;
if (maxHighPriority > readAtNow) {
const currentHigh =
latestHighPriorityByChannelRef.current.get(channelId) ?? 0;
if (maxHighPriority > currentHigh) {
latestHighPriorityByChannelRef.current.set(
channelId,
maxHighPriority,
);
didAdvance = true;
}
}
}
}
if (allThreadReplies.length > 0) {
@@ -649,34 +733,90 @@ export function useUnreadChannels(
// Unread = channels (excluding active) that have either been manually
// marked unread this session, or whose observed latest external trigger
// 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>();
}
// High-priority unread = DMs or channels with a mention/broadcast newer
// than the read marker. Forced-unread channels are dot tier only (not
// high-priority). Both sets share identical deps and always invalidate
// together, so they are computed in a single memo.
const rawUnread =
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion and latestVersion are intentional invalidation signals
React.useMemo(() => {
if (!isReadStateReady) {
return {
unreadChannelIds: new Set<string>(),
highPriorityUnreadChannelIds: new Set<string>(),
};
}
return new Set(
channels
.filter((channel) => channel.id !== activeChannelId)
.filter((channel) => {
if (forcedUnreadRef.current.has(channel.id)) return true;
const latest = latestByChannelRef.current.get(channel.id);
if (latest === undefined) return false;
const unread = new Set<string>();
const highPriority = new Set<string>();
const readAt = getEffectiveTimestamp(channel.id);
return readAt === null || latest > readAt;
})
.map((channel) => channel.id),
);
}, [
activeChannelId,
channels,
getEffectiveTimestamp,
isReadStateReady,
latestVersion,
readStateVersion,
]);
for (const channel of channels) {
if (channel.id === activeChannelId) continue;
if (forcedUnreadRef.current.has(channel.id)) {
// Forced-unread is dot tier only — not high-priority.
unread.add(channel.id);
continue;
}
const latest = latestByChannelRef.current.get(channel.id);
if (latest === undefined) continue;
const readAt = getEffectiveTimestamp(channel.id);
if (readAt !== null && latest <= readAt) continue;
unread.add(channel.id);
// DM channels: any unread DM is high-priority.
if (channel.channelType === "dm") {
highPriority.add(channel.id);
} else {
// Non-DM: high-priority only if there's a mention/broadcast newer than read marker.
const latestHigh = latestHighPriorityByChannelRef.current.get(
channel.id,
);
if (
latestHigh !== undefined &&
(readAt === null || latestHigh > readAt)
) {
highPriority.add(channel.id);
}
}
}
return {
unreadChannelIds: unread,
highPriorityUnreadChannelIds: highPriority,
};
}, [
activeChannelId,
channels,
getEffectiveTimestamp,
isReadStateReady,
latestVersion,
readStateVersion,
]);
// Stabilize Set references: only replace when contents actually change,
// so downstream memos don't re-run on every render when sets are equal.
const prevUnreadRef = React.useRef<ReadonlySet<string>>(new Set());
const prevHighPriorityRef = React.useRef<ReadonlySet<string>>(new Set());
const unreadChannelIds = setsEqual(
rawUnread.unreadChannelIds,
prevUnreadRef.current,
)
? prevUnreadRef.current
: rawUnread.unreadChannelIds;
prevUnreadRef.current = unreadChannelIds;
const highPriorityUnreadChannelIds = setsEqual(
rawUnread.highPriorityUnreadChannelIds,
prevHighPriorityRef.current,
)
? prevHighPriorityRef.current
: rawUnread.highPriorityUnreadChannelIds;
prevHighPriorityRef.current = highPriorityUnreadChannelIds;
const unreadChannelIdsRef = React.useRef(unreadChannelIds);
unreadChannelIdsRef.current = unreadChannelIds;
@@ -691,12 +831,15 @@ export function useUnreadChannels(
if (unixSeconds !== null) {
markContextRead(channelId, unixSeconds);
}
latestByChannelRef.current.delete(channelId);
latestHighPriorityByChannelRef.current.delete(channelId);
}
bumpLatestVersion();
}, [getEffectiveTimestamp, markContextRead]);
return {
unreadChannelIds,
highPriorityUnreadChannelIds,
markAllChannelsRead,
markChannelRead,
markChannelUnread,

View File

@@ -268,6 +268,7 @@ export function useHomeFeedNotificationState(
// Invalidation signal for the channel-marker projection; bump triggers
// recompute. Pass 0 to opt out.
readStateVersion: number,
highPriorityChannelIds: ReadonlySet<string>,
profiles?: UserProfileLookup,
) {
useFeedDesktopNotifications(
@@ -313,31 +314,43 @@ export function useHomeFeedNotificationState(
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion invalidates getChannelReadAt
return React.useMemo(() => {
const zero = { homeBadgeCount: 0, homeBadgeCountExcludingHighPriority: 0 };
if (!settings.homeBadgeEnabled || isHomeActive) {
return 0;
return zero;
}
if (currentFeedItems.length === 0) {
return 0;
return zero;
}
const seenFeedIdSet = new Set(seenFeedIds);
return currentFeedItems.filter((item) => {
let total = 0;
let excludingHighPriority = 0;
for (const item of currentFeedItems) {
let isUnread: boolean;
if (item.channelId) {
// Channel-backed items: trust the NIP-RS marker when we have one.
// If the channel has no marker yet (cold start, mock mode without a
// relay client), fall back to the local seen-set so a freshly-seen
// feed item doesn't keep tripping the badge forever.
const readAt = getChannelReadAt(item.channelId);
if (readAt !== null) {
return item.createdAt > readAt;
}
isUnread =
readAt !== null
? item.createdAt > readAt
: !seenFeedIdSet.has(item.id);
} else {
isUnread = !seenFeedIdSet.has(item.id);
}
return !seenFeedIdSet.has(item.id);
}).length;
if (!isUnread) continue;
total++;
if (!(item.channelId && highPriorityChannelIds.has(item.channelId))) {
excludingHighPriority++;
}
}
return {
homeBadgeCount: total,
homeBadgeCountExcludingHighPriority: excludingHighPriority,
};
}, [
currentFeedItems,
getChannelReadAt,
highPriorityChannelIds,
isHomeActive,
readStateVersion,
seenFeedIds,

View File

@@ -5,11 +5,17 @@ import {
onAction,
requestPermission,
} from "@tauri-apps/plugin-notification";
import { isMacPlatform } from "@/shared/lib/platform";
export type DesktopNotificationPermissionState =
| NotificationPermission
| "unsupported";
export type AppBadgeState =
| { kind: "none" }
| { kind: "dot" }
| { kind: "count"; count: number };
export type DesktopNotificationTarget = {
channelId: string | null;
channelName?: string | null;
@@ -35,6 +41,7 @@ type DesktopNotificationOptions = NotificationOptions & {
type TestWindow = Window & {
__SPROUT_E2E_APP_BADGE_COUNT__?: number;
__SPROUT_E2E_APP_BADGE_STATE__?: AppBadgeState["kind"];
};
function hasNotificationApi() {
@@ -192,9 +199,12 @@ export async function listenForDesktopNotificationActions(
};
}
export async function setDesktopAppBadgeCount(count: number): Promise<void> {
export async function setDesktopAppBadge(state: AppBadgeState): Promise<void> {
if (typeof window !== "undefined") {
(window as TestWindow).__SPROUT_E2E_APP_BADGE_COUNT__ = count;
const testWindow = window as TestWindow;
testWindow.__SPROUT_E2E_APP_BADGE_COUNT__ =
state.kind === "count" ? state.count : 0;
testWindow.__SPROUT_E2E_APP_BADGE_STATE__ = state.kind;
}
if (!isTauri()) {
@@ -202,7 +212,16 @@ export async function setDesktopAppBadgeCount(count: number): Promise<void> {
}
try {
await getCurrentWindow().setBadgeCount(count > 0 ? count : undefined);
if (state.kind === "count") {
await getCurrentWindow().setBadgeCount(state.count);
} else if (state.kind === "dot" && isMacPlatform()) {
await getCurrentWindow().setBadgeLabel(" ");
} else {
if (isMacPlatform()) {
await getCurrentWindow().setBadgeLabel("");
}
await getCurrentWindow().setBadgeCount(undefined);
}
} catch {
// Ignore unsupported platforms and best-effort badge sync failures.
}

View File

@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import { shouldNotifyForEvent } from "./shouldNotify.ts";
import {
isHighPriorityEventForUser,
shouldNotifyForEvent,
} from "./shouldNotify.ts";
// ── Fixtures ─────────────────────────────────────────────────────────────────
@@ -317,3 +320,36 @@ test("empty currentPubkey with participated thread still notifies (no mute)", ()
true,
);
});
// ── isHighPriorityEventForUser ────────────────────────────────────────────────
test("isHighPriorityEventForUser returns true when p-tag matches currentPubkey", () => {
const event = makeEvent([replyTag(ROOT_ID), pTag(PUBKEY)]);
assert.equal(isHighPriorityEventForUser(event, PUBKEY), true);
});
test("isHighPriorityEventForUser returns true for broadcast reply", () => {
const event = makeEvent([replyTag(ROOT_ID), broadcastTag()]);
assert.equal(isHighPriorityEventForUser(event, PUBKEY), true);
});
test("isHighPriorityEventForUser returns false when no matching p-tag and no broadcast tag", () => {
const event = makeEvent([replyTag(ROOT_ID), pTag(OTHER_PUBKEY)]);
assert.equal(isHighPriorityEventForUser(event, PUBKEY), false);
});
test("isHighPriorityEventForUser p-tag matching is case-insensitive", () => {
const event = makeEvent([replyTag(ROOT_ID), pTag(PUBKEY.toUpperCase())]);
assert.equal(isHighPriorityEventForUser(event, PUBKEY), true);
});
test("isHighPriorityEventForUser returns false when currentPubkey is empty", () => {
// Short-circuits before p-tag check; broadcast absent so also false
const event = makeEvent([replyTag(ROOT_ID), pTag(PUBKEY)]);
assert.equal(isHighPriorityEventForUser(event, ""), false);
});
test("isHighPriorityEventForUser returns false for event with no tags at all", () => {
const event = makeEvent([]);
assert.equal(isHighPriorityEventForUser(event, PUBKEY), false);
});

View File

@@ -49,3 +49,21 @@ export function shouldNotifyForEvent(
return false;
}
export function isHighPriorityEventForUser(
event: RelayEvent,
currentPubkey: string,
): boolean {
if (
currentPubkey.length > 0 &&
event.tags.some(
(tag) => tag[0] === "p" && tag[1]?.toLowerCase() === currentPubkey,
)
) {
return true;
}
if (isBroadcastReply(event.tags)) {
return true;
}
return false;
}

View File

@@ -23,7 +23,7 @@ function getChannelId(element: Element): string | null {
function getUnreadElements(
root: HTMLDivElement,
unreadChannelIds: Set<string>,
unreadChannelIds: ReadonlySet<string>,
): HTMLElement[] {
return Array.from(
root.querySelectorAll<HTMLElement>("[data-channel-id]"),
@@ -44,7 +44,7 @@ function findNextUnreadElement({
}: {
direction: UnreadDirection;
root: HTMLDivElement;
unreadChannelIds: Set<string>;
unreadChannelIds: ReadonlySet<string>;
}): HTMLElement | null {
const rootHeight = root.getBoundingClientRect().height;
let nextElement: HTMLElement | null = null;
@@ -97,7 +97,7 @@ function deriveCounts(
export function useUnreadOverflow(args: {
scrollRef: React.RefObject<HTMLDivElement | null>;
unreadChannelIds: Set<string>;
unreadChannelIds: ReadonlySet<string>;
}): UnreadOverflowCounts & {
scrollToNextAbove: () => void;
scrollToNextBelow: () => void;

View File

@@ -97,7 +97,7 @@ type AppSidebarProps = {
| "workflows"
| "pulse"
| "projects";
unreadChannelIds: Set<string>;
unreadChannelIds: ReadonlySet<string>;
workspaces: Workspace[];
onAddWorkspace: (workspace: Workspace) => void;
onAddWorkspaceOpenChange?: (open: boolean) => void;

View File

@@ -292,7 +292,7 @@ export function ChannelGroupSection({
onToggleCollapsed: () => void;
selectedChannelId: string | null;
title: string;
unreadChannelIds: Set<string>;
unreadChannelIds: ReadonlySet<string>;
hasUnread?: boolean;
onMarkAllRead?: () => void;
sections?: ChannelSection[];
@@ -431,7 +431,7 @@ export function CustomChannelSection({
isCollapsed: boolean;
isActiveChannel: boolean;
selectedChannelId: string | null;
unreadChannelIds: Set<string>;
unreadChannelIds: ReadonlySet<string>;
sections: ChannelSection[];
assignments: Record<string, string>;
isFirst: boolean;

View File

@@ -231,7 +231,7 @@ export function SidebarSection({
selectedChannelId: string | null;
title: string;
testId: string;
unreadChannelIds: Set<string>;
unreadChannelIds: ReadonlySet<string>;
onHideDm?: (channelId: string) => void;
onMarkChannelRead?: (
channelId: string,

View File

@@ -502,6 +502,8 @@ declare global {
parentEventId?: string | null;
pubkey?: string;
kind?: number;
mentionPubkeys?: string[];
extraTags?: string[][];
}) => RelayEvent;
__SPROUT_E2E_EMIT_MOCK_TYPING__?: (input: {
channelName: string;
@@ -1946,15 +1948,18 @@ function emitMockChannelMessage(
parentEventId?: string | null,
pubkey?: string,
kind?: number,
mentionPubkeys?: string[],
extraTags?: string[][],
) {
const eventKind = kind ?? 9;
if (!parentEventId) {
const event = createMockEvent(
eventKind,
content,
[["h", channelId]],
pubkey,
const tags = buildTopLevelMessageTags(
channelId,
mentionPubkeys,
pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey,
);
if (extraTags) tags.push(...extraTags);
const event = createMockEvent(eventKind, content, tags, pubkey);
recordMockMessage(channelId, event);
emitMockLiveEvent(channelId, event);
return event;
@@ -1971,18 +1976,15 @@ function emitMockChannelMessage(
};
const rootEventId = parentThread.rootEventId ?? parentEventId;
const authorPubkey = pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey;
const event = createMockEvent(
eventKind,
content,
buildReplyMessageTags(
channelId,
authorPubkey,
parentEventId,
rootEventId,
undefined,
),
const tags = buildReplyMessageTags(
channelId,
authorPubkey,
parentEventId,
rootEventId,
mentionPubkeys,
);
if (extraTags) tags.push(...extraTags);
const event = createMockEvent(eventKind, content, tags, authorPubkey);
recordMockMessage(channelId, event);
emitMockLiveEvent(channelId, event);
return event;
@@ -5114,6 +5116,8 @@ export function maybeInstallE2eTauriMocks() {
parentEventId,
pubkey,
kind,
mentionPubkeys,
extraTags,
}) => {
const channel = mockChannels.find(
(candidate) => candidate.name === channelName,
@@ -5128,6 +5132,8 @@ export function maybeInstallE2eTauriMocks() {
parentEventId,
pubkey,
kind,
mentionPubkeys,
extraTags,
);
};
window.__SPROUT_E2E_EMIT_MOCK_TYPING__ = ({ channelName, pubkey }) => {
@@ -5652,6 +5658,7 @@ export function maybeInstallE2eTauriMocks() {
case "plugin:window|unminimize":
case "plugin:window|set_focus":
case "plugin:window|set_badge_count":
case "plugin:window|set_badge_label":
return null;
case "get_channel_workflows":
return handleGetChannelWorkflows(

View File

@@ -0,0 +1,207 @@
import { expect, test } from "@playwright/test";
import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge";
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
async function waitForMockLiveSubscription(
page: import("@playwright/test").Page,
channelName: string,
kind?: number,
) {
await expect
.poll(async () => {
return page.evaluate(
({ currentChannelName, kind: k }) => {
return (
(
window as Window & {
__SPROUT_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
kind?: number;
}) => boolean;
}
).__SPROUT_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: currentChannelName,
kind: k,
}) ?? false
);
},
{ currentChannelName: channelName, kind },
);
})
.toBe(true);
}
async function getBadgeState(page: import("@playwright/test").Page) {
return page.evaluate(() => {
const w = window as Window & {
__SPROUT_E2E_APP_BADGE_STATE__?: string;
__SPROUT_E2E_APP_BADGE_COUNT__?: number;
};
return {
state: w.__SPROUT_E2E_APP_BADGE_STATE__ ?? "none",
count: w.__SPROUT_E2E_APP_BADGE_COUNT__ ?? 0,
};
});
}
async function waitForBadgeState(
page: import("@playwright/test").Page,
expected: { state: string; count?: number },
) {
await expect
.poll(async () => getBadgeState(page), { timeout: 5_000 })
.toEqual(
expect.objectContaining({
state: expected.state,
...(expected.count !== undefined ? { count: expected.count } : {}),
}),
);
}
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
test("dot badge for regular message in inactive channel", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "random");
await page.evaluate(
({ pubkey }) => {
window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "random",
content: "Regular message, no mention",
kind: 40002,
pubkey,
});
},
{ pubkey: TEST_IDENTITIES.alice.pubkey },
);
await expect(page.getByTestId("channel-unread-random")).toBeVisible();
await waitForBadgeState(page, { state: "dot" });
});
test("numeric badge for @mention in inactive channel", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "random");
await page.evaluate(
({ pubkey, mentionPubkey }) => {
window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "random",
content: "Hey @tyler check this out",
kind: 40002,
pubkey,
mentionPubkeys: [mentionPubkey],
});
},
{
pubkey: TEST_IDENTITIES.alice.pubkey,
mentionPubkey: DEFAULT_MOCK_PUBKEY,
},
);
await expect(page.getByTestId("channel-unread-random")).toBeVisible();
await waitForBadgeState(page, { state: "count", count: 1 });
});
test("numeric badge for DM message", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "alice-tyler");
await page.evaluate((pubkey) => {
window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "alice-tyler",
content: "Hey, got a minute?",
pubkey,
});
}, TEST_IDENTITIES.alice.pubkey);
await expect(page.getByTestId("channel-unread-alice-tyler")).toBeVisible();
await waitForBadgeState(page, { state: "count", count: 1 });
});
test("numeric badge for broadcast reply in inactive channel", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "random");
await page.evaluate(
({ pubkey }) => {
window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "random",
content: "Broadcast reply to the channel",
kind: 40002,
pubkey,
extraTags: [
["broadcast", "1"],
["e", "some-root-event-id"],
],
});
},
{ pubkey: TEST_IDENTITIES.alice.pubkey },
);
await expect(page.getByTestId("channel-unread-random")).toBeVisible();
await waitForBadgeState(page, { state: "count", count: 1 });
});
test("mark-as-read via context menu clears channel unread indicator", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "random");
// Wait for catch-up to settle, then record baseline badge state
// (other mock channels may have pre-existing unreads from seeded history)
await page.waitForTimeout(2000);
const baselineBadge = await getBadgeState(page);
await page.evaluate(
({ pubkey }) => {
window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "random",
content: "Message to be marked read",
kind: 40002,
pubkey,
});
},
{ pubkey: TEST_IDENTITIES.alice.pubkey },
);
await expect(page.getByTestId("channel-unread-random")).toBeVisible();
await page.getByTestId("channel-random").click({ button: "right" });
await page.getByText("Mark as read").click();
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await waitForBadgeState(page, { state: baselineBadge.state });
});
test("mark-as-unread via context menu shows dot badge", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await page.getByTestId("channel-random").click({ button: "right" });
await page.getByText("Mark unread").click();
await expect(page.getByTestId("channel-unread-random")).toBeVisible();
await waitForBadgeState(page, { state: "dot" });
});

View File

@@ -190,6 +190,7 @@ export async function installBridge(page: Page, options: BridgeOptions) {
const testWindow = window as Window & {
__SPROUT_E2E__?: Record<string, unknown>;
__SPROUT_E2E_APP_BADGE_COUNT__?: number;
__SPROUT_E2E_APP_BADGE_STATE__?: string;
__SPROUT_E2E_CLICK_NOTIFICATION__?: (index: number) => boolean;
__SPROUT_E2E_NOTIFICATIONS__?: Array<{
body: string | null;
@@ -207,6 +208,7 @@ export async function installBridge(page: Page, options: BridgeOptions) {
relayWsUrl: relayWsUrl ?? currentConfig.relayWsUrl,
};
testWindow.__SPROUT_E2E_APP_BADGE_COUNT__ = 0;
testWindow.__SPROUT_E2E_APP_BADGE_STATE__ = "none";
testWindow.__SPROUT_E2E_CLICK_NOTIFICATION__ = (index: number) => {
const notification = notificationInstances[index];
if (!notification) {

View File

@@ -10,13 +10,17 @@
// node tests/helpers/screenshot.mjs [options]
//
// Options:
// --name <name> Screenshot filename without extension (default: screenshot)
// --route <path> Client-side route to navigate to (default: /)
// --click <selector> CSS selector or data-testid to click before capture
// --wait <ms> Milliseconds to wait before capture (default: 2000)
// --viewport <WxH> Viewport dimensions (default: 1280x720)
// --outdir <path> Output directory (default: test-results/screenshots)
// --messages <path> JSON file with messages to inject before capture
// --name <name> Screenshot filename without extension (default: screenshot)
// --route <path> Client-side route to navigate to (default: /)
// --active-channel <name> Channel to navigate to and view (channel-aware navigation)
// --click <selector> CSS selector or data-testid to click before capture
// --right-click <selector> Right-click a selector (for context menus)
// --hover <selector> Hover over a selector before capture
// --clip <x,y,w,h> Crop to a region (e.g. 0,0,256,720 for sidebar)
// --wait <ms> Milliseconds to wait before capture (default: 2000)
// --viewport <WxH> Viewport dimensions (default: 1280x720)
// --outdir <path> Output directory (default: test-results/screenshots)
// --messages <path> JSON file with messages to inject before capture
import { parseArgs } from "node:util";
import { existsSync, mkdirSync, readFileSync } from "node:fs";
@@ -27,7 +31,11 @@ const { values: args } = parseArgs({
options: {
name: { type: "string", default: "screenshot" },
route: { type: "string", default: "/" },
"active-channel": { type: "string" },
click: { type: "string" },
"right-click": { type: "string" },
hover: { type: "string" },
clip: { type: "string" },
wait: { type: "string", default: "2000" },
viewport: { type: "string", default: "1280x720" },
outdir: { type: "string", default: "test-results/screenshots" },
@@ -36,6 +44,9 @@ const { values: args } = parseArgs({
strict: true,
});
const activeChannel = args["active-channel"];
const rightClick = args["right-click"];
const [vpWidth, vpHeight] = args.viewport.split("x").map(Number);
const waitMs = Number(args.wait);
const outdir = resolve(args.outdir);
@@ -44,6 +55,16 @@ if (!existsSync(outdir)) {
mkdirSync(outdir, { recursive: true });
}
function resolveSelector(value) {
return value.startsWith("[") ? value : `[data-testid="${value}"]`;
}
function bail(msg) {
console.error(msg);
process.exitCode = 1;
throw new Error(msg);
}
const BASE_URL = "http://127.0.0.1:4173";
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
const ONBOARDING_PREFIX = "sprout-onboarding-complete.v1:";
@@ -114,7 +135,7 @@ await page.addInitScript(() => {
try {
if (args.messages) {
if (args.route !== "/") {
if (args.route !== "/" && !activeChannel) {
console.warn("warning: --route is ignored when --messages is provided");
}
@@ -122,9 +143,7 @@ try {
try {
messages = JSON.parse(readFileSync(resolve(args.messages), "utf8"));
} catch (err) {
console.error(`Failed to read messages file: ${err.message}`);
process.exitCode = 1;
throw err;
bail(`Failed to read messages file: ${err.message}`);
}
if (
@@ -135,60 +154,64 @@ try {
typeof m.channelName !== "string" || typeof m.content !== "string",
)
) {
const msg =
"messages file must be a non-empty array of { channelName: string, content: string, pubkey?: string, kind?: number }";
console.error(msg);
process.exitCode = 1;
throw new Error(msg);
bail(
"messages file must be a non-empty array of { channelName, content, pubkey?, kind?, mentionPubkeys?, extraTags?, parentEventId? }",
);
}
const channels = new Set(messages.map((m) => m.channelName));
if (channels.size > 1) {
const msg =
"All messages must target the same channelName for a single screenshot";
console.error(msg);
process.exitCode = 1;
throw new Error(msg);
const targetChannels = new Set(messages.map((m) => m.channelName));
if (!activeChannel && targetChannels.size > 1) {
bail(
"All messages must target the same channelName, or use --active-channel to specify the viewing channel",
);
}
const channelName = messages[0].channelName;
const viewChannel = activeChannel ?? [...targetChannels][0];
if (!/^[a-z0-9-]+$/.test(channelName)) {
const msg = `Invalid channel name: ${channelName}`;
console.error(msg);
process.exitCode = 1;
throw new Error(msg);
for (const ch of [...targetChannels, viewChannel]) {
if (!/^[a-z0-9-]+$/.test(ch)) {
bail(`Invalid channel name: ${ch}`);
}
}
await page.goto(BASE_URL);
await page.waitForSelector(`[data-testid="channel-${channelName}"]`, {
await page.waitForSelector(`[data-testid="channel-${viewChannel}"]`, {
timeout: 10000,
});
await page.click(`[data-testid="channel-${channelName}"]`);
await page.click(`[data-testid="channel-${viewChannel}"]`);
await page.waitForFunction(
(name) =>
window.__SPROUT_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: name,
}) ?? false,
channelName,
{ timeout: 10000 },
);
for (const ch of targetChannels) {
await page.waitForFunction(
(name) =>
window.__SPROUT_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: name,
}) ?? false,
ch,
{ timeout: 10000 },
);
}
for (const msg of messages) {
await page.evaluate(
(m) => {
window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__?.(m);
},
{
channelName: msg.channelName,
content: msg.content,
pubkey: msg.pubkey ?? DEFAULT_MOCK_PUBKEY,
kind: msg.kind,
},
{ ...msg, pubkey: msg.pubkey ?? DEFAULT_MOCK_PUBKEY },
);
}
await page.waitForTimeout(waitMs);
} else if (activeChannel) {
if (!/^[a-z0-9-]+$/.test(activeChannel)) {
bail(`Invalid channel name: ${activeChannel}`);
}
await page.goto(BASE_URL);
await page.waitForSelector(`[data-testid="channel-${activeChannel}"]`, {
timeout: 10000,
});
await page.click(`[data-testid="channel-${activeChannel}"]`);
await page.waitForTimeout(waitMs);
} else {
const url = args.route === "/" ? BASE_URL : `${BASE_URL}/#${args.route}`;
@@ -196,16 +219,27 @@ try {
await page.waitForTimeout(waitMs);
}
if (args.hover) {
await page.hover(resolveSelector(args.hover));
await page.waitForTimeout(500);
}
if (args.click) {
const selector = args.click.startsWith("[")
? args.click
: `[data-testid="${args.click}"]`;
await page.click(selector);
await page.click(resolveSelector(args.click));
await page.waitForTimeout(500);
} else if (rightClick) {
await page.click(resolveSelector(rightClick), { button: "right" });
await page.waitForTimeout(500);
}
const filepath = join(outdir, `${args.name}.png`);
await page.screenshot({ path: filepath });
const clipOpts = args.clip
? (() => {
const [x, y, w, h] = args.clip.split(",").map(Number);
return { clip: { x, y, width: w, height: h } };
})()
: {};
await page.screenshot({ path: filepath, ...clipOpts });
console.log(filepath);
} finally {
await browser.close();

View File

@@ -1,4 +1,6 @@
PODS:
- app_badge_plus (1.2.10):
- Flutter
- connectivity_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
@@ -22,6 +24,7 @@ PODS:
- FlutterMacOS
DEPENDENCIES:
- app_badge_plus (from `.symlinks/plugins/app_badge_plus/ios`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- Flutter (from `Flutter`)
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
@@ -33,6 +36,8 @@ DEPENDENCIES:
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
EXTERNAL SOURCES:
app_badge_plus:
:path: ".symlinks/plugins/app_badge_plus/ios"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
Flutter:
@@ -53,6 +58,7 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
SPEC CHECKSUMS:
app_badge_plus: 09939f19a075cc742cc155d8ed85e6d8601f0104
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23

View File

@@ -1,6 +1,7 @@
import AVFoundation
import Flutter
import UIKit
import UserNotifications
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
@@ -10,6 +11,7 @@ import UIKit
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) { _, _ in }
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}

View File

@@ -1,8 +1,11 @@
import 'package:app_badge_plus/app_badge_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'features/channels/unread_badge/unread_badge_provider.dart';
import 'features/home/home_page.dart';
import 'features/pairing/pairing_page.dart';
import 'features/channels/agent_activity/observer_subscription.dart';
@@ -37,6 +40,24 @@ class App extends HookConsumerWidget {
ref.watch(userStatusCacheProvider);
}
void applyBadge(UnreadBadgeState state) {
if (state.highPriorityCount > 0) {
AppBadgePlus.updateBadge(state.highPriorityCount);
} else if (state.generalUnreadCount > 0) {
AppBadgePlus.updateBadge(1);
} else {
AppBadgePlus.updateBadge(0);
}
}
useEffect(() {
applyBadge(ref.read(unreadBadgeProvider));
return null;
}, const []);
ref.listen<UnreadBadgeState>(unreadBadgeProvider, (_, next) {
applyBadge(next);
});
return MaterialApp(
title: 'Sprout',
theme: AppTheme.light(colorScheme: lightScheme),

View File

@@ -113,7 +113,7 @@ class Channel {
purpose: details.purpose,
createdBy: details.createdBy,
createdAt: details.createdAt,
memberCount: details.memberCount,
memberCount: memberCount,
lastMessageAt: lastMessageAt,
archivedAt: details.archivedAt,
participants: participants,

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -7,6 +8,9 @@ import '../../shared/relay/relay.dart';
import '../../shared/utils/string_utils.dart';
import 'channel.dart';
import 'channel_management_provider.dart' show channelDetailsProvider;
import 'read_state/read_state_provider.dart';
import 'unread_badge/is_high_priority_event.dart';
import 'unread_badge/should_notify_for_event.dart';
const _channelTypeOrder = {'stream': 0, 'forum': 1, 'dm': 2};
@@ -26,6 +30,10 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
final List<void Function()> _unsubscribers = [];
int _subscriptionVersion = 0;
Timer? _backstopTimer;
final Map<String, int> _latestHighPriorityByChannel = {};
Map<String, int> get latestHighPriorityByChannel =>
Map.unmodifiable(_latestHighPriorityByChannel);
@override
Future<List<Channel>> build() {
@@ -42,12 +50,14 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
ref.onDispose(() {
_clearLiveSubscriptions();
_latestHighPriorityByChannel.clear();
_backstopTimer?.cancel();
_backstopTimer = null;
});
if (sessionState.status != SessionStatus.connected) {
_clearLiveSubscriptions();
_latestHighPriorityByChannel.clear();
// Preserve the last successfully loaded channels while reconnecting
// instead of re-entering a loading/error state. The UI will show cached
// channels with a "Reconnecting…" banner overlay, which is far better
@@ -63,16 +73,36 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
);
}
Future<List<Channel>> _fetch({bool subscribeLive = false}) async {
Future<List<Channel>> _fetch({
bool subscribeLive = false,
bool fetchLastMessage = true,
}) async {
final myPk = ref.read(myPubkeyProvider);
if (myPk == null) throw StateError('No signing identity available');
final session = ref.read(relaySessionProvider.notifier);
// Step 1: find the channels I'm a member of via kind:39002.
final memberships = await session.fetchHistory(
NostrFilters.myChannels(myPk),
);
final memberships = <NostrEvent>[];
{
int? until;
const pageSize = 500;
while (true) {
final page = await session.fetchHistory(
NostrFilter(
kinds: const [39002],
tags: {
'#p': [myPk],
},
limit: pageSize,
until: until,
),
);
memberships.addAll(page);
if (page.length < pageSize) break;
until = page.map((e) => e.createdAt).reduce(min) - 1;
}
}
final channelIds = memberships
.map((e) => e.getTagValue('d'))
.whereType<String>()
@@ -147,6 +177,96 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
channels.add(channel);
}
// Batch-fetch member counts via kind:39002 membership events.
final memberEvents = await session.fetchHistory(
NostrFilter(
kinds: const [39002],
tags: {'#d': channelIds},
limit: channelIds.length,
),
);
final memberCounts = <String, int>{};
for (final event in memberEvents) {
final chId = event.getTagValue('d');
if (chId == null) continue;
final pTags = <String>{};
for (final tag in event.tags) {
if (tag.isNotEmpty && tag[0] == 'p' && tag.length > 1) {
pTags.add(tag[1].toLowerCase());
}
}
memberCounts[chId] = pTags.length;
}
for (var i = 0; i < channels.length; i++) {
final count = memberCounts[channels[i].id];
if (count != null) {
channels[i] = channels[i].copyWith(memberCount: count);
}
}
// Step 3: fetch the most recent message per channel to populate lastMessageAt.
// kind:39000 metadata doesn't carry message timestamps, so channels load with
// lastMessageAt: null. Without this, unread detection and badge computation
// see every channel as having no messages. Skipped on backstop refreshes since
// live subscriptions keep lastMessageAt current after the initial load.
if (fetchLastMessage) {
final lastMessageResults = await Future.wait(
channels.map((channel) async {
if (!channel.isMember || channel.isArchived) return null;
try {
if (channel.isDm) {
final events = await session.fetchHistory(
NostrFilter(
kinds: EventKind.channelMessageEventKinds,
tags: {
'#h': [channel.id],
},
limit: 1,
),
);
if (events.isEmpty) return null;
return MapEntry(channel.id, events.first.createdAt);
}
final events = await session.fetchHistory(
NostrFilter(
kinds: EventKind.channelMessageEventKinds,
tags: {
'#h': [channel.id],
},
limit: 20,
),
);
for (final event in events) {
if (shouldNotifyForEvent(event, myPk)) {
return MapEntry(channel.id, event.createdAt);
}
}
return null;
} catch (_) {
return null;
}
}),
);
final lastMessageMap = <String, int>{};
for (final entry
in lastMessageResults.whereType<MapEntry<String, int>>()) {
lastMessageMap[entry.key] = entry.value;
}
for (var i = 0; i < channels.length; i++) {
final ts = lastMessageMap[channels[i].id];
if (ts != null) {
channels[i] = channels[i].copyWith(
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
ts * 1000,
isUtc: true,
),
);
}
}
}
channels.sort((left, right) {
final typeOrder =
(_channelTypeOrder[left.channelType] ?? 99) -
@@ -279,6 +399,10 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
_unsubscribers.addAll(subscriptions.whereType<void Function()>());
// Backfill high-priority map from recent history so unread @mentions that
// arrived before app launch are correctly classified as high-priority tier.
unawaited(_backfillHighPriority(channels));
_backstopTimer?.cancel();
_backstopTimer = Timer.periodic(
_backstopInterval,
@@ -286,27 +410,122 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
);
}
Future<void> _backfillHighPriority(List<Channel> channels) async {
final myPk = ref.read(myPubkeyProvider);
if (myPk == null) return;
final session = ref.read(relaySessionProvider.notifier);
final readState = ref.read(readStateProvider);
final futures = <Future<void>>[];
for (final channel in channels) {
if (!channel.isMember || channel.isArchived) continue;
// All DM messages are high-priority — no need to scan event history.
if (channel.isDm) {
final lastMsg = channel.lastMessageAt;
if (lastMsg != null) {
_latestHighPriorityByChannel[channel.id] =
lastMsg.millisecondsSinceEpoch ~/ 1000;
}
continue;
}
final readAt = readState.effectiveTimestamp(channel.id);
futures.add(
_backfillHighPriorityForChannel(session, channel, myPk, readAt),
);
}
// Batch into groups of 5 to avoid saturating the relay.
const batchSize = 5;
for (var i = 0; i < futures.length; i += batchSize) {
await Future.wait(futures.sublist(i, min(i + batchSize, futures.length)));
}
// Trigger unreadBadgeProvider to re-evaluate now that the map is populated.
state = state.whenData((channels) => List<Channel>.of(channels));
}
Future<void> _backfillHighPriorityForChannel(
RelaySessionNotifier session,
Channel channel,
String myPk,
int? readAt,
) async {
// For non-DM channels, fetch events since the last read timestamp and scan
// for high-priority ones. Using `since` avoids fetching messages the user
// has already seen, and `limit: 200` covers deep mention backfills.
try {
final events = await session.fetchHistory(
NostrFilter(
kinds: EventKind.channelEventKinds,
tags: {
'#h': [channel.id],
},
since: readAt ?? 0,
limit: 200,
),
);
var maxHighPriority = 0;
for (final event in events) {
if (event.pubkey == myPk) continue;
if (!EventKind.channelMessageEventKinds.contains(event.kind)) continue;
if (isHighPriorityEvent(event.tags, myPk) &&
event.createdAt > maxHighPriority) {
maxHighPriority = event.createdAt;
}
}
if (maxHighPriority > 0) {
final current = _latestHighPriorityByChannel[channel.id] ?? 0;
if (maxHighPriority > current) {
_latestHighPriorityByChannel[channel.id] = maxHighPriority;
}
}
} catch (error) {
debugPrint(
'[ChannelsNotifier] backfill failed for ${channel.id}: $error',
);
}
}
void _handleLiveEvent(NostrEvent event) {
final channelId = event.channelId;
if (channelId == null) return;
final myPk = ref.read(myPubkeyProvider);
state = state.whenData((channels) {
final idx = channels.indexWhere((c) => c.id == channelId);
if (idx == -1) {
// Unknown channel — queue a full refresh to pick it up.
refresh();
return channels;
}
final updated = List<Channel>.of(channels);
final channel = updated[idx];
final eventTime = DateTime.fromMillisecondsSinceEpoch(
event.createdAt * 1000,
isUtc: true,
);
if (channel.lastMessageAt == null ||
eventTime.isAfter(channel.lastMessageAt!)) {
updated[idx] = channel.copyWith(lastMessageAt: eventTime);
if (myPk != null && shouldNotifyForEvent(event, myPk)) {
final eventTime = DateTime.fromMillisecondsSinceEpoch(
event.createdAt * 1000,
isUtc: true,
);
if (channel.lastMessageAt == null ||
eventTime.isAfter(channel.lastMessageAt!)) {
updated[idx] = channel.copyWith(lastMessageAt: eventTime);
}
}
if (myPk != null &&
event.pubkey != myPk &&
(channel.isDm || isHighPriorityEvent(event.tags, myPk))) {
final current = _latestHighPriorityByChannel[channelId] ?? 0;
if (event.createdAt > current) {
_latestHighPriorityByChannel[channelId] = event.createdAt;
}
}
return updated;
});
}
@@ -315,9 +534,21 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
Future<void> _backstopRefresh() async {
try {
final sessionState = ref.read(relaySessionProvider);
final prevChannels = state.value ?? const [];
final prevLastMessage = {
for (final c in prevChannels)
if (c.lastMessageAt != null) c.id: c.lastMessageAt,
};
final channels = await _fetch(
subscribeLive: sessionState.status == SessionStatus.connected,
fetchLastMessage: false,
);
for (var i = 0; i < channels.length; i++) {
final prev = prevLastMessage[channels[i].id];
if (channels[i].lastMessageAt == null && prev != null) {
channels[i] = channels[i].copyWith(lastMessageAt: prev);
}
}
state = AsyncData(channels);
} catch (error) {
debugPrint('[ChannelsNotifier] backstop refresh failed: $error');

View File

@@ -59,8 +59,11 @@ class ReadStateManager {
bool _initialized = false;
bool _disposed = false;
bool _isPublishing = false;
Completer<void>? _publishCompleter;
bool _remoteUnsupported = false;
int _maxFetchedCreatedAt = 0;
final Set<String> _forcedContextIds = {};
final Map<String, int> _contextSourceCreatedAt = {};
ReadStateManager({
required this.pubkey,
@@ -104,7 +107,12 @@ class ReadStateManager {
}
void markContextRead(String contextId, int unixTimestamp) {
_forcedContextIds.remove(contextId);
_advanceContext(contextId, unixTimestamp, publishable: true);
_contextSourceCreatedAt[contextId] = max(
currentUnixSeconds(),
_maxFetchedCreatedAt + 1,
);
}
void markContextUnread(String contextId, int lastMessageTimestamp) {
@@ -112,6 +120,7 @@ class ReadStateManager {
final rollbackTo = lastMessageTimestamp - 1;
_effectiveState[contextId] = rollbackTo;
_publishableContextIds.add(contextId);
_forcedContextIds.add(contextId);
_persistLocalState();
_onChanged();
_schedulePublish();
@@ -128,6 +137,21 @@ class ReadStateManager {
await _publish();
}
Future<void> reinitializeRemote() async {
if (_disposed || !_remoteEnabled) return;
if (_isPublishing) {
await _publishCompleter?.future;
}
_unsubscribeLive?.call();
_unsubscribeLive = null;
await _fetchAndMerge();
await _startLiveSubscription();
if (!_isIdenticalToLastPublished(_currentContexts())) {
_schedulePublish();
}
_onChanged();
}
void dispose({bool flushPending = true}) {
if (_disposed) return;
_disposed = true;
@@ -213,7 +237,9 @@ class ReadStateManager {
continue;
}
_maxFetchedCreatedAt = max(_maxFetchedCreatedAt, event.createdAt);
if (_isPlausibleCreatedAt(event.createdAt)) {
_maxFetchedCreatedAt = max(_maxFetchedCreatedAt, event.createdAt);
}
if (decoded.dTag == '$readStateDTagPrefix$_slotId' &&
decoded.blob.clientId != _clientId) {
@@ -221,8 +247,14 @@ class ReadStateManager {
}
for (final entry in decoded.blob.contexts.entries) {
if (_forcedContextIds.contains(entry.key)) continue;
final sourceCreatedAt = _contextSourceCreatedAt[entry.key] ?? 0;
final current = _effectiveState[entry.key] ?? 0;
if (entry.value > current) {
if (event.createdAt > sourceCreatedAt) {
_effectiveState[entry.key] = entry.value;
_contextSourceCreatedAt[entry.key] = event.createdAt;
} else if (event.createdAt == sourceCreatedAt &&
entry.value != current) {
_effectiveState[entry.key] = entry.value;
}
_publishableContextIds.add(entry.key);
@@ -271,7 +303,9 @@ class ReadStateManager {
return;
}
_maxFetchedCreatedAt = max(_maxFetchedCreatedAt, event.createdAt);
if (_isPlausibleCreatedAt(event.createdAt)) {
_maxFetchedCreatedAt = max(_maxFetchedCreatedAt, event.createdAt);
}
if (decoded.dTag == '$readStateDTagPrefix$_slotId' &&
decoded.blob.clientId != _clientId) {
@@ -280,8 +314,16 @@ class ReadStateManager {
var changed = false;
for (final entry in decoded.blob.contexts.entries) {
if (_forcedContextIds.contains(entry.key)) continue;
final sourceCreatedAt = _contextSourceCreatedAt[entry.key] ?? 0;
final current = _effectiveState[entry.key] ?? 0;
if (entry.value > current) {
if (event.createdAt > sourceCreatedAt) {
if (_effectiveState[entry.key] != entry.value) {
_effectiveState[entry.key] = entry.value;
changed = true;
}
_contextSourceCreatedAt[entry.key] = event.createdAt;
} else if (event.createdAt == sourceCreatedAt && entry.value != current) {
_effectiveState[entry.key] = entry.value;
changed = true;
}
@@ -300,7 +342,7 @@ class ReadStateManager {
}
if (decoded.blob.clientId != _clientId &&
_contextsExceedLastPublished(decoded.blob.contexts)) {
!_isIdenticalToLastPublished(_currentContexts())) {
_schedulePublish();
}
}
@@ -324,6 +366,8 @@ class ReadStateManager {
}
if (_isPublishing) return;
final completer = Completer<void>();
_publishCompleter = completer;
_isPublishing = true;
try {
await _fetchOwnBlobBeforePublish();
@@ -348,7 +392,12 @@ class ReadStateManager {
);
_lastPublishedContexts = contexts;
_forcedContextIds.clear();
for (final key in contexts.keys) {
_contextSourceCreatedAt[key] = createdAt;
}
_maxFetchedCreatedAt = max(_maxFetchedCreatedAt, createdAt);
_persistLocalState();
} catch (error) {
if (_isPermanentReadStateRemoteError(error)) {
_remoteUnsupported = true;
@@ -363,6 +412,10 @@ class ReadStateManager {
debugPrint('[ReadStateManager] publish failed: $error');
} finally {
_isPublishing = false;
completer.complete();
if (_publishCompleter == completer) {
_publishCompleter = null;
}
}
}
@@ -390,16 +443,6 @@ class ReadStateManager {
}
}
bool _contextsExceedLastPublished(Map<String, int> contexts) {
for (final entry in contexts.entries) {
final last = _lastPublishedContexts[entry.key];
if (last == null || entry.value > last) {
return true;
}
}
return false;
}
bool _isIdenticalToLastPublished(Map<String, int> contexts) {
if (_lastPublishedContexts.length != contexts.length) {
return false;
@@ -430,11 +473,20 @@ class ReadStateManager {
_publishableContextIds
..clear()
..addAll(stored.publishableContextIds);
_forcedContextIds.clear();
_contextSourceCreatedAt
..clear()
..addAll(stored.sourceCreatedAt);
_persistLocalState();
}
void _persistLocalState() {
_storage.write(pubkey, _effectiveState, _publishableContextIds);
_storage.write(
pubkey,
_effectiveState,
_publishableContextIds,
_contextSourceCreatedAt,
);
}
void _rotateSlotId() {
@@ -442,6 +494,9 @@ class ReadStateManager {
_storage.writeSlotId(pubkey, _slotId);
}
bool _isPlausibleCreatedAt(int createdAt) =>
createdAt <= currentUnixSeconds() + readStateMaxClockDriftSeconds;
bool _isPermanentReadStateRemoteError(Object error) {
// Relay rejections come back as `Exception("<message>")` from the
// websocket OK handler. Pattern-match on the message text since we no

View File

@@ -55,7 +55,7 @@ class ReadStateNotifier extends Notifier<ReadStateState> {
_isInitialized = false;
final relayConfig = ref.watch(relayConfigProvider);
final sessionState = ref.watch(relaySessionProvider);
ref.watch(relaySessionProvider);
final activeWorkspace = ref.watch(activeWorkspaceProvider).value;
final nsec = relayConfig.nsec?.trim();
@@ -87,7 +87,7 @@ class ReadStateNotifier extends Notifier<ReadStateState> {
crypto: crypto,
relaySession: ref.read(relaySessionProvider.notifier),
signedEventRelay: signedRelay,
remoteEnabled: sessionState.status == SessionStatus.connected,
remoteEnabled: true,
onChanged: () => _emitManagerState(manager),
);
_manager = manager;
@@ -107,6 +107,13 @@ class ReadStateNotifier extends Notifier<ReadStateState> {
}
});
ref.listen(relaySessionProvider, (prev, next) {
if (prev?.status != SessionStatus.connected &&
next.status == SessionStatus.connected) {
unawaited(manager.reinitializeRemote());
}
});
Future.microtask(() async {
await manager.initialize();
if (_manager != manager) return;

View File

@@ -17,6 +17,9 @@ String localReadStateKey(String pubkey) =>
String localPublishableContextKey(String pubkey) =>
'sprout.channel-read-state.publishable.v1:$pubkey';
String localSourceCreatedAtKey(String pubkey) =>
'sprout.channel-read-state.source-created-at.v1:$pubkey';
String clientIdKey(String pubkey) => '$_clientIdKeyPrefix:$pubkey';
String slotIdKey(String pubkey) => '$_slotIdKeyPrefix:$pubkey';
@@ -24,12 +27,15 @@ String slotIdKey(String pubkey) => '$_slotIdKeyPrefix:$pubkey';
class StoredReadState {
final Map<String, int> contexts;
final Set<String> publishableContextIds;
final Map<String, int> sourceCreatedAt;
StoredReadState({
required Map<String, int> contexts,
required Set<String> publishableContextIds,
required Map<String, int> sourceCreatedAt,
}) : contexts = Map.unmodifiable(contexts),
publishableContextIds = Set.unmodifiable(publishableContextIds);
publishableContextIds = Set.unmodifiable(publishableContextIds),
sourceCreatedAt = Map.unmodifiable(sourceCreatedAt);
}
class ReadStateStorage {
@@ -69,6 +75,7 @@ class ReadStateStorage {
return StoredReadState(
contexts: _readContexts(pubkey),
publishableContextIds: _readPublishableContextIds(pubkey),
sourceCreatedAt: _readSourceCreatedAt(pubkey),
);
}
@@ -76,6 +83,7 @@ class ReadStateStorage {
String pubkey,
Map<String, int> contexts,
Set<String> publishableContextIds,
Map<String, int> sourceCreatedAt,
) {
final state = <String, String>{};
for (final entry in contexts.entries) {
@@ -87,6 +95,10 @@ class ReadStateStorage {
localPublishableContextKey(pubkey),
jsonEncode(publishableContextIds.toList()),
);
_prefs.setString(
localSourceCreatedAtKey(pubkey),
jsonEncode(sourceCreatedAt.map((k, v) => MapEntry(k, v.toString()))),
);
}
Map<String, int> _readContexts(String pubkey) {
@@ -142,6 +154,33 @@ class ReadStateStorage {
if (value is String) value,
};
}
Map<String, int> _readSourceCreatedAt(String pubkey) {
final raw = _prefs.getString(localSourceCreatedAtKey(pubkey));
if (raw == null || raw.isEmpty) return {};
final Object? parsed;
try {
parsed = jsonDecode(raw);
} catch (_) {
return {};
}
final record = asStringObjectMap(parsed);
if (record == null) return {};
final result = <String, int>{};
for (final entry in record.entries) {
final value = entry.value;
if (value is int) {
result[entry.key] = value;
} else if (value is String) {
final parsed = int.tryParse(value);
if (parsed != null) result[entry.key] = parsed;
}
}
return result;
}
}
String generateReadStateSlotId() {

View File

@@ -21,3 +21,5 @@ int? isoToUnixSeconds(Object? value) {
return dateTimeToUnixSeconds(DateTime.tryParse(value));
}
const readStateMaxClockDriftSeconds = 300;

View File

@@ -0,0 +1,14 @@
bool isHighPriorityEvent(List<List<String>> tags, String currentPubkey) {
final normalizedPubkey = currentPubkey.toLowerCase();
for (final tag in tags) {
if (tag.length >= 2 &&
tag[0] == 'p' &&
tag[1].toLowerCase() == normalizedPubkey) {
return true;
}
if (tag.length >= 2 && tag[0] == 'broadcast' && tag[1] == '1') {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,27 @@
import '../../../shared/relay/nostr_models.dart';
bool shouldNotifyForEvent(NostrEvent event, String myPubkey) {
if (!EventKind.channelMessageEventKinds.contains(event.kind)) return false;
if (event.pubkey == myPubkey) return false;
final ref = event.threadReference;
if (ref.parentId == null) return true;
for (final tag in event.tags) {
if (tag.length >= 2 && tag[0] == 'broadcast' && tag[1] == '1') {
return true;
}
}
final normalizedPk = myPubkey.toLowerCase();
for (final tag in event.tags) {
if (tag.length >= 2 &&
tag[0] == 'p' &&
tag[1].toLowerCase() == normalizedPk) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,64 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../channels_provider.dart';
import '../read_state/read_state_provider.dart';
import '../read_state/read_state_time.dart';
class UnreadBadgeState {
const UnreadBadgeState({
this.highPriorityCount = 0,
this.generalUnreadCount = 0,
});
final int highPriorityCount;
final int generalUnreadCount;
}
final unreadBadgeProvider = Provider<UnreadBadgeState>((ref) {
final channelsAsync = ref.watch(channelsProvider);
final readState = ref.watch(readStateProvider);
return channelsAsync.when(
data: (channels) {
// Safe to ref.read the notifier here: _latestHighPriorityByChannel is
// only mutated inside _handleLiveEvent's state.whenData block, which
// always emits a new channelsProvider state — so the ref.watch above
// guarantees we re-run whenever the map changes.
final notifier = ref.read(channelsProvider.notifier);
final highPriorityMap = notifier.latestHighPriorityByChannel;
int highPriority = 0;
int general = 0;
for (final channel in channels) {
if (!channel.isMember || channel.isArchived) continue;
final lastMessageAt = dateTimeToUnixSeconds(channel.lastMessageAt);
if (lastMessageAt == null) continue;
final readAt = readState.effectiveTimestamp(channel.id);
final isUnread = readAt == null || lastMessageAt > readAt;
if (!isUnread) continue;
if (channel.isDm) {
highPriority++;
} else {
final highPriorityAt = highPriorityMap[channel.id];
if (highPriorityAt != null &&
(readAt == null || highPriorityAt > readAt)) {
highPriority++;
} else {
general++;
}
}
}
return UnreadBadgeState(
highPriorityCount: highPriority,
generalUnreadCount: general,
);
},
loading: () => const UnreadBadgeState(),
error: (_, _) => const UnreadBadgeState(),
);
});

View File

@@ -41,6 +41,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.13.10"
app_badge_plus:
dependency: "direct main"
description:
name: app_badge_plus
sha256: c0d9e318d0c516764945d0d1997d4e99e9b7d26523e8404a35c70c9fe5ec249b
url: "https://pub.dev"
source: hosted
version: "1.2.10"
args:
dependency: transitive
description:

View File

@@ -28,6 +28,7 @@ dependencies:
image_picker: ^1.1.2
video_player: ^2.10.1
package_info_plus: ^10.0.0
app_badge_plus: ^1.2.10
dev_dependencies:
flutter_test:

View File

@@ -225,8 +225,9 @@ void main() {
await container.read(channelsProvider.future);
// Two history fetches: memberships (kind:39002) then metadata (kind:39000).
expect(session.historyFilters, hasLength(2));
// Two history fetches for channel loading, plus one per non-DM channel
// for high-priority event backfill.
expect(session.historyFilters.length, greaterThanOrEqualTo(2));
expect(session.historyFilters[0].kinds, [39002]);
expect(session.historyFilters[0].tags['#p'], [myPk]);
expect(session.historyFilters[1].kinds, [39000]);

View File

@@ -0,0 +1,353 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sprout_mobile/features/channels/channel.dart';
import 'package:sprout_mobile/features/channels/channels_provider.dart';
import 'package:sprout_mobile/features/channels/read_state/read_state_provider.dart';
import 'package:sprout_mobile/features/channels/unread_badge/unread_badge_provider.dart';
/// Unit tests for [unreadBadgeProvider].
///
/// Strategy: override [channelsProvider] with a [_StubbedChannelsNotifier] that
/// returns a pre-built channel list and allows seeding [latestHighPriorityByChannel],
/// and override [readStateProvider] with a [_ReadStateNotifier] that holds a
/// fixed [ReadStateState]. This avoids standing up any relay connection and
/// exercises only the badge-computation logic.
///
/// Because [channelsProvider] is an [AsyncNotifierProvider], the provider starts
/// in [AsyncLoading] — tests that check computed badge values must await
/// [channelsProvider.future] to let the notifier resolve before reading the badge.
void main() {
// Fixed epoch timestamps (Unix seconds).
const t10 = 10; // older
const t20 = 20; // newer — any channel with lastMessageAt == t20 has a message
const t30 = 30; // even newer — used for high-priority events
// ---------------------------------------------------------------------------
// Channel factory helper
// ---------------------------------------------------------------------------
Channel makeChannel({
required String id,
String channelType = 'stream',
bool isMember = true,
bool isArchived = false,
int? lastMessageAtSeconds,
}) {
return Channel(
id: id,
name: id,
channelType: channelType,
visibility: 'open',
description: '',
createdBy: 'creator',
createdAt: DateTime.utc(2024),
memberCount: 1,
isMember: isMember,
archivedAt: isArchived ? DateTime.utc(2024) : null,
lastMessageAt: lastMessageAtSeconds != null
? DateTime.fromMillisecondsSinceEpoch(
lastMessageAtSeconds * 1000,
isUtc: true,
)
: null,
);
}
// ---------------------------------------------------------------------------
// Container builder
// ---------------------------------------------------------------------------
ProviderContainer buildContainer({
required List<Channel> channels,
Map<String, int> readContexts = const {},
bool readStateReady = true,
Map<String, int> highPriorityMap = const {},
}) {
final notifier = _StubbedChannelsNotifier(
channels: channels,
highPriorityMap: highPriorityMap,
);
return ProviderContainer(
overrides: [
channelsProvider.overrideWith(() => notifier),
readStateProvider.overrideWith(
() => _ReadStateNotifier(
ReadStateState(
isReady: readStateReady,
pubkey: 'me',
contexts: readContexts,
version: 1,
),
),
),
],
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test('all channels read returns (0, 0)', () async {
final container = buildContainer(
channels: [
makeChannel(id: 'ch-a', lastMessageAtSeconds: t20),
makeChannel(id: 'ch-b', lastMessageAtSeconds: t20),
],
// Read at t30, which is after every message.
readContexts: {'ch-a': t30, 'ch-b': t30},
);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final badge = container.read(unreadBadgeProvider);
expect(badge.highPriorityCount, 0);
expect(badge.generalUnreadCount, 0);
});
test(
'one unread non-DM channel with no high-priority event → (0, 1) general only',
() async {
final container = buildContainer(
channels: [makeChannel(id: 'ch-a', lastMessageAtSeconds: t20)],
readContexts: {},
highPriorityMap: {},
);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final badge = container.read(unreadBadgeProvider);
expect(badge.highPriorityCount, 0);
expect(badge.generalUnreadCount, 1);
},
);
test('one unread DM channel → (1, 0) high priority', () async {
final container = buildContainer(
channels: [
makeChannel(id: 'dm-a', channelType: 'dm', lastMessageAtSeconds: t20),
],
readContexts: {},
);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final badge = container.read(unreadBadgeProvider);
expect(badge.highPriorityCount, 1);
expect(badge.generalUnreadCount, 0);
});
test(
'one unread non-DM with unread high-priority @mention → (1, 0) high priority',
() async {
const channelId = 'ch-a';
final container = buildContainer(
channels: [makeChannel(id: channelId, lastMessageAtSeconds: t20)],
// Read up through t10; both the message (t20) and @mention (t30) are
// newer than the read marker.
readContexts: {channelId: t10},
highPriorityMap: {channelId: t30},
);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final badge = container.read(unreadBadgeProvider);
expect(badge.highPriorityCount, 1);
expect(badge.generalUnreadCount, 0);
},
);
test(
'mixed: 1 unread DM + 2 unread non-DMs (no high-priority) → (1, 2)',
() async {
final container = buildContainer(
channels: [
makeChannel(id: 'dm-a', channelType: 'dm', lastMessageAtSeconds: t20),
makeChannel(id: 'ch-b', lastMessageAtSeconds: t20),
makeChannel(id: 'ch-c', lastMessageAtSeconds: t20),
],
readContexts: {},
highPriorityMap: {},
);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final badge = container.read(unreadBadgeProvider);
expect(badge.highPriorityCount, 1);
expect(badge.generalUnreadCount, 2);
},
);
test('archived channel is excluded even if unread', () async {
final container = buildContainer(
channels: [
makeChannel(
id: 'ch-archived',
isArchived: true,
lastMessageAtSeconds: t20,
),
makeChannel(id: 'ch-active', lastMessageAtSeconds: t20),
],
readContexts: {},
);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final badge = container.read(unreadBadgeProvider);
// Archived channel must not count; active one does.
expect(badge.highPriorityCount, 0);
expect(badge.generalUnreadCount, 1);
});
test('non-member channel is excluded even if unread', () async {
final container = buildContainer(
channels: [
makeChannel(
id: 'ch-nonmember',
isMember: false,
lastMessageAtSeconds: t20,
),
makeChannel(id: 'ch-member', lastMessageAtSeconds: t20),
],
readContexts: {},
);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final badge = container.read(unreadBadgeProvider);
expect(badge.highPriorityCount, 0);
expect(badge.generalUnreadCount, 1);
});
test('channel with lastMessageAt == null is excluded', () async {
final container = buildContainer(
channels: [
// No lastMessageAt — provider treats this as having no messages.
makeChannel(id: 'ch-nomsg'),
makeChannel(id: 'ch-withmsg', lastMessageAtSeconds: t20),
],
readContexts: {},
);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final badge = container.read(unreadBadgeProvider);
expect(badge.highPriorityCount, 0);
expect(badge.generalUnreadCount, 1);
});
test(
'read state not ready (isReady: false) does not suppress unread counts',
() async {
// The provider does not gate on isReady — it computes from whatever
// timestamps are in contexts. With an empty context map and readStateReady=false,
// readAt is null → channel is unread → (0, 1).
final container = buildContainer(
channels: [makeChannel(id: 'ch-a', lastMessageAtSeconds: t20)],
readContexts: {},
readStateReady: false,
);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final badge = container.read(unreadBadgeProvider);
expect(badge.highPriorityCount, 0);
expect(badge.generalUnreadCount, 1);
},
);
test('channelsProvider in loading state returns (0, 0)', () {
// The provider returns const UnreadBadgeState() while channels are loading.
// We intentionally do NOT await the future here — the channels notifier
// never resolves (Completer never completes) so the state stays AsyncLoading.
final container = ProviderContainer(
overrides: [
channelsProvider.overrideWith(() => _LoadingChannelsNotifier()),
readStateProvider.overrideWith(
() => _ReadStateNotifier(
const ReadStateState(
isReady: true,
pubkey: 'me',
contexts: {},
version: 1,
),
),
),
],
);
addTearDown(container.dispose);
final badge = container.read(unreadBadgeProvider);
expect(badge.highPriorityCount, 0);
expect(badge.generalUnreadCount, 0);
});
test(
'high-priority event older than read marker falls through to general bucket',
() async {
// @mention arrived at t10, user read at t20 (after the mention),
// then a new general message arrived at t30. Channel is unread but
// the mention is already read → falls through to general.
const channelId = 'ch-a';
final container = buildContainer(
channels: [makeChannel(id: channelId, lastMessageAtSeconds: t30)],
readContexts: {channelId: t20},
highPriorityMap: {channelId: t10}, // mention is older than read marker
);
addTearDown(container.dispose);
await container.read(channelsProvider.future);
final badge = container.read(unreadBadgeProvider);
expect(badge.highPriorityCount, 0);
expect(badge.generalUnreadCount, 1);
},
);
}
// ---------------------------------------------------------------------------
// Test doubles
// ---------------------------------------------------------------------------
/// A [ChannelsNotifier] that immediately resolves to a canned [channels] list
/// and exposes a pre-seeded [latestHighPriorityByChannel] map.
///
/// Extends [ChannelsNotifier] so [ref.read(channelsProvider.notifier)] returns
/// an instance whose [latestHighPriorityByChannel] getter works correctly.
class _StubbedChannelsNotifier extends ChannelsNotifier {
_StubbedChannelsNotifier({
required List<Channel> channels,
Map<String, int> highPriorityMap = const {},
}) : _channels = channels,
_highPriorityMap = Map<String, int>.unmodifiable(highPriorityMap);
final List<Channel> _channels;
final Map<String, int> _highPriorityMap;
@override
Future<List<Channel>> build() async => _channels;
@override
Map<String, int> get latestHighPriorityByChannel => _highPriorityMap;
}
/// A [ChannelsNotifier] that stays in the loading state indefinitely.
class _LoadingChannelsNotifier extends ChannelsNotifier {
@override
Future<List<Channel>> build() => Completer<List<Channel>>().future;
@override
Map<String, int> get latestHighPriorityByChannel => const {};
}
/// A [ReadStateNotifier] that returns a fixed [ReadStateState].
class _ReadStateNotifier extends ReadStateNotifier {
_ReadStateNotifier(this._fixedState);
final ReadStateState _fixedState;
@override
ReadStateState build() => _fixedState;
}

View File

@@ -35,6 +35,10 @@ NEW_ENTRIES=""
IMAGE_URLS=()
for PNG in "${PNGS[@]}"; do
FILENAME=$(basename "$PNG")
if ! [[ "$FILENAME" =~ ^[a-zA-Z0-9_.-]+$ ]]; then
echo "error: invalid PNG filename (must be alphanumeric, dots, hyphens, underscores): $FILENAME" >&2
exit 1
fi
BLOB=$(git hash-object -w "$PNG")
TREE_PATH="pr-${PR}--${FILENAME}"
NEW_ENTRIES+="$(printf '100644 blob %s\t%s' "$BLOB" "$TREE_PATH")"$'\n'
@@ -47,17 +51,37 @@ TREE=$(echo "$COMBINED" | git mktree)
COMMIT=$(git commit-tree "$TREE" -m "screenshots: PR #${PR}")
git push --force-with-lease origin "${COMMIT}:refs/heads/${BRANCH}"
IMAGES_SECTION=""
for URL in "${IMAGE_URLS[@]}"; do
FILENAME=$(basename "$URL")
NAME="${FILENAME%.png}"
IMAGES_SECTION+="![${NAME}](${URL})"$'\n\n'
declare -A IMAGE_URL_MAP
for i in "${!PNGS[@]}"; do
ORIG_NAME="$(basename "${PNGS[$i]}" .png)"
IMAGE_URL_MAP["$ORIG_NAME"]="${IMAGE_URLS[$i]}"
done
if [[ -n "$BODY_FILE" ]]; then
COMMENT_BODY="$(cat "$BODY_FILE")"$'\n\n'"${IMAGES_SECTION}"
COMMENT_BODY="$(cat "$BODY_FILE")"
UNREFERENCED=()
for NAME in "${!IMAGE_URL_MAP[@]}"; do
URL="${IMAGE_URL_MAP[$NAME]}"
PLACEHOLDER="{{${NAME}}}"
if [[ "$COMMENT_BODY" == *"$PLACEHOLDER"* ]]; then
COMMENT_BODY="${COMMENT_BODY//"$PLACEHOLDER"/![$NAME]($URL)}"
else
UNREFERENCED+=("$NAME")
fi
done
if [[ ${#UNREFERENCED[@]} -gt 0 ]]; then
IFS=$'\n' SORTED=($(printf '%s\n' "${UNREFERENCED[@]}" | sort)); unset IFS
for NAME in "${SORTED[@]}"; do
COMMENT_BODY+=$'\n\n'"![${NAME}](${IMAGE_URL_MAP[$NAME]})"
done
fi
else
COMMENT_BODY="## Screenshots"$'\n\n'"${IMAGES_SECTION}"
COMMENT_BODY="## Screenshots"$'\n\n'
for URL in "${IMAGE_URLS[@]}"; do
FILENAME=$(basename "$URL")
NAME="${FILENAME%.png}"
COMMENT_BODY+="![${NAME}](${URL})"$'\n\n'
done
fi
gh pr comment "$PR" --repo "$REPO" --body "$COMMENT_BODY"