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 {useActorStatus} from '#/lib/actor-status'
import {getModerationCauseKey} from '#/lib/moderation' import {getModerationCauseKey} from '#/lib/moderation'
import {type LogEvents} from '#/lib/statsig/statsig' 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 {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {useProfileShadow} from '#/state/cache/profile-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow'
@@ -18,7 +20,7 @@ import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar' import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, platform, useTheme} from '#/alf'
import { import {
Button, Button,
ButtonIcon, ButtonIcon,
@@ -183,14 +185,76 @@ export function AvatarPlaceholder() {
export function NameAndHandle({ export function NameAndHandle({
profile, profile,
moderationOpts, 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 profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts 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 ( return (
<View style={[a.flex_1]}> <View style={[a.flex_row, a.align_end, a.flex_shrink]}>
<Name profile={profile} moderationOpts={moderationOpts} /> <Text
<Handle profile={profile} /> 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> </View>
) )
} }
@@ -234,7 +298,6 @@ export function Handle({profile}: {profile: bsky.profile.AnyProfileView}) {
return ( return (
<Text <Text
emoji
style={[a.leading_snug, t.atoms.text_contrast_medium]} style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}> numberOfLines={1}>
{handle} {handle}
@@ -1,12 +1,9 @@
import {AppBskyActorDefs} from '@atproto/api'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import type * as bsky from '#/types/bsky'
export function createSanitizedDisplayName( export function createSanitizedDisplayName(
profile: profile: bsky.profile.AnyProfileView,
| AppBskyActorDefs.ProfileViewBasic
| AppBskyActorDefs.ProfileViewDetailed,
noAt = false, noAt = false,
) { ) {
if (profile.displayName != null && profile.displayName !== '') { if (profile.displayName != null && profile.displayName !== '') {
@@ -1,21 +1,33 @@
import {useCallback} from 'react' import {useCallback, useMemo} from 'react'
import {Text, View} from 'react-native' import {type ListRenderItemInfo, Text as RNText, View} from 'react-native'
import {type ModerationOpts} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useInfiniteQuery} from '@tanstack/react-query'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import { import {
type AllNavigatorParams, type AllNavigatorParams,
type NativeStackScreenProps, type NativeStackScreenProps,
} from '#/lib/routes/types' } 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 {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
import {useAgent} from '#/state/session'
import {List} from '#/view/com/util/List' import {List} from '#/view/com/util/List'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import * as Admonition from '#/components/Admonition' import * as Admonition from '#/components/Admonition'
import {Button, ButtonText} from '#/components/Button'
import {Bell_Filled_Corner0_Rounded as BellIcon} from '#/components/icons/Bell' import {Bell_Filled_Corner0_Rounded as BellIcon} from '#/components/icons/Bell'
import {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging' import {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
import {InlineLinkText} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import {ListFooter} from '#/components/Lists' 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 * as SettingsList from '../components/SettingsList'
import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle' import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
import {PreferenceControls} from './components/PreferenceControls' import {PreferenceControls} from './components/PreferenceControls'
@@ -28,13 +40,56 @@ export function ActivityNotificationSettingsScreen({}: Props) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const {data: preferences, isError} = useNotificationSettingsQuery() const {data: preferences, isError} = useNotificationSettingsQuery()
const agent = useAgent()
const moderationOpts = useModerationOpts()
// TODO: Fetch subscriptions // 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(() => { const items = useMemo(() => {
return null 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 ( return (
<Layout.Screen> <Layout.Screen>
@@ -78,50 +133,118 @@ export function ActivityNotificationSettingsScreen({}: Props) {
</SettingsList.Container> </SettingsList.Container>
} }
data={items} data={items}
keyExtractor={keyExtractor}
renderItem={renderItem} renderItem={renderItem}
onEndReached={onEndReached}
onEndReachedThreshold={4}
ListEmptyComponent={ ListEmptyComponent={
<View style={[a.px_xl, a.py_md]}> <View style={[a.px_xl, a.py_md]}>
<Admonition.Outer type="tip"> {!isPending ? (
<Admonition.Row> <Admonition.Outer type="tip">
<Admonition.Icon /> <Admonition.Row>
<View style={[a.flex_1, a.gap_sm]}> <Admonition.Icon />
<Admonition.Text> <View style={[a.flex_1, a.gap_sm]}>
<Trans> <Admonition.Text>
Enable notifications for an account by visiting their <Trans>
profile and pressing the{' '} Enable notifications for an account by visiting their
<Text style={[a.font_bold, t.atoms.text_contrast_high]}> profile and pressing the{' '}
bell icon <RNText
</Text>{' '} style={[a.font_bold, t.atoms.text_contrast_high]}>
<BellIcon size="xs" style={t.atoms.text_contrast_high} />. bell icon
</Trans> </RNText>{' '}
</Admonition.Text> <BellIcon
<Admonition.Text> size="xs"
<Trans> style={t.atoms.text_contrast_high}
By default, only accounts you follow can receive alerts />
from you – this can be changed in{' '} .
<InlineLinkText </Trans>
label={_(msg`Privacy & Security settings`)} </Admonition.Text>
to={{screen: 'PrivacyAndSecuritySettings'}} <Admonition.Text>
style={[a.font_bold]}> <Trans>
Settings &rarr; Privacy &amp; Security By default, only accounts you follow can receive alerts
</InlineLinkText> from you – this can be changed in{' '}
. <InlineLinkText
</Trans> label={_(msg`Privacy & Security settings`)}
</Admonition.Text> to={{screen: 'PrivacyAndSecuritySettings'}}
</View> style={[a.font_bold]}>
</Admonition.Row> Settings &rarr; Privacy &amp; Security
</Admonition.Outer> </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> </View>
} }
ListFooterComponent={ ListFooterComponent={
<ListFooter <ListFooter
style={[items.length === 0 && a.border_transparent]} style={[items.length === 0 && a.border_transparent]}
// isFetchingNextPage={isFetchingNextPage} isFetchingNextPage={isFetchingNextPage}
// error={cleanError(error)} error={cleanError(error)}
// onRetry={fetchNextPage} onRetry={fetchNextPage}
hasNextPage={hasNextPage}
/> />
} }
windowSize={11}
/> />
</Layout.Screen> </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, a.font_bold,
t.atoms.text, t.atoms.text,
a.leading_tight, a.leading_tight,
{maxWidth: '70%', flexShrink: 0}, a.flex_shrink_0,
{maxWidth: '70%'},
]}> ]}>
{forceLTR( {forceLTR(
sanitizeDisplayName( sanitizeDisplayName(