diff options
Diffstat (limited to 'src/view/com/composer')
-rw-r--r-- | src/view/com/composer/Autocomplete.tsx | 76 | ||||
-rw-r--r-- | src/view/com/composer/ComposePost.tsx | 233 |
2 files changed, 309 insertions, 0 deletions
diff --git a/src/view/com/composer/Autocomplete.tsx b/src/view/com/composer/Autocomplete.tsx new file mode 100644 index 000000000..4e4bdfc8e --- /dev/null +++ b/src/view/com/composer/Autocomplete.tsx @@ -0,0 +1,76 @@ +import React, {useEffect} from 'react' +import { + useWindowDimensions, + Text, + TouchableOpacity, + StyleSheet, +} from 'react-native' +import Animated, { + useSharedValue, + useAnimatedStyle, + withTiming, + interpolate, +} from 'react-native-reanimated' +import {colors} from '../../../lib/styles' + +export function Autocomplete({ + active, + items, + onSelect, +}: { + active: boolean + items: string[] + onSelect: (item: string) => void +}) { + const winDim = useWindowDimensions() + const positionInterp = useSharedValue<number>(0) + + useEffect(() => { + if (active) { + positionInterp.value = withTiming(1, {duration: 250}) + } else { + positionInterp.value = withTiming(0, {duration: 250}) + } + }, [positionInterp, active]) + + const topAnimStyle = useAnimatedStyle(() => ({ + top: interpolate( + positionInterp.value, + [0, 1.0], + [winDim.height, winDim.height / 4], + ), + })) + return ( + <Animated.View style={[styles.outer, topAnimStyle]}> + {items.map((item, i) => ( + <TouchableOpacity + key={i} + style={styles.item} + onPress={() => onSelect(item)}> + <Text style={styles.itemText}>@{item}</Text> + </TouchableOpacity> + ))} + </Animated.View> + ) +} + +const styles = StyleSheet.create({ + outer: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + backgroundColor: colors.white, + borderTopWidth: 1, + borderTopColor: colors.gray2, + }, + item: { + borderBottomWidth: 1, + borderBottomColor: colors.gray1, + paddingVertical: 16, + paddingHorizontal: 16, + }, + itemText: { + fontSize: 16, + }, +}) diff --git a/src/view/com/composer/ComposePost.tsx b/src/view/com/composer/ComposePost.tsx new file mode 100644 index 000000000..496b49a9b --- /dev/null +++ b/src/view/com/composer/ComposePost.tsx @@ -0,0 +1,233 @@ +import React, {useEffect, useMemo, useState} from 'react' +import {StyleSheet, Text, TextInput, TouchableOpacity, View} from 'react-native' +import LinearGradient from 'react-native-linear-gradient' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import * as GetUserFollows from '../../../third-party/api/src/types/app/bsky/getUserFollows' +import {Autocomplete} from './Autocomplete' +import Toast from '../util/Toast' +import ProgressCircle from '../util/ProgressCircle' +import {useStores} from '../../../state' +import * as apilib from '../../../state/lib/api' +import {ComposerOpts} from '../../../state/models/shell' +import {s, colors, gradients} from '../../lib/styles' + +const MAX_TEXT_LENGTH = 256 +const WARNING_TEXT_LENGTH = 200 +const DANGER_TEXT_LENGTH = 255 + +export function ComposePost({ + replyTo, + onPost, + onClose, +}: { + replyTo?: ComposerOpts['replyTo'] + onPost?: ComposerOpts['onPost'] + onClose: () => void +}) { + const store = useStores() + const [error, setError] = useState('') + const [text, setText] = useState('') + const [followedUsers, setFollowedUsers] = useState< + undefined | GetUserFollows.OutputSchema['follows'] + >(undefined) + const [autocompleteOptions, setAutocompleteOptions] = useState<string[]>([]) + + useEffect(() => { + let aborted = false + store.api.app.bsky + .getUserFollows({ + user: store.me.did || '', + }) + .then(res => { + if (aborted) return + setFollowedUsers(res.data.follows) + }) + return () => { + aborted = true + } + }) + + const onChangeText = (newText: string) => { + if (newText.length > MAX_TEXT_LENGTH) { + newText = newText.slice(0, MAX_TEXT_LENGTH) + } + setText(newText) + + const prefix = extractTextAutocompletePrefix(newText) + if (typeof prefix === 'string' && followedUsers) { + setAutocompleteOptions( + [prefix].concat( + followedUsers + .filter(user => user.name.startsWith(prefix)) + .map(user => user.name), + ), + ) + } else if (autocompleteOptions) { + setAutocompleteOptions([]) + } + } + const onPressCancel = () => { + onClose() + } + const onPressPublish = async () => { + setError('') + if (text.trim().length === 0) { + setError('Did you want to say anything?') + return false + } + try { + await apilib.post(store, text, replyTo) + } catch (e: any) { + console.error(`Failed to create post: ${e.toString()}`) + setError( + 'Post failed to upload. Please check your Internet connection and try again.', + ) + return + } + onPost?.() + onClose() + Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`, { + duration: Toast.durations.LONG, + position: Toast.positions.TOP, + shadow: true, + animation: true, + hideOnPress: true, + }) + } + const onSelectAutocompleteItem = (item: string) => { + setText(replaceTextAutocompletePrefix(text, item)) + setAutocompleteOptions([]) + } + + const progressColor = + text.length > DANGER_TEXT_LENGTH + ? '#e60000' + : text.length > WARNING_TEXT_LENGTH + ? '#f7c600' + : undefined + + const textDecorated = useMemo(() => { + return (text || '').split(/(\s)/g).map((item, i) => { + if (/^@[a-zA-Z0-9\.-]+$/g.test(item)) { + return ( + <Text key={i} style={{color: colors.blue3}}> + {item} + </Text> + ) + } + return item + }) + }, [text]) + + return ( + <View style={styles.outer}> + <View style={styles.topbar}> + <TouchableOpacity onPress={onPressCancel}> + <Text style={[s.blue3, s.f16]}>Cancel</Text> + </TouchableOpacity> + <View style={s.flex1} /> + <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]}>Post</Text> + </LinearGradient> + </TouchableOpacity> + </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> + )} + <TextInput + multiline + scrollEnabled + autoFocus + onChangeText={(text: string) => onChangeText(text)} + placeholder={replyTo ? 'Write your reply' : "What's new?"} + style={styles.textInput}> + {textDecorated} + </TextInput> + <View style={[s.flexRow, s.pt10, s.pb10, s.pr5]}> + <View style={s.flex1} /> + <View> + <ProgressCircle + color={progressColor} + progress={text.length / MAX_TEXT_LENGTH} + /> + </View> + </View> + <Autocomplete + active={autocompleteOptions.length > 0} + items={autocompleteOptions} + onSelect={onSelectAutocompleteItem} + /> + </View> + ) +} + +const atPrefixRegex = /@([\S]*)$/i +function extractTextAutocompletePrefix(text: string) { + const match = atPrefixRegex.exec(text) + if (match) { + return match[1] + } + return undefined +} +function replaceTextAutocompletePrefix(text: string, item: string) { + return text.replace(atPrefixRegex, `@${item} `) +} + +const styles = StyleSheet.create({ + outer: { + flexDirection: 'column', + backgroundColor: '#fff', + padding: 15, + height: '100%', + }, + topbar: { + flexDirection: 'row', + alignItems: 'center', + paddingTop: 10, + paddingBottom: 5, + paddingHorizontal: 5, + }, + postBtn: { + borderRadius: 20, + paddingHorizontal: 20, + paddingVertical: 6, + }, + errorLine: { + flexDirection: 'row', + backgroundColor: colors.red1, + borderRadius: 6, + paddingHorizontal: 8, + paddingVertical: 6, + marginVertical: 6, + }, + errorIcon: { + borderWidth: 1, + borderColor: colors.red4, + color: colors.red4, + borderRadius: 30, + width: 16, + height: 16, + alignItems: 'center', + justifyContent: 'center', + marginRight: 5, + }, + textInput: { + flex: 1, + padding: 5, + fontSize: 18, + }, +}) |