diff --git a/src-tauri/src/commands/moderation.rs b/src-tauri/src/commands/moderation.rs index 8d7923b..c8fd325 100644 --- a/src-tauri/src/commands/moderation.rs +++ b/src-tauri/src/commands/moderation.rs @@ -1,5 +1,11 @@ use super::super::error::Result; -use super::super::moderation::{self, ModerationUI, ReportSubjectInput, StoredModerationPrefs}; +use super::super::moderation::{ + self, + ModerationLabelerPolicyDefinition, + ModerationUI, + ReportSubjectInput, + StoredModerationPrefs, +}; use super::super::state::AppState; use tauri_plugin_log::log; @@ -46,7 +52,10 @@ pub async fn unsubscribe_labeler(did: String, state: State<'_>) -> Result<()> { /// /// Returns a `ModerationUI` describing what the frontend should do with the content. #[tauri::command] -pub async fn moderate_content(labels_json: String, state: State<'_>) -> Result { +pub async fn moderate_content(labels_json: String, context: String, state: State<'_>) -> Result { + let parsed_context = moderation::parse_moderation_context(&context)?; + log::debug!("moderate_content requested for context={}", parsed_context.as_str()); + let prefs = moderation::get_prefs(&state)?; let accepted_dids = moderation::accepted_labeler_dids(&prefs); @@ -73,6 +82,12 @@ pub async fn moderate_content(labels_json: String, state: State<'_>) -> Result) -> Result> { + moderation::get_labeler_policy_definitions(&state).await +} + /// Submit a content or account report to the Bluesky moderation service. /// /// `subject` must be `{"type":"repo","did":"..."}` or `{"type":"record","uri":"...","cid":"..."}`. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e9d1998..b7234a0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -178,6 +178,7 @@ pub fn run() { cmd::moderation::subscribe_labeler, cmd::moderation::unsubscribe_labeler, cmd::moderation::moderate_content, + cmd::moderation::get_labeler_policy_definitions, cmd::moderation::create_report, cmd::moderation::get_distribution_channel ]) diff --git a/src-tauri/src/moderation.rs b/src-tauri/src/moderation.rs index fa48406..5f8c382 100644 --- a/src-tauri/src/moderation.rs +++ b/src-tauri/src/moderation.rs @@ -79,6 +79,79 @@ impl From for ModerationUI { } } +/// The moderation context requested by the frontend. +/// +/// Context is currently validated and logged, but does not yet change moderation behavior. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModerationContext { + ContentList, + ContentView, + ContentMedia, + Avatar, + ProfileList, + ProfileView, +} + +impl ModerationContext { + pub fn as_str(self) -> &'static str { + match self { + Self::ContentList => "contentList", + Self::ContentView => "contentView", + Self::ContentMedia => "contentMedia", + Self::Avatar => "avatar", + Self::ProfileList => "profileList", + Self::ProfileView => "profileView", + } + } +} + +pub fn parse_moderation_context(value: &str) -> Result { + match value.trim() { + "contentList" => Ok(ModerationContext::ContentList), + "contentView" => Ok(ModerationContext::ContentView), + "contentMedia" => Ok(ModerationContext::ContentMedia), + "avatar" => Ok(ModerationContext::Avatar), + "profileList" => Ok(ModerationContext::ProfileList), + "profileView" => Ok(ModerationContext::ProfileView), + _ => Err(AppError::validation( + "invalid moderation context; expected one of: contentList, contentView, contentMedia, avatar, profileList, profileView", + )), + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModerationLabelPolicyLocale { + pub lang: String, + pub name: String, + pub description: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModerationLabelPolicyDefinition { + pub identifier: String, + pub adult_only: bool, + pub default_setting: Option, + pub severity: String, + pub blurs: String, + pub display_name: Option, + pub description: Option, + pub locales: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModerationLabelerPolicyDefinition { + pub labeler_did: String, + pub labeler_handle: Option, + pub labeler_display_name: Option, + pub reason_types: Option>, + pub subject_types: Option>, + pub subject_collections: Option>, + pub definitions: Vec, +} + /// Input description of what to report. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", tag = "type")] @@ -289,6 +362,181 @@ pub async fn fetch_labeler_policies_from_api( .collect() } +/// Fetch detailed labeler views from the API for the given DIDs. +/// +/// Returns only detailed views and skips malformed DIDs. +pub async fn fetch_labeler_views_from_api( + session: &LazuriteOAuthSession, dids: &[String], +) -> Vec> { + if dids.is_empty() { + return Vec::new(); + } + + let parsed_dids: Vec> = dids + .iter() + .filter_map(|s| { + Did::new(s) + .map_err(|error| { + log::warn!("skipping invalid labeler DID '{s}': {error}"); + error + }) + .ok() + }) + .collect(); + + if parsed_dids.is_empty() { + return Vec::new(); + } + + let request = GetServices::new().dids(parsed_dids).detailed(true).build(); + let response = match session.send(request).await { + Ok(r) => r, + Err(error) => { + log::warn!("failed to fetch detailed labeler views: {error}"); + return Vec::new(); + } + }; + + let output = match response.into_output() { + Ok(o) => o, + Err(error) => { + log::warn!("failed to decode detailed labeler views response: {error}"); + return Vec::new(); + } + }; + + output + .views + .into_iter() + .filter_map(|view| match view { + GetServicesOutputViewsItem::LabelerViewDetailed(detailed) => Some((*detailed).into_static()), + _ => None, + }) + .collect() +} + +fn preferred_locale_strings(locales: &[ModerationLabelPolicyLocale]) -> (Option, Option) { + if locales.is_empty() { + return (None, None); + } + + if let Some(en) = locales.iter().find(|locale| locale.lang.eq_ignore_ascii_case("en")) { + return (Some(en.name.clone()), Some(en.description.clone())); + } + + if let Some(en_region) = locales + .iter() + .find(|locale| locale.lang.to_ascii_lowercase().starts_with("en-")) + { + return (Some(en_region.name.clone()), Some(en_region.description.clone())); + } + + let fallback = &locales[0]; + (Some(fallback.name.clone()), Some(fallback.description.clone())) +} + +fn normalize_label_definition(def: &LabelValueDefinition<'_>) -> ModerationLabelPolicyDefinition { + let mut locales = def + .locales + .iter() + .map(|locale| ModerationLabelPolicyLocale { + lang: locale.lang.as_ref().to_string(), + name: locale.name.as_ref().to_string(), + description: locale.description.as_ref().to_string(), + }) + .collect::>(); + locales.sort_by(|left, right| left.lang.cmp(&right.lang).then(left.name.cmp(&right.name))); + + let (display_name, description) = preferred_locale_strings(&locales); + + ModerationLabelPolicyDefinition { + identifier: def.identifier.as_ref().to_string(), + adult_only: def.adult_only.unwrap_or(false), + default_setting: def.default_setting.as_ref().map(|setting| setting.as_ref().to_string()), + severity: def.severity.as_ref().to_string(), + blurs: def.blurs.as_ref().to_string(), + display_name, + description, + locales, + } +} + +fn normalize_label_definitions(defs: &[LabelValueDefinition<'_>]) -> Vec { + let mut normalized = defs.iter().map(normalize_label_definition).collect::>(); + normalized.sort_by(|left, right| left.identifier.cmp(&right.identifier)); + normalized.dedup_by(|left, right| left.identifier == right.identifier); + normalized +} + +/// Return structured policy definitions for all accepted labelers (built-in + subscribed). +pub async fn get_labeler_policy_definitions(state: &AppState) -> Result> { + let prefs = get_prefs(state)?; + let accepted_dids = accepted_labeler_dids(&prefs); + let session = get_session(state).await?; + + let defs = build_labeler_defs(&session, state, &accepted_dids).await; + let fetched_views = fetch_labeler_views_from_api(&session, &accepted_dids).await; + let mut views_by_did: HashMap> = + HashMap::new(); + for view in fetched_views { + views_by_did.insert(view.creator.did.as_ref().to_string(), view); + } + + let mut policies = Vec::with_capacity(accepted_dids.len()); + + for did in accepted_dids { + let view = views_by_did.get(&did); + let definitions_from_view = view + .and_then(|value| value.policies.label_value_definitions.as_ref()) + .map(|definitions| normalize_label_definitions(definitions)) + .unwrap_or_default(); + + let definitions = if !definitions_from_view.is_empty() { + definitions_from_view + } else if let Ok(parsed) = Did::new(&did) { + defs.get(&parsed) + .map(normalize_label_definitions) + .unwrap_or_default() + } else { + Vec::new() + }; + + let reason_types = view.map(|value| { + value + .reason_types + .as_ref() + .map(|types| types.iter().map(|item| item.as_ref().to_string()).collect::>()) + }); + let subject_types = view.map(|value| { + value + .subject_types + .as_ref() + .map(|types| types.iter().map(|item| item.as_ref().to_string()).collect::>()) + }); + let subject_collections = view.map(|value| { + value.subject_collections.as_ref().map(|collections| { + collections + .iter() + .map(|item| item.as_ref().to_string()) + .collect::>() + }) + }); + + policies.push(ModerationLabelerPolicyDefinition { + labeler_did: did, + labeler_handle: view.map(|value| value.creator.handle.as_ref().to_string()), + labeler_display_name: view + .and_then(|value| value.creator.display_name.as_ref().map(|name| name.as_ref().to_string())), + reason_types: reason_types.flatten(), + subject_types: subject_types.flatten(), + subject_collections: subject_collections.flatten(), + definitions, + }); + } + + Ok(policies) +} + /// Build `LabelerDefs` for the given DIDs, using the local cache where available /// and fetching from the API for any missing/stale entries. /// @@ -678,6 +926,83 @@ mod tests { assert!(!channel.is_empty()); } + #[test] + fn moderation_context_validation() { + let contexts = [ + "contentList", + "contentView", + "contentMedia", + "avatar", + "profileList", + "profileView", + ]; + + for context in contexts { + let parsed = parse_moderation_context(context).expect("context should parse"); + assert_eq!(parsed.as_str(), context); + } + + let invalid = parse_moderation_context("not-a-context").expect_err("invalid context should fail"); + assert!(invalid.to_string().contains("invalid moderation context")); + } + + #[test] + fn label_policy_definition_normalization_prefers_english_locale() { + let definition: LabelValueDefinition<'static> = serde_json::from_str( + r#"{ + "identifier":"graphic-media", + "adultOnly":true, + "blurs":"media", + "defaultSetting":"warn", + "severity":"alert", + "locales":[ + {"lang":"fr","name":"Média graphique","description":"Contenu potentiellement choquant"}, + {"lang":"en-US","name":"Graphic media","description":"Potentially disturbing media"} + ] + }"#, + ) + .expect("definition should deserialize"); + + let normalized = normalize_label_definition(&definition); + assert_eq!(normalized.identifier, "graphic-media"); + assert!(normalized.adult_only); + assert_eq!(normalized.default_setting.as_deref(), Some("warn")); + assert_eq!(normalized.severity, "alert"); + assert_eq!(normalized.blurs, "media"); + assert_eq!(normalized.display_name.as_deref(), Some("Graphic media")); + assert_eq!(normalized.description.as_deref(), Some("Potentially disturbing media")); + assert_eq!(normalized.locales.len(), 2); + } + + #[test] + fn label_policy_definition_normalization_deduplicates_identifiers() { + let definitions: Vec> = serde_json::from_str( + r#"[ + { + "identifier":"spam", + "adultOnly":false, + "blurs":"none", + "defaultSetting":"ignore", + "severity":"inform", + "locales":[{"lang":"en","name":"Spam","description":"Spam content"}] + }, + { + "identifier":"spam", + "adultOnly":false, + "blurs":"content", + "defaultSetting":"warn", + "severity":"alert", + "locales":[{"lang":"en","name":"Spam duplicate","description":"Duplicate"}] + } + ]"#, + ) + .expect("definitions should deserialize"); + + let normalized = normalize_label_definitions(&definitions); + assert_eq!(normalized.len(), 1); + assert_eq!(normalized[0].identifier, "spam"); + } + #[test] fn evaluate_labels_empty_returns_no_moderation() { let prefs = StoredModerationPrefs::default(); diff --git a/src/components/deck/AddColumnPanel.tsx b/src/components/deck/AddColumnPanel.tsx index b5b68fc..df7410b 100644 --- a/src/components/deck/AddColumnPanel.tsx +++ b/src/components/deck/AddColumnPanel.tsx @@ -1,436 +1,53 @@ -// TODO: there is a lot of prop drilling in this module, and could benefit from the splitProps pattern. -import { ActorSuggestionList, useActorSuggestions } from "$/components/actors/actor-search"; -import { FeedController } from "$/lib/api/feeds"; import type { ColumnKind } from "$/lib/api/types/columns"; import type { SearchMode } from "$/lib/api/types/search"; -import { getFeedName } from "$/lib/feeds"; -import type { FeedGeneratorView, LoginSuggestion, SavedFeedItem } from "$/lib/types"; -import * as logger from "@tauri-apps/plugin-log"; -import { createEffect, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"; +import { createEffect, createSignal, For, Match, onCleanup, Show, splitProps, Switch } from "solid-js"; import { Portal } from "solid-js/web"; import { Motion, Presence } from "solid-motionone"; -import { FeedChipAvatar } from "../feeds/FeedChipAvatar"; -import { Icon, SearchModeIcon } from "../shared/Icon"; +import { Icon } from "../shared/Icon"; +import { DiagnosticsPicker, ExplorerPicker, FeedPicker, MessagesPicker } from "./ColumnPicker"; +import { ProfilePicker } from "./ColumnPicker/ProfileColumnPicker"; +import { SearchPicker } from "./ColumnPicker/SearchPicker"; +import type { FeedPickerSelection, ProfileSelection } from "./types"; type AddColumnPanelProps = { onAdd: (kind: ColumnKind, config: string) => void; onClose: () => void; open: boolean }; type PanelTab = ColumnKind; -type FeedPickerSelection = { feed: SavedFeedItem; title: string }; - -function feedKindLabel(feed: SavedFeedItem) { - switch (feed.type) { - case "timeline": { - return "Timeline"; - } - case "list": { - return "List"; - } - default: { - return "Feed"; - } - } -} - -function FeedPicker(props: { onSelect: (selection: FeedPickerSelection) => void }) { - const [feeds, setFeeds] = createSignal([]); - const [generators, setGenerators] = createSignal>({}); - const [loading, setLoading] = createSignal(true); - - onMount(async () => { - try { - const prefs = await FeedController.getPreferences(); - setFeeds(prefs.savedFeeds); - - const uris = [...new Set(prefs.savedFeeds.filter((feed) => feed.type === "feed").map((feed) => feed.value))]; - if (uris.length > 0) { - const hydrated = await FeedController.getFeedGenerators(uris); - setGenerators(Object.fromEntries(hydrated.feeds.map((generator) => [generator.uri, generator]))); - } - } catch (err) { - logger.error(`Failed to load feeds for column picker: ${String(err)}`); - } finally { - setLoading(false); - } - }); - - return ( -
- -
- - - -
-
- - -

