Added instance configuration support

This commit is contained in:
Zhiyuan Zheng 2021-11-15 22:34:43 +01:00
parent 98dd7b2b46
commit 7ac789c18c
16 changed files with 302 additions and 101 deletions

View File

@ -299,8 +299,28 @@ declare namespace Mastodon {
// Others
thumbnail?: string
contact_account?: Account
// Custom
configuration?: {
statuses: {
max_characters: number
max_media_attachments: number
characters_reserved_per_url: number
}
media_attachments: {
supported_mime_types: string[]
image_size_limit: number
image_matrix_limit: number
video_size_limit: number
video_frame_rate_limit: number
video_matrix_limit: number
}
polls: {
max_options: number
max_characters_per_option: number
min_expiration: number
max_expiration: number
}
}
// Custom - to be deprecated in v4
max_toot_chars?: number
}

View File

@ -15,6 +15,7 @@ import pushUseReceive from '@utils/push/useReceive'
import pushUseRespond from '@utils/push/useRespond'
import { updatePreviousTab } from '@utils/slices/contextsSlice'
import { updateAccountPreferences } from '@utils/slices/instances/updateAccountPreferences'
import { updateConfiguration } from '@utils/slices/instances/updateConfiguration'
import { updateFilters } from '@utils/slices/instances/updateFilters'
import { getInstanceActive, getInstances } from '@utils/slices/instancesSlice'
import { useTheme } from '@utils/styles/ThemeManager'
@ -108,6 +109,7 @@ const Screens: React.FC<Props> = ({ localCorrupt }) => {
// Lazily update users's preferences, for e.g. composing default visibility
useEffect(() => {
if (instanceActive !== -1) {
dispatch(updateConfiguration())
dispatch(updateFilters())
dispatch(updateAccountPreferences())
}

View File

@ -22,9 +22,8 @@ const InstanceAuth = React.memo(
useProxy: false
})
const navigation = useNavigation<
TabMeStackNavigationProp<'Tab-Me-Root' | 'Tab-Me-Switch'>
>()
const navigation =
useNavigation<TabMeStackNavigationProp<'Tab-Me-Root' | 'Tab-Me-Switch'>>()
const queryClient = useQueryClient()
const dispatch = useDispatch()
@ -70,7 +69,6 @@ const InstanceAuth = React.memo(
domain: instanceDomain,
token: accessToken,
instance,
max_toot_chars: instance.max_toot_chars,
appData
})
)

View File

