mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React from 'react'
2import {observer} from 'mobx-react-lite'
3import {
4 Animated,
5 TouchableOpacity,
6 TouchableWithoutFeedback,
7 StyleSheet,
8 View,
9} from 'react-native'
10import {AppBskyEmbedImages} from '@atproto/api'
11import {AtUri} from '../../../third-party/uri'
12import {FontAwesomeIcon, Props} from '@fortawesome/react-native-fontawesome'
13import {NotificationsViewItemModel} from '../../../state/models/notifications-view'
14import {PostThreadViewModel} from '../../../state/models/post-thread-view'
15import {s, colors} from '../../lib/styles'
16import {ago, pluralize} from '../../../lib/strings'
17import {HeartIconSolid} from '../../lib/icons'
18import {Text} from '../util/text/Text'
19import {UserAvatar} from '../util/UserAvatar'
20import {ImageHorzList} from '../util/images/ImageHorzList'
21import {ErrorMessage} from '../util/error/ErrorMessage'
22import {Post} from '../post/Post'
23import {Link} from '../util/Link'
24import {usePalette} from '../../lib/hooks/usePalette'
25import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue'
26
27const MAX_AUTHORS = 5
28
29const EXPANDED_AUTHOR_EL_HEIGHT = 35
30
31interface Author {
32 href: string
33 handle: string
34 displayName?: string
35 avatar?: string
36}
37
38export const FeedItem = observer(function FeedItem({
39 item,
40}: {
41 item: NotificationsViewItemModel
42}) {
43 const pal = usePalette('default')
44 const [isAuthorsExpanded, setAuthorsExpanded] = React.useState<boolean>(false)
45 const itemHref = React.useMemo(() => {
46 if (item.isUpvote || item.isRepost) {
47 const urip = new AtUri(item.subjectUri)
48 return `/profile/${urip.host}/post/${urip.rkey}`
49 } else if (item.isFollow || item.isAssertion) {
50 return `/profile/${item.author.handle}`
51 } else if (item.isReply) {
52 const urip = new AtUri(item.uri)
53 return `/profile/${urip.host}/post/${urip.rkey}`
54 }
55 return ''
56 }, [item])
57 const itemTitle = React.useMemo(() => {
58 if (item.isUpvote || item.isRepost) {
59 return 'Post'
60 } else if (item.isFollow || item.isAssertion) {
61 return item.author.handle
62 } else if (item.isReply) {
63 return 'Post'
64 }
65 }, [item])
66
67 const onToggleAuthorsExpanded = () => {
68 setAuthorsExpanded(!isAuthorsExpanded)
69 }
70
71 if (item.additionalPost?.notFound) {
72 // don't render anything if the target post was deleted or unfindable
73 return <View />
74 }
75
76 if (item.isReply || item.isMention) {
77 return (
78 <Link href={itemHref} title={itemTitle} noFeedback>
79 <Post
80 uri={item.uri}
81 initView={item.additionalPost}
82 style={
83 item.isRead
84 ? undefined
85 : [
86 styles.outerUnread,
87 {backgroundColor: pal.colors.unreadNotifBg},
88 ]
89 }
90 />
91 </Link>
92 )
93 }
94
95 let action = ''
96 let icon: Props['icon'] | 'HeartIconSolid'
97 let iconStyle: Props['style'] = []
98 if (item.isUpvote) {
99 action = 'liked your post'
100 icon = 'HeartIconSolid'
101 iconStyle = [s.red3, {position: 'relative', top: -4}]
102 } else if (item.isRepost) {
103 action = 'reposted your post'
104 icon = 'retweet'
105 iconStyle = [s.green3]
106 } else if (item.isReply) {
107 action = 'replied to your post'
108 icon = ['far', 'comment']
109 } else if (item.isFollow) {
110 action = 'followed you'
111 icon = 'user-plus'
112 iconStyle = [s.blue3]
113 } else {
114 return <></>
115 }
116
117 let authors: Author[] = [
118 {
119 href: `/profile/${item.author.handle}`,
120 handle: item.author.handle,
121 displayName: item.author.displayName,
122 avatar: item.author.avatar,
123 },
124 ]
125 if (item.additional?.length) {
126 authors = authors.concat(
127 item.additional.map(item2 => ({
128 href: `/profile/${item2.author.handle}`,
129 handle: item2.author.handle,
130 displayName: item2.author.displayName,
131 avatar: item2.author.avatar,
132 })),
133 )
134 }
135
136 return (
137 <Link
138 style={[
139 styles.outer,
140 pal.view,
141 pal.border,
142 item.isRead
143 ? undefined
144 : [styles.outerUnread, {backgroundColor: pal.colors.unreadNotifBg}],
145 ]}
146 href={itemHref}
147 title={itemTitle}
148 noFeedback>
149 <View style={styles.layout}>
150 <View style={styles.layoutIcon}>
151 {icon === 'HeartIconSolid' ? (
152 <HeartIconSolid size={28} style={[styles.icon, ...iconStyle]} />
153 ) : (
154 <FontAwesomeIcon
155 icon={icon}
156 size={24}
157 style={[styles.icon, ...iconStyle]}
158 />
159 )}
160 </View>
161 <View style={styles.layoutContent}>
162 <TouchableWithoutFeedback
163 onPress={authors.length > 1 ? onToggleAuthorsExpanded : () => {}}>
164 <View>
165 <CondensedAuthorsList
166 visible={!isAuthorsExpanded}
167 authors={authors}
168 onToggleAuthorsExpanded={onToggleAuthorsExpanded}
169 />
170 <ExpandedAuthorsList
171 visible={isAuthorsExpanded}
172 authors={authors}
173 />
174 <View style={styles.meta}>
175 <Link
176 key={authors[0].href}
177 style={styles.metaItem}
178 href={authors[0].href}
179 title={`@${authors[0].handle}`}>
180 <Text style={[pal.text, s.bold]}>
181 {authors[0].displayName || authors[0].handle}
182 </Text>
183 </Link>
184 {authors.length > 1 ? (
185 <>
186 <Text style={[styles.metaItem, pal.text]}>and</Text>
187 <Text style={[styles.metaItem, pal.text, s.bold]}>
188 {authors.length - 1}{' '}
189 {pluralize(authors.length - 1, 'other')}
190 </Text>
191 </>
192 ) : undefined}
193 <Text style={[styles.metaItem, pal.text]}>{action}</Text>
194 <Text style={[styles.metaItem, pal.textLight]}>
195 {ago(item.indexedAt)}
196 </Text>
197 </View>
198 </View>
199 </TouchableWithoutFeedback>
200 {item.isUpvote || item.isRepost ? (
201 <AdditionalPostText additionalPost={item.additionalPost} />
202 ) : (
203 <></>
204 )}
205 </View>
206 </View>
207 </Link>
208 )
209})
210
211function CondensedAuthorsList({
212 visible,
213 authors,
214 onToggleAuthorsExpanded,
215}: {
216 visible: boolean
217 authors: Author[]
218 onToggleAuthorsExpanded: () => void
219}) {
220 const pal = usePalette('default')
221 if (!visible) {
222 return (
223 <View style={styles.avis}>
224 <TouchableOpacity
225 style={styles.expandedAuthorsCloseBtn}
226 onPress={onToggleAuthorsExpanded}>
227 <FontAwesomeIcon
228 icon="angle-up"
229 size={18}
230 style={[styles.expandedAuthorsCloseBtnIcon, pal.text]}
231 />
232 <Text type="sm-medium" style={pal.text}>
233 Hide
234 </Text>
235 </TouchableOpacity>
236 </View>
237 )
238 }
239 if (authors.length === 1) {
240 return (
241 <View style={styles.avis}>
242 <Link
243 style={s.mr5}
244 href={authors[0].href}
245 title={`@${authors[0].handle}`}>
246 <UserAvatar
247 size={35}
248 displayName={authors[0].displayName}
249 handle={authors[0].handle}
250 avatar={authors[0].avatar}
251 />
252 </Link>
253 </View>
254 )
255 }
256 return (
257 <View style={styles.avis}>
258 {authors.slice(0, MAX_AUTHORS).map(author => (
259 <View key={author.href} style={s.mr5}>
260 <UserAvatar
261 size={35}
262 displayName={author.displayName}
263 handle={author.handle}
264 avatar={author.avatar}
265 />
266 </View>
267 ))}
268 {authors.length > MAX_AUTHORS ? (
269 <Text style={[styles.aviExtraCount, pal.textLight]}>
270 +{authors.length - MAX_AUTHORS}
271 </Text>
272 ) : undefined}
273 <FontAwesomeIcon
274 icon="angle-down"
275 size={18}
276 style={[styles.expandedAuthorsCloseBtnIcon, pal.textLight]}
277 />
278 </View>
279 )
280}
281
282function ExpandedAuthorsList({
283 visible,
284 authors,
285}: {
286 visible: boolean
287 authors: Author[]
288}) {
289 const pal = usePalette('default')
290 const heightInterp = useAnimatedValue(visible ? 1 : 0)
291 const targetHeight =
292 authors.length * (EXPANDED_AUTHOR_EL_HEIGHT + 10) /*10=margin*/
293 const heightStyle = {
294 height: Animated.multiply(heightInterp, targetHeight),
295 overflow: 'hidden',
296 }
297 React.useEffect(() => {
298 Animated.timing(heightInterp, {
299 toValue: visible ? 1 : 0,
300 duration: 200,
301 useNativeDriver: false,
302 }).start()
303 }, [heightInterp, visible])
304 return (
305 <Animated.View style={[heightStyle, visible ? s.mb10 : undefined]}>
306 {authors.map(author => (
307 <Link
308 key={author.href}
309 href={author.href}
310 title={author.displayName || author.handle}
311 style={styles.expandedAuthor}>
312 <View style={styles.expandedAuthorAvi}>
313 <UserAvatar
314 size={35}
315 displayName={author.displayName}
316 handle={author.handle}
317 avatar={author.avatar}
318 />
319 </View>
320 <View style={s.flex1}>
321 <Text type="lg-bold" numberOfLines={1} style={pal.text}>
322 {author.displayName || author.handle}
323
324 <Text style={[pal.textLight]}>{author.handle}</Text>
325 </Text>
326 </View>
327 </Link>
328 ))}
329 </Animated.View>
330 )
331}
332
333function AdditionalPostText({
334 additionalPost,
335}: {
336 additionalPost?: PostThreadViewModel
337}) {
338 const pal = usePalette('default')
339 if (!additionalPost || !additionalPost.thread?.postRecord) {
340 return <View />
341 }
342 if (additionalPost.error) {
343 return <ErrorMessage message={additionalPost.error} />
344 }
345 const text = additionalPost.thread?.postRecord.text
346 const images = (
347 additionalPost.thread.post.embed as AppBskyEmbedImages.Presented
348 )?.images
349 return (
350 <>
351 {text?.length > 0 && <Text style={pal.textLight}>{text}</Text>}
352 {images && images?.length > 0 && (
353 <ImageHorzList
354 uris={images?.map(img => img.thumb)}
355 style={styles.additionalPostImages}
356 />
357 )}
358 </>
359 )
360}
361
362const styles = StyleSheet.create({
363 outer: {
364 padding: 10,
365 paddingRight: 15,
366 borderTopWidth: 1,
367 },
368 outerUnread: {
369 borderColor: colors.blue1,
370 },
371 layout: {
372 flexDirection: 'row',
373 },
374 layoutIcon: {
375 width: 70,
376 alignItems: 'flex-end',
377 paddingTop: 2,
378 },
379 icon: {
380 marginRight: 10,
381 marginTop: 4,
382 },
383 avis: {
384 flexDirection: 'row',
385 alignItems: 'center',
386 },
387 aviExtraCount: {
388 fontWeight: 'bold',
389 paddingLeft: 6,
390 },
391 layoutContent: {
392 flex: 1,
393 },
394 meta: {
395 flexDirection: 'row',
396 flexWrap: 'wrap',
397 paddingTop: 6,
398 paddingBottom: 2,
399 },
400 metaItem: {
401 paddingRight: 3,
402 },
403 postText: {
404 paddingBottom: 5,
405 color: colors.black,
406 },
407 additionalPostImages: {
408 marginTop: 5,
409 marginLeft: 2,
410 opacity: 0.8,
411 },
412
413 addedContainer: {
414 paddingTop: 4,
415 paddingLeft: 36,
416 },
417
418 expandedAuthorsCloseBtn: {
419 flexDirection: 'row',
420 alignItems: 'center',
421 paddingTop: 10,
422 paddingBottom: 6,
423 },
424 expandedAuthorsCloseBtnIcon: {
425 marginLeft: 4,
426 marginRight: 4,
427 },
428 expandedAuthor: {
429 flexDirection: 'row',
430 alignItems: 'center',
431 marginTop: 10,
432 height: EXPANDED_AUTHOR_EL_HEIGHT,
433 },
434 expandedAuthorAvi: {
435 marginRight: 5,
436 },
437})