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..0d4ec833 100644 --- a/src/@types/react-navigation.d.ts +++ b/src/@types/react-navigation.d.ts @@ -132,9 +132,23 @@ 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'] + } + } } 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..f1bd3183 --- /dev/null +++ b/src/components/Emojis.tsx @@ -0,0 +1,161 @@ +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 + }> +} + +const ComponentEmojis: React.FC = ({ + enabled = false, + value, + setValue, + selectionRange, + 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) => { + console.log(selectionRange.current) + 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('')) + } else { + setValue(`${emojiShortcode} `) + } + }, + [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..82f6cb09 --- /dev/null +++ b/src/components/Input.tsx @@ -0,0 +1,163 @@ +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, 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 + + maxLength?: number + multiline?: boolean + + emoji?: boolean + + value?: string + setValue: + | Dispatch> + | Dispatch> +} + +const Input: React.FC = ({ + autoFocus = true, + title, + maxLength, + multiline = false, + emoji = false, + value, + setValue +}) => { + 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} + maxLength={maxLength} + {...(multiline && { + multiline, + numberOfLines: Platform.OS === 'android' ? 5 : undefined + })} + /> + )} + + {title ? ( + + {title} + + ) : null} + {maxLength && value?.length ? ( + + {value?.length} / {maxLength} + + ) : null} + {inputFocused ? : null} + + + + ) +} + +const styles = StyleSheet.create({ + base: { + flexDirection: 'row', + 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 + } +}) + +export default Input diff --git a/src/components/Menu/Container.tsx b/src/components/Menu/Container.tsx index a59b4de9..0a7910c0 100644 --- a/src/components/Menu/Container.tsx +++ b/src/components/Menu/Container.tsx @@ -7,16 +7,13 @@ export interface Props { } const MenuContainer: React.FC = ({ 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/tabs.json b/src/i18n/en/screens/tabs.json index 819f7bf6..b91d867b 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,74 @@ "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", + "direct": "Direct message", + "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 +185,9 @@ "empty": "None" } }, + "update": { + "title": "Update to latest version" + }, "logout": { "button": "Log out", "alert": { @@ -125,13 +201,6 @@ } }, "settings": { - "push": { - "heading": "$t(me.stacks.push.name)", - "content": { - "enabled": "Enabled", - "disabled": "Disabled" - } - }, "fontsize": { "heading": "$t(me.stacks.fontSize.name)", "content": { @@ -158,7 +227,7 @@ } }, "browser": { - "heading": "Opening link", + "heading": "Opening Link", "options": { "internal": "Inside app", "external": "Use system browser", diff --git a/src/screens/Compose/Root/Footer/addAttachment.ts b/src/screens/Compose/Root/Footer/addAttachment.ts index 392d7576..fa2b4f22 100644 --- a/src/screens/Compose/Root/Footer/addAttachment.ts +++ b/src/screens/Compose/Root/Footer/addAttachment.ts @@ -1,14 +1,13 @@ -import * as ImagePicker from 'expo-image-picker' import * as Crypto from 'expo-crypto' import { ImageInfo } from 'expo-image-picker/build/ImagePicker.types' import * as VideoThumbnails from 'expo-video-thumbnails' import { Dispatch } from 'react' -import { Alert, Linking } from 'react-native' +import { Alert } from 'react-native' import { ComposeAction } from '../../utils/types' import { ActionSheetOptions } from '@expo/react-native-action-sheet' import i18next from 'i18next' -import analytics from '@components/analytics' import apiInstance from '@api/instance' +import mediaSelector from '@components/mediaSelector' export interface Props { composeDispatch: Dispatch @@ -22,35 +21,33 @@ const addAttachment = async ({ composeDispatch, showActionSheetWithOptions }: Props): Promise => { - const uploadAttachment = async (result: ImageInfo) => { + const uploader = async (imageInfo: ImageInfo) => { const hash = await Crypto.digestStringAsync( Crypto.CryptoDigestAlgorithm.SHA256, - result.uri + Math.random() + imageInfo.uri + Math.random() ) let attachmentType: string - // https://github.com/expo/expo/issues/11214 - const attachmentUri = result.uri.replace('file:/data', 'file:///data') - switch (result.type) { + switch (imageInfo.type) { case 'image': - attachmentType = `image/${attachmentUri.split('.')[1]}` + attachmentType = `image/${imageInfo.uri.split('.')[1]}` composeDispatch({ type: 'attachment/upload/start', payload: { - local: { ...result, local_thumbnail: attachmentUri, hash }, + local: { ...imageInfo, local_thumbnail: imageInfo.uri, hash }, uploading: true } }) break case 'video': - attachmentType = `video/${attachmentUri.split('.')[1]}` - VideoThumbnails.getThumbnailAsync(attachmentUri) + attachmentType = `video/${imageInfo.uri.split('.')[1]}` + VideoThumbnails.getThumbnailAsync(imageInfo.uri) .then(({ uri }) => composeDispatch({ type: 'attachment/upload/start', payload: { - local: { ...result, local_thumbnail: uri, hash }, + local: { ...imageInfo, local_thumbnail: uri, hash }, uploading: true } }) @@ -59,7 +56,7 @@ const addAttachment = async ({ composeDispatch({ type: 'attachment/upload/start', payload: { - local: { ...result, hash }, + local: { ...imageInfo, hash }, uploading: true } }) @@ -70,7 +67,7 @@ const addAttachment = async ({ composeDispatch({ type: 'attachment/upload/start', payload: { - local: { ...result, hash }, + local: { ...imageInfo, hash }, uploading: true } }) @@ -101,7 +98,7 @@ const addAttachment = async ({ const formData = new FormData() formData.append('file', { // @ts-ignore - uri: attachmentUri, + uri: imageInfo.uri, name: attachmentType, type: attachmentType }) @@ -115,7 +112,7 @@ const addAttachment = async ({ if (res.body.id) { composeDispatch({ type: 'attachment/upload/end', - payload: { remote: res.body, local: result } + payload: { remote: res.body, local: imageInfo } }) } else { uploadFailed() @@ -126,119 +123,7 @@ const addAttachment = async ({ }) } - showActionSheetWithOptions( - { - options: [ - i18next.t( - 'screenCompose:content.root.actions.attachment.actions.options.library' - ), - i18next.t( - 'screenCompose:content.root.actions.attachment.actions.options.photo' - ), - i18next.t( - 'screenCompose:content.root.actions.attachment.actions.options.cancel' - ) - ], - cancelButtonIndex: 2 - }, - async buttonIndex => { - if (buttonIndex === 0) { - const { - status - } = await ImagePicker.requestMediaLibraryPermissionsAsync() - if (status !== 'granted') { - Alert.alert( - i18next.t( - 'screenCompose:content.root.actions.attachment.actions.library.alert.title' - ), - i18next.t( - 'screenCompose:content.root.actions.attachment.actions.library.alert.message' - ), - [ - { - text: i18next.t( - 'screenCompose:content.root.actions.attachment.actions.library.alert.buttons.cancel' - ), - style: 'cancel', - onPress: () => { - analytics('compose_addattachment_medialibrary_nopermission', { - action: 'cancel' - }) - } - }, - { - text: i18next.t( - 'screenCompose:content.root.actions.attachment.actions.library.alert.buttons.settings' - ), - style: 'default', - onPress: () => { - analytics('compose_addattachment_medialibrary_nopermission', { - action: 'settings' - }) - Linking.openURL('app-settings:') - } - } - ] - ) - } else { - const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: ImagePicker.MediaTypeOptions.All, - exif: false - }) - - if (!result.cancelled) { - uploadAttachment(result) - } - } - } else if (buttonIndex === 1) { - const { status } = await ImagePicker.requestCameraPermissionsAsync() - if (status !== 'granted') { - Alert.alert( - i18next.t( - 'screenCompose:content.root.actions.attachment.actions.photo.alert.title' - ), - i18next.t( - 'screenCompose:content.root.actions.attachment.actions.photo.alert.message' - ), - [ - { - text: i18next.t( - 'screenCompose:content.root.actions.attachment.actions.photo.alert.buttons.cancel' - ), - style: 'cancel', - onPress: () => { - analytics('compose_addattachment_camera_nopermission', { - action: 'cancel' - }) - } - }, - { - text: i18next.t( - 'screenCompose:content.root.actions.attachment.actions.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: ImagePicker.MediaTypeOptions.All, - exif: false - }) - - if (!result.cancelled) { - uploadAttachment(result) - } - } - } - } - ) + mediaSelector({ uploader, showActionSheetWithOptions }) } export default addAttachment diff --git a/src/screens/Tabs.tsx b/src/screens/Tabs.tsx index b0ff0260..8f34c343 100644 --- a/src/screens/Tabs.tsx +++ b/src/screens/Tabs.tsx @@ -12,10 +12,14 @@ import { getInstanceAccount, getInstanceActive } from '@utils/slices/instancesSlice' +import { + getVersionUpdate, + retriveVersionLatest +} from '@utils/slices/versionSlice' import { useTheme } from '@utils/styles/ThemeManager' -import React, { useCallback, useMemo } from 'react' -import { Image, Platform } from 'react-native' -import { useSelector } from 'react-redux' +import React, { useCallback, useEffect, useMemo } from 'react' +import { Platform } from 'react-native' +import { useDispatch, useSelector } from 'react-redux' import TabLocal from './Tabs/Local' import TabMe from './Tabs/Me' import TabNotifications from './Tabs/Notifications' @@ -114,6 +118,17 @@ const ScreenTabs = React.memo( const previousTab = useSelector(getPreviousTab, () => true) + const versionUpdate = useSelector(getVersionUpdate) + const dispatch = useDispatch() + useEffect(() => { + dispatch(retriveVersionLatest()) + }, []) + const tabMeOptions = useMemo(() => { + if (versionUpdate) { + return { tabBarBadge: 1 } + } + }, [versionUpdate]) + return ( - + ) }, diff --git a/src/screens/Tabs/Me.tsx b/src/screens/Tabs/Me.tsx index f2ed9c5c..af143d5d 100644 --- a/src/screens/Tabs/Me.tsx +++ b/src/screens/Tabs/Me.tsx @@ -1,19 +1,20 @@ import { HeaderCenter, HeaderLeft } from '@components/Header' -import ScreenMeBookmarks from '@screens/Tabs/Me/Bookmarks' -import ScreenMeConversations from '@screens/Tabs/Me/Cconversations' -import ScreenMeFavourites from '@screens/Tabs/Me/Favourites' -import ScreenMeLists from '@screens/Tabs/Me/Lists' -import ScreenMeRoot from '@screens/Tabs/Me/Root' -import ScreenMeListsList from '@screens/Tabs/Me/Root/Lists/List' -import ScreenMeSettings from '@screens/Tabs/Me/Settings' -import ScreenMeSwitch from '@screens/Tabs/Me/Switch' -import sharedScreens from '@screens/Tabs/Shared/sharedScreens' import React from 'react' import { useTranslation } from 'react-i18next' import { Platform } from 'react-native' import { createNativeStackNavigator } from 'react-native-screens/native-stack' -import ScreenMeSettingsFontsize from './Me/Fontsize' -import ScreenMeSettingsPush from './Me/Push' +import TabMeBookmarks from './Me/Bookmarks' +import TabMeConversations from './Me/Cconversations' +import TabMeFavourites from './Me/Favourites' +import TabMeLists from './Me/Lists' +import TabMeListsList from './Me/ListsList' +import TabMeProfile from './Me/Profile' +import TabMePush from './Me/Push' +import TabMeRoot from './Me/Root' +import TabMeSettings from './Me/Settings' +import TabMeSettingsFontsize from './Me/SettingsFontsize' +import TabMeSwitch from './Me/Switch' +import sharedScreens from './Shared/sharedScreens' const Stack = createNativeStackNavigator() @@ -27,7 +28,7 @@ const TabMe = React.memo( > ({ headerTitle: t('me.stacks.bookmarks.name'), ...(Platform.OS === 'android' && { @@ -49,7 +50,7 @@ const TabMe = React.memo( /> ({ headerTitle: t('me.stacks.conversations.name'), ...(Platform.OS === 'android' && { @@ -62,7 +63,7 @@ const TabMe = React.memo( /> ({ headerTitle: t('me.stacks.favourites.name'), ...(Platform.OS === 'android' && { @@ -75,7 +76,7 @@ const TabMe = React.memo( /> ({ headerTitle: t('me.stacks.lists.name'), ...(Platform.OS === 'android' && { @@ -88,7 +89,7 @@ const TabMe = React.memo( /> ({ headerTitle: t('me.stacks.list.name', { list: route.params.title }), ...(Platform.OS === 'android' && { @@ -103,9 +104,30 @@ const TabMe = React.memo( headerLeft: () => navigation.pop(1)} /> })} /> + + ({ + headerTitle: t('me.stacks.push.name'), + ...(Platform.OS === 'android' && { + headerCenter: () => ( + + ) + }), + headerLeft: () => navigation.pop(1)} /> + })} + /> ({ headerTitle: t('me.stacks.settings.name'), ...(Platform.OS === 'android' && { @@ -118,7 +140,7 @@ const TabMe = React.memo( /> ({ headerTitle: t('me.stacks.fontSize.name'), ...(Platform.OS === 'android' && { @@ -129,22 +151,9 @@ const TabMe = React.memo( headerLeft: () => navigation.pop(1)} /> })} /> - ({ - headerTitle: t('me.stacks.push.name'), - ...(Platform.OS === 'android' && { - headerCenter: () => ( - - ) - }), - headerLeft: () => navigation.pop(1)} /> - })} - /> { const queryKey: QueryKeyTimeline = ['Timeline', { page: 'Bookmarks' }] const renderItem = useCallback( @@ -15,4 +15,4 @@ const ScreenMeBookmarks = React.memo( () => true ) -export default ScreenMeBookmarks +export default TabMeBookmarks diff --git a/src/screens/Tabs/Me/Cconversations.tsx b/src/screens/Tabs/Me/Cconversations.tsx index 28884ed2..65eecd6d 100644 --- a/src/screens/Tabs/Me/Cconversations.tsx +++ b/src/screens/Tabs/Me/Cconversations.tsx @@ -3,7 +3,7 @@ import TimelineConversation from '@components/Timeline/Conversation' import { QueryKeyTimeline } from '@utils/queryHooks/timeline' import React, { useCallback } from 'react' -const ScreenMeConversations = React.memo( +const TabMeConversations = React.memo( () => { const queryKey: QueryKeyTimeline = ['Timeline', { page: 'Conversations' }] const renderItem = useCallback( @@ -18,4 +18,4 @@ const ScreenMeConversations = React.memo( () => true ) -export default ScreenMeConversations +export default TabMeConversations diff --git a/src/screens/Tabs/Me/Favourites.tsx b/src/screens/Tabs/Me/Favourites.tsx index 58bcfe2a..fe799f19 100644 --- a/src/screens/Tabs/Me/Favourites.tsx +++ b/src/screens/Tabs/Me/Favourites.tsx @@ -3,7 +3,7 @@ import TimelineDefault from '@components/Timeline/Default' import { QueryKeyTimeline } from '@utils/queryHooks/timeline' import React, { useCallback } from 'react' -const ScreenMeFavourites = React.memo( +const TabMeFavourites = React.memo( () => { const queryKey: QueryKeyTimeline = ['Timeline', { page: 'Favourites' }] const renderItem = useCallback( @@ -16,4 +16,4 @@ const ScreenMeFavourites = React.memo( () => true ) -export default ScreenMeFavourites +export default TabMeFavourites diff --git a/src/screens/Tabs/Me/Lists.tsx b/src/screens/Tabs/Me/Lists.tsx index 352a4256..233c65e0 100644 --- a/src/screens/Tabs/Me/Lists.tsx +++ b/src/screens/Tabs/Me/Lists.tsx @@ -3,7 +3,7 @@ import { StackScreenProps } from '@react-navigation/stack' import { useListsQuery } from '@utils/queryHooks/lists' import React from 'react' -const ScreenMeLists: React.FC> = ({ navigation }) => { @@ -28,4 +28,4 @@ const ScreenMeLists: React.FC> = ({ @@ -21,4 +21,4 @@ const ScreenMeListsList: React.FC } -export default ScreenMeListsList +export default TabMeListsList diff --git a/src/screens/Tabs/Me/Profile.tsx b/src/screens/Tabs/Me/Profile.tsx new file mode 100644 index 00000000..4f2052a6 --- /dev/null +++ b/src/screens/Tabs/Me/Profile.tsx @@ -0,0 +1,116 @@ +import { HeaderCenter, HeaderLeft } from '@components/Header' +import { Message } from '@components/Message' +import { StackScreenProps } from '@react-navigation/stack' +import React, { useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { KeyboardAvoidingView, Platform } from 'react-native' +import FlashMessage from 'react-native-flash-message' +import { createNativeStackNavigator } from 'react-native-screens/native-stack' +import ScreenMeProfileFields from './Profile/Fields' +import ScreenMeProfileName from './Profile/Name' +import ScreenMeProfileNote from './Profile/Note' +import ScreenMeProfileRoot from './Profile/Root' + +const Stack = createNativeStackNavigator() + +const TabMeProfile: React.FC> = ({ navigation }) => { + const { t } = useTranslation('screenTabs') + const messageRef = useRef(null) + + return ( + + + ( + + ) + }), + headerLeft: () => ( + navigation.goBack()} + /> + ) + }} + /> + ( + + ) + }) + }} + > + {({ route, navigation }) => ( + + )} + + ( + + ) + }) + }} + > + {({ route, navigation }) => ( + + )} + + ( + + ) + }) + }} + > + {({ route, navigation }) => ( + + )} + + + + + + ) +} + +export default TabMeProfile diff --git a/src/screens/Tabs/Me/Profile/Fields.tsx b/src/screens/Tabs/Me/Profile/Fields.tsx new file mode 100644 index 00000000..479fa72e --- /dev/null +++ b/src/screens/Tabs/Me/Profile/Fields.tsx @@ -0,0 +1,168 @@ +import { HeaderLeft, HeaderRight } from '@components/Header' +import Input from '@components/Input' +import { displayMessage } from '@components/Message' +import { StackScreenProps } from '@react-navigation/stack' +import { useProfileMutation } from '@utils/queryHooks/profile' +import { StyleConstants } from '@utils/styles/constants' +import { useTheme } from '@utils/styles/ThemeManager' +import { isEqual } from 'lodash' +import React, { RefObject, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Alert, StyleSheet, Text, View } from 'react-native' +import FlashMessage from 'react-native-flash-message' +import { ScrollView } from 'react-native-gesture-handler' + +const prepareFields = ( + fields: Mastodon.Field[] | undefined +): Mastodon.Field[] => { + return Array.from(Array(4).keys()).map(index => { + if (fields && fields[index]) { + return fields[index] + } else { + return { name: '', value: '', verified_at: null } + } + }) +} + +const ScreenMeProfileFields: React.FC & { messageRef: RefObject }> = ({ + messageRef, + route: { + params: { fields } + }, + navigation +}) => { + const { mode, theme } = useTheme() + const { t, i18n } = useTranslation('screenTabs') + const { mutateAsync, status } = useProfileMutation() + + const [newFields, setNewFields] = useState(prepareFields(fields)) + + const [dirty, setDirty] = useState(false) + useEffect(() => { + setDirty(!isEqual(prepareFields(fields), newFields)) + }, [newFields]) + + useEffect(() => { + navigation.setOptions({ + headerLeft: () => ( + { + if (dirty) { + Alert.alert( + t('me.profile.cancellation.title'), + t('me.profile.cancellation.message'), + [ + { + text: t('me.profile.cancellation.buttons.cancel'), + style: 'default' + }, + { + text: t('me.profile.cancellation.buttons.discard'), + style: 'destructive', + onPress: () => navigation.navigate('Tab-Me-Profile-Root') + } + ] + ) + } else { + navigation.navigate('Tab-Me-Profile-Root') + } + }} + /> + ), + headerRight: () => ( + { + mutateAsync({ + type: 'fields_attributes', + data: newFields + .filter(field => field.name.length && field.value.length) + .map(field => ({ name: field.name, value: field.value })) + }) + .then(() => { + navigation.navigate('Tab-Me-Profile-Root') + displayMessage({ + ref: messageRef, + message: t('me.profile.feedback.succeed', { + type: t('me.profile.root.note.title') + }), + mode, + type: 'success' + }) + }) + .catch(() => { + displayMessage({ + ref: messageRef, + message: t('me.profile.feedback.failed', { + type: t('me.profile.root.note.title') + }), + mode, + type: 'error' + }) + }) + }} + /> + ) + }) + }, [mode, i18n.language, dirty, status, newFields]) + + return ( + + {Array.from(Array(4).keys()).map(index => ( + + + {t('me.profile.fields.group', { index: index + 1 })} + + + setNewFields( + newFields.map((field, i) => + i === index ? { ...field, name: v } : field + ) + ) + } + emoji + /> + + setNewFields( + newFields.map((field, i) => + i === index ? { ...field, value: v } : field + ) + ) + } + emoji + /> + + ))} + + ) +} + +const styles = StyleSheet.create({ + base: { + padding: StyleConstants.Spacing.Global.PagePadding + }, + group: { + marginBottom: StyleConstants.Spacing.M + }, + headline: { + ...StyleConstants.FontStyle.S, + marginBottom: StyleConstants.Spacing.XS + } +}) + +export default ScreenMeProfileFields diff --git a/src/screens/Tabs/Me/Profile/Name.tsx b/src/screens/Tabs/Me/Profile/Name.tsx new file mode 100644 index 00000000..f462e014 --- /dev/null +++ b/src/screens/Tabs/Me/Profile/Name.tsx @@ -0,0 +1,109 @@ +import { HeaderLeft, HeaderRight } from '@components/Header' +import Input from '@components/Input' +import { displayMessage } from '@components/Message' +import { StackScreenProps } from '@react-navigation/stack' +import { useProfileMutation } from '@utils/queryHooks/profile' +import { StyleConstants } from '@utils/styles/constants' +import { useTheme } from '@utils/styles/ThemeManager' +import React, { RefObject, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Alert, StyleSheet } from 'react-native' +import FlashMessage from 'react-native-flash-message' +import { ScrollView } from 'react-native-gesture-handler' + +const ScreenMeProfileName: React.FC & { messageRef: RefObject }> = ({ + messageRef, + route: { + params: { display_name } + }, + navigation +}) => { + const { mode } = useTheme() + const { t, i18n } = useTranslation('screenTabs') + const { mutateAsync, status } = useProfileMutation() + + const [displayName, setDisplayName] = useState(display_name) + + const [dirty, setDirty] = useState(false) + useEffect(() => { + setDirty(display_name !== displayName) + }, [displayName]) + + useEffect(() => { + navigation.setOptions({ + headerLeft: () => ( + { + if (dirty) { + Alert.alert( + t('me.profile.cancellation.title'), + t('me.profile.cancellation.message'), + [ + { + text: t('me.profile.cancellation.buttons.cancel'), + style: 'default' + }, + { + text: t('me.profile.cancellation.buttons.discard'), + style: 'destructive', + onPress: () => navigation.navigate('Tab-Me-Profile-Root') + } + ] + ) + } else { + navigation.navigate('Tab-Me-Profile-Root') + } + }} + /> + ), + headerRight: () => ( + { + mutateAsync({ type: 'display_name', data: displayName }) + .then(() => { + navigation.navigate('Tab-Me-Profile-Root') + displayMessage({ + ref: messageRef, + message: t('me.profile.feedback.succeed', { + type: t('me.profile.root.name.title') + }), + mode, + type: 'success' + }) + }) + .catch(() => { + displayMessage({ + ref: messageRef, + message: t('me.profile.feedback.failed', { + type: t('me.profile.root.name.title') + }), + mode, + type: 'error' + }) + }) + }} + /> + ) + }) + }, [mode, i18n.language, dirty, status, displayName]) + + return ( + + + + ) +} + +const styles = StyleSheet.create({ + base: { + padding: StyleConstants.Spacing.Global.PagePadding + } +}) + +export default ScreenMeProfileName diff --git a/src/screens/Tabs/Me/Profile/Note.tsx b/src/screens/Tabs/Me/Profile/Note.tsx new file mode 100644 index 00000000..8c961285 --- /dev/null +++ b/src/screens/Tabs/Me/Profile/Note.tsx @@ -0,0 +1,109 @@ +import { HeaderLeft, HeaderRight } from '@components/Header' +import Input from '@components/Input' +import { displayMessage } from '@components/Message' +import { StackScreenProps } from '@react-navigation/stack' +import { useProfileMutation } from '@utils/queryHooks/profile' +import { StyleConstants } from '@utils/styles/constants' +import { useTheme } from '@utils/styles/ThemeManager' +import React, { RefObject, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Alert, StyleSheet } from 'react-native' +import FlashMessage from 'react-native-flash-message' +import { ScrollView } from 'react-native-gesture-handler' + +const ScreenMeProfileNote: React.FC & { messageRef: RefObject }> = ({ + messageRef, + route: { + params: { note } + }, + navigation +}) => { + const { mode } = useTheme() + const { t, i18n } = useTranslation('screenTabs') + const { mutateAsync, status } = useProfileMutation() + + const [newNote, setNewNote] = useState(note) + + const [dirty, setDirty] = useState(false) + useEffect(() => { + setDirty(note !== newNote) + }, [newNote]) + + useEffect(() => { + navigation.setOptions({ + headerLeft: () => ( + { + if (dirty) { + Alert.alert( + t('me.profile.cancellation.title'), + t('me.profile.cancellation.message'), + [ + { + text: t('me.profile.cancellation.buttons.cancel'), + style: 'default' + }, + { + text: t('me.profile.cancellation.buttons.discard'), + style: 'destructive', + onPress: () => navigation.navigate('Tab-Me-Profile-Root') + } + ] + ) + } else { + navigation.navigate('Tab-Me-Profile-Root') + } + }} + /> + ), + headerRight: () => ( + { + mutateAsync({ type: 'note', data: newNote }) + .then(() => { + navigation.navigate('Tab-Me-Profile-Root') + displayMessage({ + ref: messageRef, + message: t('me.profile.feedback.succeed', { + type: t('me.profile.root.note.title') + }), + mode, + type: 'success' + }) + }) + .catch(() => { + displayMessage({ + ref: messageRef, + message: t('me.profile.feedback.failed', { + type: t('me.profile.root.note.title') + }), + mode, + type: 'error' + }) + }) + }} + /> + ) + }) + }, [mode, i18n.language, dirty, status, newNote]) + + return ( + + + + ) +} + +const styles = StyleSheet.create({ + base: { + padding: StyleConstants.Spacing.Global.PagePadding + } +}) + +export default ScreenMeProfileNote diff --git a/src/screens/Tabs/Me/Profile/Root.tsx b/src/screens/Tabs/Me/Profile/Root.tsx new file mode 100644 index 00000000..6a5d13e8 --- /dev/null +++ b/src/screens/Tabs/Me/Profile/Root.tsx @@ -0,0 +1,187 @@ +import { MenuContainer, MenuRow } from '@components/Menu' +import { useActionSheet } from '@expo/react-native-action-sheet' +import { StackScreenProps } from '@react-navigation/stack' +import { useProfileMutation, useProfileQuery } from '@utils/queryHooks/profile' +import React, { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { ScrollView } from 'react-native-gesture-handler' + +const ScreenMeProfileRoot: React.FC> = ({ navigation }) => { + const { t } = useTranslation('screenTabs') + + const { showActionSheetWithOptions } = useActionSheet() + + const { data, isLoading } = useProfileQuery({}) + const { mutate } = useProfileMutation() + + const onPressVisibility = useCallback(() => { + showActionSheetWithOptions( + { + title: t('me.profile.root.visibility.title'), + options: [ + t('me.profile.root.visibility.options.public'), + t('me.profile.root.visibility.options.unlisted'), + t('me.profile.root.visibility.options.private'), + t('me.profile.root.visibility.options.direct'), + t('me.profile.root.visibility.options.cancel') + ], + cancelButtonIndex: 4 + }, + async buttonIndex => { + switch (buttonIndex) { + case 0: + mutate({ type: 'source[privacy]', data: 'public' }) + break + case 1: + mutate({ type: 'source[privacy]', data: 'unlisted' }) + break + case 2: + mutate({ type: 'source[privacy]', data: 'private' }) + break + case 3: + mutate({ type: 'source[privacy]', data: 'direct' }) + break + } + } + ) + }, []) + + const onPressSensitive = useCallback(() => { + if (data?.source.sensitive === undefined) { + mutate({ type: 'source[sensitive]', data: true }) + } else { + mutate({ type: 'source[sensitive]', data: !data.source.sensitive }) + } + }, [data?.source.sensitive]) + + const onPressLock = useCallback(() => { + if (data?.locked === undefined) { + mutate({ type: 'locked', data: true }) + } else { + mutate({ type: 'locked', data: !data.locked }) + } + }, [data?.locked]) + + const onPressBot = useCallback(() => { + if (data?.bot === undefined) { + mutate({ type: 'bot', data: true }) + } else { + mutate({ type: 'bot', data: !data?.bot }) + } + }, [data?.bot]) + + return ( + + + { + data && + navigation.navigate('Tab-Me-Profile-Name', { + display_name: data.display_name + }) + }} + /> + + // } + // loading={isLoading} + // iconBack='ChevronRight' + /> + + // } + // loading={isLoading} + // iconBack='ChevronRight' + /> + { + navigation.navigate('Tab-Me-Profile-Note', { + note: data?.source?.note || '' + }) + }} + /> + { + navigation.navigate('Tab-Me-Profile-Fields', { + fields: data?.source.fields + }) + }} + /> + + + + + + + + + + + ) +} + +export default ScreenMeProfileRoot diff --git a/src/screens/Tabs/Me/Push.tsx b/src/screens/Tabs/Me/Push.tsx index e33c82cd..d4b80746 100644 --- a/src/screens/Tabs/Me/Push.tsx +++ b/src/screens/Tabs/Me/Push.tsx @@ -2,7 +2,12 @@ import { MenuContainer, MenuRow } from '@components/Menu' import { updateInstancePush } from '@utils/slices/instances/updatePush' import { updateInstancePushAlert } from '@utils/slices/instances/updatePushAlert' import { updateInstancePushDecode } from '@utils/slices/instances/updatePushDecode' -import { clearPushLoading, getInstancePush } from '@utils/slices/instancesSlice' +import { + clearPushLoading, + getInstanceAccount, + getInstancePush, + getInstanceUri +} from '@utils/slices/instancesSlice' import * as WebBrowser from 'expo-web-browser' import * as Notifications from 'expo-notifications' import React, { useEffect, useMemo, useState } from 'react' @@ -13,9 +18,18 @@ import layoutAnimation from '@utils/styles/layoutAnimation' import Button from '@components/Button' import { StyleConstants } from '@utils/styles/constants' import { AppState, Linking } from 'react-native' +import { StackScreenProps } from '@react-navigation/stack' -const ScreenMeSettingsPush: React.FC = () => { +const TabMePush: React.FC> = () => { const { t } = useTranslation('screenTabs') + const instanceAccount = useSelector( + getInstanceAccount, + (prev, next) => prev?.acct === next?.acct + ) + const instanceUri = useSelector(getInstanceUri) const dispatch = useDispatch() const instancePush = useSelector(getInstancePush) @@ -106,7 +120,9 @@ const ScreenMeSettingsPush: React.FC = () => { ) : null} { ) } -export default ScreenMeSettingsPush +export default TabMePush diff --git a/src/screens/Tabs/Me/Root.tsx b/src/screens/Tabs/Me/Root.tsx index b73915a0..5da98fe8 100644 --- a/src/screens/Tabs/Me/Root.tsx +++ b/src/screens/Tabs/Me/Root.tsx @@ -4,31 +4,25 @@ import Collections from '@screens/Tabs/Me/Root/Collections' import Logout from '@screens/Tabs/Me/Root/Logout' import MyInfo from '@screens/Tabs/Me/Root/MyInfo' import Settings from '@screens/Tabs/Me/Root/Settings' +import AccountInformationSwitch from '@screens/Tabs/Me/Root/Switch' import AccountNav from '@screens/Tabs/Shared/Account/Nav' import AccountContext from '@screens/Tabs/Shared/Account/utils/createContext' import accountInitialState from '@screens/Tabs/Shared/Account/utils/initialState' import accountReducer from '@screens/Tabs/Shared/Account/utils/reducer' -import { useAccountQuery } from '@utils/queryHooks/account' -import { - getInstanceAccount, - getInstanceActive -} from '@utils/slices/instancesSlice' +import { useProfileQuery } from '@utils/queryHooks/profile' +import { getInstanceActive } from '@utils/slices/instancesSlice' import React, { useReducer, useRef } from 'react' import Animated, { useAnimatedScrollHandler, useSharedValue } from 'react-native-reanimated' import { useSelector } from 'react-redux' +import Update from './Root/Update' -const ScreenMeRoot: React.FC = () => { +const TabMeRoot: React.FC = () => { const instanceActive = useSelector(getInstanceActive) - const instanceAccount = useSelector( - getInstanceAccount, - (prev, next) => prev?.id === next?.id - ) - const { data } = useAccountQuery({ - // @ts-ignore - id: instanceAccount?.id, + + const { data } = useProfileQuery({ options: { enabled: instanceActive !== -1, keepPreviousData: false } }) @@ -62,11 +56,13 @@ const ScreenMeRoot: React.FC = () => { )} {instanceActive !== -1 ? : null} + + {instanceActive !== -1 ? : null} {instanceActive !== -1 ? : null} ) } -export default ScreenMeRoot +export default TabMeRoot diff --git a/src/screens/Tabs/Me/Root/Logout.tsx b/src/screens/Tabs/Me/Root/Logout.tsx index b0415ff8..a59dc0e4 100644 --- a/src/screens/Tabs/Me/Root/Logout.tsx +++ b/src/screens/Tabs/Me/Root/Logout.tsx @@ -21,7 +21,7 @@ const Logout: React.FC = () => { content={t('me.root.logout.button')} style={{ marginHorizontal: StyleConstants.Spacing.Global.PagePadding * 2, - marginBottom: StyleConstants.Spacing.Global.PagePadding * 2 + marginTop: StyleConstants.Spacing.Global.PagePadding }} destructive onPress={() => diff --git a/src/screens/Tabs/Me/Root/MyInfo.tsx b/src/screens/Tabs/Me/Root/MyInfo.tsx index ce5e2b57..9a29eaef 100644 --- a/src/screens/Tabs/Me/Root/MyInfo.tsx +++ b/src/screens/Tabs/Me/Root/MyInfo.tsx @@ -9,7 +9,7 @@ export interface Props { const MyInfo: React.FC = ({ account }) => { return ( <> - + ) diff --git a/src/screens/Tabs/Shared/Account/Information/Switch.tsx b/src/screens/Tabs/Me/Root/Switch.tsx similarity index 70% rename from src/screens/Tabs/Shared/Account/Information/Switch.tsx rename to src/screens/Tabs/Me/Root/Switch.tsx index ec71a98f..b3c337d6 100644 --- a/src/screens/Tabs/Shared/Account/Information/Switch.tsx +++ b/src/screens/Tabs/Me/Root/Switch.tsx @@ -1,5 +1,6 @@ import Button from '@components/Button' import { useNavigation } from '@react-navigation/native' +import { StyleConstants } from '@utils/styles/constants' import React from 'react' import { useTranslation } from 'react-i18next' @@ -11,6 +12,10 @@ const AccountInformationSwitch: React.FC = () => {