Resolve facets on list descriptions (#2485)
* feat: add strict/loose link mapping * feat: resolve facets on list description
This commit is contained in:
parent
1828bc9755
commit
abac959d03
4 changed files with 110 additions and 24 deletions
|
@ -1,7 +1,7 @@
|
||||||
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
|
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
|
||||||
import {linkRequiresWarning} from './url-helpers'
|
import {linkRequiresWarning} from './url-helpers'
|
||||||
|
|
||||||
export function richTextToString(rt: RichText): string {
|
export function richTextToString(rt: RichText, loose: boolean): string {
|
||||||
const {text, facets} = rt
|
const {text, facets} = rt
|
||||||
|
|
||||||
if (!facets?.length) {
|
if (!facets?.length) {
|
||||||
|
@ -19,7 +19,7 @@ export function richTextToString(rt: RichText): string {
|
||||||
|
|
||||||
const requiresWarning = linkRequiresWarning(href, text)
|
const requiresWarning = linkRequiresWarning(href, text)
|
||||||
|
|
||||||
result += !requiresWarning ? href : `[${text}](${href})`
|
result += !requiresWarning ? href : loose ? `[${text}](${href})` : text
|
||||||
} else {
|
} else {
|
||||||
result += segment.text
|
result += segment.text
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ import {
|
||||||
AppBskyGraphGetList,
|
AppBskyGraphGetList,
|
||||||
AppBskyGraphList,
|
AppBskyGraphList,
|
||||||
AppBskyGraphDefs,
|
AppBskyGraphDefs,
|
||||||
|
Facet,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||||
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
|
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
|
||||||
|
@ -38,6 +39,7 @@ export interface ListCreateMutateParams {
|
||||||
purpose: string
|
purpose: string
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
|
descriptionFacets: Facet[] | undefined
|
||||||
avatar: RNImage | null | undefined
|
avatar: RNImage | null | undefined
|
||||||
}
|
}
|
||||||
export function useListCreateMutation() {
|
export function useListCreateMutation() {
|
||||||
|
@ -45,7 +47,13 @@ export function useListCreateMutation() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
return useMutation<{uri: string; cid: string}, Error, ListCreateMutateParams>(
|
return useMutation<{uri: string; cid: string}, Error, ListCreateMutateParams>(
|
||||||
{
|
{
|
||||||
async mutationFn({purpose, name, description, avatar}) {
|
async mutationFn({
|
||||||
|
purpose,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
descriptionFacets,
|
||||||
|
avatar,
|
||||||
|
}) {
|
||||||
if (!currentAccount) {
|
if (!currentAccount) {
|
||||||
throw new Error('Not logged in')
|
throw new Error('Not logged in')
|
||||||
}
|
}
|
||||||
|
@ -59,6 +67,7 @@ export function useListCreateMutation() {
|
||||||
purpose,
|
purpose,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
|
descriptionFacets,
|
||||||
avatar: undefined,
|
avatar: undefined,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
|
@ -93,6 +102,7 @@ export interface ListMetadataMutateParams {
|
||||||
uri: string
|
uri: string
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
|
descriptionFacets: Facet[] | undefined
|
||||||
avatar: RNImage | null | undefined
|
avatar: RNImage | null | undefined
|
||||||
}
|
}
|
||||||
export function useListMetadataMutation() {
|
export function useListMetadataMutation() {
|
||||||
|
@ -103,7 +113,7 @@ export function useListMetadataMutation() {
|
||||||
Error,
|
Error,
|
||||||
ListMetadataMutateParams
|
ListMetadataMutateParams
|
||||||
>({
|
>({
|
||||||
async mutationFn({uri, name, description, avatar}) {
|
async mutationFn({uri, name, description, descriptionFacets, avatar}) {
|
||||||
const {hostname, rkey} = new AtUri(uri)
|
const {hostname, rkey} = new AtUri(uri)
|
||||||
if (!currentAccount) {
|
if (!currentAccount) {
|
||||||
throw new Error('Not logged in')
|
throw new Error('Not logged in')
|
||||||
|
@ -121,6 +131,7 @@ export function useListMetadataMutation() {
|
||||||
// update the fields
|
// update the fields
|
||||||
record.name = name
|
record.name = name
|
||||||
record.description = description
|
record.description = description
|
||||||
|
record.descriptionFacets = descriptionFacets
|
||||||
if (avatar) {
|
if (avatar) {
|
||||||
const blobRes = await uploadBlob(getAgent(), avatar.path, avatar.mime)
|
const blobRes = await uploadBlob(getAgent(), avatar.path, avatar.mime)
|
||||||
record.avatar = blobRes.data.blob
|
record.avatar = blobRes.data.blob
|
||||||
|
|
|
@ -8,7 +8,11 @@ import {
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {AppBskyGraphDefs} from '@atproto/api'
|
import {
|
||||||
|
AppBskyGraphDefs,
|
||||||
|
AppBskyRichtextFacet,
|
||||||
|
RichText as RichTextAPI,
|
||||||
|
} from '@atproto/api'
|
||||||
import LinearGradient from 'react-native-linear-gradient'
|
import LinearGradient from 'react-native-linear-gradient'
|
||||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
|
@ -30,6 +34,9 @@ import {
|
||||||
useListCreateMutation,
|
useListCreateMutation,
|
||||||
useListMetadataMutation,
|
useListMetadataMutation,
|
||||||
} from '#/state/queries/list'
|
} from '#/state/queries/list'
|
||||||
|
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||||
|
import {shortenLinks} from '#/lib/strings/rich-text-manip'
|
||||||
|
import {getAgent} from '#/state/session'
|
||||||
|
|
||||||
const MAX_NAME = 64 // todo
|
const MAX_NAME = 64 // todo
|
||||||
const MAX_DESCRIPTION = 300 // todo
|
const MAX_DESCRIPTION = 300 // todo
|
||||||
|
@ -68,12 +75,42 @@ export function Component({
|
||||||
|
|
||||||
const [isProcessing, setProcessing] = useState<boolean>(false)
|
const [isProcessing, setProcessing] = useState<boolean>(false)
|
||||||
const [name, setName] = useState<string>(list?.name || '')
|
const [name, setName] = useState<string>(list?.name || '')
|
||||||
const [description, setDescription] = useState<string>(
|
|
||||||
list?.description || '',
|
const [descriptionRt, setDescriptionRt] = useState<RichTextAPI>(() => {
|
||||||
)
|
const text = list?.description
|
||||||
|
const facets = list?.descriptionFacets
|
||||||
|
|
||||||
|
if (!text || !facets) {
|
||||||
|
return new RichTextAPI({text: text || ''})
|
||||||
|
}
|
||||||
|
|
||||||
|
// We want to be working with a blank state here, so let's get the
|
||||||
|
// serialized version and turn it back into a RichText
|
||||||
|
const serialized = richTextToString(new RichTextAPI({text, facets}), false)
|
||||||
|
|
||||||
|
const richText = new RichTextAPI({text: serialized})
|
||||||
|
richText.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
return richText
|
||||||
|
})
|
||||||
|
const graphemeLength = useMemo(() => {
|
||||||
|
return shortenLinks(descriptionRt).graphemeLength
|
||||||
|
}, [descriptionRt])
|
||||||
|
const isDescriptionOver = graphemeLength > MAX_DESCRIPTION
|
||||||
|
|
||||||
const [avatar, setAvatar] = useState<string | undefined>(list?.avatar)
|
const [avatar, setAvatar] = useState<string | undefined>(list?.avatar)
|
||||||
const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>()
|
const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>()
|
||||||
|
|
||||||
|
const onDescriptionChange = useCallback(
|
||||||
|
(newText: string) => {
|
||||||
|
const richText = new RichTextAPI({text: newText})
|
||||||
|
richText.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
setDescriptionRt(richText)
|
||||||
|
},
|
||||||
|
[setDescriptionRt],
|
||||||
|
)
|
||||||
|
|
||||||
const onPressCancel = useCallback(() => {
|
const onPressCancel = useCallback(() => {
|
||||||
closeModal()
|
closeModal()
|
||||||
}, [closeModal])
|
}, [closeModal])
|
||||||
|
@ -113,11 +150,31 @@ export function Component({
|
||||||
setError('')
|
setError('')
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
let richText = new RichTextAPI(
|
||||||
|
{text: descriptionRt.text.trimEnd()},
|
||||||
|
{cleanNewlines: true},
|
||||||
|
)
|
||||||
|
|
||||||
|
await richText.detectFacets(getAgent())
|
||||||
|
richText = shortenLinks(richText)
|
||||||
|
|
||||||
|
// filter out any mention facets that didn't map to a user
|
||||||
|
richText.facets = richText.facets?.filter(facet => {
|
||||||
|
const mention = facet.features.find(feature =>
|
||||||
|
AppBskyRichtextFacet.isMention(feature),
|
||||||
|
)
|
||||||
|
if (mention && !mention.did) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
if (list) {
|
if (list) {
|
||||||
await listMetadataMutation.mutateAsync({
|
await listMetadataMutation.mutateAsync({
|
||||||
uri: list.uri,
|
uri: list.uri,
|
||||||
name: nameTrimmed,
|
name: nameTrimmed,
|
||||||
description: description.trim(),
|
description: richText.text,
|
||||||
|
descriptionFacets: richText.facets,
|
||||||
avatar: newAvatar,
|
avatar: newAvatar,
|
||||||
})
|
})
|
||||||
Toast.show(
|
Toast.show(
|
||||||
|
@ -130,7 +187,8 @@ export function Component({
|
||||||
const res = await listCreateMutation.mutateAsync({
|
const res = await listCreateMutation.mutateAsync({
|
||||||
purpose: activePurpose,
|
purpose: activePurpose,
|
||||||
name,
|
name,
|
||||||
description,
|
description: richText.text,
|
||||||
|
descriptionFacets: richText.facets,
|
||||||
avatar: newAvatar,
|
avatar: newAvatar,
|
||||||
})
|
})
|
||||||
Toast.show(
|
Toast.show(
|
||||||
|
@ -163,7 +221,7 @@ export function Component({
|
||||||
activePurpose,
|
activePurpose,
|
||||||
isCurateList,
|
isCurateList,
|
||||||
name,
|
name,
|
||||||
description,
|
descriptionRt,
|
||||||
newAvatar,
|
newAvatar,
|
||||||
list,
|
list,
|
||||||
listMetadataMutation,
|
listMetadataMutation,
|
||||||
|
@ -212,9 +270,11 @@ export function Component({
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.form}>
|
<View style={styles.form}>
|
||||||
<View>
|
<View>
|
||||||
<Text style={[styles.label, pal.text]} nativeID="list-name">
|
<View style={styles.labelWrapper}>
|
||||||
<Trans>List Name</Trans>
|
<Text style={[styles.label, pal.text]} nativeID="list-name">
|
||||||
</Text>
|
<Trans>List Name</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
<TextInput
|
<TextInput
|
||||||
testID="editNameInput"
|
testID="editNameInput"
|
||||||
style={[styles.textInput, pal.border, pal.text]}
|
style={[styles.textInput, pal.border, pal.text]}
|
||||||
|
@ -233,9 +293,17 @@ export function Component({
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View style={s.pb10}>
|
<View style={s.pb10}>
|
||||||
<Text style={[styles.label, pal.text]} nativeID="list-description">
|
<View style={styles.labelWrapper}>
|
||||||
<Trans>Description</Trans>
|
<Text
|
||||||
</Text>
|
style={[styles.label, pal.text]}
|
||||||
|
nativeID="list-description">
|
||||||
|
<Trans>Description</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[!isDescriptionOver ? pal.textLight : s.red3, s.f13]}>
|
||||||
|
{graphemeLength}/{MAX_DESCRIPTION}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
<TextInput
|
<TextInput
|
||||||
testID="editDescriptionInput"
|
testID="editDescriptionInput"
|
||||||
style={[styles.textArea, pal.border, pal.text]}
|
style={[styles.textArea, pal.border, pal.text]}
|
||||||
|
@ -247,8 +315,8 @@ export function Component({
|
||||||
placeholderTextColor={colors.gray4}
|
placeholderTextColor={colors.gray4}
|
||||||
keyboardAppearance={theme.colorScheme}
|
keyboardAppearance={theme.colorScheme}
|
||||||
multiline
|
multiline
|
||||||
value={description}
|
value={descriptionRt.text}
|
||||||
onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))}
|
onChangeText={onDescriptionChange}
|
||||||
accessible={true}
|
accessible={true}
|
||||||
accessibilityLabel={_(msg`Description`)}
|
accessibilityLabel={_(msg`Description`)}
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
|
@ -262,7 +330,8 @@ export function Component({
|
||||||
) : (
|
) : (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
testID="saveBtn"
|
testID="saveBtn"
|
||||||
style={s.mt10}
|
style={[s.mt10, isDescriptionOver && s.dimmed]}
|
||||||
|
disabled={isDescriptionOver}
|
||||||
onPress={onPressSave}
|
onPress={onPressSave}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={_(msg`Save`)}
|
accessibilityLabel={_(msg`Save`)}
|
||||||
|
@ -271,7 +340,7 @@ export function Component({
|
||||||
colors={[gradients.blueLight.start, gradients.blueLight.end]}
|
colors={[gradients.blueLight.start, gradients.blueLight.end]}
|
||||||
start={{x: 0, y: 0}}
|
start={{x: 0, y: 0}}
|
||||||
end={{x: 1, y: 1}}
|
end={{x: 1, y: 1}}
|
||||||
style={[styles.btn]}>
|
style={styles.btn}>
|
||||||
<Text style={[s.white, s.bold]}>
|
<Text style={[s.white, s.bold]}>
|
||||||
<Trans context="action">Save</Trans>
|
<Trans context="action">Save</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
|
@ -305,12 +374,18 @@ const styles = StyleSheet.create({
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
marginBottom: 18,
|
marginBottom: 18,
|
||||||
},
|
},
|
||||||
label: {
|
labelWrapper: {
|
||||||
fontWeight: 'bold',
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
paddingHorizontal: 4,
|
paddingHorizontal: 4,
|
||||||
paddingBottom: 4,
|
paddingBottom: 4,
|
||||||
marginTop: 20,
|
marginTop: 20,
|
||||||
},
|
},
|
||||||
|
label: {
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
form: {
|
form: {
|
||||||
paddingHorizontal: 6,
|
paddingHorizontal: 6,
|
||||||
},
|
},
|
||||||
|
|
|
@ -104,7 +104,7 @@ let PostDropdownBtn = ({
|
||||||
}, [rootUri, toggleThreadMute, _])
|
}, [rootUri, toggleThreadMute, _])
|
||||||
|
|
||||||
const onCopyPostText = React.useCallback(() => {
|
const onCopyPostText = React.useCallback(() => {
|
||||||
const str = richTextToString(richText)
|
const str = richTextToString(richText, true)
|
||||||
|
|
||||||
Clipboard.setString(str)
|
Clipboard.setString(str)
|
||||||
Toast.show(_(msg`Copied to clipboard`))
|
Toast.show(_(msg`Copied to clipboard`))
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue