[READ-ONLY] Mirror of https://github.com/flo-bit/ui-kit. 🦊 fox ui, svelte 5 and tailwind 4 flo-bit.dev/ui-kit/
svelte tailwindcss ui-components
0

Configure Feed

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

add test nested-comments, add relativetime

Florian (Jul 26, 2025, 12:01 AM +0200) 0313e222 b209c28a

+618 -315
+4 -31
pnpm-lock.yaml
··· 22 22 version: 3.5.2 23 23 prettier-plugin-svelte: 24 24 specifier: ^3.3.3 25 - version: 3.3.3(prettier@3.5.2)(svelte@5.29.0) 25 + version: 3.3.3(prettier@3.5.2)(svelte@5.36.16) 26 26 prettier-plugin-tailwindcss: 27 27 specifier: ^0.6.11 28 28 version: 0.6.11(prettier-plugin-svelte@3.3.3)(prettier@3.5.2) ··· 426 426 '@fuxui/base': 427 427 specifier: workspace:* 428 428 version: link:../base 429 + '@fuxui/time': 430 + specifier: workspace:* 431 + version: link:../time 429 432 '@use-gesture/vanilla': 430 433 specifier: ^10.3.1 431 434 version: 10.3.1 ··· 4393 4396 svelte: 5.28.1 4394 4397 dev: true 4395 4398 4396 - /prettier-plugin-svelte@3.3.3(prettier@3.5.2)(svelte@5.29.0): 4397 - resolution: {integrity: sha512-yViK9zqQ+H2qZD1w/bH7W8i+bVfKrD8GIFjkFe4Thl6kCT9SlAsXVNmt3jCvQOCsnOhcvYgsoVlRV/Eu6x5nNw==} 4398 - peerDependencies: 4399 - prettier: ^3.0.0 4400 - svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0 4401 - dependencies: 4402 - prettier: 3.5.2 4403 - svelte: 5.29.0 4404 - dev: true 4405 - 4406 4399 /prettier-plugin-svelte@3.3.3(prettier@3.5.2)(svelte@5.36.16): 4407 4400 resolution: {integrity: sha512-yViK9zqQ+H2qZD1w/bH7W8i+bVfKrD8GIFjkFe4Thl6kCT9SlAsXVNmt3jCvQOCsnOhcvYgsoVlRV/Eu6x5nNw==} 4408 4401 peerDependencies: ··· 5044 5037 locate-character: 3.0.0 5045 5038 magic-string: 0.30.17 5046 5039 zimmerframe: 1.1.2 5047 - 5048 - /svelte@5.29.0: 5049 - resolution: {integrity: sha512-ZF1FXoTsDbTlHShZs0kTznahQhU8drBXF2Vx1dt635MW/OHpy+iWvAHsIRp+ia25L1Vrczq9EsuMdLXM+cjknw==, tarball: https://registry.npmjs.org/svelte/-/svelte-5.29.0.tgz} 5050 - engines: {node: '>=18'} 5051 - dependencies: 5052 - '@ampproject/remapping': 2.3.0 5053 - '@jridgewell/sourcemap-codec': 1.5.0 5054 - '@sveltejs/acorn-typescript': 1.0.5(acorn@8.14.1) 5055 - '@types/estree': 1.0.7 5056 - acorn: 8.14.1 5057 - aria-query: 5.3.2 5058 - axobject-query: 4.1.0 5059 - clsx: 2.1.1 5060 - esm-env: 1.2.2 5061 - esrap: 1.4.6 5062 - is-reference: 3.0.3 5063 - locate-character: 3.0.0 5064 - magic-string: 0.30.17 5065 - zimmerframe: 1.1.2 5066 - dev: true 5067 5040 5068 5041 /svelte@5.36.16: 5069 5042 resolution: {integrity: sha512-C7HnyISfvZEofs7T4p7+bmjrbQlhd6lZfgV2tLYg6Eb3nUFM/Zu9dGlSg+GWbUBU/WPw6zDPOFNZAx9qXsoCkg==, tarball: https://registry.npmjs.org/svelte/-/svelte-5.36.16.tgz}
+6
.changeset/fancy-clocks-greet.md
··· 1 + --- 2 + '@fuxui/social': patch 3 + '@fuxui/time': patch 4 + --- 5 + 6 + add first test version nested-comments, add test relativetime
+12 -1
.vscode/settings.json
··· 3 3 { 4 4 "mode": "auto" 5 5 } 6 - ] 6 + ], 7 + "files.exclude": { 8 + "**/.git": true, 9 + "**/.svn": true, 10 + "**/.hg": true, 11 + "**/.DS_Store": true, 12 + "**/Thumbs.db": true, 13 + "**/.retool_types/**": true, 14 + "**/*tsconfig.json": true, 15 + ".cache": true, 16 + "retool.config.json": true 17 + } 7 18 }
+1
packages/social/package.json
··· 70 70 "dependencies": { 71 71 "@atproto/api": "^0.15.5", 72 72 "@fuxui/base": "workspace:*", 73 + "@fuxui/time": "workspace:*", 73 74 "@use-gesture/vanilla": "^10.3.1", 74 75 "bits-ui": "^1.4.3", 75 76 "emoji-picker-element": "^1.26.3",
+6
apps/docs/src/lib/site-components/components_time.ts
··· 1 1 import type { ComponentCard } from './components_base'; 2 2 import CardStopwatch from '$lib/cards/time/CardStopwatch.svelte'; 3 3 import CardTimer from '$lib/cards/time/CardTimer.svelte'; 4 + import CardRelativeTime from '$lib/cards/time/CardRelativeTime.svelte'; 4 5 5 6 export const timeComponents: ComponentCard[] = [ 6 7 { ··· 12 13 component: CardTimer, 13 14 label: 'Timer', 14 15 href: 'timer' 16 + }, 17 + { 18 + component: CardRelativeTime, 19 + label: 'Relative Time', 20 + href: 'relative-time' 15 21 } 16 22 ].sort((a, b) => a.label.localeCompare(b.label));
+14 -1
packages/social/src/lib/components/index.ts
··· 7 7 export * from './github-corner'; 8 8 export * from './chat'; 9 9 export * from './emoji-picker'; 10 - export * from './bluesky-post'; 10 + export * from './bluesky-post'; 11 + export * from './nested-comments'; 12 + 13 + export function numberToHumanReadable(number: number) { 14 + if (number < 1000) { 15 + return number; 16 + } 17 + 18 + if (number < 1000000) { 19 + return `${(number / 1000).toFixed(1)}k`; 20 + } 21 + 22 + return `${(number / 1000000).toFixed(1)}m`; 23 + }
+1
packages/time/src/lib/components/index.ts
··· 1 1 export * from './countdown'; 2 2 export * from './stopwatch'; 3 3 export * from './timer'; 4 + export * from './relative-time';
+18
apps/docs/src/lib/cards/time/CardRelativeTime.svelte
··· 1 + <script lang="ts"> 2 + import { Box } from '@fuxui/base'; 3 + import { RelativeTime, TimerState } from '@fuxui/time'; 4 + import { onMount } from 'svelte'; 5 + 6 + let timer: TimerState | undefined = $state(); 7 + 8 + onMount(() => { 9 + timer?.start(); 10 + }); 11 + </script> 12 + 13 + <div class="text-base-900 dark:text-base-50 flex flex-col items-center justify-center"> 14 + <RelativeTime date={new Date(Date.now() - 10000)} locale="en-US" /> 15 + <RelativeTime date={new Date(Date.now() - 1000 * 60 * 10.0)} locale="en-US" /> 16 + <RelativeTime date={new Date(Date.now() - 1000 * 60 * 60 * 24)} locale="en-US" /> 17 + <RelativeTime date={new Date(Date.now() - 1000 * 60 * 60 * 24 * 30)} locale="en-US" /> 18 + </div>
+21 -20
packages/social/src/lib/components/bluesky-post/index.ts
··· 1 1 import { RichText } from '@atproto/api'; 2 2 import type { PostData, PostEmbed } from '../post'; 3 - import type { FeedViewPost } from '@atproto/api/dist/client/types/app/bsky/feed/defs'; 3 + import type { FeedViewPost, PostView } from '@atproto/api/dist/client/types/app/bsky/feed/defs'; 4 4 5 5 function blueskyEmbedTypeToEmbedType(type: string) { 6 6 switch (type) { ··· 19 19 } 20 20 21 21 export function blueskyPostToPostData( 22 - data: FeedViewPost, 22 + data: PostView, 23 23 baseUrl: string = 'https://bsky.app' 24 24 ): PostData { 25 - const post = data.post; 26 - const reason = data.reason; 27 - const reply = data.reply?.parent; 28 - const replyId = reply?.uri?.split('/').pop(); 25 + console.log(data); 26 + const post = data; 27 + // const reason = data.reason; 28 + // const reply = data.reply?.parent; 29 + // const replyId = reply?.uri?.split('/').pop(); 29 30 30 31 const id = post.uri.split('/').pop(); 31 32 32 33 return { 33 34 id, 34 35 href: `${baseUrl}/profile/${post.author.handle}/post/${id}`, 35 - reposted: 36 - reason && reason.$type === 'app.bsky.feed.defs#reasonRepost' 37 - ? { 38 - handle: reason.by.handle, 39 - href: `${baseUrl}/profile/${reason.by.handle}` 40 - } 41 - : undefined, 36 + // reposted: 37 + // reason && reason.$type === 'app.bsky.feed.defs#reasonRepost' 38 + // ? { 39 + // handle: reason.by.handle, 40 + // href: `${baseUrl}/profile/${reason.by.handle}` 41 + // } 42 + // : undefined, 42 43 43 - replyTo: 44 - reply && replyId 45 - ? { 46 - handle: reply.author.handle, 47 - href: `${baseUrl}/profile/${reply.author.handle}/post/${replyId}` 48 - } 49 - : undefined, 44 + // replyTo: 45 + // reply && replyId 46 + // ? { 47 + // handle: reply.author.handle, 48 + // href: `${baseUrl}/profile/${reply.author.handle}/post/${replyId}` 49 + // } 50 + // : undefined, 50 51 author: { 51 52 displayName: post.author.displayName, 52 53 handle: post.author.handle,
+151
packages/social/src/lib/components/nested-comments/Comment.svelte
··· 1 + <script lang="ts"> 2 + import { numberToHumanReadable } from '../'; 3 + import Comment from './Comment.svelte'; 4 + import { Avatar, Prose } from '@fuxui/base'; 5 + import { RelativeTime } from '@fuxui/time'; 6 + import type { PostData } from '../post'; 7 + import Embed from '../post/embeds/Embed.svelte'; 8 + 9 + const { comment, depth = 0 }: { comment: PostData; depth: number } = $props(); 10 + 11 + let expanded = $state(true); 12 + </script> 13 + 14 + {#snippet top(expand: boolean)} 15 + <div class="text-base-600 dark:text-base-500 -ml-6 flex items-center text-sm"> 16 + <div class="relative size-6"> 17 + <Avatar src={comment.author.avatar} class="size-6" /> 18 + {#if expand} 19 + <button 20 + class="absolute inset-0 flex size-6 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white" 21 + onclick={() => (expanded = !expanded)} 22 + > 23 + <svg 24 + xmlns="http://www.w3.org/2000/svg" 25 + fill="none" 26 + viewBox="0 0 24 24" 27 + stroke-width="2" 28 + stroke="currentColor" 29 + class="size-3" 30 + > 31 + <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 32 + </svg> 33 + 34 + <span class="sr-only">expand comment</span> 35 + </button> 36 + {:else} 37 + <button 38 + class="absolute inset-0 flex size-6 cursor-pointer items-center justify-center rounded-full" 39 + onclick={() => (expanded = !expanded)} 40 + > 41 + <span class="sr-only">collapse comment</span> 42 + </button> 43 + {/if} 44 + </div> 45 + <a 46 + target="_blank" 47 + rel="noopener noreferrer nofollow" 48 + class="dark:text-base-400 hover:text-base-500 hover:dark:text-base-300 ml-2" 49 + href={comment.author.href} 50 + > 51 + {comment.author.displayName || comment.author.handle} 52 + </a> 53 + 54 + <div class="text-base-500 ml-2 text-xs"> 55 + <RelativeTime date={new Date(comment.createdAt)} locale="en" /> 56 + </div> 57 + </div> 58 + {/snippet} 59 + 60 + <div class="relative pl-3"> 61 + <button 62 + class="group absolute -left-1.5 top-0 flex h-full w-3 cursor-pointer items-center" 63 + onclick={() => (expanded = !expanded)} 64 + > 65 + <div 66 + class="bg-base-200 dark:bg-base-800 group-hover:bg-base-300 dark:group-hover:bg-base-700 mx-auto h-full w-0.5" 67 + ></div> 68 + <span class="sr-only">collapse comment</span> 69 + </button> 70 + 71 + <div class="mt-2 pb-2"> 72 + {#if expanded} 73 + {@render top(false)} 74 + <Prose>{@html comment.htmlContent}</Prose> 75 + 76 + {#if comment.embed} 77 + <Embed embed={comment.embed} /> 78 + {/if} 79 + 80 + <div class="text-base-500 dark:text-base-400 mt-2 flex gap-8"> 81 + <a 82 + href={comment.href} 83 + target="_blank" 84 + rel="noopener noreferrer nofollow" 85 + class="group inline-flex items-center gap-2 text-sm" 86 + > 87 + <svg 88 + xmlns="http://www.w3.org/2000/svg" 89 + fill="none" 90 + viewBox="0 0 24 24" 91 + stroke-width="1.5" 92 + stroke="currentColor" 93 + class="group-hover:bg-accent-500/10 group-hover:text-accent-700 dark:group-hover:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100" 94 + > 95 + <path 96 + stroke-linecap="round" 97 + stroke-linejoin="round" 98 + d="M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 0 1-.923 1.785A5.969 5.969 0 0 0 6 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337Z" 99 + /> 100 + </svg> 101 + <span class="sr-only">Replies</span> 102 + {numberToHumanReadable(comment.replyCount)} 103 + </a> 104 + 105 + <a 106 + href={comment.href} 107 + target="_blank" 108 + rel="noopener noreferrer nofollow" 109 + class="group inline-flex items-center gap-2 text-sm" 110 + > 111 + <svg 112 + xmlns="http://www.w3.org/2000/svg" 113 + fill="none" 114 + viewBox="0 0 24 24" 115 + stroke-width="1.5" 116 + stroke="currentColor" 117 + class="group-hover:bg-accent-500/10 group-hover:text-accent-700 dark:group-hover:text-accent-400 -m-1.5 size-7 rounded-full p-1.5 transition-all duration-100" 118 + > 119 + <path 120 + stroke-linecap="round" 121 + stroke-linejoin="round" 122 + d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" 123 + /> 124 + </svg> 125 + <span class="sr-only">Likes</span> 126 + {numberToHumanReadable(comment.likeCount)} 127 + </a> 128 + </div> 129 + 130 + <!-- {#if comment.replies?.length && depth > 4} 131 + <a 132 + href={atUriToPostUri(comment.uri)} 133 + target="_blank" 134 + rel="noopener noreferrer nofollow" 135 + class="text-base-500 dark:text-base-400 hover:dark:text-base-300 hover:text-base-600 text-sm font-medium" 136 + >View more replies on bluesky</a 137 + > 138 + {/if} --> 139 + {:else} 140 + <button onclick={() => (expanded = true)}> 141 + {@render top(true)} 142 + </button> 143 + {/if} 144 + </div> 145 + 146 + {#if comment.replies?.length && depth <= 4 && expanded} 147 + {#each comment.replies.toSorted((a: any, b: any) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) as reply} 148 + <Comment comment={reply} depth={depth + 1} /> 149 + {/each} 150 + {/if} 151 + </div>
+14
packages/social/src/lib/components/nested-comments/NestedComments.svelte
··· 1 + <script lang="ts"> 2 + import type { PostData } from '../post'; 3 + import Comment from './Comment.svelte'; 4 + 5 + let { comments }: { comments: PostData[] } = $props(); 6 + </script> 7 + 8 + {#if comments.length > 0} 9 + <div class="pl-3 pt-4"> 10 + {#each comments as comment} 11 + <Comment {comment} depth={0} /> 12 + {/each} 13 + </div> 14 + {/if}
+1
packages/social/src/lib/components/nested-comments/index.ts
··· 1 + export { default as NestedComments } from './NestedComments.svelte';
+3 -12
packages/social/src/lib/components/post/Post.svelte
··· 1 1 <script lang="ts"> 2 - import RelativeTime from './relative-time'; 3 2 import Embed from './embeds/Embed.svelte'; 4 3 import { cn, Avatar, Prose } from '@fuxui/base'; 5 4 import type { WithChildren, WithElementRef } from 'bits-ui'; ··· 7 6 import type { PostData } from '.'; 8 7 import PostAction from './PostAction.svelte'; 9 8 import type { Snippet } from 'svelte'; 9 + import { numberToHumanReadable } from '..'; 10 + import { RelativeTime } from '@fuxui/time'; 10 11 11 12 let { 12 13 ref = $bindable(), ··· 51 52 52 53 logo?: Snippet; 53 54 } = $props(); 54 - 55 - export function numberToHumanReadable(number: number) { 56 - if (number < 1000) { 57 - return number; 58 - } 59 - if (number < 1000000) { 60 - return `${Math.floor(number / 1000)}k`; 61 - } 62 - return `${Math.floor(number / 1000000)}m`; 63 - } 64 55 </script> 65 56 66 57 <div ··· 164 155 {/if} 165 156 </div> 166 157 167 - <Prose> 158 + <Prose size="md"> 168 159 {#if data.htmlContent} 169 160 {@html data.htmlContent} 170 161 {:else}
+2
packages/social/src/lib/components/post/index.ts
··· 68 68 embed?: PostEmbed; 69 69 70 70 htmlContent?: string; 71 + 72 + replies?: PostData[]; 71 73 }; 72 74 73 75 export { default as Post } from './Post.svelte';
+17
packages/time/src/lib/components/relative-time/RelativeTime.svelte
··· 1 + <script lang="ts"> 2 + import { onDestroy } from 'svelte'; 3 + import { register, unregister } from './state'; 4 + 5 + export let date: Date | number; 6 + export let locale: string; 7 + export let live = true; 8 + 9 + let instance = new Object(); 10 + let text = ''; 11 + 12 + register(instance, date, locale, live, (value) => ({ text } = value)); 13 + 14 + onDestroy(() => unregister(instance)); 15 + </script> 16 + 17 + <span class={$$props.class} title={date.toLocaleString()}>{text}</span>
+31
packages/time/src/lib/components/relative-time/action.ts
··· 1 + import type { Callback } from './render'; 2 + import { register, unregister } from './state'; 3 + 4 + export interface Options { 5 + date: Date | number; 6 + locale?: string; 7 + live?: boolean; 8 + } 9 + 10 + export function relativeTime(node: HTMLElement, options: Options) { 11 + const callback: Callback = ({ text }) => (node.textContent = text); 12 + 13 + function init(options: Options) { 14 + const date = options.date; 15 + const locale = options.locale ?? navigator.language; 16 + const live = (options.live = true); 17 + 18 + register(node, date, locale, live, callback); 19 + } 20 + 21 + init(options); 22 + 23 + return { 24 + update(options: Options) { 25 + init(options); 26 + }, 27 + destroy() { 28 + unregister(node); 29 + } 30 + }; 31 + }
+12
packages/time/src/lib/components/relative-time/formatter.ts
··· 1 + // keep a cache of formatter per locale, to avoid re-creating them (GC) 2 + const formatters = new Map<string, Intl.RelativeTimeFormat>(); 3 + 4 + // get the Intl.RelativeTimeFormat formatter for the given locale 5 + export function getFormatter(locale: string) { 6 + if (formatters.has(locale)) { 7 + return formatters.get(locale)!; 8 + } 9 + const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'always', style: 'narrow' }); 10 + formatters.set(locale, formatter); 11 + return formatter; 12 + }
+8
packages/time/src/lib/components/relative-time/index.ts
··· 1 + // adapted from https://github.com/CaptainCodeman/svelte-relative-time 2 + 3 + export * from './action'; 4 + export type { Callback } from './render'; 5 + export { register, unregister } from './state'; 6 + export { default } from './RelativeTime.svelte'; 7 + 8 + export { default as RelativeTime } from './RelativeTime.svelte';
+80
packages/time/src/lib/components/relative-time/render.ts
··· 1 + export type Callback = (result: { 2 + seconds: number; 3 + count: number; 4 + units: Intl.RelativeTimeFormatUnit; 5 + text: string; 6 + }) => void; 7 + 8 + export interface RenderState { 9 + date: Date | number; 10 + callback: Callback; 11 + formatter: Intl.RelativeTimeFormat; 12 + } 13 + 14 + // Array reprsenting one minute, hour, day, week, month, etc in seconds 15 + const cutoffs = [60, 3600, 86400, 86400 * 7, 86400 * 30, 86400 * 365, Infinity]; 16 + 17 + // Array equivalent to the above but in the string representation of the units 18 + const formatUnits: Intl.RelativeTimeFormatUnit[] = [ 19 + 'seconds', 20 + 'minutes', 21 + 'hours', 22 + 'days', 23 + 'weeks', 24 + 'months', 25 + 'years' 26 + ]; 27 + 28 + // function to render relative time into 29 + export function render(state: RenderState, now: number) { 30 + const { date, callback, formatter } = state; 31 + 32 + // Allow dates or times to be passed 33 + const timeMs = typeof date === 'number' ? date : date.getTime(); 34 + 35 + // Get the amount of seconds between the given date and now 36 + const delta = timeMs - now; 37 + const seconds = Math.round(delta / 1000); 38 + 39 + // Grab the ideal cutoff unit 40 + const unitIndex = cutoffs.findIndex((cutoff) => cutoff > Math.abs(seconds)); 41 + 42 + // units 43 + const units = formatUnits[unitIndex]; 44 + 45 + // Get the divisor to divide from the seconds. E.g. if our unit is 'day' our divisor 46 + // is one day in seconds, so we can divide our seconds by this to get the # of days 47 + const divisor = unitIndex ? cutoffs[unitIndex - 1] : 1; 48 + 49 + // count of units 50 + const count = Math.round(seconds / divisor); 51 + 52 + // Intl.RelativeTimeFormat do its magic 53 + callback({ 54 + seconds: seconds, 55 + count, 56 + units, 57 + text: formatter.format(count, units).replace('ago', '') 58 + }); 59 + 60 + // calculate time to next update, taking account rounding (e.g. it goes from 2 minutes to 1 minute at 90 seconds) 61 + // and also the changeover from units (59 seconds shouldn't show as 1 minute, so at 61 seconds we schedule the next 62 + // update for 1 second time) 63 + 64 + const divisorMs = divisor * 1000; 65 + 66 + let updateIn; 67 + 68 + if (unitIndex) { 69 + updateIn = divisorMs / 2 - (Math.abs(delta) % divisorMs); 70 + if (updateIn < 0) { 71 + updateIn += divisorMs; 72 + } 73 + } else { 74 + updateIn = divisorMs - (Math.abs(delta) % divisorMs); 75 + } 76 + 77 + const updateAt = now + updateIn; 78 + 79 + return updateAt; 80 + }
+60
packages/time/src/lib/components/relative-time/state.ts
··· 1 + import { getFormatter } from './formatter'; 2 + import { render } from './render'; 3 + import type { Callback, RenderState } from './render'; 4 + 5 + interface UpdateState extends RenderState { 6 + update: number; 7 + } 8 + 9 + // keep track of each instance 10 + const instances = new Map<object, UpdateState>(); 11 + 12 + // we use a single timer for efficiency and to keep updates in sync 13 + let updateInterval: number | NodeJS.Timeout; 14 + 15 + // register or update instance 16 + export function register( 17 + instance: object, 18 + date: Date | number, 19 + locale: string, 20 + live: boolean, 21 + callback: Callback 22 + ) { 23 + // get the formatter for the given locale, we do this here so we don't keep having to look it up on each tick 24 + const formatter = getFormatter(locale); 25 + 26 + // create state to render 27 + const state = { date, callback, formatter }; 28 + 29 + // initial render is immediate, so works for SSR 30 + const update = render(state, Date.now()); 31 + 32 + // if it's to update live, we keep a track and schedule the next update 33 + if (live) { 34 + instances.set(instance, { ...state, update }); 35 + } else { 36 + instances.delete(instance); 37 + } 38 + 39 + // start the clock ticking if there are any live instances 40 + if (instances.size) { 41 + updateInterval = 42 + updateInterval || 43 + setInterval(() => { 44 + const now = Date.now(); 45 + for (const state of instances.values()) { 46 + if (state.update <= now) { 47 + state.update = render(state, now); 48 + } 49 + } 50 + }, 1000); 51 + } 52 + } 53 + 54 + export function unregister(instance: object) { 55 + instances.delete(instance); 56 + if (instances.size === 0) { 57 + clearInterval(updateInterval); 58 + updateInterval = 0; 59 + } 60 + }
+1 -1
packages/social/src/lib/components/post/embeds/External.svelte
··· 8 8 9 9 <article 10 10 class={[ 11 - 'group dark:bg-base-900 bg-base-200 border-base-300 dark:border-base-600/30 relative isolate flex flex-col justify-end overflow-hidden rounded-2xl border p-4', 11 + 'group dark:bg-base-900 bg-base-200 border-base-300 dark:border-base-600/30 max-w-md relative isolate flex flex-col justify-end overflow-hidden rounded-2xl border p-4', 12 12 data.external.thumb ? 'aspect-[16/9]' : '' 13 13 ]} 14 14 >
-17
packages/social/src/lib/components/post/relative-time/RelativeTime.svelte
··· 1 - <script lang="ts"> 2 - import { onDestroy } from 'svelte'; 3 - import { register, unregister } from './state'; 4 - 5 - export let date: Date | number; 6 - export let locale: string; 7 - export let live = true; 8 - 9 - let instance = new Object(); 10 - let text = ''; 11 - 12 - register(instance, date, locale, live, (value) => ({ text } = value)); 13 - 14 - onDestroy(() => unregister(instance)); 15 - </script> 16 - 17 - <span class={$$props.class}>{text}</span>
-31
packages/social/src/lib/components/post/relative-time/action.ts
··· 1 - import type { Callback } from './render'; 2 - import { register, unregister } from './state'; 3 - 4 - export interface Options { 5 - date: Date | number; 6 - locale?: string; 7 - live?: boolean; 8 - } 9 - 10 - export function relativeTime(node: HTMLElement, options: Options) { 11 - const callback: Callback = ({ text }) => (node.textContent = text); 12 - 13 - function init(options: Options) { 14 - const date = options.date; 15 - const locale = options.locale ?? navigator.language; 16 - const live = (options.live = true); 17 - 18 - register(node, date, locale, live, callback); 19 - } 20 - 21 - init(options); 22 - 23 - return { 24 - update(options: Options) { 25 - init(options); 26 - }, 27 - destroy() { 28 - unregister(node); 29 - } 30 - }; 31 - }
-12
packages/social/src/lib/components/post/relative-time/formatter.ts
··· 1 - // keep a cache of formatter per locale, to avoid re-creating them (GC) 2 - const formatters = new Map<string, Intl.RelativeTimeFormat>(); 3 - 4 - // get the Intl.RelativeTimeFormat formatter for the given locale 5 - export function getFormatter(locale: string) { 6 - if (formatters.has(locale)) { 7 - return formatters.get(locale)!; 8 - } 9 - const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'always', style: 'narrow' }); 10 - formatters.set(locale, formatter); 11 - return formatter; 12 - }
-6
packages/social/src/lib/components/post/relative-time/index.ts
··· 1 - // adapted from https://github.com/CaptainCodeman/svelte-relative-time 2 - 3 - export * from './action'; 4 - export type { Callback } from './render'; 5 - export { register, unregister } from './state'; 6 - export { default } from './RelativeTime.svelte';
-80
packages/social/src/lib/components/post/relative-time/render.ts
··· 1 - export type Callback = (result: { 2 - seconds: number; 3 - count: number; 4 - units: Intl.RelativeTimeFormatUnit; 5 - text: string; 6 - }) => void; 7 - 8 - export interface RenderState { 9 - date: Date | number; 10 - callback: Callback; 11 - formatter: Intl.RelativeTimeFormat; 12 - } 13 - 14 - // Array reprsenting one minute, hour, day, week, month, etc in seconds 15 - const cutoffs = [60, 3600, 86400, 86400 * 7, 86400 * 30, 86400 * 365, Infinity]; 16 - 17 - // Array equivalent to the above but in the string representation of the units 18 - const formatUnits: Intl.RelativeTimeFormatUnit[] = [ 19 - 'seconds', 20 - 'minutes', 21 - 'hours', 22 - 'days', 23 - 'weeks', 24 - 'months', 25 - 'years' 26 - ]; 27 - 28 - // function to render relative time into 29 - export function render(state: RenderState, now: number) { 30 - const { date, callback, formatter } = state; 31 - 32 - // Allow dates or times to be passed 33 - const timeMs = typeof date === 'number' ? date : date.getTime(); 34 - 35 - // Get the amount of seconds between the given date and now 36 - const delta = timeMs - now; 37 - const seconds = Math.round(delta / 1000); 38 - 39 - // Grab the ideal cutoff unit 40 - const unitIndex = cutoffs.findIndex((cutoff) => cutoff > Math.abs(seconds)); 41 - 42 - // units 43 - const units = formatUnits[unitIndex]; 44 - 45 - // Get the divisor to divide from the seconds. E.g. if our unit is 'day' our divisor 46 - // is one day in seconds, so we can divide our seconds by this to get the # of days 47 - const divisor = unitIndex ? cutoffs[unitIndex - 1] : 1; 48 - 49 - // count of units 50 - const count = Math.round(seconds / divisor); 51 - 52 - // Intl.RelativeTimeFormat do its magic 53 - callback({ 54 - seconds: seconds, 55 - count, 56 - units, 57 - text: formatter.format(count, units).replace('ago', '') 58 - }); 59 - 60 - // calculate time to next update, taking account rounding (e.g. it goes from 2 minutes to 1 minute at 90 seconds) 61 - // and also the changeover from units (59 seconds shouldn't show as 1 minute, so at 61 seconds we schedule the next 62 - // update for 1 second time) 63 - 64 - const divisorMs = divisor * 1000; 65 - 66 - let updateIn; 67 - 68 - if (unitIndex) { 69 - updateIn = divisorMs / 2 - (Math.abs(delta) % divisorMs); 70 - if (updateIn < 0) { 71 - updateIn += divisorMs; 72 - } 73 - } else { 74 - updateIn = divisorMs - (Math.abs(delta) % divisorMs); 75 - } 76 - 77 - const updateAt = now + updateIn; 78 - 79 - return updateAt; 80 - }
-60
packages/social/src/lib/components/post/relative-time/state.ts
··· 1 - import { getFormatter } from './formatter'; 2 - import { render } from './render'; 3 - import type { Callback, RenderState } from './render'; 4 - 5 - interface UpdateState extends RenderState { 6 - update: number; 7 - } 8 - 9 - // keep track of each instance 10 - const instances = new Map<Object, UpdateState>(); 11 - 12 - // we use a single timer for efficiency and to keep updates in sync 13 - let updateInterval: number | NodeJS.Timeout; 14 - 15 - // register or update instance 16 - export function register( 17 - instance: Object, 18 - date: Date | number, 19 - locale: string, 20 - live: boolean, 21 - callback: Callback 22 - ) { 23 - // get the formatter for the given locale, we do this here so we don't keep having to look it up on each tick 24 - const formatter = getFormatter(locale); 25 - 26 - // create state to render 27 - const state = { date, callback, formatter }; 28 - 29 - // initial render is immediate, so works for SSR 30 - const update = render(state, Date.now()); 31 - 32 - // if it's to update live, we keep a track and schedule the next update 33 - if (live) { 34 - instances.set(instance, { ...state, update }); 35 - } else { 36 - instances.delete(instance); 37 - } 38 - 39 - // start the clock ticking if there are any live instances 40 - if (instances.size) { 41 - updateInterval = 42 - updateInterval || 43 - setInterval(() => { 44 - const now = Date.now(); 45 - for (const state of instances.values()) { 46 - if (state.update <= now) { 47 - state.update = render(state, now); 48 - } 49 - } 50 - }, 1000); 51 - } 52 - } 53 - 54 - export function unregister(instance: Object) { 55 - instances.delete(instance); 56 - if (instances.size === 0) { 57 - clearInterval(updateInterval); 58 - updateInterval = 0; 59 - } 60 - }
+56
apps/docs/src/routes/(main)/components/social/nested-comments/+page.svelte
··· 1 + <script lang="ts"> 2 + import type { 3 + FeedViewPost, 4 + ThreadViewPost 5 + } from '@atproto/api/dist/client/types/app/bsky/feed/defs'; 6 + import { blueskyPostToPostData, NestedComments, Post, type PostData } from '@fuxui/social'; 7 + import { onMount } from 'svelte'; 8 + 9 + let mainPost = $state<PostData>(); 10 + 11 + export async function getComments(uri: string) { 12 + const getCommentsURL = `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${uri}&depth=10`; 13 + const comments = await fetch(getCommentsURL); 14 + const commentsData = await comments.json(); 15 + 16 + console.log(commentsData); 17 + mainPost = blueskyPostToPostData(commentsData.thread.post); 18 + const replies = commentsData.thread.replies; 19 + 20 + return replies.sort( 21 + (a: any, b: any) => 22 + new Date(b.post.record.createdAt).getTime() - new Date(a.post.record.createdAt).getTime() 23 + ); 24 + } 25 + 26 + function convertComments(comments: ThreadViewPost[]) { 27 + return comments.map((comment) => ({ 28 + ...blueskyPostToPostData(comment.post), 29 + replies: convertComments(comment.replies) 30 + })); 31 + } 32 + 33 + let posts = $state<PostData[]>([]); 34 + 35 + onMount(async () => { 36 + const comments = await getComments( 37 + 'at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/app.bsky.feed.post/3lusiidbhs22o' 38 + ); 39 + 40 + //https://bsky.app/profile/ngerakines.me/post/3lusiidbhs22o 41 + console.log(comments[0]); 42 + 43 + posts = convertComments(comments); 44 + console.log(posts); 45 + }); 46 + </script> 47 + 48 + <div> 49 + {#if mainPost} 50 + <Post data={mainPost} /> 51 + {/if} 52 + 53 + {#if posts} 54 + <NestedComments comments={posts} /> 55 + {/if} 56 + </div>
-41
apps/docs/src/routes/(main)/components/social/post/Example copy.svelte
··· 1 - <script lang="ts"> 2 - import { Box } from '@fuxui/base'; 3 - import { BlueskyPost, blueskyPostToPostData, Post } from '@fuxui/social'; 4 - import { BskyAgent } from '@atproto/api'; 5 - import { onMount } from 'svelte'; 6 - import type { FeedViewPost } from '@atproto/api/dist/client/types/app/bsky/feed/defs'; 7 - 8 - async function fetchLatestPosts(handle: string) { 9 - const agent = new BskyAgent({ 10 - service: 'https://public.api.bsky.app' 11 - }); 12 - 13 - try { 14 - const did = await agent.resolveHandle({ handle }); 15 - console.log(did); 16 - const feed = await agent.getAuthorFeed({ 17 - actor: did.data.did, 18 - limit: 10 19 - }); 20 - 21 - console.log(feed); 22 - return feed.data.feed; 23 - } catch (error) { 24 - console.error('Error fetching posts:', error); 25 - } 26 - } 27 - 28 - let posts = $state<FeedViewPost[]>([]); 29 - onMount(async () => { 30 - let loadedPosts = await fetchLatestPosts('flo-bit.dev'); 31 - if (loadedPosts) { 32 - posts = loadedPosts; 33 - } 34 - }); 35 - </script> 36 - 37 - <Box class="not-prose relative flex min-h-24 w-full flex-col gap-8"> 38 - {#each posts as post} 39 - <BlueskyPost feedViewPost={post} showLogo></BlueskyPost> 40 - {/each} 41 - </Box>
+41
apps/docs/src/routes/(main)/components/social/post/Example-bluesky.svelte
··· 1 + <script lang="ts"> 2 + import { Box } from '@fuxui/base'; 3 + import { BlueskyPost, blueskyPostToPostData, Post } from '@fuxui/social'; 4 + import { BskyAgent } from '@atproto/api'; 5 + import { onMount } from 'svelte'; 6 + import type { FeedViewPost } from '@atproto/api/dist/client/types/app/bsky/feed/defs'; 7 + 8 + async function fetchLatestPosts(handle: string) { 9 + const agent = new BskyAgent({ 10 + service: 'https://public.api.bsky.app' 11 + }); 12 + 13 + try { 14 + const did = await agent.resolveHandle({ handle }); 15 + console.log(did); 16 + const feed = await agent.getAuthorFeed({ 17 + actor: did.data.did, 18 + limit: 10 19 + }); 20 + 21 + console.log(feed); 22 + return feed.data.feed; 23 + } catch (error) { 24 + console.error('Error fetching posts:', error); 25 + } 26 + } 27 + 28 + let posts = $state<FeedViewPost[]>([]); 29 + onMount(async () => { 30 + let loadedPosts = await fetchLatestPosts('flo-bit.dev'); 31 + if (loadedPosts) { 32 + posts = loadedPosts; 33 + } 34 + }); 35 + </script> 36 + 37 + <Box class="not-prose relative flex min-h-24 w-full flex-col gap-8"> 38 + {#each posts as post} 39 + <BlueskyPost feedViewPost={post} showLogo></BlueskyPost> 40 + {/each} 41 + </Box>
+19 -2
apps/docs/src/routes/(main)/components/social/post/Example.svelte
··· 1 1 <script lang="ts"> 2 - import { Box } from '@fuxui/base'; 2 + import { Box, toast } from '@fuxui/base'; 3 3 import { Post } from '@fuxui/social'; 4 + 5 + let liked = $state(false); 6 + let bookmarked = $state(false); 4 7 </script> 5 8 6 9 <Box class="not-prose relative flex min-h-24 w-full flex-col gap-8"> ··· 15 18 createdAt: new Date(Date.now() - 10000).toISOString(), 16 19 replyCount: 69, 17 20 repostCount: 42, 18 - likeCount: 420 21 + likeCount: 420, 19 22 }} 23 + onReplyClick={() => { 24 + toast('You clicked on reply'); 25 + }} 26 + onRepostClick={() => { 27 + toast('You clicked on repost'); 28 + }} 29 + onLikeClick={() => { 30 + liked = !liked; 31 + }} 32 + liked={liked} 33 + onBookmarkClick={() => { 34 + bookmarked = !bookmarked; 35 + }} 36 + bookmarked={bookmarked} 20 37 > 21 38 Hello, world! 22 39 </Post>
+8
apps/docs/src/routes/(main)/components/time/relative-time/+page.svelte
··· 1 + <script lang="ts"> 2 + import RelativeTimeDocs from './RelativeTime.md'; 3 + import { Prose } from '@fuxui/base'; 4 + </script> 5 + 6 + <Prose> 7 + <RelativeTimeDocs /> 8 + </Prose>
+8
apps/docs/src/routes/(main)/components/time/relative-time/Example.svelte
··· 1 + <script lang="ts"> 2 + import { Box } from '@fuxui/base'; 3 + import { RelativeTime } from '@fuxui/time'; 4 + </script> 5 + 6 + <Box class="min-h-0"> 7 + <RelativeTime date={new Date(Date.now() - 10000)} locale="en-US" /> 8 + </Box>
+23
apps/docs/src/routes/(main)/components/time/relative-time/RelativeTime.md
··· 1 + <script lang="ts"> 2 + import Example from './Example.svelte'; 3 + </script> 4 + 5 + # Stopwatch 6 + 7 + ## Example 8 + 9 + <Example /> 10 + 11 + ## Usage 12 + 13 + ```svelte 14 + <script lang="ts"> 15 + import { RelativeTime } from '@fuxui/time'; 16 + </script> 17 + 18 + <RelativeTime date={new Date(Date.now() - 10000)} locale="en-US" /> 19 + ``` 20 + 21 + ## Credits 22 + 23 + - Based on [svelte-relative-time](https://github.com/CaptainCodeman/svelte-relative-time)