personal web client for Bluesky
typescript solidjs bluesky atcute

wip3

mary.my.id a42a53d9 9bc3b487

verified
+2 -2
src/components/composer/composer-reply-context.tsx
··· 8 8 import { useModerationOptions } from '~/lib/states/moderation'; 9 9 10 10 import Avatar, { getUserAvatarType } from '../avatar'; 11 - import ImageEmbed from '../embeds/image-embed'; 11 + import ImageGridEmbed from '../embeds/image-grid-embed'; 12 12 import TimeAgo from '../time-ago'; 13 13 14 14 export interface ComposerReplyContextProps { ··· 76 76 77 77 {image && ( 78 78 <div class="grow basis-0"> 79 - <ImageEmbed embed={image} blur={shouldBlurImage()} /> 79 + <ImageGridEmbed embed={image} blur={shouldBlurImage()} /> 80 80 </div> 81 81 )} 82 82 </div>
+2 -2
src/components/embeds/embed.tsx
··· 14 14 15 15 import ExternalEmbed from './external-embed'; 16 16 import FeedEmbed from './feed-embed'; 17 - import ImageEmbed from './image-embed'; 17 + import ImageStandaloneEmbed from './image-standalone-embed'; 18 18 import ListEmbed from './list-embed'; 19 19 import QuoteEmbed from './quote-embed'; 20 20 import VideoEmbed from './video-embed'; ··· 70 70 const type = embed.$type; 71 71 72 72 if (type === 'app.bsky.embed.images#view') { 73 - return <ImageEmbed embed={embed} standalone />; 73 + return <ImageStandaloneEmbed embed={embed} />; 74 74 } 75 75 76 76 if (type === 'app.bsky.embed.external#view') {
-290
src/components/embeds/image-embed.tsx
··· 1 - import type { AppBskyEmbedImages } from '@atcute/client/lexicons'; 2 - 3 - import { openModal } from '~/globals/modals'; 4 - 5 - import ImageViewerModalLazy from '~/components/images/image-viewer-modal-lazy'; 6 - 7 - export interface ImageEmbedProps { 8 - /** Expected to be static */ 9 - embed: AppBskyEmbedImages.View; 10 - blur?: boolean; 11 - /** Expected to be static */ 12 - borderless?: boolean; 13 - /** Expected to be static */ 14 - standalone?: boolean; 15 - } 16 - 17 - const ImageEmbed = (props: ImageEmbedProps) => { 18 - if (props.standalone) { 19 - return StandaloneRenderer(props); 20 - } 21 - 22 - return NonStandaloneRenderer(props); 23 - }; 24 - 25 - export default ImageEmbed; 26 - 27 - const clamp = (value: number, min: number, max: number): number => { 28 - return Math.max(min, Math.min(max, value)); 29 - }; 30 - 31 - const getAspectRatio = (image: AppBskyEmbedImages.ViewImage): number => { 32 - const dims = image.aspectRatio; 33 - 34 - const width = dims ? dims.width : 1; 35 - const height = dims ? dims.height : 1; 36 - const ratio = width / height; 37 - 38 - return ratio; 39 - }; 40 - 41 - const clampBetween3_4And4_3 = (ratio: number): number => { 42 - return clamp(ratio, 3 / 4, 4 / 3); 43 - }; 44 - 45 - const clampBetween3_4And16_9 = (ratio: number): number => { 46 - return clamp(ratio, 3 / 4, 16 / 9); 47 - }; 48 - 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 - ); 57 - }; 58 - 59 - const StandaloneRenderer = (props: ImageEmbedProps) => { 60 - const { embed } = props; 61 - 62 - const images = embed.images; 63 - const length = images.length; 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 - if (length === 1) { 79 - const img = images[0]; 80 - const dims = img.aspectRatio; 81 - 82 - const width = dims ? dims.width : 16; 83 - const height = dims ? dims.height : 9; 84 - const ratio = width / height; 85 - 86 - return ( 87 - <div class="max-w-full self-start overflow-hidden rounded-md border border-outline"> 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 - /> 97 - 98 - {/* beautiful hack that ensures we're always using the maximum possible dimension */} 99 - <div class="h-screen w-screen"></div> 100 - 101 - {/* @once */ img.alt && <AltIndicator />} 102 - </div> 103 - </div> 104 - ); 105 - } 106 - 107 - if (length === 2) { 108 - const rs = images.map(getAspectRatio); 109 - const [crA, crB] = rs.map(clampBetween3_4And4_3); 110 - 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 - }); 121 - 122 - return ( 123 - <div 124 - class="grid gap-1.5" 125 - style={{ 126 - 'aspect-ratio': totalRatio, 127 - 'grid-template-columns': `minmax(0, ${Math.floor(crA * 100)}fr) minmax(0, ${Math.floor(crB * 100)}fr)`, 128 - }} 129 - > 130 - {nodes} 131 - </div> 132 - ); 133 - } 134 - 135 - if (length >= 3) { 136 - const rs = images.map(getAspectRatio); 137 - const crs = rs.map(clampBetween3_4And4_3); 138 - 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)); 145 - 146 - const nodes = images.map((img, idx) => { 147 - const h = `${height}px`; 148 - const w = `${widths[idx]}px`; 149 - const r = crs[idx]; 150 - 151 - return ( 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> 157 - </div> 158 - ); 159 - }); 160 - 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 - ); 172 - } 173 - 174 - return null; 175 - }; 176 - 177 - const NonStandaloneRenderer = (props: ImageEmbedProps) => { 178 - const { embed, borderless } = props; 179 - 180 - const images = embed.images; 181 - const length = images.length; 182 - 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 - } 196 - 197 - if (length === 2) { 198 - const rs = images.map(getAspectRatio); 199 - const [crA, crB] = rs.map(clampBetween3_4And16_9); 200 - 201 - const totalRatio = crA + crB; 202 - 203 - const nodes = images.map((img) => { 204 - return ( 205 - <img 206 - src={/* @once */ img.thumb} 207 - alt={/* @once */ img.alt} 208 - class="h-full w-full object-cover text-[0px]" 209 - /> 210 - ); 211 - }); 212 - 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} 222 - </div> 223 - ); 224 - } 225 - 226 - if (length === 3) { 227 - const a = images[0]; 228 - const b = images[1]; 229 - const c = images[2]; 230 - 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 - /> 239 - </div> 240 - 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 - /> 252 - </div> 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; 290 - };
+128
src/components/embeds/image-grid-embed.tsx
··· 1 + import type { AppBskyEmbedImages } from '@atcute/client/lexicons'; 2 + 3 + import { clampBetween3_4And16_9, getAspectRatio } from './lib/image-utils'; 4 + 5 + export interface ImageGridEmbedProps { 6 + /** Expected to be static */ 7 + embed: AppBskyEmbedImages.View; 8 + blur?: boolean; 9 + /** Expected to be static */ 10 + borderless?: boolean; 11 + } 12 + 13 + const ImageGridEmbed = (props: ImageGridEmbedProps) => { 14 + const { embed, borderless } = props; 15 + 16 + const images = embed.images; 17 + const length = images.length; 18 + 19 + if (length === 1) { 20 + const img = images[0]; 21 + 22 + return ( 23 + <div class={`aspect-video` + (!borderless ? ` overflow-hidden rounded-md border border-outline` : ``)}> 24 + <img 25 + src={/* @once */ img.thumb} 26 + alt={/* @once */ img.alt} 27 + class="h-full w-full object-contain text-[0px]" 28 + /> 29 + </div> 30 + ); 31 + } 32 + 33 + if (length === 2) { 34 + const rs = images.map(getAspectRatio); 35 + const [crA, crB] = rs.map(clampBetween3_4And16_9); 36 + 37 + const totalRatio = crA + crB; 38 + 39 + const nodes = images.map((img) => { 40 + return ( 41 + <img 42 + src={/* @once */ img.thumb} 43 + alt={/* @once */ img.alt} 44 + class="h-full w-full object-cover text-[0px]" 45 + /> 46 + ); 47 + }); 48 + 49 + return ( 50 + <div 51 + class={`grid gap-0.5` + (!borderless ? ` overflow-hidden rounded-md border border-outline` : ``)} 52 + style={{ 53 + 'aspect-ratio': totalRatio, 54 + 'grid-template-columns': `minmax(0, ${Math.floor(crA * 100)}fr) minmax(0, ${Math.floor(crB * 100)}fr)`, 55 + }} 56 + > 57 + {nodes} 58 + </div> 59 + ); 60 + } 61 + 62 + if (length === 3) { 63 + const a = images[0]; 64 + const b = images[1]; 65 + const c = images[2]; 66 + 67 + return ( 68 + <div class={'flex gap-0.5' + (!borderless ? ` overflow-hidden rounded-md border border-outline` : ``)}> 69 + <div class="flex aspect-square grow-2 basis-0 flex-col gap-0.5"> 70 + <img 71 + src={/* @once */ a.thumb} 72 + alt={/* @once */ a.alt} 73 + class="h-full w-full object-cover text-[0px]" 74 + /> 75 + </div> 76 + 77 + <div class="flex grow basis-0 flex-col gap-0.5"> 78 + <img 79 + src={/* @once */ b.thumb} 80 + alt={/* @once */ b.alt} 81 + class="h-full w-full object-cover text-[0px]" 82 + /> 83 + <img 84 + src={/* @once */ c.thumb} 85 + alt={/* @once */ c.alt} 86 + class="h-full w-full object-cover text-[0px]" 87 + /> 88 + </div> 89 + </div> 90 + ); 91 + } 92 + 93 + if (length === 4) { 94 + const rs = images.map(getAspectRatio); 95 + const [crA, crB, crC, crD] = rs.map(clampBetween3_4And16_9); 96 + 97 + const totalWidth = Math.max(crA, crC) + Math.max(crB, crD); 98 + const totalHeight = Math.max(1 / crA, 1 / crB) + Math.max(1 / crC, 1 / crD); 99 + const totalAspectRatio = totalWidth / totalHeight; 100 + 101 + const nodes = images.map((img) => { 102 + return ( 103 + <img 104 + src={/* @once */ img.thumb} 105 + alt={/* @once */ img.alt} 106 + class="h-full w-full object-cover text-[0px]" 107 + /> 108 + ); 109 + }); 110 + 111 + return ( 112 + <div 113 + class={`grid gap-0.5` + (!borderless ? ` overflow-hidden rounded-md border border-outline` : ``)} 114 + style={{ 115 + 'aspect-ratio': totalAspectRatio, 116 + 'grid-template-columns': `minmax(0, ${Math.floor(crA * 100)}fr) minmax(0, ${Math.floor(crB * 100)}fr)`, 117 + 'grid-template-rows': `minmax(0, ${Math.floor(crC * 100)}fr) minmax(0, ${Math.floor(crD * 100)}fr)`, 118 + }} 119 + > 120 + {nodes} 121 + </div> 122 + ); 123 + } 124 + 125 + return null; 126 + }; 127 + 128 + export default ImageGridEmbed;
+140
src/components/embeds/image-standalone-embed.tsx
··· 1 + import type { AppBskyEmbedImages } from '@atcute/client/lexicons'; 2 + 3 + import { openModal } from '~/globals/modals'; 4 + 5 + import ImageViewerModalLazy from '~/components/images/image-viewer-modal-lazy'; 6 + 7 + import { clampBetween3_4And4_3, getAspectRatio } from './lib/image-utils'; 8 + 9 + export interface ImageStandaloneEmbedProps { 10 + /** Expected to be static */ 11 + embed: AppBskyEmbedImages.View; 12 + } 13 + 14 + const ImageStandaloneEmbed = ({ embed }: ImageStandaloneEmbedProps) => { 15 + const images = embed.images; 16 + const length = images.length; 17 + 18 + const render = (index: number, img: AppBskyEmbedImages.ViewImage) => { 19 + return ( 20 + <img 21 + src={/* @once */ img.thumb} 22 + alt={/* @once */ img.alt} 23 + class="h-full w-full cursor-pointer object-cover text-[0px]" 24 + onClick={() => { 25 + openModal(() => <ImageViewerModalLazy images={images} active={index} />); 26 + }} 27 + /> 28 + ); 29 + }; 30 + 31 + if (length === 1) { 32 + const img = images[0]; 33 + const dims = img.aspectRatio; 34 + 35 + const width = dims ? dims.width : 16; 36 + const height = dims ? dims.height : 9; 37 + const ratio = width / height; 38 + 39 + return ( 40 + <div class="max-w-full self-start overflow-hidden rounded-md border border-outline"> 41 + <div class="relative max-h-80 min-h-16 min-w-16 max-w-full" style={{ 'aspect-ratio': ratio }}> 42 + <img 43 + src={/* @once */ img.thumb} 44 + alt={/* @once */ img.alt} 45 + class="h-full w-full cursor-pointer object-contain text-[0px]" 46 + onClick={() => { 47 + openModal(() => <ImageViewerModalLazy images={images} active={0} />); 48 + }} 49 + /> 50 + 51 + {/* beautiful hack that ensures we're always using the maximum possible dimension */} 52 + <div class="h-screen w-screen"></div> 53 + 54 + {/* @once */ img.alt && <AltIndicator />} 55 + </div> 56 + </div> 57 + ); 58 + } 59 + 60 + if (length === 2) { 61 + const rs = images.map(getAspectRatio); 62 + const [crA, crB] = rs.map(clampBetween3_4And4_3); 63 + 64 + const totalRatio = crA + crB; 65 + 66 + const nodes = images.map((img, idx) => { 67 + return ( 68 + <div class="relative overflow-hidden rounded-md border border-outline"> 69 + {/* @once */ render(idx, img)} 70 + {/* @once */ img.alt && <AltIndicator />} 71 + </div> 72 + ); 73 + }); 74 + 75 + return ( 76 + <div 77 + class="grid gap-1.5" 78 + style={{ 79 + 'aspect-ratio': totalRatio, 80 + 'grid-template-columns': `minmax(0, ${Math.floor(crA * 100)}fr) minmax(0, ${Math.floor(crB * 100)}fr)`, 81 + }} 82 + > 83 + {nodes} 84 + </div> 85 + ); 86 + } 87 + 88 + if (length >= 3) { 89 + const rs = images.map(getAspectRatio); 90 + const crs = rs.map(clampBetween3_4And4_3); 91 + 92 + // 448px - 2px = maximum possible screen width (desktop) 93 + // 80px = width covered by avatar and padding in timeline item (64px on the left, 16px on the right) 94 + // 16px = random value to make it clear there's more items to the right 95 + // 220px = reasonable height limit 96 + const height = Math.min((1 / crs[0]) * (Math.min(window.innerWidth, 448 - 2) - 80 - 16), 220); 97 + const widths = crs.map((ratio) => Math.floor(ratio * height)); 98 + 99 + const nodes = images.map((img, idx) => { 100 + const h = `${height}px`; 101 + const w = `${widths[idx]}px`; 102 + const r = crs[idx]; 103 + 104 + return ( 105 + <div class="shrink-0" style={{ width: w, height: h, 'aspect-ratio': r }}> 106 + <div class="relative h-full w-full overflow-hidden rounded-md border border-outline"> 107 + {/* @once */ render(idx, img)} 108 + {/* @once */ img.alt && <AltIndicator />} 109 + </div> 110 + </div> 111 + ); 112 + }); 113 + 114 + return ( 115 + <div 116 + class="-mr-4 flex gap-1.5 overflow-x-auto pr-4 scrollbar-hide" 117 + style={{ 118 + 'margin-left': `calc(var(--embed-left-gutter, 16px) * -1)`, 119 + 'padding-left': `var(--embed-left-gutter, 16px)`, 120 + }} 121 + > 122 + {nodes} 123 + </div> 124 + ); 125 + } 126 + 127 + return null; 128 + }; 129 + 130 + export default ImageStandaloneEmbed; 131 + 132 + const AltIndicator = () => { 133 + return ( 134 + <div class="pointer-events-none absolute bottom-0 right-0 p-2"> 135 + <div class="flex h-4 items-center rounded bg-p-neutral-950/60 px-1 text-[9px] font-bold tracking-wider text-white"> 136 + ALT 137 + </div> 138 + </div> 139 + ); 140 + };
+23
src/components/embeds/lib/image-utils.ts
··· 1 + import type { AppBskyEmbedImages } from '@atcute/client/lexicons'; 2 + 3 + const clamp = (value: number, min: number, max: number): number => { 4 + return Math.max(min, Math.min(max, value)); 5 + }; 6 + 7 + export const getAspectRatio = (image: AppBskyEmbedImages.ViewImage): number => { 8 + const dims = image.aspectRatio; 9 + 10 + const width = dims ? dims.width : 1; 11 + const height = dims ? dims.height : 1; 12 + const ratio = width / height; 13 + 14 + return ratio; 15 + }; 16 + 17 + export const clampBetween3_4And4_3 = (ratio: number): number => { 18 + return clamp(ratio, 3 / 4, 4 / 3); 19 + }; 20 + 21 + export const clampBetween3_4And16_9 = (ratio: number): number => { 22 + return clamp(ratio, 3 / 4, 16 / 9); 23 + };
+3 -3
src/components/embeds/quote-embed.tsx
··· 17 17 import Avatar, { getUserAvatarType } from '../avatar'; 18 18 import TimeAgo from '../time-ago'; 19 19 20 - import ImageEmbed from './image-embed'; 20 + import ImageGridEmbed from './image-grid-embed'; 21 21 import VideoEmbed from './video-embed'; 22 22 23 23 export interface QuoteEmbedProps { ··· 86 86 {!large ? ( 87 87 image ? ( 88 88 <div class="mb-3 ml-3 mt-2 grow basis-0"> 89 - <ImageEmbed embed={image} blur={shouldBlurMedia()} /> 89 + <ImageGridEmbed embed={image} blur={shouldBlurMedia()} /> 90 90 </div> 91 91 ) : video ? ( 92 92 <div class="mb-3 ml-3 mt-2 grow basis-0"> ··· 105 105 106 106 {large || !text ? ( 107 107 image ? ( 108 - <ImageEmbed embed={image} borderless blur={shouldBlurMedia()} /> 108 + <ImageGridEmbed embed={image} borderless blur={shouldBlurMedia()} /> 109 109 ) : video ? ( 110 110 <VideoEmbed embed={video} borderless blur={shouldBlurMedia()} /> 111 111 ) : null