diff --git a/src/components/AddColumnModal/column-types.tsx b/src/components/AddColumnModal/column-types.tsx index 5003cbe..9fb0793 100644 --- a/src/components/AddColumnModal/column-types.tsx +++ b/src/components/AddColumnModal/column-types.tsx @@ -99,7 +99,7 @@ const HOME_DESCRIPTOR: ColumnTypeDescriptor = { }), isReadyToPreview: (draft) => !!draft.viewContext, supportsViewAs: true, - PreviewBody: () => , + PreviewBody: ({ column }) => , previewHint: 'Pick an account to preview' } diff --git a/src/components/Column/HomeColumnBody.tsx b/src/components/Column/HomeColumnBody.tsx index a2ae07c..2dcc8e8 100644 --- a/src/components/Column/HomeColumnBody.tsx +++ b/src/components/Column/HomeColumnBody.tsx @@ -1,6 +1,9 @@ // src/components/Column/HomeColumnBody.tsx import FollowingFeed from '@/components/FollowingFeed' import { useAccountScope } from '@/providers/AccountScope' +import { useColumns } from '@/providers/ColumnsProvider' +import { TColumn } from '@/types/column' +import { useCallback } from 'react' /** * Body of a Home column. Subscribes to the following feed of the column's @@ -8,10 +11,31 @@ import { useAccountScope } from '@/providers/AccountScope' * foreign — via the enclosing . Passes that pubkey explicitly to * so it ignores the global useNostr() pubkey. * + * Also owns the per-column "Notes vs Notes-and-replies" tab preference: seeds + * the feed from `column.config.feedTab` and persists the user's choice back via + * updateColumnConfig (localStorage immediately; rides the deck's NIP-78 sync on + * the next deck save). Absent config → 'posts' (Notes), the app default. + * * Lives as its own file because Phase 2 will customize per-column toolbars and * Phase 3 may swap the underlying feed component for a column-specific variant. */ -export default function HomeColumnBody() { +export default function HomeColumnBody({ column }: { column: TColumn }) { const { viewContext } = useAccountScope() - return + const { updateColumnConfig } = useColumns() + const handleTabChange = useCallback( + (tabId: string) => { + // Only the two known feed tabs reach here; cast narrows string → the + // config union. An unknown value would be ignored by resolveInitialTabId + // on next mount anyway. + updateColumnConfig(column.id, { feedTab: tabId as 'posts' | 'postsAndReplies' }) + }, + [column.id, updateColumnConfig] + ) + return ( + + ) } diff --git a/src/components/Column/index.tsx b/src/components/Column/index.tsx index 16e6afc..c7f06e6 100644 --- a/src/components/Column/index.tsx +++ b/src/components/Column/index.tsx @@ -388,7 +388,7 @@ function ScopedSecondaryPage({ export function dispatchBody(column: TColumn): React.ReactNode { switch (column.type) { case 'home': - return + return case 'notifications': return case 'articles': diff --git a/src/components/FollowingFeed/index.tsx b/src/components/FollowingFeed/index.tsx index 6f6eb53..64b123f 100644 --- a/src/components/FollowingFeed/index.tsx +++ b/src/components/FollowingFeed/index.tsx @@ -15,9 +15,17 @@ import { useTranslation } from 'react-i18next' type Props = { /** Override pubkey for column-scoped use. If absent, falls back to the global active account. */ pubkey?: string + /** Persisted feed tab to open on (forwarded to ). */ + initialTabId?: string + /** Called when the user switches feed tab, so the column can persist it. */ + onTabChange?: (tabId: string) => void } -export default function FollowingFeed({ pubkey: pubkeyProp }: Props = {}) { +export default function FollowingFeed({ + pubkey: pubkeyProp, + initialTabId, + onTabChange +}: Props = {}) { const { t } = useTranslation() const { pubkey: activePubkey } = useNostr() const pubkey = pubkeyProp ?? activePubkey @@ -93,6 +101,8 @@ export default function FollowingFeed({ pubkey: pubkeyProp }: Props = {}) { setRefreshCount((count) => count + 1) }} isPubkeyFeed + initialTabId={initialTabId} + onTabChange={onTabChange} /> ) } diff --git a/src/components/NormalFeed/__tests__/initial-tab.spec.ts b/src/components/NormalFeed/__tests__/initial-tab.spec.ts new file mode 100644 index 0000000..2469db8 --- /dev/null +++ b/src/components/NormalFeed/__tests__/initial-tab.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { resolveInitialTabId } from '../initial-tab' + +const TABS = [{ id: 'posts' }, { id: 'postsAndReplies' }] + +describe('resolveInitialTabId', () => { + it('falls back to the first tab when no preference is persisted', () => { + expect(resolveInitialTabId(undefined, TABS)).toBe('posts') + }) + + it('honors a persisted tab that matches a visible tab', () => { + expect(resolveInitialTabId('postsAndReplies', TABS)).toBe('postsAndReplies') + }) + + it('ignores a stale/unknown persisted tab and falls back to the first', () => { + expect(resolveInitialTabId('media', TABS)).toBe('posts') + }) + + it('returns empty string when there are no tabs (defensive)', () => { + expect(resolveInitialTabId('posts', [])).toBe('') + }) +}) diff --git a/src/components/NormalFeed/index.tsx b/src/components/NormalFeed/index.tsx index 4628836..de08a2a 100644 --- a/src/components/NormalFeed/index.tsx +++ b/src/components/NormalFeed/index.tsx @@ -7,6 +7,7 @@ import { TFeedSubRequest, TFeedTabConfig } from '@/types' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import KindFilter from '../KindFilter' import { RefreshButton } from '../RefreshButton' +import { resolveInitialTabId } from './initial-tab' export default function NormalFeed({ feedId, @@ -14,7 +15,9 @@ export default function NormalFeed({ areAlgoRelays = false, showRelayCloseReason = false, onRefresh, - isPubkeyFeed = false + isPubkeyFeed = false, + initialTabId, + onTabChange }: { feedId: string subRequests: TFeedSubRequest[] @@ -22,17 +25,26 @@ export default function NormalFeed({ showRelayCloseReason?: boolean onRefresh?: () => void isPubkeyFeed?: boolean + /** Tab to open on (a DEFAULT_FEED_TABS id). Read once at mount. Absent → the + * first visible tab ('Notes'). The Home column passes its persisted + * column.config.feedTab here. */ + initialTabId?: string + /** Called with the new tab id when the user switches tabs, so the caller can + * persist the choice (e.g. into column.config.feedTab). */ + onTabChange?: (tabId: string) => void }) { const { getShowKinds } = useKindFilter() const feedShowKinds = useMemo(() => getShowKinds(feedId), [getShowKinds, feedId]) const [temporaryShowKinds, setTemporaryShowKinds] = useState(feedShowKinds) - const visibleTabs = useMemo( - () => DEFAULT_FEED_TABS.filter((tab) => !tab.hidden), - [] - ) + const visibleTabs = useMemo(() => DEFAULT_FEED_TABS.filter((tab) => !tab.hidden), []) - const [selectedTabId, setSelectedTabId] = useState() + // Seed from the caller's persisted tab once at mount; an absent/stale value + // falls back to the first visible tab. After mount this component owns the + // selection (re-seeding on every render would fight the user's clicks). + const [selectedTabId, setSelectedTabId] = useState(() => + resolveInitialTabId(initialTabId, visibleTabs) + ) const selectedTab: TFeedTabConfig = selectedTabId ? (visibleTabs.find((tab) => tab.id === selectedTabId) ?? visibleTabs[0]) : visibleTabs[0] @@ -57,10 +69,14 @@ export default function NormalFeed({ setTemporaryShowKinds(feedShowKinds) }, [feedShowKinds]) - const handleListModeChange = useCallback((mode: string) => { - setSelectedTabId(mode) - topRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) - }, []) + const handleListModeChange = useCallback( + (mode: string) => { + setSelectedTabId(mode) + onTabChange?.(mode) + topRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }, + [onTabChange] + ) const handleShowKindsChange = useCallback((newShowKinds: number[]) => { setTemporaryShowKinds(newShowKinds) diff --git a/src/components/NormalFeed/initial-tab.ts b/src/components/NormalFeed/initial-tab.ts new file mode 100644 index 0000000..c122204 --- /dev/null +++ b/src/components/NormalFeed/initial-tab.ts @@ -0,0 +1,15 @@ +type TabLike = { id: string } + +/** + * Resolve the initial selected feed tab for a . + * + * Uses the column's persisted `configTab` (e.g. `column.config.feedTab`) when it + * matches a currently-visible tab; otherwise falls back to the first visible tab + * — today's "Notes" (replies-hidden) default. Tolerates an unknown/stale + * persisted value (e.g. a tab that was later removed or renamed) by falling + * back rather than selecting nothing. + */ +export function resolveInitialTabId(configTab: string | undefined, tabs: TabLike[]): string { + if (configTab && tabs.some((tab) => tab.id === configTab)) return configTab + return tabs[0]?.id ?? '' +} diff --git a/src/services/__tests__/deck-sync-codec.spec.ts b/src/services/__tests__/deck-sync-codec.spec.ts index 342a7dd..83a3278 100644 --- a/src/services/__tests__/deck-sync-codec.spec.ts +++ b/src/services/__tests__/deck-sync-codec.spec.ts @@ -80,6 +80,16 @@ describe('deck-sync-codec', () => { expect(decodeWorkspace(bad)).toEqual({ ok: false, reason: 'shape' }) }) + it('round-trips a persisted feedTab on a home column config', () => { + const s = workspace({ + decks: [deck({ savedColumns: [col({ config: { feedTab: 'postsAndReplies' } })] })] + }) + const decoded = decodeWorkspace(encodeWorkspace(s)) + expect(decoded.ok).toBe(true) + if (!decoded.ok) return + expect(decoded.workspace.decks[0].columns[0].config?.feedTab).toBe('postsAndReplies') + }) + it('decode isolates columns and savedColumns config objects', () => { const s = workspace({ decks: [deck({ savedColumns: [col({ id: 'a', type: 'hashtag', config: { hashtags: ['x'] } })] })] diff --git a/src/types/column.d.ts b/src/types/column.d.ts index a136359..03ccf9b 100644 --- a/src/types/column.d.ts +++ b/src/types/column.d.ts @@ -81,6 +81,12 @@ export type TColumnConfig = { * 2-hop WoT (your follows + their follows). Per-column binary filter * replacing the prior trustScoreThreshold int. Default false. */ wotOnly?: boolean + /** 'home': which tab this column opens on, persisted per-column + * so the choice survives reloads and rides the deck's NIP-78 sync. Matches a + * DEFAULT_FEED_TABS id. Absent → opens on 'posts' (Notes, replies hidden), + * today's default. An unknown/stale value falls back to 'posts' + * (see resolveInitialTabId). Only the Home column persists this today. */ + feedTab?: 'posts' | 'postsAndReplies' } export type TColumn = {