Compare commits
4 Commits
latest
...
vouch-impl
| Author | SHA1 | Date | |
|---|---|---|---|
| d6f11e70c6 | |||
| 9f40574a13 | |||
| 1d84922481 | |||
| 5028f7ee2c |
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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--
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}, [_])
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user