Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion desktop/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ function AppReady() {
actions={onboarding.flow.actions}
initialProfile={onboarding.flow.initialProfile}
key={onboarding.currentPubkey ?? "anonymous"}
notifications={onboarding.flow.notifications}
/>
);
}
Expand Down
184 changes: 17 additions & 167 deletions desktop/src/features/notifications/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,22 @@ import * as React from "react";

import { useHomeFeedQuery } from "@/features/home/hooks";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import {
resolveUserLabel,
truncatePubkey,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import type { FeedItem, HomeFeedResponse } from "@/shared/api/types";
import {
collectHomeAlertItems,
eligibleFeedNotificationItems,
notificationBody,
notificationTitle,
} from "./lib/feed";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { HomeFeedResponse } from "@/shared/api/types";
import {
getDesktopNotificationPermissionState,
requestDesktopNotificationAccess,
sendDesktopNotification,
type DesktopNotificationPermissionState,
} from "./lib/desktop";
import { playNotificationSound } from "./lib/sound";
import {
readStoredSeenFeedIds,
useFeedDesktopNotifications,
writeStoredSeenFeedIds,
} from "./use-feed-desktop-notifications";

export type { DesktopNotificationPermissionState } from "./lib/desktop";

const NOTIFICATION_SETTINGS_STORAGE_KEY = "sprout-notification-settings.v1";
const HOME_FEED_SEEN_STORAGE_KEY = "sprout-home-feed-seen.v1";
const HOME_FEED_SEEN_MAX_ITEMS = 500;

export type NotificationSettings = {
Expand All @@ -37,7 +29,7 @@ export type NotificationSettings = {
};

const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = {
desktopEnabled: false,
desktopEnabled: true,
homeBadgeEnabled: true,
mentions: true,
needsAction: true,
Expand All @@ -48,10 +40,6 @@ function notificationSettingsStorageKey(pubkey: string) {
return `${NOTIFICATION_SETTINGS_STORAGE_KEY}:${pubkey}`;
}

function homeFeedSeenStorageKey(pubkey: string) {
return `${HOME_FEED_SEEN_STORAGE_KEY}:${pubkey}`;
}

function sanitizeNotificationSettings(value: unknown): NotificationSettings {
if (!value || typeof value !== "object") {
return DEFAULT_NOTIFICATION_SETTINGS;
Expand Down Expand Up @@ -115,41 +103,6 @@ function writeStoredNotificationSettings(
);
}

function readStoredSeenFeedIds(pubkey: string): string[] {
if (typeof window === "undefined" || pubkey.length === 0) {
return [];
}

const rawValue = window.localStorage.getItem(homeFeedSeenStorageKey(pubkey));
if (!rawValue) {
return [];
}

try {
const parsed = JSON.parse(rawValue);
if (!Array.isArray(parsed)) {
return [];
}

return parsed
.filter((value): value is string => typeof value === "string")
.slice(-HOME_FEED_SEEN_MAX_ITEMS);
} catch {
return [];
}
}

function writeStoredSeenFeedIds(pubkey: string, ids: string[]) {
if (typeof window === "undefined" || pubkey.length === 0) {
return;
}

window.localStorage.setItem(
homeFeedSeenStorageKey(pubkey),
JSON.stringify(ids.slice(-HOME_FEED_SEEN_MAX_ITEMS)),
);
}

function mergeSeenFeedIds(current: string[], nextIds: readonly string[]) {
const merged = new Set(current);
let didChange = false;
Expand Down Expand Up @@ -300,125 +253,21 @@ export function useNotificationSettings(pubkey?: string) {
};
}

export function useFeedDesktopNotifications(
feed: HomeFeedResponse | undefined,
pubkey: string | undefined,
settings: NotificationSettings,
profiles?: UserProfileLookup,
) {
const normalizedPubkey = pubkey?.trim().toLowerCase() ?? "";
const seenItemIdsRef = React.useRef<Set<string>>(
new Set(readStoredSeenFeedIds(normalizedPubkey)),
);

React.useEffect(() => {
seenItemIdsRef.current = new Set(readStoredSeenFeedIds(normalizedPubkey));
}, [normalizedPubkey]);

const deliverFeedNotification = React.useEffectEvent(
async (item: FeedItem, senderName?: string) => {
const didSend = await sendDesktopNotification({
body: notificationBody(item),
target: {
channelId: item.channelId,
channelName: item.channelName,
content: item.content,
createdAt: item.createdAt,
eventId: item.id,
kind: item.kind,
pubkey: item.pubkey,
},
title: notificationTitle(item, senderName),
});

if (didSend && settings.soundEnabled) {
playNotificationSound();
}
},
);

React.useEffect(() => {
if (!feed) {
return;
}

// Wait for sender profiles to load so notification titles include names.
// The first-load seed below marks all current items as seen, so we must
// defer it until profiles are available — otherwise items get marked seen
// before we can dispatch notifications with sender names.
if (profiles === undefined) {
return;
}

const currentFeedItems = collectHomeAlertItems(feed);

// Guard: empty seen set + populated feed means first load or cleared
// storage. Seed the seen set without notifying to prevent a flood.
if (seenItemIdsRef.current.size === 0 && currentFeedItems.length > 0) {
seenItemIdsRef.current = new Set(currentFeedItems.map((item) => item.id));
writeStoredSeenFeedIds(normalizedPubkey, [...seenItemIdsRef.current]);
return;
}

const nextSeenItemIds = new Set(seenItemIdsRef.current);
const newItems = settings.desktopEnabled
? eligibleFeedNotificationItems(feed, {
mentions: settings.mentions,
needsAction: settings.needsAction,
}).filter((item) => !nextSeenItemIds.has(item.id))
: [];

for (const item of currentFeedItems) {
nextSeenItemIds.add(item.id);
}

// Prevent unbounded growth — keep only the most recent entries.
if (nextSeenItemIds.size > HOME_FEED_SEEN_MAX_ITEMS) {
const excess = nextSeenItemIds.size - HOME_FEED_SEEN_MAX_ITEMS;
let removed = 0;
for (const id of nextSeenItemIds) {
if (removed >= excess) break;
nextSeenItemIds.delete(id);
removed++;
}
}

seenItemIdsRef.current = nextSeenItemIds;
writeStoredSeenFeedIds(normalizedPubkey, [...nextSeenItemIds]);

for (const item of newItems) {
const resolvedLabel = profiles
? resolveUserLabel({
pubkey: item.pubkey,
profiles,
preferResolvedSelfLabel: true,
})
: undefined;
// Only use real display names, not truncated pubkey fallbacks.
const senderName =
resolvedLabel && resolvedLabel !== truncatePubkey(item.pubkey)
? resolvedLabel
: undefined;
void deliverFeedNotification(item, senderName);
}
}, [
feed,
normalizedPubkey,
profiles,
settings.desktopEnabled,
settings.mentions,
settings.needsAction,
]);
}

export function useHomeFeedNotificationState(
feed: HomeFeedResponse | undefined,
pubkey: string | undefined,
settings: NotificationSettings,
setDesktopEnabled: (enabled: boolean) => Promise<boolean>,
isHomeActive: boolean,
profiles?: UserProfileLookup,
) {
useFeedDesktopNotifications(feed, pubkey, settings, profiles);
useFeedDesktopNotifications(
feed,
pubkey,
settings,
setDesktopEnabled,
profiles,
);
const normalizedPubkey = pubkey?.trim().toLowerCase() ?? "";
const [seenFeedIds, setSeenFeedIds] = React.useState<string[]>(() =>
readStoredSeenFeedIds(normalizedPubkey),
Expand Down Expand Up @@ -519,6 +368,7 @@ export function useHomeFeedNotifications(
homeFeedQuery.data,
pubkey,
notificationSettings.settings,
notificationSettings.setDesktopEnabled,
isHomeActive,
feedProfilesQuery.data?.profiles,
);
Expand Down
13 changes: 12 additions & 1 deletion desktop/src/features/notifications/lib/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,23 @@ export async function getDesktopNotificationPermissionState(): Promise<DesktopNo
}
}

let pendingPermissionRequest: Promise<DesktopNotificationPermissionState> | null =
null;

export async function requestDesktopNotificationAccess(): Promise<DesktopNotificationPermissionState> {
if (!hasNotificationApi()) {
return "unsupported";
}

return requestPermission();
if (pendingPermissionRequest) {
return pendingPermissionRequest;
}

pendingPermissionRequest = requestPermission().finally(() => {
pendingPermissionRequest = null;
});

return pendingPermissionRequest;
}

export async function listenForDesktopNotificationActions(
Expand Down
Loading
Loading