import { useModerationDecision } from "$/components/moderation/hooks/useModerationDecision"; import { ModeratedAvatar } from "$/components/moderation/ModeratedAvatar"; import { ModerationBadgeRow } from "$/components/moderation/ModerationBadgeRow"; import { useThreadOverlayNavigation } from "$/components/posts/hooks/useThreadOverlayNavigation"; import { useAppSession } from "$/contexts/app-session"; import { listNotifications, updateSeen } from "$/lib/api/notifications"; import { NOTIFICATIONS_UNREAD_COUNT_EVENT } from "$/lib/constants/events"; import { getAvatarLabel, getDisplayName } from "$/lib/feeds"; import { collectModerationLabels } from "$/lib/moderation"; import { buildPostRoute } from "$/lib/post-routes"; import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile"; import type { ListNotificationsResponse, NotificationReason, NotificationView, ProfileViewBasic } from "$/lib/types"; import { formatRelativeTime } from "$/lib/utils/text"; import { normalizeError } from "$/lib/utils/text"; import { listen } from "@tauri-apps/api/event"; import * as logger from "@tauri-apps/plugin-log"; import { createMemo, createSignal, For, Match, onCleanup, onMount, type ParentProps, Show, Switch } from "solid-js"; import { Motion, Presence } from "solid-motionone"; import { Icon } from "../shared/Icon"; import { notificationReasonCopy, notificationReasonIcon } from "./notification-copy"; import { buildAllNotificationsFeed, groupActivityNotifications, isMentionNotification, splitByReadState, toSingleFeedItems, } from "./notification-grouping"; import type { GroupedNotificationFeedItem, NotificationFeedItem, SingleNotificationFeedItem, } from "./notification-grouping"; import { NotificationItem } from "./NotificationItem"; type Tab = "all" | "mentions" | "activity"; function hasUnreadNotifications(items: NotificationView[]) { return items.some((notification) => !notification.isRead); } function groupedSummary(item: GroupedNotificationFeedItem) { const [first, second] = item.actors; const action = notificationReasonCopy(item.reason); if (!first) { return `${item.count} accounts ${action}`; } const firstName = getDisplayName(first); if (!second) { return `${firstName} ${action}`; } const secondName = getDisplayName(second); if (item.actorCount === 2) { return `${firstName} and ${secondName} ${action}`; } const others = item.actorCount - 2; const label = others === 1 ? "other" : "others"; return `${firstName}, ${secondName}, and ${others} ${label} ${action}`; } export function NotificationsPanel() { const session = useAppSession(); let threadOverlay: ReturnType | null = null; try { threadOverlay = useThreadOverlayNavigation(); } catch { threadOverlay = null; } const buildPostHref = (uri: string | null) => { if (!uri) { return "/notifications"; } if (threadOverlay) { return threadOverlay.buildThreadHref(uri); } return buildPostRoute(uri); }; const openPost = (uri: string) => { if (threadOverlay) { void threadOverlay.openThread(uri); return; } globalThis.location.hash = `#${buildPostRoute(uri)}`; }; const [tab, setTab] = createSignal("all"); const [notifications, setNotifications] = createSignal([]); const [loading, setLoading] = createSignal(true); const [error, setError] = createSignal(null); let loadRequestId = 0; let markSeenPending = false; const mentionsRaw = createMemo(() => notifications().filter((notification) => isMentionNotification(notification))); const activityRaw = createMemo(() => notifications().filter((notification) => !isMentionNotification(notification))); const mentionsFeed = createMemo(() => toSingleFeedItems(mentionsRaw())); const activityGrouped = createMemo(() => groupActivityNotifications(activityRaw())); const allMixed = createMemo(() => buildAllNotificationsFeed(mentionsRaw(), activityGrouped())); const unreadAll = createMemo(() => notifications().filter((notification) => !notification.isRead).length); const unreadMentions = createMemo(() => mentionsRaw().filter((notification) => !notification.isRead).length); const unreadActivity = createMemo(() => activityRaw().filter((notification) => !notification.isRead).length); async function markSeen() { if (!hasUnreadNotifications(notifications()) || markSeenPending) { return; } markSeenPending = true; try { await updateSeen(); setNotifications((previous) => previous.map((notification) => ({ ...notification, isRead: true }))); session.markNotificationsSeen(); } catch (err) { const errorMessage = normalizeError(err); logger.warn("failed to mark notifications as seen", { keyValues: { error: errorMessage } }); } finally { markSeenPending = false; } } async function load() { const requestId = ++loadRequestId; setLoading(true); setError(null); try { const response: ListNotificationsResponse = await listNotifications(); if (requestId !== loadRequestId) { return; } setNotifications(response.notifications); } catch (err) { if (requestId === loadRequestId) { setError(normalizeError(err)); } } finally { if (requestId === loadRequestId) { setLoading(false); } } } function reloadNotifications() { void load(); } function markReadByUris(uris: string[]) { if (uris.length === 0) { return; } const urisToRead = new Set(uris); const previous = notifications(); let changed = false; const next = previous.map((notification) => { if (notification.isRead || !urisToRead.has(notification.uri)) { return notification; } changed = true; return { ...notification, isRead: true }; }); if (!changed) { return; } setNotifications(next); if (next.every((notification) => notification.isRead)) { session.markNotificationsSeen(); } } onMount(() => { reloadNotifications(); let unlisten: (() => void) | undefined; void listen(NOTIFICATIONS_UNREAD_COUNT_EVENT, reloadNotifications).then((dispose) => { unlisten = dispose; }); onCleanup(() => unlisten?.()); }); return (
void markSeen()} onSelectTab={setTab} />
); } function NotificationsHeader( props: { activeTab: Tab; unreadActivity: number; unreadAll: number; unreadMentions: number; onMarkSeen: () => void; onSelectTab: (tab: Tab) => void; }, ) { return (

Inbox

Notifications

); } function NotificationsViewport( props: { activity: NotificationFeedItem[]; all: NotificationFeedItem[]; buildThreadHref: (uri: string | null) => string; error: string | null; loading: boolean; mentions: SingleNotificationFeedItem[]; onMarkRead: (uris: string[]) => void; onOpenThread: (uri: string) => void; tab: Tab; }, ) { return (
}>
{() => }
); } function NotificationsState(props: { error: string | null; loading: boolean }) { return ( {(message) =>
{message()}
}
); } function NotificationList( props: { ariaLabel: string; buildThreadHref: (uri: string | null) => string; emptyLabel: string; items: NotificationFeedItem[]; onMarkRead: (uris: string[]) => void; onOpenThread: (uri: string) => void; }, ) { const sections = createMemo(() => splitByReadState(props.items)); return ( 0} fallback={}>
0}> 0}>
); } function NotificationSection( props: { ariaLabel: string; buildThreadHref: (uri: string | null) => string; items: NotificationFeedItem[]; label: string; onMarkRead: (uris: string[]) => void; onOpenThread: (uri: string) => void; }, ) { return (

{props.label}

{(item, index) => ( )}
); } function NotificationFeedRow( props: { buildThreadHref: (uri: string | null) => string; item: NotificationFeedItem; onMarkRead: (uris: string[]) => void; onOpenThread: (uri: string) => void; }, ) { return ( ); } function GroupedReasonIcon(props: { reason: NotificationReason }) { const icon = createMemo(() => notificationReasonIcon(props.reason)); return (
); } function GroupedAuthorAvatar(props: { actor: ProfileViewBasic; onClick: () => void }) { const label = createMemo(() => getAvatarLabel(props.actor)); const profileHref = createMemo(() => buildProfileRoute(getProfileRouteActor(props.actor))); const labels = () => collectModerationLabels(props.actor); const decision = useModerationDecision(labels, "avatar"); return ( { event.stopPropagation(); props.onClick(); }}> ); } function GroupedNotificationItem( props: { item: GroupedNotificationFeedItem; onMarkRead: (uris: string[]) => void; onOpenThread: (uri: string) => void; }, ) { const time = createMemo(() => formatRelativeTime(props.item.latestIndexedAt)); const summary = createMemo(() => groupedSummary(props.item)); const actors = createMemo(() => props.item.actors.slice(0, 3)); const profileLabels = () => collectModerationLabels(...props.item.actors); const profileDecision = useModerationDecision(profileLabels, "profileList"); const bodyTargetUri = createMemo(() => props.item.reasonSubject ?? null); const bodyInteractive = createMemo(() => !!bodyTargetUri()); const memberUris = createMemo(() => props.item.notifications.map((notification) => notification.uri)); function openBodyTarget() { const uri = bodyTargetUri(); if (!uri) { return; } props.onMarkRead(memberUris()); props.onOpenThread(uri); } return (
{(actor) => props.onMarkRead(memberUris())} />}

{summary()}

{(value) =>

{value()}

}

{time()}

); } function InteractiveBodyRegion(props: ParentProps<{ active: boolean; onActivate: () => void }>) { return (
props.onActivate()} onKeyDown={(event) => { if ((event.key === "Enter" || event.key === " ") && props.active) { event.preventDefault(); props.onActivate(); } }}> {props.children}
); } function TabButton(props: { active: boolean; badge: number; label: string; onClick: () => void }) { return ( ); } function EmptyState(props: { label: string }) { return (
{props.label}
); } function NotificationSkeleton() { return (
); }