+
Loading stream
diff --git a/src/components/feeds/embeds/shared.ts b/src/components/feeds/embeds/shared.ts
new file mode 100644
index 0000000..1312b79
--- /dev/null
+++ b/src/components/feeds/embeds/shared.ts
@@ -0,0 +1,15 @@
+import { normalizeError } from "$/lib/utils/text";
+
+export function filenameFromPath(path: string) {
+ const parts = path.split(/[/\\]/u);
+ return parts.at(-1) || "downloaded file";
+}
+
+export function toDownloadErrorMessage(error: unknown, fallback: string) {
+ const message = normalizeError(error);
+ if (/download folder|writable|save|directory|exists/iu.test(message)) {
+ return "Couldn't save — check that the download folder exists.";
+ }
+
+ return fallback;
+}
diff --git a/src/components/feeds/hooks/useFeedWorkspaceController.ts b/src/components/feeds/hooks/useFeedWorkspaceController.ts
index 8104923..8e0d787 100644
--- a/src/components/feeds/hooks/useFeedWorkspaceController.ts
+++ b/src/components/feeds/hooks/useFeedWorkspaceController.ts
@@ -8,10 +8,10 @@ import {
extractHashtags,
getFeedName,
getReplyRootPost,
- isThreadViewPost,
patchFeedItems,
toStrongRef,
} from "$/lib/feeds";
+import { isThreadViewPost } from "$/lib/feeds/type-guards";
import type { PostEngagementTab } from "$/lib/post-engagement-routes";
import type {
ActiveSession,
diff --git a/src/components/feeds/tests/PostCard.test.tsx b/src/components/feeds/tests/PostCard.test.tsx
index 3ac1f86..82f4d5f 100644
--- a/src/components/feeds/tests/PostCard.test.tsx
+++ b/src/components/feeds/tests/PostCard.test.tsx
@@ -282,6 +282,181 @@ describe("PostCard", () => {
expect(onOpenThread).toHaveBeenCalledWith("at://did:plc:bob/app.bsky.feed.post/quoted");
});
+ it("renders quoted post image and video embeds from the quoted record", () => {
+ render(() => (
+
+ ));
+
+ expect(screen.getByAltText("Quoted image")).toHaveAttribute("src", "https://cdn.example.com/quoted-image.png");
+ expect(screen.getByRole("button", { name: "Play video" })).toBeInTheDocument();
+ expect(screen.getByText("Quoted clip")).toBeInTheDocument();
+ });
+
+ it("renders quoted external card embeds from the quoted record", () => {
+ render(() => (
+
+ ));
+
+ expect(screen.getByRole("link", { name: /external article/i })).toHaveAttribute("href", "https://example.com/article");
+ });
+
+ it("renders feed generator record embeds with feed metadata and external links", () => {
+ const onOpenThread = vi.fn();
+ render(() => (
+
+ ));
+
+ expect(screen.getByText("Embedded feed")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: /for you/i })).toHaveAttribute(
+ "href",
+ "https://bsky.app/profile/alice.test/feed/for-you",
+ );
+ fireEvent.click(screen.getByRole("link", { name: /for you/i }));
+ expect(onOpenThread).not.toHaveBeenCalled();
+ });
+
+ it("renders list record embeds with list metadata and external links", () => {
+ const onOpenThread = vi.fn();
+ render(() => (
+
+ ));
+
+ expect(screen.getByText("Embedded list")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: /science curators/i })).toHaveAttribute(
+ "href",
+ "https://bsky.app/profile/alice.test/lists/science-curators",
+ );
+ fireEvent.click(screen.getByRole("link", { name: /science curators/i }));
+ expect(onOpenThread).not.toHaveBeenCalled();
+ });
+
+ it("ignores non-media payloads inside recordWithMedia and avoids duplicate quote previews", () => {
+ render(() => (
+
+ ));
+
+ expect(screen.getByText("Outer quote")).toBeInTheDocument();
+ expect(screen.queryByText("Nested record")).not.toBeInTheDocument();
+ expect(screen.getAllByText("Quoted post")).toHaveLength(1);
+ expect(screen.queryByText("This recognized media type is not valid in recordWithMedia.media.")).not.toBeInTheDocument();
+ });
+
+ it("does not show unsupported embed fallback cards for custom quoted embeds", () => {
+ render(() => (
+
+ ));
+
+ expect(screen.queryByText("Unsupported custom embed type.")).not.toBeInTheDocument();
+ expect(screen.queryByText("View JSON")).not.toBeInTheDocument();
+ expect(screen.getByText("Quoted body")).toBeInTheDocument();
+ });
+
it("renders inline video embed player for video attachments", () => {
render(() => (
Array.isArray(props.facets) && props.facets.length > 0);
-
return (
{props.text}
}>
-
+ {facets => (
+
+ )}
);
}
+type QuotedPreviewContentProps = {
+ author: ProfileViewBasic | null;
+ emptyText?: string;
+ facets?: RichTextFacet[] | null;
+ preview: string;
+ truncated: boolean;
+};
+
+function QuotedPreviewContent(props: QuotedPreviewContentProps) {
+ return (
+ <>
+
+ {(value) => {
+ const author = value();
+ return (
+
+ {getDisplayName(author)}
+
+ {formatHandle(author.handle, author.did)}
+
+
+ );
+ }}
+
+ {props.emptyText ?? "Quoted post"}
+ }>
+ {(text) => }
+
+ >
+ );
+}
+
type QuotedPostPreviewProps = {
author: ProfileViewBasic | null;
class?: string;
+ emptyText?: string;
facets?: RichTextFacet[] | null;
href?: string | null;
onOpenPost?: () => void;
@@ -55,7 +91,13 @@ export function QuotedPostPreview(props: QuotedPostPreviewProps) {
fallback={
}>
+ fallback={
+
+ }>
{(href) => (
event.stopPropagation()}>
@@ -81,6 +124,7 @@ export function QuotedPostPreview(props: QuotedPostPreviewProps) {
}}>
@@ -89,27 +133,3 @@ export function QuotedPostPreview(props: QuotedPostPreviewProps) {
);
}
-
-function QuotedPreviewContent(
- props: { author: ProfileViewBasic | null; facets?: RichTextFacet[] | null; preview: string; truncated: boolean },
-) {
- return (
- <>
-