diff options
Diffstat (limited to 'src/view/com')
54 files changed, 875 insertions, 686 deletions
diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx index 618c15cf5..6ece903d6 100644 --- a/src/view/com/auth/create/CreateAccount.tsx +++ b/src/view/com/auth/create/CreateAccount.tsx @@ -75,16 +75,14 @@ export const CreateAccount = observer( {model.step === 3 && <Step3 model={model} />} </View> <View style={[s.flexRow, s.pl20, s.pr20]}> - <TouchableOpacity onPress={onPressBackInner}> + <TouchableOpacity onPress={onPressBackInner} testID="backBtn"> <Text type="xl" style={pal.link}> Back </Text> </TouchableOpacity> <View style={s.flex1} /> {model.canNext ? ( - <TouchableOpacity - testID="createAccountButton" - onPress={onPressNext}> + <TouchableOpacity testID="nextBtn" onPress={onPressNext}> {model.isProcessing ? ( <ActivityIndicator /> ) : ( @@ -95,7 +93,7 @@ export const CreateAccount = observer( </TouchableOpacity> ) : model.didServiceDescriptionFetchFail ? ( <TouchableOpacity - testID="registerRetryButton" + testID="retryConnectBtn" onPress={onPressRetryConnect}> <Text type="xl-bold" style={[pal.link, s.pr5]}> Retry diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx index 0a628f9d0..ca964ede2 100644 --- a/src/view/com/auth/create/Step1.tsx +++ b/src/view/com/auth/create/Step1.tsx @@ -60,12 +60,14 @@ export const Step1 = observer(({model}: {model: CreateAccountModel}) => { This is the company that keeps you online. </Text> <Option + testID="blueskyServerBtn" isSelected={isDefaultSelected} label="Bluesky" help=" (default)" onPress={onPressDefault} /> <Option + testID="otherServerBtn" isSelected={!isDefaultSelected} label="Other" onPress={onPressOther}> @@ -74,6 +76,7 @@ export const Step1 = observer(({model}: {model: CreateAccountModel}) => { Enter the address of your provider: </Text> <TextInput + testID="customServerInput" icon="globe" placeholder="Hosting provider address" value={model.serviceUrl} @@ -83,12 +86,14 @@ export const Step1 = observer(({model}: {model: CreateAccountModel}) => { {LOGIN_INCLUDE_DEV_SERVERS && ( <View style={[s.flexRow, s.mt10]}> <Button + testID="stagingServerBtn" type="default" style={s.mr5} label="Staging" onPress={() => onDebugChangeServiceUrl(STAGING_SERVICE)} /> <Button + testID="localDevServerBtn" type="default" label="Dev Server" onPress={() => onDebugChangeServiceUrl(LOCAL_DEV_SERVICE)} @@ -112,11 +117,13 @@ function Option({ label, help, onPress, + testID, }: React.PropsWithChildren<{ isSelected: boolean label: string help?: string onPress: () => void + testID?: string }>) { const theme = useTheme() const pal = usePalette('default') @@ -129,7 +136,7 @@ function Option({ return ( <View style={[styles.option, pal.border]}> - <TouchableWithoutFeedback onPress={onPress}> + <TouchableWithoutFeedback onPress={onPress} testID={testID}> <View style={styles.optionHeading}> <View style={[styles.circle, pal.border]}> {isSelected ? ( diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx index f115bf6ac..8df997bd3 100644 --- a/src/view/com/auth/create/Step2.tsx +++ b/src/view/com/auth/create/Step2.tsx @@ -59,6 +59,7 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => { Email address </Text> <TextInput + testID="emailInput" icon="envelope" placeholder="Enter your email address" value={model.email} @@ -72,6 +73,7 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => { Password </Text> <TextInput + testID="passwordInput" icon="lock" placeholder="Choose your password" value={model.password} @@ -86,7 +88,7 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => { Legal check </Text> <TouchableOpacity - testID="registerIs13Input" + testID="is13Input" style={[styles.toggleBtn, pal.border]} onPress={() => model.setIs13(!model.is13)}> <View style={[pal.borderDark, styles.checkbox]}> diff --git a/src/view/com/auth/create/Step3.tsx b/src/view/com/auth/create/Step3.tsx index 652591171..13ab39a10 100644 --- a/src/view/com/auth/create/Step3.tsx +++ b/src/view/com/auth/create/Step3.tsx @@ -17,6 +17,7 @@ export const Step3 = observer(({model}: {model: CreateAccountModel}) => { <StepHeader step="3" title="Your user handle" /> <View style={s.pb10}> <TextInput + testID="handleInput" icon="at" placeholder="eg alice" value={model.handle} diff --git a/src/view/com/auth/login/Login.tsx b/src/view/com/auth/login/Login.tsx index f99e72daa..eff1642f0 100644 --- a/src/view/com/auth/login/Login.tsx +++ b/src/view/com/auth/login/Login.tsx @@ -13,7 +13,7 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import * as EmailValidator from 'email-validator' -import AtpAgent from '@atproto/api' +import {BskyAgent} from '@atproto/api' import {useAnalytics} from 'lib/analytics' import {Text} from '../../util/text/Text' import {UserAvatar} from '../../util/UserAvatar' @@ -506,8 +506,8 @@ const ForgotPasswordForm = ({ setIsProcessing(true) try { - const agent = new AtpAgent({service: serviceUrl}) - await agent.api.com.atproto.account.requestPasswordReset({email}) + const agent = new BskyAgent({service: serviceUrl}) + await agent.com.atproto.server.requestPasswordReset({email}) onEmailSent() } catch (e: any) { const errMsg = e.toString() @@ -648,8 +648,8 @@ const SetNewPasswordForm = ({ setIsProcessing(true) try { - const agent = new AtpAgent({service: serviceUrl}) - await agent.api.com.atproto.account.resetPassword({ + const agent = new BskyAgent({service: serviceUrl}) + await agent.com.atproto.server.resetPassword({ token: resetCode, password, }) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 572eea927..6009debdd 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useRef, useState} from 'react' +import React from 'react' import {observer} from 'mobx-react-lite' import { ActivityIndicator, @@ -13,6 +13,7 @@ import { } from 'react-native' import LinearGradient from 'react-native-linear-gradient' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {RichText} from '@atproto/api' import {useAnalytics} from 'lib/analytics' import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' import {ExternalEmbed} from './ExternalEmbed' @@ -30,11 +31,11 @@ import {SelectPhotoBtn} from './photos/SelectPhotoBtn' import {OpenCameraBtn} from './photos/OpenCameraBtn' import {SelectedPhotos} from './photos/SelectedPhotos' import {usePalette} from 'lib/hooks/usePalette' -import QuoteEmbed from '../util/PostEmbeds/QuoteEmbed' +import QuoteEmbed from '../util/post-embeds/QuoteEmbed' import {useExternalLinkFetch} from './useExternalLinkFetch' import {isDesktopWeb} from 'platform/detection' -const MAX_TEXT_LENGTH = 256 +const MAX_GRAPHEME_LENGTH = 300 export const ComposePost = observer(function ComposePost({ replyTo, @@ -50,17 +51,23 @@ export const ComposePost = observer(function ComposePost({ const {track} = useAnalytics() const pal = usePalette('default') const store = useStores() - const textInput = useRef<TextInputRef>(null) - const [isProcessing, setIsProcessing] = useState(false) - const [processingState, setProcessingState] = useState('') - const [error, setError] = useState('') - const [text, setText] = useState('') - const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>( + const textInput = React.useRef<TextInputRef>(null) + const [isProcessing, setIsProcessing] = React.useState(false) + const [processingState, setProcessingState] = React.useState('') + const [error, setError] = React.useState('') + const [richtext, setRichText] = React.useState(new RichText({text: ''})) + const graphemeLength = React.useMemo( + () => richtext.graphemeLength, + [richtext], + ) + const [quote, setQuote] = React.useState<ComposerOpts['quote'] | undefined>( initQuote, ) const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) - const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set()) - const [selectedPhotos, setSelectedPhotos] = useState<string[]>([]) + const [suggestedLinks, setSuggestedLinks] = React.useState<Set<string>>( + new Set(), + ) + const [selectedPhotos, setSelectedPhotos] = React.useState<string[]>([]) const autocompleteView = React.useMemo<UserAutocompleteViewModel>( () => new UserAutocompleteViewModel(store), @@ -78,11 +85,11 @@ export const ComposePost = observer(function ComposePost({ }, [textInput, onClose]) // initial setup - useEffect(() => { + React.useEffect(() => { autocompleteView.setup() }, [autocompleteView]) - useEffect(() => { + React.useEffect(() => { // HACK // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view // -prf @@ -132,18 +139,18 @@ export const ComposePost = observer(function ComposePost({ if (isProcessing) { return } - if (text.length > MAX_TEXT_LENGTH) { + if (richtext.graphemeLength > MAX_GRAPHEME_LENGTH) { return } setError('') - if (text.trim().length === 0 && selectedPhotos.length === 0) { + if (richtext.text.trim().length === 0 && selectedPhotos.length === 0) { setError('Did you want to say anything?') return false } setIsProcessing(true) try { await apilib.post(store, { - rawText: text, + rawText: richtext.text, replyTo: replyTo?.uri, images: selectedPhotos, quote: quote, @@ -172,7 +179,7 @@ export const ComposePost = observer(function ComposePost({ Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`) }, [ isProcessing, - text, + richtext, setError, setIsProcessing, replyTo, @@ -187,7 +194,7 @@ export const ComposePost = observer(function ComposePost({ track, ]) - const canPost = text.length <= MAX_TEXT_LENGTH + const canPost = graphemeLength <= MAX_GRAPHEME_LENGTH const selectTextInputPlaceholder = replyTo ? 'Write your reply' @@ -215,7 +222,7 @@ export const ComposePost = observer(function ComposePost({ </View> ) : canPost ? ( <TouchableOpacity - testID="composerPublishButton" + testID="composerPublishBtn" onPress={onPressPublish}> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} @@ -271,42 +278,41 @@ export const ComposePost = observer(function ComposePost({ <UserAvatar avatar={store.me.avatar} size={50} /> <TextInput ref={textInput} - text={text} + richtext={richtext} placeholder={selectTextInputPlaceholder} suggestedLinks={suggestedLinks} autocompleteView={autocompleteView} - onTextChanged={setText} + setRichText={setRichText} onPhotoPasted={onPhotoPasted} onSuggestedLinksChanged={setSuggestedLinks} onError={setError} /> </View> - {quote ? ( - <View style={s.mt5}> - <QuoteEmbed quote={quote} /> - </View> - ) : undefined} - <SelectedPhotos selectedPhotos={selectedPhotos} onSelectPhotos={onSelectPhotos} /> - {!selectedPhotos.length && extLink && ( + {selectedPhotos.length === 0 && extLink && ( <ExternalEmbed link={extLink} onRemove={() => setExtLink(undefined)} /> )} + {quote ? ( + <View style={s.mt5}> + <QuoteEmbed quote={quote} /> + </View> + ) : undefined} </ScrollView> {!extLink && selectedPhotos.length === 0 && - suggestedLinks.size > 0 && - !quote ? ( + suggestedLinks.size > 0 ? ( <View style={s.mb5}> {Array.from(suggestedLinks).map(url => ( <TouchableOpacity key={`suggested-${url}`} + testID="addLinkCardBtn" style={[pal.borderDark, styles.addExtLinkBtn]} onPress={() => onPressAddLinkCard(url)}> <Text style={pal.text}> @@ -318,17 +324,17 @@ export const ComposePost = observer(function ComposePost({ ) : null} <View style={[pal.border, styles.bottomBar]}> <SelectPhotoBtn - enabled={!quote && selectedPhotos.length < 4} + enabled={selectedPhotos.length < 4} selectedPhotos={selectedPhotos} onSelectPhotos={setSelectedPhotos} /> <OpenCameraBtn - enabled={!quote && selectedPhotos.length < 4} + enabled={selectedPhotos.length < 4} selectedPhotos={selectedPhotos} onSelectPhotos={setSelectedPhotos} /> <View style={s.flex1} /> - <CharProgress count={text.length} /> + <CharProgress count={graphemeLength} /> </View> </SafeAreaView> </TouchableWithoutFeedback> @@ -408,6 +414,7 @@ const styles = StyleSheet.create({ borderRadius: 24, paddingHorizontal: 16, paddingVertical: 12, + marginHorizontal: 10, marginBottom: 4, }, bottomBar: { diff --git a/src/view/com/composer/char-progress/CharProgress.tsx b/src/view/com/composer/char-progress/CharProgress.tsx index b17cad1ba..eaaaea5e5 100644 --- a/src/view/com/composer/char-progress/CharProgress.tsx +++ b/src/view/com/composer/char-progress/CharProgress.tsx @@ -8,26 +8,24 @@ import ProgressPie from 'react-native-progress/Pie' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' -const MAX_TEXT_LENGTH = 256 -const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH +const MAX_LENGTH = 300 +const DANGER_LENGTH = MAX_LENGTH export function CharProgress({count}: {count: number}) { const pal = usePalette('default') - const textColor = count > DANGER_TEXT_LENGTH ? '#e60000' : pal.colors.text - const circleColor = count > DANGER_TEXT_LENGTH ? '#e60000' : pal.colors.link + const textColor = count > DANGER_LENGTH ? '#e60000' : pal.colors.text + const circleColor = count > DANGER_LENGTH ? '#e60000' : pal.colors.link return ( <> - <Text style={[s.mr10, {color: textColor}]}> - {MAX_TEXT_LENGTH - count} - </Text> + <Text style={[s.mr10, {color: textColor}]}>{MAX_LENGTH - count}</Text> <View> - {count > DANGER_TEXT_LENGTH ? ( + {count > DANGER_LENGTH ? ( <ProgressPie size={30} borderWidth={4} borderColor={circleColor} color={circleColor} - progress={Math.min((count - MAX_TEXT_LENGTH) / MAX_TEXT_LENGTH, 1)} + progress={Math.min((count - MAX_LENGTH) / MAX_LENGTH, 1)} /> ) : ( <ProgressCircle @@ -35,7 +33,7 @@ export function CharProgress({count}: {count: number}) { borderWidth={1} borderColor={pal.colors.border} color={circleColor} - progress={count / MAX_TEXT_LENGTH} + progress={count / MAX_LENGTH} /> )} </View> diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx index cf4a4c7d1..118728781 100644 --- a/src/view/com/composer/photos/OpenCameraBtn.tsx +++ b/src/view/com/composer/photos/OpenCameraBtn.tsx @@ -76,7 +76,11 @@ export function OpenCameraBtn({ hitSlop={HITSLOP}> <FontAwesomeIcon icon="camera" - style={(enabled ? pal.link : pal.textLight) as FontAwesomeIconStyle} + style={ + (enabled + ? pal.link + : [pal.textLight, s.dimmed]) as FontAwesomeIconStyle + } size={24} /> </TouchableOpacity> diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx index bdcb0534a..888118a85 100644 --- a/src/view/com/composer/photos/SelectPhotoBtn.tsx +++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx @@ -86,7 +86,11 @@ export function SelectPhotoBtn({ hitSlop={HITSLOP}> <FontAwesomeIcon icon={['far', 'image']} - style={(enabled ? pal.link : pal.textLight) as FontAwesomeIconStyle} + style={ + (enabled + ? pal.link + : [pal.textLight, s.dimmed]) as FontAwesomeIconStyle + } size={24} /> </TouchableOpacity> diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index e72b41f0a..393d168fe 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -9,13 +9,13 @@ import PasteInput, { PastedFile, PasteInputRef, } from '@mattermost/react-native-paste-input' +import {AppBskyRichtextFacet, RichText} from '@atproto/api' import isEqual from 'lodash.isequal' import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' import {Autocomplete} from './mobile/Autocomplete' import {Text} from 'view/com/util/text/Text' import {useStores} from 'state/index' import {cleanError} from 'lib/strings/errors' -import {detectLinkables, extractEntities} from 'lib/strings/rich-text-detection' import {getImageDim} from 'lib/media/manip' import {cropAndCompressFlow} from 'lib/media/picker' import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip' @@ -33,11 +33,11 @@ export interface TextInputRef { } interface TextInputProps { - text: string + richtext: RichText placeholder: string suggestedLinks: Set<string> autocompleteView: UserAutocompleteViewModel - onTextChanged: (v: string) => void + setRichText: (v: RichText) => void onPhotoPasted: (uri: string) => void onSuggestedLinksChanged: (uris: Set<string>) => void onError: (err: string) => void @@ -51,11 +51,11 @@ interface Selection { export const TextInput = React.forwardRef( ( { - text, + richtext, placeholder, suggestedLinks, autocompleteView, - onTextChanged, + setRichText, onPhotoPasted, onSuggestedLinksChanged, onError, @@ -92,7 +92,9 @@ export const TextInput = React.forwardRef( const onChangeText = React.useCallback( (newText: string) => { - onTextChanged(newText) + const newRt = new RichText({text: newText}) + newRt.detectFacetsWithoutResolution() + setRichText(newRt) const prefix = getMentionAt( newText, @@ -105,20 +107,21 @@ export const TextInput = React.forwardRef( autocompleteView.setActive(false) } - const ents = extractEntities(newText)?.filter( - ent => ent.type === 'link', - ) - const set = new Set(ents ? ents.map(e => e.value) : []) + const set: Set<string> = new Set() + if (newRt.facets) { + for (const facet of newRt.facets) { + for (const feature of facet.features) { + if (AppBskyRichtextFacet.isLink(feature)) { + set.add(feature.uri) + } + } + } + } if (!isEqual(set, suggestedLinks)) { onSuggestedLinksChanged(set) } }, - [ - onTextChanged, - autocompleteView, - suggestedLinks, - onSuggestedLinksChanged, - ], + [setRichText, autocompleteView, suggestedLinks, onSuggestedLinksChanged], ) const onPaste = React.useCallback( @@ -159,31 +162,35 @@ export const TextInput = React.forwardRef( const onSelectAutocompleteItem = React.useCallback( (item: string) => { onChangeText( - insertMentionAt(text, textInputSelection.current?.start || 0, item), + insertMentionAt( + richtext.text, + textInputSelection.current?.start || 0, + item, + ), ) autocompleteView.setActive(false) }, - [onChangeText, text, autocompleteView], + [onChangeText, richtext, autocompleteView], ) const textDecorated = React.useMemo(() => { let i = 0 - return detectLinkables(text).map(v => { - if (typeof v === 'string') { + return Array.from(richtext.segments()).map(segment => { + if (!segment.facet) { return ( <Text key={i++} style={[pal.text, styles.textInputFormatting]}> - {v} + {segment.text} </Text> ) } else { return ( <Text key={i++} style={[pal.link, styles.textInputFormatting]}> - {v.link} + {segment.text} </Text> ) } }) - }, [text, pal.link, pal.text]) + }, [richtext, pal.link, pal.text]) return ( <View style={styles.container}> diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 4b23e891b..ad891fa5b 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -1,5 +1,6 @@ import React from 'react' import {StyleSheet, View} from 'react-native' +import {RichText} from '@atproto/api' import {useEditor, EditorContent, JSONContent} from '@tiptap/react' import {Document} from '@tiptap/extension-document' import {Link} from '@tiptap/extension-link' @@ -17,11 +18,11 @@ export interface TextInputRef { } interface TextInputProps { - text: string + richtext: RichText placeholder: string suggestedLinks: Set<string> autocompleteView: UserAutocompleteViewModel - onTextChanged: (v: string) => void + setRichText: (v: RichText) => void onPhotoPasted: (uri: string) => void onSuggestedLinksChanged: (uris: Set<string>) => void onError: (err: string) => void @@ -30,11 +31,11 @@ interface TextInputProps { export const TextInput = React.forwardRef( ( { - text, + richtext, placeholder, suggestedLinks, autocompleteView, - onTextChanged, + setRichText, // onPhotoPasted, TODO onSuggestedLinksChanged, }: // onError, TODO @@ -60,15 +61,15 @@ export const TextInput = React.forwardRef( }), Text, ], - content: text, + content: richtext.text.toString(), autofocus: true, editable: true, injectCSS: true, onUpdate({editor: editorProp}) { const json = editorProp.getJSON() - const newText = editorJsonToText(json).trim() - onTextChanged(newText) + const newRt = new RichText({text: editorJsonToText(json).trim()}) + setRichText(newRt) const newSuggestedLinks = new Set(editorJsonToLinks(json)) if (!isEqual(newSuggestedLinks, suggestedLinks)) { diff --git a/src/view/com/discover/SuggestedFollows.tsx b/src/view/com/discover/SuggestedFollows.tsx index 0d09038ba..e4ada5204 100644 --- a/src/view/com/discover/SuggestedFollows.tsx +++ b/src/view/com/discover/SuggestedFollows.tsx @@ -1,6 +1,6 @@ import React from 'react' import {StyleSheet, View} from 'react-native' -import {AppBskyActorRef, AppBskyActorProfile} from '@atproto/api' +import {AppBskyActorDefs} from '@atproto/api' import {RefWithInfoAndFollowers} from 'state/models/discovery/foafs' import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' import {Text} from '../util/text/Text' @@ -12,9 +12,9 @@ export const SuggestedFollows = ({ }: { title: string suggestions: ( - | AppBskyActorRef.WithInfo + | AppBskyActorDefs.ProfileViewBasic + | AppBskyActorDefs.ProfileView | RefWithInfoAndFollowers - | AppBskyActorProfile.View )[] }) => { const pal = usePalette('default') @@ -28,7 +28,6 @@ export const SuggestedFollows = ({ <ProfileCardWithFollowBtn key={item.did} did={item.did} - declarationCid={item.declaration.cid} handle={item.handle} displayName={item.displayName} avatar={item.avatar} @@ -36,12 +35,12 @@ export const SuggestedFollows = ({ noBorder description={ item.description - ? (item as AppBskyActorProfile.View).description + ? (item as AppBskyActorDefs.ProfileView).description : '' } followers={ item.followers - ? (item.followers as AppBskyActorProfile.View[]) + ? (item.followers as AppBskyActorDefs.ProfileView[]) : undefined } /> diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx index f15f7ca43..37bad6957 100644 --- a/src/view/com/modals/ChangeHandle.tsx +++ b/src/view/com/modals/ChangeHandle.tsx @@ -105,7 +105,7 @@ export function Component({onChanged}: {onChanged: () => void}) { track('EditHandle:SetNewHandle') const newHandle = isCustom ? handle : createFullHandle(handle, userDomain) store.log.debug(`Updating handle to ${newHandle}`) - await store.api.com.atproto.handle.update({ + await store.agent.updateHandle({ handle: newHandle, }) store.shell.closeModal() @@ -310,7 +310,7 @@ function CustomHandleForm({ try { setIsVerifying(true) setError('') - const res = await store.api.com.atproto.handle.resolve({handle}) + const res = await store.agent.com.atproto.identity.resolveHandle({handle}) if (res.data.did === store.me.did) { setCanSave(true) } else { @@ -331,7 +331,7 @@ function CustomHandleForm({ canSave, onPressSave, store.log, - store.api, + store.agent, ]) // rendering diff --git a/src/view/com/modals/Confirm.tsx b/src/view/com/modals/Confirm.tsx index 60c104f99..2bfcf4118 100644 --- a/src/view/com/modals/Confirm.tsx +++ b/src/view/com/modals/Confirm.tsx @@ -39,7 +39,7 @@ export function Component({ } } return ( - <View style={[s.flex1, s.pl10, s.pr10]}> + <View testID="confirmModal" style={[s.flex1, s.pl10, s.pr10]}> <Text style={styles.title}>{title}</Text> {typeof message === 'string' ? ( <Text style={styles.description}>{message}</Text> @@ -56,7 +56,7 @@ export function Component({ <ActivityIndicator /> </View> ) : ( - <TouchableOpacity style={s.mt10} onPress={onPress}> + <TouchableOpacity testID="confirmBtn" style={s.mt10} onPress={onPress}> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} diff --git a/src/view/com/modals/DeleteAccount.tsx b/src/view/com/modals/DeleteAccount.tsx index 23cd9eb82..353122163 100644 --- a/src/view/com/modals/DeleteAccount.tsx +++ b/src/view/com/modals/DeleteAccount.tsx @@ -32,7 +32,7 @@ export function Component({}: {}) { setError('') setIsProcessing(true) try { - await store.api.com.atproto.account.requestDelete() + await store.agent.com.atproto.server.requestAccountDelete() setIsEmailSent(true) } catch (e: any) { setError(cleanError(e)) @@ -43,7 +43,7 @@ export function Component({}: {}) { setError('') setIsProcessing(true) try { - await store.api.com.atproto.account.delete({ + await store.agent.com.atproto.server.deleteAccount({ did: store.me.did, password, token: confirmCode, diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx index 6eb21d17d..0b81d7f39 100644 --- a/src/view/com/modals/EditProfile.tsx +++ b/src/view/com/modals/EditProfile.tsx @@ -123,7 +123,7 @@ export function Component({ } return ( - <View style={[s.flex1, pal.view]}> + <View style={[s.flex1, pal.view]} testID="editProfileModal"> <ScrollView style={styles.inner}> <Text style={[styles.title, pal.text]}>Edit my profile</Text> <View style={styles.photos}> @@ -147,6 +147,7 @@ export function Component({ <View> <Text style={[styles.label, pal.text]}>Display Name</Text> <TextInput + testID="editProfileDisplayNameInput" style={[styles.textInput, pal.text]} placeholder="e.g. Alice Roberts" placeholderTextColor={colors.gray4} @@ -157,6 +158,7 @@ export function Component({ <View style={s.pb10}> <Text style={[styles.label, pal.text]}>Description</Text> <TextInput + testID="editProfileDescriptionInput" style={[styles.textArea, pal.text]} placeholder="e.g. Artist, dog-lover, and memelord." placeholderTextColor={colors.gray4} @@ -171,7 +173,10 @@ export function Component({ <ActivityIndicator /> </View> ) : ( - <TouchableOpacity style={s.mt10} onPress={onPressSave}> + <TouchableOpacity + testID="editProfileSaveBtn" + style={s.mt10} + onPress={onPressSave}> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} @@ -181,7 +186,10 @@ export function Component({ </LinearGradient> </TouchableOpacity> )} - <TouchableOpacity style={s.mt5} onPress={onPressCancel}> + <TouchableOpacity + testID="editProfileCancelBtn" + style={s.mt5} + onPress={onPressCancel}> <View style={[styles.btn]}> <Text style={[s.black, s.bold, pal.text]}>Cancel</Text> </View> diff --git a/src/view/com/modals/ReportAccount.tsx b/src/view/com/modals/ReportAccount.tsx index c9ee004b8..601bccbd1 100644 --- a/src/view/com/modals/ReportAccount.tsx +++ b/src/view/com/modals/ReportAccount.tsx @@ -5,7 +5,7 @@ import { TouchableOpacity, View, } from 'react-native' -import {ComAtprotoReportReasonType} from '@atproto/api' +import {ComAtprotoModerationDefs} from '@atproto/api' import LinearGradient from 'react-native-linear-gradient' import {useStores} from 'state/index' import {s, colors, gradients} from 'lib/styles' @@ -39,16 +39,16 @@ export function Component({did}: {did: string}) { setIsProcessing(true) try { // NOTE: we should update the lexicon of reasontype to include more options -prf - let reasonType = ComAtprotoReportReasonType.OTHER + let reasonType = ComAtprotoModerationDefs.REASONOTHER if (issue === 'spam') { - reasonType = ComAtprotoReportReasonType.SPAM + reasonType = ComAtprotoModerationDefs.REASONSPAM } const reason = ITEMS.find(item => item.key === issue)?.label || '' - await store.api.com.atproto.report.create({ + await store.agent.com.atproto.moderation.createReport({ reasonType, reason, subject: { - $type: 'com.atproto.repo.repoRef', + $type: 'com.atproto.admin.defs#repoRef', did, }, }) @@ -61,12 +61,18 @@ export function Component({did}: {did: string}) { } } return ( - <View style={[s.flex1, s.pl10, s.pr10, pal.view]}> + <View + testID="reportAccountModal" + style={[s.flex1, s.pl10, s.pr10, pal.view]}> <Text style={[pal.text, styles.title]}>Report account</Text> <Text style={[pal.textLight, styles.description]}> What is the issue with this account? </Text> - <RadioGroup items={ITEMS} onSelect={onSelectIssue} /> + <RadioGroup + testID="reportAccountRadios" + items={ITEMS} + onSelect={onSelectIssue} + /> {error ? ( <View style={s.mt10}> <ErrorMessage message={error} /> @@ -77,7 +83,10 @@ export function Component({did}: {did: string}) { <ActivityIndicator /> </View> ) : issue ? ( - <TouchableOpacity style={s.mt10} onPress={onPress}> + <TouchableOpacity + testID="sendReportBtn" + style={s.mt10} + onPress={onPress}> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} diff --git a/src/view/com/modals/ReportPost.tsx b/src/view/com/modals/ReportPost.tsx index 3e876c6c8..01a132af0 100644 --- a/src/view/com/modals/ReportPost.tsx +++ b/src/view/com/modals/ReportPost.tsx @@ -5,7 +5,7 @@ import { TouchableOpacity, View, } from 'react-native' -import {ComAtprotoReportReasonType} from '@atproto/api' +import {ComAtprotoModerationDefs} from '@atproto/api' import LinearGradient from 'react-native-linear-gradient' import {useStores} from 'state/index' import {s, colors, gradients} from 'lib/styles' @@ -46,16 +46,16 @@ export function Component({ setIsProcessing(true) try { // NOTE: we should update the lexicon of reasontype to include more options -prf - let reasonType = ComAtprotoReportReasonType.OTHER + let reasonType = ComAtprotoModerationDefs.REASONOTHER if (issue === 'spam') { - reasonType = ComAtprotoReportReasonType.SPAM + reasonType = ComAtprotoModerationDefs.REASONSPAM } const reason = ITEMS.find(item => item.key === issue)?.label || '' - await store.api.com.atproto.report.create({ + await store.agent.createModerationReport({ reasonType, reason, subject: { - $type: 'com.atproto.repo.recordRef', + $type: 'com.atproto.repo.strongRef', uri: postUri, cid: postCid, }, @@ -69,12 +69,16 @@ export function Component({ } } return ( - <View style={[s.flex1, s.pl10, s.pr10, pal.view]}> + <View testID="reportPostModal" style={[s.flex1, s.pl10, s.pr10, pal.view]}> <Text style={[pal.text, styles.title]}>Report post</Text> <Text style={[pal.textLight, styles.description]}> What is the issue with this post? </Text> - <RadioGroup items={ITEMS} onSelect={onSelectIssue} /> + <RadioGroup + testID="reportPostRadios" + items={ITEMS} + onSelect={onSelectIssue} + /> {error ? ( <View style={s.mt10}> <ErrorMessage message={error} /> @@ -85,7 +89,10 @@ export function Component({ <ActivityIndicator /> </View> ) : issue ? ( - <TouchableOpacity style={s.mt10} onPress={onPress}> + <TouchableOpacity + testID="sendReportBtn" + style={s.mt10} + onPress={onPress}> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} diff --git a/src/view/com/modals/Repost.tsx b/src/view/com/modals/Repost.tsx index b4669a046..d5ed66b70 100644 --- a/src/view/com/modals/Repost.tsx +++ b/src/view/com/modals/Repost.tsx @@ -26,22 +26,28 @@ export function Component({ } return ( - <View style={[s.flex1, pal.view, styles.container]}> + <View testID="repostModal" style={[s.flex1, pal.view, styles.container]}> <View style={s.pb20}> - <TouchableOpacity style={[styles.actionBtn]} onPress={onRepost}> + <TouchableOpacity + testID="repostBtn" + style={[styles.actionBtn]} + onPress={onRepost}> <RepostIcon strokeWidth={2} size={24} style={s.blue3} /> <Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}> {!isReposted ? 'Repost' : 'Undo repost'} </Text> </TouchableOpacity> - <TouchableOpacity style={[styles.actionBtn]} onPress={onQuote}> + <TouchableOpacity + testID="quoteBtn" + style={[styles.actionBtn]} + onPress={onQuote}> <FontAwesomeIcon icon="quote-left" size={24} style={s.blue3} /> <Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}> Quote Post </Text> </TouchableOpacity> </View> - <TouchableOpacity onPress={onPress}> + <TouchableOpacity testID="cancelBtn" onPress={onPress}> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index 1c2299b03..7d584e8e6 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -47,10 +47,10 @@ export const FeedItem = observer(function FeedItem({ const pal = usePalette('default') const [isAuthorsExpanded, setAuthorsExpanded] = React.useState<boolean>(false) const itemHref = React.useMemo(() => { - if (item.isUpvote || item.isRepost) { + if (item.isLike || item.isRepost) { const urip = new AtUri(item.subjectUri) return `/profile/${urip.host}/post/${urip.rkey}` - } else if (item.isFollow || item.isAssertion) { + } else if (item.isFollow) { return `/profile/${item.author.handle}` } else if (item.isReply) { const urip = new AtUri(item.uri) @@ -59,9 +59,9 @@ export const FeedItem = observer(function FeedItem({ return '' }, [item]) const itemTitle = React.useMemo(() => { - if (item.isUpvote || item.isRepost) { + if (item.isLike || item.isRepost) { return 'Post' - } else if (item.isFollow || item.isAssertion) { + } else if (item.isFollow) { return item.author.handle } else if (item.isReply) { return 'Post' @@ -77,7 +77,7 @@ export const FeedItem = observer(function FeedItem({ return <View /> } - if (item.isReply || item.isMention) { + if (item.isReply || item.isMention || item.isQuote) { if (item.additionalPost?.error) { // hide errors - it doesnt help the user to show them return <View /> @@ -103,7 +103,7 @@ export const FeedItem = observer(function FeedItem({ let action = '' let icon: Props['icon'] | 'HeartIconSolid' let iconStyle: Props['style'] = [] - if (item.isUpvote) { + if (item.isLike) { action = 'liked your post' icon = 'HeartIconSolid' iconStyle = [ @@ -114,9 +114,6 @@ export const FeedItem = observer(function FeedItem({ action = 'reposted your post' icon = 'retweet' iconStyle = [s.green3 as FontAwesomeIconStyle] - } else if (item.isReply) { - action = 'replied to your post' - icon = ['far', 'comment'] } else if (item.isFollow) { action = 'followed you' icon = 'user-plus' @@ -208,7 +205,7 @@ export const FeedItem = observer(function FeedItem({ </View> </View> </TouchableWithoutFeedback> - {item.isUpvote || item.isRepost ? ( + {item.isLike || item.isRepost || item.isQuote ? ( <AdditionalPostText additionalPost={item.additionalPost} /> ) : ( <></> @@ -352,9 +349,9 @@ function AdditionalPostText({ return <View /> } const text = additionalPost.thread?.postRecord.text - const images = ( - additionalPost.thread.post.embed as AppBskyEmbedImages.Presented - )?.images + const images = AppBskyEmbedImages.isView(additionalPost.thread.post.embed) + ? additionalPost.thread.post.embed.images + : undefined return ( <> {text?.length > 0 && <Text style={pal.textLight}>{text}</Text>} diff --git a/src/view/com/pager/FeedsTabBar.tsx b/src/view/com/pager/FeedsTabBar.tsx index 9831218ec..76e0a6fc6 100644 --- a/src/view/com/pager/FeedsTabBar.tsx +++ b/src/view/com/pager/FeedsTabBar.tsx @@ -9,7 +9,9 @@ import {usePalette} from 'lib/hooks/usePalette' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' export const FeedsTabBar = observer( - (props: RenderTabBarFnProps & {onPressSelected: () => void}) => { + ( + props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, + ) => { const store = useStores() const pal = usePalette('default') const interp = useAnimatedValue(0) @@ -32,7 +34,10 @@ export const FeedsTabBar = observer( return ( <Animated.View style={[pal.view, styles.tabBar, transform]}> - <TouchableOpacity style={styles.tabBarAvi} onPress={onPressAvi}> + <TouchableOpacity + testID="viewHeaderDrawerBtn" + style={styles.tabBarAvi} + onPress={onPressAvi}> <UserAvatar avatar={store.me.avatar} size={30} /> </TouchableOpacity> <TabBar diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx index 416828a27..34747db6d 100644 --- a/src/view/com/pager/Pager.tsx +++ b/src/view/com/pager/Pager.tsx @@ -20,6 +20,7 @@ interface Props { initialPage?: number renderTabBar: RenderTabBarFn onPageSelected?: (index: number) => void + testID?: string } export const Pager = ({ children, @@ -27,6 +28,7 @@ export const Pager = ({ initialPage = 0, renderTabBar, onPageSelected, + testID, }: React.PropsWithChildren<Props>) => { const [selectedPage, setSelectedPage] = React.useState(0) const position = useAnimatedValue(0) @@ -49,7 +51,7 @@ export const Pager = ({ ) return ( - <View> + <View testID={testID}> {tabBarPosition === 'top' && renderTabBar({ selectedPage, diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 0b45d95f5..2070898bf 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -15,6 +15,7 @@ interface Layout { } export interface TabBarProps { + testID?: string selectedPage: number items: string[] position: Animated.Value @@ -26,6 +27,7 @@ export interface TabBarProps { } export function TabBar({ + testID, selectedPage, items, position, @@ -92,12 +94,15 @@ export function TabBar({ } return ( - <View style={[pal.view, styles.outer]} onLayout={onLayout}> + <View testID={testID} style={[pal.view, styles.outer]} onLayout={onLayout}> <Animated.View style={[styles.indicator, indicatorStyle]} /> {items.map((item, i) => { const selected = i === selectedPage return ( - <TouchableWithoutFeedback key={i} onPress={() => onPressItem(i)}> + <TouchableWithoutFeedback + key={i} + testID={testID ? `${testID}-${item}` : undefined} + onPress={() => onPressItem(i)}> <View style={ indicatorPosition === 'top' ? styles.itemTop : styles.itemBottom diff --git a/src/view/com/post-thread/PostVotedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx index f86798097..9fb46702e 100644 --- a/src/view/com/post-thread/PostVotedBy.tsx +++ b/src/view/com/post-thread/PostLikedBy.tsx @@ -2,24 +2,18 @@ import React, {useEffect} from 'react' import {observer} from 'mobx-react-lite' import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native' import {CenteredView, FlatList} from '../util/Views' -import {VotesViewModel, VoteItem} from 'state/models/votes-view' +import {LikesViewModel, LikeItem} from 'state/models/likes-view' import {ErrorMessage} from '../util/error/ErrorMessage' import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' -export const PostVotedBy = observer(function PostVotedBy({ - uri, - direction, -}: { - uri: string - direction: 'up' | 'down' -}) { +export const PostLikedBy = observer(function PostVotedBy({uri}: {uri: string}) { const pal = usePalette('default') const store = useStores() const view = React.useMemo( - () => new VotesViewModel(store, {uri, direction}), - [store, uri, direction], + () => new LikesViewModel(store, {uri}), + [store, uri], ) useEffect(() => { @@ -55,11 +49,10 @@ export const PostVotedBy = observer(function PostVotedBy({ // loaded // = - const renderItem = ({item}: {item: VoteItem}) => ( + const renderItem = ({item}: {item: LikeItem}) => ( <ProfileCardWithFollowBtn key={item.actor.did} did={item.actor.did} - declarationCid={item.actor.declaration.cid} handle={item.actor.handle} displayName={item.actor.displayName} avatar={item.actor.avatar} @@ -68,7 +61,7 @@ export const PostVotedBy = observer(function PostVotedBy({ ) return ( <FlatList - data={view.votes} + data={view.likes} keyExtractor={item => item.actor.did} refreshControl={ <RefreshControl diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx index fda54469c..147d0271f 100644 --- a/src/view/com/post-thread/PostRepostedBy.tsx +++ b/src/view/com/post-thread/PostRepostedBy.tsx @@ -64,7 +64,6 @@ export const PostRepostedBy = observer(function PostRepostedBy({ <ProfileCardWithFollowBtn key={item.did} did={item.did} - declarationCid={item.declaration.cid} handle={item.handle} displayName={item.displayName} avatar={item.avatar} diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index d0452331b..569c6e392 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -1,17 +1,30 @@ import React, {useRef} from 'react' import {observer} from 'mobx-react-lite' -import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native' +import { + ActivityIndicator, + RefreshControl, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native' import {CenteredView, FlatList} from '../util/Views' import { PostThreadViewModel, PostThreadViewPostModel, } from 'state/models/post-thread-view' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' import {PostThreadItem} from './PostThreadItem' import {ComposePrompt} from '../composer/Prompt' import {ErrorMessage} from '../util/error/ErrorMessage' +import {Text} from '../util/text/Text' import {s} from 'lib/styles' import {isDesktopWeb} from 'platform/detection' import {usePalette} from 'lib/hooks/usePalette' +import {useNavigation} from '@react-navigation/native' +import {NavigationProp} from 'lib/routes/types' const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false} const BOTTOM_BORDER = { @@ -32,6 +45,7 @@ export const PostThread = observer(function PostThread({ const pal = usePalette('default') const ref = useRef<FlatList>(null) const [isRefreshing, setIsRefreshing] = React.useState(false) + const navigation = useNavigation<NavigationProp>() const posts = React.useMemo(() => { if (view.thread) { return Array.from(flattenThread(view.thread)).concat([BOTTOM_BORDER]) @@ -41,6 +55,7 @@ export const PostThread = observer(function PostThread({ // events // = + const onRefresh = React.useCallback(async () => { setIsRefreshing(true) try { @@ -50,6 +65,7 @@ export const PostThread = observer(function PostThread({ } setIsRefreshing(false) }, [view, setIsRefreshing]) + const onLayout = React.useCallback(() => { const index = posts.findIndex(post => post._isHighlightedPost) if (index !== -1) { @@ -60,6 +76,7 @@ export const PostThread = observer(function PostThread({ }) } }, [posts, ref]) + const onScrollToIndexFailed = React.useCallback( (info: { index: number @@ -73,6 +90,15 @@ export const PostThread = observer(function PostThread({ }, [ref], ) + + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, [navigation]) + const renderItem = React.useCallback( ({item}: {item: YieldedItem}) => { if (item === REPLY_PROMPT) { @@ -104,6 +130,30 @@ export const PostThread = observer(function PostThread({ // error // = if (view.hasError) { + if (view.notFound) { + return ( + <CenteredView> + <View style={[pal.view, pal.border, styles.notFoundContainer]}> + <Text type="title-lg" style={[pal.text, s.mb5]}> + Post not found + </Text> + <Text type="md" style={[pal.text, s.mb10]}> + The post may have been deleted. + </Text> + <TouchableOpacity onPress={onPressBack}> + <Text type="2xl" style={pal.link}> + <FontAwesomeIcon + icon="angle-left" + style={[pal.link as FontAwesomeIconStyle, s.mr5]} + size={14} + /> + Back + </Text> + </TouchableOpacity> + </View> + </CenteredView> + ) + } return ( <CenteredView> <ErrorMessage message={view.error} onPressTryAgain={onRefresh} /> @@ -159,12 +209,18 @@ function* flattenThread( yield* flattenThread(reply as PostThreadViewPostModel) } } - } else if (!isAscending && !post.parent && post.post.replyCount > 0) { + } else if (!isAscending && !post.parent && post.post.replyCount) { post._hasMore = true } } const styles = StyleSheet.create({ + notFoundContainer: { + margin: 10, + paddingHorizontal: 18, + paddingVertical: 14, + borderRadius: 6, + }, bottomBorder: { borderBottomWidth: 1, }, diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 17c7943d9..cf2148060 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -19,7 +19,7 @@ import {ago} from 'lib/strings/time' import {pluralize} from 'lib/strings/helpers' import {useStores} from 'state/index' import {PostMeta} from '../util/PostMeta' -import {PostEmbeds} from '../util/PostEmbeds' +import {PostEmbeds} from '../util/post-embeds' import {PostCtrls} from '../util/PostCtrls' import {PostMutedWrapper} from '../util/PostMuted' import {ErrorMessage} from '../util/error/ErrorMessage' @@ -38,7 +38,7 @@ export const PostThreadItem = observer(function PostThreadItem({ const store = useStores() const [deleted, setDeleted] = React.useState(false) const record = item.postRecord - const hasEngagement = item.post.upvoteCount || item.post.repostCount + const hasEngagement = item.post.likeCount || item.post.repostCount const itemUri = item.post.uri const itemCid = item.post.cid @@ -49,11 +49,11 @@ export const PostThreadItem = observer(function PostThreadItem({ const itemTitle = `Post by ${item.post.author.handle}` const authorHref = `/profile/${item.post.author.handle}` const authorTitle = item.post.author.handle - const upvotesHref = React.useMemo(() => { + const likesHref = React.useMemo(() => { const urip = new AtUri(item.post.uri) - return `/profile/${item.post.author.handle}/post/${urip.rkey}/upvoted-by` + return `/profile/${item.post.author.handle}/post/${urip.rkey}/liked-by` }, [item.post.uri, item.post.author.handle]) - const upvotesTitle = 'Likes on this post' + const likesTitle = 'Likes on this post' const repostsHref = React.useMemo(() => { const urip = new AtUri(item.post.uri) return `/profile/${item.post.author.handle}/post/${urip.rkey}/reposted-by` @@ -80,10 +80,10 @@ export const PostThreadItem = observer(function PostThreadItem({ .toggleRepost() .catch(e => store.log.error('Failed to toggle repost', e)) }, [item, store]) - const onPressToggleUpvote = React.useCallback(() => { + const onPressToggleLike = React.useCallback(() => { return item - .toggleUpvote() - .catch(e => store.log.error('Failed to toggle upvote', e)) + .toggleLike() + .catch(e => store.log.error('Failed to toggle like', e)) }, [item, store]) const onCopyPostText = React.useCallback(() => { Clipboard.setString(record?.text || '') @@ -125,153 +125,151 @@ export const PostThreadItem = observer(function PostThreadItem({ if (item._isHighlightedPost) { return ( - <> - <View - style={[ - styles.outer, - styles.outerHighlighted, - {borderTopColor: pal.colors.border}, - pal.view, - ]}> - <View style={styles.layout}> - <View style={styles.layoutAvi}> - <Link href={authorHref} title={authorTitle} asAnchor> - <UserAvatar size={52} avatar={item.post.author.avatar} /> - </Link> - </View> - <View style={styles.layoutContent}> - <View style={[styles.meta, styles.metaExpandedLine1]}> - <View style={[s.flexRow, s.alignBaseline]}> - <Link - style={styles.metaItem} - href={authorHref} - title={authorTitle}> - <Text - type="xl-bold" - style={[pal.text]} - numberOfLines={1} - lineHeight={1.2}> - {item.post.author.displayName || item.post.author.handle} - </Text> - </Link> - <Text type="md" style={[styles.metaItem, pal.textLight]}> - · {ago(item.post.indexedAt)} - </Text> - </View> - <View style={s.flex1} /> - <PostDropdownBtn - style={styles.metaItem} - itemUri={itemUri} - itemCid={itemCid} - itemHref={itemHref} - itemTitle={itemTitle} - isAuthor={item.post.author.did === store.me.did} - onCopyPostText={onCopyPostText} - onOpenTranslate={onOpenTranslate} - onDeletePost={onDeletePost}> - <FontAwesomeIcon - icon="ellipsis-h" - size={14} - style={[s.mt2, s.mr5, pal.textLight]} - /> - </PostDropdownBtn> - </View> - <View style={styles.meta}> + <View + testID={`postThreadItem-by-${item.post.author.handle}`} + style={[ + styles.outer, + styles.outerHighlighted, + {borderTopColor: pal.colors.border}, + pal.view, + ]}> + <View style={styles.layout}> + <View style={styles.layoutAvi}> + <Link href={authorHref} title={authorTitle} asAnchor> + <UserAvatar size={52} avatar={item.post.author.avatar} /> + </Link> + </View> + <View style={styles.layoutContent}> + <View style={[styles.meta, styles.metaExpandedLine1]}> + <View style={[s.flexRow, s.alignBaseline]}> <Link style={styles.metaItem} href={authorHref} title={authorTitle}> - <Text type="md" style={[pal.textLight]} numberOfLines={1}> - @{item.post.author.handle} + <Text + type="xl-bold" + style={[pal.text]} + numberOfLines={1} + lineHeight={1.2}> + {item.post.author.displayName || item.post.author.handle} </Text> </Link> + <Text type="md" style={[styles.metaItem, pal.textLight]}> + · {ago(item.post.indexedAt)} + </Text> </View> - </View> - </View> - <View style={[s.pl10, s.pr10, s.pb10]}> - {item.richText?.text ? ( - <View - style={[ - styles.postTextContainer, - styles.postTextLargeContainer, - ]}> - <RichText - type="post-text-lg" - richText={item.richText} - lineHeight={1.3} - /> - </View> - ) : undefined} - <PostEmbeds embed={item.post.embed} style={s.mb10} /> - {item._isHighlightedPost && hasEngagement ? ( - <View style={[styles.expandedInfo, pal.border]}> - {item.post.repostCount ? ( - <Link - style={styles.expandedInfoItem} - href={repostsHref} - title={repostsTitle}> - <Text type="lg" style={pal.textLight}> - <Text type="xl-bold" style={pal.text}> - {item.post.repostCount} - </Text>{' '} - {pluralize(item.post.repostCount, 'repost')} - </Text> - </Link> - ) : ( - <></> - )} - {item.post.upvoteCount ? ( - <Link - style={styles.expandedInfoItem} - href={upvotesHref} - title={upvotesTitle}> - <Text type="lg" style={pal.textLight}> - <Text type="xl-bold" style={pal.text}> - {item.post.upvoteCount} - </Text>{' '} - {pluralize(item.post.upvoteCount, 'like')} - </Text> - </Link> - ) : ( - <></> - )} - </View> - ) : ( - <></> - )} - <View style={[s.pl10, s.pb5]}> - <PostCtrls - big + <View style={s.flex1} /> + <PostDropdownBtn + testID="postDropdownBtn" + style={styles.metaItem} itemUri={itemUri} itemCid={itemCid} itemHref={itemHref} itemTitle={itemTitle} - author={{ - avatar: item.post.author.avatar!, - handle: item.post.author.handle, - displayName: item.post.author.displayName!, - }} - text={item.richText?.text || record.text} - indexedAt={item.post.indexedAt} isAuthor={item.post.author.did === store.me.did} - isReposted={!!item.post.viewer.repost} - isUpvoted={!!item.post.viewer.upvote} - onPressReply={onPressReply} - onPressToggleRepost={onPressToggleRepost} - onPressToggleUpvote={onPressToggleUpvote} onCopyPostText={onCopyPostText} onOpenTranslate={onOpenTranslate} - onDeletePost={onDeletePost} + onDeletePost={onDeletePost}> + <FontAwesomeIcon + icon="ellipsis-h" + size={14} + style={[s.mt2, s.mr5, pal.textLight]} + /> + </PostDropdownBtn> + </View> + <View style={styles.meta}> + <Link + style={styles.metaItem} + href={authorHref} + title={authorTitle}> + <Text type="md" style={[pal.textLight]} numberOfLines={1}> + @{item.post.author.handle} + </Text> + </Link> + </View> + </View> + </View> + <View style={[s.pl10, s.pr10, s.pb10]}> + {item.richText?.text ? ( + <View + style={[styles.postTextContainer, styles.postTextLargeContainer]}> + <RichText + type="post-text-lg" + richText={item.richText} + lineHeight={1.3} /> </View> + ) : undefined} + <PostEmbeds embed={item.post.embed} style={s.mb10} /> + {item._isHighlightedPost && hasEngagement ? ( + <View style={[styles.expandedInfo, pal.border]}> + {item.post.repostCount ? ( + <Link + style={styles.expandedInfoItem} + href={repostsHref} + title={repostsTitle}> + <Text testID="repostCount" type="lg" style={pal.textLight}> + <Text type="xl-bold" style={pal.text}> + {item.post.repostCount} + </Text>{' '} + {pluralize(item.post.repostCount, 'repost')} + </Text> + </Link> + ) : ( + <></> + )} + {item.post.likeCount ? ( + <Link + style={styles.expandedInfoItem} + href={likesHref} + title={likesTitle}> + <Text testID="likeCount" type="lg" style={pal.textLight}> + <Text type="xl-bold" style={pal.text}> + {item.post.likeCount} + </Text>{' '} + {pluralize(item.post.likeCount, 'like')} + </Text> + </Link> + ) : ( + <></> + )} + </View> + ) : ( + <></> + )} + <View style={[s.pl10, s.pb5]}> + <PostCtrls + big + itemUri={itemUri} + itemCid={itemCid} + itemHref={itemHref} + itemTitle={itemTitle} + author={{ + avatar: item.post.author.avatar!, + handle: item.post.author.handle, + displayName: item.post.author.displayName!, + }} + text={item.richText?.text || record.text} + indexedAt={item.post.indexedAt} + isAuthor={item.post.author.did === store.me.did} + isReposted={!!item.post.viewer?.repost} + isLiked={!!item.post.viewer?.like} + onPressReply={onPressReply} + onPressToggleRepost={onPressToggleRepost} + onPressToggleLike={onPressToggleLike} + onCopyPostText={onCopyPostText} + onOpenTranslate={onOpenTranslate} + onDeletePost={onDeletePost} + /> </View> </View> - </> + </View> ) } else { return ( <PostMutedWrapper isMuted={item.post.author.viewer?.muted === true}> <Link + testID={`postThreadItem-by-${item.post.author.handle}`} style={[styles.outer, {borderTopColor: pal.colors.border}, pal.view]} href={itemHref} title={itemTitle} @@ -305,7 +303,6 @@ export const PostThreadItem = observer(function PostThreadItem({ timestamp={item.post.indexedAt} postHref={itemHref} did={item.post.author.did} - declarationCid={item.post.author.declaration.cid} /> {item.richText?.text ? ( <View style={styles.postTextContainer}> @@ -333,12 +330,12 @@ export const PostThreadItem = observer(function PostThreadItem({ isAuthor={item.post.author.did === store.me.did} replyCount={item.post.replyCount} repostCount={item.post.repostCount} - upvoteCount={item.post.upvoteCount} - isReposted={!!item.post.viewer.repost} - isUpvoted={!!item.post.viewer.upvote} + likeCount={item.post.likeCount} + isReposted={!!item.post.viewer?.repost} + isLiked={!!item.post.viewer?.like} onPressReply={onPressReply} onPressToggleRepost={onPressToggleRepost} - onPressToggleUpvote={onPressToggleUpvote} + onPressToggleLike={onPressToggleLike} onCopyPostText={onCopyPostText} onOpenTranslate={onOpenTranslate} onDeletePost={onDeletePost} diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index a6c66d143..6b3dc3ac6 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -15,7 +15,7 @@ import {PostThreadViewModel} from 'state/models/post-thread-view' import {Link} from '../util/Link' import {UserInfoText} from '../util/UserInfoText' import {PostMeta} from '../util/PostMeta' -import {PostEmbeds} from '../util/PostEmbeds' +import {PostEmbeds} from '../util/post-embeds' import {PostCtrls} from '../util/PostCtrls' import {PostMutedWrapper} from '../util/PostMuted' import {Text} from '../util/text/Text' @@ -118,10 +118,10 @@ export const Post = observer(function Post({ .toggleRepost() .catch(e => store.log.error('Failed to toggle repost', e)) } - const onPressToggleUpvote = () => { + const onPressToggleLike = () => { return item - .toggleUpvote() - .catch(e => store.log.error('Failed to toggle upvote', e)) + .toggleLike() + .catch(e => store.log.error('Failed to toggle like', e)) } const onCopyPostText = () => { Clipboard.setString(record.text) @@ -166,7 +166,6 @@ export const Post = observer(function Post({ timestamp={item.post.indexedAt} postHref={itemHref} did={item.post.author.did} - declarationCid={item.post.author.declaration.cid} /> {replyAuthorDid !== '' && ( <View style={[s.flexRow, s.mb2, s.alignCenter]}> @@ -211,12 +210,12 @@ export const Post = observer(function Post({ isAuthor={item.post.author.did === store.me.did} replyCount={item.post.replyCount} repostCount={item.post.repostCount} - upvoteCount={item.post.upvoteCount} - isReposted={!!item.post.viewer.repost} - isUpvoted={!!item.post.viewer.upvote} + likeCount={item.post.likeCount} + isReposted={!!item.post.viewer?.repost} + isLiked={!!item.post.viewer?.like} onPressReply={onPressReply} onPressToggleRepost={onPressToggleRepost} - onPressToggleUpvote={onPressToggleUpvote} + onPressToggleLike={onPressToggleLike} onCopyPostText={onCopyPostText} onOpenTranslate={onOpenTranslate} onDeletePost={onDeletePost} diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 4154cbe75..d07afca34 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -128,6 +128,7 @@ export const Feed = observer(function Feed({ <View testID={testID} style={style}> {data.length > 0 && ( <FlatList + testID={testID ? `${testID}-flatlist` : undefined} ref={scrollElRef} data={data} keyExtractor={item => item._reactKey} diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 573b92fd3..734034a89 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -13,7 +13,7 @@ import {Text} from '../util/text/Text' import {UserInfoText} from '../util/UserInfoText' import {PostMeta} from '../util/PostMeta' import {PostCtrls} from '../util/PostCtrls' -import {PostEmbeds} from '../util/PostEmbeds' +import {PostEmbeds} from '../util/post-embeds' import {PostMutedWrapper} from '../util/PostMuted' import {RichText} from '../util/text/RichText' import * as Toast from '../util/Toast' @@ -79,11 +79,11 @@ export const FeedItem = observer(function ({ .toggleRepost() .catch(e => store.log.error('Failed to toggle repost', e)) } - const onPressToggleUpvote = () => { + const onPressToggleLike = () => { track('FeedItem:PostLike') return item - .toggleUpvote() - .catch(e => store.log.error('Failed to toggle upvote', e)) + .toggleLike() + .catch(e => store.log.error('Failed to toggle like', e)) } const onCopyPostText = () => { Clipboard.setString(record?.text || '') @@ -127,7 +127,12 @@ export const FeedItem = observer(function ({ return ( <PostMutedWrapper isMuted={isMuted}> - <Link style={outerStyles} href={itemHref} title={itemTitle} noFeedback> + <Link + testID={`feedItem-by-${item.post.author.handle}`} + style={outerStyles} + href={itemHref} + title={itemTitle} + noFeedback> {isThreadChild && ( <View style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]} @@ -189,7 +194,6 @@ export const FeedItem = observer(function ({ timestamp={item.post.indexedAt} postHref={itemHref} did={item.post.author.did} - declarationCid={item.post.author.declaration.cid} showFollowBtn={showFollowBtn} /> {!isThreadChild && replyAuthorDid !== '' && ( @@ -239,12 +243,12 @@ export const FeedItem = observer(function ({ isAuthor={item.post.author.did === store.me.did} replyCount={item.post.replyCount} repostCount={item.post.repostCount} - upvoteCount={item.post.upvoteCount} - isReposted={!!item.post.viewer.repost} - isUpvoted={!!item.post.viewer.upvote} + likeCount={item.post.likeCount} + isReposted={!!item.post.viewer?.repost} + isLiked={!!item.post.viewer?.like} onPressReply={onPressReply} onPressToggleRepost={onPressToggleRepost} - onPressToggleUpvote={onPressToggleUpvote} + onPressToggleLike={onPressToggleLike} onCopyPostText={onCopyPostText} onOpenTranslate={onOpenTranslate} onDeletePost={onDeletePost} diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx index 5204f5a40..f22eb9b4a 100644 --- a/src/view/com/profile/FollowButton.tsx +++ b/src/view/com/profile/FollowButton.tsx @@ -2,19 +2,16 @@ import React from 'react' import {observer} from 'mobx-react-lite' import {Button, ButtonType} from '../util/forms/Button' import {useStores} from 'state/index' -import * as apilib from 'lib/api/index' import * as Toast from '../util/Toast' const FollowButton = observer( ({ type = 'inverted', did, - declarationCid, onToggleFollow, }: { type?: ButtonType did: string - declarationCid: string onToggleFollow?: (v: boolean) => void }) => { const store = useStores() @@ -23,7 +20,7 @@ const FollowButton = observer( const onToggleFollowInner = async () => { if (store.me.follows.isFollowing(did)) { try { - await apilib.unfollow(store, store.me.follows.getFollowUri(did)) + await store.agent.deleteFollow(store.me.follows.getFollowUri(did)) store.me.follows.removeFollow(did) onToggleFollow?.(false) } catch (e: any) { @@ -32,7 +29,7 @@ const FollowButton = observer( } } else { try { - const res = await apilib.follow(store, did, declarationCid) + const res = await store.agent.follow(did) store.me.follows.addFollow(did, res.uri) onToggleFollow?.(true) } catch (e: any) { diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 748648742..0beac8a7f 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -1,7 +1,7 @@ import React from 'react' import {StyleSheet, View} from 'react-native' import {observer} from 'mobx-react-lite' -import {AppBskyActorProfile} from '@atproto/api' +import {AppBskyActorDefs} from '@atproto/api' import {Link} from '../util/Link' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' @@ -11,6 +11,7 @@ import {useStores} from 'state/index' import FollowButton from './FollowButton' export function ProfileCard({ + testID, handle, displayName, avatar, @@ -21,6 +22,7 @@ export function ProfileCard({ followers, renderButton, }: { + testID?: string handle: string displayName?: string avatar?: string @@ -28,12 +30,13 @@ export function ProfileCard({ isFollowedBy?: boolean noBg?: boolean noBorder?: boolean - followers?: AppBskyActorProfile.View[] | undefined + followers?: AppBskyActorDefs.ProfileView[] | undefined renderButton?: () => JSX.Element }) { const pal = usePalette('default') return ( <Link + testID={testID} style={[ styles.outer, pal.border, @@ -106,7 +109,6 @@ export function ProfileCard({ export const ProfileCardWithFollowBtn = observer( ({ did, - declarationCid, handle, displayName, avatar, @@ -117,7 +119,6 @@ export const ProfileCardWithFollowBtn = observer( followers, }: { did: string - declarationCid: string handle: string displayName?: string avatar?: string @@ -125,7 +126,7 @@ export const ProfileCardWithFollowBtn = observer( isFollowedBy?: boolean noBg?: boolean noBorder?: boolean - followers?: AppBskyActorProfile.View[] | undefined + followers?: AppBskyActorDefs.ProfileView[] | undefined }) => { const store = useStores() const isMe = store.me.handle === handle @@ -140,11 +141,7 @@ export const ProfileCardWithFollowBtn = observer( noBg={noBg} noBorder={noBorder} followers={followers} - renderButton={ - isMe - ? undefined - : () => <FollowButton did={did} declarationCid={declarationCid} /> - } + renderButton={isMe ? undefined : () => <FollowButton did={did} />} /> ) }, diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx index d1488403a..8d489ad0a 100644 --- a/src/view/com/profile/ProfileFollowers.tsx +++ b/src/view/com/profile/ProfileFollowers.tsx @@ -19,7 +19,7 @@ export const ProfileFollowers = observer(function ProfileFollowers({ const pal = usePalette('default') const store = useStores() const view = React.useMemo( - () => new UserFollowersViewModel(store, {user: name}), + () => new UserFollowersViewModel(store, {actor: name}), [store, name], ) @@ -64,7 +64,6 @@ export const ProfileFollowers = observer(function ProfileFollowers({ <ProfileCardWithFollowBtn key={item.did} did={item.did} - declarationCid={item.declaration.cid} handle={item.handle} displayName={item.displayName} avatar={item.avatar} diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx index ddb64787a..849b33441 100644 --- a/src/view/com/profile/ProfileFollows.tsx +++ b/src/view/com/profile/ProfileFollows.tsx @@ -16,7 +16,7 @@ export const ProfileFollows = observer(function ProfileFollows({ const pal = usePalette('default') const store = useStores() const view = React.useMemo( - () => new UserFollowsViewModel(store, {user: name}), + () => new UserFollowsViewModel(store, {actor: name}), [store, name], ) @@ -61,7 +61,6 @@ export const ProfileFollows = observer(function ProfileFollows({ <ProfileCardWithFollowBtn key={item.did} did={item.did} - declarationCid={item.declaration.cid} handle={item.handle} displayName={item.displayName} avatar={item.avatar} diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 06dd20989..6294c627b 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -33,7 +33,61 @@ import {isDesktopWeb} from 'platform/detection' const BACK_HITSLOP = {left: 30, top: 30, right: 30, bottom: 30} -export const ProfileHeader = observer(function ProfileHeader({ +export const ProfileHeader = observer( + ({ + view, + onRefreshAll, + }: { + view: ProfileViewModel + onRefreshAll: () => void + }) => { + const pal = usePalette('default') + + // loading + // = + if (!view || !view.hasLoaded) { + return ( + <View style={pal.view}> + <LoadingPlaceholder width="100%" height={120} /> + <View + style={[ + pal.view, + {borderColor: pal.colors.background}, + styles.avi, + ]}> + <LoadingPlaceholder width={80} height={80} style={styles.br40} /> + </View> + <View style={styles.content}> + <View style={[styles.buttonsLine]}> + <LoadingPlaceholder width={100} height={31} style={styles.br50} /> + </View> + <View style={styles.displayNameLine}> + <Text type="title-2xl" style={[pal.text, styles.title]}> + {view.displayName || view.handle} + </Text> + </View> + </View> + </View> + ) + } + + // error + // = + if (view.hasError) { + return ( + <View testID="profileHeaderHasError"> + <Text>{view.error}</Text> + </View> + ) + } + + // loaded + // = + return <ProfileHeaderLoaded view={view} onRefreshAll={onRefreshAll} /> + }, +) + +const ProfileHeaderLoaded = observer(function ProfileHeaderLoaded({ view, onRefreshAll, }: { @@ -44,14 +98,17 @@ export const ProfileHeader = observer(function ProfileHeader({ const store = useStores() const navigation = useNavigation<NavigationProp>() const {track} = useAnalytics() + const onPressBack = React.useCallback(() => { navigation.goBack() }, [navigation]) + const onPressAvi = React.useCallback(() => { if (view.avatar) { store.shell.openLightbox(new ProfileImageLightbox(view)) } }, [store, view]) + const onPressToggleFollow = React.useCallback(() => { view?.toggleFollowing().then( () => { @@ -64,6 +121,7 @@ export const ProfileHeader = observer(function ProfileHeader({ err => store.log.error('Failed to toggle follow', err), ) }, [view, store]) + const onPressEditProfile = React.useCallback(() => { track('ProfileHeader:EditProfileButtonClicked') store.shell.openModal({ @@ -72,18 +130,22 @@ export const ProfileHeader = observer(function ProfileHeader({ onUpdate: onRefreshAll, }) }, [track, store, view, onRefreshAll]) + const onPressFollowers = React.useCallback(() => { track('ProfileHeader:FollowersButtonClicked') navigation.push('ProfileFollowers', {name: view.handle}) }, [track, navigation, view]) + const onPressFollows = React.useCallback(() => { track('ProfileHeader:FollowsButtonClicked') navigation.push('ProfileFollows', {name: view.handle}) }, [track, navigation, view]) + const onPressShare = React.useCallback(() => { track('ProfileHeader:ShareButtonClicked') Share.share({url: toShareUrl(`/profile/${view.handle}`)}) }, [track, view]) + const onPressMuteAccount = React.useCallback(async () => { track('ProfileHeader:MuteAccountButtonClicked') try { @@ -94,6 +156,7 @@ export const ProfileHeader = observer(function ProfileHeader({ Toast.show(`There was an issue! ${e.toString()}`) } }, [track, view, store]) + const onPressUnmuteAccount = React.useCallback(async () => { track('ProfileHeader:UnmuteAccountButtonClicked') try { @@ -104,6 +167,7 @@ export const ProfileHeader = observer(function ProfileHeader({ Toast.show(`There was an issue! ${e.toString()}`) } }, [track, view, store]) + const onPressReportAccount = React.useCallback(() => { track('ProfileHeader:ReportAccountButtonClicked') store.shell.openModal({ @@ -112,54 +176,39 @@ export const ProfileHeader = observer(function ProfileHeader({ }) }, [track, store, view]) - // loading - // = - if (!view || !view.hasLoaded) { - return ( - <View style={pal.view}> - <LoadingPlaceholder width="100%" height={120} /> - <View - style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}> - <LoadingPlaceholder width={80} height={80} style={styles.br40} /> - </View> - <View style={styles.content}> - <View style={[styles.buttonsLine]}> - <LoadingPlaceholder width={100} height={31} style={styles.br50} /> - </View> - <View style={styles.displayNameLine}> - <Text type="title-2xl" style={[pal.text, styles.title]}> - {view.displayName || view.handle} - </Text> - </View> - </View> - </View> - ) - } - - // error - // = - if (view.hasError) { - return ( - <View testID="profileHeaderHasError"> - <Text>{view.error}</Text> - </View> - ) - } - - // loaded - // = - const isMe = store.me.did === view.did - let dropdownItems: DropdownItem[] = [{label: 'Share', onPress: onPressShare}] - if (!isMe) { - dropdownItems.push({ - label: view.viewer.muted ? 'Unmute Account' : 'Mute Account', - onPress: view.viewer.muted ? onPressUnmuteAccount : onPressMuteAccount, - }) - dropdownItems.push({ - label: 'Report Account', - onPress: onPressReportAccount, - }) - } + const isMe = React.useMemo( + () => store.me.did === view.did, + [store.me.did, view.did], + ) + const dropdownItems: DropdownItem[] = React.useMemo(() => { + let items: DropdownItem[] = [ + { + testID: 'profileHeaderDropdownSahreBtn', + label: 'Share', + onPress: onPressShare, + }, + ] + if (!isMe) { + items.push({ + testID: 'profileHeaderDropdownMuteBtn', + label: view.viewer.muted ? 'Unmute Account' : 'Mute Account', + onPress: view.viewer.muted ? onPressUnmuteAccount : onPressMuteAccount, + }) + items.push({ + testID: 'profileHeaderDropdownReportBtn', + label: 'Report Account', + onPress: onPressReportAccount, + }) + } + return items + }, [ + isMe, + view.viewer.muted, + onPressShare, + onPressUnmuteAccount, + onPressMuteAccount, + onPressReportAccount, + ]) return ( <View style={pal.view}> <UserBanner banner={view.banner} /> @@ -178,6 +227,7 @@ export const ProfileHeader = observer(function ProfileHeader({ <> {store.me.follows.isFollowing(view.did) ? ( <TouchableOpacity + testID="unfollowBtn" onPress={onPressToggleFollow} style={[styles.btn, styles.mainBtn, pal.btn]}> <FontAwesomeIcon @@ -191,7 +241,7 @@ export const ProfileHeader = observer(function ProfileHeader({ </TouchableOpacity> ) : ( <TouchableOpacity - testID="profileHeaderToggleFollowButton" + testID="followBtn" onPress={onPressToggleFollow} style={[styles.btn, styles.primaryBtn]}> <FontAwesomeIcon @@ -207,6 +257,7 @@ export const ProfileHeader = observer(function ProfileHeader({ )} {dropdownItems?.length ? ( <DropdownButton + testID="profileHeaderDropdownBtn" type="bare" items={dropdownItems} style={[styles.btn, styles.secondaryBtn, pal.btn]}> @@ -215,7 +266,10 @@ export const ProfileHeader = observer(function ProfileHeader({ ) : undefined} </View> <View style={styles.displayNameLine}> - <Text type="title-2xl" style={[pal.text, styles.title]}> + <Text + testID="profileHeaderDisplayName" + type="title-2xl" + style={[pal.text, styles.title]}> {view.displayName || view.handle} </Text> </View> @@ -241,19 +295,17 @@ export const ProfileHeader = observer(function ProfileHeader({ {pluralize(view.followersCount, 'follower')} </Text> </TouchableOpacity> - {view.isUser ? ( - <TouchableOpacity - testID="profileHeaderFollowsButton" - style={[s.flexRow, s.mr10]} - onPress={onPressFollows}> - <Text type="md" style={[s.bold, s.mr2, pal.text]}> - {view.followsCount} - </Text> - <Text type="md" style={[pal.textLight]}> - following - </Text> - </TouchableOpacity> - ) : undefined} + <TouchableOpacity + testID="profileHeaderFollowsButton" + style={[s.flexRow, s.mr10]} + onPress={onPressFollows}> + <Text type="md" style={[s.bold, s.mr2, pal.text]}> + {view.followsCount} + </Text> + <Text type="md" style={[pal.textLight]}> + following + </Text> + </TouchableOpacity> <View style={[s.flexRow, s.mr10]}> <Text type="md" style={[s.bold, s.mr2, pal.text]}> {view.postsCount} @@ -265,13 +317,16 @@ export const ProfileHeader = observer(function ProfileHeader({ </View> {view.descriptionRichText ? ( <RichText + testID="profileHeaderDescription" style={[styles.description, pal.text]} numberOfLines={15} richText={view.descriptionRichText} /> ) : undefined} {view.viewer.muted ? ( - <View style={[styles.detailLine, pal.btn, s.p5]}> + <View + testID="profileHeaderMutedNotice" + style={[styles.detailLine, pal.btn, s.p5]}> <FontAwesomeIcon icon={['far', 'eye-slash']} style={[pal.text, s.mr5]} diff --git a/src/view/com/search/SearchResults.tsx b/src/view/com/search/SearchResults.tsx index 4bf46515c..b53965f44 100644 --- a/src/view/com/search/SearchResults.tsx +++ b/src/view/com/search/SearchResults.tsx @@ -97,7 +97,6 @@ const Profiles = observer(({model}: {model: SearchUIModel}) => { <ProfileCardWithFollowBtn key={item.did} did={item.did} - declarationCid={item.declaration.cid} handle={item.handle} displayName={item.displayName} avatar={item.avatar} diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index f356f0b09..703869be1 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -29,6 +29,7 @@ type Event = | GestureResponderEvent export const Link = observer(function Link({ + testID, style, href, title, @@ -36,6 +37,7 @@ export const Link = observer(function Link({ noFeedback, asAnchor, }: { + testID?: string style?: StyleProp<ViewStyle> href?: string title?: string @@ -58,6 +60,7 @@ export const Link = observer(function Link({ if (noFeedback) { return ( <TouchableWithoutFeedback + testID={testID} onPress={onPress} // @ts-ignore web only -prf href={asAnchor ? href : undefined}> @@ -69,6 +72,7 @@ export const Link = observer(function Link({ } return ( <TouchableOpacity + testID={testID} style={style} onPress={onPress} // @ts-ignore web only -prf @@ -79,6 +83,7 @@ export const Link = observer(function Link({ }) export const TextLink = observer(function TextLink({ + testID, type = 'md', style, href, @@ -86,6 +91,7 @@ export const TextLink = observer(function TextLink({ numberOfLines, lineHeight, }: { + testID?: string type?: TypographyVariant style?: StyleProp<TextStyle> href: string @@ -106,6 +112,7 @@ export const TextLink = observer(function TextLink({ return ( <Text + testID={testID} type={type} style={style} numberOfLines={numberOfLines} @@ -120,6 +127,7 @@ export const TextLink = observer(function TextLink({ * Only acts as a link on desktop web */ export const DesktopWebTextLink = observer(function DesktopWebTextLink({ + testID, type = 'md', style, href, @@ -127,6 +135,7 @@ export const DesktopWebTextLink = observer(function DesktopWebTextLink({ numberOfLines, lineHeight, }: { + testID?: string type?: TypographyVariant style?: StyleProp<TextStyle> href: string @@ -137,6 +146,7 @@ export const DesktopWebTextLink = observer(function DesktopWebTextLink({ if (isDesktopWeb) { return ( <TextLink + testID={testID} type={type} style={style} href={href} @@ -148,6 +158,7 @@ export const DesktopWebTextLink = observer(function DesktopWebTextLink({ } return ( <Text + testID={testID} type={type} style={style} numberOfLines={numberOfLines} diff --git a/src/view/com/util/PostCtrls.tsx b/src/view/com/util/PostCtrls.tsx index 00e35eef7..6904928f4 100644 --- a/src/view/com/util/PostCtrls.tsx +++ b/src/view/com/util/PostCtrls.tsx @@ -45,12 +45,12 @@ interface PostCtrlsOpts { style?: StyleProp<ViewStyle> replyCount?: number repostCount?: number - upvoteCount?: number + likeCount?: number isReposted: boolean - isUpvoted: boolean + isLiked: boolean onPressReply: () => void onPressToggleRepost: () => Promise<void> - onPressToggleUpvote: () => Promise<void> + onPressToggleLike: () => Promise<void> onCopyPostText: () => void onOpenTranslate: () => void onDeletePost: () => void @@ -157,26 +157,26 @@ export function PostCtrls(opts: PostCtrlsOpts) { }) } - const onPressToggleUpvoteWrapper = () => { - if (!opts.isUpvoted) { + const onPressToggleLikeWrapper = () => { + if (!opts.isLiked) { ReactNativeHapticFeedback.trigger('impactMedium') setLikeMod(1) opts - .onPressToggleUpvote() + .onPressToggleLike() .catch(_e => undefined) .then(() => setLikeMod(0)) // DISABLED see #135 // likeRef.current?.trigger( // {start: ctrlAnimStart, style: ctrlAnimStyle}, // async () => { - // await opts.onPressToggleUpvote().catch(_e => undefined) + // await opts.onPressToggleLike().catch(_e => undefined) // setLikeMod(0) // }, // ) } else { setLikeMod(-1) opts - .onPressToggleUpvote() + .onPressToggleLike() .catch(_e => undefined) .then(() => setLikeMod(0)) } @@ -186,6 +186,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { <View style={[styles.ctrls, opts.style]}> <View style={s.flex1}> <TouchableOpacity + testID="replyBtn" style={styles.ctrl} hitSlop={HITSLOP} onPress={opts.onPressReply}> @@ -203,6 +204,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { </View> <View style={s.flex1}> <TouchableOpacity + testID="repostBtn" hitSlop={HITSLOP} onPress={onPressToggleRepostWrapper} style={styles.ctrl}> @@ -230,6 +232,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { } {typeof opts.repostCount !== 'undefined' ? ( <Text + testID="repostCount" style={ opts.isReposted || repostMod > 0 ? [s.bold, s.green3, s.f15, s.ml5] @@ -242,12 +245,13 @@ export function PostCtrls(opts: PostCtrlsOpts) { </View> <View style={s.flex1}> <TouchableOpacity + testID="likeBtn" style={styles.ctrl} hitSlop={HITSLOP} - onPress={onPressToggleUpvoteWrapper}> - {opts.isUpvoted || likeMod > 0 ? ( + onPress={onPressToggleLikeWrapper}> + {opts.isLiked || likeMod > 0 ? ( <HeartIconSolid - style={styles.ctrlIconUpvoted as StyleProp<ViewStyle>} + style={styles.ctrlIconLiked as StyleProp<ViewStyle>} size={opts.big ? 22 : 16} /> ) : ( @@ -259,9 +263,9 @@ export function PostCtrls(opts: PostCtrlsOpts) { )} { undefined /*DISABLED see #135 <TriggerableAnimated ref={likeRef}> - {opts.isUpvoted || likeMod > 0 ? ( + {opts.isLiked || likeMod > 0 ? ( <HeartIconSolid - style={styles.ctrlIconUpvoted as ViewStyle} + style={styles.ctrlIconLiked as ViewStyle} size={opts.big ? 22 : 16} /> ) : ( @@ -276,14 +280,15 @@ export function PostCtrls(opts: PostCtrlsOpts) { )} </TriggerableAnimated>*/ } - {typeof opts.upvoteCount !== 'undefined' ? ( + {typeof opts.likeCount !== 'undefined' ? ( <Text + testID="likeCount" style={ - opts.isUpvoted || likeMod > 0 + opts.isLiked || likeMod > 0 ? [s.bold, s.red3, s.f15, s.ml5] : [defaultCtrlColor, s.f15, s.ml5] }> - {opts.upvoteCount + likeMod} + {opts.likeCount + likeMod} </Text> ) : undefined} </TouchableOpacity> @@ -291,6 +296,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { <View style={s.flex1}> {opts.big ? undefined : ( <PostDropdownBtn + testID="postDropdownBtn" style={styles.ctrl} itemUri={opts.itemUri} itemCid={opts.itemCid} @@ -330,7 +336,7 @@ const styles = StyleSheet.create({ ctrlIconReposted: { color: colors.green3, }, - ctrlIconUpvoted: { + ctrlIconLiked: { color: colors.red3, }, mt1: { diff --git a/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx b/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx deleted file mode 100644 index d9425fe4e..000000000 --- a/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import React, {useEffect} from 'react' -import {useState} from 'react' -import { - View, - StyleSheet, - Pressable, - TouchableWithoutFeedback, - EmitterSubscription, -} from 'react-native' -import YoutubePlayer from 'react-native-youtube-iframe' -import {usePalette} from 'lib/hooks/usePalette' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import ExternalLinkEmbed from './ExternalLinkEmbed' -import {PresentedExternal} from '@atproto/api/dist/client/types/app/bsky/embed/external' -import {useStores} from 'state/index' - -const YoutubeEmbed = ({ - link, - videoId, -}: { - videoId: string - link: PresentedExternal -}) => { - const store = useStores() - const [displayVideoPlayer, setDisplayVideoPlayer] = useState(false) - const [playerDimensions, setPlayerDimensions] = useState({ - width: 0, - height: 0, - }) - const pal = usePalette('default') - const handlePlayButtonPressed = () => { - setDisplayVideoPlayer(true) - } - const handleOnLayout = (event: { - nativeEvent: {layout: {width: any; height: any}} - }) => { - setPlayerDimensions({ - width: event.nativeEvent.layout.width, - height: event.nativeEvent.layout.height, - }) - } - useEffect(() => { - let sub: EmitterSubscription - if (displayVideoPlayer) { - sub = store.onNavigation(() => { - setDisplayVideoPlayer(false) - }) - } - return () => sub && sub.remove() - }, [displayVideoPlayer, store]) - - const imageChild = ( - <Pressable onPress={handlePlayButtonPressed} style={styles.playButton}> - <FontAwesomeIcon icon="play" size={24} color="white" /> - </Pressable> - ) - - if (!displayVideoPlayer) { - return ( - <View - style={[styles.extOuter, pal.view, pal.border]} - onLayout={handleOnLayout}> - <ExternalLinkEmbed - link={link} - onImagePress={handlePlayButtonPressed} - imageChild={imageChild} - /> - </View> - ) - } - - const height = (playerDimensions.width / 16) * 9 - const noop = () => {} - - return ( - <TouchableWithoutFeedback onPress={noop}> - <View> - {/* Removing the outter View will make tap events propagate to parents */} - <YoutubePlayer - initialPlayerParams={{ - modestbranding: true, - }} - webViewProps={{ - startInLoadingState: true, - }} - height={height} - videoId={videoId} - webViewStyle={styles.webView} - /> - </View> - </TouchableWithoutFeedback> - ) -} - -const styles = StyleSheet.create({ - extOuter: { - borderWidth: 1, - borderRadius: 8, - marginTop: 4, - }, - playButton: { - position: 'absolute', - alignSelf: 'center', - alignItems: 'center', - top: '44%', - justifyContent: 'center', - backgroundColor: 'black', - padding: 10, - borderRadius: 50, - opacity: 0.8, - }, - webView: { - alignItems: 'center', - alignContent: 'center', - justifyContent: 'center', - }, -}) - -export default YoutubeEmbed diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index c53de5c1f..a675283b8 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -16,7 +16,6 @@ interface PostMetaOpts { postHref: string timestamp: string did?: string - declarationCid?: string showFollowBtn?: boolean } @@ -34,13 +33,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) { setDidFollow(true) }, [setDidFollow]) - if ( - opts.showFollowBtn && - !isMe && - (!isFollowing || didFollow) && - opts.did && - opts.declarationCid - ) { + if (opts.showFollowBtn && !isMe && (!isFollowing || didFollow) && opts.did) { // two-liner with follow button return ( <View style={styles.metaTwoLine}> @@ -79,7 +72,6 @@ export const PostMeta = observer(function (opts: PostMetaOpts) { <FollowButton type="default" did={opts.did} - declarationCid={opts.declarationCid} onToggleFollow={onToggleFollow} /> </View> diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 2e0632521..ff741cd34 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -23,6 +23,7 @@ import {isWeb} from 'platform/detection' function DefaultAvatar({size}: {size: number}) { return ( <Svg + testID="userAvatarFallback" width={size} height={size} viewBox="0 0 24 24" @@ -56,6 +57,7 @@ export function UserAvatar({ const dropdownItems = [ !isWeb && { + testID: 'changeAvatarCameraBtn', label: 'Camera', icon: 'camera' as IconProp, onPress: async () => { @@ -73,6 +75,7 @@ export function UserAvatar({ }, }, { + testID: 'changeAvatarLibraryBtn', label: 'Library', icon: 'image' as IconProp, onPress: async () => { @@ -94,6 +97,7 @@ export function UserAvatar({ }, }, { + testID: 'changeAvatarRemoveBtn', label: 'Remove', icon: ['far', 'trash-can'] as IconProp, onPress: async () => { @@ -104,6 +108,7 @@ export function UserAvatar({ // onSelectNewAvatar is only passed as prop on the EditProfile component return onSelectNewAvatar ? ( <DropdownButton + testID="changeAvatarBtn" type="bare" items={dropdownItems} openToRight @@ -112,6 +117,7 @@ export function UserAvatar({ menuWidth={170}> {avatar ? ( <HighPriorityImage + testID="userAvatarImage" style={{ width: size, height: size, @@ -132,6 +138,7 @@ export function UserAvatar({ </DropdownButton> ) : avatar ? ( <HighPriorityImage + testID="userAvatarImage" style={{width: size, height: size, borderRadius: Math.floor(size / 2)}} resizeMode="stretch" source={{uri: avatar}} diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index 8317f93ac..56d7e370a 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -33,6 +33,7 @@ export function UserBanner({ const dropdownItems = [ !isWeb && { + testID: 'changeBannerCameraBtn', label: 'Camera', icon: 'camera' as IconProp, onPress: async () => { @@ -51,6 +52,7 @@ export function UserBanner({ }, }, { + testID: 'changeBannerLibraryBtn', label: 'Library', icon: 'image' as IconProp, onPress: async () => { @@ -73,6 +75,7 @@ export function UserBanner({ }, }, { + testID: 'changeBannerRemoveBtn', label: 'Remove', icon: ['far', 'trash-can'] as IconProp, onPress: () => { @@ -84,6 +87,7 @@ export function UserBanner({ // setUserBanner is only passed as prop on the EditProfile component return onSelectNewBanner ? ( <DropdownButton + testID="changeBannerBtn" type="bare" items={dropdownItems} openToRight @@ -91,9 +95,16 @@ export function UserBanner({ bottomOffset={-10} menuWidth={170}> {banner ? ( - <Image style={styles.bannerImage} source={{uri: banner}} /> + <Image + testID="userBannerImage" + style={styles.bannerImage} + source={{uri: banner}} + /> ) : ( - <View style={[styles.bannerImage, styles.defaultBanner]} /> + <View + testID="userBannerFallback" + style={[styles.bannerImage, styles.defaultBanner]} + /> )} <View style={[styles.editButtonContainer, pal.btn]}> <FontAwesomeIcon @@ -106,12 +117,16 @@ export function UserBanner({ </DropdownButton> ) : banner ? ( <Image + testID="userBannerImage" style={styles.bannerImage} resizeMode="cover" source={{uri: banner}} /> ) : ( - <View style={[styles.bannerImage, styles.defaultBanner]} /> + <View + testID="userBannerFallback" + style={[styles.bannerImage, styles.defaultBanner]} + /> ) } diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx index a99282512..ad0a5a1d2 100644 --- a/src/view/com/util/ViewHeader.tsx +++ b/src/view/com/util/ViewHeader.tsx @@ -51,7 +51,7 @@ export const ViewHeader = observer(function ({ return ( <Container hideOnScroll={hideOnScroll || false}> <TouchableOpacity - testID="viewHeaderBackOrMenuBtn" + testID="viewHeaderDrawerBtn" onPress={canGoBack ? onPressBack : onPressMenu} hitSlop={BACK_HITSLOP} style={canGoBack ? styles.backBtn : styles.backBtnWide}> diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx index e1280fd82..82351cf08 100644 --- a/src/view/com/util/ViewSelector.tsx +++ b/src/view/com/util/ViewSelector.tsx @@ -47,13 +47,18 @@ export function ViewSelector({ // events // = - const onSwipeEnd = (dx: number) => { - if (dx !== 0) { - setSelectedIndex(clamp(selectedIndex + dx, 0, sections.length)) - } - } - const onPressSelection = (index: number) => - setSelectedIndex(clamp(index, 0, sections.length)) + const onSwipeEnd = React.useCallback( + (dx: number) => { + if (dx !== 0) { + setSelectedIndex(clamp(selectedIndex + dx, 0, sections.length)) + } + }, + [setSelectedIndex, selectedIndex, sections], + ) + const onPressSelection = React.useCallback( + (index: number) => setSelectedIndex(clamp(index, 0, sections.length)), + [setSelectedIndex, sections], + ) useEffect(() => { onSelectView?.(selectedIndex) }, [selectedIndex, onSelectView]) @@ -61,27 +66,33 @@ export function ViewSelector({ // rendering // = - const renderItemInternal = ({item}: {item: any}) => { - if (item === HEADER_ITEM) { - if (renderHeader) { - return renderHeader() + const renderItemInternal = React.useCallback( + ({item}: {item: any}) => { + if (item === HEADER_ITEM) { + if (renderHeader) { + return renderHeader() + } + return <View /> + } else if (item === SELECTOR_ITEM) { + return ( + <Selector + items={sections} + panX={panX} + selectedIndex={selectedIndex} + onSelect={onPressSelection} + /> + ) + } else { + return renderItem(item) } - return <View /> - } else if (item === SELECTOR_ITEM) { - return ( - <Selector - items={sections} - panX={panX} - selectedIndex={selectedIndex} - onSelect={onPressSelection} - /> - ) - } else { - return renderItem(item) - } - } + }, + [sections, panX, selectedIndex, onPressSelection, renderHeader, renderItem], + ) - const data = [HEADER_ITEM, SELECTOR_ITEM, ...items] + const data = React.useMemo( + () => [HEADER_ITEM, SELECTOR_ITEM, ...items], + [items], + ) return ( <HorzSwipe hasPriority diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx index f3f4d1c79..b7c058d2d 100644 --- a/src/view/com/util/forms/Button.tsx +++ b/src/view/com/util/forms/Button.tsx @@ -27,11 +27,13 @@ export function Button({ style, onPress, children, + testID, }: React.PropsWithChildren<{ type?: ButtonType label?: string style?: StyleProp<ViewStyle> onPress?: () => void + testID?: string }>) { const theme = useTheme() const outerStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>(type, { @@ -107,7 +109,8 @@ export function Button({ return ( <TouchableOpacity style={[outerStyle, styles.outer, style]} - onPress={onPress}> + onPress={onPress} + testID={testID}> {label ? ( <Text type="button" style={[labelStyle]}> {label} diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx index d6ae800c6..938c346cd 100644 --- a/src/view/com/util/forms/DropdownButton.tsx +++ b/src/view/com/util/forms/DropdownButton.tsx @@ -24,6 +24,7 @@ const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} const ESTIMATED_MENU_ITEM_HEIGHT = 52 export interface DropdownItem { + testID?: string icon?: IconProp label: string onPress: () => void @@ -33,6 +34,7 @@ type MaybeDropdownItem = DropdownItem | false | undefined export type DropdownButtonType = ButtonType | 'bare' export function DropdownButton({ + testID, type = 'bare', style, items, @@ -43,6 +45,7 @@ export function DropdownButton({ rightOffset = 0, bottomOffset = 0, }: { + testID?: string type?: DropdownButtonType style?: StyleProp<ViewStyle> items: MaybeDropdownItem[] @@ -90,22 +93,18 @@ export function DropdownButton({ if (type === 'bare') { return ( <TouchableOpacity + testID={testID} style={style} onPress={onPress} hitSlop={HITSLOP} - // Fix an issue where specific references cause runtime error in jest environment - ref={ - typeof process !== 'undefined' && process.env.JEST_WORKER_ID != null - ? null - : ref - }> + ref={ref}> {children} </TouchableOpacity> ) } return ( <View ref={ref}> - <Button onPress={onPress} style={style} label={label}> + <Button testID={testID} onPress={onPress} style={style} label={label}> {children} </Button> </View> @@ -113,6 +112,7 @@ export function DropdownButton({ } export function PostDropdownBtn({ + testID, style, children, itemUri, @@ -123,6 +123,7 @@ export function PostDropdownBtn({ onOpenTranslate, onDeletePost, }: { + testID?: string style?: StyleProp<ViewStyle> children?: React.ReactNode itemUri: string @@ -138,6 +139,7 @@ export function PostDropdownBtn({ const dropdownItems: DropdownItem[] = [ { + testID: 'postDropdownTranslateBtn', icon: 'language', label: 'Translate...', onPress() { @@ -145,6 +147,7 @@ export function PostDropdownBtn({ }, }, { + testID: 'postDropdownCopyTextBtn', icon: ['far', 'paste'], label: 'Copy post text', onPress() { @@ -152,6 +155,7 @@ export function PostDropdownBtn({ }, }, { + testID: 'postDropdownShareBtn', icon: 'share', label: 'Share...', onPress() { @@ -159,6 +163,7 @@ export function PostDropdownBtn({ }, }, { + testID: 'postDropdownReportBtn', icon: 'circle-exclamation', label: 'Report post', onPress() { @@ -171,6 +176,7 @@ export function PostDropdownBtn({ }, isAuthor ? { + testID: 'postDropdownDeleteBtn', icon: ['far', 'trash-can'], label: 'Delete post', onPress() { @@ -186,7 +192,11 @@ export function PostDropdownBtn({ ].filter(Boolean) as DropdownItem[] return ( - <DropdownButton style={style} items={dropdownItems} menuWidth={200}> + <DropdownButton + testID={testID} + style={style} + items={dropdownItems} + menuWidth={200}> {children} </DropdownButton> ) @@ -291,6 +301,7 @@ const DropdownItems = ({ ]}> {items.map((item, index) => ( <TouchableOpacity + testID={item.testID} key={index} style={[styles.menuItem]} onPress={() => onPressItem(index)}> diff --git a/src/view/com/util/forms/RadioButton.tsx b/src/view/com/util/forms/RadioButton.tsx index d6b2bb119..f5696a76d 100644 --- a/src/view/com/util/forms/RadioButton.tsx +++ b/src/view/com/util/forms/RadioButton.tsx @@ -6,12 +6,14 @@ import {useTheme} from 'lib/ThemeContext' import {choose} from 'lib/functions' export function RadioButton({ + testID, type = 'default-light', label, isSelected, style, onPress, }: { + testID?: string type?: ButtonType label: string isSelected: boolean @@ -119,7 +121,7 @@ export function RadioButton({ }, }) return ( - <Button type={type} onPress={onPress} style={style}> + <Button testID={testID} type={type} onPress={onPress} style={style}> <View style={styles.outer}> <View style={[circleStyle, styles.circle]}> {isSelected ? ( diff --git a/src/view/com/util/forms/RadioGroup.tsx b/src/view/com/util/forms/RadioGroup.tsx index 901b0cdd8..071540b73 100644 --- a/src/view/com/util/forms/RadioGroup.tsx +++ b/src/view/com/util/forms/RadioGroup.tsx @@ -10,11 +10,13 @@ export interface RadioGroupItem { } export function RadioGroup({ + testID, type, items, initialSelection = '', onSelect, }: { + testID?: string type?: ButtonType items: RadioGroupItem[] initialSelection?: string @@ -30,6 +32,7 @@ export function RadioGroup({ {items.map((item, i) => ( <RadioButton key={item.key} + testID={testID ? `${testID}-${item.key}` : undefined} style={i !== 0 ? s.mt2 : undefined} type={type} label={item.label} diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx index 24dbe6a52..ddb09ce39 100644 --- a/src/view/com/util/images/AutoSizedImage.tsx +++ b/src/view/com/util/images/AutoSizedImage.tsx @@ -4,9 +4,9 @@ import { StyleProp, StyleSheet, TouchableOpacity, + View, ViewStyle, } from 'react-native' -// import Image from 'view/com/util/images/Image' import {clamp} from 'lib/numbers' import {useStores} from 'state/index' import {Dim} from 'lib/media/manip' @@ -51,16 +51,24 @@ export function AutoSizedImage({ }) }, [dim, setDim, setAspectRatio, store, uri]) + if (onPress || onLongPress || onPressIn) { + return ( + <TouchableOpacity + onPress={onPress} + onLongPress={onLongPress} + onPressIn={onPressIn} + delayPressIn={DELAY_PRESS_IN} + style={[styles.container, style]}> + <Image style={[styles.image, {aspectRatio}]} source={{uri}} /> + {children} + </TouchableOpacity> + ) + } return ( - <TouchableOpacity - onPress={onPress} - onLongPress={onLongPress} - onPressIn={onPressIn} - delayPressIn={DELAY_PRESS_IN} - style={[styles.container, style]}> + <View style={[styles.container, style]}> <Image style={[styles.image, {aspectRatio}]} source={{uri}} /> {children} - </TouchableOpacity> + </View> ) } diff --git a/src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx index e8c63bdb7..a4cbb3e29 100644 --- a/src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx +++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx @@ -3,25 +3,20 @@ import {Text} from '../text/Text' import {AutoSizedImage} from '../images/AutoSizedImage' import {StyleSheet, View} from 'react-native' import {usePalette} from 'lib/hooks/usePalette' -import {PresentedExternal} from '@atproto/api/dist/client/types/app/bsky/embed/external' +import {AppBskyEmbedExternal} from '@atproto/api' -const ExternalLinkEmbed = ({ +export const ExternalLinkEmbed = ({ link, - onImagePress, imageChild, }: { - link: PresentedExternal - onImagePress?: () => void + link: AppBskyEmbedExternal.ViewExternal imageChild?: React.ReactNode }) => { const pal = usePalette('default') return ( <> {link.thumb ? ( - <AutoSizedImage - uri={link.thumb} - style={styles.extImage} - onPress={onImagePress}> + <AutoSizedImage uri={link.thumb} style={styles.extImage}> {imageChild} </AutoSizedImage> ) : undefined} @@ -65,5 +60,3 @@ const styles = StyleSheet.create({ marginTop: 4, }, }) - -export default ExternalLinkEmbed diff --git a/src/view/com/util/PostEmbeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index fee67c9bc..9dc5739a0 100644 --- a/src/view/com/util/PostEmbeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -1,13 +1,21 @@ -import {StyleSheet} from 'react-native' import React from 'react' +import {StyleProp, StyleSheet, ViewStyle} from 'react-native' +import {AppBskyEmbedImages, AppBskyEmbedRecordWithMedia} from '@atproto/api' import {AtUri} from '../../../../third-party/uri' import {PostMeta} from '../PostMeta' import {Link} from '../Link' import {Text} from '../text/Text' import {usePalette} from 'lib/hooks/usePalette' import {ComposerOptsQuote} from 'state/models/ui/shell' +import {PostEmbeds} from '.' -const QuoteEmbed = ({quote}: {quote: ComposerOptsQuote}) => { +export function QuoteEmbed({ + quote, + style, +}: { + quote: ComposerOptsQuote + style?: StyleProp<ViewStyle> +}) { const pal = usePalette('default') const itemUrip = new AtUri(quote.uri) const itemHref = `/profile/${quote.author.handle}/post/${itemUrip.rkey}` @@ -16,9 +24,18 @@ const QuoteEmbed = ({quote}: {quote: ComposerOptsQuote}) => { () => quote.text.trim().length === 0, [quote.text], ) + const imagesEmbed = React.useMemo( + () => + quote.embeds?.find( + embed => + AppBskyEmbedImages.isView(embed) || + AppBskyEmbedRecordWithMedia.isView(embed), + ), + [quote.embeds], + ) return ( <Link - style={[styles.container, pal.border]} + style={[styles.container, pal.border, style]} href={itemHref} title={itemTitle}> <PostMeta @@ -37,6 +54,12 @@ const QuoteEmbed = ({quote}: {quote: ComposerOptsQuote}) => { quote.text )} </Text> + {AppBskyEmbedImages.isView(imagesEmbed) && ( + <PostEmbeds embed={imagesEmbed} /> + )} + {AppBskyEmbedRecordWithMedia.isView(imagesEmbed) && ( + <PostEmbeds embed={imagesEmbed.media} /> + )} </Link> ) } @@ -48,7 +71,6 @@ const styles = StyleSheet.create({ borderRadius: 8, paddingVertical: 8, paddingHorizontal: 12, - marginVertical: 8, borderWidth: 1, }, quotePost: { diff --git a/src/view/com/util/post-embeds/YoutubeEmbed.tsx b/src/view/com/util/post-embeds/YoutubeEmbed.tsx new file mode 100644 index 000000000..2ca0750a3 --- /dev/null +++ b/src/view/com/util/post-embeds/YoutubeEmbed.tsx @@ -0,0 +1,55 @@ +import React from 'react' +import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' +import {usePalette} from 'lib/hooks/usePalette' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {ExternalLinkEmbed} from './ExternalLinkEmbed' +import {AppBskyEmbedExternal} from '@atproto/api' +import {Link} from '../Link' + +export const YoutubeEmbed = ({ + link, + style, +}: { + link: AppBskyEmbedExternal.ViewExternal + style?: StyleProp<ViewStyle> +}) => { + const pal = usePalette('default') + + const imageChild = ( + <View style={styles.playButton}> + <FontAwesomeIcon icon="play" size={24} color="white" /> + </View> + ) + + return ( + <Link + style={[styles.extOuter, pal.view, pal.border, style]} + href={link.uri} + noFeedback> + <ExternalLinkEmbed link={link} imageChild={imageChild} /> + </Link> + ) +} + +const styles = StyleSheet.create({ + extOuter: { + borderWidth: 1, + borderRadius: 8, + }, + playButton: { + position: 'absolute', + alignSelf: 'center', + alignItems: 'center', + top: '44%', + justifyContent: 'center', + backgroundColor: 'black', + padding: 10, + borderRadius: 50, + opacity: 0.8, + }, + webView: { + alignItems: 'center', + alignContent: 'center', + justifyContent: 'center', + }, +}) diff --git a/src/view/com/util/PostEmbeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 02a8aa90e..726bea6e7 100644 --- a/src/view/com/util/PostEmbeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -10,6 +10,7 @@ import { AppBskyEmbedImages, AppBskyEmbedExternal, AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, AppBskyFeedPost, } from '@atproto/api' import {Link} from '../Link' @@ -19,15 +20,16 @@ import {ImagesLightbox} from 'state/models/ui/shell' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {saveImageModal} from 'lib/media/manip' -import YoutubeEmbed from './YoutubeEmbed' -import ExternalLinkEmbed from './ExternalLinkEmbed' +import {YoutubeEmbed} from './YoutubeEmbed' +import {ExternalLinkEmbed} from './ExternalLinkEmbed' import {getYoutubeVideoId} from 'lib/strings/url-helpers' import QuoteEmbed from './QuoteEmbed' type Embed = - | AppBskyEmbedRecord.Presented - | AppBskyEmbedImages.Presented - | AppBskyEmbedExternal.Presented + | AppBskyEmbedRecord.View + | AppBskyEmbedImages.View + | AppBskyEmbedExternal.View + | AppBskyEmbedRecordWithMedia.View | {$type: string; [k: string]: unknown} export function PostEmbeds({ @@ -39,11 +41,35 @@ export function PostEmbeds({ }) { const pal = usePalette('default') const store = useStores() - if (AppBskyEmbedRecord.isPresented(embed)) { + + if ( + AppBskyEmbedRecordWithMedia.isView(embed) && + AppBskyEmbedRecord.isViewRecord(embed.record.record) && + AppBskyFeedPost.isRecord(embed.record.record.value) && + AppBskyFeedPost.validateRecord(embed.record.record.value).success + ) { + return ( + <View style={[styles.stackContainer, style]}> + <PostEmbeds embed={embed.media} /> + <QuoteEmbed + quote={{ + author: embed.record.record.author, + cid: embed.record.record.cid, + uri: embed.record.record.uri, + indexedAt: embed.record.record.indexedAt, + text: embed.record.record.value.text, + embeds: embed.record.record.embeds, + }} + /> + </View> + ) + } + + if (AppBskyEmbedRecord.isView(embed)) { if ( - AppBskyEmbedRecord.isPresentedRecord(embed.record) && - AppBskyFeedPost.isRecord(embed.record.record) && - AppBskyFeedPost.validateRecord(embed.record.record).success + AppBskyEmbedRecord.isViewRecord(embed.record) && + AppBskyFeedPost.isRecord(embed.record.value) && + AppBskyFeedPost.validateRecord(embed.record.value).success ) { return ( <QuoteEmbed @@ -51,14 +77,17 @@ export function PostEmbeds({ author: embed.record.author, cid: embed.record.cid, uri: embed.record.uri, - indexedAt: embed.record.record.createdAt, // TODO - text: embed.record.record.text, + indexedAt: embed.record.indexedAt, + text: embed.record.value.text, + embeds: embed.record.embeds, }} + style={style} /> ) } } - if (AppBskyEmbedImages.isPresented(embed)) { + + if (AppBskyEmbedImages.isView(embed)) { if (embed.images.length > 0) { const uris = embed.images.map(img => img.fullsize) const openLightbox = (index: number) => { @@ -129,12 +158,13 @@ export function PostEmbeds({ } } } - if (AppBskyEmbedExternal.isPresented(embed)) { + + if (AppBskyEmbedExternal.isView(embed)) { const link = embed.external const youtubeVideoId = getYoutubeVideoId(link.uri) if (youtubeVideoId) { - return <YoutubeEmbed videoId={youtubeVideoId} link={link} /> + return <YoutubeEmbed link={link} style={style} /> } return ( @@ -150,6 +180,9 @@ export function PostEmbeds({ } const styles = StyleSheet.create({ + stackContainer: { + gap: 6, + }, imagesContainer: { marginTop: 4, }, diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx index d4cf19172..804db002a 100644 --- a/src/view/com/util/text/RichText.tsx +++ b/src/view/com/util/text/RichText.tsx @@ -1,20 +1,22 @@ import React from 'react' import {TextStyle, StyleProp} from 'react-native' +import {RichText as RichTextObj, AppBskyRichtextFacet} from '@atproto/api' import {TextLink} from '../Link' import {Text} from './Text' import {lh} from 'lib/styles' import {toShortUrl} from 'lib/strings/url-helpers' -import {RichText as RichTextObj, Entity} from 'lib/strings/rich-text' import {useTheme, TypographyVariant} from 'lib/ThemeContext' import {usePalette} from 'lib/hooks/usePalette' export function RichText({ + testID, type = 'md', richText, lineHeight = 1.2, style, numberOfLines, }: { + testID?: string type?: TypographyVariant richText?: RichTextObj lineHeight?: number @@ -29,17 +31,24 @@ export function RichText({ return null } - const {text, entities} = richText - if (!entities?.length) { + const {text, facets} = richText + if (!facets?.length) { if (/^\p{Extended_Pictographic}+$/u.test(text) && text.length <= 5) { style = { fontSize: 26, lineHeight: 30, } - return <Text style={[style, pal.text]}>{text}</Text> + return ( + <Text testID={testID} style={[style, pal.text]}> + {text} + </Text> + ) } return ( - <Text type={type} style={[style, pal.text, lineHeightStyle]}> + <Text + testID={testID} + type={type} + style={[style, pal.text, lineHeightStyle]}> {text} </Text> ) @@ -49,40 +58,40 @@ export function RichText({ } else if (!Array.isArray(style)) { style = [style] } - entities.sort(sortByIndex) - const segments = Array.from(toSegments(text, entities)) + const els = [] let key = 0 - for (const segment of segments) { - if (typeof segment === 'string') { - els.push(segment) + for (const segment of richText.segments()) { + const link = segment.link + const mention = segment.mention + if (mention && AppBskyRichtextFacet.validateMention(mention).success) { + els.push( + <TextLink + key={key} + type={type} + text={segment.text} + href={`/profile/${mention.did}`} + style={[style, lineHeightStyle, pal.link]} + />, + ) + } else if (link && AppBskyRichtextFacet.validateLink(link).success) { + els.push( + <TextLink + key={key} + type={type} + text={toShortUrl(segment.text)} + href={link.uri} + style={[style, lineHeightStyle, pal.link]} + />, + ) } else { - if (segment.entity.type === 'mention') { - els.push( - <TextLink - key={key} - type={type} - text={segment.text} - href={`/profile/${segment.entity.value}`} - style={[style, lineHeightStyle, pal.link]} - />, - ) - } else if (segment.entity.type === 'link') { - els.push( - <TextLink - key={key} - type={type} - text={toShortUrl(segment.text)} - href={segment.entity.value} - style={[style, lineHeightStyle, pal.link]} - />, - ) - } + els.push(segment.text) } key++ } return ( <Text + testID={testID} type={type} style={[style, pal.text, lineHeightStyle]} numberOfLines={numberOfLines}> @@ -90,38 +99,3 @@ export function RichText({ </Text> ) } - -function sortByIndex(a: Entity, b: Entity) { - return a.index.start - b.index.start -} - -function* toSegments(text: string, entities: Entity[]) { - let cursor = 0 - let i = 0 - do { - let currEnt = entities[i] - if (cursor < currEnt.index.start) { - yield text.slice(cursor, currEnt.index.start) - } else if (cursor > currEnt.index.start) { - i++ - continue - } - if (currEnt.index.start < currEnt.index.end) { - let subtext = text.slice(currEnt.index.start, currEnt.index.end) - if (!subtext.trim()) { - // dont yield links to empty strings - yield subtext - } else { - yield { - entity: currEnt, - text: subtext, - } - } - } - cursor = currEnt.index.end - i++ - } while (i < entities.length) - if (cursor < text.length) { - yield text.slice(cursor, text.length) - } -} |