Compare commits

...

4 Commits

Author SHA1 Message Date
Eric Bailey d6f11e70c6 Clean up issued and create screens/flows 2024-12-15 16:08:12 -06:00
Eric Bailey 9f40574a13 WIP UI 2024-12-14 16:38:33 -06:00
Eric Bailey 1d84922481 Make isInternalUser true in dev 2024-12-14 14:18:01 -06:00
Eric Bailey 5028f7ee2c Add screens and routes 2024-12-13 16:13:01 -06:00
18 changed files with 937 additions and 1 deletions
+4
View File
@@ -280,6 +280,10 @@ func serve(cctx *cli.Context) error {
e.GET("/profile/:handleOrDID/feed/:rkey", server.WebGeneric)
e.GET("/profile/:handleOrDID/feed/:rkey/liked-by", server.WebGeneric)
e.GET("/profile/:handleOrDID/labeler/liked-by", server.WebGeneric)
e.GET("/profile/:handleOrDID/vouches", server.WebGeneric)
e.GET("/profile/:handleOrDID/vouches/create", server.WebGeneric)
e.GET("/profile/:handleOrDID/vouches/issued", server.WebGeneric)
e.GET("/profile/:handleOrDID/vouches/received", server.WebGeneric)
// profile RSS feed (DID not handle)
e.GET("/profile/:ident/rss", server.WebProfileRSS)
+24
View File
@@ -78,6 +78,10 @@ import {ProfileFeedScreen} from '#/screens/Profile/ProfileFeed'
import {ProfileFollowersScreen} from '#/screens/Profile/ProfileFollowers'
import {ProfileFollowsScreen} from '#/screens/Profile/ProfileFollows'
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
import {Screen as ProfileVouches} from '#/screens/Profile/Vouches'
import {Screen as ProfileVouchesCreate} from '#/screens/Profile/Vouches/Create'
import {Screen as ProfileVouchesIssued} from '#/screens/Profile/Vouches/Issued'
import {Screen as ProfileVouchesReceived} from '#/screens/Profile/Vouches/Received'
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings'
@@ -239,6 +243,26 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
getComponent={() => ProfileLabelerLikedByScreen}
options={{title: title(msg`Liked by`)}}
/>
<Stack.Screen
name="ProfileVouches"
getComponent={() => ProfileVouches}
options={{title: title(msg`Vouches`)}}
/>
<Stack.Screen
name="ProfileVouchesCreate"
getComponent={() => ProfileVouchesCreate}
options={{title: title(msg`Create Vouch`)}}
/>
<Stack.Screen
name="ProfileVouchesIssued"
getComponent={() => ProfileVouchesIssued}
options={{title: title(msg`Issued Vouch`)}}
/>
<Stack.Screen
name="ProfileVouchesReceived"
getComponent={() => ProfileVouchesReceived}
options={{title: title(msg`My Vouches`)}}
/>
<Stack.Screen
name="Debug"
getComponent={() => Storybook}
+25
View File
@@ -0,0 +1,25 @@
import {timeout} from './timeout'
export async function poll<T>(
retries: number,
delay: number,
shouldExit: (
props: {response: T; error: undefined} | {response: undefined; error: any},
) => boolean,
request: () => Promise<T>,
): Promise<T | undefined> {
while (retries > 0) {
try {
const v = await request()
if (shouldExit({response: v, error: undefined})) {
return v
}
} catch (e: any) {
if (shouldExit({response: undefined, error: e})) {
return undefined
}
}
await timeout(delay)
retries--
}
}
+2
View File
@@ -32,6 +32,8 @@ export const DISCOVER_DEBUG_DIDS: Record<string, true> = {
'did:plc:3jpt2mvvsumj2r7eqk4gzzjz': true, // esb.lol
'did:plc:vjug55kidv6sye7ykr5faxxn': true, // emilyliu.me
}
export const isInternalUser = (did?: string) =>
__DEV__ ? true : did ? DISCOVER_DEBUG_DIDS[did] : false
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
export function FEEDBACK_FORM_URL({
+4
View File
@@ -23,6 +23,10 @@ export type CommonNavigatorParams = {
ProfileFeed: {name: string; rkey: string}
ProfileFeedLikedBy: {name: string; rkey: string}
ProfileLabelerLikedBy: {name: string}
ProfileVouches: {name: string}
ProfileVouchesCreate: {name: string}
ProfileVouchesIssued: {name: string}
ProfileVouchesReceived: {name: string}
Debug: undefined
DebugMod: undefined
SharedPreferencesTester: undefined
+5
View File
@@ -26,6 +26,11 @@ export const router = new Router({
ProfileFeed: '/profile/:name/feed/:rkey',
ProfileFeedLikedBy: '/profile/:name/feed/:rkey/liked-by',
ProfileLabelerLikedBy: '/profile/:name/labeler/liked-by',
ProfileVouches: '/profile/:name/vouches',
ProfileVouchesCreate: '/profile/:name/vouches/create',
ProfileVouchesIssued: '/profile/:name/vouches/issued',
ProfileVouchesReceived: '/profile/:name/vouches/received',
// debug
Debug: '/sys/debug',
DebugMod: '/sys/debug-mod',
@@ -0,0 +1,114 @@
import React from 'react'
import {View} from 'react-native'
import {msg,Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {ZodError} from 'zod'
import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped'
import {useCreateVouch} from '#/state/queries/vouches/useCreateVouch'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useGutters} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon,ButtonText} from '#/components/Button'
import * as TextField from '#/components/forms/TextField'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
export function Screen() {
const baseGutters = useGutters(['base'])
return (
<Layout.Screen>
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>
<Trans>Create Vouch</Trans>
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<Layout.Content>
<View style={[baseGutters]}>
<Form />
</View>
</Layout.Content>
</Layout.Screen>
)
}
function Form() {
const {_} = useLingui()
const navigation = useNavigationDeduped()
const {currentAccount} = useSession()
const [subject, setSubject] = React.useState('')
const [relationship, setRelationship] = React.useState('')
const [errors, setErrors] = React.useState<string[]>([])
const {mutateAsync: createVouch, isPending} = useCreateVouch()
const onSubmit = async () => {
setErrors([])
try {
await createVouch({subject, relationship})
navigation.navigate('ProfileVouches', {
name: currentAccount!.handle,
})
Toast.show(_(`Vouch created`), 'check')
} catch (e: any) {
if (e instanceof ZodError) {
setErrors(e.errors.map(err => err.message))
} else {
setErrors([e.message])
}
}
}
return (
<View style={[a.gap_md]}>
<View style={[]}>
<TextField.LabelText>
<Trans>User DID</Trans>
</TextField.LabelText>
<TextField.Input label={_(msg`Subject`)} onChangeText={setSubject} />
</View>
<View style={[]}>
<TextField.LabelText>
<Trans>User DID</Trans>
</TextField.LabelText>
<TextField.Input
label={_(msg`Relationship`)}
onChangeText={setRelationship}
/>
</View>
<View style={[a.align_end, a.pt_sm]}>
<Button
label={_(msg`Create Vouch`)}
size="large"
color="primary"
variant="solid"
onPress={onSubmit}>
<ButtonText>
<Trans>Create</Trans>
</ButtonText>
<ButtonIcon icon={isPending ? Loader : Plus} />
</Button>
</View>
{!!errors.length && (
<View style={[a.gap_sm]}>
{errors.map((error, i) => (
<Admonition key={i} type="error">
{' '}
{error}{' '}
</Admonition>
))}
</View>
)}
</View>
)
}
@@ -0,0 +1,178 @@
import React from 'react'
import {ListRenderItemInfo,View} from 'react-native'
import {AppBskyActorDefs,AppBskyGraphDefs} from '@atproto/api'
import {msg,Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useVouchesIssued} from '#/state/queries/vouches/useVouchesIssued'
import {List} from '#/view/com/util/List'
import {VouchList} from '#/screens/Profile/Vouches/components/Vouch'
import {atoms as a, useGutters,useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition'
import * as Layout from '#/components/Layout'
import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
export function Screen() {
return (
<Layout.Screen>
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>
<Trans>Issued Vouches</Trans>
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<Inner />
</Layout.Screen>
)
}
type ListItem =
| {
key: string
type: 'error'
}
| {
key: string
type: 'placeholder'
}
| {
key: string
type: 'empty'
}
| {
key: string
type: 'vouch'
vouch: AppBskyGraphDefs.VouchView
subject: AppBskyActorDefs.ProfileViewBasic
}
export function Inner() {
const [isPTR, setIsPTR] = React.useState(false)
const {
data,
isFetching,
error,
refetch,
fetchNextPage,
isFetchingNextPage,
hasNextPage,
} = useVouchesIssued()
const onEndReached = React.useCallback(() => {
if (!hasNextPage || isFetchingNextPage) return
fetchNextPage()
}, [fetchNextPage, isFetchingNextPage, hasNextPage])
const onPullToRefresh = React.useCallback(async () => {
setIsPTR(true)
await refetch()
setIsPTR(false)
}, [setIsPTR, refetch])
const items = React.useMemo<ListItem[]>(() => {
const _items: ListItem[] = []
const vouches = data?.pages.flatMap(page => page.vouches) || []
if (vouches.length) {
for (const vouch of vouches) {
_items.push({
key: vouch.cid,
type: 'vouch',
vouch,
subject: vouch.subject!,
})
}
} else {
_items.push({key: 'empty', type: 'empty'})
}
if (isFetching) {
_items.push({key: 'loading', type: 'placeholder'})
} else if (error) {
_items.push({key: 'error', type: 'error'})
}
return _items
}, [data, error, isFetching])
const renderItem = React.useCallback(
({item, index}: ListRenderItemInfo<ListItem>) => {
switch (item.type) {
case 'vouch': {
return (
<VouchList
vouch={item.vouch}
subject={item.subject}
first={index === 0}
/>
)
}
case 'empty': {
return <Empty />
}
case 'placeholder': {
// TODO
return <View style={[a.gap_md]} />
}
case 'error': {
// TODO
return (
<Admonition type="error">
<Trans>Error</Trans>
</Admonition>
)
}
}
},
[],
)
return (
<List
data={items}
keyExtractor={item => item.key}
renderItem={renderItem}
refreshing={isPTR}
onRefresh={onPullToRefresh}
initialNumToRender={10}
onEndReached={onEndReached}
desktopFixedHeight
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
/>
)
}
export function Empty() {
const t = useTheme()
const {_} = useLingui()
const gutters = useGutters(['base', 'wide'])
return (
<View style={[gutters]}>
<Text
style={[
a.text_md,
a.leading_snug,
a.text_center,
t.atoms.text_contrast_medium,
]}>
<Trans>
You haven't vouched for anyone.{' '}
<InlineLinkText
label={_(msg`Create a vouch`)}
to={{screen: 'ProfileVouchesCreate'}}
style={[a.text_md, a.leading_snug]}>
<Trans>Create one here.</Trans>
</InlineLinkText>
</Trans>
</Text>
</View>
)
}
@@ -0,0 +1,24 @@
import {Trans} from '@lingui/macro'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
export function Screen() {
return (
<Layout.Screen>
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>
<Trans>My Vouches</Trans>
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<Layout.Content>
<Text>Mine</Text>
</Layout.Content>
</Layout.Screen>
)
}
@@ -0,0 +1,177 @@
import React from 'react'
import {View} from 'react-native'
import {
AppBskyActorDefs,
AppBskyGraphDefs,
AppBskyGraphVouch,
} from '@atproto/api'
import {msg,Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useRevokeVouch} from '#/state/queries/vouches/useRevokeVouch'
import * as Toast from '#/view/com/util/Toast'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useGutters,useTheme} from '#/alf'
import {Button, ButtonIcon,ButtonText} from '#/components/Button'
import {Divider} from '#/components/Divider'
import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export function Vouch({
vouch,
subject,
}: {
vouch: AppBskyGraphDefs.VouchView
subject: AppBskyActorDefs.ProfileViewBasic
}) {
const t = useTheme()
const record = vouch.record
const ago = useGetTimeAgo()
if (!AppBskyGraphVouch.isRecord(record)) return null
return (
<View
style={[
a.p_sm,
a.rounded_md,
a.flex_1,
a.gap_sm,
t.atoms.bg_contrast_25,
{
width: 240,
},
]}>
<View style={[a.flex_row, a.align_start, a.gap_sm]}>
<UserAvatar size={32} avatar={subject.avatar} />
<View style={[a.gap_2xs]}>
<Text style={[a.text_md, a.font_bold, a.leading_tight]}>
@{subject.handle}
</Text>
<Text style={[a.leading_tight, t.atoms.text_contrast_medium]}>
{record.relationship}
</Text>
</View>
</View>
<Divider />
<View style={[a.flex_row, a.align_start, a.justify_between, a.gap_xl]}>
<Text style={[a.text_xs, a.font_bold, a.leading_tight]}>
{vouch.accept ? 'Accepted' : 'Pending'}
</Text>
<Text style={[a.text_xs, a.leading_tight]}>
<Trans>{ago(record.createdAt, new Date())} ago</Trans>
</Text>
</View>
</View>
)
}
export function VouchList({
vouch,
subject,
first,
}: {
vouch: AppBskyGraphDefs.VouchView
subject: AppBskyActorDefs.ProfileViewBasic
first?: boolean
}) {
const t = useTheme()
const {_} = useLingui()
const record = vouch.record as AppBskyGraphVouch.Record
const ago = useGetTimeAgo()
const gutters = useGutters(['compact', 'base'])
const relationship = useRelationshipLabel(record.relationship)
const {mutateAsync, isPending} = useRevokeVouch()
const revoke = React.useCallback(async () => {
try {
await mutateAsync({vouch})
Toast.show(_(msg`Vouch revoked`), 'check')
} catch (e: any) {
Toast.show(_(msg`Failed to revoke vouch`), 'xmark')
}
}, [_, vouch, mutateAsync])
return (
<View
style={[
gutters,
a.w_full,
!first && a.border_t,
t.atoms.border_contrast_low,
]}>
<View style={[a.flex_row, a.align_center, a.justify_between, a.gap_sm]}>
<UserAvatar size={40} avatar={subject.avatar} />
<View style={[a.flex_1, a.gap_xs]}>
<Text style={[a.text_md, a.font_bold, a.leading_tight]}>
@{subject.handle}
</Text>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<View
style={[
a.rounded_xs,
t.atoms.bg_contrast_25,
{
paddingVertical: 2,
paddingHorizontal: 6,
},
]}>
<Text style={[a.leading_tight, t.atoms.text_contrast_medium]}>
{relationship}
</Text>
</View>
<View
style={[
a.rounded_xs,
t.atoms.bg_contrast_25,
{
paddingVertical: 2,
paddingHorizontal: 6,
},
]}>
<Text style={[a.leading_tight, t.atoms.text_contrast_medium]}>
{vouch.accept ? _(msg`Accepted`) : _(msg`Pending`)}
</Text>
</View>
<Text style={[a.text_sm, a.leading_tight]}>
<Trans>Issued: {ago(record.createdAt, new Date())} ago</Trans>
</Text>
</View>
</View>
<Button
disabled={isPending}
label={_(msg`Revoke vouch from ${subject.handle}`)}
size="small"
variant="solid"
color="secondary"
onPress={revoke}>
<ButtonText>
<Trans>Revoke</Trans>
</ButtonText>
<ButtonIcon icon={isPending ? Loader : Times} position="right" />
</Button>
</View>
</View>
)
}
function useRelationshipLabel(
relationship: AppBskyGraphVouch.Record['relationship'],
) {
const {_} = useLingui()
return React.useMemo(() => {
switch (relationship) {
case 'verifiedBy':
return _(msg`Bopped`)
case 'employeeOf':
return _(msg`Beeped`)
default:
return _(msg`Unknown`)
}
}, [_, relationship])
}
+137
View File
@@ -0,0 +1,137 @@
import {ScrollView,View} from 'react-native'
import {msg,Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useVouchesIssued} from '#/state/queries/vouches/useVouchesIssued'
import {useSession} from '#/state/session'
import {Vouch} from '#/screens/Profile/Vouches/components/Vouch'
import {atoms as a, useGutters,useTheme} from '#/alf'
import {ButtonIcon,ButtonText} from '#/components/Button'
import {Divider} from '#/components/Divider'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export function Screen() {
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
const baseGutters = useGutters(['base'])
const compactGutters = useGutters(['compact'])
return (
<Layout.Screen>
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>
<Trans>Vouches</Trans>
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<Layout.Content>
<View style={[a.gap_lg]}>
<View style={[baseGutters, a.gap_lg]}>
<Link
label={_(msg`Create a new vouch`)}
to={{
screen: 'ProfileVouchesCreate',
params: {name: currentAccount!.handle},
}}>
{({hovered}) => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.rounded_md,
compactGutters,
t.atoms.bg_contrast_25,
hovered && [t.atoms.bg_contrast_50],
]}>
<View style={[a.flex_1]}>
<Text style={[a.text_lg, a.font_heavy]}>Create vouch</Text>
</View>
<ChevronRight />
</View>
)}
</Link>
</View>
</View>
<VouchesIssued />
</Layout.Content>
</Layout.Screen>
)
}
export function VouchesIssued() {
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
const {data: vouches, isLoading, error} = useVouchesIssued()
const baseGutters = useGutters([0, 'base'])
return (
<View style={[]}>
<View style={[baseGutters, a.pb_md]}>
<View style={[a.flex_row, a.align_center, a.justify_between, a.pb_xs]}>
<Text style={[a.text_lg, a.font_heavy, t.atoms.text_contrast_medium]}>
<Trans>Vouches Issued</Trans>
</Text>
<Link
label={_(msg`View All`)}
to={{
screen: 'ProfileVouchesIssued',
params: {name: currentAccount!.handle},
}}
size="small"
variant="ghost"
color="secondary"
style={[a.flex_row, a.align_center, a.justify_center]}>
<ButtonText>
<Trans>See all</Trans>
</ButtonText>
<ButtonIcon icon={ChevronRight} position="right" />
</Link>
</View>
<Divider />
</View>
{isLoading ? (
<View style={[baseGutters, a.py_lg]}>
<Loader />
</View>
) : error || !vouches ? (
<View style={[baseGutters, a.py_lg]}>
{error ? (
<Text>{error.toString()}</Text>
) : (
<Text>
<Trans>Somthing went wrong</Trans>
</Text>
)}
</View>
) : vouches.pages.at(0)?.vouches?.length ? (
<ScrollView
horizontal
style={[baseGutters]}
contentContainerStyle={[a.gap_md]}>
{vouches.pages[0].vouches.map(v => (
<Vouch key={v.cid} vouch={v} subject={v.subject!} />
))}
</ScrollView>
) : (
<View style={[baseGutters, a.py_lg]}>
<Text>No vouches</Text>
</View>
)}
</View>
)
}
@@ -0,0 +1,77 @@
import {AppBskyGraphVouch} from '@atproto/api'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {poll} from '#/lib/async/poll'
import {
useUpdateVouchesIssuedQueryCache,
vouchesIssuedQueryKey,
} from '#/state/queries/vouches/useVouchesIssued'
import {useVouchRecordSchema} from '#/state/queries/vouches/util'
import {useAgent,useSession} from '#/state/session'
export type CreateVouchProps = {
subject: string
relationship: AppBskyGraphVouch.Record['relationship']
}
export function useCreateVouch() {
const queryClient = useQueryClient()
const {currentAccount} = useSession()
const agent = useAgent()
const vouchRecordSchema = useVouchRecordSchema()
const updateCache = useUpdateVouchesIssuedQueryCache()
return useMutation({
mutationFn: async (props: CreateVouchProps) => {
const record: AppBskyGraphVouch.Record = {
subject: props.subject,
relationship: props.relationship,
createdAt: new Date().toISOString(),
}
vouchRecordSchema.parse(record)
return agent.app.bsky.graph.vouch.create(
{repo: currentAccount!.did},
record,
)
},
async onSuccess({uri}) {
const vouch = await poll(
5,
1e3,
({response}) => {
if (!response) return false
if (response.uri === uri) return true
return false
},
async () => {
const {data} = await agent.app.bsky.graph.getVouchesGiven({
actor: currentAccount!.did,
includeUnaccepted: true,
limit: 1,
})
return data.vouches.at(0)
},
)
if (vouch) {
updateCache(data => {
if (!data) {
// no cache, fetch fresh
queryClient.invalidateQueries({queryKey: vouchesIssuedQueryKey})
return
}
return {
...data,
pages: data.pages.map((page, i) => {
return {
...page,
vouches: i === 0 ? [vouch, ...page.vouches] : page.vouches,
}
}),
}
})
}
},
})
}
@@ -0,0 +1,39 @@
import {AppBskyGraphDefs, AtUri} from '@atproto/api'
import {useMutation} from '@tanstack/react-query'
import {useUpdateVouchesIssuedQueryCache} from '#/state/queries/vouches/useVouchesIssued'
import {useAgent,useSession} from '#/state/session'
export type RevokeVouchProps = {
vouch: AppBskyGraphDefs.VouchView
}
export function useRevokeVouch() {
const {currentAccount} = useSession()
const agent = useAgent()
const updateCache = useUpdateVouchesIssuedQueryCache()
return useMutation({
mutationFn: async (props: RevokeVouchProps) => {
const {rkey} = new AtUri(props.vouch.uri)
return agent.app.bsky.graph.vouch.delete({
repo: currentAccount!.did,
rkey,
})
},
onSuccess(_, {vouch}) {
updateCache(data => {
if (!data) return data
return {
...data,
pages: data.pages.map(page => {
return {
...page,
vouches: page.vouches.filter(v => v.uri !== vouch.uri),
}
}),
}
})
},
})
}
@@ -0,0 +1,20 @@
import {useQuery} from '@tanstack/react-query'
import {useAgent,useSession} from '#/state/session'
export const vouchesAcceptedQueryKey = ['vouches-accepted']
export function useVouchesAccepted() {
const {currentAccount} = useSession()
const agent = useAgent()
return useQuery({
queryKey: vouchesAcceptedQueryKey,
queryFn: async () => {
const {data} = await agent.app.bsky.graph.getVouchesReceived({
actor: currentAccount!.did,
})
return data.vouches
},
})
}
@@ -0,0 +1,57 @@
import React from 'react'
import {AppBskyGraphGetVouchesGiven} from '@atproto/api'
import {
InfiniteData,
QueryKey,
useInfiniteQuery,
useQueryClient,
} from '@tanstack/react-query'
import {useAgent,useSession} from '#/state/session'
export const vouchesIssuedQueryKey = ['vouches-issued']
export function useVouchesIssued() {
const {currentAccount} = useSession()
const agent = useAgent()
return useInfiniteQuery<
AppBskyGraphGetVouchesGiven.OutputSchema,
Error,
InfiniteData<AppBskyGraphGetVouchesGiven.OutputSchema>,
QueryKey,
string | undefined
>({
queryKey: vouchesIssuedQueryKey,
initialPageParam: undefined,
getNextPageParam: lastPage => lastPage.cursor,
queryFn: async ({pageParam: cursor}) => {
const {data} = await agent.app.bsky.graph.getVouchesGiven({
actor: currentAccount!.did,
includeUnaccepted: true,
cursor,
})
return data
},
})
}
export function useUpdateVouchesIssuedQueryCache() {
const q = useQueryClient()
return React.useCallback(
(
callback: (
data:
| InfiniteData<AppBskyGraphGetVouchesGiven.OutputSchema>
| undefined,
) => InfiniteData<AppBskyGraphGetVouchesGiven.OutputSchema> | undefined,
) => {
const data = q.getQueryData<
InfiniteData<AppBskyGraphGetVouchesGiven.OutputSchema>
>(vouchesIssuedQueryKey)
const updated = callback(data)
q.setQueryData(vouchesIssuedQueryKey, updated)
},
[q],
)
}
@@ -0,0 +1,17 @@
import {useQuery} from '@tanstack/react-query'
import {useAgent} from '#/state/session'
export const vouchesReceivedQueryKey = ['vouches-received']
export function useVouchesReceived() {
const agent = useAgent()
return useQuery({
queryKey: vouchesReceivedQueryKey,
queryFn: async () => {
const {data} = await agent.app.bsky.graph.getVouchesOffered()
return data.vouches
},
})
}
+16
View File
@@ -0,0 +1,16 @@
import {useMemo} from 'react'
import zod from 'zod'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
export function useVouchRecordSchema() {
const {_} = useLingui()
return useMemo(() => {
return zod.object({
subject: zod.string().startsWith('did:', { message: _(msg`Must be a valid DID`) }),
relationship: zod.enum(['verifiedBy', 'employeeOf']),
createdAt: zod.string().datetime(),
})
}, [_])
}
+17 -1
View File
@@ -4,7 +4,8 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {HITSLOP_20} from '#/lib/constants'
import {HITSLOP_20, isInternalUser} from '#/lib/constants'
import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped'
import {makeProfileLink} from '#/lib/routes/links'
import {shareUrl} from '#/lib/sharing'
import {toShareUrl} from '#/lib/strings/url-helpers'
@@ -52,6 +53,7 @@ let ProfileMenu = ({
const isBlocked = profile.viewer?.blocking || profile.viewer?.blockedBy
const isFollowingBlockedAccount = isFollowing && isBlocked
const isLabelerAndNotBlocked = !!profile.associated?.labeler && !isBlocked
const navigation = useNavigationDeduped()
const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
@@ -80,6 +82,10 @@ let ProfileMenu = ({
shareUrl(toShareUrl(makeProfileLink(profile)))
}, [profile])
const onPressManageVouches = React.useCallback(() => {
navigation.navigate('ProfileVouches', {name: profile.handle})
}, [navigation, profile.handle])
const onPressAddRemoveLists = React.useCallback(() => {
openModal({
name: 'user-add-remove-lists',
@@ -247,6 +253,16 @@ let ProfileMenu = ({
</Menu.ItemText>
<Menu.ItemIcon icon={List} />
</Menu.Item>
{isInternalUser(currentAccount?.did) && (
<Menu.Item
label={_(msg`Manage my vouches`)}
onPress={onPressManageVouches}>
<Menu.ItemText>
<Trans>Manage vouches</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={List} />
</Menu.Item>
)}
{!isSelf && (
<>
{!profile.viewer?.blocking &&