Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
1import {useCallback, useEffect, useMemo, useState} from 'react'
2import {LayoutAnimation, View} from 'react-native'
3import {
4 AppBskyFeedPost,
5 AppBskyRichtextFacet,
6 AtUri,
7 moderatePost,
8 RichText as RichTextAPI,
9} from '@atproto/api'
10import {msg} from '@lingui/core/macro'
11import {useLingui} from '@lingui/react'
12import {type RouteProp, useNavigation, useRoute} from '@react-navigation/native'
13
14import {makeProfileLink} from '#/lib/routes/links'
15import {
16 type CommonNavigatorParams,
17 type NavigationProp,
18} from '#/lib/routes/types'
19import {detectFacetsWithoutResolution} from '#/lib/strings/detect-facets'
20import {
21 convertBskyAppUrlIfNeeded,
22 isBskyPostUrl,
23 makeRecordUri,
24} from '#/lib/strings/url-helpers'
25import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
26import {useModerationOpts} from '#/state/preferences/moderation-opts'
27import {usePostQuery} from '#/state/queries/post'
28import {PostMeta} from '#/view/com/util/PostMeta'
29import {atoms as a, useTheme} from '#/alf'
30import {Button, ButtonIcon} from '#/components/Button'
31import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
32import {Loader} from '#/components/Loader'
33import * as MediaPreview from '#/components/MediaPreview'
34import {ContentHider} from '#/components/moderation/ContentHider'
35import {PostAlerts} from '#/components/moderation/PostAlerts'
36import {RichText} from '#/components/RichText'
37import {Text} from '#/components/Typography'
38import * as bsky from '#/types/bsky'
39
40export function useMessageEmbed() {
41 const route =
42 useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>()
43 const navigation = useNavigation<NavigationProp>()
44 const embedFromParams = route.params.embed
45
46 const [embedUri, setEmbed] = useState(embedFromParams)
47
48 if (embedFromParams && embedUri !== embedFromParams) {
49 setEmbed(embedFromParams)
50 }
51
52 return {
53 embedUri,
54 setEmbed: useCallback(
55 (embedUrl: string | undefined) => {
56 if (!embedUrl) {
57 navigation.setParams({embed: ''})
58 setEmbed(undefined)
59 return
60 }
61
62 if (embedFromParams) return
63
64 const url = convertBskyAppUrlIfNeeded(embedUrl)
65 const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
66 const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
67
68 setEmbed(uri)
69 },
70 [embedFromParams, navigation],
71 ),
72 }
73}
74
75export function useExtractEmbedFromFacets(
76 message: string,
77 setEmbed: (embedUrl: string | undefined) => void,
78) {
79 const rt = new RichTextAPI({text: message})
80 detectFacetsWithoutResolution(rt)
81
82 let uriFromFacet: string | undefined
83
84 for (const facet of rt.facets ?? []) {
85 for (const feature of facet.features) {
86 if (AppBskyRichtextFacet.isLink(feature) && isBskyPostUrl(feature.uri)) {
87 uriFromFacet = feature.uri
88 break
89 }
90 }
91 }
92
93 useEffect(() => {
94 if (uriFromFacet) {
95 setEmbed(uriFromFacet)
96 }
97 }, [uriFromFacet, setEmbed])
98}
99
100export function MessageInputEmbed({
101 embedUri,
102 setEmbed,
103}: {
104 embedUri: string | undefined
105 setEmbed: (embedUrl: string | undefined) => void
106}) {
107 const t = useTheme()
108 const {_} = useLingui()
109
110 const enableSquareButtons = useEnableSquareButtons()
111
112 const {data: post, status} = usePostQuery(embedUri)
113
114 const moderationOpts = useModerationOpts()
115 const moderation = useMemo(
116 () =>
117 moderationOpts && post ? moderatePost(post, moderationOpts) : undefined,
118 [moderationOpts, post],
119 )
120
121 const {rt, record} = useMemo(() => {
122 if (
123 post &&
124 bsky.dangerousIsType<AppBskyFeedPost.Record>(
125 post.record,
126 AppBskyFeedPost.isRecord,
127 )
128 ) {
129 return {
130 rt: new RichTextAPI({
131 text: post.record.text,
132 facets: post.record.facets,
133 }),
134 record: post.record,
135 }
136 }
137
138 return {rt: undefined, record: undefined}
139 }, [post])
140
141 if (!embedUri) {
142 return null
143 }
144
145 let content = null
146 switch (status) {
147 case 'pending':
148 content = (
149 <View
150 style={[a.flex_1, {minHeight: 64}, a.justify_center, a.align_center]}>
151 <Loader />
152 </View>
153 )
154 break
155 case 'error':
156 content = (
157 <View
158 style={[a.flex_1, {minHeight: 64}, a.justify_center, a.align_center]}>
159 <Text style={a.text_center}>Could not fetch post</Text>
160 </View>
161 )
162 break
163 case 'success':
164 const itemUrip = new AtUri(post.uri)
165 const itemHref = makeProfileLink(post.author, 'post', itemUrip.rkey)
166
167 if (!post || !moderation || !rt || !record) {
168 return null
169 }
170
171 content = (
172 <View
173 style={[
174 a.flex_1,
175 t.atoms.bg,
176 t.atoms.border_contrast_low,
177 a.rounded_md,
178 a.border,
179 a.p_sm,
180 a.mb_sm,
181 ]}
182 pointerEvents="none">
183 <PostMeta
184 showAvatar
185 author={post.author}
186 moderation={moderation}
187 timestamp={post.indexedAt}
188 postHref={itemHref}
189 style={a.flex_0}
190 />
191 <ContentHider modui={moderation.ui('contentView')}>
192 <PostAlerts modui={moderation.ui('contentView')} style={a.py_xs} />
193 {rt.text && (
194 <View style={a.mt_xs}>
195 <RichText
196 enableTags
197 testID="postText"
198 value={rt}
199 style={[a.text_sm, t.atoms.text_contrast_high]}
200 authorHandle={post.author.handle}
201 numberOfLines={3}
202 />
203 </View>
204 )}
205 <MediaPreview.Embed embed={post.embed} style={a.mt_sm} />
206 </ContentHider>
207 </View>
208 )
209 break
210 }
211
212 return (
213 <View style={[a.flex_row, a.gap_sm]}>
214 {content}
215 <Button
216 label={_(msg`Remove embed`)}
217 onPress={() => {
218 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
219 setEmbed(undefined)
220 }}
221 size="tiny"
222 variant="solid"
223 color="secondary"
224 shape={enableSquareButtons ? 'square' : 'round'}>
225 <ButtonIcon icon={X} />
226 </Button>
227 </View>
228 )
229}