mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Add desktop notifications and improve DM labeling (#107)
This commit is contained in:
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user