Refactor notifications to use react-query (#1878)

* Move broadcast channel to lib

* Refactor view/com/post/Post and remove temporary 2 components

* Add useModerationOpts hook

* Refactor notifications to use react-query

* Fix: only trigger updates in useModerationOpts when the values have changed

* Implement unread notification tracking

* Add moderation filtering to notifications

* Handle native/push notifications

* Remove dead code

---------

Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Paul Frazee 2023-11-12 18:13:11 -08:00 committed by GitHub
parent c584a3378d
commit b445c15cc9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 941 additions and 1739 deletions

View file

@ -1,6 +1,8 @@
import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {Linking, StyleProp, View, ViewStyle} from 'react-native'
import Clipboard from '@react-native-clipboard/clipboard'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {AppBskyFeedDefs, AppBskyFeedPost, AtUri} from '@atproto/api'
import {toShareUrl} from 'lib/strings/url-helpers'
import {useTheme} from 'lib/ThemeContext'
import {shareUrl} from 'lib/sharing'
@ -8,41 +10,83 @@ import {
NativeDropdown,
DropdownItem as NativeDropdownItem,
} from './NativeDropdown'
import * as Toast from '../Toast'
import {EventStopper} from '../EventStopper'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {useModalControls} from '#/state/modals'
import {makeProfileLink} from '#/lib/routes/links'
import {getTranslatorLink} from '#/locale/helpers'
import {useStores} from '#/state'
import {usePostDeleteMutation} from '#/state/queries/post'
import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
import {useLanguagePrefs} from '#/state/preferences'
import {logger} from '#/logger'
export function PostDropdownBtn({
testID,
itemUri,
itemCid,
itemHref,
isAuthor,
isThreadMuted,
onCopyPostText,
onOpenTranslate,
onToggleThreadMute,
onDeletePost,
post,
record,
style,
}: {
testID: string
itemUri: string
itemCid: string
itemHref: string
itemTitle: string
isAuthor: boolean
isThreadMuted: boolean
onCopyPostText: () => void
onOpenTranslate: () => void
onToggleThreadMute: () => void
onDeletePost: () => void
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
style?: StyleProp<ViewStyle>
}) {
const store = useStores()
const theme = useTheme()
const {_} = useLingui()
const defaultCtrlColor = theme.palette.default.postCtrl
const {openModal} = useModalControls()
const langPrefs = useLanguagePrefs()
const mutedThreads = useMutedThreads()
const toggleThreadMute = useToggleThreadMute()
const postDeleteMutation = usePostDeleteMutation()
const rootUri = record.reply?.root?.uri || post.uri
const isThreadMuted = mutedThreads.includes(rootUri)
const isAuthor = post.author.did === store.me.did
const href = React.useMemo(() => {
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey)
}, [post.uri, post.author])
const translatorUrl = getTranslatorLink(
record.text,
langPrefs.primaryLanguage,
)
const onDeletePost = React.useCallback(() => {
postDeleteMutation.mutateAsync({uri: post.uri}).then(
() => {
Toast.show('Post deleted')
},
e => {
logger.error('Failed to delete post', {error: e})
Toast.show('Failed to delete post, please try again')
},
)
}, [post, postDeleteMutation])
const onToggleThreadMute = React.useCallback(() => {
try {
const muted = toggleThreadMute(rootUri)
if (muted) {
Toast.show('You will no longer receive notifications for this thread')
} else {
Toast.show('You will now receive notifications for this thread')
}
} catch (e) {
logger.error('Failed to toggle thread mute', {error: e})
}
}, [rootUri, toggleThreadMute])
const onCopyPostText = React.useCallback(() => {
Clipboard.setString(record?.text || '')
Toast.show('Copied to clipboard')
}, [record])
const onOpenTranslate = React.useCallback(() => {
Linking.openURL(translatorUrl)
}, [translatorUrl])
const dropdownItems: NativeDropdownItem[] = [
{
@ -76,7 +120,7 @@ export function PostDropdownBtn({
{
label: 'Share',
onPress() {
const url = toShareUrl(itemHref)
const url = toShareUrl(href)
shareUrl(url)
},
testID: 'postDropdownShareBtn',
@ -113,8 +157,8 @@ export function PostDropdownBtn({
onPress() {
openModal({
name: 'report',
uri: itemUri,
cid: itemCid,
uri: post.uri,
cid: post.cid,
})
},
testID: 'postDropdownReportBtn',
@ -155,7 +199,7 @@ export function PostDropdownBtn({
<NativeDropdown
testID={testID}
items={dropdownItems}
accessibilityLabel={_(msg`More post options`)}
accessibilityLabel="More post options"
accessibilityHint="">
<View style={style}>
<FontAwesomeIcon icon="ellipsis" size={20} color={defaultCtrlColor} />

View file

@ -1,210 +0,0 @@
import React from 'react'
import {Linking, StyleProp, View, ViewStyle} from 'react-native'
import Clipboard from '@react-native-clipboard/clipboard'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {AppBskyFeedDefs, AppBskyFeedPost, AtUri} from '@atproto/api'
import {toShareUrl} from 'lib/strings/url-helpers'
import {useTheme} from 'lib/ThemeContext'
import {shareUrl} from 'lib/sharing'
import {
NativeDropdown,
DropdownItem as NativeDropdownItem,
} from './NativeDropdown'
import * as Toast from '../Toast'
import {EventStopper} from '../EventStopper'
import {useModalControls} from '#/state/modals'
import {makeProfileLink} from '#/lib/routes/links'
import {getTranslatorLink} from '#/locale/helpers'
import {useStores} from '#/state'
import {usePostDeleteMutation} from '#/state/queries/post'
import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
import {useLanguagePrefs} from '#/state/preferences'
import {logger} from '#/logger'
export function PostDropdownBtn({
testID,
post,
record,
style,
}: {
testID: string
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
style?: StyleProp<ViewStyle>
}) {
const store = useStores()
const theme = useTheme()
const defaultCtrlColor = theme.palette.default.postCtrl
const {openModal} = useModalControls()
const langPrefs = useLanguagePrefs()
const mutedThreads = useMutedThreads()
const toggleThreadMute = useToggleThreadMute()
const postDeleteMutation = usePostDeleteMutation()
const rootUri = record.reply?.root?.uri || post.uri
const isThreadMuted = mutedThreads.includes(rootUri)
const isAuthor = post.author.did === store.me.did
const href = React.useMemo(() => {
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey)
}, [post.uri, post.author])
const translatorUrl = getTranslatorLink(
record.text,
langPrefs.primaryLanguage,
)
const onDeletePost = React.useCallback(() => {
postDeleteMutation.mutateAsync({uri: post.uri}).then(
() => {
Toast.show('Post deleted')
},
e => {
logger.error('Failed to delete post', {error: e})
Toast.show('Failed to delete post, please try again')
},
)
}, [post, postDeleteMutation])
const onToggleThreadMute = React.useCallback(() => {
try {
const muted = toggleThreadMute(rootUri)
if (muted) {
Toast.show('You will no longer receive notifications for this thread')
} else {
Toast.show('You will now receive notifications for this thread')
}
} catch (e) {
logger.error('Failed to toggle thread mute', {error: e})
}
}, [rootUri, toggleThreadMute])
const onCopyPostText = React.useCallback(() => {
Clipboard.setString(record?.text || '')
Toast.show('Copied to clipboard')
}, [record])
const onOpenTranslate = React.useCallback(() => {
Linking.openURL(translatorUrl)
}, [translatorUrl])
const dropdownItems: NativeDropdownItem[] = [
{
label: 'Translate',
onPress() {
onOpenTranslate()
},
testID: 'postDropdownTranslateBtn',
icon: {
ios: {
name: 'character.book.closed',
},
android: 'ic_menu_sort_alphabetically',
web: 'language',
},
},
{
label: 'Copy post text',
onPress() {
onCopyPostText()
},
testID: 'postDropdownCopyTextBtn',
icon: {
ios: {
name: 'doc.on.doc',
},
android: 'ic_menu_edit',
web: ['far', 'paste'],
},
},
{
label: 'Share',
onPress() {
const url = toShareUrl(href)
shareUrl(url)
},
testID: 'postDropdownShareBtn',
icon: {
ios: {
name: 'square.and.arrow.up',
},
android: 'ic_menu_share',
web: 'share',
},
},
{
label: 'separator',
},
{
label: isThreadMuted ? 'Unmute thread' : 'Mute thread',
onPress() {
onToggleThreadMute()
},
testID: 'postDropdownMuteThreadBtn',
icon: {
ios: {
name: 'speaker.slash',
},
android: 'ic_lock_silent_mode',
web: 'comment-slash',
},
},
{
label: 'separator',
},
!isAuthor && {
label: 'Report post',
onPress() {
openModal({
name: 'report',
uri: post.uri,
cid: post.cid,
})
},
testID: 'postDropdownReportBtn',
icon: {
ios: {
name: 'exclamationmark.triangle',
},
android: 'ic_menu_report_image',
web: 'circle-exclamation',
},
},
isAuthor && {
label: 'separator',
},
isAuthor && {
label: 'Delete post',
onPress() {
openModal({
name: 'confirm',
title: 'Delete this post?',
message: 'Are you sure? This can not be undone.',
onPressConfirm: onDeletePost,
})
},
testID: 'postDropdownDeleteBtn',
icon: {
ios: {
name: 'trash',
},
android: 'ic_menu_delete',
web: ['far', 'trash-can'],
},
},
].filter(Boolean) as NativeDropdownItem[]
return (
<EventStopper>
<NativeDropdown
testID={testID}
items={dropdownItems}
accessibilityLabel="More post options"
accessibilityHint="">
<View style={style}>
<FontAwesomeIcon icon="ellipsis" size={20} color={defaultCtrlColor} />
</View>
</NativeDropdown>
</EventStopper>
)
}

View file

@ -6,6 +6,7 @@ import {
View,
ViewStyle,
} from 'react-native'
import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api'
import {Text} from '../text/Text'
import {PostDropdownBtn} from '../forms/PostDropdownBtn'
import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons'
@ -17,160 +18,155 @@ import {RepostButton} from './RepostButton'
import {Haptics} from 'lib/haptics'
import {HITSLOP_10, HITSLOP_20} from 'lib/constants'
import {useModalControls} from '#/state/modals'
import {
usePostLikeMutation,
usePostUnlikeMutation,
usePostRepostMutation,
usePostUnrepostMutation,
} from '#/state/queries/post'
interface PostCtrlsOpts {
itemUri: string
itemCid: string
itemHref: string
itemTitle: string
isAuthor: boolean
author: {
did: string
handle: string
displayName?: string | undefined
avatar?: string | undefined
}
text: string
indexedAt: string
export function PostCtrls({
big,
post,
record,
style,
onPressReply,
}: {
big?: boolean
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
style?: StyleProp<ViewStyle>
replyCount?: number
repostCount?: number
likeCount?: number
isReposted: boolean
isLiked: boolean
isThreadMuted: boolean
onPressReply: () => void
onPressToggleRepost: () => Promise<void>
onPressToggleLike: () => Promise<void>
onCopyPostText: () => void
onOpenTranslate: () => void
onToggleThreadMute: () => void
onDeletePost: () => void
}
export function PostCtrls(opts: PostCtrlsOpts) {
}) {
const store = useStores()
const theme = useTheme()
const {closeModal} = useModalControls()
const postLikeMutation = usePostLikeMutation()
const postUnlikeMutation = usePostUnlikeMutation()
const postRepostMutation = usePostRepostMutation()
const postUnrepostMutation = usePostUnrepostMutation()
const defaultCtrlColor = React.useMemo(
() => ({
color: theme.palette.default.postCtrl,
}),
[theme],
) as StyleProp<ViewStyle>
const onPressToggleLike = React.useCallback(async () => {
if (!post.viewer?.like) {
Haptics.default()
postLikeMutation.mutate({
uri: post.uri,
cid: post.cid,
likeCount: post.likeCount || 0,
})
} else {
postUnlikeMutation.mutate({
postUri: post.uri,
likeUri: post.viewer.like,
likeCount: post.likeCount || 0,
})
}
}, [post, postLikeMutation, postUnlikeMutation])
const onRepost = useCallback(() => {
closeModal()
if (!opts.isReposted) {
if (!post.viewer?.repost) {
Haptics.default()
opts.onPressToggleRepost().catch(_e => undefined)
postRepostMutation.mutate({
uri: post.uri,
cid: post.cid,
repostCount: post.repostCount || 0,
})
} else {
opts.onPressToggleRepost().catch(_e => undefined)
postUnrepostMutation.mutate({
postUri: post.uri,
repostUri: post.viewer.repost,
repostCount: post.repostCount || 0,
})
}
}, [opts, closeModal])
}, [post, closeModal, postRepostMutation, postUnrepostMutation])
const onQuote = useCallback(() => {
closeModal()
store.shell.openComposer({
quote: {
uri: opts.itemUri,
cid: opts.itemCid,
text: opts.text,
author: opts.author,
indexedAt: opts.indexedAt,
uri: post.uri,
cid: post.cid,
text: record.text,
author: post.author,
indexedAt: post.indexedAt,
},
})
Haptics.default()
}, [
opts.author,
opts.indexedAt,
opts.itemCid,
opts.itemUri,
opts.text,
store.shell,
closeModal,
])
const onPressToggleLikeWrapper = async () => {
if (!opts.isLiked) {
Haptics.default()
await opts.onPressToggleLike().catch(_e => undefined)
} else {
await opts.onPressToggleLike().catch(_e => undefined)
}
}
}, [post, record, store.shell, closeModal])
return (
<View style={[styles.ctrls, opts.style]}>
<View style={[styles.ctrls, style]}>
<TouchableOpacity
testID="replyBtn"
style={[styles.ctrl, !opts.big && styles.ctrlPad, {paddingLeft: 0}]}
onPress={opts.onPressReply}
style={[styles.ctrl, !big && styles.ctrlPad, {paddingLeft: 0}]}
onPress={onPressReply}
accessibilityRole="button"
accessibilityLabel={`Reply (${opts.replyCount} ${
opts.replyCount === 1 ? 'reply' : 'replies'
accessibilityLabel={`Reply (${post.replyCount} ${
post.replyCount === 1 ? 'reply' : 'replies'
})`}
accessibilityHint=""
hitSlop={opts.big ? HITSLOP_20 : HITSLOP_10}>
hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
<CommentBottomArrow
style={[defaultCtrlColor, opts.big ? s.mt2 : styles.mt1]}
style={[defaultCtrlColor, big ? s.mt2 : styles.mt1]}
strokeWidth={3}
size={opts.big ? 20 : 15}
size={big ? 20 : 15}
/>
{typeof opts.replyCount !== 'undefined' ? (
{typeof post.replyCount !== 'undefined' ? (
<Text style={[defaultCtrlColor, s.ml5, s.f15]}>
{opts.replyCount}
{post.replyCount}
</Text>
) : undefined}
</TouchableOpacity>
<RepostButton {...opts} onRepost={onRepost} onQuote={onQuote} />
<RepostButton
big={big}
isReposted={!!post.viewer?.repost}
repostCount={post.repostCount}
onRepost={onRepost}
onQuote={onQuote}
/>
<TouchableOpacity
testID="likeBtn"
style={[styles.ctrl, !opts.big && styles.ctrlPad]}
onPress={onPressToggleLikeWrapper}
style={[styles.ctrl, !big && styles.ctrlPad]}
onPress={onPressToggleLike}
accessibilityRole="button"
accessibilityLabel={`${opts.isLiked ? 'Unlike' : 'Like'} (${
opts.likeCount
} ${pluralize(opts.likeCount || 0, 'like')})`}
accessibilityLabel={`${post.viewer?.like ? 'Unlike' : 'Like'} (${
post.likeCount
} ${pluralize(post.likeCount || 0, 'like')})`}
accessibilityHint=""
hitSlop={opts.big ? HITSLOP_20 : HITSLOP_10}>
{opts.isLiked ? (
<HeartIconSolid
style={styles.ctrlIconLiked}
size={opts.big ? 22 : 16}
/>
hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
{post.viewer?.like ? (
<HeartIconSolid style={styles.ctrlIconLiked} size={big ? 22 : 16} />
) : (
<HeartIcon
style={[defaultCtrlColor, opts.big ? styles.mt1 : undefined]}
style={[defaultCtrlColor, big ? styles.mt1 : undefined]}
strokeWidth={3}
size={opts.big ? 20 : 16}
size={big ? 20 : 16}
/>
)}
{typeof opts.likeCount !== 'undefined' ? (
{typeof post.likeCount !== 'undefined' ? (
<Text
testID="likeCount"
style={
opts.isLiked
post.viewer?.like
? [s.bold, s.red3, s.f15, s.ml5]
: [defaultCtrlColor, s.f15, s.ml5]
}>
{opts.likeCount}
{post.likeCount}
</Text>
) : undefined}
</TouchableOpacity>
{opts.big ? undefined : (
{big ? undefined : (
<PostDropdownBtn
testID="postDropdownBtn"
itemUri={opts.itemUri}
itemCid={opts.itemCid}
itemHref={opts.itemHref}
itemTitle={opts.itemTitle}
isAuthor={opts.isAuthor}
isThreadMuted={opts.isThreadMuted}
onCopyPostText={opts.onCopyPostText}
onOpenTranslate={opts.onOpenTranslate}
onToggleThreadMute={opts.onToggleThreadMute}
onDeletePost={opts.onDeletePost}
post={post}
record={record}
style={styles.ctrlPad}
/>
)}

View file

@ -1,200 +0,0 @@
import React, {useCallback} from 'react'
import {
StyleProp,
StyleSheet,
TouchableOpacity,
View,
ViewStyle,
} from 'react-native'
import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api'
import {Text} from '../text/Text'
import {PostDropdownBtn} from '../forms/PostDropdownBtn2'
import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons'
import {s, colors} from 'lib/styles'
import {pluralize} from 'lib/strings/helpers'
import {useTheme} from 'lib/ThemeContext'
import {useStores} from 'state/index'
import {RepostButton} from './RepostButton'
import {Haptics} from 'lib/haptics'
import {HITSLOP_10, HITSLOP_20} from 'lib/constants'
import {useModalControls} from '#/state/modals'
import {
usePostLikeMutation,
usePostUnlikeMutation,
usePostRepostMutation,
usePostUnrepostMutation,
} from '#/state/queries/post'
export function PostCtrls({
big,
post,
record,
style,
onPressReply,
}: {
big?: boolean
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
style?: StyleProp<ViewStyle>
onPressReply: () => void
}) {
const store = useStores()
const theme = useTheme()
const {closeModal} = useModalControls()
const postLikeMutation = usePostLikeMutation()
const postUnlikeMutation = usePostUnlikeMutation()
const postRepostMutation = usePostRepostMutation()
const postUnrepostMutation = usePostUnrepostMutation()
const defaultCtrlColor = React.useMemo(
() => ({
color: theme.palette.default.postCtrl,
}),
[theme],
) as StyleProp<ViewStyle>
const onPressToggleLike = React.useCallback(async () => {
if (!post.viewer?.like) {
Haptics.default()
postLikeMutation.mutate({
uri: post.uri,
cid: post.cid,
likeCount: post.likeCount || 0,
})
} else {
postUnlikeMutation.mutate({
postUri: post.uri,
likeUri: post.viewer.like,
likeCount: post.likeCount || 0,
})
}
}, [post, postLikeMutation, postUnlikeMutation])
const onRepost = useCallback(() => {
closeModal()
if (!post.viewer?.repost) {
Haptics.default()
postRepostMutation.mutate({
uri: post.uri,
cid: post.cid,
repostCount: post.repostCount || 0,
})
} else {
postUnrepostMutation.mutate({
postUri: post.uri,
repostUri: post.viewer.repost,
repostCount: post.repostCount || 0,
})
}
}, [post, closeModal, postRepostMutation, postUnrepostMutation])
const onQuote = useCallback(() => {
closeModal()
store.shell.openComposer({
quote: {
uri: post.uri,
cid: post.cid,
text: record.text,
author: post.author,
indexedAt: post.indexedAt,
},
})
Haptics.default()
}, [post, record, store.shell, closeModal])
return (
<View style={[styles.ctrls, style]}>
<TouchableOpacity
testID="replyBtn"
style={[styles.ctrl, !big && styles.ctrlPad, {paddingLeft: 0}]}
onPress={onPressReply}
accessibilityRole="button"
accessibilityLabel={`Reply (${post.replyCount} ${
post.replyCount === 1 ? 'reply' : 'replies'
})`}
accessibilityHint=""
hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
<CommentBottomArrow
style={[defaultCtrlColor, big ? s.mt2 : styles.mt1]}
strokeWidth={3}
size={big ? 20 : 15}
/>
{typeof post.replyCount !== 'undefined' ? (
<Text style={[defaultCtrlColor, s.ml5, s.f15]}>
{post.replyCount}
</Text>
) : undefined}
</TouchableOpacity>
<RepostButton
big={big}
isReposted={!!post.viewer?.repost}
repostCount={post.repostCount}
onRepost={onRepost}
onQuote={onQuote}
/>
<TouchableOpacity
testID="likeBtn"
style={[styles.ctrl, !big && styles.ctrlPad]}
onPress={onPressToggleLike}
accessibilityRole="button"
accessibilityLabel={`${post.viewer?.like ? 'Unlike' : 'Like'} (${
post.likeCount
} ${pluralize(post.likeCount || 0, 'like')})`}
accessibilityHint=""
hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
{post.viewer?.like ? (
<HeartIconSolid style={styles.ctrlIconLiked} size={big ? 22 : 16} />
) : (
<HeartIcon
style={[defaultCtrlColor, big ? styles.mt1 : undefined]}
strokeWidth={3}
size={big ? 20 : 16}
/>
)}
{typeof post.likeCount !== 'undefined' ? (
<Text
testID="likeCount"
style={
post.viewer?.like
? [s.bold, s.red3, s.f15, s.ml5]
: [defaultCtrlColor, s.f15, s.ml5]
}>
{post.likeCount}
</Text>
) : undefined}
</TouchableOpacity>
{big ? undefined : (
<PostDropdownBtn
testID="postDropdownBtn"
post={post}
record={record}
style={styles.ctrlPad}
/>
)}
{/* used for adding pad to the right side */}
<View />
</View>
)
}
const styles = StyleSheet.create({
ctrls: {
flexDirection: 'row',
justifyContent: 'space-between',
},
ctrl: {
flexDirection: 'row',
alignItems: 'center',
},
ctrlPad: {
paddingTop: 5,
paddingBottom: 5,
paddingLeft: 5,
paddingRight: 5,
},
ctrlIconLiked: {
color: colors.like,
},
mt1: {
marginTop: 1,
},
})