diff options
author | Paul Frazee <pfrazee@gmail.com> | 2023-02-22 14:23:57 -0600 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-02-22 14:23:57 -0600 |
commit | f28334739b107f3e9f7b6ca2670778dba280600d (patch) | |
tree | 4e1563242e1a041c5d5483ab018123170dcb3fc8 /src/view | |
parent | 7916b26aadb7e003728d9dc653ab8b8deabf4076 (diff) | |
download | voidsky-f28334739b107f3e9f7b6ca2670778dba280600d.tar.zst |
Merge main into the Web PR (#230)
* Update to RN 71.1.0 (#100) * Update to RN 71 * Adds missing lint plugin * Add missing native changes * Bump @atproto/api@0.0.7 (#112) * Image not loading on swipe (#114) * Adds prefetching to images * Adds image prefetch * bugfix for images not showing on swipe * Fixes prefetch bug * Update src/view/com/util/PostEmbeds.tsx --------- Co-authored-by: Paul Frazee <pfrazee@gmail.com> * Fixes to session management (#117) * Update session-management to solve incorrectly dropped sessions * Reset the nav on account switch * Reset the feed on me.load() * Update tests to reflect new account-switching behavior * Increase max image resolutions and sizes (#118) * Slightly increase the hitslop for post controls * Fix character counter color in dark mode * Update login to use new session.create api, which enables email login (close #93) (#119) * Replaces the alert with dropdown for profile image and banner (#123) * replaces the alert with dropdown for profile image and banner * lint * Fix to ordering of images in the embed grid (#121) * Add explicit link-embed controls to the composer (#120) * Add explicit link-embed controls * Update the target rez/size of link embed thumbs * Remove the alert before publishing without a link card * [Draft] Fixes image failing on reupload issue (#128) * Fixes image failing on reupload issue * Use tmp folder instead of documents * lint * Image performance improvements (#126) * Switch out most images for FastImage * Add image loading placeholders * Fix tests * Collection of fixes to list rendering (#127) * Fix bug that caused endless spinners in profile feeds * Bundle fetches of suggested actors into one update * Fixes to suggested follow rendering * Fix missing replacement of flex:1 to height:100 * Fixes to navigation swipes (#129) * Nav swipe: increase the distance traveled in response to gesture movement. This causes swipes to feel faster and more responsive. * Fix: fully clamp the swipe against the edge * Improve the performance of swipes by skipping the interaction manager * Adds dark mode to the edit screen (#130) * Adds dark mode to edit screen * lint * lint * lint * Reduce render cost of post controls and improve perceived responsiveness (#132) * Move post control animations into conditional render and increase perceived responsiveness * Remove log * Adds dark mode to the dropdown (#131) * Adds dark mode to the bottom sheet * Make background button lighter (like before) * lint * Fix bug in lightbox rendering (#133) * Fix layout in onboarding to not overflow the footer * Configure feed FlatList (removeClippedSubviews=true) to improve scroll performance (#136) * Disable like/repost animations to see if theyre causing #135 (#137) * Composer: mention tagging now works in middle of text (close #105) (#139) * Implement account deletion (#141) * Fix photo & camera permission management (#140) * Check photo & camera perms and alert the user if not available (close #64) - Adds perms checks with a prompt to update settings if needed - Moves initial access of photos in the composer so that the initial prompt occurs at an intuitive time. * Add react-native-permissions test mock * Fix issue causing multiple access requests * Use longer var names * Update podfile.lock * Lint fix * Move photo perm request in composer to the gallery btn instead of when the carousel is opened * Adds more tracking all around the app (#142) * Adds more tracking all around the app * more events * lint * using better analytics naming * missed file * more fixes * Calculate image aspect ratio on load (#146) * Calculate image aspect ratio on load * Move aspect ratio bounds to constants * Adds detox testing and instructions (#147) * Adds detox testing and instructions * lint * lint * Error cleanup (close #79) (#148) * Avoid surfacing errors to the user when it's not critical * Remove now-unused GetAssertionsView * Apply cleanError() consistently * Give a better error message for Upstream Failures (http status 502) * Hide errors in notifications because they're not useful * More e2e tests (create account) (#150) * Adds respots under the 'post' tab under profile (#158) * Adds dark mode to delete account screen (#159) * 87 dark mode edit profile (#162) * Adds dark mode to delete account screen * Adds one more missed darkmode * more fixes * Remove fallback gradient on external links without thumbs (#164) * Remove fallback gradient on external links without thumbs * Remove fallback gradient on external links without thumbs in the composer preview * Fix refresh behavior around a series of models (repost, graph, vote) (#163) * Fix refresh behavior around a series of models (repost, graph, vote) * Fix cursor behavior in reposted-by view * Fixes issue where retrying on image upload fails (#166) * Fixes issue where retrying on image upload fails * Lint, longer test time * Longer waitfor time in tests * even longer timeout * longer timeout * missed file * Update src/view/com/composer/ComposePost.tsx Co-authored-by: Paul Frazee <pfrazee@gmail.com> * Update src/view/com/composer/ComposePost.tsx Co-authored-by: Paul Frazee <pfrazee@gmail.com> --------- Co-authored-by: Paul Frazee <pfrazee@gmail.com> * 154 cached image profile (#167) * Fixes issue where retrying on image upload fails * Lint, longer test time * Longer waitfor time in tests * even longer timeout * longer timeout * missed file * Fixes image cache error on second try for profile screen * lint * lint * lint * Refactor session management to use a new "Agent" API (#165) * Add the atp-agent implementation (temporarily in this repo) * Rewrite all session & API management to use the new atp-agent * Update tests for the atp-agent refactor * Refactor management of session-related state. Includes: - More careful management of when state is cleared or fetched - Debug logging to help trace future issues - Clearer APIs overall * Bubble session-expiration events to the user and display a toast to explain * Switch to the new @atproto/api@0.1.0 * Minor aesthetic cleanup in SessionModel * Wire up ReportAccount and ReportPost (#168) * Fixes embeds for youtube channels (#169) * Bump app ios version to 1.1 (needed after app store submission) * Fix potential issues with promise guards when an error occurs (#170) * Refactor models to use bundleAsync and lock regions (#171) * Fix to an edge case with feed re-ordering for threads (#172) * 151 fix youtube channel embed (#173) * Fixes embeds for youtube channels * Tests for youtube extract meta * lint * Add 'doesnt use non-exempt encryption' to ios config * Rework the search UI and add (#174) * Add search tab and move icon to footer * Remove subtitles from view header * Remove unused code * Clean up UI of search screen * Search: give better user feedback to UI state and add a cancel button * Add WhoToFollow section to search * Add a temporary SuggestedPosts solution using the patented 'bsky team algo' * Trigger reload of suggested content in search on open * Wait five min between reloading discovery content * Reduce weight of solid search icon in footer * Fix lint * Fix tests * 151 feat youtube embed iframe (#176) * youtube embed iframe temp commit * Fixes styling and code cleanup * lint * Now clicking between the pause and settings button doesn't trigger the parent * use modest branding (less yt logos) * Stop playing the video once there's a navigation event * Make sure the iframe is unmounted on any navigation event * fixes tests * lint * Add scroll-to-top for all screens (#177) * Adds hardcoded suggested list (#178) * Adds hardcoded suggested list * Update suggested-actors-view to support page sizes smaller than the hardcoded list --------- Co-authored-by: Paul Frazee <pfrazee@gmail.com> * more robust centering of the play button (#181) Co-authored-by: Aryan Goharzad <arrygoo@gmail.com> * Bundle of UI modifications (#175) * Adjust visual balance of SuggestedPosts and WhoToFollow * Fix bug in the discovery load trigger * Adjust search header aesthetic and have it scroll away * More visual balance tweaks on the search page * Even more visual balance tweaks on the search page * Hide the footer on scroll in search * Ditch the composer prompt buttons in the home feed * Center the view header title * Hide header on scroll on the home feed * Fix e2e tests * Fix home feed positioning (closes #189) (#195) * Fix home feed positioning for floating header * Fix positioning of errors in home feed * Fix lint * Don't show new-content notification for reposts (close #179) (#197) * Show the splash screen during session resumption (close #186) (#199) * Fix to suggested follows: chunk the hardcoded fetches to 25 at a time (close #196) (#198) * UI updates to the floating action button (#201) * Update FAB to use a plus icon and not drop shadow * Update FAB positioning to be more consistent in different shell modes * Animate the FAB's repositioning * Remove the 'loading' placeholder from images as it degraded feed perf (#202) * Remove the 'loading' placeholder from images as it degraded feed perf * Remove references * Fix RN bug that causes home feed not to load more; also fix home feed load view. (#208) RN has a bug where rendering a flatlist with an empty array appears to break its virtual list windowing behaviors. See https://stackoverflow.com/a/67873596 * Only give the loading spinner on the home feed during PTR (#207) (cherry picked from commit b7a5da12fdfacef74873b5cf6d75f20d259bde0e) * Implement our own lifecycle tracking to ensure it never fires while the app is backgrounded (close #193) (#211) * Push notification fixes (#210) * Fix to when screen analytics events are firing * Fix: dont trigger update state when backgrounded * Small fix to notifee API usage * Fix: properly load notification info for push card * Add feedback link to main menu (close #191) (#212) * Add "follows you" information and sync follow state between views (#215) * Bump @atproto/api@0.1.2 and update API usage * Add 'follows you' pill to profile header (close #110) * Add 'follows you' to followers and follows (close #103) * Update reposted-by and liked-by views to use the same components as followers and following * Create a local follows cache MyFollowsModel to keep views in sync (close #205) * Add incremental hydration to the MyFollows model * Fix tests * Update deps * Fix lint * Fix to paginated fetches * Fix reference * Fix potential state-desync issue * Fixes to notifications (#216) * Improve push-notification for follows * Refresh notifications on screen open (close #214) * Avoid showing loader more than needed in post threads * Refactor notification polling to handle view-state more effectively * Delete a bunch of tests taht werent adding value * Remove the accounts integration test; we'll use the e2e test instead * Load latest in notifications when the screen is open rather than full refresh * Randomize hard-coded suggested follows (#226) * Ensure follows are loaded before filtering hardcoded suggestions * Randomize hard-coded suggested profiles (close #219) * Sanitizes posts on publish and render (#217) * Sanatizes posts on publish and render * lint * lint and added sanitize to thread view as well * adjusts indices based on replaced text * Woops, fixes a bug * bugfix + cleanup * comment * lint * move sanitize text to later in the flow * undo changes to compose post * Add RichText library building upon the sanitizePost library method * Add lodash.clonedeep dep * Switch to RichText processing on record load & render * Fix lint --------- Co-authored-by: Paul Frazee <pfrazee@gmail.com> * A group of notifications fixes (#227) * Fix: don't group together notifications that can't visually be grouped (close #221) * Mark all notifications read on PTR * Small optimization: useCallback and useMemo in posts feed * Add loading spinner to footer of notifications (close #222) * Fix to scrolling to posts within a thread (#228) * Fix: render the entire thread at start so that scrollToIndex works always (close #270) * Visual fixes to thread 'load more' * A few small perf improvements to thread rendering * Fix lint * 1.2 * Remove unused logger lib * Remove state-mock * Type fixes * Reorganize the folder structure for lib and switch to typescript path aliases * Move build-flags into lib * Move to the state path alias * Add view path alias * Fix lint * iOS build fixes * Wrap analytics in native/web splitter and re-enable in all view code * Add web version of react-native-webview * Add web split for version number * Fix BlurView import for web * Add web split for fastimage * Create web split for permissions lib * Fix for web high priority images --------- Co-authored-by: Aryan Goharzad <arrygoo@gmail.com>
Diffstat (limited to 'src/view')
130 files changed, 2786 insertions, 3094 deletions
diff --git a/src/view/com/composer/Autocomplete.tsx b/src/view/com/composer/Autocomplete.tsx index 94381e644..c17e41b37 100644 --- a/src/view/com/composer/Autocomplete.tsx +++ b/src/view/com/composer/Autocomplete.tsx @@ -5,9 +5,9 @@ import { StyleSheet, useWindowDimensions, } from 'react-native' -import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import {usePalette} from 'lib/hooks/usePalette' import {Text} from '../util/text/Text' -import {usePalette} from '../../lib/hooks/usePalette' interface AutocompleteItem { handle: string diff --git a/src/view/com/composer/ComposePost.tsx b/src/view/com/composer/ComposePost.tsx index 1144b5e48..6431a11aa 100644 --- a/src/view/com/composer/ComposePost.tsx +++ b/src/view/com/composer/ComposePost.tsx @@ -3,10 +3,12 @@ import {observer} from 'mobx-react-lite' import { ActivityIndicator, KeyboardAvoidingView, + NativeSyntheticEvent, Platform, SafeAreaView, ScrollView, StyleSheet, + TextInputSelectionChangeEventData, TouchableOpacity, TouchableWithoutFeedback, View, @@ -16,8 +18,9 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -// import {useAnalytics} from '@segment/analytics-react-native' TODO -import {UserAutocompleteViewModel} from '../../../state/models/user-autocomplete-view' +import {useAnalytics} from 'lib/analytics' +import _isEqual from 'lodash.isequal' +import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' import {Autocomplete} from './Autocomplete' import {ExternalEmbed} from './ExternalEmbed' import {Text} from '../util/text/Text' @@ -26,24 +29,27 @@ import {TextInput, TextInputRef} from './text-input/TextInput' import {CharProgress} from './char-progress/CharProgress' import {TextLink} from '../util/Link' import {UserAvatar} from '../util/UserAvatar' -import {useStores} from '../../../state' -import * as apilib from '../../../state/lib/api' -import {ComposerOpts} from '../../../state/models/shell-ui' -import {s, colors, gradients} from '../../lib/styles' -import { - detectLinkables, - extractEntities, - cleanError, -} from '../../../lib/strings' -import {getLinkMeta} from '../../../lib/link-meta' -import {downloadAndResize} from '../../../lib/images' +import {useStores} from 'state/index' +import * as apilib from 'lib/api/index' +import {ComposerOpts} from 'state/models/shell-ui' +import {s, colors, gradients} from 'lib/styles' +import {cleanError} from 'lib/strings/errors' +import {detectLinkables, extractEntities} from 'lib/strings/rich-text-detection' +import {getLinkMeta} from 'lib/link-meta/link-meta' +import {downloadAndResize} from 'lib/images' import {PhotoCarouselPicker, cropPhoto} from './photos/PhotoCarouselPicker' +import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip' import {SelectedPhoto} from './SelectedPhoto' -import {usePalette} from '../../lib/hooks/usePalette' +import {usePalette} from 'lib/hooks/usePalette' const MAX_TEXT_LENGTH = 256 const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} +interface Selection { + start: number + end: number +} + export const ComposePost = observer(function ComposePost({ replyTo, imagesOpen, @@ -55,10 +61,11 @@ export const ComposePost = observer(function ComposePost({ onPost?: ComposerOpts['onPost'] onClose: () => void }) { - // const {track} = useAnalytics() TODO + const {track, screen} = useAnalytics() const pal = usePalette('default') const store = useStores() const textInput = useRef<TextInputRef>(null) + const textInputSelection = useRef<Selection>({start: 0, end: 0}) const [isProcessing, setIsProcessing] = useState(false) const [processingState, setProcessingState] = useState('') const [error, setError] = useState('') @@ -66,7 +73,9 @@ export const ComposePost = observer(function ComposePost({ const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>( undefined, ) - const [attemptedExtLinks, setAttemptedExtLinks] = useState<string[]>([]) + const [suggestedExtLinks, setSuggestedExtLinks] = useState<Set<string>>( + new Set(), + ) const [isSelectingPhotos, setIsSelectingPhotos] = useState( imagesOpen || false, ) @@ -117,10 +126,10 @@ export const ComposePost = observer(function ComposePost({ if (extLink.isLoading && extLink.meta?.image && !extLink.localThumb) { downloadAndResize({ uri: extLink.meta.image, - width: 250, - height: 250, + width: 2000, + height: 2000, mode: 'contain', - maxSize: 100000, + maxSize: 1000000, timeout: 15e3, }) .catch(() => undefined) @@ -166,6 +175,7 @@ export const ComposePost = observer(function ComposePost({ textInput.current?.focus() } const onPressSelectPhotos = () => { + track('ComposePost:SelectPhotos') if (isSelectingPhotos) { setIsSelectingPhotos(false) } else if (selectedPhotos.length < 4) { @@ -173,35 +183,31 @@ export const ComposePost = observer(function ComposePost({ } } const onSelectPhotos = (photos: string[]) => { + track('ComposePost:SelectPhotos:Done') setSelectedPhotos(photos) if (photos.length >= 4) { setIsSelectingPhotos(false) } } + const onPressAddLinkCard = (uri: string) => { + setExtLink({uri, isLoading: true}) + } const onChangeText = (newText: string) => { setText(newText) - const prefix = extractTextAutocompletePrefix(newText) - if (typeof prefix === 'string') { + const prefix = getMentionAt(newText, textInputSelection.current?.start || 0) + if (prefix) { autocompleteView.setActive(true) - autocompleteView.setPrefix(prefix) + autocompleteView.setPrefix(prefix.value) } else { autocompleteView.setActive(false) } - if (!extLink && /\s$/.test(newText)) { - const ents = extractEntities(newText) - const entLink = ents - ?.filter( - ent => ent.type === 'link' && !attemptedExtLinks.includes(ent.value), - ) - .pop() // use last - if (entLink) { - setExtLink({ - uri: entLink.value, - isLoading: true, - }) - setAttemptedExtLinks([...attemptedExtLinks, entLink.value]) + if (!extLink) { + const ents = extractEntities(newText)?.filter(ent => ent.type === 'link') + const set = new Set(ents ? ents.map(e => e.value) : []) + if (!_isEqual(set, suggestedExtLinks)) { + setSuggestedExtLinks(set) } } } @@ -218,6 +224,16 @@ export const ComposePost = observer(function ComposePost({ onSelectPhotos([...selectedPhotos, finalImgPath]) } } + const onSelectionChange = ( + evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>, + ) => { + // NOTE we track the input selection using a ref to avoid excessive renders -prf + textInputSelection.current = evt.nativeEvent.selection + } + const onSelectAutocompleteItem = (item: string) => { + setText(insertMentionAt(text, textInputSelection.current?.start || 0, item)) + autocompleteView.setActive(false) + } const onPressCancel = () => hackfixOnClose() const onPressPublish = async () => { if (isProcessing) { @@ -242,11 +258,15 @@ export const ComposePost = observer(function ComposePost({ autocompleteView.knownHandles, setProcessingState, ) - // TODO - // track('Create Post', { - // imageCount: selectedPhotos.length, - // }) + track('Create Post', { + imageCount: selectedPhotos.length, + }) } catch (e: any) { + setExtLink({ + ...extLink, + isLoading: true, + localThumb: undefined, + } as apilib.ExternalEmbedDraft) setError(cleanError(e.message)) setIsProcessing(false) return @@ -256,10 +276,6 @@ export const ComposePost = observer(function ComposePost({ hackfixOnClose() 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 @@ -386,6 +402,7 @@ export const ComposePost = observer(function ComposePost({ innerRef={textInput} onChangeText={(str: string) => onChangeText(str)} onPaste={onPaste} + onSelectionChange={onSelectionChange} placeholder={selectTextInputPlaceholder} style={[ pal.text, @@ -406,12 +423,27 @@ export const ComposePost = observer(function ComposePost({ /> )} </ScrollView> - {isSelectingPhotos && selectedPhotos.length < 4 && ( + {isSelectingPhotos && selectedPhotos.length < 4 ? ( <PhotoCarouselPicker selectedPhotos={selectedPhotos} onSelectPhotos={onSelectPhotos} /> - )} + ) : !extLink && + selectedPhotos.length === 0 && + suggestedExtLinks.size > 0 ? ( + <View style={s.mb5}> + {Array.from(suggestedExtLinks).map(url => ( + <TouchableOpacity + key={`suggested-${url}`} + style={[pal.borderDark, styles.addExtLinkBtn]} + onPress={() => onPressAddLinkCard(url)}> + <Text> + Add link card: <Text style={pal.link}>{url}</Text> + </Text> + </TouchableOpacity> + ))} + </View> + ) : null} <View style={[pal.border, styles.bottomBar]}> <TouchableOpacity testID="composerSelectPhotosButton" @@ -442,18 +474,6 @@ export const ComposePost = observer(function ComposePost({ ) }) -const atPrefixRegex = /@([a-z0-9.]*)$/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', @@ -532,6 +552,13 @@ const styles = StyleSheet.create({ paddingLeft: 13, paddingRight: 8, }, + addExtLinkBtn: { + borderWidth: 1, + borderRadius: 24, + paddingHorizontal: 16, + paddingVertical: 12, + marginBottom: 4, + }, bottomBar: { flexDirection: 'row', paddingVertical: 10, diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index ed4bfb5ed..23dcaffd5 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -7,12 +7,11 @@ import { } from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {BlurView} from '../util/BlurView' -import LinearGradient from 'react-native-linear-gradient' import {AutoSizedImage} from '../util/images/AutoSizedImage' import {Text} from '../util/text/Text' -import {s, gradients} from '../../lib/styles' -import {usePalette} from '../../lib/hooks/usePalette' -import {ExternalEmbedDraft} from '../../../state/lib/api' +import {s} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {ExternalEmbedDraft} from 'lib/api/index' export const ExternalEmbed = ({ link, @@ -30,31 +29,12 @@ export const ExternalEmbed = ({ <View style={[styles.outer, pal.view, pal.border]}> {link.isLoading ? ( <View - style={[ - styles.image, - styles.imageFallback, - {backgroundColor: pal.colors.backgroundLight}, - ]}> + style={[styles.image, {backgroundColor: pal.colors.backgroundLight}]}> <ActivityIndicator size="large" style={styles.spinner} /> </View> ) : link.localThumb ? ( - <AutoSizedImage - uri={link.localThumb.path} - containerStyle={styles.image} - /> - ) : ( - <LinearGradient - colors={[gradients.blueDark.start, gradients.blueDark.end]} - start={{x: 0, y: 0}} - end={{x: 1, y: 1}} - style={[styles.image, styles.imageFallback]} - /> - )} - <TouchableWithoutFeedback onPress={onRemove}> - <BlurView style={styles.removeBtn} blurType="dark"> - <FontAwesomeIcon size={18} icon="xmark" style={s.white} /> - </BlurView> - </TouchableWithoutFeedback> + <AutoSizedImage uri={link.localThumb.path} style={styles.image} /> + ) : undefined} <View style={styles.inner}> {!!link.meta?.title && ( <Text type="sm-bold" numberOfLines={2} style={[pal.text]}> @@ -81,6 +61,11 @@ export const ExternalEmbed = ({ </Text> )} </View> + <TouchableWithoutFeedback onPress={onRemove}> + <BlurView style={styles.removeBtn} blurType="dark"> + <FontAwesomeIcon size={18} icon="xmark" style={s.white} /> + </BlurView> + </TouchableWithoutFeedback> </View> ) } @@ -98,10 +83,7 @@ const styles = StyleSheet.create({ borderTopLeftRadius: 6, borderTopRightRadius: 6, width: '100%', - height: 200, - }, - imageFallback: { - height: 160, + maxHeight: 200, }, removeBtn: { position: 'absolute', diff --git a/src/view/com/composer/Prompt.tsx b/src/view/com/composer/Prompt.tsx index d0c023e25..46a0cec62 100644 --- a/src/view/com/composer/Prompt.tsx +++ b/src/view/com/composer/Prompt.tsx @@ -2,7 +2,7 @@ import React from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Text} from '../util/text/Text' -import {usePalette} from '../../lib/hooks/usePalette' +import {usePalette} from 'lib/hooks/usePalette' export function ComposePrompt({ text = "What's up?", diff --git a/src/view/com/composer/SelectedPhoto.tsx b/src/view/com/composer/SelectedPhoto.tsx index dd508fe1f..6aeda33cd 100644 --- a/src/view/com/composer/SelectedPhoto.tsx +++ b/src/view/com/composer/SelectedPhoto.tsx @@ -1,7 +1,8 @@ import React, {useCallback} from 'react' -import {Image, StyleSheet, TouchableOpacity, View} from 'react-native' +import {StyleSheet, TouchableOpacity, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {colors} from '../../lib/styles' +import Image from 'view/com/util/images/Image' +import {colors} from 'lib/styles' export const SelectedPhoto = ({ selectedPhotos, diff --git a/src/view/com/composer/char-progress/CharProgress.tsx b/src/view/com/composer/char-progress/CharProgress.tsx index d4093064b..cd7cb2c4e 100644 --- a/src/view/com/composer/char-progress/CharProgress.tsx +++ b/src/view/com/composer/char-progress/CharProgress.tsx @@ -5,7 +5,7 @@ import {Text} from '../../util/text/Text' import ProgressCircle from 'react-native-progress/Circle' // @ts-ignore no type definition -prf import ProgressPie from 'react-native-progress/Pie' -import {s, colors} from '../../../lib/styles' +import {s, colors} from 'lib/styles' const MAX_TEXT_LENGTH = 256 const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH diff --git a/src/view/com/composer/char-progress/CharProgress.web.tsx b/src/view/com/composer/char-progress/CharProgress.web.tsx index dfb2fad58..d32d7a72c 100644 --- a/src/view/com/composer/char-progress/CharProgress.web.tsx +++ b/src/view/com/composer/char-progress/CharProgress.web.tsx @@ -1,7 +1,7 @@ import React from 'react' import {View} from 'react-native' import {Text} from '../../util/text/Text' -import {s} from '../../../lib/styles' +import {s} from 'lib/styles' const MAX_TEXT_LENGTH = 256 const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH diff --git a/src/view/com/composer/photos/PhotoCarouselPicker.tsx b/src/view/com/composer/photos/PhotoCarouselPicker.tsx index 7a5c9f65d..406f8b04c 100644 --- a/src/view/com/composer/photos/PhotoCarouselPicker.tsx +++ b/src/view/com/composer/photos/PhotoCarouselPicker.tsx @@ -4,6 +4,7 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' +import {useAnalytics} from 'lib/analytics' import { openPicker, openCamera, @@ -12,18 +13,26 @@ import { import { UserLocalPhotosModel, PhotoIdentifier, -} from '../../../../state/models/user-local-photos' -import {compressIfNeeded, scaleDownDimensions} from '../../../../lib/images' -import {usePalette} from '../../../lib/hooks/usePalette' -import {useStores, RootStoreModel} from '../../../../state' +} from 'state/models/user-local-photos' +import { + compressIfNeeded, + moveToPremanantPath, + scaleDownDimensions, +} from 'lib/images' +import {usePalette} from 'lib/hooks/usePalette' +import {useStores, RootStoreModel} from 'state/index' +import { + requestPhotoAccessIfNeeded, + requestCameraAccessIfNeeded, +} from 'lib/permissions' -const MAX_WIDTH = 1000 -const MAX_HEIGHT = 1000 -const MAX_SIZE = 300000 +const MAX_WIDTH = 2000 +const MAX_HEIGHT = 2000 +const MAX_SIZE = 1000000 const IMAGE_PARAMS = { - width: 1000, - height: 1000, + width: 2000, + height: 2000, freeStyleCropEnabled: true, } @@ -46,8 +55,10 @@ export async function cropPhoto( width, height, }) + const img = await compressIfNeeded(cropperRes, MAX_SIZE) - return img.path + const permanentPath = await moveToPremanantPath(img.path) + return permanentPath } export const PhotoCarouselPicker = ({ @@ -57,24 +68,28 @@ export const PhotoCarouselPicker = ({ selectedPhotos: string[] onSelectPhotos: (v: string[]) => void }) => { + const {track} = useAnalytics() const pal = usePalette('default') const store = useStores() - const [localPhotos, setLocalPhotos] = React.useState< - UserLocalPhotosModel | undefined - >(undefined) + const [isSetup, setIsSetup] = React.useState<boolean>(false) + + const localPhotos = React.useMemo<UserLocalPhotosModel>( + () => new UserLocalPhotosModel(store), + [store], + ) - // initial setup React.useEffect(() => { - const photos = new UserLocalPhotosModel(store) - photos.setup().then(() => { - if (photos.photos) { - setLocalPhotos(photos) - } + // initial setup + localPhotos.setup().then(() => { + setIsSetup(true) }) - }, [store]) + }, [localPhotos]) const handleOpenCamera = useCallback(async () => { try { + if (!(await requestCameraAccessIfNeeded())) { + return + } const cameraRes = await openCamera(store, { mediaType: 'photo', ...IMAGE_PARAMS, @@ -89,6 +104,7 @@ export const PhotoCarouselPicker = ({ const handleSelectPhoto = useCallback( async (item: PhotoIdentifier) => { + track('PhotoCarouselPicker:PhotoSelected') try { const imgPath = await cropPhoto( store, @@ -102,37 +118,41 @@ export const PhotoCarouselPicker = ({ store.log.warn('Error selecting photo', err) } }, - [store, selectedPhotos, onSelectPhotos], + [track, store, onSelectPhotos, selectedPhotos], ) - const handleOpenGallery = useCallback(() => { - openPicker(store, { + const handleOpenGallery = useCallback(async () => { + track('PhotoCarouselPicker:GalleryOpened') + if (!(await requestPhotoAccessIfNeeded())) { + return + } + const items = await openPicker(store, { multiple: true, maxFiles: 4 - selectedPhotos.length, mediaType: 'photo', - }).then(async items => { - const result = [] - - for (const image of items) { - // choose target dimensions based on the original - // this causes the photo cropper to start with the full image "selected" - const {width, height} = scaleDownDimensions( - {width: image.width, height: image.height}, - {width: MAX_WIDTH, height: MAX_HEIGHT}, - ) - const cropperRes = await openCropper(store, { - mediaType: 'photo', - path: image.path, - freeStyleCropEnabled: true, - width, - height, - }) - const finalImg = await compressIfNeeded(cropperRes, MAX_SIZE) - result.push(finalImg.path) - } - onSelectPhotos([...selectedPhotos, ...result]) }) - }, [store, selectedPhotos, onSelectPhotos]) + const result = [] + + for (const image of items) { + // choose target dimensions based on the original + // this causes the photo cropper to start with the full image "selected" + const {width, height} = scaleDownDimensions( + {width: image.width, height: image.height}, + {width: MAX_WIDTH, height: MAX_HEIGHT}, + ) + const cropperRes = await openCropper(store, { + mediaType: 'photo', + path: image.path, + ...IMAGE_PARAMS, + width, + height, + }) + const finalImg = await compressIfNeeded(cropperRes, MAX_SIZE) + const permanentPath = await moveToPremanantPath(finalImg.path) + result.push(permanentPath) + } + onSelectPhotos([...selectedPhotos, ...result]) + }, [track, store, selectedPhotos, onSelectPhotos]) return ( <ScrollView @@ -161,7 +181,7 @@ export const PhotoCarouselPicker = ({ size={24} /> </TouchableOpacity> - {localPhotos != null && + {isSetup && localPhotos.photos.map((item: PhotoIdentifier, index: number) => ( <TouchableOpacity testID="openSelectPhotoButton" diff --git a/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx b/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx index bb2800026..607f8e724 100644 --- a/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx +++ b/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx @@ -9,9 +9,9 @@ import { openCamera, openCropper, } from '../../util/images/image-crop-picker/ImageCropPicker' -import {compressIfNeeded, scaleDownDimensions} from '../../../../lib/images' -import {usePalette} from '../../../lib/hooks/usePalette' -import {useStores, RootStoreModel} from '../../../../state' +import {compressIfNeeded, scaleDownDimensions} from 'lib/images' +import {usePalette} from 'lib/hooks/usePalette' +import {useStores, RootStoreModel} from 'state/index' const MAX_WIDTH = 1000 const MAX_HEIGHT = 1000 diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 3c5dacf80..be6150e11 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -1,10 +1,15 @@ import React from 'react' -import {StyleProp, TextStyle} from 'react-native' +import { + NativeSyntheticEvent, + StyleProp, + TextInputSelectionChangeEventData, + TextStyle, +} from 'react-native' import PasteInput, { PastedFile, PasteInputRef, } from '@mattermost/react-native-paste-input' -import {usePalette} from '../../../lib/hooks/usePalette' +import {usePalette} from 'lib/hooks/usePalette' export type TextInputRef = PasteInputRef @@ -14,6 +19,9 @@ interface TextInputProps { placeholder: string style: StyleProp<TextStyle> onChangeText: (str: string) => void + onSelectionChange?: + | ((e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void) + | undefined onPaste: (err: string | undefined, uris: string[]) => void } @@ -23,6 +31,7 @@ export function TextInput({ placeholder, style, onChangeText, + onSelectionChange, onPaste, children, }: React.PropsWithChildren<TextInputProps>) { @@ -44,6 +53,7 @@ export function TextInput({ multiline scrollEnabled onChangeText={(str: string) => onChangeText(str)} + onSelectionChange={onSelectionChange} onPaste={onPasteInner} placeholder={placeholder} placeholderTextColor={pal.colors.textLight} diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 395f8e5a2..2b610850c 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -1,12 +1,14 @@ import React from 'react' import { + NativeSyntheticEvent, StyleProp, StyleSheet, TextInput as RNTextInput, + TextInputSelectionChangeEventData, TextStyle, } from 'react-native' -import {usePalette} from '../../../lib/hooks/usePalette' -import {addStyle} from '../../../lib/addStyle' +import {usePalette} from 'lib/hooks/usePalette' +import {addStyle} from 'lib/styles' export type TextInputRef = RNTextInput @@ -16,6 +18,9 @@ interface TextInputProps { placeholder: string style: StyleProp<TextStyle> onChangeText: (str: string) => void + onSelectionChange?: + | ((e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void) + | undefined onPaste: (err: string | undefined, uris: string[]) => void } @@ -25,6 +30,7 @@ export function TextInput({ placeholder, style, onChangeText, + onSelectionChange, children, }: React.PropsWithChildren<TextInputProps>) { const pal = usePalette('default') @@ -36,6 +42,7 @@ export function TextInput({ multiline scrollEnabled onChangeText={(str: string) => onChangeText(str)} + onSelectionChange={onSelectionChange} placeholder={placeholder} placeholderTextColor={pal.colors.textLight} style={style}> diff --git a/src/view/com/discover/LiteSuggestedFollows.tsx b/src/view/com/discover/LiteSuggestedFollows.tsx index ce01af7c7..5314e691c 100644 --- a/src/view/com/discover/LiteSuggestedFollows.tsx +++ b/src/view/com/discover/LiteSuggestedFollows.tsx @@ -8,19 +8,18 @@ import { import LinearGradient from 'react-native-linear-gradient' import {observer} from 'mobx-react-lite' import _omit from 'lodash.omit' -import {ErrorMessage} from '../util/error/ErrorMessage' import {Link} from '../util/Link' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' import * as Toast from '../util/Toast' -import {useStores} from '../../../state' -import * as apilib from '../../../state/lib/api' +import {useStores} from 'state/index' +import * as apilib from 'lib/api/index' import { SuggestedActorsViewModel, SuggestedActor, -} from '../../../state/models/suggested-actors-view' -import {s, gradients} from '../../lib/styles' -import {usePalette} from '../../lib/hooks/usePalette' +} from 'state/models/suggested-actors-view' +import {s, gradients} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' export const LiteSuggestedFollows = observer(() => { const store = useStores() diff --git a/src/view/com/discover/SuggestedFollows.tsx b/src/view/com/discover/SuggestedFollows.tsx index 9ed893148..1e40956ce 100644 --- a/src/view/com/discover/SuggestedFollows.tsx +++ b/src/view/com/discover/SuggestedFollows.tsx @@ -1,51 +1,28 @@ -import React, {useEffect, useState} from 'react' -import { - ActivityIndicator, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' -import LinearGradient from 'react-native-linear-gradient' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {observer} from 'mobx-react-lite' -import _omit from 'lodash.omit' +import React from 'react' +import {ActivityIndicator, StyleSheet, View} from 'react-native' import {CenteredView, FlatList} from '../util/Views' +import {observer} from 'mobx-react-lite' import {ErrorScreen} from '../util/error/ErrorScreen' -import {Link} from '../util/Link' -import {Text} from '../util/text/Text' -import {UserAvatar} from '../util/UserAvatar' -import * as Toast from '../util/Toast' -import {useStores} from '../../../state' -import * as apilib from '../../../state/lib/api' +import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' +import {useStores} from 'state/index' import { SuggestedActorsViewModel, SuggestedActor, -} from '../../../state/models/suggested-actors-view' -import {s, gradients} from '../../lib/styles' -import {usePalette} from '../../lib/hooks/usePalette' +} from 'state/models/suggested-actors-view' +import {s} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' export const SuggestedFollows = observer( - ({ - onNoSuggestions, - asLinks, - }: { - onNoSuggestions?: () => void - asLinks?: boolean - }) => { + ({onNoSuggestions}: {onNoSuggestions?: () => void}) => { const pal = usePalette('default') const store = useStores() - const [follows, setFollows] = useState<Record<string, string>>({}) - // Using default import (React.use...) instead of named import (use...) to be able to mock store's data in jest environment const view = React.useMemo<SuggestedActorsViewModel>( () => new SuggestedActorsViewModel(store), [store], ) - useEffect(() => { + React.useEffect(() => { view .loadMore() .catch((err: any) => @@ -53,7 +30,7 @@ export const SuggestedFollows = observer( ) }, [view, store.log]) - useEffect(() => { + React.useEffect(() => { if (!view.isLoading && !view.hasError && !view.hasContent) { onNoSuggestions?.() } @@ -74,46 +51,16 @@ export const SuggestedFollows = observer( ) } - 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: any) { - store.log.error('Failed fo create follow', 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: any) { - store.log.error('Failed fo delete follow', e) - Toast.show('An issue occurred, please try again.') - } - } - 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 ( - <User - item={item} - follow={follows[item.did]} - onPressFollow={onPressFollow} - onPressUnfollow={onPressUnfollow} + <ProfileCardWithFollowBtn + key={item.did} + did={item.did} + declarationCid={item.declaration.cid} + handle={item.handle} + displayName={item.displayName} + avatar={item.avatar} + description={item.description} /> ) } @@ -146,7 +93,6 @@ export const SuggestedFollows = observer( </View> )} contentContainerStyle={s.contentContainer} - style={s.flex1} /> </View> )} @@ -155,128 +101,16 @@ export const SuggestedFollows = observer( }, ) -const User = ({ - item, - follow, - onPressFollow, - onPressUnfollow, -}: { - item: SuggestedActor - follow: string | undefined - onPressFollow: (item: SuggestedActor) => void - onPressUnfollow: (item: SuggestedActor) => void -}) => { - const pal = usePalette('default') - return ( - <View style={[styles.actor, pal.view, pal.border]}> - <View style={styles.actorMeta}> - <View style={styles.actorAvi}> - <UserAvatar - size={40} - displayName={item.displayName} - handle={item.handle} - avatar={item.avatar} - /> - </View> - <View style={styles.actorContent}> - <Text type="title-sm" style={pal.text} numberOfLines={1}> - {item.displayName || item.handle} - </Text> - <Text style={pal.textLight} numberOfLines={1}> - @{item.handle} - </Text> - </View> - <View style={styles.actorBtn}> - {follow ? ( - <TouchableOpacity onPress={() => onPressUnfollow(item)}> - <View style={[styles.btn, styles.secondaryBtn, pal.btn]}> - <Text type="button" style={pal.text}> - Unfollow - </Text> - </View> - </TouchableOpacity> - ) : ( - <TouchableOpacity onPress={() => onPressFollow(item)}> - <LinearGradient - colors={[gradients.blueLight.start, gradients.blueLight.end]} - start={{x: 0, y: 0}} - end={{x: 1, y: 1}} - style={[styles.btn, styles.gradientBtn]}> - <FontAwesomeIcon - icon="plus" - style={[s.white as FontAwesomeIconStyle, s.mr5]} - size={15} - /> - <Text style={[s.white, s.fw600, s.f15]}>Follow</Text> - </LinearGradient> - </TouchableOpacity> - )} - </View> - </View> - {item.description ? ( - <View style={styles.actorDetails}> - <Text style={pal.text} numberOfLines={4}> - {item.description} - </Text> - </View> - ) : undefined} - </View> - ) -} - const styles = StyleSheet.create({ container: { - flex: 1, + height: '100%', }, suggestionsContainer: { - flex: 1, + height: '100%', }, footer: { height: 200, paddingTop: 20, }, - - actor: { - borderTopWidth: 1, - }, - actorMeta: { - flexDirection: 'row', - }, - actorAvi: { - width: 60, - paddingLeft: 10, - paddingTop: 10, - paddingBottom: 10, - }, - actorContent: { - flex: 1, - paddingRight: 10, - paddingTop: 10, - }, - actorBtn: { - paddingRight: 10, - paddingTop: 10, - }, - actorDetails: { - paddingLeft: 60, - paddingRight: 10, - paddingBottom: 10, - }, - - gradientBtn: { - paddingHorizontal: 24, - paddingVertical: 6, - }, - secondaryBtn: { - paddingHorizontal: 14, - }, - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 7, - borderRadius: 50, - marginLeft: 6, - }, }) diff --git a/src/view/com/discover/SuggestedPosts.tsx b/src/view/com/discover/SuggestedPosts.tsx new file mode 100644 index 000000000..86a6bd394 --- /dev/null +++ b/src/view/com/discover/SuggestedPosts.tsx @@ -0,0 +1,66 @@ +import React from 'react' +import {ActivityIndicator, StyleSheet, View} from 'react-native' +import {observer} from 'mobx-react-lite' +import {useStores} from 'state/index' +import {SuggestedPostsView} from 'state/models/suggested-posts-view' +import {s} from 'lib/styles' +import {FeedItem as Post} from '../posts/FeedItem' +import {Text} from '../util/text/Text' +import {usePalette} from 'lib/hooks/usePalette' + +export const SuggestedPosts = observer(() => { + const pal = usePalette('default') + const store = useStores() + const suggestedPostsView = React.useMemo<SuggestedPostsView>( + () => new SuggestedPostsView(store), + [store], + ) + + React.useEffect(() => { + if (!suggestedPostsView.hasLoaded) { + suggestedPostsView.setup() + } + }, [store, suggestedPostsView]) + + return ( + <> + {(suggestedPostsView.hasContent || suggestedPostsView.isLoading) && ( + <Text type="title" style={[styles.heading, pal.text]}> + Recently, on Bluesky... + </Text> + )} + {suggestedPostsView.hasContent && ( + <> + <View style={[pal.border, styles.bottomBorder]}> + {suggestedPostsView.posts.map(item => ( + <Post item={item} key={item._reactKey} /> + ))} + </View> + </> + )} + {suggestedPostsView.isLoading && ( + <View style={s.mt10}> + <ActivityIndicator /> + </View> + )} + </> + ) +}) + +const styles = StyleSheet.create({ + heading: { + fontWeight: 'bold', + paddingHorizontal: 12, + paddingTop: 16, + paddingBottom: 8, + }, + + bottomBorder: { + borderBottomWidth: 1, + }, + + loadMore: { + paddingLeft: 12, + paddingVertical: 10, + }, +}) diff --git a/src/view/com/discover/WhoToFollow.tsx b/src/view/com/discover/WhoToFollow.tsx new file mode 100644 index 000000000..17c10ca7e --- /dev/null +++ b/src/view/com/discover/WhoToFollow.tsx @@ -0,0 +1,89 @@ +import React from 'react' +import { + ActivityIndicator, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native' +import {observer} from 'mobx-react-lite' +import {useStores} from 'state/index' +import {SuggestedActorsViewModel} from 'state/models/suggested-actors-view' +import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' +import {Text} from '../util/text/Text' +import {s} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' + +export const WhoToFollow = observer(() => { + const pal = usePalette('default') + const store = useStores() + const suggestedActorsView = React.useMemo<SuggestedActorsViewModel>( + () => new SuggestedActorsViewModel(store, {pageSize: 5}), + [store], + ) + + React.useEffect(() => { + suggestedActorsView.loadMore(true) + }, [store, suggestedActorsView]) + + const onPressLoadMoreSuggestedActors = () => { + suggestedActorsView.loadMore() + } + return ( + <> + {(suggestedActorsView.hasContent || suggestedActorsView.isLoading) && ( + <Text type="title" style={[styles.heading, pal.text]}> + Who to follow + </Text> + )} + {suggestedActorsView.hasContent && ( + <> + <View style={[pal.border, styles.bottomBorder]}> + {suggestedActorsView.suggestions.map(item => ( + <ProfileCardWithFollowBtn + key={item.did} + did={item.did} + declarationCid={item.declaration.cid} + handle={item.handle} + displayName={item.displayName} + avatar={item.avatar} + description={item.description} + /> + ))} + </View> + {!suggestedActorsView.isLoading && suggestedActorsView.hasMore && ( + <TouchableOpacity + onPress={onPressLoadMoreSuggestedActors} + style={styles.loadMore}> + <Text type="lg" style={pal.link}> + Show more + </Text> + </TouchableOpacity> + )} + </> + )} + {suggestedActorsView.isLoading && ( + <View style={s.mt10}> + <ActivityIndicator /> + </View> + )} + </> + ) +}) + +const styles = StyleSheet.create({ + heading: { + fontWeight: 'bold', + paddingHorizontal: 12, + paddingTop: 16, + paddingBottom: 8, + }, + + bottomBorder: { + borderBottomWidth: 1, + }, + + loadMore: { + paddingLeft: 16, + paddingVertical: 12, + }, +}) diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx index fdaafe737..83259330f 100644 --- a/src/view/com/lightbox/ImageViewing/index.tsx +++ b/src/view/com/lightbox/ImageViewing/index.tsx @@ -86,12 +86,18 @@ function ImageViewing({ [toggleBarsVisible], ) + const onLayout = useCallback(() => { + if (imageIndex) { + imageList.current?.scrollToIndex({index: imageIndex, animated: false}) + } + }, [imageList, imageIndex]) + if (!visible) { return null } return ( - <View style={styles.screen}> + <View style={styles.screen} onLayout={onLayout}> <Modal /> <View style={[styles.container, {opacity, backgroundColor}]}> <Animated.View style={[styles.header, {transform: headerTransform}]}> @@ -108,12 +114,8 @@ function ImageViewing({ data={images} horizontal pagingEnabled - windowSize={2} - initialNumToRender={1} - maxToRenderPerBatch={1} showsHorizontalScrollIndicator={false} showsVerticalScrollIndicator={false} - initialScrollIndex={imageIndex} getItem={(_, index) => images[index]} getItemCount={() => images.length} getItemLayout={(_, index) => ({ diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx index df5839783..894c6b118 100644 --- a/src/view/com/lightbox/Lightbox.tsx +++ b/src/view/com/lightbox/Lightbox.tsx @@ -2,9 +2,9 @@ import React from 'react' import {View} from 'react-native' import {observer} from 'mobx-react-lite' import ImageView from './ImageViewing' -import {useStores} from '../../../state' -import * as models from '../../../state/models/shell-ui' -import {saveImageModal} from '../../../lib/images' +import {useStores} from 'state/index' +import * as models from 'state/models/shell-ui' +import {saveImageModal} from 'lib/images' import {ImageSource} from './ImageViewing/@types' export const Lightbox = observer(function Lightbox() { diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/view/com/lightbox/Lightbox.web.tsx index 4062ddef7..dfe4f5603 100644 --- a/src/view/com/lightbox/Lightbox.web.tsx +++ b/src/view/com/lightbox/Lightbox.web.tsx @@ -8,9 +8,9 @@ import { } from 'react-native' import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {useStores} from '../../../state' -import * as models from '../../../state/models/shell-ui' -import {colors} from '../../lib/styles' +import {useStores} from 'state/index' +import * as models from 'state/models/shell-ui' +import {colors} from 'lib/styles' interface Img { uri: string diff --git a/src/view/com/login/CreateAccount.tsx b/src/view/com/login/CreateAccount.tsx index dbb3ab52f..6dc93f5e3 100644 --- a/src/view/com/login/CreateAccount.tsx +++ b/src/view/com/login/CreateAccount.tsx @@ -15,24 +15,22 @@ import { } from '@fortawesome/react-native-fontawesome' import {ComAtprotoAccountCreate} from '@atproto/api' import * as EmailValidator from 'email-validator' -// import {useAnalytics} from '@segment/analytics-react-native' TODO +import {useAnalytics} from 'lib/analytics' import {LogoTextHero} from './Logo' import {Picker} from '../util/Picker' import {TextLink} from '../util/Link' import {Text} from '../util/text/Text' -import {s, colors} from '../../lib/styles' -import { - makeValidHandle, - createFullHandle, - toNiceDomain, -} from '../../../lib/strings' -import {useStores, DEFAULT_SERVICE} from '../../../state' -import {ServiceDescription} from '../../../state/models/session' -import {ServerInputModal} from '../../../state/models/shell-ui' -import {usePalette} from '../../lib/hooks/usePalette' +import {s, colors} from 'lib/styles' +import {makeValidHandle, createFullHandle} from 'lib/strings/handles' +import {toNiceDomain} from 'lib/strings/url-helpers' +import {useStores, DEFAULT_SERVICE} from 'state/index' +import {ServiceDescription} from 'state/models/session' +import {ServerInputModal} from 'state/models/shell-ui' +import {usePalette} from 'lib/hooks/usePalette' +import {cleanError} from 'lib/strings/errors' export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => { - // const {track} = useAnalytics() TODO + const {track, screen} = useAnalytics() const pal = usePalette('default') const store = useStores() const [isProcessing, setIsProcessing] = useState<boolean>(false) @@ -50,6 +48,10 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => { const [is13, setIs13] = useState<boolean>(false) useEffect(() => { + screen('CreateAccount') + }, [screen]) + + useEffect(() => { let aborted = false setError('') setServiceDescription(undefined) @@ -109,7 +111,7 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => { password, inviteCode, }) - // track('Create Account') TODO + track('Create Account') } catch (e: any) { let errMsg = e.toString() if (e instanceof ComAtprotoAccountCreate.InvalidInviteCodeError) { @@ -118,7 +120,7 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => { } store.log.error('Failed to create account', e) setIsProcessing(false) - setError(errMsg.replace(/^Error:/, '')) + setError(cleanError(errMsg)) } } diff --git a/src/view/com/login/Logo.tsx b/src/view/com/login/Logo.tsx index ec53549b1..7601ea31f 100644 --- a/src/view/com/login/Logo.tsx +++ b/src/view/com/login/Logo.tsx @@ -1,7 +1,7 @@ import React from 'react' import {StyleSheet} from 'react-native' import LinearGradient from 'react-native-linear-gradient' -import {s, gradients} from '../../lib/styles' +import {s, gradients} from 'lib/styles' import {Text} from '../util/text/Text' export const LogoTextHero = () => { diff --git a/src/view/com/login/Signin.tsx b/src/view/com/login/Signin.tsx index 8a260a9ba..eed173119 100644 --- a/src/view/com/login/Signin.tsx +++ b/src/view/com/login/Signin.tsx @@ -13,19 +13,21 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import * as EmailValidator from 'email-validator' -import {sessionClient as AtpApi, SessionServiceClient} from '@atproto/api' -// import {useAnalytics} from '@segment/analytics-react-native' TODO +import AtpAgent from '@atproto/api' +import {useAnalytics} from 'lib/analytics' import {LogoTextHero} from './Logo' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' -import {s, colors} from '../../lib/styles' -import {createFullHandle, toNiceDomain} from '../../../lib/strings' -import {useStores, RootStoreModel, DEFAULT_SERVICE} from '../../../state' -import {ServiceDescription} from '../../../state/models/session' -import {ServerInputModal} from '../../../state/models/shell-ui' -import {AccountData} from '../../../state/models/session' -import {isNetworkError} from '../../../lib/errors' -import {usePalette} from '../../lib/hooks/usePalette' +import {s, colors} from 'lib/styles' +import {createFullHandle} from 'lib/strings/handles' +import {toNiceDomain} from 'lib/strings/url-helpers' +import {useStores, RootStoreModel, DEFAULT_SERVICE} from 'state/index' +import {ServiceDescription} from 'state/models/session' +import {ServerInputModal} from 'state/models/shell-ui' +import {AccountData} from 'state/models/session' +import {isNetworkError} from 'lib/strings/errors' +import {usePalette} from 'lib/hooks/usePalette' +import {cleanError} from 'lib/strings/errors' enum Forms { Login, @@ -38,6 +40,7 @@ enum Forms { export const Signin = ({onPressBack}: {onPressBack: () => void}) => { const pal = usePalette('default') const store = useStores() + const {track} = useAnalytics() const [error, setError] = useState<string>('') const [retryDescribeTrigger, setRetryDescribeTrigger] = useState<any>({}) const [serviceUrl, setServiceUrl] = useState<string>(DEFAULT_SERVICE) @@ -91,6 +94,10 @@ export const Signin = ({onPressBack}: {onPressBack: () => void}) => { }, [store.session, store.log, serviceUrl, retryDescribeTrigger]) const onPressRetryConnect = () => setRetryDescribeTrigger({}) + const onPressForgotPassword = () => { + track('Signin:PressedForgotPassword') + setCurrentForm(Forms.ForgotPassword) + } return ( <KeyboardAvoidingView testID="signIn" behavior="padding" style={[pal.view]}> @@ -104,7 +111,7 @@ export const Signin = ({onPressBack}: {onPressBack: () => void}) => { setError={setError} setServiceUrl={setServiceUrl} onPressBack={onPressBack} - onPressForgotPassword={gotoForm(Forms.ForgotPassword)} + onPressForgotPassword={onPressForgotPassword} onPressRetryConnect={onPressRetryConnect} /> ) : undefined} @@ -153,15 +160,19 @@ const ChooseAccountForm = ({ onSelectAccount: (account?: AccountData) => void onPressBack: () => void }) => { - // const {track} = useAnalytics() TODO + const {track, screen} = useAnalytics() const pal = usePalette('default') const [isProcessing, setIsProcessing] = React.useState(false) + // React.useEffect(() => { + screen('Choose Account') + // }, [screen]) + const onTryAccount = async (account: AccountData) => { if (account.accessJwt && account.refreshJwt) { setIsProcessing(true) if (await store.session.resumeSession(account)) { - // track('Sign In', {resumedSession: true}) TODO + track('Sign In', {resumedSession: true}) setIsProcessing(false) return } @@ -261,15 +272,16 @@ const LoginForm = ({ onPressBack: () => void onPressForgotPassword: () => void }) => { - // const {track} = useAnalytics() TODO + const {track} = useAnalytics() const pal = usePalette('default') const [isProcessing, setIsProcessing] = useState<boolean>(false) - const [handle, setHandle] = useState<string>(initialHandle) + const [identifier, setIdentifier] = useState<string>(initialHandle) const [password, setPassword] = useState<string>('') const onPressSelectService = () => { store.shell.openModal(new ServerInputModal(serviceUrl, setServiceUrl)) Keyboard.dismiss() + track('Signin:PressedSelectService') } const onPressNext = async () => { @@ -278,20 +290,21 @@ const LoginForm = ({ try { // try to guess the handle if the user just gave their own username - let fullHandle = handle + let fullIdent = identifier if ( + !identifier.includes('@') && // not an email serviceDescription && serviceDescription.availableUserDomains.length > 0 ) { let matched = false for (const domain of serviceDescription.availableUserDomains) { - if (fullHandle.endsWith(domain)) { + if (fullIdent.endsWith(domain)) { matched = true } } if (!matched) { - fullHandle = createFullHandle( - handle, + fullIdent = createFullHandle( + identifier, serviceDescription.availableUserDomains[0], ) } @@ -299,10 +312,10 @@ const LoginForm = ({ await store.session.login({ service: serviceUrl, - handle: fullHandle, + identifier: fullIdent, password, }) - // track('Sign In', {resumedSession: false}) TODO + track('Sign In', {resumedSession: false}) } catch (e: any) { const errMsg = e.toString() store.log.warn('Failed to login', e) @@ -314,12 +327,12 @@ const LoginForm = ({ 'Unable to contact your service. Please check your Internet connection.', ) } else { - setError(errMsg.replace(/^Error:/, '')) + setError(cleanError(errMsg)) } } } - const isReady = !!serviceDescription && !!handle && !!password + const isReady = !!serviceDescription && !!identifier && !!password return ( <View testID="loginForm"> <LogoTextHero /> @@ -361,13 +374,13 @@ const LoginForm = ({ <TextInput testID="loginUsernameInput" style={[pal.text, styles.textInput]} - placeholder="Username" + placeholder="Username or email address" placeholderTextColor={pal.colors.textLight} autoCapitalize="none" autoFocus autoCorrect={false} - value={handle} - onChangeText={str => setHandle((str || '').toLowerCase())} + value={identifier} + onChangeText={str => setIdentifier((str || '').toLowerCase())} editable={!isProcessing} /> </View> @@ -464,6 +477,11 @@ const ForgotPasswordForm = ({ const pal = usePalette('default') const [isProcessing, setIsProcessing] = useState<boolean>(false) const [email, setEmail] = useState<string>('') + const {screen} = useAnalytics() + + // useEffect(() => { + screen('Signin:ForgotPassword') + // }, [screen]) const onPressSelectService = () => { store.shell.openModal(new ServerInputModal(serviceUrl, setServiceUrl)) @@ -478,8 +496,8 @@ const ForgotPasswordForm = ({ setIsProcessing(true) try { - const api = AtpApi.service(serviceUrl) as SessionServiceClient - await api.com.atproto.account.requestPasswordReset({email}) + const agent = new AtpAgent({service: serviceUrl}) + await agent.api.com.atproto.account.requestPasswordReset({email}) onEmailSent() } catch (e: any) { const errMsg = e.toString() @@ -490,7 +508,7 @@ const ForgotPasswordForm = ({ 'Unable to contact your service. Please check your Internet connection.', ) } else { - setError(errMsg.replace(/^Error:/, '')) + setError(cleanError(errMsg)) } } } @@ -604,6 +622,12 @@ const SetNewPasswordForm = ({ onPasswordSet: () => void }) => { const pal = usePalette('default') + const {screen} = useAnalytics() + + // useEffect(() => { + screen('Signin:SetNewPasswordForm') + // }, [screen]) + const [isProcessing, setIsProcessing] = useState<boolean>(false) const [resetCode, setResetCode] = useState<string>('') const [password, setPassword] = useState<string>('') @@ -613,8 +637,11 @@ const SetNewPasswordForm = ({ setIsProcessing(true) try { - const api = AtpApi.service(serviceUrl) as SessionServiceClient - await api.com.atproto.account.resetPassword({token: resetCode, password}) + const agent = new AtpAgent({service: serviceUrl}) + await agent.api.com.atproto.account.resetPassword({ + token: resetCode, + password, + }) onPasswordSet() } catch (e: any) { const errMsg = e.toString() @@ -625,7 +652,7 @@ const SetNewPasswordForm = ({ 'Unable to contact your service. Please check your Internet connection.', ) } else { - setError(errMsg.replace(/^Error:/, '')) + setError(cleanError(errMsg)) } } } @@ -726,6 +753,12 @@ const SetNewPasswordForm = ({ } const PasswordUpdatedForm = ({onPressNext}: {onPressNext: () => void}) => { + const {screen} = useAnalytics() + + // useEffect(() => { + screen('Signin:PasswordUpdatedForm') + // }, [screen]) + const pal = usePalette('default') return ( <> diff --git a/src/view/com/modals/Confirm.tsx b/src/view/com/modals/Confirm.tsx index 3e2ad6eea..60c104f99 100644 --- a/src/view/com/modals/Confirm.tsx +++ b/src/view/com/modals/Confirm.tsx @@ -7,9 +7,10 @@ import { } from 'react-native' import LinearGradient from 'react-native-linear-gradient' import {Text} from '../util/text/Text' -import {useStores} from '../../../state' -import {s, colors, gradients} from '../../lib/styles' +import {useStores} from 'state/index' +import {s, colors, gradients} from 'lib/styles' import {ErrorMessage} from '../util/error/ErrorMessage' +import {cleanError} from 'lib/strings/errors' export const snapPoints = ['50%'] @@ -33,7 +34,7 @@ export function Component({ store.shell.closeModal() return } catch (e: any) { - setError(e.toString()) + setError(cleanError(e)) setIsProcessing(false) } } diff --git a/src/view/com/modals/DeleteAccount.tsx b/src/view/com/modals/DeleteAccount.tsx new file mode 100644 index 000000000..de29e728d --- /dev/null +++ b/src/view/com/modals/DeleteAccount.tsx @@ -0,0 +1,210 @@ +import React from 'react' +import { + ActivityIndicator, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native' +import {BottomSheetTextInput} from '@gorhom/bottom-sheet' +import LinearGradient from 'react-native-linear-gradient' +import * as Toast from '../util/Toast' +import {Text} from '../util/text/Text' +import {useStores} from 'state/index' +import {s, colors, gradients} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {ErrorMessage} from '../util/error/ErrorMessage' +import {cleanError} from 'lib/strings/errors' + +export const snapPoints = ['60%'] + +export function Component({}: {}) { + const pal = usePalette('default') + const store = useStores() + const [isEmailSent, setIsEmailSent] = React.useState<boolean>(false) + const [confirmCode, setConfirmCode] = React.useState<string>('') + const [password, setPassword] = React.useState<string>('') + const [isProcessing, setIsProcessing] = React.useState<boolean>(false) + const [error, setError] = React.useState<string>('') + const onPressSendEmail = async () => { + setError('') + setIsProcessing(true) + try { + await store.api.com.atproto.account.requestDelete() + setIsEmailSent(true) + } catch (e: any) { + setError(cleanError(e)) + } + setIsProcessing(false) + } + const onPressConfirmDelete = async () => { + setError('') + setIsProcessing(true) + try { + await store.api.com.atproto.account.delete({ + did: store.me.did, + password, + token: confirmCode, + }) + Toast.show('Your account has been deleted') + store.nav.tab.fixedTabReset() + store.session.clear() + store.shell.closeModal() + } catch (e: any) { + setError(cleanError(e)) + } + setIsProcessing(false) + } + const onCancel = () => { + store.shell.closeModal() + } + return ( + <View + style={[styles.container, {backgroundColor: pal.colors.backgroundLight}]}> + <View style={[styles.innerContainer, pal.view]}> + <Text type="title-xl" style={[styles.title, pal.text]}> + Delete account + </Text> + {!isEmailSent ? ( + <> + <Text type="lg" style={[styles.description, pal.text]}> + For security reasons, we'll need to send a confirmation code to + your email. + </Text> + {error ? ( + <View style={s.mt10}> + <ErrorMessage message={error} /> + </View> + ) : undefined} + {isProcessing ? ( + <View style={[styles.btn, s.mt10]}> + <ActivityIndicator /> + </View> + ) : ( + <> + <TouchableOpacity + style={styles.mt20} + onPress={onPressSendEmail}> + <LinearGradient + colors={[ + gradients.blueLight.start, + gradients.blueLight.end, + ]} + start={{x: 0, y: 0}} + end={{x: 1, y: 1}} + style={[styles.btn]}> + <Text type="button-lg" style={[s.white, s.bold]}> + Send email + </Text> + </LinearGradient> + </TouchableOpacity> + <TouchableOpacity + style={[styles.btn, s.mt10]} + onPress={onCancel}> + <Text type="button-lg" style={pal.textLight}> + Cancel + </Text> + </TouchableOpacity> + </> + )} + </> + ) : ( + <> + <Text type="lg" style={styles.description}> + Check your inbox for an email with the confirmation code to enter + below: + </Text> + <BottomSheetTextInput + style={[styles.textInput, pal.borderDark, pal.text, styles.mb20]} + placeholder="Confirmation code" + placeholderTextColor={pal.textLight.color} + value={confirmCode} + onChangeText={setConfirmCode} + /> + <Text type="lg" style={styles.description}> + Please enter your password as well: + </Text> + <BottomSheetTextInput + style={[styles.textInput, pal.borderDark, pal.text]} + placeholder="Password" + placeholderTextColor={pal.textLight.color} + secureTextEntry + value={password} + onChangeText={setPassword} + /> + {error ? ( + <View style={styles.mt20}> + <ErrorMessage message={error} /> + </View> + ) : undefined} + {isProcessing ? ( + <View style={[styles.btn, s.mt10]}> + <ActivityIndicator /> + </View> + ) : ( + <> + <TouchableOpacity + style={[styles.btn, styles.evilBtn, styles.mt20]} + onPress={onPressConfirmDelete}> + <Text type="button-lg" style={[s.white, s.bold]}> + Delete my account + </Text> + </TouchableOpacity> + <TouchableOpacity + style={[styles.btn, s.mt10]} + onPress={onCancel}> + <Text type="button-lg" style={pal.textLight}> + Cancel + </Text> + </TouchableOpacity> + </> + )} + </> + )} + </View> + </View> + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + innerContainer: { + paddingBottom: 20, + }, + title: { + textAlign: 'center', + marginTop: 12, + marginBottom: 12, + }, + description: { + textAlign: 'center', + paddingHorizontal: 22, + marginBottom: 10, + }, + mt20: { + marginTop: 20, + }, + mb20: { + marginBottom: 20, + }, + textInput: { + borderWidth: 1, + borderRadius: 6, + paddingHorizontal: 16, + paddingVertical: 12, + fontSize: 20, + marginHorizontal: 20, + }, + btn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 32, + padding: 14, + marginHorizontal: 20, + }, + evilBtn: { + backgroundColor: colors.red4, + }, +}) diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx index 12b72a399..add75e89e 100644 --- a/src/view/com/modals/EditProfile.tsx +++ b/src/view/com/modals/EditProfile.tsx @@ -11,18 +11,17 @@ import {ScrollView, TextInput} from './util' import {PickedMedia} from '../util/images/image-crop-picker/ImageCropPicker' import {Text} from '../util/text/Text' import {ErrorMessage} from '../util/error/ErrorMessage' -import {useStores} from '../../../state' -import {ProfileViewModel} from '../../../state/models/profile-view' -import {s, colors, gradients} from '../../lib/styles' -import { - enforceLen, - MAX_DISPLAY_NAME, - MAX_DESCRIPTION, -} from '../../../lib/strings' -import {isNetworkError} from '../../../lib/errors' -import {compressIfNeeded} from '../../../lib/images' +import {useStores} from 'state/index' +import {ProfileViewModel} from 'state/models/profile-view' +import {s, colors, gradients} from 'lib/styles' +import {enforceLen} from 'lib/strings/helpers' +import {MAX_DISPLAY_NAME, MAX_DESCRIPTION} from 'lib/constants' +import {compressIfNeeded} from 'lib/images' import {UserBanner} from '../util/UserBanner' import {UserAvatar} from '../util/UserAvatar' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnalytics} from 'lib/analytics' +import {cleanError, isNetworkError} from 'lib/strings/errors' export const snapPoints = ['80%'] @@ -35,6 +34,9 @@ export function Component({ }) { const store = useStores() const [error, setError] = useState<string>('') + const pal = usePalette('default') + const {track} = useAnalytics() + const [isProcessing, setProcessing] = useState<boolean>(false) const [displayName, setDisplayName] = useState<string>( profileView.displayName || '', @@ -54,24 +56,27 @@ export function Component({ store.shell.closeModal() } const onSelectNewAvatar = async (img: PickedMedia) => { + track('EditProfile:AvatarSelected') try { - const finalImg = await compressIfNeeded(img, 300000) - setNewUserAvatar(finalImg) + const finalImg = await compressIfNeeded(img, 1000000) + setNewUserAvatar({mediaType: 'photo', ...finalImg}) setUserAvatar(finalImg.path) } catch (e: any) { - setError(e.message || e.toString()) + setError(cleanError(e)) } } const onSelectNewBanner = async (img: PickedMedia) => { + track('EditProfile:BannerSelected') try { - const finalImg = await compressIfNeeded(img, 500000) - setNewUserBanner(finalImg) + const finalImg = await compressIfNeeded(img, 1000000) + setNewUserBanner({mediaType: 'photo', ...finalImg}) setUserBanner(finalImg.path) } catch (e: any) { - setError(e.message || e.toString()) + setError(cleanError(e)) } } const onPressSave = async () => { + track('EditProfile:Save') setProcessing(true) if (error) { setError('') @@ -94,7 +99,7 @@ export function Component({ 'Failed to save your profile. Check your internet connection and try again.', ) } else { - setError(e.message) + setError(cleanError(e)) } } setProcessing(false) @@ -103,13 +108,13 @@ export function Component({ return ( <View style={s.flex1}> <ScrollView style={styles.inner}> - <Text style={styles.title}>Edit my profile</Text> + <Text style={[styles.title, pal.text]}>Edit my profile</Text> <View style={styles.photos}> <UserBanner banner={userBanner} onSelectNewBanner={onSelectNewBanner} /> - <View style={styles.avi}> + <View style={[styles.avi, {borderColor: pal.colors.background}]}> <UserAvatar size={80} avatar={userAvatar} @@ -127,7 +132,7 @@ export function Component({ <View> <Text style={styles.label}>Display Name</Text> <TextInput - style={styles.textInput} + style={[styles.textInput, pal.text]} placeholder="e.g. Alice Roberts" placeholderTextColor={colors.gray4} value={displayName} @@ -135,9 +140,9 @@ export function Component({ /> </View> <View style={s.pb10}> - <Text style={styles.label}>Description</Text> + <Text style={[styles.label, pal.text]}>Description</Text> <TextInput - style={[styles.textArea]} + style={[styles.textArea, pal.text]} placeholder="e.g. Artist, dog-lover, and memelord." placeholderTextColor={colors.gray4} multiline @@ -162,7 +167,7 @@ export function Component({ )} <TouchableOpacity style={s.mt5} onPress={onPressCancel}> <View style={[styles.btn]}> - <Text style={[s.black, s.bold]}>Cancel</Text> + <Text style={[s.black, s.bold, pal.text]}>Cancel</Text> </View> </TouchableOpacity> </ScrollView> diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index d0b40d56d..2529d0d5b 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -2,23 +2,26 @@ import React, {useRef, useEffect} from 'react' import {View} from 'react-native' import {observer} from 'mobx-react-lite' import BottomSheet from '@gorhom/bottom-sheet' -import {useStores} from '../../../state' +import {useStores} from 'state/index' import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' -import * as models from '../../../state/models/shell-ui' +import * as models from 'state/models/shell-ui' import * as ConfirmModal from './Confirm' import * as EditProfileModal from './EditProfile' import * as ServerInputModal from './ServerInput' import * as ReportPostModal from './ReportPost' import * as ReportAccountModal from './ReportAccount' +import * as DeleteAccountModal from './DeleteAccount' +import {usePalette} from 'lib/hooks/usePalette' +import {StyleSheet} from 'react-native' const CLOSED_SNAPPOINTS = ['10%'] export const Modal = observer(function Modal() { const store = useStores() const bottomSheetRef = useRef<BottomSheet>(null) - + const pal = usePalette('default') const onBottomSheetChange = (snapPoint: number) => { if (snapPoint === -1) { store.shell.closeModal() @@ -62,10 +65,21 @@ export const Modal = observer(function Modal() { ) } else if (store.shell.activeModal?.name === 'report-post') { snapPoints = ReportPostModal.snapPoints - element = <ReportPostModal.Component /> + element = ( + <ReportPostModal.Component + {...(store.shell.activeModal as models.ReportPostModal)} + /> + ) } else if (store.shell.activeModal?.name === 'report-account') { snapPoints = ReportAccountModal.snapPoints - element = <ReportAccountModal.Component /> + element = ( + <ReportAccountModal.Component + {...(store.shell.activeModal as models.ReportAccountModal)} + /> + ) + } else if (store.shell.activeModal?.name === 'delete-account') { + snapPoints = DeleteAccountModal.snapPoints + element = <DeleteAccountModal.Component /> } else { element = <View /> } @@ -80,8 +94,17 @@ export const Modal = observer(function Modal() { backdropComponent={ store.shell.isModalActive ? createCustomBackdrop(onClose) : undefined } + handleIndicatorStyle={{backgroundColor: pal.text.color}} + handleStyle={[styles.handle, pal.view]} onChange={onBottomSheetChange}> {element} </BottomSheet> ) }) + +const styles = StyleSheet.create({ + handle: { + borderTopLeftRadius: 10, + borderTopRightRadius: 10, + }, +}) diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 44ea95f07..3c6551093 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -1,10 +1,10 @@ import React from 'react' import {TouchableWithoutFeedback, StyleSheet, View} from 'react-native' import {observer} from 'mobx-react-lite' -import {useStores} from '../../../state' -import {usePalette} from '../../lib/hooks/usePalette' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' -import * as models from '../../../state/models/shell-ui' +import * as models from 'state/models/shell-ui' import * as ConfirmModal from './Confirm' import * as EditProfileModal from './EditProfile' @@ -48,9 +48,17 @@ export const Modal = observer(function Modal() { /> ) } else if (store.shell.activeModal?.name === 'report-post') { - element = <ReportPostModal.Component /> + element = ( + <ReportPostModal.Component + {...(store.shell.activeModal as models.ReportPostModal)} + /> + ) } else if (store.shell.activeModal?.name === 'report-account') { - element = <ReportAccountModal.Component /> + element = ( + <ReportAccountModal.Component + {...(store.shell.activeModal as models.ReportAccountModal)} + /> + ) } else if (store.shell.activeModal?.name === 'crop-image') { element = ( <CropImageModal.Component diff --git a/src/view/com/modals/ReportAccount.tsx b/src/view/com/modals/ReportAccount.tsx index 1385d5711..377a32838 100644 --- a/src/view/com/modals/ReportAccount.tsx +++ b/src/view/com/modals/ReportAccount.tsx @@ -5,12 +5,15 @@ import { TouchableOpacity, View, } from 'react-native' +import {ComAtprotoReportReasonType} from '@atproto/api' import LinearGradient from 'react-native-linear-gradient' -import {useStores} from '../../../state' -import {s, colors, gradients} from '../../lib/styles' +import {useStores} from 'state/index' +import {s, colors, gradients} from 'lib/styles' import {RadioGroup, RadioGroupItem} from '../util/forms/RadioGroup' import {Text} from '../util/text/Text' +import * as Toast from '../util/Toast' import {ErrorMessage} from '../util/error/ErrorMessage' +import {cleanError} from 'lib/strings/errors' const ITEMS: RadioGroupItem[] = [ {key: 'spam', label: 'Spam or excessive repeat posts'}, @@ -20,7 +23,7 @@ const ITEMS: RadioGroupItem[] = [ export const snapPoints = ['50%'] -export function Component() { +export function Component({did}: {did: string}) { const store = useStores() const [isProcessing, setIsProcessing] = useState<boolean>(false) const [error, setError] = useState<string>('') @@ -28,13 +31,30 @@ export function Component() { const onSelectIssue = (v: string) => setIssue(v) const onPress = async () => { setError('') + if (!issue) { + return + } setIsProcessing(true) try { - // TODO + // NOTE: we should update the lexicon of reasontype to include more options -prf + let reasonType = ComAtprotoReportReasonType.OTHER + if (issue === 'spam') { + reasonType = ComAtprotoReportReasonType.SPAM + } + const reason = ITEMS.find(item => item.key === issue)?.label || '' + await store.api.com.atproto.report.create({ + reasonType, + reason, + subject: { + $type: 'com.atproto.repo.repoRef', + did, + }, + }) + Toast.show("Thank you for your report! We'll look into it promptly.") store.shell.closeModal() return } catch (e: any) { - setError(e.toString()) + setError(cleanError(e)) setIsProcessing(false) } } diff --git a/src/view/com/modals/ReportPost.tsx b/src/view/com/modals/ReportPost.tsx index 8a3a1f758..3d47e7ef0 100644 --- a/src/view/com/modals/ReportPost.tsx +++ b/src/view/com/modals/ReportPost.tsx @@ -5,12 +5,15 @@ import { TouchableOpacity, View, } from 'react-native' +import {ComAtprotoReportReasonType} from '@atproto/api' import LinearGradient from 'react-native-linear-gradient' -import {useStores} from '../../../state' -import {s, colors, gradients} from '../../lib/styles' +import {useStores} from 'state/index' +import {s, colors, gradients} from 'lib/styles' import {RadioGroup, RadioGroupItem} from '../util/forms/RadioGroup' import {Text} from '../util/text/Text' +import * as Toast from '../util/Toast' import {ErrorMessage} from '../util/error/ErrorMessage' +import {cleanError} from 'lib/strings/errors' const ITEMS: RadioGroupItem[] = [ {key: 'spam', label: 'Spam or excessive repeat posts'}, @@ -21,7 +24,13 @@ const ITEMS: RadioGroupItem[] = [ export const snapPoints = ['50%'] -export function Component() { +export function Component({ + postUri, + postCid, +}: { + postUri: string + postCid: string +}) { const store = useStores() const [isProcessing, setIsProcessing] = useState<boolean>(false) const [error, setError] = useState<string>('') @@ -29,13 +38,31 @@ export function Component() { const onSelectIssue = (v: string) => setIssue(v) const onPress = async () => { setError('') + if (!issue) { + return + } setIsProcessing(true) try { - // TODO + // NOTE: we should update the lexicon of reasontype to include more options -prf + let reasonType = ComAtprotoReportReasonType.OTHER + if (issue === 'spam') { + reasonType = ComAtprotoReportReasonType.SPAM + } + const reason = ITEMS.find(item => item.key === issue)?.label || '' + await store.api.com.atproto.report.create({ + reasonType, + reason, + subject: { + $type: 'com.atproto.repo.recordRef', + uri: postUri, + cid: postCid, + }, + }) + Toast.show("Thank you for your report! We'll look into it promptly.") store.shell.closeModal() return } catch (e: any) { - setError(e.toString()) + setError(cleanError(e)) setIsProcessing(false) } } diff --git a/src/view/com/modals/ServerInput.tsx b/src/view/com/modals/ServerInput.tsx index dde836719..5a9a4cfed 100644 --- a/src/view/com/modals/ServerInput.tsx +++ b/src/view/com/modals/ServerInput.tsx @@ -6,14 +6,10 @@ import { } from '@fortawesome/react-native-fontawesome' import {ScrollView, TextInput} from './util' import {Text} from '../util/text/Text' -import {useStores} from '../../../state' -import {s, colors} from '../../lib/styles' -import { - LOCAL_DEV_SERVICE, - STAGING_SERVICE, - PROD_SERVICE, -} from '../../../state/index' -import {LOGIN_INCLUDE_DEV_SERVERS} from '../../../build-flags' +import {useStores} from 'state/index' +import {s, colors} from 'lib/styles' +import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'state/index' +import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags' export const snapPoints = ['80%'] @@ -37,6 +33,7 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) { {LOGIN_INCLUDE_DEV_SERVERS ? ( <> <TouchableOpacity + testID="localDevServerButton" style={styles.btn} onPress={() => doSelect(LOCAL_DEV_SERVICE)}> <Text style={styles.btnText}>Local dev server</Text> diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx index 1f234c4a6..e43f37397 100644 --- a/src/view/com/modals/crop-image/CropImage.web.tsx +++ b/src/view/com/modals/crop-image/CropImage.web.tsx @@ -5,10 +5,10 @@ import {Slider} from '@miblanchard/react-native-slider' import LinearGradient from 'react-native-linear-gradient' import {Text} from '../../util/text/Text' import {PickedMedia} from '../../util/images/image-crop-picker/types' -import {s, gradients} from '../../../lib/styles' -import {useStores} from '../../../../state' -import {usePalette} from '../../../lib/hooks/usePalette' -import {SquareIcon, RectWideIcon, RectTallIcon} from '../../../lib/icons' +import {s, gradients} from 'lib/styles' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {SquareIcon, RectWideIcon, RectTallIcon} from 'lib/icons' enum AspectRatio { Square = 'square', diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx index 55e958c2b..5c1ee935b 100644 --- a/src/view/com/notifications/Feed.tsx +++ b/src/view/com/notifications/Feed.tsx @@ -1,31 +1,61 @@ -import React from 'react' +import React, {MutableRefObject} from 'react' import {observer} from 'mobx-react-lite' -import {StyleSheet, View} from 'react-native' import {CenteredView, FlatList} from '../util/Views' -import {NotificationsViewModel} from '../../../state/models/notifications-view' +import {ActivityIndicator, StyleSheet, View} from 'react-native' +import {NotificationsViewModel} from 'state/models/notifications-view' import {FeedItem} from './FeedItem' import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {ErrorMessage} from '../util/error/ErrorMessage' import {EmptyState} from '../util/EmptyState' -import {OnScrollCb} from '../../lib/hooks/useOnMainScroll' -import {s} from '../../lib/styles' +import {OnScrollCb} from 'lib/hooks/useOnMainScroll' +import {s} from 'lib/styles' const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} export const Feed = observer(function Feed({ view, + scrollElRef, onPressTryAgain, onScroll, }: { view: NotificationsViewModel + scrollElRef?: MutableRefObject<FlatList<any> | null> onPressTryAgain?: () => void onScroll?: OnScrollCb }) { + const data = React.useMemo(() => { + let feedItems + if (view.hasLoaded) { + if (view.isEmpty) { + feedItems = [EMPTY_FEED_ITEM] + } else { + feedItems = view.notifications + } + } + return feedItems + }, [view.hasLoaded, view.isEmpty, view.notifications]) + + const onRefresh = React.useCallback(async () => { + try { + await view.refresh() + await view.markAllRead() + } catch (err) { + view.rootStore.log.error('Failed to refresh notifications feed', err) + } + }, [view]) + const onEndReached = React.useCallback(async () => { + try { + await view.loadMore() + } catch (err) { + view.rootStore.log.error('Failed to load more notifications', err) + } + }, [view]) + // 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}) => { + const renderItem = React.useCallback(({item}: {item: any}) => { if (item === EMPTY_FEED_ITEM) { return ( <EmptyState @@ -36,29 +66,20 @@ export const Feed = observer(function Feed({ ) } return <FeedItem item={item} /> - } - const onRefresh = () => { - view - .refresh() - .catch(err => - view.rootStore.log.error('Failed to refresh notifications feed', err), - ) - } - const onEndReached = () => { - view - .loadMore() - .catch(err => - view.rootStore.log.error('Failed to load more notifications', err), - ) - } - let data - if (view.hasLoaded) { - if (view.isEmpty) { - data = [EMPTY_FEED_ITEM] - } else { - data = view.notifications - } - } + }, []) + + const FeedFooter = React.useCallback( + () => + view.isLoading ? ( + <View style={styles.feedFooter}> + <ActivityIndicator /> + </View> + ) : ( + <View /> + ), + [view], + ) + return ( <View style={s.h100pct}> <CenteredView> @@ -72,9 +93,11 @@ export const Feed = observer(function Feed({ </CenteredView> {data && ( <FlatList + ref={scrollElRef} data={data} keyExtractor={item => item._reactKey} renderItem={renderItem} + ListFooterComponent={FeedFooter} refreshing={view.isRefreshing} onRefresh={onRefresh} onEndReached={onEndReached} @@ -87,5 +110,6 @@ export const Feed = observer(function Feed({ }) const styles = StyleSheet.create({ + feedFooter: {paddingTop: 20}, emptyState: {paddingVertical: 40}, }) diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index da6fc7d35..94bedc46f 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -14,19 +14,19 @@ import { FontAwesomeIconStyle, Props, } from '@fortawesome/react-native-fontawesome' -import {NotificationsViewItemModel} from '../../../state/models/notifications-view' -import {PostThreadViewModel} from '../../../state/models/post-thread-view' -import {s, colors} from '../../lib/styles' -import {ago, pluralize} from '../../../lib/strings' -import {HeartIconSolid} from '../../lib/icons' +import {NotificationsViewItemModel} from 'state/models/notifications-view' +import {PostThreadViewModel} from 'state/models/post-thread-view' +import {s, colors} from 'lib/styles' +import {ago} from 'lib/strings/time' +import {pluralize} from 'lib/strings/helpers' +import {HeartIconSolid} from 'lib/icons' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' import {ImageHorzList} from '../util/images/ImageHorzList' -import {ErrorMessage} from '../util/error/ErrorMessage' import {Post} from '../post/Post' import {Link} from '../util/Link' -import {usePalette} from '../../lib/hooks/usePalette' -import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' const MAX_AUTHORS = 5 @@ -78,6 +78,10 @@ export const FeedItem = observer(function FeedItem({ } if (item.isReply || item.isMention) { + if (item.additionalPost?.error) { + // hide errors - it doesnt help the user to show them + return <View /> + } return ( <Link href={itemHref} title={itemTitle} noFeedback> <Post @@ -347,12 +351,13 @@ function AdditionalPostText({ additionalPost?: PostThreadViewModel }) { const pal = usePalette('default') - if (!additionalPost || !additionalPost.thread?.postRecord) { + if ( + !additionalPost || + !additionalPost.thread?.postRecord || + additionalPost.error + ) { return <View /> } - if (additionalPost.error) { - return <ErrorMessage message={additionalPost.error} /> - } const text = additionalPost.thread?.postRecord.text const images = ( additionalPost.thread.post.embed as AppBskyEmbedImages.Presented diff --git a/src/view/com/onboard/FeatureExplainer.tsx b/src/view/com/onboard/FeatureExplainer.tsx index d8d502cfb..323b1ba14 100644 --- a/src/view/com/onboard/FeatureExplainer.tsx +++ b/src/view/com/onboard/FeatureExplainer.tsx @@ -14,10 +14,10 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {Text} from '../util/text/Text' -import {useStores} from '../../../state' -import {s} from '../../lib/styles' -import {TABS_EXPLAINER} from '../../lib/assets' -import {TABS_ENABLED} from '../../../build-flags' +import {useStores} from 'state/index' +import {s} from 'lib/styles' +import {TABS_EXPLAINER} from 'lib/assets' +import {TABS_ENABLED} from 'lib/build-flags' const ROUTES = TABS_ENABLED ? [ @@ -127,11 +127,15 @@ export const FeatureExplainer = () => { <View /> )} <View style={styles.footer}> - <TouchableOpacity onPress={onPressSkip}> + <TouchableOpacity + onPress={onPressSkip} + testID="onboardFeatureExplainerSkipBtn"> <Text style={[s.blue3, s.f18]}>Skip</Text> </TouchableOpacity> <View style={s.flex1} /> - <TouchableOpacity onPress={onPressNext}> + <TouchableOpacity + onPress={onPressNext} + testID="onboardFeatureExplainerNextBtn"> <Text style={[s.blue3, s.f18]}>Next</Text> </TouchableOpacity> </View> diff --git a/src/view/com/onboard/FeatureExplainer.web.tsx b/src/view/com/onboard/FeatureExplainer.web.tsx index 83d460808..177ac58dd 100644 --- a/src/view/com/onboard/FeatureExplainer.web.tsx +++ b/src/view/com/onboard/FeatureExplainer.web.tsx @@ -2,7 +2,6 @@ import React, {useState} from 'react' import { Animated, Image, - SafeAreaView, StyleSheet, TouchableOpacity, useWindowDimensions, @@ -15,10 +14,10 @@ import { } from '@fortawesome/react-native-fontawesome' import {CenteredView} from '../util/Views.web' import {Text} from '../util/text/Text' -import {useStores} from '../../../state' -import {s, colors} from '../../lib/styles' -import {TABS_EXPLAINER} from '../../lib/assets' -import {TABS_ENABLED} from '../../../build-flags' +import {useStores} from 'state/index' +import {s, colors} from 'lib/styles' +import {TABS_EXPLAINER} from 'lib/assets' +import {TABS_ENABLED} from 'lib/build-flags' const ROUTES = TABS_ENABLED ? [ diff --git a/src/view/com/onboard/Follows.tsx b/src/view/com/onboard/Follows.tsx index 76eff3f4b..e7de82b39 100644 --- a/src/view/com/onboard/Follows.tsx +++ b/src/view/com/onboard/Follows.tsx @@ -3,8 +3,8 @@ import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native' import {observer} from 'mobx-react-lite' import {SuggestedFollows} from '../discover/SuggestedFollows' import {Text} from '../util/text/Text' -import {useStores} from '../../../state' -import {s} from '../../lib/styles' +import {useStores} from 'state/index' +import {s} from 'lib/styles' export const Follows = observer(() => { const store = useStores() @@ -18,13 +18,15 @@ export const Follows = observer(() => { return ( <SafeAreaView style={styles.container}> <Text style={styles.title}>Suggested follows</Text> - <SuggestedFollows onNoSuggestions={onNoSuggestions} /> + <View style={s.flex1}> + <SuggestedFollows onNoSuggestions={onNoSuggestions} /> + </View> <View style={styles.footer}> - <TouchableOpacity onPress={onPressNext}> + <TouchableOpacity onPress={onPressNext} testID="onboardFollowsSkipBtn"> <Text style={[s.blue3, s.f18]}>Skip</Text> </TouchableOpacity> <View style={s.flex1} /> - <TouchableOpacity onPress={onPressNext}> + <TouchableOpacity onPress={onPressNext} testID="onboardFollowsNextBtn"> <Text style={[s.blue3, s.f18]}>Next</Text> </TouchableOpacity> </View> diff --git a/src/view/com/onboard/Follows.web.tsx b/src/view/com/onboard/Follows.web.tsx index 50e119e49..6b015bb09 100644 --- a/src/view/com/onboard/Follows.web.tsx +++ b/src/view/com/onboard/Follows.web.tsx @@ -1,11 +1,11 @@ import React from 'react' -import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native' +import {SafeAreaView, StyleSheet, TouchableOpacity} from 'react-native' import {observer} from 'mobx-react-lite' import {SuggestedFollows} from '../discover/SuggestedFollows' import {CenteredView} from '../util/Views.web' import {Text} from '../util/text/Text' -import {useStores} from '../../../state' -import {s, colors} from '../../lib/styles' +import {useStores} from 'state/index' +import {s} from 'lib/styles' export const Follows = observer(() => { const store = useStores() diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx index dacdfa50f..a9fabac3d 100644 --- a/src/view/com/post-thread/PostRepostedBy.tsx +++ b/src/view/com/post-thread/PostRepostedBy.tsx @@ -5,13 +5,10 @@ import {CenteredView, FlatList} from '../util/Views' import { RepostedByViewModel, RepostedByItem, -} from '../../../state/models/reposted-by-view' -import {UserAvatar} from '../util/UserAvatar' +} from 'state/models/reposted-by-view' +import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' import {ErrorMessage} from '../util/error/ErrorMessage' -import {Link} from '../util/Link' -import {Text} from '../util/text/Text' -import {useStores} from '../../../state' -import {s, colors} from '../../lib/styles' +import {useStores} from 'state/index' export const PostRepostedBy = observer(function PostRepostedBy({ uri, @@ -62,7 +59,15 @@ export const PostRepostedBy = observer(function PostRepostedBy({ // loaded // = const renderItem = ({item}: {item: RepostedByItem}) => ( - <RepostedByItemCom item={item} /> + <ProfileCardWithFollowBtn + key={item.did} + did={item.did} + declarationCid={item.declaration.cid} + handle={item.handle} + displayName={item.displayName} + avatar={item.avatar} + isFollowedBy={!!item.viewer?.followedBy} + /> ) return ( <FlatList @@ -83,57 +88,7 @@ export const PostRepostedBy = observer(function PostRepostedBy({ ) }) -const RepostedByItemCom = ({item}: {item: RepostedByItem}) => { - return ( - <Link - style={styles.outer} - href={`/profile/${item.handle}`} - title={item.handle} - noFeedback> - <View style={styles.layout}> - <View style={styles.layoutAvi}> - <UserAvatar - size={40} - displayName={item.displayName} - handle={item.handle} - avatar={item.avatar} - /> - </View> - <View style={styles.layoutContent}> - <Text style={[s.f15, s.bold]}>{item.displayName || item.handle}</Text> - <Text style={[s.f14, s.gray5]}>@{item.handle}</Text> - </View> - </View> - </Link> - ) -} - const styles = StyleSheet.create({ - outer: { - marginTop: 1, - backgroundColor: colors.white, - }, - layout: { - flexDirection: 'row', - }, - layoutAvi: { - width: 60, - paddingLeft: 10, - paddingTop: 10, - paddingBottom: 10, - }, - avi: { - width: 40, - height: 40, - borderRadius: 20, - resizeMode: 'cover', - }, - layoutContent: { - flex: 1, - paddingRight: 10, - paddingTop: 10, - paddingBottom: 10, - }, footer: { height: 200, paddingTop: 20, diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 59dbf1e16..0a092c46b 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -1,14 +1,14 @@ import React, {useRef} from 'react' import {observer} from 'mobx-react-lite' -import {ActivityIndicator, View} from 'react-native' +import {ActivityIndicator} from 'react-native' import {CenteredView, FlatList} from '../util/Views' import { PostThreadViewModel, PostThreadViewPostModel, -} from '../../../state/models/post-thread-view' +} from 'state/models/post-thread-view' import {PostThreadItem} from './PostThreadItem' import {ErrorMessage} from '../util/error/ErrorMessage' -import {s} from '../../lib/styles' +import {s} from 'lib/styles' export const PostThread = observer(function PostThread({ uri, @@ -18,15 +18,24 @@ export const PostThread = observer(function PostThread({ view: PostThreadViewModel }) { const ref = useRef<FlatList>(null) - const posts = view.thread ? Array.from(flattenThread(view.thread)) : [] - const onRefresh = () => { - view - ?.refresh() - .catch(err => - view.rootStore.log.error('Failed to refresh posts thread', err), - ) - } - const onLayout = () => { + const [isRefreshing, setIsRefreshing] = React.useState(false) + const posts = React.useMemo( + () => (view.thread ? Array.from(flattenThread(view.thread)) : []), + [view.thread], + ) + + // events + // = + const onRefresh = React.useCallback(async () => { + setIsRefreshing(true) + try { + view?.refresh() + } catch (err) { + view.rootStore.log.error('Failed to refresh posts thread', err) + } + setIsRefreshing(false) + }, [view, setIsRefreshing]) + const onLayout = React.useCallback(() => { const index = posts.findIndex(post => post._isHighlightedPost) if (index !== -1) { ref.current?.scrollToIndex({ @@ -35,17 +44,20 @@ export const PostThread = observer(function PostThread({ viewOffset: 40, }) } - } - const onScrollToIndexFailed = (info: { - index: number - highestMeasuredFrameIndex: number - averageItemLength: number - }) => { - ref.current?.scrollToOffset({ - animated: false, - offset: info.averageItemLength * info.index, - }) - } + }, [posts, ref]) + const onScrollToIndexFailed = React.useCallback( + (info: { + index: number + highestMeasuredFrameIndex: number + averageItemLength: number + }) => { + ref.current?.scrollToOffset({ + animated: false, + offset: info.averageItemLength * info.index, + }) + }, + [ref], + ) // loading // = @@ -76,9 +88,10 @@ export const PostThread = observer(function PostThread({ <FlatList ref={ref} data={posts} + initialNumToRender={posts.length} keyExtractor={item => item._reactKey} renderItem={renderItem} - refreshing={view.isRefreshing} + refreshing={isRefreshing} onRefresh={onRefresh} onLayout={onLayout} onScrollToIndexFailed={onScrollToIndexFailed} diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index d39296285..cd3a49d64 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -1,4 +1,4 @@ -import React, {useMemo, useState} from 'react' +import React from 'react' import {observer} from 'mobx-react-lite' import {StyleSheet, View} from 'react-native' import Clipboard from '@react-native-clipboard/clipboard' @@ -7,22 +7,23 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -import {PostThreadViewPostModel} from '../../../state/models/post-thread-view' +import {PostThreadViewPostModel} from 'state/models/post-thread-view' import {Link} from '../util/Link' import {RichText} from '../util/text/RichText' import {Text} from '../util/text/Text' import {PostDropdownBtn} from '../util/forms/DropdownButton' import * as Toast from '../util/Toast' import {UserAvatar} from '../util/UserAvatar' -import {s} from '../../lib/styles' -import {ago, pluralize} from '../../../lib/strings' -import {useStores} from '../../../state' +import {s} from 'lib/styles' +import {ago} from 'lib/strings/time' +import {pluralize} from 'lib/strings/helpers' +import {useStores} from 'state/index' import {PostMeta} from '../util/PostMeta' import {PostEmbeds} from '../util/PostEmbeds' import {PostCtrls} from '../util/PostCtrls' import {ErrorMessage} from '../util/error/ErrorMessage' import {ComposePrompt} from '../composer/Prompt' -import {usePalette} from '../../lib/hooks/usePalette' +import {usePalette} from 'lib/hooks/usePalette' const PARENT_REPLY_LINE_LENGTH = 8 @@ -35,29 +36,31 @@ export const PostThreadItem = observer(function PostThreadItem({ }) { const pal = usePalette('default') const store = useStores() - const [deleted, setDeleted] = useState(false) + const [deleted, setDeleted] = React.useState(false) const record = item.postRecord const hasEngagement = item.post.upvoteCount || item.post.repostCount - const itemHref = useMemo(() => { + const itemUri = item.post.uri + const itemCid = item.post.cid + const itemHref = React.useMemo(() => { const urip = new AtUri(item.post.uri) return `/profile/${item.post.author.handle}/post/${urip.rkey}` }, [item.post.uri, item.post.author.handle]) const itemTitle = `Post by ${item.post.author.handle}` const authorHref = `/profile/${item.post.author.handle}` const authorTitle = item.post.author.handle - const upvotesHref = useMemo(() => { + const upvotesHref = React.useMemo(() => { const urip = new AtUri(item.post.uri) return `/profile/${item.post.author.handle}/post/${urip.rkey}/upvoted-by` }, [item.post.uri, item.post.author.handle]) const upvotesTitle = 'Likes on this post' - const repostsHref = useMemo(() => { + const repostsHref = React.useMemo(() => { const urip = new AtUri(item.post.uri) return `/profile/${item.post.author.handle}/post/${urip.rkey}/reposted-by` }, [item.post.uri, item.post.author.handle]) const repostsTitle = 'Reposts of this post' - const onPressReply = () => { + const onPressReply = React.useCallback(() => { store.shell.openComposer({ replyTo: { uri: item.post.uri, @@ -71,22 +74,22 @@ export const PostThreadItem = observer(function PostThreadItem({ }, onPost: onPostReply, }) - } - const onPressToggleRepost = () => { - item + }, [store, item, record, onPostReply]) + const onPressToggleRepost = React.useCallback(() => { + return item .toggleRepost() .catch(e => store.log.error('Failed to toggle repost', e)) - } - const onPressToggleUpvote = () => { - item + }, [item, store]) + const onPressToggleUpvote = React.useCallback(() => { + return item .toggleUpvote() .catch(e => store.log.error('Failed to toggle upvote', e)) - } - const onCopyPostText = () => { + }, [item, store]) + const onCopyPostText = React.useCallback(() => { Clipboard.setString(record?.text || '') Toast.show('Copied to clipboard') - } - const onDeletePost = () => { + }, [record]) + const onDeletePost = React.useCallback(() => { item.delete().then( () => { setDeleted(true) @@ -97,7 +100,7 @@ export const PostThreadItem = observer(function PostThreadItem({ Toast.show('Failed to delete post, please try again') }, ) - } + }, [item, store]) if (!record) { return <ErrorMessage message="Invalid or unsupported post record" /> @@ -154,6 +157,8 @@ export const PostThreadItem = observer(function PostThreadItem({ <View style={s.flex1} /> <PostDropdownBtn style={styles.metaItem} + itemUri={itemUri} + itemCid={itemCid} itemHref={itemHref} itemTitle={itemTitle} isAuthor={item.post.author.did === store.me.did} @@ -179,7 +184,7 @@ export const PostThreadItem = observer(function PostThreadItem({ </View> </View> <View style={[s.pl10, s.pr10, s.pb10]}> - {record.text ? ( + {item.richText?.text ? ( <View style={[ styles.postTextContainer, @@ -187,8 +192,7 @@ export const PostThreadItem = observer(function PostThreadItem({ ]}> <RichText type="post-text-lg" - text={record.text} - entities={record.entities} + richText={item.richText} lineHeight={1.3} /> </View> @@ -233,6 +237,8 @@ export const PostThreadItem = observer(function PostThreadItem({ <View style={[s.pl10, s.pb5]}> <PostCtrls big + itemUri={itemUri} + itemCid={itemCid} itemHref={itemHref} itemTitle={itemTitle} isAuthor={item.post.author.did === store.me.did} @@ -301,12 +307,11 @@ export const PostThreadItem = observer(function PostThreadItem({ <FontAwesomeIcon icon={['far', 'eye-slash']} style={s.mr2} /> <Text type="sm">This post is by a muted account.</Text> </View> - ) : record.text ? ( + ) : item.richText?.text ? ( <View style={styles.postTextContainer}> <RichText type="post-text" - text={record.text} - entities={record.entities} + richText={item.richText} style={pal.text} lineHeight={1.3} /> @@ -314,6 +319,8 @@ export const PostThreadItem = observer(function PostThreadItem({ ) : undefined} <PostEmbeds embed={item.post.embed} style={s.mb10} /> <PostCtrls + itemUri={itemUri} + itemCid={itemCid} itemHref={itemHref} itemTitle={itemTitle} isAuthor={item.post.author.did === store.me.did} @@ -341,7 +348,12 @@ export const PostThreadItem = observer(function PostThreadItem({ href={itemHref} title={itemTitle} noFeedback> - <Text style={pal.link}>Load more</Text> + <Text style={pal.link}>Continue thread...</Text> + <FontAwesomeIcon + icon="angle-right" + style={pal.link as FontAwesomeIconStyle} + size={18} + /> </Link> ) : undefined} </> @@ -433,8 +445,12 @@ const styles = StyleSheet.create({ marginRight: 10, }, loadMore: { + flexDirection: 'row', + justifyContent: 'space-between', borderTopWidth: 1, - paddingLeft: 28, + paddingLeft: 80, + paddingRight: 20, paddingVertical: 10, + marginBottom: 8, }, }) diff --git a/src/view/com/post-thread/PostVotedBy.tsx b/src/view/com/post-thread/PostVotedBy.tsx index 680bbadf4..2734aaea9 100644 --- a/src/view/com/post-thread/PostVotedBy.tsx +++ b/src/view/com/post-thread/PostVotedBy.tsx @@ -2,14 +2,10 @@ import React, {useEffect} from 'react' import {observer} from 'mobx-react-lite' import {ActivityIndicator, StyleSheet, View} from 'react-native' import {CenteredView, FlatList} from '../util/Views' -import {VotesViewModel, VoteItem} from '../../../state/models/votes-view' -import {Link} from '../util/Link' -import {Text} from '../util/text/Text' +import {VotesViewModel, VoteItem} from 'state/models/votes-view' import {ErrorMessage} from '../util/error/ErrorMessage' -import {UserAvatar} from '../util/UserAvatar' -import {useStores} from '../../../state' -import {s} from '../../lib/styles' -import {usePalette} from '../../lib/hooks/usePalette' +import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' +import {useStores} from 'state/index' export const PostVotedBy = observer(function PostVotedBy({ uri, @@ -57,7 +53,17 @@ export const PostVotedBy = observer(function PostVotedBy({ // loaded // = - const renderItem = ({item}: {item: VoteItem}) => <LikedByItem item={item} /> + const renderItem = ({item}: {item: VoteItem}) => ( + <ProfileCardWithFollowBtn + key={item.actor.did} + did={item.actor.did} + declarationCid={item.actor.declaration.cid} + handle={item.actor.handle} + displayName={item.actor.displayName} + avatar={item.actor.avatar} + isFollowedBy={!!item.actor.viewer?.followedBy} + /> + ) return ( <FlatList data={view.votes} @@ -77,62 +83,7 @@ export const PostVotedBy = observer(function PostVotedBy({ ) }) -const LikedByItem = ({item}: {item: VoteItem}) => { - const pal = usePalette('default') - - return ( - <Link - style={[styles.outer, pal.view]} - href={`/profile/${item.actor.handle}`} - title={item.actor.handle} - noFeedback> - <View style={styles.layout}> - <View style={styles.layoutAvi}> - <UserAvatar - size={40} - displayName={item.actor.displayName} - handle={item.actor.handle} - avatar={item.actor.avatar} - /> - </View> - <View style={styles.layoutContent}> - <Text style={[s.f15, s.bold, pal.text]}> - {item.actor.displayName || item.actor.handle} - </Text> - <Text style={[s.f14, s.gray5, pal.textLight]}> - @{item.actor.handle} - </Text> - </View> - </View> - </Link> - ) -} - const styles = StyleSheet.create({ - outer: { - marginTop: 1, - }, - layout: { - flexDirection: 'row', - }, - layoutAvi: { - width: 60, - paddingLeft: 10, - paddingTop: 10, - paddingBottom: 10, - }, - avi: { - width: 40, - height: 40, - borderRadius: 20, - resizeMode: 'cover', - }, - layoutContent: { - flex: 1, - paddingRight: 10, - paddingTop: 10, - paddingBottom: 10, - }, footer: { height: 200, paddingTop: 20, diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index d00cc83c2..8e793ecc0 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -10,7 +10,7 @@ import {observer} from 'mobx-react-lite' import Clipboard from '@react-native-clipboard/clipboard' import {AtUri} from '../../../third-party/uri' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {PostThreadViewModel} from '../../../state/models/post-thread-view' +import {PostThreadViewModel} from 'state/models/post-thread-view' import {Link} from '../util/Link' import {UserInfoText} from '../util/UserInfoText' import {PostMeta} from '../util/PostMeta' @@ -20,9 +20,9 @@ import {Text} from '../util/text/Text' import {RichText} from '../util/text/RichText' import * as Toast from '../util/Toast' import {UserAvatar} from '../util/UserAvatar' -import {useStores} from '../../../state' -import {s, colors} from '../../lib/styles' -import {usePalette} from '../../lib/hooks/usePalette' +import {useStores} from 'state/index' +import {s, colors} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' export const Post = observer(function Post({ uri, @@ -80,6 +80,8 @@ export const Post = observer(function Post({ const item = view.thread const record = view.thread.postRecord + const itemUri = item.post.uri + const itemCid = item.post.cid const itemUrip = new AtUri(item.post.uri) const itemHref = `/profile/${item.post.author.handle}/post/${itemUrip.rkey}` const itemTitle = `Post by ${item.post.author.handle}` @@ -105,12 +107,12 @@ export const Post = observer(function Post({ }) } const onPressToggleRepost = () => { - item + return item .toggleRepost() .catch(e => store.log.error('Failed to toggle repost', e)) } const onPressToggleUpvote = () => { - item + return item .toggleUpvote() .catch(e => store.log.error('Failed to toggle upvote', e)) } @@ -178,18 +180,19 @@ export const Post = observer(function Post({ <FontAwesomeIcon icon={['far', 'eye-slash']} style={s.mr2} /> <Text type="sm">This post is by a muted account.</Text> </View> - ) : record.text ? ( + ) : item.richText?.text ? ( <View style={styles.postTextContainer}> <RichText type="post-text" - text={record.text} - entities={record.entities} + richText={item.richText} lineHeight={1.3} /> </View> ) : undefined} <PostEmbeds embed={item.post.embed} style={s.mb10} /> <PostCtrls + itemUri={itemUri} + itemCid={itemCid} itemHref={itemHref} itemTitle={itemTitle} isAuthor={item.post.author.did === store.me.did} diff --git a/src/view/com/post/PostText.tsx b/src/view/com/post/PostText.tsx index a3bcfed68..a460b57c4 100644 --- a/src/view/com/post/PostText.tsx +++ b/src/view/com/post/PostText.tsx @@ -4,8 +4,8 @@ import {StyleProp, StyleSheet, TextStyle, View} from 'react-native' import {LoadingPlaceholder} from '../util/LoadingPlaceholder' import {ErrorMessage} from '../util/error/ErrorMessage' import {Text} from '../util/text/Text' -import {PostModel} from '../../../state/models/post' -import {useStores} from '../../../state' +import {PostModel} from 'state/models/post' +import {useStores} from 'state/index' export const PostText = observer(function PostText({ uri, diff --git a/src/view/com/posts/ComposerPrompt.tsx b/src/view/com/posts/ComposerPrompt.tsx index 1ddc28756..c367a17fc 100644 --- a/src/view/com/posts/ComposerPrompt.tsx +++ b/src/view/com/posts/ComposerPrompt.tsx @@ -1,47 +1,5 @@ -import React from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {Text} from '../util/text/Text' -import {usePalette} from '../../lib/hooks/usePalette' - -export function ComposerPrompt({ - onPressCompose, -}: { +export function ComposerPrompt(_opts: { onPressCompose: (imagesOpen?: boolean) => void }) { - const pal = usePalette('default') - return ( - <View style={[pal.view, pal.border, styles.container]}> - <TouchableOpacity - testID="composePromptButton" - onPress={() => onPressCompose(false)} - style={[styles.btn, {backgroundColor: pal.colors.backgroundLight}]}> - <Text type="button" style={pal.text}> - New post - </Text> - </TouchableOpacity> - <TouchableOpacity - onPress={() => onPressCompose(true)} - style={[styles.btn, {backgroundColor: pal.colors.backgroundLight}]}> - <Text type="button" style={pal.text}> - Share photo - </Text> - </TouchableOpacity> - </View> - ) + return null } - -const styles = StyleSheet.create({ - container: { - paddingVertical: 12, - paddingHorizontal: 16, - flexDirection: 'row', - alignItems: 'center', - borderTopWidth: 1, - }, - btn: { - paddingVertical: 6, - paddingHorizontal: 14, - borderRadius: 30, - marginRight: 10, - }, -}) diff --git a/src/view/com/posts/ComposerPrompt.web.tsx b/src/view/com/posts/ComposerPrompt.web.tsx index 96c09f0b3..a87653cf8 100644 --- a/src/view/com/posts/ComposerPrompt.web.tsx +++ b/src/view/com/posts/ComposerPrompt.web.tsx @@ -1,8 +1,8 @@ import React from 'react' import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' import {Text} from '../util/text/Text' -import {usePalette} from '../../lib/hooks/usePalette' -import {s} from '../../lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {s} from 'lib/styles' export function ComposerPrompt({ onPressCompose, diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 409ce4af2..57363ca51 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -11,103 +11,144 @@ import {CenteredView, FlatList} from '../util/Views' import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {EmptyState} from '../util/EmptyState' import {ErrorMessage} from '../util/error/ErrorMessage' -import {FeedModel} from '../../../state/models/feed-view' +import {FeedModel} from 'state/models/feed-view' import {FeedItem} from './FeedItem' import {ComposerPrompt} from './ComposerPrompt' -import {OnScrollCb} from '../../lib/hooks/useOnMainScroll' -import {s} from '../../lib/styles' +import {OnScrollCb} from 'lib/hooks/useOnMainScroll' +import {s} from 'lib/styles' +import {useAnalytics} from 'lib/analytics' const COMPOSE_PROMPT_ITEM = {_reactKey: '__prompt__'} const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} +const ERROR_FEED_ITEM = {_reactKey: '__error__'} export const Feed = observer(function Feed({ feed, style, scrollElRef, - onPressCompose, onPressTryAgain, + onPressCompose, onScroll, testID, + headerOffset = 0, }: { feed: FeedModel style?: StyleProp<ViewStyle> scrollElRef?: MutableRefObject<FlatList<any> | null> - onPressCompose: (imagesOpen?: boolean) => void onPressTryAgain?: () => void + onPressCompose: (imagesOpen?: boolean) => void onScroll?: OnScrollCb testID?: string + headerOffset?: number }) { + const {track} = useAnalytics() + const [isRefreshing, setIsRefreshing] = React.useState(false) + + const data = React.useMemo(() => { + let feedItems: any[] = [] + if (feed.hasLoaded) { + feedItems = feedItems.concat([COMPOSE_PROMPT_ITEM]) + if (feed.hasError) { + feedItems = feedItems.concat([ERROR_FEED_ITEM]) + } + if (feed.isEmpty) { + feedItems = feedItems.concat([EMPTY_FEED_ITEM]) + } else { + feedItems = feedItems.concat(feed.feed) + } + } + return feedItems + }, [feed.hasError, feed.hasLoaded, feed.isEmpty, feed.feed]) + + // events + // = + + const onRefresh = React.useCallback(async () => { + track('Feed:onRefresh') + setIsRefreshing(true) + try { + await feed.refresh() + } catch (err) { + feed.rootStore.log.error('Failed to refresh posts feed', err) + } + setIsRefreshing(false) + }, [feed, track, setIsRefreshing]) + const onEndReached = React.useCallback(async () => { + track('Feed:onEndReached') + try { + await feed.loadMore() + } catch (err) { + feed.rootStore.log.error('Failed to load more posts', err) + } + }, [feed, track]) + + // rendering + // = + // 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 <ComposerPrompt onPressCompose={onPressCompose} /> - } else if (item === EMPTY_FEED_ITEM) { - return ( - <EmptyState - icon="bars" - message="This feed is empty!" - style={styles.emptyState} - /> - ) - } else { - return <FeedItem item={item} /> - } - } - const onRefresh = () => { - feed - .refresh() - .catch(err => - feed.rootStore.log.error('Failed to refresh posts feed', err), - ) - } - const onEndReached = () => { - feed - .loadMore() - .catch(err => feed.rootStore.log.error('Failed to load more posts', 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 FeedFooter = () => - feed.isLoading ? ( - <View style={styles.feedFooter}> - <ActivityIndicator /> - </View> - ) : ( - <View /> - ) - return ( - <View testID={testID} style={style}> - <CenteredView> - {!data && <ComposerPrompt onPressCompose={onPressCompose} />} - {feed.isLoading && !data && <PostFeedLoadingPlaceholder />} - {feed.hasError && ( + const renderItem = React.useCallback( + ({item}: {item: any}) => { + if (item === COMPOSE_PROMPT_ITEM) { + return <ComposerPrompt onPressCompose={onPressCompose} /> + } else if (item === EMPTY_FEED_ITEM) { + return ( + <EmptyState + icon="bars" + message="This feed is empty!" + style={styles.emptyState} + /> + ) + } else if (item === ERROR_FEED_ITEM) { + return ( <ErrorMessage message={feed.error} onPressTryAgain={onPressTryAgain} /> - )} - </CenteredView> - {feed.hasLoaded && data && ( + ) + } + return <FeedItem item={item} /> + }, + [feed, onPressTryAgain, onPressCompose], + ) + + const FeedFooter = React.useCallback( + () => + feed.isLoading ? ( + <View style={styles.feedFooter}> + <ActivityIndicator /> + </View> + ) : ( + <View /> + ), + [feed], + ) + + return ( + <View testID={testID} style={style}> + {feed.isLoading && data.length === 0 && ( + <CenteredView style={{paddingTop: headerOffset}}> + <PostFeedLoadingPlaceholder /> + </CenteredView> + )} + {data.length > 0 && ( <FlatList ref={scrollElRef} data={data} keyExtractor={item => item._reactKey} renderItem={renderItem} ListFooterComponent={FeedFooter} - refreshing={feed.isRefreshing} + refreshing={isRefreshing} contentContainerStyle={s.contentContainer} onScroll={onScroll} onRefresh={onRefresh} onEndReached={onEndReached} + removeClippedSubviews={true} + contentInset={{top: headerOffset}} + contentOffset={{x: 0, y: headerOffset * -1}} + progressViewOffset={headerOffset} /> )} </View> diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index cda2ac0b0..67807b14e 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -8,7 +8,7 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -import {FeedItemModel} from '../../../state/models/feed-view' +import {FeedItemModel} from 'state/models/feed-view' import {Link} from '../util/Link' import {Text} from '../util/text/Text' import {UserInfoText} from '../util/UserInfoText' @@ -18,9 +18,10 @@ import {PostEmbeds} from '../util/PostEmbeds' import {RichText} from '../util/text/RichText' import * as Toast from '../util/Toast' import {UserAvatar} from '../util/UserAvatar' -import {s} from '../../lib/styles' -import {useStores} from '../../../state' -import {usePalette} from '../../lib/hooks/usePalette' +import {s} from 'lib/styles' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnalytics} from 'lib/analytics' export const FeedItem = observer(function ({ item, @@ -33,8 +34,11 @@ export const FeedItem = observer(function ({ }) { const store = useStores() const pal = usePalette('default') + const {track} = useAnalytics() const [deleted, setDeleted] = useState(false) const record = item.postRecord + const itemUri = item.post.uri + const itemCid = item.post.cid const itemHref = useMemo(() => { const urip = new AtUri(item.post.uri) return `/profile/${item.post.author.handle}/post/${urip.rkey}` @@ -50,6 +54,7 @@ export const FeedItem = observer(function ({ }, [record?.reply]) const onPressReply = () => { + track('FeedItem:PostReply') store.shell.openComposer({ replyTo: { uri: item.post.uri, @@ -64,12 +69,14 @@ export const FeedItem = observer(function ({ }) } const onPressToggleRepost = () => { - item + track('FeedItem:PostRepost') + return item .toggleRepost() .catch(e => store.log.error('Failed to toggle repost', e)) } const onPressToggleUpvote = () => { - item + track('FeedItem:PostLike') + return item .toggleUpvote() .catch(e => store.log.error('Failed to toggle upvote', e)) } @@ -78,6 +85,7 @@ export const FeedItem = observer(function ({ Toast.show('Copied to clipboard') } const onDeletePost = () => { + track('FeedItem:PostDelete') item.delete().then( () => { setDeleted(true) @@ -195,12 +203,11 @@ export const FeedItem = observer(function ({ <FontAwesomeIcon icon={['far', 'eye-slash']} style={s.mr2} /> <Text type="sm">This post is by a muted account.</Text> </View> - ) : record.text ? ( + ) : item.richText?.text ? ( <View style={styles.postTextContainer}> <RichText type="post-text" - text={record.text} - entities={record.entities} + richText={item.richText} lineHeight={1.3} /> </View> @@ -210,6 +217,8 @@ export const FeedItem = observer(function ({ ) : null} <PostCtrls style={styles.ctrls} + itemUri={itemUri} + itemCid={itemCid} itemHref={itemHref} itemTitle={itemTitle} isAuthor={item.post.author.did === store.me.did} diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 0cda3ba2a..2f93e59e6 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -1,23 +1,29 @@ import React from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {observer} from 'mobx-react-lite' import {Link} from '../util/Link' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' -import {s} from '../../lib/styles' -import {usePalette} from '../../lib/hooks/usePalette' +import * as Toast from '../util/Toast' +import {s} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {useStores} from 'state/index' +import * as apilib from 'lib/api/index' export function ProfileCard({ handle, displayName, avatar, + description, + isFollowedBy, renderButton, - onPressButton, }: { handle: string displayName?: string avatar?: string + description?: string + isFollowedBy?: boolean renderButton?: () => JSX.Element - onPressButton?: () => void }) { const pal = usePalette('default') return ( @@ -36,30 +42,117 @@ export function ProfileCard({ /> </View> <View style={styles.layoutContent}> - <Text style={[s.bold, pal.text]} numberOfLines={1}> + <Text type="lg" style={[s.bold, pal.text]} numberOfLines={1}> {displayName || handle} </Text> - <Text type="sm" style={[pal.textLight]} numberOfLines={1}> + <Text type="md" style={[pal.textLight]} numberOfLines={1}> @{handle} </Text> + {isFollowedBy && ( + <View style={s.flexRow}> + <View style={[s.mt5, pal.btn, styles.pill]}> + <Text type="xs">Follows You</Text> + </View> + </View> + )} </View> {renderButton ? ( - <View style={styles.layoutButton}> - <TouchableOpacity - onPress={onPressButton} - style={[styles.btn, pal.btn]}> - {renderButton()} - </TouchableOpacity> - </View> + <View style={styles.layoutButton}>{renderButton()}</View> ) : undefined} </View> + {description ? ( + <View style={styles.details}> + <Text style={pal.text} numberOfLines={4}> + {description} + </Text> + </View> + ) : undefined} </Link> ) } +export const ProfileCardWithFollowBtn = observer( + ({ + did, + declarationCid, + handle, + displayName, + avatar, + description, + isFollowedBy, + }: { + did: string + declarationCid: string + handle: string + displayName?: string + avatar?: string + description?: string + isFollowedBy?: boolean + }) => { + const store = useStores() + const isMe = store.me.handle === handle + const isFollowing = store.me.follows.isFollowing(did) + const onToggleFollow = async () => { + if (store.me.follows.isFollowing(did)) { + try { + await apilib.unfollow(store, store.me.follows.getFollowUri(did)) + store.me.follows.removeFollow(did) + } catch (e: any) { + store.log.error('Failed fo delete follow', e) + Toast.show('An issue occurred, please try again.') + } + } else { + try { + const res = await apilib.follow(store, did, declarationCid) + store.me.follows.addFollow(did, res.uri) + } catch (e: any) { + store.log.error('Failed fo create follow', e) + Toast.show('An issue occurred, please try again.') + } + } + } + return ( + <ProfileCard + handle={handle} + displayName={displayName} + avatar={avatar} + description={description} + isFollowedBy={isFollowedBy} + renderButton={ + isMe + ? undefined + : () => ( + <FollowBtn isFollowing={isFollowing} onPress={onToggleFollow} /> + ) + } + /> + ) + }, +) + +function FollowBtn({ + isFollowing, + onPress, +}: { + isFollowing: boolean + onPress: () => void +}) { + const pal = usePalette('default') + return ( + <TouchableOpacity onPress={onPress}> + <View style={[styles.btn, pal.btn]}> + <Text type="button" style={[pal.text]}> + {isFollowing ? 'Unfollow' : 'Follow'} + </Text> + </View> + </TouchableOpacity> + ) +} + const styles = StyleSheet.create({ outer: { borderTopWidth: 1, + paddingHorizontal: 6, }, layout: { flexDirection: 'row', @@ -68,7 +161,7 @@ const styles = StyleSheet.create({ layoutAvi: { width: 60, paddingLeft: 10, - paddingTop: 10, + paddingTop: 8, paddingBottom: 10, }, avi: { @@ -80,19 +173,26 @@ const styles = StyleSheet.create({ layoutContent: { flex: 1, paddingRight: 10, - paddingTop: 12, + paddingTop: 10, paddingBottom: 10, }, layoutButton: { paddingRight: 10, }, + details: { + paddingLeft: 60, + paddingRight: 10, + paddingBottom: 10, + }, + pill: { + borderRadius: 4, + paddingHorizontal: 6, + paddingVertical: 2, + }, btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', paddingVertical: 7, - paddingHorizontal: 14, borderRadius: 50, marginLeft: 6, + paddingHorizontal: 14, }, }) diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx index 13d134c39..7db770e4b 100644 --- a/src/view/com/profile/ProfileFollowers.tsx +++ b/src/view/com/profile/ProfileFollowers.tsx @@ -4,15 +4,11 @@ import {ActivityIndicator, StyleSheet, View} from 'react-native' import { UserFollowersViewModel, FollowerItem, -} from '../../../state/models/user-followers-view' +} from 'state/models/user-followers-view' import {CenteredView, FlatList} from '../util/Views' -import {Link} from '../util/Link' -import {Text} from '../util/text/Text' import {ErrorMessage} from '../util/error/ErrorMessage' -import {UserAvatar} from '../util/UserAvatar' -import {useStores} from '../../../state' -import {s} from '../../lib/styles' -import {usePalette} from '../../lib/hooks/usePalette' +import {ProfileCardWithFollowBtn} from './ProfileCard' +import {useStores} from 'state/index' export const ProfileFollowers = observer(function ProfileFollowers({ name, @@ -63,7 +59,15 @@ export const ProfileFollowers = observer(function ProfileFollowers({ // loaded // = const renderItem = ({item}: {item: FollowerItem}) => ( - <User key={item.did} item={item} /> + <ProfileCardWithFollowBtn + key={item.did} + did={item.did} + declarationCid={item.declaration.cid} + handle={item.handle} + displayName={item.displayName} + avatar={item.avatar} + isFollowedBy={!!item.viewer?.followedBy} + /> ) return ( <FlatList @@ -84,55 +88,7 @@ export const ProfileFollowers = observer(function ProfileFollowers({ ) }) -const User = ({item}: {item: FollowerItem}) => { - const pal = usePalette('default') - return ( - <Link - style={[styles.outer, pal.view, pal.border]} - href={`/profile/${item.handle}`} - title={item.handle} - noFeedback> - <View style={styles.layout}> - <View style={styles.layoutAvi}> - <UserAvatar - size={40} - displayName={item.displayName} - handle={item.handle} - avatar={item.avatar} - /> - </View> - <View style={styles.layoutContent}> - <Text style={[s.bold, pal.text]}> - {item.displayName || item.handle} - </Text> - <Text type="sm" style={[pal.textLight]}> - @{item.handle} - </Text> - </View> - </View> - </Link> - ) -} - const styles = StyleSheet.create({ - outer: { - borderTopWidth: 1, - }, - layout: { - flexDirection: 'row', - }, - layoutAvi: { - width: 60, - paddingLeft: 10, - paddingTop: 10, - paddingBottom: 10, - }, - layoutContent: { - flex: 1, - paddingRight: 10, - paddingTop: 10, - paddingBottom: 10, - }, footer: { height: 200, paddingTop: 20, diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx index de2fe3324..fb7f08ed8 100644 --- a/src/view/com/profile/ProfileFollows.tsx +++ b/src/view/com/profile/ProfileFollows.tsx @@ -2,17 +2,10 @@ import React, {useEffect} from 'react' import {observer} from 'mobx-react-lite' import {ActivityIndicator, StyleSheet, View} from 'react-native' import {CenteredView, FlatList} from '../util/Views' -import { - UserFollowsViewModel, - FollowItem, -} from '../../../state/models/user-follows-view' -import {Link} from '../util/Link' -import {Text} from '../util/text/Text' +import {UserFollowsViewModel, FollowItem} from 'state/models/user-follows-view' import {ErrorMessage} from '../util/error/ErrorMessage' -import {UserAvatar} from '../util/UserAvatar' -import {useStores} from '../../../state' -import {s} from '../../lib/styles' -import {usePalette} from '../../lib/hooks/usePalette' +import {ProfileCardWithFollowBtn} from './ProfileCard' +import {useStores} from 'state/index' export const ProfileFollows = observer(function ProfileFollows({ name, @@ -63,7 +56,15 @@ export const ProfileFollows = observer(function ProfileFollows({ // loaded // = const renderItem = ({item}: {item: FollowItem}) => ( - <User key={item.did} item={item} /> + <ProfileCardWithFollowBtn + key={item.did} + did={item.did} + declarationCid={item.declaration.cid} + handle={item.handle} + displayName={item.displayName} + avatar={item.avatar} + isFollowedBy={!!item.viewer?.followedBy} + /> ) return ( <FlatList @@ -84,59 +85,7 @@ export const ProfileFollows = observer(function ProfileFollows({ ) }) -const User = ({item}: {item: FollowItem}) => { - const pal = usePalette('default') - return ( - <Link - style={[styles.outer, pal.view, pal.border]} - href={`/profile/${item.handle}`} - title={item.handle} - noFeedback> - <View style={styles.layout}> - <View style={styles.layoutAvi}> - <UserAvatar - size={40} - displayName={item.displayName} - handle={item.handle} - avatar={ - item.avatar as - | string - | undefined /* HACK: type signature is wrong in the api */ - } - /> - </View> - <View style={styles.layoutContent}> - <Text style={[s.bold, pal.text]}> - {item.displayName || item.handle} - </Text> - <Text type="sm" style={[pal.textLight]}> - @{item.handle} - </Text> - </View> - </View> - </Link> - ) -} - const styles = StyleSheet.create({ - outer: { - borderTopWidth: 1, - }, - layout: { - flexDirection: 'row', - }, - layoutAvi: { - width: 60, - paddingLeft: 10, - paddingTop: 10, - paddingBottom: 10, - }, - layoutContent: { - flex: 1, - paddingRight: 10, - paddingTop: 10, - paddingBottom: 10, - }, footer: { height: 200, paddingTop: 20, diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index cebeea788..0ca6e1e74 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -13,15 +13,16 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {BlurView} from '../util/BlurView' -import {ProfileViewModel} from '../../../state/models/profile-view' -import {useStores} from '../../../state' +import {ProfileViewModel} from 'state/models/profile-view' +import {useStores} from 'state/index' import { EditProfileModal, ReportAccountModal, ProfileImageLightbox, -} from '../../../state/models/shell-ui' -import {pluralize, toShareUrl} from '../../../lib/strings' -import {s, gradients} from '../../lib/styles' +} from 'state/models/shell-ui' +import {pluralize} from 'lib/strings/helpers' +import {toShareUrl} from 'lib/strings/url-helpers' +import {s, gradients} from 'lib/styles' import {DropdownButton, DropdownItem} from '../util/forms/DropdownButton' import * as Toast from '../util/Toast' import {LoadingPlaceholder} from '../util/LoadingPlaceholder' @@ -29,7 +30,8 @@ import {Text} from '../util/text/Text' import {RichText} from '../util/text/RichText' import {UserAvatar} from '../util/UserAvatar' import {UserBanner} from '../util/UserBanner' -import {usePalette} from '../../lib/hooks/usePalette' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnalytics} from 'lib/analytics' export const ProfileHeader = observer(function ProfileHeader({ view, @@ -40,7 +42,7 @@ export const ProfileHeader = observer(function ProfileHeader({ }) { const pal = usePalette('default') const store = useStores() - + const {track} = useAnalytics() const onPressBack = () => { store.nav.tab.goBack() } @@ -53,7 +55,7 @@ export const ProfileHeader = observer(function ProfileHeader({ view?.toggleFollowing().then( () => { Toast.show( - `${view.myState.follow ? 'Following' : 'No longer following'} ${ + `${view.viewer.following ? 'Following' : 'No longer following'} ${ view.displayName || view.handle }`, ) @@ -62,18 +64,23 @@ export const ProfileHeader = observer(function ProfileHeader({ ) } const onPressEditProfile = () => { + track('ProfileHeader:EditProfileButtonClicked') store.shell.openModal(new EditProfileModal(view, onRefreshAll)) } const onPressFollowers = () => { + track('ProfileHeader:FollowersButtonClicked') store.nav.navigate(`/profile/${view.handle}/followers`) } const onPressFollows = () => { + track('ProfileHeader:FollowsButtonClicked') store.nav.navigate(`/profile/${view.handle}/follows`) } const onPressShare = () => { + track('ProfileHeader:ShareButtonClicked') Share.share({url: toShareUrl(`/profile/${view.handle}`)}) } const onPressMuteAccount = async () => { + track('ProfileHeader:MuteAccountButtonClicked') try { await view.muteAccount() Toast.show('Account muted') @@ -83,6 +90,7 @@ export const ProfileHeader = observer(function ProfileHeader({ } } const onPressUnmuteAccount = async () => { + track('ProfileHeader:UnmuteAccountButtonClicked') try { await view.unmuteAccount() Toast.show('Account unmuted') @@ -92,6 +100,7 @@ export const ProfileHeader = observer(function ProfileHeader({ } } const onPressReportAccount = () => { + track('ProfileHeader:ReportAccountButtonClicked') store.shell.openModal(new ReportAccountModal(view.did)) } @@ -110,7 +119,7 @@ export const ProfileHeader = observer(function ProfileHeader({ <LoadingPlaceholder width={100} height={31} style={styles.br50} /> </View> <View style={styles.displayNameLine}> - <Text type="title-xl" style={[pal.text, styles.title]}> + <Text type="title-2xl" style={[pal.text, styles.title]}> {view.displayName || view.handle} </Text> </View> @@ -135,8 +144,8 @@ export const ProfileHeader = observer(function ProfileHeader({ let dropdownItems: DropdownItem[] = [{label: 'Share', onPress: onPressShare}] if (!isMe) { dropdownItems.push({ - label: view.myState.muted ? 'Unmute Account' : 'Mute Account', - onPress: view.myState.muted ? onPressUnmuteAccount : onPressMuteAccount, + label: view.viewer.muted ? 'Unmute Account' : 'Mute Account', + onPress: view.viewer.muted ? onPressUnmuteAccount : onPressMuteAccount, }) dropdownItems.push({ label: 'Report Account', @@ -159,7 +168,7 @@ export const ProfileHeader = observer(function ProfileHeader({ </TouchableOpacity> ) : ( <> - {view.myState.follow ? ( + {store.me.follows.isFollowing(view.did) ? ( <TouchableOpacity onPress={onPressToggleFollow} style={[styles.btn, styles.mainBtn, pal.btn]}> @@ -206,11 +215,18 @@ export const ProfileHeader = observer(function ProfileHeader({ ) : undefined} </View> <View style={styles.displayNameLine}> - <Text type="title-xl" style={[pal.text, styles.title]}> + <Text type="title-2xl" style={[pal.text, styles.title]}> {view.displayName || view.handle} </Text> </View> <View style={styles.handleLine}> + {view.viewer.followedBy ? ( + <View style={[styles.pill, pal.btn, s.mr5]}> + <Text type="xs" style={[pal.text]}> + Follows you + </Text> + </View> + ) : undefined} <Text style={pal.textLight}>@{view.handle}</Text> </View> <View style={styles.metricsLine}> @@ -247,22 +263,21 @@ export const ProfileHeader = observer(function ProfileHeader({ </Text> </View> </View> - {view.description ? ( + {view.descriptionRichText ? ( <RichText style={[styles.description, pal.text]} numberOfLines={15} - text={view.description} - entities={view.descriptionEntities} + richText={view.descriptionRichText} /> ) : undefined} - {view.myState.muted ? ( + {view.viewer.muted ? ( <View style={[styles.detailLine, pal.btn, s.p5]}> <FontAwesomeIcon icon={['far', 'eye-slash']} style={[pal.text, s.mr5]} /> <Text type="md" style={[s.mr2, pal.text]}> - Account muted. + Account muted </Text> </View> ) : undefined} @@ -369,6 +384,12 @@ const styles = StyleSheet.create({ marginBottom: 5, }, + pill: { + borderRadius: 4, + paddingHorizontal: 6, + paddingVertical: 2, + }, + br40: {borderRadius: 40}, br50: {borderRadius: 50}, }) diff --git a/src/view/com/util/BlurView.web.tsx b/src/view/com/util/BlurView.web.tsx index 7e4300c7c..efcf40b9c 100644 --- a/src/view/com/util/BlurView.web.tsx +++ b/src/view/com/util/BlurView.web.tsx @@ -1,6 +1,6 @@ import React from 'react' import {StyleSheet, View, ViewProps} from 'react-native' -import {addStyle} from '../../lib/addStyle' +import {addStyle} from 'lib/styles' type BlurViewProps = ViewProps & { blurType?: 'dark' | 'light' diff --git a/src/view/com/util/EmptyState.tsx b/src/view/com/util/EmptyState.tsx index 6c5c3f342..2b2c4e657 100644 --- a/src/view/com/util/EmptyState.tsx +++ b/src/view/com/util/EmptyState.tsx @@ -6,8 +6,8 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {Text} from './text/Text' -import {UserGroupIcon} from '../../lib/icons' -import {usePalette} from '../../lib/hooks/usePalette' +import {UserGroupIcon} from 'lib/icons' +import {usePalette} from 'lib/hooks/usePalette' export function EmptyState({ icon, diff --git a/src/view/com/util/FAB.tsx b/src/view/com/util/FAB.tsx index 1129f3361..007ca0ee4 100644 --- a/src/view/com/util/FAB.tsx +++ b/src/view/com/util/FAB.tsx @@ -1,41 +1,53 @@ import React from 'react' import {observer} from 'mobx-react-lite' import { + Animated, GestureResponderEvent, StyleSheet, TouchableWithoutFeedback, - View, } from 'react-native' import LinearGradient from 'react-native-linear-gradient' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {IconProp} from '@fortawesome/fontawesome-svg-core' -import {colors, gradients} from '../../lib/styles' -import {useStores} from '../../../state' +import {colors, gradients} from 'lib/styles' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import {useStores} from 'state/index' type OnPress = ((event: GestureResponderEvent) => void) | undefined export const FAB = observer( - ({icon, onPress}: {icon: IconProp; onPress: OnPress}) => { + ({ + testID, + icon, + onPress, + }: { + testID?: string + icon: IconProp + onPress: OnPress + }) => { const store = useStores() + const interp = useAnimatedValue(0) + React.useEffect(() => { + Animated.timing(interp, { + toValue: store.shell.minimalShellMode ? 1 : 0, + duration: 100, + useNativeDriver: true, + isInteraction: false, + }).start() + }, [interp, store.shell.minimalShellMode]) + const transform = { + transform: [{translateY: Animated.multiply(interp, 60)}], + } return ( - <TouchableWithoutFeedback onPress={onPress}> - <View - style={[ - styles.outer, - store.shell.minimalShellMode ? styles.lower : undefined, - ]}> + <TouchableWithoutFeedback testID={testID} onPress={onPress}> + <Animated.View style={[styles.outer, transform]}> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} end={{x: 1, y: 1}} style={styles.inner}> - <FontAwesomeIcon - size={24} - icon={icon} - color={colors.white} - style={styles.icon} - /> + <FontAwesomeIcon size={24} icon={icon} color={colors.white} /> </LinearGradient> - </View> + </Animated.View> </TouchableWithoutFeedback> ) }, @@ -46,16 +58,10 @@ const styles = StyleSheet.create({ position: 'absolute', zIndex: 1, right: 22, - bottom: 84, + bottom: 94, width: 60, height: 60, borderRadius: 30, - shadowColor: '#000', - shadowOpacity: 0.3, - shadowOffset: {width: 0, height: 1}, - }, - lower: { - bottom: 34, }, inner: { width: 60, @@ -64,5 +70,4 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', }, - icon: {}, }) diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index 1cbb1af83..bdc447937 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -10,9 +10,9 @@ import { ViewStyle, } from 'react-native' import {Text} from './text/Text' -import {TypographyVariant} from '../../lib/ThemeContext' -import {useStores, RootStoreModel} from '../../../state' -import {convertBskyAppUrlIfNeeded} from '../../../lib/strings' +import {TypographyVariant} from 'lib/ThemeContext' +import {useStores, RootStoreModel} from 'state/index' +import {convertBskyAppUrlIfNeeded} from 'lib/strings/url-helpers' export const Link = observer(function Link({ style, @@ -22,17 +22,21 @@ export const Link = observer(function Link({ noFeedback, }: { style?: StyleProp<ViewStyle> - href: string + href?: string title?: string children?: React.ReactNode noFeedback?: boolean }) { const store = useStores() const onPress = () => { - handleLink(store, href, false) + if (href) { + handleLink(store, href, false) + } } const onLongPress = () => { - handleLink(store, href, true) + if (href) { + handleLink(store, href, true) + } } if (noFeedback) { return ( diff --git a/src/view/com/util/LoadLatestBtn.tsx b/src/view/com/util/LoadLatestBtn.tsx index 43fa97e6f..fd05ecc9c 100644 --- a/src/view/com/util/LoadLatestBtn.tsx +++ b/src/view/com/util/LoadLatestBtn.tsx @@ -4,9 +4,9 @@ import {observer} from 'mobx-react-lite' import LinearGradient from 'react-native-linear-gradient' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {Text} from './text/Text' -import {colors, gradients} from '../../lib/styles' +import {colors, gradients} from 'lib/styles' import {clamp} from 'lodash' -import {useStores} from '../../../state' +import {useStores} from 'state/index' const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20} diff --git a/src/view/com/util/LoadLatestBtn.web.tsx b/src/view/com/util/LoadLatestBtn.web.tsx index 388927388..182c1ba5d 100644 --- a/src/view/com/util/LoadLatestBtn.web.tsx +++ b/src/view/com/util/LoadLatestBtn.web.tsx @@ -1,7 +1,7 @@ import React from 'react' import {StyleSheet, TouchableOpacity} from 'react-native' import {Text} from './text/Text' -import {usePalette} from '../../lib/hooks/usePalette' +import {usePalette} from 'lib/hooks/usePalette' const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20} diff --git a/src/view/com/util/LoadingPlaceholder.tsx b/src/view/com/util/LoadingPlaceholder.tsx index 9bb200d50..9e72640d2 100644 --- a/src/view/com/util/LoadingPlaceholder.tsx +++ b/src/view/com/util/LoadingPlaceholder.tsx @@ -1,10 +1,10 @@ import React from 'react' import {StyleSheet, StyleProp, View, ViewStyle} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {HeartIcon} from '../../lib/icons' -import {s} from '../../lib/styles' -import {useTheme} from '../../lib/ThemeContext' -import {usePalette} from '../../lib/hooks/usePalette' +import {HeartIcon} from 'lib/icons' +import {s} from 'lib/styles' +import {useTheme} from 'lib/ThemeContext' +import {usePalette} from 'lib/hooks/usePalette' export function LoadingPlaceholder({ width, diff --git a/src/view/com/util/Picker.tsx b/src/view/com/util/Picker.tsx index 208ec0492..9007cb1f0 100644 --- a/src/view/com/util/Picker.tsx +++ b/src/view/com/util/Picker.tsx @@ -16,7 +16,7 @@ import { } from '@fortawesome/react-native-fontawesome' import RootSiblings from 'react-native-root-siblings' import {Text} from './text/Text' -import {colors} from '../../lib/styles' +import {colors} from 'lib/styles' interface PickerItem { value: string diff --git a/src/view/com/util/PostCtrls.tsx b/src/view/com/util/PostCtrls.tsx index fca70b687..e42c5e63b 100644 --- a/src/view/com/util/PostCtrls.tsx +++ b/src/view/com/util/PostCtrls.tsx @@ -1,6 +1,5 @@ import React from 'react' import { - Animated, StyleProp, StyleSheet, TouchableOpacity, @@ -12,6 +11,11 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import ReactNativeHapticFeedback from 'react-native-haptic-feedback' +// DISABLED see #135 +// import { +// TriggerableAnimated, +// TriggerableAnimatedRef, +// } from './anim/TriggerableAnimated' import {Text} from './text/Text' import {PostDropdownBtn} from './forms/DropdownButton' import { @@ -19,12 +23,13 @@ import { HeartIconSolid, RepostIcon, CommentBottomArrow, -} from '../../lib/icons' -import {s, colors} from '../../lib/styles' -import {useTheme} from '../../lib/ThemeContext' -import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue' +} from 'lib/icons' +import {s, colors} from 'lib/styles' +import {useTheme} from 'lib/ThemeContext' interface PostCtrlsOpts { + itemUri: string + itemCid: string itemHref: string itemTitle: string isAuthor: boolean @@ -36,91 +41,110 @@ interface PostCtrlsOpts { isReposted: boolean isUpvoted: boolean onPressReply: () => void - onPressToggleRepost: () => void - onPressToggleUpvote: () => void + onPressToggleRepost: () => Promise<void> + onPressToggleUpvote: () => Promise<void> onCopyPostText: () => void onDeletePost: () => void } -const HITSLOP = {top: 2, left: 2, bottom: 2, right: 2} +const HITSLOP = {top: 5, left: 5, bottom: 5, right: 5} -export function PostCtrls(opts: PostCtrlsOpts) { - const theme = useTheme() - const defaultCtrlColor = React.useMemo( - () => ({ - color: theme.palette.default.postCtrl, +// DISABLED see #135 +/* +function ctrlAnimStart(interp: Animated.Value) { + return Animated.sequence([ + Animated.timing(interp, { + toValue: 1, + duration: 250, + useNativeDriver: true, }), - [theme], - ) - const interp1 = useAnimatedValue(0) - const interp2 = useAnimatedValue(0) - - const anim1Style = { - transform: [ - { - scale: interp1.interpolate({ - inputRange: [0, 1.0], - outputRange: [1.0, 4.0], - }), - }, - ], - opacity: interp1.interpolate({ - inputRange: [0, 1.0], - outputRange: [1.0, 0.0], + Animated.delay(50), + Animated.timing(interp, { + toValue: 0, + duration: 20, + useNativeDriver: true, }), - } - const anim2Style = { + ]) +} + +function ctrlAnimStyle(interp: Animated.Value) { + return { transform: [ { - scale: interp2.interpolate({ + scale: interp.interpolate({ inputRange: [0, 1.0], outputRange: [1.0, 4.0], }), }, ], - opacity: interp2.interpolate({ + opacity: interp.interpolate({ inputRange: [0, 1.0], outputRange: [1.0, 0.0], }), } +} +*/ +export function PostCtrls(opts: PostCtrlsOpts) { + const theme = useTheme() + const defaultCtrlColor = React.useMemo( + () => ({ + color: theme.palette.default.postCtrl, + }), + [theme], + ) as StyleProp<ViewStyle> + const [repostMod, setRepostMod] = React.useState<number>(0) + const [likeMod, setLikeMod] = React.useState<number>(0) + // DISABLED see #135 + // const repostRef = React.useRef<TriggerableAnimatedRef | null>(null) + // const likeRef = React.useRef<TriggerableAnimatedRef | null>(null) const onPressToggleRepostWrapper = () => { if (!opts.isReposted) { ReactNativeHapticFeedback.trigger('impactMedium') - Animated.sequence([ - Animated.timing(interp1, { - toValue: 1, - duration: 400, - useNativeDriver: true, - }), - Animated.delay(100), - Animated.timing(interp1, { - toValue: 0, - duration: 20, - useNativeDriver: true, - }), - ]).start() + setRepostMod(1) + opts + .onPressToggleRepost() + .catch(_e => undefined) + .then(() => setRepostMod(0)) + // DISABLED see #135 + // repostRef.current?.trigger( + // {start: ctrlAnimStart, style: ctrlAnimStyle}, + // async () => { + // await opts.onPressToggleRepost().catch(_e => undefined) + // setRepostMod(0) + // }, + // ) + } else { + setRepostMod(-1) + opts + .onPressToggleRepost() + .catch(_e => undefined) + .then(() => setRepostMod(0)) } - opts.onPressToggleRepost() } const onPressToggleUpvoteWrapper = () => { if (!opts.isUpvoted) { ReactNativeHapticFeedback.trigger('impactMedium') - Animated.sequence([ - Animated.timing(interp2, { - toValue: 1, - duration: 400, - useNativeDriver: true, - }), - Animated.delay(100), - Animated.timing(interp2, { - toValue: 0, - duration: 20, - useNativeDriver: true, - }), - ]).start() + setLikeMod(1) + opts + .onPressToggleUpvote() + .catch(_e => undefined) + .then(() => setLikeMod(0)) + // DISABLED see #135 + // likeRef.current?.trigger( + // {start: ctrlAnimStart, style: ctrlAnimStyle}, + // async () => { + // await opts.onPressToggleUpvote().catch(_e => undefined) + // setLikeMod(0) + // }, + // ) + } else { + setLikeMod(-1) + opts + .onPressToggleUpvote() + .catch(_e => undefined) + .then(() => setLikeMod(0)) } - opts.onPressToggleUpvote() } return ( @@ -147,7 +171,17 @@ export function PostCtrls(opts: PostCtrlsOpts) { hitSlop={HITSLOP} onPress={onPressToggleRepostWrapper} style={styles.ctrl}> - <Animated.View style={anim1Style}> + <RepostIcon + style={ + opts.isReposted || repostMod > 0 + ? (styles.ctrlIconReposted as StyleProp<ViewStyle>) + : defaultCtrlColor + } + strokeWidth={2.4} + size={opts.big ? 24 : 20} + /> + { + undefined /*DISABLED see #135 <TriggerableAnimated ref={repostRef}> <RepostIcon style={ (opts.isReposted @@ -157,15 +191,16 @@ export function PostCtrls(opts: PostCtrlsOpts) { strokeWidth={2.4} size={opts.big ? 24 : 20} /> - </Animated.View> + </TriggerableAnimated>*/ + } {typeof opts.repostCount !== 'undefined' ? ( <Text style={ - opts.isReposted + opts.isReposted || repostMod > 0 ? [s.bold, s.green3, s.f15, s.ml5] : [defaultCtrlColor, s.f15, s.ml5] }> - {opts.repostCount} + {opts.repostCount + repostMod} </Text> ) : undefined} </TouchableOpacity> @@ -175,8 +210,21 @@ export function PostCtrls(opts: PostCtrlsOpts) { style={styles.ctrl} hitSlop={HITSLOP} onPress={onPressToggleUpvoteWrapper}> - <Animated.View style={anim2Style}> - {opts.isUpvoted ? ( + {opts.isUpvoted || likeMod > 0 ? ( + <HeartIconSolid + style={styles.ctrlIconUpvoted as StyleProp<ViewStyle>} + size={opts.big ? 22 : 16} + /> + ) : ( + <HeartIcon + style={[defaultCtrlColor, opts.big ? styles.mt1 : undefined]} + strokeWidth={3} + size={opts.big ? 20 : 16} + /> + )} + { + undefined /*DISABLED see #135 <TriggerableAnimated ref={likeRef}> + {opts.isUpvoted || likeMod > 0 ? ( <HeartIconSolid style={styles.ctrlIconUpvoted as ViewStyle} size={opts.big ? 22 : 16} @@ -191,15 +239,16 @@ export function PostCtrls(opts: PostCtrlsOpts) { size={opts.big ? 20 : 16} /> )} - </Animated.View> + </TriggerableAnimated>*/ + } {typeof opts.upvoteCount !== 'undefined' ? ( <Text style={ - opts.isUpvoted + opts.isUpvoted || likeMod > 0 ? [s.bold, s.red3, s.f15, s.ml5] : [defaultCtrlColor, s.f15, s.ml5] }> - {opts.upvoteCount} + {opts.upvoteCount + likeMod} </Text> ) : undefined} </TouchableOpacity> @@ -208,6 +257,8 @@ export function PostCtrls(opts: PostCtrlsOpts) { {opts.big ? undefined : ( <PostDropdownBtn style={styles.ctrl} + itemUri={opts.itemUri} + itemCid={opts.itemCid} itemHref={opts.itemHref} itemTitle={opts.itemTitle} isAuthor={opts.isAuthor} diff --git a/src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx b/src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx new file mode 100644 index 000000000..e8c63bdb7 --- /dev/null +++ b/src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx @@ -0,0 +1,69 @@ +import React from 'react' +import {Text} from '../text/Text' +import {AutoSizedImage} from '../images/AutoSizedImage' +import {StyleSheet, View} from 'react-native' +import {usePalette} from 'lib/hooks/usePalette' +import {PresentedExternal} from '@atproto/api/dist/client/types/app/bsky/embed/external' + +const ExternalLinkEmbed = ({ + link, + onImagePress, + imageChild, +}: { + link: PresentedExternal + onImagePress?: () => void + imageChild?: React.ReactNode +}) => { + const pal = usePalette('default') + return ( + <> + {link.thumb ? ( + <AutoSizedImage + uri={link.thumb} + style={styles.extImage} + onPress={onImagePress}> + {imageChild} + </AutoSizedImage> + ) : undefined} + <View style={styles.extInner}> + <Text type="md-bold" numberOfLines={2} style={[pal.text]}> + {link.title || link.uri} + </Text> + <Text + type="sm" + numberOfLines={1} + style={[pal.textLight, styles.extUri]}> + {link.uri} + </Text> + {link.description ? ( + <Text + type="sm" + numberOfLines={2} + style={[pal.text, styles.extDescription]}> + {link.description} + </Text> + ) : undefined} + </View> + </> + ) +} + +const styles = StyleSheet.create({ + extInner: { + padding: 10, + }, + extImage: { + borderTopLeftRadius: 6, + borderTopRightRadius: 6, + width: '100%', + maxHeight: 200, + }, + extUri: { + marginTop: 2, + }, + extDescription: { + marginTop: 4, + }, +}) + +export default ExternalLinkEmbed diff --git a/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx b/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx new file mode 100644 index 000000000..d9425fe4e --- /dev/null +++ b/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx @@ -0,0 +1,119 @@ +import React, {useEffect} from 'react' +import {useState} from 'react' +import { + View, + StyleSheet, + Pressable, + TouchableWithoutFeedback, + EmitterSubscription, +} from 'react-native' +import YoutubePlayer from 'react-native-youtube-iframe' +import {usePalette} from 'lib/hooks/usePalette' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import ExternalLinkEmbed from './ExternalLinkEmbed' +import {PresentedExternal} from '@atproto/api/dist/client/types/app/bsky/embed/external' +import {useStores} from 'state/index' + +const YoutubeEmbed = ({ + link, + videoId, +}: { + videoId: string + link: PresentedExternal +}) => { + const store = useStores() + const [displayVideoPlayer, setDisplayVideoPlayer] = useState(false) + const [playerDimensions, setPlayerDimensions] = useState({ + width: 0, + height: 0, + }) + const pal = usePalette('default') + const handlePlayButtonPressed = () => { + setDisplayVideoPlayer(true) + } + const handleOnLayout = (event: { + nativeEvent: {layout: {width: any; height: any}} + }) => { + setPlayerDimensions({ + width: event.nativeEvent.layout.width, + height: event.nativeEvent.layout.height, + }) + } + useEffect(() => { + let sub: EmitterSubscription + if (displayVideoPlayer) { + sub = store.onNavigation(() => { + setDisplayVideoPlayer(false) + }) + } + return () => sub && sub.remove() + }, [displayVideoPlayer, store]) + + const imageChild = ( + <Pressable onPress={handlePlayButtonPressed} style={styles.playButton}> + <FontAwesomeIcon icon="play" size={24} color="white" /> + </Pressable> + ) + + if (!displayVideoPlayer) { + return ( + <View + style={[styles.extOuter, pal.view, pal.border]} + onLayout={handleOnLayout}> + <ExternalLinkEmbed + link={link} + onImagePress={handlePlayButtonPressed} + imageChild={imageChild} + /> + </View> + ) + } + + const height = (playerDimensions.width / 16) * 9 + const noop = () => {} + + return ( + <TouchableWithoutFeedback onPress={noop}> + <View> + {/* Removing the outter View will make tap events propagate to parents */} + <YoutubePlayer + initialPlayerParams={{ + modestbranding: true, + }} + webViewProps={{ + startInLoadingState: true, + }} + height={height} + videoId={videoId} + webViewStyle={styles.webView} + /> + </View> + </TouchableWithoutFeedback> + ) +} + +const styles = StyleSheet.create({ + extOuter: { + borderWidth: 1, + borderRadius: 8, + marginTop: 4, + }, + playButton: { + position: 'absolute', + alignSelf: 'center', + alignItems: 'center', + top: '44%', + justifyContent: 'center', + backgroundColor: 'black', + padding: 10, + borderRadius: 50, + opacity: 0.8, + }, + webView: { + alignItems: 'center', + alignContent: 'center', + justifyContent: 'center', + }, +}) + +export default YoutubeEmbed diff --git a/src/view/com/util/PostEmbeds.tsx b/src/view/com/util/PostEmbeds/index.tsx index 1d8df038b..031f01e88 100644 --- a/src/view/com/util/PostEmbeds.tsx +++ b/src/view/com/util/PostEmbeds/index.tsx @@ -1,16 +1,22 @@ import React from 'react' -import {StyleSheet, StyleProp, View, ViewStyle} from 'react-native' +import { + StyleSheet, + StyleProp, + View, + ViewStyle, + Image as RNImage, +} from 'react-native' import {AppBskyEmbedImages, AppBskyEmbedExternal} from '@atproto/api' -import LinearGradient from 'react-native-linear-gradient' -import {Link} from '../util/Link' -import {Text} from './text/Text' -import {AutoSizedImage} from './images/AutoSizedImage' -import {ImageLayoutGrid} from './images/ImageLayoutGrid' -import {ImagesLightbox} from '../../../state/models/shell-ui' -import {useStores} from '../../../state' -import {usePalette} from '../../lib/hooks/usePalette' -import {gradients} from '../../lib/styles' -import {saveImageModal} from '../../../lib/images' +import {Link} from '../Link' +import {AutoSizedImage} from '../images/AutoSizedImage' +import {ImageLayoutGrid} from '../images/ImageLayoutGrid' +import {ImagesLightbox} from 'state/models/shell-ui' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {saveImageModal} from 'lib/images' +import YoutubeEmbed from './YoutubeEmbed' +import ExternalLinkEmbed from './ExternalLinkEmbed' +import {getYoutubeVideoId} from 'lib/strings/url-helpers' type Embed = | AppBskyEmbedImages.Presented @@ -35,6 +41,16 @@ export function PostEmbeds({ const onLongPress = (index: number) => { saveImageModal({uri: uris[index]}) } + const onPressIn = (index: number) => { + const firstImageToShow = uris[index] + RNImage.prefetch(firstImageToShow) + uris.forEach(uri => { + if (firstImageToShow !== uri) { + // First image already prefeched above + RNImage.prefetch(uri) + } + }) + } if (embed.images.length === 4) { return ( @@ -44,6 +60,7 @@ export function PostEmbeds({ uris={embed.images.map(img => img.thumb)} onPress={openLightbox} onLongPress={onLongPress} + onPressIn={onPressIn} /> </View> ) @@ -55,6 +72,7 @@ export function PostEmbeds({ uris={embed.images.map(img => img.thumb)} onPress={openLightbox} onLongPress={onLongPress} + onPressIn={onPressIn} /> </View> ) @@ -66,6 +84,7 @@ export function PostEmbeds({ uris={embed.images.map(img => img.thumb)} onPress={openLightbox} onLongPress={onLongPress} + onPressIn={onPressIn} /> </View> ) @@ -76,7 +95,8 @@ export function PostEmbeds({ uri={embed.images[0].thumb} onPress={() => openLightbox(0)} onLongPress={() => onLongPress(0)} - containerStyle={styles.singleImage} + onPressIn={() => onPressIn(0)} + style={styles.singleImage} /> </View> ) @@ -85,40 +105,18 @@ export function PostEmbeds({ } if (AppBskyEmbedExternal.isPresented(embed)) { const link = embed.external + const youtubeVideoId = getYoutubeVideoId(link.uri) + + if (youtubeVideoId) { + return <YoutubeEmbed videoId={youtubeVideoId} link={link} /> + } + return ( <Link style={[styles.extOuter, pal.view, pal.border, style]} href={link.uri} noFeedback> - {link.thumb ? ( - <AutoSizedImage uri={link.thumb} containerStyle={styles.extImage} /> - ) : ( - <LinearGradient - colors={[gradients.blueDark.start, gradients.blueDark.end]} - start={{x: 0, y: 0}} - end={{x: 1, y: 1}} - style={[styles.extImage, styles.extImageFallback]} - /> - )} - <View style={styles.extInner}> - <Text type="md-bold" numberOfLines={2} style={[pal.text]}> - {link.title || link.uri} - </Text> - <Text - type="sm" - numberOfLines={1} - style={[pal.textLight, styles.extUri]}> - {link.uri} - </Text> - {link.description ? ( - <Text - type="sm" - numberOfLines={2} - style={[pal.text, styles.extDescription]}> - {link.description} - </Text> - ) : undefined} - </View> + <ExternalLinkEmbed link={link} /> </Link> ) } @@ -131,28 +129,11 @@ const styles = StyleSheet.create({ }, singleImage: { borderRadius: 8, + maxHeight: 500, }, extOuter: { borderWidth: 1, borderRadius: 8, marginTop: 4, }, - extInner: { - padding: 10, - }, - extImage: { - borderTopLeftRadius: 6, - borderTopRightRadius: 6, - width: '100%', - maxHeight: 200, - }, - extImageFallback: { - height: 160, - }, - extUri: { - marginTop: 2, - }, - extDescription: { - marginTop: 4, - }, }) diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index 35edeafb4..16b9535ff 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -1,8 +1,8 @@ import React from 'react' import {Platform, StyleSheet, View} from 'react-native' import {Text} from './text/Text' -import {ago} from '../../../lib/strings' -import {usePalette} from '../../lib/hooks/usePalette' +import {ago} from 'lib/strings/time' +import {usePalette} from 'lib/hooks/usePalette' interface PostMetaOpts { authorHandle: string diff --git a/src/view/com/util/Selector.tsx b/src/view/com/util/Selector.tsx index 87540cf38..5b331dc8d 100644 --- a/src/view/com/util/Selector.tsx +++ b/src/view/com/util/Selector.tsx @@ -6,7 +6,7 @@ import { View, } from 'react-native' import {Text} from './text/Text' -import {usePalette} from '../../lib/hooks/usePalette' +import {usePalette} from 'lib/hooks/usePalette' interface Layout { x: number diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 287d94412..9b8dd3de5 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -1,15 +1,23 @@ -import React, {useCallback} from 'react' -import {Alert, Image, StyleSheet, TouchableOpacity, View} from 'react-native' +import React from 'react' +import {StyleSheet, View} from 'react-native' import Svg, {Circle, Text, Defs, LinearGradient, Stop} from 'react-native-svg' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {IconProp} from '@fortawesome/fontawesome-svg-core' +import {HighPriorityImage} from 'view/com/util/images/Image' import { openCamera, openCropper, openPicker, PickedMedia, } from './images/image-crop-picker/ImageCropPicker' -import {useStores} from '../../../state' -import {colors, gradients} from '../../lib/styles' +import { + requestPhotoAccessIfNeeded, + requestCameraAccessIfNeeded, +} from 'lib/permissions' +import {useStores} from 'state/index' +import {colors, gradients} from 'lib/styles' +import {DropdownButton} from './forms/DropdownButton' +import {usePalette} from 'lib/hooks/usePalette' export function UserAvatar({ size, @@ -25,40 +33,9 @@ export function UserAvatar({ onSelectNewAvatar?: (img: PickedMedia) => void }) { const store = useStores() + const pal = usePalette('default') const initials = getInitials(displayName || handle) - const handleEditAvatar = useCallback(() => { - Alert.alert('Select upload method', '', [ - { - text: 'Take a new photo', - onPress: () => { - openCamera(store, { - mediaType: 'photo', - width: 1000, - height: 1000, - cropperCircleOverlay: true, - }).then(onSelectNewAvatar) - }, - }, - { - text: 'Select from gallery', - onPress: () => { - openPicker(store, { - mediaType: 'photo', - }).then(async items => { - await openCropper(store, { - mediaType: 'photo', - path: items[0].path, - width: 1000, - height: 1000, - cropperCircleOverlay: true, - }).then(onSelectNewAvatar) - }) - }, - }, - ]) - }, [store, onSelectNewAvatar]) - const renderSvg = (svgSize: number, svgInitials: string) => ( <Svg width={svgSize} height={svgSize} viewBox="0 0 100 100"> <Defs> @@ -80,11 +57,65 @@ export function UserAvatar({ </Svg> ) + const dropdownItems = [ + { + label: 'Camera', + icon: 'camera' as IconProp, + onPress: async () => { + if (!(await requestCameraAccessIfNeeded())) { + return + } + onSelectNewAvatar?.( + await openCamera(store, { + mediaType: 'photo', + width: 1000, + height: 1000, + cropperCircleOverlay: true, + }), + ) + }, + }, + { + label: 'Library', + icon: 'image' as IconProp, + onPress: async () => { + if (!(await requestPhotoAccessIfNeeded())) { + return + } + const items = await openPicker(store, { + mediaType: 'photo', + }) + onSelectNewAvatar?.( + await openCropper(store, { + mediaType: 'photo', + path: items[0].path, + width: 1000, + height: 1000, + cropperCircleOverlay: true, + }), + ) + }, + }, + // TODO: Remove avatar https://github.com/bluesky-social/social-app/issues/122 + // { + // label: 'Remove', + // icon: ['far', 'trash-can'], + // onPress: () => { + // // Remove avatar API call + // }, + // }, + ] // onSelectNewAvatar is only passed as prop on the EditProfile component return onSelectNewAvatar ? ( - <TouchableOpacity onPress={handleEditAvatar}> + <DropdownButton + type="bare" + items={dropdownItems} + openToRight + rightOffset={-10} + bottomOffset={-10} + menuWidth={170}> {avatar ? ( - <Image + <HighPriorityImage style={{ width: size, height: size, @@ -95,16 +126,16 @@ export function UserAvatar({ ) : ( renderSvg(size, initials) )} - <View style={styles.editButtonContainer}> + <View style={[styles.editButtonContainer, pal.btn]}> <FontAwesomeIcon icon="camera" size={12} - style={{color: colors.white}} + color={pal.text.color as string} /> </View> - </TouchableOpacity> + </DropdownButton> ) : avatar ? ( - <Image + <HighPriorityImage style={{width: size, height: size, borderRadius: Math.floor(size / 2)}} resizeMode="stretch" source={{uri: avatar}} diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index d5d6e3aaa..dc140b035 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -1,15 +1,23 @@ -import React, {useCallback} from 'react' -import {StyleSheet, View, TouchableOpacity, Alert, Image} from 'react-native' +import React from 'react' +import {StyleSheet, View} from 'react-native' import Svg, {Rect, Defs, LinearGradient, Stop} from 'react-native-svg' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {colors, gradients} from '../../lib/styles' +import {IconProp} from '@fortawesome/fontawesome-svg-core' +import Image from 'view/com/util/images/Image' +import {colors, gradients} from 'lib/styles' import { openCamera, openCropper, openPicker, PickedMedia, } from './images/image-crop-picker/ImageCropPicker' -import {useStores} from '../../../state' +import {useStores} from 'state/index' +import { + requestPhotoAccessIfNeeded, + requestCameraAccessIfNeeded, +} from 'lib/permissions' +import {DropdownButton} from './forms/DropdownButton' +import {usePalette} from 'lib/hooks/usePalette' export function UserBanner({ banner, @@ -19,39 +27,57 @@ export function UserBanner({ onSelectNewBanner?: (img: PickedMedia) => void }) { const store = useStores() - const handleEditBanner = useCallback(() => { - Alert.alert('Select upload method', '', [ - { - text: 'Take a new photo', - onPress: () => { - openCamera(store, { + const pal = usePalette('default') + const dropdownItems = [ + { + label: 'Camera', + icon: 'camera' as IconProp, + onPress: async () => { + if (!(await requestCameraAccessIfNeeded())) { + return + } + onSelectNewBanner?.( + await openCamera(store, { mediaType: 'photo', // compressImageMaxWidth: 3000, TODO needed? width: 3000, // compressImageMaxHeight: 1000, TODO needed? height: 1000, - }).then(onSelectNewBanner) - }, + }), + ) }, - { - text: 'Select from gallery', - onPress: () => { - openPicker(store, { + }, + { + label: 'Library', + icon: 'image' as IconProp, + onPress: async () => { + if (!(await requestPhotoAccessIfNeeded())) { + return + } + const items = await openPicker(store, { + mediaType: 'photo', + }) + onSelectNewBanner?.( + await openCropper(store, { mediaType: 'photo', - }).then(async items => { - await openCropper(store, { - mediaType: 'photo', - path: items[0].path, - // compressImageMaxWidth: 3000, TODO needed? - width: 3000, - // compressImageMaxHeight: 1000, TODO needed? - height: 1000, - }).then(onSelectNewBanner) - }) - }, + path: items[0].path, + // compressImageMaxWidth: 3000, TODO needed? + width: 3000, + // compressImageMaxHeight: 1000, TODO needed? + height: 1000, + }), + ) }, - ]) - }, [store, onSelectNewBanner]) + }, + // TODO: Remove banner https://github.com/bluesky-social/social-app/issues/122 + // { + // label: 'Remove', + // icon: ['far', 'trash-can'], + // onPress: () => { + // // Remove banner api call + // }, + // }, + ] const renderSvg = () => ( <Svg width="100%" height="150" viewBox="50 0 200 100"> @@ -72,20 +98,27 @@ export function UserBanner({ // setUserBanner is only passed as prop on the EditProfile component return onSelectNewBanner ? ( - <TouchableOpacity onPress={handleEditBanner}> + <DropdownButton + type="bare" + items={dropdownItems} + openToRight + rightOffset={-200} + bottomOffset={-10} + menuWidth={170}> {banner ? ( <Image style={styles.bannerImage} source={{uri: banner}} /> ) : ( renderSvg() )} - <View style={styles.editButtonContainer}> + <View style={[styles.editButtonContainer, pal.btn]}> <FontAwesomeIcon icon="camera" size={12} style={{color: colors.white}} + color={pal.text.color as string} /> </View> - </TouchableOpacity> + </DropdownButton> ) : banner ? ( <Image style={styles.bannerImage} diff --git a/src/view/com/util/UserInfoText.tsx b/src/view/com/util/UserInfoText.tsx index a6daf18b2..d7907aa89 100644 --- a/src/view/com/util/UserInfoText.tsx +++ b/src/view/com/util/UserInfoText.tsx @@ -4,8 +4,8 @@ import {StyleProp, StyleSheet, TextStyle} from 'react-native' import {Link} from './Link' import {Text} from './text/Text' import {LoadingPlaceholder} from './LoadingPlaceholder' -import {useStores} from '../../../state' -import {TypographyVariant} from '../../lib/ThemeContext' +import {useStores} from 'state/index' +import {TypographyVariant} from 'lib/ThemeContext' export function UserInfoText({ type = 'md', diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx index 82eff2a81..259196b66 100644 --- a/src/view/com/util/ViewHeader.tsx +++ b/src/view/com/util/ViewHeader.tsx @@ -1,56 +1,40 @@ import React from 'react' import {observer} from 'mobx-react-lite' -import { - ActivityIndicator, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {CenteredView} from './Views' +import {Animated, StyleSheet, TouchableOpacity, View} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {UserAvatar} from './UserAvatar' import {Text} from './text/Text' -import {MagnifyingGlassIcon} from '../../lib/icons' -import {useStores} from '../../../state' -import {usePalette} from '../../lib/hooks/usePalette' -import {colors} from '../../lib/styles' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import {useAnalytics} from 'lib/analytics' -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, canGoBack, + hideOnScroll, }: { title: string - subtitle?: string canGoBack?: boolean + hideOnScroll?: boolean }) { const pal = usePalette('default') const store = useStores() + const {track} = useAnalytics() const onPressBack = () => { store.nav.tab.goBack() } const onPressMenu = () => { + track('ViewHeader:MenuButtonClicked') store.shell.setMainMenuOpen(true) } - const onPressSearch = () => { - store.nav.navigate('/search') - } - const onPressReconnect = () => { - store.session.connect().catch(e => { - store.log.warn('Failed to reconnect to server', e) - }) - } if (typeof canGoBack === 'undefined') { canGoBack = store.nav.tab.canGoBack } return ( - <CenteredView style={[styles.header, pal.view]}> + <Container hideOnScroll={hideOnScroll || false}> <TouchableOpacity testID="viewHeaderBackOrMenuBtn" onPress={canGoBack ? onPressBack : onPressMenu} @@ -75,48 +59,57 @@ export const ViewHeader = observer(function ViewHeader({ <Text type="title" style={[pal.text, styles.title]}> {title} </Text> - {subtitle ? ( - <Text - type="title-sm" - style={[styles.subtitle, pal.textLight]} - numberOfLines={1}> - {subtitle} - </Text> - ) : undefined} </View> - <TouchableOpacity - onPress={onPressSearch} - hitSlop={HITSLOP} - style={styles.btn}> - <MagnifyingGlassIcon size={21} strokeWidth={3} style={pal.text} /> - </TouchableOpacity> - {!store.session.online ? ( - <TouchableOpacity style={styles.btn} onPress={onPressReconnect}> - {store.session.attemptingConnect ? ( - <ActivityIndicator /> - ) : ( - <> - <FontAwesomeIcon - icon="signal" - style={pal.text as FontAwesomeIconStyle} - size={16} - /> - <FontAwesomeIcon - icon="x" - style={[ - styles.littleXIcon, - {backgroundColor: pal.colors.background}, - ]} - size={8} - /> - </> - )} - </TouchableOpacity> - ) : undefined} - </CenteredView> + <View style={canGoBack ? styles.backBtn : styles.backBtnWide} /> + </Container> ) }) +const Container = observer( + ({ + children, + hideOnScroll, + }: { + children: React.ReactNode + hideOnScroll: boolean + }) => { + const store = useStores() + const pal = usePalette('default') + const interp = useAnimatedValue(0) + + React.useEffect(() => { + if (store.shell.minimalShellMode) { + Animated.timing(interp, { + toValue: 1, + duration: 100, + useNativeDriver: true, + isInteraction: false, + }).start() + } else { + Animated.timing(interp, { + toValue: 0, + duration: 100, + useNativeDriver: true, + isInteraction: false, + }).start() + } + }, [interp, store.shell.minimalShellMode]) + const transform = { + transform: [{translateY: Animated.multiply(interp, -100)}], + } + + if (!hideOnScroll) { + return <View style={[styles.header, pal.view]}>{children}</View> + } + return ( + <Animated.View + style={[styles.header, pal.view, styles.headerFloating, transform]}> + {children} + </Animated.View> + ) + }, +) + const styles = StyleSheet.create({ header: { flexDirection: 'row', @@ -125,20 +118,20 @@ const styles = StyleSheet.create({ paddingTop: 6, paddingBottom: 6, }, + headerFloating: { + position: 'absolute', + top: 0, + width: '100%', + }, titleContainer: { - flexDirection: 'row', - alignItems: 'baseline', + marginLeft: 'auto', marginRight: 'auto', + paddingRight: 10, }, title: { fontWeight: 'bold', }, - subtitle: { - marginLeft: 4, - maxWidth: 200, - fontWeight: 'normal', - }, backBtn: { width: 30, @@ -152,19 +145,4 @@ const styles = StyleSheet.create({ backIcon: { marginTop: 6, }, - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - width: 36, - height: 36, - borderRadius: 20, - marginLeft: 4, - }, - littleXIcon: { - color: colors.red3, - position: 'absolute', - right: 7, - bottom: 7, - }, }) diff --git a/src/view/com/util/ViewHeader.web.tsx b/src/view/com/util/ViewHeader.web.tsx index 0d5c99aac..5c0869e8b 100644 --- a/src/view/com/util/ViewHeader.web.tsx +++ b/src/view/com/util/ViewHeader.web.tsx @@ -1,20 +1,12 @@ import React from 'react' import {observer} from 'mobx-react-lite' -import { - ActivityIndicator, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' +import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {CenteredView} from './Views' import {Text} from './text/Text' -import {useStores} from '../../../state' -import {usePalette} from '../../lib/hooks/usePalette' -import {colors} from '../../lib/styles' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {colors} from 'lib/styles' const BACK_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10} @@ -32,11 +24,6 @@ export const ViewHeader = observer(function ViewHeader({ const onPressBack = () => { store.nav.tab.goBack() } - const onPressReconnect = () => { - store.session.connect().catch(e => { - store.log.warn('Failed to reconnect to server', e) - }) - } if (typeof canGoBack === 'undefined') { canGoBack = store.nav.tab.canGoBack } @@ -76,29 +63,6 @@ export const ViewHeader = observer(function ViewHeader({ </Text> </View> )} - {!store.session.online ? ( - <TouchableOpacity style={styles.btn} onPress={onPressReconnect}> - {store.session.attemptingConnect ? ( - <ActivityIndicator /> - ) : ( - <> - <FontAwesomeIcon - icon="signal" - style={pal.text as FontAwesomeIconStyle} - size={16} - /> - <FontAwesomeIcon - icon="x" - style={[ - styles.littleXIcon, - {backgroundColor: pal.colors.background}, - ]} - size={8} - /> - </> - )} - </TouchableOpacity> - ) : undefined} </CenteredView> ) }) diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx index ff5115c51..b786c2290 100644 --- a/src/view/com/util/ViewSelector.tsx +++ b/src/view/com/util/ViewSelector.tsx @@ -3,10 +3,10 @@ import {View} from 'react-native' import {Selector} from './Selector' import {HorzSwipe} from './gestures/HorzSwipe' import {FlatList} from './Views' -import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue' -import {OnScrollCb} from '../../lib/hooks/useOnMainScroll' -import {clamp} from '../../../lib/numbers' -import {s} from '../../lib/styles' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import {OnScrollCb} from 'lib/hooks/useOnMainScroll' +import {clamp} from 'lib/numbers' +import {s} from 'lib/styles' const HEADER_ITEM = {_reactKey: '__header__'} const SELECTOR_ITEM = {_reactKey: '__selector__'} @@ -101,6 +101,7 @@ export function ViewSelector({ onRefresh={onRefresh} onEndReached={onEndReached} contentContainerStyle={s.contentContainer} + removeClippedSubviews={true} /> </HorzSwipe> ) diff --git a/src/view/com/util/Views.web.tsx b/src/view/com/util/Views.web.tsx index 2df534144..c16070b2b 100644 --- a/src/view/com/util/Views.web.tsx +++ b/src/view/com/util/Views.web.tsx @@ -22,9 +22,8 @@ import { View, ViewProps, } from 'react-native' -import {useTheme} from '../../lib/ThemeContext' -import {addStyle} from '../../lib/addStyle' -import {colors} from '../../lib/styles' +import {useTheme} from 'lib/ThemeContext' +import {addStyle, colors} from 'lib/styles' export function CenteredView({ style, diff --git a/src/view/com/util/anim/TriggerableAnimated.tsx b/src/view/com/util/anim/TriggerableAnimated.tsx new file mode 100644 index 000000000..2a3cbb957 --- /dev/null +++ b/src/view/com/util/anim/TriggerableAnimated.tsx @@ -0,0 +1,73 @@ +import React from 'react' +import {Animated, StyleProp, View, ViewStyle} from 'react-native' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' + +type CreateAnimFn = (interp: Animated.Value) => Animated.CompositeAnimation +type FinishCb = () => void + +interface TriggeredAnimation { + start: CreateAnimFn + style: ( + interp: Animated.Value, + ) => Animated.WithAnimatedValue<StyleProp<ViewStyle>> +} + +export interface TriggerableAnimatedRef { + trigger: (anim: TriggeredAnimation, onFinish?: FinishCb) => void +} + +type TriggerableAnimatedProps = React.PropsWithChildren<{}> + +type PropsInner = TriggerableAnimatedProps & { + anim: TriggeredAnimation + onFinish: () => void +} + +export const TriggerableAnimated = React.forwardRef< + TriggerableAnimatedRef, + TriggerableAnimatedProps +>(({children, ...props}, ref) => { + const [anim, setAnim] = React.useState<TriggeredAnimation | undefined>( + undefined, + ) + const [finishCb, setFinishCb] = React.useState<FinishCb | undefined>( + undefined, + ) + React.useImperativeHandle(ref, () => ({ + trigger(v: TriggeredAnimation, cb?: FinishCb) { + setFinishCb(() => cb) // note- wrap in function due to react behaviors around setstate + setAnim(v) + }, + })) + const onFinish = () => { + finishCb?.() + setAnim(undefined) + setFinishCb(undefined) + } + return ( + <View key="triggerable"> + {anim ? ( + <AnimatingView anim={anim} onFinish={onFinish} {...props}> + {children} + </AnimatingView> + ) : ( + children + )} + </View> + ) +}) + +function AnimatingView({ + anim, + onFinish, + children, +}: React.PropsWithChildren<PropsInner>) { + const interp = useAnimatedValue(0) + React.useEffect(() => { + anim?.start(interp).start(() => { + onFinish() + }) + }) + const animStyle = anim?.style(interp) + return <Animated.View style={animStyle}>{children}</Animated.View> +} diff --git a/src/view/com/util/error/ErrorMessage.tsx b/src/view/com/util/error/ErrorMessage.tsx index a6d77326e..e6e27fac0 100644 --- a/src/view/com/util/error/ErrorMessage.tsx +++ b/src/view/com/util/error/ErrorMessage.tsx @@ -11,8 +11,8 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {Text} from '../text/Text' -import {useTheme} from '../../../lib/ThemeContext' -import {usePalette} from '../../../lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' +import {usePalette} from 'lib/hooks/usePalette' export function ErrorMessage({ message, diff --git a/src/view/com/util/error/ErrorScreen.tsx b/src/view/com/util/error/ErrorScreen.tsx index f316dbcc6..0221ea153 100644 --- a/src/view/com/util/error/ErrorScreen.tsx +++ b/src/view/com/util/error/ErrorScreen.tsx @@ -5,9 +5,9 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {Text} from '../text/Text' -import {colors} from '../../../lib/styles' -import {useTheme} from '../../../lib/ThemeContext' -import {usePalette} from '../../../lib/hooks/usePalette' +import {colors} from 'lib/styles' +import {useTheme} from 'lib/ThemeContext' +import {usePalette} from 'lib/hooks/usePalette' export function ErrorScreen({ title, diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx index b5c4da19d..a070d2f0f 100644 --- a/src/view/com/util/forms/Button.tsx +++ b/src/view/com/util/forms/Button.tsx @@ -7,8 +7,8 @@ import { ViewStyle, } from 'react-native' import {Text} from '../text/Text' -import {useTheme} from '../../../lib/ThemeContext' -import {choose} from '../../../../lib/functions' +import {useTheme} from 'lib/ThemeContext' +import {choose} from 'lib/functions' export type ButtonType = | 'primary' diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx index f911529d2..8fddd5941 100644 --- a/src/view/com/util/forms/DropdownButton.tsx +++ b/src/view/com/util/forms/DropdownButton.tsx @@ -13,11 +13,13 @@ import RootSiblings from 'react-native-root-siblings' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Text} from '../text/Text' import {Button, ButtonType} from './Button' -import {colors} from '../../../lib/styles' -import {toShareUrl} from '../../../../lib/strings' -import {useStores} from '../../../../state' -import {ReportPostModal, ConfirmModal} from '../../../../state/models/shell-ui' -import {TABS_ENABLED} from '../../../../build-flags' +import {colors} from 'lib/styles' +import {toShareUrl} from 'lib/strings/url-helpers' +import {useStores} from 'state/index' +import {ReportPostModal, ConfirmModal} from 'state/models/shell-ui' +import {TABS_ENABLED} from 'lib/build-flags' +import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} @@ -36,6 +38,9 @@ export function DropdownButton({ label, menuWidth, children, + openToRight = false, + rightOffset = 0, + bottomOffset = 0, }: { type?: DropdownButtonType style?: StyleProp<ViewStyle> @@ -43,6 +48,9 @@ export function DropdownButton({ label?: string menuWidth?: number children?: React.ReactNode + openToRight?: boolean + rightOffset?: number + bottomOffset?: number }) { const ref = useRef<TouchableOpacity>(null) @@ -59,12 +67,11 @@ export function DropdownButton({ if (!menuWidth) { menuWidth = 200 } - createDropdownMenu( - pageX + width - menuWidth, - pageY + height, - menuWidth, - items, - ) + const newX = openToRight + ? pageX + width + rightOffset + : pageX + width - menuWidth + const newY = pageY + height + bottomOffset + createDropdownMenu(newX, newY, menuWidth, items) }, ) } @@ -97,6 +104,8 @@ export function DropdownButton({ export function PostDropdownBtn({ style, children, + itemUri, + itemCid, itemHref, isAuthor, onCopyPostText, @@ -104,6 +113,8 @@ export function PostDropdownBtn({ }: { style?: StyleProp<ViewStyle> children?: React.ReactNode + itemUri: string + itemCid: string itemHref: string itemTitle: string isAuthor: boolean @@ -140,7 +151,7 @@ export function PostDropdownBtn({ icon: 'circle-exclamation', label: 'Report post', onPress() { - store.shell.openModal(new ReportPostModal(itemHref)) + store.shell.openModal(new ReportPostModal(itemUri, itemCid)) }, }, isAuthor @@ -180,24 +191,14 @@ function createDropdownMenu( const onOuterPress = () => sibling.destroy() const sibling = new RootSiblings( ( - <> - <TouchableWithoutFeedback onPress={onOuterPress}> - <View style={styles.bg} /> - </TouchableWithoutFeedback> - <View style={[styles.menu, {left: x, top: y, width}]}> - {items.map((item, index) => ( - <TouchableOpacity - key={index} - style={[styles.menuItem]} - onPress={() => onPressItem(index)}> - {item.icon && ( - <FontAwesomeIcon style={styles.icon} icon={item.icon} /> - )} - <Text style={styles.label}>{item.label}</Text> - </TouchableOpacity> - ))} - </View> - </> + <DropdownItems + onOuterPress={onOuterPress} + x={x} + y={y} + width={width} + items={items} + onPressItem={onPressItem} + /> ), ) return sibling @@ -241,3 +242,55 @@ const styles = StyleSheet.create({ fontSize: 18, }, }) +type DropDownItemProps = { + onOuterPress: () => void + x: number + y: number + width: number + items: DropdownItem[] + onPressItem: (index: number) => void +} + +const DropdownItems = ({ + onOuterPress, + x, + y, + width, + items, + onPressItem, +}: DropDownItemProps) => { + const pal = usePalette('default') + const theme = useTheme() + const dropDownBackgroundColor = + theme.colorScheme === 'dark' ? pal.btn : pal.view + + return ( + <> + <TouchableWithoutFeedback onPress={onOuterPress}> + <View style={[styles.bg]} /> + </TouchableWithoutFeedback> + <View + style={[ + styles.menu, + {left: x, top: y, width}, + dropDownBackgroundColor, + ]}> + {items.map((item, index) => ( + <TouchableOpacity + key={index} + style={[styles.menuItem]} + onPress={() => onPressItem(index)}> + {item.icon && ( + <FontAwesomeIcon + style={styles.icon} + icon={item.icon} + color={pal.text.color as string} + /> + )} + <Text style={[styles.label, pal.text]}>{item.label}</Text> + </TouchableOpacity> + ))} + </View> + </> + ) +} diff --git a/src/view/com/util/forms/RadioButton.tsx b/src/view/com/util/forms/RadioButton.tsx index 81489c447..57a875cd3 100644 --- a/src/view/com/util/forms/RadioButton.tsx +++ b/src/view/com/util/forms/RadioButton.tsx @@ -2,8 +2,8 @@ import React from 'react' import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native' import {Text} from '../text/Text' import {Button, ButtonType} from './Button' -import {useTheme} from '../../../lib/ThemeContext' -import {choose} from '../../../../lib/functions' +import {useTheme} from 'lib/ThemeContext' +import {choose} from 'lib/functions' export function RadioButton({ type = 'default-light', diff --git a/src/view/com/util/forms/RadioGroup.tsx b/src/view/com/util/forms/RadioGroup.tsx index b33cd9831..901b0cdd8 100644 --- a/src/view/com/util/forms/RadioGroup.tsx +++ b/src/view/com/util/forms/RadioGroup.tsx @@ -2,7 +2,7 @@ import React, {useState} from 'react' import {View} from 'react-native' import {RadioButton} from './RadioButton' import {ButtonType} from './Button' -import {s} from '../../../lib/styles' +import {s} from 'lib/styles' export interface RadioGroupItem { label: string diff --git a/src/view/com/util/forms/ToggleButton.tsx b/src/view/com/util/forms/ToggleButton.tsx index 77e8fa203..005d1165e 100644 --- a/src/view/com/util/forms/ToggleButton.tsx +++ b/src/view/com/util/forms/ToggleButton.tsx @@ -2,9 +2,9 @@ import React from 'react' import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native' import {Text} from '../text/Text' import {Button, ButtonType} from './Button' -import {useTheme} from '../../../lib/ThemeContext' -import {choose} from '../../../../lib/functions' -import {colors} from '../../../lib/styles' +import {useTheme} from 'lib/ThemeContext' +import {choose} from 'lib/functions' +import {colors} from 'lib/styles' export function ToggleButton({ type = 'default-light', diff --git a/src/view/com/util/gestures/HorzSwipe.tsx b/src/view/com/util/gestures/HorzSwipe.tsx index 22b15afe7..09f6c345f 100644 --- a/src/view/com/util/gestures/HorzSwipe.tsx +++ b/src/view/com/util/gestures/HorzSwipe.tsx @@ -9,7 +9,7 @@ import { View, } from 'react-native' import {clamp} from 'lodash' -import {s} from '../../../lib/styles' +import {s} from 'lib/styles' interface Props { panX: Animated.Value @@ -90,6 +90,7 @@ export function HorzSwipe({ // swiping right (diffX < 0 && !canSwipeRight) ) { + panX.setValue(0) return } @@ -119,6 +120,7 @@ export function HorzSwipe({ toValue: final, duration: 100, useNativeDriver, + isInteraction: false, }).start(() => { onSwipeEnd?.(final) panX.flattenOffset() @@ -130,6 +132,7 @@ export function HorzSwipe({ toValue: 0, duration: 100, useNativeDriver, + isInteraction: false, }).start(() => { panX.flattenOffset() panX.setValue(0) diff --git a/src/view/com/util/gestures/SwipeAndZoom.tsx b/src/view/com/util/gestures/SwipeAndZoom.tsx index ee00edab7..75c679012 100644 --- a/src/view/com/util/gestures/SwipeAndZoom.tsx +++ b/src/view/com/util/gestures/SwipeAndZoom.tsx @@ -9,7 +9,7 @@ import { View, } from 'react-native' import {clamp} from 'lodash' -import {s} from '../../../lib/styles' +import {s} from 'lib/styles' export enum Dir { None, diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx index cdefc7123..0443c7be4 100644 --- a/src/view/com/util/images/AutoSizedImage.tsx +++ b/src/view/com/util/images/AutoSizedImage.tsx @@ -1,125 +1,59 @@ -import React, {useState, useEffect} from 'react' -import { - Image, - ImageStyle, - LayoutChangeEvent, - StyleProp, - StyleSheet, - TouchableOpacity, - View, - ViewStyle, -} from 'react-native' -import {Text} from '../text/Text' -import {useTheme} from '../../../lib/ThemeContext' -import {usePalette} from '../../../lib/hooks/usePalette' -import {DELAY_PRESS_IN} from './constants' +import React from 'react' +import {StyleProp, StyleSheet, TouchableOpacity, ViewStyle} from 'react-native' +import Image, {OnLoadEvent} from 'view/com/util/images/Image' +import {clamp} from 'lib/numbers' -const MAX_HEIGHT = 300 - -interface Dim { - width: number - height: number -} +export const DELAY_PRESS_IN = 500 +const MIN_ASPECT_RATIO = 0.33 // 1/3 +const MAX_ASPECT_RATIO = 5 // 5/1 export function AutoSizedImage({ uri, onPress, onLongPress, + onPressIn, style, - containerStyle, + children = null, }: { uri: string onPress?: () => void onLongPress?: () => void - style?: StyleProp<ImageStyle> - containerStyle?: StyleProp<ViewStyle> + onPressIn?: () => void + style?: StyleProp<ViewStyle> + children?: React.ReactNode }) { - const theme = useTheme() - const errPal = usePalette('error') - const [error, setError] = useState<string | undefined>('') - const [imgInfo, setImgInfo] = useState<Dim | undefined>() - const [containerInfo, setContainerInfo] = useState<Dim | undefined>() - - useEffect(() => { - let aborted = false - if (!imgInfo) { - Image.getSize( - uri, - (width: number, height: number) => { - if (!aborted) { - setImgInfo({width, height}) - } - }, - (err: any) => { - if (!aborted) { - setError(String(err)) - } - }, - ) - } - return () => { - aborted = true - } - }, [uri, imgInfo]) - - const onLayout = (evt: LayoutChangeEvent) => { - setContainerInfo({ - width: evt.nativeEvent.layout.width, - height: evt.nativeEvent.layout.height, - }) - } - - let calculatedStyle: StyleProp<ImageStyle> | undefined - if (imgInfo && containerInfo) { - // imgInfo.height / imgInfo.width = x / containerInfo.width - // x = imgInfo.height / imgInfo.width * containerInfo.width - calculatedStyle = { - height: Math.min( - MAX_HEIGHT, - (imgInfo.height / imgInfo.width) * containerInfo.width, + const [aspectRatio, setAspectRatio] = React.useState<number>(1) + const onLoad = (e: OnLoadEvent) => { + setAspectRatio( + clamp( + e.nativeEvent.width / e.nativeEvent.height, + MIN_ASPECT_RATIO, + MAX_ASPECT_RATIO, ), - } + ) } - return ( - <View style={style}> - <TouchableOpacity - onPress={onPress} - onLongPress={onLongPress} - delayPressIn={DELAY_PRESS_IN}> - {error ? ( - <View style={[styles.errorContainer, errPal.view, containerStyle]}> - <Text style={errPal.text}>{error}</Text> - </View> - ) : calculatedStyle ? ( - <View style={[styles.container, containerStyle]}> - <Image style={calculatedStyle} source={{uri}} /> - </View> - ) : ( - <View - style={[ - style, - styles.placeholder, - {backgroundColor: theme.palette.default.backgroundLight}, - ]} - onLayout={onLayout} - /> - )} - </TouchableOpacity> - </View> + <TouchableOpacity + onPress={onPress} + onLongPress={onLongPress} + onPressIn={onPressIn} + delayPressIn={DELAY_PRESS_IN} + style={[styles.container, style]}> + <Image + style={[styles.image, {aspectRatio}]} + source={{uri}} + onLoad={onLoad} + /> + {children} + </TouchableOpacity> ) } const styles = StyleSheet.create({ - placeholder: { - width: '100%', - aspectRatio: 1, - }, - errorContainer: { - paddingHorizontal: 12, - paddingVertical: 8, - }, container: { overflow: 'hidden', }, + image: { + width: '100%', + }, }) diff --git a/src/view/com/util/images/Image.tsx b/src/view/com/util/images/Image.tsx new file mode 100644 index 000000000..8c95a581e --- /dev/null +++ b/src/view/com/util/images/Image.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import FastImage, {FastImageProps, Source} from 'react-native-fast-image' +export default FastImage +export type {OnLoadEvent, ImageStyle, Source} from 'react-native-fast-image' + +export function HighPriorityImage({source, ...props}: FastImageProps) { + const updatedSource = { + uri: typeof source === 'object' && source ? source.uri : '', + priority: FastImage.priority.high, + } as Source + return <FastImage source={updatedSource} {...props} /> +} diff --git a/src/view/com/util/images/Image.web.tsx b/src/view/com/util/images/Image.web.tsx new file mode 100644 index 000000000..ecd9d730a --- /dev/null +++ b/src/view/com/util/images/Image.web.tsx @@ -0,0 +1,11 @@ +import { + Image, + NativeSyntheticEvent, + ImageLoadEventData, + ImageSourcePropType, +} from 'react-native' +export default Image +export const HighPriorityImage = Image +export type OnLoadEvent = NativeSyntheticEvent<ImageLoadEventData> +export type Source = ImageSourcePropType +export type {ImageStyle} from 'react-native' diff --git a/src/view/com/util/images/ImageHorzList.tsx b/src/view/com/util/images/ImageHorzList.tsx index 366424308..bed13406c 100644 --- a/src/view/com/util/images/ImageHorzList.tsx +++ b/src/view/com/util/images/ImageHorzList.tsx @@ -1,12 +1,12 @@ import React from 'react' import { - Image, StyleProp, StyleSheet, TouchableWithoutFeedback, View, ViewStyle, } from 'react-native' +import Image from 'view/com/util/images/Image' export function ImageHorzList({ uris, diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx index 97ad9d700..a1c732649 100644 --- a/src/view/com/util/images/ImageLayoutGrid.tsx +++ b/src/view/com/util/images/ImageLayoutGrid.tsx @@ -1,7 +1,5 @@ import React from 'react' import { - Image, - ImageStyle, LayoutChangeEvent, StyleProp, StyleSheet, @@ -9,7 +7,9 @@ import { View, ViewStyle, } from 'react-native' -import {DELAY_PRESS_IN} from './constants' +import Image, {ImageStyle} from 'view/com/util/images/Image' + +export const DELAY_PRESS_IN = 500 interface Dim { width: number @@ -23,12 +23,14 @@ export function ImageLayoutGrid({ uris, onPress, onLongPress, + onPressIn, style, }: { type: ImageLayoutGridType uris: string[] onPress?: (index: number) => void onLongPress?: (index: number) => void + onPressIn?: (index: number) => void style?: StyleProp<ViewStyle> }) { const [containerInfo, setContainerInfo] = React.useState<Dim | undefined>() @@ -47,6 +49,7 @@ export function ImageLayoutGrid({ type={type} uris={uris} onPress={onPress} + onPressIn={onPressIn} onLongPress={onLongPress} containerInfo={containerInfo} /> @@ -60,15 +63,17 @@ function ImageLayoutGridInner({ uris, onPress, onLongPress, + onPressIn, containerInfo, }: { type: ImageLayoutGridType uris: string[] onPress?: (index: number) => void onLongPress?: (index: number) => void + onPressIn?: (index: number) => void containerInfo: Dim }) { - const size1 = React.useMemo<ImageStyle>(() => { + const size1 = React.useMemo<StyleProp<ImageStyle>>(() => { if (type === 'three') { const size = (containerInfo.width - 10) / 3 return {width: size, height: size, resizeMode: 'cover', borderRadius: 4} @@ -77,7 +82,7 @@ function ImageLayoutGridInner({ return {width: size, height: size, resizeMode: 'cover', borderRadius: 4} } }, [type, containerInfo]) - const size2 = React.useMemo<ImageStyle>(() => { + const size2 = React.useMemo<StyleProp<ImageStyle>>(() => { if (type === 'three') { const size = ((containerInfo.width - 10) / 3) * 2 + 5 return {width: size, height: size, resizeMode: 'cover', borderRadius: 4} @@ -93,6 +98,7 @@ function ImageLayoutGridInner({ <TouchableOpacity delayPressIn={DELAY_PRESS_IN} onPress={() => onPress?.(0)} + onPressIn={() => onPressIn?.(0)} onLongPress={() => onLongPress?.(0)}> <Image source={{uri: uris[0]}} style={size1} /> </TouchableOpacity> @@ -100,6 +106,7 @@ function ImageLayoutGridInner({ <TouchableOpacity delayPressIn={DELAY_PRESS_IN} onPress={() => onPress?.(1)} + onPressIn={() => onPressIn?.(1)} onLongPress={() => onLongPress?.(1)}> <Image source={{uri: uris[1]}} style={size1} /> </TouchableOpacity> @@ -112,6 +119,7 @@ function ImageLayoutGridInner({ <TouchableOpacity delayPressIn={DELAY_PRESS_IN} onPress={() => onPress?.(0)} + onPressIn={() => onPressIn?.(0)} onLongPress={() => onLongPress?.(0)}> <Image source={{uri: uris[0]}} style={size2} /> </TouchableOpacity> @@ -120,6 +128,7 @@ function ImageLayoutGridInner({ <TouchableOpacity delayPressIn={DELAY_PRESS_IN} onPress={() => onPress?.(1)} + onPressIn={() => onPressIn?.(1)} onLongPress={() => onLongPress?.(1)}> <Image source={{uri: uris[1]}} style={size1} /> </TouchableOpacity> @@ -127,6 +136,7 @@ function ImageLayoutGridInner({ <TouchableOpacity delayPressIn={DELAY_PRESS_IN} onPress={() => onPress?.(2)} + onPressIn={() => onPressIn?.(2)} onLongPress={() => onLongPress?.(2)}> <Image source={{uri: uris[2]}} style={size1} /> </TouchableOpacity> @@ -141,29 +151,33 @@ function ImageLayoutGridInner({ <TouchableOpacity delayPressIn={DELAY_PRESS_IN} onPress={() => onPress?.(0)} + onPressIn={() => onPressIn?.(0)} onLongPress={() => onLongPress?.(0)}> <Image source={{uri: uris[0]}} style={size1} /> </TouchableOpacity> <View style={styles.hSpace} /> <TouchableOpacity delayPressIn={DELAY_PRESS_IN} - onPress={() => onPress?.(1)} - onLongPress={() => onLongPress?.(1)}> - <Image source={{uri: uris[1]}} style={size1} /> + onPress={() => onPress?.(2)} + onPressIn={() => onPressIn?.(2)} + onLongPress={() => onLongPress?.(2)}> + <Image source={{uri: uris[2]}} style={size1} /> </TouchableOpacity> </View> <View style={styles.wSpace} /> <View> <TouchableOpacity delayPressIn={DELAY_PRESS_IN} - onPress={() => onPress?.(2)} - onLongPress={() => onLongPress?.(2)}> - <Image source={{uri: uris[2]}} style={size1} /> + onPress={() => onPress?.(1)} + onPressIn={() => onPressIn?.(1)} + onLongPress={() => onLongPress?.(1)}> + <Image source={{uri: uris[1]}} style={size1} /> </TouchableOpacity> <View style={styles.hSpace} /> <TouchableOpacity delayPressIn={DELAY_PRESS_IN} onPress={() => onPress?.(3)} + onPressIn={() => onPressIn?.(3)} onLongPress={() => onLongPress?.(3)}> <Image source={{uri: uris[3]}} style={size1} /> </TouchableOpacity> diff --git a/src/view/com/util/images/constants.ts b/src/view/com/util/images/constants.ts deleted file mode 100644 index cb2c26cea..000000000 --- a/src/view/com/util/images/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const DELAY_PRESS_IN = 500 diff --git a/src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx b/src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx index ddc9e87fd..d723fef99 100644 --- a/src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx +++ b/src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx @@ -4,7 +4,7 @@ import { openCropper as openCropperFn, ImageOrVideo, } from 'react-native-image-crop-picker' -import {RootStoreModel} from '../../../../../state' +import {RootStoreModel} from 'state/index' import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types' export type {PickedMedia} from './types' diff --git a/src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx b/src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx index a7037f3a4..d632590d6 100644 --- a/src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx +++ b/src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx @@ -1,9 +1,9 @@ /// <reference lib="dom" /> -import {CropImageModal} from '../../../../../state/models/shell-ui' +import {CropImageModal} from 'state/models/shell-ui' import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types' export type {PickedMedia} from './types' -import {RootStoreModel} from '../../../../../state' +import {RootStoreModel} from 'state/index' interface PickedFile { uri: string @@ -31,17 +31,17 @@ export async function openPicker( export async function openCamera( _store: RootStoreModel, - opts: CameraOpts, + _opts: CameraOpts, ): Promise<PickedMedia> { - const mediaType = opts.mediaType || 'photo' + // const mediaType = opts.mediaType || 'photo' TODO throw new Error('TODO') } export async function openCropper( _store: RootStoreModel, - opts: CropperOpts, + _opts: CropperOpts, ): Promise<PickedMedia> { - const mediaType = opts.mediaType || 'photo' + // const mediaType = opts.mediaType || 'photo' TODO throw new Error('TODO') } diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx index f04f4566d..d4cf19172 100644 --- a/src/view/com/util/text/RichText.tsx +++ b/src/view/com/util/text/RichText.tsx @@ -2,29 +2,21 @@ import React from 'react' import {TextStyle, StyleProp} from 'react-native' import {TextLink} from '../Link' import {Text} from './Text' -import {lh} from '../../../lib/styles' -import {toShortUrl} from '../../../../lib/strings' -import {useTheme, TypographyVariant} from '../../../lib/ThemeContext' -import {usePalette} from '../../../lib/hooks/usePalette' - -type TextSlice = {start: number; end: number} -type Entity = { - index: TextSlice - type: string - value: string -} +import {lh} from 'lib/styles' +import {toShortUrl} from 'lib/strings/url-helpers' +import {RichText as RichTextObj, Entity} from 'lib/strings/rich-text' +import {useTheme, TypographyVariant} from 'lib/ThemeContext' +import {usePalette} from 'lib/hooks/usePalette' export function RichText({ type = 'md', - text, - entities, + richText, lineHeight = 1.2, style, numberOfLines, }: { type?: TypographyVariant - text: string - entities?: Entity[] + richText?: RichTextObj lineHeight?: number style?: StyleProp<TextStyle> numberOfLines?: number @@ -32,6 +24,12 @@ export function RichText({ const theme = useTheme() const pal = usePalette('default') const lineHeightStyle = lh(theme, type, lineHeight) + + if (!richText) { + return null + } + + const {text, entities} = richText if (!entities?.length) { if (/^\p{Extended_Pictographic}+$/u.test(text) && text.length <= 5) { style = { diff --git a/src/view/com/util/text/Text.tsx b/src/view/com/util/text/Text.tsx index c3a8a2194..14c57cf47 100644 --- a/src/view/com/util/text/Text.tsx +++ b/src/view/com/util/text/Text.tsx @@ -1,7 +1,7 @@ import React from 'react' import {Text as RNText, TextProps} from 'react-native' -import {s} from '../../../lib/styles' -import {useTheme, TypographyVariant} from '../../../lib/ThemeContext' +import {s} from 'lib/styles' +import {useTheme, TypographyVariant} from 'lib/ThemeContext' export type CustomTextProps = TextProps & { type?: TypographyVariant diff --git a/src/view/index.ts b/src/view/index.ts index 8ffd5957a..7cd2c1dfd 100644 --- a/src/view/index.ts +++ b/src/view/index.ts @@ -65,6 +65,8 @@ import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket' import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan' import {faX} from '@fortawesome/free-solid-svg-icons/faX' import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark' +import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay' +import {faPause} from '@fortawesome/free-solid-svg-icons/faPause' export function setup() { library.add( @@ -133,5 +135,7 @@ export function setup() { faTrashCan, faX, faXmark, + faPlay, + faPause, ) } diff --git a/src/view/lib/ThemeContext.tsx b/src/view/lib/ThemeContext.tsx deleted file mode 100644 index 16a7d9cb3..000000000 --- a/src/view/lib/ThemeContext.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import React, {createContext, useContext, useMemo} from 'react' -import {TextStyle, useColorScheme, ViewStyle} from 'react-native' -import {darkTheme, defaultTheme} from './themes' - -export type ColorScheme = 'light' | 'dark' - -export type PaletteColorName = - | 'default' - | 'primary' - | 'secondary' - | 'inverted' - | 'error' -export type PaletteColor = { - background: string - backgroundLight: string - text: string - textLight: string - textInverted: string - link: string - border: string - borderDark: string - icon: string - [k: string]: string -} -export type Palette = Record<PaletteColorName, PaletteColor> - -export type ShapeName = 'button' | 'bigButton' | 'smallButton' -export type Shapes = Record<ShapeName, ViewStyle> - -export type TypographyVariant = - | 'xl-thin' - | 'xl' - | 'xl-medium' - | 'xl-bold' - | 'xl-heavy' - | 'lg-thin' - | 'lg' - | 'lg-medium' - | 'lg-bold' - | 'lg-heavy' - | 'md-thin' - | 'md' - | 'md-medium' - | 'md-bold' - | 'md-heavy' - | 'sm-thin' - | 'sm' - | 'sm-medium' - | 'sm-bold' - | 'sm-heavy' - | 'xs-thin' - | 'xs' - | 'xs-medium' - | 'xs-bold' - | 'xs-heavy' - | 'title-xl' - | 'title-lg' - | 'title' - | 'title-sm' - | 'post-text-lg' - | 'post-text' - | 'button' - | 'mono' -export type Typography = Record<TypographyVariant, TextStyle> - -export interface Theme { - colorScheme: ColorScheme - palette: Palette - shapes: Shapes - typography: Typography -} - -export interface ThemeProviderProps { - theme?: ColorScheme -} - -export const ThemeContext = createContext<Theme>(defaultTheme) - -export const useTheme = () => useContext(ThemeContext) - -export const ThemeProvider: React.FC<ThemeProviderProps> = ({ - theme, - children, -}) => { - const colorScheme = useColorScheme() - - const value = useMemo( - () => ((theme || colorScheme) === 'dark' ? darkTheme : defaultTheme), - [colorScheme, theme], - ) - - return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider> -} diff --git a/src/view/lib/addStyle.ts b/src/view/lib/addStyle.ts deleted file mode 100644 index 93a5390a1..000000000 --- a/src/view/lib/addStyle.ts +++ /dev/null @@ -1,11 +0,0 @@ -import {StyleProp} from 'react-native' - -export function addStyle<T>( - base: StyleProp<T>, - addedStyle: StyleProp<T>, -): StyleProp<T> { - if (Array.isArray(base)) { - return base.concat([addedStyle]) - } - return [base, addedStyle] -} diff --git a/src/view/lib/assets.native.ts b/src/view/lib/assets.native.ts deleted file mode 100644 index a2db553e7..000000000 --- a/src/view/lib/assets.native.ts +++ /dev/null @@ -1,5 +0,0 @@ -import {ImageSourcePropType} from 'react-native' - -export const DEF_AVATAR: ImageSourcePropType = require('../../../public/img/default-avatar.jpg') -export const TABS_EXPLAINER: ImageSourcePropType = require('../../../public/img/tabs-explainer.jpg') -export const CLOUD_SPLASH: ImageSourcePropType = require('../../../public/img/cloud-splash.png') diff --git a/src/view/lib/assets.ts b/src/view/lib/assets.ts deleted file mode 100644 index 948f3a22f..000000000 --- a/src/view/lib/assets.ts +++ /dev/null @@ -1,7 +0,0 @@ -import {ImageSourcePropType} from 'react-native' - -export const DEF_AVATAR: ImageSourcePropType = {uri: '/img/default-avatar.jpg'} -export const TABS_EXPLAINER: ImageSourcePropType = { - uri: '/img/tabs-explainer.jpg', -} -export const CLOUD_SPLASH: ImageSourcePropType = {uri: '/img/cloud-splash.png'} diff --git a/src/view/lib/hooks/useAnimatedValue.ts b/src/view/lib/hooks/useAnimatedValue.ts deleted file mode 100644 index 1307ef952..000000000 --- a/src/view/lib/hooks/useAnimatedValue.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as React from 'react' -import {Animated} from 'react-native' - -export function useAnimatedValue(initialValue: number) { - const lazyRef = React.useRef<Animated.Value>() - - if (lazyRef.current === undefined) { - lazyRef.current = new Animated.Value(initialValue) - } - - return lazyRef.current as Animated.Value -} diff --git a/src/view/lib/hooks/useOnMainScroll.ts b/src/view/lib/hooks/useOnMainScroll.ts deleted file mode 100644 index c3c16ff83..000000000 --- a/src/view/lib/hooks/useOnMainScroll.ts +++ /dev/null @@ -1,25 +0,0 @@ -import {useState} from 'react' -import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native' -import {RootStoreModel} from '../../../state' - -export type OnScrollCb = ( - event: NativeSyntheticEvent<NativeScrollEvent>, -) => void - -export function useOnMainScroll(store: RootStoreModel) { - let [lastY, setLastY] = useState(0) - let isMinimal = store.shell.minimalShellMode - return function onMainScroll(event: NativeSyntheticEvent<NativeScrollEvent>) { - const y = event.nativeEvent.contentOffset.y - const dy = y - (lastY || 0) - setLastY(y) - - if (!isMinimal && y > 10 && dy > 10) { - store.shell.setMinimalShellMode(true) - isMinimal = true - } else if (isMinimal && (y <= 10 || dy < -10)) { - store.shell.setMinimalShellMode(false) - isMinimal = false - } - } -} diff --git a/src/view/lib/hooks/usePalette.ts b/src/view/lib/hooks/usePalette.ts deleted file mode 100644 index 5b9929c7d..000000000 --- a/src/view/lib/hooks/usePalette.ts +++ /dev/null @@ -1,48 +0,0 @@ -import {TextStyle, ViewStyle} from 'react-native' -import {useTheme, PaletteColorName, PaletteColor} from '../ThemeContext' - -export interface UsePaletteValue { - colors: PaletteColor - view: ViewStyle - btn: ViewStyle - border: ViewStyle - borderDark: ViewStyle - text: TextStyle - textLight: TextStyle - textInverted: TextStyle - link: TextStyle - icon: TextStyle -} -export function usePalette(color: PaletteColorName): UsePaletteValue { - const palette = useTheme().palette[color] - return { - colors: palette, - view: { - backgroundColor: palette.background, - }, - btn: { - backgroundColor: palette.backgroundLight, - }, - border: { - borderColor: palette.border, - }, - borderDark: { - borderColor: palette.borderDark, - }, - text: { - color: palette.text, - }, - textLight: { - color: palette.textLight, - }, - textInverted: { - color: palette.textInverted, - }, - link: { - color: palette.link, - }, - icon: { - color: palette.icon, - }, - } -} diff --git a/src/view/lib/icons.tsx b/src/view/lib/icons.tsx deleted file mode 100644 index f400c3f72..000000000 --- a/src/view/lib/icons.tsx +++ /dev/null @@ -1,529 +0,0 @@ -import React from 'react' -import {StyleProp, TextStyle, ViewStyle} from 'react-native' -import Svg, {Path, Rect} from 'react-native-svg' - -export function GridIcon({ - style, - solid, -}: { - style?: StyleProp<ViewStyle> - solid?: boolean -}) { - const DIM = 4 - const ARC = 2 - return ( - <Svg width="24" height="24" style={style}> - <Path - d={`M4,1 h${DIM} a${ARC},${ARC} 0 0 1 ${ARC},${ARC} v${DIM} a${ARC},${ARC} 0 0 1 -${ARC},${ARC} h-${DIM} a${ARC},${ARC} 0 0 1 -${ARC},-${ARC} v-${DIM} a${ARC},${ARC} 0 0 1 ${ARC},-${ARC} z`} - strokeWidth={2} - stroke="#000" - fill={solid ? '#000' : undefined} - /> - <Path - d={`M16,1 h${DIM} a${ARC},${ARC} 0 0 1 ${ARC},${ARC} v${DIM} a${ARC},${ARC} 0 0 1 -${ARC},${ARC} h-${DIM} a${ARC},${ARC} 0 0 1 -${ARC},-${ARC} v-${DIM} a${ARC},${ARC} 0 0 1 ${ARC},-${ARC} z`} - strokeWidth={2} - stroke="#000" - fill={solid ? '#000' : undefined} - /> - <Path - d={`M4,13 h${DIM} a${ARC},${ARC} 0 0 1 ${ARC},${ARC} v${DIM} a${ARC},${ARC} 0 0 1 -${ARC},${ARC} h-${DIM} a${ARC},${ARC} 0 0 1 -${ARC},-${ARC} v-${DIM} a${ARC},${ARC} 0 0 1 ${ARC},-${ARC} z`} - strokeWidth={2} - stroke="#000" - fill={solid ? '#000' : undefined} - /> - <Path - d={`M16,13 h${DIM} a${ARC},${ARC} 0 0 1 ${ARC},${ARC} v${DIM} a${ARC},${ARC} 0 0 1 -${ARC},${ARC} h-${DIM} a${ARC},${ARC} 0 0 1 -${ARC},-${ARC} v-${DIM} a${ARC},${ARC} 0 0 1 ${ARC},-${ARC} z`} - strokeWidth={2} - stroke="#000" - fill={solid ? '#000' : undefined} - /> - </Svg> - ) -} -export function GridIconSolid({style}: {style?: StyleProp<ViewStyle>}) { - return <GridIcon style={style} solid /> -} - -export function HomeIcon({ - style, - size, - strokeWidth = 4, -}: { - style?: StyleProp<ViewStyle> - size?: string | number - strokeWidth?: number -}) { - return ( - <Svg - viewBox="0 0 48 48" - width={size || 24} - height={size || 24} - stroke="currentColor" - fill="none" - style={style}> - <Path - strokeWidth={strokeWidth} - d="M 23.951 2 C 23.631 2.011 23.323 2.124 23.072 2.322 L 8.859 13.52 C 7.055 14.941 6 17.114 6 19.41 L 6 38.5 C 6 39.864 7.136 41 8.5 41 L 18.5 41 C 19.864 41 21 39.864 21 38.5 L 21 28.5 C 21 28.205 21.205 28 21.5 28 L 26.5 28 C 26.795 28 27 28.205 27 28.5 L 27 38.5 C 27 39.864 28.136 41 29.5 41 L 39.5 41 C 40.864 41 42 39.864 42 38.5 L 42 19.41 C 42 17.114 40.945 14.941 39.141 13.52 L 24.928 2.322 C 24.65 2.103 24.304 1.989 23.951 2 Z" - /> - </Svg> - ) -} - -export function HomeIconSolid({ - style, - size, -}: { - style?: StyleProp<ViewStyle> - size?: string | number -}) { - return ( - <Svg - viewBox="0 0 48 48" - width={size || 24} - height={size || 24} - stroke="currentColor" - style={style}> - <Path - strokeWidth={2} - fill="currentColor" - d="M 23.951 2 C 23.631 2.011 23.323 2.124 23.072 2.322 L 8.859 13.52 C 7.055 14.941 6 17.114 6 19.41 L 6 38.5 C 6 39.864 7.136 41 8.5 41 L 18.5 41 C 19.864 41 21 39.864 21 38.5 L 21 28.5 C 21 28.205 21.205 28 21.5 28 L 26.5 28 C 26.795 28 27 28.205 27 28.5 L 27 38.5 C 27 39.864 28.136 41 29.5 41 L 39.5 41 C 40.864 41 42 39.864 42 38.5 L 42 19.41 C 42 17.114 40.945 14.941 39.141 13.52 L 24.928 2.322 C 24.65 2.103 24.304 1.989 23.951 2 Z" - /> - </Svg> - ) -} - -// Copyright (c) 2020 Refactoring UI Inc. -// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE -export function MagnifyingGlassIcon({ - style, - size, - strokeWidth = 2, -}: { - style?: StyleProp<ViewStyle> - size?: string | number - strokeWidth?: number -}) { - return ( - <Svg - fill="none" - viewBox="0 0 24 24" - strokeWidth={strokeWidth} - stroke="currentColor" - width={size || 24} - height={size || 24} - style={style}> - <Path - strokeLinecap="round" - strokeLinejoin="round" - d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" - /> - </Svg> - ) -} - -// https://github.com/Remix-Design/RemixIcon/blob/master/License -export function BellIcon({ - style, - size, -}: { - style?: StyleProp<ViewStyle> - size?: string | number -}) { - return ( - <Svg - fill="none" - viewBox="0 0 24 24" - width={size || 24} - height={size || 24} - style={style}> - <Path fill="none" d="M0 0h24v24H0z" /> - <Path - fill="currentColor" - d="M20 17h2v2H2v-2h2v-7a8 8 0 1 1 16 0v7zm-2 0v-7a6 6 0 1 0-12 0v7h12zm-9 4h6v2H9v-2z" - /> - </Svg> - ) -} - -// https://github.com/Remix-Design/RemixIcon/blob/master/License -export function BellIconSolid({ - style, - size, -}: { - style?: StyleProp<ViewStyle> - size?: string | number -}) { - return ( - <Svg - fill="none" - viewBox="0 0 24 24" - width={size || 24} - height={size || 24} - style={style}> - <Path fill="none" d="M0 0h24v24H0z" /> - <Path - fill="currentColor" - d="M 20 17 L 22 17 L 22 19 L 2 19 L 2 17 L 4 17 L 4 10 C 4 3.842 10.667 -0.007 16 3.072 C 18.475 4.501 20 7.142 20 10 L 20 17 Z M 9 21 L 15 21 L 15 23 L 9 23 L 9 21 Z" - /> - </Svg> - ) -} - -export function CogIcon({ - style, - size, - strokeWidth = 1.5, -}: { - style?: StyleProp<ViewStyle> - size?: string | number - strokeWidth: number -}) { - return ( - <Svg - fill="none" - viewBox="0 0 24 24" - width={size || 32} - height={size || 32} - strokeWidth={strokeWidth} - stroke="currentColor" - style={style}> - <Path - strokeLinecap="round" - strokeLinejoin="round" - d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" - /> - <Path - strokeLinecap="round" - strokeLinejoin="round" - d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" - /> - </Svg> - ) -} - -// Copyright (c) 2020 Refactoring UI Inc. -// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE -export function UserIcon({ - style, - size, - strokeWidth = 1.5, -}: { - style?: StyleProp<ViewStyle> - size?: string | number - strokeWidth?: number -}) { - return ( - <Svg - fill="none" - viewBox="0 0 24 24" - width={size || 32} - height={size || 32} - strokeWidth={strokeWidth} - stroke="currentColor" - style={style}> - <Path - strokeLinecap="round" - strokeLinejoin="round" - d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" - /> - </Svg> - ) -} - -// Copyright (c) 2020 Refactoring UI Inc. -// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE -export function UserGroupIcon({ - style, - size, -}: { - style?: StyleProp<ViewStyle> - size?: string | number -}) { - return ( - <Svg - fill="none" - viewBox="0 0 24 24" - width={size || 32} - height={size || 32} - strokeWidth={2} - stroke="currentColor" - style={style}> - <Path - strokeLinecap="round" - strokeLinejoin="round" - d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" - /> - </Svg> - ) -} - -export function RepostIcon({ - style, - size = 24, - strokeWidth = 1.5, -}: { - style?: StyleProp<ViewStyle> - size?: string | number - strokeWidth: number -}) { - return ( - <Svg viewBox="0 0 24 24" width={size} height={size} style={style}> - <Path - stroke="currentColor" - strokeWidth={strokeWidth} - strokeLinejoin="round" - fill="none" - d="M 14.437 17.081 L 5.475 17.095 C 4.7 17.095 4.072 16.467 4.072 15.692 L 4.082 5.65 L 1.22 9.854 M 4.082 5.65 L 7.006 9.854 M 9.859 5.65 L 18.625 5.654 C 19.4 5.654 20.028 6.282 20.028 7.057 L 20.031 17.081 L 17.167 12.646 M 20.031 17.081 L 22.866 12.646" - /> - </Svg> - ) -} - -// Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. -export function HeartIcon({ - style, - size = 24, - strokeWidth = 1.5, -}: { - style?: StyleProp<ViewStyle> - size?: string | number - strokeWidth: number -}) { - return ( - <Svg viewBox="0 0 24 24" width={size} height={size} style={style}> - <Path - strokeWidth={strokeWidth} - stroke="currentColor" - fill="none" - d="M 3.859 13.537 L 10.918 20.127 C 11.211 20.4 11.598 20.552 12 20.552 C 12.402 20.552 12.789 20.4 13.082 20.127 L 20.141 13.537 C 21.328 12.431 22 10.88 22 9.259 L 22 9.033 C 22 6.302 20.027 3.974 17.336 3.525 C 15.555 3.228 13.742 3.81 12.469 5.084 L 12 5.552 L 11.531 5.084 C 10.258 3.81 8.445 3.228 6.664 3.525 C 3.973 3.974 2 6.302 2 9.033 L 2 9.259 C 2 10.88 2.672 12.431 3.859 13.537 Z" - /> - </Svg> - ) -} - -// Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. -export function HeartIconSolid({ - style, - size = 24, -}: { - style?: StyleProp<ViewStyle> - size?: string | number -}) { - return ( - <Svg viewBox="0 0 24 24" width={size} height={size} style={style}> - <Path - fill="currentColor" - stroke="currentColor" - strokeWidth={1} - d="M 3.859 13.537 L 10.918 20.127 C 11.211 20.4 11.598 20.552 12 20.552 C 12.402 20.552 12.789 20.4 13.082 20.127 L 20.141 13.537 C 21.328 12.431 22 10.88 22 9.259 L 22 9.033 C 22 6.302 20.027 3.974 17.336 3.525 C 15.555 3.228 13.742 3.81 12.469 5.084 L 12 5.552 L 11.531 5.084 C 10.258 3.81 8.445 3.228 6.664 3.525 C 3.973 3.974 2 6.302 2 9.033 L 2 9.259 C 2 10.88 2.672 12.431 3.859 13.537 Z" - /> - </Svg> - ) -} - -export function UpIcon({ - style, - size, - strokeWidth = 1.3, -}: { - style?: StyleProp<ViewStyle> - size?: string | number - strokeWidth: number -}) { - return ( - <Svg - viewBox="0 0 14 14" - width={size || 24} - height={size || 24} - style={style}> - <Path - strokeWidth={strokeWidth} - stroke="currentColor" - strokeLinecap="round" - strokeLinejoin="round" - d="M 7 3 L 2 8 L 4.5 8 L 4.5 11.5 L 9.5 11.5 L 9.5 8 L 12 8 L 7 3 Z" - /> - </Svg> - ) -} - -export function UpIconSolid({ - style, - size, -}: { - style?: StyleProp<ViewStyle> - size?: string | number -}) { - return ( - <Svg - viewBox="0 0 14 14" - width={size || 24} - height={size || 24} - style={style}> - <Path - strokeWidth={1.3} - stroke="currentColor" - fill="currentColor" - strokeLinecap="round" - strokeLinejoin="round" - d="M 7 3 L 2 8 L 4.5 8 L 4.5 11.5 L 9.5 11.5 L 9.5 8 L 12 8 L 7 3 Z" - /> - </Svg> - ) -} - -export function DownIcon({ - style, - size, -}: { - style?: StyleProp<ViewStyle> - size?: string | number -}) { - return ( - <Svg - viewBox="0 0 14 14" - width={size || 24} - height={size || 24} - style={style}> - <Path - strokeWidth={1.3} - stroke="currentColor" - strokeLinecap="round" - strokeLinejoin="round" - d="M 7 11.5 L 2 6.5 L 4.5 6.5 L 4.5 3 L 9.5 3 L 9.5 6.5 L 12 6.5 L 7 11.5 Z" - /> - </Svg> - ) -} - -export function DownIconSolid({ - style, - size, -}: { - style?: StyleProp<ViewStyle> - size?: string | number -}) { - return ( - <Svg - viewBox="0 0 14 14" - width={size || 24} - height={size || 24} - style={style}> - <Path - strokeWidth={1.3} - stroke="currentColor" - fill="currentColor" - strokeLinecap="round" - strokeLinejoin="round" - d="M 7 11.5 L 2 6.5 L 4.5 6.5 L 4.5 3 L 9.5 3 L 9.5 6.5 L 12 6.5 L 7 11.5 Z" - /> - </Svg> - ) -} - -// Copyright (c) 2020 Refactoring UI Inc. -// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE -export function CommentBottomArrow({ - style, - size, - strokeWidth = 1.3, -}: { - style?: StyleProp<TextStyle> - size?: string | number - strokeWidth?: number -}) { - let color = 'currentColor' - if ( - style && - typeof style === 'object' && - 'color' in style && - typeof style.color === 'string' - ) { - color = style.color - } - return ( - <Svg - fill="none" - viewBox="0 0 24 24" - strokeWidth={strokeWidth || 2.5} - stroke={color} - width={size || 24} - height={size || 24} - style={style}> - <Path - strokeLinecap="round" - strokeLinejoin="round" - d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.068.157 2.148.279 3.238.364.466.037.893.281 1.153.671L12 21l2.652-3.978c.26-.39.687-.634 1.153-.67 1.09-.086 2.17-.208 3.238-.365 1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" - /> - </Svg> - ) -} - -export function SquareIcon({ - style, - size, - strokeWidth = 1.3, -}: { - style?: StyleProp<TextStyle> - size?: string | number - strokeWidth?: number -}) { - return ( - <Svg - fill="none" - viewBox="0 0 24 24" - strokeWidth={strokeWidth || 1} - stroke="currentColor" - width={size || 24} - height={size || 24} - style={style}> - <Rect x="6" y="6" width="12" height="12" strokeLinejoin="round" /> - </Svg> - ) -} - -export function RectWideIcon({ - style, - size, - strokeWidth = 1.3, -}: { - style?: StyleProp<TextStyle> - size?: string | number - strokeWidth?: number -}) { - return ( - <Svg - fill="none" - viewBox="0 0 24 24" - strokeWidth={strokeWidth || 1} - stroke="currentColor" - width={size || 24} - height={size || 24} - style={style}> - <Rect x="4" y="6" width="16" height="12" strokeLinejoin="round" /> - </Svg> - ) -} - -export function RectTallIcon({ - style, - size, - strokeWidth = 1.3, -}: { - style?: StyleProp<TextStyle> - size?: string | number - strokeWidth?: number -}) { - return ( - <Svg - fill="none" - viewBox="0 0 24 24" - strokeWidth={strokeWidth || 1} - stroke="currentColor" - width={size || 24} - height={size || 24} - style={style}> - <Rect x="6" y="4" width="12" height="16" strokeLinejoin="round" /> - </Svg> - ) -} diff --git a/src/view/lib/notifee.ts b/src/view/lib/notifee.ts deleted file mode 100644 index 5e1917381..000000000 --- a/src/view/lib/notifee.ts +++ /dev/null @@ -1,54 +0,0 @@ -import notifee from '@notifee/react-native' -import {AppBskyEmbedImages} from '@atproto/api' -import {NotificationsViewItemModel} from '../../state/models/notifications-view' -import {enforceLen} from '../../lib/strings' - -export function displayNotification( - title: string, - body?: string, - image?: string, -) { - const opts: {title: string; body?: string; ios?: any} = {title} - if (body) { - opts.body = enforceLen(body, 70, true) - } - if (image) { - opts.ios = { - attachments: [{url: image}], - } - } - return notifee.displayNotification(opts) -} - -export function displayNotificationFromModel( - notif: NotificationsViewItemModel, -) { - let author = notif.author.displayName || notif.author.handle - let title: string - let body: string = '' - if (notif.isUpvote) { - title = `${author} liked your post` - body = notif.additionalPost?.thread?.postRecord?.text || '' - } else if (notif.isRepost) { - title = `${author} reposted your post` - body = notif.additionalPost?.thread?.postRecord?.text || '' - } else if (notif.isMention) { - title = `${author} mentioned you` - body = notif.additionalPost?.thread?.postRecord?.text || '' - } else if (notif.isReply) { - title = `${author} replied to your post` - body = notif.additionalPost?.thread?.postRecord?.text || '' - } else if (notif.isFollow) { - title = `${author} followed you` - } else { - return - } - let image - if ( - AppBskyEmbedImages.isPresented(notif.additionalPost?.thread?.post.embed) && - notif.additionalPost?.thread?.post.embed.images[0]?.thumb - ) { - image = notif.additionalPost.thread.post.embed.images[0].thumb - } - return displayNotification(title, body, image) -} diff --git a/src/view/lib/styles.ts b/src/view/lib/styles.ts deleted file mode 100644 index 7129867e9..000000000 --- a/src/view/lib/styles.ts +++ /dev/null @@ -1,208 +0,0 @@ -import {StyleSheet, TextStyle} from 'react-native' -import {Theme, TypographyVariant} from './ThemeContext' - -// 1 is lightest, 2 is light, 3 is mid, 4 is dark, 5 is darkest -export const colors = { - white: '#ffffff', - black: '#000000', - - gray1: '#F3F3F8', - gray2: '#E2E2E4', - gray3: '#B9B9C1', - gray4: '#8D8E96', - gray5: '#545664', - gray6: '#373942', - gray7: '#26272D', - gray8: '#101013', - - blue0: '#bfe1ff', - blue1: '#8bc7fd', - blue2: '#52acfe', - blue3: '#0085ff', - blue4: '#0062bd', - blue5: '#034581', - - red1: '#ffe6f2', - red2: '#fba2ce', - red3: '#ec4899', - red4: '#d1106f', - red5: '#97074e', - - pink1: '#f8ccff', - pink2: '#e966ff', - pink3: '#db00ff', - pink4: '#a601c1', - pink5: '#570066', - - purple1: '#ebdbff', - purple2: '#ba85ff', - purple3: '#9747ff', - purple4: '#6d00fa', - purple5: '#380080', - - green1: '#c1ffb8', - green2: '#27f406', - green3: '#20bc07', - green4: '#148203', - green5: '#082b03', - - unreadNotifBg: '#ebf6ff', -} - -export const gradients = { - blueLight: {start: '#5A71FA', end: colors.blue3}, // buttons - blue: {start: '#5E55FB', end: colors.blue3}, // fab - blueDark: {start: '#5F45E0', end: colors.blue3}, // avis, banner -} - -export const s = StyleSheet.create({ - // helpers - footerSpacer: {height: 100}, - contentContainer: {paddingBottom: 200}, - border1: {borderWidth: 1}, - - // font weights - fw600: {fontWeight: '600'}, - bold: {fontWeight: 'bold'}, - fw500: {fontWeight: '500'}, - semiBold: {fontWeight: '500'}, - fw400: {fontWeight: '400'}, - normal: {fontWeight: '400'}, - fw300: {fontWeight: '300'}, - light: {fontWeight: '300'}, - fw200: {fontWeight: '200'}, - - // text decoration - underline: {textDecorationLine: 'underline'}, - - // font sizes - f9: {fontSize: 9}, - f10: {fontSize: 10}, - f11: {fontSize: 11}, - f12: {fontSize: 12}, - f13: {fontSize: 13}, - f14: {fontSize: 14}, - f15: {fontSize: 15}, - f16: {fontSize: 16}, - f17: {fontSize: 17}, - f18: {fontSize: 18}, - - // line heights - ['lh13-1']: {lineHeight: 13}, - ['lh13-1.3']: {lineHeight: 16.9}, // 1.3 of 13px - ['lh14-1']: {lineHeight: 14}, - ['lh14-1.3']: {lineHeight: 18.2}, // 1.3 of 14px - ['lh15-1']: {lineHeight: 15}, - ['lh15-1.3']: {lineHeight: 19.5}, // 1.3 of 15px - ['lh16-1']: {lineHeight: 16}, - ['lh16-1.3']: {lineHeight: 20.8}, // 1.3 of 16px - ['lh17-1']: {lineHeight: 17}, - ['lh17-1.3']: {lineHeight: 22.1}, // 1.3 of 17px - ['lh18-1']: {lineHeight: 18}, - ['lh18-1.3']: {lineHeight: 23.4}, // 1.3 of 18px - - // margins - mr2: {marginRight: 2}, - mr5: {marginRight: 5}, - mr10: {marginRight: 10}, - ml2: {marginLeft: 2}, - ml5: {marginLeft: 5}, - ml10: {marginLeft: 10}, - mt2: {marginTop: 2}, - mt5: {marginTop: 5}, - mt10: {marginTop: 10}, - mb2: {marginBottom: 2}, - mb5: {marginBottom: 5}, - mb10: {marginBottom: 10}, - - // paddings - p2: {padding: 2}, - p5: {padding: 5}, - p10: {padding: 10}, - p20: {padding: 20}, - pr2: {paddingRight: 2}, - pr5: {paddingRight: 5}, - pr10: {paddingRight: 10}, - pr20: {paddingRight: 20}, - pl2: {paddingLeft: 2}, - pl5: {paddingLeft: 5}, - pl10: {paddingLeft: 10}, - pl20: {paddingLeft: 20}, - pt2: {paddingTop: 2}, - pt5: {paddingTop: 5}, - pt10: {paddingTop: 10}, - pt20: {paddingTop: 20}, - pb2: {paddingBottom: 2}, - pb5: {paddingBottom: 5}, - pb10: {paddingBottom: 10}, - pb20: {paddingBottom: 20}, - - // flex - flexRow: {flexDirection: 'row'}, - flexCol: {flexDirection: 'column'}, - flex1: {flex: 1}, - alignCenter: {alignItems: 'center'}, - alignBaseline: {alignItems: 'baseline'}, - - // position - absolute: {position: 'absolute'}, - - // dimensions - w100pct: {width: '100%'}, - h100pct: {height: '100%'}, - - // text align - textLeft: {textAlign: 'left'}, - textCenter: {textAlign: 'center'}, - textRight: {textAlign: 'right'}, - - // colors - white: {color: colors.white}, - black: {color: colors.black}, - - gray1: {color: colors.gray1}, - gray2: {color: colors.gray2}, - gray3: {color: colors.gray3}, - gray4: {color: colors.gray4}, - gray5: {color: colors.gray5}, - - blue1: {color: colors.blue1}, - blue2: {color: colors.blue2}, - blue3: {color: colors.blue3}, - blue4: {color: colors.blue4}, - blue5: {color: colors.blue5}, - - red1: {color: colors.red1}, - red2: {color: colors.red2}, - red3: {color: colors.red3}, - red4: {color: colors.red4}, - red5: {color: colors.red5}, - - pink1: {color: colors.pink1}, - pink2: {color: colors.pink2}, - pink3: {color: colors.pink3}, - pink4: {color: colors.pink4}, - pink5: {color: colors.pink5}, - - purple1: {color: colors.purple1}, - purple2: {color: colors.purple2}, - purple3: {color: colors.purple3}, - purple4: {color: colors.purple4}, - purple5: {color: colors.purple5}, - - green1: {color: colors.green1}, - green2: {color: colors.green2}, - green3: {color: colors.green3}, - green4: {color: colors.green4}, - green5: {color: colors.green5}, -}) - -export function lh( - theme: Theme, - type: TypographyVariant, - height: number, -): TextStyle { - return { - lineHeight: (theme.typography[type].fontSize || 16) * height, - } -} diff --git a/src/view/lib/themes.ts b/src/view/lib/themes.ts deleted file mode 100644 index 84e2b7883..000000000 --- a/src/view/lib/themes.ts +++ /dev/null @@ -1,297 +0,0 @@ -import {Platform} from 'react-native' -import type {Theme} from './ThemeContext' -import {colors} from './styles' - -export const defaultTheme: Theme = { - colorScheme: 'light', - palette: { - default: { - background: colors.white, - backgroundLight: colors.gray1, - text: colors.black, - textLight: colors.gray5, - textInverted: colors.white, - link: colors.blue3, - border: '#f0e9e9', - borderDark: '#e0d9d9', - icon: colors.gray3, - - // non-standard - textVeryLight: colors.gray4, - replyLine: colors.gray2, - replyLineDot: colors.gray3, - unreadNotifBg: '#ebf6ff', - postCtrl: '#71768A', - brandText: '#0066FF', - emptyStateIcon: '#B6B6C9', - }, - primary: { - background: colors.blue3, - backgroundLight: colors.blue2, - text: colors.white, - textLight: colors.blue0, - textInverted: colors.blue3, - link: colors.blue0, - border: colors.blue4, - borderDark: colors.blue5, - icon: colors.blue4, - }, - secondary: { - background: colors.green3, - backgroundLight: colors.green2, - text: colors.white, - textLight: colors.green1, - textInverted: colors.green4, - link: colors.green1, - border: colors.green4, - borderDark: colors.green5, - icon: colors.green4, - }, - inverted: { - background: colors.black, - backgroundLight: colors.gray6, - text: colors.white, - textLight: colors.gray3, - textInverted: colors.black, - link: colors.blue2, - border: colors.gray3, - borderDark: colors.gray2, - icon: colors.gray5, - }, - error: { - background: colors.red3, - backgroundLight: colors.red2, - text: colors.white, - textLight: colors.red1, - textInverted: colors.red3, - link: colors.red1, - border: colors.red4, - borderDark: colors.red5, - icon: colors.red4, - }, - }, - shapes: { - button: { - // TODO - }, - bigButton: { - // TODO - }, - smallButton: { - // TODO - }, - }, - typography: { - 'xl-thin': { - fontSize: 17, - letterSpacing: 0.25, - fontWeight: '300', - }, - xl: { - fontSize: 17, - letterSpacing: 0.25, - fontWeight: '400', - }, - 'xl-medium': { - fontSize: 17, - letterSpacing: 0.25, - fontWeight: '500', - }, - 'xl-bold': { - fontSize: 17, - letterSpacing: 0.25, - fontWeight: '700', - }, - 'xl-heavy': { - fontSize: 17, - letterSpacing: 0.25, - fontWeight: '800', - }, - 'lg-thin': { - fontSize: 16, - letterSpacing: 0.25, - fontWeight: '300', - }, - lg: { - fontSize: 16, - letterSpacing: 0.25, - fontWeight: '400', - }, - 'lg-medium': { - fontSize: 16, - letterSpacing: 0.25, - fontWeight: '500', - }, - 'lg-bold': { - fontSize: 16, - letterSpacing: 0.25, - fontWeight: '700', - }, - 'lg-heavy': { - fontSize: 16, - letterSpacing: 0.25, - fontWeight: '800', - }, - 'md-thin': { - fontSize: 15, - letterSpacing: 0.25, - fontWeight: '300', - }, - md: { - fontSize: 15, - letterSpacing: 0.25, - fontWeight: '400', - }, - 'md-medium': { - fontSize: 15, - letterSpacing: 0.25, - fontWeight: '500', - }, - 'md-bold': { - fontSize: 15, - letterSpacing: 0.25, - fontWeight: '700', - }, - 'md-heavy': { - fontSize: 15, - letterSpacing: 0.25, - fontWeight: '800', - }, - 'sm-thin': { - fontSize: 14, - letterSpacing: 0.25, - fontWeight: '300', - }, - sm: { - fontSize: 14, - letterSpacing: 0.25, - fontWeight: '400', - }, - 'sm-medium': { - fontSize: 14, - letterSpacing: 0.25, - fontWeight: '500', - }, - 'sm-bold': { - fontSize: 14, - letterSpacing: 0.25, - fontWeight: '700', - }, - 'sm-heavy': { - fontSize: 14, - letterSpacing: 0.25, - fontWeight: '800', - }, - 'xs-thin': { - fontSize: 13, - letterSpacing: 0.25, - fontWeight: '300', - }, - xs: { - fontSize: 13, - letterSpacing: 0.25, - fontWeight: '400', - }, - 'xs-medium': { - fontSize: 13, - letterSpacing: 0.25, - fontWeight: '500', - }, - 'xs-bold': { - fontSize: 13, - letterSpacing: 0.25, - fontWeight: '700', - }, - 'xs-heavy': { - fontSize: 13, - letterSpacing: 0.25, - fontWeight: '800', - }, - - 'title-xl': { - fontSize: 34, - letterSpacing: 0.25, - fontWeight: '500', - }, - 'title-lg': { - fontSize: 22, - fontWeight: '500', - }, - title: { - fontWeight: '500', - fontSize: 20, - letterSpacing: 0.15, - }, - 'title-sm': { - fontWeight: 'bold', - fontSize: 17, - letterSpacing: 0.15, - }, - 'post-text': { - fontSize: 16, - letterSpacing: 0.2, - fontWeight: '400', - }, - 'post-text-lg': { - fontSize: 22, - letterSpacing: 0.4, - fontWeight: '400', - }, - button: { - fontWeight: '500', - fontSize: 14, - letterSpacing: 0.5, - }, - mono: { - fontSize: 14, - fontFamily: Platform.OS === 'android' ? 'monospace' : 'Courier New', - }, - }, -} - -export const darkTheme: Theme = { - ...defaultTheme, - colorScheme: 'dark', - palette: { - ...defaultTheme.palette, - default: { - background: colors.gray8, - backgroundLight: colors.gray6, - text: colors.white, - textLight: colors.gray3, - textInverted: colors.black, - link: colors.blue3, - border: colors.gray6, - borderDark: colors.gray5, - icon: colors.gray5, - - // non-standard - textVeryLight: colors.gray4, - replyLine: colors.gray5, - replyLineDot: colors.gray6, - unreadNotifBg: colors.blue5, - postCtrl: '#61657A', - brandText: '#0085ff', - emptyStateIcon: colors.gray4, - }, - primary: { - ...defaultTheme.palette.primary, - textInverted: colors.blue2, - }, - secondary: { - ...defaultTheme.palette.secondary, - textInverted: colors.green2, - }, - inverted: { - background: colors.white, - backgroundLight: colors.gray2, - text: colors.black, - textLight: colors.gray5, - textInverted: colors.white, - link: colors.blue3, - border: colors.gray3, - borderDark: colors.gray4, - icon: colors.gray1, - }, - }, -} diff --git a/src/view/routes.ts b/src/view/routes.ts index 98e8111b3..1cd9ef8e2 100644 --- a/src/view/routes.ts +++ b/src/view/routes.ts @@ -1,5 +1,4 @@ -import React, {MutableRefObject} from 'react' -import {FlatList} from 'react-native' +import React from 'react' import {IconProp} from '@fortawesome/fontawesome-svg-core' import {Home} from './screens/Home' import {Contacts} from './screens/Contacts' @@ -21,7 +20,6 @@ export type ScreenParams = { navIdx: string params: Record<string, any> visible: boolean - scrollElRef?: MutableRefObject<FlatList<any> | null> } export type Route = [React.FC<ScreenParams>, string, IconProp, RegExp] export type MatchResult = { diff --git a/src/view/screens/Contacts.tsx b/src/view/screens/Contacts.tsx index cba17f285..21943a10a 100644 --- a/src/view/screens/Contacts.tsx +++ b/src/view/screens/Contacts.tsx @@ -4,10 +4,10 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {ProfileFollows as ProfileFollowsComponent} from '../com/profile/ProfileFollows' import {Selector} from '../com/util/Selector' import {Text} from '../com/util/text/Text' -import {colors} from '../lib/styles' +import {colors} from 'lib/styles' import {ScreenParams} from '../routes' -import {useStores} from '../../state' -import {useAnimatedValue} from '../lib/hooks/useAnimatedValue' +import {useStores} from 'state/index' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' export const Contacts = ({navIdx, visible}: ScreenParams) => { const store = useStores() diff --git a/src/view/screens/Debug.tsx b/src/view/screens/Debug.tsx index 0223e631d..09e3dd46a 100644 --- a/src/view/screens/Debug.tsx +++ b/src/view/screens/Debug.tsx @@ -1,11 +1,10 @@ import React from 'react' import {ScrollView, View} from 'react-native' import {ViewHeader} from '../com/util/ViewHeader' -import {ThemeProvider} from '../lib/ThemeContext' -import {PaletteColorName} from '../lib/ThemeContext' -import {usePalette} from '../lib/hooks/usePalette' -import {s} from '../lib/styles' -import {displayNotification} from '../lib/notifee' +import {ThemeProvider, PaletteColorName} from 'lib/ThemeContext' +import {usePalette} from 'lib/hooks/usePalette' +import {s} from 'lib/styles' +import {displayNotification} from 'lib/notifee' import {Text} from '../com/util/text/Text' import {ViewSelector} from '../com/util/ViewSelector' @@ -284,6 +283,9 @@ function TypographyView() { 'xs-heavy' lorem ipsum dolor </Text> + <Text type="title-2xl" style={[pal.text]}> + 'title-2xl' lorem ipsum dolor + </Text> <Text type="title-xl" style={[pal.text]}> 'title-xl' lorem ipsum dolor </Text> @@ -296,6 +298,9 @@ function TypographyView() { <Text type="button" style={[pal.text]}> Button </Text> + <Text type="button-lg" style={[pal.text]}> + Button-lg + </Text> </View> ) } diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 4222c7513..de7e61ba4 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -1,23 +1,24 @@ import React, {useEffect} from 'react' -import {View} from 'react-native' +import {FlatList, View} from 'react-native' import {observer} from 'mobx-react-lite' import useAppState from 'react-native-appstate-hook' import {ViewHeader} from '../com/util/ViewHeader' import {Feed} from '../com/posts/Feed' import {FAB} from '../com/util/FAB' import {LoadLatestBtn} from '../com/util/LoadLatestBtn' -import {useStores} from '../../state' +import {useStores} from 'state/index' import {ScreenParams} from '../routes' -import {s} from '../lib/styles' -import {useOnMainScroll} from '../lib/hooks/useOnMainScroll' +import {s} from 'lib/styles' +import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' +import {useAnalytics} from 'lib/analytics' -export const Home = observer(function Home({ - navIdx, - visible, - scrollElRef, -}: ScreenParams) { +const HEADER_HEIGHT = 42 + +export const Home = observer(function Home({navIdx, visible}: ScreenParams) { const store = useStores() const onMainScroll = useOnMainScroll(store) + const {screen, track} = useAnalytics() + const scrollElRef = React.useRef<FlatList>(null) const [wasVisible, setWasVisible] = React.useState<boolean>(false) const {appState} = useAppState({ onForeground: () => doPoll(true), @@ -31,22 +32,31 @@ export const Home = observer(function Home({ if (store.me.mainFeed.isLoading) { return } - store.log.debug('Polling home feed') - store.me.mainFeed.checkForLatest().catch(e => { - store.log.error('Failed to poll feed', e) - }) + store.log.debug('HomeScreen: Polling for new posts') + store.me.mainFeed.checkForLatest() }, [appState, visible, store], ) + const scrollToTop = React.useCallback(() => { + // NOTE: the feed is offset by the height of the collapsing header, + // so we scroll to the negative of that height -prf + scrollElRef.current?.scrollToOffset({offset: -HEADER_HEIGHT}) + }, [scrollElRef]) + useEffect(() => { + const softResetSub = store.onScreenSoftReset(scrollToTop) const feedCleanup = store.me.mainFeed.registerListeners() - const pollInterval = setInterval(() => doPoll(), 15e3) + const pollInterval = setInterval(doPoll, 15e3) const cleanup = () => { clearInterval(pollInterval) + softResetSub.remove() feedCleanup() } + // guard to only continue when transitioning from !visible -> visible + // TODO is this 100% needed? depends on if useEffect() is getting refired + // for reasons other than `visible` changing -prf if (!visible) { setWasVisible(false) return cleanup @@ -55,17 +65,20 @@ export const Home = observer(function Home({ } setWasVisible(true) + // just became visible + screen('Feed') store.nav.setTitle(navIdx, 'Home') - store.log.debug('Updating home feed') + store.log.debug('HomeScreen: Updating feed') if (store.me.mainFeed.hasContent) { store.me.mainFeed.update() } else { store.me.mainFeed.setup() } return cleanup - }, [visible, store, navIdx, doPoll, wasVisible]) + }, [visible, store, store.me.mainFeed, navIdx, doPoll, wasVisible, scrollToTop, screen]) const onPressCompose = (imagesOpen?: boolean) => { + track('Home:ComposeButtonPressed') store.shell.openComposer({imagesOpen}) } const onPressTryAgain = () => { @@ -73,26 +86,31 @@ export const Home = observer(function Home({ } const onPressLoadLatest = () => { store.me.mainFeed.refresh() - scrollElRef?.current?.scrollToOffset({offset: 0}) + scrollToTop() } return ( <View style={s.h100pct}> - <ViewHeader title="Bluesky" subtitle="Private Beta" canGoBack={false} /> <Feed testID="homeFeed" key="default" feed={store.me.mainFeed} scrollElRef={scrollElRef} style={s.h100pct} - onPressCompose={onPressCompose} onPressTryAgain={onPressTryAgain} + onPressCompose={onPressCompose} onScroll={onMainScroll} + headerOffset={HEADER_HEIGHT} /> + <ViewHeader title="Bluesky" canGoBack={false} hideOnScroll /> {store.me.mainFeed.hasNewLatest && !store.me.mainFeed.isRefreshing && ( <LoadLatestBtn onPress={onPressLoadLatest} /> )} - <FAB icon="pen-nib" onPress={() => onPressCompose(false)} /> + <FAB + testID="composeFAB" + icon="plus" + onPress={() => onPressCompose(false)} + /> </View> ) }) diff --git a/src/view/screens/Log.tsx b/src/view/screens/Log.tsx index c3e156dcb..c067d3506 100644 --- a/src/view/screens/Log.tsx +++ b/src/view/screens/Log.tsx @@ -3,13 +3,13 @@ import {StyleSheet, TouchableOpacity, View} from 'react-native' import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {ScrollView} from '../com/util/Views' -import {useStores} from '../../state' +import {useStores} from 'state/index' import {ScreenParams} from '../routes' -import {s} from '../lib/styles' +import {s} from 'lib/styles' import {ViewHeader} from '../com/util/ViewHeader' import {Text} from '../com/util/text/Text' -import {usePalette} from '../lib/hooks/usePalette' -import {ago} from '../../lib/strings' +import {usePalette} from 'lib/hooks/usePalette' +import {ago} from 'lib/strings/time' export const Log = observer(function Log({navIdx, visible}: ScreenParams) { const pal = usePalette('default') diff --git a/src/view/screens/Login.tsx b/src/view/screens/Login.tsx index 81a2c9e6b..50b2a34c0 100644 --- a/src/view/screens/Login.tsx +++ b/src/view/screens/Login.tsx @@ -1,19 +1,16 @@ -import React, {useState} from 'react' -import { - Image, - SafeAreaView, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' +import React, {useEffect, useState} from 'react' +import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native' +import Image, {Source as ImageSource} from 'view/com/util/images/Image' import {observer} from 'mobx-react-lite' import {Signin} from '../com/login/Signin' import {CreateAccount} from '../com/login/CreateAccount' import {Text} from '../com/util/text/Text' import {ErrorBoundary} from '../com/util/ErrorBoundary' -import {colors} from '../lib/styles' -import {usePalette} from '../lib/hooks/usePalette' -import {CLOUD_SPLASH} from '../lib/assets' +import {colors} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {useStores} from 'state/index' +import {CLOUD_SPLASH} from 'lib/assets' +import {useAnalytics} from 'lib/analytics' enum ScreenState { S_SigninOrCreateAccount, @@ -28,6 +25,12 @@ const SigninOrCreateAccount = ({ onPressSignin: () => void onPressCreateAccount: () => void }) => { + const {screen} = useAnalytics() + + useEffect(() => { + screen('Login') + }, [screen]) + const pal = usePalette('default') return ( <> @@ -57,22 +60,28 @@ const SigninOrCreateAccount = ({ export const Login = observer(() => { const pal = usePalette('default') + const store = useStores() const [screenState, setScreenState] = useState<ScreenState>( ScreenState.S_SigninOrCreateAccount, ) - if (screenState === ScreenState.S_SigninOrCreateAccount) { + if ( + store.session.isResumingSession || + screenState === ScreenState.S_SigninOrCreateAccount + ) { return ( <View style={styles.container}> - <Image source={CLOUD_SPLASH} style={styles.bgImg} /> + <Image source={CLOUD_SPLASH as ImageSource} style={styles.bgImg} /> <SafeAreaView testID="noSessionView" style={styles.container}> <ErrorBoundary> - <SigninOrCreateAccount - onPressSignin={() => setScreenState(ScreenState.S_Signin)} - onPressCreateAccount={() => - setScreenState(ScreenState.S_CreateAccount) - } - /> + {!store.session.isResumingSession && ( + <SigninOrCreateAccount + onPressSignin={() => setScreenState(ScreenState.S_Signin)} + onPressCreateAccount={() => + setScreenState(ScreenState.S_CreateAccount) + } + /> + )} </ErrorBoundary> </SafeAreaView> </View> diff --git a/src/view/screens/Login.web.tsx b/src/view/screens/Login.web.tsx index 77149090c..90effc5d6 100644 --- a/src/view/screens/Login.web.tsx +++ b/src/view/screens/Login.web.tsx @@ -1,20 +1,13 @@ import React, {useState} from 'react' -import { - Image, - SafeAreaView, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' +import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native' import {observer} from 'mobx-react-lite' import {CenteredView} from '../com/util/Views' import {Signin} from '../com/login/Signin' import {CreateAccount} from '../com/login/CreateAccount' import {Text} from '../com/util/text/Text' import {ErrorBoundary} from '../com/util/ErrorBoundary' -import {colors} from '../lib/styles' -import {usePalette} from '../lib/hooks/usePalette' -import {CLOUD_SPLASH} from '../lib/assets' +import {colors} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' enum ScreenState { S_SigninOrCreateAccount, @@ -125,6 +118,7 @@ const styles = StyleSheet.create({ width: '100%', height: '100%', }, + hero: {}, heroText: { backgroundColor: colors.white, paddingTop: 10, diff --git a/src/view/screens/NotFound.tsx b/src/view/screens/NotFound.tsx index c5c5ff002..77bbdd2aa 100644 --- a/src/view/screens/NotFound.tsx +++ b/src/view/screens/NotFound.tsx @@ -2,7 +2,7 @@ import React from 'react' import {Button, StyleSheet, View} from 'react-native' import {ViewHeader} from '../com/util/ViewHeader' import {Text} from '../com/util/text/Text' -import {useStores} from '../../state' +import {useStores} from 'state/index' export const NotFound = () => { const stores = useStores() diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx index 9b5dc5970..548b0d564 100644 --- a/src/view/screens/Notifications.tsx +++ b/src/view/screens/Notifications.tsx @@ -1,35 +1,79 @@ import React, {useEffect} from 'react' -import {View} from 'react-native' +import {FlatList, View} from 'react-native' +import useAppState from 'react-native-appstate-hook' import {ViewHeader} from '../com/util/ViewHeader' import {Feed} from '../com/notifications/Feed' -import {useStores} from '../../state' +import {useStores} from 'state/index' import {ScreenParams} from '../routes' -import {useOnMainScroll} from '../lib/hooks/useOnMainScroll' -import {s} from '../lib/styles' +import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' +import {s} from 'lib/styles' +import {useAnalytics} from 'lib/analytics' + +const NOTIFICATIONS_POLL_INTERVAL = 15e3 export const Notifications = ({navIdx, visible}: ScreenParams) => { const store = useStores() const onMainScroll = useOnMainScroll(store) + const scrollElRef = React.useRef<FlatList>(null) + const {screen} = useAnalytics() + const {appState} = useAppState({ + onForeground: () => doPoll(true), + }) + // event handlers + // = + const onPressTryAgain = () => { + store.me.notifications.refresh() + } + const scrollToTop = React.useCallback(() => { + scrollElRef.current?.scrollToOffset({offset: 0}) + }, [scrollElRef]) + + // periodic polling + // = + const doPoll = React.useCallback( + async (isForegrounding = false) => { + if (isForegrounding) { + // app is foregrounding, refresh optimistically + store.log.debug('NotificationsScreen: Refreshing on app foreground') + await Promise.all([ + store.me.notifications.loadUnreadCount(), + store.me.notifications.refresh(), + ]) + } else if (appState === 'active') { + // periodic poll, refresh if there are new notifs + store.log.debug('NotificationsScreen: Polling for new notifications') + const didChange = await store.me.notifications.loadUnreadCount() + if (didChange) { + store.log.debug('NotificationsScreen: Loading new notifications') + await store.me.notifications.loadLatest() + } + } + }, + [appState, store], + ) + useEffect(() => { + const pollInterval = setInterval(doPoll, NOTIFICATIONS_POLL_INTERVAL) + return () => clearInterval(pollInterval) + }, [doPoll]) + + // on-visible setup + // = useEffect(() => { if (!visible) { return } - store.log.debug('Updating notifications feed') - store.me.notifications - .update() - .catch(e => { - store.log.error('Error while updating notifications feed', e) - }) - .then(() => { - store.me.notifications.updateReadState() - }) + store.log.debug('NotificationsScreen: Updating feed') + const softResetSub = store.onScreenSoftReset(scrollToTop) + store.me.notifications.update().then(() => { + store.me.notifications.markAllRead() + }) + screen('Notifications') store.nav.setTitle(navIdx, 'Notifications') - }, [visible, store, navIdx]) - - const onPressTryAgain = () => { - store.me.notifications.refresh() - } + return () => { + softResetSub.remove() + } + }, [visible, store, navIdx, screen, scrollToTop]) return ( <View style={s.h100pct}> @@ -38,6 +82,7 @@ export const Notifications = ({navIdx, visible}: ScreenParams) => { view={store.me.notifications} onPressTryAgain={onPressTryAgain} onScroll={onMainScroll} + scrollElRef={scrollElRef} /> </View> ) diff --git a/src/view/screens/Onboard.tsx b/src/view/screens/Onboard.tsx index e31b42adc..1485670e7 100644 --- a/src/view/screens/Onboard.tsx +++ b/src/view/screens/Onboard.tsx @@ -3,8 +3,8 @@ import {StyleSheet, View} from 'react-native' import {observer} from 'mobx-react-lite' import {FeatureExplainer} from '../com/onboard/FeatureExplainer' import {Follows} from '../com/onboard/Follows' -import {OnboardStage, OnboardStageOrder} from '../../state/models/onboard' -import {useStores} from '../../state' +import {OnboardStage, OnboardStageOrder} from 'state/models/onboard' +import {useStores} from 'state/index' export const Onboard = observer(() => { const store = useStores() diff --git a/src/view/screens/PostDownvotedBy.tsx b/src/view/screens/PostDownvotedBy.tsx index 1401868d4..570482598 100644 --- a/src/view/screens/PostDownvotedBy.tsx +++ b/src/view/screens/PostDownvotedBy.tsx @@ -3,8 +3,8 @@ import {View} from 'react-native' import {ViewHeader} from '../com/util/ViewHeader' import {PostVotedBy as PostLikedByComponent} from '../com/post-thread/PostVotedBy' import {ScreenParams} from '../routes' -import {useStores} from '../../state' -import {makeRecordUri} from '../../lib/strings' +import {useStores} from 'state/index' +import {makeRecordUri} from 'lib/strings/url-helpers' export const PostDownvotedBy = ({navIdx, visible, params}: ScreenParams) => { const store = useStores() diff --git a/src/view/screens/PostRepostedBy.tsx b/src/view/screens/PostRepostedBy.tsx index bf4d6ec91..4be4b4b42 100644 --- a/src/view/screens/PostRepostedBy.tsx +++ b/src/view/screens/PostRepostedBy.tsx @@ -3,8 +3,8 @@ import {View} from 'react-native' import {ViewHeader} from '../com/util/ViewHeader' import {PostRepostedBy as PostRepostedByComponent} from '../com/post-thread/PostRepostedBy' import {ScreenParams} from '../routes' -import {useStores} from '../../state' -import {makeRecordUri} from '../../lib/strings' +import {useStores} from 'state/index' +import {makeRecordUri} from 'lib/strings/url-helpers' export const PostRepostedBy = ({navIdx, visible, params}: ScreenParams) => { const store = useStores() diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index febaddc09..4b799468d 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -1,17 +1,16 @@ -import React, {useEffect, useMemo, useState} from 'react' +import React, {useEffect, useMemo} from 'react' import {View} from 'react-native' -import {makeRecordUri} from '../../lib/strings' +import {makeRecordUri} from 'lib/strings/url-helpers' import {ViewHeader} from '../com/util/ViewHeader' import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread' -import {PostThreadViewModel} from '../../state/models/post-thread-view' +import {PostThreadViewModel} from 'state/models/post-thread-view' import {ScreenParams} from '../routes' -import {useStores} from '../../state' -import {s} from '../lib/styles' +import {useStores} from 'state/index' +import {s} from 'lib/styles' 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}), @@ -24,7 +23,6 @@ export const PostThread = ({navIdx, visible, params}: ScreenParams) => { const setTitle = () => { const author = view.thread?.post.author const niceName = author?.handle || name - setViewSubtitle(`by ${niceName}`) store.nav.setTitle(navIdx, `Post by ${niceName}`) } if (!visible) { @@ -52,7 +50,7 @@ export const PostThread = ({navIdx, visible, params}: ScreenParams) => { return ( <View style={s.h100pct}> - <ViewHeader title="Post" subtitle={viewSubtitle} /> + <ViewHeader title="Post" /> <View style={s.h100pct}> <PostThreadComponent uri={uri} view={view} /> </View> diff --git a/src/view/screens/PostUpvotedBy.tsx b/src/view/screens/PostUpvotedBy.tsx index 4bba222ae..4d6ad4114 100644 --- a/src/view/screens/PostUpvotedBy.tsx +++ b/src/view/screens/PostUpvotedBy.tsx @@ -3,8 +3,8 @@ import {View} from 'react-native' import {ViewHeader} from '../com/util/ViewHeader' import {PostVotedBy as PostLikedByComponent} from '../com/post-thread/PostVotedBy' import {ScreenParams} from '../routes' -import {useStores} from '../../state' -import {makeRecordUri} from '../../lib/strings' +import {useStores} from 'state/index' +import {makeRecordUri} from 'lib/strings/url-helpers' export const PostUpvotedBy = ({navIdx, visible, params}: ScreenParams) => { const store = useStores() diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 5c6616985..03d973b96 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -4,8 +4,8 @@ import {observer} from 'mobx-react-lite' import {ViewSelector} from '../com/util/ViewSelector' import {CenteredView} from '../com/util/Views' import {ScreenParams} from '../routes' -import {ProfileUiModel, Sections} from '../../state/models/profile-ui' -import {useStores} from '../../state' +import {ProfileUiModel, Sections} from 'state/models/profile-ui' +import {useStores} from 'state/index' import {ProfileHeader} from '../com/profile/ProfileHeader' import {FeedItem} from '../com/posts/FeedItem' import {PostFeedLoadingPlaceholder} from '../com/util/LoadingPlaceholder' @@ -14,8 +14,9 @@ import {ErrorMessage} from '../com/util/error/ErrorMessage' import {EmptyState} from '../com/util/EmptyState' import {Text} from '../com/util/text/Text' import {FAB} from '../com/util/FAB' -import {s, colors} from '../lib/styles' -import {useOnMainScroll} from '../lib/hooks/useOnMainScroll' +import {s, colors} from 'lib/styles' +import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' +import {useAnalytics} from 'lib/analytics' const LOADING_ITEM = {_reactKey: '__loading__'} const END_ITEM = {_reactKey: '__end__'} @@ -23,6 +24,12 @@ const EMPTY_ITEM = {_reactKey: '__empty__'} export const Profile = observer(({navIdx, visible, params}: ScreenParams) => { const store = useStores() + const {screen} = useAnalytics() + + useEffect(() => { + screen('Profile') + }, [screen]) + const onMainScroll = useOnMainScroll(store) const [hasSetup, setHasSetup] = useState<boolean>(false) const uiState = React.useMemo( @@ -128,7 +135,7 @@ export const Profile = observer(({navIdx, visible, params}: ScreenParams) => { } if (!uiState.feed.hasMore) { items = items.concat([END_ITEM]) - } else { + } else if (uiState.feed.isLoading) { Footer = LoadingMoreFooter } renderItem = (item: any) => { @@ -184,7 +191,7 @@ export const Profile = observer(({navIdx, visible, params}: ScreenParams) => { ) : ( <CenteredView>{renderHeader()}</CenteredView> )} - <FAB icon="pen-nib" onPress={onPressCompose} /> + <FAB icon="plus" onPress={onPressCompose} /> </View> ) }) diff --git a/src/view/screens/ProfileFollowers.tsx b/src/view/screens/ProfileFollowers.tsx index f7520549e..9f1a9c741 100644 --- a/src/view/screens/ProfileFollowers.tsx +++ b/src/view/screens/ProfileFollowers.tsx @@ -3,7 +3,7 @@ import {View} from 'react-native' import {ViewHeader} from '../com/util/ViewHeader' import {ProfileFollowers as ProfileFollowersComponent} from '../com/profile/ProfileFollowers' import {ScreenParams} from '../routes' -import {useStores} from '../../state' +import {useStores} from 'state/index' export const ProfileFollowers = ({navIdx, visible, params}: ScreenParams) => { const store = useStores() @@ -18,7 +18,7 @@ export const ProfileFollowers = ({navIdx, visible, params}: ScreenParams) => { return ( <View> - <ViewHeader title="Followers" subtitle={`of ${name}`} /> + <ViewHeader title="Followers" /> <ProfileFollowersComponent name={name} /> </View> ) diff --git a/src/view/screens/ProfileFollows.tsx b/src/view/screens/ProfileFollows.tsx index 65e4004e9..1cdb5bccf 100644 --- a/src/view/screens/ProfileFollows.tsx +++ b/src/view/screens/ProfileFollows.tsx @@ -3,7 +3,7 @@ import {View} from 'react-native' import {ViewHeader} from '../com/util/ViewHeader' import {ProfileFollows as ProfileFollowsComponent} from '../com/profile/ProfileFollows' import {ScreenParams} from '../routes' -import {useStores} from '../../state' +import {useStores} from 'state/index' export const ProfileFollows = ({navIdx, visible, params}: ScreenParams) => { const store = useStores() @@ -18,7 +18,7 @@ export const ProfileFollows = ({navIdx, visible, params}: ScreenParams) => { return ( <View> - <ViewHeader title="Followed" subtitle={`by ${name}`} /> + <ViewHeader title="Following" /> <ProfileFollowsComponent name={name} /> </View> ) diff --git a/src/view/screens/Search.tsx b/src/view/screens/Search.tsx index 2a1caab89..2e176d98f 100644 --- a/src/view/screens/Search.tsx +++ b/src/view/screens/Search.tsx @@ -1,41 +1,73 @@ -import React, {useEffect, useState, useMemo, useRef} from 'react' +import React from 'react' import { Keyboard, StyleSheet, TextInput, TouchableOpacity, + TouchableWithoutFeedback, View, } from 'react-native' -import {ViewHeader} from '../com/util/ViewHeader' -import {CenteredView, ScrollView} from '../com/util/Views' -import {SuggestedFollows} from '../com/discover/SuggestedFollows' +import {ScrollView} from '../com/util/Views' +import {observer} from 'mobx-react-lite' import {UserAvatar} from '../com/util/UserAvatar' import {Text} from '../com/util/text/Text' import {ScreenParams} from '../routes' -import {useStores} from '../../state' -import {UserAutocompleteViewModel} from '../../state/models/user-autocomplete-view' -import {s} from '../lib/styles' -import {MagnifyingGlassIcon} from '../lib/icons' -import {usePalette} from '../lib/hooks/usePalette' +import {useStores} from 'state/index' +import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' +import {s} from 'lib/styles' +import {MagnifyingGlassIcon} from 'lib/icons' +import {WhoToFollow} from '../com/discover/WhoToFollow' +import {SuggestedPosts} from '../com/discover/SuggestedPosts' +import {ProfileCard} from '../com/profile/ProfileCard' +import {usePalette} from 'lib/hooks/usePalette' +import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' +import {useAnalytics} from 'lib/analytics' -export const Search = ({navIdx, visible, params}: ScreenParams) => { +const MENU_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10} +const FIVE_MIN = 5 * 60 * 1e3 + +export const Search = observer(({navIdx, visible, params}: ScreenParams) => { const pal = usePalette('default') const store = useStores() - const textInput = useRef<TextInput>(null) - const [query, setQuery] = useState<string>('') - const autocompleteView = useMemo<UserAutocompleteViewModel>( + const {track} = useAnalytics() + const scrollElRef = React.useRef<ScrollView>(null) + const onMainScroll = useOnMainScroll(store) + const textInput = React.useRef<TextInput>(null) + const [lastRenderTime, setRenderTime] = React.useState<number>(Date.now()) // used to trigger reloads + const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false) + const [query, setQuery] = React.useState<string>('') + const autocompleteView = React.useMemo<UserAutocompleteViewModel>( () => new UserAutocompleteViewModel(store), [store], ) const {name} = params - useEffect(() => { + const onSoftReset = () => { + scrollElRef.current?.scrollTo({x: 0, y: 0}) + } + + React.useEffect(() => { + const softResetSub = store.onScreenSoftReset(onSoftReset) + const cleanup = () => { + softResetSub.remove() + } + if (visible) { + const now = Date.now() + if (now - lastRenderTime > FIVE_MIN) { + setRenderTime(Date.now()) // trigger reload of suggestions + } store.shell.setMinimalShellMode(false) autocompleteView.setup() store.nav.setTitle(navIdx, 'Search') } - }, [store, visible, name, navIdx, autocompleteView]) + return cleanup + }, [store, visible, name, navIdx, autocompleteView, lastRenderTime]) + + const onPressMenu = () => { + track('ViewHeader:MenuButtonClicked') + store.shell.setMainMenuOpen(true) + } const onChangeQuery = (text: string) => { setQuery(text) @@ -46,87 +78,139 @@ export const Search = ({navIdx, visible, params}: ScreenParams) => { autocompleteView.setActive(false) } } - const onSelect = (handle: string) => { - textInput.current?.blur() - store.nav.navigate(`/profile/${handle}`) + const onPressCancelSearch = () => { + setQuery('') + autocompleteView.setActive(false) } return ( - <View style={[pal.view, styles.container]}> - <ViewHeader title="Search" /> - <CenteredView style={[pal.view, pal.border, styles.inputContainer]}> - <MagnifyingGlassIcon style={[pal.text, styles.inputIcon]} /> - <TextInput - testID="searchTextInput" - ref={textInput} - placeholder="Type your query here..." - placeholderTextColor={pal.colors.textLight} - selectTextOnFocus - returnKeyType="search" - style={[pal.text, styles.input]} - onChangeText={onChangeQuery} - /> - </CenteredView> - <View style={styles.outputContainer}> - {query ? ( - <ScrollView testID="searchScrollView" onScroll={Keyboard.dismiss}> - {autocompleteView.searchRes.map((item, i) => ( - <TouchableOpacity - key={i} - style={[pal.view, pal.border, styles.searchResult]} - onPress={() => onSelect(item.handle)}> - <UserAvatar - handle={item.handle} - displayName={item.displayName} - avatar={item.avatar} - size={36} - /> - <View style={[s.ml10]}> - <Text type="title-sm" style={pal.text}> - {item.displayName || item.handle} - </Text> - <Text style={pal.textLight}>@{item.handle}</Text> - </View> + <TouchableWithoutFeedback onPress={Keyboard.dismiss}> + <ScrollView + ref={scrollElRef} + testID="searchScrollView" + style={[pal.view, styles.container]} + onScroll={onMainScroll} + scrollEventThrottle={100}> + <View style={[pal.view, pal.border, styles.header]}> + <TouchableOpacity + testID="viewHeaderBackOrMenuBtn" + onPress={onPressMenu} + hitSlop={MENU_HITSLOP} + style={styles.headerMenuBtn}> + <UserAvatar + size={30} + handle={store.me.handle} + displayName={store.me.displayName} + avatar={store.me.avatar} + /> + </TouchableOpacity> + <View + style={[ + {backgroundColor: pal.colors.backgroundLight}, + styles.headerSearchContainer, + ]}> + <MagnifyingGlassIcon + style={[pal.icon, styles.headerSearchIcon]} + size={21} + /> + <TextInput + testID="searchTextInput" + ref={textInput} + placeholder="Search" + placeholderTextColor={pal.colors.textLight} + selectTextOnFocus + returnKeyType="search" + value={query} + style={[pal.text, styles.headerSearchInput]} + onFocus={() => setIsInputFocused(true)} + onBlur={() => setIsInputFocused(false)} + onChangeText={onChangeQuery} + /> + </View> + {query ? ( + <View style={styles.headerCancelBtn}> + <TouchableOpacity onPress={onPressCancelSearch}> + <Text>Cancel</Text> </TouchableOpacity> + </View> + ) : undefined} + </View> + {query && autocompleteView.searchRes.length ? ( + <> + {autocompleteView.searchRes.map(item => ( + <ProfileCard + key={item.did} + handle={item.handle} + displayName={item.displayName} + avatar={item.avatar} + /> ))} + </> + ) : query && !autocompleteView.searchRes.length ? ( + <View> + <Text style={[pal.textLight, styles.searchPrompt]}> + No results found for {autocompleteView.prefix} + </Text> + </View> + ) : isInputFocused ? ( + <View> + <Text style={[pal.textLight, styles.searchPrompt]}> + Search for users on the network + </Text> + </View> + ) : ( + <ScrollView onScroll={Keyboard.dismiss}> + <WhoToFollow key={`wtf-${lastRenderTime}`} /> + <SuggestedPosts key={`sp-${lastRenderTime}`} /> <View style={s.footerSpacer} /> </ScrollView> - ) : ( - <SuggestedFollows asLinks /> )} - </View> - </View> + <View style={s.footerSpacer} /> + </ScrollView> + </TouchableWithoutFeedback> ) -} +}) const styles = StyleSheet.create({ container: { flex: 1, }, - inputContainer: { + header: { flexDirection: 'row', - paddingVertical: 16, - paddingHorizontal: 16, - borderTopWidth: 1, + alignItems: 'center', + paddingHorizontal: 12, + paddingTop: 4, + marginBottom: 14, }, - inputIcon: { - marginRight: 10, - alignSelf: 'center', + headerMenuBtn: { + width: 40, + height: 30, + marginLeft: 6, }, - input: { + headerSearchContainer: { flex: 1, - fontSize: 16, + flexDirection: 'row', + alignItems: 'center', + borderRadius: 30, + paddingHorizontal: 12, + paddingVertical: 8, }, - - outputContainer: { + headerSearchIcon: { + marginRight: 6, + alignSelf: 'center', + }, + headerSearchInput: { flex: 1, + fontSize: 17, + }, + headerCancelBtn: { + width: 60, + paddingLeft: 10, }, - searchResult: { - flexDirection: 'row', - borderTopWidth: 1, - paddingVertical: 12, - paddingHorizontal: 16, + searchPrompt: { + textAlign: 'center', + paddingTop: 10, }, }) diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index c2953b59d..94f5acd93 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -7,17 +7,20 @@ import { } from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {observer} from 'mobx-react-lite' -import {useStores} from '../../state' +import * as AppInfo from 'lib/app-info' +import {useStores} from 'state/index' import {ScreenParams} from '../routes' -import {s} from '../lib/styles' +import {s} from 'lib/styles' import {ScrollView} from '../com/util/Views' import {ViewHeader} from '../com/util/ViewHeader' import {Link} from '../com/util/Link' import {Text} from '../com/util/text/Text' import * as Toast from '../com/util/Toast' import {UserAvatar} from '../com/util/UserAvatar' -import {usePalette} from '../lib/hooks/usePalette' -import {AccountData} from '../../state/models/session' +import {usePalette} from 'lib/hooks/usePalette' +import {AccountData} from 'state/models/session' +import {useAnalytics} from 'lib/analytics' +import {DeleteAccountModal} from 'state/models/shell-ui' export const Settings = observer(function Settings({ navIdx, @@ -25,9 +28,14 @@ export const Settings = observer(function Settings({ }: ScreenParams) { const pal = usePalette('default') const store = useStores() + const {screen, track} = useAnalytics() const [isSwitching, setIsSwitching] = React.useState(false) useEffect(() => { + screen('Settings') + }, [screen]) + + useEffect(() => { if (!visible) { return } @@ -36,22 +44,30 @@ export const Settings = observer(function Settings({ }, [visible, store, navIdx]) const onPressSwitchAccount = async (acct: AccountData) => { + track('Settings:SwitchAccountButtonClicked') setIsSwitching(true) if (await store.session.resumeSession(acct)) { setIsSwitching(false) + store.nav.tab.fixedTabReset() Toast.show(`Signed in as ${acct.displayName || acct.handle}`) return } setIsSwitching(false) Toast.show('Sorry! We need you to enter your password.') + store.nav.tab.fixedTabReset() store.session.clear() } const onPressAddAccount = () => { + track('Settings:AddAccountButtonClicked') store.session.clear() } const onPressSignout = () => { + track('Settings:SignOutButtonClicked') store.session.logout() } + const onPressDeleteAccount = () => { + store.shell.openModal(new DeleteAccountModal()) + } return ( <View style={[s.h100pct]} testID="settingsScreen"> @@ -143,22 +159,34 @@ export const Settings = observer(function Settings({ </Text> </View> </TouchableOpacity> + <View style={styles.spacer} /> <Text type="sm-medium" style={[s.mb5]}> + Danger zone + </Text> + <TouchableOpacity + style={[pal.view, s.p10, s.mb10]} + onPress={onPressDeleteAccount}> + <Text style={pal.textLight}>Delete my account</Text> + </TouchableOpacity> + <Text type="sm-medium" style={[s.mt10, s.mb5]}> Developer tools </Text> <Link style={[pal.view, s.p10, s.mb2]} href="/sys/log" title="System log"> - <Text style={pal.link}>System log</Text> + <Text style={pal.textLight}>System log</Text> </Link> <Link style={[pal.view, s.p10, s.mb2]} href="/sys/debug" title="Debug tools"> - <Text style={pal.link}>Storybook</Text> + <Text style={pal.textLight}>Storybook</Text> </Link> + <Text type="sm" style={[s.mt10, pal.textLight]}> + Build version {AppInfo.appVersion} ({AppInfo.buildVersion}) + </Text> <View style={s.footerSpacer} /> </View> </ScrollView> diff --git a/src/view/shell/mobile/Composer.tsx b/src/view/shell/mobile/Composer.tsx index c93931ab4..304c17725 100644 --- a/src/view/shell/mobile/Composer.tsx +++ b/src/view/shell/mobile/Composer.tsx @@ -2,9 +2,9 @@ import React, {useEffect} from 'react' import {observer} from 'mobx-react-lite' import {Animated, Easing, Platform, StyleSheet, View} from 'react-native' import {ComposePost} from '../../com/composer/ComposePost' -import {ComposerOpts} from '../../../state/models/shell-ui' -import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue' -import {usePalette} from '../../lib/hooks/usePalette' +import {ComposerOpts} from 'state/models/shell-ui' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import {usePalette} from 'lib/hooks/usePalette' export const Composer = observer( ({ diff --git a/src/view/shell/mobile/Menu.tsx b/src/view/shell/mobile/Menu.tsx index a7d3e2142..ceeda8c58 100644 --- a/src/view/shell/mobile/Menu.tsx +++ b/src/view/shell/mobile/Menu.tsx @@ -1,6 +1,6 @@ import React from 'react' import { - ScrollView, + Linking, StyleProp, StyleSheet, TouchableOpacity, @@ -8,40 +8,56 @@ import { ViewStyle, } from 'react-native' import {observer} from 'mobx-react-lite' -import VersionNumber from 'react-native-version-number' -import {s, colors} from '../../lib/styles' -import {useStores} from '../../../state' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {s, colors} from 'lib/styles' +import {FEEDBACK_FORM_URL} from 'lib/constants' +import {useStores} from 'state/index' import { HomeIcon, BellIcon, UserIcon, CogIcon, MagnifyingGlassIcon, -} from '../../lib/icons' +} from 'lib/icons' +import {TabPurpose, TabPurposeMainPath} from 'state/models/navigation' import {UserAvatar} from '../../com/util/UserAvatar' import {Text} from '../../com/util/text/Text' import {ToggleButton} from '../../com/util/forms/ToggleButton' -import {usePalette} from '../../lib/hooks/usePalette' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnalytics} from 'lib/analytics' export const Menu = observer(({onClose}: {onClose: () => void}) => { const pal = usePalette('default') const store = useStores() + const {track} = useAnalytics() // events // = const onNavigate = (url: string) => { + track('Menu:ItemClicked', {url}) + onClose() - if (url === '/notifications') { - store.nav.switchTo(1, true) + if (url === TabPurposeMainPath[TabPurpose.Notifs]) { + store.nav.switchTo(TabPurpose.Notifs, true) + } else if (url === TabPurposeMainPath[TabPurpose.Search]) { + store.nav.switchTo(TabPurpose.Search, true) } else { - store.nav.switchTo(0, true) + store.nav.switchTo(TabPurpose.Default, true) if (url !== '/') { store.nav.navigate(url) } } } + const onPressFeedback = () => { + track('Menu:FeedbackClicked') + Linking.openURL(FEEDBACK_FORM_URL) + } + // rendering // = @@ -84,8 +100,19 @@ export const Menu = observer(({onClose}: {onClose: () => void}) => { </TouchableOpacity> ) + const onDarkmodePress = () => { + track('Menu:ItemClicked', {url: '/darkmode'}) + store.shell.setDarkMode(!store.shell.darkMode) + } + return ( - <ScrollView testID="menuView" style={[styles.view, pal.view]}> + <View + testID="menuView" + style={[ + styles.view, + pal.view, + store.shell.minimalShellMode && styles.viewMinimalShell, + ]}> <TouchableOpacity testID="profileCardButton" onPress={() => onNavigate(`/profile/${store.me.handle}`)} @@ -132,7 +159,7 @@ export const Menu = observer(({onClose}: {onClose: () => void}) => { icon={<BellIcon style={pal.text as StyleProp<ViewStyle>} size="28" />} label="Notifications" url="/notifications" - count={store.me.notificationCount} + count={store.me.notifications.unreadCount} /> <MenuItem icon={ @@ -161,23 +188,34 @@ export const Menu = observer(({onClose}: {onClose: () => void}) => { <ToggleButton label="Dark mode" isSelected={store.shell.darkMode} - onPress={() => store.shell.setDarkMode(!store.shell.darkMode)} + onPress={onDarkmodePress} /> </View> + <View style={s.flex1} /> <View style={styles.footer}> - <Text style={[pal.textLight]}> - Build version {VersionNumber.appVersion} ({VersionNumber.buildVersion} - ) - </Text> + <MenuItem + icon={ + <FontAwesomeIcon + style={pal.text as FontAwesomeIconStyle} + size={24} + icon={['far', 'message']} + /> + } + label="Feedback" + onPress={onPressFeedback} + /> </View> - <View style={s.footerSpacer} /> - </ScrollView> + </View> ) }) const styles = StyleSheet.create({ view: { flex: 1, + paddingBottom: 90, + }, + viewMinimalShell: { + paddingBottom: 50, }, section: { paddingHorizontal: 10, @@ -254,7 +292,6 @@ const styles = StyleSheet.create({ }, footer: { - paddingHorizontal: 14, - paddingVertical: 18, + paddingHorizontal: 10, }, }) diff --git a/src/view/shell/mobile/TabsSelector.tsx b/src/view/shell/mobile/TabsSelector.tsx index 921a0c85b..92854e3b6 100644 --- a/src/view/shell/mobile/TabsSelector.tsx +++ b/src/view/shell/mobile/TabsSelector.tsx @@ -12,11 +12,11 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Text} from '../../com/util/text/Text' import Swipeable from 'react-native-gesture-handler/Swipeable' -import {useStores} from '../../../state' -import {s, colors} from '../../lib/styles' -import {toShareUrl} from '../../../lib/strings' +import {useStores} from 'state/index' +import {s, colors} from 'lib/styles' +import {toShareUrl} from 'lib/strings/url-helpers' import {match} from '../../routes' -import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' const TAB_HEIGHT = 42 diff --git a/src/view/shell/mobile/index.tsx b/src/view/shell/mobile/index.tsx index b0b83b12e..0b3921b7e 100644 --- a/src/view/shell/mobile/index.tsx +++ b/src/view/shell/mobile/index.tsx @@ -1,9 +1,8 @@ -import React, {useState, useEffect, useRef} from 'react' +import React, {useState, useEffect} from 'react' import {observer} from 'mobx-react-lite' import { Animated, Easing, - FlatList, GestureResponderEvent, StatusBar, StyleSheet, @@ -12,15 +11,18 @@ import { useColorScheme, useWindowDimensions, View, - ViewStyle, } from 'react-native' import {ScreenContainer, Screen} from 'react-native-screens' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {IconProp} from '@fortawesome/fontawesome-svg-core' -import {TABS_ENABLED} from '../../../build-flags' -import {useStores} from '../../../state' -import {NavigationModel} from '../../../state/models/navigation' +import {TABS_ENABLED} from 'lib/build-flags' +import {useStores} from 'state/index' +import { + NavigationModel, + TabPurpose, + TabPurposeMainPath, +} from 'state/models/navigation' import {match, MatchResult} from '../../routes' import {Login} from '../../screens/Login' import {Menu} from './Menu' @@ -32,19 +34,21 @@ import {Text} from '../../com/util/text/Text' import {ErrorBoundary} from '../../com/util/ErrorBoundary' import {TabsSelector} from './TabsSelector' import {Composer} from './Composer' -import {s, colors} from '../../lib/styles' -import {clamp} from '../../../lib/numbers' +import {s, colors} from 'lib/styles' +import {clamp} from 'lib/numbers' import { GridIcon, GridIconSolid, HomeIcon, HomeIconSolid, + MagnifyingGlassIcon, BellIcon, BellIconSolid, -} from '../../lib/icons' -import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue' -import {useTheme} from '../../lib/ThemeContext' -import {usePalette} from '../../lib/hooks/usePalette' +} from 'lib/icons' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import {useTheme} from 'lib/ThemeContext' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnalytics} from 'lib/analytics' const Btn = ({ icon, @@ -59,6 +63,8 @@ const Btn = ({ | 'menu-solid' | 'home' | 'home-solid' + | 'search' + | 'search-solid' | 'bell' | 'bell-solid' notificationCount?: number @@ -76,24 +82,40 @@ const Btn = ({ iconEl = <HomeIcon size={27} style={[styles.ctrlIcon, pal.text]} /> } else if (icon === 'home-solid') { iconEl = <HomeIconSolid size={27} style={[styles.ctrlIcon, pal.text]} /> + } else if (icon === 'search') { + iconEl = ( + <MagnifyingGlassIcon + size={28} + style={[styles.ctrlIcon, pal.text, styles.bumpUpOnePixel]} + /> + ) + } else if (icon === 'search-solid') { + iconEl = ( + <MagnifyingGlassIcon + size={28} + strokeWidth={3} + style={[styles.ctrlIcon, pal.text, styles.bumpUpOnePixel]} + /> + ) } else if (icon === 'bell') { - const addedStyles = {position: 'relative', top: -1} as ViewStyle iconEl = ( - <BellIcon size={27} style={[styles.ctrlIcon, pal.text, addedStyles]} /> + <BellIcon + size={27} + style={[styles.ctrlIcon, pal.text, styles.bumpUpOnePixel]} + /> ) } else if (icon === 'bell-solid') { - const addedStyles = {position: 'relative', top: -1} as ViewStyle iconEl = ( <BellIconSolid size={27} - style={[styles.ctrlIcon, pal.text, addedStyles]} + style={[styles.ctrlIcon, pal.text, styles.bumpUpOnePixel]} /> ) } else { iconEl = ( <FontAwesomeIcon - size={24} icon={icon} + size={24} style={[styles.ctrlIcon, pal.text]} /> ) @@ -125,7 +147,6 @@ export const MobileShell: React.FC = observer(() => { const pal = usePalette('default') const store = useStores() const [isTabsSelectorActive, setTabsSelectorActive] = useState(false) - const scrollElRef = useRef<FlatList>(null) const winDim = useWindowDimensions() const [menuSwipingDirection, setMenuSwipingDirection] = useState(0) const swipeGestureInterp = useAnimatedValue(0) @@ -136,32 +157,48 @@ export const MobileShell: React.FC = observer(() => { const colorScheme = useColorScheme() const safeAreaInsets = useSafeAreaInsets() const screenRenderDesc = constructScreenRenderDesc(store.nav) + const {track} = useAnalytics() const onPressHome = () => { - if (store.shell.isMainMenuOpen) { - store.shell.setMainMenuOpen(false) + track('MobileShell:HomeButtonPressed') + if (store.nav.tab.fixedTabPurpose === TabPurpose.Default) { + if (!store.nav.tab.canGoBack) { + store.emitScreenSoftReset() + } else { + store.nav.tab.fixedTabReset() + } + } else { + store.nav.switchTo(TabPurpose.Default, false) + if (store.nav.tab.index === 0) { + store.nav.tab.fixedTabReset() + } } - if (store.nav.tab.fixedTabPurpose === 0) { - if (store.nav.tab.current.url === '/') { - scrollElRef.current?.scrollToOffset({offset: 0}) + } + const onPressSearch = () => { + track('MobileShell:SearchButtonPressed') + if (store.nav.tab.fixedTabPurpose === TabPurpose.Search) { + if (!store.nav.tab.canGoBack) { + store.emitScreenSoftReset() } else { store.nav.tab.fixedTabReset() } } else { - store.nav.switchTo(0, false) + store.nav.switchTo(TabPurpose.Search, false) if (store.nav.tab.index === 0) { store.nav.tab.fixedTabReset() } } } const onPressNotifications = () => { - if (store.shell.isMainMenuOpen) { - store.shell.setMainMenuOpen(false) - } - if (store.nav.tab.fixedTabPurpose === 1) { - store.nav.tab.fixedTabReset() + track('MobileShell:NotificationsButtonPressed') + if (store.nav.tab.fixedTabPurpose === TabPurpose.Notifs) { + if (!store.nav.tab.canGoBack) { + store.emitScreenSoftReset() + } else { + store.nav.tab.fixedTabReset() + } } else { - store.nav.switchTo(1, false) + store.nav.switchTo(TabPurpose.Notifs, false) if (store.nav.tab.index === 0) { store.nav.tab.fixedTabReset() } @@ -178,12 +215,14 @@ export const MobileShell: React.FC = observer(() => { toValue: 1, duration: 100, useNativeDriver: true, + isInteraction: false, }).start() } else { Animated.timing(minimalShellInterp, { toValue: 0, duration: 100, useNativeDriver: true, + isInteraction: false, }).start() } }, [minimalShellInterp, store.shell.minimalShellMode]) @@ -343,8 +382,12 @@ export const MobileShell: React.FC = observer(() => { ) } - const isAtHome = store.nav.tab.current.url === '/' - const isAtNotifications = store.nav.tab.current.url === '/notifications' + const isAtHome = + store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Default] + const isAtSearch = + store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Search] + const isAtNotifications = + store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Notifs] const screenBg = { backgroundColor: theme.colorScheme === 'dark' ? colors.gray7 : colors.gray1, @@ -358,7 +401,7 @@ export const MobileShell: React.FC = observer(() => { /> <View style={[styles.innerContainer, {paddingTop: safeAreaInsets.top}]}> <HorzSwipe - distThresholdDivisor={1.5} + distThresholdDivisor={2.5} useNativeDriver panX={swipeGestureInterp} swipeEnabled @@ -405,7 +448,6 @@ export const MobileShell: React.FC = observer(() => { params={params} navIdx={navIdx} visible={current} - scrollElRef={current ? scrollElRef : undefined} /> </ErrorBoundary> </Animated.View> @@ -454,6 +496,11 @@ export const MobileShell: React.FC = observer(() => { onPress={onPressHome} onLongPress={TABS_ENABLED ? doNewTab('/') : undefined} /> + <Btn + icon={isAtSearch ? 'search-solid' : 'search'} + onPress={onPressSearch} + onLongPress={TABS_ENABLED ? doNewTab('/') : undefined} + /> {TABS_ENABLED ? ( <Btn icon={isTabsSelectorActive ? 'clone' : ['far', 'clone']} @@ -465,7 +512,7 @@ export const MobileShell: React.FC = observer(() => { icon={isAtNotifications ? 'bell-solid' : 'bell'} onPress={onPressNotifications} onLongPress={TABS_ENABLED ? doNewTab('/notifications') : undefined} - notificationCount={store.me.notificationCount} + notificationCount={store.me.notifications.unreadCount} /> </Animated.View> <Modal /> @@ -576,7 +623,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', borderTopWidth: 1, paddingLeft: 5, - paddingRight: 15, + paddingRight: 25, }, ctrl: { flex: 1, @@ -614,4 +661,8 @@ const styles = StyleSheet.create({ inactive: { color: colors.gray3, }, + bumpUpOnePixel: { + position: 'relative', + top: -1, + }, }) diff --git a/src/view/shell/web/Composer.tsx b/src/view/shell/web/Composer.tsx index 639040097..5006b3124 100644 --- a/src/view/shell/web/Composer.tsx +++ b/src/view/shell/web/Composer.tsx @@ -2,8 +2,8 @@ import React from 'react' import {observer} from 'mobx-react-lite' import {StyleSheet, View} from 'react-native' import {ComposePost} from '../../com/composer/ComposePost' -import {ComposerOpts} from '../../../state/models/shell-ui' -import {usePalette} from '../../lib/hooks/usePalette' +import {ComposerOpts} from 'state/models/shell-ui' +import {usePalette} from 'lib/hooks/usePalette' export const Composer = observer( ({ diff --git a/src/view/shell/web/DesktopLeftColumn.tsx b/src/view/shell/web/DesktopLeftColumn.tsx index 44559b6ad..d5fe45e80 100644 --- a/src/view/shell/web/DesktopLeftColumn.tsx +++ b/src/view/shell/web/DesktopLeftColumn.tsx @@ -5,9 +5,9 @@ import LinearGradient from 'react-native-linear-gradient' import {Link} from '../../com/util/Link' import {Text} from '../../com/util/text/Text' import {UserAvatar} from '../../com/util/UserAvatar' -import {s, colors, gradients} from '../../lib/styles' -import {useStores} from '../../../state' -import {usePalette} from '../../lib/hooks/usePalette' +import {s, colors, gradients} from 'lib/styles' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' import { HomeIcon, HomeIconSolid, @@ -15,7 +15,7 @@ import { BellIconSolid, MagnifyingGlassIcon, CogIcon, -} from '../../lib/icons' +} from 'lib/icons' interface NavItemProps { label: string @@ -97,7 +97,7 @@ export const DesktopLeftColumn = observer(() => { <NavItem href="/notifications" label="Notifications" - count={store.me.notificationCount} + count={store.me.notifications.unreadCount} icon={<BellIcon />} iconFilled={<BellIconSolid />} /> diff --git a/src/view/shell/web/DesktopRightColumn.tsx b/src/view/shell/web/DesktopRightColumn.tsx index 2daa16f6a..22acac382 100644 --- a/src/view/shell/web/DesktopRightColumn.tsx +++ b/src/view/shell/web/DesktopRightColumn.tsx @@ -2,10 +2,10 @@ import React from 'react' import {View, StyleSheet} from 'react-native' import {Link} from '../../com/util/Link' import {Text} from '../../com/util/text/Text' -import {usePalette} from '../../lib/hooks/usePalette' -import {MagnifyingGlassIcon} from '../../lib/icons' +import {usePalette} from 'lib/hooks/usePalette' +import {MagnifyingGlassIcon} from 'lib/icons' import {LiteSuggestedFollows} from '../../com/discover/LiteSuggestedFollows' -import {s} from '../../lib/styles' +import {s} from 'lib/styles' export const DesktopRightColumn: React.FC = () => { const pal = usePalette('default') diff --git a/src/view/shell/web/index.tsx b/src/view/shell/web/index.tsx index 0eb5cf75c..7d76c01d8 100644 --- a/src/view/shell/web/index.tsx +++ b/src/view/shell/web/index.tsx @@ -1,7 +1,9 @@ import React from 'react' import {observer} from 'mobx-react-lite' import {View, StyleSheet} from 'react-native' -import {useStores} from '../../../state' +import {IconProp} from '@fortawesome/fontawesome-svg-core' +import {useStores} from 'state/index' +import {NavigationModel} from 'state/models/navigation' import {match, MatchResult} from '../../routes' import {DesktopLeftColumn} from './DesktopLeftColumn' import {DesktopRightColumn} from './DesktopRightColumn' @@ -11,8 +13,8 @@ import {ErrorBoundary} from '../../com/util/ErrorBoundary' import {Lightbox} from '../../com/lightbox/Lightbox' import {Modal} from '../../com/modals/Modal' import {Composer} from './Composer' -import {usePalette} from '../../lib/hooks/usePalette' -import {s} from '../../lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {s} from 'lib/styles' export const WebShell: React.FC = observer(() => { const pal = usePalette('default') |