mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(mobile+desktop): two-tier Slack-style app icon badge (#802)
This commit is contained in:
75
AGENTS.md
75
AGENTS.md
@@ -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>`.
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
207
desktop/tests/e2e/badge.spec.ts
Normal file
207
desktop/tests/e2e/badge.spec.ts
Normal 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" });
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -21,3 +21,5 @@ int? isoToUnixSeconds(Object? value) {
|
||||
|
||||
return dateTimeToUnixSeconds(DateTime.tryParse(value));
|
||||
}
|
||||
|
||||
const readStateMaxClockDriftSeconds = 300;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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+=""$'\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"/}"
|
||||
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'""
|
||||
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+=""$'\n\n'
|
||||
done
|
||||
fi
|
||||
|
||||
gh pr comment "$PR" --repo "$REPO" --body "$COMMENT_BODY"
|
||||
|
||||
Reference in New Issue
Block a user