wip bsky client for the web & android bbell.vt3e.cat

feat: uhh adjust how embeds are displayed and other things idk

vt3e.cat f2af6a5f c0c057cb

verified
+494 -274
+18
src/assets/main.css
··· 8 8 font-display: swap; 9 9 } 10 10 11 + ::-webkit-scrollbar { 12 + width: 0.5rem; 13 + height: 6px; 14 + background: transparent; 15 + } 16 + 17 + ::-webkit-scrollbar-thumb { 18 + background: hsla(var(--overlay1) / 1); 19 + border-radius: 3px; 20 + } 21 + 22 + ::-webkit-scrollbar-track { 23 + background: transparent; 24 + } 25 + 11 26 *, 12 27 *::before, 13 28 *::after { ··· 15 30 margin: 0; 16 31 padding: 0; 17 32 font-weight: normal; 33 + 34 + scrollbar-width: thin; 35 + scrollbar-color: hsla(var(--overlay1) / 1) transparent; 18 36 19 37 --transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1); 20 38 -webkit-tap-highlight-color: transparent;
+86 -78
src/components/Feed/Embeds/ImageEmbed.vue
··· 8 8 9 9 const imageCount = computed(() => props.embed.images.length) 10 10 11 - // TODO)) lightbox. 12 11 const lightboxOpen = ref(false) 13 12 const activeImageIndex = ref(0) 14 13 ··· 16 15 activeImageIndex.value = index 17 16 lightboxOpen.value = true 18 17 } 18 + 19 + const getImageStyle = (image: AppBskyEmbedImages.ViewImage) => { 20 + if (image.aspectRatio) { 21 + return { 22 + aspectRatio: `${image.aspectRatio.width} / ${image.aspectRatio.height}`, 23 + } 24 + } 25 + return {} 26 + } 19 27 </script> 20 28 21 29 <template> 22 - <div class="image-embed" :class="`count-${imageCount}`"> 23 - <div 24 - v-for="(image, index) in embed.images" 25 - :key="index" 26 - class="image-container" 27 - @click.stop="openLightbox(index)" 28 - > 29 - <img :src="image.thumb" :alt="image.alt" loading="lazy" /> 30 + <div class="image-embed__wrapper" :class="{ single: imageCount === 1, multi: imageCount > 1 }"> 31 + <div ref="scrollRow" class="image-embed__row"> 32 + <div 33 + v-for="(image, index) in embed.images" 34 + :key="index" 35 + class="image-container" 36 + @click.stop="openLightbox(index)" 37 + > 38 + <img 39 + :src="image.thumb" 40 + :alt="image.alt" 41 + loading="lazy" 42 + :draggable="false" 43 + :style="getImageStyle(image)" 44 + /> 45 + </div> 30 46 </div> 31 47 </div> 32 48 </template> 33 49 34 50 <style lang="scss" scoped> 35 - .image-embed { 36 - display: grid; 37 - gap: 2px; 38 - overflow: hidden; 39 - margin-top: 0.5rem; 51 + .image-embed__row { 52 + max-width: 100%; 53 + display: flex; 54 + gap: 0.5rem; 40 55 width: 100%; 41 - aspect-ratio: 16 / 9; 56 + transition: none !important; 42 57 43 - .image-container { 44 - position: relative; 45 - width: 100%; 46 - height: 100%; 47 - overflow: hidden; 48 - cursor: zoom-in; 49 - border: 1px solid hsla(var(--surface2) / 0.3); 58 + scroll-snap-type: x mandatory; 59 + scroll-padding: var(--gutter-width); 50 60 51 - img { 52 - width: 100%; 53 - height: 100%; 54 - object-fit: contain; 55 - object-fit: cover; 56 - display: block; 57 - } 61 + &::before, 62 + &::after { 63 + content: ''; 64 + flex-shrink: 0; 65 + min-width: calc(-0.5rem + var(--gutter-width)); 66 + } 67 + &::after { 68 + min-width: calc(-0.5rem + var(--gutter-width)); 58 69 } 59 70 } 60 71 61 - .count-1 { 62 - display: block; 63 - aspect-ratio: auto; 64 - max-height: 600px; 72 + .image-container { 73 + overflow: hidden; 74 + cursor: zoom-in; 75 + border-radius: 0.5rem; 76 + background: hsl(var(--surface1)); 77 + display: flex; 78 + align-items: center; 79 + justify-content: center; 65 80 81 + img { 82 + display: block; 83 + } 84 + } 85 + 86 + .image-embed__wrapper.single { 66 87 .image-container { 67 - height: auto; 68 - max-height: 600px; 69 - border-radius: var(--radius-md); 88 + width: fit-content; 89 + max-width: 100%; 90 + max-height: 60vh; 70 91 71 92 img { 93 + object-fit: contain; 94 + width: auto; 72 95 height: auto; 73 - max-height: 600px; 74 - object-fit: cover; 96 + max-width: 100%; 97 + max-height: 60vh; 75 98 } 76 99 } 77 100 } 78 101 79 - .count-2 { 80 - grid-template-columns: 1fr 1fr; 81 - aspect-ratio: 2 / 1; 82 - 83 - .image-container:nth-child(1) { 84 - border-radius: var(--radius-md) var(--radius-xsm) var(--radius-xsm) var(--radius-md); 85 - } 86 - .image-container:nth-child(2) { 87 - border-radius: var(--radius-xsm) var(--radius-md) var(--radius-md) var(--radius-xsm); 88 - } 89 - } 102 + .image-embed__wrapper.multi { 103 + .image-embed__row { 104 + overflow-x: auto; 105 + -webkit-overflow-scrolling: touch; 106 + scrollbar-width: none; 107 + cursor: grab; 90 108 91 - .count-3 { 92 - grid-template-columns: 1fr 1fr; 93 - grid-template-rows: 1fr 1fr; 94 - aspect-ratio: 4 / 3; 109 + &:active { 110 + cursor: grabbing; 111 + } 95 112 96 - .image-container:nth-child(1) { 97 - grid-row: 1 / -1; 98 - border-radius: var(--radius-md) var(--radius-xsm) var(--radius-xsm) var(--radius-md); 113 + &::-webkit-scrollbar { 114 + display: none; 115 + } 99 116 } 100 - .image-container:nth-child(2) { 101 - border-radius: var(--radius-xsm) var(--radius-md) var(--radius-xsm) var(--radius-xsm); 102 - } 103 - .image-container:nth-child(3) { 104 - border-radius: var(--radius-xsm) var(--radius-xsm) var(--radius-md) var(--radius-xsm); 105 - } 106 - } 107 117 108 - .count-4 { 109 - grid-template-columns: 1fr 1fr; 110 - grid-template-rows: 1fr 1fr; 111 - aspect-ratio: 4 / 3; 118 + .image-container { 119 + flex: 0 0 auto; 120 + scroll-snap-align: start; 121 + height: 16rem; 122 + max-width: 85vw; 123 + min-width: 4rem; 112 124 113 - .image-container:nth-child(1) { 114 - border-radius: var(--radius-md) var(--radius-xsm) var(--radius-xsm) var(--radius-xsm); 115 - } 116 - .image-container:nth-child(2) { 117 - border-radius: var(--radius-xsm) var(--radius-md) var(--radius-xsm) var(--radius-xsm); 118 - } 119 - .image-container:nth-child(3) { 120 - border-radius: var(--radius-xsm) var(--radius-xsm) var(--radius-xsm) var(--radius-md); 121 - } 122 - .image-container:nth-child(4) { 123 - border-radius: var(--radius-xsm) var(--radius-xsm) var(--radius-md) var(--radius-xsm); 125 + img { 126 + object-fit: contain; 127 + width: auto; 128 + height: 100%; 129 + max-width: 100%; 130 + pointer-events: none; 131 + } 124 132 } 125 133 } 126 134 </style>
+321 -193
src/components/Feed/FeedItem.vue
··· 8 8 IconFavoriteOutlineRounded, 9 9 IconFavoriteRounded, 10 10 IconMoreVert, 11 - IconSendRounded, 12 11 IconBookmarkRounded, 13 12 IconBookmarkAddedRounded, 14 13 IconFormatQuoteRounded, 14 + IconContentCopyRounded, 15 + IconLinkRounded, 16 + IconOpenInNewRounded, 17 + IconForwardRounded, 15 18 } from '@iconify-prerendered/vue-material-symbols' 16 19 17 20 import { useNavigationStore } from '@/stores/navigation' ··· 108 111 } 109 112 } 110 113 114 + const handleShare = () => { 115 + if (displayPost.value) { 116 + const url = `${window.location.origin}/profile/${displayPost.value.author.handle}/post/${rkey.value}` 117 + if (navigator.share) { 118 + navigator 119 + .share({ 120 + title: 'Check out this post on Bluesky', 121 + url, 122 + }) 123 + .then(() => tap()) 124 + .catch((error) => console.error('Error sharing', error)) 125 + return 126 + } 127 + tap() 128 + } 129 + } 130 + 131 + const share = { 132 + systemShare: () => { 133 + if (displayPost.value) { 134 + const url = `${window.location.origin}/profile/${displayPost.value.author.handle}/post/${rkey.value}` 135 + if (navigator.share) { 136 + navigator 137 + .share({ 138 + title: 'Check out this post on Bluesky', 139 + url, 140 + }) 141 + .then(() => tap()) 142 + .catch((error) => console.error('Error sharing', error)) 143 + } 144 + } 145 + }, 146 + copyLink: () => { 147 + if (displayPost.value) { 148 + const url = `${window.location.origin}/profile/${displayPost.value.author.handle}/post/${rkey.value}` 149 + navigator.clipboard.writeText(url) 150 + tap() 151 + } 152 + }, 153 + copyBlueskyLink: () => { 154 + if (displayPost.value) { 155 + const bskyUrl = `https://bsky.app/profile/${displayPost.value.author.did}/post/${rkey.value}` 156 + navigator.clipboard.writeText(bskyUrl) 157 + tap() 158 + } 159 + }, 160 + copyATUri: () => { 161 + if (displayPost.value) { 162 + navigator.clipboard.writeText(displayPost.value.uri) 163 + tap() 164 + } 165 + }, 166 + copyDid: () => { 167 + if (displayPost.value) { 168 + navigator.clipboard.writeText(displayPost.value.author.did) 169 + tap() 170 + } 171 + }, 172 + openInPDSls: () => { 173 + if (displayPost.value) { 174 + const url = `https://pds.ls/${displayPost.value.uri}` 175 + window.open(url, '_blank') 176 + } 177 + }, 178 + } 179 + 111 180 const handleClick = (e: MouseEvent) => { 112 181 if (window.getSelection()?.toString().length) return 113 182 if (props.rootPost) return ··· 207 276 </div> 208 277 209 278 <div class="post-layout"> 210 - <AppLink name="user-profile" :params="{ id: displayPost.author.did }" class="post-avatar"> 211 - <img 212 - v-if="displayPost.author.avatar" 213 - :src="displayPost.author.avatar" 214 - alt="avatar" 215 - loading="lazy" 216 - /> 217 - <div v-else class="avatar-fallback"></div> 218 - </AppLink> 279 + <div class="post-top"> 280 + <AppLink name="user-profile" :params="{ id: displayPost.author.did }" class="post-avatar"> 281 + <img 282 + v-if="displayPost.author.avatar" 283 + :src="displayPost.author.avatar" 284 + alt="avatar" 285 + loading="lazy" 286 + /> 287 + <div v-else class="avatar-fallback"></div> 288 + </AppLink> 219 289 220 - <div class="post-content"> 221 - <div class="post-header"> 222 - <div class="post-header__part"> 223 - <AppLink 224 - class="display-name" 225 - name="user-profile" 226 - :params="{ id: displayPost.author.did }" 227 - > 228 - {{ displayPost.author.displayName || displayPost.author.handle }} 229 - </AppLink> 290 + <div class="not-gutter"> 291 + <div class="post-header"> 292 + <div class="post-header__part"> 293 + <AppLink 294 + class="display-name" 295 + name="user-profile" 296 + :params="{ id: displayPost.author.did }" 297 + > 298 + {{ displayPost.author.displayName || displayPost.author.handle }} 299 + </AppLink> 230 300 231 - <p v-if="displayPost.author.pronouns" class="pronouns"> 232 - {{ displayPost.author.pronouns }} 233 - </p> 301 + <p v-if="displayPost.author.pronouns" class="pronouns"> 302 + {{ displayPost.author.pronouns }} 303 + </p> 304 + </div> 305 + <div class="post-header__part"> 306 + <p class="time" :title="new Date(displayPost.indexedAt).toLocaleString()"> 307 + {{ formatTime(displayPost.indexedAt) }} 308 + </p> 309 + </div> 234 310 </div> 235 - <div class="post-header__part"> 236 - <p class="time" :title="new Date(displayPost.indexedAt).toLocaleString()"> 237 - {{ formatTime(displayPost.indexedAt) }} 238 - </p> 311 + 312 + <div class="post-content"> 313 + <div class="post-text" v-if="displayPost.record?.text"> 314 + {{ displayPost.record.text }} 315 + </div> 239 316 </div> 240 317 </div> 241 - 242 - <div class="post-text" v-if="displayPost.record?.text"> 243 - {{ displayPost.record.text }} 244 - </div> 318 + </div> 245 319 246 - <div class="post-embeds" v-if="embed"> 247 - <ImageEmbed v-if="embed.$type === 'app.bsky.embed.images#view'" :embed="embed" /> 248 - <ExternalEmbed 249 - v-else-if="embed.$type === 'app.bsky.embed.external#view'" 250 - :embed="embed.external" 320 + <div class="post-embeds" v-if="embed"> 321 + <ImageEmbed v-if="embed.$type === 'app.bsky.embed.images#view'" :embed="embed" /> 322 + <ExternalEmbed 323 + v-else-if="embed.$type === 'app.bsky.embed.external#view'" 324 + :embed="embed.external" 325 + /> 326 + <VideoEmbed v-else-if="embed.$type === 'app.bsky.embed.video#view'" :embed="embed" /> 327 + <template v-else-if="embed.$type === 'app.bsky.embed.record#view'"> 328 + <EmbedRecord v-if="!embedded" :embed="embed" /> 329 + <div v-else class="embedded-record">Post has nested quote.</div> 330 + </template> 331 + <template v-else-if="embed.$type === 'app.bsky.embed.recordWithMedia#view'"> 332 + <ImageEmbed 333 + v-if="embed.media.$type === 'app.bsky.embed.images#view'" 334 + :embed="embed.media" 251 335 /> 252 - <VideoEmbed v-else-if="embed.$type === 'app.bsky.embed.video#view'" :embed="embed" /> 253 - <template v-else-if="embed.$type === 'app.bsky.embed.record#view'"> 254 - <EmbedRecord v-if="!embedded" :embed="embed" /> 255 - <div v-else class="embedded-record">Post has nested quote.</div> 256 - </template> 257 - <template v-else-if="embed.$type === 'app.bsky.embed.recordWithMedia#view'"> 258 - <ImageEmbed 259 - v-if="embed.media.$type === 'app.bsky.embed.images#view'" 260 - :embed="embed.media" 261 - /> 262 - <EmbedRecord 263 - v-if="embed.record.$type === 'app.bsky.embed.record#view'" 264 - :embed="embed.record" 265 - /> 266 - </template> 267 - </div> 336 + <EmbedRecord 337 + v-if="embed.record.$type === 'app.bsky.embed.record#view'" 338 + :embed="embed.record" 339 + /> 340 + </template> 341 + </div> 268 342 269 - <div class="post-footer" v-if="!embedded"> 270 - <div class="metrics row"> 271 - <AppLink 272 - name="post-thread" 273 - :params="{ identifier: displayPost.author.handle, rkey: rkey! }" 274 - class="action-button reply" 275 - aria-label="Reply" 276 - @click.stop 277 - > 278 - <div class="icon-wrapper"><IconChatBubbleOutlineRounded /></div> 279 - <span class="count" v-if="displayPost.replyCount && displayPost.replyCount > 0"> 280 - {{ formatCount(displayPost.replyCount) }} 281 - </span> 282 - </AppLink> 343 + <div class="post-footer" v-if="!embedded"> 344 + <div class="metrics row"> 345 + <AppLink 346 + name="post-thread" 347 + :params="{ identifier: displayPost.author.handle, rkey: rkey! }" 348 + class="action-button reply" 349 + aria-label="Reply" 350 + @click.stop 351 + > 352 + <div class="icon-wrapper"><IconChatBubbleOutlineRounded /></div> 353 + <span class="count" v-if="displayPost.replyCount && displayPost.replyCount > 0"> 354 + {{ formatCount(displayPost.replyCount) }} 355 + </span> 356 + </AppLink> 283 357 284 - <BasePopover 285 - :actions="[ 286 - { 287 - label: !!displayPost.viewer?.repost ? 'Undo Repost' : 'Repost', 288 - icon: IconRepeatRounded, 289 - onClick: handleRepost, 290 - }, 291 - { 292 - label: 'Quote Post', 293 - icon: IconFormatQuoteRounded, 294 - onClick: () => {}, 295 - }, 296 - ]" 297 - align="right" 298 - > 299 - <template #trigger="{ triggerProps }"> 300 - <button 301 - class="action-button repost more" 302 - :class="{ 'is-active': !!displayPost.viewer?.repost }" 303 - v-bind="triggerProps as any" 304 - > 305 - <div class="icon-wrapper"><IconRepeatRounded /></div> 306 - <span class="count" v-if="displayPost.repostCount && displayPost.repostCount > 0"> 307 - {{ formatCount(displayPost.repostCount) }} 308 - </span> 309 - </button> 310 - </template> 311 - </BasePopover> 358 + <BasePopover 359 + :actions="[ 360 + { 361 + label: !!displayPost.viewer?.repost ? 'Undo Repost' : 'Repost', 362 + icon: IconRepeatRounded, 363 + onClick: handleRepost, 364 + }, 365 + { 366 + label: 'Quote Post', 367 + icon: IconFormatQuoteRounded, 368 + onClick: () => {}, 369 + }, 370 + ]" 371 + align="right" 372 + > 373 + <template #trigger="{ triggerProps }"> 374 + <button 375 + class="action-button repost more" 376 + :class="{ 'is-active': !!displayPost.viewer?.repost }" 377 + v-bind="triggerProps as any" 378 + > 379 + <div class="icon-wrapper"><IconRepeatRounded /></div> 380 + <span class="count" v-if="displayPost.repostCount && displayPost.repostCount > 0"> 381 + {{ formatCount(displayPost.repostCount) }} 382 + </span> 383 + </button> 384 + </template> 385 + </BasePopover> 312 386 313 - <button 314 - class="action-button like" 315 - :class="{ 'is-active': !!displayPost.viewer?.like }" 316 - @click.stop="handleLike" 317 - aria-label="Like" 318 - > 319 - <div class="icon-wrapper"> 320 - <IconFavoriteRounded v-if="!!displayPost.viewer?.like" /> 321 - <IconFavoriteOutlineRounded v-else /> 322 - </div> 323 - <span class="count" v-if="displayPost.likeCount && displayPost.likeCount > 0"> 324 - {{ formatCount(displayPost.likeCount) }} 325 - </span> 326 - </button> 327 - </div> 387 + <button 388 + class="action-button like" 389 + :class="{ 'is-active': !!displayPost.viewer?.like }" 390 + @click.stop="handleLike" 391 + aria-label="Like" 392 + > 393 + <div class="icon-wrapper"> 394 + <IconFavoriteRounded v-if="!!displayPost.viewer?.like" /> 395 + <IconFavoriteOutlineRounded v-else /> 396 + </div> 397 + <span class="count" v-if="displayPost.likeCount && displayPost.likeCount > 0"> 398 + {{ formatCount(displayPost.likeCount) }} 399 + </span> 400 + </button> 401 + </div> 402 + 403 + <div class="footer-content row"> 404 + <BasePopover 405 + :actions="[ 406 + { 407 + actions: [ 408 + { label: 'System share', icon: IconForwardRounded, onClick: handleShare }, 409 + ], 410 + }, 411 + { 412 + actions: [ 413 + { 414 + label: 'Copy Link', 415 + icon: IconLinkRounded, 416 + onClick: share.copyLink, 417 + }, 418 + { 419 + label: 'Copy Bluesky link', 420 + icon: IconContentCopyRounded, 421 + onClick: share.copyBlueskyLink, 422 + }, 423 + ], 424 + }, 425 + { 426 + actions: [ 427 + { 428 + label: 'Copy AT URI', 429 + icon: IconContentCopyRounded, 430 + onClick: share.copyBlueskyLink, 431 + }, 432 + { 433 + label: 'Copy author DID', 434 + icon: IconLinkRounded, 435 + onClick: share.copyDid, 436 + }, 437 + { 438 + label: 'Open in PDSls', 439 + icon: IconOpenInNewRounded, 440 + onClick: share.openInPDSls, 441 + }, 442 + ], 443 + }, 444 + ]" 445 + align="right" 446 + > 447 + <template #trigger="{ triggerProps }"> 448 + <button class="action-button more" v-bind="triggerProps as any"> 449 + <div class="icon-wrapper"> 450 + <IconForwardRounded /> 451 + </div> 452 + </button> 453 + </template> 454 + </BasePopover> 328 455 329 - <div class="footer-content row"> 330 - <BasePopover 331 - :actions="[ 332 - { 333 - actions: [ 334 - { label: 'Share', icon: IconSendRounded, onClick: () => {} }, 335 - { 336 - label: displayPost.viewer?.bookmarked ? 'Remove Bookmark' : 'Bookmark', 337 - icon: displayPost.viewer?.bookmarked 338 - ? IconBookmarkAddedRounded 339 - : IconBookmarkRounded, 340 - onClick: handleBookmark, 341 - }, 342 - ], 343 - }, 344 - { 345 - label: 'awawa', 346 - actions: [ 347 - { label: 'Share', icon: IconSendRounded, onClick: () => {}, variant: 'danger' }, 348 - { 349 - label: displayPost.viewer?.bookmarked ? 'Remove Bookmark' : 'Bookmark', 350 - icon: displayPost.viewer?.bookmarked 351 - ? IconBookmarkAddedRounded 352 - : IconBookmarkRounded, 353 - onClick: handleBookmark, 354 - disabled: true, 355 - }, 356 - ], 357 - }, 358 - ]" 359 - align="right" 360 - > 361 - <template #trigger="{ triggerProps }"> 362 - <button class="action-button more" v-bind="triggerProps as any"> 363 - <div class="icon-wrapper"> 364 - <IconMoreVert /> 365 - </div> 366 - </button> 367 - </template> 368 - </BasePopover> 369 - </div> 456 + <BasePopover 457 + :actions="[ 458 + { 459 + actions: [ 460 + { 461 + label: displayPost.viewer?.bookmarked ? 'Remove Bookmark' : 'Bookmark', 462 + icon: displayPost.viewer?.bookmarked 463 + ? IconBookmarkAddedRounded 464 + : IconBookmarkRounded, 465 + onClick: handleBookmark, 466 + }, 467 + ], 468 + }, 469 + ]" 470 + align="right" 471 + > 472 + <template #trigger="{ triggerProps }"> 473 + <button class="action-button more" v-bind="triggerProps as any"> 474 + <div class="icon-wrapper"> 475 + <IconMoreVert /> 476 + </div> 477 + </button> 478 + </template> 479 + </BasePopover> 370 480 </div> 371 481 </div> 372 482 </div> ··· 375 485 376 486 <style lang="scss" scoped> 377 487 .feed-item { 378 - padding: 0.75rem 1rem; 379 488 display: flex; 380 489 flex-direction: column; 381 490 gap: 0.25rem; 491 + padding-top: 0.5rem; 492 + padding-bottom: 0.5rem; 382 493 383 494 opacity: 0; 384 495 filter: blur(4px); ··· 465 576 } 466 577 467 578 .post-layout { 579 + --gutter-width: calc(2.75rem + 1rem); 468 580 display: flex; 469 - gap: 0.75rem; 581 + flex-direction: column; 582 + 583 + .post-top { 584 + padding: 0 0.75rem; 585 + display: flex; 586 + gap: 0.75rem; 587 + .not-gutter { 588 + width: 100%; 589 + } 590 + } 470 591 471 592 .post-avatar { 472 593 flex-shrink: 0; ··· 490 611 } 491 612 } 492 613 493 - .post-content { 494 - flex: 1; 495 - min-width: 0; 614 + .post-header { 496 615 display: flex; 497 - flex-direction: column; 616 + align-items: last baseline; 617 + flex-direction: row; 618 + justify-content: space-between; 619 + 620 + gap: 0.5rem; 621 + font-size: 1rem; 622 + line-height: 1.3; 498 623 499 - .post-header { 624 + &__part { 500 625 display: flex; 501 - align-items: last baseline; 502 626 flex-direction: row; 503 - justify-content: space-between; 504 - 627 + align-items: last baseline; 505 628 gap: 0.5rem; 506 - font-size: 1rem; 507 - line-height: 1.3; 508 - margin-bottom: 0.125rem; 509 - 510 - &__part { 511 - display: flex; 512 - flex-direction: row; 513 - align-items: last baseline; 514 - gap: 0.5rem; 515 - } 516 - 517 - * { 518 - min-width: 0; 519 - text-wrap: nowrap; 520 - text-overflow: ellipsis; 521 - overflow: hidden; 522 - } 523 - 524 - .display-name { 525 - font-weight: 700; 526 - color: hsl(var(--text)); 527 - } 629 + } 528 630 529 - p { 530 - font-size: 0.85rem; 531 - color: hsla(var(--overlay0) / 1); 532 - font-weight: 700; 533 - } 631 + * { 632 + min-width: 0; 633 + text-wrap: nowrap; 634 + text-overflow: ellipsis; 635 + overflow: hidden; 534 636 } 535 637 536 - .post-text { 638 + .display-name { 639 + font-weight: 700; 537 640 color: hsl(var(--text)); 538 - font-size: 0.95rem; 539 - line-height: 1.4; 540 - white-space: pre-wrap; 541 - word-wrap: break-word; 542 641 } 543 642 643 + p { 644 + font-size: 0.85rem; 645 + color: hsla(var(--overlay0) / 1); 646 + font-weight: 700; 647 + } 648 + } 649 + 650 + .post-text { 651 + color: hsl(var(--text)); 652 + font-size: 0.95rem; 653 + line-height: 1.4; 654 + white-space: pre-wrap; 655 + word-wrap: break-word; 656 + } 657 + 658 + .post-content { 659 + flex: 1; 660 + min-width: 0; 661 + display: flex; 662 + flex-direction: column; 663 + 544 664 .embedded-record { 545 665 padding: 0.5rem; 546 666 border: 1px solid hsla(var(--surface2) / 0.5); ··· 553 673 } 554 674 } 555 675 676 + .post-embeds { 677 + &:not(:has(.image-embed__wrapper)) { 678 + padding: 0 0.75rem; 679 + padding-left: calc(var(--gutter-width)); 680 + } 681 + } 682 + 556 683 .post-footer { 557 684 display: flex; 558 685 align-items: center; 559 686 justify-content: space-between; 560 687 margin-top: 0.5rem; 561 - margin-left: -0.5rem; 688 + padding: 0 0.75rem; 689 + margin-left: calc(-0.75rem + var(--gutter-width)); 562 690 563 691 .row { 564 692 display: flex;
-3
src/stores/posts.ts
··· 96 96 const originalBookmarked = post.viewer?.bookmarked 97 97 const originalCount = post.bookmarkCount || 0 98 98 99 - console.log(post.viewer) 100 - console.log('Toggling bookmark', { uri, originalBookmarked, originalCount }) 101 - 102 99 if (!post.viewer) post.viewer = {} 103 100 104 101 if (originalBookmarked) {
+69
src/utils/identity.ts
··· 1 + import type { DidDocument } from '@atcute/identity' 2 + import { 3 + CompositeDidDocumentResolver, 4 + CompositeHandleResolver, 5 + DohJsonHandleResolver, 6 + PlcDidDocumentResolver, 7 + WebDidDocumentResolver, 8 + WellKnownHandleResolver, 9 + } from '@atcute/identity-resolver' 10 + import type { Handle } from '@atcute/lexicons' 11 + import { type AtprotoDid, isDid, isHandle } from '@atcute/lexicons/syntax' 12 + 13 + const handleResolver = new CompositeHandleResolver({ 14 + methods: { 15 + dns: new DohJsonHandleResolver({ 16 + dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query', 17 + }), 18 + http: new WellKnownHandleResolver(), 19 + }, 20 + }) 21 + 22 + const didResolver = new CompositeDidDocumentResolver({ 23 + methods: { 24 + plc: new PlcDidDocumentResolver(), 25 + web: new WebDidDocumentResolver(), 26 + }, 27 + }) 28 + 29 + export async function resolveHandle(handle: Handle): Promise<AtprotoDid> { 30 + return handleResolver.resolve(handle) 31 + } 32 + 33 + type Identity = { 34 + doc: DidDocument 35 + handle?: string 36 + pds?: string 37 + } 38 + export async function resolveDid(did: AtprotoDid): Promise<Identity> { 39 + const doc = await didResolver.resolve(did) 40 + 41 + const serviceEntry = doc.service?.find((service) => service.type === 'AtprotoPersonalDataServer') 42 + let pds: string | undefined 43 + if (serviceEntry) { 44 + const endpoint = serviceEntry.serviceEndpoint 45 + 46 + if (typeof endpoint === 'string') pds = endpoint 47 + else if (Array.isArray(endpoint)) { 48 + const firstString = endpoint.find((item) => typeof item === 'string') 49 + if (typeof firstString === 'string') pds = firstString 50 + } 51 + } 52 + 53 + let handle: string | undefined 54 + const alsoKnown = doc.alsoKnownAs?.find((alsoKnown) => typeof alsoKnown === 'string') 55 + if (alsoKnown) handle = alsoKnown 56 + 57 + return { doc, pds, handle } 58 + } 59 + 60 + export async function getDid(input: string): Promise<AtprotoDid> { 61 + if (isDid(input)) return input as AtprotoDid 62 + else if (isHandle(input)) return resolveHandle(input) 63 + else throw new Error('Invalid input') 64 + } 65 + 66 + export async function getIdentity(input: string): Promise<Identity> { 67 + const did = await getDid(input) 68 + return resolveDid(did) 69 + }