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: add context for better cache management and decision-making in moderation

* refactor `PostCard.tsx` to streamline props handling and improve readability

+1137 -581
+17 -2
src-tauri/src/commands/moderation.rs
··· 1 1 use super::super::error::Result; 2 - use super::super::moderation::{self, ModerationUI, ReportSubjectInput, StoredModerationPrefs}; 2 + use super::super::moderation::{ 3 + self, 4 + ModerationLabelerPolicyDefinition, 5 + ModerationUI, 6 + ReportSubjectInput, 7 + StoredModerationPrefs, 8 + }; 3 9 use super::super::state::AppState; 4 10 use tauri_plugin_log::log; 5 11 ··· 46 52 /// 47 53 /// Returns a `ModerationUI` describing what the frontend should do with the content. 48 54 #[tauri::command] 49 - pub async fn moderate_content(labels_json: String, state: State<'_>) -> Result<ModerationUI> { 55 + pub async fn moderate_content(labels_json: String, context: String, state: State<'_>) -> Result<ModerationUI> { 56 + let parsed_context = moderation::parse_moderation_context(&context)?; 57 + log::debug!("moderate_content requested for context={}", parsed_context.as_str()); 58 + 50 59 let prefs = moderation::get_prefs(&state)?; 51 60 let accepted_dids = moderation::accepted_labeler_dids(&prefs); 52 61 ··· 71 80 let defs = moderation::build_labeler_defs(&session, state.inner(), &accepted_dids).await; 72 81 73 82 moderation::evaluate_labels(&labels_json, &prefs, &defs, &accepted_dids) 83 + } 84 + 85 + /// Return structured policy definitions for all accepted labelers. 86 + #[tauri::command] 87 + pub async fn get_labeler_policy_definitions(state: State<'_>) -> Result<Vec<ModerationLabelerPolicyDefinition>> { 88 + moderation::get_labeler_policy_definitions(&state).await 74 89 } 75 90 76 91 /// Submit a content or account report to the Bluesky moderation service.
+1
src-tauri/src/lib.rs
··· 178 178 cmd::moderation::subscribe_labeler, 179 179 cmd::moderation::unsubscribe_labeler, 180 180 cmd::moderation::moderate_content, 181 + cmd::moderation::get_labeler_policy_definitions, 181 182 cmd::moderation::create_report, 182 183 cmd::moderation::get_distribution_channel 183 184 ])
+325
src-tauri/src/moderation.rs
··· 79 79 } 80 80 } 81 81 82 + /// The moderation context requested by the frontend. 83 + /// 84 + /// Context is currently validated and logged, but does not yet change moderation behavior. 85 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 86 + pub enum ModerationContext { 87 + ContentList, 88 + ContentView, 89 + ContentMedia, 90 + Avatar, 91 + ProfileList, 92 + ProfileView, 93 + } 94 + 95 + impl ModerationContext { 96 + pub fn as_str(self) -> &'static str { 97 + match self { 98 + Self::ContentList => "contentList", 99 + Self::ContentView => "contentView", 100 + Self::ContentMedia => "contentMedia", 101 + Self::Avatar => "avatar", 102 + Self::ProfileList => "profileList", 103 + Self::ProfileView => "profileView", 104 + } 105 + } 106 + } 107 + 108 + pub fn parse_moderation_context(value: &str) -> Result<ModerationContext> { 109 + match value.trim() { 110 + "contentList" => Ok(ModerationContext::ContentList), 111 + "contentView" => Ok(ModerationContext::ContentView), 112 + "contentMedia" => Ok(ModerationContext::ContentMedia), 113 + "avatar" => Ok(ModerationContext::Avatar), 114 + "profileList" => Ok(ModerationContext::ProfileList), 115 + "profileView" => Ok(ModerationContext::ProfileView), 116 + _ => Err(AppError::validation( 117 + "invalid moderation context; expected one of: contentList, contentView, contentMedia, avatar, profileList, profileView", 118 + )), 119 + } 120 + } 121 + 122 + #[derive(Debug, Clone, Serialize)] 123 + #[serde(rename_all = "camelCase")] 124 + pub struct ModerationLabelPolicyLocale { 125 + pub lang: String, 126 + pub name: String, 127 + pub description: String, 128 + } 129 + 130 + #[derive(Debug, Clone, Serialize)] 131 + #[serde(rename_all = "camelCase")] 132 + pub struct ModerationLabelPolicyDefinition { 133 + pub identifier: String, 134 + pub adult_only: bool, 135 + pub default_setting: Option<String>, 136 + pub severity: String, 137 + pub blurs: String, 138 + pub display_name: Option<String>, 139 + pub description: Option<String>, 140 + pub locales: Vec<ModerationLabelPolicyLocale>, 141 + } 142 + 143 + #[derive(Debug, Clone, Serialize)] 144 + #[serde(rename_all = "camelCase")] 145 + pub struct ModerationLabelerPolicyDefinition { 146 + pub labeler_did: String, 147 + pub labeler_handle: Option<String>, 148 + pub labeler_display_name: Option<String>, 149 + pub reason_types: Option<Vec<String>>, 150 + pub subject_types: Option<Vec<String>>, 151 + pub subject_collections: Option<Vec<String>>, 152 + pub definitions: Vec<ModerationLabelPolicyDefinition>, 153 + } 154 + 82 155 /// Input description of what to report. 83 156 #[derive(Debug, Deserialize)] 84 157 #[serde(rename_all = "camelCase", tag = "type")] ··· 289 362 .collect() 290 363 } 291 364 365 + /// Fetch detailed labeler views from the API for the given DIDs. 366 + /// 367 + /// Returns only detailed views and skips malformed DIDs. 368 + pub async fn fetch_labeler_views_from_api( 369 + session: &LazuriteOAuthSession, dids: &[String], 370 + ) -> Vec<jacquard::api::app_bsky::labeler::LabelerViewDetailed<'static>> { 371 + if dids.is_empty() { 372 + return Vec::new(); 373 + } 374 + 375 + let parsed_dids: Vec<Did<'_>> = dids 376 + .iter() 377 + .filter_map(|s| { 378 + Did::new(s) 379 + .map_err(|error| { 380 + log::warn!("skipping invalid labeler DID '{s}': {error}"); 381 + error 382 + }) 383 + .ok() 384 + }) 385 + .collect(); 386 + 387 + if parsed_dids.is_empty() { 388 + return Vec::new(); 389 + } 390 + 391 + let request = GetServices::new().dids(parsed_dids).detailed(true).build(); 392 + let response = match session.send(request).await { 393 + Ok(r) => r, 394 + Err(error) => { 395 + log::warn!("failed to fetch detailed labeler views: {error}"); 396 + return Vec::new(); 397 + } 398 + }; 399 + 400 + let output = match response.into_output() { 401 + Ok(o) => o, 402 + Err(error) => { 403 + log::warn!("failed to decode detailed labeler views response: {error}"); 404 + return Vec::new(); 405 + } 406 + }; 407 + 408 + output 409 + .views 410 + .into_iter() 411 + .filter_map(|view| match view { 412 + GetServicesOutputViewsItem::LabelerViewDetailed(detailed) => Some((*detailed).into_static()), 413 + _ => None, 414 + }) 415 + .collect() 416 + } 417 + 418 + fn preferred_locale_strings(locales: &[ModerationLabelPolicyLocale]) -> (Option<String>, Option<String>) { 419 + if locales.is_empty() { 420 + return (None, None); 421 + } 422 + 423 + if let Some(en) = locales.iter().find(|locale| locale.lang.eq_ignore_ascii_case("en")) { 424 + return (Some(en.name.clone()), Some(en.description.clone())); 425 + } 426 + 427 + if let Some(en_region) = locales 428 + .iter() 429 + .find(|locale| locale.lang.to_ascii_lowercase().starts_with("en-")) 430 + { 431 + return (Some(en_region.name.clone()), Some(en_region.description.clone())); 432 + } 433 + 434 + let fallback = &locales[0]; 435 + (Some(fallback.name.clone()), Some(fallback.description.clone())) 436 + } 437 + 438 + fn normalize_label_definition(def: &LabelValueDefinition<'_>) -> ModerationLabelPolicyDefinition { 439 + let mut locales = def 440 + .locales 441 + .iter() 442 + .map(|locale| ModerationLabelPolicyLocale { 443 + lang: locale.lang.as_ref().to_string(), 444 + name: locale.name.as_ref().to_string(), 445 + description: locale.description.as_ref().to_string(), 446 + }) 447 + .collect::<Vec<_>>(); 448 + locales.sort_by(|left, right| left.lang.cmp(&right.lang).then(left.name.cmp(&right.name))); 449 + 450 + let (display_name, description) = preferred_locale_strings(&locales); 451 + 452 + ModerationLabelPolicyDefinition { 453 + identifier: def.identifier.as_ref().to_string(), 454 + adult_only: def.adult_only.unwrap_or(false), 455 + default_setting: def.default_setting.as_ref().map(|setting| setting.as_ref().to_string()), 456 + severity: def.severity.as_ref().to_string(), 457 + blurs: def.blurs.as_ref().to_string(), 458 + display_name, 459 + description, 460 + locales, 461 + } 462 + } 463 + 464 + fn normalize_label_definitions(defs: &[LabelValueDefinition<'_>]) -> Vec<ModerationLabelPolicyDefinition> { 465 + let mut normalized = defs.iter().map(normalize_label_definition).collect::<Vec<_>>(); 466 + normalized.sort_by(|left, right| left.identifier.cmp(&right.identifier)); 467 + normalized.dedup_by(|left, right| left.identifier == right.identifier); 468 + normalized 469 + } 470 + 471 + /// Return structured policy definitions for all accepted labelers (built-in + subscribed). 472 + pub async fn get_labeler_policy_definitions(state: &AppState) -> Result<Vec<ModerationLabelerPolicyDefinition>> { 473 + let prefs = get_prefs(state)?; 474 + let accepted_dids = accepted_labeler_dids(&prefs); 475 + let session = get_session(state).await?; 476 + 477 + let defs = build_labeler_defs(&session, state, &accepted_dids).await; 478 + let fetched_views = fetch_labeler_views_from_api(&session, &accepted_dids).await; 479 + let mut views_by_did: HashMap<String, jacquard::api::app_bsky::labeler::LabelerViewDetailed<'static>> = 480 + HashMap::new(); 481 + for view in fetched_views { 482 + views_by_did.insert(view.creator.did.as_ref().to_string(), view); 483 + } 484 + 485 + let mut policies = Vec::with_capacity(accepted_dids.len()); 486 + 487 + for did in accepted_dids { 488 + let view = views_by_did.get(&did); 489 + let definitions_from_view = view 490 + .and_then(|value| value.policies.label_value_definitions.as_ref()) 491 + .map(|definitions| normalize_label_definitions(definitions)) 492 + .unwrap_or_default(); 493 + 494 + let definitions = if !definitions_from_view.is_empty() { 495 + definitions_from_view 496 + } else if let Ok(parsed) = Did::new(&did) { 497 + defs.get(&parsed) 498 + .map(normalize_label_definitions) 499 + .unwrap_or_default() 500 + } else { 501 + Vec::new() 502 + }; 503 + 504 + let reason_types = view.map(|value| { 505 + value 506 + .reason_types 507 + .as_ref() 508 + .map(|types| types.iter().map(|item| item.as_ref().to_string()).collect::<Vec<String>>()) 509 + }); 510 + let subject_types = view.map(|value| { 511 + value 512 + .subject_types 513 + .as_ref() 514 + .map(|types| types.iter().map(|item| item.as_ref().to_string()).collect::<Vec<String>>()) 515 + }); 516 + let subject_collections = view.map(|value| { 517 + value.subject_collections.as_ref().map(|collections| { 518 + collections 519 + .iter() 520 + .map(|item| item.as_ref().to_string()) 521 + .collect::<Vec<String>>() 522 + }) 523 + }); 524 + 525 + policies.push(ModerationLabelerPolicyDefinition { 526 + labeler_did: did, 527 + labeler_handle: view.map(|value| value.creator.handle.as_ref().to_string()), 528 + labeler_display_name: view 529 + .and_then(|value| value.creator.display_name.as_ref().map(|name| name.as_ref().to_string())), 530 + reason_types: reason_types.flatten(), 531 + subject_types: subject_types.flatten(), 532 + subject_collections: subject_collections.flatten(), 533 + definitions, 534 + }); 535 + } 536 + 537 + Ok(policies) 538 + } 539 + 292 540 /// Build `LabelerDefs` for the given DIDs, using the local cache where available 293 541 /// and fetching from the API for any missing/stale entries. 294 542 /// ··· 676 924 fn distribution_channel_defaults_to_github() { 677 925 let channel = distribution_channel(); 678 926 assert!(!channel.is_empty()); 927 + } 928 + 929 + #[test] 930 + fn moderation_context_validation() { 931 + let contexts = [ 932 + "contentList", 933 + "contentView", 934 + "contentMedia", 935 + "avatar", 936 + "profileList", 937 + "profileView", 938 + ]; 939 + 940 + for context in contexts { 941 + let parsed = parse_moderation_context(context).expect("context should parse"); 942 + assert_eq!(parsed.as_str(), context); 943 + } 944 + 945 + let invalid = parse_moderation_context("not-a-context").expect_err("invalid context should fail"); 946 + assert!(invalid.to_string().contains("invalid moderation context")); 947 + } 948 + 949 + #[test] 950 + fn label_policy_definition_normalization_prefers_english_locale() { 951 + let definition: LabelValueDefinition<'static> = serde_json::from_str( 952 + r#"{ 953 + "identifier":"graphic-media", 954 + "adultOnly":true, 955 + "blurs":"media", 956 + "defaultSetting":"warn", 957 + "severity":"alert", 958 + "locales":[ 959 + {"lang":"fr","name":"Média graphique","description":"Contenu potentiellement choquant"}, 960 + {"lang":"en-US","name":"Graphic media","description":"Potentially disturbing media"} 961 + ] 962 + }"#, 963 + ) 964 + .expect("definition should deserialize"); 965 + 966 + let normalized = normalize_label_definition(&definition); 967 + assert_eq!(normalized.identifier, "graphic-media"); 968 + assert!(normalized.adult_only); 969 + assert_eq!(normalized.default_setting.as_deref(), Some("warn")); 970 + assert_eq!(normalized.severity, "alert"); 971 + assert_eq!(normalized.blurs, "media"); 972 + assert_eq!(normalized.display_name.as_deref(), Some("Graphic media")); 973 + assert_eq!(normalized.description.as_deref(), Some("Potentially disturbing media")); 974 + assert_eq!(normalized.locales.len(), 2); 975 + } 976 + 977 + #[test] 978 + fn label_policy_definition_normalization_deduplicates_identifiers() { 979 + let definitions: Vec<LabelValueDefinition<'static>> = serde_json::from_str( 980 + r#"[ 981 + { 982 + "identifier":"spam", 983 + "adultOnly":false, 984 + "blurs":"none", 985 + "defaultSetting":"ignore", 986 + "severity":"inform", 987 + "locales":[{"lang":"en","name":"Spam","description":"Spam content"}] 988 + }, 989 + { 990 + "identifier":"spam", 991 + "adultOnly":false, 992 + "blurs":"content", 993 + "defaultSetting":"warn", 994 + "severity":"alert", 995 + "locales":[{"lang":"en","name":"Spam duplicate","description":"Duplicate"}] 996 + } 997 + ]"#, 998 + ) 999 + .expect("definitions should deserialize"); 1000 + 1001 + let normalized = normalize_label_definitions(&definitions); 1002 + assert_eq!(normalized.len(), 1); 1003 + assert_eq!(normalized[0].identifier, "spam"); 679 1004 } 680 1005 681 1006 #[test]
+1 -1
src/components/deck/AddColumnPanel.test.tsx src/components/deck/tests/AddColumnPanel.test.tsx
··· 1 1 import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 2 2 import { beforeEach, describe, expect, it, vi } from "vitest"; 3 - import { AddColumnPanel } from "./AddColumnPanel"; 3 + import { AddColumnPanel } from "../AddColumnPanel"; 4 4 5 5 const getFeedGeneratorsMock = vi.hoisted(() => vi.fn()); 6 6 const getPreferencesMock = vi.hoisted(() => vi.fn());
+61 -454
src/components/deck/AddColumnPanel.tsx
··· 1 - // TODO: there is a lot of prop drilling in this module, and could benefit from the splitProps pattern. 2 - import { ActorSuggestionList, useActorSuggestions } from "$/components/actors/actor-search"; 3 - import { FeedController } from "$/lib/api/feeds"; 4 1 import type { ColumnKind } from "$/lib/api/types/columns"; 5 2 import type { SearchMode } from "$/lib/api/types/search"; 6 - import { getFeedName } from "$/lib/feeds"; 7 - import type { FeedGeneratorView, LoginSuggestion, SavedFeedItem } from "$/lib/types"; 8 - import * as logger from "@tauri-apps/plugin-log"; 9 - import { createEffect, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"; 3 + import { createEffect, createSignal, For, Match, onCleanup, Show, splitProps, Switch } from "solid-js"; 10 4 import { Portal } from "solid-js/web"; 11 5 import { Motion, Presence } from "solid-motionone"; 12 - import { FeedChipAvatar } from "../feeds/FeedChipAvatar"; 13 - import { Icon, SearchModeIcon } from "../shared/Icon"; 6 + import { Icon } from "../shared/Icon"; 7 + import { DiagnosticsPicker, ExplorerPicker, FeedPicker, MessagesPicker } from "./ColumnPicker"; 8 + import { ProfilePicker } from "./ColumnPicker/ProfileColumnPicker"; 9 + import { SearchPicker } from "./ColumnPicker/SearchPicker"; 10 + import type { FeedPickerSelection, ProfileSelection } from "./types"; 14 11 15 12 type AddColumnPanelProps = { onAdd: (kind: ColumnKind, config: string) => void; onClose: () => void; open: boolean }; 16 13 17 14 type PanelTab = ColumnKind; 18 15 19 - type FeedPickerSelection = { feed: SavedFeedItem; title: string }; 20 - 21 - function feedKindLabel(feed: SavedFeedItem) { 22 - switch (feed.type) { 23 - case "timeline": { 24 - return "Timeline"; 25 - } 26 - case "list": { 27 - return "List"; 28 - } 29 - default: { 30 - return "Feed"; 31 - } 32 - } 33 - } 34 - 35 - function FeedPicker(props: { onSelect: (selection: FeedPickerSelection) => void }) { 36 - const [feeds, setFeeds] = createSignal<SavedFeedItem[]>([]); 37 - const [generators, setGenerators] = createSignal<Record<string, FeedGeneratorView>>({}); 38 - const [loading, setLoading] = createSignal(true); 39 - 40 - onMount(async () => { 41 - try { 42 - const prefs = await FeedController.getPreferences(); 43 - setFeeds(prefs.savedFeeds); 44 - 45 - const uris = [...new Set(prefs.savedFeeds.filter((feed) => feed.type === "feed").map((feed) => feed.value))]; 46 - if (uris.length > 0) { 47 - const hydrated = await FeedController.getFeedGenerators(uris); 48 - setGenerators(Object.fromEntries(hydrated.feeds.map((generator) => [generator.uri, generator]))); 49 - } 50 - } catch (err) { 51 - logger.error(`Failed to load feeds for column picker: ${String(err)}`); 52 - } finally { 53 - setLoading(false); 54 - } 55 - }); 56 - 57 - return ( 58 - <div class="grid gap-2"> 59 - <Show when={loading()}> 60 - <div class="flex items-center justify-center py-6"> 61 - <span class="flex items-center text-on-surface-variant"> 62 - <i class="i-ri-loader-4-line animate-spin" /> 63 - </span> 64 - </div> 65 - </Show> 66 - 67 - <Show when={!loading() && feeds().length === 0}> 68 - <p class="py-4 text-center text-sm text-on-surface-variant">No saved feeds found.</p> 69 - </Show> 70 - 71 - <For 72 - each={feeds()} 73 - fallback={ 74 - <Show when={!loading()}> 75 - <p class="py-4 text-center text-sm text-on-surface-variant">No saved feeds found.</p> 76 - </Show> 77 - }> 78 - {(feed) => ( 79 - <button 80 - type="button" 81 - class="flex w-full items-center gap-3 rounded-xl border-0 bg-white/4 px-4 py-3 text-left transition duration-150 hover:-translate-y-px hover:bg-white/8" 82 - onClick={() => props.onSelect({ feed, title: getFeedName(feed, generators()[feed.value]?.displayName) })}> 83 - <FeedChipAvatar feed={feed} generator={generators()[feed.value]} /> 84 - <span class="min-w-0 flex-1"> 85 - <span class="block truncate text-sm font-medium text-on-surface"> 86 - {getFeedName(feed, generators()[feed.value]?.displayName)} 87 - </span> 88 - <span class="block truncate text-xs text-on-surface-variant">{feedKindLabel(feed)}</span> 89 - </span> 90 - </button> 91 - )} 92 - </For> 93 - </div> 94 - ); 95 - } 96 - 97 - function ExplorerPicker(props: { onSubmit: (uri: string) => void }) { 98 - const [value, setValue] = createSignal(""); 99 - 100 - function handleSubmit(e: Event) { 101 - e.preventDefault(); 102 - const uri = value().trim(); 103 - if (uri) { 104 - props.onSubmit(uri); 105 - } 106 - } 107 - 108 - return ( 109 - <form onSubmit={handleSubmit} class="grid gap-3"> 110 - <label class="grid gap-1.5"> 111 - <span class="text-xs font-medium uppercase tracking-wide text-on-surface-variant"> 112 - Target URI / handle / DID / PDS URL 113 - </span> 114 - <input 115 - type="text" 116 - class="rounded-xl border-0 bg-white/6 px-4 py-2.5 text-sm text-on-surface placeholder:text-on-surface-variant/50 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)] outline-none focus:shadow-[inset_0_0_0_1px_rgba(125,175,255,0.4)]" 117 - placeholder="at://did:plc:… or handle.bsky.social" 118 - value={value()} 119 - onInput={(e) => setValue(e.currentTarget.value)} /> 120 - </label> 121 - 122 - <button 123 - type="submit" 124 - disabled={!value().trim()} 125 - class="flex items-center justify-center gap-2 rounded-xl border-0 bg-primary/15 px-4 py-2.5 text-sm font-medium text-primary transition duration-150 hover:-translate-y-px hover:bg-primary/25 disabled:cursor-not-allowed disabled:opacity-40"> 126 - <span class="flex items-center"> 127 - <i class="i-ri-compass-discover-line" /> 128 - </span> 129 - Open in column 130 - </button> 131 - </form> 132 - ); 133 - } 134 - 135 - function DiagnosticsPicker(props: { onSubmit: (did: string) => void }) { 136 - const [value, setValue] = createSignal(""); 137 - 138 - function handleSubmit(e: Event) { 139 - e.preventDefault(); 140 - const did = value().trim(); 141 - if (did) { 142 - props.onSubmit(did); 143 - } 144 - } 145 - 146 - return ( 147 - <form onSubmit={handleSubmit} class="grid gap-3"> 148 - <label class="grid gap-1.5"> 149 - <span class="text-xs font-medium uppercase tracking-wide text-on-surface-variant">Handle or DID</span> 150 - <input 151 - type="text" 152 - class="rounded-xl border-0 bg-white/6 px-4 py-2.5 text-sm text-on-surface placeholder:text-on-surface-variant/50 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)] outline-none focus:shadow-[inset_0_0_0_1px_rgba(125,175,255,0.4)]" 153 - placeholder="handle.bsky.social or did:plc:…" 154 - value={value()} 155 - onInput={(e) => setValue(e.currentTarget.value)} /> 156 - </label> 157 - 158 - <button 159 - type="submit" 160 - disabled={!value().trim()} 161 - class="flex items-center justify-center gap-2 rounded-xl border-0 bg-primary/15 px-4 py-2.5 text-sm font-medium text-primary transition duration-150 hover:-translate-y-px hover:bg-primary/25 disabled:cursor-not-allowed disabled:opacity-40"> 162 - <span class="flex items-center"> 163 - <i class="i-ri-stethoscope-line" /> 164 - </span> 165 - Open diagnostics 166 - </button> 167 - </form> 168 - ); 169 - } 170 - 171 - function MessagesPicker(props: { onSubmit: () => void }) { 172 - return ( 173 - <div class="grid gap-4"> 174 - <div class="rounded-2xl bg-white/4 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 175 - <div class="flex items-start gap-3"> 176 - <span class="mt-0.5 flex items-center text-primary"> 177 - <i class="i-ri-message-3-line" /> 178 - </span> 179 - <div class="grid gap-1.5"> 180 - <p class="m-0 text-sm font-medium text-on-surface">Direct messages</p> 181 - <p class="m-0 text-xs leading-relaxed text-on-surface-variant"> 182 - Opens your DM inbox inside the deck. Message content is blurred until you hover or focus the column. 183 - </p> 184 - </div> 185 - </div> 186 - </div> 187 - 188 - <button 189 - type="button" 190 - class="flex items-center justify-center gap-2 rounded-xl border-0 bg-primary/15 px-4 py-2.5 text-sm font-medium text-primary transition duration-150 hover:-translate-y-px hover:bg-primary/25" 191 - onClick={() => props.onSubmit()}> 192 - <span class="flex items-center"> 193 - <i class="i-ri-layout-column-line" /> 194 - </span> 195 - Add DM column 196 - </button> 197 - </div> 198 - ); 199 - } 200 - 201 - function SearchModeButton(props: { active: boolean; disabled?: boolean; mode: SearchMode; onClick: () => void }) { 202 - return ( 203 - <button 204 - type="button" 205 - disabled={props.disabled} 206 - class="inline-flex items-center justify-center gap-2 rounded-xl border-0 px-3 py-2 text-xs font-medium transition duration-150 disabled:cursor-not-allowed disabled:opacity-40" 207 - classList={{ 208 - "bg-primary/15 text-primary": props.active, 209 - "bg-white/4 text-on-surface-variant hover:bg-white/8 hover:text-on-surface": !props.active && !props.disabled, 210 - }} 211 - onClick={() => props.onClick()}> 212 - <SearchModeIcon mode={props.mode} class="text-sm" /> 213 - <span class="capitalize">{props.mode}</span> 214 - </button> 215 - ); 216 - } 217 - 218 - function SearchPicker(props: { onSubmit: (query: string, mode: SearchMode) => void }) { 219 - const [mode, setMode] = createSignal<SearchMode>("network"); 220 - const [query, setQuery] = createSignal(""); 221 - 222 - function handleSubmit(event: Event) { 223 - event.preventDefault(); 224 - const trimmed = query().trim(); 225 - if (!trimmed) { 226 - return; 227 - } 228 - 229 - props.onSubmit(trimmed, mode()); 230 - } 231 - 232 - return ( 233 - <form onSubmit={handleSubmit} class="grid gap-3"> 234 - <label class="grid gap-1.5"> 235 - <span class="text-xs font-medium uppercase tracking-wide text-on-surface-variant">Search query</span> 236 - <input 237 - type="text" 238 - class="rounded-xl border-0 bg-white/6 px-4 py-2.5 text-sm text-on-surface placeholder:text-on-surface-variant/50 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)] outline-none focus:shadow-[inset_0_0_0_1px_rgba(125,175,255,0.4)]" 239 - placeholder="from:alice at protocol" 240 - value={query()} 241 - onInput={(event) => setQuery(event.currentTarget.value)} /> 242 - </label> 243 - 244 - <div class="grid gap-1.5"> 245 - <span class="text-xs font-medium uppercase tracking-wide text-on-surface-variant">Search mode</span> 246 - <div class="grid grid-cols-2 gap-2"> 247 - <SearchModeButton active={mode() === "network"} mode="network" onClick={() => setMode("network")} /> 248 - <SearchModeButton active={mode() === "keyword"} mode="keyword" onClick={() => setMode("keyword")} /> 249 - <SearchModeButton active={mode() === "semantic"} mode="semantic" onClick={() => setMode("semantic")} /> 250 - <SearchModeButton active={mode() === "hybrid"} mode="hybrid" onClick={() => setMode("hybrid")} /> 251 - </div> 252 - </div> 253 - 254 - <button 255 - type="submit" 256 - disabled={!query().trim()} 257 - class="flex items-center justify-center gap-2 rounded-xl border-0 bg-primary/15 px-4 py-2.5 text-sm font-medium text-primary transition duration-150 hover:-translate-y-px hover:bg-primary/25 disabled:cursor-not-allowed disabled:opacity-40"> 258 - <span class="flex items-center"> 259 - <i class="i-ri-search-line" /> 260 - </span> 261 - Open search column 262 - </button> 263 - </form> 264 - ); 265 - } 266 - 267 - function ProfilePicker( 268 - props: { 269 - onSubmit: ( 270 - selection: { actor: string; did?: string | null; displayName?: string | null; handle?: string | null }, 271 - ) => void; 272 - }, 273 - ) { 274 - let container: HTMLDivElement | undefined; 275 - let input: HTMLInputElement | undefined; 276 - const [value, setValue] = createSignal(""); 277 - const typeahead = useActorSuggestions({ 278 - container: () => container, 279 - input: () => input, 280 - onError: (error) => logger.warn(`Failed to load profile suggestions: ${String(error)}`), 281 - value, 282 - }); 283 - 284 - function submitManualActor() { 285 - const actor = value().trim(); 286 - if (!actor) { 287 - return; 288 - } 289 - 290 - typeahead.close(); 291 - props.onSubmit({ actor }); 292 - } 293 - 294 - function submitSuggestion(suggestion: LoginSuggestion) { 295 - typeahead.close(); 296 - props.onSubmit({ 297 - actor: suggestion.handle, 298 - did: suggestion.did, 299 - displayName: suggestion.displayName ?? null, 300 - handle: suggestion.handle, 301 - }); 302 - } 303 - 304 - function handleKeyDown(event: KeyboardEvent) { 305 - if (event.key === "ArrowDown") { 306 - event.preventDefault(); 307 - typeahead.moveActiveIndex(1); 308 - return; 309 - } 310 - 311 - if (event.key === "ArrowUp") { 312 - event.preventDefault(); 313 - typeahead.moveActiveIndex(-1); 314 - return; 315 - } 316 - 317 - if (event.key === "Escape") { 318 - typeahead.close(); 319 - return; 320 - } 321 - 322 - if (event.key === "Enter" && typeahead.open() && typeahead.activeSuggestion()) { 323 - event.preventDefault(); 324 - submitSuggestion(typeahead.activeSuggestion() as LoginSuggestion); 325 - } 326 - } 327 - 328 - return ( 329 - <form 330 - class="grid gap-3" 331 - onSubmit={(event) => { 332 - event.preventDefault(); 333 - submitManualActor(); 334 - }}> 335 - <label class="grid gap-1.5"> 336 - <span class="text-xs font-medium uppercase tracking-wide text-on-surface-variant">Handle or DID</span> 337 - <div 338 - class="relative" 339 - ref={(element) => { 340 - container = element as HTMLDivElement; 341 - }}> 342 - <input 343 - ref={(element) => { 344 - input = element; 345 - }} 346 - type="text" 347 - role="combobox" 348 - aria-autocomplete="list" 349 - aria-controls="profile-suggestions" 350 - aria-activedescendant={typeahead.activeIndex() >= 0 351 - ? `profile-suggestions-option-${typeahead.activeIndex()}` 352 - : undefined} 353 - aria-expanded={typeahead.open()} 354 - class="w-full rounded-xl border-0 bg-white/6 px-4 py-2.5 pr-10 text-sm text-on-surface placeholder:text-on-surface-variant/50 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)] outline-none focus:shadow-[inset_0_0_0_1px_rgba(125,175,255,0.4)]" 355 - placeholder="alice.bsky.social" 356 - spellcheck={false} 357 - value={value()} 358 - onFocus={() => typeahead.focus()} 359 - onInput={(event) => setValue(event.currentTarget.value)} 360 - onKeyDown={(event) => handleKeyDown(event)} /> 361 - 362 - <TypeaheadLoading visible={typeahead.loading()} /> 363 - <ActorSuggestionList 364 - activeIndex={typeahead.activeIndex()} 365 - id="profile-suggestions" 366 - open={typeahead.open()} 367 - suggestions={typeahead.suggestions()} 368 - title="Suggested profiles" 369 - onSelect={submitSuggestion} /> 370 - </div> 371 - </label> 372 - 373 - <button 374 - type="submit" 375 - disabled={!value().trim()} 376 - class="flex items-center justify-center gap-2 rounded-xl border-0 bg-primary/15 px-4 py-2.5 text-sm font-medium text-primary transition duration-150 hover:-translate-y-px hover:bg-primary/25 disabled:cursor-not-allowed disabled:opacity-40"> 377 - <span class="flex items-center"> 378 - <i class="i-ri-user-3-line" /> 379 - </span> 380 - Open profile 381 - </button> 382 - </form> 383 - ); 384 - } 385 - 386 - function TypeaheadLoading(props: { visible: boolean }) { 387 - return ( 388 - <Show when={props.visible}> 389 - <span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-on-surface-variant"> 390 - <Icon kind="loader" class="animate-spin text-sm" /> 391 - </span> 392 - </Show> 393 - ); 394 - } 395 - 396 - type PanelContentProps = { 397 - tab: PanelTab; 16 + type PanelSubmissionHandlers = { 17 + onDiagnosticsSubmit: (did: string) => void; 18 + onExplorerSubmit: (uri: string) => void; 398 19 onFeedSelect: (selection: FeedPickerSelection) => void; 399 - onExplorerSubmit: (uri: string) => void; 400 - onDiagnosticsSubmit: (did: string) => void; 401 20 onMessagesSubmit: () => void; 402 - onProfileSubmit: ( 403 - selection: { actor: string; did?: string | null; displayName?: string | null; handle?: string | null }, 404 - ) => void; 21 + onProfileSubmit: (selection: ProfileSelection) => void; 405 22 onSearchSubmit: (query: string, mode: SearchMode) => void; 406 23 }; 407 24 408 - function PanelContent(props: PanelContentProps) { 25 + function PanelContent(props: { handlers: PanelSubmissionHandlers; tab: PanelTab }) { 409 26 return ( 410 27 <div class="min-h-0 flex-1 overflow-y-auto px-4 pb-6"> 411 28 <Switch> 412 29 <Match when={props.tab === "feed"}> 413 - <FeedPicker onSelect={props.onFeedSelect} /> 30 + <FeedPicker onSelect={props.handlers.onFeedSelect} /> 414 31 </Match> 415 32 416 33 <Match when={props.tab === "explorer"}> 417 - <ExplorerPicker onSubmit={props.onExplorerSubmit} /> 34 + <ExplorerPicker onSubmit={props.handlers.onExplorerSubmit} /> 418 35 </Match> 419 36 420 37 <Match when={props.tab === "diagnostics"}> 421 - <DiagnosticsPicker onSubmit={props.onDiagnosticsSubmit} /> 38 + <DiagnosticsPicker onSubmit={props.handlers.onDiagnosticsSubmit} /> 422 39 </Match> 423 40 424 41 <Match when={props.tab === "messages"}> 425 - <MessagesPicker onSubmit={props.onMessagesSubmit} /> 42 + <MessagesPicker onSubmit={props.handlers.onMessagesSubmit} /> 426 43 </Match> 427 44 428 45 <Match when={props.tab === "search"}> 429 - <SearchPicker onSubmit={props.onSearchSubmit} /> 46 + <SearchPicker onSubmit={props.handlers.onSearchSubmit} /> 430 47 </Match> 431 48 432 49 <Match when={props.tab === "profile"}> 433 - <ProfilePicker onSubmit={props.onProfileSubmit} /> 50 + <ProfilePicker onSubmit={props.handlers.onProfileSubmit} /> 434 51 </Match> 435 52 </Switch> 436 53 </div> ··· 486 103 ); 487 104 } 488 105 489 - function AddColumnPanelBody( 490 - props: { 491 - activeTab: PanelTab; 492 - onClose: () => void; 493 - onDiagnosticsSubmit: (did: string) => void; 494 - onExplorerSubmit: (uri: string) => void; 495 - onFeedSelect: (selection: FeedPickerSelection) => void; 496 - onMessagesSubmit: () => void; 497 - onProfileSubmit: ( 498 - selection: { actor: string; did?: string | null; displayName?: string | null; handle?: string | null }, 499 - ) => void; 500 - onSearchSubmit: (query: string, mode: SearchMode) => void; 501 - onTabChange: (tab: PanelTab) => void; 502 - tabs: Array<{ icon: string; id: PanelTab; label: string }>; 503 - }, 504 - ) { 106 + type AddColumnPanelFrame = { 107 + activeTab: PanelTab; 108 + onClose: () => void; 109 + onTabChange: (tab: PanelTab) => void; 110 + tabs: Array<{ icon: string; id: PanelTab; label: string }>; 111 + }; 112 + 113 + type AddColumnPanelBodyProps = { frame: AddColumnPanelFrame; handlers: PanelSubmissionHandlers }; 114 + 115 + function AddColumnPanelBody(props: AddColumnPanelBodyProps) { 116 + const [frameProps, contentProps] = splitProps(props, ["frame"], ["handlers"]); 117 + 505 118 return ( 506 119 <Motion.aside 507 120 role="dialog" ··· 512 125 animate={{ opacity: 1, x: 0 }} 513 126 exit={{ opacity: 0, x: 40 }} 514 127 transition={{ duration: 0.22, easing: [0.32, 0.72, 0, 1] }}> 515 - <AddColumnPanelHeader onClose={props.onClose} /> 516 - <AddColumnPanelTabs activeTab={props.activeTab} tabs={props.tabs} onTabChange={props.onTabChange} /> 517 - <PanelContent 518 - tab={props.activeTab} 519 - onFeedSelect={props.onFeedSelect} 520 - onExplorerSubmit={props.onExplorerSubmit} 521 - onDiagnosticsSubmit={props.onDiagnosticsSubmit} 522 - onMessagesSubmit={props.onMessagesSubmit} 523 - onProfileSubmit={props.onProfileSubmit} 524 - onSearchSubmit={props.onSearchSubmit} /> 128 + <AddColumnPanelHeader onClose={frameProps.frame.onClose} /> 129 + <AddColumnPanelTabs 130 + activeTab={frameProps.frame.activeTab} 131 + tabs={frameProps.frame.tabs} 132 + onTabChange={frameProps.frame.onTabChange} /> 133 + <PanelContent tab={frameProps.frame.activeTab} handlers={contentProps.handlers} /> 525 134 </Motion.aside> 526 135 ); 527 136 } 528 137 529 138 export function AddColumnPanel(props: AddColumnPanelProps) { 139 + const [panelState, panelActions] = splitProps(props, ["open"], ["onAdd", "onClose"]); 530 140 const [activeTab, setActiveTab] = createSignal<PanelTab>("feed"); 531 141 532 142 function handleFeedSelect(selection: FeedPickerSelection) { ··· 535 145 feedUri: selection.feed.value, 536 146 title: selection.title, 537 147 }); 538 - props.onAdd("feed", config); 148 + panelActions.onAdd("feed", config); 539 149 } 540 150 541 151 function handleExplorerSubmit(uri: string) { 542 152 const config = JSON.stringify({ targetUri: uri }); 543 - props.onAdd("explorer", config); 153 + panelActions.onAdd("explorer", config); 544 154 } 545 155 546 156 function handleDiagnosticsSubmit(did: string) { 547 157 const config = JSON.stringify({ did }); 548 - props.onAdd("diagnostics", config); 158 + panelActions.onAdd("diagnostics", config); 549 159 } 550 160 551 161 function handleMessagesSubmit() { 552 - props.onAdd("messages", JSON.stringify({})); 162 + panelActions.onAdd("messages", JSON.stringify({})); 553 163 } 554 164 555 165 function handleSearchSubmit(query: string, mode: SearchMode) { 556 - props.onAdd("search", JSON.stringify({ mode, query })); 166 + panelActions.onAdd("search", JSON.stringify({ mode, query })); 557 167 } 558 168 559 - function handleProfileSubmit( 560 - selection: { actor: string; did?: string | null; displayName?: string | null; handle?: string | null }, 561 - ) { 562 - props.onAdd("profile", JSON.stringify(selection)); 169 + function handleProfileSubmit(selection: ProfileSelection) { 170 + panelActions.onAdd("profile", JSON.stringify(selection)); 563 171 } 564 172 565 173 // TODO: use IconKind for Icon ··· 572 180 { icon: "i-ri-user-3-line", id: "profile", label: "Profile" }, 573 181 ]; 574 182 183 + function handleKeyDown(event: KeyboardEvent) { 184 + if (event.key === "Escape") { 185 + panelActions.onClose(); 186 + } 187 + } 188 + 575 189 createEffect(() => { 576 - if (!props.open) { 190 + if (!panelState.open) { 577 191 setActiveTab("feed"); 578 192 return; 579 193 } 580 194 581 - const handleKeyDown = (event: KeyboardEvent) => { 582 - if (event.key === "Escape") { 583 - props.onClose(); 584 - } 585 - }; 586 - 587 195 globalThis.addEventListener("keydown", handleKeyDown); 588 196 onCleanup(() => globalThis.removeEventListener("keydown", handleKeyDown)); 589 197 }); 590 198 591 199 return ( 592 200 <Presence exitBeforeEnter> 593 - <Show when={props.open}> 201 + <Show when={panelState.open}> 594 202 <Portal> 595 203 <div class="fixed inset-0 z-50 flex justify-end"> 596 204 <Motion.div ··· 599 207 animate={{ opacity: 1 }} 600 208 exit={{ opacity: 0 }} 601 209 transition={{ duration: 0.15 }} 602 - onClick={() => props.onClose()} /> 210 + onClick={() => panelActions.onClose()} /> 603 211 604 212 <AddColumnPanelBody 605 - activeTab={activeTab()} 606 - tabs={tabs} 607 - onClose={props.onClose} 608 - onTabChange={setActiveTab} 609 - onFeedSelect={handleFeedSelect} 610 - onExplorerSubmit={handleExplorerSubmit} 611 - onDiagnosticsSubmit={handleDiagnosticsSubmit} 612 - onMessagesSubmit={handleMessagesSubmit} 613 - onProfileSubmit={handleProfileSubmit} 614 - onSearchSubmit={handleSearchSubmit} /> 213 + frame={{ activeTab: activeTab(), tabs, onClose: panelActions.onClose, onTabChange: setActiveTab }} 214 + handlers={{ 215 + onDiagnosticsSubmit: handleDiagnosticsSubmit, 216 + onExplorerSubmit: handleExplorerSubmit, 217 + onFeedSelect: handleFeedSelect, 218 + onMessagesSubmit: handleMessagesSubmit, 219 + onProfileSubmit: handleProfileSubmit, 220 + onSearchSubmit: handleSearchSubmit, 221 + }} /> 615 222 </div> 616 223 </Portal> 617 224 </Show>
+187
src/components/deck/ColumnPicker.tsx
··· 1 + import { FeedController } from "$/lib/api/feeds"; 2 + import { getFeedName } from "$/lib/feeds"; 3 + import type { FeedGeneratorView, SavedFeedItem } from "$/lib/types"; 4 + import * as logger from "@tauri-apps/plugin-log"; 5 + import { createSignal, For, onMount, Show } from "solid-js"; 6 + import { FeedChipAvatar } from "../feeds/FeedChipAvatar"; 7 + import type { FeedPickerSelection } from "./types"; 8 + 9 + function feedKindLabel(feed: SavedFeedItem) { 10 + switch (feed.type) { 11 + case "timeline": { 12 + return "Timeline"; 13 + } 14 + case "list": { 15 + return "List"; 16 + } 17 + default: { 18 + return "Feed"; 19 + } 20 + } 21 + } 22 + 23 + export function FeedPicker(props: { onSelect: (selection: FeedPickerSelection) => void }) { 24 + const [feeds, setFeeds] = createSignal<SavedFeedItem[]>([]); 25 + const [generators, setGenerators] = createSignal<Record<string, FeedGeneratorView>>({}); 26 + const [loading, setLoading] = createSignal(true); 27 + 28 + onMount(async () => { 29 + try { 30 + const prefs = await FeedController.getPreferences(); 31 + setFeeds(prefs.savedFeeds); 32 + 33 + const uris = [...new Set(prefs.savedFeeds.filter((feed) => feed.type === "feed").map((feed) => feed.value))]; 34 + if (uris.length > 0) { 35 + const hydrated = await FeedController.getFeedGenerators(uris); 36 + setGenerators(Object.fromEntries(hydrated.feeds.map((generator) => [generator.uri, generator]))); 37 + } 38 + } catch (err) { 39 + logger.error(`Failed to load feeds for column picker: ${String(err)}`); 40 + } finally { 41 + setLoading(false); 42 + } 43 + }); 44 + 45 + return ( 46 + <div class="grid gap-2"> 47 + <Show when={loading()}> 48 + <div class="flex items-center justify-center py-6"> 49 + <span class="flex items-center text-on-surface-variant"> 50 + <i class="i-ri-loader-4-line animate-spin" /> 51 + </span> 52 + </div> 53 + </Show> 54 + 55 + <Show when={!loading() && feeds().length === 0}> 56 + <p class="py-4 text-center text-sm text-on-surface-variant">No saved feeds found.</p> 57 + </Show> 58 + 59 + <For 60 + each={feeds()} 61 + fallback={ 62 + <Show when={!loading()}> 63 + <p class="py-4 text-center text-sm text-on-surface-variant">No saved feeds found.</p> 64 + </Show> 65 + }> 66 + {(feed) => ( 67 + <button 68 + type="button" 69 + class="flex w-full items-center gap-3 rounded-xl border-0 bg-white/4 px-4 py-3 text-left transition duration-150 hover:-translate-y-px hover:bg-white/8" 70 + onClick={() => props.onSelect({ feed, title: getFeedName(feed, generators()[feed.value]?.displayName) })}> 71 + <FeedChipAvatar feed={feed} generator={generators()[feed.value]} /> 72 + <span class="min-w-0 flex-1"> 73 + <span class="block truncate text-sm font-medium text-on-surface"> 74 + {getFeedName(feed, generators()[feed.value]?.displayName)} 75 + </span> 76 + <span class="block truncate text-xs text-on-surface-variant">{feedKindLabel(feed)}</span> 77 + </span> 78 + </button> 79 + )} 80 + </For> 81 + </div> 82 + ); 83 + } 84 + 85 + export function ExplorerPicker(props: { onSubmit: (uri: string) => void }) { 86 + const [value, setValue] = createSignal(""); 87 + 88 + function handleSubmit(e: Event) { 89 + e.preventDefault(); 90 + const uri = value().trim(); 91 + if (uri) { 92 + props.onSubmit(uri); 93 + } 94 + } 95 + 96 + return ( 97 + <form onSubmit={handleSubmit} class="grid gap-3"> 98 + <label class="grid gap-1.5"> 99 + <span class="text-xs font-medium uppercase tracking-wide text-on-surface-variant"> 100 + Target URI / handle / DID / PDS URL 101 + </span> 102 + <input 103 + type="text" 104 + class="rounded-xl border-0 bg-white/6 px-4 py-2.5 text-sm text-on-surface placeholder:text-on-surface-variant/50 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)] outline-none focus:shadow-[inset_0_0_0_1px_rgba(125,175,255,0.4)]" 105 + placeholder="at://did:plc:… or handle.bsky.social" 106 + value={value()} 107 + onInput={(e) => setValue(e.currentTarget.value)} /> 108 + </label> 109 + 110 + <button 111 + type="submit" 112 + disabled={!value().trim()} 113 + class="flex items-center justify-center gap-2 rounded-xl border-0 bg-primary/15 px-4 py-2.5 text-sm font-medium text-primary transition duration-150 hover:-translate-y-px hover:bg-primary/25 disabled:cursor-not-allowed disabled:opacity-40"> 114 + <span class="flex items-center"> 115 + <i class="i-ri-compass-discover-line" /> 116 + </span> 117 + Open in column 118 + </button> 119 + </form> 120 + ); 121 + } 122 + 123 + export function DiagnosticsPicker(props: { onSubmit: (did: string) => void }) { 124 + const [value, setValue] = createSignal(""); 125 + 126 + function handleSubmit(e: Event) { 127 + e.preventDefault(); 128 + const did = value().trim(); 129 + if (did) { 130 + props.onSubmit(did); 131 + } 132 + } 133 + 134 + return ( 135 + <form onSubmit={handleSubmit} class="grid gap-3"> 136 + <label class="grid gap-1.5"> 137 + <span class="text-xs font-medium uppercase tracking-wide text-on-surface-variant">Handle or DID</span> 138 + <input 139 + type="text" 140 + class="rounded-xl border-0 bg-white/6 px-4 py-2.5 text-sm text-on-surface placeholder:text-on-surface-variant/50 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)] outline-none focus:shadow-[inset_0_0_0_1px_rgba(125,175,255,0.4)]" 141 + placeholder="handle.bsky.social or did:plc:…" 142 + value={value()} 143 + onInput={(e) => setValue(e.currentTarget.value)} /> 144 + </label> 145 + 146 + <button 147 + type="submit" 148 + disabled={!value().trim()} 149 + class="flex items-center justify-center gap-2 rounded-xl border-0 bg-primary/15 px-4 py-2.5 text-sm font-medium text-primary transition duration-150 hover:-translate-y-px hover:bg-primary/25 disabled:cursor-not-allowed disabled:opacity-40"> 150 + <span class="flex items-center"> 151 + <i class="i-ri-stethoscope-line" /> 152 + </span> 153 + Open diagnostics 154 + </button> 155 + </form> 156 + ); 157 + } 158 + 159 + export function MessagesPicker(props: { onSubmit: () => void }) { 160 + return ( 161 + <div class="grid gap-4"> 162 + <div class="rounded-2xl bg-white/4 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 163 + <div class="flex items-start gap-3"> 164 + <span class="mt-0.5 flex items-center text-primary"> 165 + <i class="i-ri-message-3-line" /> 166 + </span> 167 + <div class="grid gap-1.5"> 168 + <p class="m-0 text-sm font-medium text-on-surface">Direct messages</p> 169 + <p class="m-0 text-xs leading-relaxed text-on-surface-variant"> 170 + Opens your DM inbox inside the deck. Message content is blurred until you hover or focus the column. 171 + </p> 172 + </div> 173 + </div> 174 + </div> 175 + 176 + <button 177 + type="button" 178 + class="flex items-center justify-center gap-2 rounded-xl border-0 bg-primary/15 px-4 py-2.5 text-sm font-medium text-primary transition duration-150 hover:-translate-y-px hover:bg-primary/25" 179 + onClick={() => props.onSubmit()}> 180 + <span class="flex items-center"> 181 + <i class="i-ri-layout-column-line" /> 182 + </span> 183 + Add DM column 184 + </button> 185 + </div> 186 + ); 187 + }
+129
src/components/deck/ColumnPicker/ProfileColumnPicker.tsx
··· 1 + import { ActorSuggestionList, useActorSuggestions } from "$/components/actors/actor-search"; 2 + import { Icon } from "$/components/shared/Icon"; 3 + import type { LoginSuggestion } from "$/lib/types"; 4 + import * as logger from "@tauri-apps/plugin-log"; 5 + import { createSignal, Show } from "solid-js"; 6 + import type { ProfileSelection } from "../types"; 7 + 8 + function TypeaheadLoading(props: { visible: boolean }) { 9 + return ( 10 + <Show when={props.visible}> 11 + <span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-on-surface-variant"> 12 + <Icon kind="loader" class="animate-spin text-sm" /> 13 + </span> 14 + </Show> 15 + ); 16 + } 17 + 18 + export function ProfilePicker(props: { onSubmit: (selection: ProfileSelection) => void }) { 19 + let container: HTMLDivElement | undefined; 20 + let input: HTMLInputElement | undefined; 21 + const [value, setValue] = createSignal(""); 22 + const typeahead = useActorSuggestions({ 23 + container: () => container, 24 + input: () => input, 25 + onError: (error) => logger.warn(`Failed to load profile suggestions: ${String(error)}`), 26 + value, 27 + }); 28 + 29 + function submitManualActor() { 30 + const actor = value().trim(); 31 + if (!actor) { 32 + return; 33 + } 34 + 35 + typeahead.close(); 36 + props.onSubmit({ actor }); 37 + } 38 + 39 + function submitSuggestion(suggestion: LoginSuggestion) { 40 + typeahead.close(); 41 + props.onSubmit({ 42 + actor: suggestion.handle, 43 + did: suggestion.did, 44 + displayName: suggestion.displayName ?? null, 45 + handle: suggestion.handle, 46 + }); 47 + } 48 + 49 + function handleKeyDown(event: KeyboardEvent) { 50 + if (event.key === "ArrowDown") { 51 + event.preventDefault(); 52 + typeahead.moveActiveIndex(1); 53 + return; 54 + } 55 + 56 + if (event.key === "ArrowUp") { 57 + event.preventDefault(); 58 + typeahead.moveActiveIndex(-1); 59 + return; 60 + } 61 + 62 + if (event.key === "Escape") { 63 + typeahead.close(); 64 + return; 65 + } 66 + 67 + if (event.key === "Enter" && typeahead.open() && typeahead.activeSuggestion()) { 68 + event.preventDefault(); 69 + submitSuggestion(typeahead.activeSuggestion() as LoginSuggestion); 70 + } 71 + } 72 + 73 + return ( 74 + <form 75 + class="grid gap-3" 76 + onSubmit={(event) => { 77 + event.preventDefault(); 78 + submitManualActor(); 79 + }}> 80 + <label class="grid gap-1.5"> 81 + <span class="text-xs font-medium uppercase tracking-wide text-on-surface-variant">Handle or DID</span> 82 + <div 83 + class="relative" 84 + ref={(element) => { 85 + container = element as HTMLDivElement; 86 + }}> 87 + <input 88 + ref={(element) => { 89 + input = element; 90 + }} 91 + type="text" 92 + role="combobox" 93 + aria-autocomplete="list" 94 + aria-controls="profile-suggestions" 95 + aria-activedescendant={typeahead.activeIndex() >= 0 96 + ? `profile-suggestions-option-${typeahead.activeIndex()}` 97 + : undefined} 98 + aria-expanded={typeahead.open()} 99 + class="w-full rounded-xl border-0 bg-white/6 px-4 py-2.5 pr-10 text-sm text-on-surface placeholder:text-on-surface-variant/50 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)] outline-none focus:shadow-[inset_0_0_0_1px_rgba(125,175,255,0.4)]" 100 + placeholder="alice.bsky.social" 101 + spellcheck={false} 102 + value={value()} 103 + onFocus={() => typeahead.focus()} 104 + onInput={(event) => setValue(event.currentTarget.value)} 105 + onKeyDown={(event) => handleKeyDown(event)} /> 106 + 107 + <TypeaheadLoading visible={typeahead.loading()} /> 108 + <ActorSuggestionList 109 + activeIndex={typeahead.activeIndex()} 110 + id="profile-suggestions" 111 + open={typeahead.open()} 112 + suggestions={typeahead.suggestions()} 113 + title="Suggested profiles" 114 + onSelect={submitSuggestion} /> 115 + </div> 116 + </label> 117 + 118 + <button 119 + type="submit" 120 + disabled={!value().trim()} 121 + class="flex items-center justify-center gap-2 rounded-xl border-0 bg-primary/15 px-4 py-2.5 text-sm font-medium text-primary transition duration-150 hover:-translate-y-px hover:bg-primary/25 disabled:cursor-not-allowed disabled:opacity-40"> 122 + <span class="flex items-center"> 123 + <i class="i-ri-user-3-line" /> 124 + </span> 125 + Open profile 126 + </button> 127 + </form> 128 + ); 129 + }
+69
src/components/deck/ColumnPicker/SearchPicker.tsx
··· 1 + import { SearchModeIcon } from "$/components/shared/Icon"; 2 + import type { SearchMode } from "$/lib/api/types/search"; 3 + import { createSignal } from "solid-js"; 4 + 5 + function SearchModeButton(props: { active: boolean; disabled?: boolean; mode: SearchMode; onClick: () => void }) { 6 + return ( 7 + <button 8 + type="button" 9 + disabled={props.disabled} 10 + class="inline-flex items-center justify-center gap-2 rounded-xl border-0 px-3 py-2 text-xs font-medium transition duration-150 disabled:cursor-not-allowed disabled:opacity-40" 11 + classList={{ 12 + "bg-primary/15 text-primary": props.active, 13 + "bg-white/4 text-on-surface-variant hover:bg-white/8 hover:text-on-surface": !props.active && !props.disabled, 14 + }} 15 + onClick={() => props.onClick()}> 16 + <SearchModeIcon mode={props.mode} class="text-sm" /> 17 + <span class="capitalize">{props.mode}</span> 18 + </button> 19 + ); 20 + } 21 + 22 + export function SearchPicker(props: { onSubmit: (query: string, mode: SearchMode) => void }) { 23 + const [mode, setMode] = createSignal<SearchMode>("network"); 24 + const [query, setQuery] = createSignal(""); 25 + 26 + function handleSubmit(event: Event) { 27 + event.preventDefault(); 28 + const trimmed = query().trim(); 29 + if (!trimmed) { 30 + return; 31 + } 32 + 33 + props.onSubmit(trimmed, mode()); 34 + } 35 + 36 + return ( 37 + <form onSubmit={handleSubmit} class="grid gap-3"> 38 + <label class="grid gap-1.5"> 39 + <span class="text-xs font-medium uppercase tracking-wide text-on-surface-variant">Search query</span> 40 + <input 41 + type="text" 42 + class="rounded-xl border-0 bg-white/6 px-4 py-2.5 text-sm text-on-surface placeholder:text-on-surface-variant/50 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)] outline-none focus:shadow-[inset_0_0_0_1px_rgba(125,175,255,0.4)]" 43 + placeholder="from:alice at protocol" 44 + value={query()} 45 + onInput={(event) => setQuery(event.currentTarget.value)} /> 46 + </label> 47 + 48 + <div class="grid gap-1.5"> 49 + <span class="text-xs font-medium uppercase tracking-wide text-on-surface-variant">Search mode</span> 50 + <div class="grid grid-cols-2 gap-2"> 51 + <SearchModeButton active={mode() === "network"} mode="network" onClick={() => setMode("network")} /> 52 + <SearchModeButton active={mode() === "keyword"} mode="keyword" onClick={() => setMode("keyword")} /> 53 + <SearchModeButton active={mode() === "semantic"} mode="semantic" onClick={() => setMode("semantic")} /> 54 + <SearchModeButton active={mode() === "hybrid"} mode="hybrid" onClick={() => setMode("hybrid")} /> 55 + </div> 56 + </div> 57 + 58 + <button 59 + type="submit" 60 + disabled={!query().trim()} 61 + class="flex items-center justify-center gap-2 rounded-xl border-0 bg-primary/15 px-4 py-2.5 text-sm font-medium text-primary transition duration-150 hover:-translate-y-px hover:bg-primary/25 disabled:cursor-not-allowed disabled:opacity-40"> 62 + <span class="flex items-center"> 63 + <i class="i-ri-search-line" /> 64 + </span> 65 + Open search column 66 + </button> 67 + </form> 68 + ); 69 + }
+1 -1
src/components/deck/DiagnosticsPanel.test.tsx src/components/deck/tests/DiagnosticsPanel.test.tsx
··· 1 1 import { AppTestProviders } from "$/test/providers"; 2 2 import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 3 3 import { beforeEach, describe, expect, it, vi } from "vitest"; 4 - import { DiagnosticsPanel } from "./DiagnosticsPanel"; 4 + import { DiagnosticsPanel } from "../DiagnosticsPanel"; 5 5 6 6 const getAccountListsMock = vi.hoisted(() => vi.fn()); 7 7 const getAccountLabelsMock = vi.hoisted(() => vi.fn());
+1 -1
src/components/deck/types.test.ts src/components/deck/tests/types.test.ts
··· 1 1 import { describe, expect, it } from "vitest"; 2 - import { parseFeedConfig, resolveFeedColumn } from "./types"; 2 + import { parseFeedConfig, resolveFeedColumn } from "../types"; 3 3 4 4 describe("deck feed column helpers", () => { 5 5 it("rejects malformed feed configs", () => {
+9
src/components/deck/types.ts
··· 155 155 } 156 156 } 157 157 } 158 + 159 + export type FeedPickerSelection = { feed: SavedFeedItem; title: string }; 160 + 161 + export type ProfileSelection = { 162 + actor: string; 163 + did?: string | null; 164 + displayName?: string | null; 165 + handle?: string | null; 166 + };
+119 -95
src/components/feeds/PostCard.tsx
··· 29 29 } from "$/lib/types"; 30 30 import { formatCount, formatHandle, normalizeError } from "$/lib/utils/text"; 31 31 import * as logger from "@tauri-apps/plugin-log"; 32 - import { createMemo, createSignal, type ParentProps, Show } from "solid-js"; 32 + import { createMemo, createSignal, type ParentProps, Show, splitProps } from "solid-js"; 33 33 import { Motion } from "solid-motionone"; 34 34 import { EmbedContent } from "./embeds/ContentEmbed"; 35 35 import type { ReportTarget } from "./types"; ··· 117 117 ); 118 118 } 119 119 120 - // FIXME: this is an absurdly large number of props 121 - type PostActionsProps = { 120 + type PostActionStatus = { 122 121 bookmarkPending: boolean; 123 122 isBookmarked: boolean; 124 123 isLiked: boolean; 125 124 isReposted: boolean; 126 125 likeCount: string; 127 126 likePending: boolean; 128 - menuOpen: boolean; 129 127 pulseLike: boolean; 130 128 pulseRepost: boolean; 131 129 replyCount: string; 132 130 repostCount: string; 133 131 repostPending: boolean; 134 - triggerRef: (element: HTMLButtonElement) => void; 132 + }; 133 + 134 + type PostActionHandlers = { 135 135 onBookmark?: () => void; 136 136 onLike?: () => void; 137 - onMenuOpen: (element: HTMLButtonElement) => void; 138 137 onOpenThread?: () => void; 139 138 onQuote?: () => void; 140 139 onReply?: () => void; 141 140 onRepost?: () => void; 142 141 }; 143 142 143 + type PostActionsProps = { 144 + handlers: PostActionHandlers; 145 + menu: { 146 + open: boolean; 147 + onOpen: (element: HTMLButtonElement) => void; 148 + triggerRef: (element: HTMLButtonElement) => void; 149 + }; 150 + state: PostActionStatus; 151 + }; 152 + 144 153 function PostActions(props: PostActionsProps) { 154 + const [status, menu, actions] = splitProps(props, ["state"], ["menu"], ["handlers"]); 155 + 145 156 return ( 146 157 <footer class="mt-4 flex min-w-0 flex-wrap items-center gap-2 max-[520px]:gap-1"> 147 158 <PostActionButton 148 - active={props.isLiked} 149 - busy={props.likePending} 159 + active={status.state.isLiked} 160 + busy={status.state.likePending} 150 161 icon="i-ri-heart-3-line" 151 162 iconActive="i-ri-heart-3-fill" 152 - label={props.likeCount} 153 - pulse={props.pulseLike} 154 - onClick={props.onLike} /> 155 - <PostActionButton icon="i-ri-chat-1-line" label={props.replyCount} onClick={props.onReply} /> 163 + label={status.state.likeCount} 164 + pulse={status.state.pulseLike} 165 + onClick={actions.handlers.onLike} /> 166 + <PostActionButton icon="i-ri-chat-1-line" label={status.state.replyCount} onClick={actions.handlers.onReply} /> 156 167 <PostActionButton 157 - active={props.isReposted} 158 - busy={props.repostPending} 168 + active={status.state.isReposted} 169 + busy={status.state.repostPending} 159 170 icon="i-ri-repeat-2-line" 160 171 iconActive="i-ri-repeat-2-fill" 161 - label={props.repostCount} 162 - pulse={props.pulseRepost} 163 - onClick={props.onRepost} /> 172 + label={status.state.repostCount} 173 + pulse={status.state.pulseRepost} 174 + onClick={actions.handlers.onRepost} /> 164 175 <PostActionButton 165 - active={props.isBookmarked} 166 - busy={props.bookmarkPending} 176 + active={status.state.isBookmarked} 177 + busy={status.state.bookmarkPending} 167 178 icon="i-ri-bookmark-line" 168 179 iconActive="i-ri-bookmark-fill" 169 - label={props.isBookmarked ? "Saved" : "Save"} 170 - onClick={props.onBookmark} /> 171 - <PostActionButton icon="i-ri-chat-quote-line" label="Quote" onClick={props.onQuote} /> 172 - <PostActionButton icon="i-ri-node-tree" label="Thread" onClick={props.onOpenThread} /> 180 + label={status.state.isBookmarked ? "Saved" : "Save"} 181 + onClick={actions.handlers.onBookmark} /> 182 + <PostActionButton icon="i-ri-chat-quote-line" label="Quote" onClick={actions.handlers.onQuote} /> 183 + <PostActionButton icon="i-ri-node-tree" label="Thread" onClick={actions.handlers.onOpenThread} /> 173 184 <button 174 185 aria-label="More actions" 175 - ref={(element) => props.triggerRef(element)} 176 - aria-expanded={props.menuOpen} 186 + ref={(element) => menu.menu.triggerRef(element)} 187 + aria-expanded={menu.menu.open} 177 188 aria-haspopup="menu" 178 189 class="inline-flex items-center justify-center rounded-full border-0 bg-transparent px-3 py-2 text-xs text-on-surface-variant transition duration-150 ease-out hover:-translate-y-px hover:bg-white/5 hover:text-primary max-[520px]:px-2.5" 179 190 type="button" 180 191 onClick={(event) => { 181 192 event.stopPropagation(); 182 - props.onMenuOpen(event.currentTarget); 193 + menu.menu.onOpen(event.currentTarget); 183 194 }}> 184 195 <Icon aria-hidden="true" iconClass="i-ri-more-fill" /> 185 196 </button> ··· 228 239 }; 229 240 230 241 export function PostCard(props: PostCardProps) { 231 - const authorName = createMemo(() => getDisplayName(props.post.author)); 232 - const createdAt = createMemo(() => formatRelativeTime(getPostCreatedAt(props.post))); 233 - const isBookmarked = createMemo(() => !!props.post.viewer?.bookmarked); 234 - const isLiked = createMemo(() => !!props.post.viewer?.like); 235 - const isReposted = createMemo(() => !!props.post.viewer?.repost); 236 - const likeCount = createMemo(() => formatCount(props.post.likeCount)); 237 - const postText = createMemo(() => getPostText(props.post)); 238 - const replyCount = createMemo(() => formatCount(props.post.replyCount)); 239 - const repostCount = createMemo(() => formatCount(props.post.repostCount)); 240 - const authorHandle = createMemo(() => formatHandle(props.post.author.handle, props.post.author.did)); 241 - const profileHref = createMemo(() => buildProfileRoute(getProfileRouteActor(props.post.author))); 242 - const contentLabels = () => collectModerationLabels(props.post); 243 - const mediaLabels = () => collectModerationLabels(props.post, props.post.embed); 244 - const avatarLabels = () => collectModerationLabels(props.post.author); 245 - const contentDecision = useModerationDecision(contentLabels); 246 - const mediaDecision = useModerationDecision(mediaLabels); 247 - const avatarDecision = useModerationDecision(avatarLabels); 242 + const [view, interactions, actionFlags] = splitProps( 243 + props, 244 + ["focused", "item", "post", "registerRef", "showActions"], 245 + ["onBookmark", "onFocus", "onLike", "onOpenThread", "onQuote", "onReply", "onRepost"], 246 + ["bookmarkPending", "likePending", "pulseLike", "pulseRepost", "repostPending"], 247 + ); 248 + 249 + const authorName = createMemo(() => getDisplayName(view.post.author)); 250 + const createdAt = createMemo(() => formatRelativeTime(getPostCreatedAt(view.post))); 251 + const isBookmarked = createMemo(() => !!view.post.viewer?.bookmarked); 252 + const isLiked = createMemo(() => !!view.post.viewer?.like); 253 + const isReposted = createMemo(() => !!view.post.viewer?.repost); 254 + const likeCount = createMemo(() => formatCount(view.post.likeCount)); 255 + const postText = createMemo(() => getPostText(view.post)); 256 + const replyCount = createMemo(() => formatCount(view.post.replyCount)); 257 + const repostCount = createMemo(() => formatCount(view.post.repostCount)); 258 + const authorHandle = createMemo(() => formatHandle(view.post.author.handle, view.post.author.did)); 259 + const profileHref = createMemo(() => buildProfileRoute(getProfileRouteActor(view.post.author))); 260 + const contentLabels = () => collectModerationLabels(view.post); 261 + const mediaLabels = () => collectModerationLabels(view.post, view.post.embed); 262 + const avatarLabels = () => collectModerationLabels(view.post.author); 263 + const contentDecision = useModerationDecision(contentLabels, "contentList"); 264 + const mediaDecision = useModerationDecision(mediaLabels, "contentMedia"); 265 + const avatarDecision = useModerationDecision(avatarLabels, "avatar"); 248 266 const reasonLabel = createMemo(() => { 249 - const reason = props.item?.reason; 267 + const reason = view.item?.reason; 250 268 if (!reason || reason.$type !== "app.bsky.feed.defs#reasonRepost") { 251 269 return null; 252 270 } ··· 255 273 }); 256 274 257 275 const replyLabel = createMemo(() => { 258 - const item = props.item; 276 + const item = view.item; 259 277 if (!item || !isReplyItem(item)) { 260 278 return null; 261 279 } ··· 277 295 const menuItems = createMemo<ContextMenuItem[]>(() => { 278 296 const items: ContextMenuItem[] = []; 279 297 280 - if (props.onReply) { 281 - items.push({ icon: "i-ri-chat-1-line", label: "Reply", onSelect: props.onReply }); 298 + if (interactions.onReply) { 299 + items.push({ icon: "i-ri-chat-1-line", label: "Reply", onSelect: interactions.onReply }); 282 300 } 283 301 284 - if (props.onQuote) { 285 - items.push({ icon: "i-ri-chat-quote-line", label: "Quote", onSelect: props.onQuote }); 302 + if (interactions.onQuote) { 303 + items.push({ icon: "i-ri-chat-quote-line", label: "Quote", onSelect: interactions.onQuote }); 286 304 } 287 305 288 - if (props.onLike) { 306 + if (interactions.onLike) { 289 307 items.push({ 290 308 icon: isLiked() ? "i-ri-heart-3-fill" : "i-ri-heart-3-line", 291 309 label: isLiked() ? "Unlike" : "Like", 292 - onSelect: props.onLike, 310 + onSelect: interactions.onLike, 293 311 }); 294 312 } 295 313 296 - if (props.onRepost) { 314 + if (interactions.onRepost) { 297 315 items.push({ 298 316 icon: isReposted() ? "i-ri-repeat-2-fill" : "i-ri-repeat-2-line", 299 317 label: isReposted() ? "Undo repost" : "Repost", 300 - onSelect: props.onRepost, 318 + onSelect: interactions.onRepost, 301 319 }); 302 320 } 303 321 304 - if (props.onBookmark) { 322 + if (interactions.onBookmark) { 305 323 items.push({ 306 324 icon: isBookmarked() ? "i-ri-bookmark-fill" : "i-ri-bookmark-line", 307 325 label: isBookmarked() ? "Unsave" : "Save", 308 - onSelect: props.onBookmark, 326 + onSelect: interactions.onBookmark, 309 327 }); 310 328 } 311 329 312 330 items.push({ 313 331 icon: "i-ri-link-m", 314 332 label: "Copy post link", 315 - onSelect: () => void navigator.clipboard?.writeText(buildPublicPostUrl(props.post)), 333 + onSelect: () => void navigator.clipboard?.writeText(buildPublicPostUrl(view.post)), 316 334 }); 317 335 318 - if (props.onOpenThread) { 319 - items.push({ icon: "i-ri-node-tree", label: "Open thread", onSelect: props.onOpenThread }); 336 + if (interactions.onOpenThread) { 337 + items.push({ icon: "i-ri-node-tree", label: "Open thread", onSelect: interactions.onOpenThread }); 320 338 } 321 339 322 340 items.push({ ··· 324 342 label: "Report post", 325 343 onSelect: () => { 326 344 setReportTarget({ 327 - subject: { type: "record", uri: props.post.uri, cid: props.post.cid }, 328 - subjectLabel: `Post by @${props.post.author.handle}`, 345 + subject: { type: "record", uri: view.post.uri, cid: view.post.cid }, 346 + subjectLabel: `Post by @${view.post.author.handle}`, 329 347 }); 330 348 setReportOpen(true); 331 349 }, ··· 334 352 label: "Report account", 335 353 onSelect: () => { 336 354 setReportTarget({ 337 - subject: { type: "repo", did: props.post.author.did }, 338 - subjectLabel: `Account @${props.post.author.handle}`, 355 + subject: { type: "repo", did: view.post.author.did }, 356 + subjectLabel: `Account @${view.post.author.handle}`, 339 357 }); 340 358 setReportOpen(true); 341 359 }, 342 - }, { icon: "i-ri-forbid-2-line", label: `Block @${props.post.author.handle}`, onSelect: () => void blockAuthor() }); 360 + }, { icon: "i-ri-forbid-2-line", label: `Block @${view.post.author.handle}`, onSelect: () => void blockAuthor() }); 343 361 344 362 return items; 345 363 }); ··· 375 393 376 394 async function blockAuthor() { 377 395 const confirmed = globalThis.confirm 378 - ? globalThis.confirm(`Block @${props.post.author.handle}? You can unblock from Bluesky settings.`) 396 + ? globalThis.confirm(`Block @${view.post.author.handle}? You can unblock from Bluesky settings.`) 379 397 : true; 380 398 381 399 if (!confirmed) { ··· 383 401 } 384 402 385 403 try { 386 - await ModerationController.blockActor(props.post.author.did); 404 + await ModerationController.blockActor(view.post.author.did); 387 405 } catch (error) { 388 406 logger.error("failed to block account", { keyValues: { error: normalizeError(error) } }); 389 407 } ··· 391 409 392 410 return ( 393 411 <article 394 - ref={(element) => props.registerRef?.(element)} 412 + ref={(element) => view.registerRef?.(element)} 395 413 class="group min-w-0 overflow-hidden rounded-3xl bg-white/2.5 px-4 py-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)] transition duration-150 ease-out hover:bg-white/4 max-[760px]:px-3.5 max-[760px]:py-3.5 max-[520px]:rounded-3xl max-[520px]:px-3 max-[520px]:py-3" 396 414 classList={{ 397 415 "bg-[linear-gradient(135deg,rgba(125,175,255,0.11),rgba(0,115,222,0.06))] shadow-[inset_0_0_0_1px_rgba(125,175,255,0.22),0_0_0_1px_rgba(125,175,255,0.08)]": 398 - !!props.focused, 416 + !!view.focused, 399 417 }} 400 418 role="article" 401 419 onContextMenu={(event) => { ··· 421 439 <div class="flex min-w-0 gap-3"> 422 440 <a class="shrink-0 no-underline" href={`#${profileHref()}`} onClick={(event) => event.stopPropagation()}> 423 441 <ModeratedAvatar 424 - avatar={props.post.author.avatar} 442 + avatar={view.post.author.avatar} 425 443 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)]" 426 444 hidden={avatarDecision().filter || avatarDecision().blur !== "none"} 427 - label={getAvatarLabel(props.post.author)} 445 + label={getAvatarLabel(view.post.author)} 428 446 fallbackClass="text-sm font-semibold text-on-primary-fixed" /> 429 447 </a> 430 448 431 449 <div class="min-w-0 flex-1"> 432 - <PostPrimaryRegion onFocus={props.onFocus} onOpenThread={props.onOpenThread}> 450 + <PostPrimaryRegion onFocus={interactions.onFocus} onOpenThread={interactions.onOpenThread}> 433 451 <PostHeader 434 452 authorName={authorName()} 435 453 authorHandle={authorHandle()} ··· 441 459 <ModeratedPostBody 442 460 decision={contentDecision()} 443 461 labels={contentLabels()} 444 - post={props.post} 462 + post={view.post} 445 463 text={postText()} /> 446 464 447 - <Show when={props.post.embed}> 465 + <Show when={view.post.embed}> 448 466 {(current) => ( 449 467 <ModeratedBlurOverlay decision={mediaDecision()} labels={mediaLabels()} class="mt-4"> 450 - <EmbedContent embed={current()} post={props.post} /> 468 + <EmbedContent embed={current()} post={view.post} /> 451 469 </ModeratedBlurOverlay> 452 470 )} 453 471 </Show> 454 472 </PostPrimaryRegion> 455 473 456 - <Show when={props.showActions !== false}> 474 + <Show when={view.showActions !== false}> 457 475 <PostActions 458 - bookmarkPending={!!props.bookmarkPending} 459 - isBookmarked={isBookmarked()} 460 - isLiked={isLiked()} 461 - isReposted={isReposted()} 462 - likeCount={likeCount()} 463 - likePending={!!props.likePending} 464 - menuOpen={menuOpen()} 465 - pulseLike={!!props.pulseLike} 466 - pulseRepost={!!props.pulseRepost} 467 - replyCount={replyCount()} 468 - repostCount={repostCount()} 469 - repostPending={!!props.repostPending} 470 - triggerRef={(element) => { 471 - menuTriggerRef = element; 476 + handlers={{ 477 + onBookmark: interactions.onBookmark, 478 + onLike: interactions.onLike, 479 + onOpenThread: interactions.onOpenThread, 480 + onQuote: interactions.onQuote, 481 + onReply: interactions.onReply, 482 + onRepost: interactions.onRepost, 483 + }} 484 + menu={{ 485 + open: menuOpen(), 486 + onOpen: openMenuFromTrigger, 487 + triggerRef: (element) => { 488 + menuTriggerRef = element; 489 + }, 472 490 }} 473 - onBookmark={props.onBookmark} 474 - onLike={props.onLike} 475 - onMenuOpen={openMenuFromTrigger} 476 - onOpenThread={props.onOpenThread} 477 - onQuote={props.onQuote} 478 - onReply={props.onReply} 479 - onRepost={props.onRepost} /> 491 + state={{ 492 + bookmarkPending: !!actionFlags.bookmarkPending, 493 + isBookmarked: isBookmarked(), 494 + isLiked: isLiked(), 495 + isReposted: isReposted(), 496 + likeCount: likeCount(), 497 + likePending: !!actionFlags.likePending, 498 + pulseLike: !!actionFlags.pulseLike, 499 + pulseRepost: !!actionFlags.pulseRepost, 500 + replyCount: replyCount(), 501 + repostCount: repostCount(), 502 + repostPending: !!actionFlags.repostPending, 503 + }} /> 480 504 </Show> 481 505 </div> 482 506 </div>
+45
src/components/moderation/useModerationDecision.test.tsx
··· 1 + import type { ModerationLabel } from "$/lib/types"; 2 + import { render, waitFor } from "@solidjs/testing-library"; 3 + import { beforeEach, describe, expect, it, vi } from "vitest"; 4 + import { useModerationDecision } from "./useModerationDecision"; 5 + 6 + const moderateContentMock = vi.hoisted(() => vi.fn()); 7 + 8 + vi.mock("$/lib/api/moderation", () => ({ ModerationController: { moderateContent: moderateContentMock } })); 9 + 10 + function DecisionProbe(props: { context: "contentList" | "contentMedia"; labels: ModerationLabel[] }) { 11 + const labels = () => props.labels; 12 + const ctx = () => props.context; 13 + const decision = useModerationDecision(labels, ctx()); 14 + return <span>{decision().blur}</span>; 15 + } 16 + 17 + describe("useModerationDecision", () => { 18 + beforeEach(() => { 19 + moderateContentMock.mockReset(); 20 + moderateContentMock.mockResolvedValue({ 21 + alert: false, 22 + blur: "none", 23 + filter: false, 24 + inform: false, 25 + noOverride: false, 26 + }); 27 + }); 28 + 29 + it("includes context in its cache key", async () => { 30 + const labels: ModerationLabel[] = [{ src: "did:plc:labeler", val: "warn", uri: "at://did:plc:alice/app.test/1" }]; 31 + 32 + const first = render(() => <DecisionProbe context="contentList" labels={labels} />); 33 + await waitFor(() => expect(moderateContentMock).toHaveBeenCalledWith(labels, "contentList")); 34 + expect(moderateContentMock).toHaveBeenCalledTimes(1); 35 + first.unmount(); 36 + 37 + const second = render(() => <DecisionProbe context="contentList" labels={labels} />); 38 + await waitFor(() => expect(moderateContentMock).toHaveBeenCalledTimes(1)); 39 + second.unmount(); 40 + 41 + render(() => <DecisionProbe context="contentMedia" labels={labels} />); 42 + await waitFor(() => expect(moderateContentMock).toHaveBeenCalledWith(labels, "contentMedia")); 43 + expect(moderateContentMock).toHaveBeenCalledTimes(2); 44 + }); 45 + });
+7 -4
src/components/moderation/useModerationDecision.ts
··· 1 1 import { ModerationController } from "$/lib/api/moderation"; 2 2 import { DEFAULT_MODERATION_DECISION, moderationLabelsKey } from "$/lib/moderation"; 3 - import type { ModerationLabel, ModerationUiDecision } from "$/lib/types"; 3 + import type { ModerationContext, ModerationLabel, ModerationUiDecision } from "$/lib/types"; 4 4 import { type Accessor, createMemo, createResource } from "solid-js"; 5 5 6 6 const decisionCache = new Map<string, ModerationUiDecision>(); 7 7 8 - export function useModerationDecision(labelsAccessor: Accessor<ModerationLabel[]>) { 9 - const cacheKey = createMemo(() => moderationLabelsKey(labelsAccessor())); 8 + export function useModerationDecision(labelsAccessor: Accessor<ModerationLabel[]>, context: ModerationContext) { 9 + const cacheKey = createMemo(() => { 10 + const labelKey = moderationLabelsKey(labelsAccessor()); 11 + return labelKey ? `${context}:${labelKey}` : ""; 12 + }); 10 13 11 14 const [decision] = createResource(cacheKey, async (key) => { 12 15 if (!key) { ··· 18 21 return cached; 19 22 } 20 23 21 - const next = await ModerationController.moderateContent(labelsAccessor()); 24 + const next = await ModerationController.moderateContent(labelsAccessor(), context); 22 25 decisionCache.set(key, next); 23 26 return next; 24 27 }, { initialValue: DEFAULT_MODERATION_DECISION });
+2 -2
src/components/notifications/NotificationItem.tsx
··· 50 50 const detail = createMemo(() => postText() ?? followDetail(props.notification)); 51 51 const avatarLabels = () => collectModerationLabels(props.notification.author); 52 52 const contentLabels = () => collectModerationLabels(props.notification); 53 - const avatarDecision = useModerationDecision(avatarLabels); 54 - const contentDecision = useModerationDecision(contentLabels); 53 + const avatarDecision = useModerationDecision(avatarLabels, "avatar"); 54 + const contentDecision = useModerationDecision(contentLabels, "contentList"); 55 55 56 56 function openBodyTarget() { 57 57 const uri = bodyTargetUri();
+1 -1
src/components/notifications/NotificationsPanel.tsx
··· 430 430 const label = createMemo(() => getAvatarLabel(props.actor)); 431 431 const profileHref = createMemo(() => buildProfileRoute(getProfileRouteActor(props.actor))); 432 432 const labels = () => collectModerationLabels(props.actor); 433 - const decision = useModerationDecision(labels); 433 + const decision = useModerationDecision(labels, "avatar"); 434 434 435 435 return ( 436 436 <a
+3 -3
src/components/profile/ProfileHero.tsx
··· 212 212 const isFollowing = createMemo(() => !!props.profile.viewer?.following); 213 213 const bannerStyle = createMemo(() => ({ transform: `translate3d(0, ${props.coverOffset}px, 0)` })); 214 214 const profileLabels = () => collectModerationLabels(props.profile); 215 - const profileDecision = useModerationDecision(profileLabels); 215 + const profileDecision = useModerationDecision(profileLabels, "profileView"); 216 216 217 217 return ( 218 218 <header class="relative" ref={(element) => props.rootRef?.(element)}> ··· 273 273 const profile = () => props.profile; 274 274 const label = createMemo(() => getAvatarLabel(props.profile)); 275 275 const labels = () => collectModerationLabels(props.profile); 276 - const decision = useModerationDecision(labels); 276 + const decision = useModerationDecision(labels, "avatar"); 277 277 278 278 return ( 279 279 <ModeratedAvatar ··· 290 290 const displayName = createMemo(() => getDisplayName(props.profile)); 291 291 const visibleBadges = createMemo(() => props.profileBadges.slice(0, 2)); 292 292 const labels = () => collectModerationLabels(props.profile); 293 - const decision = useModerationDecision(labels); 293 + const decision = useModerationDecision(labels, "avatar"); 294 294 295 295 return ( 296 296 <div
+19
src/components/settings/SettingsModeration.test.tsx
··· 7 7 const setLabelPreferenceMock = vi.hoisted(() => vi.fn()); 8 8 const subscribeLabelerMock = vi.hoisted(() => vi.fn()); 9 9 const unsubscribeLabelerMock = vi.hoisted(() => vi.fn()); 10 + const getLabelerPolicyDefinitionsMock = vi.hoisted(() => vi.fn()); 10 11 const getDistributionChannelMock = vi.hoisted(() => vi.fn()); 11 12 const openUrlMock = vi.hoisted(() => vi.fn()); 12 13 ··· 19 20 setLabelPreference: setLabelPreferenceMock, 20 21 subscribeLabeler: subscribeLabelerMock, 21 22 unsubscribeLabeler: unsubscribeLabelerMock, 23 + getLabelerPolicyDefinitions: getLabelerPolicyDefinitionsMock, 22 24 getDistributionChannel: getDistributionChannelMock, 23 25 }, 24 26 }), ··· 38 40 setLabelPreferenceMock.mockResolvedValue(void 0); 39 41 subscribeLabelerMock.mockResolvedValue(void 0); 40 42 unsubscribeLabelerMock.mockResolvedValue(void 0); 43 + getLabelerPolicyDefinitionsMock.mockResolvedValue([{ 44 + labelerDid: "did:plc:ar7c4by46qjdydhdevvrndac", 45 + definitions: [{ identifier: "graphic-media", adultOnly: false, severity: "alert", blurs: "media", locales: [] }], 46 + }, { 47 + labelerDid: "did:plc:custom-labeler", 48 + definitions: [{ identifier: "porn", adultOnly: true, severity: "alert", blurs: "media", locales: [] }], 49 + }]); 41 50 getDistributionChannelMock.mockResolvedValue("github"); 42 51 openUrlMock.mockResolvedValue(void 0); 43 52 }); ··· 87 96 await waitFor(() => 88 97 expect(setLabelPreferenceMock).toHaveBeenCalledWith("did:plc:ar7c4by46qjdydhdevvrndac", "graphic-media", "warn") 89 98 ); 99 + }); 100 + 101 + it("disables adult-only overrides when adult content is off", async () => { 102 + render(() => <SettingsModeration />); 103 + 104 + const helperText = await screen.findByText("Enable adult content to edit this label."); 105 + const controls = helperText.closest("div"); 106 + const select = controls?.querySelector("select"); 107 + expect(select).toBeTruthy(); 108 + expect(select).toBeDisabled(); 90 109 }); 91 110 });
+94 -14
src/components/settings/SettingsModeration.tsx
··· 1 1 import { ModerationController } from "$/lib/api/moderation"; 2 2 import { BUILTIN_LABELER_DID } from "$/lib/moderation"; 3 - import type { ModerationLabelVisibility, StoredModerationPrefs } from "$/lib/types"; 3 + import type { 4 + ModerationLabelerPolicyDefinition, 5 + ModerationLabelPolicyDefinition, 6 + ModerationLabelVisibility, 7 + StoredModerationPrefs, 8 + } from "$/lib/types"; 4 9 import { normalizeError } from "$/lib/utils/text"; 5 10 import * as logger from "@tauri-apps/plugin-log"; 6 11 import { openUrl } from "@tauri-apps/plugin-opener"; ··· 19 24 20 25 const VISIBILITY_OPTIONS: ModerationLabelVisibility[] = ["ignore", "warn", "hide"]; 21 26 22 - function isAdultOnlyLikeLabel(label: string) { 23 - return /adult|nsfw|porn|sexual|nudity/iu.test(label); 27 + function normalizeLabelIdentifier(label: string) { 28 + return label.trim().toLowerCase(); 24 29 } 25 30 26 31 function VisibilityOptions() { ··· 59 64 const [savingAdult, setSavingAdult] = createSignal(false); 60 65 const [busyLabelerDid, setBusyLabelerDid] = createSignal<string | null>(null); 61 66 const [prefs, setPrefs] = createSignal<StoredModerationPrefs | null>(null); 67 + const [policyDefinitions, setPolicyDefinitions] = createSignal<ModerationLabelerPolicyDefinition[]>([]); 62 68 const [distributionChannel, setDistributionChannel] = createSignal("github"); 63 69 const [draft, setDraft] = createStore<DraftState>({ 64 70 addLabelerDid: "", ··· 73 79 return [BUILTIN_LABELER_DID, ...custom.filter((did) => did !== BUILTIN_LABELER_DID)]; 74 80 }); 75 81 82 + const policyDefinitionsByDid = createMemo(() => { 83 + const map = new Map<string, Map<string, ModerationLabelPolicyDefinition>>(); 84 + 85 + for (const policy of policyDefinitions()) { 86 + const byLabel = new Map<string, ModerationLabelPolicyDefinition>(); 87 + for (const definition of policy.definitions) { 88 + byLabel.set(normalizeLabelIdentifier(definition.identifier), definition); 89 + } 90 + map.set(policy.labelerDid, byLabel); 91 + } 92 + 93 + return map; 94 + }); 95 + 96 + const policyByDid = createMemo(() => { 97 + const map = new Map<string, ModerationLabelerPolicyDefinition>(); 98 + for (const policy of policyDefinitions()) { 99 + map.set(policy.labelerDid, policy); 100 + } 101 + return map; 102 + }); 103 + 76 104 onMount(() => { 77 105 void loadState(); 78 106 }); ··· 80 108 async function loadState() { 81 109 setLoading(true); 82 110 try { 83 - const [loadedPrefs, channel] = await Promise.all([ 111 + const [loadedPrefs, loadedPolicies, channel] = await Promise.all([ 84 112 ModerationController.getModerationPrefs(), 113 + ModerationController.getLabelerPolicyDefinitions(), 85 114 ModerationController.getDistributionChannel(), 86 115 ]); 87 116 setPrefs(loadedPrefs); 117 + setPolicyDefinitions(loadedPolicies); 88 118 setDistributionChannel(channel); 89 119 } catch (error) { 90 120 const message = normalizeError(error); ··· 129 159 setBusyLabelerDid(did); 130 160 try { 131 161 await ModerationController.subscribeLabeler(did); 132 - await refreshPrefs(); 162 + await refreshModerationState(); 133 163 setDraft("addLabelerDid", ""); 134 164 feedback.queueFeedback({ kind: "success", message: "Labeler added." }); 135 165 } catch (error) { ··· 149 179 setBusyLabelerDid(did); 150 180 try { 151 181 await ModerationController.unsubscribeLabeler(did); 152 - await refreshPrefs(); 182 + await refreshModerationState(); 153 183 feedback.queueFeedback({ kind: "success", message: "Labeler removed." }); 154 184 } catch (error) { 155 185 const message = normalizeError(error); ··· 202 232 return entries.toSorted(([left], [right]) => left.localeCompare(right)); 203 233 } 204 234 235 + function getPolicyDefinition(labelerDid: string, label: string) { 236 + return policyDefinitionsByDid().get(labelerDid)?.get(normalizeLabelIdentifier(label)); 237 + } 238 + 239 + function isAdultOnlyLabel(labelerDid: string, label: string) { 240 + return !!getPolicyDefinition(labelerDid, label)?.adultOnly; 241 + } 242 + 243 + function getLabelDisplayName(labelerDid: string, label: string) { 244 + const definition = getPolicyDefinition(labelerDid, label); 245 + return definition?.displayName?.trim() || label; 246 + } 247 + 248 + function getLabelerTitle(did: string) { 249 + const policy = policyByDid().get(did); 250 + return policy?.labelerDisplayName?.trim() || policy?.labelerHandle?.trim() || did; 251 + } 252 + 253 + function getLabelerSubtitle(did: string) { 254 + const policy = policyByDid().get(did); 255 + if (!policy) { 256 + return did === BUILTIN_LABELER_DID ? "Built-in Bluesky safety labeler" : null; 257 + } 258 + 259 + if (policy.labelerDisplayName?.trim() && policy.labelerHandle?.trim()) { 260 + return `@${policy.labelerHandle.trim()}`; 261 + } 262 + 263 + return null; 264 + } 265 + 205 266 function isMasBuild() { 206 267 return distributionChannel() === "mac_app_store"; 207 268 } 208 269 209 - async function refreshPrefs() { 210 - const next = await ModerationController.getModerationPrefs(); 211 - setPrefs(next); 270 + async function refreshModerationState() { 271 + const [nextPrefs, nextPolicies] = await Promise.all([ 272 + ModerationController.getModerationPrefs(), 273 + ModerationController.getLabelerPolicyDefinitions(), 274 + ]); 275 + setPrefs(nextPrefs); 276 + setPolicyDefinitions(nextPolicies); 212 277 } 213 278 214 279 return ( ··· 260 325 {(did) => ( 261 326 <div class="flex flex-wrap items-center justify-between gap-2 rounded-xl bg-black/25 px-3 py-2"> 262 327 <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> 328 + <span class="text-xs font-medium text-on-surface">{getLabelerTitle(did)}</span> 329 + <Show when={getLabelerSubtitle(did)}> 330 + {(subtitle) => <span class="text-[0.7rem] text-on-surface-variant">{subtitle()}</span>} 266 331 </Show> 267 332 </div> 268 333 <Show when={did !== BUILTIN_LABELER_DID}> ··· 319 384 fallback={<p class="m-0 text-xs text-on-surface-variant">No overrides yet.</p>}> 320 385 <For each={entries()}> 321 386 {([label, visibility]) => { 322 - const gated = !current().adultContentEnabled && isAdultOnlyLikeLabel(label); 387 + const definition = getPolicyDefinition(did, label); 388 + const gated = !current().adultContentEnabled && isAdultOnlyLabel(did, label); 389 + const displayName = getLabelDisplayName(did, label); 323 390 return ( 324 391 <div class="grid gap-1 rounded-lg bg-black/30 px-3 py-2"> 325 - <span class="text-xs text-on-surface">{label}</span> 392 + <span class="text-xs text-on-surface">{displayName}</span> 393 + <Show when={displayName !== label}> 394 + <span class="text-[0.7rem] text-on-surface-variant">Identifier: {label}</span> 395 + </Show> 396 + <Show when={definition}> 397 + {(currentDefinition) => ( 398 + <span class="text-[0.7rem] text-on-surface-variant"> 399 + {currentDefinition().severity} • {currentDefinition().blurs} 400 + <Show when={currentDefinition().defaultSetting}> 401 + {(defaultSetting) => ` • default ${defaultSetting()}`} 402 + </Show> 403 + </span> 404 + )} 405 + </Show> 326 406 <div class="flex flex-wrap items-center gap-2"> 327 407 <select 328 408 value={visibility}
+3
src/components/settings/SettingsPanel.test.tsx
··· 18 18 const setLabelPreferenceMock = vi.hoisted(() => vi.fn()); 19 19 const subscribeLabelerMock = vi.hoisted(() => vi.fn()); 20 20 const unsubscribeLabelerMock = vi.hoisted(() => vi.fn()); 21 + const getLabelerPolicyDefinitionsMock = vi.hoisted(() => vi.fn()); 21 22 const getDistributionChannelMock = vi.hoisted(() => vi.fn()); 22 23 const dialogOpenMock = vi.hoisted(() => vi.fn()); 23 24 const navigateMock = vi.hoisted(() => vi.fn()); ··· 58 59 setLabelPreference: setLabelPreferenceMock, 59 60 subscribeLabeler: subscribeLabelerMock, 60 61 unsubscribeLabeler: unsubscribeLabelerMock, 62 + getLabelerPolicyDefinitions: getLabelerPolicyDefinitionsMock, 61 63 getDistributionChannel: getDistributionChannelMock, 62 64 }, 63 65 }), ··· 147 149 setLabelPreferenceMock.mockResolvedValue(void 0); 148 150 subscribeLabelerMock.mockResolvedValue(void 0); 149 151 unsubscribeLabelerMock.mockResolvedValue(void 0); 152 + getLabelerPolicyDefinitionsMock.mockResolvedValue([]); 150 153 getDistributionChannelMock.mockResolvedValue("github"); 151 154 dialogOpenMock.mockResolvedValue(null); 152 155 });
+12 -3
src/lib/api/moderation.ts
··· 1 1 import { DEFAULT_MODERATION_DECISION } from "$/lib/moderation"; 2 2 import type { 3 3 DistributionChannel, 4 + ModerationContext, 4 5 ModerationLabel, 6 + ModerationLabelerPolicyDefinition, 5 7 ModerationLabelVisibility, 6 8 ModerationReasonType, 7 9 ModerationUiDecision, ··· 31 33 return invoke<void>("unsubscribe_labeler", { did }); 32 34 } 33 35 34 - async function moderateContent(labels: ModerationLabel[]): Promise<ModerationUiDecision> { 36 + async function getLabelerPolicyDefinitions() { 37 + return invoke<ModerationLabelerPolicyDefinition[]>("get_labeler_policy_definitions"); 38 + } 39 + 40 + async function moderateContent(labels: ModerationLabel[], context: ModerationContext): Promise<ModerationUiDecision> { 35 41 if (labels.length === 0) { 36 42 return DEFAULT_MODERATION_DECISION; 37 43 } 38 44 39 45 try { 40 - return await invoke<ModerationUiDecision>("moderate_content", { labelsJson: JSON.stringify(labels) }); 46 + return await invoke<ModerationUiDecision>("moderate_content", { labelsJson: JSON.stringify(labels), context }); 41 47 } catch (error) { 42 - logger.warn("moderation decision failed", { keyValues: { error: String(error), labels: String(labels.length) } }); 48 + logger.warn("moderation decision failed", { 49 + keyValues: { context, error: String(error), labels: String(labels.length) }, 50 + }); 43 51 return DEFAULT_MODERATION_DECISION; 44 52 } 45 53 } ··· 67 75 setLabelPreference, 68 76 subscribeLabeler, 69 77 unsubscribeLabeler, 78 + getLabelerPolicyDefinitions, 70 79 moderateContent, 71 80 createReport, 72 81 getDistributionChannel,
+31
src/lib/types.ts
··· 12 12 13 13 export type ModerationLabelVisibility = "ignore" | "warn" | "hide"; 14 14 15 + export type ModerationContext = 16 + | "contentList" 17 + | "contentView" 18 + | "contentMedia" 19 + | "avatar" 20 + | "profileList" 21 + | "profileView"; 22 + 23 + export type ModerationLabelPolicyLocale = { lang: string; name: string; description: string }; 24 + 25 + export type ModerationLabelPolicyDefinition = { 26 + identifier: string; 27 + adultOnly: boolean; 28 + defaultSetting?: string | null; 29 + severity: string; 30 + blurs: string; 31 + displayName?: string | null; 32 + description?: string | null; 33 + locales: ModerationLabelPolicyLocale[]; 34 + }; 35 + 36 + export type ModerationLabelerPolicyDefinition = { 37 + labelerDid: string; 38 + labelerHandle?: string | null; 39 + labelerDisplayName?: string | null; 40 + reasonTypes?: string[] | null; 41 + subjectTypes?: string[] | null; 42 + subjectCollections?: string[] | null; 43 + definitions: ModerationLabelPolicyDefinition[]; 44 + }; 45 + 15 46 export type StoredModerationPrefs = { 16 47 adultContentEnabled: boolean; 17 48 subscribedLabelers: string[];