BlueSky & more on desktop
lazurite.stormlightlabs.org/
tauri
rust
typescript
bluesky
appview
atproto
solid
1import { ImageGallery } from "$/components/feeds/ImageGallery";
2import { type MediaNotice, MediaNoticeToast } from "$/components/feeds/MediaNoticeToast";
3import { ContextMenu, type ContextMenuAnchor, type ContextMenuItem } from "$/components/shared/ContextMenu";
4import { MediaController } from "$/lib/api/media";
5import { getPostText, postRkeyFromUri } from "$/lib/feeds";
6import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile";
7import type { ImagesEmbedView, PostView } from "$/lib/types";
8import { formatHandle } from "$/lib/utils/text";
9import { revealItemInDir } from "@tauri-apps/plugin-opener";
10import { createMemo, createSignal, For, onCleanup } from "solid-js";
11import { filenameFromPath, toDownloadErrorMessage } from "./shared";
12
13function buildImageFilename(postRkey: string | null, imageCount: number, imageIndex: number | null) {
14 if (!postRkey) {
15 return null;
16 }
17
18 if (imageCount > 1 && imageIndex !== null && imageIndex >= 0) {
19 return `${postRkey}_${imageIndex + 1}`;
20 }
21
22 return postRkey;
23}
24
25export function ImageEmbed(props: { embed: ImagesEmbedView; post: PostView }) {
26 const images = createMemo(() => props.embed.images.slice(0, 4));
27 const postRkey = createMemo(() => postRkeyFromUri(props.post.uri));
28 const [galleryStartIndex, setGalleryStartIndex] = createSignal<number | null>(null);
29 const [menuAnchor, setMenuAnchor] = createSignal<ContextMenuAnchor | null>(null);
30 const [menuOpen, setMenuOpen] = createSignal(false);
31 const [menuImageIndex, setMenuImageIndex] = createSignal<number | null>(null);
32 const [menuImageUrl, setMenuImageUrl] = createSignal<string | null>(null);
33 const [downloadPending, setDownloadPending] = createSignal(false);
34 const [notice, setNotice] = createSignal<MediaNotice | null>(null);
35 let noticeTimer: ReturnType<typeof setTimeout> | null = null;
36
37 const postText = createMemo(() => getPostText(props.post));
38 const authorHandle = createMemo(() => formatHandle(props.post.author.handle, props.post.author.did));
39 const profileHref = createMemo(() => buildProfileRoute(getProfileRouteActor(props.post.author)));
40 const menuItems = createMemo<ContextMenuItem[]>(
41 () => [{
42 disabled: !menuImageUrl() || downloadPending(),
43 icon: downloadPending() ? "i-ri-loader-4-line animate-spin" : "i-ri-download-2-line",
44 label: downloadPending() ? "Saving..." : "Save image",
45 onSelect: () => void downloadFromContextMenu(),
46 }]
47 );
48
49 onCleanup(() => {
50 if (noticeTimer !== null) {
51 clearTimeout(noticeTimer);
52 }
53 });
54
55 function dismissNotice() {
56 setNotice(null);
57 if (noticeTimer !== null) {
58 clearTimeout(noticeTimer);
59 noticeTimer = null;
60 }
61 }
62
63 function queueNotice(next: MediaNotice) {
64 dismissNotice();
65 setNotice(next);
66 noticeTimer = setTimeout(() => {
67 setNotice(null);
68 noticeTimer = null;
69 }, 6000);
70 }
71
72 function closeMenu() {
73 setMenuOpen(false);
74 setMenuAnchor(null);
75 setMenuImageIndex(null);
76 setMenuImageUrl(null);
77 }
78
79 function openGallery(index: number, event: MouseEvent) {
80 event.stopPropagation();
81 setGalleryStartIndex(index);
82 }
83
84 function openImageMenu(event: MouseEvent, url: string | undefined, imageIndex: number) {
85 event.preventDefault();
86 event.stopPropagation();
87
88 setMenuImageIndex(imageIndex);
89 setMenuImageUrl(url ?? null);
90 setMenuAnchor({ kind: "point", x: event.clientX, y: event.clientY });
91 setMenuOpen(true);
92 }
93
94 async function downloadFromContextMenu() {
95 const url = menuImageUrl();
96 const imageIndex = menuImageIndex();
97 if (!url || downloadPending()) {
98 return;
99 }
100
101 setDownloadPending(true);
102 try {
103 const requestedFilename = buildImageFilename(postRkey(), images().length, imageIndex)?.trim();
104 const result = await MediaController.downloadImage(url, requestedFilename ?? null);
105
106 queueNotice({ kind: "success", message: `Saved ${filenameFromPath(result.path)}.`, path: result.path });
107 } catch (error) {
108 queueNotice({ kind: "error", message: toDownloadErrorMessage(error, "Couldn't save this image right now.") });
109 } finally {
110 setDownloadPending(false);
111 }
112 }
113
114 return (
115 <>
116 <div class="grid min-w-0 gap-2" classList={{ "grid-cols-2": props.embed.images.length > 1 }}>
117 <For each={images()}>
118 {(image, index) => (
119 <button
120 type="button"
121 class="ui-input-strong overflow-hidden rounded-[1.2rem] border-0 p-0 shadow-(--inset-shadow)"
122 onClick={(event) => openGallery(index(), event)}
123 onContextMenu={(event) => openImageMenu(event, image.fullsize ?? image.thumb, index())}>
124 <img class="max-h-88 w-full object-cover" src={image.fullsize ?? image.thumb} alt={image.alt ?? ""} />
125 </button>
126 )}
127 </For>
128 </div>
129
130 <ImageGallery
131 authorHandle={authorHandle()}
132 authorHref={profileHref()}
133 images={images()}
134 open={galleryStartIndex() !== null}
135 postText={postText()}
136 startIndex={galleryStartIndex() ?? 0}
137 downloadFilenameForIndex={(imageIndex) => buildImageFilename(postRkey(), images().length, imageIndex)}
138 onClose={() => setGalleryStartIndex(null)} />
139
140 <ContextMenu
141 anchor={menuAnchor()}
142 items={menuItems()}
143 label="Image actions"
144 open={menuOpen()}
145 onClose={closeMenu} />
146
147 <MediaNoticeToast notice={notice()} onDismiss={dismissNotice} onOpenPath={revealItemInDir} />
148 </>
149 );
150}