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:
parent
19f3a2fa92
commit
a3334a01a2
133 changed files with 3103 additions and 2839 deletions
|
@ -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
|
||||
|
|
|
@ -60,12 +60,14 @@ export const Step1 = observer(({model}: {model: CreateAccountModel}) => {
|
|||
This is the company that keeps you online.
|
||||
</Text>
|
||||
<Option
|
||||
testID="blueskyServerBtn"
|
||||
isSelected={isDefaultSelected}
|
||||
label="Bluesky"
|
||||
help=" (default)"
|
||||
onPress={onPressDefault}
|
||||
/>
|
||||
<Option
|
||||
testID="otherServerBtn"
|
||||
isSelected={!isDefaultSelected}
|
||||
label="Other"
|
||||
onPress={onPressOther}>
|
||||
|
@ -74,6 +76,7 @@ export const Step1 = observer(({model}: {model: CreateAccountModel}) => {
|
|||
Enter the address of your provider:
|
||||
</Text>
|
||||
<TextInput
|
||||
testID="customServerInput"
|
||||
icon="globe"
|
||||
placeholder="Hosting provider address"
|
||||
value={model.serviceUrl}
|
||||
|
@ -83,12 +86,14 @@ export const Step1 = observer(({model}: {model: CreateAccountModel}) => {
|
|||
{LOGIN_INCLUDE_DEV_SERVERS && (
|
||||
<View style={[s.flexRow, s.mt10]}>
|
||||
<Button
|
||||
testID="stagingServerBtn"
|
||||
type="default"
|
||||
style={s.mr5}
|
||||
label="Staging"
|
||||
onPress={() => onDebugChangeServiceUrl(STAGING_SERVICE)}
|
||||
/>
|
||||
<Button
|
||||
testID="localDevServerBtn"
|
||||
type="default"
|
||||
label="Dev Server"
|
||||
onPress={() => onDebugChangeServiceUrl(LOCAL_DEV_SERVICE)}
|
||||
|
@ -112,11 +117,13 @@ function Option({
|
|||
label,
|
||||
help,
|
||||
onPress,
|
||||
testID,
|
||||
}: React.PropsWithChildren<{
|
||||
isSelected: boolean
|
||||
label: string
|
||||
help?: string
|
||||
onPress: () => void
|
||||
testID?: string
|
||||
}>) {
|
||||
const theme = useTheme()
|
||||
const pal = usePalette('default')
|
||||
|
@ -129,7 +136,7 @@ function Option({
|
|||
|
||||
return (
|
||||
<View style={[styles.option, pal.border]}>
|
||||
<TouchableWithoutFeedback onPress={onPress}>
|
||||
<TouchableWithoutFeedback onPress={onPress} testID={testID}>
|
||||
<View style={styles.optionHeading}>
|
||||
<View style={[styles.circle, pal.border]}>
|
||||
{isSelected ? (
|
||||
|
|
|
@ -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]}>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
/>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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>}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -19,7 +19,7 @@ import {ago} from 'lib/strings/time'
|
|||
import {pluralize} from 'lib/strings/helpers'
|
||||
import {useStores} from 'state/index'
|
||||
import {PostMeta} from '../util/PostMeta'
|
||||
import {PostEmbeds} from '../util/PostEmbeds'
|
||||
import {PostEmbeds} from '../util/post-embeds'
|
||||
import {PostCtrls} from '../util/PostCtrls'
|
||||
import {PostMutedWrapper} from '../util/PostMuted'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
|
@ -38,7 +38,7 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
const store = useStores()
|
||||
const [deleted, setDeleted] = React.useState(false)
|
||||
const record = item.postRecord
|
||||
const hasEngagement = item.post.upvoteCount || item.post.repostCount
|
||||
const hasEngagement = item.post.likeCount || item.post.repostCount
|
||||
|
||||
const itemUri = item.post.uri
|
||||
const itemCid = item.post.cid
|
||||
|
@ -49,11 +49,11 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
const itemTitle = `Post by ${item.post.author.handle}`
|
||||
const authorHref = `/profile/${item.post.author.handle}`
|
||||
const authorTitle = item.post.author.handle
|
||||
const upvotesHref = React.useMemo(() => {
|
||||
const likesHref = React.useMemo(() => {
|
||||
const urip = new AtUri(item.post.uri)
|
||||
return `/profile/${item.post.author.handle}/post/${urip.rkey}/upvoted-by`
|
||||
return `/profile/${item.post.author.handle}/post/${urip.rkey}/liked-by`
|
||||
}, [item.post.uri, item.post.author.handle])
|
||||
const upvotesTitle = 'Likes on this post'
|
||||
const likesTitle = 'Likes on this post'
|
||||
const repostsHref = React.useMemo(() => {
|
||||
const urip = new AtUri(item.post.uri)
|
||||
return `/profile/${item.post.author.handle}/post/${urip.rkey}/reposted-by`
|
||||
|
@ -80,10 +80,10 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
.toggleRepost()
|
||||
.catch(e => store.log.error('Failed to toggle repost', e))
|
||||
}, [item, store])
|
||||
const onPressToggleUpvote = React.useCallback(() => {
|
||||
const onPressToggleLike = React.useCallback(() => {
|
||||
return item
|
||||
.toggleUpvote()
|
||||
.catch(e => store.log.error('Failed to toggle upvote', e))
|
||||
.toggleLike()
|
||||
.catch(e => store.log.error('Failed to toggle like', e))
|
||||
}, [item, store])
|
||||
const onCopyPostText = React.useCallback(() => {
|
||||
Clipboard.setString(record?.text || '')
|
||||
|
@ -125,153 +125,151 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
|
||||
if (item._isHighlightedPost) {
|
||||
return (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
styles.outer,
|
||||
styles.outerHighlighted,
|
||||
{borderTopColor: pal.colors.border},
|
||||
pal.view,
|
||||
]}>
|
||||
<View style={styles.layout}>
|
||||
<View style={styles.layoutAvi}>
|
||||
<Link href={authorHref} title={authorTitle} asAnchor>
|
||||
<UserAvatar size={52} avatar={item.post.author.avatar} />
|
||||
</Link>
|
||||
</View>
|
||||
<View style={styles.layoutContent}>
|
||||
<View style={[styles.meta, styles.metaExpandedLine1]}>
|
||||
<View style={[s.flexRow, s.alignBaseline]}>
|
||||
<Link
|
||||
style={styles.metaItem}
|
||||
href={authorHref}
|
||||
title={authorTitle}>
|
||||
<Text
|
||||
type="xl-bold"
|
||||
style={[pal.text]}
|
||||
numberOfLines={1}
|
||||
lineHeight={1.2}>
|
||||
{item.post.author.displayName || item.post.author.handle}
|
||||
</Text>
|
||||
</Link>
|
||||
<Text type="md" style={[styles.metaItem, pal.textLight]}>
|
||||
· {ago(item.post.indexedAt)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={s.flex1} />
|
||||
<PostDropdownBtn
|
||||
style={styles.metaItem}
|
||||
itemUri={itemUri}
|
||||
itemCid={itemCid}
|
||||
itemHref={itemHref}
|
||||
itemTitle={itemTitle}
|
||||
isAuthor={item.post.author.did === store.me.did}
|
||||
onCopyPostText={onCopyPostText}
|
||||
onOpenTranslate={onOpenTranslate}
|
||||
onDeletePost={onDeletePost}>
|
||||
<FontAwesomeIcon
|
||||
icon="ellipsis-h"
|
||||
size={14}
|
||||
style={[s.mt2, s.mr5, pal.textLight]}
|
||||
/>
|
||||
</PostDropdownBtn>
|
||||
</View>
|
||||
<View style={styles.meta}>
|
||||
<View
|
||||
testID={`postThreadItem-by-${item.post.author.handle}`}
|
||||
style={[
|
||||
styles.outer,
|
||||
styles.outerHighlighted,
|
||||
{borderTopColor: pal.colors.border},
|
||||
pal.view,
|
||||
]}>
|
||||
<View style={styles.layout}>
|
||||
<View style={styles.layoutAvi}>
|
||||
<Link href={authorHref} title={authorTitle} asAnchor>
|
||||
<UserAvatar size={52} avatar={item.post.author.avatar} />
|
||||
</Link>
|
||||
</View>
|
||||
<View style={styles.layoutContent}>
|
||||
<View style={[styles.meta, styles.metaExpandedLine1]}>
|
||||
<View style={[s.flexRow, s.alignBaseline]}>
|
||||
<Link
|
||||
style={styles.metaItem}
|
||||
href={authorHref}
|
||||
title={authorTitle}>
|
||||
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
|
||||
@{item.post.author.handle}
|
||||
<Text
|
||||
type="xl-bold"
|
||||
style={[pal.text]}
|
||||
numberOfLines={1}
|
||||
lineHeight={1.2}>
|
||||
{item.post.author.displayName || item.post.author.handle}
|
||||
</Text>
|
||||
</Link>
|
||||
<Text type="md" style={[styles.metaItem, pal.textLight]}>
|
||||
· {ago(item.post.indexedAt)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[s.pl10, s.pr10, s.pb10]}>
|
||||
{item.richText?.text ? (
|
||||
<View
|
||||
style={[
|
||||
styles.postTextContainer,
|
||||
styles.postTextLargeContainer,
|
||||
]}>
|
||||
<RichText
|
||||
type="post-text-lg"
|
||||
richText={item.richText}
|
||||
lineHeight={1.3}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
<PostEmbeds embed={item.post.embed} style={s.mb10} />
|
||||
{item._isHighlightedPost && hasEngagement ? (
|
||||
<View style={[styles.expandedInfo, pal.border]}>
|
||||
{item.post.repostCount ? (
|
||||
<Link
|
||||
style={styles.expandedInfoItem}
|
||||
href={repostsHref}
|
||||
title={repostsTitle}>
|
||||
<Text type="lg" style={pal.textLight}>
|
||||
<Text type="xl-bold" style={pal.text}>
|
||||
{item.post.repostCount}
|
||||
</Text>{' '}
|
||||
{pluralize(item.post.repostCount, 'repost')}
|
||||
</Text>
|
||||
</Link>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{item.post.upvoteCount ? (
|
||||
<Link
|
||||
style={styles.expandedInfoItem}
|
||||
href={upvotesHref}
|
||||
title={upvotesTitle}>
|
||||
<Text type="lg" style={pal.textLight}>
|
||||
<Text type="xl-bold" style={pal.text}>
|
||||
{item.post.upvoteCount}
|
||||
</Text>{' '}
|
||||
{pluralize(item.post.upvoteCount, 'like')}
|
||||
</Text>
|
||||
</Link>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<View style={[s.pl10, s.pb5]}>
|
||||
<PostCtrls
|
||||
big
|
||||
<View style={s.flex1} />
|
||||
<PostDropdownBtn
|
||||
testID="postDropdownBtn"
|
||||
style={styles.metaItem}
|
||||
itemUri={itemUri}
|
||||
itemCid={itemCid}
|
||||
itemHref={itemHref}
|
||||
itemTitle={itemTitle}
|
||||
author={{
|
||||
avatar: item.post.author.avatar!,
|
||||
handle: item.post.author.handle,
|
||||
displayName: item.post.author.displayName!,
|
||||
}}
|
||||
text={item.richText?.text || record.text}
|
||||
indexedAt={item.post.indexedAt}
|
||||
isAuthor={item.post.author.did === store.me.did}
|
||||
isReposted={!!item.post.viewer.repost}
|
||||
isUpvoted={!!item.post.viewer.upvote}
|
||||
onPressReply={onPressReply}
|
||||
onPressToggleRepost={onPressToggleRepost}
|
||||
onPressToggleUpvote={onPressToggleUpvote}
|
||||
onCopyPostText={onCopyPostText}
|
||||
onOpenTranslate={onOpenTranslate}
|
||||
onDeletePost={onDeletePost}
|
||||
/>
|
||||
onDeletePost={onDeletePost}>
|
||||
<FontAwesomeIcon
|
||||
icon="ellipsis-h"
|
||||
size={14}
|
||||
style={[s.mt2, s.mr5, pal.textLight]}
|
||||
/>
|
||||
</PostDropdownBtn>
|
||||
</View>
|
||||
<View style={styles.meta}>
|
||||
<Link
|
||||
style={styles.metaItem}
|
||||
href={authorHref}
|
||||
title={authorTitle}>
|
||||
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
|
||||
@{item.post.author.handle}
|
||||
</Text>
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
<View style={[s.pl10, s.pr10, s.pb10]}>
|
||||
{item.richText?.text ? (
|
||||
<View
|
||||
style={[styles.postTextContainer, styles.postTextLargeContainer]}>
|
||||
<RichText
|
||||
type="post-text-lg"
|
||||
richText={item.richText}
|
||||
lineHeight={1.3}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
<PostEmbeds embed={item.post.embed} style={s.mb10} />
|
||||
{item._isHighlightedPost && hasEngagement ? (
|
||||
<View style={[styles.expandedInfo, pal.border]}>
|
||||
{item.post.repostCount ? (
|
||||
<Link
|
||||
style={styles.expandedInfoItem}
|
||||
href={repostsHref}
|
||||
title={repostsTitle}>
|
||||
<Text testID="repostCount" type="lg" style={pal.textLight}>
|
||||
<Text type="xl-bold" style={pal.text}>
|
||||
{item.post.repostCount}
|
||||
</Text>{' '}
|
||||
{pluralize(item.post.repostCount, 'repost')}
|
||||
</Text>
|
||||
</Link>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{item.post.likeCount ? (
|
||||
<Link
|
||||
style={styles.expandedInfoItem}
|
||||
href={likesHref}
|
||||
title={likesTitle}>
|
||||
<Text testID="likeCount" type="lg" style={pal.textLight}>
|
||||
<Text type="xl-bold" style={pal.text}>
|
||||
{item.post.likeCount}
|
||||
</Text>{' '}
|
||||
{pluralize(item.post.likeCount, 'like')}
|
||||
</Text>
|
||||
</Link>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<View style={[s.pl10, s.pb5]}>
|
||||
<PostCtrls
|
||||
big
|
||||
itemUri={itemUri}
|
||||
itemCid={itemCid}
|
||||
itemHref={itemHref}
|
||||
itemTitle={itemTitle}
|
||||
author={{
|
||||
avatar: item.post.author.avatar!,
|
||||
handle: item.post.author.handle,
|
||||
displayName: item.post.author.displayName!,
|
||||
}}
|
||||
text={item.richText?.text || record.text}
|
||||
indexedAt={item.post.indexedAt}
|
||||
isAuthor={item.post.author.did === store.me.did}
|
||||
isReposted={!!item.post.viewer?.repost}
|
||||
isLiked={!!item.post.viewer?.like}
|
||||
onPressReply={onPressReply}
|
||||
onPressToggleRepost={onPressToggleRepost}
|
||||
onPressToggleLike={onPressToggleLike}
|
||||
onCopyPostText={onCopyPostText}
|
||||
onOpenTranslate={onOpenTranslate}
|
||||
onDeletePost={onDeletePost}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<PostMutedWrapper isMuted={item.post.author.viewer?.muted === true}>
|
||||
<Link
|
||||
testID={`postThreadItem-by-${item.post.author.handle}`}
|
||||
style={[styles.outer, {borderTopColor: pal.colors.border}, pal.view]}
|
||||
href={itemHref}
|
||||
title={itemTitle}
|
||||
|
@ -305,7 +303,6 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
timestamp={item.post.indexedAt}
|
||||
postHref={itemHref}
|
||||
did={item.post.author.did}
|
||||
declarationCid={item.post.author.declaration.cid}
|
||||
/>
|
||||
{item.richText?.text ? (
|
||||
<View style={styles.postTextContainer}>
|
||||
|
@ -333,12 +330,12 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
isAuthor={item.post.author.did === store.me.did}
|
||||
replyCount={item.post.replyCount}
|
||||
repostCount={item.post.repostCount}
|
||||
upvoteCount={item.post.upvoteCount}
|
||||
isReposted={!!item.post.viewer.repost}
|
||||
isUpvoted={!!item.post.viewer.upvote}
|
||||
likeCount={item.post.likeCount}
|
||||
isReposted={!!item.post.viewer?.repost}
|
||||
isLiked={!!item.post.viewer?.like}
|
||||
onPressReply={onPressReply}
|
||||
onPressToggleRepost={onPressToggleRepost}
|
||||
onPressToggleUpvote={onPressToggleUpvote}
|
||||
onPressToggleLike={onPressToggleLike}
|
||||
onCopyPostText={onCopyPostText}
|
||||
onOpenTranslate={onOpenTranslate}
|
||||
onDeletePost={onDeletePost}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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} />}
|
||||
/>
|
||||
)
|
||||
},
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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]}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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)}>
|
||||
|
|
|
@ -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 ? (
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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: {
|
55
src/view/com/util/post-embeds/YoutubeEmbed.tsx
Normal file
55
src/view/com/util/post-embeds/YoutubeEmbed.tsx
Normal 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',
|
||||
},
|
||||
})
|
|
@ -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,
|
||||
},
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
})
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -162,7 +162,7 @@ export const DrawerContent = observer(() => {
|
|||
|
||||
return (
|
||||
<View
|
||||
testID="menuView"
|
||||
testID="drawer"
|
||||
style={[
|
||||
styles.view,
|
||||
theme.colorScheme === 'light' ? pal.view : styles.viewDarkMode,
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue