Priority notifications (#4798)
* new settings screen * bring back the spinner * add experimental language * fix typo, change leading * integrate priority notifications API * update package * use refetch instead of invalidateQueries * fix read-after-write issue by polling for update * add spinner for initial load * rm onmutate, it's overcomplicated * set error state eagerly * Change language in description Co-authored-by: Hailey <me@haileyok.com> * prettier * add `Toggle.Platform` * extract out mutation hook + error state * rm useless cache mutation * disambiguate isError and isPending * rm unused isError --------- Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> Co-authored-by: Hailey <me@haileyok.com>zio/stable
parent
9bd8393685
commit
cfb8a3160e
|
@ -197,6 +197,7 @@ func serve(cctx *cli.Context) error {
|
||||||
e.GET("/search", server.WebGeneric)
|
e.GET("/search", server.WebGeneric)
|
||||||
e.GET("/feeds", server.WebGeneric)
|
e.GET("/feeds", server.WebGeneric)
|
||||||
e.GET("/notifications", server.WebGeneric)
|
e.GET("/notifications", server.WebGeneric)
|
||||||
|
e.GET("/notifications/settings", server.WebGeneric)
|
||||||
e.GET("/lists", server.WebGeneric)
|
e.GET("/lists", server.WebGeneric)
|
||||||
e.GET("/moderation", server.WebGeneric)
|
e.GET("/moderation", server.WebGeneric)
|
||||||
e.GET("/moderation/modlists", server.WebGeneric)
|
e.GET("/moderation/modlists", server.WebGeneric)
|
||||||
|
|
|
@ -52,7 +52,7 @@
|
||||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "^0.12.23",
|
"@atproto/api": "0.12.25",
|
||||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||||
|
|
|
@ -76,6 +76,7 @@ import {LogScreen} from './view/screens/Log'
|
||||||
import {ModerationModlistsScreen} from './view/screens/ModerationModlists'
|
import {ModerationModlistsScreen} from './view/screens/ModerationModlists'
|
||||||
import {NotFoundScreen} from './view/screens/NotFound'
|
import {NotFoundScreen} from './view/screens/NotFound'
|
||||||
import {NotificationsScreen} from './view/screens/Notifications'
|
import {NotificationsScreen} from './view/screens/Notifications'
|
||||||
|
import {NotificationsSettingsScreen} from './view/screens/NotificationsSettings'
|
||||||
import {PostLikedByScreen} from './view/screens/PostLikedBy'
|
import {PostLikedByScreen} from './view/screens/PostLikedBy'
|
||||||
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
|
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
|
||||||
import {PostThreadScreen} from './view/screens/PostThread'
|
import {PostThreadScreen} from './view/screens/PostThread'
|
||||||
|
@ -324,6 +325,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||||
getComponent={() => MessagesSettingsScreen}
|
getComponent={() => MessagesSettingsScreen}
|
||||||
options={{title: title(msg`Chat settings`), requireAuth: true}}
|
options={{title: title(msg`Chat settings`), requireAuth: true}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="NotificationsSettings"
|
||||||
|
getComponent={() => NotificationsSettingsScreen}
|
||||||
|
options={{title: title(msg`Notification settings`), requireAuth: true}}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="Feeds"
|
name="Feeds"
|
||||||
getComponent={() => FeedsScreen}
|
getComponent={() => FeedsScreen}
|
||||||
|
|
|
@ -189,7 +189,7 @@ let ListMaybePlaceholder = ({
|
||||||
return (
|
return (
|
||||||
<Error
|
<Error
|
||||||
title={errorTitle ?? _(msg`Oops!`)}
|
title={errorTitle ?? _(msg`Oops!`)}
|
||||||
message={errorMessage ?? _(`Something went wrong!`)}
|
message={errorMessage ?? _(msg`Something went wrong!`)}
|
||||||
onRetry={onRetry}
|
onRetry={onRetry}
|
||||||
onGoBack={onGoBack}
|
onGoBack={onGoBack}
|
||||||
sideBorders={sideBorders}
|
sideBorders={sideBorders}
|
||||||
|
|
|
@ -2,6 +2,7 @@ import React from 'react'
|
||||||
import {Pressable, View, ViewStyle} from 'react-native'
|
import {Pressable, View, ViewStyle} from 'react-native'
|
||||||
import Animated, {LinearTransition} from 'react-native-reanimated'
|
import Animated, {LinearTransition} from 'react-native-reanimated'
|
||||||
|
|
||||||
|
import {isNative} from '#/platform/detection'
|
||||||
import {HITSLOP_10} from 'lib/constants'
|
import {HITSLOP_10} from 'lib/constants'
|
||||||
import {
|
import {
|
||||||
atoms as a,
|
atoms as a,
|
||||||
|
@ -459,3 +460,5 @@ export function Radio() {
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const Platform = isNative ? Switch : Checkbox
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
import {createSinglePathSVG} from './TEMPLATE'
|
|
||||||
|
|
||||||
export const SettingsGear2_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
|
||||||
path: 'M11.1 2a1 1 0 0 0-.832.445L8.851 4.57 6.6 4.05a1 1 0 0 0-.932.268l-1.35 1.35a1 1 0 0 0-.267.932l.52 2.251-2.126 1.417A1 1 0 0 0 2 11.1v1.8a1 1 0 0 0 .445.832l2.125 1.417-.52 2.251a1 1 0 0 0 .268.932l1.35 1.35a1 1 0 0 0 .932.267l2.251-.52 1.417 2.126A1 1 0 0 0 11.1 22h1.8a1 1 0 0 0 .832-.445l1.417-2.125 2.251.52a1 1 0 0 0 .932-.268l1.35-1.35a1 1 0 0 0 .267-.932l-.52-2.251 2.126-1.417A1 1 0 0 0 22 12.9v-1.8a1 1 0 0 0-.445-.832L19.43 8.851l.52-2.251a1 1 0 0 0-.268-.932l-1.35-1.35a1 1 0 0 0-.932-.267l-2.251.52-1.417-2.126A1 1 0 0 0 12.9 2h-1.8Zm-.968 4.255L11.635 4h.73l1.503 2.255a1 1 0 0 0 1.057.42l2.385-.551.566.566-.55 2.385a1 1 0 0 0 .42 1.057L20 11.635v.73l-2.255 1.503a1 1 0 0 0-.42 1.057l.551 2.385-.566.566-2.385-.55a1 1 0 0 0-1.057.42L12.365 20h-.73l-1.503-2.255a1 1 0 0 0-1.057-.42l-2.385.551-.566-.566.55-2.385a1 1 0 0 0-.42-1.057L4 12.365v-.73l2.255-1.503a1 1 0 0 0 .42-1.057L6.123 6.69l.566-.566 2.385.55a1 1 0 0 0 1.057-.42ZM8 12a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm4-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z',
|
|
||||||
})
|
|
|
@ -1,10 +1,10 @@
|
||||||
import {timeout} from './timeout'
|
import {timeout} from './timeout'
|
||||||
|
|
||||||
export async function until(
|
export async function until<T>(
|
||||||
retries: number,
|
retries: number,
|
||||||
delay: number,
|
delay: number,
|
||||||
cond: (v: any, err: any) => boolean,
|
cond: (v: T, err: any) => boolean,
|
||||||
fn: () => Promise<any>,
|
fn: () => Promise<T>,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
while (retries > 0) {
|
while (retries > 0) {
|
||||||
try {
|
try {
|
||||||
|
@ -13,7 +13,9 @@ export async function until(
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (cond(undefined, e)) {
|
// TODO: change the type signature of cond to accept undefined
|
||||||
|
// however this breaks every existing usage of until -sfn
|
||||||
|
if (cond(undefined as unknown as T, e)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,14 +42,13 @@ export type CommonNavigatorParams = {
|
||||||
Hashtag: {tag: string; author?: string}
|
Hashtag: {tag: string; author?: string}
|
||||||
MessagesConversation: {conversation: string; embed?: string}
|
MessagesConversation: {conversation: string; embed?: string}
|
||||||
MessagesSettings: undefined
|
MessagesSettings: undefined
|
||||||
|
NotificationsSettings: undefined
|
||||||
Feeds: undefined
|
Feeds: undefined
|
||||||
Start: {name: string; rkey: string}
|
Start: {name: string; rkey: string}
|
||||||
StarterPack: {name: string; rkey: string; new?: boolean}
|
StarterPack: {name: string; rkey: string; new?: boolean}
|
||||||
StarterPackShort: {code: string}
|
StarterPackShort: {code: string}
|
||||||
StarterPackWizard: undefined
|
StarterPackWizard: undefined
|
||||||
StarterPackEdit: {
|
StarterPackEdit: {rkey?: string}
|
||||||
rkey?: string
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||||
|
@ -69,7 +68,7 @@ export type SearchTabNavigatorParams = CommonNavigatorParams & {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NotificationsTabNavigatorParams = CommonNavigatorParams & {
|
export type NotificationsTabNavigatorParams = CommonNavigatorParams & {
|
||||||
Notifications: undefined
|
Notifications: {show?: 'all'}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
|
export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
|
||||||
|
@ -84,7 +83,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
|
||||||
Home: undefined
|
Home: undefined
|
||||||
Search: {q?: string}
|
Search: {q?: string}
|
||||||
Feeds: undefined
|
Feeds: undefined
|
||||||
Notifications: undefined
|
Notifications: {show?: 'all'}
|
||||||
Hashtag: {tag: string; author?: string}
|
Hashtag: {tag: string; author?: string}
|
||||||
Messages: {pushToConversation?: string; animation?: 'push' | 'pop'}
|
Messages: {pushToConversation?: string; animation?: 'push' | 'pop'}
|
||||||
}
|
}
|
||||||
|
@ -96,7 +95,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
|
||||||
Search: {q?: string}
|
Search: {q?: string}
|
||||||
Feeds: undefined
|
Feeds: undefined
|
||||||
NotificationsTab: undefined
|
NotificationsTab: undefined
|
||||||
Notifications: undefined
|
Notifications: {show?: 'all'}
|
||||||
MyProfileTab: undefined
|
MyProfileTab: undefined
|
||||||
Hashtag: {tag: string; author?: string}
|
Hashtag: {tag: string; author?: string}
|
||||||
MessagesTab: undefined
|
MessagesTab: undefined
|
||||||
|
@ -105,9 +104,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
|
||||||
StarterPack: {name: string; rkey: string; new?: boolean}
|
StarterPack: {name: string; rkey: string; new?: boolean}
|
||||||
StarterPackShort: {code: string}
|
StarterPackShort: {code: string}
|
||||||
StarterPackWizard: undefined
|
StarterPackWizard: undefined
|
||||||
StarterPackEdit: {
|
StarterPackEdit: {rkey?: string}
|
||||||
rkey?: string
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE
|
// NOTE
|
||||||
|
|
|
@ -5,6 +5,7 @@ export const router = new Router({
|
||||||
Search: '/search',
|
Search: '/search',
|
||||||
Feeds: '/feeds',
|
Feeds: '/feeds',
|
||||||
Notifications: '/notifications',
|
Notifications: '/notifications',
|
||||||
|
NotificationsSettings: '/notifications/settings',
|
||||||
Settings: '/settings',
|
Settings: '/settings',
|
||||||
LanguageSettings: '/settings/language',
|
LanguageSettings: '/settings/language',
|
||||||
Lists: '/lists',
|
Lists: '/lists',
|
||||||
|
|
|
@ -106,6 +106,7 @@ function Inner() {
|
||||||
title={_(msg`Something went wrong`)}
|
title={_(msg`Something went wrong`)}
|
||||||
message={_(msg`We couldn't load this conversation`)}
|
message={_(msg`We couldn't load this conversation`)}
|
||||||
onRetry={() => convoState.error.retry()}
|
onRetry={() => convoState.error.retry()}
|
||||||
|
sideBorders={false}
|
||||||
/>
|
/>
|
||||||
</CenteredView>
|
</CenteredView>
|
||||||
)
|
)
|
||||||
|
|
|
@ -309,7 +309,7 @@ function DesktopHeader({
|
||||||
a.gap_lg,
|
a.gap_lg,
|
||||||
a.px_lg,
|
a.px_lg,
|
||||||
a.pr_md,
|
a.pr_md,
|
||||||
a.py_md,
|
a.py_sm,
|
||||||
a.border_b,
|
a.border_b,
|
||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
]}>
|
]}>
|
||||||
|
|
|
@ -107,7 +107,7 @@ export function MessagesSettingsScreen({}: Props) {
|
||||||
a.rounded_md,
|
a.rounded_md,
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_25,
|
||||||
]}>
|
]}>
|
||||||
<Text style={[t.atoms.text_contrast_high]}>
|
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
|
||||||
<Trans>
|
<Trans>
|
||||||
You can continue ongoing conversations regardless of which setting
|
You can continue ongoing conversations regardless of which setting
|
||||||
you choose.
|
you choose.
|
||||||
|
|
|
@ -46,11 +46,14 @@ const PAGE_SIZE = 30
|
||||||
type RQPageParam = string | undefined
|
type RQPageParam = string | undefined
|
||||||
|
|
||||||
const RQKEY_ROOT = 'notification-feed'
|
const RQKEY_ROOT = 'notification-feed'
|
||||||
export function RQKEY() {
|
export function RQKEY(priority?: false) {
|
||||||
return [RQKEY_ROOT]
|
return [RQKEY_ROOT, priority]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
|
export function useNotificationFeedQuery(opts?: {
|
||||||
|
enabled?: boolean
|
||||||
|
overridePriorityNotifications?: boolean
|
||||||
|
}) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
|
@ -59,6 +62,10 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
|
||||||
const lastPageCountRef = useRef(0)
|
const lastPageCountRef = useRef(0)
|
||||||
const gate = useGate()
|
const gate = useGate()
|
||||||
|
|
||||||
|
// false: force showing all notifications
|
||||||
|
// undefined: let the server decide
|
||||||
|
const priority = opts?.overridePriorityNotifications ? false : undefined
|
||||||
|
|
||||||
const query = useInfiniteQuery<
|
const query = useInfiniteQuery<
|
||||||
FeedPage,
|
FeedPage,
|
||||||
Error,
|
Error,
|
||||||
|
@ -67,7 +74,7 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
|
||||||
RQPageParam
|
RQPageParam
|
||||||
>({
|
>({
|
||||||
staleTime: STALE.INFINITY,
|
staleTime: STALE.INFINITY,
|
||||||
queryKey: RQKEY(),
|
queryKey: RQKEY(priority),
|
||||||
async queryFn({pageParam}: {pageParam: RQPageParam}) {
|
async queryFn({pageParam}: {pageParam: RQPageParam}) {
|
||||||
let page
|
let page
|
||||||
if (!pageParam) {
|
if (!pageParam) {
|
||||||
|
@ -75,17 +82,17 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
|
||||||
page = unreads.getCachedUnreadPage()
|
page = unreads.getCachedUnreadPage()
|
||||||
}
|
}
|
||||||
if (!page) {
|
if (!page) {
|
||||||
page = (
|
const {page: fetchedPage} = await fetchPage({
|
||||||
await fetchPage({
|
agent,
|
||||||
agent,
|
limit: PAGE_SIZE,
|
||||||
limit: PAGE_SIZE,
|
cursor: pageParam,
|
||||||
cursor: pageParam,
|
queryClient,
|
||||||
queryClient,
|
moderationOpts,
|
||||||
moderationOpts,
|
fetchAdditionalData: true,
|
||||||
fetchAdditionalData: true,
|
shouldUngroupFollowBacks: () => gate('ungroup_follow_backs'),
|
||||||
shouldUngroupFollowBacks: () => gate('ungroup_follow_backs'),
|
priority,
|
||||||
})
|
})
|
||||||
).page
|
page = fetchedPage
|
||||||
}
|
}
|
||||||
|
|
||||||
// if the first page has an unread, mark all read
|
// if the first page has an unread, mark all read
|
||||||
|
|
|
@ -0,0 +1,67 @@
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {until} from '#/lib/async/until'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'
|
||||||
|
import {useAgent} from '#/state/session'
|
||||||
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
|
|
||||||
|
export function useNotificationsSettingsMutation() {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const agent = useAgent()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (keys: string[]) => {
|
||||||
|
const enabled = keys[0] === 'enabled'
|
||||||
|
|
||||||
|
await agent.api.app.bsky.notification.putPreferences({
|
||||||
|
priority: enabled,
|
||||||
|
})
|
||||||
|
|
||||||
|
await until(
|
||||||
|
5, // 5 tries
|
||||||
|
1e3, // 1s delay between tries
|
||||||
|
res => res.data.priority === enabled,
|
||||||
|
() => agent.api.app.bsky.notification.listNotifications({limit: 1}),
|
||||||
|
)
|
||||||
|
|
||||||
|
eagerlySetCachedPriority(queryClient, enabled)
|
||||||
|
},
|
||||||
|
onError: err => {
|
||||||
|
logger.error('Failed to save notification preferences', {
|
||||||
|
safeMessage: err,
|
||||||
|
})
|
||||||
|
Toast.show(
|
||||||
|
_(msg`Failed to save notification preferences, please try again`),
|
||||||
|
'xmark',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
Toast.show(_(msg`Preference saved`))
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function eagerlySetCachedPriority(
|
||||||
|
queryClient: ReturnType<typeof useQueryClient>,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
queryClient.setQueryData(RQKEY_NOTIFS(), (old: any) => {
|
||||||
|
if (!old) return old
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
pages: old.pages.map((page: any) => {
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
priority: enabled,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -22,6 +22,7 @@ export interface FeedPage {
|
||||||
cursor: string | undefined
|
cursor: string | undefined
|
||||||
seenAt: Date
|
seenAt: Date
|
||||||
items: FeedNotification[]
|
items: FeedNotification[]
|
||||||
|
priority: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CachedFeedPage {
|
export interface CachedFeedPage {
|
||||||
|
|
|
@ -39,10 +39,15 @@ export async function fetchPage({
|
||||||
moderationOpts: ModerationOpts | undefined
|
moderationOpts: ModerationOpts | undefined
|
||||||
fetchAdditionalData: boolean
|
fetchAdditionalData: boolean
|
||||||
shouldUngroupFollowBacks?: () => boolean
|
shouldUngroupFollowBacks?: () => boolean
|
||||||
}): Promise<{page: FeedPage; indexedAt: string | undefined}> {
|
priority?: boolean
|
||||||
|
}): Promise<{
|
||||||
|
page: FeedPage
|
||||||
|
indexedAt: string | undefined
|
||||||
|
}> {
|
||||||
const res = await agent.listNotifications({
|
const res = await agent.listNotifications({
|
||||||
limit,
|
limit,
|
||||||
cursor,
|
cursor,
|
||||||
|
// priority,
|
||||||
})
|
})
|
||||||
|
|
||||||
const indexedAt = res.data.notifications[0]?.indexedAt
|
const indexedAt = res.data.notifications[0]?.indexedAt
|
||||||
|
@ -88,6 +93,7 @@ export async function fetchPage({
|
||||||
cursor: res.data.cursor,
|
cursor: res.data.cursor,
|
||||||
seenAt,
|
seenAt,
|
||||||
items: notifsGrouped,
|
items: notifsGrouped,
|
||||||
|
priority: res.data.priority ?? false,
|
||||||
},
|
},
|
||||||
indexedAt,
|
indexedAt,
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,11 +35,13 @@ export function Feed({
|
||||||
onPressTryAgain,
|
onPressTryAgain,
|
||||||
onScrolledDownChange,
|
onScrolledDownChange,
|
||||||
ListHeaderComponent,
|
ListHeaderComponent,
|
||||||
|
overridePriorityNotifications,
|
||||||
}: {
|
}: {
|
||||||
scrollElRef?: ListRef
|
scrollElRef?: ListRef
|
||||||
onPressTryAgain?: () => void
|
onPressTryAgain?: () => void
|
||||||
onScrolledDownChange: (isScrolledDown: boolean) => void
|
onScrolledDownChange: (isScrolledDown: boolean) => void
|
||||||
ListHeaderComponent?: () => JSX.Element
|
ListHeaderComponent?: () => JSX.Element
|
||||||
|
overridePriorityNotifications?: boolean
|
||||||
}) {
|
}) {
|
||||||
const initialNumToRender = useInitialNumToRender()
|
const initialNumToRender = useInitialNumToRender()
|
||||||
|
|
||||||
|
@ -59,7 +61,10 @@ export function Feed({
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
} = useNotificationFeedQuery({enabled: !!moderationOpts})
|
} = useNotificationFeedQuery({
|
||||||
|
enabled: !!moderationOpts,
|
||||||
|
overridePriorityNotifications,
|
||||||
|
})
|
||||||
const isEmpty = !isFetching && !data?.pages[0]?.items.length
|
const isEmpty = !isFetching && !data?.pages[0]?.items.length
|
||||||
|
|
||||||
const items = React.useMemo(() => {
|
const items = React.useMemo(() => {
|
||||||
|
|
|
@ -1,11 +1,19 @@
|
||||||
import React from 'react'
|
import React, {useCallback} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useFocusEffect, useIsFocused} from '@react-navigation/native'
|
import {useFocusEffect, useIsFocused} from '@react-navigation/native'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
|
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||||
|
import {ComposeIcon2} from '#/lib/icons'
|
||||||
|
import {
|
||||||
|
NativeStackScreenProps,
|
||||||
|
NotificationsTabNavigatorParams,
|
||||||
|
} from '#/lib/routes/types'
|
||||||
|
import {s} from '#/lib/styles'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
import {emitSoftReset, listenSoftReset} from '#/state/events'
|
import {emitSoftReset, listenSoftReset} from '#/state/events'
|
||||||
|
@ -17,37 +25,32 @@ import {
|
||||||
import {truncateAndInvalidate} from '#/state/queries/util'
|
import {truncateAndInvalidate} from '#/state/queries/util'
|
||||||
import {useSetMinimalShellMode} from '#/state/shell'
|
import {useSetMinimalShellMode} from '#/state/shell'
|
||||||
import {useComposerControls} from '#/state/shell/composer'
|
import {useComposerControls} from '#/state/shell/composer'
|
||||||
import {useAnalytics} from 'lib/analytics/analytics'
|
import {Feed} from '#/view/com/notifications/Feed'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {FAB} from '#/view/com/util/fab/FAB'
|
||||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
import {MainScrollProvider} from '#/view/com/util/MainScrollProvider'
|
||||||
import {ComposeIcon2} from 'lib/icons'
|
import {ViewHeader} from '#/view/com/util/ViewHeader'
|
||||||
import {
|
|
||||||
NativeStackScreenProps,
|
|
||||||
NotificationsTabNavigatorParams,
|
|
||||||
} from 'lib/routes/types'
|
|
||||||
import {colors, s} from 'lib/styles'
|
|
||||||
import {TextLink} from 'view/com/util/Link'
|
|
||||||
import {ListMethods} from 'view/com/util/List'
|
import {ListMethods} from 'view/com/util/List'
|
||||||
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
|
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
|
||||||
import {CenteredView} from 'view/com/util/Views'
|
import {CenteredView} from 'view/com/util/Views'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button} from '#/components/Button'
|
||||||
|
import {SettingsGear2_Stroke2_Corner0_Rounded as SettingsIcon} from '#/components/icons/SettingsGear2'
|
||||||
|
import {Link} from '#/components/Link'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import {Feed} from '../com/notifications/Feed'
|
import {Text} from '#/components/Typography'
|
||||||
import {FAB} from '../com/util/fab/FAB'
|
|
||||||
import {MainScrollProvider} from '../com/util/MainScrollProvider'
|
|
||||||
import {ViewHeader} from '../com/util/ViewHeader'
|
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<
|
type Props = NativeStackScreenProps<
|
||||||
NotificationsTabNavigatorParams,
|
NotificationsTabNavigatorParams,
|
||||||
'Notifications'
|
'Notifications'
|
||||||
>
|
>
|
||||||
export function NotificationsScreen({}: Props) {
|
export function NotificationsScreen({route: {params}}: Props) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const setMinimalShellMode = useSetMinimalShellMode()
|
const setMinimalShellMode = useSetMinimalShellMode()
|
||||||
const [isScrolledDown, setIsScrolledDown] = React.useState(false)
|
const [isScrolledDown, setIsScrolledDown] = React.useState(false)
|
||||||
const [isLoadingLatest, setIsLoadingLatest] = React.useState(false)
|
const [isLoadingLatest, setIsLoadingLatest] = React.useState(false)
|
||||||
const scrollElRef = React.useRef<ListMethods>(null)
|
const scrollElRef = React.useRef<ListMethods>(null)
|
||||||
const {screen} = useAnalytics()
|
const {screen} = useAnalytics()
|
||||||
const pal = usePalette('default')
|
const t = useTheme()
|
||||||
const {isDesktop} = useWebMediaQueries()
|
const {isDesktop} = useWebMediaQueries()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const unreadNotifs = useUnreadNotifications()
|
const unreadNotifs = useUnreadNotifications()
|
||||||
|
@ -109,56 +112,87 @@ export function NotificationsScreen({}: Props) {
|
||||||
return listenSoftReset(onPressLoadLatest)
|
return listenSoftReset(onPressLoadLatest)
|
||||||
}, [onPressLoadLatest, isScreenFocused])
|
}, [onPressLoadLatest, isScreenFocused])
|
||||||
|
|
||||||
|
const renderButton = useCallback(() => {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to="/notifications/settings"
|
||||||
|
label={_(msg`Notification settings`)}
|
||||||
|
size="small"
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
shape="square"
|
||||||
|
style={[a.justify_center]}>
|
||||||
|
<SettingsIcon size="md" style={t.atoms.text_contrast_medium} />
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}, [_, t])
|
||||||
|
|
||||||
const ListHeaderComponent = React.useCallback(() => {
|
const ListHeaderComponent = React.useCallback(() => {
|
||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
pal.view,
|
t.atoms.bg,
|
||||||
{
|
a.flex_row,
|
||||||
flexDirection: 'row',
|
a.align_center,
|
||||||
alignItems: 'center',
|
a.justify_between,
|
||||||
justifyContent: 'space-between',
|
a.gap_lg,
|
||||||
paddingHorizontal: 18,
|
a.px_lg,
|
||||||
paddingVertical: 12,
|
a.pr_md,
|
||||||
},
|
a.py_sm,
|
||||||
]}>
|
]}>
|
||||||
<TextLink
|
<Button
|
||||||
type="title-lg"
|
label={_(msg`Notifications`)}
|
||||||
href="/notifications"
|
accessibilityHint={_(msg`Refresh notifications`)}
|
||||||
style={[pal.text, {fontWeight: 'bold'}]}
|
onPress={emitSoftReset}>
|
||||||
text={
|
{({hovered, pressed}) => (
|
||||||
<>
|
<Text
|
||||||
<Trans>Notifications</Trans>{' '}
|
style={[
|
||||||
|
a.text_2xl,
|
||||||
|
a.font_bold,
|
||||||
|
(hovered || pressed) && a.underline,
|
||||||
|
]}>
|
||||||
|
<Trans>Notifications</Trans>
|
||||||
{hasNew && (
|
{hasNew && (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
|
left: 4,
|
||||||
top: -8,
|
top: -8,
|
||||||
backgroundColor: colors.blue3,
|
backgroundColor: t.palette.primary_500,
|
||||||
width: 8,
|
width: 8,
|
||||||
height: 8,
|
height: 8,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</Text>
|
||||||
}
|
)}
|
||||||
onPress={emitSoftReset}
|
</Button>
|
||||||
/>
|
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
{isLoadingLatest ? <Loader size="md" /> : <></>}
|
{isLoadingLatest ? <Loader size="md" /> : <></>}
|
||||||
|
{renderButton()}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return <></>
|
return <></>
|
||||||
}, [isDesktop, pal, hasNew, isLoadingLatest])
|
}, [isDesktop, t, hasNew, renderButton, _, isLoadingLatest])
|
||||||
|
|
||||||
const renderHeaderSpinner = React.useCallback(() => {
|
const renderHeaderSpinner = React.useCallback(() => {
|
||||||
return (
|
return (
|
||||||
<View style={{width: 30, height: 20, alignItems: 'flex-end'}}>
|
<View
|
||||||
|
style={[
|
||||||
|
{width: 30, height: 20},
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_end,
|
||||||
|
a.gap_md,
|
||||||
|
]}>
|
||||||
{isLoadingLatest ? <Loader width={20} /> : <></>}
|
{isLoadingLatest ? <Loader width={20} /> : <></>}
|
||||||
|
{renderButton()}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}, [isLoadingLatest])
|
}, [renderButton, isLoadingLatest])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CenteredView
|
<CenteredView
|
||||||
|
@ -176,6 +210,7 @@ export function NotificationsScreen({}: Props) {
|
||||||
onScrolledDownChange={setIsScrolledDown}
|
onScrolledDownChange={setIsScrolledDown}
|
||||||
scrollElRef={scrollElRef}
|
scrollElRef={scrollElRef}
|
||||||
ListHeaderComponent={ListHeaderComponent}
|
ListHeaderComponent={ListHeaderComponent}
|
||||||
|
overridePriorityNotifications={params?.show === 'all'}
|
||||||
/>
|
/>
|
||||||
</MainScrollProvider>
|
</MainScrollProvider>
|
||||||
{(isScrolledDown || hasNew) && (
|
{(isScrolledDown || hasNew) && (
|
||||||
|
|
|
@ -0,0 +1,94 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {AllNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
|
||||||
|
import {useNotificationFeedQuery} from '#/state/queries/notifications/feed'
|
||||||
|
import {useNotificationsSettingsMutation} from '#/state/queries/notifications/settings'
|
||||||
|
import {ViewHeader} from '#/view/com/util/ViewHeader'
|
||||||
|
import {CenteredView} from '#/view/com/util/Views'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Error} from '#/components/Error'
|
||||||
|
import * as Toggle from '#/components/forms/Toggle'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<AllNavigatorParams, 'NotificationsSettings'>
|
||||||
|
export function NotificationsSettingsScreen({}: Props) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
const {data, isError: isQueryError, refetch} = useNotificationFeedQuery()
|
||||||
|
const serverPriority = data?.pages.at(0)?.priority
|
||||||
|
|
||||||
|
const {
|
||||||
|
mutate: onChangePriority,
|
||||||
|
isPending: isMutationPending,
|
||||||
|
variables,
|
||||||
|
} = useNotificationsSettingsMutation()
|
||||||
|
|
||||||
|
const priority = isMutationPending
|
||||||
|
? variables[0] === 'enabled'
|
||||||
|
: serverPriority
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CenteredView style={a.flex_1} sideBorders>
|
||||||
|
<ViewHeader
|
||||||
|
title={_(msg`Notification Settings`)}
|
||||||
|
showOnDesktop
|
||||||
|
showBorder
|
||||||
|
/>
|
||||||
|
{isQueryError ? (
|
||||||
|
<Error
|
||||||
|
title={_(msg`Oops!`)}
|
||||||
|
message={_(msg`Something went wrong!`)}
|
||||||
|
onRetry={refetch}
|
||||||
|
sideBorders={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={[a.p_lg, a.gap_md]}>
|
||||||
|
<Text style={[a.text_lg, a.font_bold]}>
|
||||||
|
<FontAwesomeIcon icon="flask" style={t.atoms.text} />{' '}
|
||||||
|
<Trans>Notification filters</Trans>
|
||||||
|
</Text>
|
||||||
|
<Toggle.Group
|
||||||
|
label={_(msg`Priority notifications`)}
|
||||||
|
type="checkbox"
|
||||||
|
values={priority ? ['enabled'] : []}
|
||||||
|
onChange={onChangePriority}
|
||||||
|
disabled={typeof priority !== 'boolean' || isMutationPending}>
|
||||||
|
<View>
|
||||||
|
<Toggle.Item
|
||||||
|
name="enabled"
|
||||||
|
label={_(msg`Enable priority notifications`)}
|
||||||
|
style={[a.justify_between, a.py_sm]}>
|
||||||
|
<Toggle.LabelText>
|
||||||
|
<Trans>Enable priority notifications</Trans>
|
||||||
|
</Toggle.LabelText>
|
||||||
|
{!data ? <Loader size="md" /> : <Toggle.Platform />}
|
||||||
|
</Toggle.Item>
|
||||||
|
</View>
|
||||||
|
</Toggle.Group>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.mt_sm,
|
||||||
|
a.px_xl,
|
||||||
|
a.py_lg,
|
||||||
|
a.rounded_md,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
]}>
|
||||||
|
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
|
||||||
|
<Trans>
|
||||||
|
Experimental: When this preference is enabled, you'll only
|
||||||
|
receive reply and quote notifications from users you follow.
|
||||||
|
We'll continue to add more controls here over time.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</CenteredView>
|
||||||
|
)
|
||||||
|
}
|
|
@ -34,10 +34,10 @@
|
||||||
jsonpointer "^5.0.0"
|
jsonpointer "^5.0.0"
|
||||||
leven "^3.1.0"
|
leven "^3.1.0"
|
||||||
|
|
||||||
"@atproto/api@^0.12.23":
|
"@atproto/api@0.12.25":
|
||||||
version "0.12.23"
|
version "0.12.25"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.23.tgz#b3409817d0b981a64f30d16e8257f0fe261338af"
|
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.25.tgz#9eeb51484106a5e07f89f124e505674a3574f93b"
|
||||||
integrity sha512-fgQ30u+q9smX5g41eep7fISSkSAhRkX0inc81PZ82QwcHbFkC8ePaha/KP0CoTaPWKi7EsC89Z/8BEBCJo0oBA==
|
integrity sha512-IV3vGPnDw9bmyP/JOd8YKbm8fOpRAgJpEUVnIZNVb/Vo8v+WOroOjrJxtzdHOcXTL9IEcTTyXSCc7yE7kwhN2A==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/common-web" "^0.3.0"
|
"@atproto/common-web" "^0.3.0"
|
||||||
"@atproto/lexicon" "^0.4.0"
|
"@atproto/lexicon" "^0.4.0"
|
||||||
|
|
Loading…
Reference in New Issue