Add desktop notifications and improve DM labeling (#107)

This commit is contained in:
Wes
2026-03-18 13:27:15 -07:00
committed by GitHub
parent 42cf6c1dc8
commit ba1c8df5c6
20 changed files with 1426 additions and 218 deletions
@@ -74,6 +74,7 @@ function getMessageTimestamp(event: RelayEvent) {
export function useUnreadChannels(
channels: Channel[],
activeChannel: Channel | null,
activeReadAt?: string | null,
) {
const queryClient = useQueryClient();
const [lastReadByChannel, setLastReadByChannel] =
@@ -81,6 +82,7 @@ export function useUnreadChannels(
const hasInitializedChannelsRef = React.useRef(false);
const activeChannelId = activeChannel?.id ?? null;
const activeChannelLastMessageAt = activeChannel?.lastMessageAt ?? null;
const effectiveActiveReadAt = activeReadAt ?? activeChannelLastMessageAt;
const markChannelRead = React.useCallback(
(channelId: string, readAt: string | null | undefined) => {
@@ -163,8 +165,8 @@ export function useUnreadChannels(
return;
}
markChannelRead(activeChannelId, activeChannelLastMessageAt);
}, [activeChannelId, activeChannelLastMessageAt, markChannelRead]);
markChannelRead(activeChannelId, effectiveActiveReadAt);
}, [activeChannelId, effectiveActiveReadAt, markChannelRead]);
const liveChannelIds = React.useMemo(
() =>
+483
View File
@@ -0,0 +1,483 @@
import * as React from "react";
import { useHomeFeedQuery } from "@/features/home/hooks";
import type { FeedItem, HomeFeedResponse } from "@/shared/api/types";
import {
getDesktopNotificationPermissionState,
requestDesktopNotificationAccess,
sendDesktopNotification,
type DesktopNotificationPermissionState,
} from "./lib/desktop";
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 FEED_NOTIFICATION_BODY_MAX_LENGTH = 140;
const HOME_FEED_SEEN_MAX_ITEMS = 500;
export type NotificationSettings = {
desktopEnabled: boolean;
homeBadgeEnabled: boolean;
mentions: boolean;
needsAction: boolean;
};
const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = {
desktopEnabled: false,
homeBadgeEnabled: true,
mentions: true,
needsAction: true,
};
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;
}
const candidate = value as Partial<NotificationSettings>;
return {
desktopEnabled:
typeof candidate.desktopEnabled === "boolean"
? candidate.desktopEnabled
: DEFAULT_NOTIFICATION_SETTINGS.desktopEnabled,
homeBadgeEnabled:
typeof candidate.homeBadgeEnabled === "boolean"
? candidate.homeBadgeEnabled
: DEFAULT_NOTIFICATION_SETTINGS.homeBadgeEnabled,
mentions:
typeof candidate.mentions === "boolean"
? candidate.mentions
: DEFAULT_NOTIFICATION_SETTINGS.mentions,
needsAction:
typeof candidate.needsAction === "boolean"
? candidate.needsAction
: DEFAULT_NOTIFICATION_SETTINGS.needsAction,
};
}
function readStoredNotificationSettings(pubkey: string): NotificationSettings {
if (typeof window === "undefined" || pubkey.length === 0) {
return DEFAULT_NOTIFICATION_SETTINGS;
}
const rawValue = window.localStorage.getItem(
notificationSettingsStorageKey(pubkey),
);
if (!rawValue) {
return DEFAULT_NOTIFICATION_SETTINGS;
}
try {
return sanitizeNotificationSettings(JSON.parse(rawValue));
} catch {
return DEFAULT_NOTIFICATION_SETTINGS;
}
}
function writeStoredNotificationSettings(
pubkey: string,
settings: NotificationSettings,
) {
if (typeof window === "undefined" || pubkey.length === 0) {
return;
}
window.localStorage.setItem(
notificationSettingsStorageKey(pubkey),
JSON.stringify(settings),
);
}
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;
for (const id of nextIds) {
if (merged.has(id)) {
continue;
}
merged.add(id);
didChange = true;
}
if (!didChange) {
return current;
}
const values = [...merged];
return values.length <= HOME_FEED_SEEN_MAX_ITEMS
? values
: values.slice(values.length - HOME_FEED_SEEN_MAX_ITEMS);
}
function notificationTitle(item: FeedItem) {
const channelLabel = item.channelName.trim()
? ` in #${item.channelName.trim()}`
: "";
if (item.category === "mention") {
return `@Mention${channelLabel}`;
}
if (item.kind === 46010) {
return `Approval Requested${channelLabel}`;
}
return `Needs Action${channelLabel}`;
}
function notificationBody(item: FeedItem) {
const content = item.content.trim();
const fallback =
item.kind === 46010
? "A workflow is waiting for your approval."
: "Something in Sprout needs your attention.";
const body = content.length > 0 ? content : fallback;
if (body.length <= FEED_NOTIFICATION_BODY_MAX_LENGTH) {
return body;
}
return `${body.slice(0, FEED_NOTIFICATION_BODY_MAX_LENGTH - 3).trimEnd()}...`;
}
function collectHomeAlertItems(feed: HomeFeedResponse) {
return [...feed.feed.mentions, ...feed.feed.needsAction];
}
function eligibleFeedNotificationItems(
feed: HomeFeedResponse,
options: Pick<NotificationSettings, "mentions" | "needsAction">,
) {
const items: FeedItem[] = [];
if (options.mentions) {
items.push(...feed.feed.mentions);
}
if (options.needsAction) {
items.push(...feed.feed.needsAction);
}
return items.sort((left, right) => left.createdAt - right.createdAt);
}
export function useNotificationSettings(pubkey?: string) {
const normalizedPubkey = pubkey?.trim().toLowerCase() ?? "";
const [settings, setSettings] = React.useState<NotificationSettings>(() =>
readStoredNotificationSettings(normalizedPubkey),
);
const [permission, setPermission] =
React.useState<DesktopNotificationPermissionState>("default");
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
const [isUpdatingDesktopEnabled, setIsUpdatingDesktopEnabled] =
React.useState(false);
React.useEffect(() => {
setSettings(readStoredNotificationSettings(normalizedPubkey));
setErrorMessage(null);
}, [normalizedPubkey]);
React.useEffect(() => {
writeStoredNotificationSettings(normalizedPubkey, settings);
}, [normalizedPubkey, settings]);
const refreshPermission = React.useEffectEvent(async () => {
const nextPermission = await getDesktopNotificationPermissionState();
setPermission(nextPermission);
return nextPermission;
});
React.useEffect(() => {
void normalizedPubkey;
void refreshPermission();
}, [normalizedPubkey]);
const setDesktopEnabled = React.useCallback(async (enabled: boolean) => {
if (!enabled) {
setErrorMessage(null);
setSettings((current) => ({
...current,
desktopEnabled: false,
}));
void refreshPermission();
return true;
}
setIsUpdatingDesktopEnabled(true);
setErrorMessage(null);
try {
let nextPermission = await refreshPermission();
if (nextPermission === "default") {
nextPermission = await requestDesktopNotificationAccess();
setPermission(nextPermission);
}
if (nextPermission !== "granted") {
setSettings((current) => ({
...current,
desktopEnabled: false,
}));
setErrorMessage(
nextPermission === "denied"
? "Desktop notifications are blocked for Sprout. Enable them in system settings to turn alerts on."
: "Desktop notifications are unavailable in this environment.",
);
return false;
}
setSettings((current) => ({
...current,
desktopEnabled: true,
}));
return true;
} catch (error) {
setSettings((current) => ({
...current,
desktopEnabled: false,
}));
setErrorMessage(
error instanceof Error
? error.message
: "Failed to enable desktop notifications.",
);
return false;
} finally {
setIsUpdatingDesktopEnabled(false);
}
}, []);
const setHomeBadgeEnabled = React.useCallback((enabled: boolean) => {
setSettings((current) => ({
...current,
homeBadgeEnabled: enabled,
}));
}, []);
const setMentionsEnabled = React.useCallback((enabled: boolean) => {
setSettings((current) => ({
...current,
mentions: enabled,
}));
}, []);
const setNeedsActionEnabled = React.useCallback((enabled: boolean) => {
setSettings((current) => ({
...current,
needsAction: enabled,
}));
}, []);
return {
errorMessage,
isUpdatingDesktopEnabled,
permission,
setDesktopEnabled,
setHomeBadgeEnabled,
setMentionsEnabled,
setNeedsActionEnabled,
settings,
};
}
export function useFeedDesktopNotifications(
feed: HomeFeedResponse | undefined,
pubkey: string | undefined,
settings: NotificationSettings,
) {
const normalizedPubkey = pubkey?.trim().toLowerCase() ?? "";
const seenItemIdsRef = React.useRef<Set<string>>(new Set());
const hasInitializedFeedRef = React.useRef(false);
React.useEffect(() => {
void normalizedPubkey;
seenItemIdsRef.current = new Set();
hasInitializedFeedRef.current = false;
}, [normalizedPubkey]);
const deliverFeedNotification = React.useEffectEvent(
async (item: FeedItem) => {
await sendDesktopNotification({
body: notificationBody(item),
title: notificationTitle(item),
});
},
);
React.useEffect(() => {
if (!feed) {
return;
}
const currentFeedItems = collectHomeAlertItems(feed);
if (!hasInitializedFeedRef.current) {
hasInitializedFeedRef.current = true;
seenItemIdsRef.current = new Set(currentFeedItems.map((item) => item.id));
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);
}
seenItemIdsRef.current = nextSeenItemIds;
for (const item of newItems) {
void deliverFeedNotification(item);
}
}, [feed, settings.desktopEnabled, settings.mentions, settings.needsAction]);
}
export function useHomeFeedNotificationState(
feed: HomeFeedResponse | undefined,
pubkey: string | undefined,
settings: NotificationSettings,
isHomeActive: boolean,
) {
useFeedDesktopNotifications(feed, pubkey, settings);
const normalizedPubkey = pubkey?.trim().toLowerCase() ?? "";
const [seenFeedIds, setSeenFeedIds] = React.useState<string[]>(() =>
readStoredSeenFeedIds(normalizedPubkey),
);
const currentFeedIds = React.useMemo(
() =>
feed
? [...feed.feed.mentions, ...feed.feed.needsAction].map(
(item) => item.id,
)
: [],
[feed],
);
React.useEffect(() => {
setSeenFeedIds(readStoredSeenFeedIds(normalizedPubkey));
}, [normalizedPubkey]);
React.useEffect(() => {
writeStoredSeenFeedIds(normalizedPubkey, seenFeedIds);
}, [normalizedPubkey, seenFeedIds]);
const markCurrentFeedSeen = React.useEffectEvent(() => {
setSeenFeedIds((current) => mergeSeenFeedIds(current, currentFeedIds));
});
React.useEffect(() => {
if (!isHomeActive || currentFeedIds.length === 0) {
return;
}
void normalizedPubkey;
markCurrentFeedSeen();
}, [currentFeedIds, isHomeActive, normalizedPubkey]);
return React.useMemo(() => {
if (!settings.homeBadgeEnabled || isHomeActive) {
return 0;
}
if (currentFeedIds.length === 0) {
return 0;
}
const seenFeedIdSet = new Set(seenFeedIds);
return currentFeedIds.filter((id) => !seenFeedIdSet.has(id)).length;
}, [currentFeedIds, isHomeActive, seenFeedIds, settings.homeBadgeEnabled]);
}
export function useHomeFeedNotifications(
pubkey: string | undefined,
isHomeActive: boolean,
) {
const notificationSettings = useNotificationSettings(pubkey);
const homeFeedQuery = useHomeFeedQuery();
const refetchHomeFeedForE2e = React.useEffectEvent(() => {
void homeFeedQuery.refetch();
});
React.useEffect(() => {
if (typeof window === "undefined") {
return;
}
function handleMockHomeFeedUpdate() {
refetchHomeFeedForE2e();
}
window.addEventListener(
"sprout:e2e-home-feed-updated",
handleMockHomeFeedUpdate,
);
return () => {
window.removeEventListener(
"sprout:e2e-home-feed-updated",
handleMockHomeFeedUpdate,
);
};
}, []);
const homeBadgeCount = useHomeFeedNotificationState(
homeFeedQuery.data,
pubkey,
notificationSettings.settings,
isHomeActive,
);
return {
homeBadgeCount,
homeFeedQuery,
notificationSettings,
};
}
@@ -0,0 +1,61 @@
import { isTauri } from "@tauri-apps/api/core";
import {
isPermissionGranted,
requestPermission,
sendNotification,
} from "@tauri-apps/plugin-notification";
export type DesktopNotificationPermissionState =
| NotificationPermission
| "unsupported";
type DesktopNotificationPayload = {
body?: string;
title: string;
};
function hasNotificationApi() {
return typeof window !== "undefined" && "Notification" in window;
}
export async function getDesktopNotificationPermissionState(): Promise<DesktopNotificationPermissionState> {
if (!hasNotificationApi()) {
return "unsupported";
}
if (window.Notification.permission !== "default") {
return window.Notification.permission;
}
if (!isTauri()) {
return "default";
}
try {
return (await isPermissionGranted()) ? "granted" : "default";
} catch {
return "default";
}
}
export async function requestDesktopNotificationAccess(): Promise<DesktopNotificationPermissionState> {
if (!hasNotificationApi()) {
return "unsupported";
}
return requestPermission();
}
export async function sendDesktopNotification(
payload: DesktopNotificationPayload,
): Promise<boolean> {
if ((await getDesktopNotificationPermissionState()) !== "granted") {
return false;
}
sendNotification({
body: payload.body,
title: payload.title,
});
return true;
}
@@ -0,0 +1,202 @@
import {
AtSign,
BellRing,
CircleAlert,
Home as HomeIcon,
type LucideIcon,
} from "lucide-react";
import type {
DesktopNotificationPermissionState,
NotificationSettings,
} from "@/features/notifications/hooks";
import { cn } from "@/shared/lib/cn";
function notificationStatusLabel(
desktopEnabled: boolean,
permission: DesktopNotificationPermissionState,
) {
if (permission === "unsupported") {
return "Unavailable";
}
if (permission === "denied") {
return "Blocked";
}
return desktopEnabled ? "On" : "Off";
}
function notificationStatusClassName(
desktopEnabled: boolean,
permission: DesktopNotificationPermissionState,
) {
if (permission === "unsupported" || permission === "denied") {
return "border-destructive/30 bg-destructive/10 text-destructive";
}
if (desktopEnabled) {
return "border-primary/30 bg-primary/10 text-primary";
}
return "border-border/80 bg-muted text-muted-foreground";
}
function NotificationPreferenceCard({
description,
disabled = false,
enabled,
icon: Icon,
onToggle,
testId,
title,
}: {
description: string;
disabled?: boolean;
enabled: boolean;
icon: LucideIcon;
onToggle: () => void;
testId: string;
title: string;
}) {
return (
<button
aria-pressed={enabled}
className={cn(
"flex min-h-24 flex-col items-start justify-between rounded-xl border px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
enabled
? "border-primary bg-primary/10 text-foreground"
: "border-border/80 bg-background/60 text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
data-testid={testId}
disabled={disabled}
onClick={onToggle}
type="button"
>
<div className="flex items-center gap-2">
<Icon className="h-4 w-4" />
<span className="font-medium text-foreground">{title}</span>
</div>
<p className="text-sm text-muted-foreground">{description}</p>
</button>
);
}
export function NotificationSettingsCard({
isUpdatingDesktopNotifications,
notificationErrorMessage,
notificationPermission,
notificationSettings,
onSetDesktopNotificationsEnabled,
onSetHomeBadgeEnabled,
onSetMentionNotificationsEnabled,
onSetNeedsActionNotificationsEnabled,
}: {
isUpdatingDesktopNotifications: boolean;
notificationErrorMessage: string | null;
notificationPermission: DesktopNotificationPermissionState;
notificationSettings: NotificationSettings;
onSetDesktopNotificationsEnabled: (enabled: boolean) => Promise<boolean>;
onSetHomeBadgeEnabled: (enabled: boolean) => void;
onSetMentionNotificationsEnabled: (enabled: boolean) => void;
onSetNeedsActionNotificationsEnabled: (enabled: boolean) => void;
}) {
return (
<section
className="rounded-xl border border-border/80 bg-card/80 p-4 shadow-sm"
data-testid="settings-notifications"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h2 className="text-sm font-semibold tracking-tight">
Notifications
</h2>
<p className="text-sm text-muted-foreground">
Zero by default. Keep channel noise inside Home, then opt in to
desktop alerts only for the items that truly need you.
</p>
</div>
<span
className={cn(
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em]",
notificationStatusClassName(
notificationSettings.desktopEnabled,
notificationPermission,
),
)}
data-testid="notifications-desktop-state"
>
{notificationStatusLabel(
notificationSettings.desktopEnabled,
notificationPermission,
)}
</span>
</div>
<div className="mt-4 grid gap-2 md:grid-cols-2">
<NotificationPreferenceCard
description={
notificationSettings.desktopEnabled
? "Native desktop alerts are enabled for the categories you have armed below."
: "Request OS permission and surface new mentions or needs-action items outside the app."
}
disabled={isUpdatingDesktopNotifications}
enabled={notificationSettings.desktopEnabled}
icon={BellRing}
onToggle={() => {
void onSetDesktopNotificationsEnabled(
!notificationSettings.desktopEnabled,
);
}}
testId="notifications-desktop-toggle"
title={
isUpdatingDesktopNotifications ? "Requesting..." : "Desktop alerts"
}
/>
<NotificationPreferenceCard
description="Show a Home badge for mentions and needs-action items in the sidebar."
enabled={notificationSettings.homeBadgeEnabled}
icon={HomeIcon}
onToggle={() => {
onSetHomeBadgeEnabled(!notificationSettings.homeBadgeEnabled);
}}
testId="notifications-home-badge-toggle"
title="Home badge"
/>
<NotificationPreferenceCard
description="Alert when someone tags your pubkey in a channel you can access."
enabled={notificationSettings.mentions}
icon={AtSign}
onToggle={() => {
onSetMentionNotificationsEnabled(!notificationSettings.mentions);
}}
testId="notifications-mentions-toggle"
title="@Mentions"
/>
<NotificationPreferenceCard
description="Alert for reminders and workflow approvals that are waiting on you."
enabled={notificationSettings.needsAction}
icon={CircleAlert}
onToggle={() => {
onSetNeedsActionNotificationsEnabled(
!notificationSettings.needsAction,
);
}}
testId="notifications-needs-action-toggle"
title="Needs action"
/>
</div>
{notificationErrorMessage ? (
<p className="mt-4 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{notificationErrorMessage}
</p>
) : null}
<p className="mt-4 text-sm text-muted-foreground">
The Home badge is an in-app signal. Desktop alerts only fire for new
feed items after you enable them.
</p>
</section>
);
}
@@ -1,4 +1,5 @@
import {
BellRing,
CircleDot,
KeyRound,
MonitorCog,
@@ -8,6 +9,10 @@ import {
UserRound,
type LucideIcon,
} from "lucide-react";
import type {
DesktopNotificationPermissionState,
NotificationSettings,
} from "@/features/notifications/hooks";
import {
PresenceBadge,
PresenceDot,
@@ -17,11 +22,13 @@ import type { PresenceStatus } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { useTheme } from "@/shared/theme/ThemeProvider";
import { DoctorSettingsPanel } from "./DoctorSettingsPanel";
import { NotificationSettingsCard } from "./NotificationSettingsCard";
import { ProfileSettingsCard } from "./ProfileSettingsCard";
export type SettingsSection =
| "profile"
| "presence"
| "notifications"
| "appearance"
| "tokens"
| "doctor";
@@ -37,8 +44,16 @@ export type SettingsSectionDescriptor = {
export type SettingsPanelProps = {
currentPubkey?: string;
fallbackDisplayName?: string;
isUpdatingDesktopNotifications: boolean;
isPresenceLoading: boolean;
isUpdatingPresence: boolean;
notificationErrorMessage: string | null;
notificationPermission: DesktopNotificationPermissionState;
notificationSettings: NotificationSettings;
onSetDesktopNotificationsEnabled: (enabled: boolean) => Promise<boolean>;
onSetHomeBadgeEnabled: (enabled: boolean) => void;
onSetMentionNotificationsEnabled: (enabled: boolean) => void;
onSetNeedsActionNotificationsEnabled: (enabled: boolean) => void;
onSetPresence: (status: PresenceStatus) => Promise<void>;
presenceError: Error | null;
presenceStatus: PresenceStatus;
@@ -61,6 +76,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [
label: "Presence",
icon: CircleDot,
},
{
value: "notifications",
label: "Notifications",
icon: BellRing,
},
{
value: "appearance",
label: "Appearance",
@@ -276,6 +296,25 @@ export function renderSettingsSection(
presenceStatus={props.presenceStatus}
/>
);
case "notifications":
return (
<NotificationSettingsCard
isUpdatingDesktopNotifications={props.isUpdatingDesktopNotifications}
notificationErrorMessage={props.notificationErrorMessage}
notificationPermission={props.notificationPermission}
notificationSettings={props.notificationSettings}
onSetDesktopNotificationsEnabled={
props.onSetDesktopNotificationsEnabled
}
onSetHomeBadgeEnabled={props.onSetHomeBadgeEnabled}
onSetMentionNotificationsEnabled={
props.onSetMentionNotificationsEnabled
}
onSetNeedsActionNotificationsEnabled={
props.onSetNeedsActionNotificationsEnabled
}
/>
);
case "appearance":
return <ThemeSettingsCard />;
case "tokens":
@@ -76,10 +76,18 @@ function SettingsSectionButton({
export function SettingsView({
currentPubkey,
fallbackDisplayName,
isUpdatingDesktopNotifications,
isPresenceLoading,
isUpdatingPresence,
notificationErrorMessage,
notificationPermission,
notificationSettings,
onClose,
onSectionChange,
onSetDesktopNotificationsEnabled,
onSetHomeBadgeEnabled,
onSetMentionNotificationsEnabled,
onSetNeedsActionNotificationsEnabled,
onSetPresence,
presenceError,
presenceStatus,
@@ -146,8 +154,16 @@ export function SettingsView({
{renderSettingsSection(section, {
currentPubkey,
fallbackDisplayName,
isUpdatingDesktopNotifications,
isPresenceLoading,
isUpdatingPresence,
notificationErrorMessage,
notificationPermission,
notificationSettings,
onSetDesktopNotificationsEnabled,
onSetHomeBadgeEnabled,
onSetMentionNotificationsEnabled,
onSetNeedsActionNotificationsEnabled,
onSetPresence,
presenceError,
presenceStatus,
@@ -0,0 +1,49 @@
import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import type { Channel } from "@/shared/api/types";
function isGenericDmChannelName(name: string) {
const normalized = name.trim().toLowerCase();
return (
normalized.length === 0 ||
normalized === "dm" ||
normalized === "direct message" ||
normalized === "direct messages"
);
}
export function resolveChannelDisplayLabel(
channel: Channel,
currentPubkey: string | undefined,
profiles: UserProfileLookup | undefined,
) {
if (channel.channelType !== "dm" || !isGenericDmChannelName(channel.name)) {
return channel.name;
}
const participants = channel.participantPubkeys.map((pubkey, index) => ({
fallbackName: channel.participants[index] ?? null,
pubkey,
}));
const otherParticipants = currentPubkey
? participants.filter(
(participant) =>
participant.pubkey.toLowerCase() !== currentPubkey.toLowerCase(),
)
: participants;
const resolvedLabels = (
otherParticipants.length > 0 ? otherParticipants : participants
).map((participant) =>
resolveUserLabel({
currentPubkey,
fallbackName: participant.fallbackName,
profiles,
pubkey: participant.pubkey,
}),
);
const uniqueLabels = [...new Set(resolvedLabels)];
return uniqueLabels.length > 0 ? uniqueLabels.join(", ") : channel.name;
}
+35 -116
View File
@@ -1,23 +1,19 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import {
Bot,
CircleDot,
Compass,
FileText,
Hash,
Home,
Plus,
Search,
} from "lucide-react";
import { Bot, Compass, Home, Plus, Search } from "lucide-react";
import * as React from "react";
import { useManagedAgentsQuery } from "@/features/agents/hooks";
import { getPresenceLabel } from "@/features/presence/lib/presence";
import { usePresenceQuery } from "@/features/presence/hooks";
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { resolveChannelDisplayLabel } from "@/features/sidebar/lib/channelLabels";
import {
ChannelMenuButton,
SidebarSection,
} from "@/features/sidebar/ui/SidebarSection";
import type { Channel, PresenceStatus, Profile } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import {
@@ -41,6 +37,7 @@ type AppSidebarProps = {
channels: Channel[];
currentPubkey?: string;
fallbackDisplayName?: string;
homeBadgeCount: number;
isLoading: boolean;
isCreatingChannel: boolean;
isCreatingForum: boolean;
@@ -66,111 +63,6 @@ type AppSidebarProps = {
onSelectSettings: () => void;
};
function SidebarChannelIcon({ channel }: { channel: Channel }) {
if (channel.channelType === "dm") {
return <CircleDot className="h-4 w-4" />;
}
if (channel.channelType === "forum") {
return <FileText className="h-4 w-4" />;
}
return <Hash className="h-4 w-4" />;
}
function ChannelMenuButton({
channel,
isActive,
hasUnread,
presenceStatus,
onSelectChannel,
}: {
channel: Channel;
isActive: boolean;
hasUnread: boolean;
presenceStatus?: PresenceStatus;
onSelectChannel: (channelId: string) => void;
}) {
return (
<SidebarMenuButton
className={cn(
!isActive &&
hasUnread &&
"font-semibold text-sidebar-foreground hover:text-sidebar-foreground",
)}
data-testid={`channel-${channel.name}`}
isActive={isActive}
onClick={() => onSelectChannel(channel.id)}
tooltip={channel.name}
type="button"
>
<SidebarChannelIcon channel={channel} />
<span className="min-w-0 flex-1 truncate">{channel.name}</span>
<div className="ml-auto flex items-center gap-2">
{presenceStatus ? (
<PresenceDot
className="h-2 w-2"
data-testid={`channel-presence-${channel.name}`}
status={presenceStatus}
/>
) : null}
{hasUnread && !isActive ? (
<span
aria-hidden="true"
className="h-2.5 w-2.5 shrink-0 rounded-full bg-primary"
data-testid={`channel-unread-${channel.name}`}
/>
) : null}
</div>
</SidebarMenuButton>
);
}
function SidebarSection({
items,
isActiveChannel,
presenceByChannelId,
selectedChannelId,
title,
testId,
unreadChannelIds,
onSelectChannel,
}: {
items: Channel[];
isActiveChannel: boolean;
presenceByChannelId?: Record<string, PresenceStatus>;
selectedChannelId: string | null;
title: string;
testId: string;
unreadChannelIds: Set<string>;
onSelectChannel: (channelId: string) => void;
}) {
if (items.length === 0) {
return null;
}
return (
<SidebarGroup>
<SidebarGroupLabel>{title}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu data-testid={testId}>
{items.map((channel) => (
<SidebarMenuItem key={channel.id}>
<ChannelMenuButton
channel={channel}
hasUnread={unreadChannelIds.has(channel.id)}
isActive={isActiveChannel && selectedChannelId === channel.id}
presenceStatus={presenceByChannelId?.[channel.id]}
onSelectChannel={onSelectChannel}
/>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
function StreamsSection({
items,
isCreateOpen,
@@ -425,6 +317,7 @@ export function AppSidebar({
channels,
currentPubkey,
fallbackDisplayName,
homeBadgeCount,
isLoading,
isCreatingChannel,
isCreatingForum,
@@ -479,6 +372,9 @@ export function AppSidebar({
const dmPresenceQuery = usePresenceQuery(dmParticipantPubkeys, {
enabled: directMessages.length > 0,
});
const dmProfilesQuery = useUsersBatchQuery(dmParticipantPubkeys, {
enabled: directMessages.length > 0,
});
const dmPresenceByChannelId = React.useMemo(
() =>
Object.fromEntries(
@@ -498,6 +394,20 @@ export function AppSidebar({
) satisfies Record<string, PresenceStatus>,
[currentPubkey, directMessages, dmPresenceQuery.data],
);
const dmChannelLabels = React.useMemo(
() =>
Object.fromEntries(
directMessages.map((channel) => [
channel.id,
resolveChannelDisplayLabel(
channel,
currentPubkey,
dmProfilesQuery.data?.profiles,
),
]),
),
[currentPubkey, directMessages, dmProfilesQuery.data],
);
const managedAgentsQuery = useManagedAgentsQuery();
const totalAgentCount = managedAgentsQuery.data?.length ?? 0;
const shouldShowAgentCount =
@@ -626,6 +536,14 @@ export function AppSidebar({
<Home className="h-4 w-4" />
<span>Home</span>
</SidebarMenuButton>
{homeBadgeCount > 0 ? (
<SidebarMenuBadge
className="right-2 rounded-full bg-primary/15 px-1.5 text-[11px] text-primary peer-data-[active=true]/menu-button:bg-sidebar-primary-foreground/20 peer-data-[active=true]/menu-button:text-sidebar-primary-foreground"
data-testid="sidebar-home-count"
>
{Math.min(homeBadgeCount, 99)}
</SidebarMenuBadge>
) : null}
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
@@ -753,6 +671,7 @@ export function AppSidebar({
<SidebarSection
isActiveChannel={selectedView === "channel"}
items={directMessages}
channelLabels={dmChannelLabels}
onSelectChannel={onSelectChannel}
presenceByChannelId={dmPresenceByChannelId}
selectedChannelId={selectedChannelId}
@@ -0,0 +1,126 @@
import { CircleDot, FileText, Hash } from "lucide-react";
import type { Channel, PresenceStatus } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/shared/ui/sidebar";
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
function SidebarChannelIcon({ channel }: { channel: Channel }) {
if (channel.channelType === "dm") {
return <CircleDot className="h-4 w-4" />;
}
if (channel.channelType === "forum") {
return <FileText className="h-4 w-4" />;
}
return <Hash className="h-4 w-4" />;
}
export function ChannelMenuButton({
channel,
label,
isActive,
hasUnread,
presenceStatus,
onSelectChannel,
}: {
channel: Channel;
label?: string;
isActive: boolean;
hasUnread: boolean;
presenceStatus?: PresenceStatus;
onSelectChannel: (channelId: string) => void;
}) {
const resolvedLabel = label ?? channel.name;
return (
<SidebarMenuButton
className={cn(
!isActive &&
hasUnread &&
"font-semibold text-sidebar-foreground hover:text-sidebar-foreground",
)}
data-testid={`channel-${channel.name}`}
isActive={isActive}
onClick={() => onSelectChannel(channel.id)}
tooltip={resolvedLabel}
type="button"
>
<SidebarChannelIcon channel={channel} />
<span className="min-w-0 flex-1 truncate">{resolvedLabel}</span>
<div className="ml-auto flex items-center gap-2">
{presenceStatus ? (
<PresenceDot
className="h-2 w-2"
data-testid={`channel-presence-${channel.name}`}
status={presenceStatus}
/>
) : null}
{hasUnread && !isActive ? (
<span
aria-hidden="true"
className="h-2.5 w-2.5 shrink-0 rounded-full bg-primary"
data-testid={`channel-unread-${channel.name}`}
/>
) : null}
</div>
</SidebarMenuButton>
);
}
export function SidebarSection({
items,
channelLabels,
isActiveChannel,
presenceByChannelId,
selectedChannelId,
title,
testId,
unreadChannelIds,
onSelectChannel,
}: {
items: Channel[];
channelLabels?: Record<string, string>;
isActiveChannel: boolean;
presenceByChannelId?: Record<string, PresenceStatus>;
selectedChannelId: string | null;
title: string;
testId: string;
unreadChannelIds: Set<string>;
onSelectChannel: (channelId: string) => void;
}) {
if (items.length === 0) {
return null;
}
return (
<SidebarGroup>
<SidebarGroupLabel>{title}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu data-testid={testId}>
{items.map((channel) => (
<SidebarMenuItem key={channel.id}>
<ChannelMenuButton
channel={channel}
hasUnread={unreadChannelIds.has(channel.id)}
isActive={isActiveChannel && selectedChannelId === channel.id}
label={channelLabels?.[channel.id] ?? channel.name}
presenceStatus={presenceByChannelId?.[channel.id]}
onSelectChannel={onSelectChannel}
/>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}