Revert "using register"

This reverts commit e28093ec35.
This commit is contained in:
João Ferreiro
2022-12-12 16:58:33 +00:00
parent e28093ec35
commit a4422f1819
60 changed files with 3474 additions and 3642 deletions
-1
View File
@@ -34,7 +34,6 @@
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-native": "0.68.2", "react-native": "0.68.2",
"react-native-appstate-hook": "^1.0.6", "react-native-appstate-hook": "^1.0.6",
"react-native-bundle-splitter": "^2.2.3",
"react-native-gesture-handler": "^2.5.0", "react-native-gesture-handler": "^2.5.0",
"react-native-image-crop-picker": "^0.38.1", "react-native-image-crop-picker": "^0.38.1",
"react-native-inappbrowser-reborn": "^3.6.3", "react-native-inappbrowser-reborn": "^3.6.3",
+2 -3
View File
@@ -8,14 +8,13 @@ import {
} from 'react-native' } from 'react-native'
import {useAnimatedValue} from '../../lib/useAnimatedValue' import {useAnimatedValue} from '../../lib/useAnimatedValue'
import {colors} from '../../lib/styles' import {colors} from '../../lib/styles'
import {register} from 'react-native-bundle-splitter'
interface AutocompleteItem { interface AutocompleteItem {
handle: string handle: string
displayName?: string displayName?: string
} }
export const Autocomplete = register(function Autocomplete({ export function Autocomplete({
active, active,
items, items,
onSelect, onSelect,
@@ -53,7 +52,7 @@ export const Autocomplete = register(function Autocomplete({
))} ))}
</Animated.View> </Animated.View>
) )
}) }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outer: { outer: {
+231 -235
View File
@@ -30,268 +30,264 @@ import {UserLocalPhotosModel} from '../../../state/models/user-local-photos'
import {PhotoCarouselPicker} from './PhotoCarouselPicker' import {PhotoCarouselPicker} from './PhotoCarouselPicker'
import {SelectedPhoto} from './SelectedPhoto' import {SelectedPhoto} from './SelectedPhoto'
import {IMAGES_ENABLED} from '../../../build-flags' import {IMAGES_ENABLED} from '../../../build-flags'
import {register} from 'react-native-bundle-splitter'
const MAX_TEXT_LENGTH = 256 const MAX_TEXT_LENGTH = 256
const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH
export const ComposePost = register( export const ComposePost = observer(function ComposePost({
observer(function ComposePost({ replyTo,
replyTo, onPost,
onPost, onClose,
onClose, }: {
}: { replyTo?: ComposerOpts['replyTo']
replyTo?: ComposerOpts['replyTo'] onPost?: ComposerOpts['onPost']
onPost?: ComposerOpts['onPost'] onClose: () => void
onClose: () => void }) {
}) { const store = useStores()
const store = useStores() const textInput = useRef<TextInput>(null)
const textInput = useRef<TextInput>(null) const [isProcessing, setIsProcessing] = useState(false)
const [isProcessing, setIsProcessing] = useState(false) const [error, setError] = useState('')
const [error, setError] = useState('') const [text, setText] = useState('')
const [text, setText] = useState('') const [selectedPhotos, setSelectedPhotos] = useState<string[]>([])
const [selectedPhotos, setSelectedPhotos] = useState<string[]>([])
const autocompleteView = useMemo<UserAutocompleteViewModel>( const autocompleteView = useMemo<UserAutocompleteViewModel>(
() => new UserAutocompleteViewModel(store), () => new UserAutocompleteViewModel(store),
[store], [store],
) )
const localPhotos = useMemo<UserLocalPhotosModel>( const localPhotos = useMemo<UserLocalPhotosModel>(
() => new UserLocalPhotosModel(store), () => new UserLocalPhotosModel(store),
[store], [store],
) )
useEffect(() => { useEffect(() => {
autocompleteView.setup() autocompleteView.setup()
localPhotos.setup() localPhotos.setup()
}, [autocompleteView, localPhotos]) }, [autocompleteView, localPhotos])
useEffect(() => { useEffect(() => {
// HACK // HACK
// wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view
// -prf // -prf
let to: NodeJS.Timeout | undefined let to: NodeJS.Timeout | undefined
if (textInput.current) { if (textInput.current) {
to = setTimeout(() => { to = setTimeout(() => {
textInput.current?.focus() textInput.current?.focus()
}, 250) }, 250)
} }
return () => { return () => {
if (to) { if (to) {
clearTimeout(to) clearTimeout(to)
}
}
}, [])
const onChangeText = (newText: string) => {
setText(newText)
const prefix = extractTextAutocompletePrefix(newText)
if (typeof prefix === 'string') {
autocompleteView.setActive(true)
autocompleteView.setPrefix(prefix)
} else {
autocompleteView.setActive(false)
} }
} }
const onPressCancel = () => { }, [])
onClose()
} const onChangeText = (newText: string) => {
const onPressPublish = async () => { setText(newText)
if (isProcessing) {
return const prefix = extractTextAutocompletePrefix(newText)
} if (typeof prefix === 'string') {
if (text.length > MAX_TEXT_LENGTH) { autocompleteView.setActive(true)
return autocompleteView.setPrefix(prefix)
} } else {
setError('')
if (text.trim().length === 0) {
setError('Did you want to say anything?')
return false
}
setIsProcessing(true)
try {
const replyRef = replyTo
? {uri: replyTo.uri, cid: replyTo.cid}
: undefined
await apilib.post(store, text, replyRef, autocompleteView.knownHandles)
} catch (e: any) {
console.error(`Failed to create post: ${e.toString()}`)
setError(
'Post failed to upload. Please check your Internet connection and try again.',
)
setIsProcessing(false)
return
}
onPost?.()
onClose()
Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
}
const onSelectAutocompleteItem = (item: string) => {
setText(replaceTextAutocompletePrefix(text, item))
autocompleteView.setActive(false) autocompleteView.setActive(false)
} }
}
const onPressCancel = () => {
onClose()
}
const onPressPublish = async () => {
if (isProcessing) {
return
}
if (text.length > MAX_TEXT_LENGTH) {
return
}
setError('')
if (text.trim().length === 0) {
setError('Did you want to say anything?')
return false
}
setIsProcessing(true)
try {
const replyRef = replyTo
? {uri: replyTo.uri, cid: replyTo.cid}
: undefined
await apilib.post(store, text, replyRef, autocompleteView.knownHandles)
} catch (e: any) {
console.error(`Failed to create post: ${e.toString()}`)
setError(
'Post failed to upload. Please check your Internet connection and try again.',
)
setIsProcessing(false)
return
}
onPost?.()
onClose()
Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
}
const onSelectAutocompleteItem = (item: string) => {
setText(replaceTextAutocompletePrefix(text, item))
autocompleteView.setActive(false)
}
const canPost = text.length <= MAX_TEXT_LENGTH const canPost = text.length <= MAX_TEXT_LENGTH
const progressColor = const progressColor = text.length > DANGER_TEXT_LENGTH ? '#e60000' : undefined
text.length > DANGER_TEXT_LENGTH ? '#e60000' : undefined
const selectTextInputLayout = const selectTextInputLayout =
selectedPhotos.length !== 0 selectedPhotos.length !== 0
? styles.textInputLayoutWithPhoto ? styles.textInputLayoutWithPhoto
: styles.textInputLayoutWithoutPhoto : styles.textInputLayoutWithoutPhoto
const selectTextInputPlaceholder = replyTo const selectTextInputPlaceholder = replyTo
? 'Write your reply' ? 'Write your reply'
: selectedPhotos.length !== 0 : selectedPhotos.length !== 0
? 'Write a comment' ? 'Write a comment'
: "What's up?" : "What's up?"
const textDecorated = useMemo(() => { const textDecorated = useMemo(() => {
let i = 0 let i = 0
return detectLinkables(text).map(v => { return detectLinkables(text).map(v => {
if (typeof v === 'string') { if (typeof v === 'string') {
return v return v
} else { } else {
return ( return (
<Text key={i++} style={{color: colors.blue3}}> <Text key={i++} style={{color: colors.blue3}}>
{v.link} {v.link}
</Text> </Text>
) )
} }
}) })
}, [text]) }, [text])
return ( return (
<KeyboardAvoidingView behavior="padding" style={styles.outer}> <KeyboardAvoidingView behavior="padding" style={styles.outer}>
<SafeAreaView style={s.flex1}> <SafeAreaView style={s.flex1}>
<View style={styles.topbar}> <View style={styles.topbar}>
<TouchableOpacity onPress={onPressCancel}> <TouchableOpacity onPress={onPressCancel}>
<Text style={[s.blue3, s.f18]}>Cancel</Text> <Text style={[s.blue3, s.f18]}>Cancel</Text>
</TouchableOpacity>
<View style={s.flex1} />
{isProcessing ? (
<View style={styles.postBtn}>
<ActivityIndicator />
</View>
) : canPost ? (
<TouchableOpacity onPress={onPressPublish}>
<LinearGradient
colors={[gradients.primary.start, gradients.primary.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={styles.postBtn}>
<Text style={[s.white, s.f16, s.bold]}>
{replyTo ? 'Reply' : 'Post'}
</Text>
</LinearGradient>
</TouchableOpacity> </TouchableOpacity>
<View style={s.flex1} /> ) : (
{isProcessing ? ( <View style={[styles.postBtn, {backgroundColor: colors.gray1}]}>
<View style={styles.postBtn}> <Text style={[s.gray5, s.f16, s.bold]}>Post</Text>
<ActivityIndicator />
</View>
) : canPost ? (
<TouchableOpacity onPress={onPressPublish}>
<LinearGradient
colors={[gradients.primary.start, gradients.primary.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={styles.postBtn}>
<Text style={[s.white, s.f16, s.bold]}>
{replyTo ? 'Reply' : 'Post'}
</Text>
</LinearGradient>
</TouchableOpacity>
) : (
<View style={[styles.postBtn, {backgroundColor: colors.gray1}]}>
<Text style={[s.gray5, s.f16, s.bold]}>Post</Text>
</View>
)}
</View>
{error !== '' && (
<View style={styles.errorLine}>
<View style={styles.errorIcon}>
<FontAwesomeIcon
icon="exclamation"
style={{color: colors.red4}}
size={10}
/>
</View>
<Text style={s.red4}>{error}</Text>
</View> </View>
)} )}
{replyTo ? ( </View>
<View style={styles.replyToLayout}> {error !== '' && (
<UserAvatar <View style={styles.errorLine}>
handle={replyTo.author.handle} <View style={styles.errorIcon}>
displayName={replyTo.author.displayName} <FontAwesomeIcon
avatar={replyTo.author.avatar} icon="exclamation"
size={50} style={{color: colors.red4}}
size={10}
/> />
<View style={styles.replyToPost}>
<TextLink
href={`/profile/${replyTo.author.handle}`}
text={replyTo.author.displayName || replyTo.author.handle}
style={[s.f16, s.bold]}
/>
<Text style={[s.f16, s['lh16-1.3']]} numberOfLines={6}>
{replyTo.text}
</Text>
</View>
</View> </View>
) : undefined} <Text style={s.red4}>{error}</Text>
<View style={[styles.textInputLayout, selectTextInputLayout]}> </View>
)}
{replyTo ? (
<View style={styles.replyToLayout}>
<UserAvatar <UserAvatar
handle={store.me.handle || ''} handle={replyTo.author.handle}
displayName={store.me.displayName} displayName={replyTo.author.displayName}
avatar={store.me.avatar} avatar={replyTo.author.avatar}
size={50} size={50}
/> />
<TextInput <View style={styles.replyToPost}>
ref={textInput} <TextLink
multiline href={`/profile/${replyTo.author.handle}`}
scrollEnabled text={replyTo.author.displayName || replyTo.author.handle}
onChangeText={(text: string) => onChangeText(text)} style={[s.f16, s.bold]}
placeholder={selectTextInputPlaceholder}
style={styles.textInput}>
{textDecorated}
</TextInput>
</View>
<SelectedPhoto
selectedPhotos={selectedPhotos}
setSelectedPhotos={setSelectedPhotos}
/>
{IMAGES_ENABLED &&
localPhotos.photos != null &&
text === '' &&
selectedPhotos.length === 0 && (
<PhotoCarouselPicker
selectedPhotos={selectedPhotos}
setSelectedPhotos={setSelectedPhotos}
localPhotos={localPhotos}
/> />
)} <Text style={[s.f16, s['lh16-1.3']]} numberOfLines={6}>
<View style={styles.bottomBar}> {replyTo.text}
<View style={s.flex1} /> </Text>
<Text style={[s.mr10, {color: progressColor}]}>
{MAX_TEXT_LENGTH - text.length}
</Text>
<View>
{text.length > DANGER_TEXT_LENGTH ? (
<ProgressPie
size={30}
borderWidth={4}
borderColor={progressColor}
color={progressColor}
progress={Math.min(
(text.length - MAX_TEXT_LENGTH) / MAX_TEXT_LENGTH,
1,
)}
/>
) : (
<ProgressCircle
size={30}
borderWidth={1}
borderColor={colors.gray2}
color={progressColor}
progress={text.length / MAX_TEXT_LENGTH}
/>
)}
</View> </View>
</View> </View>
<Autocomplete ) : undefined}
active={autocompleteView.isActive} <View style={[styles.textInputLayout, selectTextInputLayout]}>
items={autocompleteView.suggestions} <UserAvatar
onSelect={onSelectAutocompleteItem} handle={store.me.handle || ''}
displayName={store.me.displayName}
avatar={store.me.avatar}
size={50}
/> />
</SafeAreaView> <TextInput
</KeyboardAvoidingView> ref={textInput}
) multiline
}), scrollEnabled
) onChangeText={(text: string) => onChangeText(text)}
placeholder={selectTextInputPlaceholder}
style={styles.textInput}>
{textDecorated}
</TextInput>
</View>
<SelectedPhoto
selectedPhotos={selectedPhotos}
setSelectedPhotos={setSelectedPhotos}
/>
{IMAGES_ENABLED &&
localPhotos.photos != null &&
text === '' &&
selectedPhotos.length === 0 && (
<PhotoCarouselPicker
selectedPhotos={selectedPhotos}
setSelectedPhotos={setSelectedPhotos}
localPhotos={localPhotos}
/>
)}
<View style={styles.bottomBar}>
<View style={s.flex1} />
<Text style={[s.mr10, {color: progressColor}]}>
{MAX_TEXT_LENGTH - text.length}
</Text>
<View>
{text.length > DANGER_TEXT_LENGTH ? (
<ProgressPie
size={30}
borderWidth={4}
borderColor={progressColor}
color={progressColor}
progress={Math.min(
(text.length - MAX_TEXT_LENGTH) / MAX_TEXT_LENGTH,
1,
)}
/>
) : (
<ProgressCircle
size={30}
borderWidth={1}
borderColor={colors.gray2}
color={progressColor}
progress={text.length / MAX_TEXT_LENGTH}
/>
)}
</View>
</View>
<Autocomplete
active={autocompleteView.isActive}
items={autocompleteView.suggestions}
onSelect={onSelectAutocompleteItem}
/>
</SafeAreaView>
</KeyboardAvoidingView>
)
})
const atPrefixRegex = /@([a-z0-9\.]*)$/i const atPrefixRegex = /@([a-z0-9\.]*)$/i
function extractTextAutocompletePrefix(text: string) { function extractTextAutocompletePrefix(text: string) {
+77 -84
View File
@@ -7,103 +7,96 @@ import {
openCamera, openCamera,
openCropper, openCropper,
} from 'react-native-image-crop-picker' } from 'react-native-image-crop-picker'
import {register} from 'react-native-bundle-splitter'
export const PhotoCarouselPicker = register( export const PhotoCarouselPicker = ({
({ selectedPhotos,
selectedPhotos, setSelectedPhotos,
setSelectedPhotos, localPhotos,
localPhotos, }: {
}: { selectedPhotos: string[]
selectedPhotos: string[] setSelectedPhotos: React.Dispatch<React.SetStateAction<string[]>>
setSelectedPhotos: React.Dispatch<React.SetStateAction<string[]>> localPhotos: any
localPhotos: any }) => {
}) => { const handleOpenCamera = useCallback(() => {
const handleOpenCamera = useCallback(() => { openCamera({
openCamera({ mediaType: 'photo',
cropping: true,
width: 1000,
height: 1000,
}).then(
item => {
setSelectedPhotos([item.path, ...selectedPhotos])
},
_err => {
// ignore
},
)
}, [selectedPhotos, setSelectedPhotos])
const handleSelectPhoto = useCallback(
async (uri: string) => {
const img = await openCropper({
mediaType: 'photo', mediaType: 'photo',
cropping: true, path: uri,
width: 1000, width: 1000,
height: 1000, height: 1000,
}).then( })
item => { setSelectedPhotos([img.path, ...selectedPhotos])
setSelectedPhotos([item.path, ...selectedPhotos]) },
}, [selectedPhotos, setSelectedPhotos],
_err => { )
// ignore
},
)
}, [selectedPhotos, setSelectedPhotos])
const handleSelectPhoto = useCallback( const handleOpenGallery = useCallback(() => {
async (uri: string) => { openPicker({
multiple: true,
maxFiles: 4,
mediaType: 'photo',
}).then(async items => {
const result = []
for await (const image of items) {
const img = await openCropper({ const img = await openCropper({
mediaType: 'photo', mediaType: 'photo',
path: uri, path: image.path,
width: 1000, width: 1000,
height: 1000, height: 1000,
}) })
setSelectedPhotos([img.path, ...selectedPhotos]) result.push(img.path)
}, }
[selectedPhotos, setSelectedPhotos], setSelectedPhotos([...result, ...selectedPhotos])
) })
}, [selectedPhotos, setSelectedPhotos])
const handleOpenGallery = useCallback(() => { return (
openPicker({ <ScrollView
multiple: true, horizontal
maxFiles: 4, style={styles.photosContainer}
mediaType: 'photo', showsHorizontalScrollIndicator={false}>
}).then(async items => { <TouchableOpacity
const result = [] style={[styles.galleryButton, styles.photo]}
onPress={handleOpenCamera}>
for await (const image of items) { <FontAwesomeIcon
const img = await openCropper({ icon="camera"
mediaType: 'photo', size={24}
path: image.path, style={{color: colors.blue3}}
width: 1000, />
height: 1000, </TouchableOpacity>
}) {localPhotos.photos.map((item: any, index: number) => (
result.push(img.path)
}
setSelectedPhotos([...result, ...selectedPhotos])
})
}, [selectedPhotos, setSelectedPhotos])
return (
<ScrollView
horizontal
style={styles.photosContainer}
showsHorizontalScrollIndicator={false}>
<TouchableOpacity <TouchableOpacity
style={[styles.galleryButton, styles.photo]} key={`local-image-${index}`}
onPress={handleOpenCamera}> style={styles.photoButton}
<FontAwesomeIcon onPress={() => handleSelectPhoto(item.node.image.uri)}>
icon="camera" <Image style={styles.photo} source={{uri: item.node.image.uri}} />
size={24}
style={{color: colors.blue3}}
/>
</TouchableOpacity> </TouchableOpacity>
{localPhotos.photos.map((item: any, index: number) => ( ))}
<TouchableOpacity <TouchableOpacity
key={`local-image-${index}`} style={[styles.galleryButton, styles.photo]}
style={styles.photoButton} onPress={handleOpenGallery}>
onPress={() => handleSelectPhoto(item.node.image.uri)}> <FontAwesomeIcon icon="image" style={{color: colors.blue3}} size={24} />
<Image style={styles.photo} source={{uri: item.node.image.uri}} /> </TouchableOpacity>
</TouchableOpacity> </ScrollView>
))} )
<TouchableOpacity }
style={[styles.galleryButton, styles.photo]}
onPress={handleOpenGallery}>
<FontAwesomeIcon
icon="image"
style={{color: colors.blue3}}
size={24}
/>
</TouchableOpacity>
</ScrollView>
)
},
)
const styles = StyleSheet.create({ const styles = StyleSheet.create({
photosContainer: { photosContainer: {
+2 -3
View File
@@ -3,9 +3,8 @@ import {StyleSheet, Text, TouchableOpacity, View} from 'react-native'
import {colors} from '../../lib/styles' import {colors} from '../../lib/styles'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {UserAvatar} from '../util/UserAvatar' import {UserAvatar} from '../util/UserAvatar'
import {register} from 'react-native-bundle-splitter'
export const ComposePrompt = register(function ComposePrompt({ export function ComposePrompt({
noAvi = false, noAvi = false,
text = "What's up?", text = "What's up?",
btn = 'Post', btn = 'Post',
@@ -42,7 +41,7 @@ export const ComposePrompt = register(function ComposePrompt({
</View> </View>
</TouchableOpacity> </TouchableOpacity>
) )
}) }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
+43 -46
View File
@@ -2,56 +2,53 @@ import React, {useCallback} from 'react'
import {Image, StyleSheet, TouchableOpacity, View} from 'react-native' import {Image, StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {colors} from '../../lib/styles' import {colors} from '../../lib/styles'
import {register} from 'react-native-bundle-splitter'
export const SelectedPhoto = register( export const SelectedPhoto = ({
({ selectedPhotos,
selectedPhotos, setSelectedPhotos,
setSelectedPhotos, }: {
}: { selectedPhotos: string[]
selectedPhotos: string[] setSelectedPhotos: React.Dispatch<React.SetStateAction<string[]>>
setSelectedPhotos: React.Dispatch<React.SetStateAction<string[]>> }) => {
}) => { const imageStyle =
const imageStyle = selectedPhotos.length === 1
selectedPhotos.length === 1 ? styles.image250
? styles.image250 : selectedPhotos.length === 2
: selectedPhotos.length === 2 ? styles.image175
? styles.image175 : styles.image85
: styles.image85
const handleRemovePhoto = useCallback( const handleRemovePhoto = useCallback(
item => { item => {
setSelectedPhotos( setSelectedPhotos(
selectedPhotos.filter(filterItem => filterItem !== item), selectedPhotos.filter(filterItem => filterItem !== item),
) )
}, },
[selectedPhotos, setSelectedPhotos], [selectedPhotos, setSelectedPhotos],
) )
return selectedPhotos.length !== 0 ? ( return selectedPhotos.length !== 0 ? (
<View style={styles.imageContainer}> <View style={styles.imageContainer}>
{selectedPhotos.length !== 0 && {selectedPhotos.length !== 0 &&
selectedPhotos.map((item, index) => ( selectedPhotos.map((item, index) => (
<View <View
key={`selected-image-${index}`} key={`selected-image-${index}`}
style={[styles.image, imageStyle]}> style={[styles.image, imageStyle]}>
<TouchableOpacity <TouchableOpacity
onPress={() => handleRemovePhoto(item)} onPress={() => handleRemovePhoto(item)}
style={styles.removePhotoButton}> style={styles.removePhotoButton}>
<FontAwesomeIcon <FontAwesomeIcon
icon="xmark" icon="xmark"
size={16} size={16}
style={{color: colors.white}} style={{color: colors.white}}
/> />
</TouchableOpacity> </TouchableOpacity>
<Image style={[styles.image, imageStyle]} source={{uri: item}} /> <Image style={[styles.image, imageStyle]} source={{uri: item}} />
</View> </View>
))} ))}
</View> </View>
) : null ) : null
}, }
)
const styles = StyleSheet.create({ const styles = StyleSheet.create({
imageContainer: { imageContainer: {
+95 -102
View File
@@ -22,119 +22,112 @@ import {
SuggestedActor, SuggestedActor,
} from '../../../state/models/suggested-actors-view' } from '../../../state/models/suggested-actors-view'
import {s, colors, gradients} from '../../lib/styles' import {s, colors, gradients} from '../../lib/styles'
import {register} from 'react-native-bundle-splitter'
export const SuggestedFollows = register( export const SuggestedFollows = observer(
observer( ({
({ onNoSuggestions,
onNoSuggestions, asLinks,
asLinks, }: {
}: { onNoSuggestions?: () => void
onNoSuggestions?: () => void asLinks?: boolean
asLinks?: boolean }) => {
}) => { const store = useStores()
const store = useStores() const [follows, setFollows] = useState<Record<string, string>>({})
const [follows, setFollows] = useState<Record<string, string>>({})
const view = useMemo<SuggestedActorsViewModel>( const view = useMemo<SuggestedActorsViewModel>(
() => new SuggestedActorsViewModel(store), () => new SuggestedActorsViewModel(store),
[], [],
) )
useEffect(() => { useEffect(() => {
console.log('Fetching suggested actors') console.log('Fetching suggested actors')
view view
.setup() .setup()
.catch((err: any) => .catch((err: any) => console.error('Failed to fetch suggestions', err))
console.error('Failed to fetch suggestions', err), }, [view])
)
}, [view])
useEffect(() => { useEffect(() => {
if (!view.isLoading && !view.hasError && !view.hasContent) { if (!view.isLoading && !view.hasError && !view.hasContent) {
onNoSuggestions?.() onNoSuggestions?.()
}
}, [view, view.isLoading, view.hasError, view.hasContent])
const onPressTryAgain = () =>
view
.setup()
.catch((err: any) =>
console.error('Failed to fetch suggestions', err),
)
const onPressFollow = async (item: SuggestedActor) => {
try {
const res = await apilib.follow(store, item.did, item.declaration.cid)
setFollows({[item.did]: res.uri, ...follows})
} catch (e) {
console.log(e)
Toast.show('An issue occurred, please try again.')
}
}
const onPressUnfollow = async (item: SuggestedActor) => {
try {
await apilib.unfollow(store, follows[item.did])
setFollows(_omit(follows, [item.did]))
} catch (e) {
console.log(e)
Toast.show('An issue occurred, please try again.')
}
} }
}, [view, view.isLoading, view.hasError, view.hasContent])
const renderItem = ({item}: {item: SuggestedActor}) => { const onPressTryAgain = () =>
if (asLinks) { view
return ( .setup()
<Link .catch((err: any) => console.error('Failed to fetch suggestions', err))
href={`/profile/${item.handle}`}
title={item.displayName || item.handle}> const onPressFollow = async (item: SuggestedActor) => {
<User try {
item={item} const res = await apilib.follow(store, item.did, item.declaration.cid)
follow={follows[item.did]} setFollows({[item.did]: res.uri, ...follows})
onPressFollow={onPressFollow} } catch (e) {
onPressUnfollow={onPressUnfollow} console.log(e)
/> Toast.show('An issue occurred, please try again.')
</Link> }
) }
} const onPressUnfollow = async (item: SuggestedActor) => {
try {
await apilib.unfollow(store, follows[item.did])
setFollows(_omit(follows, [item.did]))
} catch (e) {
console.log(e)
Toast.show('An issue occurred, please try again.')
}
}
const renderItem = ({item}: {item: SuggestedActor}) => {
if (asLinks) {
return ( return (
<User <Link
item={item} href={`/profile/${item.handle}`}
follow={follows[item.did]} title={item.displayName || item.handle}>
onPressFollow={onPressFollow} <User
onPressUnfollow={onPressUnfollow} item={item}
/> follow={follows[item.did]}
onPressFollow={onPressFollow}
onPressUnfollow={onPressUnfollow}
/>
</Link>
) )
} }
return ( return (
<View style={styles.container}> <User
{view.isLoading ? ( item={item}
<View> follow={follows[item.did]}
<ActivityIndicator /> onPressFollow={onPressFollow}
</View> onPressUnfollow={onPressUnfollow}
) : view.hasError ? ( />
<ErrorScreen
title="Failed to load suggestions"
message="There was an error while trying to load suggested follows."
details={view.error}
onPressTryAgain={onPressTryAgain}
/>
) : view.isEmpty ? (
<View />
) : (
<View style={styles.suggestionsContainer}>
<FlatList
data={view.suggestions}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
style={s.flex1}
/>
</View>
)}
</View>
) )
}, }
), return (
<View style={styles.container}>
{view.isLoading ? (
<View>
<ActivityIndicator />
</View>
) : view.hasError ? (
<ErrorScreen
title="Failed to load suggestions"
message="There was an error while trying to load suggested follows."
details={view.error}
onPressTryAgain={onPressTryAgain}
/>
) : view.isEmpty ? (
<View />
) : (
<View style={styles.suggestionsContainer}>
<FlatList
data={view.suggestions}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
style={s.flex1}
/>
</View>
)}
</View>
)
},
) )
const User = ({ const User = ({
+59 -62
View File
@@ -6,70 +6,67 @@ import {FeedItem} from './FeedItem'
import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
import {ErrorMessage} from '../util/ErrorMessage' import {ErrorMessage} from '../util/ErrorMessage'
import {EmptyState} from '../util/EmptyState' import {EmptyState} from '../util/EmptyState'
import {register} from 'react-native-bundle-splitter'
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
export const Feed = register( export const Feed = observer(function Feed({
observer(function Feed({ view,
view, onPressTryAgain,
onPressTryAgain, }: {
}: { view: NotificationsViewModel
view: NotificationsViewModel onPressTryAgain?: () => void
onPressTryAgain?: () => void }) {
}) { // TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf
// TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf // VirtualizedList: You have a large list that is slow to update - make sure your
// VirtualizedList: You have a large list that is slow to update - make sure your // renderItem function renders components that follow React performance best practices
// renderItem function renders components that follow React performance best practices // like PureComponent, shouldComponentUpdate, etc
// like PureComponent, shouldComponentUpdate, etc const renderItem = ({item}: {item: any}) => {
const renderItem = ({item}: {item: any}) => { if (item === EMPTY_FEED_ITEM) {
if (item === EMPTY_FEED_ITEM) { return (
return ( <EmptyState
<EmptyState icon="bell"
icon="bell" message="No notifications yet!"
message="No notifications yet!" style={{paddingVertical: 40}}
style={{paddingVertical: 40}} />
/> )
)
}
return <FeedItem item={item} />
} }
const onRefresh = () => { return <FeedItem item={item} />
view.refresh().catch(err => console.error('Failed to refresh', err)) }
const onRefresh = () => {
view.refresh().catch(err => console.error('Failed to refresh', err))
}
const onEndReached = () => {
view.loadMore().catch(err => console.error('Failed to load more', err))
}
let data
if (view.hasLoaded) {
if (view.isEmpty) {
data = [EMPTY_FEED_ITEM]
} else {
data = view.notifications
} }
const onEndReached = () => { }
view.loadMore().catch(err => console.error('Failed to load more', err)) return (
} <View style={{flex: 1}}>
let data {view.isLoading && !data && <NotificationFeedLoadingPlaceholder />}
if (view.hasLoaded) { {view.hasError && (
if (view.isEmpty) { <ErrorMessage
data = [EMPTY_FEED_ITEM] dark
} else { message={view.error}
data = view.notifications style={{margin: 6}}
} onPressTryAgain={onPressTryAgain}
} />
return ( )}
<View style={{flex: 1}}> {data && (
{view.isLoading && !data && <NotificationFeedLoadingPlaceholder />} <FlatList
{view.hasError && ( data={data}
<ErrorMessage keyExtractor={item => item._reactKey}
dark renderItem={renderItem}
message={view.error} refreshing={view.isRefreshing}
style={{margin: 6}} onRefresh={onRefresh}
onPressTryAgain={onPressTryAgain} onEndReached={onEndReached}
/> />
)} )}
{data && ( </View>
<FlatList )
data={data} })
keyExtractor={item => item._reactKey}
renderItem={renderItem}
refreshing={view.isRefreshing}
onRefresh={onRefresh}
onEndReached={onEndReached}
/>
)}
</View>
)
}),
)
+169 -169
View File
@@ -13,185 +13,185 @@ import {ErrorMessage} from '../util/ErrorMessage'
import {Post} from '../post/Post' import {Post} from '../post/Post'
import {Link} from '../util/Link' import {Link} from '../util/Link'
import {InviteAccepter} from './InviteAccepter' import {InviteAccepter} from './InviteAccepter'
import {register} from 'react-native-bundle-splitter'
const MAX_AUTHORS = 8 const MAX_AUTHORS = 8
export const FeedItem = register( export const FeedItem = observer(function FeedItem({
observer(function FeedItem({item}: {item: NotificationsViewItemModel}) { item,
const itemHref = useMemo(() => { }: {
if (item.isUpvote || item.isRepost || item.isTrend) { item: NotificationsViewItemModel
const urip = new AtUri(item.subjectUri) }) {
return `/profile/${urip.host}/post/${urip.rkey}` const itemHref = useMemo(() => {
} else if (item.isFollow || item.isAssertion) { if (item.isUpvote || item.isRepost || item.isTrend) {
return `/profile/${item.author.handle}` const urip = new AtUri(item.subjectUri)
} else if (item.isReply) { return `/profile/${urip.host}/post/${urip.rkey}`
const urip = new AtUri(item.uri) } else if (item.isFollow || item.isAssertion) {
return `/profile/${urip.host}/post/${urip.rkey}` return `/profile/${item.author.handle}`
}
return ''
}, [item])
const itemTitle = useMemo(() => {
if (item.isUpvote || item.isRepost) {
return 'Post'
} else if (item.isFollow || item.isAssertion) {
return item.author.handle
} else if (item.isReply) {
return 'Post'
}
}, [item])
if (item.additionalPost?.notFound) {
// don't render anything if the target post was deleted or unfindable
return <View />
}
if (item.isReply || item.isMention) {
return (
<Link href={itemHref} title={itemTitle}>
<Post
uri={item.uri}
initView={item.additionalPost}
style={[
styles.outerMinimal,
item.isRead ? undefined : styles.outerUnread,
]}
/>
</Link>
)
}
let action = ''
let icon: Props['icon'] | 'UpIconSolid'
let iconStyle: Props['style'] = []
if (item.isUpvote) {
action = 'upvoted your post'
icon = 'UpIconSolid'
iconStyle = [s.red3, {position: 'relative', top: -4}]
} else if (item.isRepost) {
action = 'reposted your post'
icon = 'retweet'
iconStyle = [s.green3]
} else if (item.isTrend) {
action = 'Your post is trending with'
icon = 'arrow-trend-up'
iconStyle = [s.red3]
} else if (item.isReply) { } else if (item.isReply) {
action = 'replied to your post' const urip = new AtUri(item.uri)
icon = ['far', 'comment'] return `/profile/${urip.host}/post/${urip.rkey}`
} else if (item.isFollow) {
action = 'followed you'
icon = 'user-plus'
iconStyle = [s.blue3]
} else if (item.isInvite) {
icon = 'users'
iconStyle = [s.blue3]
action = 'invited you to join their scene'
} else {
return <></>
} }
return ''
let authors: {href: string; handle: string; displayName?: string}[] = [ }, [item])
{ const itemTitle = useMemo(() => {
href: `/profile/${item.author.handle}`, if (item.isUpvote || item.isRepost) {
handle: item.author.handle, return 'Post'
displayName: item.author.displayName, } else if (item.isFollow || item.isAssertion) {
}, return item.author.handle
] } else if (item.isReply) {
if (item.additional?.length) { return 'Post'
authors = authors.concat(
item.additional.map(item2 => ({
href: `/profile/${item2.author.handle}`,
handle: item2.author.handle,
displayName: item2.author.displayName,
})),
)
} }
}, [item])
if (item.additionalPost?.notFound) {
// don't render anything if the target post was deleted or unfindable
return <View />
}
if (item.isReply || item.isMention) {
return ( return (
<Link <Link href={itemHref} title={itemTitle}>
style={[styles.outer, item.isRead ? undefined : styles.outerUnread]} <Post
href={itemHref} uri={item.uri}
title={itemTitle}> initView={item.additionalPost}
<View style={styles.layout}> style={[
<View style={styles.layoutIcon}> styles.outerMinimal,
{icon === 'UpIconSolid' ? ( item.isRead ? undefined : styles.outerUnread,
<UpIconSolid size={26} style={[styles.icon, ...iconStyle]} /> ]}
) : ( />
<FontAwesomeIcon
icon={icon}
size={22}
style={[styles.icon, ...iconStyle]}
/>
)}
</View>
<View style={styles.layoutContent}>
<View style={styles.avis}>
{authors.slice(0, MAX_AUTHORS).map(author => (
<Link
style={{marginRight: 3}}
key={author.href}
href={author.href}
title={`@${author.handle}`}>
<UserAvatar
size={30}
displayName={author.displayName}
handle={author.handle}
avatar={author.avatar}
/>
</Link>
))}
{authors.length > MAX_AUTHORS ? (
<Text style={styles.aviExtraCount}>
+{authors.length - MAX_AUTHORS}
</Text>
) : undefined}
</View>
<View style={styles.meta}>
{item.isTrend && (
<Text style={[styles.metaItem, s.f15]}>{action}</Text>
)}
<Link
key={authors[0].href}
style={styles.metaItem}
href={authors[0].href}
title={`@${authors[0].handle}`}>
<Text style={[s.f15, s.bold]}>
{authors[0].displayName || authors[0].handle}
</Text>
</Link>
{authors.length > 1 ? (
<>
<Text style={[styles.metaItem, s.f15]}>and</Text>
<Text style={[styles.metaItem, s.f15, s.bold]}>
{authors.length - 1}{' '}
{pluralize(authors.length - 1, 'other')}
</Text>
</>
) : undefined}
{!item.isTrend && (
<Text style={[styles.metaItem, s.f15]}>{action}</Text>
)}
<Text style={[styles.metaItem, s.f15, s.gray5]}>
{ago(item.indexedAt)}
</Text>
</View>
{item.isUpvote || item.isRepost || item.isTrend ? (
<AdditionalPostText additionalPost={item.additionalPost} />
) : (
<></>
)}
</View>
</View>
{item.isInvite && (
<View style={styles.addedContainer}>
<InviteAccepter item={item} />
</View>
)}
</Link> </Link>
) )
}), }
)
let action = ''
let icon: Props['icon'] | 'UpIconSolid'
let iconStyle: Props['style'] = []
if (item.isUpvote) {
action = 'upvoted your post'
icon = 'UpIconSolid'
iconStyle = [s.red3, {position: 'relative', top: -4}]
} else if (item.isRepost) {
action = 'reposted your post'
icon = 'retweet'
iconStyle = [s.green3]
} else if (item.isTrend) {
action = 'Your post is trending with'
icon = 'arrow-trend-up'
iconStyle = [s.red3]
} else if (item.isReply) {
action = 'replied to your post'
icon = ['far', 'comment']
} else if (item.isFollow) {
action = 'followed you'
icon = 'user-plus'
iconStyle = [s.blue3]
} else if (item.isInvite) {
icon = 'users'
iconStyle = [s.blue3]
action = 'invited you to join their scene'
} else {
return <></>
}
let authors: {href: string; handle: string; displayName?: string}[] = [
{
href: `/profile/${item.author.handle}`,
handle: item.author.handle,
displayName: item.author.displayName,
},
]
if (item.additional?.length) {
authors = authors.concat(
item.additional.map(item2 => ({
href: `/profile/${item2.author.handle}`,
handle: item2.author.handle,
displayName: item2.author.displayName,
})),
)
}
return (
<Link
style={[styles.outer, item.isRead ? undefined : styles.outerUnread]}
href={itemHref}
title={itemTitle}>
<View style={styles.layout}>
<View style={styles.layoutIcon}>
{icon === 'UpIconSolid' ? (
<UpIconSolid size={26} style={[styles.icon, ...iconStyle]} />
) : (
<FontAwesomeIcon
icon={icon}
size={22}
style={[styles.icon, ...iconStyle]}
/>
)}
</View>
<View style={styles.layoutContent}>
<View style={styles.avis}>
{authors.slice(0, MAX_AUTHORS).map(author => (
<Link
style={{marginRight: 3}}
key={author.href}
href={author.href}
title={`@${author.handle}`}>
<UserAvatar
size={30}
displayName={author.displayName}
handle={author.handle}
avatar={author.avatar}
/>
</Link>
))}
{authors.length > MAX_AUTHORS ? (
<Text style={styles.aviExtraCount}>
+{authors.length - MAX_AUTHORS}
</Text>
) : undefined}
</View>
<View style={styles.meta}>
{item.isTrend && (
<Text style={[styles.metaItem, s.f15]}>{action}</Text>
)}
<Link
key={authors[0].href}
style={styles.metaItem}
href={authors[0].href}
title={`@${authors[0].handle}`}>
<Text style={[s.f15, s.bold]}>
{authors[0].displayName || authors[0].handle}
</Text>
</Link>
{authors.length > 1 ? (
<>
<Text style={[styles.metaItem, s.f15]}>and</Text>
<Text style={[styles.metaItem, s.f15, s.bold]}>
{authors.length - 1} {pluralize(authors.length - 1, 'other')}
</Text>
</>
) : undefined}
{!item.isTrend && (
<Text style={[styles.metaItem, s.f15]}>{action}</Text>
)}
<Text style={[styles.metaItem, s.f15, s.gray5]}>
{ago(item.indexedAt)}
</Text>
</View>
{item.isUpvote || item.isRepost || item.isTrend ? (
<AdditionalPostText additionalPost={item.additionalPost} />
) : (
<></>
)}
</View>
</View>
{item.isInvite && (
<View style={styles.addedContainer}>
<InviteAccepter item={item} />
</View>
)}
</Link>
)
})
function AdditionalPostText({ function AdditionalPostText({
additionalPost, additionalPost,
@@ -9,13 +9,8 @@ import {useStores} from '../../../state'
import {ProfileCard} from '../profile/ProfileCard' import {ProfileCard} from '../profile/ProfileCard'
import * as Toast from '../util/Toast' import * as Toast from '../util/Toast'
import {s, colors, gradients} from '../../lib/styles' import {s, colors, gradients} from '../../lib/styles'
import {register} from 'react-native-bundle-splitter'
export const InviteAccepter = register(function InviteAccepter({ export function InviteAccepter({item}: {item: NotificationsViewItemModel}) {
item,
}: {
item: NotificationsViewItemModel
}) {
const store = useStores() const store = useStores()
const [confirmationUri, setConfirmationUri] = useState<string>('') const [confirmationUri, setConfirmationUri] = useState<string>('')
const isMember = const isMember =
@@ -75,7 +70,7 @@ export const InviteAccepter = register(function InviteAccepter({
)} )}
</View> </View>
) )
}) }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
+2 -3
View File
@@ -16,7 +16,6 @@ import {useStores} from '../../../state'
import {s} from '../../lib/styles' import {s} from '../../lib/styles'
import {SCENE_EXPLAINER, TABS_EXPLAINER} from '../../lib/assets' import {SCENE_EXPLAINER, TABS_EXPLAINER} from '../../lib/assets'
import {TABS_ENABLED} from '../../../build-flags' import {TABS_ENABLED} from '../../../build-flags'
import {register} from 'react-native-bundle-splitter'
const Intro = () => ( const Intro = () => (
<View style={styles.explainer}> <View style={styles.explainer}>
@@ -80,7 +79,7 @@ const SCENE_MAP = {
} }
const renderScene = SceneMap(SCENE_MAP) const renderScene = SceneMap(SCENE_MAP)
export const FeatureExplainer = register(() => { export const FeatureExplainer = () => {
const layout = useWindowDimensions() const layout = useWindowDimensions()
const store = useStores() const store = useStores()
const [index, setIndex] = useState(0) const [index, setIndex] = useState(0)
@@ -154,7 +153,7 @@ export const FeatureExplainer = register(() => {
</View> </View>
</SafeAreaView> </SafeAreaView>
) )
}) }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
+23 -26
View File
@@ -10,35 +10,32 @@ import {observer} from 'mobx-react-lite'
import {SuggestedFollows} from '../discover/SuggestedFollows' import {SuggestedFollows} from '../discover/SuggestedFollows'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {s} from '../../lib/styles' import {s} from '../../lib/styles'
import {register} from 'react-native-bundle-splitter'
export const Follows = register( export const Follows = observer(() => {
observer(() => { const store = useStores()
const store = useStores()
const onNoSuggestions = () => { const onNoSuggestions = () => {
// no suggestions, bounce from this view // no suggestions, bounce from this view
store.onboard.next() store.onboard.next()
} }
const onPressNext = () => store.onboard.next() const onPressNext = () => store.onboard.next()
return ( return (
<SafeAreaView style={styles.container}> <SafeAreaView style={styles.container}>
<Text style={styles.title}>Suggested follows</Text> <Text style={styles.title}>Suggested follows</Text>
<SuggestedFollows onNoSuggestions={onNoSuggestions} /> <SuggestedFollows onNoSuggestions={onNoSuggestions} />
<View style={styles.footer}> <View style={styles.footer}>
<TouchableOpacity onPress={onPressNext}> <TouchableOpacity onPress={onPressNext}>
<Text style={[s.blue3, s.f18]}>Skip</Text> <Text style={[s.blue3, s.f18]}>Skip</Text>
</TouchableOpacity> </TouchableOpacity>
<View style={s.flex1} /> <View style={s.flex1} />
<TouchableOpacity onPress={onPressNext}> <TouchableOpacity onPress={onPressNext}>
<Text style={[s.blue3, s.f18]}>Next</Text> <Text style={[s.blue3, s.f18]}>Next</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</SafeAreaView> </SafeAreaView>
) )
}), })
)
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
+60 -59
View File
@@ -10,75 +10,76 @@ import {ErrorMessage} from '../util/ErrorMessage'
import {Link} from '../util/Link' import {Link} from '../util/Link'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {s, colors} from '../../lib/styles' import {s, colors} from '../../lib/styles'
import {register} from 'react-native-bundle-splitter'
export const PostRepostedBy = register( export const PostRepostedBy = observer(function PostRepostedBy({
observer(function PostRepostedBy({uri}: {uri: string}) { uri,
const store = useStores() }: {
const [view, setView] = useState<RepostedByViewModel | undefined>() uri: string
}) {
const store = useStores()
const [view, setView] = useState<RepostedByViewModel | undefined>()
useEffect(() => { useEffect(() => {
if (view?.params.uri === uri) { if (view?.params.uri === uri) {
console.log('Reposted by doing nothing') console.log('Reposted by doing nothing')
return // no change needed? or trigger refresh? return // no change needed? or trigger refresh?
}
console.log('Fetching Reposted by', uri)
const newView = new RepostedByViewModel(store, {uri})
setView(newView)
newView
.setup()
.catch(err => console.error('Failed to fetch reposted by', err))
}, [uri, view?.params.uri, store])
const onRefresh = () => {
view?.refresh()
} }
console.log('Fetching Reposted by', uri)
const newView = new RepostedByViewModel(store, {uri})
setView(newView)
newView
.setup()
.catch(err => console.error('Failed to fetch reposted by', err))
}, [uri, view?.params.uri, store])
// loading const onRefresh = () => {
// = view?.refresh()
if ( }
!view ||
(view.isLoading && !view.isRefreshing) ||
view.params.uri !== uri
) {
return (
<View>
<ActivityIndicator />
</View>
)
}
// error // loading
// = // =
if (view.hasError) { if (
return ( !view ||
<View> (view.isLoading && !view.isRefreshing) ||
<ErrorMessage view.params.uri !== uri
dark ) {
message={view.error}
style={{margin: 6}}
onPressTryAgain={onRefresh}
/>
</View>
)
}
// loaded
// =
const renderItem = ({item}: {item: RepostedByViewItemModel}) => (
<RepostedByItem item={item} />
)
return ( return (
<View> <View>
<FlatList <ActivityIndicator />
data={view.repostedBy} </View>
keyExtractor={item => item._reactKey} )
renderItem={renderItem} }
// error
// =
if (view.hasError) {
return (
<View>
<ErrorMessage
dark
message={view.error}
style={{margin: 6}}
onPressTryAgain={onRefresh}
/> />
</View> </View>
) )
}), }
)
// loaded
// =
const renderItem = ({item}: {item: RepostedByViewItemModel}) => (
<RepostedByItem item={item} />
)
return (
<View>
<FlatList
data={view.repostedBy}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
/>
</View>
)
})
const RepostedByItem = ({item}: {item: RepostedByViewItemModel}) => { const RepostedByItem = ({item}: {item: RepostedByViewItemModel}) => {
return ( return (
+72 -75
View File
@@ -8,88 +8,85 @@ import {
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {PostThreadItem} from './PostThreadItem' import {PostThreadItem} from './PostThreadItem'
import {ErrorMessage} from '../util/ErrorMessage' import {ErrorMessage} from '../util/ErrorMessage'
import {register} from 'react-native-bundle-splitter'
export const PostThread = register( export const PostThread = observer(function PostThread({
observer(function PostThread({ uri,
uri, view,
view, }: {
}: { uri: string
uri: string view: PostThreadViewModel
view: PostThreadViewModel }) {
}) { const ref = useRef<FlatList>(null)
const ref = useRef<FlatList>(null) const posts = view.thread ? Array.from(flattenThread(view.thread)) : []
const posts = view.thread ? Array.from(flattenThread(view.thread)) : [] const onRefresh = () => {
const onRefresh = () => { view?.refresh().catch(err => console.error('Failed to refresh', err))
view?.refresh().catch(err => console.error('Failed to refresh', err)) }
} const onLayout = () => {
const onLayout = () => { const index = posts.findIndex(post => post._isHighlightedPost)
const index = posts.findIndex(post => post._isHighlightedPost) if (index !== -1) {
if (index !== -1) { ref.current?.scrollToIndex({
ref.current?.scrollToIndex({ index,
index,
animated: false,
viewOffset: 40,
})
}
}
const onScrollToIndexFailed = (info: {
index: number
highestMeasuredFrameIndex: number
averageItemLength: number
}) => {
ref.current?.scrollToOffset({
animated: false, animated: false,
offset: info.averageItemLength * info.index, viewOffset: 40,
}) })
} }
}
const onScrollToIndexFailed = (info: {
index: number
highestMeasuredFrameIndex: number
averageItemLength: number
}) => {
ref.current?.scrollToOffset({
animated: false,
offset: info.averageItemLength * info.index,
})
}
// loading // loading
// = // =
if ((view.isLoading && !view.isRefreshing) || view.params.uri !== uri) { if ((view.isLoading && !view.isRefreshing) || view.params.uri !== uri) {
return (
<View>
<ActivityIndicator />
</View>
)
}
// error
// =
if (view.hasError) {
return (
<View>
<ErrorMessage
dark
message={view.error}
style={{margin: 6}}
onPressTryAgain={onRefresh}
/>
</View>
)
}
// loaded
// =
const renderItem = ({item}: {item: PostThreadViewPostModel}) => (
<PostThreadItem item={item} onPostReply={onRefresh} />
)
return ( return (
<FlatList <View>
ref={ref} <ActivityIndicator />
data={posts} </View>
keyExtractor={item => item._reactKey}
renderItem={renderItem}
refreshing={view.isRefreshing}
onRefresh={onRefresh}
onLayout={onLayout}
onScrollToIndexFailed={onScrollToIndexFailed}
style={{flex: 1}}
contentContainerStyle={{paddingBottom: 200}}
/>
) )
}), }
)
// error
// =
if (view.hasError) {
return (
<View>
<ErrorMessage
dark
message={view.error}
style={{margin: 6}}
onPressTryAgain={onRefresh}
/>
</View>
)
}
// loaded
// =
const renderItem = ({item}: {item: PostThreadViewPostModel}) => (
<PostThreadItem item={item} onPostReply={onRefresh} />
)
return (
<FlatList
ref={ref}
data={posts}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
refreshing={view.isRefreshing}
onRefresh={onRefresh}
onLayout={onLayout}
onScrollToIndexFailed={onScrollToIndexFailed}
style={{flex: 1}}
contentContainerStyle={{paddingBottom: 200}}
/>
)
})
function* flattenThread( function* flattenThread(
post: PostThreadViewPostModel, post: PostThreadViewPostModel,
+243 -252
View File
@@ -18,240 +18,95 @@ import {PostMeta} from '../util/PostMeta'
import {PostEmbeds} from '../util/PostEmbeds' import {PostEmbeds} from '../util/PostEmbeds'
import {PostCtrls} from '../util/PostCtrls' import {PostCtrls} from '../util/PostCtrls'
import {ComposePrompt} from '../composer/Prompt' import {ComposePrompt} from '../composer/Prompt'
import {register} from 'react-native-bundle-splitter'
const PARENT_REPLY_LINE_LENGTH = 8 const PARENT_REPLY_LINE_LENGTH = 8
const REPLYING_TO_LINE_LENGTH = 6 const REPLYING_TO_LINE_LENGTH = 6
export const PostThreadItem = register( export const PostThreadItem = observer(function PostThreadItem({
observer(function PostThreadItem({ item,
item, onPostReply,
onPostReply, }: {
}: { item: PostThreadViewPostModel
item: PostThreadViewPostModel onPostReply: () => void
onPostReply: () => void }) {
}) { const store = useStores()
const store = useStores() const [deleted, setDeleted] = useState(false)
const [deleted, setDeleted] = useState(false) const record = item.record as unknown as PostType.Record
const record = item.record as unknown as PostType.Record const hasEngagement = item.upvoteCount || item.repostCount
const hasEngagement = item.upvoteCount || item.repostCount
const itemHref = useMemo(() => { const itemHref = useMemo(() => {
const urip = new AtUri(item.uri) const urip = new AtUri(item.uri)
return `/profile/${item.author.handle}/post/${urip.rkey}` return `/profile/${item.author.handle}/post/${urip.rkey}`
}, [item.uri, item.author.handle]) }, [item.uri, item.author.handle])
const itemTitle = `Post by ${item.author.handle}` const itemTitle = `Post by ${item.author.handle}`
const authorHref = `/profile/${item.author.handle}` const authorHref = `/profile/${item.author.handle}`
const authorTitle = item.author.handle const authorTitle = item.author.handle
const upvotesHref = useMemo(() => { const upvotesHref = useMemo(() => {
const urip = new AtUri(item.uri) const urip = new AtUri(item.uri)
return `/profile/${item.author.handle}/post/${urip.rkey}/upvoted-by` return `/profile/${item.author.handle}/post/${urip.rkey}/upvoted-by`
}, [item.uri, item.author.handle]) }, [item.uri, item.author.handle])
const upvotesTitle = 'Upvotes on this post' const upvotesTitle = 'Upvotes on this post'
const repostsHref = useMemo(() => { const repostsHref = useMemo(() => {
const urip = new AtUri(item.uri) const urip = new AtUri(item.uri)
return `/profile/${item.author.handle}/post/${urip.rkey}/reposted-by` return `/profile/${item.author.handle}/post/${urip.rkey}/reposted-by`
}, [item.uri, item.author.handle]) }, [item.uri, item.author.handle])
const repostsTitle = 'Reposts of this post' const repostsTitle = 'Reposts of this post'
const onPressReply = () => { const onPressReply = () => {
store.shell.openComposer({ store.shell.openComposer({
replyTo: { replyTo: {
uri: item.uri, uri: item.uri,
cid: item.cid, cid: item.cid,
text: item.record.text as string, text: item.record.text as string,
author: { author: {
handle: item.author.handle, handle: item.author.handle,
displayName: item.author.displayName, displayName: item.author.displayName,
avatar: item.author.avatar, avatar: item.author.avatar,
},
}, },
onPost: onPostReply, },
}) onPost: onPostReply,
} })
const onPressToggleRepost = () => { }
item const onPressToggleRepost = () => {
.toggleRepost() item
.catch(e => console.error('Failed to toggle repost', record, e)) .toggleRepost()
} .catch(e => console.error('Failed to toggle repost', record, e))
const onPressToggleUpvote = () => { }
item const onPressToggleUpvote = () => {
.toggleUpvote() item
.catch(e => console.error('Failed to toggle upvote', record, e)) .toggleUpvote()
} .catch(e => console.error('Failed to toggle upvote', record, e))
const onCopyPostText = () => { }
Clipboard.setString(record.text) const onCopyPostText = () => {
Toast.show('Copied to clipboard') Clipboard.setString(record.text)
} Toast.show('Copied to clipboard')
const onDeletePost = () => { }
item.delete().then( const onDeletePost = () => {
() => { item.delete().then(
setDeleted(true) () => {
Toast.show('Post deleted') setDeleted(true)
}, Toast.show('Post deleted')
e => { },
console.error(e) e => {
Toast.show('Failed to delete post, please try again') console.error(e)
}, Toast.show('Failed to delete post, please try again')
) },
} )
}
if (deleted) { if (deleted) {
return ( return (
<View style={[styles.outer, s.p20, s.flexRow]}> <View style={[styles.outer, s.p20, s.flexRow]}>
<FontAwesomeIcon icon={['far', 'trash-can']} style={[s.gray4]} /> <FontAwesomeIcon icon={['far', 'trash-can']} style={[s.gray4]} />
<Text style={[s.gray5, s.ml10]}>This post has been deleted.</Text> <Text style={[s.gray5, s.ml10]}>This post has been deleted.</Text>
</View> </View>
) )
} }
if (item._isHighlightedPost) { if (item._isHighlightedPost) {
return ( return (
<> <>
<View style={styles.outer}> <View style={styles.outer}>
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<Link href={authorHref} title={authorTitle}>
<UserAvatar
size={50}
displayName={item.author.displayName}
handle={item.author.handle}
avatar={item.author.avatar}
/>
</Link>
</View>
<View style={styles.layoutContent}>
<View style={[styles.meta, {paddingTop: 5, paddingBottom: 0}]}>
<Link
style={styles.metaItem}
href={authorHref}
title={authorTitle}>
<Text style={[s.f16, s.bold]} numberOfLines={1}>
{item.author.displayName || item.author.handle}
</Text>
</Link>
<Text style={[styles.metaItem, s.f15, s.gray5]}>
&middot; {ago(item.indexedAt)}
</Text>
<View style={s.flex1} />
<PostDropdownBtn
style={styles.metaItem}
itemHref={itemHref}
itemTitle={itemTitle}
isAuthor={item.author.did === store.me.did}
onCopyPostText={onCopyPostText}
onDeletePost={onDeletePost}>
<FontAwesomeIcon
icon="ellipsis-h"
size={14}
style={[s.mt2, s.mr5]}
/>
</PostDropdownBtn>
</View>
<View style={styles.meta}>
<Link
style={styles.metaItem}
href={authorHref}
title={authorTitle}>
<Text style={[s.f15, s.gray5]} numberOfLines={1}>
@{item.author.handle}
</Text>
</Link>
</View>
</View>
</View>
<View style={[s.pl10, s.pr10, s.pb10]}>
<View
style={[
styles.postTextContainer,
styles.postTextLargeContainer,
]}>
<RichText
text={record.text}
entities={record.entities}
style={[styles.postText, styles.postTextLarge]}
/>
</View>
<PostEmbeds entities={record.entities} style={s.mb10} />
{item._isHighlightedPost && hasEngagement ? (
<View style={styles.expandedInfo}>
{item.repostCount ? (
<Link
style={styles.expandedInfoItem}
href={repostsHref}
title={repostsTitle}>
<Text style={[s.gray5, s.semiBold, s.f17]}>
<Text style={[s.bold, s.black, s.f17]}>
{item.repostCount}
</Text>{' '}
{pluralize(item.repostCount, 'repost')}
</Text>
</Link>
) : (
<></>
)}
{item.upvoteCount ? (
<Link
style={styles.expandedInfoItem}
href={upvotesHref}
title={upvotesTitle}>
<Text style={[s.gray5, s.semiBold, s.f17]}>
<Text style={[s.bold, s.black, s.f17]}>
{item.upvoteCount}
</Text>{' '}
{pluralize(item.upvoteCount, 'upvote')}
</Text>
</Link>
) : (
<></>
)}
</View>
) : (
<></>
)}
<View style={[s.pl10, s.pb5]}>
<PostCtrls
big
isReposted={!!item.myState.repost}
isUpvoted={!!item.myState.upvote}
onPressReply={onPressReply}
onPressToggleRepost={onPressToggleRepost}
onPressToggleUpvote={onPressToggleUpvote}
/>
</View>
</View>
</View>
<ComposePrompt
noAvi
text="Write your reply"
btn="Reply"
onPressCompose={onPressReply}
/>
</>
)
} else {
return (
<Link style={styles.outer} href={itemHref} title={itemTitle}>
{!item.replyingTo && item.record.reply && (
<View style={styles.parentReplyLine} />
)}
{item.replies?.length !== 0 && <View style={styles.childReplyLine} />}
{item.replyingTo ? (
<View style={styles.replyingTo}>
<View style={styles.replyingToLine} />
<View style={styles.replyingToAvatar}>
<UserAvatar
handle={item.replyingTo.author.handle}
displayName={item.replyingTo.author.displayName}
avatar={item.replyingTo.author.avatar}
size={30}
/>
</View>
<Text style={styles.replyingToText} numberOfLines={2}>
{item.replyingTo.text}
</Text>
</View>
) : undefined}
<View style={styles.layout}> <View style={styles.layout}>
<View style={styles.layoutAvi}> <View style={styles.layoutAvi}>
<Link href={authorHref} title={authorTitle}> <Link href={authorHref} title={authorTitle}>
@@ -264,32 +119,94 @@ export const PostThreadItem = register(
</Link> </Link>
</View> </View>
<View style={styles.layoutContent}> <View style={styles.layoutContent}>
<PostMeta <View style={[styles.meta, {paddingTop: 5, paddingBottom: 0}]}>
itemHref={itemHref} <Link
itemTitle={itemTitle} style={styles.metaItem}
authorHref={authorHref} href={authorHref}
authorHandle={item.author.handle} title={authorTitle}>
authorDisplayName={item.author.displayName} <Text style={[s.f16, s.bold]} numberOfLines={1}>
timestamp={item.indexedAt} {item.author.displayName || item.author.handle}
isAuthor={item.author.did === store.me.did} </Text>
onCopyPostText={onCopyPostText} </Link>
onDeletePost={onDeletePost} <Text style={[styles.metaItem, s.f15, s.gray5]}>
/> &middot; {ago(item.indexedAt)}
<View style={styles.postTextContainer}> </Text>
<RichText <View style={s.flex1} />
text={record.text} <PostDropdownBtn
entities={record.entities} style={styles.metaItem}
style={[styles.postText]} itemHref={itemHref}
/> itemTitle={itemTitle}
isAuthor={item.author.did === store.me.did}
onCopyPostText={onCopyPostText}
onDeletePost={onDeletePost}>
<FontAwesomeIcon
icon="ellipsis-h"
size={14}
style={[s.mt2, s.mr5]}
/>
</PostDropdownBtn>
</View> </View>
<PostEmbeds <View style={styles.meta}>
<Link
style={styles.metaItem}
href={authorHref}
title={authorTitle}>
<Text style={[s.f15, s.gray5]} numberOfLines={1}>
@{item.author.handle}
</Text>
</Link>
</View>
</View>
</View>
<View style={[s.pl10, s.pr10, s.pb10]}>
<View
style={[styles.postTextContainer, styles.postTextLargeContainer]}>
<RichText
text={record.text}
entities={record.entities} entities={record.entities}
style={{marginBottom: 10}} style={[styles.postText, styles.postTextLarge]}
/> />
</View>
<PostEmbeds entities={record.entities} style={s.mb10} />
{item._isHighlightedPost && hasEngagement ? (
<View style={styles.expandedInfo}>
{item.repostCount ? (
<Link
style={styles.expandedInfoItem}
href={repostsHref}
title={repostsTitle}>
<Text style={[s.gray5, s.semiBold, s.f17]}>
<Text style={[s.bold, s.black, s.f17]}>
{item.repostCount}
</Text>{' '}
{pluralize(item.repostCount, 'repost')}
</Text>
</Link>
) : (
<></>
)}
{item.upvoteCount ? (
<Link
style={styles.expandedInfoItem}
href={upvotesHref}
title={upvotesTitle}>
<Text style={[s.gray5, s.semiBold, s.f17]}>
<Text style={[s.bold, s.black, s.f17]}>
{item.upvoteCount}
</Text>{' '}
{pluralize(item.upvoteCount, 'upvote')}
</Text>
</Link>
) : (
<></>
)}
</View>
) : (
<></>
)}
<View style={[s.pl10, s.pb5]}>
<PostCtrls <PostCtrls
replyCount={item.replyCount} big
repostCount={item.repostCount}
upvoteCount={item.upvoteCount}
isReposted={!!item.myState.repost} isReposted={!!item.myState.repost}
isUpvoted={!!item.myState.upvote} isUpvoted={!!item.myState.upvote}
onPressReply={onPressReply} onPressReply={onPressReply}
@@ -298,11 +215,85 @@ export const PostThreadItem = register(
/> />
</View> </View>
</View> </View>
</Link> </View>
) <ComposePrompt
} noAvi
}), text="Write your reply"
) btn="Reply"
onPressCompose={onPressReply}
/>
</>
)
} else {
return (
<Link style={styles.outer} href={itemHref} title={itemTitle}>
{!item.replyingTo && item.record.reply && (
<View style={styles.parentReplyLine} />
)}
{item.replies?.length !== 0 && <View style={styles.childReplyLine} />}
{item.replyingTo ? (
<View style={styles.replyingTo}>
<View style={styles.replyingToLine} />
<View style={styles.replyingToAvatar}>
<UserAvatar
handle={item.replyingTo.author.handle}
displayName={item.replyingTo.author.displayName}
avatar={item.replyingTo.author.avatar}
size={30}
/>
</View>
<Text style={styles.replyingToText} numberOfLines={2}>
{item.replyingTo.text}
</Text>
</View>
) : undefined}
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<Link href={authorHref} title={authorTitle}>
<UserAvatar
size={50}
displayName={item.author.displayName}
handle={item.author.handle}
avatar={item.author.avatar}
/>
</Link>
</View>
<View style={styles.layoutContent}>
<PostMeta
itemHref={itemHref}
itemTitle={itemTitle}
authorHref={authorHref}
authorHandle={item.author.handle}
authorDisplayName={item.author.displayName}
timestamp={item.indexedAt}
isAuthor={item.author.did === store.me.did}
onCopyPostText={onCopyPostText}
onDeletePost={onDeletePost}
/>
<View style={styles.postTextContainer}>
<RichText
text={record.text}
entities={record.entities}
style={[styles.postText]}
/>
</View>
<PostEmbeds entities={record.entities} style={{marginBottom: 10}} />
<PostCtrls
replyCount={item.replyCount}
repostCount={item.repostCount}
upvoteCount={item.upvoteCount}
isReposted={!!item.myState.repost}
isUpvoted={!!item.myState.upvote}
onPressReply={onPressReply}
onPressToggleRepost={onPressToggleRepost}
onPressToggleUpvote={onPressToggleUpvote}
/>
</View>
</View>
</Link>
)
}
})
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outer: { outer: {
+60 -65
View File
@@ -10,81 +10,76 @@ import {ErrorMessage} from '../util/ErrorMessage'
import {UserAvatar} from '../util/UserAvatar' import {UserAvatar} from '../util/UserAvatar'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {s, colors} from '../../lib/styles' import {s, colors} from '../../lib/styles'
import {register} from 'react-native-bundle-splitter'
export const PostVotedBy = register( export const PostVotedBy = observer(function PostVotedBy({
observer(function PostVotedBy({ uri,
uri, direction,
direction, }: {
}: { uri: string
uri: string direction: 'up' | 'down'
direction: 'up' | 'down' }) {
}) { const store = useStores()
const store = useStores() const [view, setView] = useState<VotesViewModel | undefined>()
const [view, setView] = useState<VotesViewModel | undefined>()
useEffect(() => { useEffect(() => {
if (view?.params.uri === uri) { if (view?.params.uri === uri) {
console.log('Voted by doing nothing') console.log('Voted by doing nothing')
return // no change needed? or trigger refresh? return // no change needed? or trigger refresh?
}
console.log('Fetching voted by', uri)
const newView = new VotesViewModel(store, {uri, direction})
setView(newView)
newView
.setup()
.catch(err => console.error('Failed to fetch voted by', err))
}, [uri, view?.params.uri, store])
const onRefresh = () => {
view?.refresh()
} }
console.log('Fetching voted by', uri)
const newView = new VotesViewModel(store, {uri, direction})
setView(newView)
newView.setup().catch(err => console.error('Failed to fetch voted by', err))
}, [uri, view?.params.uri, store])
// loading const onRefresh = () => {
// = view?.refresh()
if ( }
!view ||
(view.isLoading && !view.isRefreshing) ||
view.params.uri !== uri
) {
return (
<View>
<ActivityIndicator />
</View>
)
}
// error // loading
// = // =
if (view.hasError) { if (
return ( !view ||
<View> (view.isLoading && !view.isRefreshing) ||
<ErrorMessage view.params.uri !== uri
dark ) {
message={view.error}
style={{margin: 6}}
onPressTryAgain={onRefresh}
/>
</View>
)
}
// loaded
// =
const renderItem = ({item}: {item: VotesViewItemModel}) => (
<LikedByItem item={item} />
)
return ( return (
<View> <View>
<FlatList <ActivityIndicator />
data={view.votes} </View>
keyExtractor={item => item._reactKey} )
renderItem={renderItem} }
// error
// =
if (view.hasError) {
return (
<View>
<ErrorMessage
dark
message={view.error}
style={{margin: 6}}
onPressTryAgain={onRefresh}
/> />
</View> </View>
) )
}), }
)
// loaded
// =
const renderItem = ({item}: {item: VotesViewItemModel}) => (
<LikedByItem item={item} />
)
return (
<View>
<FlatList
data={view.votes}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
/>
</View>
)
})
const LikedByItem = ({item}: {item: VotesViewItemModel}) => { const LikedByItem = ({item}: {item: VotesViewItemModel}) => {
return ( return (
+161 -168
View File
@@ -22,180 +22,173 @@ import * as Toast from '../util/Toast'
import {UserAvatar} from '../util/UserAvatar' import {UserAvatar} from '../util/UserAvatar'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {s, colors} from '../../lib/styles' import {s, colors} from '../../lib/styles'
import {register} from 'react-native-bundle-splitter'
export const Post = register( export const Post = observer(function Post({
observer(function Post({ uri,
uri, initView,
initView, style,
style, }: {
}: { uri: string
uri: string initView?: PostThreadViewModel
initView?: PostThreadViewModel style?: StyleProp<ViewStyle>
style?: StyleProp<ViewStyle> }) {
}) { const store = useStores()
const store = useStores() const [view, setView] = useState<PostThreadViewModel | undefined>(initView)
const [view, setView] = useState<PostThreadViewModel | undefined>(initView) const [deleted, setDeleted] = useState(false)
const [deleted, setDeleted] = useState(false)
useEffect(() => { useEffect(() => {
if (initView || view?.params.uri === uri) { if (initView || view?.params.uri === uri) {
return // no change needed? or trigger refresh? return // no change needed? or trigger refresh?
} }
const newView = new PostThreadViewModel(store, {uri, depth: 0}) const newView = new PostThreadViewModel(store, {uri, depth: 0})
setView(newView) setView(newView)
newView.setup().catch(err => console.error('Failed to fetch post', err)) newView.setup().catch(err => console.error('Failed to fetch post', err))
}, [initView, uri, view?.params.uri, store]) }, [initView, uri, view?.params.uri, store])
// deleted // deleted
// = // =
if (deleted) { if (deleted) {
return <View /> return <View />
} }
// loading
// =
if (!view || view.isLoading || view.params.uri !== uri) {
return (
<View>
<ActivityIndicator />
</View>
)
}
// error
// =
if (view.hasError || !view.thread) {
return (
<View>
<Text>{view.error || 'Thread not found'}</Text>
</View>
)
}
// loaded
// =
const item = view.thread
const record = view.thread?.record as unknown as PostType.Record
const itemUrip = new AtUri(item.uri)
const itemHref = `/profile/${item.author.handle}/post/${itemUrip.rkey}`
const itemTitle = `Post by ${item.author.handle}`
const authorHref = `/profile/${item.author.handle}`
const authorTitle = item.author.handle
let replyAuthorDid = ''
let replyHref = ''
if (record.reply) {
const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri)
replyAuthorDid = urip.hostname
replyHref = `/profile/${urip.hostname}/post/${urip.rkey}`
}
const onPressReply = () => {
store.shell.openComposer({
replyTo: {
uri: item.uri,
cid: item.cid,
text: item.record.text as string,
author: {
handle: item.author.handle,
displayName: item.author.displayName,
avatar: item.author.avatar,
},
},
})
}
const onPressToggleRepost = () => {
item
.toggleRepost()
.catch(e => console.error('Failed to toggle repost', record, e))
}
const onPressToggleUpvote = () => {
item
.toggleUpvote()
.catch(e => console.error('Failed to toggle upvote', record, e))
}
const onCopyPostText = () => {
Clipboard.setString(record.text)
Toast.show('Copied to clipboard')
}
const onDeletePost = () => {
item.delete().then(
() => {
setDeleted(true)
Toast.show('Post deleted')
},
e => {
console.error(e)
Toast.show('Failed to delete post, please try again')
},
)
}
// loading
// =
if (!view || view.isLoading || view.params.uri !== uri) {
return ( return (
<Link style={[styles.outer, style]} href={itemHref} title={itemTitle}> <View>
<View style={styles.layout}> <ActivityIndicator />
<View style={styles.layoutAvi}> </View>
<Link href={authorHref} title={authorTitle}>
<UserAvatar
size={50}
displayName={item.author.displayName}
handle={item.author.handle}
avatar={item.author.avatar}
/>
</Link>
</View>
<View style={styles.layoutContent}>
<PostMeta
itemHref={itemHref}
itemTitle={itemTitle}
authorHref={authorHref}
authorHandle={item.author.handle}
authorDisplayName={item.author.displayName}
timestamp={item.indexedAt}
isAuthor={item.author.did === store.me.did}
onCopyPostText={onCopyPostText}
onDeletePost={onDeletePost}
/>
{replyHref !== '' && (
<View style={[s.flexRow, s.mb2, {alignItems: 'center'}]}>
<FontAwesomeIcon
icon="reply"
size={9}
style={[s.gray4, s.mr5]}
/>
<Text style={[s.gray4, s.f12, s.mr2]}>Reply to</Text>
<Link href={replyHref} title="Parent post">
<UserInfoText
did={replyAuthorDid}
style={[s.f12, s.gray5]}
prefix="@"
/>
</Link>
</View>
)}
<View style={styles.postTextContainer}>
<RichText
text={record.text}
entities={record.entities}
style={styles.postText}
/>
</View>
<PostCtrls
replyCount={item.replyCount}
repostCount={item.repostCount}
upvoteCount={item.upvoteCount}
isReposted={!!item.myState.repost}
isUpvoted={!!item.myState.upvote}
onPressReply={onPressReply}
onPressToggleRepost={onPressToggleRepost}
onPressToggleUpvote={onPressToggleUpvote}
/>
</View>
</View>
</Link>
) )
}), }
)
// error
// =
if (view.hasError || !view.thread) {
return (
<View>
<Text>{view.error || 'Thread not found'}</Text>
</View>
)
}
// loaded
// =
const item = view.thread
const record = view.thread?.record as unknown as PostType.Record
const itemUrip = new AtUri(item.uri)
const itemHref = `/profile/${item.author.handle}/post/${itemUrip.rkey}`
const itemTitle = `Post by ${item.author.handle}`
const authorHref = `/profile/${item.author.handle}`
const authorTitle = item.author.handle
let replyAuthorDid = ''
let replyHref = ''
if (record.reply) {
const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri)
replyAuthorDid = urip.hostname
replyHref = `/profile/${urip.hostname}/post/${urip.rkey}`
}
const onPressReply = () => {
store.shell.openComposer({
replyTo: {
uri: item.uri,
cid: item.cid,
text: item.record.text as string,
author: {
handle: item.author.handle,
displayName: item.author.displayName,
avatar: item.author.avatar,
},
},
})
}
const onPressToggleRepost = () => {
item
.toggleRepost()
.catch(e => console.error('Failed to toggle repost', record, e))
}
const onPressToggleUpvote = () => {
item
.toggleUpvote()
.catch(e => console.error('Failed to toggle upvote', record, e))
}
const onCopyPostText = () => {
Clipboard.setString(record.text)
Toast.show('Copied to clipboard')
}
const onDeletePost = () => {
item.delete().then(
() => {
setDeleted(true)
Toast.show('Post deleted')
},
e => {
console.error(e)
Toast.show('Failed to delete post, please try again')
},
)
}
return (
<Link style={[styles.outer, style]} href={itemHref} title={itemTitle}>
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<Link href={authorHref} title={authorTitle}>
<UserAvatar
size={50}
displayName={item.author.displayName}
handle={item.author.handle}
avatar={item.author.avatar}
/>
</Link>
</View>
<View style={styles.layoutContent}>
<PostMeta
itemHref={itemHref}
itemTitle={itemTitle}
authorHref={authorHref}
authorHandle={item.author.handle}
authorDisplayName={item.author.displayName}
timestamp={item.indexedAt}
isAuthor={item.author.did === store.me.did}
onCopyPostText={onCopyPostText}
onDeletePost={onDeletePost}
/>
{replyHref !== '' && (
<View style={[s.flexRow, s.mb2, {alignItems: 'center'}]}>
<FontAwesomeIcon icon="reply" size={9} style={[s.gray4, s.mr5]} />
<Text style={[s.gray4, s.f12, s.mr2]}>Reply to</Text>
<Link href={replyHref} title="Parent post">
<UserInfoText
did={replyAuthorDid}
style={[s.f12, s.gray5]}
prefix="@"
/>
</Link>
</View>
)}
<View style={styles.postTextContainer}>
<RichText
text={record.text}
entities={record.entities}
style={styles.postText}
/>
</View>
<PostCtrls
replyCount={item.replyCount}
repostCount={item.repostCount}
upvoteCount={item.upvoteCount}
isReposted={!!item.myState.repost}
isUpvoted={!!item.myState.upvote}
onPressReply={onPressReply}
onPressToggleRepost={onPressToggleRepost}
onPressToggleUpvote={onPressToggleUpvote}
/>
</View>
</View>
</Link>
)
})
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outer: { outer: {
+42 -39
View File
@@ -5,50 +5,53 @@ import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
import {ErrorMessage} from '../util/ErrorMessage' import {ErrorMessage} from '../util/ErrorMessage'
import {PostModel} from '../../../state/models/post' import {PostModel} from '../../../state/models/post'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {register} from 'react-native-bundle-splitter'
export const PostText = register( export const PostText = observer(function PostText({
observer(function PostText({uri, style}: {uri: string; style?: StyleProp}) { uri,
const store = useStores() style,
const [model, setModel] = useState<PostModel | undefined>() }: {
uri: string
style?: StyleProp
}) {
const store = useStores()
const [model, setModel] = useState<PostModel | undefined>()
useEffect(() => { useEffect(() => {
if (model?.uri === uri) { if (model?.uri === uri) {
return // no change needed? or trigger refresh? return // no change needed? or trigger refresh?
}
const newModel = new PostModel(store, uri)
setModel(newModel)
newModel.setup().catch(err => console.error('Failed to fetch post', err))
}, [uri, model?.uri, store])
// loading
// =
if (!model || model.isLoading || model.uri !== uri) {
return (
<View>
<LoadingPlaceholder width="100%" height={8} style={{marginTop: 6}} />
<LoadingPlaceholder width="100%" height={8} style={{marginTop: 6}} />
<LoadingPlaceholder width={100} height={8} style={{marginTop: 6}} />
</View>
)
} }
const newModel = new PostModel(store, uri)
setModel(newModel)
newModel.setup().catch(err => console.error('Failed to fetch post', err))
}, [uri, model?.uri, store])
// error // loading
// = // =
if (model.hasError) { if (!model || model.isLoading || model.uri !== uri) {
return (
<View>
<ErrorMessage style={style} message={model.error} />
</View>
)
}
// loaded
// =
return ( return (
<View> <View>
<Text style={style}>{model.text}</Text> <LoadingPlaceholder width="100%" height={8} style={{marginTop: 6}} />
<LoadingPlaceholder width="100%" height={8} style={{marginTop: 6}} />
<LoadingPlaceholder width={100} height={8} style={{marginTop: 6}} />
</View> </View>
) )
}), }
)
// error
// =
if (model.hasError) {
return (
<View>
<ErrorMessage style={style} message={model.error} />
</View>
)
}
// loaded
// =
return (
<View>
<Text style={style}>{model.text}</Text>
</View>
)
})
+71 -74
View File
@@ -7,83 +7,80 @@ import {ErrorMessage} from '../util/ErrorMessage'
import {FeedModel} from '../../../state/models/feed-view' import {FeedModel} from '../../../state/models/feed-view'
import {FeedItem} from './FeedItem' import {FeedItem} from './FeedItem'
import {ComposePrompt} from '../composer/Prompt' import {ComposePrompt} from '../composer/Prompt'
import {register} from 'react-native-bundle-splitter'
const COMPOSE_PROMPT_ITEM = {_reactKey: '__prompt__'} const COMPOSE_PROMPT_ITEM = {_reactKey: '__prompt__'}
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
export const Feed = register( export const Feed = observer(function Feed({
observer(function Feed({ feed,
feed, style,
style, scrollElRef,
scrollElRef, onPressCompose,
onPressCompose, onPressTryAgain,
onPressTryAgain, }: {
}: { feed: FeedModel
feed: FeedModel style?: StyleProp<ViewStyle>
style?: StyleProp<ViewStyle> scrollElRef?: MutableRefObject<FlatList<any> | null>
scrollElRef?: MutableRefObject<FlatList<any> | null> onPressCompose: () => void
onPressCompose: () => void onPressTryAgain?: () => void
onPressTryAgain?: () => void }) {
}) { // TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf
// TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf // VirtualizedList: You have a large list that is slow to update - make sure your
// VirtualizedList: You have a large list that is slow to update - make sure your // renderItem function renders components that follow React performance best practices
// renderItem function renders components that follow React performance best practices // like PureComponent, shouldComponentUpdate, etc
// like PureComponent, shouldComponentUpdate, etc const renderItem = ({item}: {item: any}) => {
const renderItem = ({item}: {item: any}) => { if (item === COMPOSE_PROMPT_ITEM) {
if (item === COMPOSE_PROMPT_ITEM) { return <ComposePrompt onPressCompose={onPressCompose} />
return <ComposePrompt onPressCompose={onPressCompose} /> } else if (item === EMPTY_FEED_ITEM) {
} else if (item === EMPTY_FEED_ITEM) { return (
return ( <EmptyState
<EmptyState icon="bars"
icon="bars" message="This feed is empty!"
message="This feed is empty!" style={{paddingVertical: 40}}
style={{paddingVertical: 40}} />
/> )
) } else {
} else { return <FeedItem item={item} />
return <FeedItem item={item} />
}
} }
const onRefresh = () => { }
feed.refresh().catch(err => console.error('Failed to refresh', err)) const onRefresh = () => {
feed.refresh().catch(err => console.error('Failed to refresh', err))
}
const onEndReached = () => {
feed.loadMore().catch(err => console.error('Failed to load more', err))
}
let data
if (feed.hasLoaded) {
if (feed.isEmpty) {
data = [COMPOSE_PROMPT_ITEM, EMPTY_FEED_ITEM]
} else {
data = [COMPOSE_PROMPT_ITEM].concat(feed.feed)
} }
const onEndReached = () => { }
feed.loadMore().catch(err => console.error('Failed to load more', err)) return (
} <View style={style}>
let data {!data && <ComposePrompt onPressCompose={onPressCompose} />}
if (feed.hasLoaded) { {feed.isLoading && !data && <PostFeedLoadingPlaceholder />}
if (feed.isEmpty) { {feed.hasError && (
data = [COMPOSE_PROMPT_ITEM, EMPTY_FEED_ITEM] <ErrorMessage
} else { dark
data = [COMPOSE_PROMPT_ITEM].concat(feed.feed) message={feed.error}
} style={{margin: 6}}
} onPressTryAgain={onPressTryAgain}
return ( />
<View style={style}> )}
{!data && <ComposePrompt onPressCompose={onPressCompose} />} {feed.hasLoaded && data && (
{feed.isLoading && !data && <PostFeedLoadingPlaceholder />} <FlatList
{feed.hasError && ( ref={scrollElRef}
<ErrorMessage data={data}
dark keyExtractor={item => item._reactKey}
message={feed.error} renderItem={renderItem}
style={{margin: 6}} refreshing={feed.isRefreshing}
onPressTryAgain={onPressTryAgain} contentContainerStyle={{paddingBottom: 100}}
/> onRefresh={onRefresh}
)} onEndReached={onEndReached}
{feed.hasLoaded && data && ( />
<FlatList )}
ref={scrollElRef} </View>
data={data} )
keyExtractor={item => item._reactKey} })
renderItem={renderItem}
refreshing={feed.isRefreshing}
contentContainerStyle={{paddingBottom: 100}}
onRefresh={onRefresh}
onEndReached={onEndReached}
/>
)}
</View>
)
}),
)
+185 -190
View File
@@ -16,209 +16,204 @@ import * as Toast from '../util/Toast'
import {UserAvatar} from '../util/UserAvatar' import {UserAvatar} from '../util/UserAvatar'
import {s, colors} from '../../lib/styles' import {s, colors} from '../../lib/styles'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {register} from 'react-native-bundle-splitter'
const TOP_REPLY_LINE_LENGTH = 12 const TOP_REPLY_LINE_LENGTH = 12
const REPLYING_TO_LINE_LENGTH = 8 const REPLYING_TO_LINE_LENGTH = 8
export const FeedItem = register( export const FeedItem = observer(function FeedItem({
observer(function FeedItem({item}: {item: FeedItemModel}) { item,
const store = useStores() }: {
const [deleted, setDeleted] = useState(false) item: FeedItemModel
const record = item.record as unknown as PostType.Record }) {
const itemHref = useMemo(() => { const store = useStores()
const urip = new AtUri(item.uri) const [deleted, setDeleted] = useState(false)
return `/profile/${item.author.handle}/post/${urip.rkey}` const record = item.record as unknown as PostType.Record
}, [item.uri, item.author.handle]) const itemHref = useMemo(() => {
const itemTitle = `Post by ${item.author.handle}` const urip = new AtUri(item.uri)
const authorHref = `/profile/${item.author.handle}` return `/profile/${item.author.handle}/post/${urip.rkey}`
const replyAuthorDid = useMemo(() => { }, [item.uri, item.author.handle])
if (!record.reply) { const itemTitle = `Post by ${item.author.handle}`
return '' const authorHref = `/profile/${item.author.handle}`
} const replyAuthorDid = useMemo(() => {
const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri) if (!record.reply) return ''
return urip.hostname const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri)
}, [record.reply]) return urip.hostname
const replyHref = useMemo(() => { }, [record.reply])
if (!record.reply) { const replyHref = useMemo(() => {
return '' if (!record.reply) return ''
} const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri)
const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri) return `/profile/${urip.hostname}/post/${urip.rkey}`
return `/profile/${urip.hostname}/post/${urip.rkey}` }, [record.reply])
}, [record.reply])
const onPressReply = () => { const onPressReply = () => {
store.shell.openComposer({ store.shell.openComposer({
replyTo: { replyTo: {
uri: item.uri, uri: item.uri,
cid: item.cid, cid: item.cid,
text: item.record.text as string, text: item.record.text as string,
author: { author: {
handle: item.author.handle, handle: item.author.handle,
displayName: item.author.displayName, displayName: item.author.displayName,
avatar: item.author.avatar, avatar: item.author.avatar,
},
}, },
}) },
} })
const onPressToggleRepost = () => { }
item const onPressToggleRepost = () => {
.toggleRepost() item
.catch(e => console.error('Failed to toggle repost', record, e)) .toggleRepost()
} .catch(e => console.error('Failed to toggle repost', record, e))
const onPressToggleUpvote = () => { }
item const onPressToggleUpvote = () => {
.toggleUpvote() item
.catch(e => console.error('Failed to toggle upvote', record, e)) .toggleUpvote()
} .catch(e => console.error('Failed to toggle upvote', record, e))
const onCopyPostText = () => { }
Clipboard.setString(record.text) const onCopyPostText = () => {
Toast.show('Copied to clipboard') Clipboard.setString(record.text)
} Toast.show('Copied to clipboard')
const onDeletePost = () => { }
item.delete().then( const onDeletePost = () => {
() => { item.delete().then(
setDeleted(true) () => {
Toast.show('Post deleted') setDeleted(true)
}, Toast.show('Post deleted')
e => { },
console.error(e) e => {
Toast.show('Failed to delete post, please try again') console.error(e)
}, Toast.show('Failed to delete post, please try again')
) },
} )
}
if (deleted) { if (deleted) {
return <View /> return <View />
} }
const outerStyles = [ const outerStyles = [
styles.outer, styles.outer,
item._isThreadChild ? styles.outerNoTop : undefined, item._isThreadChild ? styles.outerNoTop : undefined,
item._isThreadParent ? styles.outerNoBottom : undefined, item._isThreadParent ? styles.outerNoBottom : undefined,
] ]
return ( return (
<Link style={outerStyles} href={itemHref} title={itemTitle}> <Link style={outerStyles} href={itemHref} title={itemTitle}>
{item._isThreadChild && <View style={styles.topReplyLine} />} {item._isThreadChild && <View style={styles.topReplyLine} />}
{item._isThreadParent && ( {item._isThreadParent && (
<View <View
style={[ style={[
styles.bottomReplyLine, styles.bottomReplyLine,
item._isThreadChild ? styles.bottomReplyLineSmallAvi : undefined, item._isThreadChild ? styles.bottomReplyLineSmallAvi : undefined,
]} ]}
/>
)}
{item.repostedBy && (
<Link
style={styles.includeReason}
href={`/profile/${item.repostedBy.handle}`}
title={item.repostedBy.displayName || item.repostedBy.handle}>
<FontAwesomeIcon icon="retweet" style={styles.includeReasonIcon} />
<Text style={[s.gray4, s.bold, s.f13]}>
Reposted by {item.repostedBy.displayName || item.repostedBy.handle}
</Text>
</Link>
)}
{item.trendedBy && (
<Link
style={styles.includeReason}
href={`/profile/${item.trendedBy.handle}`}
title={item.trendedBy.displayName || item.trendedBy.handle}>
<FontAwesomeIcon
icon="arrow-trend-up"
style={styles.includeReasonIcon}
/> />
)} <Text style={[s.gray4, s.bold, s.f13]}>
{item.repostedBy && ( Trending with {item.trendedBy.displayName || item.trendedBy.handle}
<Link </Text>
style={styles.includeReason} </Link>
href={`/profile/${item.repostedBy.handle}`} )}
title={item.repostedBy.displayName || item.repostedBy.handle}> {item.additionalParentPost ? (
<FontAwesomeIcon icon="retweet" style={styles.includeReasonIcon} /> <View style={styles.replyingTo}>
<Text style={[s.gray4, s.bold, s.f13]}> <View style={styles.replyingToLine} />
Reposted by{' '} <View style={styles.replyingToAvatar}>
{item.repostedBy.displayName || item.repostedBy.handle} <UserAvatar
</Text> handle={item.additionalParentPost?.thread?.author.handle}
</Link> displayName={
)} item.additionalParentPost?.thread?.author.displayName
{item.trendedBy && ( }
<Link avatar={item.additionalParentPost?.thread?.author.avatar}
style={styles.includeReason} size={32}
href={`/profile/${item.trendedBy.handle}`}
title={item.trendedBy.displayName || item.trendedBy.handle}>
<FontAwesomeIcon
icon="arrow-trend-up"
style={styles.includeReasonIcon}
/> />
<Text style={[s.gray4, s.bold, s.f13]}> </View>
Trending with{' '} <View style={styles.replyingToTextContainer}>
{item.trendedBy.displayName || item.trendedBy.handle} <Text style={styles.replyingToText} numberOfLines={2}>
{item.additionalParentPost?.thread?.record.text}
</Text> </Text>
</Link>
)}
{item.additionalParentPost ? (
<View style={styles.replyingTo}>
<View style={styles.replyingToLine} />
<View style={styles.replyingToAvatar}>
<UserAvatar
handle={item.additionalParentPost?.thread?.author.handle}
displayName={
item.additionalParentPost?.thread?.author.displayName
}
avatar={item.additionalParentPost?.thread?.author.avatar}
size={32}
/>
</View>
<View style={styles.replyingToTextContainer}>
<Text style={styles.replyingToText} numberOfLines={2}>
{item.additionalParentPost?.thread?.record.text}
</Text>
</View>
</View>
) : undefined}
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<Link
href={authorHref}
title={item.author.handle}
style={item._isThreadChild ? {marginLeft: 10} : undefined}>
<UserAvatar
size={item._isThreadChild ? 30 : 50}
displayName={item.author.displayName}
handle={item.author.handle}
avatar={item.author.avatar}
/>
</Link>
</View>
<View style={styles.layoutContent}>
{!item._isThreadChild ? (
<PostMeta
itemHref={itemHref}
itemTitle={itemTitle}
authorHref={authorHref}
authorHandle={item.author.handle}
authorDisplayName={item.author.displayName}
timestamp={item.indexedAt}
isAuthor={item.author.did === store.me.did}
onCopyPostText={onCopyPostText}
onDeletePost={onDeletePost}
/>
) : undefined}
{!item._isThreadChild && replyHref !== '' && (
<View style={[s.flexRow, s.mb5, {alignItems: 'center'}]}>
<Text style={[s.gray5, s.f15, s.mr2]}>Replying to</Text>
<Link href={replyHref} title="Parent post">
<UserInfoText
did={replyAuthorDid}
style={[s.f15, s.blue3]}
prefix="@"
/>
</Link>
</View>
)}
<View style={styles.postTextContainer}>
<RichText
text={record.text}
entities={record.entities}
style={styles.postText}
/>
</View>
<PostEmbeds entities={record.entities} style={{marginBottom: 10}} />
<PostCtrls
replyCount={item.replyCount}
repostCount={item.repostCount}
upvoteCount={item.upvoteCount}
isReposted={!!item.myState.repost}
isUpvoted={!!item.myState.upvote}
onPressReply={onPressReply}
onPressToggleRepost={onPressToggleRepost}
onPressToggleUpvote={onPressToggleUpvote}
/>
</View> </View>
</View> </View>
</Link> ) : undefined}
) <View style={styles.layout}>
}), <View style={styles.layoutAvi}>
) <Link
href={authorHref}
title={item.author.handle}
style={item._isThreadChild ? {marginLeft: 10} : undefined}>
<UserAvatar
size={item._isThreadChild ? 30 : 50}
displayName={item.author.displayName}
handle={item.author.handle}
avatar={item.author.avatar}
/>
</Link>
</View>
<View style={styles.layoutContent}>
{!item._isThreadChild ? (
<PostMeta
itemHref={itemHref}
itemTitle={itemTitle}
authorHref={authorHref}
authorHandle={item.author.handle}
authorDisplayName={item.author.displayName}
timestamp={item.indexedAt}
isAuthor={item.author.did === store.me.did}
onCopyPostText={onCopyPostText}
onDeletePost={onDeletePost}
/>
) : undefined}
{!item._isThreadChild && replyHref !== '' && (
<View style={[s.flexRow, s.mb5, {alignItems: 'center'}]}>
<Text style={[s.gray5, s.f15, s.mr2]}>Replying to</Text>
<Link href={replyHref} title="Parent post">
<UserInfoText
did={replyAuthorDid}
style={[s.f15, s.blue3]}
prefix="@"
/>
</Link>
</View>
)}
<View style={styles.postTextContainer}>
<RichText
text={record.text}
entities={record.entities}
style={styles.postText}
/>
</View>
<PostEmbeds entities={record.entities} style={{marginBottom: 10}} />
<PostCtrls
replyCount={item.replyCount}
repostCount={item.repostCount}
upvoteCount={item.upvoteCount}
isReposted={!!item.myState.repost}
isUpvoted={!!item.myState.upvote}
onPressReply={onPressReply}
onPressToggleRepost={onPressToggleRepost}
onPressToggleUpvote={onPressToggleUpvote}
/>
</View>
</View>
</Link>
)
})
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outer: { outer: {
+2 -3
View File
@@ -3,9 +3,8 @@ import {StyleSheet, Text, TouchableOpacity, View} from 'react-native'
import {Link} from '../util/Link' import {Link} from '../util/Link'
import {UserAvatar} from '../util/UserAvatar' import {UserAvatar} from '../util/UserAvatar'
import {s, colors} from '../../lib/styles' import {s, colors} from '../../lib/styles'
import {register} from 'react-native-bundle-splitter'
export const ProfileCard = register(function ProfileCard({ export function ProfileCard({
did, did,
handle, handle,
displayName, displayName,
@@ -49,7 +48,7 @@ export const ProfileCard = register(function ProfileCard({
</View> </View>
</Link> </Link>
) )
}) }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outer: { outer: {
+58 -57
View File
@@ -10,73 +10,74 @@ import {ErrorMessage} from '../util/ErrorMessage'
import {UserAvatar} from '../util/UserAvatar' import {UserAvatar} from '../util/UserAvatar'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {s, colors} from '../../lib/styles' import {s, colors} from '../../lib/styles'
import {register} from 'react-native-bundle-splitter'
export const ProfileFollowers = register( export const ProfileFollowers = observer(function ProfileFollowers({
observer(function ProfileFollowers({name}: {name: string}) { name,
const store = useStores() }: {
const [view, setView] = useState<UserFollowersViewModel | undefined>() name: string
}) {
const store = useStores()
const [view, setView] = useState<UserFollowersViewModel | undefined>()
useEffect(() => { useEffect(() => {
if (view?.params.user === name) { if (view?.params.user === name) {
console.log('User followers doing nothing') console.log('User followers doing nothing')
return // no change needed? or trigger refresh? return // no change needed? or trigger refresh?
}
console.log('Fetching user followers', name)
const newView = new UserFollowersViewModel(store, {user: name})
setView(newView)
newView
.setup()
.catch(err => console.error('Failed to fetch user followers', err))
}, [name, view?.params.user, store])
const onRefresh = () => {
view?.refresh()
} }
console.log('Fetching user followers', name)
const newView = new UserFollowersViewModel(store, {user: name})
setView(newView)
newView
.setup()
.catch(err => console.error('Failed to fetch user followers', err))
}, [name, view?.params.user, store])
// loading const onRefresh = () => {
// = view?.refresh()
if ( }
!view ||
(view.isLoading && !view.isRefreshing) ||
view.params.user !== name
) {
return (
<View>
<ActivityIndicator />
</View>
)
}
// error // loading
// = // =
if (view.hasError) { if (
return ( !view ||
<View> (view.isLoading && !view.isRefreshing) ||
<ErrorMessage view.params.user !== name
dark ) {
message={view.error}
style={{margin: 6}}
onPressTryAgain={onRefresh}
/>
</View>
)
}
// loaded
// =
const renderItem = ({item}: {item: FollowerItem}) => <User item={item} />
return ( return (
<View> <View>
<FlatList <ActivityIndicator />
data={view.followers} </View>
keyExtractor={item => item._reactKey} )
renderItem={renderItem} }
// error
// =
if (view.hasError) {
return (
<View>
<ErrorMessage
dark
message={view.error}
style={{margin: 6}}
onPressTryAgain={onRefresh}
/> />
</View> </View>
) )
}), }
)
// loaded
// =
const renderItem = ({item}: {item: FollowerItem}) => <User item={item} />
return (
<View>
<FlatList
data={view.followers}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
/>
</View>
)
})
const User = ({item}: {item: FollowerItem}) => { const User = ({item}: {item: FollowerItem}) => {
return ( return (
+58 -57
View File
@@ -10,73 +10,74 @@ import {Link} from '../util/Link'
import {ErrorMessage} from '../util/ErrorMessage' import {ErrorMessage} from '../util/ErrorMessage'
import {UserAvatar} from '../util/UserAvatar' import {UserAvatar} from '../util/UserAvatar'
import {s, colors} from '../../lib/styles' import {s, colors} from '../../lib/styles'
import {register} from 'react-native-bundle-splitter'
export const ProfileFollows = register( export const ProfileFollows = observer(function ProfileFollows({
observer(function ProfileFollows({name}: {name: string}) { name,
const store = useStores() }: {
const [view, setView] = useState<UserFollowsViewModel | undefined>() name: string
}) {
const store = useStores()
const [view, setView] = useState<UserFollowsViewModel | undefined>()
useEffect(() => { useEffect(() => {
if (view?.params.user === name) { if (view?.params.user === name) {
console.log('User follows doing nothing') console.log('User follows doing nothing')
return // no change needed? or trigger refresh? return // no change needed? or trigger refresh?
}
console.log('Fetching user follows', name)
const newView = new UserFollowsViewModel(store, {user: name})
setView(newView)
newView
.setup()
.catch(err => console.error('Failed to fetch user follows', err))
}, [name, view?.params.user, store])
const onRefresh = () => {
view?.refresh()
} }
console.log('Fetching user follows', name)
const newView = new UserFollowsViewModel(store, {user: name})
setView(newView)
newView
.setup()
.catch(err => console.error('Failed to fetch user follows', err))
}, [name, view?.params.user, store])
// loading const onRefresh = () => {
// = view?.refresh()
if ( }
!view ||
(view.isLoading && !view.isRefreshing) ||
view.params.user !== name
) {
return (
<View>
<ActivityIndicator />
</View>
)
}
// error // loading
// = // =
if (view.hasError) { if (
return ( !view ||
<View> (view.isLoading && !view.isRefreshing) ||
<ErrorMessage view.params.user !== name
dark ) {
message={view.error}
style={{margin: 6}}
onPressTryAgain={onRefresh}
/>
</View>
)
}
// loaded
// =
const renderItem = ({item}: {item: FollowItem}) => <User item={item} />
return ( return (
<View> <View>
<FlatList <ActivityIndicator />
data={view.follows} </View>
keyExtractor={item => item._reactKey} )
renderItem={renderItem} }
// error
// =
if (view.hasError) {
return (
<View>
<ErrorMessage
dark
message={view.error}
style={{margin: 6}}
onPressTryAgain={onRefresh}
/> />
</View> </View>
) )
}), }
)
// loaded
// =
const renderItem = ({item}: {item: FollowItem}) => <User item={item} />
return (
<View>
<FlatList
data={view.follows}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
/>
</View>
)
})
const User = ({item}: {item: FollowItem}) => { const User = ({item}: {item: FollowItem}) => {
return ( return (
+262 -268
View File
@@ -21,298 +21,292 @@ import {RichText} from '../util/RichText'
import {UserAvatar} from '../util/UserAvatar' import {UserAvatar} from '../util/UserAvatar'
import {UserBanner} from '../util/UserBanner' import {UserBanner} from '../util/UserBanner'
import {UserInfoText} from '../util/UserInfoText' import {UserInfoText} from '../util/UserInfoText'
import {register} from 'react-native-bundle-splitter'
export const ProfileHeader = register( export const ProfileHeader = observer(function ProfileHeader({
observer(function ProfileHeader({ view,
view, onRefreshAll,
onRefreshAll, }: {
}: { view: ProfileViewModel
view: ProfileViewModel onRefreshAll: () => void
onRefreshAll: () => void }) {
}) { const store = useStores()
const store = useStores() const isMember = useMemo(
const isMember = useMemo( () => view.isScene && view.myState.member,
() => view.isScene && view.myState.member, [view.myState.member],
[view.myState.member], )
const onPressBack = () => {
store.nav.tab.goBack()
}
const onPressSearch = () => {
store.nav.navigate(`/search`)
}
const onPressToggleFollow = () => {
view?.toggleFollowing().then(
() => {
Toast.show(
`${view.myState.follow ? 'Following' : 'No longer following'} ${
view.displayName || view.handle
}`,
)
},
err => console.error('Failed to toggle follow', err),
) )
}
const onPressEditProfile = () => {
store.shell.openModal(new EditProfileModel(view, onRefreshAll))
}
const onPressFollowers = () => {
store.nav.navigate(`/profile/${view.handle}/followers`)
}
const onPressFollows = () => {
store.nav.navigate(`/profile/${view.handle}/follows`)
}
const onPressMembers = () => {
store.nav.navigate(`/profile/${view.handle}/members`)
}
const onPressInviteMembers = () => {
store.shell.openModal(new InviteToSceneModel(view))
}
const onPressLeaveScene = () => {
store.shell.openModal(
new ConfirmModel(
'Leave this scene?',
`You'll be able to come back unless your invite is revoked.`,
onPressConfirmLeaveScene,
),
)
}
const onPressConfirmLeaveScene = async () => {
if (view.myState.member) {
await store.api.app.bsky.graph.confirmation.delete({
did: store.me.did || '',
rkey: new AtUri(view.myState.member).rkey,
})
Toast.show(`Scene left`)
}
onRefreshAll()
}
const onPressBack = () => { // loading
store.nav.tab.goBack() // =
} if (!view || !view.hasLoaded) {
const onPressSearch = () => {
store.nav.navigate('/search')
}
const onPressToggleFollow = () => {
view?.toggleFollowing().then(
() => {
Toast.show(
`${view.myState.follow ? 'Following' : 'No longer following'} ${
view.displayName || view.handle
}`,
)
},
err => console.error('Failed to toggle follow', err),
)
}
const onPressEditProfile = () => {
store.shell.openModal(new EditProfileModel(view, onRefreshAll))
}
const onPressFollowers = () => {
store.nav.navigate(`/profile/${view.handle}/followers`)
}
const onPressFollows = () => {
store.nav.navigate(`/profile/${view.handle}/follows`)
}
const onPressMembers = () => {
store.nav.navigate(`/profile/${view.handle}/members`)
}
const onPressInviteMembers = () => {
store.shell.openModal(new InviteToSceneModel(view))
}
const onPressLeaveScene = () => {
store.shell.openModal(
new ConfirmModel(
'Leave this scene?',
"You'll be able to come back unless your invite is revoked.",
onPressConfirmLeaveScene,
),
)
}
const onPressConfirmLeaveScene = async () => {
if (view.myState.member) {
await store.api.app.bsky.graph.confirmation.delete({
did: store.me.did || '',
rkey: new AtUri(view.myState.member).rkey,
})
Toast.show('Scene left')
}
onRefreshAll()
}
// loading
// =
if (!view || !view.hasLoaded) {
return (
<View style={styles.outer}>
<LoadingPlaceholder width="100%" height={120} />
<View style={styles.avi}>
<LoadingPlaceholder
width={80}
height={80}
style={{borderRadius: 40}}
/>
</View>
<View style={styles.content}>
<View style={[styles.buttonsLine]}>
<LoadingPlaceholder
width={100}
height={31}
style={{borderRadius: 50}}
/>
</View>
<View style={styles.displayNameLine}>
<Text style={styles.displayName}>
{view.displayName || view.handle}
</Text>
</View>
</View>
</View>
)
}
// error
// =
if (view.hasError) {
return (
<View>
<Text>{view.error}</Text>
</View>
)
}
// loaded
// =
const gradient = getGradient(view.handle)
const isMe = store.me.did === view.did
const isCreator = view.isScene && view.creator === store.me.did
let dropdownItems: DropdownItem[] | undefined
if (isCreator || isMember) {
dropdownItems = []
if (isCreator) {
dropdownItems.push({
label: 'Edit Profile',
onPress: onPressEditProfile,
})
}
if (isMember) {
dropdownItems.push({
label: 'Leave Scene...',
onPress: onPressLeaveScene,
})
}
}
return ( return (
<View style={styles.outer}> <View style={styles.outer}>
<UserBanner handle={view.handle} userBanner={view.userBanner} /> <LoadingPlaceholder width="100%" height={120} />
<View style={styles.avi}> <View style={styles.avi}>
<UserAvatar <LoadingPlaceholder
size={80} width={80}
handle={view.handle} height={80}
displayName={view.displayName} style={{borderRadius: 40}}
avatar={view.avatar}
/> />
</View> </View>
<View style={styles.content}> <View style={styles.content}>
<View style={[styles.buttonsLine]}> <View style={[styles.buttonsLine]}>
{isMe ? ( <LoadingPlaceholder
<TouchableOpacity width={100}
onPress={onPressEditProfile} height={31}
style={[styles.btn, styles.mainBtn]}> style={{borderRadius: 50}}
<Text style={[s.fw400, s.f14]}>Edit Profile</Text> />
</TouchableOpacity>
) : (
<>
{view.myState.follow ? (
<TouchableOpacity
onPress={onPressToggleFollow}
style={[styles.btn, styles.mainBtn]}>
<FontAwesomeIcon icon="check" style={[s.mr5]} size={14} />
<Text style={[s.fw400, s.f14]}>Following</Text>
</TouchableOpacity>
) : (
<TouchableOpacity onPress={onPressToggleFollow}>
<LinearGradient
colors={[gradient[1], gradient[0]]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.btn, styles.gradientBtn]}>
<FontAwesomeIcon icon="plus" style={[s.white, s.mr5]} />
<Text style={[s.white, s.fw600, s.f16]}>Follow</Text>
</LinearGradient>
</TouchableOpacity>
)}
</>
)}
{view.isScene &&
(view.myState.member || view.creator === store.me.did) ? (
<DropdownBtn
items={dropdownItems}
style={[styles.btn, styles.secondaryBtn]}>
<FontAwesomeIcon icon="ellipsis" style={[s.gray5]} />
</DropdownBtn>
) : undefined}
</View> </View>
<View style={styles.displayNameLine}> <View style={styles.displayNameLine}>
<Text style={styles.displayName}> <Text style={styles.displayName}>
{view.displayName || view.handle} {view.displayName || view.handle}
</Text> </Text>
</View> </View>
<View style={styles.handleLine}> </View>
{view.isScene ? ( </View>
<View style={styles.typeLabelWrapper}> )
<Text style={styles.typeLabel}>Scene</Text> }
</View>
) : undefined} // error
<Text style={styles.handle}>@{view.handle}</Text> // =
</View> if (view.hasError) {
<View style={styles.metricsLine}> return (
<View>
<Text>{view.error}</Text>
</View>
)
}
// loaded
// =
const gradient = getGradient(view.handle)
const isMe = store.me.did === view.did
const isCreator = view.isScene && view.creator === store.me.did
let dropdownItems: DropdownItem[] | undefined
if (isCreator || isMember) {
dropdownItems = []
if (isCreator) {
dropdownItems.push({
label: 'Edit Profile',
onPress: onPressEditProfile,
})
}
if (isMember) {
dropdownItems.push({
label: 'Leave Scene...',
onPress: onPressLeaveScene,
})
}
}
return (
<View style={styles.outer}>
<UserBanner handle={view.handle} userBanner={view.userBanner} />
<View style={styles.avi}>
<UserAvatar
size={80}
handle={view.handle}
displayName={view.displayName}
avatar={view.avatar}
/>
</View>
<View style={styles.content}>
<View style={[styles.buttonsLine]}>
{isMe ? (
<TouchableOpacity <TouchableOpacity
style={[s.flexRow, s.mr10]} onPress={onPressEditProfile}
onPress={onPressFollowers}> style={[styles.btn, styles.mainBtn]}>
<Text style={[s.bold, s.mr2, styles.metricsText]}> <Text style={[s.fw400, s.f14]}>Edit Profile</Text>
{view.followersCount}
</Text>
<Text style={[s.gray5, styles.metricsText]}>
{pluralize(view.followersCount, 'follower')}
</Text>
</TouchableOpacity> </TouchableOpacity>
{view.isUser ? ( ) : (
<TouchableOpacity <>
style={[s.flexRow, s.mr10]} {view.myState.follow ? (
onPress={onPressFollows}> <TouchableOpacity
<Text style={[s.bold, s.mr2, styles.metricsText]}> onPress={onPressToggleFollow}
{view.followsCount} style={[styles.btn, styles.mainBtn]}>
</Text> <FontAwesomeIcon icon="check" style={[s.mr5]} size={14} />
<Text style={[s.gray5, styles.metricsText]}>following</Text> <Text style={[s.fw400, s.f14]}>Following</Text>
</TouchableOpacity> </TouchableOpacity>
) : undefined} ) : (
{view.isScene ? ( <TouchableOpacity onPress={onPressToggleFollow}>
<TouchableOpacity <LinearGradient
style={[s.flexRow, s.mr10]} colors={[gradient[1], gradient[0]]}
onPress={onPressMembers}> start={{x: 0, y: 0}}
<Text style={[s.bold, s.mr2, styles.metricsText]}> end={{x: 1, y: 1}}
{view.membersCount} style={[styles.btn, styles.gradientBtn]}>
</Text> <FontAwesomeIcon icon="plus" style={[s.white, s.mr5]} />
<Text style={[s.gray5, styles.metricsText]}> <Text style={[s.white, s.fw600, s.f16]}>Follow</Text>
{pluralize(view.membersCount, 'member')} </LinearGradient>
</Text> </TouchableOpacity>
</TouchableOpacity> )}
) : undefined} </>
<View style={[s.flexRow, s.mr10]}> )}
<Text style={[s.bold, s.mr2, styles.metricsText]}> {view.isScene &&
{view.postsCount} (view.myState.member || view.creator === store.me.did) ? (
</Text> <DropdownBtn
<Text style={[s.gray5, styles.metricsText]}> items={dropdownItems}
{pluralize(view.postsCount, 'post')} style={[styles.btn, styles.secondaryBtn]}>
</Text> <FontAwesomeIcon icon="ellipsis" style={[s.gray5]} />
</View> </DropdownBtn>
</View>
{view.description ? (
<RichText
style={styles.description}
numberOfLines={3}
text={view.description}
entities={view.descriptionEntities}
/>
) : undefined}
{view.isScene && view.creator ? (
<View style={styles.relationshipsLine}>
<FontAwesomeIcon
icon={['far', 'user']}
style={[s.gray5, s.mr5]}
/>
<Text style={[s.mr2, s.gray5, s.f15]}>Created by</Text>
<UserInfoText
style={[s.blue3, s.f15]}
did={view.creator}
prefix="@"
asLink
/>
</View>
) : undefined}
{view.isScene && view.myState.member ? (
<View style={styles.relationshipsLine}>
<FontAwesomeIcon
icon={['far', 'circle-check']}
style={[s.gray5, s.mr5]}
/>
<Text style={[s.mr2, s.gray5, s.f15]}>You are a member</Text>
</View>
) : undefined} ) : undefined}
</View> </View>
{view.isScene && view.creator === store.me.did ? ( <View style={styles.displayNameLine}>
<View style={styles.sceneAdminContainer}> <Text style={styles.displayName}>
<TouchableOpacity onPress={onPressInviteMembers}> {view.displayName || view.handle}
<LinearGradient </Text>
colors={[gradient[1], gradient[0]]} </View>
start={{x: 0, y: 0}} <View style={styles.handleLine}>
end={{x: 1, y: 1}} {view.isScene ? (
style={[styles.btn, styles.gradientBtn, styles.sceneAdminBtn]}> <View style={styles.typeLabelWrapper}>
<FontAwesomeIcon <Text style={styles.typeLabel}>Scene</Text>
icon="user-plus" </View>
style={[s.mr5, s.white]} ) : undefined}
size={15} <Text style={styles.handle}>@{view.handle}</Text>
/> </View>
<Text style={[s.bold, s.f15, s.white]}>Invite Members</Text> <View style={styles.metricsLine}>
</LinearGradient> <TouchableOpacity
style={[s.flexRow, s.mr10]}
onPress={onPressFollowers}>
<Text style={[s.bold, s.mr2, styles.metricsText]}>
{view.followersCount}
</Text>
<Text style={[s.gray5, styles.metricsText]}>
{pluralize(view.followersCount, 'follower')}
</Text>
</TouchableOpacity>
{view.isUser ? (
<TouchableOpacity
style={[s.flexRow, s.mr10]}
onPress={onPressFollows}>
<Text style={[s.bold, s.mr2, styles.metricsText]}>
{view.followsCount}
</Text>
<Text style={[s.gray5, styles.metricsText]}>following</Text>
</TouchableOpacity> </TouchableOpacity>
) : undefined}
{view.isScene ? (
<TouchableOpacity
style={[s.flexRow, s.mr10]}
onPress={onPressMembers}>
<Text style={[s.bold, s.mr2, styles.metricsText]}>
{view.membersCount}
</Text>
<Text style={[s.gray5, styles.metricsText]}>
{pluralize(view.membersCount, 'member')}
</Text>
</TouchableOpacity>
) : undefined}
<View style={[s.flexRow, s.mr10]}>
<Text style={[s.bold, s.mr2, styles.metricsText]}>
{view.postsCount}
</Text>
<Text style={[s.gray5, styles.metricsText]}>
{pluralize(view.postsCount, 'post')}
</Text>
</View>
</View>
{view.description ? (
<RichText
style={styles.description}
numberOfLines={3}
text={view.description}
entities={view.descriptionEntities}
/>
) : undefined}
{view.isScene && view.creator ? (
<View style={styles.relationshipsLine}>
<FontAwesomeIcon icon={['far', 'user']} style={[s.gray5, s.mr5]} />
<Text style={[s.mr2, s.gray5, s.f15]}>Created by</Text>
<UserInfoText
style={[s.blue3, s.f15]}
did={view.creator}
prefix="@"
asLink
/>
</View>
) : undefined}
{view.isScene && view.myState.member ? (
<View style={styles.relationshipsLine}>
<FontAwesomeIcon
icon={['far', 'circle-check']}
style={[s.gray5, s.mr5]}
/>
<Text style={[s.mr2, s.gray5, s.f15]}>You are a member</Text>
</View> </View>
) : undefined} ) : undefined}
</View> </View>
) {view.isScene && view.creator === store.me.did ? (
}), <View style={styles.sceneAdminContainer}>
) <TouchableOpacity onPress={onPressInviteMembers}>
<LinearGradient
colors={[gradient[1], gradient[0]]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.btn, styles.gradientBtn, styles.sceneAdminBtn]}>
<FontAwesomeIcon
icon="user-plus"
style={[s.mr5, s.white]}
size={15}
/>
<Text style={[s.bold, s.f15, s.white]}>Invite Members</Text>
</LinearGradient>
</TouchableOpacity>
</View>
) : undefined}
</View>
)
})
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outer: { outer: {
+63 -64
View File
@@ -5,77 +5,76 @@ import {MembersViewModel, MemberItem} from '../../../state/models/members-view'
import {ProfileCard} from './ProfileCard' import {ProfileCard} from './ProfileCard'
import {ErrorMessage} from '../util/ErrorMessage' import {ErrorMessage} from '../util/ErrorMessage'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {register} from 'react-native-bundle-splitter'
export const ProfileMembers = register( export const ProfileMembers = observer(function ProfileMembers({
observer(function ProfileMembers({name}: {name: string}) { name,
const store = useStores() }: {
const [view, setView] = useState<MembersViewModel | undefined>() name: string
}) {
const store = useStores()
const [view, setView] = useState<MembersViewModel | undefined>()
useEffect(() => { useEffect(() => {
if (view?.params.actor === name) { if (view?.params.actor === name) {
console.log('Members doing nothing') console.log('Members doing nothing')
return // no change needed? or trigger refresh? return // no change needed? or trigger refresh?
}
console.log('Fetching members', name)
const newView = new MembersViewModel(store, {actor: name})
setView(newView)
newView
.setup()
.catch(err => console.error('Failed to fetch members', err))
}, [name, view?.params.actor, store])
const onRefresh = () => {
view?.refresh()
} }
console.log('Fetching members', name)
const newView = new MembersViewModel(store, {actor: name})
setView(newView)
newView.setup().catch(err => console.error('Failed to fetch members', err))
}, [name, view?.params.actor, store])
// loading const onRefresh = () => {
// = view?.refresh()
if ( }
!view ||
(view.isLoading && !view.isRefreshing) ||
view.params.actor !== name
) {
return (
<View>
<ActivityIndicator />
</View>
)
}
// error // loading
// = // =
if (view.hasError) { if (
return ( !view ||
<View> (view.isLoading && !view.isRefreshing) ||
<ErrorMessage view.params.actor !== name
dark ) {
message={view.error}
style={{margin: 6}}
onPressTryAgain={onRefresh}
/>
</View>
)
}
// loaded
// =
const renderItem = ({item}: {item: MemberItem}) => (
<ProfileCard
did={item.did}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
/>
)
return ( return (
<View> <View>
<FlatList <ActivityIndicator />
data={view.members} </View>
keyExtractor={item => item._reactKey} )
renderItem={renderItem} }
// error
// =
if (view.hasError) {
return (
<View>
<ErrorMessage
dark
message={view.error}
style={{margin: 6}}
onPressTryAgain={onRefresh}
/> />
</View> </View>
) )
}), }
)
// loaded
// =
const renderItem = ({item}: {item: MemberItem}) => (
<ProfileCard
did={item.did}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
/>
)
return (
<View>
<FlatList
data={view.members}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
/>
</View>
)
})
+44 -47
View File
@@ -17,7 +17,6 @@ import {toShareUrl} from '../../../lib/strings'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {ConfirmModel} from '../../../state/models/shell-ui' import {ConfirmModel} from '../../../state/models/shell-ui'
import {TABS_ENABLED} from '../../../build-flags' import {TABS_ENABLED} from '../../../build-flags'
import {register} from 'react-native-bundle-splitter'
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
@@ -27,54 +26,52 @@ export interface DropdownItem {
onPress: () => void onPress: () => void
} }
export const DropdownBtn = register( export function DropdownBtn({
({ style,
style, items,
items, menuWidth,
menuWidth, children,
children, }: {
}: { style?: StyleProp<ViewStyle>
style?: StyleProp<ViewStyle> items: DropdownItem[]
items: DropdownItem[] menuWidth?: number
menuWidth?: number children?: React.ReactNode
children?: React.ReactNode }) {
}) => { const ref = useRef<TouchableOpacity>(null)
const ref = useRef<TouchableOpacity>(null)
const onPress = () => { const onPress = () => {
ref.current?.measure( ref.current?.measure(
( (
_x: number, _x: number,
_y: number, _y: number,
width: number, width: number,
height: number, height: number,
pageX: number, pageX: number,
pageY: number, pageY: number,
) => { ) => {
if (!menuWidth) { if (!menuWidth) {
menuWidth = 200 menuWidth = 200
} }
createDropdownMenu( createDropdownMenu(
pageX + width - menuWidth, pageX + width - menuWidth,
pageY + height, pageY + height,
menuWidth, menuWidth,
items, items,
) )
}, },
)
}
return (
<TouchableOpacity
style={style}
onPress={onPress}
hitSlop={HITSLOP}
ref={ref}>
{children}
</TouchableOpacity>
) )
}, }
)
return (
<TouchableOpacity
style={style}
onPress={onPress}
hitSlop={HITSLOP}
ref={ref}>
{children}
</TouchableOpacity>
)
}
export function PostDropdownBtn({ export function PostDropdownBtn({
style, style,
+21 -23
View File
@@ -5,30 +5,28 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {UserGroupIcon} from '../../lib/icons' import {UserGroupIcon} from '../../lib/icons'
import {colors} from '../../lib/styles' import {colors} from '../../lib/styles'
export const EmptyState = register( export function EmptyState({
({ icon,
icon, message,
message, style,
style, }: {
}: { icon: IconProp | 'user-group'
icon: IconProp | 'user-group' message: string
message: string style?: StyleProp<ViewStyle>
style?: StyleProp<ViewStyle> }) {
}) => { return (
return ( <View style={[styles.container, style]}>
<View style={[styles.container, style]}> <View style={styles.iconContainer}>
<View style={styles.iconContainer}> {icon === 'user-group' ? (
{icon === 'user-group' ? ( <UserGroupIcon size="64" style={styles.icon} />
<UserGroupIcon size="64" style={styles.icon} /> ) : (
) : ( <FontAwesomeIcon icon={icon} size={64} style={styles.icon} />
<FontAwesomeIcon icon={icon} size={64} style={styles.icon} /> )}
)}
</View>
<Text style={styles.text}>{message}</Text>
</View> </View>
) <Text style={styles.text}>{message}</Text>
}, </View>
) )
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
+47 -51
View File
@@ -10,62 +10,58 @@ import {
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import LinearGradient from 'react-native-linear-gradient' import LinearGradient from 'react-native-linear-gradient'
import {colors, gradients} from '../../lib/styles' import {colors, gradients} from '../../lib/styles'
import {register} from 'react-native-bundle-splitter'
export const ErrorMessage = register( export function ErrorMessage({
({ message,
message, numberOfLines,
numberOfLines, dark,
dark, style,
style, onPressTryAgain,
onPressTryAgain, }: {
}: { message: string
message: string numberOfLines?: number
numberOfLines?: number dark?: boolean
dark?: boolean style?: StyleProp<ViewStyle>
style?: StyleProp<ViewStyle> onPressTryAgain?: () => void
onPressTryAgain?: () => void }) {
}) => { const inner = (
const inner = ( <>
<> <View style={[styles.errorIcon, dark ? styles.darkErrorIcon : undefined]}>
<View <FontAwesomeIcon
style={[styles.errorIcon, dark ? styles.darkErrorIcon : undefined]}> icon="exclamation"
style={{color: dark ? colors.red3 : colors.white}}
size={16}
/>
</View>
<Text
style={[styles.message, dark ? styles.darkMessage : undefined]}
numberOfLines={numberOfLines}>
{message}
</Text>
{onPressTryAgain && (
<TouchableOpacity style={styles.btn} onPress={onPressTryAgain}>
<FontAwesomeIcon <FontAwesomeIcon
icon="exclamation" icon="arrows-rotate"
style={{color: dark ? colors.red3 : colors.white}} style={{color: dark ? colors.white : colors.red4}}
size={16} size={16}
/> />
</View> </TouchableOpacity>
<Text )}
style={[styles.message, dark ? styles.darkMessage : undefined]} </>
numberOfLines={numberOfLines}> )
{message} if (dark) {
</Text> return (
{onPressTryAgain && ( <LinearGradient
<TouchableOpacity style={styles.btn} onPress={onPressTryAgain}> colors={[gradients.error.start, gradients.error.end]}
<FontAwesomeIcon start={{x: 0.5, y: 0}}
icon="arrows-rotate" end={{x: 1, y: 1}}
style={{color: dark ? colors.white : colors.red4}} style={[styles.outer, style]}>
size={16} {inner}
/> </LinearGradient>
</TouchableOpacity>
)}
</>
) )
if (dark) { }
return ( return <View style={[styles.outer, style]}>{inner}</View>
<LinearGradient }
colors={[gradients.error.start, gradients.error.end]}
start={{x: 0.5, y: 0}}
end={{x: 1, y: 1}}
style={[styles.outer, style]}>
{inner}
</LinearGradient>
)
}
return <View style={[styles.outer, style]}>{inner}</View>
},
)
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outer: { outer: {
+38 -41
View File
@@ -2,50 +2,47 @@ import React from 'react'
import {StyleSheet, Text, TouchableOpacity, View} from 'react-native' import {StyleSheet, Text, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {colors} from '../../lib/styles' import {colors} from '../../lib/styles'
import {register} from 'react-native-bundle-splitter'
export const ErrorScreen = register( export function ErrorScreen({
({ title,
title, message,
message, details,
details, onPressTryAgain,
onPressTryAgain, }: {
}: { title: string
title: string message: string
message: string details?: string
details?: string onPressTryAgain?: () => void
onPressTryAgain?: () => void }) {
}) => { return (
return ( <View style={styles.outer}>
<View style={styles.outer}> <View style={styles.errorIconContainer}>
<View style={styles.errorIconContainer}> <View style={styles.errorIcon}>
<View style={styles.errorIcon}> <FontAwesomeIcon
<FontAwesomeIcon icon="exclamation"
icon="exclamation" style={{color: colors.white}}
style={{color: colors.white}} size={24}
size={24} />
/>
</View>
</View> </View>
<Text style={styles.title}>{title}</Text>
<Text style={styles.message}>{message}</Text>
{details && <Text style={styles.details}>{details}</Text>}
{onPressTryAgain && (
<View style={styles.btnContainer}>
<TouchableOpacity style={styles.btn} onPress={onPressTryAgain}>
<FontAwesomeIcon
icon="arrows-rotate"
style={{color: colors.white}}
size={16}
/>
<Text style={styles.btnText}>Try again</Text>
</TouchableOpacity>
</View>
)}
</View> </View>
) <Text style={styles.title}>{title}</Text>
}, <Text style={styles.message}>{message}</Text>
) {details && <Text style={styles.details}>{details}</Text>}
{onPressTryAgain && (
<View style={styles.btnContainer}>
<TouchableOpacity style={styles.btn} onPress={onPressTryAgain}>
<FontAwesomeIcon
icon="arrows-rotate"
style={{color: colors.white}}
size={16}
/>
<Text style={styles.btnText}>Try again</Text>
</TouchableOpacity>
</View>
)}
</View>
)
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outer: { outer: {
+28 -31
View File
@@ -10,38 +10,35 @@ import {
} from 'react-native' } from 'react-native'
import {useStores, RootStoreModel} from '../../../state' import {useStores, RootStoreModel} from '../../../state'
import {convertBskyAppUrlIfNeeded} from '../../../lib/strings' import {convertBskyAppUrlIfNeeded} from '../../../lib/strings'
import {register} from 'react-native-bundle-splitter'
export const Link = register( export const Link = observer(function Link({
observer(function Link({ style,
style, href,
href, title,
title, children,
children, }: {
}: { style?: StyleProp<ViewStyle>
style?: StyleProp<ViewStyle> href: string
href: string title?: string
title?: string children?: React.ReactNode
children?: React.ReactNode }) {
}) { const store = useStores()
const store = useStores() const onPress = () => {
const onPress = () => { handleLink(store, href, false)
handleLink(store, href, false) }
} const onLongPress = () => {
const onLongPress = () => { handleLink(store, href, true)
handleLink(store, href, true) }
} return (
return ( <TouchableOpacity
<TouchableOpacity style={style}
style={style} onPress={onPress}
onPress={onPress} onLongPress={onLongPress}
onLongPress={onLongPress} delayPressIn={50}>
delayPressIn={50}> {children ? children : <Text>{title || 'link'}</Text>}
{children ? children : <Text>{title || 'link'}</Text>} </TouchableOpacity>
</TouchableOpacity> )
) })
}),
)
export const TextLink = observer(function Link({ export const TextLink = observer(function Link({
style, style,
+31 -33
View File
@@ -4,39 +4,37 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {UpIcon} from '../../lib/icons' import {UpIcon} from '../../lib/icons'
import {s, colors} from '../../lib/styles' import {s, colors} from '../../lib/styles'
export const LoadingPlaceholder = register( export function LoadingPlaceholder({
({ width,
width, height,
height, style,
style, }: {
}: { width: string | number
width: string | number height: string | number
height: string | number style?: StyleProp<ViewStyle>
style?: StyleProp<ViewStyle> }) {
}) => { return (
return ( <View
style={[
{
width,
height,
backgroundColor: '#e7e9ea',
borderRadius: 6,
overflow: 'hidden',
},
style,
]}>
<View <View
style={[ style={{
{ width,
width, height,
height, backgroundColor: '#e7e9ea',
backgroundColor: '#e7e9ea', }}
borderRadius: 6, />
overflow: 'hidden', </View>
}, )
style, }
]}>
<View
style={{
width,
height,
backgroundColor: '#e7e9ea',
}}
/>
</View>
)
},
)
export function PostLoadingPlaceholder({ export function PostLoadingPlaceholder({
style, style,
@@ -65,7 +63,7 @@ export function PostLoadingPlaceholder({
<View style={s.flex1}> <View style={s.flex1}>
<UpIcon style={s.gray3} size={17} strokeWidth={1.7} /> <UpIcon style={s.gray3} size={17} strokeWidth={1.7} />
</View> </View>
<View style={s.flex1} /> <View style={s.flex1}></View>
</View> </View>
</View> </View>
</View> </View>
+37 -39
View File
@@ -33,47 +33,45 @@ interface PickerOpts {
const MENU_WIDTH = 200 const MENU_WIDTH = 200
export const Picker = register( export function Picker({
({ style,
style, labelStyle,
labelStyle, iconStyle,
iconStyle, items,
items, value,
value, onChange,
onChange, enabled,
enabled, }: PickerOpts) {
}: PickerOpts) => { const ref = useRef<View>(null)
const ref = useRef<View>(null) const valueLabel = items.find(item => item.value === value)?.label || value
const valueLabel = items.find(item => item.value === value)?.label || value const onPress = () => {
const onPress = () => { if (!enabled) {
if (!enabled) { return
return
}
ref.current?.measure(
(
_x: number,
_y: number,
width: number,
height: number,
pageX: number,
pageY: number,
) => {
createDropdownMenu(pageX, pageY + height, MENU_WIDTH, items, onChange)
},
)
} }
return ( ref.current?.measure(
<TouchableWithoutFeedback onPress={onPress}> (
<View style={[styles.outer, style]} ref={ref}> _x: number,
<View style={styles.label}> _y: number,
<Text style={labelStyle}>{valueLabel}</Text> width: number,
</View> height: number,
<FontAwesomeIcon icon="angle-down" style={[styles.icon, iconStyle]} /> pageX: number,
</View> pageY: number,
</TouchableWithoutFeedback> ) => {
createDropdownMenu(pageX, pageY + height, MENU_WIDTH, items, onChange)
},
) )
}, }
) return (
<TouchableWithoutFeedback onPress={onPress}>
<View style={[styles.outer, style]} ref={ref}>
<View style={styles.label}>
<Text style={labelStyle}>{valueLabel}</Text>
</View>
<FontAwesomeIcon icon="angle-down" style={[styles.icon, iconStyle]} />
</View>
</TouchableWithoutFeedback>
)
}
function createDropdownMenu( function createDropdownMenu(
x: number, x: number,
+2 -3
View File
@@ -4,7 +4,6 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {UpIcon, UpIconSolid} from '../../lib/icons' import {UpIcon, UpIconSolid} from '../../lib/icons'
import {s, colors} from '../../lib/styles' import {s, colors} from '../../lib/styles'
import {useAnimatedValue} from '../../lib/useAnimatedValue' import {useAnimatedValue} from '../../lib/useAnimatedValue'
import {register} from 'react-native-bundle-splitter'
interface PostCtrlsOpts { interface PostCtrlsOpts {
big?: boolean big?: boolean
@@ -22,7 +21,7 @@ const redgray = '#7A6161'
const sRedgray = {color: redgray} const sRedgray = {color: redgray}
const HITSLOP = {top: 10, left: 10, bottom: 10, right: 10} const HITSLOP = {top: 10, left: 10, bottom: 10, right: 10}
export const PostCtrls = register((opts: PostCtrlsOpts) => { export function PostCtrls(opts: PostCtrlsOpts) {
const interp1 = useAnimatedValue(0) const interp1 = useAnimatedValue(0)
const interp2 = useAnimatedValue(0) const interp2 = useAnimatedValue(0)
@@ -169,7 +168,7 @@ export const PostCtrls = register((opts: PostCtrlsOpts) => {
<View style={s.flex1}></View> <View style={s.flex1}></View>
</View> </View>
) )
}) }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
ctrls: { ctrls: {
+47 -45
View File
@@ -12,58 +12,60 @@ import {Link} from '../util/Link'
import {LinkMeta, getLikelyType, LikelyType} from '../../../lib/link-meta' import {LinkMeta, getLikelyType, LikelyType} from '../../../lib/link-meta'
import {colors} from '../../lib/styles' import {colors} from '../../lib/styles'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {register} from 'react-native-bundle-splitter'
export const PostEmbeds = register( export function PostEmbeds({
({entities, style}: {entities?: Entity[]; style?: StyleProp<ViewStyle>}) => { entities,
const store = useStores() style,
const [linkMeta, setLinkMeta] = useState<LinkMeta | undefined>(undefined) }: {
const link = entities?.find( entities?: Entity[]
ent => style?: StyleProp<ViewStyle>
ent.type === 'link' && }) {
getLikelyType(ent.value || '') === LikelyType.HTML, const store = useStores()
) const [linkMeta, setLinkMeta] = useState<LinkMeta | undefined>(undefined)
const link = entities?.find(
ent =>
ent.type === 'link' && getLikelyType(ent.value || '') === LikelyType.HTML,
)
useEffect(() => { useEffect(() => {
let aborted = false let aborted = false
store.linkMetas.getLinkMeta(link?.value || '').then(linkMeta => { store.linkMetas.getLinkMeta(link?.value || '').then(linkMeta => {
if (!aborted) { if (!aborted) {
setLinkMeta(linkMeta) setLinkMeta(linkMeta)
}
})
return () => {
aborted = true
} }
}, [link]) })
if (!link) { return () => {
return <View /> aborted = true
} }
}, [link])
return ( if (!link) {
<Link style={[styles.outer, style]} href={link.value}> return <View />
{linkMeta ? ( }
<>
<Text numberOfLines={1} style={styles.title}> return (
{linkMeta.title || linkMeta.url} <Link style={[styles.outer, style]} href={link.value}>
{linkMeta ? (
<>
<Text numberOfLines={1} style={styles.title}>
{linkMeta.title || linkMeta.url}
</Text>
<Text numberOfLines={1} style={styles.url}>
{linkMeta.url}
</Text>
{linkMeta.description ? (
<Text numberOfLines={2} style={styles.description}>
{linkMeta.description}
</Text> </Text>
<Text numberOfLines={1} style={styles.url}> ) : undefined}
{linkMeta.url} </>
</Text> ) : (
{linkMeta.description ? ( <ActivityIndicator />
<Text numberOfLines={2} style={styles.description}> )}
{linkMeta.description} </Link>
</Text> )
) : undefined} }
</>
) : (
<ActivityIndicator />
)}
</Link>
)
},
)
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outer: { outer: {
+2 -3
View File
@@ -5,7 +5,6 @@ import {Link} from '../util/Link'
import {PostDropdownBtn} from '../util/DropdownBtn' import {PostDropdownBtn} from '../util/DropdownBtn'
import {s} from '../../lib/styles' import {s} from '../../lib/styles'
import {ago} from '../../../lib/strings' import {ago} from '../../../lib/strings'
import {register} from 'react-native-bundle-splitter'
interface PostMetaOpts { interface PostMetaOpts {
itemHref: string itemHref: string
@@ -19,7 +18,7 @@ interface PostMetaOpts {
onDeletePost: () => void onDeletePost: () => void
} }
export const PostMeta = register((opts: PostMetaOpts) => { export function PostMeta(opts: PostMetaOpts) {
return ( return (
<View style={styles.meta}> <View style={styles.meta}>
<Link <Link
@@ -48,7 +47,7 @@ export const PostMeta = register((opts: PostMetaOpts) => {
</PostDropdownBtn> </PostDropdownBtn>
</View> </View>
) )
}) }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
meta: { meta: {
+54 -60
View File
@@ -3,7 +3,6 @@ import {Text, TextStyle, StyleProp} from 'react-native'
import {TextLink} from './Link' import {TextLink} from './Link'
import {s} from '../../lib/styles' import {s} from '../../lib/styles'
import {toShortUrl} from '../../../lib/strings' import {toShortUrl} from '../../../lib/strings'
import {register} from 'react-native-bundle-splitter'
type TextSlice = {start: number; end: number} type TextSlice = {start: number; end: number}
type Entity = { type Entity = {
@@ -12,70 +11,65 @@ type Entity = {
value: string value: string
} }
export const RichText = register( export function RichText({
({ text,
text, entities,
entities, style,
style, numberOfLines,
numberOfLines, }: {
}: { text: string
text: string entities?: Entity[]
entities?: Entity[] style?: StyleProp<TextStyle>
style?: StyleProp<TextStyle> numberOfLines?: number
numberOfLines?: number }) {
}) => { if (!entities?.length) {
if (!entities?.length) { if (/^\p{Extended_Pictographic}+$/u.test(text) && text.length <= 5) {
if (/^\p{Extended_Pictographic}+$/u.test(text) && text.length <= 5) { style = {
style = { fontSize: 26,
fontSize: 26, lineHeight: 30,
lineHeight: 30,
}
return <Text style={style}>{text}</Text>
} }
return <Text style={style}>{text}</Text> return <Text style={style}>{text}</Text>
} }
if (!style) { return <Text style={style}>{text}</Text>
style = [] }
} else if (!Array.isArray(style)) { if (!style) style = []
style = [style] else if (!Array.isArray(style)) style = [style]
} entities.sort(sortByIndex)
entities.sort(sortByIndex) const segments = Array.from(toSegments(text, entities))
const segments = Array.from(toSegments(text, entities)) const els = []
const els = [] let key = 0
let key = 0 for (const segment of segments) {
for (const segment of segments) { if (typeof segment === 'string') {
if (typeof segment === 'string') { els.push(segment)
els.push(segment) } else {
} else { if (segment.entity.type === 'mention') {
if (segment.entity.type === 'mention') { els.push(
els.push( <TextLink
<TextLink key={key}
key={key} text={segment.text}
text={segment.text} href={`/profile/${segment.entity.value}`}
href={`/profile/${segment.entity.value}`} style={[style, s.blue3]}
style={[style, s.blue3]} />,
/>, )
) } else if (segment.entity.type === 'link') {
} else if (segment.entity.type === 'link') { els.push(
els.push( <TextLink
<TextLink key={key}
key={key} text={toShortUrl(segment.text)}
text={toShortUrl(segment.text)} href={segment.entity.value}
href={segment.entity.value} style={[style, s.blue3]}
style={[style, s.blue3]} />,
/>, )
)
}
} }
key++
} }
return ( key++
<Text style={style} numberOfLines={numberOfLines}> }
{els} return (
</Text> <Text style={style} numberOfLines={numberOfLines}>
) {els}
}, </Text>
) )
}
function sortByIndex(a: Entity, b: Entity) { function sortByIndex(a: Entity, b: Entity) {
return a.index.start - b.index.start return a.index.start - b.index.start
+83 -87
View File
@@ -7,104 +7,100 @@ import {
View, View,
} from 'react-native' } from 'react-native'
import {colors} from '../../lib/styles' import {colors} from '../../lib/styles'
import {register} from 'react-native-bundle-splitter'
interface Layout { interface Layout {
x: number x: number
width: number width: number
} }
export const Selector = register( export function Selector({
({ selectedIndex,
selectedIndex, items,
items, panX,
panX, onSelect,
onSelect, }: {
}: { selectedIndex: number
selectedIndex: number items: string[]
items: string[] panX: Animated.Value
panX: Animated.Value onSelect?: (index: number) => void
onSelect?: (index: number) => void }) {
}) => { const [itemLayouts, setItemLayouts] = useState<undefined | Layout[]>(
const [itemLayouts, setItemLayouts] = useState<undefined | Layout[]>( undefined,
undefined, )
) const itemRefs = useMemo(
const itemRefs = useMemo( () => Array.from({length: items.length}).map(() => createRef<View>()),
() => Array.from({length: items.length}).map(() => createRef<View>()), [items.length],
[items.length], )
)
const currentLayouts = useMemo(() => { const currentLayouts = useMemo(() => {
const left = itemLayouts?.[selectedIndex - 1] || {x: 0, width: 0} const left = itemLayouts?.[selectedIndex - 1] || {x: 0, width: 0}
const middle = itemLayouts?.[selectedIndex] || {x: 0, width: 0} const middle = itemLayouts?.[selectedIndex] || {x: 0, width: 0}
const right = itemLayouts?.[selectedIndex + 1] || { const right = itemLayouts?.[selectedIndex + 1] || {
x: middle.x + 20, x: middle.x + 20,
width: middle.width, width: middle.width,
}
return [left, middle, right]
}, [selectedIndex, items, itemLayouts])
const underlineStyle = {
left: panX.interpolate({
inputRange: [-1, 0, 1],
outputRange: [
currentLayouts[0].x,
currentLayouts[1].x,
currentLayouts[2].x,
],
}),
width: panX.interpolate({
inputRange: [-1, 0, 1],
outputRange: [
currentLayouts[0].width,
currentLayouts[1].width,
currentLayouts[2].width,
],
}),
} }
return [left, middle, right]
}, [selectedIndex, items, itemLayouts])
const onLayout = () => { const underlineStyle = {
const promises = [] left: panX.interpolate({
for (let i = 0; i < items.length; i++) { inputRange: [-1, 0, 1],
promises.push( outputRange: [
new Promise<Layout>(resolve => { currentLayouts[0].x,
itemRefs[i].current?.measure( currentLayouts[1].x,
(x: number, _y: number, width: number) => { currentLayouts[2].x,
resolve({x, width}) ],
}, }),
) width: panX.interpolate({
}), inputRange: [-1, 0, 1],
) outputRange: [
} currentLayouts[0].width,
Promise.all(promises).then((layouts: Layout[]) => { currentLayouts[1].width,
setItemLayouts(layouts) currentLayouts[2].width,
}) ],
} }),
}
const onPressItem = (index: number) => { const onLayout = () => {
onSelect?.(index) const promises = []
} for (let i = 0; i < items.length; i++) {
promises.push(
return ( new Promise<Layout>(resolve => {
<View style={[styles.outer]} onLayout={onLayout}> itemRefs[i].current?.measure(
<Animated.View style={[styles.underline, underlineStyle]} /> (x: number, _y: number, width: number) => {
{items.map((item, i) => { resolve({x, width})
const selected = i === selectedIndex },
return (
<TouchableWithoutFeedback key={i} onPress={() => onPressItem(i)}>
<View style={styles.item} ref={itemRefs[i]}>
<Text
style={selected ? styles.labelSelected : styles.itemLabel}>
{item}
</Text>
</View>
</TouchableWithoutFeedback>
) )
})} }),
</View> )
) }
}, Promise.all(promises).then((layouts: Layout[]) => {
) setItemLayouts(layouts)
})
}
const onPressItem = (index: number) => {
onSelect?.(index)
}
return (
<View style={[styles.outer]} onLayout={onLayout}>
<Animated.View style={[styles.underline, underlineStyle]} />
{items.map((item, i) => {
const selected = i === selectedIndex
return (
<TouchableWithoutFeedback key={i} onPress={() => onPressItem(i)}>
<View style={styles.item} ref={itemRefs[i]}>
<Text style={selected ? styles.labelSelected : styles.itemLabel}>
{item}
</Text>
</View>
</TouchableWithoutFeedback>
)
})}
</View>
)
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outer: { outer: {
+92 -95
View File
@@ -10,113 +10,110 @@ import {
} from 'react-native-image-crop-picker' } from 'react-native-image-crop-picker'
import {getGradient} from '../../lib/asset-gen' import {getGradient} from '../../lib/asset-gen'
import {colors} from '../../lib/styles' import {colors} from '../../lib/styles'
import {register} from 'react-native-bundle-splitter'
export const UserAvatar = register( export function UserAvatar({
({ size,
size, handle,
handle, avatar,
avatar, displayName,
displayName, onSelectNewAvatar,
onSelectNewAvatar, }: {
}: { size: number
size: number handle: string
handle: string displayName: string | undefined
displayName: string | undefined avatar?: string | null
avatar?: string | null onSelectNewAvatar?: (img: PickedImage) => void
onSelectNewAvatar?: (img: PickedImage) => void }) {
}) => { const initials = getInitials(displayName || handle)
const initials = getInitials(displayName || handle) const gradient = getGradient(handle)
const gradient = getGradient(handle)
const handleEditAvatar = useCallback(() => { const handleEditAvatar = useCallback(() => {
Alert.alert('Select upload method', '', [ Alert.alert('Select upload method', '', [
{ {
text: 'Take a new photo', text: 'Take a new photo',
onPress: () => { onPress: () => {
openCamera({ openCamera({
mediaType: 'photo',
cropping: true,
width: 400,
height: 400,
cropperCircleOverlay: true,
forceJpg: true, // ios only
compressImageQuality: 0.7,
}).then(onSelectNewAvatar)
},
},
{
text: 'Select from gallery',
onPress: () => {
openPicker({
mediaType: 'photo',
}).then(async item => {
await openCropper({
mediaType: 'photo', mediaType: 'photo',
cropping: true, path: item.path,
width: 400, width: 400,
height: 400, height: 400,
cropperCircleOverlay: true, cropperCircleOverlay: true,
forceJpg: true, // ios only forceJpg: true, // ios only
compressImageQuality: 0.7, compressImageQuality: 0.7,
}).then(onSelectNewAvatar) }).then(onSelectNewAvatar)
}, })
}, },
{ },
text: 'Select from gallery', ])
onPress: () => { }, [onSelectNewAvatar])
openPicker({
mediaType: 'photo',
}).then(async item => {
await openCropper({
mediaType: 'photo',
path: item.path,
width: 400,
height: 400,
cropperCircleOverlay: true,
forceJpg: true, // ios only
compressImageQuality: 0.7,
}).then(onSelectNewAvatar)
})
},
},
])
}, [onSelectNewAvatar])
const renderSvg = (size: number, initials: string) => ( const renderSvg = (size: number, initials: string) => (
<Svg width={size} height={size} viewBox="0 0 100 100"> <Svg width={size} height={size} viewBox="0 0 100 100">
<Defs> <Defs>
<LinearGradient id="grad" x1="0" y1="0" x2="1" y2="1"> <LinearGradient id="grad" x1="0" y1="0" x2="1" y2="1">
<Stop offset="0" stopColor={gradient[0]} stopOpacity="1" /> <Stop offset="0" stopColor={gradient[0]} stopOpacity="1" />
<Stop offset="1" stopColor={gradient[1]} stopOpacity="1" /> <Stop offset="1" stopColor={gradient[1]} stopOpacity="1" />
</LinearGradient> </LinearGradient>
</Defs> </Defs>
<Circle cx="50" cy="50" r="50" fill="url(#grad)" /> <Circle cx="50" cy="50" r="50" fill="url(#grad)" />
<Text <Text
fill="white" fill="white"
fontSize="50" fontSize="50"
fontWeight="bold" fontWeight="bold"
x="50" x="50"
y="67" y="67"
textAnchor="middle"> textAnchor="middle">
{initials} {initials}
</Text> </Text>
</Svg> </Svg>
) )
// onSelectNewAvatar is only passed as prop on the EditProfile component // onSelectNewAvatar is only passed as prop on the EditProfile component
return onSelectNewAvatar ? ( return onSelectNewAvatar ? (
<TouchableOpacity onPress={handleEditAvatar}> <TouchableOpacity onPress={handleEditAvatar}>
{avatar ? ( {avatar ? (
<Image <Image
style={{width: size, height: size, borderRadius: (size / 2) | 0}} style={{width: size, height: size, borderRadius: (size / 2) | 0}}
source={{uri: avatar}} source={{uri: avatar}}
/> />
) : ( ) : (
renderSvg(size, initials) renderSvg(size, initials)
)} )}
<View style={styles.editButtonContainer}> <View style={styles.editButtonContainer}>
<FontAwesomeIcon <FontAwesomeIcon
icon="camera" icon="camera"
size={12} size={12}
style={{color: colors.white}} style={{color: colors.white}}
/> />
</View> </View>
</TouchableOpacity> </TouchableOpacity>
) : avatar ? ( ) : avatar ? (
<Image <Image
style={{width: size, height: size, borderRadius: (size / 2) | 0}} style={{width: size, height: size, borderRadius: (size / 2) | 0}}
resizeMode="stretch" resizeMode="stretch"
source={{uri: avatar}} source={{uri: avatar}}
/> />
) : ( ) : (
renderSvg(size, initials) renderSvg(size, initials)
) )
}, }
)
function getInitials(str: string): string { function getInitials(str: string): string {
const tokens = str const tokens = str
+83 -86
View File
@@ -10,103 +10,100 @@ import {
openPicker, openPicker,
} from 'react-native-image-crop-picker' } from 'react-native-image-crop-picker'
import {IMAGES_ENABLED} from '../../../build-flags' import {IMAGES_ENABLED} from '../../../build-flags'
import {register} from 'react-native-bundle-splitter'
export const UserBanner = register( export function UserBanner({
({ handle,
handle, userBanner,
userBanner, setUserBanner,
setUserBanner, }: {
}: { handle: string
handle: string userBanner?: string | null
userBanner?: string | null setUserBanner?: React.Dispatch<React.SetStateAction<string | null>>
setUserBanner?: React.Dispatch<React.SetStateAction<string | null>> }) {
}) => { const gradient = getGradient(handle)
const gradient = getGradient(handle)
const handleEditBanner = useCallback(() => { const handleEditBanner = useCallback(() => {
Alert.alert('Select upload method', '', [ Alert.alert('Select upload method', '', [
{ {
text: 'Take a new photo', text: 'Take a new photo',
onPress: () => { onPress: () => {
openCamera({ openCamera({
mediaType: 'photo',
cropping: true,
width: 1500,
height: 500,
}).then(item => {
if (setUserBanner != null) {
setUserBanner(item.path)
}
})
},
},
{
text: 'Select from gallery',
onPress: () => {
openPicker({
mediaType: 'photo',
}).then(async item => {
await openCropper({
mediaType: 'photo', mediaType: 'photo',
cropping: true, path: item.path,
width: 1500, width: 1500,
height: 500, height: 500,
}).then(item => { }).then(croppedItem => {
if (setUserBanner != null) { if (setUserBanner != null) {
setUserBanner(item.path) setUserBanner(croppedItem.path)
} }
}) })
}, })
}, },
{ },
text: 'Select from gallery', ])
onPress: () => { }, [setUserBanner])
openPicker({
mediaType: 'photo',
}).then(async item => {
await openCropper({
mediaType: 'photo',
path: item.path,
width: 1500,
height: 500,
}).then(croppedItem => {
if (setUserBanner != null) {
setUserBanner(croppedItem.path)
}
})
})
},
},
])
}, [setUserBanner])
const renderSvg = () => ( const renderSvg = () => (
<Svg width="100%" height="120" viewBox="50 0 200 100"> <Svg width="100%" height="120" viewBox="50 0 200 100">
<Defs> <Defs>
<LinearGradient id="grad" x1="0" y1="0" x2="1" y2="1"> <LinearGradient id="grad" x1="0" y1="0" x2="1" y2="1">
<Stop offset="0" stopColor={gradient[0]} stopOpacity="1" /> <Stop offset="0" stopColor={gradient[0]} stopOpacity="1" />
<Stop offset="1" stopColor={gradient[1]} stopOpacity="1" /> <Stop offset="1" stopColor={gradient[1]} stopOpacity="1" />
</LinearGradient> </LinearGradient>
<LinearGradient id="grad2" x1="0" y1="0" x2="0" y2="1"> <LinearGradient id="grad2" x1="0" y1="0" x2="0" y2="1">
<Stop offset="0" stopColor="#fff" stopOpacity="0" /> <Stop offset="0" stopColor="#fff" stopOpacity="0" />
<Stop offset="1" stopColor="#fff" stopOpacity="0.3" /> <Stop offset="1" stopColor="#fff" stopOpacity="0.3" />
</LinearGradient> </LinearGradient>
</Defs> </Defs>
<Rect x="0" y="0" width="400" height="100" fill="url(#grad)" /> <Rect x="0" y="0" width="400" height="100" fill="url(#grad)" />
<Rect x="0" y="0" width="400" height="100" fill="url(#grad2)" /> <Rect x="0" y="0" width="400" height="100" fill="url(#grad2)" />
</Svg> </Svg>
) )
// setUserBanner is only passed as prop on the EditProfile component // setUserBanner is only passed as prop on the EditProfile component
return setUserBanner != null && IMAGES_ENABLED ? ( return setUserBanner != null && IMAGES_ENABLED ? (
<TouchableOpacity onPress={handleEditBanner}> <TouchableOpacity onPress={handleEditBanner}>
{userBanner ? ( {userBanner ? (
<Image style={styles.bannerImage} source={{uri: userBanner}} /> <Image style={styles.bannerImage} source={{uri: userBanner}} />
) : ( ) : (
renderSvg() renderSvg()
)} )}
<View style={styles.editButtonContainer}> <View style={styles.editButtonContainer}>
<FontAwesomeIcon <FontAwesomeIcon
icon="camera" icon="camera"
size={12} size={12}
style={{color: colors.white}} style={{color: colors.white}}
/> />
</View> </View>
</TouchableOpacity> </TouchableOpacity>
) : userBanner ? ( ) : userBanner ? (
<Image <Image
style={styles.bannerImage} style={styles.bannerImage}
resizeMode="stretch" resizeMode="stretch"
source={{uri: userBanner}} source={{uri: userBanner}}
/> />
) : ( ) : (
renderSvg() renderSvg()
) )
}, }
)
const styles = StyleSheet.create({ const styles = StyleSheet.create({
editButtonContainer: { editButtonContainer: {
+67 -74
View File
@@ -4,83 +4,76 @@ import {StyleProp, Text, TextStyle} from 'react-native'
import {Link} from './Link' import {Link} from './Link'
import {LoadingPlaceholder} from './LoadingPlaceholder' import {LoadingPlaceholder} from './LoadingPlaceholder'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {register} from 'react-native-bundle-splitter'
export const UserInfoText = register( export function UserInfoText({
({ did,
did, attr,
attr, loading,
loading, failed,
failed, prefix,
prefix, style,
style, asLink,
asLink, }: {
}: { did: string
did: string attr?: keyof GetProfile.OutputSchema
attr?: keyof GetProfile.OutputSchema loading?: string
loading?: string failed?: string
failed?: string prefix?: string
prefix?: string style?: StyleProp<TextStyle>
style?: StyleProp<TextStyle> asLink?: boolean
asLink?: boolean }) {
}) => { attr = attr || 'handle'
attr = attr || 'handle' loading = loading || '...'
loading = loading || '...' failed = failed || 'user'
failed = failed || 'user'
const store = useStores() const store = useStores()
const [profile, setProfile] = useState<undefined | GetProfile.OutputSchema>( const [profile, setProfile] = useState<undefined | GetProfile.OutputSchema>(
undefined, undefined,
)
const [didFail, setFailed] = useState<boolean>(false)
useEffect(() => {
let aborted = false
store.profiles.getProfile(did).then(
v => {
if (aborted) return
setProfile(v.data)
},
_err => {
if (aborted) return
setFailed(true)
},
) )
const [didFail, setFailed] = useState<boolean>(false) return () => {
aborted = true
useEffect(() => {
let aborted = false
store.profiles.getProfile(did).then(
v => {
if (aborted) {
return
}
setProfile(v.data)
},
_err => {
if (aborted) {
return
}
setFailed(true)
},
)
return () => {
aborted = true
}
}, [did, store.api.app.bsky])
let inner
if (didFail) {
inner = <Text style={style}>{failed}</Text>
} else if (profile) {
inner = <Text style={style}>{`${prefix || ''}${profile[attr]}`}</Text>
} else {
inner = (
<LoadingPlaceholder
width={80}
height={8}
style={{position: 'relative', top: 1, left: 2}}
/>
)
} }
}, [did, store.api.app.bsky])
if (asLink) { let inner
const title = profile?.displayName || profile?.handle || 'User' if (didFail) {
return ( inner = <Text style={style}>{failed}</Text>
<Link } else if (profile) {
href={`/profile/${profile?.handle ? profile.handle : did}`} inner = <Text style={style}>{`${prefix || ''}${profile[attr]}`}</Text>
title={title}> } else {
{inner} inner = (
</Link> <LoadingPlaceholder
) width={80}
} height={8}
style={{position: 'relative', top: 1, left: 2}}
/>
)
}
return inner if (asLink) {
}, const title = profile?.displayName || profile?.handle || 'User'
) return (
<Link
href={`/profile/${profile?.handle ? profile.handle : did}`}
title={title}>
{inner}
</Link>
)
}
return inner
}
+106 -110
View File
@@ -1,4 +1,5 @@
import React from 'react' import React from 'react'
import {observer} from 'mobx-react-lite'
import { import {
ActivityIndicator, ActivityIndicator,
StyleSheet, StyleSheet,
@@ -10,121 +11,116 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {s, colors} from '../../lib/styles' import {s, colors} from '../../lib/styles'
import {MagnifyingGlassIcon} from '../../lib/icons' import {MagnifyingGlassIcon} from '../../lib/icons'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {register} from 'react-native-bundle-splitter'
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
const BACK_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10} const BACK_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10}
export const ViewHeader = register( export const ViewHeader = observer(function ViewHeader({
observer( title,
({ subtitle,
title, onPost,
subtitle, }: {
onPost, title: string
}: { subtitle?: string
title: string onPost?: () => void
subtitle?: string }) {
onPost?: () => void const store = useStores()
}) => { const onPressBack = () => {
const store = useStores() store.nav.tab.goBack()
const onPressBack = () => { }
store.nav.tab.goBack() const onPressMenu = () => {
} store.shell.setMainMenuOpen(true)
const onPressMenu = () => { }
store.shell.setMainMenuOpen(true) const onPressCompose = () => {
} store.shell.openComposer({onPost})
const onPressCompose = () => { }
store.shell.openComposer({onPost}) const onPressSearch = () => {
} store.nav.navigate(`/search`)
const onPressSearch = () => { }
store.nav.navigate('/search') const onPressReconnect = () => {
} store.session.connect().catch(e => {
const onPressReconnect = () => { // log for debugging but ignore otherwise
store.session.connect().catch(e => { console.log(e)
// log for debugging but ignore otherwise })
console.log(e) }
}) const canGoBack = store.nav.tab.canGoBack
} return (
const canGoBack = store.nav.tab.canGoBack <>
return ( <View style={styles.header}>
<> <TouchableOpacity
<View style={styles.header}> onPress={canGoBack ? onPressBack : onPressMenu}
<TouchableOpacity hitSlop={BACK_HITSLOP}
onPress={canGoBack ? onPressBack : onPressMenu} style={styles.backIcon}>
hitSlop={BACK_HITSLOP} <FontAwesomeIcon
style={styles.backIcon}> size={18}
<FontAwesomeIcon icon={canGoBack ? 'angle-left' : 'bars'}
size={18} style={{marginTop: 6}}
icon={canGoBack ? 'angle-left' : 'bars'} />
style={{marginTop: 6}} </TouchableOpacity>
/> <View style={styles.titleContainer} pointerEvents="none">
</TouchableOpacity> <Text style={styles.title}>{title}</Text>
<View style={styles.titleContainer} pointerEvents="none"> {subtitle ? (
<Text style={styles.title}>{title}</Text> <Text style={styles.subtitle} numberOfLines={1}>
{subtitle ? ( {subtitle}
<Text style={styles.subtitle} numberOfLines={1}> </Text>
{subtitle}
</Text>
) : undefined}
</View>
<TouchableOpacity
onPress={onPressCompose}
hitSlop={HITSLOP}
style={styles.btn}>
<FontAwesomeIcon size={18} icon="plus" />
</TouchableOpacity>
<TouchableOpacity
onPress={onPressSearch}
hitSlop={HITSLOP}
style={[styles.btn, {marginLeft: 8}]}>
<MagnifyingGlassIcon
size={18}
strokeWidth={3}
style={styles.searchBtnIcon}
/>
</TouchableOpacity>
</View>
{!store.session.online ? (
<TouchableOpacity style={styles.offline} onPress={onPressReconnect}>
{store.session.attemptingConnect ? (
<>
<ActivityIndicator />
<Text style={[s.gray1, s.bold, s.flex1, s.pl5, s.pt5, s.pb5]}>
Connecting...
</Text>
</>
) : (
<>
<FontAwesomeIcon icon="signal" style={[s.gray2]} size={18} />
<FontAwesomeIcon
icon="x"
style={[
s.red4,
{
backgroundColor: colors.gray6,
position: 'relative',
left: -4,
top: 6,
},
]}
border
size={12}
/>
<Text style={[s.gray1, s.bold, s.flex1, s.pl2]}>
Unable to connect
</Text>
<View style={styles.offlineBtn}>
<Text style={styles.offlineBtnText}>Try again</Text>
</View>
</>
)}
</TouchableOpacity>
) : undefined} ) : undefined}
</> </View>
) <TouchableOpacity
}, onPress={onPressCompose}
), hitSlop={HITSLOP}
) style={styles.btn}>
<FontAwesomeIcon size={18} icon="plus" />
</TouchableOpacity>
<TouchableOpacity
onPress={onPressSearch}
hitSlop={HITSLOP}
style={[styles.btn, {marginLeft: 8}]}>
<MagnifyingGlassIcon
size={18}
strokeWidth={3}
style={styles.searchBtnIcon}
/>
</TouchableOpacity>
</View>
{!store.session.online ? (
<TouchableOpacity style={styles.offline} onPress={onPressReconnect}>
{store.session.attemptingConnect ? (
<>
<ActivityIndicator />
<Text style={[s.gray1, s.bold, s.flex1, s.pl5, s.pt5, s.pb5]}>
Connecting...
</Text>
</>
) : (
<>
<FontAwesomeIcon icon="signal" style={[s.gray2]} size={18} />
<FontAwesomeIcon
icon="x"
style={[
s.red4,
{
backgroundColor: colors.gray6,
position: 'relative',
left: -4,
top: 6,
},
]}
border
size={12}
/>
<Text style={[s.gray1, s.bold, s.flex1, s.pl2]}>
Unable to connect
</Text>
<View style={styles.offlineBtn}>
<Text style={styles.offlineBtnText}>Try again</Text>
</View>
</>
)}
</TouchableOpacity>
) : undefined}
</>
)
})
const styles = StyleSheet.create({ const styles = StyleSheet.create({
header: { header: {
+75 -78
View File
@@ -4,95 +4,92 @@ import {Selector} from './Selector'
import {HorzSwipe} from './gestures/HorzSwipe' import {HorzSwipe} from './gestures/HorzSwipe'
import {useAnimatedValue} from '../../lib/useAnimatedValue' import {useAnimatedValue} from '../../lib/useAnimatedValue'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {register} from 'react-native-bundle-splitter'
const HEADER_ITEM = {_reactKey: '__header__'} const HEADER_ITEM = {_reactKey: '__header__'}
const SELECTOR_ITEM = {_reactKey: '__selector__'} const SELECTOR_ITEM = {_reactKey: '__selector__'}
const STICKY_HEADER_INDICES = [1] const STICKY_HEADER_INDICES = [1]
export const ViewSelector = register( export function ViewSelector({
({ sections,
sections, items,
items, refreshing,
refreshing, swipeEnabled,
swipeEnabled, renderHeader,
renderHeader, renderItem,
renderItem, onSelectView,
onSelectView, onRefresh,
onRefresh, onEndReached,
onEndReached, }: {
}: { sections: string[]
sections: string[] items: any[]
items: any[] refreshing?: boolean
refreshing?: boolean swipeEnabled?: boolean
swipeEnabled?: boolean renderHeader?: () => JSX.Element
renderHeader?: () => JSX.Element renderItem: (item: any) => JSX.Element
renderItem: (item: any) => JSX.Element onSelectView?: (viewIndex: number) => void
onSelectView?: (viewIndex: number) => void onRefresh?: () => void
onRefresh?: () => void onEndReached?: (info: {distanceFromEnd: number}) => void
onEndReached?: (info: {distanceFromEnd: number}) => void }) {
}) => { const store = useStores()
const store = useStores() const [selectedIndex, setSelectedIndex] = useState<number>(0)
const [selectedIndex, setSelectedIndex] = useState<number>(0) const panX = useAnimatedValue(0)
const panX = useAnimatedValue(0)
// events // events
// = // =
const onSwipeEnd = (dx: number) => { const onSwipeEnd = (dx: number) => {
if (dx !== 0) { if (dx !== 0) {
setSelectedIndex(selectedIndex + dx) setSelectedIndex(selectedIndex + dx)
}
} }
const onPressSelection = (index: number) => setSelectedIndex(index) }
useEffect(() => { const onPressSelection = (index: number) => setSelectedIndex(index)
onSelectView?.(selectedIndex) useEffect(() => {
}, [selectedIndex]) onSelectView?.(selectedIndex)
}, [selectedIndex])
// rendering // rendering
// = // =
const renderItemInternal = ({item}: {item: any}) => { const renderItemInternal = ({item}: {item: any}) => {
if (item === HEADER_ITEM) { if (item === HEADER_ITEM) {
if (renderHeader) { if (renderHeader) {
return 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) {
const data = [HEADER_ITEM, SELECTOR_ITEM, ...items] return (
return ( <Selector
<HorzSwipe items={sections}
hasPriority panX={panX}
panX={panX} selectedIndex={selectedIndex}
swipeEnabled={swipeEnabled || false} onSelect={onPressSelection}
canSwipeLeft={selectedIndex > 0}
canSwipeRight={selectedIndex < sections.length - 1}
onSwipeEnd={onSwipeEnd}>
<FlatList
data={data}
keyExtractor={item => item._reactKey}
renderItem={renderItemInternal}
stickyHeaderIndices={STICKY_HEADER_INDICES}
refreshing={refreshing}
onRefresh={onRefresh}
onEndReached={onEndReached}
/> />
</HorzSwipe> )
) } else {
}, return renderItem(item)
) }
}
const data = [HEADER_ITEM, SELECTOR_ITEM, ...items]
return (
<HorzSwipe
hasPriority
panX={panX}
swipeEnabled={swipeEnabled || false}
canSwipeLeft={selectedIndex > 0}
canSwipeRight={selectedIndex < sections.length - 1}
onSwipeEnd={onSwipeEnd}>
<FlatList
data={data}
keyExtractor={item => item._reactKey}
renderItem={renderItemInternal}
stickyHeaderIndices={STICKY_HEADER_INDICES}
refreshing={refreshing}
onRefresh={onRefresh}
onEndReached={onEndReached}
/>
</HorzSwipe>
)
}
const styles = StyleSheet.create({}) const styles = StyleSheet.create({})
+2 -3
View File
@@ -7,9 +7,8 @@ import {colors} from '../lib/styles'
import {ScreenParams} from '../routes' import {ScreenParams} from '../routes'
import {useStores} from '../../state' import {useStores} from '../../state'
import {useAnimatedValue} from '../lib/useAnimatedValue' import {useAnimatedValue} from '../lib/useAnimatedValue'
import {register} from 'react-native-bundle-splitter'
export const Contacts = register(({navIdx, visible, params}: ScreenParams) => { export const Contacts = ({navIdx, visible, params}: ScreenParams) => {
const store = useStores() const store = useStores()
const selectorInterp = useAnimatedValue(0) const selectorInterp = useAnimatedValue(0)
@@ -51,7 +50,7 @@ export const Contacts = register(({navIdx, visible, params}: ScreenParams) => {
{!!store.me.handle && <ProfileFollowsComponent name={store.me.handle} />} {!!store.me.handle && <ProfileFollowsComponent name={store.me.handle} />}
</View> </View>
) )
}) }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
section: { section: {
+91 -92
View File
@@ -9,106 +9,105 @@ import {useStores} from '../../state'
import {FeedModel} from '../../state/models/feed-view' import {FeedModel} from '../../state/models/feed-view'
import {ScreenParams} from '../routes' import {ScreenParams} from '../routes'
import {s, colors} from '../lib/styles' import {s, colors} from '../lib/styles'
import {register} from 'react-native-bundle-splitter'
const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20} const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20}
export const Home = register( export const Home = observer(function Home({
observer(({navIdx, visible, scrollElRef}: ScreenParams) => { navIdx,
const store = useStores() visible,
const [hasSetup, setHasSetup] = useState<boolean>(false) scrollElRef,
const {appState} = useAppState({ }: ScreenParams) {
onForeground: () => doPoll(true), const store = useStores()
}) const [hasSetup, setHasSetup] = useState<boolean>(false)
const defaultFeedView = useMemo<FeedModel>( const {appState} = useAppState({
() => onForeground: () => doPoll(true),
new FeedModel(store, 'home', { })
algorithm: 'reverse-chronological', const defaultFeedView = useMemo<FeedModel>(
}), () =>
[store], new FeedModel(store, 'home', {
) algorithm: 'reverse-chronological',
}),
[store],
)
const doPoll = (knownActive = false) => { const doPoll = (knownActive = false) => {
if ((!knownActive && appState !== 'active') || !visible) { if ((!knownActive && appState !== 'active') || !visible) {
return return
} }
if (defaultFeedView.isLoading) { if (defaultFeedView.isLoading) {
return return
} }
console.log('Polling home feed') console.log('Polling home feed')
defaultFeedView.checkForLatest().catch(e => { defaultFeedView.checkForLatest().catch(e => {
console.error('Failed to poll feed', e) console.error('Failed to poll feed', e)
})
}
useEffect(() => {
let aborted = false
const pollInterval = setInterval(() => doPoll(), 15e3)
if (!visible) {
return
}
if (hasSetup) {
console.log('Updating home feed')
defaultFeedView.update()
} else {
store.nav.setTitle(navIdx, 'Home')
console.log('Fetching home feed')
defaultFeedView.setup().then(() => {
if (aborted) return
setHasSetup(true)
}) })
} }
return () => {
useEffect(() => { clearInterval(pollInterval)
let aborted = false aborted = true
const pollInterval = setInterval(() => doPoll(), 15e3)
if (!visible) {
return
}
if (hasSetup) {
console.log('Updating home feed')
defaultFeedView.update()
} else {
store.nav.setTitle(navIdx, 'Home')
console.log('Fetching home feed')
defaultFeedView.setup().then(() => {
if (aborted) {
return
}
setHasSetup(true)
})
}
return () => {
clearInterval(pollInterval)
aborted = true
}
}, [visible, store])
const onPressCompose = () => {
store.shell.openComposer({onPost: onCreatePost})
}
const onCreatePost = () => {
defaultFeedView.loadLatest()
}
const onPressTryAgain = () => {
defaultFeedView.refresh()
}
const onPressLoadLatest = () => {
defaultFeedView.refresh()
scrollElRef?.current?.scrollToOffset({offset: 0})
} }
}, [visible, store])
return ( const onPressCompose = () => {
<View style={s.flex1}> store.shell.openComposer({onPost: onCreatePost})
<ViewHeader }
title="Bluesky" const onCreatePost = () => {
subtitle="Private Beta" defaultFeedView.loadLatest()
onPost={onCreatePost} }
/> const onPressTryAgain = () => {
<Feed defaultFeedView.refresh()
key="default" }
feed={defaultFeedView} const onPressLoadLatest = () => {
scrollElRef={scrollElRef} defaultFeedView.refresh()
style={{flex: 1}} scrollElRef?.current?.scrollToOffset({offset: 0})
onPressCompose={onPressCompose} }
onPressTryAgain={onPressTryAgain}
/> return (
{defaultFeedView.hasNewLatest ? ( <View style={s.flex1}>
<TouchableOpacity <ViewHeader
style={styles.loadLatest} title="Bluesky"
onPress={onPressLoadLatest} subtitle="Private Beta"
hitSlop={HITSLOP}> onPost={onCreatePost}
<FontAwesomeIcon icon="arrow-up" style={{color: colors.white}} /> />
<Text style={styles.loadLatestText}>Load new posts</Text> <Feed
</TouchableOpacity> key="default"
) : undefined} feed={defaultFeedView}
</View> scrollElRef={scrollElRef}
) style={{flex: 1}}
}), onPressCompose={onPressCompose}
) onPressTryAgain={onPressTryAgain}
/>
{defaultFeedView.hasNewLatest ? (
<TouchableOpacity
style={styles.loadLatest}
onPress={onPressLoadLatest}
hitSlop={HITSLOP}>
<FontAwesomeIcon icon="arrow-up" style={{color: colors.white}} />
<Text style={styles.loadLatestText}>Load new posts</Text>
</TouchableOpacity>
) : undefined}
</View>
)
})
const styles = StyleSheet.create({ const styles = StyleSheet.create({
loadLatest: { loadLatest: {
-7
View File
@@ -27,7 +27,6 @@ import {ServiceDescription} from '../../state/models/session'
import {ServerInputModel} from '../../state/models/shell-ui' import {ServerInputModel} from '../../state/models/shell-ui'
import {ComAtprotoAccountCreate} from '../../third-party/api/index' import {ComAtprotoAccountCreate} from '../../third-party/api/index'
import {isNetworkError} from '../../lib/errors' import {isNetworkError} from '../../lib/errors'
import {investigate} from 'react-native-bundle-splitter/dist/utils'
enum ScreenState { enum ScreenState {
SigninOrCreateAccount, SigninOrCreateAccount,
@@ -590,12 +589,6 @@ export const Login = observer(
ScreenState.SigninOrCreateAccount, ScreenState.SigninOrCreateAccount,
) )
console.log(
`loaded: ${investigate().loaded.length} \n waiting: ${
investigate().waiting.length
}`,
)
return ( return (
<View style={styles.outer}> <View style={styles.outer}>
{screenState === ScreenState.SigninOrCreateAccount ? ( {screenState === ScreenState.SigninOrCreateAccount ? (
+2 -3
View File
@@ -2,9 +2,8 @@ import React from 'react'
import {Text, Button, View} from 'react-native' import {Text, Button, View} from 'react-native'
import {ViewHeader} from '../com/util/ViewHeader' import {ViewHeader} from '../com/util/ViewHeader'
import {useStores} from '../../state' import {useStores} from '../../state'
import {register} from 'react-native-bundle-splitter'
export const NotFound = register(() => { export const NotFound = () => {
const stores = useStores() const stores = useStores()
return ( return (
<View> <View>
@@ -20,4 +19,4 @@ export const NotFound = register(() => {
</View> </View>
</View> </View>
) )
}) }
+2 -3
View File
@@ -5,9 +5,8 @@ import {Feed} from '../com/notifications/Feed'
import {useStores} from '../../state' import {useStores} from '../../state'
import {NotificationsViewModel} from '../../state/models/notifications-view' import {NotificationsViewModel} from '../../state/models/notifications-view'
import {ScreenParams} from '../routes' import {ScreenParams} from '../routes'
import {register} from 'react-native-bundle-splitter'
export const Notifications = register(({navIdx, visible}: ScreenParams) => { export const Notifications = ({navIdx, visible}: ScreenParams) => {
const store = useStores() const store = useStores()
useEffect(() => { useEffect(() => {
@@ -37,4 +36,4 @@ export const Notifications = register(({navIdx, visible}: ScreenParams) => {
<Feed view={store.me.notifications} onPressTryAgain={onPressTryAgain} /> <Feed view={store.me.notifications} onPressTryAgain={onPressTryAgain} />
</View> </View>
) )
}) }
+22 -25
View File
@@ -5,32 +5,29 @@ import {FeatureExplainer} from '../com/onboard/FeatureExplainer'
import {Follows} from '../com/onboard/Follows' import {Follows} from '../com/onboard/Follows'
import {OnboardStage, OnboardStageOrder} from '../../state/models/onboard' import {OnboardStage, OnboardStageOrder} from '../../state/models/onboard'
import {useStores} from '../../state' import {useStores} from '../../state'
import {register} from 'react-native-bundle-splitter'
export const Onboard = register( export const Onboard = observer(() => {
observer(() => { const store = useStores()
const store = useStores()
useEffect(() => { useEffect(() => {
// sanity check - bounce out of onboarding if the stage is wrong somehow // sanity check - bounce out of onboarding if the stage is wrong somehow
if (!OnboardStageOrder.includes(store.onboard.stage)) { if (!OnboardStageOrder.includes(store.onboard.stage)) {
store.onboard.stop() store.onboard.stop()
}
}, [store.onboard.stage])
let Com
if (store.onboard.stage === OnboardStage.Explainers) {
Com = FeatureExplainer
} else if (store.onboard.stage === OnboardStage.Follows) {
Com = Follows
} else {
Com = View
} }
}, [store.onboard.stage])
return ( let Com
<View style={{flex: 1, backgroundColor: '#fff'}}> if (store.onboard.stage === OnboardStage.Explainers) {
<Com /> Com = FeatureExplainer
</View> } else if (store.onboard.stage === OnboardStage.Follows) {
) Com = Follows
}), } else {
) Com = View
}
return (
<View style={{flex: 1, backgroundColor: '#fff'}}>
<Com />
</View>
)
})
+16 -19
View File
@@ -5,25 +5,22 @@ import {PostVotedBy as PostLikedByComponent} from '../com/post-thread/PostVotedB
import {ScreenParams} from '../routes' import {ScreenParams} from '../routes'
import {useStores} from '../../state' import {useStores} from '../../state'
import {makeRecordUri} from '../../lib/strings' import {makeRecordUri} from '../../lib/strings'
import {register} from 'react-native-bundle-splitter'
export const PostDownvotedBy = register( export const PostDownvotedBy = ({navIdx, visible, params}: ScreenParams) => {
({navIdx, visible, params}: ScreenParams) => { const store = useStores()
const store = useStores() const {name, rkey} = params
const {name, rkey} = params const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
store.nav.setTitle(navIdx, 'Downvoted by') store.nav.setTitle(navIdx, 'Downvoted by')
} }
}, [store, visible]) }, [store, visible])
return ( return (
<View> <View>
<ViewHeader title="Downvoted by" /> <ViewHeader title="Downvoted by" />
<PostLikedByComponent uri={uri} direction="down" /> <PostLikedByComponent uri={uri} direction="down" />
</View> </View>
) )
}, }
)
+16 -19
View File
@@ -5,25 +5,22 @@ import {PostRepostedBy as PostRepostedByComponent} from '../com/post-thread/Post
import {ScreenParams} from '../routes' import {ScreenParams} from '../routes'
import {useStores} from '../../state' import {useStores} from '../../state'
import {makeRecordUri} from '../../lib/strings' import {makeRecordUri} from '../../lib/strings'
import {register} from 'react-native-bundle-splitter'
export const PostRepostedBy = register( export const PostRepostedBy = ({navIdx, visible, params}: ScreenParams) => {
({navIdx, visible, params}: ScreenParams) => { const store = useStores()
const store = useStores() const {name, rkey} = params
const {name, rkey} = params const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
store.nav.setTitle(navIdx, 'Reposted by') store.nav.setTitle(navIdx, 'Reposted by')
} }
}, [store, visible]) }, [store, visible])
return ( return (
<View> <View>
<ViewHeader title="Reposted by" /> <ViewHeader title="Reposted by" />
<PostRepostedByComponent uri={uri} /> <PostRepostedByComponent uri={uri} />
</View> </View>
) )
}, }
)
+44 -47
View File
@@ -6,56 +6,53 @@ import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread'
import {PostThreadViewModel} from '../../state/models/post-thread-view' import {PostThreadViewModel} from '../../state/models/post-thread-view'
import {ScreenParams} from '../routes' import {ScreenParams} from '../routes'
import {useStores} from '../../state' import {useStores} from '../../state'
import {register} from 'react-native-bundle-splitter'
export const PostThread = register( export const PostThread = ({navIdx, visible, params}: ScreenParams) => {
({navIdx, visible, params}: ScreenParams) => { const store = useStores()
const store = useStores() const {name, rkey} = params
const {name, rkey} = params const [viewSubtitle, setViewSubtitle] = useState<string>(`by ${name}`)
const [viewSubtitle, setViewSubtitle] = useState<string>(`by ${name}`) const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) const view = useMemo<PostThreadViewModel>(
const view = useMemo<PostThreadViewModel>( () => new PostThreadViewModel(store, {uri}),
() => new PostThreadViewModel(store, {uri}), [uri],
[uri], )
)
const setTitle = () => { const setTitle = () => {
const author = view.thread?.author const author = view.thread?.author
const niceName = author?.handle || name const niceName = author?.handle || name
setViewSubtitle(`by ${niceName}`) setViewSubtitle(`by ${niceName}`)
store.nav.setTitle(navIdx, `Post by ${niceName}`) store.nav.setTitle(navIdx, `Post by ${niceName}`)
}
useEffect(() => {
let aborted = false
if (!visible) {
return
} }
useEffect(() => { setTitle()
let aborted = false if (!view.hasLoaded && !view.isLoading) {
if (!visible) { console.log('Fetching post thread', uri)
return view.setup().then(
} () => {
setTitle() if (!aborted) {
if (!view.hasLoaded && !view.isLoading) { setTitle()
console.log('Fetching post thread', uri) }
view.setup().then( },
() => { err => {
if (!aborted) { console.error('Failed to fetch thread', err)
setTitle() },
} )
}, }
err => { return () => {
console.error('Failed to fetch thread', err) aborted = true
}, }
) }, [visible, store.nav, name])
}
return () => {
aborted = true
}
}, [visible, store.nav, name])
return ( return (
<View style={{flex: 1}}>
<ViewHeader title="Post" subtitle={viewSubtitle} />
<View style={{flex: 1}}> <View style={{flex: 1}}>
<ViewHeader title="Post" subtitle={viewSubtitle} /> <PostThreadComponent uri={uri} view={view} />
<View style={{flex: 1}}>
<PostThreadComponent uri={uri} view={view} />
</View>
</View> </View>
) </View>
}, )
) }
+16 -19
View File
@@ -5,25 +5,22 @@ import {PostVotedBy as PostLikedByComponent} from '../com/post-thread/PostVotedB
import {ScreenParams} from '../routes' import {ScreenParams} from '../routes'
import {useStores} from '../../state' import {useStores} from '../../state'
import {makeRecordUri} from '../../lib/strings' import {makeRecordUri} from '../../lib/strings'
import {register} from 'react-native-bundle-splitter'
export const PostUpvotedBy = register( export const PostUpvotedBy = ({navIdx, visible, params}: ScreenParams) => {
({navIdx, visible, params}: ScreenParams) => { const store = useStores()
const store = useStores() const {name, rkey} = params
const {name, rkey} = params const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
store.nav.setTitle(navIdx, 'Upvoted by') store.nav.setTitle(navIdx, 'Upvoted by')
} }
}, [store, visible]) }, [store, visible])
return ( return (
<View> <View>
<ViewHeader title="Upvoted by" /> <ViewHeader title="Upvoted by" />
<PostLikedByComponent uri={uri} direction="up" /> <PostLikedByComponent uri={uri} direction="up" />
</View> </View>
) )
}, }
)
+225 -230
View File
@@ -18,248 +18,243 @@ import {EmptyState} from '../com/util/EmptyState'
import {ViewHeader} from '../com/util/ViewHeader' import {ViewHeader} from '../com/util/ViewHeader'
import * as Toast from '../com/util/Toast' import * as Toast from '../com/util/Toast'
import {s, colors} from '../lib/styles' import {s, colors} from '../lib/styles'
import {register} from 'react-native-bundle-splitter'
const LOADING_ITEM = {_reactKey: '__loading__'} const LOADING_ITEM = {_reactKey: '__loading__'}
const END_ITEM = {_reactKey: '__end__'} const END_ITEM = {_reactKey: '__end__'}
const EMPTY_ITEM = {_reactKey: '__empty__'} const EMPTY_ITEM = {_reactKey: '__empty__'}
export const Profile = register( export const Profile = observer(({navIdx, visible, params}: ScreenParams) => {
observer(({navIdx, visible, params}: ScreenParams) => { const store = useStores()
const store = useStores() const [hasSetup, setHasSetup] = useState<boolean>(false)
const [hasSetup, setHasSetup] = useState<boolean>(false) const uiState = useMemo(
const uiState = useMemo( () => new ProfileUiModel(store, {user: params.name}),
() => new ProfileUiModel(store, {user: params.name}), [params.user],
[params.user], )
useEffect(() => {
let aborted = false
if (!visible) {
return
}
if (hasSetup) {
console.log('Updating profile for', params.name)
uiState.update()
} else {
console.log('Fetching profile for', params.name)
store.nav.setTitle(navIdx, params.name)
uiState.setup().then(() => {
if (aborted) return
setHasSetup(true)
})
}
return () => {
aborted = true
}
}, [visible, params.name, store])
// events
// =
const onSelectView = (index: number) => {
uiState.setSelectedViewIndex(index)
}
const onRefresh = () => {
uiState
.refresh()
.catch((err: any) => console.error('Failed to refresh', err))
}
const onEndReached = () => {
uiState
.loadMore()
.catch((err: any) => console.error('Failed to load more', err))
}
const onPressTryAgain = () => {
uiState.setup()
}
const onPressRemoveMember = (membership: MembershipItem) => {
store.shell.openModal(
new ConfirmModel(
`Remove ${membership.displayName || membership.handle}?`,
`You'll be able to invite them again if you change your mind.`,
async () => {
await uiState.members.removeMember(membership.did)
Toast.show(`User removed`)
},
),
) )
}
useEffect(() => { // rendering
let aborted = false // =
if (!visible) {
return
}
if (hasSetup) {
console.log('Updating profile for', params.name)
uiState.update()
} else {
console.log('Fetching profile for', params.name)
store.nav.setTitle(navIdx, params.name)
uiState.setup().then(() => {
if (aborted) {
return
}
setHasSetup(true)
})
}
return () => {
aborted = true
}
}, [visible, params.name, store])
// events const isSceneCreator =
// = uiState.isScene && store.me.did === uiState.profile.creator
const onSelectView = (index: number) => { const renderHeader = () => {
uiState.setSelectedViewIndex(index) if (!uiState) {
return <View />
} }
const onRefresh = () => { return <ProfileHeader view={uiState.profile} onRefreshAll={onRefresh} />
uiState }
.refresh() let renderItem
.catch((err: any) => console.error('Failed to refresh', err)) let items: any[] = []
} if (uiState) {
const onEndReached = () => { if (uiState.isInitialLoading) {
uiState items.push(LOADING_ITEM)
.loadMore() renderItem = () => <PostFeedLoadingPlaceholder />
.catch((err: any) => console.error('Failed to load more', err)) } else if (uiState.currentView.hasError) {
} items.push({
const onPressTryAgain = () => { _reactKey: '__error__',
uiState.setup() error: uiState.currentView.error,
} })
const onPressRemoveMember = (membership: MembershipItem) => { renderItem = (item: any) => (
store.shell.openModal( <View style={s.p5}>
new ConfirmModel( <ErrorMessage
`Remove ${membership.displayName || membership.handle}?`, dark
"You'll be able to invite them again if you change your mind.", message={item.error}
async () => {
await uiState.members.removeMember(membership.did)
Toast.show('User removed')
},
),
)
}
// rendering
// =
const isSceneCreator =
uiState.isScene && store.me.did === uiState.profile.creator
const renderHeader = () => {
if (!uiState) {
return <View />
}
return <ProfileHeader view={uiState.profile} onRefreshAll={onRefresh} />
}
let renderItem
let items: any[] = []
if (uiState) {
if (uiState.isInitialLoading) {
items.push(LOADING_ITEM)
renderItem = () => <PostFeedLoadingPlaceholder />
} else if (uiState.currentView.hasError) {
items.push({
_reactKey: '__error__',
error: uiState.currentView.error,
})
renderItem = (item: any) => (
<View style={s.p5}>
<ErrorMessage
dark
message={item.error}
onPressTryAgain={onPressTryAgain}
/>
</View>
)
} else {
if (
uiState.selectedView === Sections.Posts ||
uiState.selectedView === Sections.PostsWithReplies ||
uiState.selectedView === Sections.Trending
) {
if (uiState.feed.hasContent) {
if (uiState.selectedView === Sections.Posts) {
items = uiState.feed.nonReplyFeed
} else {
items = uiState.feed.feed.slice()
}
if (!uiState.feed.hasMore) {
items.push(END_ITEM)
}
renderItem = (item: any) => {
if (item === END_ITEM) {
return <Text style={styles.endItem}>- end of feed -</Text>
}
return <FeedItem item={item} />
}
} else if (uiState.feed.isEmpty) {
items.push(EMPTY_ITEM)
if (uiState.profile.isScene) {
renderItem = () => (
<EmptyState
icon="user-group"
message="As members upvote posts, they will trend here. Follow the scene to see its trending posts in your timeline."
/>
)
} else {
renderItem = () => (
<EmptyState
icon={['far', 'message']}
message="No posts yet!"
style={{paddingVertical: 40}}
/>
)
}
}
} else if (uiState.selectedView === Sections.Scenes) {
if (uiState.memberships.hasContent) {
items = uiState.memberships.memberships.slice()
renderItem = (item: any) => {
return (
<ProfileCard
did={item.did}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
/>
)
}
} else if (uiState.memberships.isEmpty) {
items.push(EMPTY_ITEM)
renderItem = () => (
<EmptyState
icon="user-group"
message="This user hasn't joined any scenes."
/>
)
}
} else if (uiState.selectedView === Sections.Members) {
if (uiState.members.hasContent) {
items = uiState.members.members.slice()
renderItem = (item: any) => {
const shouldAdmin = isSceneCreator && item.did !== store.me.did
const renderButton = shouldAdmin
? () => (
<>
<FontAwesomeIcon
icon="user-xmark"
style={[s.mr5]}
size={14}
/>
<Text style={[s.fw400, s.f14]}>Remove</Text>
</>
)
: undefined
return (
<ProfileCard
did={item.did}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
renderButton={renderButton}
onPressButton={() => onPressRemoveMember(item)}
/>
)
}
} else if (uiState.members.isEmpty) {
items.push(EMPTY_ITEM)
renderItem = () => (
<EmptyState
icon="user-group"
message="This scene doesn't have any members."
/>
)
}
} else {
items.push(EMPTY_ITEM)
renderItem = () => <Text>TODO</Text>
}
}
}
if (!renderItem) {
renderItem = () => <View />
}
const title =
uiState.profile.displayName || uiState.profile.handle || params.name
return (
<View style={styles.container}>
<ViewHeader title={title} />
{uiState.profile.hasError ? (
<ErrorScreen
title="Failed to load profile"
message={`There was an issue when attempting to load ${params.name}`}
details={uiState.profile.error}
onPressTryAgain={onPressTryAgain} onPressTryAgain={onPressTryAgain}
/> />
) : uiState.profile.hasLoaded ? ( </View>
<ViewSelector )
swipeEnabled } else {
sections={uiState.selectorItems} if (
items={items} uiState.selectedView === Sections.Posts ||
renderHeader={renderHeader} uiState.selectedView === Sections.PostsWithReplies ||
renderItem={renderItem} uiState.selectedView === Sections.Trending
refreshing={uiState.isRefreshing || false} ) {
onSelectView={onSelectView} if (uiState.feed.hasContent) {
onRefresh={onRefresh} if (uiState.selectedView === Sections.Posts) {
onEndReached={onEndReached} items = uiState.feed.nonReplyFeed
/> } else {
) : ( items = uiState.feed.feed.slice()
renderHeader() }
)} if (!uiState.feed.hasMore) {
</View> items.push(END_ITEM)
) }
}), renderItem = (item: any) => {
) if (item === END_ITEM) {
return <Text style={styles.endItem}>- end of feed -</Text>
}
return <FeedItem item={item} />
}
} else if (uiState.feed.isEmpty) {
items.push(EMPTY_ITEM)
if (uiState.profile.isScene) {
renderItem = () => (
<EmptyState
icon="user-group"
message="As members upvote posts, they will trend here. Follow the scene to see its trending posts in your timeline."
/>
)
} else {
renderItem = () => (
<EmptyState
icon={['far', 'message']}
message="No posts yet!"
style={{paddingVertical: 40}}
/>
)
}
}
} else if (uiState.selectedView === Sections.Scenes) {
if (uiState.memberships.hasContent) {
items = uiState.memberships.memberships.slice()
renderItem = (item: any) => {
return (
<ProfileCard
did={item.did}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
/>
)
}
} else if (uiState.memberships.isEmpty) {
items.push(EMPTY_ITEM)
renderItem = () => (
<EmptyState
icon="user-group"
message="This user hasn't joined any scenes."
/>
)
}
} else if (uiState.selectedView === Sections.Members) {
if (uiState.members.hasContent) {
items = uiState.members.members.slice()
renderItem = (item: any) => {
const shouldAdmin = isSceneCreator && item.did !== store.me.did
const renderButton = shouldAdmin
? () => (
<>
<FontAwesomeIcon
icon="user-xmark"
style={[s.mr5]}
size={14}
/>
<Text style={[s.fw400, s.f14]}>Remove</Text>
</>
)
: undefined
return (
<ProfileCard
did={item.did}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
renderButton={renderButton}
onPressButton={() => onPressRemoveMember(item)}
/>
)
}
} else if (uiState.members.isEmpty) {
items.push(EMPTY_ITEM)
renderItem = () => (
<EmptyState
icon="user-group"
message="This scene doesn't have any members."
/>
)
}
} else {
items.push(EMPTY_ITEM)
renderItem = () => <Text>TODO</Text>
}
}
}
if (!renderItem) {
renderItem = () => <View />
}
const title =
uiState.profile.displayName || uiState.profile.handle || params.name
return (
<View style={styles.container}>
<ViewHeader title={title} />
{uiState.profile.hasError ? (
<ErrorScreen
title="Failed to load profile"
message={`There was an issue when attempting to load ${params.name}`}
details={uiState.profile.error}
onPressTryAgain={onPressTryAgain}
/>
) : uiState.profile.hasLoaded ? (
<ViewSelector
swipeEnabled
sections={uiState.selectorItems}
items={items}
renderHeader={renderHeader}
renderItem={renderItem}
refreshing={uiState.isRefreshing || false}
onSelectView={onSelectView}
onRefresh={onRefresh}
onEndReached={onEndReached}
/>
) : (
renderHeader()
)}
</View>
)
})
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
+15 -18
View File
@@ -4,24 +4,21 @@ import {ViewHeader} from '../com/util/ViewHeader'
import {ProfileFollowers as ProfileFollowersComponent} from '../com/profile/ProfileFollowers' import {ProfileFollowers as ProfileFollowersComponent} from '../com/profile/ProfileFollowers'
import {ScreenParams} from '../routes' import {ScreenParams} from '../routes'
import {useStores} from '../../state' import {useStores} from '../../state'
import {register} from 'react-native-bundle-splitter'
export const ProfileFollowers = register( export const ProfileFollowers = ({navIdx, visible, params}: ScreenParams) => {
({navIdx, visible, params}: ScreenParams) => { const store = useStores()
const store = useStores() const {name} = params
const {name} = params
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
store.nav.setTitle(navIdx, `Followers of ${name}`) store.nav.setTitle(navIdx, `Followers of ${name}`)
} }
}, [store, visible, name]) }, [store, visible, name])
return ( return (
<View> <View>
<ViewHeader title="Followers" subtitle={`of ${name}`} /> <ViewHeader title="Followers" subtitle={`of ${name}`} />
<ProfileFollowersComponent name={name} /> <ProfileFollowersComponent name={name} />
</View> </View>
) )
}, }
)
+15 -18
View File
@@ -4,24 +4,21 @@ import {ViewHeader} from '../com/util/ViewHeader'
import {ProfileFollows as ProfileFollowsComponent} from '../com/profile/ProfileFollows' import {ProfileFollows as ProfileFollowsComponent} from '../com/profile/ProfileFollows'
import {ScreenParams} from '../routes' import {ScreenParams} from '../routes'
import {useStores} from '../../state' import {useStores} from '../../state'
import {register} from 'react-native-bundle-splitter'
export const ProfileFollows = register( export const ProfileFollows = ({navIdx, visible, params}: ScreenParams) => {
({navIdx, visible, params}: ScreenParams) => { const store = useStores()
const store = useStores() const {name} = params
const {name} = params
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
store.nav.setTitle(navIdx, `Followed by ${name}`) store.nav.setTitle(navIdx, `Followed by ${name}`)
} }
}, [store, visible, name]) }, [store, visible, name])
return ( return (
<View> <View>
<ViewHeader title="Followed" subtitle={`by ${name}`} /> <ViewHeader title="Followed" subtitle={`by ${name}`} />
<ProfileFollowsComponent name={name} /> <ProfileFollowsComponent name={name} />
</View> </View>
) )
}, }
)
+15 -18
View File
@@ -4,24 +4,21 @@ import {ViewHeader} from '../com/util/ViewHeader'
import {ProfileMembers as ProfileMembersComponent} from '../com/profile/ProfileMembers' import {ProfileMembers as ProfileMembersComponent} from '../com/profile/ProfileMembers'
import {ScreenParams} from '../routes' import {ScreenParams} from '../routes'
import {useStores} from '../../state' import {useStores} from '../../state'
import {register} from 'react-native-bundle-splitter'
export const ProfileMembers = register( export const ProfileMembers = ({navIdx, visible, params}: ScreenParams) => {
({navIdx, visible, params}: ScreenParams) => { const store = useStores()
const store = useStores() const {name} = params
const {name} = params
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
store.nav.setTitle(navIdx, `Members of ${name}`) store.nav.setTitle(navIdx, `Members of ${name}`)
} }
}, [store, visible, name]) }, [store, visible, name])
return ( return (
<View> <View>
<ViewHeader title="Members" subtitle={`of ${name}`} /> <ViewHeader title="Members" subtitle={`of ${name}`} />
<ProfileMembersComponent name={name} /> <ProfileMembersComponent name={name} />
</View> </View>
) )
}, }
)
+2 -3
View File
@@ -16,9 +16,8 @@ import {useStores} from '../../state'
import {UserAutocompleteViewModel} from '../../state/models/user-autocomplete-view' import {UserAutocompleteViewModel} from '../../state/models/user-autocomplete-view'
import {s, colors} from '../lib/styles' import {s, colors} from '../lib/styles'
import {MagnifyingGlassIcon} from '../lib/icons' import {MagnifyingGlassIcon} from '../lib/icons'
import {register} from 'react-native-bundle-splitter'
export const Search = register(({navIdx, visible, params}: ScreenParams) => { export const Search = ({navIdx, visible, params}: ScreenParams) => {
const store = useStores() const store = useStores()
const textInput = useRef<TextInput>(null) const textInput = useRef<TextInput>(null)
const [query, setQuery] = useState<string>('') const [query, setQuery] = useState<string>('')
@@ -93,7 +92,7 @@ export const Search = register(({navIdx, visible, params}: ScreenParams) => {
</View> </View>
</View> </View>
) )
}) }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
+43 -43
View File
@@ -7,55 +7,55 @@ import {s, colors} from '../lib/styles'
import {ViewHeader} from '../com/util/ViewHeader' import {ViewHeader} from '../com/util/ViewHeader'
import {Link} from '../com/util/Link' import {Link} from '../com/util/Link'
import {UserAvatar} from '../com/util/UserAvatar' import {UserAvatar} from '../com/util/UserAvatar'
import {register} from 'react-native-bundle-splitter'
export const Settings = register( export const Settings = observer(function Settings({
observer(function Settings({navIdx, visible}: ScreenParams) { navIdx,
const store = useStores() visible,
}: ScreenParams) {
const store = useStores()
useEffect(() => { useEffect(() => {
if (!visible) { if (!visible) {
return return
}
store.nav.setTitle(navIdx, 'Settings')
}, [visible, store])
const onPressSignout = () => {
store.session.logout()
} }
store.nav.setTitle(navIdx, 'Settings')
}, [visible, store])
return ( const onPressSignout = () => {
<View style={[s.flex1]}> store.session.logout()
<ViewHeader title="Settings" /> }
<View style={[s.mt10, s.pl10, s.pr10]}>
<View style={[s.flexRow]}> return (
<Text>Signed in as</Text> <View style={[s.flex1]}>
<View style={s.flex1} /> <ViewHeader title="Settings" />
<TouchableOpacity onPress={onPressSignout}> <View style={[s.mt10, s.pl10, s.pr10]}>
<Text style={[s.blue3, s.bold]}>Sign out</Text> <View style={[s.flexRow]}>
</TouchableOpacity> <Text>Signed in as</Text>
</View> <View style={s.flex1} />
<Link href={`/profile/${store.me.handle}`} title="Your profile"> <TouchableOpacity onPress={onPressSignout}>
<View style={styles.profile}> <Text style={[s.blue3, s.bold]}>Sign out</Text>
<UserAvatar </TouchableOpacity>
size={40}
displayName={store.me.displayName}
handle={store.me.handle || ''}
avatar={store.me.avatar}
/>
<View style={[s.ml10]}>
<Text style={[s.f18]}>
{store.me.displayName || store.me.handle}
</Text>
<Text style={[s.gray5]}>@{store.me.handle}</Text>
</View>
</View>
</Link>
</View> </View>
<Link href={`/profile/${store.me.handle}`} title="Your profile">
<View style={styles.profile}>
<UserAvatar
size={40}
displayName={store.me.displayName}
handle={store.me.handle || ''}
avatar={store.me.avatar}
/>
<View style={[s.ml10]}>
<Text style={[s.f18]}>
{store.me.displayName || store.me.handle}
</Text>
<Text style={[s.gray5]}>@{store.me.handle}</Text>
</View>
</View>
</Link>
</View> </View>
) </View>
}), )
) })
const styles = StyleSheet.create({ const styles = StyleSheet.create({
title: { title: {
+49 -52
View File
@@ -4,65 +4,62 @@ import {Animated, Easing, StyleSheet, View} from 'react-native'
import {ComposePost} from '../../com/composer/ComposePost' import {ComposePost} from '../../com/composer/ComposePost'
import {ComposerOpts} from '../../../state/models/shell-ui' import {ComposerOpts} from '../../../state/models/shell-ui'
import {useAnimatedValue} from '../../lib/useAnimatedValue' import {useAnimatedValue} from '../../lib/useAnimatedValue'
import {register} from 'react-native-bundle-splitter'
export const Composer = register( export const Composer = observer(
observer( ({
({ active,
active, winHeight,
winHeight, replyTo,
replyTo, onPost,
onPost, onClose,
onClose, }: {
}: { active: boolean
active: boolean winHeight: number
winHeight: number replyTo?: ComposerOpts['replyTo']
replyTo?: ComposerOpts['replyTo'] onPost?: ComposerOpts['onPost']
onPost?: ComposerOpts['onPost'] onClose: () => void
onClose: () => void }) => {
}) => { const initInterp = useAnimatedValue(0)
const initInterp = useAnimatedValue(0)
useEffect(() => { useEffect(() => {
if (active) { if (active) {
Animated.timing(initInterp, { Animated.timing(initInterp, {
toValue: 1, toValue: 1,
duration: 300, duration: 300,
easing: Easing.out(Easing.exp), easing: Easing.out(Easing.exp),
useNativeDriver: true, useNativeDriver: true,
}).start() }).start()
} else { } else {
initInterp.setValue(0) initInterp.setValue(0)
}
}, [initInterp, active])
const wrapperAnimStyle = {
transform: [
{
translateY: initInterp.interpolate({
inputRange: [0, 1],
outputRange: [winHeight, 0],
}),
},
],
} }
}, [initInterp, active])
const wrapperAnimStyle = {
transform: [
{
translateY: initInterp.interpolate({
inputRange: [0, 1],
outputRange: [winHeight, 0],
}),
},
],
}
// events // events
// = // =
// rendering // rendering
// = // =
if (!active) { if (!active) {
return <View /> return <View />
} }
return ( return (
<Animated.View style={[styles.wrapper, wrapperAnimStyle]}> <Animated.View style={[styles.wrapper, wrapperAnimStyle]}>
<ComposePost replyTo={replyTo} onPost={onPost} onClose={onClose} /> <ComposePost replyTo={replyTo} onPost={onPost} onClose={onClose} />
</Animated.View> </Animated.View>
) )
}, },
),
) )
const styles = StyleSheet.create({ const styles = StyleSheet.create({
-5
View File
@@ -10163,11 +10163,6 @@ react-native-appstate-hook@^1.0.6:
resolved "https://registry.yarnpkg.com/react-native-appstate-hook/-/react-native-appstate-hook-1.0.6.tgz#cbc16e7b89cfaea034cabd999f00e99053cabd06" resolved "https://registry.yarnpkg.com/react-native-appstate-hook/-/react-native-appstate-hook-1.0.6.tgz#cbc16e7b89cfaea034cabd999f00e99053cabd06"
integrity sha512-0hPVyf5yLxCSVrrNEuGqN1ZnSSj3Ye2gZex0NtcK/AHYwMc0rXWFNZjBKOoZSouspqu3hXBbQ6NOUSTzrME1AQ== integrity sha512-0hPVyf5yLxCSVrrNEuGqN1ZnSSj3Ye2gZex0NtcK/AHYwMc0rXWFNZjBKOoZSouspqu3hXBbQ6NOUSTzrME1AQ==
react-native-bundle-splitter@^2.2.3:
version "2.2.3"
resolved "https://registry.yarnpkg.com/react-native-bundle-splitter/-/react-native-bundle-splitter-2.2.3.tgz#aff144fb1b7c9dbd2da334b318b86032869a7755"
integrity sha512-cxKb8/NUKTqAZ+nHp9PoTdRCI6+tWlUUP0Mq0UKgpVbD0KaxRIXnvdSM8Fp9ctiMyB1rk6izZzQkTJ+GcDXQLA==
react-native-codegen@^0.0.17: react-native-codegen@^0.0.17:
version "0.0.17" version "0.0.17"
resolved "https://registry.yarnpkg.com/react-native-codegen/-/react-native-codegen-0.0.17.tgz#83fb814d94061cbd46667f510d2ddba35ffb50ac" resolved "https://registry.yarnpkg.com/react-native-codegen/-/react-native-codegen-0.0.17.tgz#83fb814d94061cbd46667f510d2ddba35ffb50ac"