BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 150 lines 5.6 kB view raw
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}