add list cards, add inline variant to NameAndHandle

This commit is contained in:
Samuel Newman
2025-06-19 19:32:24 +03:00
parent e4dfa77812
commit 9180aa10be
4 changed files with 234 additions and 50 deletions
+68 -5
View File
@@ -11,6 +11,8 @@ import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {getModerationCauseKey} from '#/lib/moderation'
import {type LogEvents} from '#/lib/statsig/statsig'
import {forceLTR} from '#/lib/strings/bidi'
import {NON_BREAKING_SPACE} from '#/lib/strings/constants'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useProfileShadow} from '#/state/cache/profile-shadow'
@@ -18,7 +20,7 @@ import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {atoms as a, platform, useTheme} from '#/alf'
import {
Button,
ButtonIcon,
@@ -183,14 +185,76 @@ export function AvatarPlaceholder() {
export function NameAndHandle({
profile,
moderationOpts,
inline = false,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
inline?: boolean
}) {
if (inline) {
return (
<InlineNameAndHandle profile={profile} moderationOpts={moderationOpts} />
)
} else {
return (
<View style={[a.flex_1]}>
<Name profile={profile} moderationOpts={moderationOpts} />
<Handle profile={profile} />
</View>
)
}
}
function InlineNameAndHandle({
profile,
moderationOpts,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
}) {
const t = useTheme()
const verification = useSimpleVerificationState({profile})
const moderation = moderateProfile(profile, moderationOpts)
const name = sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)
const handle = sanitizeHandle(profile.handle, '@')
return (
<View style={[a.flex_1]}>
<Name profile={profile} moderationOpts={moderationOpts} />
<Handle profile={profile} />
<View style={[a.flex_row, a.align_end, a.flex_shrink]}>
<Text
emoji
style={[
a.font_bold,
a.leading_tight,
a.flex_shrink_0,
{maxWidth: '70%'},
]}
numberOfLines={1}>
{forceLTR(name)}
</Text>
{verification.showBadge && (
<View
style={[
a.pl_2xs,
a.self_center,
{marginTop: platform({default: 0, android: -1})},
]}>
<VerificationCheck
width={platform({android: 13, default: 12})}
verifier={verification.role === 'verifier'}
/>
</View>
)}
<Text
style={[
a.leading_tight,
t.atoms.text_contrast_medium,
{flexShrink: 10},
]}
numberOfLines={1}>
{NON_BREAKING_SPACE + handle}
</Text>
</View>
)
}
@@ -234,7 +298,6 @@ export function Handle({profile}: {profile: bsky.profile.AnyProfileView}) {
return (
<Text
emoji
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{handle}
@@ -1,12 +1,9 @@
import {AppBskyActorDefs} from '@atproto/api'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import type * as bsky from '#/types/bsky'
export function createSanitizedDisplayName(
profile:
| AppBskyActorDefs.ProfileViewBasic
| AppBskyActorDefs.ProfileViewDetailed,
profile: bsky.profile.AnyProfileView,
noAt = false,
) {
if (profile.displayName != null && profile.displayName !== '') {
@@ -1,21 +1,33 @@
import {useCallback} from 'react'
import {Text, View} from 'react-native'
import {useCallback, useMemo} from 'react'
import {type ListRenderItemInfo, Text as RNText, View} from 'react-native'
import {type ModerationOpts} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useInfiniteQuery} from '@tanstack/react-query'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {
type AllNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
import {useAgent} from '#/state/session'
import {List} from '#/view/com/util/List'
import {atoms as a, useTheme} from '#/alf'
import * as Admonition from '#/components/Admonition'
import {Button, ButtonText} from '#/components/Button'
import {Bell_Filled_Corner0_Rounded as BellIcon} from '#/components/icons/Bell'
import {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
import * as Layout from '#/components/Layout'
import {InlineLinkText} from '#/components/Link'
import {ListFooter} from '#/components/Lists'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
import * as SettingsList from '../components/SettingsList'
import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
import {PreferenceControls} from './components/PreferenceControls'
@@ -28,13 +40,56 @@ export function ActivityNotificationSettingsScreen({}: Props) {
const t = useTheme()
const {_} = useLingui()
const {data: preferences, isError} = useNotificationSettingsQuery()
const agent = useAgent()
const moderationOpts = useModerationOpts()
// TODO: Fetch subscriptions
const items = [] as string[]
const {
data: subscriptions,
isPending,
error,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
} = useInfiniteQuery({
queryKey: ['TEMP'],
queryFn: async ({pageParam}) => {
const response = await agent.getFollows({
actor: agent.assertDid,
cursor: pageParam,
})
return response.data
},
initialPageParam: undefined as string | undefined,
getNextPageParam: prev => prev.cursor,
})
const renderItem = useCallback(() => {
return null
}, [])
const items = useMemo(() => {
if (!subscriptions) return []
return subscriptions?.pages.flatMap(page => page.follows)
}, [subscriptions])
const renderItem = useCallback(
({item}: ListRenderItemInfo<bsky.profile.AnyProfileView>) => {
if (!moderationOpts) return null
return (
<ActivitySubscriptionCard
profile={item}
moderationOpts={moderationOpts}
/>
)
},
[moderationOpts],
)
const onEndReached = useCallback(async () => {
if (isFetchingNextPage || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more likes', {message: err})
}
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
return (
<Layout.Screen>
@@ -78,50 +133,118 @@ export function ActivityNotificationSettingsScreen({}: Props) {
</SettingsList.Container>
}
data={items}
keyExtractor={keyExtractor}
renderItem={renderItem}
onEndReached={onEndReached}
onEndReachedThreshold={4}
ListEmptyComponent={
<View style={[a.px_xl, a.py_md]}>
<Admonition.Outer type="tip">
<Admonition.Row>
<Admonition.Icon />
<View style={[a.flex_1, a.gap_sm]}>
<Admonition.Text>
<Trans>
Enable notifications for an account by visiting their
profile and pressing the{' '}
<Text style={[a.font_bold, t.atoms.text_contrast_high]}>
bell icon
</Text>{' '}
<BellIcon size="xs" style={t.atoms.text_contrast_high} />.
</Trans>
</Admonition.Text>
<Admonition.Text>
<Trans>
By default, only accounts you follow can receive alerts
from you – this can be changed in{' '}
<InlineLinkText
label={_(msg`Privacy & Security settings`)}
to={{screen: 'PrivacyAndSecuritySettings'}}
style={[a.font_bold]}>
Settings &rarr; Privacy &amp; Security
</InlineLinkText>
.
</Trans>
</Admonition.Text>
</View>
</Admonition.Row>
</Admonition.Outer>
{!isPending ? (
<Admonition.Outer type="tip">
<Admonition.Row>
<Admonition.Icon />
<View style={[a.flex_1, a.gap_sm]}>
<Admonition.Text>
<Trans>
Enable notifications for an account by visiting their
profile and pressing the{' '}
<RNText
style={[a.font_bold, t.atoms.text_contrast_high]}>
bell icon
</RNText>{' '}
<BellIcon
size="xs"
style={t.atoms.text_contrast_high}
/>
.
</Trans>
</Admonition.Text>
<Admonition.Text>
<Trans>
By default, only accounts you follow can receive alerts
from you – this can be changed in{' '}
<InlineLinkText
label={_(msg`Privacy & Security settings`)}
to={{screen: 'PrivacyAndSecuritySettings'}}
style={[a.font_bold]}>
Settings &rarr; Privacy &amp; Security
</InlineLinkText>
.
</Trans>
</Admonition.Text>
</View>
</Admonition.Row>
</Admonition.Outer>
) : (
<View style={[a.flex_1, a.align_center, a.pt_xl]}>
<Loader size="lg" />
</View>
)}
</View>
}
ListFooterComponent={
<ListFooter
style={[items.length === 0 && a.border_transparent]}
// isFetchingNextPage={isFetchingNextPage}
// error={cleanError(error)}
// onRetry={fetchNextPage}
isFetchingNextPage={isFetchingNextPage}
error={cleanError(error)}
onRetry={fetchNextPage}
hasNextPage={hasNextPage}
/>
}
windowSize={11}
/>
</Layout.Screen>
)
}
function keyExtractor(item: bsky.profile.AnyProfileView) {
return item.did
}
function ActivitySubscriptionCard({
profile,
moderationOpts,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
}) {
const {_} = useLingui()
const t = useTheme()
return (
<View style={[a.py_md, a.px_xl, a.border_t, t.atoms.border_contrast_low]}>
<ProfileCard.Outer>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
/>
<View style={[a.flex_1, a.gap_2xs]}>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
inline
/>
<Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>Posts, Replies</Trans>
</Text>
</View>
<Button
label={_(
msg`Edit notifications from ${createSanitizedDisplayName(
profile,
)}`,
)}
size="small"
color="primary"
variant="solid">
<ButtonText>
<Trans>Edit</Trans>
</ButtonText>
</Button>
</ProfileCard.Header>
{/* <ProfileCard.Labels profile={profile} moderationOpts={moderationOpts} /> */}
</ProfileCard.Outer>
</View>
)
}
+2 -1
View File
@@ -96,7 +96,8 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
a.font_bold,
t.atoms.text,
a.leading_tight,
{maxWidth: '70%', flexShrink: 0},
a.flex_shrink_0,
{maxWidth: '70%'},
]}>
{forceLTR(
sanitizeDisplayName(