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/com/util | |
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/com/util')
41 files changed, 902 insertions, 570 deletions
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 |