Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/AddColumnModal/column-types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ const HOME_DESCRIPTOR: ColumnTypeDescriptor = {
}),
isReadyToPreview: (draft) => !!draft.viewContext,
supportsViewAs: true,
PreviewBody: () => <HomeColumnBody />,
PreviewBody: ({ column }) => <HomeColumnBody column={column} />,
previewHint: 'Pick an account to preview'
}

Expand Down
28 changes: 26 additions & 2 deletions src/components/Column/HomeColumnBody.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,41 @@
// 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
* `viewContext` (not the global active account) — any pubkey, paired or
* foreign — via the enclosing <AccountScope>. Passes that pubkey explicitly to
* <FollowingFeed> 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 <FollowingFeed pubkey={viewContext} />
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 (
<FollowingFeed
pubkey={viewContext}
initialTabId={column.config?.feedTab}
onTabChange={handleTabChange}
/>
)
}
2 changes: 1 addition & 1 deletion src/components/Column/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@ function ScopedSecondaryPage({
export function dispatchBody(column: TColumn): React.ReactNode {
switch (column.type) {
case 'home':
return <HomeColumnBody />
return <HomeColumnBody column={column} />
case 'notifications':
return <NotificationsColumnBody column={column} />
case 'articles':
Expand Down
12 changes: 11 additions & 1 deletion src/components/FollowingFeed/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <NormalFeed>). */
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
Expand Down Expand Up @@ -93,6 +101,8 @@ export default function FollowingFeed({ pubkey: pubkeyProp }: Props = {}) {
setRefreshCount((count) => count + 1)
}}
isPubkeyFeed
initialTabId={initialTabId}
onTabChange={onTabChange}
/>
)
}
22 changes: 22 additions & 0 deletions src/components/NormalFeed/__tests__/initial-tab.spec.ts
Original file line number Diff line number Diff line change
@@ -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('')
})
})
36 changes: 26 additions & 10 deletions src/components/NormalFeed/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,44 @@ 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,
subRequests,
areAlgoRelays = false,
showRelayCloseReason = false,
onRefresh,
isPubkeyFeed = false
isPubkeyFeed = false,
initialTabId,
onTabChange
}: {
feedId: string
subRequests: TFeedSubRequest[]
areAlgoRelays?: boolean
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<string | undefined>()
// 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<string | undefined>(() =>
resolveInitialTabId(initialTabId, visibleTabs)
)
const selectedTab: TFeedTabConfig = selectedTabId
? (visibleTabs.find((tab) => tab.id === selectedTabId) ?? visibleTabs[0])
: visibleTabs[0]
Expand All @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions src/components/NormalFeed/initial-tab.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
type TabLike = { id: string }

/**
* Resolve the initial selected feed tab for a <NormalFeed>.
*
* 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 ?? ''
}
10 changes: 10 additions & 0 deletions src/services/__tests__/deck-sync-codec.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'] } })] })]
Expand Down
6 changes: 6 additions & 0 deletions src/types/column.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <NormalFeed> 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 = {
Expand Down
Loading