diff --git a/src/components/PostPreview.tsx b/src/components/PostPreview.tsx
index 954c162..629ab8d 100644
--- a/src/components/PostPreview.tsx
+++ b/src/components/PostPreview.tsx
@@ -11,7 +11,7 @@ import { BskyPost } from '@/utils/recordFetcher';
import { getEmbedImages, type EmbedDisplayImage } from '@/utils/postEmbeds';
import { formatCount } from '@/utils/ufos/format';
import { sanitizeFacetLink, sanitizeDid, sanitizeHashtag, sanitizeUrl, sanitizeHandle } from '@/utils/sanitize';
-import { User, MessageSquare, Repeat2, Heart, Quote, Play, CornerDownRight, Telescope } from 'lucide-react';
+import { User, MessageSquare, Repeat2, Heart, Quote, Play, CornerDownRight, Telescope, Globe } from 'lucide-react';
import { explorePathFromAtUri } from '@/utils/atproto/urls';
type PostPreviewProps = {
@@ -1296,6 +1296,39 @@ export default function PostPreview({ post, parent, hideExplorerCtas }: PostPrev
);
})()}
+
+ {/* Inverse of the universal link page's "View record data in the
+ Explorer →" CTA: inside the explorer, a quiet link back out to the
+ shareable universal link page for this post. Mirrors the explorer
+ profile card's "View the universal link page →". */}
+ {hideExplorerCtas && (() => {
+ const postUrl = buildPostUrl(post.uri, author);
+ if (!postUrl) return null;
+ return (
+
+
+
+ View the universal link page →
+
+
+ );
+ })()}
>
);
diff --git a/src/components/account/tabs/GeneralTab.tsx b/src/components/account/tabs/GeneralTab.tsx
index 3386796..4c37a2b 100644
--- a/src/components/account/tabs/GeneralTab.tsx
+++ b/src/components/account/tabs/GeneralTab.tsx
@@ -114,6 +114,14 @@ function AppearanceCard() {
checked={prefs.minimalProfile}
onChange={(next) => update((p) => ({ ...p, minimalProfile: next }))}
/>
+
+ update((p) => ({ ...p, minimalPostPreview: next }))}
+ />
);
}
diff --git a/src/components/explore/LinkButton.tsx b/src/components/explore/LinkButton.tsx
new file mode 100644
index 0000000..697a4fe
--- /dev/null
+++ b/src/components/explore/LinkButton.tsx
@@ -0,0 +1,79 @@
+'use client';
+
+import Link from 'next/link';
+import { ExternalLink } from 'lucide-react';
+
+type Props = {
+ href: string;
+ label: string;
+ /** External target (opens in a new tab) vs internal Next navigation. */
+ external?: boolean;
+ title?: string;
+};
+
+/**
+ * An outbound-link button styled to match CopyButton (variant="subtle",
+ * compact) so the explorer's copy row reads as one consistent set of
+ * pill buttons. The external-link icon signals "navigates away", paired
+ * with the copy buttons' copy icon — which is why the row no longer needs
+ * a leading "Copy" label to explain itself.
+ */
+export default function LinkButton({ href, label, external, title }: Props) {
+ const style: React.CSSProperties = {
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: '0.4rem',
+ padding: '0.25rem 0.5rem',
+ background: 'transparent',
+ border: '1px solid var(--border-subtle)',
+ color: 'var(--text-secondary)',
+ fontFamily: 'var(--font-mono)',
+ fontSize: '0.75rem',
+ textDecoration: 'none',
+ cursor: 'pointer',
+ transition: 'color 0.2s ease, border-color 0.2s ease',
+ };
+ const onMouseEnter = (e: React.MouseEvent) => {
+ e.currentTarget.style.color = 'var(--text-primary)';
+ e.currentTarget.style.borderColor = 'var(--border-medium)';
+ };
+ const onMouseLeave = (e: React.MouseEvent) => {
+ e.currentTarget.style.color = 'var(--text-secondary)';
+ e.currentTarget.style.borderColor = 'var(--border-subtle)';
+ };
+
+ const inner = (
+ <>
+
+ {label}
+ >
+ );
+
+ if (external) {
+ return (
+
+ {inner}
+
+ );
+ }
+
+ return (
+
+ {inner}
+
+ );
+}
diff --git a/src/components/explore/RecordExplorer.tsx b/src/components/explore/RecordExplorer.tsx
index 526530b..32fde54 100644
--- a/src/components/explore/RecordExplorer.tsx
+++ b/src/components/explore/RecordExplorer.tsx
@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
-import { ExternalLink, FilePenLine, SearchX, TriangleAlert } from 'lucide-react';
+import { FilePenLine, SearchX, TriangleAlert } from 'lucide-react';
import { getRecord, getRecordUrl, type AtRecord } from '@/utils/atproto/pdsClient';
import { resolveIdentifier, type IdentityBundle } from '@/utils/atproto/identity';
import { encodeRepo } from '@/utils/atproto/urls';
@@ -18,9 +18,10 @@ import BacklinksTab from './tabs/BacklinksTab';
import LexiconUsageCard from './lexicons/LexiconUsageCard';
import RecordEditor from './RecordEditor';
import SignInPanel from './SignInPanel';
-import UniversalLinkChip from './UniversalLinkChip';
+import LinkButton from './LinkButton';
import NotFoundPanel from '@/components/NotFoundPanel';
import { useAtprotoSession } from '@/components/AtprotoSessionProvider';
+import { usePreferences } from '@/components/PreferencesProvider';
type Props = {
repo: string;
@@ -36,6 +37,7 @@ export default function RecordExplorer({ repo, collection, rkey }: Props) {
const [recordError, setRecordError] = useState(null);
const [editing, setEditing] = useState(false);
const { agent, did: signedInDid, session } = useAtprotoSession();
+ const { prefs, update: updatePrefs, loading: prefsLoading } = usePreferences();
const decodedRkey = useMemo(() => {
try {
@@ -103,6 +105,12 @@ export default function RecordExplorer({ repo, collection, rkey }: Props) {
const atUri = `at://${identity.did}/${collection}/${decodedRkey}`;
const repoSeg = encodeRepo(identity.handle || identity.did);
const canEdit = Boolean(agent && signedInDid && signedInDid === identity.did);
+ // "Minimal post view" preference — collapse the rich Bluesky post card to
+ // just the structured record. Only applies to posts, and only once prefs
+ // have settled (defaults render the full preview, matching first paint).
+ const isPost = collection === 'app.bsky.feed.post';
+ const showPostViewSwitch = !prefsLoading && isPost;
+ const minimalPost = showPostViewSwitch && prefs.minimalPostPreview;
// Universal link uses the canonical `/profile/` path; bare-form
// `///` still works as a fallback route but
// shareable copies should point at the canonical one.
@@ -193,9 +201,21 @@ export default function RecordExplorer({ repo, collection, rkey }: Props) {
rkey={decodedRkey}
record={record}
footerActions={editsInPreviewFooter ? editButton : null}
+ minimalPost={minimalPost}
/>
)}
+ {/* Inline switch between the rich post card and the minimal
+ record-only view — mirrors the repo page's profile switch so
+ users can flip without round-tripping through settings. */}
+ {!editing && showPostViewSwitch && (
+
+ updatePrefs((p) => ({ ...p, minimalPostPreview: !p.minimalPostPreview }))
+ }
+ />
+ )}
>
)}
@@ -296,6 +316,47 @@ export default function RecordExplorer({ repo, collection, rkey }: Props) {
);
}
+/**
+ * Quiet text toggle between the rich post card and the minimal
+ * record-only view, mirroring the repo page's ProfileViewSwitch. Flips the
+ * persisted `minimalPostPreview` pref so the choice sticks and syncs.
+ */
+function PostViewSwitch({
+ minimal,
+ onToggle,
+}: {
+ minimal: boolean;
+ onToggle: () => void;
+}) {
+ return (
+
+ );
+}
+
/**
* Single-row of compact copy buttons for the values a record-page
* visitor might want to grab. Replaces the older RecordMeta + per-cell
@@ -338,12 +399,6 @@ function CopyRow({
background: 'var(--bg-secondary)',
}}
>
-
- Copy
-
@@ -351,28 +406,13 @@ function CopyRow({
{recordJson && (
)}
-
-
+
-
- View on PDS
-
+ />
);
}
diff --git a/src/components/explore/RichRecordPreview.tsx b/src/components/explore/RichRecordPreview.tsx
index 6b29ed8..07512a4 100644
--- a/src/components/explore/RichRecordPreview.tsx
+++ b/src/components/explore/RichRecordPreview.tsx
@@ -34,6 +34,12 @@ type Props = {
* 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;
};
/**
@@ -65,6 +71,7 @@ export default function RichRecordPreview({
rkey,
record,
footerActions,
+ minimalPost = false,
}: Props) {
const marginType = getMarginLexiconType(collection);
const isPost = collection === 'app.bsky.feed.post';
@@ -72,7 +79,8 @@ export default function RichRecordPreview({
const [postThread, setPostThread] = useState(null);
useEffect(() => {
- if (!isPost) {
+ // Minimal view doesn't render the post card, so skip the AppView fetch.
+ if (!isPost || minimalPost) {
setPostThread(null);
return undefined;
}
@@ -84,7 +92,7 @@ export default function RichRecordPreview({
return () => {
cancelled = true;
};
- }, [isPost, did, collection, rkey]);
+ }, [isPost, minimalPost, did, collection, rkey]);
const recordForLegacy: GenericRecord | null = record
? {
@@ -101,6 +109,19 @@ export default function RichRecordPreview({
// (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 (
diff --git a/src/components/explore/UniversalLinkChip.tsx b/src/components/explore/UniversalLinkChip.tsx
deleted file mode 100644
index 6a70fae..0000000
--- a/src/components/explore/UniversalLinkChip.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-'use client';
-
-import Link from 'next/link';
-import { Globe } from 'lucide-react';
-
-type Props = {
- /**
- * Internal path to the universal link page, e.g.
- * `/profile///` or `/profile/`.
- */
- href: string;
- /** Override the link text. Defaults to "Universal link page". */
- label?: string;
-};
-
-/**
- * The inverse of the "View full record in Explorer →" CTA on universal link
- * pages: a navigation link from inside the explorer back out to the
- * shareable, client-agnostic universal link page for the same record/profile.
- *
- * Styled to match the "View on PDS" anchor in the explorer's copy row so the
- * two outbound affordances read as a pair.
- */
-export default function UniversalLinkChip({
- href,
- label = 'Universal link page',
-}: Props) {
- return (
- {
- e.currentTarget.style.color = 'var(--text-primary)';
- e.currentTarget.style.borderColor = 'var(--text-accent)';
- }}
- onMouseLeave={(e) => {
- e.currentTarget.style.color = 'var(--text-secondary)';
- e.currentTarget.style.borderColor = 'var(--border-medium)';
- }}
- >
-
- {label}
-
- );
-}
diff --git a/src/utils/preferences.ts b/src/utils/preferences.ts
index ab0d26d..81707e9 100644
--- a/src/utils/preferences.ts
+++ b/src/utils/preferences.ts
@@ -125,6 +125,12 @@ export type Preferences = {
* offers an inline switch to flip back to the full profile.
*/
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.
+ */
+ minimalPostPreview: boolean;
/**
* ISO timestamp of last local change. Used to break ties when local and
* PDS prefs both exist on sign-in.
@@ -148,6 +154,7 @@ export const DEFAULT_PREFERENCES: Preferences = {
hideRepoGlance: false,
repoGlanceCollapsedByDefault: false,
minimalProfile: false,
+ minimalPostPreview: false,
updatedAt: new Date(0).toISOString(),
};
@@ -246,6 +253,8 @@ export function mergeWithDefaults(input: Partial | null | undefined
: false;
const minimalProfile =
typeof input.minimalProfile === 'boolean' ? input.minimalProfile : false;
+ const minimalPostPreview =
+ typeof input.minimalPostPreview === 'boolean' ? input.minimalPostPreview : false;
return {
waypointGroups,
hiddenWaypoints,
@@ -259,6 +268,7 @@ export function mergeWithDefaults(input: Partial | null | undefined
hideRepoGlance,
repoGlanceCollapsedByDefault,
minimalProfile,
+ minimalPostPreview,
updatedAt:
typeof input.updatedAt === 'string' ? input.updatedAt : new Date(0).toISOString(),
};
@@ -327,6 +337,7 @@ export function preferencesAreEqual(a: Preferences, b: Preferences): boolean {
a.hideRepoGlance === b.hideRepoGlance &&
a.repoGlanceCollapsedByDefault === b.repoGlanceCollapsedByDefault &&
a.minimalProfile === b.minimalProfile &&
+ a.minimalPostPreview === b.minimalPostPreview &&
JSON.stringify(a.waypointGroups) === JSON.stringify(b.waypointGroups) &&
JSON.stringify(a.customWaypoints) === JSON.stringify(b.customWaypoints) &&
JSON.stringify(a.pinnedLexicons) === JSON.stringify(b.pinnedLexicons) &&