diff --git a/Gemfile.lock b/Gemfile.lock index ad8060fe..09bf78cf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -86,6 +86,7 @@ GEM xcpretty (~> 0.3.0) xcpretty-travis-formatter (>= 0.0.3) fastlane-plugin-json (1.0.0) + fastlane-plugin-sentry (1.8.0) fastlane-plugin-versioning_android (0.1.0) fastlane-plugin-yarn (1.2) gh_inspector (1.1.3) @@ -200,6 +201,7 @@ PLATFORMS DEPENDENCIES fastlane fastlane-plugin-json + fastlane-plugin-sentry fastlane-plugin-versioning_android fastlane-plugin-yarn diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 015e237d..fb65638b 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -2,7 +2,7 @@ fastlane_version "2.173.0" skip_docs ensure_env_vars( - env_vars: ["TOOOT_ENVIRONMENT"] + env_vars: ["TOOOT_ENVIRONMENT", "SENTRY_ORGANIZATION", "SENTRY_PROJECT", "SENTRY_AUTH_TOKEN"] ) VERSIONS = read_json( json_path: "./package.json" )[:versions] @@ -15,7 +15,7 @@ case ENVIRONMENT when "development" GITHUB_RELEASE= "" when "staging" - GITHUB_RELEASE = "v#{VERSION}-rc#{VERSIONS[:patch]}" + GITHUB_RELEASE = "v#{VERSION}-#{VERSIONS[:patch]}" when "production" GITHUB_RELEASE = "v#{VERSION}" end @@ -37,6 +37,15 @@ private_lane :update_expo_ios do set_info_plist_value( path: EXPO_PLIST, key: "EXUpdatesReleaseChannel", value: RELEASE_CHANNEL ) end +desc 'IOS: Upload dSYM' +lane :upload_symbols_ios do + download_dsyms + sentry_upload_dsym( + org_slug: ENV["SENTRY_ORGANIZATION"], + project_slug: ENV["SENTRY_PROJECT"] + ) +end + desc "ANDROID: Prepare play store" private_lane :prepare_playstore_android do android_set_version_name( version_name: VERSION, gradle_file: "./android/app/build.gradle" ) @@ -77,13 +86,14 @@ private_lane :build_ios do when "staging" prepare_appstore_ios match( type: "appstore", readonly: true ) - build_ios_app( export_method: "app-store" ) + build_ios_app( export_method: "app-store", include_symbols: true, include_bitcode: true ) upload_to_testflight( demo_account_required: true, distribute_external: true, groups: "内测用户", changelog: "Ready for testing" ) + upload_symbols_ios when "production" prepare_appstore_ios match( type: "appstore", readonly: true ) diff --git a/fastlane/Pluginfile b/fastlane/Pluginfile index 77d7a7ef..55633271 100644 --- a/fastlane/Pluginfile +++ b/fastlane/Pluginfile @@ -5,3 +5,4 @@ gem 'fastlane-plugin-yarn' gem 'fastlane-plugin-json' gem 'fastlane-plugin-versioning_android' +gem 'fastlane-plugin-sentry' diff --git a/package.json b/package.json index 9c0f4969..3474f035 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "react-native-tab-view-viewpager-adapter": "^1.1.0", "react-native-toast-message": "^1.4.3", "react-native-unimodules": "~0.12.0", - "react-query": "^3.8.2", + "react-query": "^3.9.7", "react-redux": "^7.2.2", "react-timeago": "^5.2.0", "reconnecting-websocket": "^4.4.0", diff --git a/src/components/Instance.tsx b/src/components/Instance.tsx index db1390dc..58da03e5 100644 --- a/src/components/Instance.tsx +++ b/src/components/Instance.tsx @@ -162,12 +162,12 @@ const ComponentInstance: React.FC = ({ type='text' content={t('server.button.local')} onPress={processUpdate} - disabled={!instanceQuery.data?.uri || !agreed} + disabled={!instanceQuery.data?.uri} loading={instanceQuery.isFetching || appsQuery.isFetching} /> - + {/* */} = ({ ...queryKeyParams, options: { getPreviousPageParam: firstPage => - firstPage.links?.prev && { + firstPage?.links?.prev && { min_id: firstPage.links.prev, // https://github.com/facebook/react-native/issues/25239#issuecomment-731100372 - limit: '6' + limit: '5' }, getNextPageParam: lastPage => - lastPage.links?.next && { max_id: lastPage.links.next } + lastPage?.links?.next && { max_id: lastPage.links.next } } }) const flattenData = data?.pages ? data.pages.flatMap(d => [...d.body]) : [] + // Auto go back when toot page is empty + const navigation = useNavigation() + useEffect(() => { + if (toot && isSuccess && flattenData.length === 0) { + navigation.goBack() + } + }, [isSuccess, flattenData.length]) // Toot page auto scroll to selected toot const flRef = useRef>(null) const scrolled = useRef(false) @@ -119,13 +126,6 @@ const Timeline: React.FC = ({ 350 ) }, []) - // Auto go back when toot page is empty - const navigation = useNavigation() - useEffect(() => { - if (toot && isSuccess && flattenData.length === 0) { - navigation.goBack() - } - }, [isSuccess, flattenData.length]) const keyExtractor = useCallback(({ id }) => id, []) const renderItem = useCallback( @@ -233,26 +233,57 @@ const Timeline: React.FC = ({ scrollY.value = nativeEvent.contentOffset.y }, []) const onResponderRelease = useCallback(() => { - if ( - scrollY.value <= -StyleConstants.Spacing.XL && - isFetchingLatest === 0 && - !disableRefresh - ) { - haptics('Light') - setIsFetchingLatest(1) - flRef.current?.scrollToOffset({ - animated: true, - offset: 1 - }) + if (!disableRefresh) { + const separation01 = -( + (StyleConstants.Spacing.M * 2.5) / 2 + + StyleConstants.Font.Size.S / 2 + ) + const separation02 = -( + StyleConstants.Spacing.M * 2.5 * 1.5 + + StyleConstants.Font.Size.S / 2 + ) + if ( + scrollY.value <= separation02 && + !isFetching && + isFetchingLatest === 0 + ) { + haptics('Light') + queryClient.setQueryData | undefined>( + queryKey, + data => { + if (data?.pages[0].body.length === 0) { + return { + pages: data.pages.slice(1), + pageParams: data.pageParams.slice(1) + } + } else { + return data + } + } + ) + refetch() + } else if ( + scrollY.value <= separation01 && + !isFetching && + isFetchingLatest === 0 + ) { + haptics('Light') + setIsFetchingLatest(1) + flRef.current?.scrollToOffset({ + animated: true, + offset: 1 + }) + } } - }, [scrollY.value, isFetchingLatest, disableRefresh]) + }, [scrollY.value, isFetching, isFetchingLatest, disableRefresh]) const headerPadding = useAnimatedStyle(() => { - if (isFetchingLatest !== 0) { - return { paddingTop: withTiming(StyleConstants.Spacing.XL) } - } else { - return { paddingTop: withTiming(0) } + return { + paddingTop: + isFetchingLatest !== 0 || (isFetching && !isLoading) + ? withTiming(StyleConstants.Spacing.M * 2.5) + : withTiming(0) } - }, [isFetchingLatest]) + }, [isFetchingLatest, isFetching, isLoading]) const ListHeaderComponent = useMemo( () => , [] @@ -267,7 +298,7 @@ const Timeline: React.FC = ({ colors={[theme.primary]} progressBackgroundColor={theme.background} refreshing={isFetching || isLoading} - // onRefresh={() => refetch()} + onRefresh={() => refetch()} /> ) }, @@ -276,8 +307,14 @@ const Timeline: React.FC = ({ return ( <> - + = ({ } ]} onPress={onPress} - disabled={queryKey && queryKey[1].toot !== undefined} > {item.reblog ? ( @@ -143,11 +143,13 @@ const TimelineDefault: React.FC = ({ queryKey={queryKey} rootQueryKey={rootQueryKey} status={actualStatus} - accts={([actualStatus.account] as Mastodon.Account[] & - Mastodon.Mention[]) - .concat(actualStatus.mentions) - .filter(d => d.id !== localAccount?.id) - .map(d => d.acct)} + accts={uniqBy( + ([actualStatus.account] as Mastodon.Account[] & + Mastodon.Mention[]) + .concat(actualStatus.mentions) + .filter(d => d.id !== localAccount?.id), + d => d.id + ).map(d => d.acct)} reblog={item.reblog ? true : false} /> diff --git a/src/components/Timeline/Notifications.tsx b/src/components/Timeline/Notifications.tsx index 5e9ef07d..e5139e4e 100644 --- a/src/components/Timeline/Notifications.tsx +++ b/src/components/Timeline/Notifications.tsx @@ -13,6 +13,7 @@ import { QueryKeyTimeline } from '@utils/queryHooks/timeline' import { getLocalAccount } from '@utils/slices/instancesSlice' import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' +import { uniqBy } from 'lodash' import React, { useCallback } from 'react' import { Pressable, StyleSheet, View } from 'react-native' import { useSelector } from 'react-redux' @@ -126,11 +127,13 @@ const TimelineNotifications: React.FC = ({ d.id !== localAccount?.id) - .map(d => d.acct)} + accts={uniqBy( + ([notification.status.account] as Mastodon.Account[] & + Mastodon.Mention[]) + .concat(notification.status.mentions) + .filter(d => d.id !== localAccount?.id), + d => d.id + ).map(d => d.acct)} reblog={false} /> diff --git a/src/components/Timeline/Refresh.tsx b/src/components/Timeline/Refresh.tsx index 9ee66337..f108b4bd 100644 --- a/src/components/Timeline/Refresh.tsx +++ b/src/components/Timeline/Refresh.tsx @@ -1,25 +1,164 @@ +import haptics from '@components/haptics' +import Icon from '@components/Icon' import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' -import React from 'react' -import { StyleSheet, View } from 'react-native' +import React, { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { StyleSheet, Text, View } from 'react-native' import { Circle } from 'react-native-animated-spinkit' +import Animated, { + Extrapolate, + interpolate, + runOnJS, + useAnimatedReaction, + useAnimatedStyle, + useSharedValue, + withTiming +} from 'react-native-reanimated' export interface Props { + scrollY: Animated.SharedValue isLoading: boolean + isFetching: boolean disable?: boolean } -const TimelineRefresh: React.FC = ({ isLoading, disable = false }) => { - const { theme } = useTheme() - return !isLoading && !disable ? ( - - } - /> - ) : null -} +const CONTAINER_HEIGHT = StyleConstants.Spacing.M * 2.5 + +const TimelineRefresh = React.memo( + ({ scrollY, isLoading, isFetching, disable = false }: Props) => { + if (disable || isLoading) { + return null + } + + const { t } = useTranslation('componentTimeline') + const { theme } = useTheme() + + const separation01 = -( + CONTAINER_HEIGHT / 2 + + StyleConstants.Font.Size.S / 2 + ) + const separation02 = -( + CONTAINER_HEIGHT * 1.5 + + StyleConstants.Font.Size.S / 2 + ) + const [textRight, setTextRight] = useState(0) + const arrowY = useAnimatedStyle(() => ({ + transform: [ + { + translateY: interpolate( + scrollY.value, + [0, separation01], + [ + -CONTAINER_HEIGHT / 2 - StyleConstants.Font.Size.M / 2, + CONTAINER_HEIGHT / 2 - StyleConstants.Font.Size.S / 2 + ], + Extrapolate.CLAMP + ) + } + ] + })) + const arrowTop = useAnimatedStyle(() => ({ + marginTop: + scrollY.value < separation02 + ? withTiming(CONTAINER_HEIGHT) + : withTiming(0) + })) + + const arrowStage = useSharedValue(0) + const onLayout = useCallback( + ({ nativeEvent }) => { + if (nativeEvent.layout.x + nativeEvent.layout.width > textRight) { + setTextRight(nativeEvent.layout.x + nativeEvent.layout.width) + } + }, + [textRight] + ) + useAnimatedReaction( + () => { + if (isFetching) { + return false + } + switch (arrowStage.value) { + case 0: + if (scrollY.value < separation01) { + arrowStage.value = 1 + return true + } + return false + case 1: + if (scrollY.value < separation02) { + arrowStage.value = 2 + return true + } + if (scrollY.value > separation01) { + arrowStage.value = 0 + return false + } + return false + case 2: + if (scrollY.value > separation02) { + arrowStage.value = 1 + return false + } + return false + } + }, + data => { + if (data) { + runOnJS(haptics)('Light') + } + }, + [isFetching] + ) + + return ( + + {isFetching ? ( + + + + ) : ( + <> + + + + } + /> + + + + + + )} + + ) + }, + (prev, next) => + prev.isLoading === next.isLoading && prev.isFetching === next.isFetching +) const styles = StyleSheet.create({ base: { @@ -27,9 +166,18 @@ const styles = StyleSheet.create({ top: 0, left: 0, right: 0, - height: StyleConstants.Spacing.XL, - justifyContent: 'center', + height: CONTAINER_HEIGHT * 2, alignItems: 'center' + }, + container1: { + flex: 1, + flexDirection: 'row', + height: CONTAINER_HEIGHT + }, + container2: { height: CONTAINER_HEIGHT, justifyContent: 'center' }, + explanation: { + fontSize: StyleConstants.Font.Size.S, + lineHeight: CONTAINER_HEIGHT } }) diff --git a/src/i18n/en/components/timeline.ts b/src/i18n/en/components/timeline.ts index b6a80cb7..663c4db5 100644 --- a/src/i18n/en/components/timeline.ts +++ b/src/i18n/en/components/timeline.ts @@ -11,10 +11,9 @@ export default { end: { message: 'The end, what about a cup of <0 />' }, - header: { - explanation: - 'External instance might not be known to logged in instance, thus actions are not allowed but only for reading. You can switch to any instance in the settings.', - button: 'Go to settings' + refresh: { + fetchPreviousPage: 'Refresh upwards', + refetch: 'Refresh all' }, shared: { actioned: { diff --git a/src/i18n/zh-Hans/components/timeline.ts b/src/i18n/zh-Hans/components/timeline.ts index a4fe9ee7..045b7157 100644 --- a/src/i18n/zh-Hans/components/timeline.ts +++ b/src/i18n/zh-Hans/components/timeline.ts @@ -11,10 +11,9 @@ export default { end: { message: '居然刷到底了,喝杯 <0 /> 吧' }, - header: { - explanation: - '围观的社区可能不属于已经登录的社区的已知连结,因此只可围观嘟文,不能进行操作。设置里可以切换想要围观的社区。', - button: '前往设置' + refresh: { + fetchPreviousPage: '向上刷新', + refetch: '完全刷新' }, shared: { actioned: { diff --git a/yarn.lock b/yarn.lock index 8903c129..fde883f2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8736,10 +8736,10 @@ react-navigation@*, react-navigation@^4.4.3: "@react-navigation/core" "^3.7.9" "@react-navigation/native" "^3.8.3" -react-query@^3.8.2: - version "3.8.2" - resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.8.2.tgz#e2dac76b5d9b3465d854f4ca040a35ba4bcae1ae" - integrity sha512-Ha8+WZLIHOUkKhqE4+2RZZB03WvEWmNhN4WIIw3zWVCtR7blo2n2TXtu+d3YhaY1o6dt+sHT+J+zNV8IzR583w== +react-query@^3.9.7: + version "3.9.7" + resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.9.7.tgz#324c697f418827c129c8c126d233c6052bb1e35e" + integrity sha512-vpQgRFOljd7Lr1wL8hOwxWzb7awLIjaeqaq6DJ1fzA8N9mK1fAkK+UVrt8WaXJGBfz7JEnfCiXuENQspk0N7Sw== dependencies: "@babel/runtime" "^7.5.5" match-sorter "^6.0.2"