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