diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 43b60c42..089a1ea3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,7 +7,7 @@ on: jobs: build: - runs-on: macos-latest + runs-on: macos-10.15 steps: - name: -- Step 0 -- Extract branch name shell: bash diff --git a/package.json b/package.json index 2de1612d..6f8c2355 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "versions": { "native": "210317", "major": 1, - "minor": 0, + "minor": 1, "patch": 0, "expo": "40.0.0" }, @@ -20,7 +20,8 @@ "ios": "react-native run-ios", "app:build": "bundle exec fastlane build", "test": "jest --watchAll", - "release": "scripts/release.sh" + "release": "scripts/release.sh", + "clean": "react-native-clean-project" }, "dependencies": { "@expo/react-native-action-sheet": "^3.9.0", @@ -114,6 +115,7 @@ "jest": "^26.6.3", "jest-expo": "^40.0.2", "nock": "^13.0.11", + "react-native-clean-project": "^3.6.3", "react-navigation": "^4.4.4", "react-navigation-stack": "^2.10.4", "react-test-renderer": "^17.0.1", diff --git a/src/@types/mastodon.d.ts b/src/@types/mastodon.d.ts index d127148a..5a0e88fa 100644 --- a/src/@types/mastodon.d.ts +++ b/src/@types/mastodon.d.ts @@ -28,7 +28,7 @@ declare namespace Mastodon { moved?: Account fields: Field[] bot: boolean - source: Source + source?: Source } type Announcement = { @@ -258,7 +258,7 @@ declare namespace Mastodon { type Field = { name: string value: string - verified_at?: string + verified_at: string | null } type List = { diff --git a/src/@types/react-navigation.d.ts b/src/@types/react-navigation.d.ts index f90844f0..9a0112c6 100644 --- a/src/@types/react-navigation.d.ts +++ b/src/@types/react-navigation.d.ts @@ -132,9 +132,27 @@ declare namespace Nav { list: Mastodon.List['id'] title: Mastodon.List['title'] } + 'Tab-Me-Profile': undefined + 'Tab-Me-Push': undefined 'Tab-Me-Settings': undefined 'Tab-Me-Settings-Fontsize': undefined - 'Tab-Me-Settings-Push': undefined 'Tab-Me-Switch': undefined } & TabSharedStackParamList + + type TabMeProfileStackParamList = { + 'Tab-Me-Profile-Root': undefined + 'Tab-Me-Profile-Name': { + display_name: Mastodon.Account['display_name'] + } + 'Tab-Me-Profile-Note': { + note: Mastodon.Source['note'] + } + 'Tab-Me-Profile-Fields': { + fields?: Mastodon.Source['fields'] + } + } + + type TabMePushStackParamList = { + 'Tab-Me-Push-Root': undefined + } } diff --git a/src/Screens.tsx b/src/Screens.tsx index 83bfade6..0a4d0693 100644 --- a/src/Screens.tsx +++ b/src/Screens.tsx @@ -168,7 +168,7 @@ const Screens: React.FC = ({ localCorrupt }) => { options={{ stackPresentation: 'transparentModal', stackAnimation: 'fade', - headerShown: false // Android + headerShown: false }} /> = ({ localCorrupt }) => { options={{ stackPresentation: 'transparentModal', stackAnimation: 'fade', - headerShown: false // Android + headerShown: false }} /> = ({ localCorrupt }) => { component={ScreenCompose} options={{ stackPresentation: 'fullScreenModal', - headerShown: false // Android + headerShown: false }} /> = ({ localCorrupt }) => { options={{ stackPresentation: 'fullScreenModal', stackAnimation: 'fade', - headerShown: false // Android + headerShown: false }} /> @@ -206,6 +206,3 @@ const Screens: React.FC = ({ localCorrupt }) => { } export default React.memo(Screens, () => true) -function toast (arg0: { type: string; content: string; autoHide: boolean }) { - throw new Error('Function not implemented.') -} diff --git a/src/api/general.ts b/src/api/general.ts index 330d15c5..59e4c15e 100644 --- a/src/api/general.ts +++ b/src/api/general.ts @@ -6,7 +6,7 @@ const ctx = new chalk.Instance({ level: 3 }) export type Params = { method: 'get' | 'post' | 'put' | 'delete' - domain?: string + domain: string url: string params?: { [key: string]: string | number | boolean | string[] | number[] | boolean[] @@ -25,10 +25,6 @@ const apiGeneral = async ({ body, sentry = false }: Params): Promise<{ body: T }> => { - if (!domain) { - return Promise.reject() - } - console.log( ctx.bgGreen.bold(' API general ') + ' ' + diff --git a/src/api/instance.ts b/src/api/instance.ts index d64457c5..57736082 100644 --- a/src/api/instance.ts +++ b/src/api/instance.ts @@ -6,7 +6,7 @@ import li from 'li' const ctx = new chalk.Instance({ level: 3 }) export type Params = { - method: 'get' | 'post' | 'put' | 'delete' + method: 'get' | 'post' | 'put' | 'delete' | 'patch' version?: 'v1' | 'v2' url: string params?: { diff --git a/src/components/Button.tsx b/src/components/Button.tsx index ecce14f5..d884b472 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -2,7 +2,7 @@ import Icon from '@components/Icon' import { StyleConstants } from '@utils/styles/constants' import layoutAnimation from '@utils/styles/layoutAnimation' import { useTheme } from '@utils/styles/ThemeManager' -import React, { useEffect, useMemo, useRef } from 'react' +import React, { useEffect, useMemo, useRef, useState } from 'react' import { AccessibilityProps, Pressable, @@ -121,9 +121,6 @@ const Button: React.FC = ({ color: mainColor, fontSize: StyleConstants.Font.Size[size] * (size === 'L' ? 1.25 : 1), - fontWeight: destructive - ? StyleConstants.Font.Weight.Bold - : undefined, opacity: loading ? 0 : 1 }} children={content} @@ -135,12 +132,7 @@ const Button: React.FC = ({ } }, [mode, content, loading, disabled]) - enum spacingMapping { - XS = 'S', - S = 'M', - M = 'L', - L = 'XL' - } + const [layoutHeight, setLayoutHeight] = useState() return ( = ({ backgroundColor: colorBackground, paddingVertical: StyleConstants.Spacing[spacing], paddingHorizontal: - StyleConstants.Spacing[round ? spacing : spacingMapping[spacing]] + StyleConstants.Spacing[spacing] + StyleConstants.Spacing.XS, + width: round && layoutHeight ? layoutHeight : undefined }, customStyle ]} + {...(round && { + onLayout: ({ nativeEvent }) => + setLayoutHeight(nativeEvent.layout.height) + })} testID='base' onPress={onPress} children={children} @@ -176,7 +173,6 @@ const Button: React.FC = ({ const styles = StyleSheet.create({ button: { borderRadius: 100, - flexDirection: 'row', justifyContent: 'center', alignItems: 'center' } diff --git a/src/components/Emojis.tsx b/src/components/Emojis.tsx new file mode 100644 index 00000000..26afa00f --- /dev/null +++ b/src/components/Emojis.tsx @@ -0,0 +1,166 @@ +import EmojisButton from '@components/Emojis/Button' +import EmojisList from '@components/Emojis/List' +import { useAccessibility } from '@utils/accessibility/AccessibilityManager' +import { useEmojisQuery } from '@utils/queryHooks/emojis' +import { chunk, forEach, groupBy, sortBy } from 'lodash' +import React, { + createContext, + Dispatch, + MutableRefObject, + SetStateAction, + useCallback, + useEffect, + useReducer +} from 'react' +import FastImage from 'react-native-fast-image' + +type EmojisState = { + enabled: boolean + active: boolean + emojis: { title: string; data: Mastodon.Emoji[][] }[] + shortcode: Mastodon.Emoji['shortcode'] | null +} + +type EmojisAction = + | { + type: 'load' + payload: NonNullable + } + | { + type: 'activate' + payload: EmojisState['active'] + } + | { + type: 'shortcode' + payload: EmojisState['shortcode'] + } + +const emojisReducer = (state: EmojisState, action: EmojisAction) => { + switch (action.type) { + case 'activate': + return { ...state, active: action.payload } + case 'load': + return { ...state, emojis: action.payload } + case 'shortcode': + return { ...state, shortcode: action.payload } + } +} + +type ContextType = { + emojisState: EmojisState + emojisDispatch: Dispatch +} +const EmojisContext = createContext({} as ContextType) + +const prefetchEmojis = ( + sortedEmojis: { title: string; data: Mastodon.Emoji[][] }[], + reduceMotionEnabled: boolean +) => { + const prefetches: { uri: string }[] = [] + let requestedIndex = 0 + sortedEmojis.forEach(sorted => { + sorted.data.forEach(emojis => + emojis.forEach(emoji => { + if (requestedIndex > 40) { + return + } + prefetches.push({ + uri: reduceMotionEnabled ? emoji.static_url : emoji.url + }) + requestedIndex++ + }) + ) + }) + try { + FastImage.preload(prefetches) + } catch {} +} + +export interface Props { + enabled?: boolean + value?: string + setValue: + | Dispatch> + | Dispatch> + selectionRange: MutableRefObject<{ + start: number + end: number + }> + maxLength?: number +} + +const ComponentEmojis: React.FC = ({ + enabled = false, + value, + setValue, + selectionRange, + maxLength, + children +}) => { + const { reduceMotionEnabled } = useAccessibility() + + const [emojisState, emojisDispatch] = useReducer(emojisReducer, { + enabled, + active: false, + emojis: [], + shortcode: null + }) + + useEffect(() => { + if (emojisState.shortcode) { + addEmoji(emojisState.shortcode) + emojisDispatch({ + type: 'shortcode', + payload: null + }) + } + }, [emojisState.shortcode]) + + const addEmoji = useCallback( + (emojiShortcode: string) => { + if (value?.length) { + const contentFront = value.slice(0, selectionRange.current?.start) + const contentRear = value.slice(selectionRange.current?.end) + + const whiteSpaceRear = /\s/g.test(contentRear.slice(-1)) + + const newTextWithSpace = ` ${emojiShortcode}${ + whiteSpaceRear ? '' : ' ' + }` + setValue( + [contentFront, newTextWithSpace, contentRear] + .join('') + .slice(0, maxLength) + ) + } else { + setValue(`${emojiShortcode} `.slice(0, maxLength)) + } + }, + [value, selectionRange.current?.start, selectionRange.current?.end] + ) + + const { data } = useEmojisQuery({ options: { enabled } }) + useEffect(() => { + if (data && data.length) { + let sortedEmojis: { title: string; data: Mastodon.Emoji[][] }[] = [] + forEach( + groupBy(sortBy(data, ['category', 'shortcode']), 'category'), + (value, key) => sortedEmojis.push({ title: key, data: chunk(value, 5) }) + ) + emojisDispatch({ + type: 'load', + payload: sortedEmojis + }) + prefetchEmojis(sortedEmojis, reduceMotionEnabled) + } + }, [data, reduceMotionEnabled]) + + return ( + + ) +} + +export { ComponentEmojis, EmojisContext, EmojisButton, EmojisList } diff --git a/src/components/Emojis/Button.tsx b/src/components/Emojis/Button.tsx new file mode 100644 index 00000000..897273a9 --- /dev/null +++ b/src/components/Emojis/Button.tsx @@ -0,0 +1,50 @@ +import { EmojisContext } from '@components/Emojis' +import Icon from '@components/Icon' +import { StyleConstants } from '@utils/styles/constants' +import { useTheme } from '@utils/styles/ThemeManager' +import React, { useContext } from 'react' +import { Pressable, StyleSheet } from 'react-native' + +const EmojisButton = React.memo( + () => { + const { theme } = useTheme() + const { emojisState, emojisDispatch } = useContext(EmojisContext) + + return emojisState.enabled ? ( + + emojisDispatch({ type: 'activate', payload: !emojisState.active }) + } + hitSlop={StyleConstants.Spacing.S} + style={styles.base} + children={ + + } + /> + ) : null + }, + () => true +) + +const styles = StyleSheet.create({ + base: { + paddingLeft: StyleConstants.Spacing.S + } +}) + +export default EmojisButton diff --git a/src/components/Emojis/List.tsx b/src/components/Emojis/List.tsx new file mode 100644 index 00000000..d0fdc749 --- /dev/null +++ b/src/components/Emojis/List.tsx @@ -0,0 +1,122 @@ +import { EmojisContext } from '@components/Emojis' +import { useAccessibility } from '@utils/accessibility/AccessibilityManager' +import { StyleConstants } from '@utils/styles/constants' +import layoutAnimation from '@utils/styles/layoutAnimation' +import { useTheme } from '@utils/styles/ThemeManager' +import React, { useCallback, useContext, useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { + AccessibilityInfo, + findNodeHandle, + Pressable, + SectionList, + StyleSheet, + Text, + View +} from 'react-native' +import FastImage from 'react-native-fast-image' +import validUrl from 'valid-url' + +const EmojisList = React.memo( + () => { + const { reduceMotionEnabled } = useAccessibility() + const { t } = useTranslation() + + const { emojisState, emojisDispatch } = useContext(EmojisContext) + const { theme } = useTheme() + + const listHeader = useCallback( + ({ section: { title } }) => ( + {title} + ), + [] + ) + + const listItem = useCallback( + ({ index, item }: { item: Mastodon.Emoji[]; index: number }) => { + return ( + + {item.map(emoji => { + const uri = reduceMotionEnabled ? emoji.static_url : emoji.url + if (validUrl.isHttpsUri(uri)) { + return ( + + emojisDispatch({ + type: 'shortcode', + payload: `:${emoji.shortcode}:` + }) + } + > + + + ) + } else { + return null + } + })} + + ) + }, + [] + ) + + const listRef = useRef(null) + useEffect(() => { + layoutAnimation() + const tagEmojis = findNodeHandle(listRef.current) + if (emojisState.active) { + tagEmojis && AccessibilityInfo.setAccessibilityFocus(tagEmojis) + } + }, [emojisState.active]) + + return emojisState.active ? ( + item[0].shortcode} + renderSectionHeader={listHeader} + renderItem={listItem} + windowSize={4} + /> + ) : null + }, + () => true +) + +const styles = StyleSheet.create({ + group: { + position: 'absolute', + ...StyleConstants.FontStyle.S + }, + emojis: { + flex: 1, + flexWrap: 'wrap', + marginTop: StyleConstants.Spacing.M, + marginRight: StyleConstants.Spacing.S + }, + emoji: { + width: 32, + height: 32, + padding: StyleConstants.Spacing.S, + margin: StyleConstants.Spacing.S + } +}) + +export default EmojisList diff --git a/src/components/Input.tsx b/src/components/Input.tsx new file mode 100644 index 00000000..795a07b6 --- /dev/null +++ b/src/components/Input.tsx @@ -0,0 +1,194 @@ +import { StyleConstants } from '@utils/styles/constants' +import layoutAnimation from '@utils/styles/layoutAnimation' +import { useTheme } from '@utils/styles/ThemeManager' +import React, { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useRef, + useState +} from 'react' +import { + Platform, + StyleSheet, + Text, + TextInput, + TextInputProps, + View +} from 'react-native' +import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated' +import { + ComponentEmojis, + EmojisButton, + EmojisContext, + EmojisList +} from './Emojis' + +export interface Props { + autoFocus?: boolean + + title?: string + + multiline?: boolean + + emoji?: boolean + + value?: string + setValue: + | Dispatch> + | Dispatch> + + options?: Omit< + TextInputProps, + | 'autoFocus' + | 'onFocus' + | 'onBlur' + | 'style' + | 'onChangeText' + | 'onSelectionChange' + | 'keyboardAppearance' + | 'textAlignVertical' + > +} + +const Input: React.FC = ({ + autoFocus = true, + title, + multiline = false, + emoji = false, + value, + setValue, + options +}) => { + const { mode, theme } = useTheme() + + const animateTitle = useAnimatedStyle(() => { + if (value) { + return { + fontSize: withTiming(StyleConstants.Font.Size.S), + paddingHorizontal: withTiming(StyleConstants.Spacing.XS), + left: withTiming(StyleConstants.Spacing.S), + top: withTiming(-(StyleConstants.Font.Size.S / 2) - 2), + backgroundColor: withTiming(theme.backgroundDefault) + } + } else { + return { + fontSize: withTiming(StyleConstants.Font.Size.M), + paddingHorizontal: withTiming(0), + left: withTiming(StyleConstants.Spacing.S), + top: withTiming(StyleConstants.Spacing.S + 1), + backgroundColor: withTiming(theme.backgroundDefaultTransparent) + } + } + }, [mode, value]) + + const selectionRange = useRef<{ start: number; end: number }>( + value + ? { + start: value.length, + end: value.length + } + : { start: 0, end: 0 } + ) + const onSelectionChange = useCallback( + ({ nativeEvent: { selection } }) => (selectionRange.current = selection), + [] + ) + + const [inputFocused, setInputFocused] = useState(false) + useEffect(() => { + layoutAnimation() + }, [inputFocused]) + + return ( + + + + {({ emojisDispatch }) => ( + setInputFocused(true)} + onBlur={() => { + setInputFocused(false) + emojisDispatch({ type: 'activate', payload: false }) + }} + style={[ + styles.textInput, + { + color: theme.primaryDefault, + minHeight: + Platform.OS === 'ios' && multiline + ? StyleConstants.Font.LineHeight.M * 5 + : undefined + } + ]} + onChangeText={setValue} + onSelectionChange={onSelectionChange} + value={value} + {...(multiline && { + multiline, + numberOfLines: Platform.OS === 'android' ? 5 : undefined + })} + keyboardAppearance={mode} + textAlignVertical='top' + {...options} + /> + )} + + {title ? ( + + {title} + + ) : null} + + {options?.maxLength && value?.length ? ( + + {value?.length} / {options.maxLength} + + ) : null} + {inputFocused ? : null} + + + + + ) +} + +const styles = StyleSheet.create({ + base: { + alignItems: 'flex-end', + borderWidth: 1, + marginVertical: StyleConstants.Spacing.S, + padding: StyleConstants.Spacing.S + }, + title: { + position: 'absolute' + }, + textInput: { + flex: 1, + fontSize: StyleConstants.Font.Size.M + }, + maxLength: { + ...StyleConstants.FontStyle.S, + paddingLeft: StyleConstants.Spacing.XS + } +}) + +export default Input diff --git a/src/components/Instance.tsx b/src/components/Instance.tsx index 6a592512..27659c53 100644 --- a/src/components/Instance.tsx +++ b/src/components/Instance.tsx @@ -149,14 +149,14 @@ const ComponentInstance: React.FC = ({ style={[ styles.prefix, { + color: theme.primaryDefault, borderBottomColor: instanceQuery.isError ? theme.red : theme.border } ]} editable={false} - placeholder='https://' - placeholderTextColor={theme.primaryDefault} + defaultValue='https://' /> = ({ children }) => { - return ( - - {children} - - ) + return {children} } const styles = StyleSheet.create({ base: { - marginBottom: StyleConstants.Spacing.L + paddingHorizontal: StyleConstants.Spacing.Global.PagePadding, + marginBottom: StyleConstants.Spacing.Global.PagePadding } }) diff --git a/src/components/Menu/Header.tsx b/src/components/Menu/Header.tsx index 0eb36122..fc455484 100644 --- a/src/components/Menu/Header.tsx +++ b/src/components/Menu/Header.tsx @@ -19,8 +19,6 @@ const MenuHeader: React.FC = ({ heading }) => { const styles = StyleSheet.create({ base: { - paddingLeft: StyleConstants.Spacing.Global.PagePadding, - paddingRight: StyleConstants.Spacing.Global.PagePadding, paddingBottom: StyleConstants.Spacing.S }, text: { diff --git a/src/components/Menu/Row.tsx b/src/components/Menu/Row.tsx index a2756bea..e7dde35b 100644 --- a/src/components/Menu/Row.tsx +++ b/src/components/Menu/Row.tsx @@ -15,6 +15,7 @@ export interface Props { title: string description?: string content?: string | React.ReactNode + badge?: boolean switchValue?: boolean switchDisabled?: boolean @@ -33,6 +34,7 @@ const MenuRow: React.FC = ({ title, description, content, + badge = false, switchValue, switchDisabled, switchOnValueChange, @@ -84,6 +86,17 @@ const MenuRow: React.FC = ({ style={styles.iconFront} /> )} + {badge ? ( + + ) : null} = ({ const styles = StyleSheet.create({ base: { - minHeight: 50 + minHeight: 46, + paddingVertical: StyleConstants.Spacing.S }, core: { flex: 1, - flexDirection: 'row', - paddingHorizontal: StyleConstants.Spacing.Global.PagePadding + flexDirection: 'row' }, front: { flex: 2, @@ -167,7 +180,7 @@ const styles = StyleSheet.create({ marginLeft: StyleConstants.Spacing.M }, iconFront: { - marginRight: 8 + marginRight: StyleConstants.Spacing.S }, main: { flex: 1 @@ -176,9 +189,7 @@ const styles = StyleSheet.create({ ...StyleConstants.FontStyle.M }, description: { - ...StyleConstants.FontStyle.S, - marginTop: StyleConstants.Spacing.XS, - paddingHorizontal: StyleConstants.Spacing.Global.PagePadding + ...StyleConstants.FontStyle.S }, content: { ...StyleConstants.FontStyle.M diff --git a/src/components/Message.tsx b/src/components/Message.tsx index 577615ac..7ba3377f 100644 --- a/src/components/Message.tsx +++ b/src/components/Message.tsx @@ -2,7 +2,7 @@ import Icon from '@components/Icon' import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' import { getTheme } from '@utils/styles/themes' -import React from 'react' +import React, { RefObject } from 'react' import { AccessibilityInfo } from 'react-native' import FlashMessage, { hideMessage, @@ -11,6 +11,7 @@ import FlashMessage, { import haptics from './haptics' const displayMessage = ({ + ref, duration = 'short', autoHide = true, message, @@ -20,6 +21,7 @@ const displayMessage = ({ type }: | { + ref?: RefObject duration?: 'short' | 'long' autoHide?: boolean message: string @@ -29,6 +31,7 @@ const displayMessage = ({ type?: undefined } | { + ref?: RefObject duration?: 'short' | 'long' autoHide?: boolean message: string @@ -54,63 +57,88 @@ const displayMessage = ({ haptics('Error') } - showMessage({ - duration: type === 'error' ? 5000 : duration === 'short' ? 1500 : 3000, - autoHide, - message, - description, - onPress, - ...(mode && - type && { - renderFlashMessageIcon: () => { - return ( - - ) - } - }) - }) + if (ref) { + ref.current?.showMessage({ + duration: type === 'error' ? 5000 : duration === 'short' ? 1500 : 3000, + autoHide, + message, + description, + onPress, + ...(mode && + type && { + renderFlashMessageIcon: () => { + return ( + + ) + } + }) + }) + } else { + showMessage({ + duration: type === 'error' ? 5000 : duration === 'short' ? 1500 : 3000, + autoHide, + message, + description, + onPress, + ...(mode && + type && { + renderFlashMessageIcon: () => { + return ( + + ) + } + }) + }) + } } const removeMessage = () => { + // if (ref) { + // ref.current?.hideMessage() + // } else { hideMessage() + // } } -const Message = React.memo( - () => { - const { mode, theme } = useTheme() +const Message = React.forwardRef((_, ref) => { + const { mode, theme } = useTheme() - return ( - - ) - }, - () => true -) + return ( + + ) +}) export { Message, displayMessage, removeMessage } diff --git a/src/components/mediaSelector.ts b/src/components/mediaSelector.ts new file mode 100644 index 00000000..682c6549 --- /dev/null +++ b/src/components/mediaSelector.ts @@ -0,0 +1,133 @@ +import * as ImagePicker from 'expo-image-picker' +import { Alert, Linking } from 'react-native' +import { ActionSheetOptions } from '@expo/react-native-action-sheet' +import i18next from 'i18next' +import analytics from '@components/analytics' +import { ImageInfo } from 'expo-image-picker/build/ImagePicker.types' + +export interface Props { + mediaTypes?: ImagePicker.MediaTypeOptions + uploader: (imageInfo: ImageInfo) => void + showActionSheetWithOptions: ( + options: ActionSheetOptions, + callback: (i: number) => void + ) => void +} + +const mediaSelector = async ({ + mediaTypes = ImagePicker.MediaTypeOptions.All, + uploader, + showActionSheetWithOptions +}: Props): Promise => { + showActionSheetWithOptions( + { + title: i18next.t('componentMediaSelector:title'), + options: [ + i18next.t('componentMediaSelector:options.library'), + i18next.t('componentMediaSelector:options.photo'), + i18next.t('componentMediaSelector:options.cancel') + ], + cancelButtonIndex: 2 + }, + async buttonIndex => { + if (buttonIndex === 0) { + const { + status + } = await ImagePicker.requestMediaLibraryPermissionsAsync() + if (status !== 'granted') { + Alert.alert( + i18next.t('componentMediaSelector:library.alert.title'), + i18next.t('componentMediaSelector:library.alert.message'), + [ + { + text: i18next.t( + 'componentMediaSelector:library.alert.buttons.cancel' + ), + style: 'cancel', + onPress: () => + analytics('mediaSelector_nopermission', { action: 'cancel' }) + }, + { + text: i18next.t( + 'componentMediaSelector:library.alert.buttons.settings' + ), + style: 'default', + onPress: () => { + analytics('mediaSelector_nopermission', { + action: 'settings' + }) + Linking.openURL('app-settings:') + } + } + ] + ) + } else { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes, + exif: false + }) + + if (!result.cancelled) { + // https://github.com/expo/expo/issues/11214 + const fixResult = { + ...result, + uri: result.uri.replace('file:/data', 'file:///data') + } + uploader(fixResult) + return + } + } + } else if (buttonIndex === 1) { + const { status } = await ImagePicker.requestCameraPermissionsAsync() + if (status !== 'granted') { + Alert.alert( + i18next.t('componentMediaSelector:photo.alert.title'), + i18next.t('componentMediaSelector:photo.alert.message'), + [ + { + text: i18next.t( + 'componentMediaSelector:photo.alert.buttons.cancel' + ), + style: 'cancel', + onPress: () => { + analytics('compose_addattachment_camera_nopermission', { + action: 'cancel' + }) + } + }, + { + text: i18next.t( + 'componentMediaSelector:photo.alert.buttons.settings' + ), + style: 'default', + onPress: () => { + analytics('compose_addattachment_camera_nopermission', { + action: 'settings' + }) + Linking.openURL('app-settings:') + } + } + ] + ) + } else { + const result = await ImagePicker.launchCameraAsync({ + mediaTypes, + exif: false + }) + + if (!result.cancelled) { + // https://github.com/expo/expo/issues/11214 + const fixResult = { + ...result, + uri: result.uri.replace('file:/data', 'file:///data') + } + uploader(fixResult) + return + } + } + } + } + ) +} + +export default mediaSelector diff --git a/src/i18n/en/_all.ts b/src/i18n/en/_all.ts index d326e4a7..d4c1b7c5 100644 --- a/src/i18n/en/_all.ts +++ b/src/i18n/en/_all.ts @@ -9,6 +9,7 @@ export default { screenTabs: require('./screens/tabs'), componentInstance: require('./components/instance'), + componentMediaSelector: require('./components/mediaSelector'), componentParse: require('./components/parse'), componentRelationship: require('./components/relationship'), componentRelativeTime: require('./components/relativeTime'), diff --git a/src/i18n/en/components/mediaSelector.json b/src/i18n/en/components/mediaSelector.json new file mode 100644 index 00000000..6f509ea7 --- /dev/null +++ b/src/i18n/en/components/mediaSelector.json @@ -0,0 +1,28 @@ +{ + "title": "Select media source", + "options": { + "library": "Upload from library", + "photo": "Take a photo", + "cancel": "$t(common:buttons.cancel)" + }, + "library": { + "alert": { + "title": "No permission", + "message": "Require photo library read permission to upload", + "buttons": { + "settings": "Update setting", + "cancel": "$t(common:buttons.cancel)" + } + } + }, + "photo": { + "alert": { + "title": "No permission", + "message": "Require camera usage permission to upload", + "buttons": { + "settings": "Update setting", + "cancel": "$t(common:buttons.cancel)" + } + } + } +} \ No newline at end of file diff --git a/src/i18n/en/screens/compose.json b/src/i18n/en/screens/compose.json index ba3e02f3..a0e41ae1 100644 --- a/src/i18n/en/screens/compose.json +++ b/src/i18n/en/screens/compose.json @@ -104,33 +104,6 @@ "attachment": { "accessibilityLabel": "Upload attachment", "accessibilityHint": "Poll function will be disabled when there is any attachment", - "actions": { - "options": { - "library": "Upload from photo library", - "photo": "Upload with camera", - "cancel": "$t(common:buttons.cancel)" - }, - "library": { - "alert": { - "title": "No permission", - "message": "Require photo library read permission to upload", - "buttons": { - "settings": "Update setting", - "cancel": "Cancel" - } - } - }, - "photo": { - "alert": { - "title": "No permission", - "message": "Require camera usage permission to upload", - "buttons": { - "settings": "Update setting", - "cancel": "Cancel" - } - } - } - }, "failed": { "alert": { "title": "Upload failed", diff --git a/src/i18n/en/screens/imageViewer.json b/src/i18n/en/screens/imageViewer.json index 06c0014f..7b41e857 100644 --- a/src/i18n/en/screens/imageViewer.json +++ b/src/i18n/en/screens/imageViewer.json @@ -10,8 +10,8 @@ "cancel": "$t(common:buttons.cancel)" }, "save": { - "function": "Saving image", - "success": "Image saved" + "succeed": "Image saved", + "failed": "Saving image failed" } } } \ No newline at end of file diff --git a/src/i18n/en/screens/tabs.json b/src/i18n/en/screens/tabs.json index 819f7bf6..18ae1a72 100644 --- a/src/i18n/en/screens/tabs.json +++ b/src/i18n/en/screens/tabs.json @@ -52,8 +52,20 @@ "push": { "name": "Push Notification" }, + "profile": { + "name": "Edit Profile" + }, + "profileName": { + "name": "Edit Display Name" + }, + "profileNote": { + "name": "Edit Description" + }, + "profileFields": { + "name": "Edit Metadata" + }, "settings": { - "name": "Settings" + "name": "App Settings" }, "switch": { "name": "Switch Account" @@ -71,13 +83,73 @@ "XXL": "XXL" } }, + "profile": { + "cancellation": { + "title": "Change Not Saved", + "message": "Your change has not been saved. Would you discard saving the changes?", + "buttons": { + "cancel": "$t(common:buttons.cancel)", + "discard": "Discard" + } + }, + "feedback": { + "succeed": "{{type}} updated", + "failed": "{{type}} update failed, please try again" + }, + "root": { + "name": { + "title": "Display Name" + }, + "avatar": { + "title": "Avatar", + "description": "Available in next version" + }, + "banner": { + "title": "Banner", + "description": "Available in next version" + }, + "note": { + "title": "Description" + }, + "fields": { + "title": "Metadata", + "total": "{{count}} field", + "total_plural": "{{count}} fields" + }, + "visibility": { + "title": "Posting Visibility", + "options": { + "public": "Public", + "unlisted": "Unlisted", + "private": "Followers only", + "cancel": "$t(common:buttons.cancel)" + } + }, + "sensitive": { + "title": "Posting Media Sensitive" + }, + "lock": { + "title": "Lock Account", + "description": "Requires you to manually approve followers" + }, + "bot": { + "title": "Bot account", + "description": "This account mainly performs automated actions and might not be monitored" + } + }, + "fields": { + "group": "Group {{index}}", + "label": "Label", + "content": "Content" + } + }, "push": { "enable": { "direct": "Enable push notification", "settings": "Enable in settings" }, "global": { - "heading": "Enable push notification", + "heading": "Enable for {{acct}}", "description": "Messages are routed through tooot's server" }, "decode": { @@ -112,6 +184,9 @@ "empty": "None" } }, + "update": { + "title": "Update to latest version" + }, "logout": { "button": "Log out", "alert": { @@ -125,13 +200,6 @@ } }, "settings": { - "push": { - "heading": "$t(me.stacks.push.name)", - "content": { - "enabled": "Enabled", - "disabled": "Disabled" - } - }, "fontsize": { "heading": "$t(me.stacks.fontSize.name)", "content": { @@ -158,7 +226,7 @@ } }, "browser": { - "heading": "Opening link", + "heading": "Opening Link", "options": { "internal": "Inside app", "external": "Use system browser", diff --git a/src/i18n/zh-Hans/_all.ts b/src/i18n/zh-Hans/_all.ts index d326e4a7..d4c1b7c5 100644 --- a/src/i18n/zh-Hans/_all.ts +++ b/src/i18n/zh-Hans/_all.ts @@ -9,6 +9,7 @@ export default { screenTabs: require('./screens/tabs'), componentInstance: require('./components/instance'), + componentMediaSelector: require('./components/mediaSelector'), componentParse: require('./components/parse'), componentRelationship: require('./components/relationship'), componentRelativeTime: require('./components/relativeTime'), diff --git a/src/i18n/zh-Hans/components/mediaSelector.json b/src/i18n/zh-Hans/components/mediaSelector.json new file mode 100644 index 00000000..1b4cc25e --- /dev/null +++ b/src/i18n/zh-Hans/components/mediaSelector.json @@ -0,0 +1,28 @@ +{ + "title": "选择媒体", + "options": { + "library": "从相册上传", + "photo": "拍摄照片", + "cancel": "$t(common:buttons.cancel)" + }, + "library": { + "alert": { + "title": "无权限", + "message": "需要读取相册权限才能上传附件", + "buttons": { + "settings": "去更新设置", + "cancel": "$t(common:buttons.cancel)" + } + } + }, + "photo": { + "alert": { + "title": "无权限", + "message": "需要使用相机权限才能上传附件", + "buttons": { + "settings": "去更新设置", + "cancel": "$t(common:buttons.cancel)" + } + } + } +} \ No newline at end of file diff --git a/src/i18n/zh-Hans/screens/compose.json b/src/i18n/zh-Hans/screens/compose.json index b4787a72..3da606da 100644 --- a/src/i18n/zh-Hans/screens/compose.json +++ b/src/i18n/zh-Hans/screens/compose.json @@ -62,7 +62,7 @@ "poll": { "option": { "placeholder": { - "accessibilityLabel": "选项{{index}}", + "accessibilityLabel": "投票选项 {{index}}", "single": "单选项", "multiple": "多选项" } @@ -104,33 +104,6 @@ "attachment": { "accessibilityLabel": "上传附件", "accessibilityHint": "当有任何附件时,投票功能将被禁用", - "actions": { - "options": { - "library": "从相册上传", - "photo": "拍摄上传", - "cancel": "$t(common:buttons.cancel)" - }, - "library": { - "alert": { - "title": "无读取权限", - "message": "需要读取相册权限才能上传附件", - "buttons": { - "settings": "去更新设置", - "cancel": "取消上传" - } - } - }, - "photo": { - "alert": { - "title": "无拍照权限", - "message": "需要使用相机权限才能上传附件", - "buttons": { - "settings": "去更新设置", - "cancel": "取消上传" - } - } - } - }, "failed": { "alert": { "title": "上传失败", diff --git a/src/i18n/zh-Hans/screens/imageViewer.json b/src/i18n/zh-Hans/screens/imageViewer.json index 3c348e1e..27ab50ac 100644 --- a/src/i18n/zh-Hans/screens/imageViewer.json +++ b/src/i18n/zh-Hans/screens/imageViewer.json @@ -10,8 +10,8 @@ "cancel": "$t(common:buttons.cancel)" }, "save": { - "function": "保存图片", - "success": "图片保存成功" + "succeed": "图片保存成功", + "failed": "保存图片失败" } } } \ No newline at end of file diff --git a/src/i18n/zh-Hans/screens/tabs.json b/src/i18n/zh-Hans/screens/tabs.json index 25485eaa..2f8d80dc 100644 --- a/src/i18n/zh-Hans/screens/tabs.json +++ b/src/i18n/zh-Hans/screens/tabs.json @@ -52,8 +52,20 @@ "push": { "name": "推送通知" }, + "profile": { + "name": "修改个人资料" + }, + "profileName": { + "name": "修改昵称" + }, + "profileNote": { + "name": "修改简介" + }, + "profileFields": { + "name": "修改附加信息" + }, "settings": { - "name": "设置" + "name": "应用设置" }, "switch": { "name": "切换账号" @@ -71,13 +83,73 @@ "XXL": "超大号" } }, + "profile": { + "cancellation": { + "title": "更改尚未保存", + "message": "您的更改尚未保存。是否放弃保存更改?", + "buttons": { + "cancel": "$t(common:buttons.cancel)", + "discard": "不保存" + } + }, + "feedback": { + "succeed": "{{type}}已更新", + "failed": "{{type}}更新失败,请重试" + }, + "root": { + "name": { + "title": "昵称" + }, + "avatar": { + "title": "头像", + "description": "将在下一版中启用" + }, + "banner": { + "title": "横幅", + "description": "将在下一版中启用" + }, + "note": { + "title": "简介" + }, + "fields": { + "title": "附加信息", + "total": "{{count}} 项", + "total_plural": "{{count}} 项" + }, + "visibility": { + "title": "嘟文默认可见范围", + "options": { + "public": "公开", + "unlisted": "不公开", + "private": "仅关注者", + "cancel": "$t(common:buttons.cancel)" + } + }, + "sensitive": { + "title": "媒体默认设为敏感" + }, + "lock": { + "title": "锁嘟", + "description": "你需要手动审核所有关注请求" + }, + "bot": { + "title": "机器人帐户", + "description": "来自这个帐户的绝大多数操作都是自动进行的,并且可能无人监控" + } + }, + "fields": { + "group": "第 {{index}} 组", + "label": "标签", + "content": "内容" + } + }, "push": { "enable": { "direct": "启用推送通知", "settings": "在系统设置中启用" }, "global": { - "heading": "启用tooot推送通知", + "heading": "启用 {{acct}}", "description": "通知消息将经由tooot服务器转发" }, "decode": { @@ -112,6 +184,9 @@ "empty": "无公告" } }, + "update": { + "title": "更新至最新版本" + }, "logout": { "button": "退出当前账号", "alert": { @@ -125,13 +200,6 @@ } }, "settings": { - "push": { - "heading": "$t(me.stacks.push.name)", - "content": { - "enabled": "已启用", - "disabled": "已禁用" - } - }, "fontsize": { "heading": "$t(me.stacks.fontSize.name)", "content": { diff --git a/src/screens/Compose/Root/Footer/Poll.tsx b/src/screens/Compose/Root/Footer/Poll.tsx index 2778c5f3..f3c8f89d 100644 --- a/src/screens/Compose/Root/Footer/Poll.tsx +++ b/src/screens/Compose/Root/Footer/Poll.tsx @@ -84,7 +84,7 @@ const ComposePoll: React.FC = () => {