Partially fixed #113

This commit is contained in:
Zhiyuan Zheng 2021-05-09 21:59:03 +02:00
parent 006edd5c87
commit 0b659913dc
No known key found for this signature in database
GPG Key ID: 078A93AB607D85E0
62 changed files with 2308 additions and 703 deletions

View File

@ -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 = {

View File

@ -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']
}
}
}

View File

@ -168,7 +168,7 @@ const Screens: React.FC<Props> = ({ localCorrupt }) => {
options={{
stackPresentation: 'transparentModal',
stackAnimation: 'fade',
headerShown: false // Android
headerShown: false
}}
/>
<Stack.Screen
@ -177,7 +177,7 @@ const Screens: React.FC<Props> = ({ localCorrupt }) => {
options={{
stackPresentation: 'transparentModal',
stackAnimation: 'fade',
headerShown: false // Android
headerShown: false
}}
/>
<Stack.Screen
@ -185,7 +185,7 @@ const Screens: React.FC<Props> = ({ localCorrupt }) => {
component={ScreenCompose}
options={{
stackPresentation: 'fullScreenModal',
headerShown: false // Android
headerShown: false
}}
/>
<Stack.Screen
@ -194,7 +194,7 @@ const Screens: React.FC<Props> = ({ localCorrupt }) => {
options={{
stackPresentation: 'fullScreenModal',
stackAnimation: 'fade',
headerShown: false // Android
headerShown: false
}}
/>
</Stack.Navigator>
@ -206,6 +206,3 @@ const Screens: React.FC<Props> = ({ localCorrupt }) => {
}
export default React.memo(Screens, () => true)
function toast (arg0: { type: string; content: string; autoHide: boolean }) {
throw new Error('Function not implemented.')
}

View File

@ -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 <T = unknown>({
body,
sentry = false
}: Params): Promise<{ body: T }> => {
if (!domain) {
return Promise.reject()
}
console.log(
ctx.bgGreen.bold(' API general ') +
' ' +

View File

@ -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?: {

View File

@ -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<Props> = ({
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<Props> = ({
}
}, [mode, content, loading, disabled])
enum spacingMapping {
XS = 'S',
S = 'M',
M = 'L',
L = 'XL'
}
const [layoutHeight, setLayoutHeight] = useState<number | undefined>()
return (
<Pressable
@ -161,10 +153,15 @@ const Button: React.FC<Props> = ({
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<Props> = ({
const styles = StyleSheet.create({
button: {
borderRadius: 100,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center'
}

161
src/components/Emojis.tsx Normal file
View File

@ -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<EmojisState['emojis']>
}
| {
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<EmojisAction>
}
const EmojisContext = createContext<ContextType>({} 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<SetStateAction<string | undefined>>
| Dispatch<SetStateAction<string>>
selectionRange: MutableRefObject<{
start: number
end: number
}>
}
const ComponentEmojis: React.FC<Props> = ({
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 (
<EmojisContext.Provider
value={{ emojisState, emojisDispatch }}
children={children}
/>
)
}
export { ComponentEmojis, EmojisContext, EmojisButton, EmojisList }

View File

@ -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 ? (
<Pressable
disabled={!emojisState.emojis || !emojisState.emojis.length}
onPress={() =>
emojisDispatch({ type: 'activate', payload: !emojisState.active })
}
hitSlop={StyleConstants.Spacing.S}
style={styles.base}
children={
<Icon
name={
emojisState.emojis && emojisState.emojis.length
? emojisState.active
? 'Type'
: 'Smile'
: 'Meh'
}
size={StyleConstants.Font.Size.L}
color={
emojisState.emojis && emojisState.emojis.length
? theme.primaryDefault
: theme.disabled
}
/>
}
/>
) : null
},
() => true
)
const styles = StyleSheet.create({
base: {
paddingLeft: StyleConstants.Spacing.S
}
})
export default EmojisButton

View File

@ -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 } }) => (
<Text style={[styles.group, { color: theme.secondary }]}>{title}</Text>
),
[]
)
const listItem = useCallback(
({ index, item }: { item: Mastodon.Emoji[]; index: number }) => {
return (
<View key={index} style={styles.emojis}>
{item.map(emoji => {
const uri = reduceMotionEnabled ? emoji.static_url : emoji.url
if (validUrl.isHttpsUri(uri)) {
return (
<Pressable
key={emoji.shortcode}
onPress={() =>
emojisDispatch({
type: 'shortcode',
payload: `:${emoji.shortcode}:`
})
}
>
<FastImage
accessibilityLabel={t(
'common:customEmoji.accessibilityLabel',
{
emoji: emoji.shortcode
}
)}
accessibilityHint={t(
'screenCompose:content.root.footer.emojis.accessibilityHint'
)}
source={{ uri }}
style={styles.emoji}
/>
</Pressable>
)
} else {
return null
}
})}
</View>
)
},
[]
)
const listRef = useRef<SectionList>(null)
useEffect(() => {
layoutAnimation()
const tagEmojis = findNodeHandle(listRef.current)
if (emojisState.active) {
tagEmojis && AccessibilityInfo.setAccessibilityFocus(tagEmojis)
}
}, [emojisState.active])
return emojisState.active ? (
<SectionList
accessible
ref={listRef}
horizontal
keyboardShouldPersistTaps='always'
sections={emojisState.emojis}
keyExtractor={item => 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

163
src/components/Input.tsx Normal file
View File

@ -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<SetStateAction<string | undefined>>
| Dispatch<SetStateAction<string>>
}
const Input: React.FC<Props> = ({
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 (
<ComponentEmojis
enabled={emoji}
value={value}
setValue={setValue}
selectionRange={selectionRange}
>
<View style={[styles.base, { borderColor: theme.border }]}>
<EmojisContext.Consumer>
{({ emojisDispatch }) => (
<TextInput
autoFocus={autoFocus}
onFocus={() => 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
})}
/>
)}
</EmojisContext.Consumer>
{title ? (
<Animated.Text
style={[styles.title, animateTitle, { color: theme.secondary }]}
>
{title}
</Animated.Text>
) : null}
{maxLength && value?.length ? (
<Text style={[styles.maxLength, { color: theme.secondary }]}>
{value?.length} / {maxLength}
</Text>
) : null}
{inputFocused ? <EmojisButton /> : null}
</View>
<EmojisList />
</ComponentEmojis>
)
}
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

View File

@ -7,16 +7,13 @@ export interface Props {
}
const MenuContainer: React.FC<Props> = ({ children }) => {
return (
<View style={styles.base}>
{children}
</View>
)
return <View style={styles.base}>{children}</View>
}
const styles = StyleSheet.create({
base: {
marginBottom: StyleConstants.Spacing.L
paddingHorizontal: StyleConstants.Spacing.Global.PagePadding,
marginBottom: StyleConstants.Spacing.Global.PagePadding
}
})

View File

@ -19,8 +19,6 @@ const MenuHeader: React.FC<Props> = ({ heading }) => {
const styles = StyleSheet.create({
base: {
paddingLeft: StyleConstants.Spacing.Global.PagePadding,
paddingRight: StyleConstants.Spacing.Global.PagePadding,
paddingBottom: StyleConstants.Spacing.S
},
text: {

View File

@ -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<Props> = ({
title,
description,
content,
badge = false,
switchValue,
switchDisabled,
switchOnValueChange,
@ -84,6 +86,17 @@ const MenuRow: React.FC<Props> = ({
style={styles.iconFront}
/>
)}
{badge ? (
<View
style={{
width: 8,
height: 8,
backgroundColor: theme.red,
borderRadius: 8,
marginRight: StyleConstants.Spacing.S
}}
/>
) : null}
<View style={styles.main}>
<Text
style={[styles.title, { color: theme.primaryDefault }]}
@ -147,12 +160,12 @@ const MenuRow: React.FC<Props> = ({
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

View File

@ -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<FlashMessage>
duration?: 'short' | 'long'
autoHide?: boolean
message: string
@ -29,6 +31,7 @@ const displayMessage = ({
type?: undefined
}
| {
ref?: RefObject<FlashMessage>
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 (
<Icon
name={iconMapping[type]}
size={StyleConstants.Font.LineHeight.M}
color={getTheme(mode)[colorMapping[type]]}
style={{ marginRight: StyleConstants.Spacing.S }}
/>
)
}
})
})
if (ref) {
ref.current?.showMessage({
duration: type === 'error' ? 5000 : duration === 'short' ? 1500 : 3000,
autoHide,
message,
description,
onPress,
...(mode &&
type && {
renderFlashMessageIcon: () => {
return (
<Icon
name={iconMapping[type]}
size={StyleConstants.Font.LineHeight.M}
color={getTheme(mode)[colorMapping[type]]}
style={{ marginRight: StyleConstants.Spacing.S }}
/>
)
}
})
})
} else {
showMessage({
duration: type === 'error' ? 5000 : duration === 'short' ? 1500 : 3000,
autoHide,
message,
description,
onPress,
...(mode &&
type && {
renderFlashMessageIcon: () => {
return (
<Icon
name={iconMapping[type]}
size={StyleConstants.Font.LineHeight.M}
color={getTheme(mode)[colorMapping[type]]}
style={{ marginRight: StyleConstants.Spacing.S }}
/>
)
}
})
})
}
}
const removeMessage = () => {
// if (ref) {
// ref.current?.hideMessage()
// } else {
hideMessage()
// }
}
const Message = React.memo(
() => {
const { mode, theme } = useTheme()
const Message = React.forwardRef<FlashMessage>((_, ref) => {
const { mode, theme } = useTheme()
return (
<FlashMessage
icon='auto'
position='top'
floating
style={{
backgroundColor: theme.backgroundDefault,
shadowColor: theme.primaryDefault,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: mode === 'light' ? 0.16 : 0.24,
shadowRadius: 4
}}
titleStyle={{
color: theme.primaryDefault,
...StyleConstants.FontStyle.M,
fontWeight: StyleConstants.Font.Weight.Bold
}}
textStyle={{
color: theme.primaryDefault,
...StyleConstants.FontStyle.S
}}
// @ts-ignore
textProps={{ numberOfLines: 2 }}
/>
)
},
() => true
)
return (
<FlashMessage
ref={ref}
icon='auto'
position='top'
floating
style={{
backgroundColor: theme.backgroundDefault,
shadowColor: theme.primaryDefault,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: mode === 'light' ? 0.16 : 0.24,
shadowRadius: 4
}}
titleStyle={{
color: theme.primaryDefault,
...StyleConstants.FontStyle.M,
fontWeight: StyleConstants.Font.Weight.Bold
}}
textStyle={{
color: theme.primaryDefault,
...StyleConstants.FontStyle.S
}}
// @ts-ignore
textProps={{ numberOfLines: 2 }}
/>
)
})
export { Message, displayMessage, removeMessage }

View File

@ -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<any> => {
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

View File

@ -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'),

View File

@ -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)"
}
}
}
}

View File

@ -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",

View File

@ -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",

View File

@ -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<ComposeAction>
@ -22,35 +21,33 @@ const addAttachment = async ({
composeDispatch,
showActionSheetWithOptions
}: Props): Promise<any> => {
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

View File

@ -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 (
<Tab.Navigator
initialRouteName={instanceActive !== -1 ? previousTab : 'Tab-Me'}
@ -128,7 +143,7 @@ const ScreenTabs = React.memo(
listeners={composeListeners}
/>
<Tab.Screen name='Tab-Notifications' component={TabNotifications} />
<Tab.Screen name='Tab-Me' component={TabMe} />
<Tab.Screen name='Tab-Me' component={TabMe} options={tabMeOptions} />
</Tab.Navigator>
)
},

View File

@ -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<Nav.TabMeStackParamList>()
@ -27,7 +28,7 @@ const TabMe = React.memo(
>
<Stack.Screen
name='Tab-Me-Root'
component={ScreenMeRoot}
component={TabMeRoot}
options={{
headerTranslucent: true,
headerStyle: { backgroundColor: 'rgba(255, 255, 255, 0)' },
@ -36,7 +37,7 @@ const TabMe = React.memo(
/>
<Stack.Screen
name='Tab-Me-Bookmarks'
component={ScreenMeBookmarks}
component={TabMeBookmarks}
options={({ navigation }: any) => ({
headerTitle: t('me.stacks.bookmarks.name'),
...(Platform.OS === 'android' && {
@ -49,7 +50,7 @@ const TabMe = React.memo(
/>
<Stack.Screen
name='Tab-Me-Conversations'
component={ScreenMeConversations}
component={TabMeConversations}
options={({ navigation }: any) => ({
headerTitle: t('me.stacks.conversations.name'),
...(Platform.OS === 'android' && {
@ -62,7 +63,7 @@ const TabMe = React.memo(
/>
<Stack.Screen
name='Tab-Me-Favourites'
component={ScreenMeFavourites}
component={TabMeFavourites}
options={({ navigation }: any) => ({
headerTitle: t('me.stacks.favourites.name'),
...(Platform.OS === 'android' && {
@ -75,7 +76,7 @@ const TabMe = React.memo(
/>
<Stack.Screen
name='Tab-Me-Lists'
component={ScreenMeLists}
component={TabMeLists}
options={({ navigation }: any) => ({
headerTitle: t('me.stacks.lists.name'),
...(Platform.OS === 'android' && {
@ -88,7 +89,7 @@ const TabMe = React.memo(
/>
<Stack.Screen
name='Tab-Me-Lists-List'
component={ScreenMeListsList}
component={TabMeListsList}
options={({ route, navigation }: any) => ({
headerTitle: t('me.stacks.list.name', { list: route.params.title }),
...(Platform.OS === 'android' && {
@ -103,9 +104,30 @@ const TabMe = React.memo(
headerLeft: () => <HeaderLeft onPress={() => navigation.pop(1)} />
})}
/>
<Stack.Screen
name='Tab-Me-Profile'
component={TabMeProfile}
options={{
stackPresentation: 'modal',
headerShown: false
}}
/>
<Stack.Screen
name='Tab-Me-Push'
component={TabMePush}
options={({ navigation }: any) => ({
headerTitle: t('me.stacks.push.name'),
...(Platform.OS === 'android' && {
headerCenter: () => (
<HeaderCenter content={t('me.stacks.push.name')} />
)
}),
headerLeft: () => <HeaderLeft onPress={() => navigation.pop(1)} />
})}
/>
<Stack.Screen
name='Tab-Me-Settings'
component={ScreenMeSettings}
component={TabMeSettings}
options={({ navigation }: any) => ({
headerTitle: t('me.stacks.settings.name'),
...(Platform.OS === 'android' && {
@ -118,7 +140,7 @@ const TabMe = React.memo(
/>
<Stack.Screen
name='Tab-Me-Settings-Fontsize'
component={ScreenMeSettingsFontsize}
component={TabMeSettingsFontsize}
options={({ navigation }: any) => ({
headerTitle: t('me.stacks.fontSize.name'),
...(Platform.OS === 'android' && {
@ -129,22 +151,9 @@ const TabMe = React.memo(
headerLeft: () => <HeaderLeft onPress={() => navigation.pop(1)} />
})}
/>
<Stack.Screen
name='Tab-Me-Settings-Push'
component={ScreenMeSettingsPush}
options={({ navigation }: any) => ({
headerTitle: t('me.stacks.push.name'),
...(Platform.OS === 'android' && {
headerCenter: () => (
<HeaderCenter content={t('me.stacks.push.name')} />
)
}),
headerLeft: () => <HeaderLeft onPress={() => navigation.pop(1)} />
})}
/>
<Stack.Screen
name='Tab-Me-Switch'
component={ScreenMeSwitch}
component={TabMeSwitch}
options={{
stackPresentation: 'modal',
headerShown: false

View File

@ -3,7 +3,7 @@ import TimelineDefault from '@components/Timeline/Default'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import React, { useCallback } from 'react'
const ScreenMeBookmarks = React.memo(
const TabMeBookmarks = React.memo(
() => {
const queryKey: QueryKeyTimeline = ['Timeline', { page: 'Bookmarks' }]
const renderItem = useCallback(
@ -15,4 +15,4 @@ const ScreenMeBookmarks = React.memo(
() => true
)
export default ScreenMeBookmarks
export default TabMeBookmarks

View File

@ -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

View File

@ -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

View File

@ -3,7 +3,7 @@ import { StackScreenProps } from '@react-navigation/stack'
import { useListsQuery } from '@utils/queryHooks/lists'
import React from 'react'
const ScreenMeLists: React.FC<StackScreenProps<
const TabMeLists: React.FC<StackScreenProps<
Nav.TabMeStackParamList,
'Tab-Me-Lists'
>> = ({ navigation }) => {
@ -28,4 +28,4 @@ const ScreenMeLists: React.FC<StackScreenProps<
)
}
export default ScreenMeLists
export default TabMeLists

View File

@ -4,7 +4,7 @@ import { StackScreenProps } from '@react-navigation/stack'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import React, { useCallback } from 'react'
const ScreenMeListsList: React.FC<StackScreenProps<
const TabMeListsList: React.FC<StackScreenProps<
Nav.TabMeStackParamList,
'Tab-Me-Lists-List'
>> = ({
@ -21,4 +21,4 @@ const ScreenMeListsList: React.FC<StackScreenProps<
return <Timeline queryKey={queryKey} customProps={{ renderItem }} />
}
export default ScreenMeListsList
export default TabMeListsList

View File

@ -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<Nav.TabMeProfileStackParamList>()
const TabMeProfile: React.FC<StackScreenProps<
Nav.TabMeStackParamList,
'Tab-Me-Switch'
>> = ({ navigation }) => {
const { t } = useTranslation('screenTabs')
const messageRef = useRef<FlashMessage>(null)
return (
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<Stack.Navigator
screenOptions={{
headerHideShadow: true,
headerTopInsetEnabled: false
}}
>
<Stack.Screen
name='Tab-Me-Profile-Root'
component={ScreenMeProfileRoot}
options={{
headerTitle: t('me.stacks.profile.name'),
...(Platform.OS === 'android' && {
headerCenter: () => (
<HeaderCenter content={t('me.stacks.profile.name')} />
)
}),
headerLeft: () => (
<HeaderLeft
content='ChevronDown'
onPress={() => navigation.goBack()}
/>
)
}}
/>
<Stack.Screen
name='Tab-Me-Profile-Name'
options={{
headerTitle: t('me.stacks.profileName.name'),
...(Platform.OS === 'android' && {
headerCenter: () => (
<HeaderCenter content={t('me.stacks.profileName.name')} />
)
})
}}
>
{({ route, navigation }) => (
<ScreenMeProfileName
messageRef={messageRef}
route={route}
navigation={navigation}
/>
)}
</Stack.Screen>
<Stack.Screen
name='Tab-Me-Profile-Note'
options={{
headerTitle: t('me.stacks.profileNote.name'),
...(Platform.OS === 'android' && {
headerCenter: () => (
<HeaderCenter content={t('me.stacks.profileNote.name')} />
)
})
}}
>
{({ route, navigation }) => (
<ScreenMeProfileNote
messageRef={messageRef}
route={route}
navigation={navigation}
/>
)}
</Stack.Screen>
<Stack.Screen
name='Tab-Me-Profile-Fields'
options={{
headerTitle: t('me.stacks.profileFields.name'),
...(Platform.OS === 'android' && {
headerCenter: () => (
<HeaderCenter content={t('me.stacks.profileFields.name')} />
)
})
}}
>
{({ route, navigation }) => (
<ScreenMeProfileFields
messageRef={messageRef}
route={route}
navigation={navigation}
/>
)}
</Stack.Screen>
</Stack.Navigator>
<Message ref={messageRef} />
</KeyboardAvoidingView>
)
}
export default TabMeProfile

View File

@ -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<StackScreenProps<
Nav.TabMeProfileStackParamList,
'Tab-Me-Profile-Fields'
> & { messageRef: RefObject<FlashMessage> }> = ({
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: () => (
<HeaderLeft
onPress={() => {
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: () => (
<HeaderRight
disabled={!dirty}
loading={status === 'loading'}
content='Save'
onPress={async () => {
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 (
<ScrollView style={styles.base}>
{Array.from(Array(4).keys()).map(index => (
<View key={index} style={styles.group}>
<Text style={[styles.headline, { color: theme.primaryDefault }]}>
{t('me.profile.fields.group', { index: index + 1 })}
</Text>
<Input
title={t('me.profile.fields.label')}
autoFocus={false}
maxLength={255}
value={newFields[index].name}
setValue={(v: any) =>
setNewFields(
newFields.map((field, i) =>
i === index ? { ...field, name: v } : field
)
)
}
emoji
/>
<Input
title={t('me.profile.fields.content')}
autoFocus={false}
maxLength={255}
value={newFields[index].value}
setValue={(v: any) =>
setNewFields(
newFields.map((field, i) =>
i === index ? { ...field, value: v } : field
)
)
}
emoji
/>
</View>
))}
</ScrollView>
)
}
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

View File

@ -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<StackScreenProps<
Nav.TabMeProfileStackParamList,
'Tab-Me-Profile-Name'
> & { messageRef: RefObject<FlashMessage> }> = ({
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: () => (
<HeaderLeft
onPress={() => {
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: () => (
<HeaderRight
disabled={!dirty}
loading={status === 'loading'}
content='Save'
onPress={async () => {
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 (
<ScrollView style={styles.base}>
<Input value={displayName} setValue={setDisplayName} emoji />
</ScrollView>
)
}
const styles = StyleSheet.create({
base: {
padding: StyleConstants.Spacing.Global.PagePadding
}
})
export default ScreenMeProfileName

View File

@ -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<StackScreenProps<
Nav.TabMeProfileStackParamList,
'Tab-Me-Profile-Note'
> & { messageRef: RefObject<FlashMessage> }> = ({
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: () => (
<HeaderLeft
onPress={() => {
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: () => (
<HeaderRight
disabled={!dirty}
loading={status === 'loading'}
content='Save'
onPress={async () => {
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 (
<ScrollView style={styles.base}>
<Input value={newNote} setValue={setNewNote} multiline emoji />
</ScrollView>
)
}
const styles = StyleSheet.create({
base: {
padding: StyleConstants.Spacing.Global.PagePadding
}
})
export default ScreenMeProfileNote

View File

@ -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<StackScreenProps<
Nav.TabMeProfileStackParamList,
'Tab-Me-Profile-Root'
>> = ({ 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 (
<ScrollView>
<MenuContainer>
<MenuRow
title={t('me.profile.root.name.title')}
content={data?.display_name}
loading={isLoading}
iconBack='ChevronRight'
onPress={() => {
data &&
navigation.navigate('Tab-Me-Profile-Name', {
display_name: data.display_name
})
}}
/>
<MenuRow
title={t('me.profile.root.avatar.title')}
description={t('me.profile.root.avatar.description')}
// content={
// <GracefullyImage
// style={{ flex: 1 }}
// uri={{
// original: data?.avatar_static
// }}
// />
// }
// loading={isLoading}
// iconBack='ChevronRight'
/>
<MenuRow
title={t('me.profile.root.banner.title')}
description={t('me.profile.root.banner.description')}
// content={
// <GracefullyImage
// style={{ flex: 1 }}
// uri={{
// original: data?.header_static
// }}
// />
// }
// loading={isLoading}
// iconBack='ChevronRight'
/>
<MenuRow
title={t('me.profile.root.note.title')}
content={data?.source.note}
loading={isLoading}
iconBack='ChevronRight'
onPress={() => {
navigation.navigate('Tab-Me-Profile-Note', {
note: data?.source?.note || ''
})
}}
/>
<MenuRow
title={t('me.profile.root.fields.title')}
content={
data?.source.fields && data.source.fields.length
? t('me.profile.root.fields.total', {
count: data.source.fields.length
})
: undefined
}
loading={isLoading}
iconBack='ChevronRight'
onPress={() => {
navigation.navigate('Tab-Me-Profile-Fields', {
fields: data?.source.fields
})
}}
/>
</MenuContainer>
<MenuContainer>
<MenuRow
title={t('me.profile.root.visibility.title')}
content={
data?.source.privacy
? t(`me.profile.root.visibility.options.${data?.source.privacy}`)
: undefined
}
loading={isLoading}
iconBack='ChevronRight'
onPress={onPressVisibility}
/>
<MenuRow
title={t('me.profile.root.sensitive.title')}
switchValue={data?.source.sensitive}
switchOnValueChange={onPressSensitive}
loading={isLoading}
/>
</MenuContainer>
<MenuContainer>
<MenuRow
title={t('me.profile.root.lock.title')}
description={t('me.profile.root.lock.description')}
switchValue={data?.locked}
switchOnValueChange={onPressLock}
loading={isLoading}
/>
<MenuRow
title={t('me.profile.root.bot.title')}
description={t('me.profile.root.bot.description')}
switchValue={data?.bot}
switchOnValueChange={onPressBot}
loading={isLoading}
/>
</MenuContainer>
</ScrollView>
)
}
export default ScreenMeProfileRoot

View File

@ -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<StackScreenProps<
Nav.TabMeStackParamList,
'Tab-Me-Push'
>> = () => {
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}
<MenuContainer>
<MenuRow
title={t('me.push.global.heading')}
title={t('me.push.global.heading', {
acct: `@${instanceAccount?.acct}@${instanceUri}`
})}
description={t('me.push.global.description')}
loading={instancePush?.global.loading}
switchDisabled={!pushEnabled || isLoading}
@ -144,4 +160,4 @@ const ScreenMeSettingsPush: React.FC = () => {
)
}
export default ScreenMeSettingsPush
export default TabMePush

View File

@ -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 = () => {
<ComponentInstance />
)}
{instanceActive !== -1 ? <Collections /> : null}
<Update />
<Settings />
{instanceActive !== -1 ? <AccountInformationSwitch /> : null}
{instanceActive !== -1 ? <Logout /> : null}
</Animated.ScrollView>
</AccountContext.Provider>
)
}
export default ScreenMeRoot
export default TabMeRoot

View File

@ -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={() =>

View File

@ -9,7 +9,7 @@ export interface Props {
const MyInfo: React.FC<Props> = ({ account }) => {
return (
<>
<AccountHeader account={account} limitHeight />
<AccountHeader account={account} />
<AccountInformation account={account} myInfo />
</>
)

View File

@ -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 = () => {
<Button
type='text'
content={t('me.stacks.switch.name')}
style={{
marginHorizontal: StyleConstants.Spacing.Global.PagePadding * 2,
marginTop: StyleConstants.Spacing.Global.PagePadding
}}
onPress={() => navigation.navigate('Tab-Me-Switch')}
/>
)

View File

@ -0,0 +1,32 @@
import { MenuContainer, MenuRow } from '@components/Menu'
import { getVersionUpdate } from '@utils/slices/versionSlice'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { Linking, Platform } from 'react-native'
import { useSelector } from 'react-redux'
const Update: React.FC = () => {
const { t } = useTranslation('screenTabs')
const versionUpdate = useSelector(getVersionUpdate)
return versionUpdate ? (
<MenuContainer>
<MenuRow
iconFront='ChevronsUp'
iconBack='ExternalLink'
title={t('me.root.update.title')}
badge
onPress={() => {
if (Platform.OS === 'ios') {
Linking.openURL('itms-appss://itunes.apple.com/app/id1549772269')
} else {
Linking.openURL('https://tooot.app')
}
}}
/>
</MenuContainer>
) : null
}
export default Update

View File

@ -6,7 +6,7 @@ import SettingsApp from './Settings/App'
import SettingsDev from './Settings/Dev'
import SettingsTooot from './Settings/Tooot'
const ScreenMeSettings: React.FC = () => {
const TabMeSettings: React.FC = () => {
return (
<ScrollView>
<SettingsApp />
@ -23,4 +23,4 @@ const ScreenMeSettings: React.FC = () => {
)
}
export default ScreenMeSettings
export default TabMeSettings

View File

@ -5,10 +5,10 @@ import {
} from '@utils/slices/settingsSlice'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import Constants from 'expo-constants'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, Text } from 'react-native'
import { Constants } from 'react-native-unimodules'
import { useDispatch, useSelector } from 'react-redux'
const SettingsAnalytics: React.FC = () => {

View File

@ -5,11 +5,7 @@ import { useActionSheet } from '@expo/react-native-action-sheet'
import { useNavigation } from '@react-navigation/native'
import { LOCALES } from '@root/i18n/locales'
import androidDefaults from '@utils/slices/instances/push/androidDefaults'
import {
getInstanceActive,
getInstancePush,
getInstances
} from '@utils/slices/instancesSlice'
import { getInstances } from '@utils/slices/instancesSlice'
import {
changeBrowser,
changeLanguage,
@ -24,7 +20,7 @@ import React from 'react'
import { useTranslation } from 'react-i18next'
import { Platform } from 'react-native'
import { useDispatch, useSelector } from 'react-redux'
import { mapFontsizeToName } from '../Fontsize'
import { mapFontsizeToName } from '../SettingsFontsize'
const SettingsApp: React.FC = () => {
const navigation = useNavigation()
@ -34,43 +30,22 @@ const SettingsApp: React.FC = () => {
const { t, i18n } = useTranslation('screenTabs')
const instances = useSelector(getInstances, () => true)
const instanceActive = useSelector(getInstanceActive)
const settingsFontsize = useSelector(getSettingsFontsize)
const settingsTheme = useSelector(getSettingsTheme)
const settingsBrowser = useSelector(getSettingsBrowser)
const instancePush = useSelector(
getInstancePush,
(prev, next) => prev?.global.value === next?.global.value
)
return (
<MenuContainer>
{instanceActive !== -1 ? (
<>
<MenuRow
title={t('me.settings.push.heading')}
content={
instancePush?.global.value
? t('me.settings.push.content.enabled')
: t('me.settings.push.content.disabled')
}
iconBack='ChevronRight'
onPress={() => {
navigation.navigate('Tab-Me-Settings-Push')
}}
/>
<MenuRow
title={t('me.settings.fontsize.heading')}
content={t(
`me.settings.fontsize.content.${mapFontsizeToName(settingsFontsize)}`
)}
iconBack='ChevronRight'
onPress={() => {
navigation.navigate('Tab-Me-Settings-Fontsize')
}}
/>
</>
) : null}
<MenuRow
title={t('me.settings.fontsize.heading')}
content={t(
`me.settings.fontsize.content.${mapFontsizeToName(settingsFontsize)}`
)}
iconBack='ChevronRight'
onPress={() => {
navigation.navigate('Tab-Me-Settings-Fontsize')
}}
/>
<MenuRow
title={t('me.settings.language.heading')}
// @ts-ignore

View File

@ -1,5 +1,6 @@
import Button from '@components/Button'
import { MenuContainer, MenuRow } from '@components/Menu'
import { displayMessage } from '@components/Message'
import { useActionSheet } from '@expo/react-native-action-sheet'
import { persistor } from '@root/store'
import { getInstanceActive, getInstances } from '@utils/slices/instancesSlice'
@ -50,12 +51,21 @@ const SettingsDev: React.FC = () => {
)
}
/>
<Button
type='text'
content={'Test flash message'}
style={{
marginHorizontal: StyleConstants.Spacing.Global.PagePadding * 2,
marginBottom: StyleConstants.Spacing.Global.PagePadding
}}
onPress={() => displayMessage({ message: 'This is a testing message' })}
/>
<Button
type='text'
content={'Purge secure storage'}
style={{
marginHorizontal: StyleConstants.Spacing.Global.PagePadding * 2,
marginBottom: StyleConstants.Spacing.Global.PagePadding * 2
marginBottom: StyleConstants.Spacing.Global.PagePadding
}}
destructive
onPress={() => persistor.purge()}

View File

@ -32,7 +32,7 @@ export const mapFontsizeToName = (size: SettingsState['fontsize']) => {
}
}
const ScreenMeSettingsFontsize: React.FC<StackScreenProps<
const TabMeSettingsFontsize: React.FC<StackScreenProps<
Nav.TabMeStackParamList,
'Tab-Me-Settings-Fontsize'
>> = () => {
@ -183,4 +183,4 @@ const styles = StyleSheet.create({
}
})
export default ScreenMeSettingsFontsize
export default TabMeSettingsFontsize

View File

@ -8,7 +8,7 @@ import ScreenMeSwitchRoot from './Switch/Root'
const Stack = createNativeStackNavigator()
const ScreenMeSwitch: React.FC<StackScreenProps<
const TabMeSwitch: React.FC<StackScreenProps<
Nav.TabMeStackParamList,
'Tab-Me-Switch'
>> = ({ navigation }) => {
@ -44,4 +44,4 @@ const ScreenMeSwitch: React.FC<StackScreenProps<
)
}
export default ScreenMeSwitch
export default TabMeSwitch

View File

@ -5,7 +5,7 @@ import TimelineDefault from '@components/Timeline/Default'
import { useAccountQuery } from '@utils/queryHooks/account'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useEffect, useMemo, useReducer } from 'react'
import React, { useCallback, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, View } from 'react-native'
import { useSharedValue } from 'react-native-reanimated'
@ -13,9 +13,6 @@ import AccountAttachments from './Account/Attachments'
import AccountHeader from './Account/Header'
import AccountInformation from './Account/Information'
import AccountNav from './Account/Nav'
import AccountContext from './Account/utils/createContext'
import accountInitialState from './Account/utils/initialState'
import accountReducer from './Account/utils/reducer'
import { SharedAccountProp } from './sharedScreens'
const TabSharedAccount: React.FC<SharedAccountProp> = ({
@ -30,10 +27,6 @@ const TabSharedAccount: React.FC<SharedAccountProp> = ({
const { data } = useAccountQuery({ id: account.id })
const scrollY = useSharedValue(0)
const [accountState, accountDispatch] = useReducer(
accountReducer,
accountInitialState
)
useEffect(() => {
const updateHeaderRight = () =>
@ -86,7 +79,7 @@ const TabSharedAccount: React.FC<SharedAccountProp> = ({
)
return (
<AccountContext.Provider value={{ accountState, accountDispatch }}>
<>
<AccountNav scrollY={scrollY} account={data} />
<Timeline
@ -98,7 +91,7 @@ const TabSharedAccount: React.FC<SharedAccountProp> = ({
ListHeaderComponent
}}
/>
</AccountContext.Provider>
</>
)
}

View File

@ -1,36 +1,50 @@
import Button from '@components/Button'
import { useAccessibility } from '@utils/accessibility/AccessibilityManager'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useContext } from 'react'
import { Dimensions, Image } from 'react-native'
import React from 'react'
import { Dimensions, Image, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import AccountContext from './utils/createContext'
export interface Props {
account?: Mastodon.Account
limitHeight?: boolean
edit?: boolean
}
const AccountHeader: React.FC<Props> = ({ account }) => {
const { accountState } = useContext(AccountContext)
const { reduceMotionEnabled } = useAccessibility()
const { theme } = useTheme()
const topInset = useSafeAreaInsets().top
const AccountHeader = React.memo(
({ account, edit }: Props) => {
const { reduceMotionEnabled } = useAccessibility()
const { theme } = useTheme()
const topInset = useSafeAreaInsets().top
return (
<Image
source={{
uri: reduceMotionEnabled ? account?.header_static : account?.header
}}
style={{
height:
Dimensions.get('screen').width * accountState.headerRatio + topInset,
backgroundColor: theme.disabled
}}
/>
)
}
export default React.memo(
AccountHeader,
return (
<View>
<Image
source={{
uri: reduceMotionEnabled ? account?.header_static : account?.header
}}
style={{
height: Dimensions.get('screen').width / 3 + topInset,
backgroundColor: theme.disabled
}}
/>
{edit ? (
<View
style={{
position: 'absolute',
width: '100%',
height: '100%',
alignContent: 'center',
justifyContent: 'center',
alignItems: 'center'
}}
>
<Button type='icon' content='Edit' round onPress={() => {}} />
</View>
) : null}
</View>
)
},
(_, next) => next.account === undefined
)
export default AccountHeader

View File

@ -1,9 +1,7 @@
import { getInstanceAccount } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback } from 'react'
import { StyleSheet, View } from 'react-native'
import { useSelector } from 'react-redux'
import { Placeholder, Fade } from 'rn-placeholder'
import AccountInformationAccount from './Information/Account'
import AccountInformationActions from './Information/Actions'
@ -11,69 +9,59 @@ import AccountInformationAvatar from './Information/Avatar'
import AccountInformationCreated from './Information/Created'
import AccountInformationFields from './Information/Fields'
import AccountInformationName from './Information/Name'
import AccountInformationNotes from './Information/Notes'
import AccountInformationNote from './Information/Note'
import AccountInformationStats from './Information/Stats'
import AccountInformationSwitch from './Information/Switch'
export interface Props {
account: Mastodon.Account | undefined
myInfo?: boolean // Showing from my info page
}
const AccountInformation: React.FC<Props> = ({ account, myInfo = false }) => {
const ownAccount =
account?.id ===
useSelector(getInstanceAccount, (prev, next) => prev?.id === next?.id)?.id
const { mode, theme } = useTheme()
const AccountInformation = React.memo(
({ account, myInfo = false }: Props) => {
const { mode, theme } = useTheme()
const animation = useCallback(
props => (
<Fade {...props} style={{ backgroundColor: theme.shimmerHighlight }} />
),
[mode]
)
const animation = useCallback(
props => (
<Fade {...props} style={{ backgroundColor: theme.shimmerHighlight }} />
),
[mode]
)
return (
<View style={styles.base}>
<Placeholder Animation={animation}>
<View style={styles.avatarAndActions}>
<AccountInformationAvatar account={account} myInfo={myInfo} />
<View style={styles.actions}>
{myInfo ? (
<AccountInformationSwitch />
) : (
<AccountInformationActions
account={account}
ownAccount={ownAccount}
/>
)}
return (
<View style={styles.base}>
<Placeholder Animation={animation}>
<View style={styles.avatarAndActions}>
<AccountInformationAvatar account={account} myInfo={myInfo} />
<AccountInformationActions account={account} myInfo={myInfo} />
</View>
</View>
<AccountInformationName account={account} />
<AccountInformationName account={account} />
<AccountInformationAccount account={account} myInfo={myInfo} />
<AccountInformationAccount account={account} localInstance={myInfo} />
{!myInfo ? (
<>
{account?.fields && account.fields.length > 0 ? (
<AccountInformationFields account={account} />
) : null}
{account?.note &&
account.note.length > 0 &&
account.note !== '<p></p>' ? (
// Empty notes might generate empty p tag
<AccountInformationNotes account={account} />
) : null}
<AccountInformationCreated account={account} />
</>
) : null}
<AccountInformationFields account={account} myInfo={myInfo} />
<AccountInformationStats account={account} myInfo={myInfo} />
</Placeholder>
</View>
)
}
<AccountInformationNote account={account} myInfo={myInfo} />
<AccountInformationCreated account={account} hidden={myInfo} />
<AccountInformationStats account={account} />
</Placeholder>
</View>
)
},
(prev, next) => {
let skipUpdate = true
if (prev.account?.id !== next.account?.id) {
skipUpdate = false
}
if (prev.account?.acct === next.account?.acct) {
skipUpdate = false
}
return skipUpdate
}
)
const styles = StyleSheet.create({
base: {
@ -90,13 +78,4 @@ const styles = StyleSheet.create({
}
})
export default React.memo(AccountInformation, (prev, next) => {
let skipUpdate = true
if (prev.account?.id !== next.account?.id) {
skipUpdate = false
}
if (prev.account?.acct === next.account?.acct) {
skipUpdate = false
}
return skipUpdate
})
export default AccountInformation

View File

@ -12,10 +12,10 @@ import { PlaceholderLine } from 'rn-placeholder'
export interface Props {
account: Mastodon.Account | undefined
myInfo: boolean
localInstance: boolean
}
const AccountInformationAccount: React.FC<Props> = ({ account, myInfo }) => {
const AccountInformationAccount: React.FC<Props> = ({ account, localInstance }) => {
const { theme } = useTheme()
const instanceAccount = useSelector(
getInstanceAccount,
@ -48,7 +48,7 @@ const AccountInformationAccount: React.FC<Props> = ({ account, myInfo }) => {
}
}, [account?.moved])
if (account || (myInfo && instanceAccount)) {
if (account || (localInstance && instanceAccount)) {
return (
<View
style={[styles.base, { flexDirection: 'row', alignItems: 'center' }]}
@ -63,8 +63,8 @@ const AccountInformationAccount: React.FC<Props> = ({ account, myInfo }) => {
]}
selectable
>
@{myInfo ? instanceAccount?.acct : account?.acct}
{myInfo ? `@${instanceUri}` : null}
@{localInstance ? instanceAccount?.acct : account?.acct}
{localInstance ? `@${instanceUri}` : null}
</Text>
{movedContent}
{account?.locked ? (

View File

@ -2,34 +2,21 @@ import analytics from '@components/analytics'
import Button from '@components/Button'
import { RelationshipOutgoing } from '@components/Relationship'
import { useNavigation } from '@react-navigation/native'
import { StackNavigationProp } from '@react-navigation/stack'
import { useRelationshipQuery } from '@utils/queryHooks/relationship'
import {
getInstanceAccount,
getInstancePush,
getInstanceUri
} from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet } from 'react-native'
import { StyleSheet, View } from 'react-native'
import { useSelector } from 'react-redux'
export interface Props {
account: Mastodon.Account | undefined
ownAccount: boolean
}
const GoToMoved = ({ accountMoved }: { accountMoved: Mastodon.Account }) => {
const { t } = useTranslation('screenTabs')
const navigation = useNavigation<
StackNavigationProp<Nav.TabLocalStackParamList>
>()
return (
<Button
type='text'
content={t('shared.account.moved')}
onPress={() => {
analytics('account_gotomoved_press')
navigation.push('Tab-Shared-Account', { account: accountMoved })
}}
/>
)
myInfo?: boolean
}
const Conversation = ({ account }: { account: Mastodon.Account }) => {
@ -41,7 +28,7 @@ const Conversation = ({ account }: { account: Mastodon.Account }) => {
round
type='icon'
content='Mail'
style={styles.actionConversation}
style={styles.actionLeft}
onPress={() => {
analytics('account_DM_press')
navigation.navigate('Screen-Compose', {
@ -53,24 +40,76 @@ const Conversation = ({ account }: { account: Mastodon.Account }) => {
) : null
}
const AccountInformationActions: React.FC<Props> = ({
account,
ownAccount
}) => {
return account && account.id ? (
account.moved ? (
<GoToMoved accountMoved={account.moved} />
) : !ownAccount ? (
<>
const AccountInformationActions: React.FC<Props> = ({ account, myInfo }) => {
const { t } = useTranslation('screenTabs')
const navigation = useNavigation()
if (account?.moved) {
const accountMoved = account.moved
return (
<View style={styles.base}>
<Button
type='text'
content={t('shared.account.moved')}
onPress={() => {
analytics('account_gotomoved_press')
// @ts-ignore
navigation.push('Tab-Shared-Account', { account: accountMoved })
}}
/>
</View>
)
}
const instancePush = useSelector(
getInstancePush,
(prev, next) => prev?.global.value === next?.global.value
)
const instanceUri = useSelector(getInstanceUri)
if (myInfo) {
return (
<View style={styles.base}>
<Button
round
type='icon'
content={instancePush?.global.value ? 'Bell' : 'BellOff'}
style={styles.actionLeft}
onPress={() => navigation.navigate('Tab-Me-Push')}
/>
<Button
type='text'
disabled={account === undefined}
content={t('me.stacks.profile.name')}
onPress={() => navigation.navigate('Tab-Me-Profile')}
/>
</View>
)
}
const instanceAccount = useSelector(getInstanceAccount, () => true)
const ownAccount =
account?.id === instanceAccount?.id &&
account?.acct === instanceAccount?.acct
if (!ownAccount && account) {
return (
<View style={styles.base}>
<Conversation account={account} />
<RelationshipOutgoing id={account.id} />
</>
) : null
) : null
</View>
)
} else {
return null
}
}
const styles = StyleSheet.create({
actionConversation: { marginRight: StyleConstants.Spacing.S }
base: {
alignSelf: 'flex-end',
flexDirection: 'row'
},
actionLeft: { marginRight: StyleConstants.Spacing.S }
})
export default AccountInformationActions

View File

@ -1,18 +1,24 @@
import analytics from '@components/analytics'
import Button from '@components/Button'
import GracefullyImage from '@components/GracefullyImage'
import { useNavigation } from '@react-navigation/native'
import { StackNavigationProp } from '@react-navigation/stack'
import { useAccessibility } from '@utils/accessibility/AccessibilityManager'
import { StyleConstants } from '@utils/styles/constants'
import React from 'react'
import { Pressable, StyleSheet } from 'react-native'
import { Pressable, StyleSheet, View } from 'react-native'
export interface Props {
account: Mastodon.Account | undefined
myInfo: boolean
edit?: boolean
}
const AccountInformationAvatar: React.FC<Props> = ({ account, myInfo }) => {
const AccountInformationAvatar: React.FC<Props> = ({
account,
myInfo,
edit
}) => {
const navigation = useNavigation<
StackNavigationProp<Nav.TabLocalStackParamList>
>()
@ -36,6 +42,20 @@ const AccountInformationAvatar: React.FC<Props> = ({ account, myInfo }) => {
: account?.avatar
}}
/>
{edit ? (
<View
style={{
position: 'absolute',
width: '100%',
height: '100%',
alignContent: 'center',
justifyContent: 'center',
alignItems: 'center'
}}
>
<Button type='icon' content='Edit' round onPress={() => {}} />
</View>
) : null}
</Pressable>
)
}

View File

@ -8,55 +8,63 @@ import { PlaceholderLine } from 'rn-placeholder'
export interface Props {
account: Mastodon.Account | undefined
hidden?: boolean
}
const AccountInformationCreated: React.FC<Props> = ({ account }) => {
const { i18n } = useTranslation()
const { theme } = useTheme()
const { t } = useTranslation('screenTabs')
const AccountInformationCreated = React.memo(
({ account, hidden = false }: Props) => {
if (hidden) {
return null
}
if (account) {
return (
<View
style={[styles.base, { flexDirection: 'row', alignItems: 'center' }]}
>
<Icon
name='Calendar'
size={StyleConstants.Font.Size.S}
color={theme.secondary}
style={styles.icon}
/>
<Text
style={{
color: theme.secondary,
...StyleConstants.FontStyle.S
}}
const { i18n } = useTranslation()
const { theme } = useTheme()
const { t } = useTranslation('screenTabs')
if (account) {
return (
<View
style={[styles.base, { flexDirection: 'row', alignItems: 'center' }]}
>
{t('shared.account.created_at', {
date: new Date(account.created_at || '').toLocaleDateString(
i18n.language,
{
year: 'numeric',
month: 'long',
day: 'numeric'
}
)
})}
</Text>
</View>
)
} else {
return (
<PlaceholderLine
width={StyleConstants.Font.Size.S * 4}
height={StyleConstants.Font.LineHeight.S}
color={theme.shimmerDefault}
noMargin
style={styles.base}
/>
)
}
}
<Icon
name='Calendar'
size={StyleConstants.Font.Size.S}
color={theme.secondary}
style={styles.icon}
/>
<Text
style={{
color: theme.secondary,
...StyleConstants.FontStyle.S
}}
>
{t('shared.account.created_at', {
date: new Date(account.created_at || '').toLocaleDateString(
i18n.language,
{
year: 'numeric',
month: 'long',
day: 'numeric'
}
)
})}
</Text>
</View>
)
} else {
return (
<PlaceholderLine
width={StyleConstants.Font.Size.S * 4}
height={StyleConstants.Font.LineHeight.S}
color={theme.shimmerDefault}
noMargin
style={styles.base}
/>
)
}
},
(_, next) => next.account === undefined
)
const styles = StyleSheet.create({
base: {
@ -68,7 +76,4 @@ const styles = StyleSheet.create({
}
})
export default React.memo(
AccountInformationCreated,
(_, next) => next.account === undefined
)
export default AccountInformationCreated

View File

@ -6,11 +6,16 @@ import React from 'react'
import { StyleSheet, View } from 'react-native'
export interface Props {
account: Mastodon.Account
account: Mastodon.Account | undefined
myInfo?: boolean
}
const AccountInformationFields = React.memo(
({ account }: Props) => {
({ account, myInfo }: Props) => {
if (myInfo || !account?.fields || account.fields.length === 0) {
return null
}
const { theme } = useTheme()
return (
@ -88,3 +93,6 @@ const styles = StyleSheet.create({
})
export default AccountInformationFields
function htmlToText (note: string): any {
throw new Error('Function not implemented.')
}

View File

@ -1,26 +1,19 @@
import Input from '@components/Input'
import { ParseEmojis } from '@components/Parse'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useMemo } from 'react'
import React, { useMemo, useState } from 'react'
import { StyleSheet, Text, View } from 'react-native'
import { PlaceholderLine } from 'rn-placeholder'
export interface Props {
account: Mastodon.Account | undefined
edit?: boolean // Editing mode
}
const AccountInformationName: React.FC<Props> = ({ account }) => {
const AccountInformationName: React.FC<Props> = ({ account, edit }) => {
const { theme } = useTheme()
const movedStyle = useMemo(
() =>
StyleSheet.create({
base: {
textDecorationLine: account?.moved ? 'line-through' : undefined
}
}),
[account?.moved]
)
const movedContent = useMemo(() => {
if (account?.moved) {
return (
@ -36,20 +29,30 @@ const AccountInformationName: React.FC<Props> = ({ account }) => {
}
}, [account?.moved])
const [displatName, setDisplayName] = useState(account?.display_name)
return (
<View style={[styles.base, { flexDirection: 'row' }]}>
{account ? (
<>
<Text style={movedStyle.base}>
<ParseEmojis
content={account.display_name || account.username}
emojis={account.emojis}
size='L'
fontBold
/>
</Text>
{movedContent}
</>
edit ? (
<Input title='昵称' value={displatName} setValue={setDisplayName} />
) : (
<>
<Text
style={{
textDecorationLine: account?.moved ? 'line-through' : undefined
}}
>
<ParseEmojis
content={account.display_name || account.username}
emojis={account.emojis}
size='L'
fontBold
/>
</Text>
{movedContent}
</>
)
) : (
<PlaceholderLine
width={StyleConstants.Font.Size.L * 2}

View File

@ -0,0 +1,44 @@
import Input from '@components/Input'
import { ParseHTML } from '@components/Parse'
import { StyleConstants } from '@utils/styles/constants'
import React, { useState } from 'react'
import { StyleSheet, View } from 'react-native'
export interface Props {
account: Mastodon.Account | undefined
myInfo?: boolean
edit?: boolean
}
const AccountInformationNote = React.memo(
({ account, myInfo, edit }: Props) => {
const [note, setNote] = useState(account?.source?.note)
if (edit) {
return <Input title='简介' value={note} setValue={setNote} multiline />
}
if (
myInfo ||
!account?.note ||
account.note.length === 0 ||
account.note === '<p></p>'
) {
return null
}
return (
<View style={styles.note}>
<ParseHTML content={account.note!} size={'M'} emojis={account.emojis} />
</View>
)
},
() => true
)
const styles = StyleSheet.create({
note: {
marginBottom: StyleConstants.Spacing.L
}
})
export default AccountInformationNote

View File

@ -1,27 +0,0 @@
import { ParseHTML } from '@components/Parse'
import { StyleConstants } from '@utils/styles/constants'
import React from 'react'
import { StyleSheet, View } from 'react-native'
export interface Props {
account: Mastodon.Account
}
const AccountInformationNotes = React.memo(
({ account }: Props) => {
return (
<View style={styles.note}>
<ParseHTML content={account.note!} size={'M'} emojis={account.emojis} />
</View>
)
},
() => true
)
const styles = StyleSheet.create({
note: {
marginBottom: StyleConstants.Spacing.L
}
})
export default AccountInformationNotes

View File

@ -10,10 +10,9 @@ import { PlaceholderLine } from 'rn-placeholder'
export interface Props {
account: Mastodon.Account | undefined
myInfo: boolean
}
const AccountInformationStats: React.FC<Props> = ({ account, myInfo }) => {
const AccountInformationStats: React.FC<Props> = ({ account }) => {
const navigation = useNavigation<
StackNavigationProp<Nav.TabLocalStackParamList>
>()
@ -28,12 +27,6 @@ const AccountInformationStats: React.FC<Props> = ({ account, myInfo }) => {
children={t('shared.account.summary.statuses_count', {
count: account.statuses_count || 0
})}
onPress={() => {
analytics('account_stats_toots_press', {
count: account.statuses_count
})
myInfo && navigation.push('Tab-Shared-Account', { account })
}}
/>
) : (
<PlaceholderLine
@ -46,7 +39,10 @@ const AccountInformationStats: React.FC<Props> = ({ account, myInfo }) => {
)}
{account ? (
<Text
style={[styles.stat, { color: theme.primaryDefault, textAlign: 'right' }]}
style={[
styles.stat,
{ color: theme.primaryDefault, textAlign: 'right' }
]}
children={t('shared.account.summary.following_count', {
count: account.following_count
})}
@ -73,7 +69,10 @@ const AccountInformationStats: React.FC<Props> = ({ account, myInfo }) => {
)}
{account ? (
<Text
style={[styles.stat, { color: theme.primaryDefault, textAlign: 'center' }]}
style={[
styles.stat,
{ color: theme.primaryDefault, textAlign: 'center' }
]}
children={t('shared.account.summary.followers_count', {
count: account.followers_count
})}

View File

@ -1,7 +1,7 @@
import { ParseEmojis } from '@components/Parse'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useContext } from 'react'
import React from 'react'
import { Dimensions, StyleSheet, Text, View } from 'react-native'
import Animated, {
Extrapolate,
@ -9,73 +9,74 @@ import Animated, {
useAnimatedStyle
} from 'react-native-reanimated'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import AccountContext from './utils/createContext'
export interface Props {
scrollY: Animated.SharedValue<number>
account: Mastodon.Account | undefined
}
const AccountNav: React.FC<Props> = ({ scrollY, account }) => {
const { accountState } = useContext(AccountContext)
const { theme } = useTheme()
const headerHeight = useSafeAreaInsets().top + 44
const AccountNav = React.memo(
({ scrollY, account }: Props) => {
const { theme } = useTheme()
const headerHeight = useSafeAreaInsets().top + 44
const nameY =
Dimensions.get('screen').width * accountState.headerRatio +
StyleConstants.Avatar.L -
StyleConstants.Spacing.Global.PagePadding * 2 +
StyleConstants.Spacing.M -
headerHeight
const nameY =
Dimensions.get('screen').width / 3 +
StyleConstants.Avatar.L -
StyleConstants.Spacing.Global.PagePadding * 2 +
StyleConstants.Spacing.M -
headerHeight
const styleOpacity = useAnimatedStyle(() => {
return {
opacity: interpolate(scrollY.value, [0, 200], [0, 1], Extrapolate.CLAMP)
}
})
const styleMarginTop = useAnimatedStyle(() => {
return {
marginTop: interpolate(
scrollY.value,
[nameY, nameY + 20],
[50, 0],
Extrapolate.CLAMP
)
}
})
const styleOpacity = useAnimatedStyle(() => {
return {
opacity: interpolate(scrollY.value, [0, 200], [0, 1], Extrapolate.CLAMP)
}
})
const styleMarginTop = useAnimatedStyle(() => {
return {
marginTop: interpolate(
scrollY.value,
[nameY, nameY + 20],
[50, 0],
Extrapolate.CLAMP
)
}
})
return (
<Animated.View
style={[
styles.base,
styleOpacity,
{ backgroundColor: theme.backgroundDefault, height: headerHeight }
]}
>
<View
return (
<Animated.View
style={[
styles.content,
{
marginTop:
useSafeAreaInsets().top + (44 - StyleConstants.Font.Size.L) / 2
}
styles.base,
styleOpacity,
{ backgroundColor: theme.backgroundDefault, height: headerHeight }
]}
>
<Animated.View style={[styles.display_name, styleMarginTop]}>
{account ? (
<Text numberOfLines={1}>
<ParseEmojis
content={account.display_name || account.username}
emojis={account.emojis}
fontBold
/>
</Text>
) : null}
</Animated.View>
</View>
</Animated.View>
)
}
<View
style={[
styles.content,
{
marginTop:
useSafeAreaInsets().top + (44 - StyleConstants.Font.Size.L) / 2
}
]}
>
<Animated.View style={[styles.display_name, styleMarginTop]}>
{account ? (
<Text numberOfLines={1}>
<ParseEmojis
content={account.display_name || account.username}
emojis={account.emojis}
fontBold
/>
</Text>
) : null}
</Animated.View>
</View>
</Animated.View>
)
},
(_, next) => next.account === undefined
)
const styles = StyleSheet.create({
base: {
@ -92,4 +93,4 @@ const styles = StyleSheet.create({
}
})
export default React.memo(AccountNav, (_, next) => next.account === undefined)
export default AccountNav

View File

@ -1,14 +1,11 @@
import createSecureStore from '@neverdull-agency/expo-unlimited-secure-store'
import AsyncStorage from '@react-native-async-storage/async-storage'
import {
combineReducers,
configureStore,
getDefaultMiddleware
} from '@reduxjs/toolkit'
import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit'
import instancesMigration from '@utils/migrations/instances/migration'
import contextsSlice from '@utils/slices/contextsSlice'
import instancesSlice from '@utils/slices/instancesSlice'
import settingsSlice from '@utils/slices/settingsSlice'
import versionSlice from '@utils/slices/versionSlice'
import { createMigrate, persistReducer, persistStore } from 'redux-persist'
const secureStorage = createSecureStore()
@ -27,7 +24,7 @@ const instancesPersistConfig = {
storage: secureStorage,
version: 5,
// @ts-ignore
migrate: createMigrate(instancesMigration, { debug: true })
migrate: createMigrate(instancesMigration)
}
const settingsPersistConfig = {
@ -36,21 +33,13 @@ const settingsPersistConfig = {
storage: AsyncStorage
}
const rootPersistConfig = {
key: 'root',
prefix,
version: 0,
storage: AsyncStorage
}
const rootReducer = combineReducers({
contexts: persistReducer(contextsPersistConfig, contextsSlice),
instances: persistReducer(instancesPersistConfig, instancesSlice),
settings: persistReducer(settingsPersistConfig, settingsSlice)
})
const store = configureStore({
reducer: persistReducer(rootPersistConfig, rootReducer),
reducer: {
contexts: persistReducer(contextsPersistConfig, contextsSlice),
instances: persistReducer(instancesPersistConfig, instancesSlice),
settings: persistReducer(settingsPersistConfig, settingsSlice),
version: versionSlice
},
middleware: getDefaultMiddleware({
serializableCheck: {
ignoredActions: ['persist/PERSIST']

View File

@ -2,9 +2,9 @@ import apiInstance from '@api/instance'
import { AxiosError } from 'axios'
import { useQuery, UseQueryOptions } from 'react-query'
export type QueryKey = ['Account', { id: Mastodon.Account['id'] }]
export type QueryKeyAccount = ['Account', { id: Mastodon.Account['id'] }]
const queryFunction = ({ queryKey }: { queryKey: QueryKey }) => {
const queryFunction = ({ queryKey }: { queryKey: QueryKeyAccount }) => {
const { id } = queryKey[1]
return apiInstance<Mastodon.Account>({
@ -16,10 +16,10 @@ const queryFunction = ({ queryKey }: { queryKey: QueryKey }) => {
const useAccountQuery = <TData = Mastodon.Account>({
options,
...queryKeyParams
}: QueryKey[1] & {
}: QueryKeyAccount[1] & {
options?: UseQueryOptions<Mastodon.Account, AxiosError, TData>
}) => {
const queryKey: QueryKey = ['Account', { ...queryKeyParams }]
const queryKey: QueryKeyAccount = ['Account', { ...queryKeyParams }]
return useQuery(queryKey, queryFunction, options)
}

View File

@ -0,0 +1,121 @@
import apiInstance from '@api/instance'
import { displayMessage } from '@components/Message'
import { queryClient } from '@root/App'
import { AxiosError } from 'axios'
import { useMutation, useQuery, UseQueryOptions } from 'react-query'
import { QueryKeyAccount } from './account'
type AccountWithSource = Mastodon.Account &
Required<Pick<Mastodon.Account, 'source'>>
type QueryKeyProfile = ['Profile']
const queryKey: QueryKeyProfile = ['Profile']
const queryFunction = () => {
return apiInstance<AccountWithSource>({
method: 'get',
url: `accounts/verify_credentials`
}).then(res => res.body)
}
const useProfileQuery = <TData = AccountWithSource>({
options
}: {
options?: UseQueryOptions<AccountWithSource, AxiosError, TData>
}) => {
return useQuery(queryKey, queryFunction, options)
}
type MutationVarsProfile =
| { type: 'display_name'; data: string }
| { type: 'note'; data: string }
| { type: 'avatar'; data: string }
| { type: 'header'; data: string }
| { type: 'locked'; data: boolean }
| { type: 'bot'; data: boolean }
| {
type: 'source[privacy]'
data: Mastodon.Preferences['posting:default:visibility']
}
| {
type: 'source[sensitive]'
data: Mastodon.Preferences['posting:default:sensitive']
}
| {
type: 'fields_attributes'
data: { name: string; value: string }[]
}
const mutationFunction = async ({ type, data }: MutationVarsProfile) => {
const formData = new FormData()
if (type === 'fields_attributes') {
const tempData = data as { name: string; value: string }[]
tempData.forEach((d, index) => {
formData.append(`fields_attributes[${index}][name]`, d.name)
formData.append(`fields_attributes[${index}][value]`, d.value)
})
} else if (type === 'avatar' || type === 'header') {
formData.append(type, {
// @ts-ignore
uri: data,
name: 'image/jpeg',
type: 'image/jpeg'
})
} else {
// @ts-ignore
formData.append(type, data)
}
return apiInstance<AccountWithSource>({
method: 'patch',
url: 'accounts/update_credentials',
body: formData
})
}
const useProfileMutation = () => {
return useMutation<
{ body: AccountWithSource },
AxiosError,
MutationVarsProfile
>(mutationFunction, {
onMutate: async variables => {
await queryClient.cancelQueries(queryKey)
const oldData = queryClient.getQueryData<AccountWithSource>(queryKey)
queryClient.setQueryData<AccountWithSource | undefined>(queryKey, old => {
if (old) {
switch (variables.type) {
case 'source[privacy]':
return {
...old,
source: { ...old.source, privacy: variables.data }
}
case 'source[sensitive]':
return {
...old,
source: { ...old.source, sensitive: variables.data }
}
case 'locked':
return { ...old, locked: variables.data }
case 'bot':
return { ...old, bot: variables.data }
default:
return old
}
}
})
return oldData
},
onError: (_, variables, context) => {
queryClient.setQueryData(queryKey, context)
},
onSettled: () => {
queryClient.invalidateQueries(queryKey)
}
})
}
export { useProfileQuery, useProfileMutation }

View File

@ -0,0 +1,43 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import { RootState } from '@root/store'
import apiGeneral from '@api/general'
import { Constants } from 'react-native-unimodules'
export const retriveVersionLatest = createAsyncThunk(
'version/latest',
async () => {
const res = await apiGeneral<{ latest: string }>({
method: 'get',
domain: 'tooot.app',
url: 'version.json'
})
return res.body.latest
}
)
export type VersionState = {
update: boolean
}
export const versionInitialState = {
update: false
}
const versionSlice = createSlice({
name: 'version',
initialState: versionInitialState,
reducers: {},
extraReducers: builder => {
builder.addCase(retriveVersionLatest.fulfilled, (state, action) => {
if (action.payload && Constants.manifest.version) {
if (parseInt(action.payload) > parseInt(Constants.manifest.version)) {
state.update = true
}
}
})
}
})
export const getVersionUpdate = (state: RootState) => state.version.update
export default versionSlice.reducer

View File

@ -10,6 +10,7 @@ export type ColorDefinitions =
| 'green'
| 'yellow'
| 'backgroundDefault'
| 'backgroundDefaultTransparent'
| 'backgroundOverlayDefault'
| 'backgroundOverlayInvert'
| 'border'
@ -59,6 +60,10 @@ const themeColors: {
light: 'rgb(250, 250, 250)',
dark: 'rgb(18, 18, 18)'
},
backgroundDefaultTransparent: {
light: 'rgba(250, 250, 250, 0)',
dark: 'rgba(18, 18, 18, 0)'
},
backgroundOverlayDefault: {
light: 'rgba(250, 250, 250, 0.5)',
dark: 'rgba(0, 0, 0, 0.5)'