using register

This commit is contained in:
João Ferreiro
2022-12-12 16:30:52 +00:00
parent 3aca890da6
commit e28093ec35
60 changed files with 3620 additions and 3452 deletions
+1
View File
@@ -34,6 +34,7 @@
"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",
+3 -2
View File
@@ -8,13 +8,14 @@ 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 function Autocomplete({ export const Autocomplete = register(function Autocomplete({
active, active,
items, items,
onSelect, onSelect,
@@ -52,7 +53,7 @@ export function Autocomplete({
))} ))}
</Animated.View> </Animated.View>
) )
} })
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outer: { outer: {
+243 -239
View File
@@ -30,264 +30,268 @@ 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 = observer(function ComposePost({ export const ComposePost = register(
replyTo, observer(function ComposePost({
onPost, replyTo,
onClose, onPost,
}: { onClose,
replyTo?: ComposerOpts['replyTo'] }: {
onPost?: ComposerOpts['onPost'] replyTo?: ComposerOpts['replyTo']
onClose: () => void onPost?: ComposerOpts['onPost']
}) { onClose: () => void
const store = useStores() }) {
const textInput = useRef<TextInput>(null) const store = useStores()
const [isProcessing, setIsProcessing] = useState(false) const textInput = useRef<TextInput>(null)
const [error, setError] = useState('') const [isProcessing, setIsProcessing] = useState(false)
const [text, setText] = useState('') const [error, setError] = useState('')
const [selectedPhotos, setSelectedPhotos] = useState<string[]>([]) const [text, setText] = useState('')
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) => { }
setText(newText) const onPressPublish = async () => {
if (isProcessing) {
const prefix = extractTextAutocompletePrefix(newText) return
if (typeof prefix === 'string') { }
autocompleteView.setActive(true) if (text.length > MAX_TEXT_LENGTH) {
autocompleteView.setPrefix(prefix) return
} 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 = text.length > DANGER_TEXT_LENGTH ? '#e60000' : undefined const progressColor =
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} />
<View style={[styles.postBtn, {backgroundColor: colors.gray1}]}> {isProcessing ? (
<Text style={[s.gray5, s.f16, s.bold]}>Post</Text> <View style={styles.postBtn}>
</View> <ActivityIndicator />
)} </View>
</View> ) : canPost ? (
{error !== '' && ( <TouchableOpacity onPress={onPressPublish}>
<View style={styles.errorLine}> <LinearGradient
<View style={styles.errorIcon}> colors={[gradients.primary.start, gradients.primary.end]}
<FontAwesomeIcon start={{x: 0, y: 0}}
icon="exclamation" end={{x: 1, y: 1}}
style={{color: colors.red4}} style={styles.postBtn}>
size={10} <Text style={[s.white, s.f16, s.bold]}>
/> {replyTo ? 'Reply' : 'Post'}
</View> </Text>
<Text style={s.red4}>{error}</Text> </LinearGradient>
</View> </TouchableOpacity>
)}
{replyTo ? (
<View style={styles.replyToLayout}>
<UserAvatar
handle={replyTo.author.handle}
displayName={replyTo.author.displayName}
avatar={replyTo.author.avatar}
size={50}
/>
<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>
) : undefined}
<View style={[styles.textInputLayout, selectTextInputLayout]}>
<UserAvatar
handle={store.me.handle || ''}
displayName={store.me.displayName}
avatar={store.me.avatar}
size={50}
/>
<TextInput
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 <View style={[styles.postBtn, {backgroundColor: colors.gray1}]}>
size={30} <Text style={[s.gray5, s.f16, s.bold]}>Post</Text>
borderWidth={1} </View>
borderColor={colors.gray2}
color={progressColor}
progress={text.length / MAX_TEXT_LENGTH}
/>
)} )}
</View> </View>
</View> {error !== '' && (
<Autocomplete <View style={styles.errorLine}>
active={autocompleteView.isActive} <View style={styles.errorIcon}>
items={autocompleteView.suggestions} <FontAwesomeIcon
onSelect={onSelectAutocompleteItem} icon="exclamation"
/> style={{color: colors.red4}}
</SafeAreaView> size={10}
</KeyboardAvoidingView> />
) </View>
}) <Text style={s.red4}>{error}</Text>
</View>
)}
{replyTo ? (
<View style={styles.replyToLayout}>
<UserAvatar
handle={replyTo.author.handle}
displayName={replyTo.author.displayName}
avatar={replyTo.author.avatar}
size={50}
/>
<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>
) : undefined}
<View style={[styles.textInputLayout, selectTextInputLayout]}>
<UserAvatar
handle={store.me.handle || ''}
displayName={store.me.displayName}
avatar={store.me.avatar}
size={50}
/>
<TextInput
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) {
+84 -77
View File
@@ -7,96 +7,103 @@ 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 = ({ export const PhotoCarouselPicker = register(
selectedPhotos, ({
setSelectedPhotos, selectedPhotos,
localPhotos, setSelectedPhotos,
}: { localPhotos,
selectedPhotos: string[] }: {
setSelectedPhotos: React.Dispatch<React.SetStateAction<string[]>> selectedPhotos: string[]
localPhotos: any setSelectedPhotos: React.Dispatch<React.SetStateAction<string[]>>
}) => { localPhotos: any
const handleOpenCamera = useCallback(() => { }) => {
openCamera({ const handleOpenCamera = useCallback(() => {
mediaType: 'photo', openCamera({
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',
path: uri, cropping: true,
width: 1000, width: 1000,
height: 1000, height: 1000,
}) }).then(
setSelectedPhotos([img.path, ...selectedPhotos]) item => {
}, setSelectedPhotos([item.path, ...selectedPhotos])
[selectedPhotos, setSelectedPhotos], },
) _err => {
// ignore
},
)
}, [selectedPhotos, setSelectedPhotos])
const handleOpenGallery = useCallback(() => { const handleSelectPhoto = useCallback(
openPicker({ async (uri: string) => {
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: image.path, path: uri,
width: 1000, width: 1000,
height: 1000, height: 1000,
}) })
result.push(img.path) setSelectedPhotos([img.path, ...selectedPhotos])
} },
setSelectedPhotos([...result, ...selectedPhotos]) [selectedPhotos, setSelectedPhotos],
}) )
}, [selectedPhotos, setSelectedPhotos])
return ( const handleOpenGallery = useCallback(() => {
<ScrollView openPicker({
horizontal multiple: true,
style={styles.photosContainer} maxFiles: 4,
showsHorizontalScrollIndicator={false}> mediaType: 'photo',
<TouchableOpacity }).then(async items => {
style={[styles.galleryButton, styles.photo]} const result = []
onPress={handleOpenCamera}>
<FontAwesomeIcon for await (const image of items) {
icon="camera" const img = await openCropper({
size={24} mediaType: 'photo',
style={{color: colors.blue3}} path: image.path,
/> width: 1000,
</TouchableOpacity> height: 1000,
{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
key={`local-image-${index}`} style={[styles.galleryButton, styles.photo]}
style={styles.photoButton} onPress={handleOpenCamera}>
onPress={() => handleSelectPhoto(item.node.image.uri)}> <FontAwesomeIcon
<Image style={styles.photo} source={{uri: item.node.image.uri}} /> icon="camera"
size={24}
style={{color: colors.blue3}}
/>
</TouchableOpacity> </TouchableOpacity>
))} {localPhotos.photos.map((item: any, index: number) => (
<TouchableOpacity <TouchableOpacity
style={[styles.galleryButton, styles.photo]} key={`local-image-${index}`}
onPress={handleOpenGallery}> style={styles.photoButton}
<FontAwesomeIcon icon="image" style={{color: colors.blue3}} size={24} /> onPress={() => handleSelectPhoto(item.node.image.uri)}>
</TouchableOpacity> <Image style={styles.photo} source={{uri: item.node.image.uri}} />
</ScrollView> </TouchableOpacity>
) ))}
} <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: {
+3 -2
View File
@@ -3,8 +3,9 @@ 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 function ComposePrompt({ export const ComposePrompt = register(function ComposePrompt({
noAvi = false, noAvi = false,
text = "What's up?", text = "What's up?",
btn = 'Post', btn = 'Post',
@@ -41,7 +42,7 @@ export function ComposePrompt({
</View> </View>
</TouchableOpacity> </TouchableOpacity>
) )
} })
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
+46 -43
View File
@@ -2,53 +2,56 @@ 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 = ({ export const SelectedPhoto = register(
selectedPhotos, ({
setSelectedPhotos, selectedPhotos,
}: { setSelectedPhotos,
selectedPhotos: string[] }: {
setSelectedPhotos: React.Dispatch<React.SetStateAction<string[]>> selectedPhotos: string[]
}) => { setSelectedPhotos: React.Dispatch<React.SetStateAction<string[]>>
const imageStyle = }) => {
selectedPhotos.length === 1 const imageStyle =
? styles.image250 selectedPhotos.length === 1
: selectedPhotos.length === 2 ? styles.image250
? styles.image175 : selectedPhotos.length === 2
: styles.image85 ? styles.image175
: 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: {
+101 -94
View File
@@ -22,112 +22,119 @@ 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 = observer( export const SuggestedFollows = register(
({ observer(
onNoSuggestions, ({
asLinks, onNoSuggestions,
}: { asLinks,
onNoSuggestions?: () => void }: {
asLinks?: boolean onNoSuggestions?: () => void
}) => { asLinks?: boolean
const store = useStores() }) => {
const [follows, setFollows] = useState<Record<string, string>>({}) const store = useStores()
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) => console.error('Failed to fetch suggestions', err)) .catch((err: any) =>
}, [view]) console.error('Failed to fetch suggestions', err),
)
}, [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.')
}
} }
}, [view, view.isLoading, view.hasError, view.hasContent]) const onPressUnfollow = async (item: SuggestedActor) => {
try {
const onPressTryAgain = () => await apilib.unfollow(store, follows[item.did])
view setFollows(_omit(follows, [item.did]))
.setup() } catch (e) {
.catch((err: any) => console.error('Failed to fetch suggestions', err)) console.log(e)
Toast.show('An issue occurred, please try again.')
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.')
}
}
const renderItem = ({item}: {item: SuggestedActor}) => { const renderItem = ({item}: {item: SuggestedActor}) => {
if (asLinks) { if (asLinks) {
return (
<Link
href={`/profile/${item.handle}`}
title={item.displayName || item.handle}>
<User
item={item}
follow={follows[item.did]}
onPressFollow={onPressFollow}
onPressUnfollow={onPressUnfollow}
/>
</Link>
)
}
return ( return (
<Link <User
href={`/profile/${item.handle}`} item={item}
title={item.displayName || item.handle}> follow={follows[item.did]}
<User onPressFollow={onPressFollow}
item={item} onPressUnfollow={onPressUnfollow}
follow={follows[item.did]} />
onPressFollow={onPressFollow}
onPressUnfollow={onPressUnfollow}
/>
</Link>
) )
} }
return ( return (
<User <View style={styles.container}>
item={item} {view.isLoading ? (
follow={follows[item.did]} <View>
onPressFollow={onPressFollow} <ActivityIndicator />
onPressUnfollow={onPressUnfollow} </View>
/> ) : view.hasError ? (
) <ErrorScreen
} title="Failed to load suggestions"
return ( message="There was an error while trying to load suggested follows."
<View style={styles.container}> details={view.error}
{view.isLoading ? ( onPressTryAgain={onPressTryAgain}
<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.isEmpty ? (
)} <View />
</View> ) : (
) <View style={styles.suggestionsContainer}>
}, <FlatList
data={view.suggestions}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
style={s.flex1}
/>
</View>
)}
</View>
)
},
),
) )
const User = ({ const User = ({
+62 -59
View File
@@ -6,67 +6,70 @@ 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 = observer(function Feed({ export const Feed = register(
view, observer(function Feed({
onPressTryAgain, view,
}: { onPressTryAgain,
view: NotificationsViewModel }: {
onPressTryAgain?: () => void view: NotificationsViewModel
}) { onPressTryAgain?: () => void
// 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 // TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf
// renderItem function renders components that follow React performance best practices // VirtualizedList: You have a large list that is slow to update - make sure your
// like PureComponent, shouldComponentUpdate, etc // renderItem function renders components that follow React performance best practices
const renderItem = ({item}: {item: any}) => { // like PureComponent, shouldComponentUpdate, etc
if (item === EMPTY_FEED_ITEM) { const renderItem = ({item}: {item: any}) => {
return ( if (item === EMPTY_FEED_ITEM) {
<EmptyState return (
icon="bell" <EmptyState
message="No notifications yet!" icon="bell"
style={{paddingVertical: 40}} message="No notifications yet!"
/> style={{paddingVertical: 40}}
) />
)
}
return <FeedItem item={item} />
} }
return <FeedItem item={item} /> const onRefresh = () => {
} 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 = () => {
return ( view.loadMore().catch(err => console.error('Failed to load more', err))
<View style={{flex: 1}}> }
{view.isLoading && !data && <NotificationFeedLoadingPlaceholder />} let data
{view.hasError && ( if (view.hasLoaded) {
<ErrorMessage if (view.isEmpty) {
dark data = [EMPTY_FEED_ITEM]
message={view.error} } else {
style={{margin: 6}} data = view.notifications
onPressTryAgain={onPressTryAgain} }
/> }
)} return (
{data && ( <View style={{flex: 1}}>
<FlatList {view.isLoading && !data && <NotificationFeedLoadingPlaceholder />}
data={data} {view.hasError && (
keyExtractor={item => item._reactKey} <ErrorMessage
renderItem={renderItem} dark
refreshing={view.isRefreshing} message={view.error}
onRefresh={onRefresh} style={{margin: 6}}
onEndReached={onEndReached} onPressTryAgain={onPressTryAgain}
/> />
)} )}
</View> {data && (
) <FlatList
}) data={data}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
refreshing={view.isRefreshing}
onRefresh={onRefresh}
onEndReached={onEndReached}
/>
)}
</View>
)
}),
)
+171 -171
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 = observer(function FeedItem({ export const FeedItem = register(
item, observer(function FeedItem({item}: {item: NotificationsViewItemModel}) {
}: { const itemHref = useMemo(() => {
item: NotificationsViewItemModel if (item.isUpvote || item.isRepost || item.isTrend) {
}) { const urip = new AtUri(item.subjectUri)
const itemHref = useMemo(() => { return `/profile/${urip.host}/post/${urip.rkey}`
if (item.isUpvote || item.isRepost || item.isTrend) { } else if (item.isFollow || item.isAssertion) {
const urip = new AtUri(item.subjectUri) return `/profile/${item.author.handle}`
return `/profile/${urip.host}/post/${urip.rkey}` } else if (item.isReply) {
} else if (item.isFollow || item.isAssertion) { const urip = new AtUri(item.uri)
return `/profile/${item.author.handle}` return `/profile/${urip.host}/post/${urip.rkey}`
} else if (item.isReply) { }
const urip = new AtUri(item.uri) return ''
return `/profile/${urip.host}/post/${urip.rkey}` }, [item])
} const itemTitle = useMemo(() => {
return '' if (item.isUpvote || item.isRepost) {
}, [item]) return 'Post'
const itemTitle = useMemo(() => { } else if (item.isFollow || item.isAssertion) {
if (item.isUpvote || item.isRepost) { return item.author.handle
return 'Post' } else if (item.isReply) {
} else if (item.isFollow || item.isAssertion) { return 'Post'
return item.author.handle }
} else if (item.isReply) { }, [item])
return 'Post'
}
}, [item])
if (item.additionalPost?.notFound) { if (item.additionalPost?.notFound) {
// don't render anything if the target post was deleted or unfindable // don't render anything if the target post was deleted or unfindable
return <View /> 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) {
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,
})),
)
}
if (item.isReply || item.isMention) {
return ( return (
<Link href={itemHref} title={itemTitle}> <Link
<Post style={[styles.outer, item.isRead ? undefined : styles.outerUnread]}
uri={item.uri} href={itemHref}
initView={item.additionalPost} title={itemTitle}>
style={[ <View style={styles.layout}>
styles.outerMinimal, <View style={styles.layoutIcon}>
item.isRead ? undefined : styles.outerUnread, {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> </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,8 +9,13 @@ 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 function InviteAccepter({item}: {item: NotificationsViewItemModel}) { export const InviteAccepter = register(function InviteAccepter({
item,
}: {
item: NotificationsViewItemModel
}) {
const store = useStores() const store = useStores()
const [confirmationUri, setConfirmationUri] = useState<string>('') const [confirmationUri, setConfirmationUri] = useState<string>('')
const isMember = const isMember =
@@ -70,7 +75,7 @@ export function InviteAccepter({item}: {item: NotificationsViewItemModel}) {
)} )}
</View> </View>
) )
} })
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
+3 -2
View File
@@ -16,6 +16,7 @@ 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}>
@@ -79,7 +80,7 @@ const SCENE_MAP = {
} }
const renderScene = SceneMap(SCENE_MAP) const renderScene = SceneMap(SCENE_MAP)
export const FeatureExplainer = () => { export const FeatureExplainer = register(() => {
const layout = useWindowDimensions() const layout = useWindowDimensions()
const store = useStores() const store = useStores()
const [index, setIndex] = useState(0) const [index, setIndex] = useState(0)
@@ -153,7 +154,7 @@ export const FeatureExplainer = () => {
</View> </View>
</SafeAreaView> </SafeAreaView>
) )
} })
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
+26 -23
View File
@@ -10,32 +10,35 @@ 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 = observer(() => { export const Follows = register(
const store = useStores() observer(() => {
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: {
+58 -59
View File
@@ -10,76 +10,75 @@ 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 = observer(function PostRepostedBy({ export const PostRepostedBy = register(
uri, observer(function PostRepostedBy({uri}: {uri: string}) {
}: { const store = useStores()
uri: string const [view, setView] = useState<RepostedByViewModel | undefined>()
}) {
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])
const onRefresh = () => { // loading
view?.refresh() // =
} if (
!view ||
(view.isLoading && !view.isRefreshing) ||
view.params.uri !== uri
) {
return (
<View>
<ActivityIndicator />
</View>
)
}
// loading // error
// = // =
if ( if (view.hasError) {
!view || return (
(view.isLoading && !view.isRefreshing) || <View>
view.params.uri !== uri <ErrorMessage
) { dark
return ( message={view.error}
<View> style={{margin: 6}}
<ActivityIndicator /> onPressTryAgain={onRefresh}
</View> />
</View>
)
}
// loaded
// =
const renderItem = ({item}: {item: RepostedByViewItemModel}) => (
<RepostedByItem item={item} />
) )
}
// error
// =
if (view.hasError) {
return ( return (
<View> <View>
<ErrorMessage <FlatList
dark data={view.repostedBy}
message={view.error} keyExtractor={item => item._reactKey}
style={{margin: 6}} renderItem={renderItem}
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 (
+74 -71
View File
@@ -8,85 +8,88 @@ 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 = observer(function PostThread({ export const PostThread = register(
uri, observer(function PostThread({
view, uri,
}: { view,
uri: string }: {
view: PostThreadViewModel uri: string
}) { view: PostThreadViewModel
const ref = useRef<FlatList>(null) }) {
const posts = view.thread ? Array.from(flattenThread(view.thread)) : [] const ref = useRef<FlatList>(null)
const onRefresh = () => { const posts = view.thread ? Array.from(flattenThread(view.thread)) : []
view?.refresh().catch(err => console.error('Failed to refresh', err)) const onRefresh = () => {
} view?.refresh().catch(err => console.error('Failed to refresh', err))
const onLayout = () => { }
const index = posts.findIndex(post => post._isHighlightedPost) const onLayout = () => {
if (index !== -1) { const index = posts.findIndex(post => post._isHighlightedPost)
ref.current?.scrollToIndex({ if (index !== -1) {
index, ref.current?.scrollToIndex({
index,
animated: false,
viewOffset: 40,
})
}
}
const onScrollToIndexFailed = (info: {
index: number
highestMeasuredFrameIndex: number
averageItemLength: number
}) => {
ref.current?.scrollToOffset({
animated: false, animated: false,
viewOffset: 40, offset: info.averageItemLength * info.index,
}) })
} }
}
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 ( return (
<View> <View>
<ActivityIndicator /> <ActivityIndicator />
</View> </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} />
) )
}
// error
// =
if (view.hasError) {
return ( return (
<View> <FlatList
<ErrorMessage ref={ref}
dark data={posts}
message={view.error} keyExtractor={item => item._reactKey}
style={{margin: 6}} renderItem={renderItem}
onPressTryAgain={onRefresh} refreshing={view.isRefreshing}
/> onRefresh={onRefresh}
</View> onLayout={onLayout}
onScrollToIndexFailed={onScrollToIndexFailed}
style={{flex: 1}}
contentContainerStyle={{paddingBottom: 200}}
/>
) )
} }),
)
// 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,
+253 -244
View File
@@ -18,95 +18,240 @@ 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 = observer(function PostThreadItem({ export const PostThreadItem = register(
item, observer(function PostThreadItem({
onPostReply, item,
}: { onPostReply,
item: PostThreadViewPostModel }: {
onPostReply: () => void item: PostThreadViewPostModel
}) { onPostReply: () => void
const store = useStores() }) {
const [deleted, setDeleted] = useState(false) const store = useStores()
const record = item.record as unknown as PostType.Record const [deleted, setDeleted] = useState(false)
const hasEngagement = item.upvoteCount || item.repostCount const record = item.record as unknown as PostType.Record
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 = () => {
const onPressToggleRepost = () => { item
item .toggleRepost()
.toggleRepost() .catch(e => console.error('Failed to toggle repost', record, e))
.catch(e => console.error('Failed to toggle repost', record, e)) }
} const onPressToggleUpvote = () => {
const onPressToggleUpvote = () => { item
item .toggleUpvote()
.toggleUpvote() .catch(e => console.error('Failed to toggle upvote', record, e))
.catch(e => console.error('Failed to toggle upvote', record, e)) }
} const onCopyPostText = () => {
const onCopyPostText = () => { Clipboard.setString(record.text)
Clipboard.setString(record.text) Toast.show('Copied to clipboard')
Toast.show('Copied to clipboard') }
} const onDeletePost = () => {
const onDeletePost = () => { item.delete().then(
item.delete().then( () => {
() => { setDeleted(true)
setDeleted(true) Toast.show('Post deleted')
Toast.show('Post deleted') },
}, e => {
e => { console.error(e)
console.error(e) Toast.show('Failed to delete post, please try again')
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}>
@@ -119,94 +264,32 @@ export const PostThreadItem = observer(function PostThreadItem({
</Link> </Link>
</View> </View>
<View style={styles.layoutContent}> <View style={styles.layoutContent}>
<View style={[styles.meta, {paddingTop: 5, paddingBottom: 0}]}> <PostMeta
<Link itemHref={itemHref}
style={styles.metaItem} itemTitle={itemTitle}
href={authorHref} authorHref={authorHref}
title={authorTitle}> authorHandle={item.author.handle}
<Text style={[s.f16, s.bold]} numberOfLines={1}> authorDisplayName={item.author.displayName}
{item.author.displayName || item.author.handle} timestamp={item.indexedAt}
</Text> isAuthor={item.author.did === store.me.did}
</Link> onCopyPostText={onCopyPostText}
<Text style={[styles.metaItem, s.f15, s.gray5]}> onDeletePost={onDeletePost}
&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> <View style={styles.postTextContainer}>
<PostEmbeds entities={record.entities} style={s.mb10} /> <RichText
{item._isHighlightedPost && hasEngagement ? ( text={record.text}
<View style={styles.expandedInfo}> entities={record.entities}
{item.repostCount ? ( style={[styles.postText]}
<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>
) : ( <PostEmbeds
<></> entities={record.entities}
)} style={{marginBottom: 10}}
<View style={[s.pl10, s.pb5]}> />
<PostCtrls <PostCtrls
big replyCount={item.replyCount}
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}
@@ -215,85 +298,11 @@ export const PostThreadItem = observer(function PostThreadItem({
/> />
</View> </View>
</View> </View>
</View> </Link>
<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: {
+64 -59
View File
@@ -10,76 +10,81 @@ 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 = observer(function PostVotedBy({ export const PostVotedBy = register(
uri, observer(function PostVotedBy({
direction, uri,
}: { direction,
uri: string }: {
direction: 'up' | 'down' uri: string
}) { direction: 'up' | 'down'
const store = useStores() }) {
const [view, setView] = useState<VotesViewModel | undefined>() const store = useStores()
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])
const onRefresh = () => { // loading
view?.refresh() // =
} if (
!view ||
(view.isLoading && !view.isRefreshing) ||
view.params.uri !== uri
) {
return (
<View>
<ActivityIndicator />
</View>
)
}
// loading // error
// = // =
if ( if (view.hasError) {
!view || return (
(view.isLoading && !view.isRefreshing) || <View>
view.params.uri !== uri <ErrorMessage
) { dark
return ( message={view.error}
<View> style={{margin: 6}}
<ActivityIndicator /> onPressTryAgain={onRefresh}
</View> />
</View>
)
}
// loaded
// =
const renderItem = ({item}: {item: VotesViewItemModel}) => (
<LikedByItem item={item} />
) )
}
// error
// =
if (view.hasError) {
return ( return (
<View> <View>
<ErrorMessage <FlatList
dark data={view.votes}
message={view.error} keyExtractor={item => item._reactKey}
style={{margin: 6}} renderItem={renderItem}
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 (
+164 -157
View File
@@ -22,173 +22,180 @@ 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 = observer(function Post({ export const Post = register(
uri, observer(function Post({
initView, uri,
style, initView,
}: { style,
uri: string }: {
initView?: PostThreadViewModel uri: string
style?: StyleProp<ViewStyle> initView?: PostThreadViewModel
}) { style?: StyleProp<ViewStyle>
const store = useStores() }) {
const [view, setView] = useState<PostThreadViewModel | undefined>(initView) const store = useStores()
const [deleted, setDeleted] = useState(false) const [view, setView] = useState<PostThreadViewModel | undefined>(initView)
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})
setView(newView)
newView.setup().catch(err => console.error('Failed to fetch post', err))
}, [initView, uri, view?.params.uri, store])
// deleted
// =
if (deleted) {
return <View />
} }
const newView = new PostThreadViewModel(store, {uri, depth: 0})
setView(newView)
newView.setup().catch(err => console.error('Failed to fetch post', err))
}, [initView, uri, view?.params.uri, store])
// deleted // loading
// = // =
if (deleted) { if (!view || view.isLoading || view.params.uri !== uri) {
return <View /> return (
} <View>
<ActivityIndicator />
// 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')
},
)
}
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>
<View style={styles.layoutContent}> )
<PostMeta }
itemHref={itemHref}
itemTitle={itemTitle} // error
authorHref={authorHref} // =
authorHandle={item.author.handle} if (view.hasError || !view.thread) {
authorDisplayName={item.author.displayName} return (
timestamp={item.indexedAt} <View>
isAuthor={item.author.did === store.me.did} <Text>{view.error || 'Thread not found'}</Text>
onCopyPostText={onCopyPostText} </View>
onDeletePost={onDeletePost} )
/> }
{replyHref !== '' && (
<View style={[s.flexRow, s.mb2, {alignItems: 'center'}]}> // loaded
<FontAwesomeIcon icon="reply" size={9} style={[s.gray4, s.mr5]} /> // =
<Text style={[s.gray4, s.f12, s.mr2]}>Reply to</Text> const item = view.thread
<Link href={replyHref} title="Parent post"> const record = view.thread?.record as unknown as PostType.Record
<UserInfoText
did={replyAuthorDid} const itemUrip = new AtUri(item.uri)
style={[s.f12, s.gray5]} const itemHref = `/profile/${item.author.handle}/post/${itemUrip.rkey}`
prefix="@" 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]}
/> />
</Link> <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> </View>
)} <PostCtrls
<View style={styles.postTextContainer}> replyCount={item.replyCount}
<RichText repostCount={item.repostCount}
text={record.text} upvoteCount={item.upvoteCount}
entities={record.entities} isReposted={!!item.myState.repost}
style={styles.postText} isUpvoted={!!item.myState.upvote}
onPressReply={onPressReply}
onPressToggleRepost={onPressToggleRepost}
onPressToggleUpvote={onPressToggleUpvote}
/> />
</View> </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>
</View> </Link>
</Link> )
) }),
}) )
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outer: { outer: {
+39 -42
View File
@@ -5,53 +5,50 @@ 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 = observer(function PostText({ export const PostText = register(
uri, observer(function PostText({uri, style}: {uri: string; style?: StyleProp}) {
style, const store = useStores()
}: { 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])
// loading // error
// = // =
if (!model || model.isLoading || model.uri !== uri) { if (model.hasError) {
return (
<View>
<ErrorMessage style={style} message={model.error} />
</View>
)
}
// loaded
// =
return ( return (
<View> <View>
<LoadingPlaceholder width="100%" height={8} style={{marginTop: 6}} /> <Text style={style}>{model.text}</Text>
<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>
)
})
+74 -71
View File
@@ -7,80 +7,83 @@ 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 = observer(function Feed({ export const Feed = register(
feed, observer(function Feed({
style, feed,
scrollElRef, style,
onPressCompose, scrollElRef,
onPressTryAgain, onPressCompose,
}: { onPressTryAgain,
feed: FeedModel }: {
style?: StyleProp<ViewStyle> feed: FeedModel
scrollElRef?: MutableRefObject<FlatList<any> | null> style?: StyleProp<ViewStyle>
onPressCompose: () => void scrollElRef?: MutableRefObject<FlatList<any> | null>
onPressTryAgain?: () => void onPressCompose: () => void
}) { onPressTryAgain?: () => void
// 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 // TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf
// renderItem function renders components that follow React performance best practices // VirtualizedList: You have a large list that is slow to update - make sure your
// like PureComponent, shouldComponentUpdate, etc // renderItem function renders components that follow React performance best practices
const renderItem = ({item}: {item: any}) => { // like PureComponent, shouldComponentUpdate, etc
if (item === COMPOSE_PROMPT_ITEM) { const renderItem = ({item}: {item: any}) => {
return <ComposePrompt onPressCompose={onPressCompose} /> if (item === COMPOSE_PROMPT_ITEM) {
} else if (item === EMPTY_FEED_ITEM) { return <ComposePrompt onPressCompose={onPressCompose} />
return ( } else if (item === EMPTY_FEED_ITEM) {
<EmptyState return (
icon="bars" <EmptyState
message="This feed is empty!" icon="bars"
style={{paddingVertical: 40}} message="This feed is empty!"
/> style={{paddingVertical: 40}}
) />
} else { )
return <FeedItem item={item} /> } else {
return <FeedItem item={item} />
}
} }
} const onRefresh = () => {
const onRefresh = () => { feed.refresh().catch(err => console.error('Failed to refresh', err))
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 = () => {
return ( feed.loadMore().catch(err => console.error('Failed to load more', err))
<View style={style}> }
{!data && <ComposePrompt onPressCompose={onPressCompose} />} let data
{feed.isLoading && !data && <PostFeedLoadingPlaceholder />} if (feed.hasLoaded) {
{feed.hasError && ( if (feed.isEmpty) {
<ErrorMessage data = [COMPOSE_PROMPT_ITEM, EMPTY_FEED_ITEM]
dark } else {
message={feed.error} data = [COMPOSE_PROMPT_ITEM].concat(feed.feed)
style={{margin: 6}} }
onPressTryAgain={onPressTryAgain} }
/> return (
)} <View style={style}>
{feed.hasLoaded && data && ( {!data && <ComposePrompt onPressCompose={onPressCompose} />}
<FlatList {feed.isLoading && !data && <PostFeedLoadingPlaceholder />}
ref={scrollElRef} {feed.hasError && (
data={data} <ErrorMessage
keyExtractor={item => item._reactKey} dark
renderItem={renderItem} message={feed.error}
refreshing={feed.isRefreshing} style={{margin: 6}}
contentContainerStyle={{paddingBottom: 100}} onPressTryAgain={onPressTryAgain}
onRefresh={onRefresh} />
onEndReached={onEndReached} )}
/> {feed.hasLoaded && data && (
)} <FlatList
</View> ref={scrollElRef}
) data={data}
}) keyExtractor={item => item._reactKey}
renderItem={renderItem}
refreshing={feed.isRefreshing}
contentContainerStyle={{paddingBottom: 100}}
onRefresh={onRefresh}
onEndReached={onEndReached}
/>
)}
</View>
)
}),
)
+187 -182
View File
@@ -16,204 +16,209 @@ 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 = observer(function FeedItem({ export const FeedItem = register(
item, observer(function FeedItem({item}: {item: FeedItemModel}) {
}: { const store = useStores()
item: FeedItemModel const [deleted, setDeleted] = useState(false)
}) { const record = item.record as unknown as PostType.Record
const store = useStores() const itemHref = useMemo(() => {
const [deleted, setDeleted] = useState(false) const urip = new AtUri(item.uri)
const record = item.record as unknown as PostType.Record return `/profile/${item.author.handle}/post/${urip.rkey}`
const itemHref = useMemo(() => { }, [item.uri, item.author.handle])
const urip = new AtUri(item.uri) const itemTitle = `Post by ${item.author.handle}`
return `/profile/${item.author.handle}/post/${urip.rkey}` const authorHref = `/profile/${item.author.handle}`
}, [item.uri, item.author.handle]) const replyAuthorDid = useMemo(() => {
const itemTitle = `Post by ${item.author.handle}` if (!record.reply) {
const authorHref = `/profile/${item.author.handle}` return ''
const replyAuthorDid = useMemo(() => { }
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 urip.hostname
return urip.hostname }, [record.reply])
}, [record.reply]) const replyHref = useMemo(() => {
const replyHref = useMemo(() => { if (!record.reply) {
if (!record.reply) return '' return ''
const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri) }
return `/profile/${urip.hostname}/post/${urip.rkey}` const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri)
}, [record.reply]) return `/profile/${urip.hostname}/post/${urip.rkey}`
}, [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 = () => {
const onPressToggleRepost = () => { item
item .toggleRepost()
.toggleRepost() .catch(e => console.error('Failed to toggle repost', record, e))
.catch(e => console.error('Failed to toggle repost', record, e)) }
} const onPressToggleUpvote = () => {
const onPressToggleUpvote = () => { item
item .toggleUpvote()
.toggleUpvote() .catch(e => console.error('Failed to toggle upvote', record, e))
.catch(e => console.error('Failed to toggle upvote', record, e)) }
} const onCopyPostText = () => {
const onCopyPostText = () => { Clipboard.setString(record.text)
Clipboard.setString(record.text) Toast.show('Copied to clipboard')
Toast.show('Copied to clipboard') }
} const onDeletePost = () => {
const onDeletePost = () => { item.delete().then(
item.delete().then( () => {
() => { setDeleted(true)
setDeleted(true) Toast.show('Post deleted')
Toast.show('Post deleted') },
}, e => {
e => { console.error(e)
console.error(e) Toast.show('Failed to delete post, please try again')
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]}> )}
Trending with {item.trendedBy.displayName || item.trendedBy.handle} {item.repostedBy && (
</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 <Link
href={authorHref} style={styles.includeReason}
title={item.author.handle} href={`/profile/${item.repostedBy.handle}`}
style={item._isThreadChild ? {marginLeft: 10} : undefined}> title={item.repostedBy.displayName || item.repostedBy.handle}>
<UserAvatar <FontAwesomeIcon icon="retweet" style={styles.includeReasonIcon} />
size={item._isThreadChild ? 30 : 50} <Text style={[s.gray4, s.bold, s.f13]}>
displayName={item.author.displayName} Reposted by{' '}
handle={item.author.handle} {item.repostedBy.displayName || item.repostedBy.handle}
avatar={item.author.avatar} </Text>
/>
</Link> </Link>
</View> )}
<View style={styles.layoutContent}> {item.trendedBy && (
{!item._isThreadChild ? ( <Link
<PostMeta style={styles.includeReason}
itemHref={itemHref} href={`/profile/${item.trendedBy.handle}`}
itemTitle={itemTitle} title={item.trendedBy.displayName || item.trendedBy.handle}>
authorHref={authorHref} <FontAwesomeIcon
authorHandle={item.author.handle} icon="arrow-trend-up"
authorDisplayName={item.author.displayName} style={styles.includeReasonIcon}
timestamp={item.indexedAt}
isAuthor={item.author.did === store.me.did}
onCopyPostText={onCopyPostText}
onDeletePost={onDeletePost}
/> />
) : undefined} <Text style={[s.gray4, s.bold, s.f13]}>
{!item._isThreadChild && replyHref !== '' && ( Trending with{' '}
<View style={[s.flexRow, s.mb5, {alignItems: 'center'}]}> {item.trendedBy.displayName || item.trendedBy.handle}
<Text style={[s.gray5, s.f15, s.mr2]}>Replying to</Text> </Text>
<Link href={replyHref} title="Parent post"> </Link>
<UserInfoText )}
did={replyAuthorDid} {item.additionalParentPost ? (
style={[s.f15, s.blue3]} <View style={styles.replyingTo}>
prefix="@" <View style={styles.replyingToLine} />
/> <View style={styles.replyingToAvatar}>
</Link> <UserAvatar
handle={item.additionalParentPost?.thread?.author.handle}
displayName={
item.additionalParentPost?.thread?.author.displayName
}
avatar={item.additionalParentPost?.thread?.author.avatar}
size={32}
/>
</View> </View>
)} <View style={styles.replyingToTextContainer}>
<View style={styles.postTextContainer}> <Text style={styles.replyingToText} numberOfLines={2}>
<RichText {item.additionalParentPost?.thread?.record.text}
text={record.text} </Text>
entities={record.entities} </View>
style={styles.postText} </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>
<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> </Link>
</Link> )
) }),
}) )
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outer: { outer: {
+3 -2
View File
@@ -3,8 +3,9 @@ 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 function ProfileCard({ export const ProfileCard = register(function ProfileCard({
did, did,
handle, handle,
displayName, displayName,
@@ -48,7 +49,7 @@ export function ProfileCard({
</View> </View>
</Link> </Link>
) )
} })
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outer: { outer: {
+57 -58
View File
@@ -10,74 +10,73 @@ 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 = observer(function ProfileFollowers({ export const ProfileFollowers = register(
name, observer(function ProfileFollowers({name}: {name: string}) {
}: { const store = useStores()
name: string const [view, setView] = useState<UserFollowersViewModel | undefined>()
}) {
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])
const onRefresh = () => { // loading
view?.refresh() // =
} if (
!view ||
(view.isLoading && !view.isRefreshing) ||
view.params.user !== name
) {
return (
<View>
<ActivityIndicator />
</View>
)
}
// loading // error
// = // =
if ( if (view.hasError) {
!view || return (
(view.isLoading && !view.isRefreshing) || <View>
view.params.user !== name <ErrorMessage
) { dark
message={view.error}
style={{margin: 6}}
onPressTryAgain={onRefresh}
/>
</View>
)
}
// loaded
// =
const renderItem = ({item}: {item: FollowerItem}) => <User item={item} />
return ( return (
<View> <View>
<ActivityIndicator /> <FlatList
</View> data={view.followers}
) 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 (
+57 -58
View File
@@ -10,74 +10,73 @@ 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 = observer(function ProfileFollows({ export const ProfileFollows = register(
name, observer(function ProfileFollows({name}: {name: string}) {
}: { const store = useStores()
name: string const [view, setView] = useState<UserFollowsViewModel | undefined>()
}) {
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])
const onRefresh = () => { // loading
view?.refresh() // =
} if (
!view ||
(view.isLoading && !view.isRefreshing) ||
view.params.user !== name
) {
return (
<View>
<ActivityIndicator />
</View>
)
}
// loading // error
// = // =
if ( if (view.hasError) {
!view || return (
(view.isLoading && !view.isRefreshing) || <View>
view.params.user !== name <ErrorMessage
) { dark
message={view.error}
style={{margin: 6}}
onPressTryAgain={onRefresh}
/>
</View>
)
}
// loaded
// =
const renderItem = ({item}: {item: FollowItem}) => <User item={item} />
return ( return (
<View> <View>
<ActivityIndicator /> <FlatList
</View> data={view.follows}
) 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 (
+263 -257
View File
@@ -21,292 +21,298 @@ 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 = observer(function ProfileHeader({ export const ProfileHeader = register(
view, observer(function ProfileHeader({
onRefreshAll, view,
}: { onRefreshAll,
view: ProfileViewModel }: {
onRefreshAll: () => void view: ProfileViewModel
}) { onRefreshAll: () => void
const store = useStores() }) {
const isMember = useMemo( const store = useStores()
() => view.isScene && view.myState.member, const isMember = useMemo(
[view.myState.member], () => view.isScene && view.myState.member,
) [view.myState.member],
)
const onPressBack = () => { const onPressBack = () => {
store.nav.tab.goBack() store.nav.tab.goBack()
} }
const onPressSearch = () => { const onPressSearch = () => {
store.nav.navigate(`/search`) store.nav.navigate('/search')
} }
const onPressToggleFollow = () => { const onPressToggleFollow = () => {
view?.toggleFollowing().then( view?.toggleFollowing().then(
() => { () => {
Toast.show( Toast.show(
`${view.myState.follow ? 'Following' : 'No longer following'} ${ `${view.myState.follow ? 'Following' : 'No longer following'} ${
view.displayName || view.handle view.displayName || view.handle
}`, }`,
) )
}, },
err => console.error('Failed to toggle follow', err), err => console.error('Failed to toggle follow', err),
) )
} }
const onPressEditProfile = () => { const onPressEditProfile = () => {
store.shell.openModal(new EditProfileModel(view, onRefreshAll)) store.shell.openModal(new EditProfileModel(view, onRefreshAll))
} }
const onPressFollowers = () => { const onPressFollowers = () => {
store.nav.navigate(`/profile/${view.handle}/followers`) store.nav.navigate(`/profile/${view.handle}/followers`)
} }
const onPressFollows = () => { const onPressFollows = () => {
store.nav.navigate(`/profile/${view.handle}/follows`) store.nav.navigate(`/profile/${view.handle}/follows`)
} }
const onPressMembers = () => { const onPressMembers = () => {
store.nav.navigate(`/profile/${view.handle}/members`) store.nav.navigate(`/profile/${view.handle}/members`)
} }
const onPressInviteMembers = () => { const onPressInviteMembers = () => {
store.shell.openModal(new InviteToSceneModel(view)) store.shell.openModal(new InviteToSceneModel(view))
} }
const onPressLeaveScene = () => { const onPressLeaveScene = () => {
store.shell.openModal( store.shell.openModal(
new ConfirmModel( new ConfirmModel(
'Leave this scene?', 'Leave this scene?',
`You'll be able to come back unless your invite is revoked.`, "You'll be able to come back unless your invite is revoked.",
onPressConfirmLeaveScene, onPressConfirmLeaveScene,
), ),
) )
} }
const onPressConfirmLeaveScene = async () => { const onPressConfirmLeaveScene = async () => {
if (view.myState.member) { if (view.myState.member) {
await store.api.app.bsky.graph.confirmation.delete({ await store.api.app.bsky.graph.confirmation.delete({
did: store.me.did || '', did: store.me.did || '',
rkey: new AtUri(view.myState.member).rkey, rkey: new AtUri(view.myState.member).rkey,
}) })
Toast.show(`Scene left`) Toast.show('Scene left')
}
onRefreshAll()
} }
onRefreshAll()
}
// loading // loading
// = // =
if (!view || !view.hasLoaded) { 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}>
<LoadingPlaceholder width="100%" height={120} /> <UserBanner handle={view.handle} userBanner={view.userBanner} />
<View style={styles.avi}> <View style={styles.avi}>
<LoadingPlaceholder <UserAvatar
width={80} size={80}
height={80} handle={view.handle}
style={{borderRadius: 40}} displayName={view.displayName}
avatar={view.avatar}
/> />
</View> </View>
<View style={styles.content}> <View style={styles.content}>
<View style={[styles.buttonsLine]}> <View style={[styles.buttonsLine]}>
<LoadingPlaceholder {isMe ? (
width={100} <TouchableOpacity
height={31} onPress={onPressEditProfile}
style={{borderRadius: 50}} style={[styles.btn, styles.mainBtn]}>
/> <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> <View style={styles.handleLine}>
</View> {view.isScene ? (
) <View style={styles.typeLabelWrapper}>
} <Text style={styles.typeLabel}>Scene</Text>
</View>
// error ) : undefined}
// = <Text style={styles.handle}>@{view.handle}</Text>
if (view.hasError) { </View>
return ( <View style={styles.metricsLine}>
<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
onPress={onPressEditProfile}
style={[styles.btn, styles.mainBtn]}>
<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 style={styles.displayNameLine}>
<Text style={styles.displayName}>
{view.displayName || view.handle}
</Text>
</View>
<View style={styles.handleLine}>
{view.isScene ? (
<View style={styles.typeLabelWrapper}>
<Text style={styles.typeLabel}>Scene</Text>
</View>
) : undefined}
<Text style={styles.handle}>@{view.handle}</Text>
</View>
<View style={styles.metricsLine}>
<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 <TouchableOpacity
style={[s.flexRow, s.mr10]} style={[s.flexRow, s.mr10]}
onPress={onPressFollows}> onPress={onPressFollowers}>
<Text style={[s.bold, s.mr2, styles.metricsText]}> <Text style={[s.bold, s.mr2, styles.metricsText]}>
{view.followsCount} {view.followersCount}
</Text>
<Text style={[s.gray5, styles.metricsText]}>following</Text>
</TouchableOpacity>
) : undefined}
{view.isScene ? (
<TouchableOpacity
style={[s.flexRow, s.mr10]}
onPress={onPressMembers}>
<Text style={[s.bold, s.mr2, styles.metricsText]}>
{view.membersCount}
</Text> </Text>
<Text style={[s.gray5, styles.metricsText]}> <Text style={[s.gray5, styles.metricsText]}>
{pluralize(view.membersCount, 'member')} {pluralize(view.followersCount, 'follower')}
</Text> </Text>
</TouchableOpacity> </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>
) : 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>
) : undefined} ) : 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>
{view.description ? ( {view.isScene && view.creator === store.me.did ? (
<RichText <View style={styles.sceneAdminContainer}>
style={styles.description} <TouchableOpacity onPress={onPressInviteMembers}>
numberOfLines={3} <LinearGradient
text={view.description} colors={[gradient[1], gradient[0]]}
entities={view.descriptionEntities} start={{x: 0, y: 0}}
/> end={{x: 1, y: 1}}
) : undefined} style={[styles.btn, styles.gradientBtn, styles.sceneAdminBtn]}>
{view.isScene && view.creator ? ( <FontAwesomeIcon
<View style={styles.relationshipsLine}> icon="user-plus"
<FontAwesomeIcon icon={['far', 'user']} style={[s.gray5, s.mr5]} /> style={[s.mr5, s.white]}
<Text style={[s.mr2, s.gray5, s.f15]}>Created by</Text> size={15}
<UserInfoText />
style={[s.blue3, s.f15]} <Text style={[s.bold, s.f15, s.white]}>Invite Members</Text>
did={view.creator} </LinearGradient>
prefix="@" </TouchableOpacity>
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 -62
View File
@@ -5,76 +5,77 @@ 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 = observer(function ProfileMembers({ export const ProfileMembers = register(
name, observer(function ProfileMembers({name}: {name: string}) {
}: { const store = useStores()
name: string const [view, setView] = useState<MembersViewModel | undefined>()
}) {
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])
const onRefresh = () => { // loading
view?.refresh() // =
} if (
!view ||
(view.isLoading && !view.isRefreshing) ||
view.params.actor !== name
) {
return (
<View>
<ActivityIndicator />
</View>
)
}
// loading // error
// = // =
if ( if (view.hasError) {
!view || return (
(view.isLoading && !view.isRefreshing) || <View>
view.params.actor !== name <ErrorMessage
) { dark
return ( message={view.error}
<View> style={{margin: 6}}
<ActivityIndicator /> onPressTryAgain={onRefresh}
</View> />
</View>
)
}
// loaded
// =
const renderItem = ({item}: {item: MemberItem}) => (
<ProfileCard
did={item.did}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
/>
) )
}
// error
// =
if (view.hasError) {
return ( return (
<View> <View>
<ErrorMessage <FlatList
dark data={view.members}
message={view.error} keyExtractor={item => item._reactKey}
style={{margin: 6}} renderItem={renderItem}
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>
)
})
+47 -44
View File
@@ -17,6 +17,7 @@ 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}
@@ -26,52 +27,54 @@ export interface DropdownItem {
onPress: () => void onPress: () => void
} }
export function DropdownBtn({ export const DropdownBtn = register(
style, ({
items, style,
menuWidth, items,
children, menuWidth,
}: { children,
style?: StyleProp<ViewStyle> }: {
items: DropdownItem[] style?: StyleProp<ViewStyle>
menuWidth?: number items: DropdownItem[]
children?: React.ReactNode menuWidth?: number
}) { 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,
+23 -21
View File
@@ -5,28 +5,30 @@ 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 function EmptyState({ export const EmptyState = register(
icon, ({
message, icon,
style, message,
}: { style,
icon: IconProp | 'user-group' }: {
message: string icon: IconProp | 'user-group'
style?: StyleProp<ViewStyle> message: string
}) { style?: StyleProp<ViewStyle>
return ( }) => {
<View style={[styles.container, style]}> return (
<View style={styles.iconContainer}> <View style={[styles.container, style]}>
{icon === 'user-group' ? ( <View style={styles.iconContainer}>
<UserGroupIcon size="64" style={styles.icon} /> {icon === 'user-group' ? (
) : ( <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: {
+51 -47
View File
@@ -10,58 +10,62 @@ 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 function ErrorMessage({ export const ErrorMessage = register(
message, ({
numberOfLines, message,
dark, numberOfLines,
style, dark,
onPressTryAgain, style,
}: { onPressTryAgain,
message: string }: {
numberOfLines?: number message: string
dark?: boolean numberOfLines?: number
style?: StyleProp<ViewStyle> dark?: boolean
onPressTryAgain?: () => void style?: StyleProp<ViewStyle>
}) { onPressTryAgain?: () => void
const inner = ( }) => {
<> const inner = (
<View style={[styles.errorIcon, dark ? styles.darkErrorIcon : undefined]}> <>
<FontAwesomeIcon <View
icon="exclamation" style={[styles.errorIcon, dark ? styles.darkErrorIcon : undefined]}>
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="arrows-rotate" icon="exclamation"
style={{color: dark ? colors.white : colors.red4}} style={{color: dark ? colors.red3 : colors.white}}
size={16} size={16}
/> />
</TouchableOpacity> </View>
)} <Text
</> style={[styles.message, dark ? styles.darkMessage : undefined]}
) numberOfLines={numberOfLines}>
if (dark) { {message}
return ( </Text>
<LinearGradient {onPressTryAgain && (
colors={[gradients.error.start, gradients.error.end]} <TouchableOpacity style={styles.btn} onPress={onPressTryAgain}>
start={{x: 0.5, y: 0}} <FontAwesomeIcon
end={{x: 1, y: 1}} icon="arrows-rotate"
style={[styles.outer, style]}> style={{color: dark ? colors.white : colors.red4}}
{inner} size={16}
</LinearGradient> />
</TouchableOpacity>
)}
</>
) )
} if (dark) {
return <View style={[styles.outer, style]}>{inner}</View> return (
} <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: {
+39 -36
View File
@@ -2,47 +2,50 @@ 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 function ErrorScreen({ export const ErrorScreen = register(
title, ({
message, title,
details, message,
onPressTryAgain, details,
}: { onPressTryAgain,
title: string }: {
message: string title: string
details?: string message: string
onPressTryAgain?: () => void details?: string
}) { onPressTryAgain?: () => void
return ( }) => {
<View style={styles.outer}> return (
<View style={styles.errorIconContainer}> <View style={styles.outer}>
<View style={styles.errorIcon}> <View style={styles.errorIconContainer}>
<FontAwesomeIcon <View style={styles.errorIcon}>
icon="exclamation"
style={{color: colors.white}}
size={24}
/>
</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 <FontAwesomeIcon
icon="arrows-rotate" icon="exclamation"
style={{color: colors.white}} style={{color: colors.white}}
size={16} size={24}
/> />
<Text style={styles.btnText}>Try again</Text> </View>
</TouchableOpacity>
</View> </View>
)} <Text style={styles.title}>{title}</Text>
</View> <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: {
+31 -28
View File
@@ -10,35 +10,38 @@ 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 = observer(function Link({ export const Link = register(
style, observer(function Link({
href, style,
title, href,
children, title,
}: { children,
style?: StyleProp<ViewStyle> }: {
href: string style?: StyleProp<ViewStyle>
title?: string href: string
children?: React.ReactNode title?: string
}) { children?: React.ReactNode
const store = useStores() }) {
const onPress = () => { const store = useStores()
handleLink(store, href, false) const onPress = () => {
} handleLink(store, href, false)
const onLongPress = () => { }
handleLink(store, href, true) const onLongPress = () => {
} handleLink(store, href, true)
return ( }
<TouchableOpacity return (
style={style} <TouchableOpacity
onPress={onPress} style={style}
onLongPress={onLongPress} onPress={onPress}
delayPressIn={50}> onLongPress={onLongPress}
{children ? children : <Text>{title || 'link'}</Text>} delayPressIn={50}>
</TouchableOpacity> {children ? children : <Text>{title || 'link'}</Text>}
) </TouchableOpacity>
}) )
}),
)
export const TextLink = observer(function Link({ export const TextLink = observer(function Link({
style, style,
+33 -31
View File
@@ -4,37 +4,39 @@ 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 function LoadingPlaceholder({ export const LoadingPlaceholder = register(
width, ({
height, width,
style, height,
}: { style,
width: string | number }: {
height: string | number width: string | number
style?: StyleProp<ViewStyle> height: string | number
}) { style?: StyleProp<ViewStyle>
return ( }) => {
<View return (
style={[
{
width,
height,
backgroundColor: '#e7e9ea',
borderRadius: 6,
overflow: 'hidden',
},
style,
]}>
<View <View
style={{ style={[
width, {
height, width,
backgroundColor: '#e7e9ea', height,
}} backgroundColor: '#e7e9ea',
/> borderRadius: 6,
</View> overflow: 'hidden',
) },
} style,
]}>
<View
style={{
width,
height,
backgroundColor: '#e7e9ea',
}}
/>
</View>
)
},
)
export function PostLoadingPlaceholder({ export function PostLoadingPlaceholder({
style, style,
@@ -63,7 +65,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> <View style={s.flex1} />
</View> </View>
</View> </View>
</View> </View>
+39 -37
View File
@@ -33,45 +33,47 @@ interface PickerOpts {
const MENU_WIDTH = 200 const MENU_WIDTH = 200
export function Picker({ export const Picker = register(
style, ({
labelStyle, style,
iconStyle, labelStyle,
items, iconStyle,
value, items,
onChange, value,
enabled, onChange,
}: PickerOpts) { enabled,
const ref = useRef<View>(null) }: PickerOpts) => {
const valueLabel = items.find(item => item.value === value)?.label || value const ref = useRef<View>(null)
const onPress = () => { const valueLabel = items.find(item => item.value === value)?.label || value
if (!enabled) { const onPress = () => {
return if (!enabled) {
return
}
ref.current?.measure(
(
_x: number,
_y: number,
width: number,
height: number,
pageX: number,
pageY: number,
) => {
createDropdownMenu(pageX, pageY + height, MENU_WIDTH, items, onChange)
},
)
} }
ref.current?.measure( return (
( <TouchableWithoutFeedback onPress={onPress}>
_x: number, <View style={[styles.outer, style]} ref={ref}>
_y: number, <View style={styles.label}>
width: number, <Text style={labelStyle}>{valueLabel}</Text>
height: number, </View>
pageX: number, <FontAwesomeIcon icon="angle-down" style={[styles.icon, iconStyle]} />
pageY: number,
) => {
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> </View>
<FontAwesomeIcon icon="angle-down" style={[styles.icon, iconStyle]} /> </TouchableWithoutFeedback>
</View> )
</TouchableWithoutFeedback> },
) )
}
function createDropdownMenu( function createDropdownMenu(
x: number, x: number,
+3 -2
View File
@@ -4,6 +4,7 @@ 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
@@ -21,7 +22,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 function PostCtrls(opts: PostCtrlsOpts) { export const PostCtrls = register((opts: PostCtrlsOpts) => {
const interp1 = useAnimatedValue(0) const interp1 = useAnimatedValue(0)
const interp2 = useAnimatedValue(0) const interp2 = useAnimatedValue(0)
@@ -168,7 +169,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
<View style={s.flex1}></View> <View style={s.flex1}></View>
</View> </View>
) )
} })
const styles = StyleSheet.create({ const styles = StyleSheet.create({
ctrls: { ctrls: {
+45 -47
View File
@@ -12,60 +12,58 @@ 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 function PostEmbeds({ export const PostEmbeds = register(
entities, ({entities, style}: {entities?: Entity[]; style?: StyleProp<ViewStyle>}) => {
style, const store = useStores()
}: { const [linkMeta, setLinkMeta] = useState<LinkMeta | undefined>(undefined)
entities?: Entity[] const link = entities?.find(
style?: StyleProp<ViewStyle> ent =>
}) { ent.type === 'link' &&
const store = useStores() getLikelyType(ent.value || '') === LikelyType.HTML,
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])
return () => { if (!link) {
aborted = true return <View />
} }
}, [link])
if (!link) { return (
return <View /> <Link style={[styles.outer, style]} href={link.value}>
} {linkMeta ? (
<>
return ( <Text numberOfLines={1} style={styles.title}>
<Link style={[styles.outer, style]} href={link.value}> {linkMeta.title || linkMeta.url}
{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>
) : undefined} <Text numberOfLines={1} style={styles.url}>
</> {linkMeta.url}
) : ( </Text>
<ActivityIndicator /> {linkMeta.description ? (
)} <Text numberOfLines={2} style={styles.description}>
</Link> {linkMeta.description}
) </Text>
} ) : undefined}
</>
) : (
<ActivityIndicator />
)}
</Link>
)
},
)
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outer: { outer: {
+3 -2
View File
@@ -5,6 +5,7 @@ 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
@@ -18,7 +19,7 @@ interface PostMetaOpts {
onDeletePost: () => void onDeletePost: () => void
} }
export function PostMeta(opts: PostMetaOpts) { export const PostMeta = register((opts: PostMetaOpts) => {
return ( return (
<View style={styles.meta}> <View style={styles.meta}>
<Link <Link
@@ -47,7 +48,7 @@ export function PostMeta(opts: PostMetaOpts) {
</PostDropdownBtn> </PostDropdownBtn>
</View> </View>
) )
} })
const styles = StyleSheet.create({ const styles = StyleSheet.create({
meta: { meta: {
+61 -55
View File
@@ -3,6 +3,7 @@ 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 = {
@@ -11,65 +12,70 @@ type Entity = {
value: string value: string
} }
export function RichText({ export const RichText = register(
text, ({
entities, text,
style, entities,
numberOfLines, style,
}: { numberOfLines,
text: string }: {
entities?: Entity[] text: string
style?: StyleProp<TextStyle> entities?: Entity[]
numberOfLines?: number style?: StyleProp<TextStyle>
}) { numberOfLines?: number
if (!entities?.length) { }) => {
if (/^\p{Extended_Pictographic}+$/u.test(text) && text.length <= 5) { if (!entities?.length) {
style = { if (/^\p{Extended_Pictographic}+$/u.test(text) && text.length <= 5) {
fontSize: 26, style = {
lineHeight: 30, fontSize: 26,
lineHeight: 30,
}
return <Text style={style}>{text}</Text>
} }
return <Text style={style}>{text}</Text> return <Text style={style}>{text}</Text>
} }
return <Text style={style}>{text}</Text> if (!style) {
} style = []
if (!style) style = [] } else if (!Array.isArray(style)) {
else if (!Array.isArray(style)) style = [style] style = [style]
entities.sort(sortByIndex)
const segments = Array.from(toSegments(text, entities))
const els = []
let key = 0
for (const segment of segments) {
if (typeof segment === 'string') {
els.push(segment)
} else {
if (segment.entity.type === 'mention') {
els.push(
<TextLink
key={key}
text={segment.text}
href={`/profile/${segment.entity.value}`}
style={[style, s.blue3]}
/>,
)
} else if (segment.entity.type === 'link') {
els.push(
<TextLink
key={key}
text={toShortUrl(segment.text)}
href={segment.entity.value}
style={[style, s.blue3]}
/>,
)
}
} }
key++ entities.sort(sortByIndex)
} const segments = Array.from(toSegments(text, entities))
return ( const els = []
<Text style={style} numberOfLines={numberOfLines}> let key = 0
{els} for (const segment of segments) {
</Text> if (typeof segment === 'string') {
) els.push(segment)
} } else {
if (segment.entity.type === 'mention') {
els.push(
<TextLink
key={key}
text={segment.text}
href={`/profile/${segment.entity.value}`}
style={[style, s.blue3]}
/>,
)
} else if (segment.entity.type === 'link') {
els.push(
<TextLink
key={key}
text={toShortUrl(segment.text)}
href={segment.entity.value}
style={[style, s.blue3]}
/>,
)
}
}
key++
}
return (
<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
+88 -84
View File
@@ -7,100 +7,104 @@ 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 function Selector({ export const Selector = register(
selectedIndex, ({
items, selectedIndex,
panX, items,
onSelect, panX,
}: { onSelect,
selectedIndex: number }: {
items: string[] selectedIndex: number
panX: Animated.Value items: string[]
onSelect?: (index: number) => void panX: Animated.Value
}) { onSelect?: (index: number) => void
const [itemLayouts, setItemLayouts] = useState<undefined | Layout[]>( }) => {
undefined, const [itemLayouts, setItemLayouts] = useState<undefined | Layout[]>(
) undefined,
const itemRefs = useMemo( )
() => Array.from({length: items.length}).map(() => createRef<View>()), const itemRefs = useMemo(
[items.length], () => Array.from({length: items.length}).map(() => createRef<View>()),
) [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 underlineStyle = { const onLayout = () => {
left: panX.interpolate({ const promises = []
inputRange: [-1, 0, 1], for (let i = 0; i < items.length; i++) {
outputRange: [ promises.push(
currentLayouts[0].x, new Promise<Layout>(resolve => {
currentLayouts[1].x, itemRefs[i].current?.measure(
currentLayouts[2].x, (x: number, _y: number, width: number) => {
], resolve({x, width})
}), },
width: panX.interpolate({ )
inputRange: [-1, 0, 1], }),
outputRange: [
currentLayouts[0].width,
currentLayouts[1].width,
currentLayouts[2].width,
],
}),
}
const onLayout = () => {
const promises = []
for (let i = 0; i < items.length; i++) {
promises.push(
new Promise<Layout>(resolve => {
itemRefs[i].current?.measure(
(x: number, _y: number, width: number) => {
resolve({x, width})
},
)
}),
)
}
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> 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: {
+95 -92
View File
@@ -10,110 +10,113 @@ 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 function UserAvatar({ export const UserAvatar = register(
size, ({
handle, size,
avatar, handle,
displayName, avatar,
onSelectNewAvatar, displayName,
}: { onSelectNewAvatar,
size: number }: {
handle: string size: number
displayName: string | undefined handle: string
avatar?: string | null displayName: string | undefined
onSelectNewAvatar?: (img: PickedImage) => void avatar?: string | null
}) { onSelectNewAvatar?: (img: PickedImage) => void
const initials = getInitials(displayName || handle) }) => {
const gradient = getGradient(handle) const initials = getInitials(displayName || 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',
path: item.path, cropping: true,
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',
}, [onSelectNewAvatar]) onPress: () => {
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
+86 -83
View File
@@ -10,100 +10,103 @@ 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 function UserBanner({ export const UserBanner = register(
handle, ({
userBanner, handle,
setUserBanner, userBanner,
}: { setUserBanner,
handle: string }: {
userBanner?: string | null handle: string
setUserBanner?: React.Dispatch<React.SetStateAction<string | null>> userBanner?: 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',
path: item.path, cropping: true,
width: 1500, width: 1500,
height: 500, height: 500,
}).then(croppedItem => { }).then(item => {
if (setUserBanner != null) { if (setUserBanner != null) {
setUserBanner(croppedItem.path) setUserBanner(item.path)
} }
}) })
}) },
}, },
}, {
]) text: 'Select from gallery',
}, [setUserBanner]) onPress: () => {
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: {
+74 -67
View File
@@ -4,76 +4,83 @@ 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 function UserInfoText({ export const UserInfoText = register(
did, ({
attr, did,
loading, attr,
failed, loading,
prefix, failed,
style, prefix,
asLink, style,
}: { asLink,
did: string }: {
attr?: keyof GetProfile.OutputSchema did: string
loading?: string attr?: keyof GetProfile.OutputSchema
failed?: string loading?: string
prefix?: string failed?: string
style?: StyleProp<TextStyle> prefix?: string
asLink?: boolean style?: StyleProp<TextStyle>
}) { asLink?: boolean
attr = attr || 'handle' }) => {
loading = loading || '...' attr = attr || 'handle'
failed = failed || 'user' loading = loading || '...'
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)
},
) )
return () => { const [didFail, setFailed] = useState<boolean>(false)
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])
let inner if (asLink) {
if (didFail) { const title = profile?.displayName || profile?.handle || 'User'
inner = <Text style={style}>{failed}</Text> return (
} else if (profile) { <Link
inner = <Text style={style}>{`${prefix || ''}${profile[attr]}`}</Text> href={`/profile/${profile?.handle ? profile.handle : did}`}
} else { title={title}>
inner = ( {inner}
<LoadingPlaceholder </Link>
width={80} )
height={8} }
style={{position: 'relative', top: 1, left: 2}}
/>
)
}
if (asLink) { return inner
const title = profile?.displayName || profile?.handle || 'User' },
return ( )
<Link
href={`/profile/${profile?.handle ? profile.handle : did}`}
title={title}>
{inner}
</Link>
)
}
return inner
}
+109 -105
View File
@@ -1,5 +1,4 @@
import React from 'react' import React from 'react'
import {observer} from 'mobx-react-lite'
import { import {
ActivityIndicator, ActivityIndicator,
StyleSheet, StyleSheet,
@@ -11,116 +10,121 @@ 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 = observer(function ViewHeader({ export const ViewHeader = register(
title, observer(
subtitle, ({
onPost, title,
}: { subtitle,
title: string onPost,
subtitle?: string }: {
onPost?: () => void title: string
}) { subtitle?: string
const store = useStores() onPost?: () => void
const onPressBack = () => { }) => {
store.nav.tab.goBack() const store = useStores()
} const onPressBack = () => {
const onPressMenu = () => { store.nav.tab.goBack()
store.shell.setMainMenuOpen(true) }
} const onPressMenu = () => {
const onPressCompose = () => { store.shell.setMainMenuOpen(true)
store.shell.openComposer({onPost}) }
} const onPressCompose = () => {
const onPressSearch = () => { store.shell.openComposer({onPost})
store.nav.navigate(`/search`) }
} const onPressSearch = () => {
const onPressReconnect = () => { store.nav.navigate('/search')
store.session.connect().catch(e => { }
// log for debugging but ignore otherwise const onPressReconnect = () => {
console.log(e) store.session.connect().catch(e => {
}) // log for debugging but ignore otherwise
} console.log(e)
const canGoBack = store.nav.tab.canGoBack })
return ( }
<> const canGoBack = store.nav.tab.canGoBack
<View style={styles.header}> return (
<TouchableOpacity <>
onPress={canGoBack ? onPressBack : onPressMenu} <View style={styles.header}>
hitSlop={BACK_HITSLOP} <TouchableOpacity
style={styles.backIcon}> onPress={canGoBack ? onPressBack : onPressMenu}
<FontAwesomeIcon hitSlop={BACK_HITSLOP}
size={18} style={styles.backIcon}>
icon={canGoBack ? 'angle-left' : 'bars'}
style={{marginTop: 6}}
/>
</TouchableOpacity>
<View style={styles.titleContainer} pointerEvents="none">
<Text style={styles.title}>{title}</Text>
{subtitle ? (
<Text style={styles.subtitle} numberOfLines={1}>
{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 <FontAwesomeIcon
icon="x" size={18}
style={[ icon={canGoBack ? 'angle-left' : 'bars'}
s.red4, style={{marginTop: 6}}
{
backgroundColor: colors.gray6,
position: 'relative',
left: -4,
top: 6,
},
]}
border
size={12}
/> />
<Text style={[s.gray1, s.bold, s.flex1, s.pl2]}> </TouchableOpacity>
Unable to connect <View style={styles.titleContainer} pointerEvents="none">
</Text> <Text style={styles.title}>{title}</Text>
<View style={styles.offlineBtn}> {subtitle ? (
<Text style={styles.offlineBtnText}>Try again</Text> <Text style={styles.subtitle} numberOfLines={1}>
</View> {subtitle}
</> </Text>
)} ) : undefined}
</TouchableOpacity> </View>
) : undefined} <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: {
+80 -77
View File
@@ -4,92 +4,95 @@ 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 function ViewSelector({ export const ViewSelector = register(
sections, ({
items, sections,
refreshing, items,
swipeEnabled, refreshing,
renderHeader, swipeEnabled,
renderItem, renderHeader,
onSelectView, renderItem,
onRefresh, onSelectView,
onEndReached, onRefresh,
}: { onEndReached,
sections: string[] }: {
items: any[] sections: string[]
refreshing?: boolean items: any[]
swipeEnabled?: boolean refreshing?: boolean
renderHeader?: () => JSX.Element swipeEnabled?: boolean
renderItem: (item: any) => JSX.Element renderHeader?: () => JSX.Element
onSelectView?: (viewIndex: number) => void renderItem: (item: any) => JSX.Element
onRefresh?: () => void onSelectView?: (viewIndex: number) => void
onEndReached?: (info: {distanceFromEnd: number}) => void onRefresh?: () => void
}) { onEndReached?: (info: {distanceFromEnd: number}) => void
const store = useStores() }) => {
const [selectedIndex, setSelectedIndex] = useState<number>(0) const store = useStores()
const panX = useAnimatedValue(0) const [selectedIndex, setSelectedIndex] = useState<number>(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(() => {
onSelectView?.(selectedIndex)
}, [selectedIndex])
// rendering
// =
const renderItemInternal = ({item}: {item: any}) => {
if (item === HEADER_ITEM) {
if (renderHeader) {
return renderHeader()
} }
return <View />
} else if (item === SELECTOR_ITEM) {
return (
<Selector
items={sections}
panX={panX}
selectedIndex={selectedIndex}
onSelect={onPressSelection}
/>
)
} else {
return renderItem(item)
} }
} const onPressSelection = (index: number) => setSelectedIndex(index)
useEffect(() => {
onSelectView?.(selectedIndex)
}, [selectedIndex])
const data = [HEADER_ITEM, SELECTOR_ITEM, ...items] // rendering
return ( // =
<HorzSwipe
hasPriority const renderItemInternal = ({item}: {item: any}) => {
panX={panX} if (item === HEADER_ITEM) {
swipeEnabled={swipeEnabled || false} if (renderHeader) {
canSwipeLeft={selectedIndex > 0} return renderHeader()
canSwipeRight={selectedIndex < sections.length - 1} }
onSwipeEnd={onSwipeEnd}> return <View />
<FlatList } else if (item === SELECTOR_ITEM) {
data={data} return (
keyExtractor={item => item._reactKey} <Selector
renderItem={renderItemInternal} items={sections}
stickyHeaderIndices={STICKY_HEADER_INDICES} panX={panX}
refreshing={refreshing} selectedIndex={selectedIndex}
onRefresh={onRefresh} onSelect={onPressSelection}
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({})
+3 -2
View File
@@ -7,8 +7,9 @@ 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 = ({navIdx, visible, params}: ScreenParams) => { export const Contacts = register(({navIdx, visible, params}: ScreenParams) => {
const store = useStores() const store = useStores()
const selectorInterp = useAnimatedValue(0) const selectorInterp = useAnimatedValue(0)
@@ -50,7 +51,7 @@ export const Contacts = ({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 -90
View File
@@ -9,105 +9,106 @@ 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 = observer(function Home({ export const Home = register(
navIdx, observer(({navIdx, visible, scrollElRef}: ScreenParams) => {
visible, const store = useStores()
scrollElRef, const [hasSetup, setHasSetup] = useState<boolean>(false)
}: ScreenParams) { const {appState} = useAppState({
const store = useStores() onForeground: () => doPoll(true),
const [hasSetup, setHasSetup] = useState<boolean>(false)
const {appState} = useAppState({
onForeground: () => doPoll(true),
})
const defaultFeedView = useMemo<FeedModel>(
() =>
new FeedModel(store, 'home', {
algorithm: 'reverse-chronological',
}),
[store],
)
const doPoll = (knownActive = false) => {
if ((!knownActive && appState !== 'active') || !visible) {
return
}
if (defaultFeedView.isLoading) {
return
}
console.log('Polling home feed')
defaultFeedView.checkForLatest().catch(e => {
console.error('Failed to poll feed', e)
}) })
} const defaultFeedView = useMemo<FeedModel>(
() =>
new FeedModel(store, 'home', {
algorithm: 'reverse-chronological',
}),
[store],
)
useEffect(() => { const doPoll = (knownActive = false) => {
let aborted = false if ((!knownActive && appState !== 'active') || !visible) {
const pollInterval = setInterval(() => doPoll(), 15e3) return
if (!visible) { }
return if (defaultFeedView.isLoading) {
} return
}
if (hasSetup) { console.log('Polling home feed')
console.log('Updating home feed') defaultFeedView.checkForLatest().catch(e => {
defaultFeedView.update() console.error('Failed to poll feed', e)
} else {
store.nav.setTitle(navIdx, 'Home')
console.log('Fetching home feed')
defaultFeedView.setup().then(() => {
if (aborted) return
setHasSetup(true)
}) })
} }
return () => {
clearInterval(pollInterval) useEffect(() => {
aborted = true 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 () => {
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])
const onPressCompose = () => { return (
store.shell.openComposer({onPost: onCreatePost}) <View style={s.flex1}>
} <ViewHeader
const onCreatePost = () => { title="Bluesky"
defaultFeedView.loadLatest() subtitle="Private Beta"
} onPost={onCreatePost}
const onPressTryAgain = () => { />
defaultFeedView.refresh() <Feed
} key="default"
const onPressLoadLatest = () => { feed={defaultFeedView}
defaultFeedView.refresh() scrollElRef={scrollElRef}
scrollElRef?.current?.scrollToOffset({offset: 0}) style={{flex: 1}}
} onPressCompose={onPressCompose}
onPressTryAgain={onPressTryAgain}
return ( />
<View style={s.flex1}> {defaultFeedView.hasNewLatest ? (
<ViewHeader <TouchableOpacity
title="Bluesky" style={styles.loadLatest}
subtitle="Private Beta" onPress={onPressLoadLatest}
onPost={onCreatePost} hitSlop={HITSLOP}>
/> <FontAwesomeIcon icon="arrow-up" style={{color: colors.white}} />
<Feed <Text style={styles.loadLatestText}>Load new posts</Text>
key="default" </TouchableOpacity>
feed={defaultFeedView} ) : undefined}
scrollElRef={scrollElRef} </View>
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,6 +27,7 @@ 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,
@@ -589,6 +590,12 @@ 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 ? (
+3 -2
View File
@@ -2,8 +2,9 @@ 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 = () => { export const NotFound = register(() => {
const stores = useStores() const stores = useStores()
return ( return (
<View> <View>
@@ -19,4 +20,4 @@ export const NotFound = () => {
</View> </View>
</View> </View>
) )
} })
+3 -2
View File
@@ -5,8 +5,9 @@ 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 = ({navIdx, visible}: ScreenParams) => { export const Notifications = register(({navIdx, visible}: ScreenParams) => {
const store = useStores() const store = useStores()
useEffect(() => { useEffect(() => {
@@ -36,4 +37,4 @@ export const Notifications = ({navIdx, visible}: ScreenParams) => {
<Feed view={store.me.notifications} onPressTryAgain={onPressTryAgain} /> <Feed view={store.me.notifications} onPressTryAgain={onPressTryAgain} />
</View> </View>
) )
} })
+25 -22
View File
@@ -5,29 +5,32 @@ 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 = observer(() => { export const Onboard = register(
const store = useStores() observer(() => {
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])
let Com return (
if (store.onboard.stage === OnboardStage.Explainers) { <View style={{flex: 1, backgroundColor: '#fff'}}>
Com = FeatureExplainer <Com />
} else if (store.onboard.stage === OnboardStage.Follows) { </View>
Com = Follows )
} else { }),
Com = View )
}
return (
<View style={{flex: 1, backgroundColor: '#fff'}}>
<Com />
</View>
)
})
+19 -16
View File
@@ -5,22 +5,25 @@ 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 = ({navIdx, visible, params}: ScreenParams) => { export const PostDownvotedBy = register(
const store = useStores() ({navIdx, visible, params}: ScreenParams) => {
const {name, rkey} = params const store = useStores()
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) const {name, rkey} = params
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>
) )
} },
)
+19 -16
View File
@@ -5,22 +5,25 @@ 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 = ({navIdx, visible, params}: ScreenParams) => { export const PostRepostedBy = register(
const store = useStores() ({navIdx, visible, params}: ScreenParams) => {
const {name, rkey} = params const store = useStores()
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) const {name, rkey} = params
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>
) )
} },
)
+47 -44
View File
@@ -6,53 +6,56 @@ 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 = ({navIdx, visible, params}: ScreenParams) => { export const PostThread = register(
const store = useStores() ({navIdx, visible, params}: ScreenParams) => {
const {name, rkey} = params const store = useStores()
const [viewSubtitle, setViewSubtitle] = useState<string>(`by ${name}`) const {name, rkey} = params
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) const [viewSubtitle, setViewSubtitle] = useState<string>(`by ${name}`)
const view = useMemo<PostThreadViewModel>( const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
() => new PostThreadViewModel(store, {uri}), const view = useMemo<PostThreadViewModel>(
[uri], () => new PostThreadViewModel(store, {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
} }
setTitle() useEffect(() => {
if (!view.hasLoaded && !view.isLoading) { let aborted = false
console.log('Fetching post thread', uri) if (!visible) {
view.setup().then( return
() => { }
if (!aborted) { setTitle()
setTitle() if (!view.hasLoaded && !view.isLoading) {
} console.log('Fetching post thread', uri)
}, view.setup().then(
err => { () => {
console.error('Failed to fetch thread', err) if (!aborted) {
}, setTitle()
) }
} },
return () => { err => {
aborted = true console.error('Failed to fetch thread', err)
} },
}, [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}}>
<PostThreadComponent uri={uri} view={view} /> <ViewHeader title="Post" subtitle={viewSubtitle} />
<View style={{flex: 1}}>
<PostThreadComponent uri={uri} view={view} />
</View>
</View> </View>
</View> )
) },
} )
+19 -16
View File
@@ -5,22 +5,25 @@ 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 = ({navIdx, visible, params}: ScreenParams) => { export const PostUpvotedBy = register(
const store = useStores() ({navIdx, visible, params}: ScreenParams) => {
const {name, rkey} = params const store = useStores()
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) const {name, rkey} = params
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>
) )
} },
)
+214 -209
View File
@@ -18,243 +18,248 @@ 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 = observer(({navIdx, visible, params}: ScreenParams) => { export const Profile = register(
const store = useStores() observer(({navIdx, visible, params}: ScreenParams) => {
const [hasSetup, setHasSetup] = useState<boolean>(false) const store = useStores()
const uiState = useMemo( const [hasSetup, setHasSetup] = useState<boolean>(false)
() => new ProfileUiModel(store, {user: params.name}), const uiState = useMemo(
[params.user], () => new ProfileUiModel(store, {user: params.name}),
) [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`)
},
),
) )
}
// rendering 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])
const isSceneCreator = // events
uiState.isScene && store.me.did === uiState.profile.creator // =
const renderHeader = () => { const onSelectView = (index: number) => {
if (!uiState) { uiState.setSelectedViewIndex(index)
return <View />
} }
return <ProfileHeader view={uiState.profile} onRefreshAll={onRefresh} /> const onRefresh = () => {
} uiState
let renderItem .refresh()
let items: any[] = [] .catch((err: any) => console.error('Failed to refresh', err))
if (uiState) { }
if (uiState.isInitialLoading) { const onEndReached = () => {
items.push(LOADING_ITEM) uiState
renderItem = () => <PostFeedLoadingPlaceholder /> .loadMore()
} else if (uiState.currentView.hasError) { .catch((err: any) => console.error('Failed to load more', err))
items.push({ }
_reactKey: '__error__', const onPressTryAgain = () => {
error: uiState.currentView.error, uiState.setup()
}) }
renderItem = (item: any) => ( const onPressRemoveMember = (membership: MembershipItem) => {
<View style={s.p5}> store.shell.openModal(
<ErrorMessage new ConfirmModel(
dark `Remove ${membership.displayName || membership.handle}?`,
message={item.error} "You'll be able to invite them again if you change your mind.",
onPressTryAgain={onPressTryAgain} async () => {
/> await uiState.members.removeMember(membership.did)
</View> Toast.show('User removed')
},
),
) )
} else { }
if (
uiState.selectedView === Sections.Posts || // rendering
uiState.selectedView === Sections.PostsWithReplies || // =
uiState.selectedView === Sections.Trending
) { const isSceneCreator =
if (uiState.feed.hasContent) { uiState.isScene && store.me.did === uiState.profile.creator
if (uiState.selectedView === Sections.Posts) {
items = uiState.feed.nonReplyFeed const renderHeader = () => {
} else { if (!uiState) {
items = uiState.feed.feed.slice() return <View />
} }
if (!uiState.feed.hasMore) { return <ProfileHeader view={uiState.profile} onRefreshAll={onRefresh} />
items.push(END_ITEM) }
} let renderItem
renderItem = (item: any) => { let items: any[] = []
if (item === END_ITEM) { if (uiState) {
return <Text style={styles.endItem}>- end of feed -</Text> 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}}
/>
)
} }
return <FeedItem item={item} />
} }
} else if (uiState.feed.isEmpty) { } else if (uiState.selectedView === Sections.Scenes) {
items.push(EMPTY_ITEM) if (uiState.memberships.hasContent) {
if (uiState.profile.isScene) { 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 = () => ( renderItem = () => (
<EmptyState <EmptyState
icon="user-group" icon="user-group"
message="As members upvote posts, they will trend here. Follow the scene to see its trending posts in your timeline." message="This user hasn't joined any scenes."
/> />
) )
} else { }
} 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 = () => ( renderItem = () => (
<EmptyState <EmptyState
icon={['far', 'message']} icon="user-group"
message="No posts yet!" message="This scene doesn't have any members."
style={{paddingVertical: 40}}
/> />
) )
} }
} } else {
} 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) items.push(EMPTY_ITEM)
renderItem = () => ( renderItem = () => <Text>TODO</Text>
<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) {
if (!renderItem) { renderItem = () => <View />
renderItem = () => <View /> }
}
const title = const title =
uiState.profile.displayName || uiState.profile.handle || params.name uiState.profile.displayName || uiState.profile.handle || params.name
return ( return (
<View style={styles.container}> <View style={styles.container}>
<ViewHeader title={title} /> <ViewHeader title={title} />
{uiState.profile.hasError ? ( {uiState.profile.hasError ? (
<ErrorScreen <ErrorScreen
title="Failed to load profile" title="Failed to load profile"
message={`There was an issue when attempting to load ${params.name}`} message={`There was an issue when attempting to load ${params.name}`}
details={uiState.profile.error} details={uiState.profile.error}
onPressTryAgain={onPressTryAgain} onPressTryAgain={onPressTryAgain}
/> />
) : uiState.profile.hasLoaded ? ( ) : uiState.profile.hasLoaded ? (
<ViewSelector <ViewSelector
swipeEnabled swipeEnabled
sections={uiState.selectorItems} sections={uiState.selectorItems}
items={items} items={items}
renderHeader={renderHeader} renderHeader={renderHeader}
renderItem={renderItem} renderItem={renderItem}
refreshing={uiState.isRefreshing || false} refreshing={uiState.isRefreshing || false}
onSelectView={onSelectView} onSelectView={onSelectView}
onRefresh={onRefresh} onRefresh={onRefresh}
onEndReached={onEndReached} onEndReached={onEndReached}
/> />
) : ( ) : (
renderHeader() renderHeader()
)} )}
</View> </View>
) )
}) }),
)
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
+18 -15
View File
@@ -4,21 +4,24 @@ 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 = ({navIdx, visible, params}: ScreenParams) => { export const ProfileFollowers = register(
const store = useStores() ({navIdx, visible, params}: ScreenParams) => {
const {name} = params const store = useStores()
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>
) )
} },
)
+18 -15
View File
@@ -4,21 +4,24 @@ 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 = ({navIdx, visible, params}: ScreenParams) => { export const ProfileFollows = register(
const store = useStores() ({navIdx, visible, params}: ScreenParams) => {
const {name} = params const store = useStores()
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>
) )
} },
)
+18 -15
View File
@@ -4,21 +4,24 @@ 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 = ({navIdx, visible, params}: ScreenParams) => { export const ProfileMembers = register(
const store = useStores() ({navIdx, visible, params}: ScreenParams) => {
const {name} = params const store = useStores()
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>
) )
} },
)
+3 -2
View File
@@ -16,8 +16,9 @@ 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 = ({navIdx, visible, params}: ScreenParams) => { export const Search = register(({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>('')
@@ -92,7 +93,7 @@ export const Search = ({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 = observer(function Settings({ export const Settings = register(
navIdx, observer(function Settings({navIdx, visible}: ScreenParams) {
visible, const store = useStores()
}: 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])
const onPressSignout = () => { return (
store.session.logout() <View style={[s.flex1]}>
} <ViewHeader title="Settings" />
<View style={[s.mt10, s.pl10, s.pr10]}>
return ( <View style={[s.flexRow]}>
<View style={[s.flex1]}> <Text>Signed in as</Text>
<ViewHeader title="Settings" /> <View style={s.flex1} />
<View style={[s.mt10, s.pl10, s.pr10]}> <TouchableOpacity onPress={onPressSignout}>
<View style={[s.flexRow]}> <Text style={[s.blue3, s.bold]}>Sign out</Text>
<Text>Signed in as</Text> </TouchableOpacity>
<View style={s.flex1} />
<TouchableOpacity onPress={onPressSignout}>
<Text style={[s.blue3, s.bold]}>Sign out</Text>
</TouchableOpacity>
</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> </View>
</Link> <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>
</View> )
) }),
}) )
const styles = StyleSheet.create({ const styles = StyleSheet.create({
title: { title: {
+52 -49
View File
@@ -4,62 +4,65 @@ 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 = observer( export const Composer = register(
({ observer(
active, ({
winHeight, active,
replyTo, winHeight,
onPost, replyTo,
onClose, onPost,
}: { onClose,
active: boolean }: {
winHeight: number active: boolean
replyTo?: ComposerOpts['replyTo'] winHeight: number
onPost?: ComposerOpts['onPost'] replyTo?: ComposerOpts['replyTo']
onClose: () => void onPost?: ComposerOpts['onPost']
}) => { 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,6 +10163,11 @@ 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"