using register
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]}>
|
||||
· {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]}>
|
||||
· {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: {
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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: {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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({})
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user