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.

feat: moderation settings and UI

+1412 -108
+12 -12
docs/tasks/13-release.md
··· 10 10 11 11 #### Backend 12 12 13 - - [x] `ModerationService` in Rust - fetch labeler policies, evaluate labels into `ModerationDecision`, cache in `labeler_cache` table 14 - - [x] Send `atproto-accept-labelers` header with all API requests (built-in Bluesky labeler + user-subscribed labelers) 15 - - [x] Moderation preferences storage - per-account JSON in `app_settings` keyed by `moderation_preferences::{did}` 16 - - [x] `create_report` command - calls `com.atproto.moderation.createReport` 17 - - [x] `get_distribution_channel` command - returns compile-time `DISTRIBUTION_CHANNEL` env var 13 + - [ ] `ModerationService` in Rust - fetch labeler policies, evaluate labels into `ModerationDecision`, cache in `labeler_cache` table 14 + - [ ] Send `atproto-accept-labelers` header with all API requests (built-in Bluesky labeler + user-subscribed labelers) 15 + - [ ] Moderation preferences storage - per-account JSON in `app_settings` keyed by `moderation_preferences::{did}` 16 + - [ ] `create_report` command - calls `com.atproto.moderation.createReport` 17 + - [ ] `get_distribution_channel` command - returns compile-time `DISTRIBUTION_CHANNEL` env var 18 18 19 19 #### Frontend 20 20 21 - - [ ] `ModeratedBlurOverlay` component - 14px blur, overlay icon, "Show content" button, label display 22 - - [ ] `ModeratedAvatar` component - shield icon fallback for hidden avatars 23 - - [ ] `ModerationBadgeRow` component - alert (red) and inform (blue) badges with label source 24 - - [ ] `ReportDialog` modal - reason type selector, free-text, submit 25 - - [ ] Wire moderation into `PostCard`, image/video embeds, profile views, notifications 26 - - [ ] Moderation Settings section - adult content toggle (hidden on MAS, link to web settings instead), labeler management, per-label preferences 27 - - [ ] Block user flow via `app.bsky.graph.block` 21 + - [x] `ModeratedBlurOverlay` component - 14px blur, overlay icon, "Show content" button, label display 22 + - [x] `ModeratedAvatar` component - shield icon fallback for hidden avatars 23 + - [x] `ModerationBadgeRow` component - alert (red) and inform (blue) badges with label source 24 + - [x] `ReportDialog` modal - reason type selector, free-text, submit 25 + - [x] Wire moderation into `PostCard`, image/video embeds, profile views, notifications 26 + - [x] Moderation Settings section - adult content toggle (hidden on MAS, link to web settings instead), labeler management, per-label preferences 27 + - [x] Block user flow via `app.bsky.graph.block` 28 28 29 29 ### App Identity & Branding 30 30
+5
src-tauri/src/commands/mod.rs
··· 146 146 } 147 147 148 148 #[tauri::command] 149 + pub async fn block_actor(did: String, state: State<'_>) -> Result<CreateRecordResult> { 150 + feed::block_actor(did, &state).await 151 + } 152 + 153 + #[tauri::command] 149 154 pub async fn unfollow_actor(follow_uri: String, state: State<'_>) -> Result<()> { 150 155 feed::unfollow_actor(follow_uri, &state).await 151 156 }
+38
src-tauri/src/feed.rs
··· 23 23 use jacquard::api::app_bsky::feed::like::Like; 24 24 use jacquard::api::app_bsky::feed::post::{Post, PostEmbed, ReplyRef}; 25 25 use jacquard::api::app_bsky::feed::repost::Repost; 26 + use jacquard::api::app_bsky::graph::block::Block; 26 27 use jacquard::api::app_bsky::graph::follow::Follow; 27 28 use jacquard::api::app_bsky::graph::get_followers::GetFollowers; 28 29 use jacquard::api::app_bsky::graph::get_follows::GetFollows; ··· 830 831 .map_err(|error| { 831 832 log::error!("createRecord (follow) output error: {error}"); 832 833 AppError::validation("Could not follow this account.") 834 + })?; 835 + 836 + Ok(CreateRecordResult { uri: output.uri.to_string(), cid: output.cid.to_string() }) 837 + } 838 + 839 + pub async fn block_actor(did: String, state: &AppState) -> Result<CreateRecordResult> { 840 + let session = get_session(state).await?; 841 + let active_did = active_did(state)?; 842 + 843 + let block = Block::new() 844 + .created_at(Datetime::now()) 845 + .subject(Did::new(&did).map_err(|_| AppError::validation("invalid account DID"))?) 846 + .build(); 847 + 848 + let record_json = serde_json::to_value(&block)?; 849 + let record_data = Data::from_json_owned(record_json).map_err(|_| AppError::validation("serialize block"))?; 850 + 851 + let repo = AtIdentifier::Did(Did::new(&active_did)?); 852 + let collection = Nsid::new("app.bsky.graph.block").map_err(|_| AppError::validation("nsid"))?; 853 + 854 + let output = session 855 + .send( 856 + CreateRecord::new() 857 + .repo(repo) 858 + .collection(collection) 859 + .record(record_data) 860 + .build(), 861 + ) 862 + .await 863 + .map_err(|error| { 864 + log::error!("createRecord (block) error: {error}"); 865 + AppError::validation("Could not block this account.") 866 + })? 867 + .into_output() 868 + .map_err(|error| { 869 + log::error!("createRecord (block) output error: {error}"); 870 + AppError::validation("Could not block this account.") 833 871 })?; 834 872 835 873 Ok(CreateRecordResult { uri: output.uri.to_string(), cid: output.cid.to_string() })
+1
src-tauri/src/lib.rs
··· 104 104 cmd::bookmark_post, 105 105 cmd::remove_bookmark, 106 106 cmd::follow_actor, 107 + cmd::block_actor, 107 108 cmd::unfollow_actor, 108 109 cmd::get_followers, 109 110 cmd::get_follows,
+1 -4
src-tauri/src/moderation.rs
··· 212 212 /// This must be called after changing labeler subscriptions so that all subsequent API calls 213 213 /// carry the correct header. 214 214 pub async fn apply_labeler_headers(session: &LazuriteOAuthSession, prefs: &StoredModerationPrefs) { 215 - let dids: Vec<CowStr<'static>> = accepted_labeler_dids(prefs) 216 - .into_iter() 217 - .map(|did| CowStr::from(did)) 218 - .collect(); 215 + let dids: Vec<CowStr<'static>> = accepted_labeler_dids(prefs).into_iter().map(CowStr::from).collect(); 219 216 let opts = CallOptions { atproto_accept_labelers: Some(dids), ..Default::default() }; 220 217 session.set_options(opts).await; 221 218 log::debug!(
-2
src/components/deck/DeckWorkspace.tsx
··· 134 134 const session = useAppSession(); 135 135 const threadOverlay = useThreadOverlayNavigation(); 136 136 let feedColumnRequest = 0; 137 - // Module-level variable: WebKit dataTransfer.getData() returns empty string on drop, 138 - // so we track the dragging column ID here instead. 139 137 let draggingColumnId: string | null = null; 140 138 141 139 const [state, setState] = createStore<DeckState>({
-22
src/components/explorer/views/RecordView.tsx
··· 140 140 return null; 141 141 }; 142 142 143 - // return ( 144 - // <> 145 - // <Show when={kind() === "app.bsky.feed.post" && content()}> 146 - // <CollapsibleSection title="Post Preview"> 147 - // <div class="p-4 rounded-xl bg-black/30"> 148 - // <PostRichText class="text-sm" facets={postRecord().facets} text={content() ?? ""} /> 149 - // </div> 150 - // </CollapsibleSection> 151 - // </Show> 152 - 153 - // <Show when={kind() !== "app.bsky.feed.post" && subject()}> 154 - // {(value) => ( 155 - // <CollapsibleSection title="Subject"> 156 - // <div class="p-4 rounded-xl bg-black/30"> 157 - // <SubjectPreview subject={value()} /> 158 - // </div> 159 - // </CollapsibleSection> 160 - // )} 161 - // </Show> 162 - // </> 163 - // ); 164 - 165 143 return ( 166 144 <Show 167 145 when={kind() === "app.bsky.feed.post"}
-2
src/components/feeds/FeedComposer.test.tsx
··· 75 75 it("does not show draft count badge when draftCount is zero", () => { 76 76 render(() => <FeedComposer {...BASE_PROPS} draftCount={0} onOpenDrafts={() => {}} />); 77 77 78 - // The drafts button should exist but no badge (the badge span is conditionally rendered) 79 78 const draftsButton = screen.getByTitle("Drafts (Ctrl+D)"); 80 79 expect(draftsButton).toBeInTheDocument(); 81 - // Badge is only rendered when count > 0; with count=0 the badge span should be absent 82 80 expect(draftsButton.textContent?.trim()).toBe(""); 83 81 }); 84 82 });
+61
src/components/feeds/PostCard.test.tsx
··· 6 6 const downloadImageMock = vi.hoisted(() => vi.fn()); 7 7 const downloadVideoMock = vi.hoisted(() => vi.fn()); 8 8 const listenMock = vi.hoisted(() => vi.fn()); 9 + const moderateContentMock = vi.hoisted(() => vi.fn()); 10 + const createReportMock = vi.hoisted(() => vi.fn()); 11 + const blockActorMock = vi.hoisted(() => vi.fn()); 9 12 10 13 vi.mock( 11 14 "$/lib/api/media", 12 15 () => ({ MediaController: { downloadImage: downloadImageMock, downloadVideo: downloadVideoMock } }), 13 16 ); 17 + vi.mock( 18 + "$/lib/api/moderation", 19 + () => ({ 20 + MODERATION_REASON_OPTIONS: [{ label: "Spam", value: "com.atproto.moderation.defs#reasonSpam" }, { 21 + label: "Violation", 22 + value: "com.atproto.moderation.defs#reasonViolation", 23 + }], 24 + ModerationController: { 25 + moderateContent: moderateContentMock, 26 + createReport: createReportMock, 27 + blockActor: blockActorMock, 28 + }, 29 + }), 30 + ); 14 31 vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock })); 15 32 16 33 function createPost() { ··· 42 59 downloadImageMock.mockReset(); 43 60 downloadVideoMock.mockReset(); 44 61 listenMock.mockReset(); 62 + moderateContentMock.mockReset(); 63 + createReportMock.mockReset(); 64 + blockActorMock.mockReset(); 45 65 listenMock.mockResolvedValue(() => {}); 66 + moderateContentMock.mockResolvedValue({ 67 + filter: false, 68 + blur: "none", 69 + alert: false, 70 + inform: false, 71 + noOverride: false, 72 + }); 73 + createReportMock.mockResolvedValue(1); 74 + blockActorMock.mockResolvedValue({ uri: "at://did:plc:test/app.bsky.graph.block/1", cid: "cid-block" }); 46 75 }); 47 76 48 77 it("renders links, mentions, and hashtags from facets", () => { ··· 246 275 await waitFor(() => 247 276 expect(downloadImageMock).toHaveBeenCalledWith("https://cdn.example.com/post-image-two.jpg", "123_2") 248 277 ); 278 + }); 279 + 280 + it("submits a report for the current post", async () => { 281 + render(() => <PostCard post={createPost()} onOpenThread={vi.fn()} />); 282 + 283 + fireEvent.click(screen.getByRole("button", { name: "More actions" })); 284 + fireEvent.click(screen.getByRole("menuitem", { name: "Report post" })); 285 + 286 + expect(await screen.findByText("Report content")).toBeInTheDocument(); 287 + fireEvent.input(screen.getByPlaceholderText("Add context for moderators"), { 288 + target: { value: "misleading link" }, 289 + }); 290 + fireEvent.click(screen.getByRole("button", { name: "Submit report" })); 291 + 292 + await waitFor(() => 293 + expect(createReportMock).toHaveBeenCalledWith( 294 + { type: "record", uri: "at://did:plc:alice/app.bsky.feed.post/123", cid: "cid-post" }, 295 + "com.atproto.moderation.defs#reasonSpam", 296 + "misleading link", 297 + ) 298 + ); 299 + }); 300 + 301 + it("blocks the post author from the context menu", async () => { 302 + const confirmSpy = vi.spyOn(globalThis, "confirm").mockReturnValue(true); 303 + render(() => <PostCard post={createPost()} onOpenThread={vi.fn()} />); 304 + 305 + fireEvent.click(screen.getByRole("button", { name: "More actions" })); 306 + fireEvent.click(screen.getByRole("menuitem", { name: "Block @alice.test" })); 307 + 308 + await waitFor(() => expect(blockActorMock).toHaveBeenCalledWith("did:plc:alice")); 309 + confirmSpy.mockRestore(); 249 310 }); 250 311 });
+114 -21
src/components/feeds/PostCard.tsx
··· 1 1 import { ImageGallery } from "$/components/feeds/ImageGallery"; 2 2 import { type MediaNotice, MediaNoticeToast } from "$/components/feeds/MediaNoticeToast"; 3 3 import { VideoEmbed } from "$/components/feeds/VideoEmbed"; 4 + import { ModeratedAvatar } from "$/components/moderation/ModeratedAvatar"; 5 + import { ModeratedBlurOverlay } from "$/components/moderation/ModeratedBlurOverlay"; 6 + import { ModerationBadgeRow } from "$/components/moderation/ModerationBadgeRow"; 7 + import { ReportDialog } from "$/components/moderation/ReportDialog"; 8 + import { useModerationDecision } from "$/components/moderation/useModerationDecision"; 4 9 import { ContextMenu, type ContextMenuAnchor, type ContextMenuItem } from "$/components/shared/ContextMenu"; 5 10 import { Icon } from "$/components/shared/Icon"; 6 11 import { PostRichText } from "$/components/shared/PostRichText"; 7 12 import { QuotedPostPreview } from "$/components/shared/QuotedPostPreview"; 8 13 import { MediaController } from "$/lib/api/media"; 14 + import { ModerationController } from "$/lib/api/moderation"; 9 15 import { 10 16 buildPublicPostUrl, 11 17 formatRelativeTime, ··· 19 25 getQuotedText, 20 26 isReplyItem, 21 27 } from "$/lib/feeds"; 28 + import { collectModerationLabels } from "$/lib/moderation"; 22 29 import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile"; 23 - import type { EmbedView, FeedViewPost, ImagesEmbedView, PostView, ProfileViewBasic, RichTextFacet } from "$/lib/types"; 30 + import type { 31 + EmbedView, 32 + FeedViewPost, 33 + ImagesEmbedView, 34 + ModerationLabel, 35 + ModerationReasonType, 36 + ModerationUiDecision, 37 + PostView, 38 + ProfileViewBasic, 39 + ReportSubjectInput, 40 + RichTextFacet, 41 + } from "$/lib/types"; 24 42 import { formatCount, formatHandle, normalizeError } from "$/lib/utils/text"; 43 + import * as logger from "@tauri-apps/plugin-log"; 25 44 import { revealItemInDir } from "@tauri-apps/plugin-opener"; 26 45 import { createMemo, createSignal, For, Match, onCleanup, type ParentProps, Show, Switch } from "solid-js"; 27 46 import { Motion } from "solid-motionone"; ··· 46 65 showActions?: boolean; 47 66 }; 48 67 68 + type ReportTarget = { subject: ReportSubjectInput; subjectLabel: string }; 69 + 49 70 export function PostCard(props: PostCardProps) { 50 71 const authorName = createMemo(() => getDisplayName(props.post.author)); 51 72 const createdAt = createMemo(() => formatRelativeTime(getPostCreatedAt(props.post))); ··· 58 79 const repostCount = createMemo(() => formatCount(props.post.repostCount)); 59 80 const authorHandle = createMemo(() => formatHandle(props.post.author.handle, props.post.author.did)); 60 81 const profileHref = createMemo(() => buildProfileRoute(getProfileRouteActor(props.post.author))); 82 + const contentLabels = () => collectModerationLabels(props.post); 83 + const mediaLabels = () => collectModerationLabels(props.post, props.post.embed); 84 + const avatarLabels = () => collectModerationLabels(props.post.author); 85 + const contentDecision = useModerationDecision(contentLabels); 86 + const mediaDecision = useModerationDecision(mediaLabels); 87 + const avatarDecision = useModerationDecision(avatarLabels); 61 88 const reasonLabel = createMemo(() => { 62 89 const reason = props.item?.reason; 63 90 if (!reason || reason.$type !== "app.bsky.feed.defs#reasonRepost") { ··· 83 110 84 111 const [menuAnchor, setMenuAnchor] = createSignal<ContextMenuAnchor | null>(null); 85 112 const [menuOpen, setMenuOpen] = createSignal(false); 113 + const [reportOpen, setReportOpen] = createSignal(false); 114 + const [reportTarget, setReportTarget] = createSignal<ReportTarget | null>(null); 86 115 let menuTriggerRef: HTMLButtonElement | undefined; 87 116 88 117 const menuItems = createMemo<ContextMenuItem[]>(() => { ··· 130 159 items.push({ icon: "i-ri-node-tree", label: "Open thread", onSelect: props.onOpenThread }); 131 160 } 132 161 162 + items.push({ 163 + icon: "i-ri-flag-line", 164 + label: "Report post", 165 + onSelect: () => { 166 + setReportTarget({ 167 + subject: { type: "record", uri: props.post.uri, cid: props.post.cid }, 168 + subjectLabel: `Post by @${props.post.author.handle}`, 169 + }); 170 + setReportOpen(true); 171 + }, 172 + }, { 173 + icon: "i-ri-flag-2-line", 174 + label: "Report account", 175 + onSelect: () => { 176 + setReportTarget({ 177 + subject: { type: "repo", did: props.post.author.did }, 178 + subjectLabel: `Account @${props.post.author.handle}`, 179 + }); 180 + setReportOpen(true); 181 + }, 182 + }, { icon: "i-ri-forbid-2-line", label: `Block @${props.post.author.handle}`, onSelect: () => void blockAuthor() }); 183 + 133 184 return items; 134 185 }); 135 186 ··· 149 200 setMenuOpen(true); 150 201 } 151 202 203 + async function submitReport(input: { reasonType: ModerationReasonType; reason: string }) { 204 + const target = reportTarget(); 205 + if (!target) { 206 + return; 207 + } 208 + 209 + try { 210 + await ModerationController.createReport(target.subject, input.reasonType, input.reason); 211 + } catch (error) { 212 + logger.error("failed to submit report", { keyValues: { error: normalizeError(error) } }); 213 + } 214 + } 215 + 216 + async function blockAuthor() { 217 + const confirmed = globalThis.confirm 218 + ? globalThis.confirm(`Block @${props.post.author.handle}? You can unblock from Bluesky settings.`) 219 + : true; 220 + 221 + if (!confirmed) { 222 + return; 223 + } 224 + 225 + try { 226 + await ModerationController.blockActor(props.post.author.did); 227 + } catch (error) { 228 + logger.error("failed to block account", { keyValues: { error: normalizeError(error) } }); 229 + } 230 + } 231 + 152 232 return ( 153 233 <article 154 234 ref={(element) => props.registerRef?.(element)} ··· 180 260 181 261 <div class="flex min-w-0 gap-3"> 182 262 <a class="shrink-0 no-underline" href={`#${profileHref()}`} onClick={(event) => event.stopPropagation()}> 183 - <AuthorAvatar avatar={props.post.author.avatar} label={getAvatarLabel(props.post.author)} /> 263 + <ModeratedAvatar 264 + avatar={props.post.author.avatar} 265 + class="relative mt-0.5 h-11 w-11 shrink-0 overflow-hidden rounded-full bg-[linear-gradient(135deg,rgba(125,175,255,0.9),rgba(0,115,222,0.72))] shadow-[0_0_0_2px_rgba(14,14,14,1),0_0_0_3px_rgba(125,175,255,0.28)]" 266 + hidden={avatarDecision().filter || avatarDecision().blur !== "none"} 267 + label={getAvatarLabel(props.post.author)} 268 + fallbackClass="text-sm font-semibold text-on-primary-fixed" /> 184 269 </a> 185 270 186 271 <div class="min-w-0 flex-1"> ··· 191 276 createdAt={createdAt()} 192 277 profileHref={profileHref()} /> 193 278 194 - <PostBodyText facets={getPostFacets(props.post)} text={postText()} /> 279 + <ModerationBadgeRow decision={contentDecision()} labels={contentLabels()} /> 195 280 196 - <PostEmbeds post={props.post} /> 281 + <ModeratedPostBody 282 + decision={contentDecision()} 283 + labels={contentLabels()} 284 + post={props.post} 285 + text={postText()} /> 286 + 287 + <PostEmbeds decision={mediaDecision()} labels={mediaLabels()} post={props.post} /> 197 288 </PostPrimaryRegion> 198 289 199 290 <Show when={props.showActions !== false}> ··· 231 322 open={menuOpen()} 232 323 returnFocusTo={menuTriggerRef} 233 324 onClose={closeMenu} /> 325 + 326 + <ReportDialog 327 + open={reportOpen()} 328 + subjectLabel={reportTarget()?.subjectLabel ?? "Report content"} 329 + onClose={() => setReportOpen(false)} 330 + onSubmit={submitReport} /> 234 331 </article> 235 332 ); 236 333 } ··· 350 447 ); 351 448 } 352 449 353 - function AuthorAvatar(props: { avatar?: string | null; label: string }) { 450 + function PostBodyText(props: { facets: RichTextFacet[]; text: string }) { 354 451 return ( 355 - <div class="relative mt-0.5 h-11 w-11 shrink-0 overflow-hidden rounded-full bg-[linear-gradient(135deg,rgba(125,175,255,0.9),rgba(0,115,222,0.72))] shadow-[0_0_0_2px_rgba(14,14,14,1),0_0_0_3px_rgba(125,175,255,0.28)]"> 356 - <Show 357 - when={props.avatar} 358 - fallback={ 359 - <div class="flex h-full w-full items-center justify-center text-sm font-semibold text-on-primary-fixed"> 360 - {props.label} 361 - </div> 362 - }> 363 - {(avatar) => <img class="h-full w-full object-cover" src={avatar()} alt="" />} 364 - </Show> 365 - </div> 452 + <Show when={props.text.trim().length > 0}> 453 + <PostRichText class="m-0" facets={props.facets} text={props.text} /> 454 + </Show> 366 455 ); 367 456 } 368 457 369 - function PostBodyText(props: { facets: RichTextFacet[]; text: string }) { 458 + function ModeratedPostBody( 459 + props: { decision: ModerationUiDecision; labels: ModerationLabel[]; post: PostView; text: string }, 460 + ) { 370 461 return ( 371 462 <Show when={props.text.trim().length > 0}> 372 - <PostRichText class="m-0" facets={props.facets} text={props.text} /> 463 + <ModeratedBlurOverlay decision={props.decision} labels={props.labels} class="mt-3"> 464 + <PostBodyText facets={getPostFacets(props.post)} text={props.text} /> 465 + </ModeratedBlurOverlay> 373 466 </Show> 374 467 ); 375 468 } ··· 407 500 ); 408 501 } 409 502 410 - function PostEmbeds(props: { post: PostView }) { 503 + function PostEmbeds(props: { decision: ModerationUiDecision; labels: ModerationLabel[]; post: PostView }) { 411 504 return ( 412 505 <Show when={props.post.embed}> 413 506 {(current) => ( 414 - <div class="mt-4"> 507 + <ModeratedBlurOverlay decision={props.decision} labels={props.labels} class="mt-4"> 415 508 <EmbedContent embed={current()} post={props.post} /> 416 - </div> 509 + </ModeratedBlurOverlay> 417 510 )} 418 511 </Show> 419 512 );
-1
src/components/feeds/useFeedWorkspaceController.test.tsx
··· 235 235 render(() => <ControllerHarness />); 236 236 await screen.findByText("following"); 237 237 238 - // Load a draft to set draftId 239 238 fireEvent.click(screen.getByRole("button", { name: "Load draft" })); 240 239 expect(screen.getByTestId("draft-id")).toHaveTextContent(SAMPLE_DRAFT.id); 241 240
+33
src/components/moderation/ModeratedAvatar.tsx
··· 1 + import { Show } from "solid-js"; 2 + import { Icon } from "../shared/Icon"; 3 + 4 + type ModeratedAvatarProps = { 5 + avatar?: string | null; 6 + class: string; 7 + hidden: boolean; 8 + label: string; 9 + fallbackClass?: string; 10 + imageClass?: string; 11 + }; 12 + 13 + export function ModeratedAvatar(props: ModeratedAvatarProps) { 14 + return ( 15 + <div class={props.class}> 16 + <Show 17 + when={!props.hidden && props.avatar} 18 + fallback={ 19 + <span 20 + class="flex h-full w-full items-center justify-center" 21 + classList={{ [props.fallbackClass ?? "text-sm font-semibold text-on-surface"]: true }}> 22 + <Show 23 + when={!props.hidden} 24 + fallback={<Icon aria-hidden="true" iconClass="i-ri-shield-line" class="text-lg text-on-surface" />}> 25 + {props.label} 26 + </Show> 27 + </span> 28 + }> 29 + {(avatar) => <img class={props.imageClass ?? "h-full w-full object-cover"} src={avatar()} alt="" />} 30 + </Show> 31 + </div> 32 + ); 33 + }
+32
src/components/moderation/ModeratedBlurOverlay.test.tsx
··· 1 + import { fireEvent, render, screen } from "@solidjs/testing-library"; 2 + import { describe, expect, it } from "vitest"; 3 + import { ModeratedBlurOverlay } from "./ModeratedBlurOverlay"; 4 + 5 + describe("ModeratedBlurOverlay", () => { 6 + it("shows overlay and allows reveal when decision is blur-only", () => { 7 + render(() => ( 8 + <ModeratedBlurOverlay 9 + decision={{ alert: false, blur: "media", filter: false, inform: false, noOverride: false }} 10 + labels={[{ src: "did:plc:labeler", val: "sexual" }]}> 11 + <p>Hidden body</p> 12 + </ModeratedBlurOverlay> 13 + )); 14 + 15 + expect(screen.getByText("Content blurred")).toBeInTheDocument(); 16 + fireEvent.click(screen.getByRole("button", { name: "Show content" })); 17 + expect(screen.queryByText("Content blurred")).not.toBeInTheDocument(); 18 + }); 19 + 20 + it("hides reveal button when content is fully filtered", () => { 21 + render(() => ( 22 + <ModeratedBlurOverlay 23 + decision={{ alert: true, blur: "content", filter: true, inform: false, noOverride: true }} 24 + labels={[{ src: "did:plc:labeler", val: "porn" }]}> 25 + <p>Hidden body</p> 26 + </ModeratedBlurOverlay> 27 + )); 28 + 29 + expect(screen.getByText("Hidden by your moderation settings.")).toBeInTheDocument(); 30 + expect(screen.queryByRole("button", { name: "Show content" })).not.toBeInTheDocument(); 31 + }); 32 + });
+80
src/components/moderation/ModeratedBlurOverlay.tsx
··· 1 + import { summarizeModerationLabels } from "$/lib/moderation"; 2 + import type { ModerationLabel, ModerationUiDecision } from "$/lib/types"; 3 + import { createMemo, createSignal, type ParentProps, Show } from "solid-js"; 4 + import { Icon } from "../shared/Icon"; 5 + 6 + type ModeratedBlurOverlayProps = ParentProps< 7 + { decision: ModerationUiDecision; labels: ModerationLabel[]; class?: string; revealLabel?: string } 8 + >; 9 + 10 + export function ModeratedBlurOverlay(props: ModeratedBlurOverlayProps) { 11 + const [revealed, setRevealed] = createSignal(false); 12 + const hidden = createMemo(() => (props.decision.filter || props.decision.blur !== "none") && !revealed()); 13 + const revealable = createMemo(() => !props.decision.filter && !props.decision.noOverride); 14 + const summaryText = createMemo(() => 15 + summarizeModerationLabels(props.labels, 2).map((summary) => `${summary.value} (${summary.source})`).join(", ") 16 + ); 17 + 18 + return ( 19 + <div class="relative min-w-0" classList={{ [props.class ?? ""]: !!props.class }}> 20 + <BlurredChildren hidden={hidden()}>{props.children}</BlurredChildren> 21 + <Show when={hidden()}> 22 + <OverlayMask> 23 + <OverlayCard 24 + revealLabel={props.revealLabel ?? "Show content"} 25 + revealable={revealable()} 26 + summaryText={summaryText()} 27 + onReveal={() => setRevealed(true)} /> 28 + </OverlayMask> 29 + </Show> 30 + </div> 31 + ); 32 + } 33 + 34 + function BlurredChildren(props: ParentProps<{ hidden: boolean }>) { 35 + return <div classList={{ "blur-[14px] pointer-events-none select-none": props.hidden }}>{props.children}</div>; 36 + } 37 + 38 + function OverlayMask(props: ParentProps) { 39 + return ( 40 + <div class="absolute inset-0 z-10 grid place-items-center rounded-2xl bg-[rgba(8,8,8,0.68)] p-4 text-center backdrop-blur-[2px]"> 41 + {props.children} 42 + </div> 43 + ); 44 + } 45 + 46 + function OverlayCard(props: { revealLabel: string; revealable: boolean; summaryText: string; onReveal: () => void }) { 47 + return ( 48 + <div class="grid max-w-sm gap-2 rounded-2xl bg-surface-container-high/85 px-4 py-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08)]"> 49 + <OverlayIcon /> 50 + <p class="m-0 text-sm font-medium text-on-surface">Content blurred</p> 51 + <Show when={props.summaryText.length > 0}> 52 + <p class="m-0 text-xs leading-relaxed text-on-surface-variant">{props.summaryText}</p> 53 + </Show> 54 + <OverlayAction revealLabel={props.revealLabel} revealable={props.revealable} onReveal={props.onReveal} /> 55 + </div> 56 + ); 57 + } 58 + 59 + function OverlayIcon() { 60 + return ( 61 + <div class="mx-auto flex h-9 w-9 items-center justify-center rounded-full bg-white/8 text-on-surface"> 62 + <Icon aria-hidden="true" class="text-lg" iconClass="i-ri-eye-off-line" /> 63 + </div> 64 + ); 65 + } 66 + 67 + function OverlayAction(props: { revealLabel: string; revealable: boolean; onReveal: () => void }) { 68 + return ( 69 + <Show 70 + when={props.revealable} 71 + fallback={<p class="m-0 text-xs text-on-surface-variant">Hidden by your moderation settings.</p>}> 72 + <button 73 + type="button" 74 + class="mt-1 justify-self-center rounded-full border-0 bg-primary px-3 py-1.5 text-xs font-medium text-on-primary-fixed transition hover:opacity-90" 75 + onClick={() => props.onReveal()}> 76 + {props.revealLabel} 77 + </button> 78 + </Show> 79 + ); 80 + }
+37
src/components/moderation/ModerationBadgeRow.tsx
··· 1 + import { summarizeModerationLabels } from "$/lib/moderation"; 2 + import type { ModerationLabel, ModerationUiDecision } from "$/lib/types"; 3 + import { createMemo, Show } from "solid-js"; 4 + import { Icon } from "../shared/Icon"; 5 + 6 + type ModerationBadgeRowProps = { decision: ModerationUiDecision; labels: ModerationLabel[]; class?: string }; 7 + 8 + export function ModerationBadgeRow(props: ModerationBadgeRowProps) { 9 + const summaries = createMemo(() => summarizeModerationLabels(props.labels, 3)); 10 + const sourceText = createMemo(() => { 11 + if (summaries().length === 0) { 12 + return null; 13 + } 14 + 15 + return summaries().map((summary) => `${summary.value} · ${summary.source}`).join(" | "); 16 + }); 17 + 18 + return ( 19 + <Show when={props.decision.alert || props.decision.inform}> 20 + <div class="mt-2 flex flex-wrap items-center gap-2" classList={{ [props.class ?? ""]: !!props.class }}> 21 + <Show when={props.decision.alert}> 22 + <span class="inline-flex items-center gap-1 rounded-full bg-red-500/18 px-2.5 py-1 text-[0.68rem] font-medium uppercase tracking-[0.08em] text-red-200"> 23 + <Icon aria-hidden="true" iconClass="i-ri-alarm-warning-line" class="text-xs" /> 24 + Alert 25 + </span> 26 + </Show> 27 + <Show when={props.decision.inform}> 28 + <span class="inline-flex items-center gap-1 rounded-full bg-primary/18 px-2.5 py-1 text-[0.68rem] font-medium uppercase tracking-[0.08em] text-primary"> 29 + <Icon aria-hidden="true" iconClass="i-ri-information-line" class="text-xs" /> 30 + Inform 31 + </span> 32 + </Show> 33 + <Show when={sourceText()}>{(text) => <span class="text-xs text-on-surface-variant">{text()}</span>}</Show> 34 + </div> 35 + </Show> 36 + ); 37 + }
+144
src/components/moderation/ReportDialog.tsx
··· 1 + import { MODERATION_REASON_OPTIONS } from "$/lib/api/moderation"; 2 + import type { ModerationReasonType } from "$/lib/types"; 3 + import { createEffect, createSignal, For, type ParentProps, Show } from "solid-js"; 4 + import { Motion, Presence } from "solid-motionone"; 5 + 6 + type ReportDialogProps = { 7 + open: boolean; 8 + subjectLabel: string; 9 + onClose: () => void; 10 + onSubmit: (input: { reasonType: ModerationReasonType; reason: string }) => Promise<void> | void; 11 + }; 12 + 13 + export function ReportDialog(props: ReportDialogProps) { 14 + const [reasonType, setReasonType] = createSignal<ModerationReasonType>(MODERATION_REASON_OPTIONS[0].value); 15 + const [reason, setReason] = createSignal(""); 16 + const [submitting, setSubmitting] = createSignal(false); 17 + 18 + createEffect(() => { 19 + if (!props.open) { 20 + return; 21 + } 22 + 23 + setReasonType(MODERATION_REASON_OPTIONS[0].value); 24 + setReason(""); 25 + setSubmitting(false); 26 + }); 27 + 28 + async function submit() { 29 + if (submitting()) { 30 + return; 31 + } 32 + 33 + setSubmitting(true); 34 + try { 35 + await props.onSubmit({ reason: reason().trim(), reasonType: reasonType() }); 36 + props.onClose(); 37 + } finally { 38 + setSubmitting(false); 39 + } 40 + } 41 + 42 + return ( 43 + <Presence> 44 + <Show when={props.open}> 45 + <DialogBackdrop onClose={props.onClose}> 46 + <DialogSurface> 47 + <DialogHeader subjectLabel={props.subjectLabel} /> 48 + <ReasonTypeField value={reasonType()} onChange={setReasonType} /> 49 + <ReasonDetailsField value={reason()} onChange={setReason} /> 50 + <DialogActions submitting={submitting()} onCancel={props.onClose} onSubmit={() => void submit()} /> 51 + </DialogSurface> 52 + </DialogBackdrop> 53 + </Show> 54 + </Presence> 55 + ); 56 + } 57 + 58 + function DialogBackdrop(props: ParentProps<{ onClose: () => void }>) { 59 + return ( 60 + <Motion.div 61 + class="fixed inset-0 z-60 flex items-center justify-center bg-surface-container-highest/70 p-4 backdrop-blur-xl" 62 + initial={{ opacity: 0 }} 63 + animate={{ opacity: 1 }} 64 + exit={{ opacity: 0 }} 65 + transition={{ duration: 0.2 }}> 66 + <button 67 + type="button" 68 + aria-label="Close report dialog" 69 + class="absolute inset-0 border-0 bg-transparent" 70 + onClick={() => props.onClose()} /> 71 + {props.children} 72 + </Motion.div> 73 + ); 74 + } 75 + 76 + function DialogSurface(props: ParentProps) { 77 + return ( 78 + <Motion.div 79 + class="relative z-1 grid w-full max-w-lg gap-4 rounded-2xl bg-surface-container p-5 shadow-2xl" 80 + initial={{ scale: 0.96, opacity: 0 }} 81 + animate={{ scale: 1, opacity: 1 }} 82 + exit={{ scale: 0.96, opacity: 0 }} 83 + transition={{ duration: 0.2 }}> 84 + {props.children} 85 + </Motion.div> 86 + ); 87 + } 88 + 89 + function DialogHeader(props: { subjectLabel: string }) { 90 + return ( 91 + <div class="grid gap-1"> 92 + <h3 class="m-0 text-lg font-semibold text-on-surface">Report content</h3> 93 + <p class="m-0 text-sm text-on-surface-variant">{props.subjectLabel}</p> 94 + </div> 95 + ); 96 + } 97 + 98 + function ReasonTypeField(props: { value: ModerationReasonType; onChange: (value: ModerationReasonType) => void }) { 99 + return ( 100 + <label class="grid gap-1"> 101 + <span class="text-sm font-medium text-on-surface">Reason type</span> 102 + <select 103 + value={props.value} 104 + class="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-sm text-on-surface outline-none transition focus:border-primary/50" 105 + onInput={(event) => props.onChange(event.currentTarget.value as ModerationReasonType)}> 106 + <For each={MODERATION_REASON_OPTIONS}>{(option) => <option value={option.value}>{option.label}</option>}</For> 107 + </select> 108 + </label> 109 + ); 110 + } 111 + 112 + function ReasonDetailsField(props: { value: string; onChange: (value: string) => void }) { 113 + return ( 114 + <label class="grid gap-1"> 115 + <span class="text-sm font-medium text-on-surface">Details (optional)</span> 116 + <textarea 117 + rows={4} 118 + value={props.value} 119 + placeholder="Add context for moderators" 120 + class="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-sm text-on-surface outline-none transition focus:border-primary/50" 121 + onInput={(event) => props.onChange(event.currentTarget.value)} /> 122 + </label> 123 + ); 124 + } 125 + 126 + function DialogActions(props: { submitting: boolean; onCancel: () => void; onSubmit: () => void }) { 127 + return ( 128 + <div class="flex justify-end gap-2"> 129 + <button 130 + type="button" 131 + class="rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-white/5" 132 + onClick={() => props.onCancel()}> 133 + Cancel 134 + </button> 135 + <button 136 + type="button" 137 + disabled={props.submitting} 138 + class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-on-primary-fixed transition hover:bg-primary-dim disabled:cursor-wait disabled:opacity-70" 139 + onClick={() => props.onSubmit()}> 140 + {props.submitting ? "Submitting..." : "Submit report"} 141 + </button> 142 + </div> 143 + ); 144 + }
+27
src/components/moderation/useModerationDecision.ts
··· 1 + import { ModerationController } from "$/lib/api/moderation"; 2 + import { DEFAULT_MODERATION_DECISION, moderationLabelsKey } from "$/lib/moderation"; 3 + import type { ModerationLabel, ModerationUiDecision } from "$/lib/types"; 4 + import { type Accessor, createMemo, createResource } from "solid-js"; 5 + 6 + const decisionCache = new Map<string, ModerationUiDecision>(); 7 + 8 + export function useModerationDecision(labelsAccessor: Accessor<ModerationLabel[]>) { 9 + const cacheKey = createMemo(() => moderationLabelsKey(labelsAccessor())); 10 + 11 + const [decision] = createResource(cacheKey, async (key) => { 12 + if (!key) { 13 + return DEFAULT_MODERATION_DECISION; 14 + } 15 + 16 + const cached = decisionCache.get(key); 17 + if (cached) { 18 + return cached; 19 + } 20 + 21 + const next = await ModerationController.moderateContent(labelsAccessor()); 22 + decisionCache.set(key, next); 23 + return next; 24 + }, { initialValue: DEFAULT_MODERATION_DECISION }); 25 + 26 + return createMemo(() => decision() ?? DEFAULT_MODERATION_DECISION); 27 + }
+22 -12
src/components/notifications/NotificationItem.tsx
··· 1 + import { ModeratedAvatar } from "$/components/moderation/ModeratedAvatar"; 2 + import { ModeratedBlurOverlay } from "$/components/moderation/ModeratedBlurOverlay"; 3 + import { ModerationBadgeRow } from "$/components/moderation/ModerationBadgeRow"; 4 + import { useModerationDecision } from "$/components/moderation/useModerationDecision"; 1 5 import { Icon } from "$/components/shared/Icon"; 2 6 import { formatRelativeTime, getAvatarLabel, getDisplayName } from "$/lib/feeds"; 7 + import { collectModerationLabels } from "$/lib/moderation"; 3 8 import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile"; 4 9 import type { NotificationReason, NotificationView } from "$/lib/types"; 5 10 import { createMemo, Show } from "solid-js"; ··· 20 25 ); 21 26 } 22 27 23 - function AuthorAvatar(props: { avatar?: string | null; label: string }) { 24 - return ( 25 - <span 26 - 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" 27 - aria-hidden="true"> 28 - {props.avatar ? <img src={props.avatar} alt="" class="h-full w-full object-cover" /> : props.label} 29 - </span> 30 - ); 31 - } 32 - 33 28 type NotificationItemProps = { notification: NotificationView }; 34 29 type NotificationInteractionProps = { 35 30 buildThreadHref?: (uri: string | null) => string; ··· 53 48 return typeof text === "string" && text.trim() ? text.trim() : null; 54 49 }); 55 50 const detail = createMemo(() => postText() ?? followDetail(props.notification)); 51 + const avatarLabels = () => collectModerationLabels(props.notification.author); 52 + const contentLabels = () => collectModerationLabels(props.notification); 53 + const avatarDecision = useModerationDecision(avatarLabels); 54 + const contentDecision = useModerationDecision(contentLabels); 56 55 57 56 function openBodyTarget() { 58 57 const uri = bodyTargetUri(); ··· 79 78 href={`#${profileHref()}`} 80 79 aria-label={`View @${props.notification.author.handle}`} 81 80 onClick={() => markRead()}> 82 - <AuthorAvatar avatar={props.notification.author.avatar} label={avatarLabel()} /> 81 + <ModeratedAvatar 82 + avatar={props.notification.author.avatar} 83 + 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" 84 + hidden={avatarDecision().filter || avatarDecision().blur !== "none"} 85 + label={avatarLabel()} 86 + fallbackClass="text-xs font-semibold text-on-surface-variant" /> 83 87 </a> 84 88 85 89 <div ··· 114 118 reason={props.notification.reason} /> 115 119 </p> 116 120 121 + <ModerationBadgeRow decision={contentDecision()} labels={contentLabels()} class="mt-1" /> 122 + 117 123 <Show when={detail()}> 118 - {(value) => <p class="mt-1 line-clamp-2 text-sm text-on-secondary-container">{value()}</p>} 124 + {(value) => ( 125 + <ModeratedBlurOverlay decision={contentDecision()} labels={contentLabels()} class="mt-1"> 126 + <p class="m-0 line-clamp-2 text-sm text-on-secondary-container">{value()}</p> 127 + </ModeratedBlurOverlay> 128 + )} 119 129 </Show> 120 130 121 131 <p class="mt-2 text-xs text-on-surface-variant">{time()}</p>
+10 -6
src/components/notifications/NotificationsPanel.tsx
··· 1 + import { ModeratedAvatar } from "$/components/moderation/ModeratedAvatar"; 2 + import { useModerationDecision } from "$/components/moderation/useModerationDecision"; 1 3 import { useAppSession } from "$/contexts/app-session"; 2 4 import { listNotifications, updateSeen } from "$/lib/api/notifications"; 3 5 import { NOTIFICATIONS_UNREAD_COUNT_EVENT } from "$/lib/constants/events"; 4 6 import { buildThreadOverlayRoute, formatRelativeTime, getAvatarLabel, getDisplayName } from "$/lib/feeds"; 7 + import { collectModerationLabels } from "$/lib/moderation"; 5 8 import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile"; 6 9 import type { ListNotificationsResponse, NotificationReason, NotificationView, ProfileViewBasic } from "$/lib/types"; 7 10 import { normalizeError } from "$/lib/utils/text"; ··· 426 429 function GroupedAuthorAvatar(props: { actor: ProfileViewBasic; onClick: () => void }) { 427 430 const label = createMemo(() => getAvatarLabel(props.actor)); 428 431 const profileHref = createMemo(() => buildProfileRoute(getProfileRouteActor(props.actor))); 432 + const labels = () => collectModerationLabels(props.actor); 433 + const decision = useModerationDecision(labels); 429 434 430 435 return ( 431 436 <a ··· 436 441 event.stopPropagation(); 437 442 props.onClick(); 438 443 }}> 439 - <span 444 + <ModeratedAvatar 445 + avatar={props.actor.avatar} 440 446 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 shadow-[0_0_0_2px_var(--surface-container)]" 441 - aria-hidden="true"> 442 - <Show when={props.actor.avatar} fallback={label()}> 443 - {(avatar) => <img src={avatar()} alt="" class="h-full w-full object-cover" />} 444 - </Show> 445 - </span> 447 + hidden={decision().filter || decision().blur !== "none"} 448 + label={label()} 449 + fallbackClass="text-xs font-semibold text-on-surface-variant" /> 446 450 </a> 447 451 ); 448 452 }
+24 -23
src/components/profile/ProfileHero.tsx
··· 1 + import { ModeratedAvatar } from "$/components/moderation/ModeratedAvatar"; 2 + import { ModerationBadgeRow } from "$/components/moderation/ModerationBadgeRow"; 3 + import { useModerationDecision } from "$/components/moderation/useModerationDecision"; 1 4 import { Icon } from "$/components/shared/Icon"; 2 5 import { getAvatarLabel, getDisplayName } from "$/lib/feeds"; 6 + import { collectModerationLabels } from "$/lib/moderation"; 3 7 import type { ProfileViewDetailed } from "$/lib/types"; 4 8 import { formatCount } from "$/lib/utils/text"; 5 9 import { createMemo, For, Show } from "solid-js"; ··· 207 211 const displayName = createMemo(() => getDisplayName(props.profile)); 208 212 const isFollowing = createMemo(() => !!props.profile.viewer?.following); 209 213 const bannerStyle = createMemo(() => ({ transform: `translate3d(0, ${props.coverOffset}px, 0)` })); 214 + const profileLabels = () => collectModerationLabels(props.profile); 215 + const profileDecision = useModerationDecision(profileLabels); 210 216 211 217 return ( 212 218 <header class="relative" ref={(element) => props.rootRef?.(element)}> ··· 244 250 onUnfollow={props.onUnfollow} /> 245 251 </div> 246 252 253 + <ModerationBadgeRow decision={profileDecision()} labels={profileLabels()} /> 254 + 247 255 <ProfileMetaRow 248 256 did={props.profile.did} 249 257 joinedLabel={props.joinedLabel} ··· 263 271 264 272 function ProfileAvatar(props: { profile: ProfileViewDetailed }) { 265 273 const profile = () => props.profile; 266 - const avatar = createMemo(() => profile().avatar); 267 274 const label = createMemo(() => getAvatarLabel(props.profile)); 275 + const labels = () => collectModerationLabels(props.profile); 276 + const decision = useModerationDecision(labels); 268 277 269 278 return ( 270 - <div class="relative h-32 w-32 shrink-0 overflow-hidden rounded-full bg-black/60 shadow-[0_0_0_4px_rgba(8,8,8,0.96),0_0_0_6px_rgba(125,175,255,0.22),0_24px_40px_rgba(0,0,0,0.36)] backdrop-blur-sm"> 271 - <Show 272 - when={avatar()} 273 - fallback={ 274 - <div class="flex h-full w-full items-center justify-center text-[2rem] font-semibold text-on-surface"> 275 - {label()} 276 - </div> 277 - }> 278 - {(a) => <img alt="" class="h-full w-full object-cover" src={a()} />} 279 - </Show> 280 - </div> 279 + <ModeratedAvatar 280 + avatar={profile().avatar} 281 + class="relative h-32 w-32 shrink-0 overflow-hidden rounded-full bg-black/60 shadow-[0_0_0_4px_rgba(8,8,8,0.96),0_0_0_6px_rgba(125,175,255,0.22),0_24px_40px_rgba(0,0,0,0.36)] backdrop-blur-sm" 282 + hidden={decision().filter || decision().blur !== "none"} 283 + label={label()} 284 + fallbackClass="text-[2rem] font-semibold text-on-surface" /> 281 285 ); 282 286 } 283 287 ··· 285 289 const avatarLabel = createMemo(() => getAvatarLabel(props.profile)); 286 290 const displayName = createMemo(() => getDisplayName(props.profile)); 287 291 const visibleBadges = createMemo(() => props.profileBadges.slice(0, 2)); 292 + const labels = () => collectModerationLabels(props.profile); 293 + const decision = useModerationDecision(labels); 288 294 289 295 return ( 290 296 <div 291 297 class="sticky top-0 z-30 px-3 pb-3 pt-3 backdrop-blur-[18px] max-[520px]:px-2" 292 298 data-testid="profile-sticky-header"> 293 299 <div class="flex items-center gap-3 rounded-3xl bg-[rgba(14,14,14,0.92)] px-4 py-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 294 - <div class="relative h-12 w-12 shrink-0 overflow-hidden rounded-full bg-black/60 shadow-[0_0_0_2px_rgba(8,8,8,0.96),0_0_0_3px_rgba(125,175,255,0.2)]"> 295 - <Show 296 - when={props.profile.avatar} 297 - fallback={ 298 - <div class="flex h-full w-full items-center justify-center text-sm font-semibold text-on-surface"> 299 - {avatarLabel()} 300 - </div> 301 - }> 302 - {(avatar) => <img alt="" class="h-full w-full object-cover" src={avatar()} />} 303 - </Show> 304 - </div> 300 + <ModeratedAvatar 301 + avatar={props.profile.avatar} 302 + class="relative h-12 w-12 shrink-0 overflow-hidden rounded-full bg-black/60 shadow-[0_0_0_2px_rgba(8,8,8,0.96),0_0_0_3px_rgba(125,175,255,0.2)]" 303 + hidden={decision().filter || decision().blur !== "none"} 304 + label={avatarLabel()} 305 + fallbackClass="text-sm font-semibold text-on-surface" /> 305 306 306 307 <div class="min-w-0"> 307 308 <p class="m-0 truncate text-base font-semibold leading-tight tracking-[-0.02em] text-on-surface">
-2
src/components/search/SyncStatusPanel.test.tsx
··· 96 96 const syncButton = await screen.findByRole("button", { name: /sync now/i }); 97 97 fireEvent.click(syncButton); 98 98 99 - // Check that the button shows syncing state and is disabled 100 99 await waitFor(() => { 101 100 expect(screen.getByRole("button", { name: /syncing/i })).toBeDisabled(); 102 101 }); ··· 219 218 expect(syncPostsMock).toHaveBeenCalled(); 220 219 }); 221 220 222 - // Should return to normal state after error 223 221 expect(await screen.findByRole("button", { name: /sync now/i })).toBeEnabled(); 224 222 }); 225 223
+91
src/components/settings/SettingsModeration.test.tsx
··· 1 + import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 2 + import { beforeEach, describe, expect, it, vi } from "vitest"; 3 + import { SettingsModeration } from "./SettingsModeration"; 4 + 5 + const getModerationPrefsMock = vi.hoisted(() => vi.fn()); 6 + const setAdultContentEnabledMock = vi.hoisted(() => vi.fn()); 7 + const setLabelPreferenceMock = vi.hoisted(() => vi.fn()); 8 + const subscribeLabelerMock = vi.hoisted(() => vi.fn()); 9 + const unsubscribeLabelerMock = vi.hoisted(() => vi.fn()); 10 + const getDistributionChannelMock = vi.hoisted(() => vi.fn()); 11 + const openUrlMock = vi.hoisted(() => vi.fn()); 12 + 13 + vi.mock( 14 + "$/lib/api/moderation", 15 + () => ({ 16 + ModerationController: { 17 + getModerationPrefs: getModerationPrefsMock, 18 + setAdultContentEnabled: setAdultContentEnabledMock, 19 + setLabelPreference: setLabelPreferenceMock, 20 + subscribeLabeler: subscribeLabelerMock, 21 + unsubscribeLabeler: unsubscribeLabelerMock, 22 + getDistributionChannel: getDistributionChannelMock, 23 + }, 24 + }), 25 + ); 26 + 27 + vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: openUrlMock })); 28 + 29 + describe("SettingsModeration", () => { 30 + beforeEach(() => { 31 + vi.resetAllMocks(); 32 + getModerationPrefsMock.mockResolvedValue({ 33 + adultContentEnabled: false, 34 + subscribedLabelers: ["did:plc:custom-labeler"], 35 + labelPreferences: { "did:plc:custom-labeler": { porn: "hide" } }, 36 + }); 37 + setAdultContentEnabledMock.mockResolvedValue(void 0); 38 + setLabelPreferenceMock.mockResolvedValue(void 0); 39 + subscribeLabelerMock.mockResolvedValue(void 0); 40 + unsubscribeLabelerMock.mockResolvedValue(void 0); 41 + getDistributionChannelMock.mockResolvedValue("github"); 42 + openUrlMock.mockResolvedValue(void 0); 43 + }); 44 + 45 + it("toggles adult content on github builds", async () => { 46 + render(() => <SettingsModeration />); 47 + 48 + const toggle = await screen.findByRole("switch", { name: "Adult content" }); 49 + fireEvent.click(toggle); 50 + 51 + await waitFor(() => expect(setAdultContentEnabledMock).toHaveBeenCalledWith(true)); 52 + }); 53 + 54 + it("shows web-settings path for mac app store builds", async () => { 55 + getDistributionChannelMock.mockResolvedValue("mac_app_store"); 56 + render(() => <SettingsModeration />); 57 + 58 + expect(await screen.findByText(/use Bluesky web settings/i)).toBeInTheDocument(); 59 + expect(screen.queryByRole("switch", { name: "Adult content" })).not.toBeInTheDocument(); 60 + }); 61 + 62 + it("adds custom labelers", async () => { 63 + render(() => <SettingsModeration />); 64 + 65 + const input = await screen.findByPlaceholderText("did:plc:..."); 66 + fireEvent.input(input, { target: { value: "did:plc:new-labeler" } }); 67 + fireEvent.click(screen.getByRole("button", { name: "Add labeler" })); 68 + 69 + await waitFor(() => expect(subscribeLabelerMock).toHaveBeenCalledWith("did:plc:new-labeler")); 70 + }); 71 + 72 + it("removes custom labelers", async () => { 73 + render(() => <SettingsModeration />); 74 + 75 + await screen.findByText("Subscribed labelers"); 76 + fireEvent.click(screen.getByRole("button", { name: "Remove" })); 77 + await waitFor(() => expect(unsubscribeLabelerMock).toHaveBeenCalledWith("did:plc:custom-labeler")); 78 + }); 79 + 80 + it("adds a label visibility override", async () => { 81 + render(() => <SettingsModeration />); 82 + 83 + const inputs = await screen.findAllByPlaceholderText("label identifier (for example: sexual)"); 84 + fireEvent.input(inputs[0], { target: { value: "graphic-media" } }); 85 + fireEvent.click(screen.getAllByRole("button", { name: "Add override" })[0]); 86 + 87 + await waitFor(() => 88 + expect(setLabelPreferenceMock).toHaveBeenCalledWith("did:plc:ar7c4by46qjdydhdevvrndac", "graphic-media", "warn") 89 + ); 90 + }); 91 + });
+376
src/components/settings/SettingsModeration.tsx
··· 1 + import { ModerationController } from "$/lib/api/moderation"; 2 + import { BUILTIN_LABELER_DID } from "$/lib/moderation"; 3 + import type { ModerationLabelVisibility, StoredModerationPrefs } from "$/lib/types"; 4 + import { normalizeError } from "$/lib/utils/text"; 5 + import * as logger from "@tauri-apps/plugin-log"; 6 + import { openUrl } from "@tauri-apps/plugin-opener"; 7 + import { createMemo, createSignal, For, onMount, Show } from "solid-js"; 8 + import { createStore } from "solid-js/store"; 9 + import { Icon } from "../shared/Icon"; 10 + import { SettingsCard } from "./SettingsCard"; 11 + import { SettingsInlineFeedback, useTransientFeedback } from "./SettingsInlineFeedback"; 12 + import { ToggleRow } from "./SettingsToggleRow"; 13 + 14 + type DraftState = { 15 + addLabelerDid: string; 16 + addLabelNameByDid: Record<string, string>; 17 + addLabelVisibilityByDid: Record<string, ModerationLabelVisibility>; 18 + }; 19 + 20 + const VISIBILITY_OPTIONS: ModerationLabelVisibility[] = ["ignore", "warn", "hide"]; 21 + 22 + function isAdultOnlyLikeLabel(label: string) { 23 + return /adult|nsfw|porn|sexual|nudity/iu.test(label); 24 + } 25 + 26 + function VisibilityOptions() { 27 + return <For each={VISIBILITY_OPTIONS}>{(option) => <option value={option}>{option}</option>}</For>; 28 + } 29 + 30 + function LabelOverrideDraftEditor( 31 + props: { 32 + canAdd: boolean; 33 + onAdd: () => void; 34 + onVisibilityChange: (visibility: ModerationLabelVisibility) => void; 35 + visibility: ModerationLabelVisibility; 36 + }, 37 + ) { 38 + return ( 39 + <div class="flex flex-wrap items-center gap-2"> 40 + <select 41 + value={props.visibility} 42 + class="rounded-lg border border-white/10 bg-black/35 px-2 py-1 text-xs text-on-surface outline-none transition focus:border-primary/50" 43 + onInput={(event) => props.onVisibilityChange(event.currentTarget.value as ModerationLabelVisibility)}> 44 + <VisibilityOptions /> 45 + </select> 46 + <button 47 + type="button" 48 + disabled={!props.canAdd} 49 + class="rounded-full border-0 bg-primary px-3 py-1 text-[0.7rem] font-medium text-on-primary-fixed transition hover:bg-primary-dim disabled:opacity-60" 50 + onClick={() => props.onAdd()}> 51 + Add override 52 + </button> 53 + </div> 54 + ); 55 + } 56 + 57 + export function SettingsModeration() { 58 + const [loading, setLoading] = createSignal(true); 59 + const [savingAdult, setSavingAdult] = createSignal(false); 60 + const [busyLabelerDid, setBusyLabelerDid] = createSignal<string | null>(null); 61 + const [prefs, setPrefs] = createSignal<StoredModerationPrefs | null>(null); 62 + const [distributionChannel, setDistributionChannel] = createSignal("github"); 63 + const [draft, setDraft] = createStore<DraftState>({ 64 + addLabelerDid: "", 65 + addLabelNameByDid: {}, 66 + addLabelVisibilityByDid: {}, 67 + }); 68 + const feedback = useTransientFeedback(); 69 + 70 + const effectiveLabelers = createMemo(() => { 71 + const current = prefs(); 72 + const custom = current?.subscribedLabelers ?? []; 73 + return [BUILTIN_LABELER_DID, ...custom.filter((did) => did !== BUILTIN_LABELER_DID)]; 74 + }); 75 + 76 + onMount(() => { 77 + void loadState(); 78 + }); 79 + 80 + async function loadState() { 81 + setLoading(true); 82 + try { 83 + const [loadedPrefs, channel] = await Promise.all([ 84 + ModerationController.getModerationPrefs(), 85 + ModerationController.getDistributionChannel(), 86 + ]); 87 + setPrefs(loadedPrefs); 88 + setDistributionChannel(channel); 89 + } catch (error) { 90 + const message = normalizeError(error); 91 + logger.error("failed to load moderation settings", { keyValues: { error: message } }); 92 + feedback.queueFeedback({ kind: "error", message: "Could not load moderation settings." }); 93 + } finally { 94 + setLoading(false); 95 + } 96 + } 97 + 98 + async function toggleAdultContent() { 99 + const current = prefs(); 100 + if (!current || savingAdult()) { 101 + return; 102 + } 103 + 104 + const enabled = !current.adultContentEnabled; 105 + setSavingAdult(true); 106 + 107 + try { 108 + await ModerationController.setAdultContentEnabled(enabled); 109 + setPrefs({ ...current, adultContentEnabled: enabled }); 110 + feedback.queueFeedback({ 111 + kind: "success", 112 + message: enabled ? "Adult content is now enabled." : "Adult content is now disabled.", 113 + }); 114 + } catch (error) { 115 + const message = normalizeError(error); 116 + logger.error("failed to update adult-content preference", { keyValues: { error: message } }); 117 + feedback.queueFeedback({ kind: "error", message: "Could not update adult-content preference." }); 118 + } finally { 119 + setSavingAdult(false); 120 + } 121 + } 122 + 123 + async function addLabeler() { 124 + const did = draft.addLabelerDid.trim(); 125 + if (!did || busyLabelerDid()) { 126 + return; 127 + } 128 + 129 + setBusyLabelerDid(did); 130 + try { 131 + await ModerationController.subscribeLabeler(did); 132 + await refreshPrefs(); 133 + setDraft("addLabelerDid", ""); 134 + feedback.queueFeedback({ kind: "success", message: "Labeler added." }); 135 + } catch (error) { 136 + const message = normalizeError(error); 137 + logger.error("failed to subscribe labeler", { keyValues: { did, error: message } }); 138 + feedback.queueFeedback({ kind: "error", message: message || "Could not add that labeler." }); 139 + } finally { 140 + setBusyLabelerDid(null); 141 + } 142 + } 143 + 144 + async function removeLabeler(did: string) { 145 + if (!did || busyLabelerDid()) { 146 + return; 147 + } 148 + 149 + setBusyLabelerDid(did); 150 + try { 151 + await ModerationController.unsubscribeLabeler(did); 152 + await refreshPrefs(); 153 + feedback.queueFeedback({ kind: "success", message: "Labeler removed." }); 154 + } catch (error) { 155 + const message = normalizeError(error); 156 + logger.error("failed to unsubscribe labeler", { keyValues: { did, error: message } }); 157 + feedback.queueFeedback({ kind: "error", message: message || "Could not remove that labeler." }); 158 + } finally { 159 + setBusyLabelerDid(null); 160 + } 161 + } 162 + 163 + async function updateLabelPreference(labelerDid: string, label: string, visibility: ModerationLabelVisibility) { 164 + const current = prefs(); 165 + if (!current) { 166 + return; 167 + } 168 + 169 + try { 170 + await ModerationController.setLabelPreference(labelerDid, label, visibility); 171 + const next: StoredModerationPrefs = { 172 + ...current, 173 + labelPreferences: { 174 + ...current.labelPreferences, 175 + [labelerDid]: { ...current.labelPreferences[labelerDid], [label]: visibility }, 176 + }, 177 + }; 178 + setPrefs(next); 179 + feedback.queueFeedback({ kind: "success", message: "Label preference saved." }); 180 + } catch (error) { 181 + const message = normalizeError(error); 182 + logger.error("failed to set label preference", { keyValues: { label, labelerDid, error: message } }); 183 + feedback.queueFeedback({ kind: "error", message: "Could not save that label preference." }); 184 + } 185 + } 186 + 187 + async function addLabelPreference(labelerDid: string) { 188 + const label = (draft.addLabelNameByDid[labelerDid] ?? "").trim(); 189 + const visibility = draft.addLabelVisibilityByDid[labelerDid] ?? "warn"; 190 + 191 + if (!label) { 192 + return; 193 + } 194 + 195 + await updateLabelPreference(labelerDid, label, visibility); 196 + setDraft("addLabelNameByDid", labelerDid, ""); 197 + } 198 + 199 + function labelEntries(labelerDid: string) { 200 + const current = prefs(); 201 + const entries = Object.entries(current?.labelPreferences[labelerDid] ?? {}); 202 + return entries.toSorted(([left], [right]) => left.localeCompare(right)); 203 + } 204 + 205 + function isMasBuild() { 206 + return distributionChannel() === "mac_app_store"; 207 + } 208 + 209 + async function refreshPrefs() { 210 + const next = await ModerationController.getModerationPrefs(); 211 + setPrefs(next); 212 + } 213 + 214 + return ( 215 + <SettingsCard icon="danger" title="Moderation"> 216 + <div class="grid gap-4"> 217 + <SettingsInlineFeedback feedback={feedback.feedback()} /> 218 + 219 + <Show when={loading()}> 220 + <p class="m-0 text-sm text-on-surface-variant">Loading moderation settings...</p> 221 + </Show> 222 + 223 + <Show when={!loading() && prefs()}> 224 + {(current) => ( 225 + <> 226 + <Show 227 + when={!isMasBuild()} 228 + fallback={ 229 + <div class="grid gap-2 rounded-2xl bg-surface-container-high px-4 py-3 text-sm text-on-surface-variant"> 230 + <p class="m-0 font-medium text-on-surface">Adult content</p> 231 + <p class="m-0"> 232 + On Mac App Store builds, use Bluesky web settings to manage adult-content visibility. 233 + </p> 234 + <button 235 + type="button" 236 + class="inline-flex w-fit items-center gap-2 rounded-full border-0 bg-primary/20 px-3 py-1.5 text-xs font-medium text-primary transition hover:bg-primary/30" 237 + onClick={() => void openUrl("https://bsky.app/settings/content-moderation")}> 238 + <Icon aria-hidden="true" iconClass="i-ri-external-link-line" /> 239 + Open web settings 240 + </button> 241 + </div> 242 + }> 243 + <ToggleRow 244 + checked={current().adultContentEnabled} 245 + disabled={savingAdult()} 246 + label="Adult content" 247 + description="Allow reveal of adult-only labeled content" 248 + onChange={() => void toggleAdultContent()} /> 249 + </Show> 250 + 251 + <section class="grid gap-3 rounded-2xl bg-surface-container-high/65 px-4 py-3"> 252 + <div class="grid gap-1"> 253 + <p class="m-0 text-sm font-medium text-on-surface">Subscribed labelers</p> 254 + <p class="m-0 text-xs text-on-surface-variant"> 255 + {current().subscribedLabelers.length}/20 custom labelers configured. 256 + </p> 257 + </div> 258 + 259 + <For each={effectiveLabelers()}> 260 + {(did) => ( 261 + <div class="flex flex-wrap items-center justify-between gap-2 rounded-xl bg-black/25 px-3 py-2"> 262 + <div class="grid gap-0.5"> 263 + <span class="text-xs font-medium text-on-surface">{did}</span> 264 + <Show when={did === BUILTIN_LABELER_DID}> 265 + <span class="text-[0.7rem] text-on-surface-variant">Built-in Bluesky safety labeler</span> 266 + </Show> 267 + </div> 268 + <Show when={did !== BUILTIN_LABELER_DID}> 269 + <button 270 + type="button" 271 + disabled={busyLabelerDid() === did} 272 + class="rounded-lg border border-white/20 px-3 py-1.5 text-xs font-medium text-on-surface transition hover:bg-white/5 disabled:opacity-70" 273 + onClick={() => void removeLabeler(did)}> 274 + {busyLabelerDid() === did ? "Removing..." : "Remove"} 275 + </button> 276 + </Show> 277 + </div> 278 + )} 279 + </For> 280 + 281 + <div class="grid gap-2 rounded-xl bg-black/20 p-3"> 282 + <label class="grid gap-1"> 283 + <span class="text-xs text-on-surface-variant">Add labeler DID</span> 284 + <input 285 + type="text" 286 + value={draft.addLabelerDid} 287 + placeholder="did:plc:..." 288 + class="rounded-lg border border-white/10 bg-black/35 px-3 py-2 text-sm text-on-surface outline-none transition focus:border-primary/50" 289 + onInput={(event) => setDraft("addLabelerDid", event.currentTarget.value)} /> 290 + </label> 291 + <button 292 + type="button" 293 + disabled={!draft.addLabelerDid.trim() || !!busyLabelerDid()} 294 + class="justify-self-start rounded-full border-0 bg-primary px-3 py-1.5 text-xs font-medium text-on-primary-fixed transition hover:bg-primary-dim disabled:opacity-60" 295 + onClick={() => void addLabeler()}> 296 + Add labeler 297 + </button> 298 + </div> 299 + </section> 300 + 301 + <section class="grid gap-3 rounded-2xl bg-surface-container-high/65 px-4 py-3"> 302 + <div class="grid gap-1"> 303 + <p class="m-0 text-sm font-medium text-on-surface">Label preferences</p> 304 + <p class="m-0 text-xs text-on-surface-variant"> 305 + Override label visibility per labeler: ignore, warn, or hide. 306 + </p> 307 + </div> 308 + 309 + <For each={effectiveLabelers()}> 310 + {(did) => { 311 + const entries = () => labelEntries(did); 312 + 313 + return ( 314 + <details class="rounded-xl bg-black/25 px-3 py-2" open> 315 + <summary class="cursor-pointer select-none text-xs font-medium text-on-surface">{did}</summary> 316 + <div class="mt-3 grid gap-2"> 317 + <Show 318 + when={entries().length > 0} 319 + fallback={<p class="m-0 text-xs text-on-surface-variant">No overrides yet.</p>}> 320 + <For each={entries()}> 321 + {([label, visibility]) => { 322 + const gated = !current().adultContentEnabled && isAdultOnlyLikeLabel(label); 323 + return ( 324 + <div class="grid gap-1 rounded-lg bg-black/30 px-3 py-2"> 325 + <span class="text-xs text-on-surface">{label}</span> 326 + <div class="flex flex-wrap items-center gap-2"> 327 + <select 328 + value={visibility} 329 + disabled={gated} 330 + class="rounded-lg border border-white/10 bg-black/35 px-2 py-1 text-xs text-on-surface outline-none transition focus:border-primary/50 disabled:opacity-60" 331 + onInput={(event) => 332 + void updateLabelPreference( 333 + did, 334 + label, 335 + event.currentTarget.value as ModerationLabelVisibility, 336 + )}> 337 + <VisibilityOptions /> 338 + </select> 339 + <Show when={gated}> 340 + <span class="text-[0.7rem] text-on-surface-variant"> 341 + Enable adult content to edit this label. 342 + </span> 343 + </Show> 344 + </div> 345 + </div> 346 + ); 347 + }} 348 + </For> 349 + </Show> 350 + 351 + <div class="grid gap-2 rounded-lg bg-black/30 p-2"> 352 + <input 353 + type="text" 354 + value={draft.addLabelNameByDid[did] ?? ""} 355 + placeholder="label identifier (for example: sexual)" 356 + class="rounded-lg border border-white/10 bg-black/35 px-3 py-1.5 text-xs text-on-surface outline-none transition focus:border-primary/50" 357 + onInput={(event) => setDraft("addLabelNameByDid", did, event.currentTarget.value)} /> 358 + <LabelOverrideDraftEditor 359 + canAdd={!!(draft.addLabelNameByDid[did] ?? "").trim()} 360 + onAdd={() => void addLabelPreference(did)} 361 + onVisibilityChange={(visibility) => setDraft("addLabelVisibilityByDid", did, visibility)} 362 + visibility={draft.addLabelVisibilityByDid[did] ?? "warn"} /> 363 + </div> 364 + </div> 365 + </details> 366 + ); 367 + }} 368 + </For> 369 + </section> 370 + </> 371 + )} 372 + </Show> 373 + </div> 374 + </SettingsCard> 375 + ); 376 + }
+31
src/components/settings/SettingsPanel.test.tsx
··· 13 13 const getLogEntriesMock = vi.hoisted(() => vi.fn()); 14 14 const getDownloadDirectoryMock = vi.hoisted(() => vi.fn()); 15 15 const setDownloadDirectoryMock = vi.hoisted(() => vi.fn()); 16 + const getModerationPrefsMock = vi.hoisted(() => vi.fn()); 17 + const setAdultContentEnabledMock = vi.hoisted(() => vi.fn()); 18 + const setLabelPreferenceMock = vi.hoisted(() => vi.fn()); 19 + const subscribeLabelerMock = vi.hoisted(() => vi.fn()); 20 + const unsubscribeLabelerMock = vi.hoisted(() => vi.fn()); 21 + const getDistributionChannelMock = vi.hoisted(() => vi.fn()); 16 22 const dialogOpenMock = vi.hoisted(() => vi.fn()); 17 23 const navigateMock = vi.hoisted(() => vi.fn()); 18 24 const infoMock = vi.hoisted(() => vi.fn()); ··· 39 45 resetApp: resetAppMock, 40 46 resetAndRestartApp: resetAndRestartAppMock, 41 47 getLogEntries: getLogEntriesMock, 48 + }, 49 + }), 50 + ); 51 + 52 + vi.mock( 53 + "$/lib/api/moderation", 54 + () => ({ 55 + ModerationController: { 56 + getModerationPrefs: getModerationPrefsMock, 57 + setAdultContentEnabled: setAdultContentEnabledMock, 58 + setLabelPreference: setLabelPreferenceMock, 59 + subscribeLabeler: subscribeLabelerMock, 60 + unsubscribeLabeler: unsubscribeLabelerMock, 61 + getDistributionChannel: getDistributionChannelMock, 42 62 }, 43 63 }), 44 64 ); ··· 118 138 resetAndRestartAppMock.mockResolvedValue(void 0); 119 139 getDownloadDirectoryMock.mockResolvedValue("/Users/test/Downloads"); 120 140 setDownloadDirectoryMock.mockResolvedValue(void 0); 141 + getModerationPrefsMock.mockResolvedValue({ 142 + adultContentEnabled: false, 143 + subscribedLabelers: [], 144 + labelPreferences: {}, 145 + }); 146 + setAdultContentEnabledMock.mockResolvedValue(void 0); 147 + setLabelPreferenceMock.mockResolvedValue(void 0); 148 + subscribeLabelerMock.mockResolvedValue(void 0); 149 + unsubscribeLabelerMock.mockResolvedValue(void 0); 150 + getDistributionChannelMock.mockResolvedValue("github"); 121 151 dialogOpenMock.mockResolvedValue(null); 122 152 }); 123 153 ··· 128 158 expect(await screen.findByText("Appearance")).toBeInTheDocument(); 129 159 expect(await screen.findByText("Timeline")).toBeInTheDocument(); 130 160 expect(await screen.findByText("Notifications")).toBeInTheDocument(); 161 + expect(await screen.findByText("Moderation")).toBeInTheDocument(); 131 162 expect(await screen.findByText("Accounts")).toBeInTheDocument(); 132 163 expect(await screen.findByText("Services")).toBeInTheDocument(); 133 164 expect(await screen.findByText("Data")).toBeInTheDocument();
+2
src/components/settings/SettingsPanel.tsx
··· 23 23 import { SettingsData } from "./SettingsData"; 24 24 import { SettingsDownloads } from "./SettingsDownloads"; 25 25 import { SettingsLogs } from "./SettingsLogs"; 26 + import { SettingsModeration } from "./SettingsModeration"; 26 27 import { NotificationsControl } from "./SettingsNotification"; 27 28 import { SettingsService } from "./SettingsService"; 28 29 import { AppearanceControl } from "./SettingsTheme"; ··· 283 284 <AppearanceControl currentTheme={currentTheme()} handleUpdateSetting={handleUpdateSetting} /> 284 285 <TimelineControl currentRefresh={currentRefresh()} handleUpdateSetting={handleUpdateSetting} /> 285 286 <NotificationsControl settings={settings()} handleUpdateSetting={handleUpdateSetting} /> 287 + <SettingsModeration /> 286 288 <EmbeddingsSettings /> 287 289 <AccountControl openConfirmation={openConfirmation} /> 288 290 <SettingsService settings={settings()} handleUpdateSetting={handleUpdateSetting} />
+2 -1
src/index.tsx
··· 1 1 /* @refresh reload */ 2 2 import { getCurrentWindow } from "@tauri-apps/api/window"; 3 + import * as logger from "@tauri-apps/plugin-log"; 3 4 import { render } from "solid-js/web"; 4 5 import App from "./App"; 5 6 ··· 13 14 globalThis.history.replaceState(null, "", "#/composer"); 14 15 } 15 16 } catch { 16 - // Non-Tauri environments do not expose a window label. 17 + logger.debug("Failed to get window label"); 17 18 } 18 19 }
+83
src/lib/api/moderation.ts
··· 1 + import { DEFAULT_MODERATION_DECISION } from "$/lib/moderation"; 2 + import type { 3 + DistributionChannel, 4 + ModerationLabel, 5 + ModerationLabelVisibility, 6 + ModerationReasonType, 7 + ModerationUiDecision, 8 + ReportSubjectInput, 9 + StoredModerationPrefs, 10 + } from "$/lib/types"; 11 + import { invoke } from "@tauri-apps/api/core"; 12 + import * as logger from "@tauri-apps/plugin-log"; 13 + 14 + async function getModerationPrefs() { 15 + return invoke<StoredModerationPrefs>("get_moderation_prefs"); 16 + } 17 + 18 + async function setAdultContentEnabled(enabled: boolean) { 19 + return invoke<void>("set_adult_content_enabled", { enabled }); 20 + } 21 + 22 + async function setLabelPreference(labelerDid: string, label: string, visibility: ModerationLabelVisibility) { 23 + return invoke<void>("set_label_preference", { labelerDid, label, visibility }); 24 + } 25 + 26 + async function subscribeLabeler(did: string) { 27 + return invoke<void>("subscribe_labeler", { did }); 28 + } 29 + 30 + async function unsubscribeLabeler(did: string) { 31 + return invoke<void>("unsubscribe_labeler", { did }); 32 + } 33 + 34 + async function moderateContent(labels: ModerationLabel[]): Promise<ModerationUiDecision> { 35 + if (labels.length === 0) { 36 + return DEFAULT_MODERATION_DECISION; 37 + } 38 + 39 + try { 40 + return await invoke<ModerationUiDecision>("moderate_content", { labelsJson: JSON.stringify(labels) }); 41 + } catch (error) { 42 + logger.warn("moderation decision failed", { keyValues: { error: String(error), labels: String(labels.length) } }); 43 + return DEFAULT_MODERATION_DECISION; 44 + } 45 + } 46 + 47 + async function createReport(subject: ReportSubjectInput, reasonType: ModerationReasonType, reason?: string) { 48 + return invoke<number>("create_report", { reason: reason?.trim() ? reason.trim() : null, reasonType, subject }); 49 + } 50 + 51 + async function getDistributionChannel(): Promise<DistributionChannel> { 52 + const value = await invoke<string>("get_distribution_channel"); 53 + if (value === "github" || value === "mac_app_store" || value === "microsoft_store") { 54 + return value; 55 + } 56 + 57 + return "github"; 58 + } 59 + 60 + async function blockActor(did: string) { 61 + return invoke<{ uri: string; cid: string }>("block_actor", { did }); 62 + } 63 + 64 + export const ModerationController = { 65 + getModerationPrefs, 66 + setAdultContentEnabled, 67 + setLabelPreference, 68 + subscribeLabeler, 69 + unsubscribeLabeler, 70 + moderateContent, 71 + createReport, 72 + getDistributionChannel, 73 + blockActor, 74 + }; 75 + 76 + export const MODERATION_REASON_OPTIONS: Array<{ label: string; value: ModerationReasonType }> = [ 77 + { label: "Spam", value: "com.atproto.moderation.defs#reasonSpam" }, 78 + { label: "Violation", value: "com.atproto.moderation.defs#reasonViolation" }, 79 + { label: "Misleading", value: "com.atproto.moderation.defs#reasonMisleading" }, 80 + { label: "Sexual", value: "com.atproto.moderation.defs#reasonSexual" }, 81 + { label: "Rude", value: "com.atproto.moderation.defs#reasonRude" }, 82 + { label: "Other", value: "com.atproto.moderation.defs#reasonOther" }, 83 + ];
+147
src/lib/moderation.ts
··· 1 + import type { ModerationLabel, ModerationUiDecision } from "$/lib/types"; 2 + 3 + /** 4 + * Official Bluesky labeler DID (@moderation.bsky.app) 5 + */ 6 + export const BUILTIN_LABELER_DID = "did:plc:ar7c4by46qjdydhdevvrndac"; 7 + 8 + export const DEFAULT_MODERATION_DECISION: ModerationUiDecision = { 9 + alert: false, 10 + blur: "none", 11 + filter: false, 12 + inform: false, 13 + noOverride: false, 14 + }; 15 + 16 + export type ModerationLabelSummary = { key: string; source: string; value: string }; 17 + 18 + export function asModerationLabels(value: unknown): ModerationLabel[] { 19 + const record = asRecord(value); 20 + const labels = record?.["labels"]; 21 + if (!Array.isArray(labels)) { 22 + return []; 23 + } 24 + 25 + return labels.filter((label): label is ModerationLabel => isRecordLike(label)); 26 + } 27 + 28 + export function collectModerationLabels(...values: unknown[]): ModerationLabel[] { 29 + const labels = values.flatMap((value) => asModerationLabels(value)); 30 + if (labels.length <= 1) { 31 + return labels; 32 + } 33 + 34 + const deduped: ModerationLabel[] = []; 35 + const seen = new Set<string>(); 36 + 37 + for (const label of labels) { 38 + const source = typeof label.src === "string" ? label.src : ""; 39 + const value = typeof label.val === "string" ? label.val : ""; 40 + const uri = typeof label.uri === "string" ? label.uri : ""; 41 + const key = `${source}|${value}|${uri}`; 42 + if (seen.has(key)) { 43 + continue; 44 + } 45 + 46 + seen.add(key); 47 + deduped.push(label); 48 + } 49 + 50 + return deduped; 51 + } 52 + 53 + export function moderationLabelsKey(labels: ModerationLabel[]): string { 54 + if (labels.length === 0) { 55 + return ""; 56 + } 57 + 58 + const tokens = labels.map((label) => { 59 + const source = typeof label.src === "string" ? label.src.trim() : ""; 60 + const value = typeof label.val === "string" ? label.val.trim() : ""; 61 + const uri = typeof label.uri === "string" ? label.uri.trim() : ""; 62 + return `${source}|${value}|${uri}`; 63 + }); 64 + 65 + return tokens.toSorted().join(";"); 66 + } 67 + 68 + export function summarizeModerationLabels(labels: ModerationLabel[], limit = 3): ModerationLabelSummary[] { 69 + if (labels.length === 0) { 70 + return []; 71 + } 72 + 73 + const summaries: ModerationLabelSummary[] = []; 74 + const seen = new Set<string>(); 75 + 76 + for (const label of labels) { 77 + const value = toLabelDisplayValue(label.val); 78 + if (!value) { 79 + continue; 80 + } 81 + 82 + const source = toSourceDisplayValue(label.src); 83 + const key = `${source}|${value}`; 84 + if (seen.has(key)) { 85 + continue; 86 + } 87 + 88 + seen.add(key); 89 + summaries.push({ key, source, value }); 90 + 91 + if (summaries.length >= limit) { 92 + break; 93 + } 94 + } 95 + 96 + return summaries; 97 + } 98 + 99 + function toLabelDisplayValue(value: unknown): string | null { 100 + if (typeof value !== "string") { 101 + return null; 102 + } 103 + 104 + const normalized = value.trim(); 105 + if (!normalized) { 106 + return null; 107 + } 108 + 109 + if (normalized.startsWith("!")) { 110 + return `Not ${normalized.slice(1)}`; 111 + } 112 + 113 + return normalized; 114 + } 115 + 116 + function toSourceDisplayValue(source: unknown): string { 117 + if (typeof source !== "string") { 118 + return "Unknown"; 119 + } 120 + 121 + const normalized = source.trim(); 122 + if (!normalized) { 123 + return "Unknown"; 124 + } 125 + 126 + if (!normalized.startsWith("did:")) { 127 + return normalized; 128 + } 129 + 130 + if (normalized.length <= 22) { 131 + return normalized; 132 + } 133 + 134 + return `${normalized.slice(0, 16)}...${normalized.slice(-6)}`; 135 + } 136 + 137 + function asRecord(value: unknown): Record<string, unknown> | null { 138 + if (!isRecordLike(value)) { 139 + return null; 140 + } 141 + 142 + return value; 143 + } 144 + 145 + function isRecordLike(value: unknown): value is Record<string, unknown> { 146 + return !!value && typeof value === "object" && !Array.isArray(value); 147 + }
+2
src/lib/profile.test.ts
··· 37 37 did: "did:plc:bob", 38 38 displayName: "Bob", 39 39 handle: "bob.test", 40 + labels: [], 40 41 viewer: { following: "at://did:plc:alice/app.bsky.graph.follow/1" }, 41 42 }], 42 43 }); ··· 84 85 followsCount: null, 85 86 handle: "bob.test", 86 87 indexedAt: null, 88 + labels: [], 87 89 pinnedPost: null, 88 90 postsCount: null, 89 91 pronouns: null,
+3
src/lib/profile.ts
··· 1 1 import { isReplyItem, parseFeedResponse } from "$/lib/feeds"; 2 + import { asModerationLabels } from "$/lib/moderation"; 2 3 import type { 3 4 ActorListResponse, 4 5 FeedResponse, ··· 56 57 followsCount: optionalNumber(record.followsCount), 57 58 handle: record.handle, 58 59 indexedAt: optionalString(record.indexedAt), 60 + labels: asModerationLabels(record), 59 61 pinnedPost: pinnedPost && typeof pinnedPost.uri === "string" 60 62 ? { cid: optionalString(pinnedPost.cid), uri: pinnedPost.uri } 61 63 : null, ··· 123 125 displayName: optionalString(record.displayName), 124 126 avatar: optionalString(record.avatar), 125 127 description: optionalString(record.description), 128 + labels: asModerationLabels(record), 126 129 viewer: asRecord(record.viewer) ? { following: optionalString(asRecord(record.viewer)?.following) } : null, 127 130 }; 128 131 }
+34
src/lib/types.ts
··· 1 1 export type Maybe<T> = T | null | undefined; 2 2 3 + export type ModerationLabel = { src?: string; uri?: string; val?: string; [key: string]: unknown }; 4 + 5 + export type ModerationUiDecision = { 6 + filter: boolean; 7 + blur: "none" | "content" | "media" | string; 8 + alert: boolean; 9 + inform: boolean; 10 + noOverride: boolean; 11 + }; 12 + 13 + export type ModerationLabelVisibility = "ignore" | "warn" | "hide"; 14 + 15 + export type StoredModerationPrefs = { 16 + adultContentEnabled: boolean; 17 + subscribedLabelers: string[]; 18 + labelPreferences: Record<string, Record<string, ModerationLabelVisibility | string>>; 19 + }; 20 + 21 + export type DistributionChannel = "github" | "mac_app_store" | "microsoft_store"; 22 + 23 + export type ReportSubjectInput = { type: "repo"; did: string } | { type: "record"; uri: string; cid: string }; 24 + 25 + export type ModerationReasonType = 26 + | "com.atproto.moderation.defs#reasonSpam" 27 + | "com.atproto.moderation.defs#reasonViolation" 28 + | "com.atproto.moderation.defs#reasonMisleading" 29 + | "com.atproto.moderation.defs#reasonSexual" 30 + | "com.atproto.moderation.defs#reasonRude" 31 + | "com.atproto.moderation.defs#reasonOther"; 32 + 3 33 export type AccountSummary = { did: string; handle: string; pdsUrl: string; active: boolean; avatar?: string | null }; 4 34 5 35 export type ActiveSession = { did: string; handle: string }; ··· 35 65 displayName?: string | null; 36 66 avatar?: string | null; 37 67 description?: string | null; 68 + labels?: ModerationLabel[] | null; 38 69 viewer?: AuthorViewerState | null; 39 70 }; 40 71 ··· 132 163 author?: ProfileViewBasic; 133 164 cid?: string; 134 165 embeds?: EmbedView[]; 166 + labels?: ModerationLabel[] | null; 135 167 uri?: string; 136 168 value?: Record<string, unknown>; 137 169 }; ··· 164 196 cid: string; 165 197 embed?: EmbedView | null; 166 198 indexedAt: string; 199 + labels?: ModerationLabel[] | null; 167 200 likeCount?: number | null; 168 201 quoteCount?: number | null; 169 202 record: PostRecord | Record<string, unknown>; ··· 233 266 uri: string; 234 267 cid: string; 235 268 author: ProfileViewBasic; 269 + labels?: ModerationLabel[] | null; 236 270 reason: NotificationReason; 237 271 reasonSubject?: string | null; 238 272 record: Record<string, unknown>;