diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb91560..f41f6b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: - name: Setup Deno uses: denoland/setup-deno@v1 with: - deno-version: v2.0.0 + deno-version: v2.1.3 - name: Check types run: deno task check diff --git a/components/Avatar.tsx b/components/Avatar.tsx new file mode 100644 index 0000000..a10d95c --- /dev/null +++ b/components/Avatar.tsx @@ -0,0 +1,19 @@ +import { TbBrandBluesky } from '@preact-icons/tb' + +export function Avatar( + { src, name }: { src?: string; name?: string }, +) { + return src + ? ( + {`${name}'s + ) + : ( +
+ {name?.charAt(0).toUpperCase() || } +
+ ) +} diff --git a/components/BskyAuthor.tsx b/components/BskyAuthor.tsx new file mode 100644 index 0000000..b1726ab --- /dev/null +++ b/components/BskyAuthor.tsx @@ -0,0 +1,35 @@ +import { formatDateTime, getTimeAgo } from '../utils/intl.ts' + +export function BskyAuthor({ author, indexedAt, postUri }: { + author: { handle: string; displayName?: string } + indexedAt: string + postUri?: string +}) { + return ( +
+ + {author.displayName}{' '} + + @{author.handle} + + + ยท + {postUri && ( + + {getTimeAgo(new Date(indexedAt))} + + )} +
+ ) +} diff --git a/components/PostEmbed.tsx b/components/PostEmbed.tsx new file mode 100644 index 0000000..844da5b --- /dev/null +++ b/components/PostEmbed.tsx @@ -0,0 +1,124 @@ +import type { + AppBskyEmbedExternal, + AppBskyEmbedImages, + AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, + AppBskyEmbedVideo, +} from '@atproto/api' +import { Avatar } from './Avatar.tsx' +import { BskyAuthor } from './BskyAuthor.tsx' +import { ReactionBar } from './ReactionBar.tsx' +import { TbArrowUpRight, TbBrandBluesky } from '@preact-icons/tb' + +// Type guard to check if the record has a text property +const hasText = (value: unknown): value is { text: string } => + typeof value === 'object' && value !== null && 'text' in value + +function EmbedContent({ postUri }: { postUri: string }) { + // Extract the post ID from the URI + const postId = postUri.split('/').pop() + + // For AT protocol URIs, we need to extract the DID + const match = postUri.match(/at:\/\/([^/]+)\//) + const did = match ? match[1] : null + + if (!postId || !did) { + console.warn('Invalid post URI:', postUri) + return null + } + + return ( + + View content on + Bluesky + + ) +} + +function EmbedBskyPost({ embed }: { embed: AppBskyEmbedRecord.View }) { + const record = embed.record as AppBskyEmbedRecord.ViewRecord + + return ( +
+
+ + +
+ {hasText(record.value) && ( +

{record.value.text}

+ )} + + {typeof record.value === 'object' && record.value !== null && + 'embed' in record.value && } + +
+ +
+
+ ) +} + +export function PostEmbed({ embed, postUri }: { + embed: + | AppBskyEmbedImages.View + | AppBskyEmbedVideo.View + | AppBskyEmbedExternal.View + | AppBskyEmbedRecord.View + | AppBskyEmbedRecordWithMedia.View + | { $type: string } + postUri: string +}) { + if (!embed || typeof embed.$type !== 'string') return null + + const type = embed.$type.replace('#view', '') + + // Handle BlueSky post embeds + if (type === 'app.bsky.embed.record' && 'record' in embed) { + // Handle not found records + if ( + typeof embed.record === 'object' && embed.record !== null && + '$type' in embed.record && + embed.record.$type === 'app.bsky.embed.record#viewNotFound' + ) { + return ( +
+ Post not found +
+ ) + } + + // Handle list view + if ( + typeof embed.record === 'object' && embed.record !== null && + '$type' in embed.record && + embed.record.$type === 'app.bsky.graph.defs#listView' + ) { + return + } + return + } + + // For all other types of embeds, show a link to view on BlueSky + return +} diff --git a/components/ReactionBar.tsx b/components/ReactionBar.tsx new file mode 100644 index 0000000..faf0d5d --- /dev/null +++ b/components/ReactionBar.tsx @@ -0,0 +1,24 @@ +import { ReactionCount } from './ReactionCount.tsx' + +export function ReactionBar( + { likes, reposts, replies, postUri }: { + likes: number + reposts: number + replies: number + postUri: string + }, +) { + return ( + + + + + + ) +} diff --git a/components/ReactionCount.tsx b/components/ReactionCount.tsx new file mode 100644 index 0000000..c4967ca --- /dev/null +++ b/components/ReactionCount.tsx @@ -0,0 +1,28 @@ +import TbHeart from '@preact-icons/tb/TbHeart' +import TbMessageCircle from '@preact-icons/tb/TbMessageCircle' +import TbRepeat from '@preact-icons/tb/TbRepeat' +import { formatCount } from '../utils/intl.ts' + +export function ReactionCount( + { count, label }: { count: number; label: string }, +) { + const Icon = () => { + switch (label) { + case 'like': + return + case 'repost': + return + case 'reply': + return + default: + return null + } + } + + return ( +
+ + {formatCount(count)} +
+ ) +} diff --git a/components/Reply.tsx b/components/Reply.tsx new file mode 100644 index 0000000..516cb00 --- /dev/null +++ b/components/Reply.tsx @@ -0,0 +1,91 @@ +import type { AppBskyFeedDefs } from '@atproto/api' +import { AppBskyFeedPost } from '@atproto/api' +import { Avatar } from './Avatar.tsx' +import { BskyAuthor } from './BskyAuthor.tsx' +import { PostEmbed } from './PostEmbed.tsx' +import { ReactionBar } from './ReactionBar.tsx' +import { TbArrowUpRight, TbBrandBluesky } from '@preact-icons/tb' + +export function Reply( + { thread }: { + thread: AppBskyFeedDefs.ThreadViewPost + }, +) { + const post = thread.post + const replies = + thread.replies?.filter((r): r is AppBskyFeedDefs.ThreadViewPost => + !('notFound' in r) && !('blocked' in r) + ).sort((a, b) => + new Date(a.post.indexedAt).getTime() - + new Date(b.post.indexedAt).getTime() + ) ?? [] + + const hasMoreReplies = (post.replyCount ?? 0) > (replies?.length ?? 0) + const showThread = replies.length > 0 || hasMoreReplies + + return ( +
+
+
+ + + + {showThread && ( +
+ )} +
+
+ +

+ {AppBskyFeedPost.isRecord(post.record) ? post.record.text : ''} +

+ {post.embed && } +
+ +
+
+
+ + {showThread && ( +
+ {replies.map((reply) => ( + + ))} + {hasMoreReplies && ( + + Continue thread on + BlueSky + + )} +
+ )} +
+ ) +} diff --git a/deno.json b/deno.json index fad5c83..6cd6cd8 100644 --- a/deno.json +++ b/deno.json @@ -4,7 +4,11 @@ "dev": "deno run -A --watch=static/,routes/ dev.ts --env-file .env ", "build": "deno run -A dev.ts build", "start": "deno run -A main.ts", - "update": "deno run -A -r jsr:@fresh/update ." + "update": "deno run -A -r jsr:@fresh/update .", + "bsky-uri": { + "description": "Utility to retrieve the `at:` URI for a given bsky post URL. Useful to set the `site.bskyUri` in blog post's frontmatter.", + "command": "deno run -A utils/bsky-uri.ts" + } }, "lint": { "rules": { @@ -18,6 +22,7 @@ "**/_fresh/*" ], "imports": { + "@atproto/api": "npm:@atproto/api@^0.13.19", "@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@^0.1.48", "@fresh/plugin-tailwind": "jsr:@fresh/plugin-tailwind@^0.0.1-alpha.7", "@preact-icons/gr": "jsr:@preact-icons/gr@^1.0.12", @@ -29,6 +34,8 @@ "@std/fs": "jsr:@std/fs@^1.0.6", "@std/path": "jsr:@std/path@^1.0.8", "@tailwindcss/typography": "npm:@tailwindcss/typography@^0.5.15", + "@jridgewell/gen-mapping": "npm:@jridgewell/gen-mapping@^0.3.5", + "@jridgewell/trace-mapping": "npm:@jridgewell/trace-mapping@^0.3.25", "fresh": "jsr:@fresh/core@^2.0.0-alpha.22", "myst-frontmatter": "npm:myst-frontmatter@^1.7.5", "preact": "npm:preact@10.24.2", diff --git a/deno.lock b/deno.lock index 8ea0488..1d37dd3 100644 --- a/deno.lock +++ b/deno.lock @@ -3,7 +3,7 @@ "specifiers": { "jsr:@b-fuze/deno-dom@~0.1.48": "0.1.48", "jsr:@denosaurs/plug@1.0.3": "1.0.3", - "jsr:@fresh/core@^2.0.0-alpha.1": "2.0.0-alpha.22", + "jsr:@fresh/core@^2.0.0-alpha.1": "2.0.0-alpha.25", "jsr:@fresh/core@^2.0.0-alpha.22": "2.0.0-alpha.25", "jsr:@fresh/plugin-tailwind@^0.0.1-alpha.7": "0.0.1-alpha.7", "jsr:@luca/esbuild-deno-loader@0.11": "0.11.0", @@ -36,6 +36,7 @@ "jsr:@std/html@1": "1.0.3", "jsr:@std/html@~0.224.2": "0.224.2", "jsr:@std/internal@^1.0.5": "1.0.5", + "jsr:@std/json@1": "1.0.0", "jsr:@std/json@^1.0.0-rc.1": "1.0.0", "jsr:@std/json@~0.213.1": "0.213.1", "jsr:@std/jsonc@0.213": "0.213.1", @@ -54,6 +55,9 @@ "jsr:@std/path@~0.225.2": "0.225.2", "jsr:@std/semver@1": "1.0.3", "jsr:@std/semver@~0.224.3": "0.224.3", + "npm:@atproto/api@~0.13.19": "0.13.19", + "npm:@jridgewell/gen-mapping@~0.3.5": "0.3.5", + "npm:@jridgewell/trace-mapping@~0.3.25": "0.3.25", "npm:@preact/signals@1.3.0": "1.3.0_preact@10.22.1", "npm:@preact/signals@^1.2.3": "1.3.0_preact@10.22.1", "npm:@preact/signals@^1.3.0": "1.3.0_preact@10.22.1", @@ -296,7 +300,10 @@ ] }, "@std/jsonc@1.0.1": { - "integrity": "6b36956e2a7cbb08ca5ad7fbec72e661e6217c202f348496ea88747636710dda" + "integrity": "6b36956e2a7cbb08ca5ad7fbec72e661e6217c202f348496ea88747636710dda", + "dependencies": [ + "jsr:@std/json@1" + ] }, "@std/media-types@1.0.3": { "integrity": "b12d30a7852f7578f4d210622df713bbfd1cbdd9b4ec2eaf5c1845ab70bab159" @@ -339,6 +346,48 @@ "@alloc/quick-lru@5.2.0": { "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==" }, + "@atproto/api@0.13.19": { + "integrity": "sha512-rLWQBZaOIk3ds1Fx9CwrdyX3X2GbdSEvVJ9mdSPNX40joiEaE1ljGMOcziFipbvZacXynozE4E0Sb1CgOhzfmA==", + "dependencies": [ + "@atproto/common-web", + "@atproto/lexicon", + "@atproto/syntax", + "@atproto/xrpc", + "await-lock", + "multiformats", + "tlds", + "zod" + ] + }, + "@atproto/common-web@0.3.1": { + "integrity": "sha512-N7wiTnus5vAr+lT//0y8m/FaHHLJ9LpGuEwkwDAeV3LCiPif4m/FS8x/QOYrx1PdZQwKso95RAPzCGWQBH5j6Q==", + "dependencies": [ + "graphemer", + "multiformats", + "uint8arrays", + "zod" + ] + }, + "@atproto/lexicon@0.4.3": { + "integrity": "sha512-lFVZXe1S1pJP0dcxvJuHP3r/a+EAIBwwU7jUK+r8iLhIja+ml6NmYv8KeFHmIJATh03spEQ9s02duDmFVdCoXg==", + "dependencies": [ + "@atproto/common-web", + "@atproto/syntax", + "iso-datestring-validator", + "multiformats", + "zod" + ] + }, + "@atproto/syntax@0.3.1": { + "integrity": "sha512-fzW0Mg1QUOVCWUD3RgEsDt6d1OZ6DdFmbKcDdbzUfh0t4rhtRAC05KbZYmxuMPWDAiJ4BbbQ5dkAc/mNypMXkw==" + }, + "@atproto/xrpc@0.6.4": { + "integrity": "sha512-9ZAJ8nsXTqC4XFyS0E1Wlg7bAvonhXQNQ3Ocs1L1LIwFLXvsw/4fNpIHXxvXvqTCVeyHLbImOnE9UiO1c/qIYA==", + "dependencies": [ + "@atproto/lexicon", + "zod" + ] + }, "@esbuild/aix-ppc64@0.23.1": { "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==" }, @@ -605,6 +654,9 @@ "postcss-value-parser" ] }, + "await-lock@2.2.2": { + "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==" + }, "bail@2.0.2": { "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==" }, @@ -1024,6 +1076,9 @@ "path-scurry" ] }, + "graphemer@1.4.0": { + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" + }, "has-flag@4.0.0": { "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" }, @@ -1349,6 +1404,9 @@ "isexe@2.0.0": { "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, + "iso-datestring-validator@2.2.2": { + "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==" + }, "jackspeak@3.4.3": { "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "dependencies": [ @@ -1572,6 +1630,9 @@ "minipass@7.1.2": { "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==" }, + "multiformats@9.9.0": { + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==" + }, "myst-cli-utils@1.0.0": { "integrity": "sha512-6pHTGOgY63WyfYWY0tz7HhdBBOISrZ08QaAbCuk7tKHNrQnIsEhgXZwy0+gVEH8eVXehOyrEC/BmeNkhufblTA==", "dependencies": [ @@ -2449,6 +2510,9 @@ "any-promise" ] }, + "tlds@1.255.0": { + "integrity": "sha512-tcwMRIioTcF/FcxLev8MJWxCp+GUALRhFEqbDoZrnowmKSGqPrl5pqS+Sut2m8BgJ6S4FExCSSpGffZ0Tks6Aw==" + }, "to-regex-range@5.0.1": { "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dependencies": [ @@ -2470,6 +2534,12 @@ "uc.micro@1.0.6": { "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" }, + "uint8arrays@3.0.0": { + "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", + "dependencies": [ + "multiformats" + ] + }, "unified@10.1.2": { "integrity": "sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==", "dependencies": [ @@ -2712,6 +2782,9 @@ "yaml@2.5.1": { "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==" }, + "zod@3.23.8": { + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==" + }, "zwitch@2.0.4": { "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==" } @@ -2735,6 +2808,9 @@ "jsr:@std/encoding@^1.0.5", "jsr:@std/fs@^1.0.6", "jsr:@std/path@^1.0.8", + "npm:@atproto/api@~0.13.19", + "npm:@jridgewell/gen-mapping@~0.3.5", + "npm:@jridgewell/trace-mapping@~0.3.25", "npm:@preact/signals@^1.3.1", "npm:@tailwindcss/typography@~0.5.15", "npm:myst-frontmatter@^1.7.5", diff --git a/islands/BlueSkyInteractions.tsx b/islands/BlueSkyInteractions.tsx new file mode 100644 index 0000000..66f1d23 --- /dev/null +++ b/islands/BlueSkyInteractions.tsx @@ -0,0 +1,142 @@ +import { useEffect, useState } from 'preact/hooks' +import type { AppBskyFeedDefs } from '@atproto/api' +import { getPostInteractions } from '../utils/bluesky.ts' +import { Reply } from '../components/Reply.tsx' +import { formatCount } from '../utils/intl.ts' + +interface Props { + postUri: string +} + +// deno-lint-ignore no-explicit-any +function isThreadViewPost(data: any): data is AppBskyFeedDefs.ThreadViewPost { + return 'post' in data +} + +export default function BlueSkyInteractions({ postUri }: Props) { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + + useEffect(() => { + const fetchData = async () => { + setLoading(true) + const response = await getPostInteractions(postUri) + if (response?.success) { + if (isThreadViewPost(response.data.thread)) { + setData(response.data.thread) + } else { + setData(null) + } + } else { + setData(null) + } + setLoading(false) + } + + fetchData() + }, [postUri]) + + if (loading && !data) { + return ( +
+ Loading reactions... +
+ ) + } + + if (!data) { + return null + } + + const post = data.post + const replies = + data.replies?.filter((r): r is AppBskyFeedDefs.ThreadViewPost => + !('notFound' in r) && !('blocked' in r) + ) ?? [] + + return ( +
+ + {replies.length > 0 && ( +
+ {replies + .sort((a, b) => + new Date(a.post.indexedAt).getTime() - + new Date(b.post.indexedAt).getTime() + ) + .map((reply) => )} +
+ )} +
+ ) +} diff --git a/routes/posts/[slug].tsx b/routes/posts/[slug].tsx index d974104..efd2936 100644 --- a/routes/posts/[slug].tsx +++ b/routes/posts/[slug].tsx @@ -10,6 +10,7 @@ import ReadTime from '../../components/ReadTime.tsx' import DialogMessages from '../../components/DialogMessages.tsx' import { Authors } from '../../components/Authors.tsx' import { PostVersions } from '../../components/PostVersions.tsx' +import BlueSkyInteractions from '../../islands/BlueSkyInteractions.tsx' interface Data { post: Post @@ -106,6 +107,14 @@ export default define.page( data-dark-theme='dark' dangerouslySetInnerHTML={{ __html: post.content }} /> + {post.frontmatter.site?.bskyUri && ( + <> +
+ + + )} ) diff --git a/static/freshblog.css b/static/freshblog.css index 7d1d078..0bdd8f1 100644 --- a/static/freshblog.css +++ b/static/freshblog.css @@ -77,7 +77,7 @@ } .freshBlog-post-content { - margin-bottom: 12rem; + margin-bottom: 4rem; } .freshBlog-post-content .footnotes { @@ -138,6 +138,10 @@ margin-right: 0.5rem; } +.freshBlog-post { + margin-bottom: 8rem; +} + .freshBlog-post header { margin-bottom: 2rem; } diff --git a/utils/bluesky.ts b/utils/bluesky.ts new file mode 100644 index 0000000..6905086 --- /dev/null +++ b/utils/bluesky.ts @@ -0,0 +1,20 @@ +import { AtpAgent } from '@atproto/api' +import type { AppBskyFeedGetPostThread } from '@atproto/api' + +const agent = new AtpAgent({ + service: 'https://public.api.bsky.app', +}) + +export async function getPostInteractions( + postUri: string, +): Promise { + try { + return await agent.getPostThread({ + uri: postUri, + depth: 3, + }) + } catch (error) { + console.error('Error fetching BlueSky post:', error) + return null + } +} diff --git a/utils/bsky-uri.ts b/utils/bsky-uri.ts new file mode 100644 index 0000000..464a098 --- /dev/null +++ b/utils/bsky-uri.ts @@ -0,0 +1,52 @@ +import { AtpAgent } from '@atproto/api' + +const agent = new AtpAgent({ + service: 'https://public.api.bsky.app', +}) + +function extractPostId(url: string): { handle: string; rkey: string } | null { + try { + // Handle URLs like https://bsky.app/profile/nrako.bsky.social/post/3kh3gxwn2if2d + const match = url.match(/bsky\.app\/profile\/([^/]+)\/post\/([^/]+)/) + if (!match) return null + return { + handle: match[1], + rkey: match[2], + } + } catch { + return null + } +} + +async function getAtUri(url: string): Promise { + const parsed = extractPostId(url) + if (!parsed) { + console.error('Invalid BlueSky post URL') + return null + } + + try { + const { data: profile } = await agent.getProfile({ actor: parsed.handle }) + const atUri = `at://${profile.did}/app.bsky.feed.post/${parsed.rkey}` + return atUri + } catch (error) { + console.error('Error fetching BlueSky post:', error) + return null + } +} + +// CLI entry point +if (import.meta.main) { + const url = Deno.args[0] + if (!url) { + console.error('Please provide a BlueSky post URL') + Deno.exit(1) + } + + const atUri = await getAtUri(url) + if (atUri) { + console.log(atUri) + } else { + Deno.exit(1) + } +} diff --git a/utils/intl.ts b/utils/intl.ts new file mode 100644 index 0000000..154af04 --- /dev/null +++ b/utils/intl.ts @@ -0,0 +1,49 @@ +export function formatCount(count: number): string { + if (count === 0) return '0' + + return new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, + }).format(count) +} + +export function getTimeAgo(date: Date) { + const now = new Date() + const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000) + + if (diffInSeconds < 30) { + return 'now' + } + if (diffInSeconds < 60) { + return `${diffInSeconds}s` + } + const diffInMinutes = Math.floor(diffInSeconds / 60) + if (diffInMinutes < 60) { + return `${diffInMinutes}m` + } + const diffInHours = Math.floor(diffInMinutes / 60) + if (diffInHours < 24) { + return `${diffInHours}h` + } + const diffInDays = Math.floor(diffInHours / 24) + if (diffInDays < 30) { + return `${diffInDays}d` + } + const diffInMonths = Math.floor(diffInDays / 30) + if (diffInMonths < 12) { + return `${diffInMonths}mo` + } + const diffInYears = Math.floor(diffInDays / 365) + return `${diffInYears}y` +} + +export function formatDateTime(date: Date) { + return new Intl.DateTimeFormat('en', { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: 'numeric', + }).format(date) +}