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
Paul Frazee 2023-11-12 18:13:11 -08:00 committed by GitHub
parent c584a3378d
commit b445c15cc9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 941 additions and 1739 deletions

View File

@ -31,6 +31,7 @@ import {
useSession,
useSessionApi,
} from 'state/session'
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import * as persisted from '#/state/persisted'
import {i18n} from '@lingui/core'
import {I18nProvider} from '@lingui/react'
@ -53,7 +54,7 @@ const InnerApp = observer(function AppImpl() {
setupState().then(store => {
setRootStore(store)
analytics.init(store)
notifications.init(store)
notifications.init(store, queryClient)
store.onSessionDropped(() => {
Toast.show('Sorry! Your session expired. Please log in again.')
})
@ -72,7 +73,6 @@ const InnerApp = observer(function AppImpl() {
}
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={colorMode}>
<RootSiblingParent>
<analytics.Provider>
@ -87,7 +87,6 @@ const InnerApp = observer(function AppImpl() {
</analytics.Provider>
</RootSiblingParent>
</ThemeProvider>
</QueryClientProvider>
)
})
@ -103,19 +102,23 @@ function App() {
}
return (
<QueryClientProvider client={queryClient}>
<SessionProvider>
<ShellStateProvider>
<PrefsStateProvider>
<MutedThreadsProvider>
<UnreadNotifsProvider>
<InvitesStateProvider>
<ModalStateProvider>
<InnerApp />
</ModalStateProvider>
</InvitesStateProvider>
</UnreadNotifsProvider>
</MutedThreadsProvider>
</PrefsStateProvider>
</ShellStateProvider>
</SessionProvider>
</QueryClientProvider>
)
}

View File

@ -29,6 +29,7 @@ import {
useSession,
useSessionApi,
} from 'state/session'
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import * as persisted from '#/state/persisted'
const InnerApp = observer(function AppImpl() {
@ -60,7 +61,6 @@ const InnerApp = observer(function AppImpl() {
}
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={colorMode}>
<RootSiblingParent>
<analytics.Provider>
@ -75,7 +75,6 @@ const InnerApp = observer(function AppImpl() {
</analytics.Provider>
</RootSiblingParent>
</ThemeProvider>
</QueryClientProvider>
)
})
@ -91,19 +90,23 @@ function App() {
}
return (
<QueryClientProvider client={queryClient}>
<SessionProvider>
<ShellStateProvider>
<PrefsStateProvider>
<MutedThreadsProvider>
<UnreadNotifsProvider>
<InvitesStateProvider>
<ModalStateProvider>
<InnerApp />
</ModalStateProvider>
</InvitesStateProvider>
</UnreadNotifsProvider>
</MutedThreadsProvider>
</PrefsStateProvider>
</ShellStateProvider>
</SessionProvider>
</QueryClientProvider>
)
}

View File

@ -1,7 +1,6 @@
import * as React from 'react'
import {StyleSheet} from 'react-native'
import * as SplashScreen from 'expo-splash-screen'
import {observer} from 'mobx-react-lite'
import {
NavigationContainer,
createNavigationContainerRef,
@ -33,10 +32,10 @@ import {isNative} from 'platform/detection'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {router} from './routes'
import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from './state'
import {bskyTitle} from 'lib/strings/headings'
import {JSX} from 'react/jsx-runtime'
import {timeout} from 'lib/async/timeout'
import {useUnreadNotifications} from './state/queries/notifications/unread'
import {HomeScreen} from './view/screens/Home'
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)
return (
<MyProfileTab.Navigator
@ -368,18 +367,17 @@ const MyProfileTabNavigator = observer(function MyProfileTabNavigatorImpl() {
{commonScreens(MyProfileTab as typeof HomeTab)}
</MyProfileTab.Navigator>
)
})
}
/**
* The FlatNavigator is used by Web to represent the routes
* in a single ("flat") stack.
*/
const FlatNavigator = observer(function FlatNavigatorImpl() {
const FlatNavigator = () => {
const pal = usePalette('default')
const store = useStores()
const unreadCountLabel = store.me.notifications.unreadCountLabel
const numUnread = useUnreadNotifications()
const title = (page: string) => bskyTitle(page, unreadCountLabel)
const title = (page: string) => bskyTitle(page, numUnread)
return (
<Flat.Navigator
screenOptions={{
@ -409,10 +407,10 @@ const FlatNavigator = observer(function FlatNavigatorImpl() {
getComponent={() => NotificationsScreen}
options={{title: title('Notifications')}}
/>
{commonScreens(Flat as typeof HomeTab, unreadCountLabel)}
{commonScreens(Flat as typeof HomeTab, numUnread)}
</Flat.Navigator>
)
})
}
/**
* The RoutesContainer should wrap all components which need access

View File

@ -3,4 +3,9 @@ export default class BroadcastChannel {
postMessage(_data: any) {}
close() {}
onmessage: (event: MessageEvent) => void = () => {}
addEventListener(_type: string, _listener: (event: MessageEvent) => void) {}
removeEventListener(
_type: string,
_listener: (event: MessageEvent) => void,
) {}
}

View File

@ -3,18 +3,14 @@ import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
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) {
const navigation = useNavigation<NavigationProp>()
const {unreadCountLabel} = useStores().me.notifications
const numUnread = useUnreadNotifications()
useEffect(() => {
if (title) {
navigation.setOptions({title: bskyTitle(title, unreadCountLabel)})
navigation.setOptions({title: bskyTitle(title, numUnread)})
}
}, [title, navigation, unreadCountLabel])
}, [title, navigation, numUnread])
}

View File

@ -1,18 +1,18 @@
import * as Notifications from 'expo-notifications'
import {QueryClient} from '@tanstack/react-query'
import {RootStoreModel} from '../../state'
import {resetToTab} from '../../Navigation'
import {devicePlatform, isIOS} from 'platform/detection'
import {track} from 'lib/analytics/analytics'
import {logger} from '#/logger'
import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'
const SERVICE_DID = (serviceUrl?: string) =>
serviceUrl?.includes('staging')
? 'did:web:api.staging.bsky.dev'
: 'did:web:api.bsky.app'
export function init(store: RootStoreModel) {
store.onUnreadNotifications(count => Notifications.setBadgeCountAsync(count))
export function init(store: RootStoreModel, queryClient: QueryClient) {
store.onSessionLoaded(async () => {
// request notifications permission once the user has logged in
const perms = await Notifications.getPermissionsAsync()
@ -83,7 +83,7 @@ export function init(store: RootStoreModel) {
)
if (event.request.trigger.type === 'push') {
// refresh notifications in the background
store.me.notifications.syncQueue()
queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
// handle payload-based deeplinks
let payload
if (isIOS) {
@ -121,7 +121,7 @@ export function init(store: RootStoreModel) {
logger.DebugContext.notifications,
)
track('Notificatons:OpenApp')
store.me.notifications.refresh() // refresh notifications
queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
resetToTab('NotificationsTab') // open notifications tab
}
},

View File

@ -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
}

View File

@ -4,13 +4,11 @@ import {
ComAtprotoServerListAppPasswords,
} from '@atproto/api'
import {RootStoreModel} from './root-store'
import {NotificationsFeedModel} from './feeds/notifications'
import {MyFollowsCache} from './cache/my-follows'
import {isObj, hasProp} from 'lib/type-guards'
import {logger} from '#/logger'
const PROFILE_UPDATE_INTERVAL = 10 * 60 * 1e3 // 10min
const NOTIFS_UPDATE_INTERVAL = 30 * 1e3 // 30sec
export class MeModel {
did: string = ''
@ -20,12 +18,10 @@ export class MeModel {
avatar: string = ''
followsCount: number | undefined
followersCount: number | undefined
notifications: NotificationsFeedModel
follows: MyFollowsCache
invites: ComAtprotoServerDefs.InviteCode[] = []
appPasswords: ComAtprotoServerListAppPasswords.AppPassword[] = []
lastProfileStateUpdate = Date.now()
lastNotifsUpdate = Date.now()
get invitesAvailable() {
return this.invites.filter(isInviteAvailable).length
@ -37,12 +33,10 @@ export class MeModel {
{rootStore: false, serialize: false, hydrate: false},
{autoBind: true},
)
this.notifications = new NotificationsFeedModel(this.rootStore)
this.follows = new MyFollowsCache(this.rootStore)
}
clear() {
this.notifications.clear()
this.follows.clear()
this.rootStore.profiles.cache.clear()
this.rootStore.posts.cache.clear()
@ -99,16 +93,6 @@ export class MeModel {
if (sess.hasSession) {
this.did = sess.currentSession?.did || ''
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()
await this.fetchInviteCodes()
await this.fetchAppPasswords()
@ -125,10 +109,6 @@ export class MeModel {
await this.fetchInviteCodes()
await this.fetchAppPasswords()
}
if (Date.now() - this.lastNotifsUpdate > NOTIFS_UPDATE_INTERVAL) {
this.lastNotifsUpdate = Date.now()
await this.notifications.syncQueue()
}
}
async fetchProfile() {

View File

@ -203,14 +203,6 @@ export class RootStoreModel {
emitScreenSoftReset() {
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(

View File

@ -3,7 +3,7 @@ import {logger} from '#/logger'
import {defaults, Schema} from '#/state/persisted/schema'
import {migrate} from '#/state/persisted/legacy'
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 {defaults} from '#/state/persisted/schema'

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -1,5 +1,11 @@
import {useEffect, useState} from 'react'
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 {getAge} from '#/lib/strings/time'
@ -15,6 +21,7 @@ import {
DEFAULT_HOME_FEED_PREFS,
DEFAULT_THREAD_VIEW_PREFS,
} from '#/state/queries/preferences/const'
import {getModerationOpts} from '#/state/queries/preferences/moderation'
export * from '#/state/queries/preferences/types'
export * from '#/state/queries/preferences/moderation'
@ -23,7 +30,7 @@ export * from '#/state/queries/preferences/const'
export const usePreferencesQueryKey = ['getPreferences']
export function usePreferencesQuery() {
const {agent} = useSession()
const {agent, hasSession} = useSession()
return useQuery({
queryKey: usePreferencesQueryKey,
queryFn: async () => {
@ -76,9 +83,30 @@ export function usePreferencesQuery() {
}
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() {
const {agent} = useSession()
const queryClient = useQueryClient()

View File

@ -1,8 +1,6 @@
import React, {MutableRefObject} from 'react'
import {observer} from 'mobx-react-lite'
import {CenteredView, FlatList} from '../util/Views'
import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
import {NotificationsFeedModel} from 'state/models/feeds/notifications'
import {FeedItem} from './FeedItem'
import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
import {ErrorMessage} from '../util/error/ErrorMessage'
@ -12,20 +10,22 @@ import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {s} from 'lib/styles'
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 {cleanError} from '#/lib/strings/errors'
import {useModerationOpts} from '#/state/queries/preferences'
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
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({
view,
export function Feed({
scrollElRef,
onPressTryAgain,
onScroll,
ListHeaderComponent,
}: {
view: NotificationsFeedModel
scrollElRef?: MutableRefObject<FlatList<any> | null>
onPressTryAgain?: () => void
onScroll?: OnScrollHandler
@ -33,35 +33,54 @@ export const Feed = observer(function Feed({
}) {
const pal = usePalette('default')
const [isPTRing, setIsPTRing] = React.useState(false)
const data = React.useMemo(() => {
let feedItems: any[] = []
if (view.isRefreshing && !isPTRing) {
feedItems = [LOADING_SPINNER]
const moderationOpts = useModerationOpts()
const {markAllRead} = useUnreadNotificationsApi()
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()
}
}, [firstItem, markAllRead])
const items = React.useMemo(() => {
let arr: any[] = []
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])
}
if (view.hasLoaded) {
if (view.isEmpty) {
feedItems = feedItems.concat([EMPTY_FEED_ITEM])
} else {
feedItems = feedItems.concat(view.notifications)
arr.push(LOADING_ITEM)
}
}
if (view.loadMoreError) {
feedItems = (feedItems || []).concat([LOAD_MORE_ERROR_ITEM])
}
return feedItems
}, [
view.hasLoaded,
view.isEmpty,
view.notifications,
view.loadMoreError,
view.isRefreshing,
isPTRing,
])
return arr
}, [isFetched, isError, isEmpty, data])
const onRefresh = React.useCallback(async () => {
try {
setIsPTRing(true)
await view.refresh()
await refetch()
} catch (err) {
logger.error('Failed to refresh notifications feed', {
error: err,
@ -69,21 +88,21 @@ export const Feed = observer(function Feed({
} finally {
setIsPTRing(false)
}
}, [view, setIsPTRing])
}, [refetch, setIsPTRing])
const onEndReached = React.useCallback(async () => {
if (isFetching || !hasNextPage || isError) return
try {
await view.loadMore()
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more notifications', {
error: err,
})
logger.error('Failed to load more notifications', {error: err})
}
}, [view])
}, [isFetching, hasNextPage, isError, fetchNextPage])
const onPressRetryLoadMore = React.useCallback(() => {
view.retryLoadMore()
}, [view])
fetchNextPage()
}, [fetchNextPage])
// 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
@ -106,49 +125,47 @@ export const Feed = observer(function Feed({
onPress={onPressRetryLoadMore}
/>
)
} else if (item === LOADING_SPINNER) {
return (
<View style={styles.loading}>
<ActivityIndicator size="small" />
</View>
)
} else if (item === LOADING_ITEM) {
return <NotificationFeedLoadingPlaceholder />
}
return <FeedItem item={item} />
return (
<FeedItem
item={item}
dataUpdatedAt={dataUpdatedAt}
moderationOpts={moderationOpts!}
/>
)
},
[onPressRetryLoadMore],
[onPressRetryLoadMore, dataUpdatedAt, moderationOpts],
)
const FeedFooter = React.useCallback(
() =>
view.isLoading ? (
isFetchingNextPage ? (
<View style={styles.feedFooter}>
<ActivityIndicator />
</View>
) : (
<View />
),
[view],
[isFetchingNextPage],
)
const scrollHandler = useAnimatedScrollHandler(onScroll || {})
return (
<View style={s.hContentRegion}>
{error && (
<CenteredView>
{view.isLoading && !data.length && (
<NotificationFeedLoadingPlaceholder />
)}
{view.hasError && (
<ErrorMessage
message={view.error}
message={cleanError(error)}
onPressTryAgain={onPressTryAgain}
/>
)}
</CenteredView>
{data.length ? (
)}
<FlatList
testID="notifsFeed"
ref={scrollElRef}
data={data}
data={items}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
ListHeaderComponent={ListHeaderComponent}
@ -169,15 +186,11 @@ export const Feed = observer(function Feed({
// @ts-ignore our .web version only -prf
desktopFixedHeight
/>
) : null}
</View>
)
})
}
const styles = StyleSheet.create({
loading: {
paddingVertical: 20,
},
feedFooter: {paddingTop: 20},
emptyState: {paddingVertical: 40},
})

View File

@ -1,5 +1,4 @@
import React, {useMemo, useState, useEffect} from 'react'
import {observer} from 'mobx-react-lite'
import {
Animated,
TouchableOpacity,
@ -9,6 +8,9 @@ import {
} from 'react-native'
import {
AppBskyEmbedImages,
AppBskyFeedDefs,
AppBskyFeedPost,
ModerationOpts,
ProfileModeration,
moderateProfile,
AppBskyEmbedRecordWithMedia,
@ -19,8 +21,7 @@ import {
FontAwesomeIconStyle,
Props,
} from '@fortawesome/react-native-fontawesome'
import {NotificationsFeedItemModel} from 'state/models/feeds/notifications'
import {PostThreadModel} from 'state/models/content/post-thread'
import {FeedNotification} from '#/state/queries/notifications/feed'
import {s, colors} from 'lib/styles'
import {niceDate} from 'lib/strings/time'
import {sanitizeDisplayName} from 'lib/strings/display-names'
@ -33,7 +34,6 @@ import {UserPreviewLink} from '../util/UserPreviewLink'
import {ImageHorzList} from '../util/images/ImageHorzList'
import {Post} from '../post/Post'
import {Link, TextLink} from '../util/Link'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {formatCount} from '../util/numeric/format'
@ -56,39 +56,35 @@ interface Author {
moderation: ProfileModeration
}
export const FeedItem = observer(function FeedItemImpl({
export function FeedItem({
item,
dataUpdatedAt,
moderationOpts,
}: {
item: NotificationsFeedItemModel
item: FeedNotification
dataUpdatedAt: number
moderationOpts: ModerationOpts
}) {
const store = useStores()
const pal = usePalette('default')
const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false)
const itemHref = useMemo(() => {
if (item.isLike || item.isRepost) {
if (item.type === 'post-like' || item.type === 'repost') {
if (item.subjectUri) {
const urip = new AtUri(item.subjectUri)
return `/profile/${urip.host}/post/${urip.rkey}`
} else if (item.isFollow) {
return makeProfileLink(item.author)
} else if (item.isReply) {
const urip = new AtUri(item.uri)
}
} 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}`
} else if (item.isCustomFeedLike) {
} else if (item.type === 'feedgen-like') {
if (item.subjectUri) {
const urip = new AtUri(item.subjectUri)
return `/profile/${urip.host}/feed/${urip.rkey}`
}
return ''
}, [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'
}
return ''
}, [item])
const onToggleAuthorsExpanded = () => {
@ -98,15 +94,12 @@ export const FeedItem = observer(function FeedItemImpl({
const authors: Author[] = useMemo(() => {
return [
{
href: makeProfileLink(item.author),
did: item.author.did,
handle: item.author.handle,
displayName: item.author.displayName,
avatar: item.author.avatar,
moderation: moderateProfile(
item.author,
store.preferences.moderationOpts,
),
href: makeProfileLink(item.notification.author),
did: item.notification.author.did,
handle: item.notification.author.handle,
displayName: item.notification.author.displayName,
avatar: item.notification.author.avatar,
moderation: moderateProfile(item.notification.author, moderationOpts),
},
...(item.additional?.map(({author}) => {
return {
@ -115,33 +108,36 @@ export const FeedItem = observer(function FeedItemImpl({
handle: author.handle,
displayName: author.displayName,
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
return <View />
}
if (item.isReply || item.isMention || item.isQuote) {
if (!item.additionalPost || item.additionalPost?.error) {
// hide errors - it doesnt help the user to show them
return <View />
if (
item.type === 'reply' ||
item.type === 'mention' ||
item.type === 'quote'
) {
if (!item.subject) {
return null
}
return (
<Link
testID={`feedItem-by-${item.author.handle}`}
testID={`feedItem-by-${item.notification.author.handle}`}
href={itemHref}
title={itemTitle}
noFeedback
accessible={false}>
<Post
view={item.additionalPost}
post={item.subject}
dataUpdatedAt={dataUpdatedAt}
style={
item.isRead
item.notification.isRead
? undefined
: {
backgroundColor: pal.colors.unreadNotifBg,
@ -156,23 +152,25 @@ export const FeedItem = observer(function FeedItemImpl({
let action = ''
let icon: Props['icon'] | 'HeartIconSolid'
let iconStyle: Props['style'] = []
if (item.isLike) {
if (item.type === 'post-like') {
action = 'liked your post'
icon = 'HeartIconSolid'
iconStyle = [
s.likeColor as FontAwesomeIconStyle,
{position: 'relative', top: -4},
]
} else if (item.isRepost) {
} else if (item.type === 'repost') {
action = 'reposted your post'
icon = 'retweet'
iconStyle = [s.green3 as FontAwesomeIconStyle]
} else if (item.isFollow) {
} else if (item.type === 'follow') {
action = 'followed you'
icon = 'user-plus'
iconStyle = [s.blue3 as FontAwesomeIconStyle]
} else if (item.isCustomFeedLike) {
action = `liked your custom feed '${new AtUri(item.subjectUri).rkey}'`
} else if (item.type === 'feedgen-like') {
action = `liked your custom feed${
item.subjectUri ? ` '${new AtUri(item.subjectUri).rkey}}'` : ''
}`
icon = 'HeartIconSolid'
iconStyle = [
s.likeColor as FontAwesomeIconStyle,
@ -184,12 +182,12 @@ export const FeedItem = observer(function FeedItemImpl({
return (
<Link
testID={`feedItem-by-${item.author.handle}`}
testID={`feedItem-by-${item.notification.author.handle}`}
style={[
styles.outer,
pal.view,
pal.border,
item.isRead
item.notification.isRead
? undefined
: {
backgroundColor: pal.colors.unreadNotifBg,
@ -197,9 +195,11 @@ export const FeedItem = observer(function FeedItemImpl({
},
]}
href={itemHref}
title={itemTitle}
noFeedback
accessible={(item.isLike && authors.length === 1) || item.isRepost}>
accessible={
(item.type === 'post-like' && authors.length === 1) ||
item.type === 'repost'
}>
<View style={styles.layoutIcon}>
{/* TODO: Prevent conditional rendering and move toward composable
notifications for clearer accessibility labeling */}
@ -244,24 +244,24 @@ export const FeedItem = observer(function FeedItemImpl({
</>
) : undefined}
<Text style={[pal.text]}> {action}</Text>
<TimeElapsed timestamp={item.indexedAt}>
<TimeElapsed timestamp={item.notification.indexedAt}>
{({timeElapsed}) => (
<Text
style={[pal.textLight, styles.pointer]}
title={niceDate(item.indexedAt)}>
title={niceDate(item.notification.indexedAt)}>
{' ' + timeElapsed}
</Text>
)}
</TimeElapsed>
</Text>
</ExpandListPressable>
{item.isLike || item.isRepost || item.isQuote ? (
<AdditionalPostText additionalPost={item.additionalPost} />
{item.type === 'post-like' || item.type === 'repost' ? (
<AdditionalPostText post={item.subject} />
) : null}
</View>
</Link>
)
})
}
function ExpandListPressable({
hasMultipleAuthors,
@ -423,25 +423,15 @@ function ExpandedAuthorsList({
)
}
function AdditionalPostText({
additionalPost,
}: {
additionalPost?: PostThreadModel
}) {
function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
const pal = usePalette('default')
if (
!additionalPost ||
!additionalPost.thread?.postRecord ||
additionalPost.error
) {
return <View />
}
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
if (post && AppBskyFeedPost.isRecord(post?.record)) {
const text = post.record.text
const images = AppBskyEmbedImages.isView(post.embed)
? post.embed.images
: AppBskyEmbedRecordWithMedia.isView(post.embed) &&
AppBskyEmbedImages.isView(post.embed.media)
? post.embed.media.images
: undefined
return (
<>
@ -452,6 +442,7 @@ function AdditionalPostText({
</>
)
}
}
const styles = StyleSheet.create({
overflowHidden: {

View File

@ -23,8 +23,8 @@ import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
import {useStores} from 'state/index'
import {PostMeta} from '../util/PostMeta'
import {PostEmbeds} from '../util/post-embeds'
import {PostCtrls} from '../util/post-ctrls/PostCtrls2'
import {PostDropdownBtn} from '../util/forms/PostDropdownBtn2'
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
import {PostDropdownBtn} from '../util/forms/PostDropdownBtn'
import {PostHider} from '../util/moderation/PostHider'
import {ContentHider} from '../util/moderation/ContentHider'
import {PostAlerts} from '../util/moderation/PostAlerts'

View File

@ -1,19 +1,14 @@
import React, {useState} from 'react'
import React, {useState, useMemo} from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {
ActivityIndicator,
Linking,
StyleProp,
StyleSheet,
View,
ViewStyle,
} from 'react-native'
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'
AppBskyFeedDefs,
AppBskyFeedPost,
AtUri,
moderatePost,
PostModeration,
RichText as RichTextAPI,
} from '@atproto/api'
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 {UserInfoText} from '../util/UserInfoText'
import {PostMeta} from '../util/PostMeta'
@ -23,174 +18,111 @@ import {ContentHider} from '../util/moderation/ContentHider'
import {PostAlerts} from '../util/moderation/PostAlerts'
import {Text} from '../util/text/Text'
import {RichText} from '../util/text/RichText'
import * as Toast from '../util/Toast'
import {PreviewableUserAvatar} from '../util/UserAvatar'
import {useStores} from 'state/index'
import {s, colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {getTranslatorLink} from '../../../locale/helpers'
import {makeProfileLink} from 'lib/routes/links'
import {MAX_POST_LINES} from 'lib/constants'
import {countLines} from 'lib/strings/helpers'
import {logger} from '#/logger'
import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
import {useLanguagePrefs} from '#/state/preferences'
import {useModerationOpts} from '#/state/queries/preferences'
import {usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
export const Post = observer(function PostImpl({
view,
export function Post({
post,
dataUpdatedAt,
showReplyLine,
hideError,
style,
}: {
view: PostThreadModel
post: AppBskyFeedDefs.PostView
dataUpdatedAt: number
showReplyLine?: boolean
hideError?: boolean
style?: StyleProp<ViewStyle>
}) {
const pal = usePalette('default')
const [deleted, setDeleted] = useState(false)
// deleted
// =
if (deleted) {
return <View />
}
// loading
// =
if (!view.hasContent && view.isLoading) {
return (
<View style={pal.view}>
<ActivityIndicator />
</View>
const moderationOpts = useModerationOpts()
const record = useMemo<AppBskyFeedPost.Record | undefined>(
() =>
AppBskyFeedPost.isRecord(post.record) &&
AppBskyFeedPost.validateRecord(post.record).success
? post.record
: undefined,
[post],
)
}
// 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>
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
}
// loaded
// =
if (record && richText && moderation) {
return (
<PostLoaded
item={view.thread}
record={view.thread.postRecord}
setDeleted={setDeleted}
<PostInner
post={postShadowed}
record={record}
richText={richText}
moderation={moderation}
showReplyLine={showReplyLine}
style={style}
/>
)
})
}
return null
}
const PostLoaded = observer(function PostLoadedImpl({
item,
function PostInner({
post,
record,
setDeleted,
richText,
moderation,
showReplyLine,
style,
}: {
item: PostThreadItemModel
record: FeedPost.Record
setDeleted: (v: boolean) => void
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
richText: RichTextAPI
moderation: PostModeration
showReplyLine?: boolean
style?: StyleProp<ViewStyle>
}) {
const pal = usePalette('default')
const store = useStores()
const mutedThreads = useMutedThreads()
const toggleThreadMute = useToggleThreadMute()
const langPrefs = useLanguagePrefs()
const [limitLines, setLimitLines] = React.useState(
countLines(item.richText?.text) >= MAX_POST_LINES,
const [limitLines, setLimitLines] = useState(
countLines(richText?.text) >= MAX_POST_LINES,
)
const itemUri = item.post.uri
const itemCid = item.post.cid
const itemUrip = new AtUri(item.post.uri)
const itemHref = makeProfileLink(item.post.author, 'post', itemUrip.rkey)
const itemTitle = `Post by ${item.post.author.handle}`
const itemUrip = new AtUri(post.uri)
const itemHref = makeProfileLink(post.author, 'post', itemUrip.rkey)
let replyAuthorDid = ''
if (record.reply) {
const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri)
replyAuthorDid = urip.hostname
}
const translatorUrl = getTranslatorLink(
record?.text || '',
langPrefs.primaryLanguage,
)
const onPressReply = React.useCallback(() => {
store.shell.openComposer({
replyTo: {
uri: item.post.uri,
cid: item.post.cid,
text: record.text as string,
uri: post.uri,
cid: post.cid,
text: record.text,
author: {
handle: item.post.author.handle,
displayName: item.post.author.displayName,
avatar: item.post.author.avatar,
handle: post.author.handle,
displayName: post.author.displayName,
avatar: post.author.avatar,
},
},
})
}, [store, item, 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])
}, [store, post, record])
const onPressShowMore = React.useCallback(() => {
setLimitLines(false)
@ -203,17 +135,17 @@ const PostLoaded = observer(function PostLoadedImpl({
<View style={styles.layoutAvi}>
<PreviewableUserAvatar
size={52}
did={item.post.author.did}
handle={item.post.author.handle}
avatar={item.post.author.avatar}
moderation={item.moderation.avatar}
did={post.author.did}
handle={post.author.handle}
avatar={post.author.avatar}
moderation={moderation.avatar}
/>
</View>
<View style={styles.layoutContent}>
<PostMeta
author={item.post.author}
authorHasWarning={!!item.post.author.labels?.length}
timestamp={item.post.indexedAt}
author={post.author}
authorHasWarning={!!post.author.labels?.length}
timestamp={post.indexedAt}
postHref={itemHref}
/>
{replyAuthorDid !== '' && (
@ -239,19 +171,16 @@ const PostLoaded = observer(function PostLoadedImpl({
</View>
)}
<ContentHider
moderation={item.moderation.content}
moderation={moderation.content}
style={styles.contentHider}
childContainerStyle={styles.contentHiderChild}>
<PostAlerts
moderation={item.moderation.content}
style={styles.alert}
/>
{item.richText?.text ? (
<PostAlerts moderation={moderation.content} style={styles.alert} />
{richText.text ? (
<View style={styles.postTextContainer}>
<RichText
testID="postText"
type="post-text"
richText={item.richText}
richText={richText}
lineHeight={1.3}
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
style={s.flex1}
@ -266,45 +195,20 @@ const PostLoaded = observer(function PostLoadedImpl({
href="#"
/>
) : undefined}
{item.post.embed ? (
{post.embed ? (
<ContentHider
moderation={item.moderation.embed}
moderation={moderation.embed}
style={styles.contentHider}>
<PostEmbeds
embed={item.post.embed}
moderation={item.moderation.embed}
/>
<PostEmbeds embed={post.embed} moderation={moderation.embed} />
</ContentHider>
) : null}
</ContentHider>
<PostCtrls
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}
/>
<PostCtrls post={post} record={record} onPressReply={onPressReply} />
</View>
</View>
</Link>
)
})
}
const styles = StyleSheet.create({
outer: {

View File

@ -68,7 +68,7 @@ export function Feed({
const pal = usePalette('default')
const theme = useTheme()
const {track} = useAnalytics()
const [isRefreshing, setIsRefreshing] = React.useState(false)
const [isPTRing, setIsPTRing] = React.useState(false)
const checkForNewRef = React.useRef<(() => void) | null>(null)
const opts = React.useMemo(() => ({enabled}), [enabled])
@ -137,15 +137,15 @@ export function Feed({
const onRefresh = React.useCallback(async () => {
track('Feed:onRefresh')
setIsRefreshing(true)
setIsPTRing(true)
try {
await refetch()
onHasNew?.(false)
} catch (err) {
logger.error('Failed to refresh posts feed', {error: err})
}
setIsRefreshing(false)
}, [refetch, track, setIsRefreshing, onHasNew])
setIsPTRing(false)
}, [refetch, track, setIsPTRing, onHasNew])
const onEndReached = React.useCallback(async () => {
if (isFetching || !hasNextPage || isError) return
@ -233,7 +233,7 @@ export function Feed({
ListHeaderComponent={ListHeaderComponent}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
refreshing={isPTRing}
onRefresh={onRefresh}
tintColor={pal.colors.text}
titleColor={pal.colors.text}

View File

@ -16,7 +16,7 @@ import {Link, TextLinkOnWebOnly, TextLink} from '../util/Link'
import {Text} from '../util/text/Text'
import {UserInfoText} from '../util/UserInfoText'
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 {ContentHider} from '../util/moderation/ContentHider'
import {PostAlerts} from '../util/moderation/PostAlerts'

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import React from 'react'
import {FlatList, View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {observer} from 'mobx-react-lite'
import {useQueryClient} from '@tanstack/react-query'
import {
NativeStackScreenProps,
NotificationsTabNavigatorParams,
@ -13,21 +13,21 @@ import {TextLink} from 'view/com/util/Link'
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
import {useStores} from 'state/index'
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
import {useTabFocusEffect} from 'lib/hooks/useTabFocusEffect'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {s, colors} from 'lib/styles'
import {useAnalytics} from 'lib/analytics/analytics'
import {isWeb} from 'platform/detection'
import {logger} from '#/logger'
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<
NotificationsTabNavigatorParams,
'Notifications'
>
export const NotificationsScreen = withAuthRequired(
observer(function NotificationsScreenImpl({}: Props) {
function NotificationsScreenImpl({}: Props) {
const store = useStores()
const setMinimalShellMode = useSetMinimalShellMode()
const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll()
@ -35,17 +35,12 @@ export const NotificationsScreen = withAuthRequired(
const {screen} = useAnalytics()
const pal = usePalette('default')
const {isDesktop} = useWebMediaQueries()
const hasNew =
store.me.notifications.hasNewLatest &&
!store.me.notifications.isRefreshing
const unreadNotifs = useUnreadNotifications()
const queryClient = useQueryClient()
const hasNew = !!unreadNotifs
// event handlers
// =
const onPressTryAgain = React.useCallback(() => {
store.me.notifications.refresh()
}, [store])
const scrollToTop = React.useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: 0})
resetMainScroll()
@ -53,8 +48,8 @@ export const NotificationsScreen = withAuthRequired(
const onPressLoadLatest = React.useCallback(() => {
scrollToTop()
store.me.notifications.refresh()
}, [store, scrollToTop])
queryClient.invalidateQueries({queryKey: NOTIFS_RQKEY()})
}, [scrollToTop, queryClient])
// on-visible setup
// =
@ -63,42 +58,14 @@ export const NotificationsScreen = withAuthRequired(
setMinimalShellMode(false)
logger.debug('NotificationsScreen: Updating feed')
const softResetSub = store.onScreenSoftReset(onPressLoadLatest)
store.me.notifications.update()
screen('Notifications')
return () => {
softResetSub.remove()
store.me.notifications.markAllRead()
}
}, [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(() => {
if (isDesktop) {
return (
@ -145,8 +112,6 @@ export const NotificationsScreen = withAuthRequired(
<View testID="notificationsScreen" style={s.hContentRegion}>
<ViewHeader title="Notifications" canGoBack={false} />
<Feed
view={store.me.notifications}
onPressTryAgain={onPressTryAgain}
onScroll={onMainScroll}
scrollElRef={scrollElRef}
ListHeaderComponent={ListHeaderComponent}
@ -160,5 +125,5 @@ export const NotificationsScreen = withAuthRequired(
)}
</View>
)
}),
},
)

View File

@ -49,6 +49,7 @@ import {useSetDrawerOpen} from '#/state/shell'
import {useModalControls} from '#/state/modals'
import {useSession, SessionAccount} from '#/state/session'
import {useProfileQuery} from '#/state/queries/profile'
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
export function DrawerProfileCard({
account,
@ -110,8 +111,7 @@ export const DrawerContent = observer(function DrawerContentImpl() {
const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} =
useNavigationTabState()
const {currentAccount} = useSession()
const {notifications} = store.me
const numUnreadNotifications = useUnreadNotifications()
// events
// =
@ -286,11 +286,11 @@ export const DrawerContent = observer(function DrawerContentImpl() {
label="Notifications"
accessibilityLabel={_(msg`Notifications`)}
accessibilityHint={
notifications.unreadCountLabel === ''
numUnreadNotifications === ''
? ''
: `${notifications.unreadCountLabel} unread`
: `${numUnreadNotifications} unread`
}
count={notifications.unreadCountLabel}
count={numUnreadNotifications}
bold={isAtNotifications}
onPress={onPressNotifications}
/>

View File

@ -28,6 +28,7 @@ import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {useModalControls} from '#/state/modals'
import {useShellLayout} from '#/state/shell/shell-layout'
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
type TabOptions = 'Home' | 'Search' | 'Notifications' | 'MyProfile' | 'Feeds'
@ -43,9 +44,8 @@ export const BottomBar = observer(function BottomBarImpl({
const {footerHeight} = useShellLayout()
const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} =
useNavigationTabState()
const numUnreadNotifications = useUnreadNotifications()
const {footerMinimalShellTransform} = useMinimalShellMode()
const {notifications} = store.me
const onPressTab = React.useCallback(
(tab: TabOptions) => {
@ -178,14 +178,14 @@ export const BottomBar = observer(function BottomBarImpl({
)
}
onPress={onPressNotifications}
notificationCount={notifications.unreadCountLabel}
notificationCount={numUnreadNotifications}
accessible={true}
accessibilityRole="tab"
accessibilityLabel={_(msg`Notifications`)}
accessibilityHint={
notifications.unreadCountLabel === ''
numUnreadNotifications === ''
? ''
: `${notifications.unreadCountLabel} unread`
: `${numUnreadNotifications} unread`
}
/>
<Btn

View File

@ -43,6 +43,7 @@ import {useLingui} from '@lingui/react'
import {Trans, msg} from '@lingui/macro'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
const ProfileCard = observer(function ProfileCardImpl() {
const {currentAccount} = useSession()
@ -253,6 +254,7 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
const store = useStores()
const pal = usePalette('default')
const {isDesktop, isTablet} = useWebMediaQueries()
const numUnread = useUnreadNotifications()
return (
<View
@ -314,7 +316,7 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
/>
<NavItem
href="/notifications"
count={store.me.notifications.unreadCountLabel}
count={numUnread}
icon={
<BellIcon
strokeWidth={2}