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