An ATproto social media client -- with an independent Appview.
1import {
2 AppBskyFeedDefs,
3 AppBskyFeedPost,
4 AppBskyRichtextFacet,
5 RichText,
6} from '@atproto/api'
7import {h} from 'preact'
8
9import replyIcon from '../../assets/bubble_filled_stroke2_corner2_rounded.svg'
10import likeIcon from '../../assets/heart2_filled_stroke2_corner0_rounded.svg'
11import logo from '../../assets/logo.svg'
12import repostIcon from '../../assets/repost_stroke2_corner2_rounded.svg'
13import {CONTENT_LABELS} from '../labels'
14import * as bsky from '../types/bsky'
15import {niceDate} from '../util/nice-date'
16import {prettyNumber} from '../util/pretty-number'
17import {getRkey} from '../util/rkey'
18import {getVerificationState} from '../util/verification-state'
19import {Container} from './container'
20import {Embed} from './embed'
21import {Link} from './link'
22import {VerificationCheck} from './verification-check'
23
24interface Props {
25 thread: AppBskyFeedDefs.ThreadViewPost
26}
27
28export function Post({thread}: Props) {
29 const post = thread.post
30
31 const isAuthorLabeled = post.author.labels?.some(label =>
32 CONTENT_LABELS.includes(label.val),
33 )
34
35 let record: AppBskyFeedPost.Record | null = null
36 if (
37 bsky.dangerousIsType<AppBskyFeedPost.Record>(
38 post.record,
39 AppBskyFeedPost.isRecord,
40 )
41 ) {
42 record = post.record
43 }
44
45 const verification = getVerificationState({profile: post.author})
46
47 const href = `/profile/${post.author.did}/post/${getRkey(post)}`
48 return (
49 <Container href={href}>
50 <div className="flex-1 flex-col flex gap-2" lang={record?.langs?.[0]}>
51 <div className="flex gap-2.5 items-center cursor-pointer w-full max-w-full">
52 <Link
53 href={`/profile/${post.author.did}`}
54 className="rounded-full shrink-0">
55 <div className="w-10 h-10 overflow-hidden rounded-full bg-neutral-300 dark:bg-slate-700 shrink-0">
56 <img
57 src={post.author.avatar}
58 style={isAuthorLabeled ? {filter: 'blur(2.5px)'} : undefined}
59 />
60 </div>
61 </Link>
62 <div className="flex flex-1 flex-col min-w-0">
63 <div className="flex flex-1 items-center">
64 <Link
65 href={`/profile/${post.author.did}`}
66 className="block font-bold text-[17px] leading-5 line-clamp-1 hover:underline underline-offset-2 text-ellipsis decoration-2">
67 {post.author.displayName?.trim() || post.author.handle}
68 </Link>
69 {verification.isVerified && (
70 <VerificationCheck
71 className="pl-[3px] mt-px shrink-0"
72 verifier={verification.role === 'verifier'}
73 size={15}
74 />
75 )}
76 </div>
77 <Link
78 href={`/profile/${post.author.did}`}
79 className="block text-[15px] text-textLight dark:text-textDimmed hover:underline line-clamp-1">
80 @{post.author.handle}
81 </Link>
82 </div>
83 <Link
84 href={href}
85 className="transition-transform hover:scale-110 shrink-0 self-start">
86 <img src={logo} className="h-8" />
87 </Link>
88 </div>
89 <PostContent record={record} />
90 <Embed content={post.embed} labels={post.labels} />
91 <Link href={href}>
92 <time
93 datetime={new Date(post.indexedAt).toISOString()}
94 className="text-textLight dark:text-textDimmed mt-1 text-sm hover:underline">
95 {niceDate(post.indexedAt)}
96 </time>
97 </Link>
98 <div className="border-t dark:border-slate-600 w-full pt-2.5 flex items-center gap-5 text-sm cursor-pointer">
99 {!!post.likeCount && (
100 <div className="flex items-center gap-2 cursor-pointer">
101 <img src={likeIcon} className="w-5 h-5" />
102 <p className="font-bold text-neutral-500 dark:text-neutral-300 mb-px">
103 {prettyNumber(post.likeCount)}
104 </p>
105 </div>
106 )}
107 {!!post.repostCount && (
108 <div className="flex items-center gap-2 cursor-pointer">
109 <img src={repostIcon} className="w-5 h-5" />
110 <p className="font-bold text-neutral-500 dark:text-neutral-300 mb-px">
111 {prettyNumber(post.repostCount)}
112 </p>
113 </div>
114 )}
115 <div className="flex items-center gap-2 cursor-pointer">
116 <img src={replyIcon} className="w-5 h-5" />
117 <p className="font-bold text-neutral-500 dark:text-neutral-300 mb-px">
118 Reply
119 </p>
120 </div>
121 <div className="flex-1" />
122 <p className="cursor-pointer text-brand dark:text-brandLighten font-bold hover:underline hidden min-[450px]:inline">
123 {post.replyCount
124 ? `Read ${prettyNumber(post.replyCount)} ${
125 post.replyCount > 1 ? 'replies' : 'reply'
126 } on Bluesky`
127 : `View on Bluesky`}
128 </p>
129 <p className="cursor-pointer text-brand font-bold hover:underline min-[450px]:hidden">
130 <span className="hidden min-[380px]:inline">View on </span>Bluesky
131 </p>
132 </div>
133 </div>
134 </Container>
135 )
136}
137
138function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
139 if (!record) return null
140
141 const rt = new RichText({
142 text: record.text,
143 facets: record.facets,
144 })
145
146 const richText = []
147
148 let counter = 0
149 for (const segment of rt.segments()) {
150 if (
151 segment.link &&
152 AppBskyRichtextFacet.validateLink(segment.link).success
153 ) {
154 richText.push(
155 <Link
156 key={counter}
157 href={segment.link.uri}
158 className="text-blue-500 hover:underline"
159 disableTracking={
160 !segment.link.uri.startsWith('https://bsky.app') &&
161 !segment.link.uri.startsWith('https://social.shatteredsky.net') &&
162 !segment.link.uri.startsWith('https://go.bsky.app')
163 }>
164 {segment.text}
165 </Link>,
166 )
167 } else if (
168 segment.mention &&
169 AppBskyRichtextFacet.validateMention(segment.mention).success
170 ) {
171 richText.push(
172 <Link
173 key={counter}
174 href={`/profile/${segment.mention.did}`}
175 className="text-blue-500 hover:underline">
176 {segment.text}
177 </Link>,
178 )
179 } else if (
180 segment.tag &&
181 AppBskyRichtextFacet.validateTag(segment.tag).success
182 ) {
183 richText.push(
184 <Link
185 key={counter}
186 href={`/hashtag/${segment.tag.tag}`}
187 className="text-blue-500 hover:underline">
188 {segment.text}
189 </Link>,
190 )
191 } else {
192 richText.push(segment.text)
193 }
194
195 counter++
196 }
197
198 return (
199 <p className="min-[300px]:text-lg leading-6 min-[300px]:leading-6 break-word break-words whitespace-pre-wrap">
200 {richText}
201 </p>
202 )
203}