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: islands, sorta, whatever

Mary d31e848c ea7e374a

+348 -34
+53
src/lib/components/island.svelte
··· 1 + <script lang="ts" module> 2 + import { getContext, setContext, type Snippet } from 'svelte'; 3 + 4 + const key = Symbol('island'); 5 + 6 + interface IslandContext { 7 + injected: Set<string>; 8 + } 9 + 10 + export const provideIsland = () => { 11 + const context: IslandContext = { 12 + injected: new Set(), 13 + }; 14 + 15 + setContext(key, context); 16 + }; 17 + 18 + export const injectIsland = (): IslandContext => { 19 + const context = getContext<IslandContext | undefined>(key); 20 + 21 + if (!context) { 22 + throw new Error('Island context not provided'); 23 + } 24 + 25 + return context; 26 + }; 27 + </script> 28 + 29 + <!-- svelte-ignore non_reactive_update --> 30 + <script lang="ts"> 31 + interface Props { 32 + scriptUrl: string; 33 + children: Snippet; 34 + } 35 + 36 + const { scriptUrl, children }: Props = $props(); 37 + 38 + const context = injectIsland(); 39 + 40 + let first = false; 41 + if (!context.injected.has(scriptUrl)) { 42 + context.injected.add(scriptUrl); 43 + first = true; 44 + } 45 + </script> 46 + 47 + <svelte:head> 48 + {#if first} 49 + <script type="module" src={scriptUrl}></script> 50 + {/if} 51 + </svelte:head> 52 + 53 + {@render children()}
+28
src/lib/components/islands/relative-time.svelte
··· 1 + <script lang="ts"> 2 + import { dev } from '$app/environment'; 3 + import { base } from '$app/paths'; 4 + 5 + import { formatLongDate, formatRelativeTime } from '$lib/utils/intl/date'; 6 + 7 + import Island from '../island.svelte'; 8 + 9 + interface Props { 10 + date: string | number; 11 + } 12 + 13 + const { date: rawDate }: Props = $props(); 14 + 15 + const date = $derived(new Date(rawDate)); 16 + </script> 17 + 18 + {#if dev} 19 + <time class="isl-relative-time" title={formatLongDate(date.getTime())} datetime={date.toISOString()}> 20 + {formatRelativeTime(date.getTime())} 21 + </time> 22 + {:else} 23 + <Island scriptUrl="{base}/_scripts/relative-time.js"> 24 + <time class="isl-relative-time" title={formatLongDate(date.getTime())} datetime={date.toISOString()}> 25 + {formatRelativeTime(date.getTime())} 26 + </time> 27 + </Island> 28 + {/if}
+3 -3
src/lib/components/timeline/post-meta.svelte
··· 1 1 <script lang="ts"> 2 2 import type { AppBskyFeedDefs, AppBskyFeedPost } from '@atcute/client/lexicons'; 3 3 4 - import { formatLongDate, formatRelativeTime } from '$lib/utils/intl/date'; 4 + import RelativeTime from '$lib/components/islands/relative-time.svelte'; 5 5 6 6 interface Props { 7 7 post: AppBskyFeedDefs.PostView; ··· 31 31 32 32 <span aria-hidden="true" class="dot"> · </span> 33 33 34 - <a href={postUrl} title={formatLongDate(createdAt)} class="date"> 35 - <time datetime={createdAt}>{formatRelativeTime(createdAt)}</time> 34 + <a href={postUrl} class="date"> 35 + <RelativeTime date={createdAt} /> 36 36 </a> 37 37 </div> 38 38
+1 -29
src/lib/utils/intl/date.ts
··· 1 - import { createSubscriber } from 'svelte/reactivity'; 2 - 3 - const reactiveNow = (() => { 4 - let subscribed = false; 5 - let now = 0; 6 - 7 - const subscribe = createSubscriber((update) => { 8 - // updates every ~minute 9 - const interval = setInterval(() => { 10 - now = Date.now(); 11 - update(); 12 - }, 60_000); 13 - 14 - subscribed = true; 15 - now = Date.now(); 16 - 17 - return () => { 18 - clearInterval(interval); 19 - subscribed = false; 20 - }; 21 - }); 22 - 23 - return () => { 24 - subscribe(); 25 - return subscribed ? now : Date.now(); 26 - }; 27 - })(); 28 - 29 1 let startOfYear = 0; 30 2 let endOfYear = 0; 31 3 ··· 83 55 export const formatRelativeTime = (date: string | number): string => { 84 56 const time = new Date(date).getTime(); 85 57 86 - const now = reactiveNow(); 58 + const now = Date.now(); 87 59 const delta = now - time; 88 60 89 61 if (delta < -NOW || delta > WEEK) {
+4
src/routes/(app)/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import type { Snippet } from 'svelte'; 3 3 4 + import { provideIsland } from '$lib/components/island.svelte'; 5 + 4 6 import '$lib/styles/app.css'; 5 7 6 8 interface Props { ··· 8 10 } 9 11 10 12 const { children }: Props = $props(); 13 + 14 + provideIsland(); 11 15 </script> 12 16 13 17 {@render children()}
+3
src/routes/+layout.ts
··· 1 + import { dev } from '$app/environment'; 2 + 3 + export const csr = dev;
+72
static/_scripts/_lib/signals.js
··· 1 + // @ts-check 2 + 3 + /** 4 + * @template T 5 + * @typedef {{ value: T }} Signal 6 + */ 7 + 8 + /** @type {?() => void} */ 9 + let tracking_effect = null; 10 + 11 + /** 12 + * @template T 13 + * @param {T} value 14 + * @returns {Signal<T>} 15 + */ 16 + export const signal = (value) => { 17 + const listeners = new Set(); 18 + 19 + return { 20 + get value() { 21 + if (tracking_effect) { 22 + listeners.add(tracking_effect); 23 + } 24 + 25 + return value; 26 + }, 27 + set value(next) { 28 + if (next !== value) { 29 + value = next; 30 + listeners.forEach((listener) => listener()); 31 + } 32 + }, 33 + }; 34 + }; 35 + 36 + /** 37 + * @param {() => void} fn 38 + */ 39 + export const effect = (fn) => { 40 + const runner = () => { 41 + const previous_effect = tracking_effect; 42 + tracking_effect = runner; 43 + 44 + try { 45 + fn(); 46 + } finally { 47 + tracking_effect = previous_effect; 48 + } 49 + }; 50 + 51 + runner(); 52 + }; 53 + 54 + /** 55 + * @template T 56 + * @param {() => T} fn 57 + * @returns {T} 58 + */ 59 + export const untrack = (fn) => { 60 + if (tracking_effect === null) { 61 + return fn(); 62 + } 63 + 64 + const previous_effect = tracking_effect; 65 + tracking_effect = null; 66 + 67 + try { 68 + return fn(); 69 + } finally { 70 + tracking_effect = previous_effect; 71 + } 72 + };
+158
static/_scripts/relative-time.js
··· 1 + // @ts-check 2 + 3 + import { signal, effect } from './_lib/signals.js'; 4 + 5 + let startOfYear = 0; 6 + let endOfYear = 0; 7 + 8 + const fmtAbsoluteLong = new Intl.DateTimeFormat('en-US', { dateStyle: 'long', timeStyle: 'short' }); 9 + const fmtAbsShortWithYear = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }); 10 + const fmtAbsShort = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }); 11 + 12 + /** 13 + * @param {string | number} date 14 + * @returns {string} 15 + */ 16 + const formatShortDate = (date) => { 17 + const inst = new Date(date); 18 + const time = inst.getTime(); 19 + 20 + if (isNaN(time)) { 21 + return 'N/A'; 22 + } 23 + 24 + const now = Date.now(); 25 + if (now > endOfYear) { 26 + const date = new Date(now); 27 + 28 + date.setMonth(0, 1); 29 + date.setHours(0, 0, 0); 30 + startOfYear = date.getTime(); 31 + 32 + date.setFullYear(date.getFullYear() + 1, 0, 0); 33 + date.setHours(23, 59, 59, 999); 34 + endOfYear = date.getTime(); 35 + } 36 + 37 + if (time >= startOfYear && time <= endOfYear) { 38 + return fmtAbsShort.format(inst); 39 + } 40 + 41 + return fmtAbsShortWithYear.format(inst); 42 + }; 43 + 44 + /** 45 + * @param {string | number} date 46 + * @returns {string} 47 + */ 48 + const formatLongDate = (date) => { 49 + const inst = new Date(date); 50 + 51 + if (isNaN(inst.getTime())) { 52 + return 'N/A'; 53 + } 54 + 55 + return fmtAbsoluteLong.format(inst); 56 + }; 57 + 58 + /** @type {Record<string, Intl.NumberFormat>} */ 59 + const relativeFormatters = {}; 60 + 61 + const SECOND = 1e3; 62 + const NOW = SECOND * 10; 63 + const MINUTE = SECOND * 60; 64 + const HOUR = MINUTE * 60; 65 + const DAY = HOUR * 24; 66 + const WEEK = DAY * 7; 67 + 68 + /** 69 + * @param {string | number} date 70 + * @param {number} now 71 + * @returns {string} 72 + */ 73 + const formatRelativeTime = (date, now) => { 74 + const time = new Date(date).getTime(); 75 + 76 + const delta = now - time; 77 + 78 + if (delta < -NOW || delta > WEEK) { 79 + if (now > endOfYear) { 80 + const date = new Date(); 81 + 82 + date.setMonth(0, 1); 83 + date.setHours(0, 0, 0); 84 + startOfYear = date.getTime(); 85 + 86 + date.setFullYear(date.getFullYear() + 1, 0, 0); 87 + date.setHours(23, 59, 59, 999); 88 + endOfYear = date.getTime(); 89 + } 90 + 91 + // if it happened this year, don't show the year. 92 + if (time >= startOfYear && time <= endOfYear) { 93 + return fmtAbsShort.format(time); 94 + } 95 + 96 + return fmtAbsShortWithYear.format(time); 97 + } 98 + 99 + if (delta < NOW) { 100 + return `now`; 101 + } 102 + 103 + { 104 + /** @type {number} */ 105 + let value; 106 + /** @type {Intl.RelativeTimeFormatUnit} */ 107 + let unit; 108 + 109 + if (delta < MINUTE) { 110 + value = Math.floor(delta / SECOND); 111 + unit = 'second'; 112 + } else if (delta < HOUR) { 113 + value = Math.floor(delta / MINUTE); 114 + unit = 'minute'; 115 + } else if (delta < DAY) { 116 + value = Math.floor(delta / HOUR); 117 + unit = 'hour'; 118 + } else { 119 + // use rounding, this handles the following scenario: 120 + // - 2024-02-13T09:00Z <- 2024-02-15T07:00Z = 2d 121 + value = Math.round(delta / DAY); 122 + unit = 'day'; 123 + } 124 + 125 + const formatter = (relativeFormatters[unit] ||= new Intl.NumberFormat('en-US', { 126 + style: 'unit', 127 + unit: unit, 128 + unitDisplay: 'narrow', 129 + })); 130 + 131 + return formatter.format(Math.abs(value)); 132 + } 133 + }; 134 + 135 + (() => { 136 + /** @type {NodeListOf<HTMLTimeElement>} */ 137 + const nodes = document.querySelectorAll('time.isl-relative-time'); 138 + 139 + if (nodes.length === 0) { 140 + return; 141 + } 142 + 143 + const now = signal(Date.now()); 144 + setInterval(() => { 145 + now.value = Date.now(); 146 + }, 60_000); 147 + 148 + effect(() => { 149 + const $now = now.value; 150 + 151 + for (const node of nodes) { 152 + const dt = node.dateTime; 153 + 154 + node.textContent = formatRelativeTime(dt, $now); 155 + node.title = formatLongDate(dt); 156 + } 157 + }); 158 + })();
+26 -2
vite.config.ts
··· 1 1 import { sveltekit } from '@sveltejs/kit/vite'; 2 2 import { defineConfig } from 'vite'; 3 3 4 - export default defineConfig({ 5 - plugins: [sveltekit()] 4 + export default defineConfig(({ command }) => { 5 + return { 6 + plugins: [ 7 + sveltekit(), 8 + 9 + // Nasty hack to remove the hydration markers that SvelteKit adds to the HTML 10 + command === 'build' && { 11 + name: 'remove-hydration-markers', 12 + transform(code, id, options) { 13 + if (id.endsWith('.svelte') && code.includes('$$payload')) { 14 + code = code 15 + .replace(/<!--.*?-->/g, '') 16 + .replace(/\$\$slots: {.+?},?/g, '') 17 + .replace(/\$\$payload\.out \+= ["`]{2};|\$\.(push|pop)\(\);/g, '') 18 + .replace(/(?<=\$\$payload\.out \+= )`\${([a-zA-Z0-9_$.,()[\]\s]+?)}`(?=;)/, '$1'); 19 + 20 + return code; 21 + } else if (id.includes('node_modules/svelte/') && code.includes('<!--')) { 22 + code = code.replace(/<!--.*?-->/g, ''); 23 + 24 + return code; 25 + } 26 + }, 27 + }, 28 + ], 29 + }; 6 30 });