Lex refactor (#362)

* Remove the hackcheck for upgrades

* Rename the PostEmbeds folder to match the codebase style

* Updates to latest lex refactor

* Update to use new bsky agent

* Update to use api package's richtext library

* Switch to upsertProfile

* Add TextEncoder/TextDecoder polyfill

* Add Intl.Segmenter polyfill

* Update composer to calculate lengths by grapheme

* Fix detox

* Fix login in e2e

* Create account e2e passing

* Implement an e2e mocking framework

* Don't use private methods on mobx models as mobx can't track them

* Add tooling for e2e-specific builds and add e2e media-picker mock

* Add some tests and fix some bugs around profile editing

* Add shell tests

* Add home screen tests

* Add thread screen tests

* Add tests for other user profile screens

* Add search screen tests

* Implement profile imagery change tools and tests

* Update to new embed behaviors

* Add post tests

* Fix to profile-screen test

* Fix session resumption

* Update web composer to new api

* 1.11.0

* Fix pagination cursor parameters

* Add quote posts to notifications

* Fix embed layouts

* Remove youtube inline player and improve tap handling on link cards

* Reset minimal shell mode on all screen loads and feed swipes (close #299)

* Update podfile.lock

* Improve post notfound UI (close #366)

* Bump atproto packages
This commit is contained in:
Paul Frazee 2023-03-31 13:17:26 -05:00 committed by GitHub
parent 19f3a2fa92
commit a3334a01a2
133 changed files with 3103 additions and 2839 deletions

View file

@ -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

View file

@ -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="&nbsp;(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 ? (

View file

@ -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]}>

View file

@ -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}

View file

@ -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,
})

View file

@ -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: {

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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}>

View file

@ -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)) {

View file

@ -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
}
/>

View file

@ -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

View file

@ -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}}

View file

@ -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,

View file

@ -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>

View file

@ -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}}

View file

@ -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}}

View file

@ -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}}

View file

@ -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>}

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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}

View file

@ -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,
},

View file

@ -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]}>
&middot; {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]}>
&middot; {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}

View file

@ -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}

View file

@ -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}

View file

@ -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}

View file

@ -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) {

View file

@ -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} />}
/>
)
},

View file

@ -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}

View file

@ -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}

View file

@ -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]}

View file

@ -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}

View file

@ -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}

View file

@ -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: {

View file

@ -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

View file

@ -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>

View file

@ -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}}

View file

@ -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]}
/>
)
}

View file

@ -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}>

View file

@ -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

View file

@ -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}

View file

@ -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)}>

View file

@ -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 ? (

View file

@ -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}

View file

@ -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>
)
}

View file

@ -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

View file

@ -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: {

View file

@ -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',
},
})

View file

@ -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,
},

View file

@ -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)
}
}

View file

