personal web client for Bluesky
typescript solidjs bluesky atcute

wip2

mary.my.id 9bc3b487 59c541b2

verified
Changed files
+181 -123
src
+1
src/components/bookmarks/bookmark-feed-item.tsx
··· 67 67 onAuxClick={handleClick} 68 68 onKeyDown={handleClick} 69 69 class="relative flex gap-3 border-b border-outline px-4 pt-3" 70 + style={{ '--embed-left-gutter': '64px' }} 70 71 > 71 72 <div class="flex shrink-0 flex-col items-center"> 72 73 <Avatar
+1 -1
src/components/embeds/embed.tsx
··· 70 70 const type = embed.$type; 71 71 72 72 if (type === 'app.bsky.embed.images#view') { 73 - return <ImageEmbed embed={embed} interactive standalone />; 73 + return <ImageEmbed embed={embed} standalone />; 74 74 } 75 75 76 76 if (type === 'app.bsky.embed.external#view') {
+177 -122
src/components/embeds/image-embed.tsx
··· 2 2 3 3 import { openModal } from '~/globals/modals'; 4 4 5 - import ImageViewerModalLazy from '../images/image-viewer-modal-lazy'; 5 + import ImageViewerModalLazy from '~/components/images/image-viewer-modal-lazy'; 6 6 7 7 export interface ImageEmbedProps { 8 8 /** Expected to be static */ ··· 12 12 borderless?: boolean; 13 13 /** Expected to be static */ 14 14 standalone?: boolean; 15 - /** Expected to be static */ 16 - interactive?: boolean; 17 - } 18 - 19 - const enum RenderMode { 20 - MULTIPLE, 21 - MULTIPLE_SQUARE, 22 - STANDALONE, 23 15 } 24 16 25 17 const ImageEmbed = (props: ImageEmbedProps) => { ··· 36 28 return Math.max(min, Math.min(max, value)); 37 29 }; 38 30 39 - const isCloseToThreeByFour = (ratio: number): boolean => { 40 - return Math.abs(ratio - 3 / 4) < 0.01; 41 - }; 42 - 43 - const isCloseToFourByThree = (ratio: number): boolean => { 44 - return Math.abs(ratio - 4 / 3) < 0.01; 45 - }; 46 - 47 - const getClampedAspectRatio = (image: AppBskyEmbedImages.ViewImage) => { 31 + const getAspectRatio = (image: AppBskyEmbedImages.ViewImage): number => { 48 32 const dims = image.aspectRatio; 49 33 50 34 const width = dims ? dims.width : 1; 51 35 const height = dims ? dims.height : 1; 52 36 const ratio = width / height; 53 37 38 + return ratio; 39 + }; 40 + 41 + const clampBetween3_4And4_3 = (ratio: number): number => { 54 42 return clamp(ratio, 3 / 4, 4 / 3); 55 43 }; 56 44 57 - const deriveMultiMediaHeight = (ratioA: number, ratioB: number) => { 58 - if (isCloseToFourByThree(ratioA) && isCloseToFourByThree(ratioB)) { 59 - return 184; 60 - } 45 + const clampBetween3_4And16_9 = (ratio: number): number => { 46 + return clamp(ratio, 3 / 4, 16 / 9); 47 + }; 61 48 62 - if ( 63 - (isCloseToThreeByFour(ratioA) && isCloseToFourByThree(ratioB)) || 64 - (isCloseToThreeByFour(ratioA) && isCloseToThreeByFour(ratioB)) 65 - ) { 66 - return 235; 67 - } 68 - 69 - return ratioA === 1 && ratioB === 1 ? 245 : 200; 49 + const AltIndicator = () => { 50 + return ( 51 + <div class="pointer-events-none absolute bottom-0 right-0 p-2"> 52 + <div class="flex h-4 items-center rounded bg-p-neutral-950/60 px-1 text-[9px] font-bold tracking-wider text-white"> 53 + ALT 54 + </div> 55 + </div> 56 + ); 70 57 }; 71 58 72 59 const StandaloneRenderer = (props: ImageEmbedProps) => { ··· 75 62 const images = embed.images; 76 63 const length = images.length; 77 64 65 + const render = (index: number, img: AppBskyEmbedImages.ViewImage) => { 66 + return ( 67 + <img 68 + src={/* @once */ img.thumb} 69 + alt={/* @once */ img.alt} 70 + class="h-full w-full cursor-pointer object-cover text-[0px]" 71 + onClick={() => { 72 + openModal(() => <ImageViewerModalLazy images={images} active={index} />); 73 + }} 74 + /> 75 + ); 76 + }; 77 + 78 78 if (length === 1) { 79 - const image = images[0]; 80 - const dims = image.aspectRatio; 79 + const img = images[0]; 80 + const dims = img.aspectRatio; 81 81 82 82 const width = dims ? dims.width : 16; 83 83 const height = dims ? dims.height : 9; ··· 85 85 86 86 return ( 87 87 <div class="max-w-full self-start overflow-hidden rounded-md border border-outline"> 88 - <div class="max-h-80 min-h-16 min-w-16" style={{ 'aspect-ratio': ratio }}> 89 - <img src={/* @once */ image.thumb} class="h-full w-full object-contain text-[0px]" /> 88 + <div class="relative max-h-80 min-h-16 min-w-16 max-w-full" style={{ 'aspect-ratio': ratio }}> 89 + <img 90 + src={/* @once */ img.thumb} 91 + alt={/* @once */ img.alt} 92 + class="h-full w-full cursor-pointer object-contain text-[0px]" 93 + onClick={() => { 94 + openModal(() => <ImageViewerModalLazy images={images} active={0} />); 95 + }} 96 + /> 90 97 91 98 {/* beautiful hack that ensures we're always using the maximum possible dimension */} 92 99 <div class="h-screen w-screen"></div> 100 + 101 + {/* @once */ img.alt && <AltIndicator />} 93 102 </div> 94 103 </div> 95 104 ); 96 105 } 97 106 98 107 if (length === 2) { 99 - const a = images[0]; 100 - const b = images[1]; 108 + const rs = images.map(getAspectRatio); 109 + const [crA, crB] = rs.map(clampBetween3_4And4_3); 101 110 102 - const ratioA = getClampedAspectRatio(a); 103 - const ratioB = getClampedAspectRatio(b); 104 - const totalRatio = ratioA + ratioB; 111 + const totalRatio = crA + crB; 112 + 113 + const nodes = images.map((img, idx) => { 114 + return ( 115 + <div class="relative overflow-hidden rounded-md border border-outline"> 116 + {/* @once */ render(idx, img)} 117 + {/* @once */ img.alt && <AltIndicator />} 118 + </div> 119 + ); 120 + }); 105 121 106 122 return ( 107 123 <div 108 124 class="grid gap-1.5" 109 125 style={{ 110 126 'aspect-ratio': totalRatio, 111 - 'grid-template-columns': `minmax(0, ${Math.floor(ratioA * 100)}fr) minmax(0, ${Math.floor(ratioB * 100)}fr)`, 127 + 'grid-template-columns': `minmax(0, ${Math.floor(crA * 100)}fr) minmax(0, ${Math.floor(crB * 100)}fr)`, 112 128 }} 113 129 > 114 - <div class="overflow-hidden rounded-md border border-outline"> 115 - <img src={/* @once */ a.thumb} class="h-full w-full object-cover text-[0px]" /> 116 - </div> 117 - <div class="overflow-hidden rounded-md border border-outline"> 118 - <img src={/* @once */ b.thumb} class="h-full w-full object-cover text-[0px]" /> 119 - </div> 130 + {nodes} 120 131 </div> 121 132 ); 122 133 } 123 134 124 135 if (length >= 3) { 125 - const ratios = images.map(getClampedAspectRatio); 136 + const rs = images.map(getAspectRatio); 137 + const crs = rs.map(clampBetween3_4And4_3); 126 138 127 - const height = deriveMultiMediaHeight(ratios[0], ratios[1]); 128 - const widths = ratios.map((ratio) => Math.floor(ratio * height)); 139 + // 448px - 2px = maximum possible screen width (desktop) 140 + // 80px = width covered by avatar and padding in timeline item (64px on the left, 16px on the right) 141 + // 16px = random value to make it clear there's more items to the right 142 + // 220px = reasonable height limit 143 + const height = Math.min((1 / crs[0]) * (Math.min(window.innerWidth, 448 - 2) - 80 - 16), 220); 144 + const widths = crs.map((ratio) => Math.floor(ratio * height)); 129 145 130 146 const nodes = images.map((img, idx) => { 131 147 const h = `${height}px`; 132 148 const w = `${widths[idx]}px`; 133 - const r = ratios[idx]; 149 + const r = crs[idx]; 134 150 135 151 return ( 136 - <div 137 - class="box-content shrink-0 overflow-hidden rounded-md border border-outline" 138 - style={{ height: h, width: w, 'aspect-ratio': r }} 139 - > 140 - <img src={/* @once */ img.thumb} class="h-full w-full object-cover text-[0px]" /> 152 + <div class="shrink-0" style={{ width: w, height: h, 'aspect-ratio': r }}> 153 + <div class="relative h-full w-full overflow-hidden rounded-md border border-outline"> 154 + {/* @once */ render(idx, img)} 155 + {/* @once */ img.alt && <AltIndicator />} 156 + </div> 141 157 </div> 142 158 ); 143 159 }); 144 160 145 - return <div class="-mx-4 flex gap-1.5 overflow-x-auto px-4">{nodes}</div>; 161 + return ( 162 + <div 163 + class="-mr-4 flex gap-1.5 overflow-x-auto pr-4 scrollbar-hide" 164 + style={{ 165 + 'margin-left': `calc(var(--embed-left-gutter, 16px) * -1)`, 166 + 'padding-left': `var(--embed-left-gutter, 16px)`, 167 + }} 168 + > 169 + {nodes} 170 + </div> 171 + ); 146 172 } 147 173 148 174 return null; 149 175 }; 150 176 151 177 const NonStandaloneRenderer = (props: ImageEmbedProps) => { 152 - const { embed, borderless, interactive } = props; 178 + const { embed, borderless } = props; 153 179 154 180 const images = embed.images; 155 181 const length = images.length; 156 182 157 - const render = (index: number, mode: RenderMode) => { 158 - const { alt, thumb } = images[index]; 183 + if (length === 1) { 184 + const img = images[0]; 185 + 186 + return ( 187 + <div class={`aspect-video` + (!borderless ? ` overflow-hidden rounded-md border border-outline` : ``)}> 188 + <img 189 + src={/* @once */ img.thumb} 190 + alt={/* @once */ img.alt} 191 + class="h-full w-full object-contain text-[0px]" 192 + /> 193 + </div> 194 + ); 195 + } 159 196 160 - let cn: string | undefined; 161 - let ratio: string | undefined; 197 + if (length === 2) { 198 + const rs = images.map(getAspectRatio); 199 + const [crA, crB] = rs.map(clampBetween3_4And16_9); 162 200 163 - if (mode === RenderMode.MULTIPLE) { 164 - cn = `min-h-0 grow basis-0 overflow-hidden`; 165 - } else if (mode === RenderMode.MULTIPLE_SQUARE) { 166 - cn = `aspect-square overflow-hidden`; 167 - } else if (mode === RenderMode.STANDALONE) { 168 - cn = `aspect-video overflow-hidden`; 169 - } 201 + const totalRatio = crA + crB; 170 202 171 - return ( 172 - <div class={`relative bg-background ` + cn} style={{ 'aspect-ratio': ratio }}> 203 + const nodes = images.map((img) => { 204 + return ( 173 205 <img 174 - src={thumb} 175 - title={alt} 176 - class={ 177 - `h-full w-full object-contain text-[0px]` + 178 - (interactive ? ` cursor-pointer` : ``) + 179 - // prettier-ignore 180 - (props.blur ? ` scale-125` + (!borderless ? ` blur` : ` blur-lg`) : ``) 181 - } 182 - onClick={() => { 183 - if (interactive) { 184 - openModal(() => <ImageViewerModalLazy active={index} images={images} />); 185 - } 186 - }} 206 + src={/* @once */ img.thumb} 207 + alt={/* @once */ img.alt} 208 + class="h-full w-full object-cover text-[0px]" 187 209 /> 210 + ); 211 + }); 188 212 189 - {interactive && alt && ( 190 - <div class="pointer-events-none absolute bottom-0 right-0 p-2"> 191 - <div class="flex h-4 items-center rounded bg-p-neutral-950/60 px-1 text-[9px] font-bold tracking-wider text-white"> 192 - ALT 193 - </div> 194 - </div> 195 - )} 213 + return ( 214 + <div 215 + class={`grid gap-0.5` + (!borderless ? ` overflow-hidden rounded-md border border-outline` : ``)} 216 + style={{ 217 + 'aspect-ratio': totalRatio, 218 + 'grid-template-columns': `minmax(0, ${Math.floor(crA * 100)}fr) minmax(0, ${Math.floor(crB * 100)}fr)`, 219 + }} 220 + > 221 + {nodes} 196 222 </div> 197 223 ); 198 - }; 224 + } 199 225 200 - return ( 201 - <div class={`` + (!borderless ? ` overflow-hidden rounded-md border border-outline` : ``)}> 202 - {length === 4 ? ( 203 - <div class="flex gap-0.5"> 204 - <div class="flex grow basis-0 flex-col gap-0.5"> 205 - {/* @once */ render(0, RenderMode.MULTIPLE_SQUARE)} 206 - {/* @once */ render(2, RenderMode.MULTIPLE_SQUARE)} 207 - </div> 226 + if (length === 3) { 227 + const a = images[0]; 228 + const b = images[1]; 229 + const c = images[2]; 208 230 209 - <div class="flex grow basis-0 flex-col gap-0.5"> 210 - {/* @once */ render(1, RenderMode.MULTIPLE_SQUARE)} 211 - {/* @once */ render(3, RenderMode.MULTIPLE_SQUARE)} 212 - </div> 231 + return ( 232 + <div class={'flex gap-0.5' + (!borderless ? ` overflow-hidden rounded-md border border-outline` : ``)}> 233 + <div class="flex aspect-square grow-2 basis-0 flex-col gap-0.5"> 234 + <img 235 + src={/* @once */ a.thumb} 236 + alt={/* @once */ a.alt} 237 + class="h-full w-full object-cover text-[0px]" 238 + /> 213 239 </div> 214 - ) : length === 3 ? ( 215 - <div class="flex gap-0.5"> 216 - <div class="flex aspect-square grow-2 basis-0 flex-col gap-0.5"> 217 - {/* @once */ render(0, RenderMode.MULTIPLE)} 218 - </div> 219 240 220 - <div class="flex grow basis-0 flex-col gap-0.5"> 221 - {/* @once */ render(1, RenderMode.MULTIPLE_SQUARE)} 222 - {/* @once */ render(2, RenderMode.MULTIPLE_SQUARE)} 223 - </div> 224 - </div> 225 - ) : length === 2 ? ( 226 - <div class="flex aspect-video gap-0.5"> 227 - <div class="flex grow basis-0 flex-col gap-0.5">{/* @once */ render(0, RenderMode.MULTIPLE)}</div> 228 - <div class="flex grow basis-0 flex-col gap-0.5">{/* @once */ render(1, RenderMode.MULTIPLE)}</div> 241 + <div class="flex grow basis-0 flex-col gap-0.5"> 242 + <img 243 + src={/* @once */ b.thumb} 244 + alt={/* @once */ b.alt} 245 + class="h-full w-full object-cover text-[0px]" 246 + /> 247 + <img 248 + src={/* @once */ c.thumb} 249 + alt={/* @once */ c.alt} 250 + class="h-full w-full object-cover text-[0px]" 251 + /> 229 252 </div> 230 - ) : length === 1 ? ( 231 - <>{/* @once */ render(0, RenderMode.STANDALONE)}</> 232 - ) : null} 233 - </div> 234 - ); 253 + </div> 254 + ); 255 + } 256 + 257 + if (length === 4) { 258 + const rs = images.map(getAspectRatio); 259 + const [crA, crB, crC, crD] = rs.map(clampBetween3_4And16_9); 260 + 261 + const totalWidth = Math.max(crA, crC) + Math.max(crB, crD); 262 + const totalHeight = Math.max(1 / crA, 1 / crB) + Math.max(1 / crC, 1 / crD); 263 + const totalAspectRatio = totalWidth / totalHeight; 264 + 265 + const nodes = images.map((img) => { 266 + return ( 267 + <img 268 + src={/* @once */ img.thumb} 269 + alt={/* @once */ img.alt} 270 + class="h-full w-full object-cover text-[0px]" 271 + /> 272 + ); 273 + }); 274 + 275 + return ( 276 + <div 277 + class={`grid gap-0.5` + (!borderless ? ` overflow-hidden rounded-md border border-outline` : ``)} 278 + style={{ 279 + 'aspect-ratio': totalAspectRatio, 280 + 'grid-template-columns': `minmax(0, ${Math.floor(crA * 100)}fr) minmax(0, ${Math.floor(crB * 100)}fr)`, 281 + 'grid-template-rows': `minmax(0, ${Math.floor(crC * 100)}fr) minmax(0, ${Math.floor(crD * 100)}fr)`, 282 + }} 283 + > 284 + {nodes} 285 + </div> 286 + ); 287 + } 288 + 289 + return null; 235 290 };
+1
src/components/threads/post-thread-item.tsx
··· 82 82 // prettier-ignore 83 83 (!treeView ? ` px-4` + (!item().next ? ` border-b` : ``) : ` px-3`) 84 84 } 85 + style={{ '--embed-left-gutter': !treeView ? '64px' : `${12 + 8 + (item().lines!.length + 1) * 20}px` }} 85 86 > 86 87 <ThreadLines lines={item().lines} /> 87 88
+1
src/components/timeline/post-feed-item.tsx
··· 82 82 (!highlighted ? ` hover:bg-contrast/sm` : ` bg-accent/15 hover:bg-accent/md`) + 83 83 (!next ? ` border-b` : ``) 84 84 } 85 + style={{ '--embed-left-gutter': '64px' }} 85 86 > 86 87 <div class="relative flex flex-col pb-1 pt-2"> 87 88 {prev && (