mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at utm-source 330 lines 9.0 kB view raw
1import React from 'react' 2import { 3 StyleProp, 4 StyleSheet, 5 TouchableOpacity, 6 View, 7 ViewStyle, 8} from 'react-native' 9import { 10 AppBskyEmbedExternal, 11 AppBskyEmbedImages, 12 AppBskyEmbedRecord, 13 AppBskyEmbedRecordWithMedia, 14 AppBskyEmbedVideo, 15 AppBskyFeedDefs, 16 AppBskyFeedPost, 17 ModerationDecision, 18 RichText as RichTextAPI, 19} from '@atproto/api' 20import {AtUri} from '@atproto/api' 21import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 22import {msg, Trans} from '@lingui/macro' 23import {useLingui} from '@lingui/react' 24import {useQueryClient} from '@tanstack/react-query' 25 26import {HITSLOP_20} from '#/lib/constants' 27import {usePalette} from '#/lib/hooks/usePalette' 28import {InfoCircleIcon} from '#/lib/icons' 29import {moderatePost_wrapped} from '#/lib/moderatePost_wrapped' 30import {makeProfileLink} from '#/lib/routes/links' 31import {s} from '#/lib/styles' 32import {useModerationOpts} from '#/state/preferences/moderation-opts' 33import {precacheProfile} from '#/state/queries/profile' 34import {useResolveLinkQuery} from '#/state/queries/resolve-link' 35import {useSession} from '#/state/session' 36import {atoms as a, useTheme} from '#/alf' 37import {RichText} from '#/components/RichText' 38import {SubtleWebHover} from '#/components/SubtleWebHover' 39import {ContentHider} from '../../../../components/moderation/ContentHider' 40import {PostAlerts} from '../../../../components/moderation/PostAlerts' 41import {Link} from '../Link' 42import {PostMeta} from '../PostMeta' 43import {Text} from '../text/Text' 44import {PostEmbeds} from '.' 45import {QuoteEmbedViewContext} from './types' 46 47export function MaybeQuoteEmbed({ 48 embed, 49 onOpen, 50 style, 51 allowNestedQuotes, 52 viewContext, 53}: { 54 embed: AppBskyEmbedRecord.View 55 onOpen?: () => void 56 style?: StyleProp<ViewStyle> 57 allowNestedQuotes?: boolean 58 viewContext?: QuoteEmbedViewContext 59}) { 60 const t = useTheme() 61 const pal = usePalette('default') 62 const {currentAccount} = useSession() 63 if ( 64 AppBskyEmbedRecord.isViewRecord(embed.record) && 65 AppBskyFeedPost.isRecord(embed.record.value) && 66 AppBskyFeedPost.validateRecord(embed.record.value).success 67 ) { 68 return ( 69 <QuoteEmbedModerated 70 viewRecord={embed.record} 71 onOpen={onOpen} 72 style={style} 73 allowNestedQuotes={allowNestedQuotes} 74 viewContext={viewContext} 75 /> 76 ) 77 } else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) { 78 return ( 79 <View 80 style={[styles.errorContainer, a.border, t.atoms.border_contrast_low]}> 81 <InfoCircleIcon size={18} style={pal.text} /> 82 <Text type="lg" style={pal.text}> 83 <Trans>Blocked</Trans> 84 </Text> 85 </View> 86 ) 87 } else if (AppBskyEmbedRecord.isViewNotFound(embed.record)) { 88 return ( 89 <View 90 style={[styles.errorContainer, a.border, t.atoms.border_contrast_low]}> 91 <InfoCircleIcon size={18} style={pal.text} /> 92 <Text type="lg" style={pal.text}> 93 <Trans>Deleted</Trans> 94 </Text> 95 </View> 96 ) 97 } else if (AppBskyEmbedRecord.isViewDetached(embed.record)) { 98 const isViewerOwner = currentAccount?.did 99 ? embed.record.uri.includes(currentAccount.did) 100 : false 101 return ( 102 <View 103 style={[styles.errorContainer, a.border, t.atoms.border_contrast_low]}> 104 <InfoCircleIcon size={18} style={pal.text} /> 105 <Text type="lg" style={pal.text}> 106 {isViewerOwner ? ( 107 <Trans>Removed by you</Trans> 108 ) : ( 109 <Trans>Removed by author</Trans> 110 )} 111 </Text> 112 </View> 113 ) 114 } 115 return null 116} 117 118function QuoteEmbedModerated({ 119 viewRecord, 120 onOpen, 121 style, 122 allowNestedQuotes, 123 viewContext, 124}: { 125 viewRecord: AppBskyEmbedRecord.ViewRecord 126 onOpen?: () => void 127 style?: StyleProp<ViewStyle> 128 allowNestedQuotes?: boolean 129 viewContext?: QuoteEmbedViewContext 130}) { 131 const moderationOpts = useModerationOpts() 132 const postView = React.useMemo( 133 () => viewRecordToPostView(viewRecord), 134 [viewRecord], 135 ) 136 const moderation = React.useMemo(() => { 137 return moderationOpts 138 ? moderatePost_wrapped(postView, moderationOpts) 139 : undefined 140 }, [postView, moderationOpts]) 141 142 return ( 143 <QuoteEmbed 144 quote={postView} 145 moderation={moderation} 146 onOpen={onOpen} 147 style={style} 148 allowNestedQuotes={allowNestedQuotes} 149 viewContext={viewContext} 150 /> 151 ) 152} 153 154export function QuoteEmbed({ 155 quote, 156 moderation, 157 onOpen, 158 style, 159 allowNestedQuotes, 160}: { 161 quote: AppBskyFeedDefs.PostView 162 moderation?: ModerationDecision 163 onOpen?: () => void 164 style?: StyleProp<ViewStyle> 165 allowNestedQuotes?: boolean 166 viewContext?: QuoteEmbedViewContext 167}) { 168 const t = useTheme() 169 const queryClient = useQueryClient() 170 const pal = usePalette('default') 171 const itemUrip = new AtUri(quote.uri) 172 const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey) 173 const itemTitle = `Post by ${quote.author.handle}` 174 175 const richText = React.useMemo(() => { 176 const text = AppBskyFeedPost.isRecord(quote.record) ? quote.record.text : '' 177 const facets = AppBskyFeedPost.isRecord(quote.record) 178 ? quote.record.facets 179 : undefined 180 return text.trim() 181 ? new RichTextAPI({text: text, facets: facets}) 182 : undefined 183 }, [quote.record]) 184 185 const embed = React.useMemo(() => { 186 const e = quote.embed 187 188 if (allowNestedQuotes) { 189 return e 190 } else { 191 if ( 192 AppBskyEmbedImages.isView(e) || 193 AppBskyEmbedExternal.isView(e) || 194 AppBskyEmbedVideo.isView(e) 195 ) { 196 return e 197 } else if ( 198 AppBskyEmbedRecordWithMedia.isView(e) && 199 (AppBskyEmbedImages.isView(e.media) || 200 AppBskyEmbedExternal.isView(e.media) || 201 AppBskyEmbedVideo.isView(e.media)) 202 ) { 203 return e.media 204 } 205 } 206 }, [quote.embed, allowNestedQuotes]) 207 208 const onBeforePress = React.useCallback(() => { 209 precacheProfile(queryClient, quote.author) 210 onOpen?.() 211 }, [queryClient, quote.author, onOpen]) 212 213 const [hover, setHover] = React.useState(false) 214 return ( 215 <View 216 onPointerEnter={() => { 217 setHover(true) 218 }} 219 onPointerLeave={() => { 220 setHover(false) 221 }}> 222 <ContentHider 223 modui={moderation?.ui('contentList')} 224 style={[ 225 a.rounded_md, 226 a.p_md, 227 a.mt_sm, 228 a.border, 229 t.atoms.border_contrast_low, 230 style, 231 ]} 232 childContainerStyle={[a.pt_sm]}> 233 <SubtleWebHover hover={hover} /> 234 <Link 235 hoverStyle={{borderColor: pal.colors.borderLinkHover}} 236 href={itemHref} 237 title={itemTitle} 238 onBeforePress={onBeforePress}> 239 <View pointerEvents="none"> 240 <PostMeta 241 author={quote.author} 242 moderation={moderation} 243 showAvatar 244 postHref={itemHref} 245 timestamp={quote.indexedAt} 246 /> 247 </View> 248 {moderation ? ( 249 <PostAlerts 250 modui={moderation.ui('contentView')} 251 style={[a.py_xs]} 252 /> 253 ) : null} 254 {richText ? ( 255 <RichText 256 value={richText} 257 style={a.text_md} 258 numberOfLines={20} 259 disableLinks 260 /> 261 ) : null} 262 {embed && <PostEmbeds embed={embed} moderation={moderation} />} 263 </Link> 264 </ContentHider> 265 </View> 266 ) 267} 268 269export function QuoteX({onRemove}: {onRemove: () => void}) { 270 const {_} = useLingui() 271 return ( 272 <TouchableOpacity 273 style={[ 274 a.absolute, 275 a.p_xs, 276 a.rounded_full, 277 a.align_center, 278 a.justify_center, 279 { 280 top: 16, 281 right: 10, 282 backgroundColor: 'rgba(0, 0, 0, 0.75)', 283 }, 284 ]} 285 onPress={onRemove} 286 accessibilityRole="button" 287 accessibilityLabel={_(msg`Remove quote`)} 288 accessibilityHint={_(msg`Removes quoted post`)} 289 onAccessibilityEscape={onRemove} 290 hitSlop={HITSLOP_20}> 291 <FontAwesomeIcon size={12} icon="xmark" style={s.white} /> 292 </TouchableOpacity> 293 ) 294} 295 296export function LazyQuoteEmbed({uri}: {uri: string}) { 297 const {data} = useResolveLinkQuery(uri) 298 if (!data || data.type !== 'record' || data.kind !== 'post') { 299 return null 300 } 301 return <QuoteEmbed quote={data.view} /> 302} 303 304function viewRecordToPostView( 305 viewRecord: AppBskyEmbedRecord.ViewRecord, 306): AppBskyFeedDefs.PostView { 307 const {value, embeds, ...rest} = viewRecord 308 return { 309 ...rest, 310 $type: 'app.bsky.feed.defs#postView', 311 record: value, 312 embed: embeds?.[0], 313 } 314} 315 316const styles = StyleSheet.create({ 317 errorContainer: { 318 flexDirection: 'row', 319 alignItems: 'center', 320 gap: 4, 321 borderRadius: 8, 322 marginTop: 8, 323 paddingVertical: 14, 324 paddingHorizontal: 14, 325 borderWidth: StyleSheet.hairlineWidth, 326 }, 327 alert: { 328 marginBottom: 6, 329 }, 330})