my fork of the bluesky client

Moderate content in embeds (#3525)

* move info to its own file

* Revert "move info to its own file"

This reverts commit 1d45a2f4034f50cbe9cb25070f954042cdf9127a.

* better way

* all cases

* pass labelInfo to ImageEmbed

* blur avatars

* add back as string

* one more as string

* external embed

* add back as string again

authored by hailey.at and committed by

GitHub 826f6b04 f5bb348b

+91 -17
+58 -12
bskyembed/src/components/embed.tsx
··· 9 AppBskyLabelerDefs, 10 } from '@atproto/api' 11 import {ComponentChildren, h} from 'preact' 12 13 import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg' 14 import {getRkey} from '../utils' 15 import {Link} from './link' 16 17 - export function Embed({content}: {content: AppBskyFeedDefs.PostView['embed']}) { 18 if (!content) return null 19 20 try { 21 // Case 1: Image 22 if (AppBskyEmbedImages.isView(content)) { 23 - return <ImageEmbed content={content} /> 24 } 25 26 // Case 2: External link 27 if (AppBskyEmbedExternal.isView(content)) { 28 - return <ExternalEmbed content={content} /> 29 } 30 31 // Case 3: Record (quote or linked post) ··· 50 if (AppBskyFeedPost.isRecord(record.value)) { 51 text = record.value.text 52 } 53 return ( 54 <Link 55 href={`/profile/${record.author.did}/post/${getRkey(record)}`} 56 className="transition-colors hover:bg-neutral-100 border rounded-lg p-2 gap-1.5 w-full flex flex-col"> 57 <div className="flex gap-1.5 items-center"> 58 - <img 59 - src={record.author.avatar} 60 - className="w-4 h-4 rounded-full bg-neutral-300 shrink-0" 61 - /> 62 <p className="line-clamp-1 text-sm"> 63 <span className="font-bold">{record.author.displayName}</span> 64 <span className="text-textLight ml-1"> ··· 74 return false 75 }) 76 .map(embed => ( 77 - <Embed key={embed.$type} content={embed} /> 78 ))} 79 </Link> 80 ) ··· 137 } 138 139 // Case 4: Record with media 140 - if (AppBskyEmbedRecordWithMedia.isView(content)) { 141 return ( 142 <div className="flex flex-col gap-2"> 143 - <Embed content={content.media} /> 144 <Embed 145 content={{ 146 $type: 'app.bsky.embed.record#view', 147 record: content.record.record, 148 }} 149 /> 150 </div> 151 ) ··· 168 ) 169 } 170 171 - function ImageEmbed({content}: {content: AppBskyEmbedImages.View}) { 172 switch (content.images.length) { 173 case 1: 174 return ( ··· 229 } 230 } 231 232 - function ExternalEmbed({content}: {content: AppBskyEmbedExternal.View}) { 233 function toNiceDomain(url: string): string { 234 try { 235 const urlp = new URL(url) ··· 238 return url 239 } 240 } 241 return ( 242 <Link 243 href={content.external.uri}
··· 9 AppBskyLabelerDefs, 10 } from '@atproto/api' 11 import {ComponentChildren, h} from 'preact' 12 + import {useMemo} from 'preact/hooks' 13 14 import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg' 15 + import {CONTENT_LABELS, labelsToInfo} from '../labels' 16 import {getRkey} from '../utils' 17 import {Link} from './link' 18 19 + export function Embed({ 20 + content, 21 + labels, 22 + }: { 23 + content: AppBskyFeedDefs.PostView['embed'] 24 + labels: AppBskyFeedDefs.PostView['labels'] 25 + }) { 26 + const labelInfo = useMemo(() => labelsToInfo(labels), [labels]) 27 + 28 if (!content) return null 29 30 try { 31 // Case 1: Image 32 if (AppBskyEmbedImages.isView(content)) { 33 + return <ImageEmbed content={content} labelInfo={labelInfo} /> 34 } 35 36 // Case 2: External link 37 if (AppBskyEmbedExternal.isView(content)) { 38 + return <ExternalEmbed content={content} labelInfo={labelInfo} /> 39 } 40 41 // Case 3: Record (quote or linked post) ··· 60 if (AppBskyFeedPost.isRecord(record.value)) { 61 text = record.value.text 62 } 63 + 64 + const isAuthorLabeled = record.author.labels?.some(label => 65 + CONTENT_LABELS.includes(label.val), 66 + ) 67 + 68 return ( 69 <Link 70 href={`/profile/${record.author.did}/post/${getRkey(record)}`} 71 className="transition-colors hover:bg-neutral-100 border rounded-lg p-2 gap-1.5 w-full flex flex-col"> 72 <div className="flex gap-1.5 items-center"> 73 + <div className="w-4 h-4 overflow-hidden rounded-full bg-neutral-300 shrink-0"> 74 + <img 75 + src={record.author.avatar} 76 + style={isAuthorLabeled ? {filter: 'blur(1.5px)'} : undefined} 77 + /> 78 + </div> 79 <p className="line-clamp-1 text-sm"> 80 <span className="font-bold">{record.author.displayName}</span> 81 <span className="text-textLight ml-1"> ··· 91 return false 92 }) 93 .map(embed => ( 94 + <Embed 95 + key={embed.$type} 96 + content={embed} 97 + labels={record.labels} 98 + /> 99 ))} 100 </Link> 101 ) ··· 158 } 159 160 // Case 4: Record with media 161 + if ( 162 + AppBskyEmbedRecordWithMedia.isView(content) && 163 + AppBskyEmbedRecord.isViewRecord(content.record.record) 164 + ) { 165 return ( 166 <div className="flex flex-col gap-2"> 167 + <Embed content={content.media} labels={labels} /> 168 <Embed 169 content={{ 170 $type: 'app.bsky.embed.record#view', 171 record: content.record.record, 172 }} 173 + labels={content.record.record.labels} 174 /> 175 </div> 176 ) ··· 193 ) 194 } 195 196 + function ImageEmbed({ 197 + content, 198 + labelInfo, 199 + }: { 200 + content: AppBskyEmbedImages.View 201 + labelInfo?: string 202 + }) { 203 + if (labelInfo) { 204 + return <Info>{labelInfo}</Info> 205 + } 206 + 207 switch (content.images.length) { 208 case 1: 209 return ( ··· 264 } 265 } 266 267 + function ExternalEmbed({ 268 + content, 269 + labelInfo, 270 + }: { 271 + content: AppBskyEmbedExternal.View 272 + labelInfo?: string 273 + }) { 274 function toNiceDomain(url: string): string { 275 try { 276 const urlp = new URL(url) ··· 279 return url 280 } 281 } 282 + 283 + if (labelInfo) { 284 + return <Info>{labelInfo}</Info> 285 + } 286 + 287 return ( 288 <Link 289 href={content.external.uri}
+12 -5
bskyembed/src/components/post.tsx
··· 5 import likeIcon from '../../assets/heart2_filled_stroke2_corner0_rounded.svg' 6 import logo from '../../assets/logo.svg' 7 import repostIcon from '../../assets/repost_stroke2_corner2_rounded.svg' 8 import {getRkey, niceDate} from '../utils' 9 import {Container} from './container' 10 import {Embed} from './embed' ··· 16 17 export function Post({thread}: Props) { 18 const post = thread.post 19 20 let record: AppBskyFeedPost.Record | null = null 21 if (AppBskyFeedPost.isRecord(post.record)) { ··· 28 <div className="flex-1 flex-col flex gap-2" lang={record?.langs?.[0]}> 29 <div className="flex gap-2.5 items-center"> 30 <Link href={`/profile/${post.author.did}`} className="rounded-full"> 31 - <img 32 - src={post.author.avatar} 33 - className="w-10 h-10 rounded-full bg-neutral-300 shrink-0" 34 - /> 35 </Link> 36 <div className="flex-1"> 37 <Link ··· 52 </Link> 53 </div> 54 <PostContent record={record} /> 55 - <Embed content={post.embed} /> 56 <time 57 datetime={new Date(post.indexedAt).toISOString()} 58 className="text-textLight mt-1 text-sm">
··· 5 import likeIcon from '../../assets/heart2_filled_stroke2_corner0_rounded.svg' 6 import logo from '../../assets/logo.svg' 7 import repostIcon from '../../assets/repost_stroke2_corner2_rounded.svg' 8 + import {CONTENT_LABELS} from '../labels' 9 import {getRkey, niceDate} from '../utils' 10 import {Container} from './container' 11 import {Embed} from './embed' ··· 17 18 export function Post({thread}: Props) { 19 const post = thread.post 20 + 21 + const isAuthorLabeled = post.author.labels?.some(label => 22 + CONTENT_LABELS.includes(label.val), 23 + ) 24 25 let record: AppBskyFeedPost.Record | null = null 26 if (AppBskyFeedPost.isRecord(post.record)) { ··· 33 <div className="flex-1 flex-col flex gap-2" lang={record?.langs?.[0]}> 34 <div className="flex gap-2.5 items-center"> 35 <Link href={`/profile/${post.author.did}`} className="rounded-full"> 36 + <div className="w-10 h-10 overflow-hidden rounded-full bg-neutral-300 shrink-0"> 37 + <img 38 + src={post.author.avatar} 39 + style={isAuthorLabeled ? {filter: 'blur(2.5px)'} : undefined} 40 + /> 41 + </div> 42 </Link> 43 <div className="flex-1"> 44 <Link ··· 59 </Link> 60 </div> 61 <PostContent record={record} /> 62 + <Embed content={post.embed} labels={post.labels} /> 63 <time 64 datetime={new Date(post.indexedAt).toISOString()} 65 className="text-textLight mt-1 text-sm">
+21
bskyembed/src/labels.ts
···
··· 1 + import {AppBskyFeedDefs} from '@atproto/api' 2 + 3 + export const CONTENT_LABELS = ['porn', 'sexual', 'nudity', 'graphic-media'] 4 + 5 + export function labelsToInfo( 6 + labels?: AppBskyFeedDefs.PostView['labels'], 7 + ): string | undefined { 8 + const label = labels?.find(label => CONTENT_LABELS.includes(label.val)) 9 + 10 + switch (label?.val) { 11 + case 'porn': 12 + case 'sexual': 13 + return 'Adult Content' 14 + case 'nudity': 15 + return 'Non-sexual Nudity' 16 + case 'graphic-media': 17 + return 'Graphic Media' 18 + default: 19 + return undefined 20 + } 21 + }