An ATproto social media client -- with an independent Appview.
at main 203 lines 6.9 kB view raw
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}