@ -33,6 +33,7 @@ export const HomeScreen = withAuthRequired((_opts: Props) => {
useFocusEffect(
React.useCallback(() => {
store.shell.setMinimalShellMode(false)
store.shell.setIsDrawerSwipeDisabled(selectedPage > 0)
return () => {
store.shell.setIsDrawerSwipeDisabled(false)
@ -42,6 +43,7 @@ export const HomeScreen = withAuthRequired((_opts: Props) => {
const onPageSelected = React.useCallback(
(index: number) => {
store.shell.setMinimalShellMode(false)
setSelectedPage(index)
store.shell.setIsDrawerSwipeDisabled(index > 0)
},
@ -54,7 +56,13 @@ export const HomeScreen = withAuthRequired((_opts: Props) => {
const renderTabBar = React.useCallback(
(props: RenderTabBarFnProps) => {
return <FeedsTabBar {...props} onPressSelected={onPressSelected} />
return (
<FeedsTabBar
{...props}
testID="homeScreenFeedTabs"
onPressSelected={onPressSelected}
/>
)
},
[onPressSelected],
)
@ -66,27 +74,36 @@ export const HomeScreen = withAuthRequired((_opts: Props) => {
const initialPage = store.me.follows.isEmpty ? 1 : 0
return (
<Pager
testID="homeScreen"
onPageSelected={onPageSelected}
renderTabBar={renderTabBar}
tabBarPosition="top"
initialPage={initialPage}>
<FeedPage
key="1"
testID="followingFeedPage"
isPageFocused={selectedPage === 0}
feed={store.me.mainFeed}
renderEmptyState={renderFollowingEmptyState}
/>
<FeedPage key="2" isPageFocused={selectedPage === 1} feed={algoFeed} />
<FeedPage
key="2"
testID="whatshotFeedPage"
isPageFocused={selectedPage === 1}
feed={algoFeed}
/>
</Pager>
)
})
const FeedPage = observer(
({
testID,
isPageFocused,
feed,
renderEmptyState,
}: {
testID?: string
feed: FeedModel
isPageFocused: boolean
renderEmptyState?: () => JSX.Element
@ -163,9 +180,9 @@ const FeedPage = observer(
}, [feed, scrollToTop])
return (
<View style={s.h100pct}>
<View testID={testID} style={s.h100pct}>
<Feed
testID="homeFeed"
testID={testID ? `${testID}-feed` : undefined}
key="default"
feed={feed}
scrollElRef={scrollElRef}

View file

@ -1,16 +1,28 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {useNavigation, StackActions} from '@react-navigation/native'
import {
useNavigation,
StackActions,
useFocusEffect,
} from '@react-navigation/native'
import {ViewHeader} from '../com/util/ViewHeader'
import {Text} from '../com/util/text/Text'
import {Button} from 'view/com/util/forms/Button'
import {NavigationProp} from 'lib/routes/types'
import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index'
import {s} from 'lib/styles'
export const NotFoundScreen = () => {
const pal = usePalette('default')
const navigation = useNavigation<NavigationProp>()
const store = useStores()
useFocusEffect(
React.useCallback(() => {
store.shell.setMinimalShellMode(false)
}, [store]),
)
const canGoBack = navigation.canGoBack()
const onPressHome = React.useCallback(() => {

View file

@ -72,6 +72,7 @@ export const NotificationsScreen = withAuthRequired(
// =
useFocusEffect(
React.useCallback(() => {
store.shell.setMinimalShellMode(false)
store.log.debug('NotificationsScreen: Updating feed')
const softResetSub = store.onScreenSoftReset(scrollToTop)
store.me.notifications.loadUnreadCount()
@ -86,7 +87,7 @@ export const NotificationsScreen = withAuthRequired(
)
return (
<View style={s.hContentRegion}>
<View testID="notificationsScreen" style={s.hContentRegion}>
<ViewHeader title="Notifications" canGoBack={false} />
<Feed
view={store.me.notifications}

View file

@ -4,12 +4,12 @@ import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewHeader} from '../com/util/ViewHeader'
import {PostVotedBy as PostLikedByComponent} from '../com/post-thread/PostVotedBy'
import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy'
import {useStores} from 'state/index'
import {makeRecordUri} from 'lib/strings/url-helpers'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostUpvotedBy'>
export const PostUpvotedByScreen = withAuthRequired(({route}: Props) => {
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostLikedBy'>
export const PostLikedByScreen = withAuthRequired(({route}: Props) => {
const store = useStores()
const {name, rkey} = route.params
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
@ -23,7 +23,7 @@ export const PostUpvotedByScreen = withAuthRequired(({route}: Props) => {
return (
<View>
<ViewHeader title="Liked by" />
<PostLikedByComponent uri={uri} direction="up" />
<PostLikedByComponent uri={uri} />
</View>
)
})

View file

@ -29,8 +29,8 @@ export const PostThreadScreen = withAuthRequired(({route}: Props) => {
useFocusEffect(
React.useCallback(() => {
const threadCleanup = view.registerListeners()
store.shell.setMinimalShellMode(false)
const threadCleanup = view.registerListeners()
if (!view.hasLoaded && !view.isLoading) {
view.setup().catch(err => {
store.log.error('Failed to fetch thread', err)

View file

@ -42,6 +42,7 @@ export const ProfileScreen = withAuthRequired(
useFocusEffect(
React.useCallback(() => {
let aborted = false
store.shell.setMinimalShellMode(false)
const feedCleanup = uiState.feed.registerListeners()
if (hasSetup) {
uiState.update()
@ -57,7 +58,7 @@ export const ProfileScreen = withAuthRequired(
aborted = true
feedCleanup()
}
}, [hasSetup, uiState]),
}, [hasSetup, uiState, store]),
)
// events

View file

@ -152,6 +152,7 @@ export const SearchScreen = withAuthRequired(
{autocompleteView.searchRes.map(item => (
<ProfileCard
key={item.did}
testID={`searchAutoCompleteResult-${item.handle}`}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}

View file

@ -112,6 +112,7 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
footerMinimalShellTransform,
]}>
<Btn
testID="bottomBarHomeBtn"
icon={
isAtHome ? (
<HomeIconSolid
@ -130,6 +131,7 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
onPress={onPressHome}
/>
<Btn
testID="bottomBarSearchBtn"
icon={
isAtSearch ? (
<MagnifyingGlassIcon2Solid
@ -148,6 +150,7 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
onPress={onPressSearch}
/>
<Btn
testID="bottomBarNotificationsBtn"
icon={
isAtNotifications ? (
<BellIconSolid
@ -167,6 +170,7 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
notificationCount={store.me.notifications.unreadCount}
/>
<Btn
testID="bottomBarProfileBtn"
icon={
<View style={styles.ctrlIconSizingWrapper}>
<UserIcon
@ -183,11 +187,13 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
})
function Btn({
testID,
icon,
notificationCount,
onPress,
onLongPress,
}: {
testID?: string
icon: JSX.Element
notificationCount?: number
onPress?: (event: GestureResponderEvent) => void
@ -195,6 +201,7 @@ function Btn({
}) {
return (
<TouchableOpacity
testID={testID}
style={styles.ctrl}
onPress={onLongPress ? onPress : undefined}
onPressIn={onLongPress ? undefined : onPress}

View file

@ -162,7 +162,7 @@ export const DrawerContent = observer(() => {
return (
<View
testID="menuView"
testID="drawer"
style={[
styles.view,
theme.colorScheme === 'light' ? pal.view : styles.viewDarkMode,

View file

@ -7,11 +7,9 @@ import {useNavigationState} from '@react-navigation/native'
import {useStores} from 'state/index'
import {ModalsContainer} from 'view/com/modals/Modal'
import {Lightbox} from 'view/com/lightbox/Lightbox'
import {Text} from 'view/com/util/text/Text'
import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
import {DrawerContent} from './Drawer'
import {Composer} from './Composer'
import {s} from 'lib/styles'
import {useTheme} from 'lib/ThemeContext'
import {usePalette} from 'lib/hooks/usePalette'
import {RoutesContainer, TabsNavigator} from '../../Navigation'
@ -72,41 +70,6 @@ const ShellInner = observer(() => {
export const Shell: React.FC = observer(() => {
const theme = useTheme()
const pal = usePalette('default')
const store = useStores()
if (store.hackUpgradeNeeded) {
return (
<View style={styles.outerContainer}>
<View style={[s.flexCol, s.p20, s.h100pct]}>
<View style={s.flex1} />
<View>
<Text type="title-2xl" style={s.pb10}>
Update required
</Text>
<Text style={[s.pb20, s.bold]}>
Please update your app to the latest version. If no update is
available yet, please check the App Store in a day or so.
</Text>
<Text type="title" style={s.pb10}>
What's happening?
</Text>
<Text style={s.pb10}>
We're in the final stages of the AT Protocol's v1 development. To
make sure everything works as well as possible, we're making final
breaking changes to the APIs.
</Text>
<Text>
If we didn't botch this process, a new version of the app should
be available now.
</Text>
</View>
<View style={s.flex1} />
<View style={s.footerSpacer} />
</View>
</View>
)
}
return (
<View testID="mobileShellView" style={[styles.outerContainer, pal.view]}>
<StatusBar