JavaScript-optional public web frontend for Bluesky anartia.kelinci.net
sveltekit atcute bluesky typescript svelte
7
fork

Configure Feed

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

feat: unroll page

Mary 73118956 b361650b

+289 -39
+2 -1
package.json
··· 31 31 "@atcute/bluesky-richtext-segmenter": "^1.0.5", 32 32 "@atcute/client": "^2.0.7", 33 33 "@badrap/valita": "^0.4.2", 34 - "@mary/array-fns": "npm:@jsr/mary__array-fns@^0.1.2", 34 + "@mary/array-fns": "npm:@jsr/mary__array-fns@^0.1.4", 35 + "@mary/date-fns": "npm:@jsr/mary__date-fns@^0.1.2", 35 36 "hls.js": "^1.5.20" 36 37 }, 37 38 "pnpm": {
+13 -5
pnpm-lock.yaml
··· 24 24 specifier: ^0.4.2 25 25 version: 0.4.2 26 26 '@mary/array-fns': 27 - specifier: npm:@jsr/mary__array-fns@^0.1.2 28 - version: '@jsr/mary__array-fns@0.1.2' 27 + specifier: npm:@jsr/mary__array-fns@^0.1.4 28 + version: '@jsr/mary__array-fns@0.1.4' 29 + '@mary/date-fns': 30 + specifier: npm:@jsr/mary__date-fns@^0.1.2 31 + version: '@jsr/mary__date-fns@0.1.2' 29 32 hls.js: 30 33 specifier: ^1.5.20 31 34 version: 1.5.20 ··· 557 560 '@jridgewell/trace-mapping@0.3.9': 558 561 resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} 559 562 560 - '@jsr/mary__array-fns@0.1.2': 561 - resolution: {integrity: sha512-g1fq3dvBEmM/Mb14Ic3W++GoIO6bUlkB3Uz8JqyVcwQfz4vUzmT1erO2+DKjw+ux25RJCsd0EC9FjuicQ7E3+Q==, tarball: https://npm.jsr.io/~/11/@jsr/mary__array-fns/0.1.2.tgz} 563 + '@jsr/mary__array-fns@0.1.4': 564 + resolution: {integrity: sha512-+HbGYR9Ll5blEmAvVAoPejyGj01YeBbVmJ59qxaMDKt5i3F90ohYLA5a78y6AULDlet1IxYB+a/cMN+A0vGnDg==, tarball: https://npm.jsr.io/~/11/@jsr/mary__array-fns/0.1.4.tgz} 565 + 566 + '@jsr/mary__date-fns@0.1.2': 567 + resolution: {integrity: sha512-tRBbtJfgESGkP3p2qLG1IexQT2u0gdsB4o346Uu9fGpiqmG9Tte5tldEBLaAo8IdFnOsA44Pi2P6iSDTgsVCMg==, tarball: https://npm.jsr.io/~/11/@jsr/mary__date-fns/0.1.2.tgz} 562 568 563 569 '@polka/url@1.0.0-next.28': 564 570 resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==} ··· 1434 1440 '@jridgewell/resolve-uri': 3.1.2 1435 1441 '@jridgewell/sourcemap-codec': 1.5.0 1436 1442 1437 - '@jsr/mary__array-fns@0.1.2': {} 1443 + '@jsr/mary__array-fns@0.1.4': {} 1444 + 1445 + '@jsr/mary__date-fns@0.1.2': {} 1438 1446 1439 1447 '@polka/url@1.0.0-next.28': {} 1440 1448
+15
src/lib/components/central-icons/bubbles-outlined.svelte
··· 1 + <svg class="sv-icon" fill="none" viewBox="0 0 24 24" 2 + ><path 3 + stroke="currentColor" 4 + stroke-linecap="round" 5 + stroke-linejoin="round" 6 + stroke-width="2" 7 + d="M3.002 8h14v10h-6.5l-4.5 2.5V18h-3V8Z" 8 + /><path 9 + stroke="currentColor" 10 + stroke-linecap="round" 11 + stroke-linejoin="round" 12 + stroke-width="2" 13 + d="M17 14h4.002V4h-14v4" 14 + /></svg 15 + >
+9
src/lib/components/central-icons/thread-outlined.svelte
··· 1 + <svg class="sv-icon" fill="none" viewBox="0 0 24 24" 2 + ><path 3 + stroke="currentColor" 4 + stroke-linecap="round" 5 + stroke-linejoin="round" 6 + stroke-width="2" 7 + d="M6 4v4.5M6 4H5m1 0h5M6 8.5v5m0-5 12 2m-12 3V20m0-6.5 12 2M6 20H5m1 0h5m7-9.5V4m0 6.5v5M18 4h1m-1 0h-5m5 11.5V20m0-4.5 3 .5m-3 4h1m-1 0h-5M11 4v-.5a1 1 0 1 1 2 0V4m-2 0h2m-2 16v.5a1 1 0 0 0 1 1v0a1 1 0 0 0 1-1V20m-2 0h2" 8 + /></svg 9 + >
+6 -2
src/lib/types/at-uri.ts
··· 29 29 }; 30 30 }; 31 31 32 - export const makeAtUri = (repo: string, collection: keyof Records | (string & {}), rkey: string): AtUri => { 33 - return `at://${repo}/${collection}/${rkey}`; 32 + export const makeAtUri = ( 33 + repo: Did | Handle, 34 + collection: keyof Records | (Nsid & {}), 35 + rkey: string, 36 + ): AtUri => { 37 + return `at://${repo}/${collection as Nsid}/${rkey}`; 34 38 };
+27 -13
src/lib/utils/intl/date.ts
··· 5 5 let startOfYear = 0; 6 6 let endOfYear = 0; 7 7 8 - const fmtAbsoluteLong = new Intl.DateTimeFormat('en-US', { 8 + const fmtTime = new Intl.DateTimeFormat('en-US', { 9 + timeZone: timezone, 10 + timeStyle: 'short', 11 + }); 12 + const fmtDateTime = new Intl.DateTimeFormat('en-US', { 9 13 timeZone: timezone, 10 14 dateStyle: 'long', 11 15 timeStyle: 'short', 12 16 }); 13 - const fmtAbsShortWithYear = new Intl.DateTimeFormat('en-US', { 17 + const fmtShortDateWithYear = new Intl.DateTimeFormat('en-US', { 14 18 timeZone: timezone, 15 19 dateStyle: 'medium', 16 20 }); 17 - const fmtAbsShort = new Intl.DateTimeFormat('en-US', { 21 + const fmtShortDate = new Intl.DateTimeFormat('en-US', { 18 22 timeZone: timezone, 19 23 month: 'short', 20 24 day: 'numeric', 21 25 }); 22 26 23 - export const formatShortDate = (date: string | number): string => { 27 + export const formatShortDate = (date: Date | string | number): string => { 24 28 const inst = new Date(date); 25 29 const time = inst.getTime(); 26 30 27 - if (isNaN(time)) { 31 + if (Number.isNaN(time)) { 28 32 return 'N/A'; 29 33 } 30 34 ··· 42 46 } 43 47 44 48 if (time >= startOfYear && time <= endOfYear) { 45 - return fmtAbsShort.format(inst); 49 + return fmtShortDate.format(inst); 46 50 } 47 51 48 - return fmtAbsShortWithYear.format(inst); 52 + return fmtShortDateWithYear.format(inst); 49 53 }; 50 54 51 - export const formatLongDate = (date: string | number): string => { 55 + export const formatTime = (date: Date | string | number): string => { 52 56 const inst = new Date(date); 53 57 54 - if (isNaN(inst.getTime())) { 58 + if (Number.isNaN(inst.getTime())) { 55 59 return 'N/A'; 56 60 } 57 61 58 - return fmtAbsoluteLong.format(inst); 62 + return fmtTime.format(inst); 63 + }; 64 + 65 + export const formatLongDate = (date: Date | string | number): string => { 66 + const inst = new Date(date); 67 + 68 + if (Number.isNaN(inst.getTime())) { 69 + return 'N/A'; 70 + } 71 + 72 + return fmtDateTime.format(inst); 59 73 }; 60 74 61 75 const relativeFormatters: Record<string, Intl.NumberFormat> = {}; ··· 67 81 const DAY = HOUR * 24; 68 82 const WEEK = DAY * 7; 69 83 70 - export const formatRelativeTime = (date: string | number): string => { 84 + export const formatRelativeTime = (date: Date | string | number): string => { 71 85 const time = new Date(date).getTime(); 72 86 73 87 const now = Date.now(); ··· 88 102 89 103 // if it happened this year, don't show the year. 90 104 if (time >= startOfYear && time <= endOfYear) { 91 - return fmtAbsShort.format(time); 105 + return fmtShortDate.format(time); 92 106 } 93 107 94 - return fmtAbsShortWithYear.format(time); 108 + return fmtShortDateWithYear.format(time); 95 109 } 96 110 97 111 if (delta < NOW) {
+6
src/routes/(app)/[actor=didOrHandle]/[rkey=tid]/components/main-post.svelte
··· 8 8 9 9 import Avatar from '$lib/components/avatar.svelte'; 10 10 import SquareArrowTopRightOutlined from '$lib/components/central-icons/square-arrow-top-right-outlined.svelte'; 11 + import ThreadOutlined from '$lib/components/central-icons/thread-outlined.svelte'; 11 12 import ContentHider from '$lib/components/content-hider.svelte'; 12 13 import Embeds from '$lib/components/embeds/embeds.svelte'; 13 14 import LongDate from '$lib/components/islands/long-date.svelte'; ··· 61 62 <OverflowMenu 62 63 class="post-actions" 63 64 items={[ 65 + { 66 + label: `Unroll thread`, 67 + href: `${base}/${author.did}/${uri.rkey}/unroll`, 68 + icon: ThreadOutlined, 69 + }, 64 70 { 65 71 label: `Open in Bluesky app`, 66 72 href: `https://bsky.app/profile/${author.did}/post/${uri.rkey}`,
+211 -12
src/routes/(app)/[actor=did]/[rkey=tid]/unroll/+page.svelte
··· 1 1 <script lang="ts"> 2 + import type { AppBskyFeedPost } from '@atcute/client/lexicons'; 3 + import { cluster } from '@mary/array-fns'; 4 + import { isSameCalendarDate } from '@mary/date-fns'; 5 + 6 + import { base } from '$app/paths'; 7 + import { PUBLIC_APP_NAME } from '$env/static/public'; 2 8 import type { PageProps } from './$types'; 3 9 10 + import { findLabel, FlagsBlurMedia } from '$lib/moderation'; 11 + import { parseAtUri } from '$lib/types/at-uri'; 12 + import { formatLongDate, formatTime } from '$lib/utils/intl/date'; 13 + import { truncateMiddle, truncateRight } from '$lib/utils/strings'; 14 + 15 + import Avatar from '$lib/components/avatar.svelte'; 16 + import BubblesOutlined from '$lib/components/central-icons/bubbles-outlined.svelte'; 17 + import Embeds from '$lib/components/embeds/embeds.svelte'; 18 + import OverflowMenu from '$lib/components/overflow-menu.svelte'; 4 19 import PageContainer from '$lib/components/page/page-container.svelte'; 5 - import PostFeedItem from '$lib/components/timeline/post-feed-item.svelte'; 20 + import RichtextRenderer from '$lib/components/richtext-renderer.svelte'; 6 21 7 22 const { data }: PageProps = $props(); 23 + 24 + const main = $derived(data.posts[0]); 25 + 26 + const author = $derived(main.author); 27 + const authorName = $derived(author.displayName?.trim()); 28 + 29 + const uri = $derived(parseAtUri(main.uri)); 30 + const postUrl = $derived(`${base}/${uri.repo}/${uri.rkey}#main`); 31 + const authorUrl = $derived(`${base}/${uri.repo}`); 32 + 33 + const isAviBlurred = $derived(!!findLabel(author.labels, author.did, FlagsBlurMedia)); 34 + 35 + const title = $derived.by(() => { 36 + const author = `@${truncateMiddle(main.author.handle, 29)}`; 37 + const content = truncateRight((main.record as AppBskyFeedPost.Record).text.trim(), 70); 38 + 39 + return `${author}: "${content}" — ${PUBLIC_APP_NAME}`; 40 + }); 41 + 42 + const clusters = $derived.by(() => { 43 + return cluster(data.posts, (_a, b, [first]) => { 44 + const bDate = new Date(b.indexedAt); 45 + const firstDate = new Date(first.indexedAt); 46 + 47 + const diff = bDate.getTime() - firstDate.getTime(); 48 + return diff < 2.5 * 60 * 1_000; 49 + }); 50 + }); 8 51 </script> 9 52 53 + <svelte:head> 54 + <title>{title}</title> 55 + <link rel="canonical" href="https://bsky.app/profile/{uri.repo}/post/{uri.rkey}" /> 56 + <link rel="alternate" href={main.uri} /> 57 + </svelte:head> 58 + 10 59 <PageContainer> 11 60 <div class="unroll-page"> 12 - {#each data.posts as post, idx (post.uri)} 13 - <PostFeedItem 14 - item={{ 15 - id: post.uri, 16 - post, 17 - reply: undefined, 18 - reason: undefined, 19 - next: idx !== data.posts.length - 1, 20 - prev: idx !== 0, 21 - }} 61 + <div class="profile"> 62 + <Avatar profile={author} size="lg" tabindex={-1} href={authorUrl} blur={isAviBlurred} /> 63 + 64 + <a href={authorUrl} class="name-wrapper"> 65 + {#if authorName} 66 + <bdi class="display-name-wrapper"> 67 + <span class="display-name">{authorName}</span> 68 + </bdi> 69 + {/if} 70 + 71 + <span class="handle">@{author.handle}</span> 72 + </a> 73 + 74 + <OverflowMenu 75 + class="thread-actions" 76 + items={[ 77 + { 78 + label: `View original thread`, 79 + icon: BubblesOutlined, 80 + href: postUrl, 81 + }, 82 + ]} 22 83 /> 23 - {/each} 84 + </div> 85 + 86 + <ol class="thread"> 87 + {#each clusters as cluster, idx} 88 + {@const first = cluster[0]} 89 + {@const date = new Date(first.indexedAt)} 90 + 91 + {@const prevCluster = clusters[idx - 1] as typeof cluster | undefined} 92 + {@const prevClusterDate = prevCluster && new Date(prevCluster[0].indexedAt)} 93 + 94 + <li class="item" role="article"> 95 + <div class="aside"> 96 + <div class="dot"></div> 97 + 98 + {#if idx !== clusters.length - 1} 99 + <div class="line"></div> 100 + {/if} 101 + </div> 102 + 103 + <div class="content"> 104 + <p class="meta"> 105 + <span class="date"> 106 + {#if !prevClusterDate || !isSameCalendarDate(date, prevClusterDate)} 107 + {formatLongDate(date)} 108 + {:else} 109 + {formatTime(date)} 110 + {/if} 111 + </span> 112 + </p> 113 + 114 + {#each cluster as post} 115 + {@const record = post.record as AppBskyFeedPost.Record} 116 + 117 + <div class="subitem"> 118 + <RichtextRenderer text={record.text} facets={record.facets} /> 119 + 120 + {#if post.embed} 121 + <Embeds embed={post.embed} /> 122 + {/if} 123 + </div> 124 + {/each} 125 + </div> 126 + </li> 127 + {/each} 128 + </ol> 24 129 </div> 25 130 </PageContainer> 26 131 27 132 <style> 28 133 .unroll-page { 29 134 background: var(--bg-primary); 135 + } 136 + 137 + .profile { 138 + display: flex; 139 + align-items: center; 140 + gap: 12px; 141 + padding: 16px 16px 0; 142 + color: var(--text-blurb); 143 + 144 + :global(.thread-actions) { 145 + margin: 0 -4px; 146 + } 147 + } 148 + .name-wrapper { 149 + display: block; 150 + margin: 0 auto 0 0; 151 + max-width: 100%; 152 + overflow: hidden; 153 + color: inherit; 154 + text-overflow: ellipsis; 155 + white-space: nowrap; 156 + } 157 + .display-name-wrapper { 158 + overflow: hidden; 159 + text-overflow: ellipsis; 160 + 161 + .name-wrapper:hover & { 162 + text-decoration: underline; 163 + } 164 + } 165 + .display-name { 166 + color: var(--text-primary); 167 + font-weight: 700; 168 + } 169 + .handle { 170 + display: block; 171 + overflow: hidden; 172 + text-overflow: ellipsis; 173 + white-space: nowrap; 174 + } 175 + 176 + .thread { 177 + margin: 0; 178 + padding: 0 16px; 179 + list-style: none; 180 + } 181 + 182 + .item { 183 + display: flex; 184 + gap: 16px; 185 + } 186 + 187 + .aside { 188 + position: relative; 189 + 190 + .item:only-child & { 191 + display: none; 192 + } 193 + } 194 + .dot { 195 + margin: 30px 0 0 0; 196 + border-radius: 9999px; 197 + background: color-mix(in srgb, var(--divider-md), var(--text-blurb) 50%); 198 + width: 8px; 199 + height: 8px; 200 + } 201 + .line { 202 + position: absolute; 203 + top: 42px; 204 + bottom: -26px; 205 + left: calc(4px - 1px); 206 + border-left: 2px solid var(--divider-md); 207 + } 208 + 209 + .content { 210 + display: flex; 211 + flex-direction: column; 212 + padding: 24px 0; 213 + min-width: 0; 214 + 215 + .item:last-child & { 216 + padding-bottom: 16px; 217 + } 218 + } 219 + .meta { 220 + color: var(--text-blurb); 221 + } 222 + 223 + .subitem { 224 + margin: 6px 0 0 0; 225 + 226 + & + & { 227 + margin: 18px 0 0 0; 228 + } 30 229 } 31 230 </style>
-6
src/routes/(app)/[actor=did]/[rkey=tid]/unroll/+page.ts
··· 57 57 return false; 58 58 } 59 59 60 - // If it's more than 5 minutes than tail, skip 61 - const diff = new Date(reply.post.indexedAt).getTime() - new Date(tail.post.indexedAt).getTime(); 62 - if (diff > 5 * 60 * 1000) { 63 - return false; 64 - } 65 - 66 60 return true; 67 61 }); 68 62