diff --git a/src/components/account/tabs/GeneralTab.tsx b/src/components/account/tabs/GeneralTab.tsx index 307118c..f0255df 100644 --- a/src/components/account/tabs/GeneralTab.tsx +++ b/src/components/account/tabs/GeneralTab.tsx @@ -109,28 +109,28 @@ function AppearanceCard() { update((p) => ({ ...p, minimalProfile: next }))} /> - update((p) => ({ ...p, minimalPostPreview: next }))} - /> - update((p) => ({ ...p, hideRichPreview: next }))} /> + update((p) => ({ ...p, hideRichJsonPreview: next }))} + /> + + updatePrefs((p) => ({ ...p, hideRichPreview: !p.hideRichPreview })); + const toggleJsonPreview = () => + updatePrefs((p) => + p.hideRichJsonPreview + ? { ...p, hideRichJsonPreview: false } + : { ...p, hideRichJsonPreview: true, showRawRecordJson: true }, + ); + const toggleRawJson = () => + updatePrefs((p) => + p.showRawRecordJson + ? { ...p, showRawRecordJson: false, hideRichJsonPreview: false } + : { ...p, showRawRecordJson: true }, + ); + // Universal link uses the canonical `/profile/` path; bare-form // `///` still works as a fallback route but // shareable copies should point at the canonical one. const aturiUniversalPath = `/profile/${identity.handle || identity.did}/${collection}/${encodeURIComponent(decodedRkey)}`; const universalLinkFull = `https://aturi.to${aturiUniversalPath}`; - // The generic RecordPreview has a footer slot for the Edit button; for - // post/margin previews we keep a standalone Edit chip above the copy row. - const editsInPreviewFooter = previewRendersGeneric(collection); + // Edit affordance is a standalone chip rendered beneath the view sections — + // the sections are independently toggleable, so the button can't live inside + // any one of them (it would vanish when that section is hidden). const editButton = canEdit && !editing ? ( ); } diff --git a/src/components/explore/RichRecordCard.tsx b/src/components/explore/RichRecordCard.tsx new file mode 100644 index 0000000..3274f39 --- /dev/null +++ b/src/components/explore/RichRecordCard.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import PostPreview from '@/components/PostPreview'; +import { + MarginAnnotationPreview, + MarginBookmarkPreview, + MarginCollectionItemPreview, + MarginCollectionPreview, + MarginHighlightPreview, + MarginLikePreview, + MarginReplyPreview, +} from '@/components/margin'; +import { getMarginLexiconType } from '@/utils/marginLexicons'; +import { + fetchPostThread, + type GenericRecord, + type PostThread, +} from '@/utils/recordFetcher'; +import type { AtRecord } from '@/utils/atproto/pdsClient'; + +type Props = { + handle: string; + did: string; + collection: string; + rkey: string; + /** PDS-fetched record; the source for the margin previews. */ + record: AtRecord | null; +}; + +/** + * Whether a record type has a "rich preview" card — a rendered view distinct + * from the structured field table (RecordPreview). Posts get the Bluesky post + * card; the at.margin.* lexicons get their bespoke cards. Generic records have + * no such card: their rich view *is* the structured field table, so the + * explorer skips the card section (and its toggle) entirely for them. + */ +export function recordHasRichCard(collection: string): boolean { + if (collection === 'app.bsky.feed.post') return true; + if (getMarginLexiconType(collection)) return true; + return false; +} + +/** + * Renders ONLY the rich preview card for a record — the Bluesky post card for + * posts (via the AppView thread, for author/embeds/counts), and the bespoke + * at.margin.* cards for margin lexicons. Returns null for generic records (no + * card) and while a post's thread is still loading. + * + * The structured field table (RecordPreview) and the raw JSON are separate, + * independently-toggleable sections that RecordExplorer renders alongside this + * one — keeping each as its own "section with a toggle underneath". + */ +export default function RichRecordCard({ + handle, + did, + collection, + rkey, + record, +}: Props) { + const marginType = getMarginLexiconType(collection); + const isPost = collection === 'app.bsky.feed.post'; + + const [postThread, setPostThread] = useState(null); + + useEffect(() => { + if (!isPost) { + setPostThread(null); + return undefined; + } + let cancelled = false; + const atUri = `at://${did}/${collection}/${rkey}`; + fetchPostThread(atUri).then((thread) => { + if (!cancelled) setPostThread(thread); + }); + return () => { + cancelled = true; + }; + }, [isPost, did, collection, rkey]); + + const recordForLegacy: GenericRecord | null = record + ? { uri: record.uri, cid: record.cid, value: record.value } + : null; + + if (isPost) { + const post = postThread?.thread[0]?.value.post; + if (!post) return null; + return ; + } + + if (!recordForLegacy) return null; + + // Margin lexicons get their own renderers — close visual parity with the + // universal link page so users get the same affordances. + if (marginType === 'at.margin.annotation') { + return ; + } + if (marginType === 'at.margin.bookmark') { + return ; + } + if (marginType === 'at.margin.highlight') { + return ; + } + if (marginType === 'at.margin.collection') { + return ; + } + if (marginType === 'at.margin.collectionItem') { + return ; + } + if (marginType === 'at.margin.reply') { + return ; + } + if (marginType === 'at.margin.like') { + return ; + } + + return null; +} diff --git a/src/components/explore/RichRecordPreview.tsx b/src/components/explore/RichRecordPreview.tsx deleted file mode 100644 index 07512a4..0000000 --- a/src/components/explore/RichRecordPreview.tsx +++ /dev/null @@ -1,179 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import PostPreview from '@/components/PostPreview'; -import RecordPreview from '@/components/RecordPreview'; -import { - MarginAnnotationPreview, - MarginBookmarkPreview, - MarginCollectionItemPreview, - MarginCollectionPreview, - MarginHighlightPreview, - MarginLikePreview, - MarginReplyPreview, -} from '@/components/margin'; -import { getMarginLexiconType } from '@/utils/marginLexicons'; -import { - fetchPostThread, - type GenericRecord, - type PostThread, -} from '@/utils/recordFetcher'; -import type { AtRecord } from '@/utils/atproto/pdsClient'; - -type Props = { - handle: string; - did: string; - collection: string; - rkey: string; - /** PDS-fetched record; used as the source for non-post / margin previews. */ - record: AtRecord | null; - /** - * Optional action UI (e.g. the explorer's Edit button) rendered inside - * the generic RecordPreview's footer next to the CID. Ignored by the - * specialised post / margin previews — those have their own surrounds - * and the explorer renders the action above them instead. - */ - footerActions?: import('react').ReactNode; - /** - * When true (posts only), collapse the rich Bluesky post card and show - * just the record's structured data — the user's "minimal post view" - * preference, mirroring the minimal profile view on repo pages. - */ - minimalPost?: boolean; -}; - -/** - * Returns true when the given collection will render through the generic - * RecordPreview fallback (i.e. not a post, not a margin lexicon). The - * explorer uses this to decide whether its edit button gets slotted into - * the preview's footer or rendered as a standalone chip above the card. - */ -export function previewRendersGeneric(collection: string): boolean { - if (collection === 'app.bsky.feed.post') return false; - if (getMarginLexiconType(collection)) return false; - return true; -} - -/** - * Renders the same rich record preview that universal link pages show — - * PostPreview for Bluesky posts, the specialised margin previews for the - * at.margin.* lexicons, and the generic RecordPreview otherwise. Returns - * null when there's nothing to render yet (e.g. still fetching). - * - * Posts need a second fetch (AppView's getPostThread for author info, - * embeds, engagement counts) because the PDS-only record we already have - * is just the bare record value. - */ -export default function RichRecordPreview({ - handle, - did, - collection, - rkey, - record, - footerActions, - minimalPost = false, -}: Props) { - const marginType = getMarginLexiconType(collection); - const isPost = collection === 'app.bsky.feed.post'; - - const [postThread, setPostThread] = useState(null); - - useEffect(() => { - // Minimal view doesn't render the post card, so skip the AppView fetch. - if (!isPost || minimalPost) { - setPostThread(null); - return undefined; - } - let cancelled = false; - const atUri = `at://${did}/${collection}/${rkey}`; - fetchPostThread(atUri).then((thread) => { - if (!cancelled) setPostThread(thread); - }); - return () => { - cancelled = true; - }; - }, [isPost, minimalPost, did, collection, rkey]); - - const recordForLegacy: GenericRecord | null = record - ? { - uri: record.uri, - cid: record.cid, - value: record.value, - } - : null; - - // Posts: prefer the AppView thread (renders embeds, author, counts) for - // the rich preview, then surface the underlying record's structured - // fields beneath it — the same "rich JSON" view every other record type - // gets through RecordPreview. Without this a post's actual record data - // (text, facets, langs, reply refs, embed) is only reachable in the - // collapsed "Raw record JSON" disclosure at the bottom of the page. - if (isPost) { - // Minimal view: drop the rich post card, keep just the structured - // record data (the same RecordPreview shown beneath the full preview). - if (minimalPost) { - return recordForLegacy ? ( - - ) : null; - } - const post = postThread?.thread[0]?.value.post; - if (!post) return null; - return ( -
- - {recordForLegacy && ( - - )} -
- ); - } - - if (!recordForLegacy) return null; - - // Margin lexicons get their own renderers — close visual parity with the - // universal link page so users get the same affordances. - if (marginType === 'at.margin.annotation') { - return ; - } - if (marginType === 'at.margin.bookmark') { - return ; - } - if (marginType === 'at.margin.highlight') { - return ; - } - if (marginType === 'at.margin.collection') { - return ; - } - if (marginType === 'at.margin.collectionItem') { - return ; - } - if (marginType === 'at.margin.reply') { - return ; - } - if (marginType === 'at.margin.like') { - return ; - } - - return ( - - ); -} diff --git a/src/utils/atproto/preferencesPds.ts b/src/utils/atproto/preferencesPds.ts index 1adbcdf..57d2f8a 100644 --- a/src/utils/atproto/preferencesPds.ts +++ b/src/utils/atproto/preferencesPds.ts @@ -24,8 +24,9 @@ * hideRelationshipBar?: boolean // hide the explorer relationship strip * hideRepoGlance?: boolean // hide the "Repo at a glance" section * repoGlanceCollapsedByDefault?: boolean // start that section collapsed - * minimalProfile?: boolean // minimal profile on repo pages - * hideRichPreview?: boolean // collapse the rich preview on record pages + * minimalProfile?: boolean // collapse the rich profile card on repo pages + * hideRichPreview?: boolean // collapse the rich preview card on record pages + * hideRichJsonPreview?: boolean // collapse the structured field table on record pages * showRawRecordJson?: boolean // show raw record JSON on record pages * updatedAt: datetime * @@ -104,6 +105,7 @@ export async function writePreferencesToPds( repoGlanceCollapsedByDefault: prefs.repoGlanceCollapsedByDefault, minimalProfile: prefs.minimalProfile, hideRichPreview: prefs.hideRichPreview, + hideRichJsonPreview: prefs.hideRichJsonPreview, showRawRecordJson: prefs.showRawRecordJson, // Legacy fields kept for back-compat with older clients. hiddenWaypoints: prefs.hiddenWaypoints, diff --git a/src/utils/preferences.ts b/src/utils/preferences.ts index 4f591ce..7d6c1fe 100644 --- a/src/utils/preferences.ts +++ b/src/utils/preferences.ts @@ -135,22 +135,32 @@ export type Preferences = { */ minimalProfile: boolean; /** - * Show a minimal post on explorer record pages — just the record's - * structured data — instead of the rich Bluesky post card. The page also - * offers an inline switch to flip back to the full preview. + * @deprecated Folded into `hideRichPreview` — both mean "collapse the rich + * card on a record page, leaving the structured field view". Read once on + * migration to seed `hideRichPreview`; no longer written by the UI. */ minimalPostPreview: boolean; /** - * Collapse the rich preview card on explorer record pages. Paired with - * `showRawRecordJson` this lets a visitor read the record as raw JSON - * instead of (or alongside) the rich rendering. Flipped by the inline - * "Hide rich preview" switch beneath the preview. + * Collapse the rich preview *card* on explorer record pages — the Bluesky + * post card or an at.margin.* card. The structured field table and raw JSON + * are separate sections with their own toggles, so hiding the card just + * drops the rendering, not the record's data. Flipped by the inline + * "Hide rich preview" switch beneath the card. */ hideRichPreview: boolean; + /** + * Collapse the rich JSON preview (the structured field table) on explorer + * record pages. Constrained against `showRawRecordJson`: the field table and + * the raw JSON are the record's two data views, and at least one always stays + * visible — hiding this forces raw JSON on. Flipped by the inline + * "Hide rich JSON preview" switch beneath the table. + */ + hideRichJsonPreview: boolean; /** * Show the raw (linkified) record JSON on explorer record pages. Replaces * the old always-present "Raw record JSON" disclosure with a persisted - * toggle, surfaced by the inline "Show raw JSON" switch. + * toggle, surfaced by the inline "Show raw JSON" switch. See + * `hideRichJsonPreview` for the "at least one data view" constraint. */ showRawRecordJson: boolean; /** @@ -179,6 +189,7 @@ export const DEFAULT_PREFERENCES: Preferences = { minimalProfile: false, minimalPostPreview: false, hideRichPreview: false, + hideRichJsonPreview: false, showRawRecordJson: false, updatedAt: new Date(0).toISOString(), }; @@ -281,8 +292,15 @@ export function mergeWithDefaults(input: Partial | null | undefined typeof input.minimalProfile === 'boolean' ? input.minimalProfile : false; const minimalPostPreview = typeof input.minimalPostPreview === 'boolean' ? input.minimalPostPreview : false; + // `hideRichPreview` superseded the post-only `minimalPostPreview`; when a + // stored blob predates it, carry the old value over so the user's choice to + // collapse the post card survives the rename. const hideRichPreview = - typeof input.hideRichPreview === 'boolean' ? input.hideRichPreview : false; + typeof input.hideRichPreview === 'boolean' + ? input.hideRichPreview + : minimalPostPreview; + const hideRichJsonPreview = + typeof input.hideRichJsonPreview === 'boolean' ? input.hideRichJsonPreview : false; const showRawRecordJson = typeof input.showRawRecordJson === 'boolean' ? input.showRawRecordJson : false; return { @@ -301,6 +319,7 @@ export function mergeWithDefaults(input: Partial | null | undefined minimalProfile, minimalPostPreview, hideRichPreview, + hideRichJsonPreview, showRawRecordJson, updatedAt: typeof input.updatedAt === 'string' ? input.updatedAt : new Date(0).toISOString(), @@ -372,6 +391,7 @@ export function preferencesAreEqual(a: Preferences, b: Preferences): boolean { a.minimalProfile === b.minimalProfile && a.minimalPostPreview === b.minimalPostPreview && a.hideRichPreview === b.hideRichPreview && + a.hideRichJsonPreview === b.hideRichJsonPreview && a.showRawRecordJson === b.showRawRecordJson && JSON.stringify(a.waypointGroups) === JSON.stringify(b.waypointGroups) && JSON.stringify(a.customWaypoints) === JSON.stringify(b.customWaypoints) &&