@ -11,7 +11,7 @@ export interface Props {
resize?: { width?: number; height?: number } // Resize mode contain
showActionSheetWithOptions: (
options: ActionSheetOptions,
callback: (i: number) => void
callback: (i?: number | undefined) => void | Promise<void>
) => void
}
@ -57,9 +57,8 @@ const mediaSelector = async ({
},
async buttonIndex => {
if (buttonIndex === 0) {
const {
status
} = await ImagePicker.requestMediaLibraryPermissionsAsync()
const { status } =
await ImagePicker.requestMediaLibraryPermissionsAsync()
if (status !== 'granted') {
Alert.alert(
i18next.t('componentMediaSelector:library.alert.title'),

View File

@ -9,7 +9,7 @@ import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { updateStoreReview } from '@utils/slices/contextsSlice'
import {
getInstanceAccount,
getInstanceMaxTootChar,
getInstanceConfigurationStatusMaxChars,
removeInstanceDraft,
updateInstanceDraft
} from '@utils/slices/instancesSlice'
@ -103,7 +103,10 @@ const ScreenCompose: React.FC<RootStackScreenProps<'Screen-Compose'>> = ({
initialReducerState
)
const maxTootChars = useSelector(getInstanceMaxTootChar, () => true)
const maxTootChars = useSelector(
getInstanceConfigurationStatusMaxChars,
() => true
)
const totalTextCount =
(composeState.spoiler.active ? composeState.spoiler.count : 0) +
composeState.text.count

View File

@ -29,6 +29,8 @@ import ComposeDrafts from './Root/Drafts'
import FastImage from 'react-native-fast-image'
import { useAccessibility } from '@utils/accessibility/AccessibilityManager'
import { ComposeState } from './utils/types'
import { useSelector } from 'react-redux'
import { getInstanceConfigurationStatusCharsURL } from '@utils/slices/instancesSlice'
const prefetchEmojis = (
sortedEmojis: NonNullable<ComposeState['emoji']['emojis']>,
@ -54,11 +56,18 @@ const prefetchEmojis = (
} catch {}
}
export let instanceConfigurationStatusCharsURL = 23
const ComposeRoot = React.memo(
() => {
const { reduceMotionEnabled } = useAccessibility()
const { theme } = useTheme()
instanceConfigurationStatusCharsURL = useSelector(
getInstanceConfigurationStatusCharsURL,
() => true
)
const accessibleRefDrafts = useRef(null)
const accessibleRefAttachments = useRef(null)
const accessibleRefEmojis = useRef(null)

View File

@ -1,12 +1,14 @@
import analytics from '@components/analytics'
import Icon from '@components/Icon'
import { useActionSheet } from '@expo/react-native-action-sheet'
import { getInstanceConfigurationStatusMaxAttachments } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants'
import layoutAnimation from '@utils/styles/layoutAnimation'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useContext, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Pressable, StyleSheet, View } from 'react-native'
import { useSelector } from 'react-redux'
import ComposeContext from '../utils/createContext'
import addAttachment from './Footer/addAttachment'
@ -15,6 +17,10 @@ const ComposeActions: React.FC = () => {
const { composeState, composeDispatch } = useContext(ComposeContext)
const { t } = useTranslation('screenCompose')
const { theme } = useTheme()
const instanceConfigurationStatusMaxAttachments = useSelector(
getInstanceConfigurationStatusMaxAttachments,
() => true
)
const attachmentColor = useMemo(() => {
if (composeState.poll.active) return theme.disabled
@ -28,7 +34,10 @@ const ComposeActions: React.FC = () => {
const attachmentOnPress = useCallback(async () => {
if (composeState.poll.active) return
if (composeState.attachments.uploads.length < 4) {
if (
composeState.attachments.uploads.length <
instanceConfigurationStatusMaxAttachments
) {
analytics('compose_actions_attachment_press', {
count: composeState.attachments.uploads.length
})

View File

@ -3,11 +3,13 @@ import Button from '@components/Button'
import Icon from '@components/Icon'
import { MenuRow } from '@components/Menu'
import { useActionSheet } from '@expo/react-native-action-sheet'
import { getInstanceConfigurationPoll } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useContext, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, TextInput, View } from 'react-native'
import { StyleSheet, Text, TextInput, View } from 'react-native'
import { useSelector } from 'react-redux'
import ComposeContext from '../../utils/createContext'
const ComposePoll: React.FC = () => {
@ -21,6 +23,16 @@ const ComposePoll: React.FC = () => {
const { t } = useTranslation('screenCompose')
const { mode, theme } = useTheme()
const instanceConfigurationPoll = useSelector(
getInstanceConfigurationPoll,
() => true
)
const MAX_OPTIONS = instanceConfigurationPoll.max_options
const MAX_CHARS_PER_OPTION =
instanceConfigurationPoll.max_characters_per_option
const MIN_EXPIRATION = instanceConfigurationPoll.min_expiration
const MAX_EXPIRATION = instanceConfigurationPoll.max_expiration
const [firstRender, setFirstRender] = useState(true)
useEffect(() => {
setFirstRender(false)
@ -67,7 +79,7 @@ const ComposePoll: React.FC = () => {
: t('content.root.footer.poll.option.placeholder.single')
}
placeholderTextColor={theme.disabled}
maxLength={50}
maxLength={MAX_CHARS_PER_OPTION}
// @ts-ignore
value={options[i]}
onChangeText={e =>
@ -82,37 +94,38 @@ const ComposePoll: React.FC = () => {
})}
</View>
<View style={styles.controlAmount}>
<View style={styles.firstButton}>
<Button
{...(total > 2
? {
accessibilityLabel: t(
'content.root.footer.poll.quantity.reduce.accessibilityLabel',
{ amount: total - 1 }
)
}
: {
accessibilityHint: t(
'content.root.footer.poll.quantity.reduce.accessibilityHint',
{ amount: total }
)
})}
onPress={() => {
analytics('compose_poll_reduce_press')
total > 2 &&
composeDispatch({
type: 'poll',
payload: { total: total - 1 }
})
}}
type='icon'
content='Minus'
round
disabled={!(total > 2)}
/>
</View>
<Button
{...(total < 4
{...(total > 2
? {
accessibilityLabel: t(
'content.root.footer.poll.quantity.reduce.accessibilityLabel',
{ amount: total - 1 }
)
}
: {
accessibilityHint: t(
'content.root.footer.poll.quantity.reduce.accessibilityHint',
{ amount: total }
)
})}
onPress={() => {
analytics('compose_poll_reduce_press')
total > 2 &&
composeDispatch({
type: 'poll',
payload: { total: total - 1 }
})
}}
type='icon'
content='Minus'
round
disabled={!(total > 2)}
/>
<Text style={styles.controlCount}>
{total} / {MAX_OPTIONS}
</Text>
<Button
{...(total < MAX_OPTIONS
? {
accessibilityLabel: t(
'content.root.footer.poll.quantity.increase.accessibilityLabel',
@ -127,7 +140,7 @@ const ComposePoll: React.FC = () => {
})}
onPress={() => {
analytics('compose_poll_increase_press')
total < 4 &&
total < MAX_OPTIONS &&
composeDispatch({
type: 'poll',
payload: { total: total + 1 }
@ -136,7 +149,7 @@ const ComposePoll: React.FC = () => {
type='icon'
content='Plus'
round
disabled={!(total < 4)}
disabled={!(total < MAX_OPTIONS)}
/>
</View>
<View style={styles.controlOptions}>
@ -158,7 +171,7 @@ const ComposePoll: React.FC = () => {
cancelButtonIndex: 2
},
index => {
if (index < 2) {
if (index && index < 2) {
analytics('compose_poll_expiration_press', {
current: multiple,
new: index === 1
@ -177,6 +190,7 @@ const ComposePoll: React.FC = () => {
title={t('content.root.footer.poll.expiration.heading')}
content={t(`content.root.footer.poll.expiration.options.${expire}`)}
onPress={() => {
// @ts-ignore
const expirations: [
'300',
'1800',
@ -185,7 +199,19 @@ const ComposePoll: React.FC = () => {
'86400',
'259200',
'604800'
] = ['300', '1800', '3600', '21600', '86400', '259200', '604800']
] = [
'300',
'1800',
'3600',
'21600',
'86400',
'259200',
'604800'
].filter(
expiration =>
parseInt(expiration) >= MIN_EXPIRATION &&
parseInt(expiration) <= MAX_EXPIRATION
)
showActionSheetWithOptions(
{
options: [
@ -197,7 +223,7 @@ const ComposePoll: React.FC = () => {
cancelButtonIndex: 7
},
index => {
if (index < 7) {
if (index && expirations.length < 7) {
analytics('compose_poll_expiration_press', {
current: expire,
new: expirations[index]
@ -246,14 +272,15 @@ const styles = StyleSheet.create({
controlAmount: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
marginRight: StyleConstants.Spacing.M
},
controlOptions: {
paddingHorizontal: StyleConstants.Spacing.Global.PagePadding
},
firstButton: {
marginRight: StyleConstants.Spacing.S
controlCount: {
marginHorizontal: StyleConstants.Spacing.S
}
})

View File

@ -13,7 +13,7 @@ export interface Props {
composeDispatch: Dispatch<ComposeAction>
showActionSheetWithOptions: (
options: ActionSheetOptions,
callback: (i: number) => void
callback: (i?: number | undefined) => void | Promise<void>
) => void
}

View File

@ -5,6 +5,7 @@ import { FetchOptions } from 'react-query/types/core/query'
import Autolinker from '@root/modules/autolinker'
import { useTheme } from '@utils/styles/ThemeManager'
import { ComposeAction, ComposeState } from './utils/types'
import { instanceConfigurationStatusCharsURL } from './Root'
export interface Params {
textInput: ComposeState['textInputFocus']['current']
@ -92,7 +93,7 @@ const formatText = ({
children.push(<TagText key={index} text={main} />)
switch (tag.type) {
case 'url':
contentLength = contentLength + 23
contentLength = contentLength + instanceConfigurationStatusCharsURL
break
case 'accounts':
const theMatch = main.match(/@/g)

View File

@ -22,7 +22,7 @@ const instancesPersistConfig = {
key: 'instances',
prefix,
storage: secureStorage,
version: 5,
version: 6,
// @ts-ignore
migrate: createMigrate(instancesMigration)
}

View File

@ -1,5 +1,6 @@
import { InstanceV3 } from './v3'
import { InstanceV4 } from './v4'
import { InstanceV5 } from './v5'
const instancesMigration = {
4: (state: InstanceV3) => {
@ -27,7 +28,6 @@ const instancesMigration = {
}
},
5: (state: InstanceV4) => {
// Migration is run on each start, don't know why
// @ts-ignore
if (state.instances.length && !state.instances[0].notifications_filter) {
return {
@ -47,6 +47,11 @@ const instancesMigration = {
} else {
return state
}
},
6: (state: InstanceV5) => {
return state.instances.map(instance => {
return { ...instance, configuration: undefined }
})
}
}

View File

@ -0,0 +1,93 @@
import { ComposeStateDraft } from '@screens/Compose/utils/types'
type Instance = {
active: boolean
appData: {
clientId: string
clientSecret: string
}
url: string
token: string
uri: Mastodon.Instance['uri']
urls: Mastodon.Instance['urls']
max_toot_chars: number
account: {
id: Mastodon.Account['id']
acct: Mastodon.Account['acct']
avatarStatic: Mastodon.Account['avatar_static']
preferences: Mastodon.Preferences
}
filters: Mastodon.Filter[]
notifications_filter: {
follow: boolean
favourite: boolean
reblog: boolean
mention: boolean
poll: boolean
follow_request: boolean
}
push:
| {
global: { loading: boolean; value: boolean }
decode: { loading: boolean; value: true }
alerts: {
follow: {
loading: boolean
value: Mastodon.PushSubscription['alerts']['follow']
}
favourite: {
loading: boolean
value: Mastodon.PushSubscription['alerts']['favourite']
}
reblog: {
loading: boolean
value: Mastodon.PushSubscription['alerts']['reblog']
}
mention: {
loading: boolean
value: Mastodon.PushSubscription['alerts']['mention']
}
poll: {
loading: boolean
value: Mastodon.PushSubscription['alerts']['poll']
}
}
keys: {
auth: string
public: string
private: string
}
}
| {
global: { loading: boolean; value: boolean }
decode: { loading: boolean; value: false }
alerts: {
follow: {
loading: boolean
value: Mastodon.PushSubscription['alerts']['follow']
}
favourite: {
loading: boolean
value: Mastodon.PushSubscription['alerts']['favourite']
}
reblog: {
loading: boolean
value: Mastodon.PushSubscription['alerts']['reblog']
}
mention: {
loading: boolean
value: Mastodon.PushSubscription['alerts']['mention']
}
poll: {
loading: boolean
value: Mastodon.PushSubscription['alerts']['poll']
}
}
keys: undefined
}
drafts: ComposeStateDraft[]
}
export type InstanceV5 = {
instances: Instance[]
}

View File

@ -9,13 +9,11 @@ const addInstance = createAsyncThunk(
domain,
token,
instance,
max_toot_chars = 500,
appData
}: {
domain: Instance['url']
token: Instance['token']
instance: Mastodon.Instance
max_toot_chars?: number
appData: Instance['appData']
}): Promise<{ type: 'add' | 'overwrite'; data: Instance }> => {
const { store } = require('@root/store')
@ -70,13 +68,18 @@ const addInstance = createAsyncThunk(
token,
uri: instance.uri,
urls: instance.urls,
max_toot_chars,
account: {
id,
acct,
avatarStatic: avatar_static,
preferences
},
...(instance.max_toot_chars && {
max_toot_chars: instance.max_toot_chars
}),
...(instance.configuration && {
configuration: instance.configuration
}),
filters,
notifications_filter: {
follow: true,

View File

@ -0,0 +1,12 @@
import apiInstance from '@api/instance'
import { createAsyncThunk } from '@reduxjs/toolkit'
export const updateConfiguration = createAsyncThunk(
'instances/updateConfiguration',
async (): Promise<Mastodon.Instance> => {
return apiInstance<Mastodon.Instance>({
method: 'get',
url: `instance`
}).then(res => res.body)
}
)

View File

@ -6,6 +6,7 @@ import { findIndex } from 'lodash'
import addInstance from './instances/add'
import removeInstance from './instances/remove'
import { updateAccountPreferences } from './instances/updateAccountPreferences'
import { updateConfiguration } from './instances/updateConfiguration'
import { updateFilters } from './instances/updateFilters'
import { updateInstancePush } from './instances/updatePush'
import { updateInstancePushAlert } from './instances/updatePushAlert'
@ -21,13 +22,14 @@ export type Instance = {
token: string
uri: Mastodon.Instance['uri']
urls: Mastodon.Instance['urls']
max_toot_chars: number
account: {
id: Mastodon.Account['id']
acct: Mastodon.Account['acct']
avatarStatic: Mastodon.Account['avatar_static']
preferences: Mastodon.Preferences
}
max_toot_chars?: number // To be deprecated in v4
configuration?: Mastodon.Instance['configuration']
filters: Mastodon.Filter[]
notifications_filter: {
follow: boolean
@ -107,8 +109,8 @@ export const instancesInitialState: InstancesState = {
instances: []
}
const findInstanceActive = (state: Instance[]) =>
state.findIndex(instance => instance.active)
const findInstanceActive = (instances: Instance[]) =>
instances.findIndex(instance => instance.active)
const instancesSlice = createSlice({
name: 'instances',
@ -254,6 +256,18 @@ const instancesSlice = createSlice({
console.error(action.error)
})
// Update Instance Configuration
.addCase(updateConfiguration.fulfilled, (state, action) => {
const activeIndex = findInstanceActive(state.instances)
state.instances[activeIndex].max_toot_chars =
action.payload.max_toot_chars
state.instances[activeIndex].configuration =
action.payload.configuration
})
.addCase(updateConfiguration.rejected, (_, action) => {
console.error(action.error)
})
// Update Instance Push Global
.addCase(updateInstancePush.fulfilled, (state, action) => {
const activeIndex = findInstanceActive(state.instances)
@ -314,56 +328,62 @@ export const getInstanceActive = ({ instances: { instances } }: RootState) =>
export const getInstances = ({ instances: { instances } }: RootState) =>
instances
export const getInstance = ({ instances: { instances } }: RootState) => {
const instanceActive = findInstanceActive(instances)
return instanceActive !== -1 ? instances[instanceActive] : null
}
export const getInstance = ({ instances: { instances } }: RootState) =>
instances[findInstanceActive(instances)]
export const getInstanceUrl = ({ instances: { instances } }: RootState) => {
const instanceActive = findInstanceActive(instances)
return instanceActive !== -1 ? instances[instanceActive].url : null
}
export const getInstanceUrl = ({ instances: { instances } }: RootState) =>
instances[findInstanceActive(instances)]?.url
export const getInstanceUri = ({ instances: { instances } }: RootState) => {
const instanceActive = findInstanceActive(instances)
return instanceActive !== -1 ? instances[instanceActive].uri : null
}
export const getInstanceUri = ({ instances: { instances } }: RootState) =>
instances[findInstanceActive(instances)]?.uri
export const getInstanceUrls = ({ instances: { instances } }: RootState) => {
const instanceActive = findInstanceActive(instances)
return instanceActive !== -1 ? instances[instanceActive].urls : null
}
export const getInstanceUrls = ({ instances: { instances } }: RootState) =>
instances[findInstanceActive(instances)]?.urls
export const getInstanceMaxTootChar = ({
/* Get Instance Configuration */
export const getInstanceConfigurationStatusMaxChars = ({
instances: { instances }
}: RootState) => {
const instanceActive = findInstanceActive(instances)
return instanceActive !== -1 ? instances[instanceActive].max_toot_chars : 500
}
}: RootState) =>
instances[findInstanceActive(instances)]?.configuration?.statuses
.max_characters ||
instances[findInstanceActive(instances)]?.max_toot_chars ||
500
export const getInstanceAccount = ({ instances: { instances } }: RootState) => {
const instanceActive = findInstanceActive(instances)
return instanceActive !== -1 ? instances[instanceActive].account : null
}
export const getInstanceConfigurationStatusMaxAttachments = ({
instances: { instances }
}: RootState) =>
instances[findInstanceActive(instances)]?.configuration?.statuses
.max_media_attachments || 4
export const getInstanceConfigurationStatusCharsURL = ({
instances: { instances }
}: RootState) =>
instances[findInstanceActive(instances)]?.configuration?.statuses
.characters_reserved_per_url || 23
export const getInstanceConfigurationPoll = ({
instances: { instances }
}: RootState) =>
instances[findInstanceActive(instances)]?.configuration?.polls || {
max_options: 4,
max_characters_per_option: 50,
min_expiration: 300,
max_expiration: 2629746
}
/* END */
export const getInstanceAccount = ({ instances: { instances } }: RootState) =>
instances[findInstanceActive(instances)]?.account
export const getInstanceNotificationsFilter = ({
instances: { instances }
}: RootState) => {
const instanceActive = findInstanceActive(instances)
return instanceActive !== -1
? instances[instanceActive].notifications_filter
: null
}
}: RootState) => instances[findInstanceActive(instances)].notifications_filter
export const getInstancePush = ({ instances: { instances } }: RootState) => {
const instanceActive = findInstanceActive(instances)
return instanceActive !== -1 ? instances[instanceActive].push : null
}
export const getInstancePush = ({ instances: { instances } }: RootState) =>
instances[findInstanceActive(instances)]?.push
export const getInstanceDrafts = ({ instances: { instances } }: RootState) => {
const instanceActive = findInstanceActive(instances)
return instanceActive !== -1 ? instances[instanceActive].drafts : null
}
export const getInstanceDrafts = ({ instances: { instances } }: RootState) =>
instances[findInstanceActive(instances)]?.drafts
export const {
updateInstanceActive,