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