BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 181 lines 7.5 kB view raw
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}