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.

refactor: reorganize helpers & types

* more icon stuff

+221 -241
+5 -13
src/components/deck/ColumnPicker.tsx
··· 4 4 import * as logger from "@tauri-apps/plugin-log"; 5 5 import { createSignal, For, onMount, Show } from "solid-js"; 6 6 import { FeedChipAvatar } from "../feeds/FeedChipAvatar"; 7 - import { LoadingIcon } from "../shared/Icon"; 7 + import { Icon, LoadingIcon } from "../shared/Icon"; 8 8 import type { FeedPickerSelection } from "./types"; 9 9 10 10 function feedKindLabel(feed: SavedFeedItem) { ··· 110 110 type="submit" 111 111 disabled={!value().trim()} 112 112 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"> 113 - <span class="flex items-center"> 114 - <i class="i-ri-compass-discover-line" /> 115 - </span> 113 + <Icon kind="explore" /> 116 114 Open in column 117 115 </button> 118 116 </form> ··· 146 144 type="submit" 147 145 disabled={!value().trim()} 148 146 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"> 149 - <span class="flex items-center"> 150 - <i class="i-ri-stethoscope-line" /> 151 - </span> 147 + <Icon kind="diagnostics" /> 152 148 Open diagnostics 153 149 </button> 154 150 </form> ··· 160 156 <div class="grid gap-4"> 161 157 <div class="rounded-2xl bg-surface-container-high p-4 shadow-(--inset-shadow)"> 162 158 <div class="flex items-start gap-3"> 163 - <span class="mt-0.5 flex items-center text-primary"> 164 - <i class="i-ri-message-3-line" /> 165 - </span> 159 + <Icon kind="messages" class="text-primary mt-0.5" /> 166 160 <div class="grid gap-1.5"> 167 161 <p class="m-0 text-sm font-medium text-on-surface">Direct messages</p> 168 162 <p class="m-0 text-xs leading-relaxed text-on-surface-variant"> ··· 176 170 type="button" 177 171 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" 178 172 onClick={() => props.onSubmit()}> 179 - <span class="flex items-center"> 180 - <i class="i-ri-layout-column-line" /> 181 - </span> 173 + <Icon kind="deck" /> 182 174 Add DM column 183 175 </button> 184 176 </div>
+2 -3
src/components/deck/ColumnPicker/ProfileColumnPicker.tsx
··· 1 1 import { ActorSuggestionList, useActorSuggestions } from "$/components/actors/ActorSearch"; 2 2 import { ActorTypeaheadLoading } from "$/components/actors/ActorTypeaheadLoading"; 3 3 import { useActorTypeaheadCombobox } from "$/components/actors/hooks/useActorTypeaheadCombobox"; 4 + import { Icon } from "$/components/shared/Icon"; 4 5 import type { ActorSuggestion } from "$/lib/types"; 5 6 import * as logger from "@tauri-apps/plugin-log"; 6 7 import { createSignal } from "solid-js"; ··· 89 90 type="submit" 90 91 disabled={!value().trim()} 91 92 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"> 92 - <span class="flex items-center"> 93 - <i class="i-ri-user-3-line" /> 94 - </span> 93 + <Icon kind="profile" /> 95 94 Open profile 96 95 </button> 97 96 </form>
+2 -4
src/components/deck/ColumnPicker/SearchPicker.tsx
··· 1 - import { SearchModeIcon } from "$/components/shared/Icon"; 1 + import { Icon, SearchModeIcon } from "$/components/shared/Icon"; 2 2 import type { SearchMode } from "$/lib/api/types/search"; 3 3 import { createSignal } from "solid-js"; 4 4 ··· 60 60 type="submit" 61 61 disabled={!query().trim()} 62 62 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"> 63 - <span class="flex items-center"> 64 - <i class="i-ri-search-line" /> 65 - </span> 63 + <Icon kind="search" /> 66 64 Open search column 67 65 </button> 68 66 </form>
+3 -10
src/components/deck/DeckColumn.tsx
··· 9 9 import { createSignal, Match, Show, Switch } from "solid-js"; 10 10 import { ArrowIcon, Icon } from "../shared/Icon"; 11 11 import { DiagnosticsColumn } from "./DiagnosticsColumn"; 12 - import { 13 - COLUMN_WIDTH_PX, 14 - columnTitle, 15 - cycleWidth, 16 - parseDiagnosticsConfig, 17 - parseProfileConfig, 18 - parseSearchConfig, 19 - type ResolvedFeedColumn, 20 - } from "./types"; 12 + import { cycleWidth, parseColumnTitle, parseDiagnosticsConfig, parseProfileConfig, parseSearchConfig } from "./helpers"; 13 + import { COLUMN_WIDTH_PX, type ResolvedFeedColumn } from "./types"; 21 14 import { useFeedColumnState } from "./useFeedColumnState"; 22 15 23 16 type DeckColumnProps = { ··· 273 266 274 267 export function DeckColumn(props: DeckColumnProps) { 275 268 const [resizingWidth, setResizingWidth] = createSignal<number | null>(null); 276 - const title = () => props.feedColumn?.title ?? columnTitle(props.column.kind, props.column.config); 269 + const title = () => props.feedColumn?.title ?? parseColumnTitle(props.column.kind, props.column.config); 277 270 const widthPx = () => resizingWidth() ?? COLUMN_WIDTH_PX[props.column.width]; 278 271 279 272 function handleDragStart(e: DragEvent) {
+5 -8
src/components/deck/DeckWorkspace.tsx
··· 9 9 import { createEffect, For, onCleanup, onMount, Show } from "solid-js"; 10 10 import { createStore, produce } from "solid-js/store"; 11 11 import { Motion } from "solid-motionone"; 12 - import { ActionIcon, LoadingIcon } from "../shared/Icon"; 12 + import { ActionIcon, Icon, LoadingIcon } from "../shared/Icon"; 13 13 import { AddColumnPanel } from "./AddColumnPanel"; 14 14 import { DeckColumn } from "./DeckColumn"; 15 - import { parseFeedConfig, type ResolvedFeedColumn, resolveFeedColumn } from "./types"; 15 + import { parseFeedConfig, resolveFeedColumn } from "./helpers"; 16 + import { type ResolvedFeedColumn } from "./types"; 16 17 17 18 type DeckState = { 18 19 addPanelOpen: boolean; ··· 38 39 aria-label="Add column (Ctrl+Shift+N)" 39 40 title="Add column (Ctrl+Shift+N)" 40 41 onClick={() => props.onAdd()}> 41 - <span class="flex items-center"> 42 - <i class="i-ri-add-line" /> 43 - </span> 42 + <ActionIcon kind="add" /> 44 43 Add column 45 44 </button> 46 45 </div> ··· 50 49 function EmptyDeck(props: { onAdd: () => void }) { 51 50 return ( 52 51 <div class="flex h-full min-h-104 flex-col items-center justify-center gap-4 rounded-[1.75rem] bg-surface-container px-6 text-center shadow-(--inset-shadow)"> 53 - <span class="flex items-center text-[2.5rem] text-on-surface-variant opacity-30"> 54 - <i class="i-ri-layout-column-line" /> 55 - </span> 52 + <Icon kind="deck" class="text-[1.75rem] text-on-surface-variant opacity-30" /> 56 53 <div> 57 54 <p class="m-0 text-sm font-medium text-on-surface">No columns yet</p> 58 55 <p class="m-0 mt-1 text-xs text-on-surface-variant">
+144
src/components/deck/helpers.ts
··· 1 + import { 2 + type ColumnWidth, 3 + type DiagnosticsColumnConfig, 4 + type ExplorerColumnConfig, 5 + type FeedColumnConfig, 6 + isFeedType, 7 + type ProfileColumnConfig, 8 + type SearchColumnConfig, 9 + } from "$/lib/api/types/columns"; 10 + import { getFeedName } from "$/lib/feeds"; 11 + import type { FeedGeneratorView, SavedFeedItem } from "$/lib/types"; 12 + import type { ResolvedFeedColumn } from "./types"; 13 + 14 + function feedConfigToSavedFeedItem(config: FeedColumnConfig): SavedFeedItem { 15 + return { 16 + id: config.feedUri || "following", 17 + pinned: false, 18 + type: config.feedType, 19 + value: config.feedUri || "following", 20 + }; 21 + } 22 + 23 + function parseExplorerConfig(config: string): ExplorerColumnConfig | null { 24 + try { 25 + const parsed = JSON.parse(config) as unknown; 26 + if (parsed && typeof parsed === "object" && "targetUri" in parsed) { 27 + return parsed as ExplorerColumnConfig; 28 + } 29 + return null; 30 + } catch { 31 + return null; 32 + } 33 + } 34 + 35 + export function cycleWidth(current: ColumnWidth): ColumnWidth { 36 + switch (current) { 37 + case "narrow": { 38 + return "standard"; 39 + } 40 + case "standard": { 41 + return "wide"; 42 + } 43 + case "wide": { 44 + return "narrow"; 45 + } 46 + } 47 + } 48 + 49 + export function parseFeedConfig(config: string): FeedColumnConfig | null { 50 + try { 51 + const parsed = JSON.parse(config) as Record<string, unknown>; 52 + if (!parsed || typeof parsed !== "object") { 53 + return null; 54 + } 55 + 56 + if (!isFeedType(parsed.feedType) || typeof parsed.feedUri !== "string") { 57 + return null; 58 + } 59 + 60 + if (parsed.title !== undefined && parsed.title !== null && typeof parsed.title !== "string") { 61 + return null; 62 + } 63 + 64 + return { feedType: parsed.feedType, feedUri: parsed.feedUri, title: parsed.title as string | null | undefined }; 65 + } catch { 66 + return null; 67 + } 68 + } 69 + 70 + export function parseDiagnosticsConfig(config: string): DiagnosticsColumnConfig | null { 71 + try { 72 + const parsed = JSON.parse(config) as unknown; 73 + if (parsed && typeof parsed === "object" && "did" in parsed) { 74 + return parsed as DiagnosticsColumnConfig; 75 + } 76 + return null; 77 + } catch { 78 + return null; 79 + } 80 + } 81 + 82 + export function parseSearchConfig(config: string): SearchColumnConfig | null { 83 + try { 84 + const parsed = JSON.parse(config) as unknown; 85 + if (parsed && typeof parsed === "object" && "mode" in parsed && "query" in parsed) { 86 + return parsed as SearchColumnConfig; 87 + } 88 + return null; 89 + } catch { 90 + return null; 91 + } 92 + } 93 + 94 + export function parseProfileConfig(config: string): ProfileColumnConfig | null { 95 + try { 96 + const parsed = JSON.parse(config) as unknown; 97 + if (parsed && typeof parsed === "object" && "actor" in parsed) { 98 + return parsed as ProfileColumnConfig; 99 + } 100 + return null; 101 + } catch { 102 + return null; 103 + } 104 + } 105 + 106 + export function parseColumnTitle(kind: string, config: string): string { 107 + switch (kind) { 108 + case "feed": { 109 + return "Feed"; 110 + } 111 + case "explorer": { 112 + const parsed = parseExplorerConfig(config); 113 + if (!parsed?.targetUri) return "Explorer"; 114 + return parsed.targetUri.length > 30 ? `${parsed.targetUri.slice(0, 30)}…` : parsed.targetUri; 115 + } 116 + case "diagnostics": { 117 + const parsed = parseDiagnosticsConfig(config); 118 + return parsed?.did ?? "Diagnostics"; 119 + } 120 + case "messages": { 121 + return "Messages"; 122 + } 123 + case "search": { 124 + const parsed = parseSearchConfig(config); 125 + const query = parsed?.query.trim(); 126 + return query ? `Search: ${query}` : "Search"; 127 + } 128 + case "profile": { 129 + const parsed = parseProfileConfig(config); 130 + return parsed?.displayName?.trim() || parsed?.handle?.trim() || parsed?.actor || "Profile"; 131 + } 132 + default: { 133 + return "Column"; 134 + } 135 + } 136 + } 137 + 138 + type Opts = { generator?: FeedGeneratorView; savedFeedTitle?: string | null }; 139 + 140 + export function resolveFeedColumn(config: FeedColumnConfig, options: Opts = {}): ResolvedFeedColumn { 141 + const feed = feedConfigToSavedFeedItem(config); 142 + const hydratedTitle = options.generator?.displayName || config.title?.trim() || options.savedFeedTitle?.trim(); 143 + return { config, feed, generator: options.generator, title: getFeedName(feed, hydratedTitle) }; 144 + }
+1 -1
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 "../helpers"; 3 3 4 4 describe("deck feed column helpers", () => { 5 5 it("rejects malformed feed configs", () => {
+1 -147
src/components/deck/types.ts
··· 1 - import type { 2 - ColumnWidth, 3 - DiagnosticsColumnConfig, 4 - ExplorerColumnConfig, 5 - FeedColumnConfig, 6 - ProfileColumnConfig, 7 - SearchColumnConfig, 8 - } from "$/lib/api/types/columns"; 9 - import { getFeedName } from "$/lib/feeds"; 1 + import type { ColumnWidth, FeedColumnConfig } from "$/lib/api/types/columns"; 10 2 import type { FeedGeneratorView, SavedFeedItem } from "$/lib/types"; 11 3 12 4 export const COLUMN_WIDTH_PX: Record<ColumnWidth, number> = { narrow: 320, standard: 420, wide: 560 }; ··· 17 9 generator?: FeedGeneratorView; 18 10 title: string; 19 11 }; 20 - 21 - export function cycleWidth(current: ColumnWidth): ColumnWidth { 22 - switch (current) { 23 - case "narrow": { 24 - return "standard"; 25 - } 26 - case "standard": { 27 - return "wide"; 28 - } 29 - case "wide": { 30 - return "narrow"; 31 - } 32 - } 33 - } 34 - 35 - function isFeedType(value: unknown): value is FeedColumnConfig["feedType"] { 36 - return value === "timeline" || value === "feed" || value === "list"; 37 - } 38 - 39 - export function parseFeedConfig(config: string): FeedColumnConfig | null { 40 - try { 41 - const parsed = JSON.parse(config) as Record<string, unknown>; 42 - if (!parsed || typeof parsed !== "object") { 43 - return null; 44 - } 45 - 46 - if (!isFeedType(parsed.feedType) || typeof parsed.feedUri !== "string") { 47 - return null; 48 - } 49 - 50 - if (parsed.title !== undefined && parsed.title !== null && typeof parsed.title !== "string") { 51 - return null; 52 - } 53 - 54 - return { feedType: parsed.feedType, feedUri: parsed.feedUri, title: parsed.title as string | null | undefined }; 55 - } catch { 56 - return null; 57 - } 58 - } 59 - 60 - function parseExplorerConfig(config: string): ExplorerColumnConfig | null { 61 - try { 62 - const parsed = JSON.parse(config) as unknown; 63 - if (parsed && typeof parsed === "object" && "targetUri" in parsed) { 64 - return parsed as ExplorerColumnConfig; 65 - } 66 - return null; 67 - } catch { 68 - return null; 69 - } 70 - } 71 - 72 - export function parseDiagnosticsConfig(config: string): DiagnosticsColumnConfig | null { 73 - try { 74 - const parsed = JSON.parse(config) as unknown; 75 - if (parsed && typeof parsed === "object" && "did" in parsed) { 76 - return parsed as DiagnosticsColumnConfig; 77 - } 78 - return null; 79 - } catch { 80 - return null; 81 - } 82 - } 83 - 84 - export function parseSearchConfig(config: string): SearchColumnConfig | null { 85 - try { 86 - const parsed = JSON.parse(config) as unknown; 87 - if (parsed && typeof parsed === "object" && "mode" in parsed && "query" in parsed) { 88 - return parsed as SearchColumnConfig; 89 - } 90 - return null; 91 - } catch { 92 - return null; 93 - } 94 - } 95 - 96 - export function parseProfileConfig(config: string): ProfileColumnConfig | null { 97 - try { 98 - const parsed = JSON.parse(config) as unknown; 99 - if (parsed && typeof parsed === "object" && "actor" in parsed) { 100 - return parsed as ProfileColumnConfig; 101 - } 102 - return null; 103 - } catch { 104 - return null; 105 - } 106 - } 107 - 108 - function feedConfigToSavedFeedItem(config: FeedColumnConfig): SavedFeedItem { 109 - return { 110 - id: config.feedUri || "following", 111 - pinned: false, 112 - type: config.feedType, 113 - value: config.feedUri || "following", 114 - }; 115 - } 116 - 117 - export function resolveFeedColumn( 118 - config: FeedColumnConfig, 119 - options: { generator?: FeedGeneratorView; savedFeedTitle?: string | null } = {}, 120 - ): ResolvedFeedColumn { 121 - const feed = feedConfigToSavedFeedItem(config); 122 - const hydratedTitle = options.generator?.displayName || config.title?.trim() || options.savedFeedTitle?.trim(); 123 - 124 - return { config, feed, generator: options.generator, title: getFeedName(feed, hydratedTitle) }; 125 - } 126 - 127 - export function columnTitle(kind: string, config: string): string { 128 - switch (kind) { 129 - case "feed": { 130 - return "Feed"; 131 - } 132 - case "explorer": { 133 - const parsed = parseExplorerConfig(config); 134 - if (!parsed?.targetUri) return "Explorer"; 135 - return parsed.targetUri.length > 30 ? `${parsed.targetUri.slice(0, 30)}…` : parsed.targetUri; 136 - } 137 - case "diagnostics": { 138 - const parsed = parseDiagnosticsConfig(config); 139 - return parsed?.did ?? "Diagnostics"; 140 - } 141 - case "messages": { 142 - return "Messages"; 143 - } 144 - case "search": { 145 - const parsed = parseSearchConfig(config); 146 - const query = parsed?.query.trim(); 147 - return query ? `Search: ${query}` : "Search"; 148 - } 149 - case "profile": { 150 - const parsed = parseProfileConfig(config); 151 - return parsed?.displayName?.trim() || parsed?.handle?.trim() || parsed?.actor || "Profile"; 152 - } 153 - default: { 154 - return "Column"; 155 - } 156 - } 157 - } 158 12 159 13 export type FeedPickerSelection = { feed: SavedFeedItem; title: string }; 160 14
+3 -3
src/components/explorer/ExplorerUrlBar.tsx
··· 1 1 import { ActorSuggestionList, useActorSuggestions } from "$/components/actors/ActorSearch"; 2 2 import { ActorTypeaheadLoading } from "$/components/actors/ActorTypeaheadLoading"; 3 3 import { useActorTypeaheadCombobox } from "$/components/actors/hooks/useActorTypeaheadCombobox"; 4 - import { ArrowIcon, Icon } from "$/components/shared/Icon"; 4 + import { ArrowIcon, Icon, LoadingIcon } from "$/components/shared/Icon"; 5 5 import type { ActorSuggestion } from "$/lib/types"; 6 6 import { createEffect, createSignal } from "solid-js"; 7 7 ··· 149 149 class="rounded-lg p-2 text-on-surface-variant transition-all hover:bg-surface-bright hover:text-on-surface disabled:cursor-not-allowed disabled:opacity-30" 150 150 aria-label="Clear icon cache" 151 151 title="Clear icon cache"> 152 - <Icon iconClass={props.clearingIconCache ? "i-ri-loader-4-line" : "i-ri-delete-bin-6-line"} /> 152 + <LoadingIcon isLoading={props.clearingIconCache} fallback={<Icon iconClass="i-ri-delete-bin-6-line" />} /> 153 153 </button> 154 154 155 155 <button ··· 158 158 class="rounded-lg p-2 text-on-surface-variant transition-all hover:bg-surface-bright hover:text-on-surface disabled:cursor-not-allowed disabled:opacity-30" 159 159 aria-label="Download CAR" 160 160 title="Download CAR"> 161 - <Icon iconClass="i-ri-download-2-line" /> 161 + <Icon kind="download" /> 162 162 </button> 163 163 </div> 164 164 </header>
+6 -6
src/components/feeds/FeedChipAvatar.tsx
··· 1 - import { Icon } from "$/components/shared/Icon"; 1 + import { Icon, type IconKind } from "$/components/shared/Icon"; 2 2 import type { FeedGeneratorView, SavedFeedItem } from "$/lib/types"; 3 3 import { createMemo, Show } from "solid-js"; 4 4 5 5 export function FeedChipAvatar(props: { feed: SavedFeedItem; generator?: FeedGeneratorView }) { 6 - const icon = createMemo(() => { 6 + const icon = createMemo<IconKind>(() => { 7 7 switch (props.feed.type) { 8 8 case "list": { 9 - return "i-ri-list-check-2"; 9 + return "check"; 10 10 } 11 11 case "timeline": { 12 - return "i-ri-home-5-line"; 12 + return "timeline"; 13 13 } 14 14 default: { 15 - return "i-ri-rss-line"; 15 + return "rss"; 16 16 } 17 17 } 18 18 }); ··· 22 22 when={props.generator?.avatar} 23 23 fallback={ 24 24 <div class="flex h-8 w-8 items-center justify-center rounded-full bg-white/6 text-primary"> 25 - <Icon aria-hidden iconClass={icon()} /> 25 + <Icon aria-hidden kind={icon()} /> 26 26 </div> 27 27 }> 28 28 {(avatar) => <img class="h-8 w-8 rounded-full object-cover" src={avatar()} alt="" />}
-7
src/components/feeds/PostCard.tsx
··· 300 300 labels={props.contentLabels} 301 301 post={props.post} 302 302 text={props.text} /> 303 - 304 303 <Show when={props.post.embed}> 305 304 {(current) => ( 306 305 <ModeratedBlurOverlay decision={props.mediaDecision} labels={props.mediaLabels} class="mt-4"> ··· 634 633 authorHandle={authorHandle()} 635 634 authorHref={profileHref()} 636 635 createdAt={createdAt()} /> 637 - 638 636 <ModerationBadgeRow decision={authorDecision()} labels={authorLabels()} /> 639 - 640 637 <ModerationBadgeRow decision={contentDecision()} labels={contentLabels()} /> 641 - 642 638 <PostModeratedContent 643 639 contentDecision={contentDecision()} 644 640 contentLabels={contentLabels()} ··· 651 647 post={view.post} 652 648 text={postText()} /> 653 649 </PostPrimaryRegion> 654 - 655 650 <Show when={view.showActions !== false}> 656 651 <PostActions 657 652 handlers={{ ··· 717 712 open={menuOpen()} 718 713 returnFocusTo={menuTriggerRef} 719 714 onClose={closeContextMenu} /> 720 - 721 715 <ContextMenu 722 716 anchor={repostMenuAnchor()} 723 717 items={repostMenuItems()} ··· 725 719 open={repostMenuOpen()} 726 720 returnFocusTo={repostMenuTriggerRef} 727 721 onClose={closeRepostMenu} /> 728 - 729 722 <ReportDialog 730 723 open={reportOpen()} 731 724 subjectLabel={reportTarget()?.subjectLabel ?? "Report content"}
+1 -1
src/components/feeds/embeds/VideoEmbed.tsx
··· 191 191 <LoadingIcon 192 192 isLoading={downloadPending()} 193 193 class="text-base" 194 - fallback={<Icon iconClass="i-ri-download-2-line" class="text-base" />} /> 194 + fallback={<Icon kind="download" class="text-base" />} /> 195 195 <span> 196 196 {downloadPending() ? (progressLabel() ? `Saving ${progressLabel()}` : "Saving...") : "Download"} 197 197 </span>
+35 -35
src/components/profile/ProfileHero.tsx
··· 10 10 import { formatCount } from "$/lib/utils/text"; 11 11 import { createMemo, For, Show } from "solid-js"; 12 12 13 - function ProfileHeroActions( 14 - props: { 15 - badges: string[]; 16 - followLoading: boolean; 17 - isFollowing: boolean; 18 - isSelf: boolean; 19 - moderationDecision: ModerationUiDecision; 20 - moderationLabels: ModerationLabel[]; 21 - onFollow: () => void; 22 - onMessage: () => void; 23 - onOpenFollowHygiene: () => void; 24 - onUnfollow: () => void; 25 - }, 26 - ) { 13 + type ProfileHeroActionProps = { 14 + badges: string[]; 15 + followLoading: boolean; 16 + isFollowing: boolean; 17 + isSelf: boolean; 18 + moderationDecision: ModerationUiDecision; 19 + moderationLabels: ModerationLabel[]; 20 + onFollow: () => void; 21 + onMessage: () => void; 22 + onOpenFollowHygiene: () => void; 23 + onUnfollow: () => void; 24 + }; 25 + 26 + function ProfileHeroActions(props: ProfileHeroActionProps) { 27 27 return ( 28 28 <div class="flex flex-col items-end gap-2"> 29 29 <Show ··· 42 42 class="tone-muted inline-flex min-h-9 items-center gap-2 rounded-full border ui-outline-subtle px-4 text-sm font-medium text-on-surface shadow-(--inset-shadow) transition duration-150 ease-out hover:bg-surface-bright" 43 43 type="button" 44 44 onClick={() => props.onOpenFollowHygiene()}> 45 - <Icon iconClass="i-ri-user-search-line" class="text-base" /> 45 + <Icon kind="user-search" class="text-base" /> 46 46 Audit follows 47 47 </button> 48 48 </Show> ··· 156 156 </Show> 157 157 158 158 <span class="inline-flex items-center gap-2"> 159 - <Icon iconClass="i-ri-at-line" class="text-base" /> 159 + <Icon class="text-base" kind="at" /> 160 160 <span class="max-w-full break-all">{props.did}</span> 161 161 </span> 162 162 ··· 213 213 ); 214 214 } 215 215 216 - export function ProfileHero( 217 - props: { 218 - coverOffset: number; 219 - followLoading: boolean; 220 - isSelf: boolean; 221 - joinedLabel: string | null; 222 - onFollow: () => void; 223 - onMessage: () => void; 224 - onOpenFollowHygiene: () => void; 225 - onOpenFollowers: () => void; 226 - onOpenFollows: () => void; 227 - onUnfollow: () => void; 228 - pinnedPostHref: string | null; 229 - profile: ProfileViewDetailed; 230 - profileBadges: string[]; 231 - rootRef?: (element: HTMLElement) => void; 232 - viewLabel: string; 233 - }, 234 - ) { 216 + type ProfileHeroProps = { 217 + coverOffset: number; 218 + followLoading: boolean; 219 + isSelf: boolean; 220 + joinedLabel: string | null; 221 + onFollow: () => void; 222 + onMessage: () => void; 223 + onOpenFollowHygiene: () => void; 224 + onOpenFollowers: () => void; 225 + onOpenFollows: () => void; 226 + onUnfollow: () => void; 227 + pinnedPostHref: string | null; 228 + profile: ProfileViewDetailed; 229 + profileBadges: string[]; 230 + rootRef?: (element: HTMLElement) => void; 231 + viewLabel: string; 232 + }; 233 + 234 + export function ProfileHero(props: ProfileHeroProps) { 235 235 const displayName = createMemo(() => getDisplayName(props.profile)); 236 236 const isFollowing = createMemo(() => !!props.profile.viewer?.following); 237 237 const bannerStyle = createMemo(() => ({ transform: `translate3d(0, ${props.coverOffset}px, 0)` }));
+1 -1
src/components/settings/SettingsAccount.tsx
··· 110 110 type="button" 111 111 onClick={() => props.onOpenFollowHygiene()} 112 112 class="ui-control ui-control-hoverable inline-flex min-h-9 items-center justify-center gap-2 rounded-full px-4 text-sm font-medium text-on-surface"> 113 - <Icon iconClass="i-ri-user-search-line" class="text-base" /> 113 + <Icon kind="user-search" class="text-base" /> 114 114 Audit follows 115 115 </button> 116 116 </div>
+5 -1
src/components/shared/Icon.tsx
··· 65 65 | "stethoscope" 66 66 | "check" 67 67 | "radar" 68 - | "unfollow"; 68 + | "unfollow" 69 + | "user-search"; 69 70 70 71 type IconProps = JSX.HTMLAttributes<HTMLSpanElement> & { 71 72 class?: string; ··· 199 200 </Match> 200 201 <Match when={local.kind === "unfollow"}> 201 202 <i class="i-ri-user-unfollow-line" /> 203 + </Match> 204 + <Match when={local.kind === "user-search"}> 205 + <i class="i-ri-user-search-line" /> 202 206 </Match> 203 207 </Switch> 204 208 </span>
+7 -1
src/lib/api/types/columns.ts
··· 14 14 createdAt: string; 15 15 }; 16 16 17 - export type FeedColumnConfig = { feedUri: string; feedType: "timeline" | "feed" | "list"; title?: string | null }; 17 + export type FeedKind = "timeline" | "feed" | "list"; 18 + 19 + export function isFeedType(value: unknown): value is FeedKind { 20 + return value === "timeline" || value === "feed" || value === "list"; 21 + } 22 + 23 + export type FeedColumnConfig = { feedUri: string; feedType: FeedKind; title?: string | null }; 18 24 19 25 export type ExplorerColumnConfig = { targetUri: string }; 20 26