[Embeds] Embed for single post (#3450)
* add bskyembed vite app * create build script (temp until embedr is ready) * add build output to web build * simplify post-build step by copying everything at once * add simple post viewer * add butterfly logo * add vite plugin legacy * proper error screen * add image embed * add url embed * record embed + embedwithmedia * add list+feed embeds * add labeller embed (just to be safe) * fix curatelist and modlist being the wrong way around * Add PWI opt-out * add favicon * improve wording of PWI * remove padding I used for screenshots * add disabled state to embed * improve PWI styles by adding an icon * remove unused prop * rm open proxy * [Embeds] Add CTA and add general polish - input needed! (#3454) * add CTA, colourful icons, and bigger logo * make hover effect smaller + add to cta * more responsive + preserve whitespace * add trailing newsline to deploy script * add repost indicator * Make butterfly link to content * More consistent error text wording --------- Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
This commit is contained in:
parent
93731e6d6b
commit
8e29b1f633
23 changed files with 2371 additions and 1315 deletions
|
@ -1,18 +0,0 @@
|
|||
import {Fragment, h} from 'preact'
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<>
|
||||
<p>Hello Vite + Preact!</p>
|
||||
<p>
|
||||
<a
|
||||
className="link"
|
||||
href="https://preactjs.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
Learn Preact
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
32
bskyembed/src/container.tsx
Normal file
32
bskyembed/src/container.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import {ComponentChildren, h} from 'preact'
|
||||
import {useRef} from 'preact/hooks'
|
||||
|
||||
import {Link} from './link'
|
||||
|
||||
export function Container({
|
||||
children,
|
||||
href,
|
||||
}: {
|
||||
children: ComponentChildren
|
||||
href: string
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="w-full bg-white hover:bg-neutral-50 relative transition-colors max-w-[550px] min-w-[300px] flex border rounded-xl px-4 pt-3 pb-2.5"
|
||||
onClick={() => {
|
||||
if (ref.current) {
|
||||
// forwardRef requires preact/compat - let's keep it simple
|
||||
// to keep the bundle size down
|
||||
const anchor = ref.current.querySelector('a')
|
||||
if (anchor) {
|
||||
anchor.click()
|
||||
}
|
||||
}
|
||||
}}>
|
||||
<Link href={href} />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
299
bskyembed/src/embed.tsx
Normal file
299
bskyembed/src/embed.tsx
Normal file
|
@ -0,0 +1,299 @@
|
|||
import {
|
||||
AppBskyEmbedExternal,
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyGraphDefs,
|
||||
AppBskyLabelerDefs,
|
||||
} from '@atproto/api'
|
||||
import {ComponentChildren, h} from 'preact'
|
||||
|
||||
import infoIcon from '../assets/circleInfo_stroke2_corner0_rounded.svg'
|
||||
import {Link} from './link'
|
||||
import {getRkey} from './utils'
|
||||
|
||||
export function Embed({content}: {content: AppBskyFeedDefs.PostView['embed']}) {
|
||||
if (!content) return null
|
||||
|
||||
try {
|
||||
// Case 1: Image
|
||||
if (AppBskyEmbedImages.isView(content)) {
|
||||
return <ImageEmbed content={content} />
|
||||
}
|
||||
|
||||
// Case 2: External link
|
||||
if (AppBskyEmbedExternal.isView(content)) {
|
||||
return <ExternalEmbed content={content} />
|
||||
}
|
||||
|
||||
// Case 3: Record (quote or linked post)
|
||||
if (AppBskyEmbedRecord.isView(content)) {
|
||||
const record = content.record
|
||||
|
||||
// Case 3.1: Post
|
||||
if (AppBskyEmbedRecord.isViewRecord(record)) {
|
||||
const pwiOptOut = !!record.author.labels?.find(
|
||||
label => label.val === '!no-unauthenticated',
|
||||
)
|
||||
if (pwiOptOut) {
|
||||
return (
|
||||
<Info>
|
||||
The author of the quoted post has requested their posts not be
|
||||
displayed on external sites.
|
||||
</Info>
|
||||
)
|
||||
}
|
||||
|
||||
let text
|
||||
if (AppBskyFeedPost.isRecord(record.value)) {
|
||||
text = record.value.text
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
href={`/profile/${record.author.did}/post/${getRkey(record)}`}
|
||||
className="transition-colors hover:bg-neutral-100 border rounded-lg p-2 gap-1.5 w-full flex flex-col">
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<img
|
||||
src={record.author.avatar}
|
||||
className="w-4 h-4 rounded-full bg-neutral-300 shrink-0"
|
||||
/>
|
||||
<p className="line-clamp-1 text-sm">
|
||||
<span className="font-bold">{record.author.displayName}</span>
|
||||
<span className="text-textLight ml-1">
|
||||
@{record.author.handle}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
{text && <p className="text-sm">{text}</p>}
|
||||
{record.embeds
|
||||
?.filter(embed => {
|
||||
if (AppBskyEmbedImages.isView(embed)) return true
|
||||
if (AppBskyEmbedExternal.isView(embed)) return true
|
||||
return false
|
||||
})
|
||||
.map(embed => (
|
||||
<Embed key={embed.$type} content={embed} />
|
||||
))}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// Case 3.2: List
|
||||
if (AppBskyGraphDefs.isListView(record)) {
|
||||
return (
|
||||
<GenericWithImage
|
||||
image={record.avatar}
|
||||
title={record.name}
|
||||
href={`/profile/${record.creator.did}/lists/${getRkey(record)}`}
|
||||
subtitle={
|
||||
record.purpose === AppBskyGraphDefs.MODLIST
|
||||
? `Moderation list by @${record.creator.handle}`
|
||||
: `User list by @${record.creator.handle}`
|
||||
}
|
||||
description={record.description}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Case 3.3: Feed
|
||||
if (AppBskyFeedDefs.isGeneratorView(record)) {
|
||||
return (
|
||||
<GenericWithImage
|
||||
image={record.avatar}
|
||||
title={record.displayName}
|
||||
href={`/profile/${record.creator.did}/feed/${getRkey(record)}`}
|
||||
subtitle={`Feed by @${record.creator.handle}`}
|
||||
description={`Liked by ${record.likeCount ?? 0} users`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Case 3.4: Labeler
|
||||
if (AppBskyLabelerDefs.isLabelerView(record)) {
|
||||
return (
|
||||
<GenericWithImage
|
||||
image={record.creator.avatar}
|
||||
title={record.creator.displayName || record.creator.handle}
|
||||
href={`/profile/${record.creator.did}`}
|
||||
subtitle="Labeler"
|
||||
description={`Liked by ${record.likeCount ?? 0} users`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Case 3.5: Post not found
|
||||
if (AppBskyEmbedRecord.isViewNotFound(record)) {
|
||||
return <Info>Quoted post not found, it may have been deleted.</Info>
|
||||
}
|
||||
|
||||
// Case 3.6: Post blocked
|
||||
if (AppBskyEmbedRecord.isViewBlocked(record)) {
|
||||
return <Info>The quoted post is blocked.</Info>
|
||||
}
|
||||
|
||||
throw new Error('Unknown embed type')
|
||||
}
|
||||
|
||||
// Case 4: Record with media
|
||||
if (AppBskyEmbedRecordWithMedia.isView(content)) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Embed content={content.media} />
|
||||
<Embed
|
||||
content={{
|
||||
$type: 'app.bsky.embed.record#view',
|
||||
record: content.record.record,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error('Unsupported embed type')
|
||||
} catch (err) {
|
||||
return (
|
||||
<Info>{err instanceof Error ? err.message : 'An error occurred'}</Info>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function Info({children}: {children: ComponentChildren}) {
|
||||
return (
|
||||
<div className="w-full rounded-lg border py-2 px-2.5 flex-row flex gap-2 bg-neutral-50">
|
||||
<img src={infoIcon as string} className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-textLight">{children}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ImageEmbed({content}: {content: AppBskyEmbedImages.View}) {
|
||||
switch (content.images.length) {
|
||||
case 1:
|
||||
return (
|
||||
<img
|
||||
src={content.images[0].thumb}
|
||||
alt={content.images[0].alt}
|
||||
className="w-full rounded-lg overflow-hidden object-cover h-auto max-h-[1000px]"
|
||||
/>
|
||||
)
|
||||
case 2:
|
||||
return (
|
||||
<div className="flex gap-1 rounded-lg overflow-hidden w-full aspect-[2/1]">
|
||||
{content.images.map((image, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={image.thumb}
|
||||
alt={image.alt}
|
||||
className="w-1/2 h-full object-cover rounded-sm"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
case 3:
|
||||
return (
|
||||
<div className="flex gap-1 rounded-lg overflow-hidden w-full aspect-[2/1]">
|
||||
<img
|
||||
src={content.images[0].thumb}
|
||||
alt={content.images[0].alt}
|
||||
className="flex-[3] object-cover rounded-sm"
|
||||
/>
|
||||
<div className="flex flex-col gap-1 flex-[2]">
|
||||
{content.images.slice(1).map((image, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={image.thumb}
|
||||
alt={image.alt}
|
||||
className="w-full h-full object-cover rounded-sm"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
case 4:
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-1 rounded-lg overflow-hidden">
|
||||
{content.images.map((image, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={image.thumb}
|
||||
alt={image.alt}
|
||||
className="aspect-square w-full object-cover rounded-sm"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function ExternalEmbed({content}: {content: AppBskyEmbedExternal.View}) {
|
||||
function toNiceDomain(url: string): string {
|
||||
try {
|
||||
const urlp = new URL(url)
|
||||
return urlp.host ? urlp.host : url
|
||||
} catch (e) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
href={content.external.uri}
|
||||
className="w-full rounded-lg overflow-hidden border flex flex-col items-stretch">
|
||||
{content.external.thumb && (
|
||||
<img
|
||||
src={content.external.thumb}
|
||||
className="aspect-[1.91/1] object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="py-3 px-4">
|
||||
<p className="text-sm text-textLight line-clamp-1">
|
||||
{toNiceDomain(content.external.uri)}
|
||||
</p>
|
||||
<p className="font-semibold line-clamp-3">{content.external.title}</p>
|
||||
<p className="text-sm text-textLight line-clamp-2 mt-0.5">
|
||||
{content.external.description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function GenericWithImage({
|
||||
title,
|
||||
subtitle,
|
||||
href,
|
||||
image,
|
||||
description,
|
||||
}: {
|
||||
title: string
|
||||
subtitle: string
|
||||
href: string
|
||||
image?: string
|
||||
description?: string
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="w-full rounded-lg border py-2 px-3 flex flex-col gap-2">
|
||||
<div className="flex gap-2.5 items-center">
|
||||
{image ? (
|
||||
<img
|
||||
src={image}
|
||||
alt={title}
|
||||
className="w-8 h-8 rounded-md bg-neutral-300 shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-md bg-brand shrink-0" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="font-bold text-sm">{title}</p>
|
||||
<p className="text-textLight text-sm">{subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
{description && <p className="text-textLight text-sm">{description}</p>}
|
||||
</Link>
|
||||
)
|
||||
}
|
|
@ -1,29 +1,7 @@
|
|||
html, body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: #FAFAFA;
|
||||
font-family: 'Helvetica Neue', arial, sans-serif;
|
||||
font-weight: 400;
|
||||
color: #444;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
background-color: #673ab8;
|
||||
color: #fff;
|
||||
font-size: 1.5em;
|
||||
padding-top: 100px;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #fff;
|
||||
}
|
||||
.break-word {
|
||||
word-break: break-word;
|
||||
}
|
21
bskyembed/src/link.tsx
Normal file
21
bskyembed/src/link.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
import {h} from 'preact'
|
||||
|
||||
export function Link({
|
||||
href,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
href: string
|
||||
className?: string
|
||||
} & h.JSX.HTMLAttributes<HTMLAnchorElement>) {
|
||||
return (
|
||||
<a
|
||||
href={href.startsWith('http') ? href : `https://bsky.app${href}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
onClick={evt => evt.stopPropagation()}
|
||||
className={`cursor-pointer ${className || ''}`}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -1,9 +1,88 @@
|
|||
import './index.css'
|
||||
|
||||
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
|
||||
import {h, render} from 'preact'
|
||||
|
||||
import {App} from './app'
|
||||
import logo from '../assets/logo.svg'
|
||||
import {Container} from './container'
|
||||
import {Link} from './link'
|
||||
import {Post} from './post'
|
||||
import {getRkey} from './utils'
|
||||
|
||||
const root = document.getElementById('app')
|
||||
if (!root) throw new Error('No root element')
|
||||
render(<App />, root)
|
||||
|
||||
const searchParams = new URLSearchParams(window.location.search)
|
||||
|
||||
const agent = new BskyAgent({
|
||||
service: 'https://public.api.bsky.app',
|
||||
})
|
||||
|
||||
const uri = searchParams.get('uri')
|
||||
|
||||
if (!uri) {
|
||||
throw new Error('No uri in query string')
|
||||
}
|
||||
|
||||
agent
|
||||
.getPostThread({
|
||||
uri,
|
||||
depth: 0,
|
||||
parentHeight: 0,
|
||||
})
|
||||
.then(({data}) => {
|
||||
if (!AppBskyFeedDefs.isThreadViewPost(data.thread)) {
|
||||
throw new Error('Expected a ThreadViewPost')
|
||||
}
|
||||
const pwiOptOut = !!data.thread.post.author.labels?.find(
|
||||
label => label.val === '!no-unauthenticated',
|
||||
)
|
||||
if (pwiOptOut) {
|
||||
render(<PwiOptOut thread={data.thread} />, root)
|
||||
} else {
|
||||
render(<Post thread={data.thread} />, root)
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err)
|
||||
render(<ErrorMessage />, root)
|
||||
})
|
||||
|
||||
function PwiOptOut({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
|
||||
const href = `/profile/${thread.post.author.did}/post/${getRkey(thread.post)}`
|
||||
return (
|
||||
<Container href={href}>
|
||||
<Link
|
||||
href={href}
|
||||
className="transition-transform hover:scale-110 absolute top-4 right-4">
|
||||
<img src={logo as string} className="h-6" />
|
||||
</Link>
|
||||
<div className="w-full py-12 gap-4 flex flex-col items-center">
|
||||
<p className="max-w-80 text-center w-full text-textLight">
|
||||
The author of this post has requested their posts not be displayed on
|
||||
external sites.
|
||||
</p>
|
||||
<Link
|
||||
href={href}
|
||||
className="max-w-80 rounded-lg bg-brand text-white color-white text-center py-1 px-4 w-full mx-auto">
|
||||
View on Bluesky
|
||||
</Link>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorMessage() {
|
||||
return (
|
||||
<Container href="https://bsky.app/">
|
||||
<Link
|
||||
href="https://bsky.app/"
|
||||
className="transition-transform hover:scale-110 absolute top-4 right-4">
|
||||
<img src={logo as string} className="h-6" />
|
||||
</Link>
|
||||
<p className="my-16 text-center w-full text-textLight">
|
||||
Post not found, it may have been deleted.
|
||||
</p>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
|
150
bskyembed/src/post.tsx
Normal file
150
bskyembed/src/post.tsx
Normal file
|
@ -0,0 +1,150 @@
|
|||
import {AppBskyFeedDefs, AppBskyFeedPost, RichText} from '@atproto/api'
|
||||
import {h} from 'preact'
|
||||
|
||||
import replyIcon from '../assets/bubble_filled_stroke2_corner2_rounded.svg'
|
||||
import likeIcon from '../assets/heart2_filled_stroke2_corner0_rounded.svg'
|
||||
import logo from '../assets/logo.svg'
|
||||
import repostIcon from '../assets/repost_stroke2_corner2_rounded.svg'
|
||||
import {Container} from './container'
|
||||
import {Embed} from './embed'
|
||||
import {Link} from './link'
|
||||
import {getRkey, niceDate} from './utils'
|
||||
|
||||
interface Props {
|
||||
thread: AppBskyFeedDefs.ThreadViewPost
|
||||
}
|
||||
|
||||
export function Post({thread}: Props) {
|
||||
const post = thread.post
|
||||
|
||||
let record: AppBskyFeedPost.Record | null = null
|
||||
if (AppBskyFeedPost.isRecord(post.record)) {
|
||||
record = post.record
|
||||
}
|
||||
|
||||
const href = `/profile/${post.author.did}/post/${getRkey(post)}`
|
||||
return (
|
||||
<Container href={href}>
|
||||
<div className="flex-1 flex-col flex gap-2">
|
||||
<div className="flex gap-2.5 items-center">
|
||||
<Link href={`/profile/${post.author.did}`} className="rounded-full">
|
||||
<img
|
||||
src={post.author.avatar}
|
||||
className="w-10 h-10 rounded-full bg-neutral-300 shrink-0"
|
||||
/>
|
||||
</Link>
|
||||
<div className="flex-1">
|
||||
<Link
|
||||
href={`/profile/${post.author.did}`}
|
||||
className="font-bold text-[17px] leading-5 line-clamp-1 hover:underline underline-offset-2 decoration-2">
|
||||
<p>{post.author.displayName}</p>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/profile/${post.author.did}`}
|
||||
className="text-[15px] text-textLight hover:underline line-clamp-1">
|
||||
<p>@{post.author.handle}</p>
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href={href}
|
||||
className="transition-transform hover:scale-110 shrink-0 self-start">
|
||||
<img src={logo as string} className="h-8" />
|
||||
</Link>
|
||||
</div>
|
||||
<PostContent record={record} />
|
||||
<Embed content={post.embed} />
|
||||
<time
|
||||
datetime={new Date(post.indexedAt).toISOString()}
|
||||
className="text-textLight mt-1 text-sm">
|
||||
{niceDate(post.indexedAt)}
|
||||
</time>
|
||||
<div className="border-t w-full pt-2.5 flex items-center gap-5 text-sm">
|
||||
{!!post.likeCount && (
|
||||
<div className="flex items-center gap-2 cursor-pointer">
|
||||
<img src={likeIcon as string} className="w-5 h-5" />
|
||||
<p className="font-bold text-neutral-500 mb-px">
|
||||
{post.likeCount}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!!post.repostCount && (
|
||||
<div className="flex items-center gap-2 cursor-pointer">
|
||||
<img src={repostIcon as string} className="w-5 h-5" />
|
||||
<p className="font-bold text-neutral-500 mb-px">
|
||||
{post.repostCount}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 cursor-pointer">
|
||||
<img src={replyIcon as string} className="w-5 h-5" />
|
||||
<p className="font-bold text-neutral-500 mb-px">Reply</p>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<p className="cursor-pointer text-brand font-bold hover:underline hidden min-[450px]:inline">
|
||||
{post.replyCount
|
||||
? `Read ${post.replyCount} ${
|
||||
post.replyCount > 1 ? 'replies' : 'reply'
|
||||
} on Bluesky`
|
||||
: `View on Bluesky`}
|
||||
</p>
|
||||
<p className="cursor-pointer text-brand font-bold hover:underline min-[450px]:hidden">
|
||||
<span className="hidden min-[380px]:inline">View on </span>Bluesky
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
|
||||
if (!record) return null
|
||||
|
||||
const rt = new RichText({
|
||||
text: record.text,
|
||||
facets: record.facets,
|
||||
})
|
||||
|
||||
const richText = []
|
||||
|
||||
let counter = 0
|
||||
for (const segment of rt.segments()) {
|
||||
if (segment.isLink() && segment.link) {
|
||||
richText.push(
|
||||
<Link
|
||||
key={counter}
|
||||
href={segment.link.uri}
|
||||
className="text-blue-400 hover:underline">
|
||||
{segment.text}
|
||||
</Link>,
|
||||
)
|
||||
} else if (segment.isMention() && segment.mention) {
|
||||
richText.push(
|
||||
<Link
|
||||
key={counter}
|
||||
href={`/profile/${segment.mention.did}`}
|
||||
className="text-blue-500 hover:underline">
|
||||
{segment.text}
|
||||
</Link>,
|
||||
)
|
||||
} else if (segment.isTag() && segment.tag) {
|
||||
richText.push(
|
||||
<Link
|
||||
key={counter}
|
||||
href={`/tag/${segment.tag.tag}`}
|
||||
className="text-blue-500 hover:underline">
|
||||
{segment.text}
|
||||
</Link>,
|
||||
)
|
||||
} else {
|
||||
richText.push(segment.text)
|
||||
}
|
||||
|
||||
counter++
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="text-lg leading-6 break-word break-words whitespace-pre-wrap">
|
||||
{richText}
|
||||
</p>
|
||||
)
|
||||
}
|
15
bskyembed/src/utils.ts
Normal file
15
bskyembed/src/utils.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
export function niceDate(date: number | string | Date) {
|
||||
const d = new Date(date)
|
||||
return `${d.toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})} at ${d.toLocaleTimeString(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})}`
|
||||
}
|
||||
|
||||
export function getRkey({uri}: {uri: string}): string {
|
||||
return uri.split('/').pop() as string
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue