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
33 changes: 33 additions & 0 deletions app/account/tabs/browse/__tests__/shared.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,39 @@ describe("shared browse helpers", () => {

expect(result.size).toBe(0);
});

it("should map account-local assets for offline browse", async () => {
(mockDb.getAllAsync as jest.Mock).mockResolvedValue([
{
postUri: "at://did/post/1",
position: 0,
mediaType: "video",
alt: "Saved video",
width: 1920,
height: 1080,
thumbUrl: "https://cdn.bsky.app/thumb.jpg",
fullsizeUrl: null,
playlistUrl: "https://video.bsky.app/playlist.m3u8",
contentCid: "bafy-video",
localPath: "file:///account/media/bafy-video",
downloadState: "complete",
lastError: null,
},
] satisfies MediaRow[]);

const result = await fetchMediaForPosts(mockDb, ["at://did/post/1"]);

expect(result.get("at://did/post/1")?.[0]).toMatchObject({
contentCid: "bafy-video",
localUri: "file:///account/media/bafy-video",
downloadState: "complete",
});
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(mockDb.getAllAsync).toHaveBeenCalledWith(
expect.stringContaining("JOIN media_asset"),
["at://did/post/1"],
);
});
});

describe("fetchExternalEmbedsForPosts", () => {
Expand Down
71 changes: 62 additions & 9 deletions components/PostPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@ export function PostPreview({
return post.media
.filter((item) => item.type === "image")
.map((item, index) => ({
uri: item.fullsizeUrl ?? item.thumbUrl ?? "",
uri: item.downloadState === "complete" ? item.localUri ?? "" : "",
index,
}))
.filter((item) => item.uri !== "");
Expand Down Expand Up @@ -503,11 +503,10 @@ export function PostPreview({
/>
)}

{browseMode && currentVideo && currentVideo.playlistUrl && (
{browseMode && currentVideo?.localUri && (
<VideoPlayerModal
visible={videoVisible}
videoUri={String(currentVideo.playlistUrl)}
posterUri={currentVideo.thumbUrl ?? undefined}
videoUri={currentVideo.localUri}
onClose={closeVideo}
/>
)}
Expand Down Expand Up @@ -563,13 +562,51 @@ export function PostPreview({
<View style={styles.mediaGrid}>
{post.media.map((item, index) => {
const key = `${item.type}-${index}`;
const thumbUri = item.thumbUrl ?? undefined;
const isLocallyAvailable =
item.downloadState === "complete" && !!item.localUri;
const thumbUri = browseMode
? item.type === "image" && isLocallyAvailable
? item.localUri ?? undefined
: undefined
: item.thumbUrl ?? undefined;

if (browseMode && !isLocallyAvailable) {
return (
<View
key={key}
style={[
styles.unavailableMedia,
{ borderColor: palette.icon + "55" },
]}
>
<Text
style={[
styles.unavailableMediaTitle,
{ color: palette.text },
]}
>
Media unavailable — save again to retry
</Text>
{item.downloadError ? (
<Text
style={[
styles.unavailableMediaError,
{ color: palette.icon },
]}
>
{item.downloadError}
</Text>
) : null}
</View>
);
}

if (item.type === "video") {
const hasPlaylistUrl = !!item.playlistUrl;
const hasPlayableVideo = browseMode
? !!item.localUri
: !!item.playlistUrl;

// In browse mode with a playlist URL, make it tappable to play
if (browseMode && hasPlaylistUrl) {
if (browseMode && hasPlayableVideo) {
return (
<Pressable
key={key}
Expand Down Expand Up @@ -631,7 +668,7 @@ export function PostPreview({
if (browseMode) {
// Find the gallery index for this image
const galleryIdx = galleryImages.findIndex(
(g) => g.uri === (item.fullsizeUrl ?? item.thumbUrl),
(g) => g.uri === item.localUri,
);

return (
Expand Down Expand Up @@ -833,6 +870,22 @@ const styles = StyleSheet.create({
borderRadius: 10,
backgroundColor: "#0001",
},
unavailableMedia: {
width: 240,
minHeight: 100,
borderWidth: 1,
borderRadius: 10,
padding: 12,
justifyContent: "center",
gap: 6,
},
unavailableMediaTitle: {
fontSize: 14,
fontWeight: "600",
},
unavailableMediaError: {
fontSize: 12,
},
videoPlaceholder: {
width: 120,
height: 120,
Expand Down
69 changes: 68 additions & 1 deletion components/__tests__/PostPreview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import type {
PostPreviewData,
} from "@/controllers/bluesky/types";
import type { AccountTabPalette } from "@/types/account-tabs";
import { render, screen } from "@testing-library/react-native";
import { fireEvent, render, screen } from "@testing-library/react-native";
import { useVideoPlayer } from "expo-video";
import React from "react";
import { PostPreview } from "../PostPreview";

Expand Down Expand Up @@ -183,6 +184,72 @@ describe("PostPreview", () => {
).toBeTruthy();
});

it("should use saved local media while browsing", () => {
const post: PostPreviewData = {
...basePost,
media: [
{
type: "image",
localUri: "file:///account/media/bafy-image",
fullsizeUrl: "https://cdn.bsky.app/full.jpg",
downloadState: "complete",
},
],
};

const rendered = render(
<PostPreview post={post} palette={defaultPalette} browseMode />,
);

expect(JSON.stringify(rendered.toJSON())).toContain(
"file:///account/media/bafy-image",
);
expect(JSON.stringify(rendered.toJSON())).not.toContain(
"https://cdn.bsky.app/full.jpg",
);
});

it("should play saved video from its local asset while offline", () => {
const post: PostPreviewData = {
...basePost,
media: [
{
type: "video",
localUri: "file:///account/media/bafy-video",
playlistUrl: "https://video.bsky.app/playlist.m3u8",
downloadState: "complete",
},
],
};

render(<PostPreview post={post} palette={defaultPalette} browseMode />);
fireEvent.press(screen.getByText("▶"));

expect(jest.mocked(useVideoPlayer)).toHaveBeenCalledWith(
"file:///account/media/bafy-video",
expect.any(Function),
);
});

it("should explicitly show failed saved media and how to retry", () => {
const post: PostPreviewData = {
...basePost,
media: [
{
type: "video",
downloadState: "failed",
downloadError: "source unavailable",
playlistUrl: "https://video.bsky.app/playlist.m3u8",
},
],
};

render(<PostPreview post={post} palette={defaultPalette} browseMode />);

expect(screen.getByText("Media unavailable — save again to retry")).toBeTruthy();
expect(screen.getByText("source unavailable")).toBeTruthy();
});

it("should render post with external embed (link preview)", () => {
const externalEmbed: ExternalEmbed = {
uri: "https://example.com/article",
Expand Down
23 changes: 18 additions & 5 deletions components/account/browse-shared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ export type MediaRow = {
thumbUrl: string | null;
fullsizeUrl: string | null;
playlistUrl: string | null;
contentCid?: string | null;
localPath?: string | null;
downloadState?: "pending" | "downloading" | "complete" | "failed" | null;
lastError?: string | null;
};

export type ExternalRow = {
Expand Down Expand Up @@ -193,6 +197,13 @@ export function mapRowToPreview(
function mapMediaRowToAttachment(row: MediaRow): MediaAttachment {
return {
type: row.mediaType,
contentCid: row.contentCid ?? null,
localUri: row.localPath ?? null,
downloadState:
row.downloadState === "complete" || row.downloadState === "failed"
? row.downloadState
: "pending",
downloadError: row.lastError ?? null,
alt: row.alt,
width: row.width,
height: row.height,
Expand All @@ -212,11 +223,13 @@ export async function fetchMediaForPosts(

const placeholders = postUris.map(() => "?").join(",");
const mediaRows = await db.getAllAsync<MediaRow>(
`SELECT postUri, position, mediaType, alt, width, height,
thumbUrl, fullsizeUrl, playlistUrl
FROM post_media
WHERE postUri IN (${placeholders})
ORDER BY postUri, position;`,
`SELECT pm.postUri, pm.position, pm.mediaType, pm.alt, pm.width, pm.height,
pm.thumbUrl, pm.fullsizeUrl, pm.playlistUrl,
ma.contentCid, ma.localPath, ma.downloadState, ma.lastError
FROM post_media pm
LEFT JOIN media_asset ma ON ma.contentCid = pm.assetCid
WHERE pm.postUri IN (${placeholders})
ORDER BY pm.postUri, pm.position;`,
postUris,
);

Expand Down
6 changes: 5 additions & 1 deletion controllers/BlueskyAccountController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { SQLiteDatabase } from "expo-sqlite";
import { zip } from "react-native-zip-archive";

import { getDatabase } from "@/database";
import { resolveBlobDownloadUrl } from "./bluesky/blob-url";
import {
applyAccountMigrations,
blueskyAccountMigrations,
Expand Down Expand Up @@ -136,6 +137,8 @@ export class BlueskyAccountController extends BaseAccountController<BlueskyProgr
waitForPause: () => this.waitForPause(),
makeApiRequest: <T>(requestFn: ApiRequestFn<T>) =>
this.makeApiRequest<T>(requestFn),
downloadMedia: (blobCid: string, did: string) =>
this.downloadMedia(blobCid, did),
downloadMediaFromUrl: (url: string, did: string) =>
this.downloadMediaFromUrl(url, did),
});
Expand Down Expand Up @@ -1661,9 +1664,10 @@ export class BlueskyAccountController extends BaseAccountController<BlueskyProgr
throw new Error("Invalid blobCid or did");
}

const url = await resolveBlobDownloadUrl(did, blobCid);
return this.downloadToAccountMedia({
filename: blobCid,
url: `https://cdn.bsky.app/blob/${encodeURIComponent(did)}/${encodeURIComponent(blobCid)}`,
url,
});
}

Expand Down
35 changes: 34 additions & 1 deletion controllers/__tests__/BlueskyAccountController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,20 @@ describe("BlueskyAccountController", () => {
});

describe("media operations", () => {
beforeEach(() => {
jest.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({
service: [
{
type: "AtprotoPersonalDataServer",
serviceEndpoint: "https://pds.example.com",
},
],
}),
} as unknown as Response);
});

it("should skip download when file already exists", async () => {
const controller = new BlueskyAccountController(1);
(controller as unknown as { agent: Agent | null }).agent = {} as Agent;
Expand Down Expand Up @@ -688,10 +702,29 @@ describe("BlueskyAccountController", () => {
const mockedDownload = jest.mocked(File.downloadFileAsync);
expect(mockedDownload).toHaveBeenCalledTimes(1);
const [url, dest] = mockedDownload.mock.calls[0] as [string, File];
expect(url).toBe("https://cdn.bsky.app/blob/did%3Aplc%3A123/bafy%2Ftest");
expect(url).toBe(
"https://pds.example.com/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3A123&cid=bafy%2Ftest",
);
expect(dest).toBeInstanceOf(File);
expect(path).toBe(targetPath);
});

it("should isolate the same content CID within each local account", async () => {
const first = new BlueskyAccountController(1, "account-one");
const second = new BlueskyAccountController(2, "account-two");
(first as unknown as { agent: Agent | null }).agent = {} as Agent;
(second as unknown as { agent: Agent | null }).agent = {} as Agent;

const firstPath = await first.downloadMedia("shared-cid", "did:plc:author");
const secondPath = await second.downloadMedia(
"shared-cid",
"did:plc:author",
);

expect(firstPath).toContain("bluesky-account-one/media/shared-cid");
expect(secondPath).toContain("bluesky-account-two/media/shared-cid");
expect(firstPath).not.toBe(secondPath);
});
});

describe("cleanup", () => {
Expand Down
Loading