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>zio/stable
parent
c584a3378d
commit
b445c15cc9
|
@ -31,6 +31,7 @@ import {
|
||||||
useSession,
|
useSession,
|
||||||
useSessionApi,
|
useSessionApi,
|
||||||
} from 'state/session'
|
} from 'state/session'
|
||||||
|
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
|
||||||
import * as persisted from '#/state/persisted'
|
import * as persisted from '#/state/persisted'
|
||||||
import {i18n} from '@lingui/core'
|
import {i18n} from '@lingui/core'
|
||||||
import {I18nProvider} from '@lingui/react'
|
import {I18nProvider} from '@lingui/react'
|
||||||
|
@ -53,7 +54,7 @@ const InnerApp = observer(function AppImpl() {
|
||||||
setupState().then(store => {
|
setupState().then(store => {
|
||||||
setRootStore(store)
|
setRootStore(store)
|
||||||
analytics.init(store)
|
analytics.init(store)
|
||||||
notifications.init(store)
|
notifications.init(store, queryClient)
|
||||||
store.onSessionDropped(() => {
|
store.onSessionDropped(() => {
|
||||||
Toast.show('Sorry! Your session expired. Please log in again.')
|
Toast.show('Sorry! Your session expired. Please log in again.')
|
||||||
})
|
})
|
||||||
|
@ -72,22 +73,20 @@ const InnerApp = observer(function AppImpl() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<ThemeProvider theme={colorMode}>
|
||||||
<ThemeProvider theme={colorMode}>
|
<RootSiblingParent>
|
||||||
<RootSiblingParent>
|
<analytics.Provider>
|
||||||
<analytics.Provider>
|
<RootStoreProvider value={rootStore}>
|
||||||
<RootStoreProvider value={rootStore}>
|
<I18nProvider i18n={i18n}>
|
||||||
<I18nProvider i18n={i18n}>
|
<GestureHandlerRootView style={s.h100pct}>
|
||||||
<GestureHandlerRootView style={s.h100pct}>
|
<TestCtrls />
|
||||||
<TestCtrls />
|
<Shell />
|
||||||
<Shell />
|
</GestureHandlerRootView>
|
||||||
</GestureHandlerRootView>
|
</I18nProvider>
|
||||||
</I18nProvider>
|
</RootStoreProvider>
|
||||||
</RootStoreProvider>
|
</analytics.Provider>
|
||||||
</analytics.Provider>
|
</RootSiblingParent>
|
||||||
</RootSiblingParent>
|
</ThemeProvider>
|
||||||
</ThemeProvider>
|
|
||||||
</QueryClientProvider>
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -103,19 +102,23 @@ function App() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SessionProvider>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ShellStateProvider>
|
<SessionProvider>
|
||||||
<PrefsStateProvider>
|
<ShellStateProvider>
|
||||||
<MutedThreadsProvider>
|
<PrefsStateProvider>
|
||||||
<InvitesStateProvider>
|
<MutedThreadsProvider>
|
||||||
<ModalStateProvider>
|
<UnreadNotifsProvider>
|
||||||
<InnerApp />
|
<InvitesStateProvider>
|
||||||
</ModalStateProvider>
|
<ModalStateProvider>
|
||||||
</InvitesStateProvider>
|
<InnerApp />
|
||||||
</MutedThreadsProvider>
|
</ModalStateProvider>
|
||||||
</PrefsStateProvider>
|
</InvitesStateProvider>
|
||||||
</ShellStateProvider>
|
</UnreadNotifsProvider>
|
||||||
</SessionProvider>
|
</MutedThreadsProvider>
|
||||||
|
</PrefsStateProvider>
|
||||||
|
</ShellStateProvider>
|
||||||
|
</SessionProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -29,6 +29,7 @@ import {
|
||||||
useSession,
|
useSession,
|
||||||
useSessionApi,
|
useSessionApi,
|
||||||
} from 'state/session'
|
} from 'state/session'
|
||||||
|
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
|
||||||
import * as persisted from '#/state/persisted'
|
import * as persisted from '#/state/persisted'
|
||||||
|
|
||||||
const InnerApp = observer(function AppImpl() {
|
const InnerApp = observer(function AppImpl() {
|
||||||
|
@ -60,22 +61,20 @@ const InnerApp = observer(function AppImpl() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<ThemeProvider theme={colorMode}>
|
||||||
<ThemeProvider theme={colorMode}>
|
<RootSiblingParent>
|
||||||
<RootSiblingParent>
|
<analytics.Provider>
|
||||||
<analytics.Provider>
|
<RootStoreProvider value={rootStore}>
|
||||||
<RootStoreProvider value={rootStore}>
|
<I18nProvider i18n={i18n}>
|
||||||
<I18nProvider i18n={i18n}>
|
<SafeAreaProvider>
|
||||||
<SafeAreaProvider>
|
<Shell />
|
||||||
<Shell />
|
</SafeAreaProvider>
|
||||||
</SafeAreaProvider>
|
</I18nProvider>
|
||||||
</I18nProvider>
|
<ToastContainer />
|
||||||
<ToastContainer />
|
</RootStoreProvider>
|
||||||
</RootStoreProvider>
|
</analytics.Provider>
|
||||||
</analytics.Provider>
|
</RootSiblingParent>
|
||||||
</RootSiblingParent>
|
</ThemeProvider>
|
||||||
</ThemeProvider>
|
|
||||||
</QueryClientProvider>
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -91,19 +90,23 @@ function App() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SessionProvider>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ShellStateProvider>
|
<SessionProvider>
|
||||||
<PrefsStateProvider>
|
<ShellStateProvider>
|
||||||
<MutedThreadsProvider>
|
<PrefsStateProvider>
|
||||||
<InvitesStateProvider>
|
<MutedThreadsProvider>
|
||||||
<ModalStateProvider>
|
<UnreadNotifsProvider>
|
||||||
<InnerApp />
|
<InvitesStateProvider>
|
||||||
</ModalStateProvider>
|
<ModalStateProvider>
|
||||||
</InvitesStateProvider>
|
<InnerApp />
|
||||||
</MutedThreadsProvider>
|
</ModalStateProvider>
|
||||||
</PrefsStateProvider>
|
</InvitesStateProvider>
|
||||||
</ShellStateProvider>
|
</UnreadNotifsProvider>
|
||||||
</SessionProvider>
|
</MutedThreadsProvider>
|
||||||
|
</PrefsStateProvider>
|
||||||
|
</ShellStateProvider>
|
||||||
|
</SessionProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import {StyleSheet} from 'react-native'
|
import {StyleSheet} from 'react-native'
|
||||||
import * as SplashScreen from 'expo-splash-screen'
|
import * as SplashScreen from 'expo-splash-screen'
|
||||||
import {observer} from 'mobx-react-lite'
|
|
||||||
import {
|
import {
|
||||||
NavigationContainer,
|
NavigationContainer,
|
||||||
createNavigationContainerRef,
|
createNavigationContainerRef,
|
||||||
|
@ -33,10 +32,10 @@ import {isNative} from 'platform/detection'
|
||||||
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
||||||
import {router} from './routes'
|
import {router} from './routes'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {useStores} from './state'
|
|
||||||
import {bskyTitle} from 'lib/strings/headings'
|
import {bskyTitle} from 'lib/strings/headings'
|
||||||
import {JSX} from 'react/jsx-runtime'
|
import {JSX} from 'react/jsx-runtime'
|
||||||
import {timeout} from 'lib/async/timeout'
|
import {timeout} from 'lib/async/timeout'
|
||||||
|
import {useUnreadNotifications} from './state/queries/notifications/unread'
|
||||||
|
|
||||||
import {HomeScreen} from './view/screens/Home'
|
import {HomeScreen} from './view/screens/Home'
|
||||||
import {SearchScreen} from './view/screens/Search'
|
import {SearchScreen} from './view/screens/Search'
|
||||||
|
@ -346,7 +345,7 @@ function NotificationsTabNavigator() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const MyProfileTabNavigator = observer(function MyProfileTabNavigatorImpl() {
|
function MyProfileTabNavigator() {
|
||||||
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
|
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
|
||||||
return (
|
return (
|
||||||
<MyProfileTab.Navigator
|
<MyProfileTab.Navigator
|
||||||
|
@ -368,18 +367,17 @@ const MyProfileTabNavigator = observer(function MyProfileTabNavigatorImpl() {
|
||||||
{commonScreens(MyProfileTab as typeof HomeTab)}
|
{commonScreens(MyProfileTab as typeof HomeTab)}
|
||||||
</MyProfileTab.Navigator>
|
</MyProfileTab.Navigator>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The FlatNavigator is used by Web to represent the routes
|
* The FlatNavigator is used by Web to represent the routes
|
||||||
* in a single ("flat") stack.
|
* in a single ("flat") stack.
|
||||||
*/
|
*/
|
||||||
const FlatNavigator = observer(function FlatNavigatorImpl() {
|
const FlatNavigator = () => {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const numUnread = useUnreadNotifications()
|
||||||
const unreadCountLabel = store.me.notifications.unreadCountLabel
|
|
||||||
|
|
||||||
const title = (page: string) => bskyTitle(page, unreadCountLabel)
|
const title = (page: string) => bskyTitle(page, numUnread)
|
||||||
return (
|
return (
|
||||||
<Flat.Navigator
|
<Flat.Navigator
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
|
@ -409,10 +407,10 @@ const FlatNavigator = observer(function FlatNavigatorImpl() {
|
||||||
getComponent={() => NotificationsScreen}
|
getComponent={() => NotificationsScreen}
|
||||||
options={{title: title('Notifications')}}
|
options={{title: title('Notifications')}}
|
||||||
/>
|
/>
|
||||||
{commonScreens(Flat as typeof HomeTab, unreadCountLabel)}
|
{commonScreens(Flat as typeof HomeTab, numUnread)}
|
||||||
</Flat.Navigator>
|
</Flat.Navigator>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The RoutesContainer should wrap all components which need access
|
* The RoutesContainer should wrap all components which need access
|
||||||
|
|
|
@ -3,4 +3,9 @@ export default class BroadcastChannel {
|
||||||
postMessage(_data: any) {}
|
postMessage(_data: any) {}
|
||||||
close() {}
|
close() {}
|
||||||
onmessage: (event: MessageEvent) => void = () => {}
|
onmessage: (event: MessageEvent) => void = () => {}
|
||||||
|
addEventListener(_type: string, _listener: (event: MessageEvent) => void) {}
|
||||||
|
removeEventListener(
|
||||||
|
_type: string,
|
||||||
|
_listener: (event: MessageEvent) => void,
|
||||||
|
) {}
|
||||||
}
|
}
|
|
@ -3,18 +3,14 @@ import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
import {NavigationProp} from 'lib/routes/types'
|
import {NavigationProp} from 'lib/routes/types'
|
||||||
import {bskyTitle} from 'lib/strings/headings'
|
import {bskyTitle} from 'lib/strings/headings'
|
||||||
import {useStores} from 'state/index'
|
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
|
||||||
|
|
||||||
/**
|
|
||||||
* Requires consuming component to be wrapped in `observer`:
|
|
||||||
* https://stackoverflow.com/a/71488009
|
|
||||||
*/
|
|
||||||
export function useSetTitle(title?: string) {
|
export function useSetTitle(title?: string) {
|
||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
const {unreadCountLabel} = useStores().me.notifications
|
const numUnread = useUnreadNotifications()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (title) {
|
if (title) {
|
||||||
navigation.setOptions({title: bskyTitle(title, unreadCountLabel)})
|
navigation.setOptions({title: bskyTitle(title, numUnread)})
|
||||||
}
|
}
|
||||||
}, [title, navigation, unreadCountLabel])
|
}, [title, navigation, numUnread])
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
import * as Notifications from 'expo-notifications'
|
import * as Notifications from 'expo-notifications'
|
||||||
|
import {QueryClient} from '@tanstack/react-query'
|
||||||
import {RootStoreModel} from '../../state'
|
import {RootStoreModel} from '../../state'
|
||||||
import {resetToTab} from '../../Navigation'
|
import {resetToTab} from '../../Navigation'
|
||||||
import {devicePlatform, isIOS} from 'platform/detection'
|
import {devicePlatform, isIOS} from 'platform/detection'
|
||||||
import {track} from 'lib/analytics/analytics'
|
import {track} from 'lib/analytics/analytics'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
|
import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'
|
||||||
|
|
||||||
const SERVICE_DID = (serviceUrl?: string) =>
|
const SERVICE_DID = (serviceUrl?: string) =>
|
||||||
serviceUrl?.includes('staging')
|
serviceUrl?.includes('staging')
|
||||||
? 'did:web:api.staging.bsky.dev'
|
? 'did:web:api.staging.bsky.dev'
|
||||||
: 'did:web:api.bsky.app'
|
: 'did:web:api.bsky.app'
|
||||||
|
|
||||||
export function init(store: RootStoreModel) {
|
export function init(store: RootStoreModel, queryClient: QueryClient) {
|
||||||
store.onUnreadNotifications(count => Notifications.setBadgeCountAsync(count))
|
|
||||||
|
|
||||||
store.onSessionLoaded(async () => {
|
store.onSessionLoaded(async () => {
|
||||||
// request notifications permission once the user has logged in
|
// request notifications permission once the user has logged in
|
||||||
const perms = await Notifications.getPermissionsAsync()
|
const perms = await Notifications.getPermissionsAsync()
|
||||||
|
@ -83,7 +83,7 @@ export function init(store: RootStoreModel) {
|
||||||
)
|
)
|
||||||
if (event.request.trigger.type === 'push') {
|
if (event.request.trigger.type === 'push') {
|
||||||
// refresh notifications in the background
|
// refresh notifications in the background
|
||||||
store.me.notifications.syncQueue()
|
queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
|
||||||
// handle payload-based deeplinks
|
// handle payload-based deeplinks
|
||||||
let payload
|
let payload
|
||||||
if (isIOS) {
|
if (isIOS) {
|
||||||
|
@ -121,7 +121,7 @@ export function init(store: RootStoreModel) {
|
||||||
logger.DebugContext.notifications,
|
logger.DebugContext.notifications,
|
||||||
)
|
)
|
||||||
track('Notificatons:OpenApp')
|
track('Notificatons:OpenApp')
|
||||||
store.me.notifications.refresh() // refresh notifications
|
queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
|
||||||
resetToTab('NotificationsTab') // open notifications tab
|
resetToTab('NotificationsTab') // open notifications tab
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,671 +0,0 @@
|
||||||
import {makeAutoObservable, runInAction} from 'mobx'
|
|
||||||
import {
|
|
||||||
AppBskyNotificationListNotifications as ListNotifications,
|
|
||||||
AppBskyActorDefs,
|
|
||||||
AppBskyFeedDefs,
|
|
||||||
AppBskyFeedPost,
|
|
||||||
AppBskyFeedRepost,
|
|
||||||
AppBskyFeedLike,
|
|
||||||
AppBskyGraphFollow,
|
|
||||||
ComAtprotoLabelDefs,
|
|
||||||
moderatePost,
|
|
||||||
moderateProfile,
|
|
||||||
} from '@atproto/api'
|
|
||||||
import AwaitLock from 'await-lock'
|
|
||||||
import chunk from 'lodash.chunk'
|
|
||||||
import {bundleAsync} from 'lib/async/bundle'
|
|
||||||
import {RootStoreModel} from '../root-store'
|
|
||||||
import {PostThreadModel} from '../content/post-thread'
|
|
||||||
import {cleanError} from 'lib/strings/errors'
|
|
||||||
import {logger} from '#/logger'
|
|
||||||
import {isThreadMuted} from '#/state/muted-threads'
|
|
||||||
|
|
||||||
const GROUPABLE_REASONS = ['like', 'repost', 'follow']
|
|
||||||
const PAGE_SIZE = 30
|
|
||||||
const MS_1HR = 1e3 * 60 * 60
|
|
||||||
const MS_2DAY = MS_1HR * 48
|
|
||||||
|
|
||||||
export const MAX_VISIBLE_NOTIFS = 30
|
|
||||||
|
|
||||||
export interface GroupedNotification extends ListNotifications.Notification {
|
|
||||||
additional?: ListNotifications.Notification[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type SupportedRecord =
|
|
||||||
| AppBskyFeedPost.Record
|
|
||||||
| AppBskyFeedRepost.Record
|
|
||||||
| AppBskyFeedLike.Record
|
|
||||||
| AppBskyGraphFollow.Record
|
|
||||||
|
|
||||||
export class NotificationsFeedItemModel {
|
|
||||||
// ui state
|
|
||||||
_reactKey: string = ''
|
|
||||||
|
|
||||||
// data
|
|
||||||
uri: string = ''
|
|
||||||
cid: string = ''
|
|
||||||
author: AppBskyActorDefs.ProfileViewBasic = {
|
|
||||||
did: '',
|
|
||||||
handle: '',
|
|
||||||
avatar: '',
|
|
||||||
}
|
|
||||||
reason: string = ''
|
|
||||||
reasonSubject?: string
|
|
||||||
record?: SupportedRecord
|
|
||||||
isRead: boolean = false
|
|
||||||
indexedAt: string = ''
|
|
||||||
labels?: ComAtprotoLabelDefs.Label[]
|
|
||||||
additional?: NotificationsFeedItemModel[]
|
|
||||||
|
|
||||||
// additional data
|
|
||||||
additionalPost?: PostThreadModel
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
public rootStore: RootStoreModel,
|
|
||||||
reactKey: string,
|
|
||||||
v: GroupedNotification,
|
|
||||||
) {
|
|
||||||
makeAutoObservable(this, {rootStore: false})
|
|
||||||
this._reactKey = reactKey
|
|
||||||
this.copy(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
copy(v: GroupedNotification, preserve = false) {
|
|
||||||
this.uri = v.uri
|
|
||||||
this.cid = v.cid
|
|
||||||
this.author = v.author
|
|
||||||
this.reason = v.reason
|
|
||||||
this.reasonSubject = v.reasonSubject
|
|
||||||
this.record = this.toSupportedRecord(v.record)
|
|
||||||
this.isRead = v.isRead
|
|
||||||
this.indexedAt = v.indexedAt
|
|
||||||
this.labels = v.labels
|
|
||||||
if (v.additional?.length) {
|
|
||||||
this.additional = []
|
|
||||||
for (const add of v.additional) {
|
|
||||||
this.additional.push(
|
|
||||||
new NotificationsFeedItemModel(this.rootStore, '', add),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (!preserve) {
|
|
||||||
this.additional = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get shouldFilter(): boolean {
|
|
||||||
if (this.additionalPost?.thread) {
|
|
||||||
const postMod = moderatePost(
|
|
||||||
this.additionalPost.thread.data.post,
|
|
||||||
this.rootStore.preferences.moderationOpts,
|
|
||||||
)
|
|
||||||
return postMod.content.filter || false
|
|
||||||
}
|
|
||||||
const profileMod = moderateProfile(
|
|
||||||
this.author,
|
|
||||||
this.rootStore.preferences.moderationOpts,
|
|
||||||
)
|
|
||||||
return profileMod.account.filter || false
|
|
||||||
}
|
|
||||||
|
|
||||||
get numUnreadInGroup(): number {
|
|
||||||
if (this.additional?.length) {
|
|
||||||
return (
|
|
||||||
this.additional.reduce(
|
|
||||||
(acc, notif) => acc + notif.numUnreadInGroup,
|
|
||||||
0,
|
|
||||||
) + (this.isRead ? 0 : 1)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return this.isRead ? 0 : 1
|
|
||||||
}
|
|
||||||
|
|
||||||
markGroupRead() {
|
|
||||||
if (this.additional?.length) {
|
|
||||||
for (const notif of this.additional) {
|
|
||||||
notif.markGroupRead()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.isRead = true
|
|
||||||
}
|
|
||||||
|
|
||||||
get isLike() {
|
|
||||||
return this.reason === 'like' && !this.isCustomFeedLike // the reason property for custom feed likes is also 'like'
|
|
||||||
}
|
|
||||||
|
|
||||||
get isRepost() {
|
|
||||||
return this.reason === 'repost'
|
|
||||||
}
|
|
||||||
|
|
||||||
get isMention() {
|
|
||||||
return this.reason === 'mention'
|
|
||||||
}
|
|
||||||
|
|
||||||
get isReply() {
|
|
||||||
return this.reason === 'reply'
|
|
||||||
}
|
|
||||||
|
|
||||||
get isQuote() {
|
|
||||||
return this.reason === 'quote'
|
|
||||||
}
|
|
||||||
|
|
||||||
get isFollow() {
|
|
||||||
return this.reason === 'follow'
|
|
||||||
}
|
|
||||||
|
|
||||||
get isCustomFeedLike() {
|
|
||||||
return (
|
|
||||||
this.reason === 'like' && this.reasonSubject?.includes('feed.generator')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
get needsAdditionalData() {
|
|
||||||
if (
|
|
||||||
this.isLike ||
|
|
||||||
this.isRepost ||
|
|
||||||
this.isReply ||
|
|
||||||
this.isQuote ||
|
|
||||||
this.isMention
|
|
||||||
) {
|
|
||||||
return !this.additionalPost
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
get additionalDataUri(): string | undefined {
|
|
||||||
if (this.isReply || this.isQuote || this.isMention) {
|
|
||||||
return this.uri
|
|
||||||
} else if (this.isLike || this.isRepost) {
|
|
||||||
return this.subjectUri
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get subjectUri(): string {
|
|
||||||
if (this.reasonSubject) {
|
|
||||||
return this.reasonSubject
|
|
||||||
}
|
|
||||||
const record = this.record
|
|
||||||
if (
|
|
||||||
AppBskyFeedRepost.isRecord(record) ||
|
|
||||||
AppBskyFeedLike.isRecord(record)
|
|
||||||
) {
|
|
||||||
return record.subject.uri
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
get reasonSubjectRootUri(): string | undefined {
|
|
||||||
if (this.additionalPost) {
|
|
||||||
return this.additionalPost.rootUri
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
toSupportedRecord(v: unknown): SupportedRecord | undefined {
|
|
||||||
for (const ns of [
|
|
||||||
AppBskyFeedPost,
|
|
||||||
AppBskyFeedRepost,
|
|
||||||
AppBskyFeedLike,
|
|
||||||
AppBskyGraphFollow,
|
|
||||||
]) {
|
|
||||||
if (ns.isRecord(v)) {
|
|
||||||
const valid = ns.validateRecord(v)
|
|
||||||
if (valid.success) {
|
|
||||||
return v
|
|
||||||
} else {
|
|
||||||
logger.warn('Received an invalid record', {
|
|
||||||
record: v,
|
|
||||||
error: valid.error,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logger.warn(
|
|
||||||
'app.bsky.notifications.list served an unsupported record type',
|
|
||||||
{record: v},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
setAdditionalData(additionalPost: AppBskyFeedDefs.PostView) {
|
|
||||||
if (this.additionalPost) {
|
|
||||||
this.additionalPost._replaceAll({
|
|
||||||
success: true,
|
|
||||||
headers: {},
|
|
||||||
data: {
|
|
||||||
thread: {
|
|
||||||
post: additionalPost,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
this.additionalPost = PostThreadModel.fromPostView(
|
|
||||||
this.rootStore,
|
|
||||||
additionalPost,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class NotificationsFeedModel {
|
|
||||||
// state
|
|
||||||
isLoading = false
|
|
||||||
isRefreshing = false
|
|
||||||
hasLoaded = false
|
|
||||||
error = ''
|
|
||||||
loadMoreError = ''
|
|
||||||
hasMore = true
|
|
||||||
loadMoreCursor?: string
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The last time notifications were seen. Refers to either the
|
|
||||||
* user's machine clock or the value of the `indexedAt` property on their
|
|
||||||
* latest notification, whichever was greater at the time of viewing.
|
|
||||||
*/
|
|
||||||
lastSync?: Date
|
|
||||||
|
|
||||||
// used to linearize async modifications to state
|
|
||||||
lock = new AwaitLock()
|
|
||||||
|
|
||||||
// data
|
|
||||||
notifications: NotificationsFeedItemModel[] = []
|
|
||||||
queuedNotifications: undefined | NotificationsFeedItemModel[] = undefined
|
|
||||||
unreadCount = 0
|
|
||||||
|
|
||||||
// this is used to help trigger push notifications
|
|
||||||
mostRecentNotificationUri: string | undefined
|
|
||||||
|
|
||||||
constructor(public rootStore: RootStoreModel) {
|
|
||||||
makeAutoObservable(
|
|
||||||
this,
|
|
||||||
{
|
|
||||||
rootStore: false,
|
|
||||||
mostRecentNotificationUri: false,
|
|
||||||
},
|
|
||||||
{autoBind: true},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
get hasContent() {
|
|
||||||
return this.notifications.length !== 0
|
|
||||||
}
|
|
||||||
|
|
||||||
get hasError() {
|
|
||||||
return this.error !== ''
|
|
||||||
}
|
|
||||||
|
|
||||||
get isEmpty() {
|
|
||||||
return this.hasLoaded && !this.hasContent
|
|
||||||
}
|
|
||||||
|
|
||||||
get hasNewLatest() {
|
|
||||||
return Boolean(
|
|
||||||
this.queuedNotifications && this.queuedNotifications?.length > 0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
get unreadCountLabel(): string {
|
|
||||||
const count = this.unreadCount
|
|
||||||
if (count >= MAX_VISIBLE_NOTIFS) {
|
|
||||||
return `${MAX_VISIBLE_NOTIFS}+`
|
|
||||||
}
|
|
||||||
if (count === 0) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
return String(count)
|
|
||||||
}
|
|
||||||
|
|
||||||
// public api
|
|
||||||
// =
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Nuke all data
|
|
||||||
*/
|
|
||||||
clear() {
|
|
||||||
logger.debug('NotificationsModel:clear')
|
|
||||||
this.isLoading = false
|
|
||||||
this.isRefreshing = false
|
|
||||||
this.hasLoaded = false
|
|
||||||
this.error = ''
|
|
||||||
this.hasMore = true
|
|
||||||
this.loadMoreCursor = undefined
|
|
||||||
this.notifications = []
|
|
||||||
this.unreadCount = 0
|
|
||||||
this.rootStore.emitUnreadNotifications(0)
|
|
||||||
this.mostRecentNotificationUri = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load for first render
|
|
||||||
*/
|
|
||||||
setup = bundleAsync(async (isRefreshing: boolean = false) => {
|
|
||||||
logger.debug('NotificationsModel:refresh', {isRefreshing})
|
|
||||||
await this.lock.acquireAsync()
|
|
||||||
try {
|
|
||||||
this._xLoading(isRefreshing)
|
|
||||||
try {
|
|
||||||
const res = await this.rootStore.agent.listNotifications({
|
|
||||||
limit: PAGE_SIZE,
|
|
||||||
})
|
|
||||||
await this._replaceAll(res)
|
|
||||||
this._setQueued(undefined)
|
|
||||||
this._countUnread()
|
|
||||||
this._xIdle()
|
|
||||||
} catch (e: any) {
|
|
||||||
this._xIdle(e)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
this.lock.release()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset and load
|
|
||||||
*/
|
|
||||||
async refresh() {
|
|
||||||
this.isRefreshing = true // set optimistically for UI
|
|
||||||
return this.setup(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sync the next set of notifications to show
|
|
||||||
*/
|
|
||||||
syncQueue = bundleAsync(async () => {
|
|
||||||
logger.debug('NotificationsModel:syncQueue')
|
|
||||||
if (this.unreadCount >= MAX_VISIBLE_NOTIFS) {
|
|
||||||
return // no need to check
|
|
||||||
}
|
|
||||||
await this.lock.acquireAsync()
|
|
||||||
try {
|
|
||||||
const res = await this.rootStore.agent.listNotifications({
|
|
||||||
limit: PAGE_SIZE,
|
|
||||||
})
|
|
||||||
|
|
||||||
const queue = []
|
|
||||||
for (const notif of res.data.notifications) {
|
|
||||||
if (this.notifications.length) {
|
|
||||||
if (isEq(notif, this.notifications[0])) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!notif.isRead) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
queue.push(notif)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE
|
|
||||||
// because filtering depends on the added information we have to fetch
|
|
||||||
// the full models here. this is *not* ideal performance and we need
|
|
||||||
// to update the notifications route to give all the info we need
|
|
||||||
// -prf
|
|
||||||
const queueModels = await this._fetchItemModels(queue)
|
|
||||||
this._setQueued(this._filterNotifications(queueModels))
|
|
||||||
this._countUnread()
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('NotificationsModel:syncQueue failed', {
|
|
||||||
error: e,
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
this.lock.release()
|
|
||||||
}
|
|
||||||
|
|
||||||
// if there are no notifications, we should refresh the list
|
|
||||||
// this will only run for new users who have no notifications
|
|
||||||
// NOTE: needs to be after the lock is released
|
|
||||||
if (this.isEmpty) {
|
|
||||||
this.refresh()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load more posts to the end of the notifications
|
|
||||||
*/
|
|
||||||
loadMore = bundleAsync(async () => {
|
|
||||||
if (!this.hasMore) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await this.lock.acquireAsync()
|
|
||||||
try {
|
|
||||||
this._xLoading()
|
|
||||||
try {
|
|
||||||
const res = await this.rootStore.agent.listNotifications({
|
|
||||||
limit: PAGE_SIZE,
|
|
||||||
cursor: this.loadMoreCursor,
|
|
||||||
})
|
|
||||||
await this._appendAll(res)
|
|
||||||
this._xIdle()
|
|
||||||
} catch (e: any) {
|
|
||||||
this._xIdle(undefined, e)
|
|
||||||
runInAction(() => {
|
|
||||||
this.hasMore = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
this.lock.release()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempt to load more again after a failure
|
|
||||||
*/
|
|
||||||
async retryLoadMore() {
|
|
||||||
this.loadMoreError = ''
|
|
||||||
this.hasMore = true
|
|
||||||
return this.loadMore()
|
|
||||||
}
|
|
||||||
|
|
||||||
// unread notification in-place
|
|
||||||
// =
|
|
||||||
async update() {
|
|
||||||
const promises = []
|
|
||||||
for (const item of this.notifications) {
|
|
||||||
if (item.additionalPost) {
|
|
||||||
promises.push(item.additionalPost.update())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await Promise.all(promises).catch(e => {
|
|
||||||
logger.error('Uncaught failure during notifications update()', e)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update read/unread state
|
|
||||||
*/
|
|
||||||
async markAllRead() {
|
|
||||||
try {
|
|
||||||
for (const notif of this.notifications) {
|
|
||||||
notif.markGroupRead()
|
|
||||||
}
|
|
||||||
this._countUnread()
|
|
||||||
await this.rootStore.agent.updateSeenNotifications(
|
|
||||||
this.lastSync ? this.lastSync.toISOString() : undefined,
|
|
||||||
)
|
|
||||||
} catch (e: any) {
|
|
||||||
logger.warn('Failed to update notifications read state', {
|
|
||||||
error: e,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// state transitions
|
|
||||||
// =
|
|
||||||
|
|
||||||
_xLoading(isRefreshing = false) {
|
|
||||||
this.isLoading = true
|
|
||||||
this.isRefreshing = isRefreshing
|
|
||||||
this.error = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
_xIdle(error?: any, loadMoreError?: any) {
|
|
||||||
this.isLoading = false
|
|
||||||
this.isRefreshing = false
|
|
||||||
this.hasLoaded = true
|
|
||||||
this.error = cleanError(error)
|
|
||||||
this.loadMoreError = cleanError(loadMoreError)
|
|
||||||
if (error) {
|
|
||||||
logger.error('Failed to fetch notifications', {error})
|
|
||||||
}
|
|
||||||
if (loadMoreError) {
|
|
||||||
logger.error('Failed to load more notifications', {
|
|
||||||
error: loadMoreError,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// helper functions
|
|
||||||
// =
|
|
||||||
|
|
||||||
async _replaceAll(res: ListNotifications.Response) {
|
|
||||||
const latest = res.data.notifications[0]
|
|
||||||
|
|
||||||
if (latest) {
|
|
||||||
const now = new Date()
|
|
||||||
const lastIndexed = new Date(latest.indexedAt)
|
|
||||||
const nowOrLastIndexed = now > lastIndexed ? now : lastIndexed
|
|
||||||
|
|
||||||
this.mostRecentNotificationUri = latest.uri
|
|
||||||
this.lastSync = nowOrLastIndexed
|
|
||||||
}
|
|
||||||
|
|
||||||
return this._appendAll(res, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
async _appendAll(res: ListNotifications.Response, replace = false) {
|
|
||||||
this.loadMoreCursor = res.data.cursor
|
|
||||||
this.hasMore = !!this.loadMoreCursor
|
|
||||||
const itemModels = await this._processNotifications(res.data.notifications)
|
|
||||||
runInAction(() => {
|
|
||||||
if (replace) {
|
|
||||||
this.notifications = itemModels
|
|
||||||
} else {
|
|
||||||
this.notifications = this.notifications.concat(itemModels)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
_filterNotifications(
|
|
||||||
items: NotificationsFeedItemModel[],
|
|
||||||
): NotificationsFeedItemModel[] {
|
|
||||||
return items
|
|
||||||
.filter(item => {
|
|
||||||
const hideByLabel = item.shouldFilter
|
|
||||||
let mutedThread = !!(
|
|
||||||
item.reasonSubjectRootUri && isThreadMuted(item.reasonSubjectRootUri)
|
|
||||||
)
|
|
||||||
return !hideByLabel && !mutedThread
|
|
||||||
})
|
|
||||||
.map(item => {
|
|
||||||
if (item.additional?.length) {
|
|
||||||
item.additional = this._filterNotifications(item.additional)
|
|
||||||
}
|
|
||||||
return item
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async _fetchItemModels(
|
|
||||||
items: ListNotifications.Notification[],
|
|
||||||
): Promise<NotificationsFeedItemModel[]> {
|
|
||||||
// construct item models and track who needs more data
|
|
||||||
const itemModels: NotificationsFeedItemModel[] = []
|
|
||||||
const addedPostMap = new Map<string, NotificationsFeedItemModel[]>()
|
|
||||||
for (const item of items) {
|
|
||||||
const itemModel = new NotificationsFeedItemModel(
|
|
||||||
this.rootStore,
|
|
||||||
`notification-${item.uri}`,
|
|
||||||
item,
|
|
||||||
)
|
|
||||||
const uri = itemModel.additionalDataUri
|
|
||||||
if (uri) {
|
|
||||||
const models = addedPostMap.get(uri) || []
|
|
||||||
models.push(itemModel)
|
|
||||||
addedPostMap.set(uri, models)
|
|
||||||
}
|
|
||||||
itemModels.push(itemModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetch additional data
|
|
||||||
if (addedPostMap.size > 0) {
|
|
||||||
const uriChunks = chunk(Array.from(addedPostMap.keys()), 25)
|
|
||||||
const postsChunks = await Promise.all(
|
|
||||||
uriChunks.map(uris =>
|
|
||||||
this.rootStore.agent.app.bsky.feed
|
|
||||||
.getPosts({uris})
|
|
||||||
.then(res => res.data.posts),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for (const post of postsChunks.flat()) {
|
|
||||||
this.rootStore.posts.set(post.uri, post)
|
|
||||||
const models = addedPostMap.get(post.uri)
|
|
||||||
if (models?.length) {
|
|
||||||
for (const model of models) {
|
|
||||||
model.setAdditionalData(post)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return itemModels
|
|
||||||
}
|
|
||||||
|
|
||||||
async _processNotifications(
|
|
||||||
items: ListNotifications.Notification[],
|
|
||||||
): Promise<NotificationsFeedItemModel[]> {
|
|
||||||
const itemModels = await this._fetchItemModels(groupNotifications(items))
|
|
||||||
return this._filterNotifications(itemModels)
|
|
||||||
}
|
|
||||||
|
|
||||||
_setQueued(queued: undefined | NotificationsFeedItemModel[]) {
|
|
||||||
this.queuedNotifications = queued
|
|
||||||
}
|
|
||||||
|
|
||||||
_countUnread() {
|
|
||||||
let unread = 0
|
|
||||||
for (const notif of this.notifications) {
|
|
||||||
unread += notif.numUnreadInGroup
|
|
||||||
}
|
|
||||||
if (this.queuedNotifications) {
|
|
||||||
unread += this.queuedNotifications.filter(notif => !notif.isRead).length
|
|
||||||
}
|
|
||||||
this.unreadCount = unread
|
|
||||||
this.rootStore.emitUnreadNotifications(unread)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupNotifications(
|
|
||||||
items: ListNotifications.Notification[],
|
|
||||||
): GroupedNotification[] {
|
|
||||||
const items2: GroupedNotification[] = []
|
|
||||||
for (const item of items) {
|
|
||||||
const ts = +new Date(item.indexedAt)
|
|
||||||
let grouped = false
|
|
||||||
if (GROUPABLE_REASONS.includes(item.reason)) {
|
|
||||||
for (const item2 of items2) {
|
|
||||||
const ts2 = +new Date(item2.indexedAt)
|
|
||||||
if (
|
|
||||||
Math.abs(ts2 - ts) < MS_2DAY &&
|
|
||||||
item.reason === item2.reason &&
|
|
||||||
item.reasonSubject === item2.reasonSubject &&
|
|
||||||
item.author.did !== item2.author.did
|
|
||||||
) {
|
|
||||||
item2.additional = item2.additional || []
|
|
||||||
item2.additional.push(item)
|
|
||||||
grouped = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!grouped) {
|
|
||||||
items2.push(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return items2
|
|
||||||
}
|
|
||||||
|
|
||||||
type N = ListNotifications.Notification | NotificationsFeedItemModel
|
|
||||||
function isEq(a: N, b: N) {
|
|
||||||
// this function has a key subtlety- the indexedAt comparison
|
|
||||||
// the reason for this is reposts: they set the URI of the original post, not of the repost record
|
|
||||||
// the indexedAt time will be for the repost however, so we use that to help us
|
|
||||||
return a.uri === b.uri && a.indexedAt === b.indexedAt
|
|
||||||
}
|
|
|
@ -4,13 +4,11 @@ import {
|
||||||
ComAtprotoServerListAppPasswords,
|
ComAtprotoServerListAppPasswords,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {RootStoreModel} from './root-store'
|
import {RootStoreModel} from './root-store'
|
||||||
import {NotificationsFeedModel} from './feeds/notifications'
|
|
||||||
import {MyFollowsCache} from './cache/my-follows'
|
import {MyFollowsCache} from './cache/my-follows'
|
||||||
import {isObj, hasProp} from 'lib/type-guards'
|
import {isObj, hasProp} from 'lib/type-guards'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
|
|
||||||
const PROFILE_UPDATE_INTERVAL = 10 * 60 * 1e3 // 10min
|
const PROFILE_UPDATE_INTERVAL = 10 * 60 * 1e3 // 10min
|
||||||
const NOTIFS_UPDATE_INTERVAL = 30 * 1e3 // 30sec
|
|
||||||
|
|
||||||
export class MeModel {
|
export class MeModel {
|
||||||
did: string = ''
|
did: string = ''
|
||||||
|
@ -20,12 +18,10 @@ export class MeModel {
|
||||||
avatar: string = ''
|
avatar: string = ''
|
||||||
followsCount: number | undefined
|
followsCount: number | undefined
|
||||||
followersCount: number | undefined
|
followersCount: number | undefined
|
||||||
notifications: NotificationsFeedModel
|
|
||||||
follows: MyFollowsCache
|
follows: MyFollowsCache
|
||||||
invites: ComAtprotoServerDefs.InviteCode[] = []
|
invites: ComAtprotoServerDefs.InviteCode[] = []
|
||||||
appPasswords: ComAtprotoServerListAppPasswords.AppPassword[] = []
|
appPasswords: ComAtprotoServerListAppPasswords.AppPassword[] = []
|
||||||
lastProfileStateUpdate = Date.now()
|
lastProfileStateUpdate = Date.now()
|
||||||
lastNotifsUpdate = Date.now()
|
|
||||||
|
|
||||||
get invitesAvailable() {
|
get invitesAvailable() {
|
||||||
return this.invites.filter(isInviteAvailable).length
|
return this.invites.filter(isInviteAvailable).length
|
||||||
|
@ -37,12 +33,10 @@ export class MeModel {
|
||||||
{rootStore: false, serialize: false, hydrate: false},
|
{rootStore: false, serialize: false, hydrate: false},
|
||||||
{autoBind: true},
|
{autoBind: true},
|
||||||
)
|
)
|
||||||
this.notifications = new NotificationsFeedModel(this.rootStore)
|
|
||||||
this.follows = new MyFollowsCache(this.rootStore)
|
this.follows = new MyFollowsCache(this.rootStore)
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
this.notifications.clear()
|
|
||||||
this.follows.clear()
|
this.follows.clear()
|
||||||
this.rootStore.profiles.cache.clear()
|
this.rootStore.profiles.cache.clear()
|
||||||
this.rootStore.posts.cache.clear()
|
this.rootStore.posts.cache.clear()
|
||||||
|
@ -99,16 +93,6 @@ export class MeModel {
|
||||||
if (sess.hasSession) {
|
if (sess.hasSession) {
|
||||||
this.did = sess.currentSession?.did || ''
|
this.did = sess.currentSession?.did || ''
|
||||||
await this.fetchProfile()
|
await this.fetchProfile()
|
||||||
/* dont await */ this.notifications.setup().catch(e => {
|
|
||||||
logger.error('Failed to setup notifications model', {
|
|
||||||
error: e,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
/* dont await */ this.notifications.setup().catch(e => {
|
|
||||||
logger.error('Failed to setup notifications model', {
|
|
||||||
error: e,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
this.rootStore.emitSessionLoaded()
|
this.rootStore.emitSessionLoaded()
|
||||||
await this.fetchInviteCodes()
|
await this.fetchInviteCodes()
|
||||||
await this.fetchAppPasswords()
|
await this.fetchAppPasswords()
|
||||||
|
@ -125,10 +109,6 @@ export class MeModel {
|
||||||
await this.fetchInviteCodes()
|
await this.fetchInviteCodes()
|
||||||
await this.fetchAppPasswords()
|
await this.fetchAppPasswords()
|
||||||
}
|
}
|
||||||
if (Date.now() - this.lastNotifsUpdate > NOTIFS_UPDATE_INTERVAL) {
|
|
||||||
this.lastNotifsUpdate = Date.now()
|
|
||||||
await this.notifications.syncQueue()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchProfile() {
|
async fetchProfile() {
|
||||||
|
|
|
@ -203,14 +203,6 @@ export class RootStoreModel {
|
||||||
emitScreenSoftReset() {
|
emitScreenSoftReset() {
|
||||||
DeviceEventEmitter.emit('screen-soft-reset')
|
DeviceEventEmitter.emit('screen-soft-reset')
|
||||||
}
|
}
|
||||||
|
|
||||||
// the unread notifications count has changed
|
|
||||||
onUnreadNotifications(handler: (count: number) => void): EmitterSubscription {
|
|
||||||
return DeviceEventEmitter.addListener('unread-notifications', handler)
|
|
||||||
}
|
|
||||||
emitUnreadNotifications(count: number) {
|
|
||||||
DeviceEventEmitter.emit('unread-notifications', count)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const throwawayInst = new RootStoreModel(
|
const throwawayInst = new RootStoreModel(
|
||||||
|
|
|
@ -3,7 +3,7 @@ import {logger} from '#/logger'
|
||||||
import {defaults, Schema} from '#/state/persisted/schema'
|
import {defaults, Schema} from '#/state/persisted/schema'
|
||||||
import {migrate} from '#/state/persisted/legacy'
|
import {migrate} from '#/state/persisted/legacy'
|
||||||
import * as store from '#/state/persisted/store'
|
import * as store from '#/state/persisted/store'
|
||||||
import BroadcastChannel from '#/state/persisted/broadcast'
|
import BroadcastChannel from '#/lib/broadcast'
|
||||||
|
|
||||||
export type {Schema, PersistedAccount} from '#/state/persisted/schema'
|
export type {Schema, PersistedAccount} from '#/state/persisted/schema'
|
||||||
export {defaults} from '#/state/persisted/schema'
|
export {defaults} from '#/state/persisted/schema'
|
||||||
|
|
|
@ -0,0 +1,212 @@
|
||||||
|
import {
|
||||||
|
AppBskyFeedDefs,
|
||||||
|
AppBskyFeedPost,
|
||||||
|
AppBskyFeedRepost,
|
||||||
|
AppBskyFeedLike,
|
||||||
|
AppBskyNotificationListNotifications,
|
||||||
|
BskyAgent,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import chunk from 'lodash.chunk'
|
||||||
|
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
|
||||||
|
import {useSession} from '../../session'
|
||||||
|
import {useModerationOpts} from '../preferences'
|
||||||
|
import {shouldFilterNotif} from './util'
|
||||||
|
import {useMutedThreads} from '#/state/muted-threads'
|
||||||
|
|
||||||
|
const GROUPABLE_REASONS = ['like', 'repost', 'follow']
|
||||||
|
const PAGE_SIZE = 30
|
||||||
|
const MS_1HR = 1e3 * 60 * 60
|
||||||
|
const MS_2DAY = MS_1HR * 48
|
||||||
|
|
||||||
|
type RQPageParam = string | undefined
|
||||||
|
type NotificationType =
|
||||||
|
| 'post-like'
|
||||||
|
| 'feedgen-like'
|
||||||
|
| 'repost'
|
||||||
|
| 'mention'
|
||||||
|
| 'reply'
|
||||||
|
| 'quote'
|
||||||
|
| 'follow'
|
||||||
|
| 'unknown'
|
||||||
|
|
||||||
|
export function RQKEY() {
|
||||||
|
return ['notification-feed']
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedNotification {
|
||||||
|
_reactKey: string
|
||||||
|
type: NotificationType
|
||||||
|
notification: AppBskyNotificationListNotifications.Notification
|
||||||
|
additional?: AppBskyNotificationListNotifications.Notification[]
|
||||||
|
subjectUri?: string
|
||||||
|
subject?: AppBskyFeedDefs.PostView
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedPage {
|
||||||
|
cursor: string | undefined
|
||||||
|
items: FeedNotification[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
|
||||||
|
const {agent} = useSession()
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
const threadMutes = useMutedThreads()
|
||||||
|
const enabled = opts?.enabled !== false
|
||||||
|
|
||||||
|
return useInfiniteQuery<
|
||||||
|
FeedPage,
|
||||||
|
Error,
|
||||||
|
InfiniteData<FeedPage>,
|
||||||
|
QueryKey,
|
||||||
|
RQPageParam
|
||||||
|
>({
|
||||||
|
queryKey: RQKEY(),
|
||||||
|
async queryFn({pageParam}: {pageParam: RQPageParam}) {
|
||||||
|
const res = await agent.listNotifications({
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
cursor: pageParam,
|
||||||
|
})
|
||||||
|
|
||||||
|
// filter out notifs by mod rules
|
||||||
|
const notifs = res.data.notifications.filter(
|
||||||
|
notif => !shouldFilterNotif(notif, moderationOpts),
|
||||||
|
)
|
||||||
|
|
||||||
|
// group notifications which are essentially similar (follows, likes on a post)
|
||||||
|
let notifsGrouped = groupNotifications(notifs)
|
||||||
|
|
||||||
|
// we fetch subjects of notifications (usually posts) now instead of lazily
|
||||||
|
// in the UI to avoid relayouts
|
||||||
|
const subjects = await fetchSubjects(agent, notifsGrouped)
|
||||||
|
for (const notif of notifsGrouped) {
|
||||||
|
if (notif.subjectUri) {
|
||||||
|
notif.subject = subjects.get(notif.subjectUri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply thread muting
|
||||||
|
notifsGrouped = notifsGrouped.filter(
|
||||||
|
notif => !isThreadMuted(notif, threadMutes),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
cursor: res.data.cursor,
|
||||||
|
items: notifsGrouped,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
initialPageParam: undefined,
|
||||||
|
getNextPageParam: lastPage => lastPage.cursor,
|
||||||
|
enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupNotifications(
|
||||||
|
notifs: AppBskyNotificationListNotifications.Notification[],
|
||||||
|
): FeedNotification[] {
|
||||||
|
const groupedNotifs: FeedNotification[] = []
|
||||||
|
for (const notif of notifs) {
|
||||||
|
const ts = +new Date(notif.indexedAt)
|
||||||
|
let grouped = false
|
||||||
|
if (GROUPABLE_REASONS.includes(notif.reason)) {
|
||||||
|
for (const groupedNotif of groupedNotifs) {
|
||||||
|
const ts2 = +new Date(groupedNotif.notification.indexedAt)
|
||||||
|
if (
|
||||||
|
Math.abs(ts2 - ts) < MS_2DAY &&
|
||||||
|
notif.reason === groupedNotif.notification.reason &&
|
||||||
|
notif.reasonSubject === groupedNotif.notification.reasonSubject &&
|
||||||
|
notif.author.did !== groupedNotif.notification.author.did
|
||||||
|
) {
|
||||||
|
groupedNotif.additional = groupedNotif.additional || []
|
||||||
|
groupedNotif.additional.push(notif)
|
||||||
|
grouped = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!grouped) {
|
||||||
|
const type = toKnownType(notif)
|
||||||
|
groupedNotifs.push({
|
||||||
|
_reactKey: `notif-${notif.uri}`,
|
||||||
|
type,
|
||||||
|
notification: notif,
|
||||||
|
subjectUri: getSubjectUri(type, notif),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groupedNotifs
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSubjects(
|
||||||
|
agent: BskyAgent,
|
||||||
|
groupedNotifs: FeedNotification[],
|
||||||
|
): Promise<Map<string, AppBskyFeedDefs.PostView>> {
|
||||||
|
const uris = new Set<string>()
|
||||||
|
for (const notif of groupedNotifs) {
|
||||||
|
if (notif.subjectUri) {
|
||||||
|
uris.add(notif.subjectUri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const uriChunks = chunk(Array.from(uris), 25)
|
||||||
|
const postsChunks = await Promise.all(
|
||||||
|
uriChunks.map(uris =>
|
||||||
|
agent.app.bsky.feed.getPosts({uris}).then(res => res.data.posts),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const map = new Map<string, AppBskyFeedDefs.PostView>()
|
||||||
|
for (const post of postsChunks.flat()) {
|
||||||
|
if (
|
||||||
|
AppBskyFeedPost.isRecord(post.record) &&
|
||||||
|
AppBskyFeedPost.validateRecord(post.record).success
|
||||||
|
) {
|
||||||
|
map.set(post.uri, post)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
function toKnownType(
|
||||||
|
notif: AppBskyNotificationListNotifications.Notification,
|
||||||
|
): NotificationType {
|
||||||
|
if (notif.reason === 'like') {
|
||||||
|
if (notif.reasonSubject?.includes('feed.generator')) {
|
||||||
|
return 'feedgen-like'
|
||||||
|
}
|
||||||
|
return 'post-like'
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
notif.reason === 'repost' ||
|
||||||
|
notif.reason === 'mention' ||
|
||||||
|
notif.reason === 'reply' ||
|
||||||
|
notif.reason === 'quote' ||
|
||||||
|
notif.reason === 'follow'
|
||||||
|
) {
|
||||||
|
return notif.reason as NotificationType
|
||||||
|
}
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSubjectUri(
|
||||||
|
type: NotificationType,
|
||||||
|
notif: AppBskyNotificationListNotifications.Notification,
|
||||||
|
): string | undefined {
|
||||||
|
if (type === 'reply' || type === 'quote' || type === 'mention') {
|
||||||
|
return notif.uri
|
||||||
|
} else if (type === 'post-like' || type === 'repost') {
|
||||||
|
if (
|
||||||
|
AppBskyFeedRepost.isRecord(notif.record) ||
|
||||||
|
AppBskyFeedLike.isRecord(notif.record)
|
||||||
|
) {
|
||||||
|
return typeof notif.record.subject?.uri === 'string'
|
||||||
|
? notif.record.subject?.uri
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isThreadMuted(notif: FeedNotification, mutes: string[]): boolean {
|
||||||
|
if (!notif.subject) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const record = notif.subject.record as AppBskyFeedPost.Record // assured in fetchSubjects()
|
||||||
|
return mutes.includes(record.reply?.root.uri || notif.subject.uri)
|
||||||
|
}
|
|
@ -0,0 +1,113 @@
|
||||||
|
import React from 'react'
|
||||||
|
import * as Notifications from 'expo-notifications'
|
||||||
|
import BroadcastChannel from '#/lib/broadcast'
|
||||||
|
import {useSession} from '#/state/session'
|
||||||
|
import {useModerationOpts} from '../preferences'
|
||||||
|
import {shouldFilterNotif} from './util'
|
||||||
|
import {isNative} from '#/platform/detection'
|
||||||
|
|
||||||
|
const UPDATE_INTERVAL = 30 * 1e3 // 30sec
|
||||||
|
|
||||||
|
const broadcast = new BroadcastChannel('NOTIFS_BROADCAST_CHANNEL')
|
||||||
|
|
||||||
|
type StateContext = string
|
||||||
|
|
||||||
|
interface ApiContext {
|
||||||
|
markAllRead: () => Promise<void>
|
||||||
|
checkUnread: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateContext = React.createContext<StateContext>('')
|
||||||
|
|
||||||
|
const apiContext = React.createContext<ApiContext>({
|
||||||
|
async markAllRead() {},
|
||||||
|
async checkUnread() {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
|
const {hasSession, agent} = useSession()
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
|
||||||
|
const [numUnread, setNumUnread] = React.useState('')
|
||||||
|
|
||||||
|
const checkUnreadRef = React.useRef<(() => Promise<void>) | null>(null)
|
||||||
|
const lastSyncRef = React.useRef<Date>(new Date())
|
||||||
|
|
||||||
|
// periodic sync
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!hasSession || !checkUnreadRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
checkUnreadRef.current() // fire on init
|
||||||
|
const interval = setInterval(checkUnreadRef.current, UPDATE_INTERVAL)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [hasSession])
|
||||||
|
|
||||||
|
// listen for broadcasts
|
||||||
|
React.useEffect(() => {
|
||||||
|
const listener = ({data}: MessageEvent) => {
|
||||||
|
lastSyncRef.current = new Date()
|
||||||
|
setNumUnread(data.event)
|
||||||
|
}
|
||||||
|
broadcast.addEventListener('message', listener)
|
||||||
|
return () => {
|
||||||
|
broadcast.removeEventListener('message', listener)
|
||||||
|
}
|
||||||
|
}, [setNumUnread])
|
||||||
|
|
||||||
|
// create API
|
||||||
|
const api = React.useMemo<ApiContext>(() => {
|
||||||
|
return {
|
||||||
|
async markAllRead() {
|
||||||
|
// update server
|
||||||
|
await agent.updateSeenNotifications(lastSyncRef.current.toISOString())
|
||||||
|
|
||||||
|
// update & broadcast
|
||||||
|
setNumUnread('')
|
||||||
|
broadcast.postMessage({event: ''})
|
||||||
|
},
|
||||||
|
|
||||||
|
async checkUnread() {
|
||||||
|
// count
|
||||||
|
const res = await agent.listNotifications({limit: 40})
|
||||||
|
const filtered = res.data.notifications.filter(
|
||||||
|
notif => !notif.isRead && !shouldFilterNotif(notif, moderationOpts),
|
||||||
|
)
|
||||||
|
const num =
|
||||||
|
filtered.length >= 30
|
||||||
|
? '30+'
|
||||||
|
: filtered.length === 0
|
||||||
|
? ''
|
||||||
|
: String(filtered.length)
|
||||||
|
if (isNative) {
|
||||||
|
Notifications.setBadgeCountAsync(Math.min(filtered.length, 30))
|
||||||
|
}
|
||||||
|
|
||||||
|
// track last sync
|
||||||
|
const now = new Date()
|
||||||
|
const lastIndexed = filtered[0] && new Date(filtered[0].indexedAt)
|
||||||
|
lastSyncRef.current =
|
||||||
|
!lastIndexed || now > lastIndexed ? now : lastIndexed
|
||||||
|
|
||||||
|
// update & broadcast
|
||||||
|
setNumUnread(num)
|
||||||
|
broadcast.postMessage({event: num})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}, [setNumUnread, agent, moderationOpts])
|
||||||
|
checkUnreadRef.current = api.checkUnread
|
||||||
|
|
||||||
|
return (
|
||||||
|
<stateContext.Provider value={numUnread}>
|
||||||
|
<apiContext.Provider value={api}>{children}</apiContext.Provider>
|
||||||
|
</stateContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUnreadNotifications() {
|
||||||
|
return React.useContext(stateContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUnreadNotificationsApi() {
|
||||||
|
return React.useContext(apiContext)
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
import {
|
||||||
|
AppBskyNotificationListNotifications,
|
||||||
|
ModerationOpts,
|
||||||
|
moderateProfile,
|
||||||
|
moderatePost,
|
||||||
|
} from '@atproto/api'
|
||||||
|
|
||||||
|
// TODO this should be in the sdk as moderateNotification -prf
|
||||||
|
export function shouldFilterNotif(
|
||||||
|
notif: AppBskyNotificationListNotifications.Notification,
|
||||||
|
moderationOpts: ModerationOpts | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (!moderationOpts) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const profile = moderateProfile(notif.author, moderationOpts)
|
||||||
|
if (
|
||||||
|
profile.account.filter ||
|
||||||
|
profile.profile.filter ||
|
||||||
|
notif.author.viewer?.muted
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
notif.type === 'reply' ||
|
||||||
|
notif.type === 'quote' ||
|
||||||
|
notif.type === 'mention'
|
||||||
|
) {
|
||||||
|
// NOTE: the notification overlaps the post enough for this to work
|
||||||
|
const post = moderatePost(notif, moderationOpts)
|
||||||
|
if (post.content.filter) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TODO: thread muting is not being applied
|
||||||
|
// (this requires fetching the post)
|
||||||
|
return false
|
||||||
|
}
|
|
@ -1,5 +1,11 @@
|
||||||
|
import {useEffect, useState} from 'react'
|
||||||
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
|
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
|
||||||
import {LabelPreference, BskyFeedViewPreference} from '@atproto/api'
|
import {
|
||||||
|
LabelPreference,
|
||||||
|
BskyFeedViewPreference,
|
||||||
|
ModerationOpts,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import isEqual from 'lodash.isequal'
|
||||||
|
|
||||||
import {track} from '#/lib/analytics/analytics'
|
import {track} from '#/lib/analytics/analytics'
|
||||||
import {getAge} from '#/lib/strings/time'
|
import {getAge} from '#/lib/strings/time'
|
||||||
|
@ -15,6 +21,7 @@ import {
|
||||||
DEFAULT_HOME_FEED_PREFS,
|
DEFAULT_HOME_FEED_PREFS,
|
||||||
DEFAULT_THREAD_VIEW_PREFS,
|
DEFAULT_THREAD_VIEW_PREFS,
|
||||||
} from '#/state/queries/preferences/const'
|
} from '#/state/queries/preferences/const'
|
||||||
|
import {getModerationOpts} from '#/state/queries/preferences/moderation'
|
||||||
|
|
||||||
export * from '#/state/queries/preferences/types'
|
export * from '#/state/queries/preferences/types'
|
||||||
export * from '#/state/queries/preferences/moderation'
|
export * from '#/state/queries/preferences/moderation'
|
||||||
|
@ -23,7 +30,7 @@ export * from '#/state/queries/preferences/const'
|
||||||
export const usePreferencesQueryKey = ['getPreferences']
|
export const usePreferencesQueryKey = ['getPreferences']
|
||||||
|
|
||||||
export function usePreferencesQuery() {
|
export function usePreferencesQuery() {
|
||||||
const {agent} = useSession()
|
const {agent, hasSession} = useSession()
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: usePreferencesQueryKey,
|
queryKey: usePreferencesQueryKey,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
@ -76,9 +83,30 @@ export function usePreferencesQuery() {
|
||||||
}
|
}
|
||||||
return preferences
|
return preferences
|
||||||
},
|
},
|
||||||
|
enabled: hasSession,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useModerationOpts() {
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const [opts, setOpts] = useState<ModerationOpts | undefined>()
|
||||||
|
const prefs = usePreferencesQuery()
|
||||||
|
useEffect(() => {
|
||||||
|
if (!prefs.data) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// only update this hook when the moderation options change
|
||||||
|
const newOpts = getModerationOpts({
|
||||||
|
userDid: currentAccount?.did || '',
|
||||||
|
preferences: prefs.data,
|
||||||
|
})
|
||||||
|
if (!isEqual(opts, newOpts)) {
|
||||||
|
setOpts(newOpts)
|
||||||
|
}
|
||||||
|
}, [prefs.data, currentAccount, opts, setOpts])
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
export function useClearPreferencesMutation() {
|
export function useClearPreferencesMutation() {
|
||||||
const {agent} = useSession()
|
const {agent} = useSession()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
import React, {MutableRefObject} from 'react'
|
import React, {MutableRefObject} from 'react'
|
||||||
import {observer} from 'mobx-react-lite'
|
|
||||||
import {CenteredView, FlatList} from '../util/Views'
|
import {CenteredView, FlatList} from '../util/Views'
|
||||||
import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
|
import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
|
||||||
import {NotificationsFeedModel} from 'state/models/feeds/notifications'
|
|
||||||
import {FeedItem} from './FeedItem'
|
import {FeedItem} from './FeedItem'
|
||||||
import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
|
import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
|
||||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||||
|
@ -12,20 +10,22 @@ import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
|
||||||
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
|
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
|
||||||
import {s} from 'lib/styles'
|
import {s} from 'lib/styles'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {useNotificationFeedQuery} from '#/state/queries/notifications/feed'
|
||||||
|
import {useUnreadNotificationsApi} from '#/state/queries/notifications/unread'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
|
import {useModerationOpts} from '#/state/queries/preferences'
|
||||||
|
|
||||||
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
|
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
|
||||||
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
|
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
|
||||||
const LOADING_SPINNER = {_reactKey: '__loading_spinner__'}
|
const LOADING_ITEM = {_reactKey: '__loading__'}
|
||||||
|
|
||||||
export const Feed = observer(function Feed({
|
export function Feed({
|
||||||
view,
|
|
||||||
scrollElRef,
|
scrollElRef,
|
||||||
onPressTryAgain,
|
onPressTryAgain,
|
||||||
onScroll,
|
onScroll,
|
||||||
ListHeaderComponent,
|
ListHeaderComponent,
|
||||||
}: {
|
}: {
|
||||||
view: NotificationsFeedModel
|
|
||||||
scrollElRef?: MutableRefObject<FlatList<any> | null>
|
scrollElRef?: MutableRefObject<FlatList<any> | null>
|
||||||
onPressTryAgain?: () => void
|
onPressTryAgain?: () => void
|
||||||
onScroll?: OnScrollHandler
|
onScroll?: OnScrollHandler
|
||||||
|
@ -33,35 +33,54 @@ export const Feed = observer(function Feed({
|
||||||
}) {
|
}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||||
const data = React.useMemo(() => {
|
|
||||||
let feedItems: any[] = []
|
const moderationOpts = useModerationOpts()
|
||||||
if (view.isRefreshing && !isPTRing) {
|
const {markAllRead} = useUnreadNotificationsApi()
|
||||||
feedItems = [LOADING_SPINNER]
|
const {
|
||||||
|
data,
|
||||||
|
dataUpdatedAt,
|
||||||
|
isFetching,
|
||||||
|
isFetched,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
} = useNotificationFeedQuery({enabled: !!moderationOpts})
|
||||||
|
const isEmpty = !isFetching && !data?.pages[0]?.items.length
|
||||||
|
const firstItem = data?.pages[0]?.items[0]
|
||||||
|
|
||||||
|
// mark all read on fresh data
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (firstItem) {
|
||||||
|
markAllRead()
|
||||||
}
|
}
|
||||||
if (view.hasLoaded) {
|
}, [firstItem, markAllRead])
|
||||||
if (view.isEmpty) {
|
|
||||||
feedItems = feedItems.concat([EMPTY_FEED_ITEM])
|
const items = React.useMemo(() => {
|
||||||
} else {
|
let arr: any[] = []
|
||||||
feedItems = feedItems.concat(view.notifications)
|
if (isFetched) {
|
||||||
|
if (isEmpty) {
|
||||||
|
arr = arr.concat([EMPTY_FEED_ITEM])
|
||||||
|
} else if (data) {
|
||||||
|
for (const page of data?.pages) {
|
||||||
|
arr = arr.concat(page.items)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (isError && !isEmpty) {
|
||||||
|
arr = arr.concat([LOAD_MORE_ERROR_ITEM])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
arr.push(LOADING_ITEM)
|
||||||
}
|
}
|
||||||
if (view.loadMoreError) {
|
return arr
|
||||||
feedItems = (feedItems || []).concat([LOAD_MORE_ERROR_ITEM])
|
}, [isFetched, isError, isEmpty, data])
|
||||||
}
|
|
||||||
return feedItems
|
|
||||||
}, [
|
|
||||||
view.hasLoaded,
|
|
||||||
view.isEmpty,
|
|
||||||
view.notifications,
|
|
||||||
view.loadMoreError,
|
|
||||||
view.isRefreshing,
|
|
||||||
isPTRing,
|
|
||||||
])
|
|
||||||
|
|
||||||
const onRefresh = React.useCallback(async () => {
|
const onRefresh = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setIsPTRing(true)
|
setIsPTRing(true)
|
||||||
await view.refresh()
|
await refetch()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to refresh notifications feed', {
|
logger.error('Failed to refresh notifications feed', {
|
||||||
error: err,
|
error: err,
|
||||||
|
@ -69,21 +88,21 @@ export const Feed = observer(function Feed({
|
||||||
} finally {
|
} finally {
|
||||||
setIsPTRing(false)
|
setIsPTRing(false)
|
||||||
}
|
}
|
||||||
}, [view, setIsPTRing])
|
}, [refetch, setIsPTRing])
|
||||||
|
|
||||||
const onEndReached = React.useCallback(async () => {
|
const onEndReached = React.useCallback(async () => {
|
||||||
|
if (isFetching || !hasNextPage || isError) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await view.loadMore()
|
await fetchNextPage()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to load more notifications', {
|
logger.error('Failed to load more notifications', {error: err})
|
||||||
error: err,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}, [view])
|
}, [isFetching, hasNextPage, isError, fetchNextPage])
|
||||||
|
|
||||||
const onPressRetryLoadMore = React.useCallback(() => {
|
const onPressRetryLoadMore = React.useCallback(() => {
|
||||||
view.retryLoadMore()
|
fetchNextPage()
|
||||||
}, [view])
|
}, [fetchNextPage])
|
||||||
|
|
||||||
// TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf
|
// TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf
|
||||||
// VirtualizedList: You have a large list that is slow to update - make sure your
|
// VirtualizedList: You have a large list that is slow to update - make sure your
|
||||||
|
@ -106,78 +125,72 @@ export const Feed = observer(function Feed({
|
||||||
onPress={onPressRetryLoadMore}
|
onPress={onPressRetryLoadMore}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
} else if (item === LOADING_SPINNER) {
|
} else if (item === LOADING_ITEM) {
|
||||||
return (
|
return <NotificationFeedLoadingPlaceholder />
|
||||||
<View style={styles.loading}>
|
|
||||||
<ActivityIndicator size="small" />
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return <FeedItem item={item} />
|
return (
|
||||||
|
<FeedItem
|
||||||
|
item={item}
|
||||||
|
dataUpdatedAt={dataUpdatedAt}
|
||||||
|
moderationOpts={moderationOpts!}
|
||||||
|
/>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
[onPressRetryLoadMore],
|
[onPressRetryLoadMore, dataUpdatedAt, moderationOpts],
|
||||||
)
|
)
|
||||||
|
|
||||||
const FeedFooter = React.useCallback(
|
const FeedFooter = React.useCallback(
|
||||||
() =>
|
() =>
|
||||||
view.isLoading ? (
|
isFetchingNextPage ? (
|
||||||
<View style={styles.feedFooter}>
|
<View style={styles.feedFooter}>
|
||||||
<ActivityIndicator />
|
<ActivityIndicator />
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View />
|
<View />
|
||||||
),
|
),
|
||||||
[view],
|
[isFetchingNextPage],
|
||||||
)
|
)
|
||||||
|
|
||||||
const scrollHandler = useAnimatedScrollHandler(onScroll || {})
|
const scrollHandler = useAnimatedScrollHandler(onScroll || {})
|
||||||
return (
|
return (
|
||||||
<View style={s.hContentRegion}>
|
<View style={s.hContentRegion}>
|
||||||
<CenteredView>
|
{error && (
|
||||||
{view.isLoading && !data.length && (
|
<CenteredView>
|
||||||
<NotificationFeedLoadingPlaceholder />
|
|
||||||
)}
|
|
||||||
{view.hasError && (
|
|
||||||
<ErrorMessage
|
<ErrorMessage
|
||||||
message={view.error}
|
message={cleanError(error)}
|
||||||
onPressTryAgain={onPressTryAgain}
|
onPressTryAgain={onPressTryAgain}
|
||||||
/>
|
/>
|
||||||
)}
|
</CenteredView>
|
||||||
</CenteredView>
|
)}
|
||||||
{data.length ? (
|
<FlatList
|
||||||
<FlatList
|
testID="notifsFeed"
|
||||||
testID="notifsFeed"
|
ref={scrollElRef}
|
||||||
ref={scrollElRef}
|
data={items}
|
||||||
data={data}
|
keyExtractor={item => item._reactKey}
|
||||||
keyExtractor={item => item._reactKey}
|
renderItem={renderItem}
|
||||||
renderItem={renderItem}
|
ListHeaderComponent={ListHeaderComponent}
|
||||||
ListHeaderComponent={ListHeaderComponent}
|
ListFooterComponent={FeedFooter}
|
||||||
ListFooterComponent={FeedFooter}
|
refreshControl={
|
||||||
refreshControl={
|
<RefreshControl
|
||||||
<RefreshControl
|
refreshing={isPTRing}
|
||||||
refreshing={isPTRing}
|
onRefresh={onRefresh}
|
||||||
onRefresh={onRefresh}
|
tintColor={pal.colors.text}
|
||||||
tintColor={pal.colors.text}
|
titleColor={pal.colors.text}
|
||||||
titleColor={pal.colors.text}
|
/>
|
||||||
/>
|
}
|
||||||
}
|
onEndReached={onEndReached}
|
||||||
onEndReached={onEndReached}
|
onEndReachedThreshold={0.6}
|
||||||
onEndReachedThreshold={0.6}
|
onScroll={scrollHandler}
|
||||||
onScroll={scrollHandler}
|
scrollEventThrottle={1}
|
||||||
scrollEventThrottle={1}
|
contentContainerStyle={s.contentContainer}
|
||||||
contentContainerStyle={s.contentContainer}
|
// @ts-ignore our .web version only -prf
|
||||||
// @ts-ignore our .web version only -prf
|
desktopFixedHeight
|
||||||
desktopFixedHeight
|
/>
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
loading: {
|
|
||||||
paddingVertical: 20,
|
|
||||||
},
|
|
||||||
feedFooter: {paddingTop: 20},
|
feedFooter: {paddingTop: 20},
|
||||||
emptyState: {paddingVertical: 40},
|
emptyState: {paddingVertical: 40},
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import React, {useMemo, useState, useEffect} from 'react'
|
import React, {useMemo, useState, useEffect} from 'react'
|
||||||
import {observer} from 'mobx-react-lite'
|
|
||||||
import {
|
import {
|
||||||
Animated,
|
Animated,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
|
@ -9,6 +8,9 @@ import {
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {
|
import {
|
||||||
AppBskyEmbedImages,
|
AppBskyEmbedImages,
|
||||||
|
AppBskyFeedDefs,
|
||||||
|
AppBskyFeedPost,
|
||||||
|
ModerationOpts,
|
||||||
ProfileModeration,
|
ProfileModeration,
|
||||||
moderateProfile,
|
moderateProfile,
|
||||||
AppBskyEmbedRecordWithMedia,
|
AppBskyEmbedRecordWithMedia,
|
||||||
|
@ -19,8 +21,7 @@ import {
|
||||||
FontAwesomeIconStyle,
|
FontAwesomeIconStyle,
|
||||||
Props,
|
Props,
|
||||||
} from '@fortawesome/react-native-fontawesome'
|
} from '@fortawesome/react-native-fontawesome'
|
||||||
import {NotificationsFeedItemModel} from 'state/models/feeds/notifications'
|
import {FeedNotification} from '#/state/queries/notifications/feed'
|
||||||
import {PostThreadModel} from 'state/models/content/post-thread'
|
|
||||||
import {s, colors} from 'lib/styles'
|
import {s, colors} from 'lib/styles'
|
||||||
import {niceDate} from 'lib/strings/time'
|
import {niceDate} from 'lib/strings/time'
|
||||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||||
|
@ -33,7 +34,6 @@ import {UserPreviewLink} from '../util/UserPreviewLink'
|
||||||
import {ImageHorzList} from '../util/images/ImageHorzList'
|
import {ImageHorzList} from '../util/images/ImageHorzList'
|
||||||
import {Post} from '../post/Post'
|
import {Post} from '../post/Post'
|
||||||
import {Link, TextLink} from '../util/Link'
|
import {Link, TextLink} from '../util/Link'
|
||||||
import {useStores} from 'state/index'
|
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||||
import {formatCount} from '../util/numeric/format'
|
import {formatCount} from '../util/numeric/format'
|
||||||
|
@ -56,40 +56,36 @@ interface Author {
|
||||||
moderation: ProfileModeration
|
moderation: ProfileModeration
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FeedItem = observer(function FeedItemImpl({
|
export function FeedItem({
|
||||||
item,
|
item,
|
||||||
|
dataUpdatedAt,
|
||||||
|
moderationOpts,
|
||||||
}: {
|
}: {
|
||||||
item: NotificationsFeedItemModel
|
item: FeedNotification
|
||||||
|
dataUpdatedAt: number
|
||||||
|
moderationOpts: ModerationOpts
|
||||||
}) {
|
}) {
|
||||||
const store = useStores()
|
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false)
|
const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false)
|
||||||
const itemHref = useMemo(() => {
|
const itemHref = useMemo(() => {
|
||||||
if (item.isLike || item.isRepost) {
|
if (item.type === 'post-like' || item.type === 'repost') {
|
||||||
const urip = new AtUri(item.subjectUri)
|
if (item.subjectUri) {
|
||||||
|
const urip = new AtUri(item.subjectUri)
|
||||||
|
return `/profile/${urip.host}/post/${urip.rkey}`
|
||||||
|
}
|
||||||
|
} else if (item.type === 'follow') {
|
||||||
|
return makeProfileLink(item.notification.author)
|
||||||
|
} else if (item.type === 'reply') {
|
||||||
|
const urip = new AtUri(item.notification.uri)
|
||||||
return `/profile/${urip.host}/post/${urip.rkey}`
|
return `/profile/${urip.host}/post/${urip.rkey}`
|
||||||
} else if (item.isFollow) {
|
} else if (item.type === 'feedgen-like') {
|
||||||
return makeProfileLink(item.author)
|
if (item.subjectUri) {
|
||||||
} else if (item.isReply) {
|
const urip = new AtUri(item.subjectUri)
|
||||||
const urip = new AtUri(item.uri)
|
return `/profile/${urip.host}/feed/${urip.rkey}`
|
||||||
return `/profile/${urip.host}/post/${urip.rkey}`
|
}
|
||||||
} else if (item.isCustomFeedLike) {
|
|
||||||
const urip = new AtUri(item.subjectUri)
|
|
||||||
return `/profile/${urip.host}/feed/${urip.rkey}`
|
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
}, [item])
|
}, [item])
|
||||||
const itemTitle = useMemo(() => {
|
|
||||||
if (item.isLike || item.isRepost) {
|
|
||||||
return 'Post'
|
|
||||||
} else if (item.isFollow) {
|
|
||||||
return item.author.handle
|
|
||||||
} else if (item.isReply) {
|
|
||||||
return 'Post'
|
|
||||||
} else if (item.isCustomFeedLike) {
|
|
||||||
return 'Custom Feed'
|
|
||||||
}
|
|
||||||
}, [item])
|
|
||||||
|
|
||||||
const onToggleAuthorsExpanded = () => {
|
const onToggleAuthorsExpanded = () => {
|
||||||
setAuthorsExpanded(currentlyExpanded => !currentlyExpanded)
|
setAuthorsExpanded(currentlyExpanded => !currentlyExpanded)
|
||||||
|
@ -98,15 +94,12 @@ export const FeedItem = observer(function FeedItemImpl({
|
||||||
const authors: Author[] = useMemo(() => {
|
const authors: Author[] = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
href: makeProfileLink(item.author),
|
href: makeProfileLink(item.notification.author),
|
||||||
did: item.author.did,
|
did: item.notification.author.did,
|
||||||
handle: item.author.handle,
|
handle: item.notification.author.handle,
|
||||||
displayName: item.author.displayName,
|
displayName: item.notification.author.displayName,
|
||||||
avatar: item.author.avatar,
|
avatar: item.notification.author.avatar,
|
||||||
moderation: moderateProfile(
|
moderation: moderateProfile(item.notification.author, moderationOpts),
|
||||||
item.author,
|
|
||||||
store.preferences.moderationOpts,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
...(item.additional?.map(({author}) => {
|
...(item.additional?.map(({author}) => {
|
||||||
return {
|
return {
|
||||||
|
@ -115,33 +108,36 @@ export const FeedItem = observer(function FeedItemImpl({
|
||||||
handle: author.handle,
|
handle: author.handle,
|
||||||
displayName: author.displayName,
|
displayName: author.displayName,
|
||||||
avatar: author.avatar,
|
avatar: author.avatar,
|
||||||
moderation: moderateProfile(author, store.preferences.moderationOpts),
|
moderation: moderateProfile(author, moderationOpts),
|
||||||
}
|
}
|
||||||
}) || []),
|
}) || []),
|
||||||
]
|
]
|
||||||
}, [store, item.additional, item.author])
|
}, [item, moderationOpts])
|
||||||
|
|
||||||
if (item.additionalPost?.notFound) {
|
if (item.subjectUri && !item.subject) {
|
||||||
// don't render anything if the target post was deleted or unfindable
|
// don't render anything if the target post was deleted or unfindable
|
||||||
return <View />
|
return <View />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.isReply || item.isMention || item.isQuote) {
|
if (
|
||||||
if (!item.additionalPost || item.additionalPost?.error) {
|
item.type === 'reply' ||
|
||||||
// hide errors - it doesnt help the user to show them
|
item.type === 'mention' ||
|
||||||
return <View />
|
item.type === 'quote'
|
||||||
|
) {
|
||||||
|
if (!item.subject) {
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
testID={`feedItem-by-${item.author.handle}`}
|
testID={`feedItem-by-${item.notification.author.handle}`}
|
||||||
href={itemHref}
|
href={itemHref}
|
||||||
title={itemTitle}
|
|
||||||
noFeedback
|
noFeedback
|
||||||
accessible={false}>
|
accessible={false}>
|
||||||
<Post
|
<Post
|
||||||
view={item.additionalPost}
|
post={item.subject}
|
||||||
|
dataUpdatedAt={dataUpdatedAt}
|
||||||
style={
|
style={
|
||||||
item.isRead
|
item.notification.isRead
|
||||||
? undefined
|
? undefined
|
||||||
: {
|
: {
|
||||||
backgroundColor: pal.colors.unreadNotifBg,
|
backgroundColor: pal.colors.unreadNotifBg,
|
||||||
|
@ -156,23 +152,25 @@ export const FeedItem = observer(function FeedItemImpl({
|
||||||
let action = ''
|
let action = ''
|
||||||
let icon: Props['icon'] | 'HeartIconSolid'
|
let icon: Props['icon'] | 'HeartIconSolid'
|
||||||
let iconStyle: Props['style'] = []
|
let iconStyle: Props['style'] = []
|
||||||
if (item.isLike) {
|
if (item.type === 'post-like') {
|
||||||
action = 'liked your post'
|
action = 'liked your post'
|
||||||
icon = 'HeartIconSolid'
|
icon = 'HeartIconSolid'
|
||||||
iconStyle = [
|
iconStyle = [
|
||||||
s.likeColor as FontAwesomeIconStyle,
|
s.likeColor as FontAwesomeIconStyle,
|
||||||
{position: 'relative', top: -4},
|
{position: 'relative', top: -4},
|
||||||
]
|
]
|
||||||
} else if (item.isRepost) {
|
} else if (item.type === 'repost') {
|
||||||
action = 'reposted your post'
|
action = 'reposted your post'
|
||||||
icon = 'retweet'
|
icon = 'retweet'
|
||||||
iconStyle = [s.green3 as FontAwesomeIconStyle]
|
iconStyle = [s.green3 as FontAwesomeIconStyle]
|
||||||
} else if (item.isFollow) {
|
} else if (item.type === 'follow') {
|
||||||
action = 'followed you'
|
action = 'followed you'
|
||||||
icon = 'user-plus'
|
icon = 'user-plus'
|
||||||
iconStyle = [s.blue3 as FontAwesomeIconStyle]
|
iconStyle = [s.blue3 as FontAwesomeIconStyle]
|
||||||
} else if (item.isCustomFeedLike) {
|
} else if (item.type === 'feedgen-like') {
|
||||||
action = `liked your custom feed '${new AtUri(item.subjectUri).rkey}'`
|
action = `liked your custom feed${
|
||||||
|
item.subjectUri ? ` '${new AtUri(item.subjectUri).rkey}}'` : ''
|
||||||
|
}`
|
||||||
icon = 'HeartIconSolid'
|
icon = 'HeartIconSolid'
|
||||||
iconStyle = [
|
iconStyle = [
|
||||||
s.likeColor as FontAwesomeIconStyle,
|
s.likeColor as FontAwesomeIconStyle,
|
||||||
|
@ -184,12 +182,12 @@ export const FeedItem = observer(function FeedItemImpl({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
testID={`feedItem-by-${item.author.handle}`}
|
testID={`feedItem-by-${item.notification.author.handle}`}
|
||||||
style={[
|
style={[
|
||||||
styles.outer,
|
styles.outer,
|
||||||
pal.view,
|
pal.view,
|
||||||
pal.border,
|
pal.border,
|
||||||
item.isRead
|
item.notification.isRead
|
||||||
? undefined
|
? undefined
|
||||||
: {
|
: {
|
||||||
backgroundColor: pal.colors.unreadNotifBg,
|
backgroundColor: pal.colors.unreadNotifBg,
|
||||||
|
@ -197,9 +195,11 @@ export const FeedItem = observer(function FeedItemImpl({
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
href={itemHref}
|
href={itemHref}
|
||||||
title={itemTitle}
|
|
||||||
noFeedback
|
noFeedback
|
||||||
accessible={(item.isLike && authors.length === 1) || item.isRepost}>
|
accessible={
|
||||||
|
(item.type === 'post-like' && authors.length === 1) ||
|
||||||
|
item.type === 'repost'
|
||||||
|
}>
|
||||||
<View style={styles.layoutIcon}>
|
<View style={styles.layoutIcon}>
|
||||||
{/* TODO: Prevent conditional rendering and move toward composable
|
{/* TODO: Prevent conditional rendering and move toward composable
|
||||||
notifications for clearer accessibility labeling */}
|
notifications for clearer accessibility labeling */}
|
||||||
|
@ -244,24 +244,24 @@ export const FeedItem = observer(function FeedItemImpl({
|
||||||
</>
|
</>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<Text style={[pal.text]}> {action}</Text>
|
<Text style={[pal.text]}> {action}</Text>
|
||||||
<TimeElapsed timestamp={item.indexedAt}>
|
<TimeElapsed timestamp={item.notification.indexedAt}>
|
||||||
{({timeElapsed}) => (
|
{({timeElapsed}) => (
|
||||||
<Text
|
<Text
|
||||||
style={[pal.textLight, styles.pointer]}
|
style={[pal.textLight, styles.pointer]}
|
||||||
title={niceDate(item.indexedAt)}>
|
title={niceDate(item.notification.indexedAt)}>
|
||||||
{' ' + timeElapsed}
|
{' ' + timeElapsed}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</TimeElapsed>
|
</TimeElapsed>
|
||||||
</Text>
|
</Text>
|
||||||
</ExpandListPressable>
|
</ExpandListPressable>
|
||||||
{item.isLike || item.isRepost || item.isQuote ? (
|
{item.type === 'post-like' || item.type === 'repost' ? (
|
||||||
<AdditionalPostText additionalPost={item.additionalPost} />
|
<AdditionalPostText post={item.subject} />
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|
||||||
function ExpandListPressable({
|
function ExpandListPressable({
|
||||||
hasMultipleAuthors,
|
hasMultipleAuthors,
|
||||||
|
@ -423,34 +423,25 @@ function ExpandedAuthorsList({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AdditionalPostText({
|
function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
|
||||||
additionalPost,
|
|
||||||
}: {
|
|
||||||
additionalPost?: PostThreadModel
|
|
||||||
}) {
|
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
if (
|
if (post && AppBskyFeedPost.isRecord(post?.record)) {
|
||||||
!additionalPost ||
|
const text = post.record.text
|
||||||
!additionalPost.thread?.postRecord ||
|
const images = AppBskyEmbedImages.isView(post.embed)
|
||||||
additionalPost.error
|
? post.embed.images
|
||||||
) {
|
: AppBskyEmbedRecordWithMedia.isView(post.embed) &&
|
||||||
return <View />
|
AppBskyEmbedImages.isView(post.embed.media)
|
||||||
|
? post.embed.media.images
|
||||||
|
: undefined
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{text?.length > 0 && <Text style={pal.textLight}>{text}</Text>}
|
||||||
|
{images && images?.length > 0 && (
|
||||||
|
<ImageHorzList images={images} style={styles.additionalPostImages} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
const text = additionalPost.thread?.postRecord.text
|
|
||||||
const images = AppBskyEmbedImages.isView(additionalPost.thread.post.embed)
|
|
||||||
? additionalPost.thread.post.embed.images
|
|
||||||
: AppBskyEmbedRecordWithMedia.isView(additionalPost.thread.post.embed) &&
|
|
||||||
AppBskyEmbedImages.isView(additionalPost.thread.post.embed.media)
|
|
||||||
? additionalPost.thread.post.embed.media.images
|
|
||||||
: undefined
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{text?.length > 0 && <Text style={pal.textLight}>{text}</Text>}
|
|
||||||
{images && images?.length > 0 && (
|
|
||||||
<ImageHorzList images={images} style={styles.additionalPostImages} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
|
|
@ -23,8 +23,8 @@ import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
import {PostMeta} from '../util/PostMeta'
|
import {PostMeta} from '../util/PostMeta'
|
||||||
import {PostEmbeds} from '../util/post-embeds'
|
import {PostEmbeds} from '../util/post-embeds'
|
||||||
import {PostCtrls} from '../util/post-ctrls/PostCtrls2'
|
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
|
||||||
import {PostDropdownBtn} from '../util/forms/PostDropdownBtn2'
|
import {PostDropdownBtn} from '../util/forms/PostDropdownBtn'
|
||||||
import {PostHider} from '../util/moderation/PostHider'
|
import {PostHider} from '../util/moderation/PostHider'
|
||||||
import {ContentHider} from '../util/moderation/ContentHider'
|
import {ContentHider} from '../util/moderation/ContentHider'
|
||||||
import {PostAlerts} from '../util/moderation/PostAlerts'
|
import {PostAlerts} from '../util/moderation/PostAlerts'
|
||||||
|
|
|
@ -1,19 +1,14 @@
|
||||||
import React, {useState} from 'react'
|
import React, {useState, useMemo} from 'react'
|
||||||
|
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
AppBskyFeedDefs,
|
||||||
Linking,
|
AppBskyFeedPost,
|
||||||
StyleProp,
|
AtUri,
|
||||||
StyleSheet,
|
moderatePost,
|
||||||
View,
|
PostModeration,
|
||||||
ViewStyle,
|
RichText as RichTextAPI,
|
||||||
} from 'react-native'
|
} from '@atproto/api'
|
||||||
import {AppBskyFeedPost as FeedPost} from '@atproto/api'
|
|
||||||
import {observer} from 'mobx-react-lite'
|
|
||||||
import Clipboard from '@react-native-clipboard/clipboard'
|
|
||||||
import {AtUri} from '@atproto/api'
|
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
import {PostThreadModel} from 'state/models/content/post-thread'
|
|
||||||
import {PostThreadItemModel} from 'state/models/content/post-thread-item'
|
|
||||||
import {Link, TextLink} from '../util/Link'
|
import {Link, TextLink} from '../util/Link'
|
||||||
import {UserInfoText} from '../util/UserInfoText'
|
import {UserInfoText} from '../util/UserInfoText'
|
||||||
import {PostMeta} from '../util/PostMeta'
|
import {PostMeta} from '../util/PostMeta'
|
||||||
|
@ -23,174 +18,111 @@ import {ContentHider} from '../util/moderation/ContentHider'
|
||||||
import {PostAlerts} from '../util/moderation/PostAlerts'
|
import {PostAlerts} from '../util/moderation/PostAlerts'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
import {RichText} from '../util/text/RichText'
|
import {RichText} from '../util/text/RichText'
|
||||||
import * as Toast from '../util/Toast'
|
|
||||||
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
import {s, colors} from 'lib/styles'
|
import {s, colors} from 'lib/styles'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {getTranslatorLink} from '../../../locale/helpers'
|
|
||||||
import {makeProfileLink} from 'lib/routes/links'
|
import {makeProfileLink} from 'lib/routes/links'
|
||||||
import {MAX_POST_LINES} from 'lib/constants'
|
import {MAX_POST_LINES} from 'lib/constants'
|
||||||
import {countLines} from 'lib/strings/helpers'
|
import {countLines} from 'lib/strings/helpers'
|
||||||
import {logger} from '#/logger'
|
import {useModerationOpts} from '#/state/queries/preferences'
|
||||||
import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
|
import {usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
|
||||||
import {useLanguagePrefs} from '#/state/preferences'
|
|
||||||
|
|
||||||
export const Post = observer(function PostImpl({
|
export function Post({
|
||||||
view,
|
post,
|
||||||
|
dataUpdatedAt,
|
||||||
showReplyLine,
|
showReplyLine,
|
||||||
hideError,
|
|
||||||
style,
|
style,
|
||||||
}: {
|
}: {
|
||||||
view: PostThreadModel
|
post: AppBskyFeedDefs.PostView
|
||||||
|
dataUpdatedAt: number
|
||||||
showReplyLine?: boolean
|
showReplyLine?: boolean
|
||||||
hideError?: boolean
|
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
}) {
|
}) {
|
||||||
const pal = usePalette('default')
|
const moderationOpts = useModerationOpts()
|
||||||
const [deleted, setDeleted] = useState(false)
|
const record = useMemo<AppBskyFeedPost.Record | undefined>(
|
||||||
|
() =>
|
||||||
// deleted
|
AppBskyFeedPost.isRecord(post.record) &&
|
||||||
// =
|
AppBskyFeedPost.validateRecord(post.record).success
|
||||||
if (deleted) {
|
? post.record
|
||||||
return <View />
|
: undefined,
|
||||||
}
|
[post],
|
||||||
|
|
||||||
// loading
|
|
||||||
// =
|
|
||||||
if (!view.hasContent && view.isLoading) {
|
|
||||||
return (
|
|
||||||
<View style={pal.view}>
|
|
||||||
<ActivityIndicator />
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// error
|
|
||||||
// =
|
|
||||||
if (view.hasError || !view.thread || !view.thread?.postRecord) {
|
|
||||||
if (hideError) {
|
|
||||||
return <View />
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<View style={pal.view}>
|
|
||||||
<Text>{view.error || 'Thread not found'}</Text>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// loaded
|
|
||||||
// =
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PostLoaded
|
|
||||||
item={view.thread}
|
|
||||||
record={view.thread.postRecord}
|
|
||||||
setDeleted={setDeleted}
|
|
||||||
showReplyLine={showReplyLine}
|
|
||||||
style={style}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
})
|
const postShadowed = usePostShadow(post, dataUpdatedAt)
|
||||||
|
const richText = useMemo(
|
||||||
|
() =>
|
||||||
|
record
|
||||||
|
? new RichTextAPI({
|
||||||
|
text: record.text,
|
||||||
|
facets: record.facets,
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
|
[record],
|
||||||
|
)
|
||||||
|
const moderation = useMemo(
|
||||||
|
() => (moderationOpts ? moderatePost(post, moderationOpts) : undefined),
|
||||||
|
[moderationOpts, post],
|
||||||
|
)
|
||||||
|
if (postShadowed === POST_TOMBSTONE) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (record && richText && moderation) {
|
||||||
|
return (
|
||||||
|
<PostInner
|
||||||
|
post={postShadowed}
|
||||||
|
record={record}
|
||||||
|
richText={richText}
|
||||||
|
moderation={moderation}
|
||||||
|
showReplyLine={showReplyLine}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const PostLoaded = observer(function PostLoadedImpl({
|
function PostInner({
|
||||||
item,
|
post,
|
||||||
record,
|
record,
|
||||||
setDeleted,
|
richText,
|
||||||
|
moderation,
|
||||||
showReplyLine,
|
showReplyLine,
|
||||||
style,
|
style,
|
||||||
}: {
|
}: {
|
||||||
item: PostThreadItemModel
|
post: AppBskyFeedDefs.PostView
|
||||||
record: FeedPost.Record
|
record: AppBskyFeedPost.Record
|
||||||
setDeleted: (v: boolean) => void
|
richText: RichTextAPI
|
||||||
|
moderation: PostModeration
|
||||||
showReplyLine?: boolean
|
showReplyLine?: boolean
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
}) {
|
}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const mutedThreads = useMutedThreads()
|
const [limitLines, setLimitLines] = useState(
|
||||||
const toggleThreadMute = useToggleThreadMute()
|
countLines(richText?.text) >= MAX_POST_LINES,
|
||||||
const langPrefs = useLanguagePrefs()
|
|
||||||
const [limitLines, setLimitLines] = React.useState(
|
|
||||||
countLines(item.richText?.text) >= MAX_POST_LINES,
|
|
||||||
)
|
)
|
||||||
const itemUri = item.post.uri
|
const itemUrip = new AtUri(post.uri)
|
||||||
const itemCid = item.post.cid
|
const itemHref = makeProfileLink(post.author, 'post', itemUrip.rkey)
|
||||||
const itemUrip = new AtUri(item.post.uri)
|
|
||||||
const itemHref = makeProfileLink(item.post.author, 'post', itemUrip.rkey)
|
|
||||||
const itemTitle = `Post by ${item.post.author.handle}`
|
|
||||||
let replyAuthorDid = ''
|
let replyAuthorDid = ''
|
||||||
if (record.reply) {
|
if (record.reply) {
|
||||||
const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri)
|
const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri)
|
||||||
replyAuthorDid = urip.hostname
|
replyAuthorDid = urip.hostname
|
||||||
}
|
}
|
||||||
|
|
||||||
const translatorUrl = getTranslatorLink(
|
|
||||||
record?.text || '',
|
|
||||||
langPrefs.primaryLanguage,
|
|
||||||
)
|
|
||||||
|
|
||||||
const onPressReply = React.useCallback(() => {
|
const onPressReply = React.useCallback(() => {
|
||||||
store.shell.openComposer({
|
store.shell.openComposer({
|
||||||
replyTo: {
|
replyTo: {
|
||||||
uri: item.post.uri,
|
uri: post.uri,
|
||||||
cid: item.post.cid,
|
cid: post.cid,
|
||||||
text: record.text as string,
|
text: record.text,
|
||||||
author: {
|
author: {
|
||||||
handle: item.post.author.handle,
|
handle: post.author.handle,
|
||||||
displayName: item.post.author.displayName,
|
displayName: post.author.displayName,
|
||||||
avatar: item.post.author.avatar,
|
avatar: post.author.avatar,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}, [store, item, record])
|
}, [store, post, record])
|
||||||
|
|
||||||
const onPressToggleRepost = React.useCallback(() => {
|
|
||||||
return item
|
|
||||||
.toggleRepost()
|
|
||||||
.catch(e => logger.error('Failed to toggle repost', {error: e}))
|
|
||||||
}, [item])
|
|
||||||
|
|
||||||
const onPressToggleLike = React.useCallback(() => {
|
|
||||||
return item
|
|
||||||
.toggleLike()
|
|
||||||
.catch(e => logger.error('Failed to toggle like', {error: e}))
|
|
||||||
}, [item])
|
|
||||||
|
|
||||||
const onCopyPostText = React.useCallback(() => {
|
|
||||||
Clipboard.setString(record.text)
|
|
||||||
Toast.show('Copied to clipboard')
|
|
||||||
}, [record])
|
|
||||||
|
|
||||||
const onOpenTranslate = React.useCallback(() => {
|
|
||||||
Linking.openURL(translatorUrl)
|
|
||||||
}, [translatorUrl])
|
|
||||||
|
|
||||||
const onToggleThreadMute = React.useCallback(() => {
|
|
||||||
try {
|
|
||||||
const muted = toggleThreadMute(item.data.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})
|
|
||||||
}
|
|
||||||
}, [item, toggleThreadMute])
|
|
||||||
|
|
||||||
const onDeletePost = React.useCallback(() => {
|
|
||||||
item.delete().then(
|
|
||||||
() => {
|
|
||||||
setDeleted(true)
|
|
||||||
Toast.show('Post deleted')
|
|
||||||
},
|
|
||||||
e => {
|
|
||||||
logger.error('Failed to delete post', {error: e})
|
|
||||||
Toast.show('Failed to delete post, please try again')
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}, [item, setDeleted])
|
|
||||||
|
|
||||||
const onPressShowMore = React.useCallback(() => {
|
const onPressShowMore = React.useCallback(() => {
|
||||||
setLimitLines(false)
|
setLimitLines(false)
|
||||||
|
@ -203,17 +135,17 @@ const PostLoaded = observer(function PostLoadedImpl({
|
||||||
<View style={styles.layoutAvi}>
|
<View style={styles.layoutAvi}>
|
||||||
<PreviewableUserAvatar
|
<PreviewableUserAvatar
|
||||||
size={52}
|
size={52}
|
||||||
did={item.post.author.did}
|
did={post.author.did}
|
||||||
handle={item.post.author.handle}
|
handle={post.author.handle}
|
||||||
avatar={item.post.author.avatar}
|
avatar={post.author.avatar}
|
||||||
moderation={item.moderation.avatar}
|
moderation={moderation.avatar}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.layoutContent}>
|
<View style={styles.layoutContent}>
|
||||||
<PostMeta
|
<PostMeta
|
||||||
author={item.post.author}
|
author={post.author}
|
||||||
authorHasWarning={!!item.post.author.labels?.length}
|
authorHasWarning={!!post.author.labels?.length}
|
||||||
timestamp={item.post.indexedAt}
|
timestamp={post.indexedAt}
|
||||||
postHref={itemHref}
|
postHref={itemHref}
|
||||||
/>
|
/>
|
||||||
{replyAuthorDid !== '' && (
|
{replyAuthorDid !== '' && (
|
||||||
|
@ -239,19 +171,16 @@ const PostLoaded = observer(function PostLoadedImpl({
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<ContentHider
|
<ContentHider
|
||||||
moderation={item.moderation.content}
|
moderation={moderation.content}
|
||||||
style={styles.contentHider}
|
style={styles.contentHider}
|
||||||
childContainerStyle={styles.contentHiderChild}>
|
childContainerStyle={styles.contentHiderChild}>
|
||||||
<PostAlerts
|
<PostAlerts moderation={moderation.content} style={styles.alert} />
|
||||||
moderation={item.moderation.content}
|
{richText.text ? (
|
||||||
style={styles.alert}
|
|
||||||
/>
|
|
||||||
{item.richText?.text ? (
|
|
||||||
<View style={styles.postTextContainer}>
|
<View style={styles.postTextContainer}>
|
||||||
<RichText
|
<RichText
|
||||||
testID="postText"
|
testID="postText"
|
||||||
type="post-text"
|
type="post-text"
|
||||||
richText={item.richText}
|
richText={richText}
|
||||||
lineHeight={1.3}
|
lineHeight={1.3}
|
||||||
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
||||||
style={s.flex1}
|
style={s.flex1}
|
||||||
|
@ -266,45 +195,20 @@ const PostLoaded = observer(function PostLoadedImpl({
|
||||||
href="#"
|
href="#"
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
{item.post.embed ? (
|
{post.embed ? (
|
||||||
<ContentHider
|
<ContentHider
|
||||||
moderation={item.moderation.embed}
|
moderation={moderation.embed}
|
||||||
style={styles.contentHider}>
|
style={styles.contentHider}>
|
||||||
<PostEmbeds
|
<PostEmbeds embed={post.embed} moderation={moderation.embed} />
|
||||||
embed={item.post.embed}
|
|
||||||
moderation={item.moderation.embed}
|
|
||||||
/>
|
|
||||||
</ContentHider>
|
</ContentHider>
|
||||||
) : null}
|
) : null}
|
||||||
</ContentHider>
|
</ContentHider>
|
||||||
<PostCtrls
|
<PostCtrls post={post} record={record} onPressReply={onPressReply} />
|
||||||
itemUri={itemUri}
|
|
||||||
itemCid={itemCid}
|
|
||||||
itemHref={itemHref}
|
|
||||||
itemTitle={itemTitle}
|
|
||||||
author={item.post.author}
|
|
||||||
indexedAt={item.post.indexedAt}
|
|
||||||
text={item.richText?.text || record.text}
|
|
||||||
isAuthor={item.post.author.did === store.me.did}
|
|
||||||
replyCount={item.post.replyCount}
|
|
||||||
repostCount={item.post.repostCount}
|
|
||||||
likeCount={item.post.likeCount}
|
|
||||||
isReposted={!!item.post.viewer?.repost}
|
|
||||||
isLiked={!!item.post.viewer?.like}
|
|
||||||
isThreadMuted={mutedThreads.includes(item.data.rootUri)}
|
|
||||||
onPressReply={onPressReply}
|
|
||||||
onPressToggleRepost={onPressToggleRepost}
|
|
||||||
onPressToggleLike={onPressToggleLike}
|
|
||||||
onCopyPostText={onCopyPostText}
|
|
||||||
onOpenTranslate={onOpenTranslate}
|
|
||||||
onToggleThreadMute={onToggleThreadMute}
|
|
||||||
onDeletePost={onDeletePost}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
outer: {
|
outer: {
|
||||||
|
|
|
@ -68,7 +68,7 @@ export function Feed({
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
const [isRefreshing, setIsRefreshing] = React.useState(false)
|
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||||
const checkForNewRef = React.useRef<(() => void) | null>(null)
|
const checkForNewRef = React.useRef<(() => void) | null>(null)
|
||||||
|
|
||||||
const opts = React.useMemo(() => ({enabled}), [enabled])
|
const opts = React.useMemo(() => ({enabled}), [enabled])
|
||||||
|
@ -137,15 +137,15 @@ export function Feed({
|
||||||
|
|
||||||
const onRefresh = React.useCallback(async () => {
|
const onRefresh = React.useCallback(async () => {
|
||||||
track('Feed:onRefresh')
|
track('Feed:onRefresh')
|
||||||
setIsRefreshing(true)
|
setIsPTRing(true)
|
||||||
try {
|
try {
|
||||||
await refetch()
|
await refetch()
|
||||||
onHasNew?.(false)
|
onHasNew?.(false)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to refresh posts feed', {error: err})
|
logger.error('Failed to refresh posts feed', {error: err})
|
||||||
}
|
}
|
||||||
setIsRefreshing(false)
|
setIsPTRing(false)
|
||||||
}, [refetch, track, setIsRefreshing, onHasNew])
|
}, [refetch, track, setIsPTRing, onHasNew])
|
||||||
|
|
||||||
const onEndReached = React.useCallback(async () => {
|
const onEndReached = React.useCallback(async () => {
|
||||||
if (isFetching || !hasNextPage || isError) return
|
if (isFetching || !hasNextPage || isError) return
|
||||||
|
@ -233,7 +233,7 @@ export function Feed({
|
||||||
ListHeaderComponent={ListHeaderComponent}
|
ListHeaderComponent={ListHeaderComponent}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={isRefreshing}
|
refreshing={isPTRing}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
tintColor={pal.colors.text}
|
tintColor={pal.colors.text}
|
||||||
titleColor={pal.colors.text}
|
titleColor={pal.colors.text}
|
||||||
|
|
|
@ -16,7 +16,7 @@ import {Link, TextLinkOnWebOnly, TextLink} from '../util/Link'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
import {UserInfoText} from '../util/UserInfoText'
|
import {UserInfoText} from '../util/UserInfoText'
|
||||||
import {PostMeta} from '../util/PostMeta'
|
import {PostMeta} from '../util/PostMeta'
|
||||||
import {PostCtrls} from '../util/post-ctrls/PostCtrls2'
|
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
|
||||||
import {PostEmbeds} from '../util/post-embeds'
|
import {PostEmbeds} from '../util/post-embeds'
|
||||||
import {ContentHider} from '../util/moderation/ContentHider'
|
import {ContentHider} from '../util/moderation/ContentHider'
|
||||||
import {PostAlerts} from '../util/moderation/PostAlerts'
|
import {PostAlerts} from '../util/moderation/PostAlerts'
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import React from 'react'
|
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 {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
|
import {AppBskyFeedDefs, AppBskyFeedPost, AtUri} from '@atproto/api'
|
||||||
import {toShareUrl} from 'lib/strings/url-helpers'
|
import {toShareUrl} from 'lib/strings/url-helpers'
|
||||||
import {useTheme} from 'lib/ThemeContext'
|
import {useTheme} from 'lib/ThemeContext'
|
||||||
import {shareUrl} from 'lib/sharing'
|
import {shareUrl} from 'lib/sharing'
|
||||||
|
@ -8,41 +10,83 @@ import {
|
||||||
NativeDropdown,
|
NativeDropdown,
|
||||||
DropdownItem as NativeDropdownItem,
|
DropdownItem as NativeDropdownItem,
|
||||||
} from './NativeDropdown'
|
} from './NativeDropdown'
|
||||||
|
import * as Toast from '../Toast'
|
||||||
import {EventStopper} from '../EventStopper'
|
import {EventStopper} from '../EventStopper'
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {msg} from '@lingui/macro'
|
|
||||||
import {useModalControls} from '#/state/modals'
|
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({
|
export function PostDropdownBtn({
|
||||||
testID,
|
testID,
|
||||||
itemUri,
|
post,
|
||||||
itemCid,
|
record,
|
||||||
itemHref,
|
|
||||||
isAuthor,
|
|
||||||
isThreadMuted,
|
|
||||||
onCopyPostText,
|
|
||||||
onOpenTranslate,
|
|
||||||
onToggleThreadMute,
|
|
||||||
onDeletePost,
|
|
||||||
style,
|
style,
|
||||||
}: {
|
}: {
|
||||||
testID: string
|
testID: string
|
||||||
itemUri: string
|
post: AppBskyFeedDefs.PostView
|
||||||
itemCid: string
|
record: AppBskyFeedPost.Record
|
||||||
itemHref: string
|
|
||||||
itemTitle: string
|
|
||||||
isAuthor: boolean
|
|
||||||
isThreadMuted: boolean
|
|
||||||
onCopyPostText: () => void
|
|
||||||
onOpenTranslate: () => void
|
|
||||||
onToggleThreadMute: () => void
|
|
||||||
onDeletePost: () => void
|
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
}) {
|
}) {
|
||||||
|
const store = useStores()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const {_} = useLingui()
|
|
||||||
const defaultCtrlColor = theme.palette.default.postCtrl
|
const defaultCtrlColor = theme.palette.default.postCtrl
|
||||||
const {openModal} = useModalControls()
|
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[] = [
|
const dropdownItems: NativeDropdownItem[] = [
|
||||||
{
|
{
|
||||||
|
@ -76,7 +120,7 @@ export function PostDropdownBtn({
|
||||||
{
|
{
|
||||||
label: 'Share',
|
label: 'Share',
|
||||||
onPress() {
|
onPress() {
|
||||||
const url = toShareUrl(itemHref)
|
const url = toShareUrl(href)
|
||||||
shareUrl(url)
|
shareUrl(url)
|
||||||
},
|
},
|
||||||
testID: 'postDropdownShareBtn',
|
testID: 'postDropdownShareBtn',
|
||||||
|
@ -113,8 +157,8 @@ export function PostDropdownBtn({
|
||||||
onPress() {
|
onPress() {
|
||||||
openModal({
|
openModal({
|
||||||
name: 'report',
|
name: 'report',
|
||||||
uri: itemUri,
|
uri: post.uri,
|
||||||
cid: itemCid,
|
cid: post.cid,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
testID: 'postDropdownReportBtn',
|
testID: 'postDropdownReportBtn',
|
||||||
|
@ -155,7 +199,7 @@ export function PostDropdownBtn({
|
||||||
<NativeDropdown
|
<NativeDropdown
|
||||||
testID={testID}
|
testID={testID}
|
||||||
items={dropdownItems}
|
items={dropdownItems}
|
||||||
accessibilityLabel={_(msg`More post options`)}
|
accessibilityLabel="More post options"
|
||||||
accessibilityHint="">
|
accessibilityHint="">
|
||||||
<View style={style}>
|
<View style={style}>
|
||||||
<FontAwesomeIcon icon="ellipsis" size={20} color={defaultCtrlColor} />
|
<FontAwesomeIcon icon="ellipsis" size={20} color={defaultCtrlColor} />
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -6,6 +6,7 @@ import {
|
||||||
View,
|
View,
|
||||||
ViewStyle,
|
ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
|
import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api'
|
||||||
import {Text} from '../text/Text'
|
import {Text} from '../text/Text'
|
||||||
import {PostDropdownBtn} from '../forms/PostDropdownBtn'
|
import {PostDropdownBtn} from '../forms/PostDropdownBtn'
|
||||||
import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons'
|
import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons'
|
||||||
|
@ -17,160 +18,155 @@ import {RepostButton} from './RepostButton'
|
||||||
import {Haptics} from 'lib/haptics'
|
import {Haptics} from 'lib/haptics'
|
||||||
import {HITSLOP_10, HITSLOP_20} from 'lib/constants'
|
import {HITSLOP_10, HITSLOP_20} from 'lib/constants'
|
||||||
import {useModalControls} from '#/state/modals'
|
import {useModalControls} from '#/state/modals'
|
||||||
|
import {
|
||||||
|
usePostLikeMutation,
|
||||||
|
usePostUnlikeMutation,
|
||||||
|
usePostRepostMutation,
|
||||||
|
usePostUnrepostMutation,
|
||||||
|
} from '#/state/queries/post'
|
||||||
|
|
||||||
interface PostCtrlsOpts {
|
export function PostCtrls({
|
||||||
itemUri: string
|
big,
|
||||||
itemCid: string
|
post,
|
||||||
itemHref: string
|
record,
|
||||||
itemTitle: string
|
style,
|
||||||
isAuthor: boolean
|
onPressReply,
|
||||||
author: {
|
}: {
|
||||||
did: string
|
|
||||||
handle: string
|
|
||||||
displayName?: string | undefined
|
|
||||||
avatar?: string | undefined
|
|
||||||
}
|
|
||||||
text: string
|
|
||||||
indexedAt: string
|
|
||||||
big?: boolean
|
big?: boolean
|
||||||
|
post: AppBskyFeedDefs.PostView
|
||||||
|
record: AppBskyFeedPost.Record
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
replyCount?: number
|
|
||||||
repostCount?: number
|
|
||||||
likeCount?: number
|
|
||||||
isReposted: boolean
|
|
||||||
isLiked: boolean
|
|
||||||
isThreadMuted: boolean
|
|
||||||
onPressReply: () => void
|
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 store = useStores()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const {closeModal} = useModalControls()
|
const {closeModal} = useModalControls()
|
||||||
|
const postLikeMutation = usePostLikeMutation()
|
||||||
|
const postUnlikeMutation = usePostUnlikeMutation()
|
||||||
|
const postRepostMutation = usePostRepostMutation()
|
||||||
|
const postUnrepostMutation = usePostUnrepostMutation()
|
||||||
|
|
||||||
const defaultCtrlColor = React.useMemo(
|
const defaultCtrlColor = React.useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
color: theme.palette.default.postCtrl,
|
color: theme.palette.default.postCtrl,
|
||||||
}),
|
}),
|
||||||
[theme],
|
[theme],
|
||||||
) as StyleProp<ViewStyle>
|
) 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(() => {
|
const onRepost = useCallback(() => {
|
||||||
closeModal()
|
closeModal()
|
||||||
if (!opts.isReposted) {
|
if (!post.viewer?.repost) {
|
||||||
Haptics.default()
|
Haptics.default()
|
||||||
opts.onPressToggleRepost().catch(_e => undefined)
|
postRepostMutation.mutate({
|
||||||
|
uri: post.uri,
|
||||||
|
cid: post.cid,
|
||||||
|
repostCount: post.repostCount || 0,
|
||||||
|
})
|
||||||
} else {
|
} 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(() => {
|
const onQuote = useCallback(() => {
|
||||||
closeModal()
|
closeModal()
|
||||||
store.shell.openComposer({
|
store.shell.openComposer({
|
||||||
quote: {
|
quote: {
|
||||||
uri: opts.itemUri,
|
uri: post.uri,
|
||||||
cid: opts.itemCid,
|
cid: post.cid,
|
||||||
text: opts.text,
|
text: record.text,
|
||||||
author: opts.author,
|
author: post.author,
|
||||||
indexedAt: opts.indexedAt,
|
indexedAt: post.indexedAt,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
Haptics.default()
|
Haptics.default()
|
||||||
}, [
|
}, [post, record, store.shell, closeModal])
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.ctrls, opts.style]}>
|
<View style={[styles.ctrls, style]}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
testID="replyBtn"
|
testID="replyBtn"
|
||||||
style={[styles.ctrl, !opts.big && styles.ctrlPad, {paddingLeft: 0}]}
|
style={[styles.ctrl, !big && styles.ctrlPad, {paddingLeft: 0}]}
|
||||||
onPress={opts.onPressReply}
|
onPress={onPressReply}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={`Reply (${opts.replyCount} ${
|
accessibilityLabel={`Reply (${post.replyCount} ${
|
||||||
opts.replyCount === 1 ? 'reply' : 'replies'
|
post.replyCount === 1 ? 'reply' : 'replies'
|
||||||
})`}
|
})`}
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
hitSlop={opts.big ? HITSLOP_20 : HITSLOP_10}>
|
hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
|
||||||
<CommentBottomArrow
|
<CommentBottomArrow
|
||||||
style={[defaultCtrlColor, opts.big ? s.mt2 : styles.mt1]}
|
style={[defaultCtrlColor, big ? s.mt2 : styles.mt1]}
|
||||||
strokeWidth={3}
|
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]}>
|
<Text style={[defaultCtrlColor, s.ml5, s.f15]}>
|
||||||
{opts.replyCount}
|
{post.replyCount}
|
||||||
</Text>
|
</Text>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<RepostButton {...opts} onRepost={onRepost} onQuote={onQuote} />
|
<RepostButton
|
||||||
|
big={big}
|
||||||
|
isReposted={!!post.viewer?.repost}
|
||||||
|
repostCount={post.repostCount}
|
||||||
|
onRepost={onRepost}
|
||||||
|
onQuote={onQuote}
|
||||||
|
/>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
testID="likeBtn"
|
testID="likeBtn"
|
||||||
style={[styles.ctrl, !opts.big && styles.ctrlPad]}
|
style={[styles.ctrl, !big && styles.ctrlPad]}
|
||||||
onPress={onPressToggleLikeWrapper}
|
onPress={onPressToggleLike}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={`${opts.isLiked ? 'Unlike' : 'Like'} (${
|
accessibilityLabel={`${post.viewer?.like ? 'Unlike' : 'Like'} (${
|
||||||
opts.likeCount
|
post.likeCount
|
||||||
} ${pluralize(opts.likeCount || 0, 'like')})`}
|
} ${pluralize(post.likeCount || 0, 'like')})`}
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
hitSlop={opts.big ? HITSLOP_20 : HITSLOP_10}>
|
hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
|
||||||
{opts.isLiked ? (
|
{post.viewer?.like ? (
|
||||||
<HeartIconSolid
|
<HeartIconSolid style={styles.ctrlIconLiked} size={big ? 22 : 16} />
|
||||||
style={styles.ctrlIconLiked}
|
|
||||||
size={opts.big ? 22 : 16}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<HeartIcon
|
<HeartIcon
|
||||||
style={[defaultCtrlColor, opts.big ? styles.mt1 : undefined]}
|
style={[defaultCtrlColor, big ? styles.mt1 : undefined]}
|
||||||
strokeWidth={3}
|
strokeWidth={3}
|
||||||
size={opts.big ? 20 : 16}
|
size={big ? 20 : 16}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{typeof opts.likeCount !== 'undefined' ? (
|
{typeof post.likeCount !== 'undefined' ? (
|
||||||
<Text
|
<Text
|
||||||
testID="likeCount"
|
testID="likeCount"
|
||||||
style={
|
style={
|
||||||
opts.isLiked
|
post.viewer?.like
|
||||||
? [s.bold, s.red3, s.f15, s.ml5]
|
? [s.bold, s.red3, s.f15, s.ml5]
|
||||||
: [defaultCtrlColor, s.f15, s.ml5]
|
: [defaultCtrlColor, s.f15, s.ml5]
|
||||||
}>
|
}>
|
||||||
{opts.likeCount}
|
{post.likeCount}
|
||||||
</Text>
|
</Text>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{opts.big ? undefined : (
|
{big ? undefined : (
|
||||||
<PostDropdownBtn
|
<PostDropdownBtn
|
||||||
testID="postDropdownBtn"
|
testID="postDropdownBtn"
|
||||||
itemUri={opts.itemUri}
|
post={post}
|
||||||
itemCid={opts.itemCid}
|
record={record}
|
||||||
itemHref={opts.itemHref}
|
|
||||||
itemTitle={opts.itemTitle}
|
|
||||||
isAuthor={opts.isAuthor}
|
|
||||||
isThreadMuted={opts.isThreadMuted}
|
|
||||||
onCopyPostText={opts.onCopyPostText}
|
|
||||||
onOpenTranslate={opts.onOpenTranslate}
|
|
||||||
onToggleThreadMute={opts.onToggleThreadMute}
|
|
||||||
onDeletePost={opts.onDeletePost}
|
|
||||||
style={styles.ctrlPad}
|
style={styles.ctrlPad}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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,
|
|
||||||
},
|
|
||||||
})
|
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {FlatList, View} from 'react-native'
|
import {FlatList, View} from 'react-native'
|
||||||
import {useFocusEffect} from '@react-navigation/native'
|
import {useFocusEffect} from '@react-navigation/native'
|
||||||
import {observer} from 'mobx-react-lite'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
NativeStackScreenProps,
|
NativeStackScreenProps,
|
||||||
NotificationsTabNavigatorParams,
|
NotificationsTabNavigatorParams,
|
||||||
|
@ -13,21 +13,21 @@ import {TextLink} from 'view/com/util/Link'
|
||||||
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
|
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
|
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
|
||||||
import {useTabFocusEffect} from 'lib/hooks/useTabFocusEffect'
|
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
import {s, colors} from 'lib/styles'
|
import {s, colors} from 'lib/styles'
|
||||||
import {useAnalytics} from 'lib/analytics/analytics'
|
import {useAnalytics} from 'lib/analytics/analytics'
|
||||||
import {isWeb} from 'platform/detection'
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {useSetMinimalShellMode} from '#/state/shell'
|
import {useSetMinimalShellMode} from '#/state/shell'
|
||||||
|
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
|
||||||
|
import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<
|
type Props = NativeStackScreenProps<
|
||||||
NotificationsTabNavigatorParams,
|
NotificationsTabNavigatorParams,
|
||||||
'Notifications'
|
'Notifications'
|
||||||
>
|
>
|
||||||
export const NotificationsScreen = withAuthRequired(
|
export const NotificationsScreen = withAuthRequired(
|
||||||
observer(function NotificationsScreenImpl({}: Props) {
|
function NotificationsScreenImpl({}: Props) {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const setMinimalShellMode = useSetMinimalShellMode()
|
const setMinimalShellMode = useSetMinimalShellMode()
|
||||||
const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll()
|
const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll()
|
||||||
|
@ -35,17 +35,12 @@ export const NotificationsScreen = withAuthRequired(
|
||||||
const {screen} = useAnalytics()
|
const {screen} = useAnalytics()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const {isDesktop} = useWebMediaQueries()
|
const {isDesktop} = useWebMediaQueries()
|
||||||
|
const unreadNotifs = useUnreadNotifications()
|
||||||
const hasNew =
|
const queryClient = useQueryClient()
|
||||||
store.me.notifications.hasNewLatest &&
|
const hasNew = !!unreadNotifs
|
||||||
!store.me.notifications.isRefreshing
|
|
||||||
|
|
||||||
// event handlers
|
// event handlers
|
||||||
// =
|
// =
|
||||||
const onPressTryAgain = React.useCallback(() => {
|
|
||||||
store.me.notifications.refresh()
|
|
||||||
}, [store])
|
|
||||||
|
|
||||||
const scrollToTop = React.useCallback(() => {
|
const scrollToTop = React.useCallback(() => {
|
||||||
scrollElRef.current?.scrollToOffset({offset: 0})
|
scrollElRef.current?.scrollToOffset({offset: 0})
|
||||||
resetMainScroll()
|
resetMainScroll()
|
||||||
|
@ -53,8 +48,8 @@ export const NotificationsScreen = withAuthRequired(
|
||||||
|
|
||||||
const onPressLoadLatest = React.useCallback(() => {
|
const onPressLoadLatest = React.useCallback(() => {
|
||||||
scrollToTop()
|
scrollToTop()
|
||||||
store.me.notifications.refresh()
|
queryClient.invalidateQueries({queryKey: NOTIFS_RQKEY()})
|
||||||
}, [store, scrollToTop])
|
}, [scrollToTop, queryClient])
|
||||||
|
|
||||||
// on-visible setup
|
// on-visible setup
|
||||||
// =
|
// =
|
||||||
|
@ -63,42 +58,14 @@ export const NotificationsScreen = withAuthRequired(
|
||||||
setMinimalShellMode(false)
|
setMinimalShellMode(false)
|
||||||
logger.debug('NotificationsScreen: Updating feed')
|
logger.debug('NotificationsScreen: Updating feed')
|
||||||
const softResetSub = store.onScreenSoftReset(onPressLoadLatest)
|
const softResetSub = store.onScreenSoftReset(onPressLoadLatest)
|
||||||
store.me.notifications.update()
|
|
||||||
screen('Notifications')
|
screen('Notifications')
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
softResetSub.remove()
|
softResetSub.remove()
|
||||||
store.me.notifications.markAllRead()
|
|
||||||
}
|
}
|
||||||
}, [store, screen, onPressLoadLatest, setMinimalShellMode]),
|
}, [store, screen, onPressLoadLatest, setMinimalShellMode]),
|
||||||
)
|
)
|
||||||
|
|
||||||
useTabFocusEffect(
|
|
||||||
'Notifications',
|
|
||||||
React.useCallback(
|
|
||||||
isInside => {
|
|
||||||
// on mobile:
|
|
||||||
// fires with `isInside=true` when the user navigates to the root tab
|
|
||||||
// but not when the user goes back to the screen by pressing back
|
|
||||||
// on web:
|
|
||||||
// essentially equivalent to useFocusEffect because we dont used tabbed
|
|
||||||
// navigation
|
|
||||||
if (isInside) {
|
|
||||||
if (isWeb) {
|
|
||||||
store.me.notifications.syncQueue()
|
|
||||||
} else {
|
|
||||||
if (store.me.notifications.unreadCount > 0) {
|
|
||||||
store.me.notifications.refresh()
|
|
||||||
} else {
|
|
||||||
store.me.notifications.syncQueue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[store],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const ListHeaderComponent = React.useCallback(() => {
|
const ListHeaderComponent = React.useCallback(() => {
|
||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
return (
|
return (
|
||||||
|
@ -145,8 +112,6 @@ export const NotificationsScreen = withAuthRequired(
|
||||||
<View testID="notificationsScreen" style={s.hContentRegion}>
|
<View testID="notificationsScreen" style={s.hContentRegion}>
|
||||||
<ViewHeader title="Notifications" canGoBack={false} />
|
<ViewHeader title="Notifications" canGoBack={false} />
|
||||||
<Feed
|
<Feed
|
||||||
view={store.me.notifications}
|
|
||||||
onPressTryAgain={onPressTryAgain}
|
|
||||||
onScroll={onMainScroll}
|
onScroll={onMainScroll}
|
||||||
scrollElRef={scrollElRef}
|
scrollElRef={scrollElRef}
|
||||||
ListHeaderComponent={ListHeaderComponent}
|
ListHeaderComponent={ListHeaderComponent}
|
||||||
|
@ -160,5 +125,5 @@ export const NotificationsScreen = withAuthRequired(
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}),
|
},
|
||||||
)
|
)
|
||||||
|
|
|
@ -49,6 +49,7 @@ import {useSetDrawerOpen} from '#/state/shell'
|
||||||
import {useModalControls} from '#/state/modals'
|
import {useModalControls} from '#/state/modals'
|
||||||
import {useSession, SessionAccount} from '#/state/session'
|
import {useSession, SessionAccount} from '#/state/session'
|
||||||
import {useProfileQuery} from '#/state/queries/profile'
|
import {useProfileQuery} from '#/state/queries/profile'
|
||||||
|
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
|
||||||
|
|
||||||
export function DrawerProfileCard({
|
export function DrawerProfileCard({
|
||||||
account,
|
account,
|
||||||
|
@ -110,8 +111,7 @@ export const DrawerContent = observer(function DrawerContentImpl() {
|
||||||
const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} =
|
const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} =
|
||||||
useNavigationTabState()
|
useNavigationTabState()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
|
const numUnreadNotifications = useUnreadNotifications()
|
||||||
const {notifications} = store.me
|
|
||||||
|
|
||||||
// events
|
// events
|
||||||
// =
|
// =
|
||||||
|
@ -286,11 +286,11 @@ export const DrawerContent = observer(function DrawerContentImpl() {
|
||||||
label="Notifications"
|
label="Notifications"
|
||||||
accessibilityLabel={_(msg`Notifications`)}
|
accessibilityLabel={_(msg`Notifications`)}
|
||||||
accessibilityHint={
|
accessibilityHint={
|
||||||
notifications.unreadCountLabel === ''
|
numUnreadNotifications === ''
|
||||||
? ''
|
? ''
|
||||||
: `${notifications.unreadCountLabel} unread`
|
: `${numUnreadNotifications} unread`
|
||||||
}
|
}
|
||||||
count={notifications.unreadCountLabel}
|
count={numUnreadNotifications}
|
||||||
bold={isAtNotifications}
|
bold={isAtNotifications}
|
||||||
onPress={onPressNotifications}
|
onPress={onPressNotifications}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -28,6 +28,7 @@ import {useLingui} from '@lingui/react'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useModalControls} from '#/state/modals'
|
import {useModalControls} from '#/state/modals'
|
||||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||||
|
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
|
||||||
|
|
||||||
type TabOptions = 'Home' | 'Search' | 'Notifications' | 'MyProfile' | 'Feeds'
|
type TabOptions = 'Home' | 'Search' | 'Notifications' | 'MyProfile' | 'Feeds'
|
||||||
|
|
||||||
|
@ -43,9 +44,8 @@ export const BottomBar = observer(function BottomBarImpl({
|
||||||
const {footerHeight} = useShellLayout()
|
const {footerHeight} = useShellLayout()
|
||||||
const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} =
|
const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} =
|
||||||
useNavigationTabState()
|
useNavigationTabState()
|
||||||
|
const numUnreadNotifications = useUnreadNotifications()
|
||||||
const {footerMinimalShellTransform} = useMinimalShellMode()
|
const {footerMinimalShellTransform} = useMinimalShellMode()
|
||||||
const {notifications} = store.me
|
|
||||||
|
|
||||||
const onPressTab = React.useCallback(
|
const onPressTab = React.useCallback(
|
||||||
(tab: TabOptions) => {
|
(tab: TabOptions) => {
|
||||||
|
@ -178,14 +178,14 @@ export const BottomBar = observer(function BottomBarImpl({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onPress={onPressNotifications}
|
onPress={onPressNotifications}
|
||||||
notificationCount={notifications.unreadCountLabel}
|
notificationCount={numUnreadNotifications}
|
||||||
accessible={true}
|
accessible={true}
|
||||||
accessibilityRole="tab"
|
accessibilityRole="tab"
|
||||||
accessibilityLabel={_(msg`Notifications`)}
|
accessibilityLabel={_(msg`Notifications`)}
|
||||||
accessibilityHint={
|
accessibilityHint={
|
||||||
notifications.unreadCountLabel === ''
|
numUnreadNotifications === ''
|
||||||
? ''
|
? ''
|
||||||
: `${notifications.unreadCountLabel} unread`
|
: `${numUnreadNotifications} unread`
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Btn
|
<Btn
|
||||||
|
|
|
@ -43,6 +43,7 @@ import {useLingui} from '@lingui/react'
|
||||||
import {Trans, msg} from '@lingui/macro'
|
import {Trans, msg} from '@lingui/macro'
|
||||||
import {useProfileQuery} from '#/state/queries/profile'
|
import {useProfileQuery} from '#/state/queries/profile'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
|
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
|
||||||
|
|
||||||
const ProfileCard = observer(function ProfileCardImpl() {
|
const ProfileCard = observer(function ProfileCardImpl() {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
|
@ -253,6 +254,7 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const {isDesktop, isTablet} = useWebMediaQueries()
|
const {isDesktop, isTablet} = useWebMediaQueries()
|
||||||
|
const numUnread = useUnreadNotifications()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
|
@ -314,7 +316,7 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
|
||||||
/>
|
/>
|
||||||
<NavItem
|
<NavItem
|
||||||
href="/notifications"
|
href="/notifications"
|
||||||
count={store.me.notifications.unreadCountLabel}
|
count={numUnread}
|
||||||
icon={
|
icon={
|
||||||
<BellIcon
|
<BellIcon
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
|
|
Loading…
Reference in New Issue