BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
1import { useModerationDecision } from "$/components/moderation/hooks/useModerationDecision";
2import { ModeratedAvatar } from "$/components/moderation/ModeratedAvatar";
3import { ModeratedBlurOverlay } from "$/components/moderation/ModeratedBlurOverlay";
4import { ModerationBadgeRow } from "$/components/moderation/ModerationBadgeRow";
5import { Icon } from "$/components/shared/Icon";
6import { getAvatarLabel, getDisplayName } from "$/lib/feeds";
7import { collectModerationLabels } from "$/lib/moderation";
8import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile";
9import type { NotificationReason, NotificationView } from "$/lib/types";
10import { formatRelativeTime } from "$/lib/utils/text";
11import { createMemo, Show } from "solid-js";
12import {
13 notificationBodyTargetUri,
14 notificationOriginalPostUri,
15 notificationReasonCopy,
16 notificationReasonIcon,
17} from "./notification-copy";
18
19function ReasonIcon(props: { reason: NotificationReason }) {
20 const icon = createMemo(() => notificationReasonIcon(props.reason));
21
22 return (
23 <div class="flex w-8 shrink-0 justify-center pt-0.5">
24 <Icon kind={icon().kind} class={icon().className} aria-hidden />
25 </div>
26 );
27}
28
29type NotificationItemProps = { notification: NotificationView };
30type NotificationInteractionProps = {
31 buildThreadHref?: (uri: string | null) => string;
32 onMarkRead?: (uris: string[]) => void;
33 onOpenThread?: (uri: string) => void;
34};
35
36export function NotificationItem(props: NotificationItemProps & NotificationInteractionProps) {
37 const name = createMemo(() => getDisplayName(props.notification.author));
38 const description = createMemo(() => notificationReasonCopy(props.notification.reason));
39 const time = createMemo(() => formatRelativeTime(props.notification.indexedAt));
40 const avatarLabel = createMemo(() => getAvatarLabel(props.notification.author));
41 const profileHref = createMemo(() => buildProfileRoute(getProfileRouteActor(props.notification.author)));
42 const bodyTargetUri = createMemo(() => notificationBodyTargetUri(props.notification));
43 const originalPostUri = createMemo(() => notificationOriginalPostUri(props.notification));
44 const originalPostHref = createMemo(() => props.buildThreadHref?.(originalPostUri() ?? null) ?? null);
45 const bodyInteractive = createMemo(() => !!props.onOpenThread && !!bodyTargetUri());
46 const postText = createMemo<string | null>(() => {
47 const record = props.notification.record;
48 const text = record["text"];
49 return typeof text === "string" && text.trim() ? text.trim() : null;
50 });
51 const detail = createMemo(() => postText() ?? followDetail(props.notification));
52 const avatarLabels = () => collectModerationLabels(props.notification.author);
53 const profileLabels = () => collectModerationLabels(props.notification.author);
54 const contentLabels = () => collectModerationLabels(props.notification);
55 const avatarDecision = useModerationDecision(avatarLabels, "avatar");
56 const profileDecision = useModerationDecision(profileLabels, "profileList");
57 const contentDecision = useModerationDecision(contentLabels, "contentList");
58
59 function openBodyTarget() {
60 const uri = bodyTargetUri();
61 if (!uri || !props.onOpenThread) {
62 return;
63 }
64
65 props.onMarkRead?.([props.notification.uri]);
66 props.onOpenThread(uri);
67 }
68
69 function markRead() {
70 props.onMarkRead?.([props.notification.uri]);
71 }
72
73 return (
74 <article
75 class="flex items-start gap-4 rounded-2xl px-4 py-4 transition-colors duration-150 hover:bg-surface-container-high"
76 classList={{ "opacity-60": props.notification.isRead }}
77 aria-label={`${name()} ${description()}`}>
78 <ReasonIcon reason={props.notification.reason} />
79 <a
80 class="shrink-0 no-underline"
81 href={`#${profileHref()}`}
82 aria-label={`View @${props.notification.author.handle}`}
83 onClick={() => markRead()}>
84 <ModeratedAvatar
85 avatar={props.notification.author.avatar}
86 class="inline-flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full bg-surface-container-high text-xs font-semibold text-on-surface-variant"
87 hidden={avatarDecision().filter || avatarDecision().blur !== "none"}
88 label={avatarLabel()}
89 fallbackClass="text-xs font-semibold text-on-surface-variant" />
90 </a>
91
92 <div
93 class="min-w-0 flex-1 rounded-xl p-1.5 transition duration-150"
94 classList={{
95 "cursor-pointer hover:bg-white/2 focus-visible:bg-white/3 focus-visible:ring-1 focus-visible:ring-primary/30":
96 bodyInteractive(),
97 }}
98 role={bodyInteractive() ? "button" : undefined}
99 tabIndex={bodyInteractive() ? 0 : undefined}
100 onClick={() => openBodyTarget()}
101 onKeyDown={(event) => {
102 if ((event.key === "Enter" || event.key === " ") && bodyInteractive()) {
103 event.preventDefault();
104 openBodyTarget();
105 }
106 }}>
107 <p class="m-0 text-sm leading-relaxed text-on-surface">
108 <a
109 class="font-semibold text-on-surface no-underline transition hover:text-primary"
110 href={`#${profileHref()}`}
111 onClick={(event) => {
112 event.stopPropagation();
113 markRead();
114 }}>
115 {name()}
116 </a>{" "}
117 <NotificationDescription
118 description={description()}
119 onOpenOriginalPost={() => markRead()}
120 originalPostHref={originalPostHref()}
121 reason={props.notification.reason} />
122 </p>
123
124 <ModerationBadgeRow decision={profileDecision()} labels={profileLabels()} class="mt-1" />
125
126 <ModerationBadgeRow decision={contentDecision()} labels={contentLabels()} class="mt-1" />
127
128 <Show when={detail()}>
129 {(value) => (
130 <ModeratedBlurOverlay decision={contentDecision()} labels={contentLabels()} class="mt-1">
131 <p class="m-0 line-clamp-2 text-sm text-on-secondary-container">{value()}</p>
132 </ModeratedBlurOverlay>
133 )}
134 </Show>
135
136 <p class="mt-2 text-xs text-on-surface-variant">{time()}</p>
137 </div>
138
139 <Show when={!props.notification.isRead}>
140 <span class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary" aria-label="Unread" role="status" />
141 </Show>
142 </article>
143 );
144}
145
146function NotificationDescription(
147 props: {
148 description: string;
149 onOpenOriginalPost: () => void;
150 originalPostHref: string | null;
151 reason: NotificationReason;
152 },
153) {
154 const postHref = createMemo(() => props.originalPostHref);
155 const shouldLinkToOriginal = createMemo(() => (props.reason === "reply" || props.reason === "quote") && !!postHref());
156
157 return (
158 <Show when={shouldLinkToOriginal()} fallback={<span class="text-on-surface-variant">{props.description}</span>}>
159 <span class="text-on-surface-variant">
160 <span>{props.reason === "reply" ? "replied to " : "quoted "}</span>
161 <a
162 class="font-medium text-on-surface no-underline transition hover:text-primary hover:underline"
163 href={`#${postHref()}`}
164 onClick={(event) => {
165 event.stopPropagation();
166 props.onOpenOriginalPost();
167 }}>
168 your post
169 </a>
170 </span>
171 </Show>
172 );
173}
174
175function followDetail(notification: NotificationView) {
176 if (notification.reason !== "follow") {
177 return null;
178 }
179
180 return `@${notification.author.handle}`;
181}