No saved feeds found.

-
- - -

No saved feeds found.

- - }> - {(feed) => ( - - )} -
-
- ); -} - -function ExplorerPicker(props: { onSubmit: (uri: string) => void }) { - const [value, setValue] = createSignal(""); - - function handleSubmit(e: Event) { - e.preventDefault(); - const uri = value().trim(); - if (uri) { - props.onSubmit(uri); - } - } - - return ( -
- - - - - ); -} - -function DiagnosticsPicker(props: { onSubmit: (did: string) => void }) { - const [value, setValue] = createSignal(""); - - function handleSubmit(e: Event) { - e.preventDefault(); - const did = value().trim(); - if (did) { - props.onSubmit(did); - } - } - - return ( -
- - - - - ); -} - -function MessagesPicker(props: { onSubmit: () => void }) { - return ( -
-
-
- - - -
-

Direct messages

-

- Opens your DM inbox inside the deck. Message content is blurred until you hover or focus the column. -

-
-
-
- - -
- ); -} - -function SearchModeButton(props: { active: boolean; disabled?: boolean; mode: SearchMode; onClick: () => void }) { - return ( - - ); -} - -function SearchPicker(props: { onSubmit: (query: string, mode: SearchMode) => void }) { - const [mode, setMode] = createSignal("network"); - const [query, setQuery] = createSignal(""); - - function handleSubmit(event: Event) { - event.preventDefault(); - const trimmed = query().trim(); - if (!trimmed) { - return; - } - - props.onSubmit(trimmed, mode()); - } - - return ( -
- - -
- Search mode -
- setMode("network")} /> - setMode("keyword")} /> - setMode("semantic")} /> - setMode("hybrid")} /> -
-
- - - - ); -} - -function ProfilePicker( - props: { - onSubmit: ( - selection: { actor: string; did?: string | null; displayName?: string | null; handle?: string | null }, - ) => void; - }, -) { - let container: HTMLDivElement | undefined; - let input: HTMLInputElement | undefined; - const [value, setValue] = createSignal(""); - const typeahead = useActorSuggestions({ - container: () => container, - input: () => input, - onError: (error) => logger.warn(`Failed to load profile suggestions: ${String(error)}`), - value, - }); - - function submitManualActor() { - const actor = value().trim(); - if (!actor) { - return; - } - - typeahead.close(); - props.onSubmit({ actor }); - } - - function submitSuggestion(suggestion: LoginSuggestion) { - typeahead.close(); - props.onSubmit({ - actor: suggestion.handle, - did: suggestion.did, - displayName: suggestion.displayName ?? null, - handle: suggestion.handle, - }); - } - - function handleKeyDown(event: KeyboardEvent) { - if (event.key === "ArrowDown") { - event.preventDefault(); - typeahead.moveActiveIndex(1); - return; - } - - if (event.key === "ArrowUp") { - event.preventDefault(); - typeahead.moveActiveIndex(-1); - return; - } - - if (event.key === "Escape") { - typeahead.close(); - return; - } - - if (event.key === "Enter" && typeahead.open() && typeahead.activeSuggestion()) { - event.preventDefault(); - submitSuggestion(typeahead.activeSuggestion() as LoginSuggestion); - } - } - - return ( -
{ - event.preventDefault(); - submitManualActor(); - }}> - - - - - ); -} - -function TypeaheadLoading(props: { visible: boolean }) { - return ( - - - - - - ); -} - -type PanelContentProps = { - tab: PanelTab; - onFeedSelect: (selection: FeedPickerSelection) => void; - onExplorerSubmit: (uri: string) => void; +type PanelSubmissionHandlers = { onDiagnosticsSubmit: (did: string) => void; + onExplorerSubmit: (uri: string) => void; + onFeedSelect: (selection: FeedPickerSelection) => void; onMessagesSubmit: () => void; - onProfileSubmit: ( - selection: { actor: string; did?: string | null; displayName?: string | null; handle?: string | null }, - ) => void; + onProfileSubmit: (selection: ProfileSelection) => void; onSearchSubmit: (query: string, mode: SearchMode) => void; }; -function PanelContent(props: PanelContentProps) { +function PanelContent(props: { handlers: PanelSubmissionHandlers; tab: PanelTab }) { return (
- + - + - + - + - + - +
@@ -486,22 +103,18 @@ function AddColumnPanelTabs(props: AddColumnPanelTabsProps) { ); } -function AddColumnPanelBody( - props: { - activeTab: PanelTab; - onClose: () => void; - onDiagnosticsSubmit: (did: string) => void; - onExplorerSubmit: (uri: string) => void; - onFeedSelect: (selection: FeedPickerSelection) => void; - onMessagesSubmit: () => void; - onProfileSubmit: ( - selection: { actor: string; did?: string | null; displayName?: string | null; handle?: string | null }, - ) => void; - onSearchSubmit: (query: string, mode: SearchMode) => void; - onTabChange: (tab: PanelTab) => void; - tabs: Array<{ icon: string; id: PanelTab; label: string }>; - }, -) { +type AddColumnPanelFrame = { + activeTab: PanelTab; + onClose: () => void; + onTabChange: (tab: PanelTab) => void; + tabs: Array<{ icon: string; id: PanelTab; label: string }>; +}; + +type AddColumnPanelBodyProps = { frame: AddColumnPanelFrame; handlers: PanelSubmissionHandlers }; + +function AddColumnPanelBody(props: AddColumnPanelBodyProps) { + const [frameProps, contentProps] = splitProps(props, ["frame"], ["handlers"]); + return ( - - - + + + ); } export function AddColumnPanel(props: AddColumnPanelProps) { + const [panelState, panelActions] = splitProps(props, ["open"], ["onAdd", "onClose"]); const [activeTab, setActiveTab] = createSignal("feed"); function handleFeedSelect(selection: FeedPickerSelection) { @@ -535,31 +145,29 @@ export function AddColumnPanel(props: AddColumnPanelProps) { feedUri: selection.feed.value, title: selection.title, }); - props.onAdd("feed", config); + panelActions.onAdd("feed", config); } function handleExplorerSubmit(uri: string) { const config = JSON.stringify({ targetUri: uri }); - props.onAdd("explorer", config); + panelActions.onAdd("explorer", config); } function handleDiagnosticsSubmit(did: string) { const config = JSON.stringify({ did }); - props.onAdd("diagnostics", config); + panelActions.onAdd("diagnostics", config); } function handleMessagesSubmit() { - props.onAdd("messages", JSON.stringify({})); + panelActions.onAdd("messages", JSON.stringify({})); } function handleSearchSubmit(query: string, mode: SearchMode) { - props.onAdd("search", JSON.stringify({ mode, query })); + panelActions.onAdd("search", JSON.stringify({ mode, query })); } - function handleProfileSubmit( - selection: { actor: string; did?: string | null; displayName?: string | null; handle?: string | null }, - ) { - props.onAdd("profile", JSON.stringify(selection)); + function handleProfileSubmit(selection: ProfileSelection) { + panelActions.onAdd("profile", JSON.stringify(selection)); } // TODO: use IconKind for Icon @@ -572,25 +180,25 @@ export function AddColumnPanel(props: AddColumnPanelProps) { { icon: "i-ri-user-3-line", id: "profile", label: "Profile" }, ]; + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + panelActions.onClose(); + } + } + createEffect(() => { - if (!props.open) { + if (!panelState.open) { setActiveTab("feed"); return; } - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - props.onClose(); - } - }; - globalThis.addEventListener("keydown", handleKeyDown); onCleanup(() => globalThis.removeEventListener("keydown", handleKeyDown)); }); return ( - +
props.onClose()} /> + onClick={() => panelActions.onClose()} /> + frame={{ activeTab: activeTab(), tabs, onClose: panelActions.onClose, onTabChange: setActiveTab }} + handlers={{ + onDiagnosticsSubmit: handleDiagnosticsSubmit, + onExplorerSubmit: handleExplorerSubmit, + onFeedSelect: handleFeedSelect, + onMessagesSubmit: handleMessagesSubmit, + onProfileSubmit: handleProfileSubmit, + onSearchSubmit: handleSearchSubmit, + }} />
diff --git a/src/components/deck/ColumnPicker.tsx b/src/components/deck/ColumnPicker.tsx new file mode 100644 index 0000000..b505969 --- /dev/null +++ b/src/components/deck/ColumnPicker.tsx @@ -0,0 +1,187 @@ +import { FeedController } from "$/lib/api/feeds"; +import { getFeedName } from "$/lib/feeds"; +import type { FeedGeneratorView, SavedFeedItem } from "$/lib/types"; +import * as logger from "@tauri-apps/plugin-log"; +import { createSignal, For, onMount, Show } from "solid-js"; +import { FeedChipAvatar } from "../feeds/FeedChipAvatar"; +import type { FeedPickerSelection } from "./types"; + +function feedKindLabel(feed: SavedFeedItem) { + switch (feed.type) { + case "timeline": { + return "Timeline"; + } + case "list": { + return "List"; + } + default: { + return "Feed"; + } + } +} + +export function FeedPicker(props: { onSelect: (selection: FeedPickerSelection) => void }) { + const [feeds, setFeeds] = createSignal([]); + const [generators, setGenerators] = createSignal>({}); + const [loading, setLoading] = createSignal(true); + + onMount(async () => { + try { + const prefs = await FeedController.getPreferences(); + setFeeds(prefs.savedFeeds); + + const uris = [...new Set(prefs.savedFeeds.filter((feed) => feed.type === "feed").map((feed) => feed.value))]; + if (uris.length > 0) { + const hydrated = await FeedController.getFeedGenerators(uris); + setGenerators(Object.fromEntries(hydrated.feeds.map((generator) => [generator.uri, generator]))); + } + } catch (err) { + logger.error(`Failed to load feeds for column picker: ${String(err)}`); + } finally { + setLoading(false); + } + }); + + return ( +
+ +
+ + + +
+
+ + +

No saved feeds found.

+
+ + +

No saved feeds found.

+ + }> + {(feed) => ( + + )} +
+
+ ); +} + +export function ExplorerPicker(props: { onSubmit: (uri: string) => void }) { + const [value, setValue] = createSignal(""); + + function handleSubmit(e: Event) { + e.preventDefault(); + const uri = value().trim(); + if (uri) { + props.onSubmit(uri); + } + } + + return ( +
+ + + + + ); +} + +export function DiagnosticsPicker(props: { onSubmit: (did: string) => void }) { + const [value, setValue] = createSignal(""); + + function handleSubmit(e: Event) { + e.preventDefault(); + const did = value().trim(); + if (did) { + props.onSubmit(did); + } + } + + return ( +
+ + + + + ); +} + +export function MessagesPicker(props: { onSubmit: () => void }) { + return ( +
+
+
+ + + +
+

Direct messages

+

+ Opens your DM inbox inside the deck. Message content is blurred until you hover or focus the column. +

+
+
+
+ + +
+ ); +} diff --git a/src/components/deck/ColumnPicker/ProfileColumnPicker.tsx b/src/components/deck/ColumnPicker/ProfileColumnPicker.tsx new file mode 100644 index 0000000..26e3dda --- /dev/null +++ b/src/components/deck/ColumnPicker/ProfileColumnPicker.tsx @@ -0,0 +1,129 @@ +import { ActorSuggestionList, useActorSuggestions } from "$/components/actors/actor-search"; +import { Icon } from "$/components/shared/Icon"; +import type { LoginSuggestion } from "$/lib/types"; +import * as logger from "@tauri-apps/plugin-log"; +import { createSignal, Show } from "solid-js"; +import type { ProfileSelection } from "../types"; + +function TypeaheadLoading(props: { visible: boolean }) { + return ( + + + + + + ); +} + +export function ProfilePicker(props: { onSubmit: (selection: ProfileSelection) => void }) { + let container: HTMLDivElement | undefined; + let input: HTMLInputElement | undefined; + const [value, setValue] = createSignal(""); + const typeahead = useActorSuggestions({ + container: () => container, + input: () => input, + onError: (error) => logger.warn(`Failed to load profile suggestions: ${String(error)}`), + value, + }); + + function submitManualActor() { + const actor = value().trim(); + if (!actor) { + return; + } + + typeahead.close(); + props.onSubmit({ actor }); + } + + function submitSuggestion(suggestion: LoginSuggestion) { + typeahead.close(); + props.onSubmit({ + actor: suggestion.handle, + did: suggestion.did, + displayName: suggestion.displayName ?? null, + handle: suggestion.handle, + }); + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "ArrowDown") { + event.preventDefault(); + typeahead.moveActiveIndex(1); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + typeahead.moveActiveIndex(-1); + return; + } + + if (event.key === "Escape") { + typeahead.close(); + return; + } + + if (event.key === "Enter" && typeahead.open() && typeahead.activeSuggestion()) { + event.preventDefault(); + submitSuggestion(typeahead.activeSuggestion() as LoginSuggestion); + } + } + + return ( +
{ + event.preventDefault(); + submitManualActor(); + }}> + + + + + ); +} diff --git a/src/components/deck/ColumnPicker/SearchPicker.tsx b/src/components/deck/ColumnPicker/SearchPicker.tsx new file mode 100644 index 0000000..0ea746a --- /dev/null +++ b/src/components/deck/ColumnPicker/SearchPicker.tsx @@ -0,0 +1,69 @@ +import { SearchModeIcon } from "$/components/shared/Icon"; +import type { SearchMode } from "$/lib/api/types/search"; +import { createSignal } from "solid-js"; + +function SearchModeButton(props: { active: boolean; disabled?: boolean; mode: SearchMode; onClick: () => void }) { + return ( + + ); +} + +export function SearchPicker(props: { onSubmit: (query: string, mode: SearchMode) => void }) { + const [mode, setMode] = createSignal("network"); + const [query, setQuery] = createSignal(""); + + function handleSubmit(event: Event) { + event.preventDefault(); + const trimmed = query().trim(); + if (!trimmed) { + return; + } + + props.onSubmit(trimmed, mode()); + } + + return ( +
+ + +
+ Search mode +
+ setMode("network")} /> + setMode("keyword")} /> + setMode("semantic")} /> + setMode("hybrid")} /> +
+
+ + + + ); +} diff --git a/src/components/deck/AddColumnPanel.test.tsx b/src/components/deck/tests/AddColumnPanel.test.tsx similarity index 98% rename from src/components/deck/AddColumnPanel.test.tsx rename to src/components/deck/tests/AddColumnPanel.test.tsx index 1c45154..e5d52d8 100644 --- a/src/components/deck/AddColumnPanel.test.tsx +++ b/src/components/deck/tests/AddColumnPanel.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { AddColumnPanel } from "./AddColumnPanel"; +import { AddColumnPanel } from "../AddColumnPanel"; const getFeedGeneratorsMock = vi.hoisted(() => vi.fn()); const getPreferencesMock = vi.hoisted(() => vi.fn()); diff --git a/src/components/deck/DiagnosticsPanel.test.tsx b/src/components/deck/tests/DiagnosticsPanel.test.tsx similarity index 99% rename from src/components/deck/DiagnosticsPanel.test.tsx rename to src/components/deck/tests/DiagnosticsPanel.test.tsx index fa0a74f..dc88fbe 100644 --- a/src/components/deck/DiagnosticsPanel.test.tsx +++ b/src/components/deck/tests/DiagnosticsPanel.test.tsx @@ -1,7 +1,7 @@ import { AppTestProviders } from "$/test/providers"; import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { DiagnosticsPanel } from "./DiagnosticsPanel"; +import { DiagnosticsPanel } from "../DiagnosticsPanel"; const getAccountListsMock = vi.hoisted(() => vi.fn()); const getAccountLabelsMock = vi.hoisted(() => vi.fn()); diff --git a/src/components/deck/types.test.ts b/src/components/deck/tests/types.test.ts similarity index 95% rename from src/components/deck/types.test.ts rename to src/components/deck/tests/types.test.ts index 6e55c96..c2f3da1 100644 --- a/src/components/deck/types.test.ts +++ b/src/components/deck/tests/types.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { parseFeedConfig, resolveFeedColumn } from "./types"; +import { parseFeedConfig, resolveFeedColumn } from "../types"; describe("deck feed column helpers", () => { it("rejects malformed feed configs", () => { diff --git a/src/components/deck/types.ts b/src/components/deck/types.ts index 553e53b..bdc8489 100644 --- a/src/components/deck/types.ts +++ b/src/components/deck/types.ts @@ -155,3 +155,12 @@ export function columnTitle(kind: string, config: string): string { } } } + +export type FeedPickerSelection = { feed: SavedFeedItem; title: string }; + +export type ProfileSelection = { + actor: string; + did?: string | null; + displayName?: string | null; + handle?: string | null; +}; diff --git a/src/components/feeds/PostCard.tsx b/src/components/feeds/PostCard.tsx index 4902005..871428f 100644 --- a/src/components/feeds/PostCard.tsx +++ b/src/components/feeds/PostCard.tsx @@ -29,7 +29,7 @@ import type { } from "$/lib/types"; import { formatCount, formatHandle, normalizeError } from "$/lib/utils/text"; import * as logger from "@tauri-apps/plugin-log"; -import { createMemo, createSignal, type ParentProps, Show } from "solid-js"; +import { createMemo, createSignal, type ParentProps, Show, splitProps } from "solid-js"; import { Motion } from "solid-motionone"; import { EmbedContent } from "./embeds/ContentEmbed"; import type { ReportTarget } from "./types"; @@ -117,69 +117,80 @@ function PostActionButton(props: PostActionButtonProps) { ); } -// FIXME: this is an absurdly large number of props -type PostActionsProps = { +type PostActionStatus = { bookmarkPending: boolean; isBookmarked: boolean; isLiked: boolean; isReposted: boolean; likeCount: string; likePending: boolean; - menuOpen: boolean; pulseLike: boolean; pulseRepost: boolean; replyCount: string; repostCount: string; repostPending: boolean; - triggerRef: (element: HTMLButtonElement) => void; +}; + +type PostActionHandlers = { onBookmark?: () => void; onLike?: () => void; - onMenuOpen: (element: HTMLButtonElement) => void; onOpenThread?: () => void; onQuote?: () => void; onReply?: () => void; onRepost?: () => void; }; +type PostActionsProps = { + handlers: PostActionHandlers; + menu: { + open: boolean; + onOpen: (element: HTMLButtonElement) => void; + triggerRef: (element: HTMLButtonElement) => void; + }; + state: PostActionStatus; +}; + function PostActions(props: PostActionsProps) { + const [status, menu, actions] = splitProps(props, ["state"], ["menu"], ["handlers"]); + return (