1
0
mirror of https://github.com/tooot-app/app synced 2025-06-05 22:19:13 +02:00

619 restructure local storage (#628)

* To MMKV migration working

* POC migrated font size settings

* Moved settings to mmkv

* Fix typos

* Migrated contexts slice

* Migrated app slice

* POC instance emoji update

* Migrated drafts

* Migrated simple instance properties

* All migrated!

* Re-structure files

* Tolerant of undefined settings

* Can properly logging in and out including empty state
This commit is contained in:
xmflsct
2022-12-28 23:41:36 +01:00
committed by GitHub
parent 71ccb4a93c
commit 1ea6aff328
214 changed files with 2151 additions and 3694 deletions

View File

@ -1,9 +1,9 @@
import React, {
createContext,
PropsWithChildren,
useContext,
useEffect,
useState
createContext,
PropsWithChildren,
useContext,
useEffect,
useState
} from 'react'
import { AccessibilityInfo } from 'react-native'

78
src/utils/api/general.ts Normal file
View File

@ -0,0 +1,78 @@
import axios from 'axios'
import { ctx, handleError, PagedResponse, userAgent } from './helpers'
export type Params = {
method: 'get' | 'post' | 'put' | 'delete'
domain: string
url: string
params?: {
[key: string]: string | number | boolean | string[] | number[] | boolean[]
}
headers?: { [key: string]: string }
body?: FormData | Object
}
const apiGeneral = async <T = unknown>({
method,
domain,
url,
params,
headers,
body
}: Params): Promise<PagedResponse<T>> => {
console.log(
ctx.bgGreen.bold(' API general ') +
' ' +
domain +
' ' +
method +
ctx.green(' -> ') +
`/${url}` +
(params ? ctx.green(' -> ') : ''),
params ? params : ''
)
return axios({
timeout: method === 'post' ? 1000 * 60 : 1000 * 15,
method,
baseURL: `https://${domain}/`,
url,
params,
headers: {
'Content-Type': body && body instanceof FormData ? 'multipart/form-data' : 'application/json',
Accept: '*/*',
...userAgent,
...headers
},
...(body &&
(body instanceof FormData
? (body as (FormData & { _parts: [][] }) | undefined)?._parts?.length
: Object.keys(body).length) && { data: body })
})
.then(response => {
let links: {
prev?: { id: string; isOffset: boolean }
next?: { id: string; isOffset: boolean }
} = {}
if (response.headers?.link) {
const linksParsed = response.headers.link.matchAll(
new RegExp('[?&](.*?_id|offset)=(.*?)>; *rel="(.*?)"', 'gi')
)
for (const link of linksParsed) {
switch (link[3]) {
case 'prev':
links.prev = { id: link[2], isOffset: link[1].includes('offset') }
break
case 'next':
links.next = { id: link[2], isOffset: link[1].includes('offset') }
break
}
}
}
return Promise.resolve({ body: response.data, links })
})
.catch(handleError())
}
export default apiGeneral

View File

@ -0,0 +1,73 @@
import * as Sentry from '@sentry/react-native'
import chalk from 'chalk'
import Constants from 'expo-constants'
import { Platform } from 'react-native'
const userAgent = {
'User-Agent': `tooot/${Constants.expoConfig?.version} ${Platform.OS}/${Platform.Version}`
}
const ctx = new chalk.Instance({ level: 3 })
const handleError =
(
config: {
message: string
captureRequest?: { url: string; params: any; body: any }
captureResponse?: boolean
} | void
) =>
(error: any) => {
const shouldReportToSentry = config && (config.captureRequest || config.captureResponse)
shouldReportToSentry && Sentry.setContext('Error object', error)
if (config?.captureRequest) {
Sentry.setContext('Error request', config.captureRequest)
}
if (error?.response) {
if (config?.captureResponse) {
Sentry.setContext('Error response', {
data: error.response.data,
status: error.response.status,
headers: error.response.headers
})
}
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
console.error(
ctx.bold(' API '),
ctx.bold('response'),
error.response.status,
error.request._url,
error?.response.data?.error || error?.response.message || 'Unknown error'
)
shouldReportToSentry && Sentry.captureMessage(config.message)
return Promise.reject({
status: error?.response.status,
message: error?.response.data?.error || error?.response.message || 'Unknown error'
})
} else if (error?.request) {
// The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
// http.ClientRequest in node.js
console.error(ctx.bold(' API '), ctx.bold('request'), error)
shouldReportToSentry && Sentry.captureMessage(config.message)
return Promise.reject(error)
} else {
console.error(ctx.bold(' API '), ctx.bold('internal'), error?.message)
shouldReportToSentry && Sentry.captureMessage(config.message)
return Promise.reject(error)
}
}
type LinkFormat = { id: string; isOffset: boolean }
export type PagedResponse<T = unknown> = {
body: T
links: { prev?: LinkFormat; next?: LinkFormat }
}
export { ctx, handleError, userAgent }

86
src/utils/api/instance.ts Normal file
View File

@ -0,0 +1,86 @@
import { getAccountDetails } from '@utils/storage/actions'
import axios, { AxiosRequestConfig } from 'axios'
import { ctx, handleError, PagedResponse, userAgent } from './helpers'
export type Params = {
method: 'get' | 'post' | 'put' | 'delete' | 'patch'
version?: 'v1' | 'v2'
url: string
params?: {
[key: string]: string | number | boolean | string[] | number[] | boolean[]
}
headers?: { [key: string]: string }
body?: FormData
extras?: Omit<AxiosRequestConfig, 'method' | 'url' | 'params' | 'headers' | 'data'>
}
const apiInstance = async <T = unknown>({
method,
version = 'v1',
url,
params,
headers,
body,
extras
}: Params): Promise<PagedResponse<T>> => {
const accountDetails = getAccountDetails(['auth.domain', 'auth.token'])
if (!accountDetails) {
console.warn(ctx.bgRed.white.bold(' API instance '), 'No account detail available')
return Promise.reject()
}
if (!accountDetails['auth.domain'] || !accountDetails['auth.token']) {
console.warn(ctx.bgRed.white.bold(' API ') + ' ' + 'No domain or token available')
return Promise.reject()
}
console.log(
ctx.bgGreen.bold(' API instance '),
accountDetails['auth.domain'],
method + ctx.green(' -> ') + `/${url}` + (params ? ctx.green(' -> ') : ''),
params ? params : ''
)
return axios({
timeout: method === 'post' ? 1000 * 60 : 1000 * 15,
method,
baseURL: `https://${accountDetails['auth.domain']}/api/${version}/`,
url,
params,
headers: {
'Content-Type': body && body instanceof FormData ? 'multipart/form-data' : 'application/json',
Accept: '*/*',
...userAgent,
...headers,
Authorization: `Bearer ${accountDetails['auth.token']}`
},
...((body as (FormData & { _parts: [][] }) | undefined)?._parts.length && { data: body }),
...extras
})
.then(response => {
let links: {
prev?: { id: string; isOffset: boolean }
next?: { id: string; isOffset: boolean }
} = {}
if (response.headers?.link) {
const linksParsed = response.headers.link.matchAll(
new RegExp('[?&](.*?_id|offset)=(.*?)>; *rel="(.*?)"', 'gi')
)
for (const link of linksParsed) {
switch (link[3]) {
case 'prev':
links.prev = { id: link[2], isOffset: link[1].includes('offset') }
break
case 'next':
links.next = { id: link[2], isOffset: link[1].includes('offset') }
break
}
}
}
return Promise.resolve({ body: response.data, links })
})
.catch(handleError())
}
export default apiInstance

69
src/utils/api/tooot.ts Normal file
View File

@ -0,0 +1,69 @@
import { mapEnvironment } from '@utils/helpers/checkEnvironment'
import axios from 'axios'
import { ctx, handleError, userAgent } from './helpers'
export type Params = {
method: 'get' | 'post' | 'put' | 'delete'
url: string
params?: {
[key: string]: string | number | boolean | string[] | number[] | boolean[]
}
headers?: { [key: string]: string }
body?: FormData | Object
}
export const TOOOT_API_DOMAIN = mapEnvironment({
release: 'api.tooot.app',
candidate: 'api-candidate.tooot.app',
development: 'api-development.tooot.app'
})
const apiTooot = async <T = unknown>({
method,
url,
params,
headers,
body
}: Params): Promise<{ body: T }> => {
console.log(
ctx.bgGreen.bold(' API tooot ') +
' ' +
method +
ctx.green(' -> ') +
`/${url}` +
(params ? ctx.green(' -> ') : ''),
params ? params : ''
)
return axios({
timeout: method === 'post' ? 1000 * 60 : 1000 * 30,
method,
baseURL: `https://${TOOOT_API_DOMAIN}/`,
url: `${url}`,
params,
headers: {
'Content-Type': body && body instanceof FormData ? 'multipart/form-data' : 'application/json',
Accept: '*/*',
...userAgent,
...headers
},
...(body &&
(body instanceof FormData
? (body as (FormData & { _parts: [][] }) | undefined)?._parts?.length
: Object.keys(body).length) && { data: body })
})
.then(response => {
return Promise.resolve({
body: response.data
})
})
.catch(
handleError({
message: 'API error',
captureRequest: { url, params, body },
captureResponse: true
})
)
}
export default apiTooot

View File

@ -0,0 +1,5 @@
export const androidActionSheetStyles = (colors: any) => ({
containerStyle: { backgroundColor: colors.backgroundDefault },
textStyle: { color: colors.primaryDefault },
titleTextStyle: { color: colors.secondary }
})

View File

@ -0,0 +1,18 @@
import * as WebBrowser from 'expo-web-browser'
import { Platform } from 'react-native'
const browserPackage = async (): Promise<{ browserPackage?: string }> => {
if (Platform.OS === 'android') {
const tabsSupportingBrowsers = await WebBrowser.getCustomTabsSupportingBrowsersAsync()
return {
browserPackage:
tabsSupportingBrowsers?.preferredBrowserPackage ||
tabsSupportingBrowsers.browserPackages[0] ||
tabsSupportingBrowsers.servicePackages[0]
}
} else {
return {}
}
}
export default browserPackage

View File

@ -0,0 +1,10 @@
import detect from 'react-native-language-detection';
const detectLanguage = async (
text: string
): Promise<{ language: string; confidence: number } | null> => {
const possibleLanguages = await detect(text).catch(() => {})
return possibleLanguages ? possibleLanguages.filter(lang => lang.confidence > 0.5)?.[0] : null
}
export default detectLanguage

View File

@ -0,0 +1,58 @@
import { getAccountStorage } from '@utils/storage/actions'
const features = [
{
feature: 'account_follow_notify',
version: 3.3
},
{
feature: 'notification_type_status',
version: 3.3
},
{
feature: 'account_return_suspended',
version: 3.3
},
{
feature: 'edit_post',
version: 3.5
},
{
feature: 'deprecate_auth_follow',
version: 3.5
},
{
feature: 'notification_type_update',
version: 3.5
},
{
feature: 'notification_type_admin_signup',
version: 3.5
},
{
feature: 'notification_types_positive_filter',
version: 3.5
},
{
feature: 'trends_new_path',
version: 3.5
},
{
feature: 'follow_tags',
version: 4.0
},
{
feature: 'notification_type_admin_report',
version: 4.0
},
{
feature: 'filter_server_side',
version: 4.0
}
]
export const featureCheck = (feature: string): boolean => {
const version = getAccountStorage.string('version')
return !!features.filter(f => f.feature === feature).filter(f => parseFloat(version) >= f.version)
?.length
}

View File

@ -0,0 +1,54 @@
[
{
"feature": "account_follow_notify",
"version": 3.3
},
{
"feature": "notification_type_status",
"version": 3.3
},
{
"feature": "account_return_suspended",
"version": 3.3
},
{
"feature": "edit_post",
"version": 3.5
},
{
"feature": "deprecate_auth_follow",
"version": 3.5
},
{
"feature": "notification_type_update",
"version": 3.5
},
{
"feature": "notification_type_admin_signup",
"version": 3.5
},
{
"feature": "notification_types_positive_filter",
"version": 3.5
},
{
"feature": "trends_new_path",
"version": 3.5
},
{
"feature": "follow_tags",
"version": 4.0
},
{
"feature": "notification_type_admin_report",
"version": 4.0
},
{
"feature": "filter_server_side",
"version": 4.0
},
{
"feature": "instance_new_path",
"version": 4.0
}
]

View File

@ -0,0 +1,9 @@
import { getGlobalStorage } from '@utils/storage/actions'
import * as Localization from 'expo-localization'
import { Platform } from 'react-native'
const getLanguage = (): string | undefined => {
return Platform.OS === 'ios' ? Localization.locale : getGlobalStorage.string('app.language')
}
export default getLanguage

View File

@ -0,0 +1,9 @@
export const PERMISSION_MANAGE_REPORTS = 0x0000000000000010
export const PERMISSION_MANAGE_USERS = 0x0000000000000400
export const checkPermission = (permission: number, permissions?: string | number): boolean =>
permissions
? !!(
(typeof permissions === 'string' ? parseInt(permissions || '0') : permissions) & permission
)
: false

View File

@ -0,0 +1,21 @@
import * as htmlparser2 from 'htmlparser2'
const removeHTML = (text: string): string => {
let raw: string = ''
const parser = new htmlparser2.Parser({
ontext: (text: string) => {
raw = raw + text
},
onclosetag: (tag: string) => {
if (['p', 'br'].includes(tag)) raw = raw + `\n`
}
})
parser.write(text)
parser.end()
return raw
}
export default removeHTML

View File

@ -0,0 +1,62 @@
import { getAccountStorage } from '@utils/storage/actions'
const getHost = (url: unknown): string | undefined | null => {
if (typeof url !== 'string') return undefined
const matches = url.match(/^(?:https?:\/\/)?(?:[^@\/\n]+@)?(?:www\.)?([^:\/\n]+)/i)
return matches?.[1]
}
const matchStatus = (
url: string
): { id: string; style: 'default' | 'pretty'; sameInstance: boolean } | null => {
// https://social.xmflsct.com/web/statuses/105590085754428765 <- default
// https://social.xmflsct.com/@tooot/105590085754428765 <- pretty
const matcherStatus = new RegExp(/(https?:\/\/)?([^\/]+)\/(web\/statuses|@.+)\/([0-9]+)/)
const matched = url.match(matcherStatus)
if (matched) {
const hostname = matched[2]
const style = matched[3] === 'web/statuses' ? 'default' : 'pretty'
const id = matched[4]
const sameInstance = hostname === getAccountStorage.string('auth.domain')
return { id, style, sameInstance }
}
return null
}
const matchAccount = (
url: string
):
| { id: string; style: 'default'; sameInstance: boolean }
| { username: string; style: 'pretty'; sameInstance: boolean }
| null => {
// https://social.xmflsct.com/web/accounts/14195 <- default
// https://social.xmflsct.com/web/@tooot <- pretty ! cannot be searched on the same instance
// https://social.xmflsct.com/@tooot <- pretty
const matcherAccount = new RegExp(
/(https?:\/\/)?([^\/]+)(\/web\/accounts\/([0-9]+)|\/web\/(@.+)|\/(@.+))/
)
const matched = url.match(matcherAccount)
if (matched) {
const hostname = matched[2]
const account = matched.filter(i => i).reverse()?.[0]
if (account) {
const style = account.startsWith('@') ? 'pretty' : 'default'
const sameInstance = hostname === getAccountStorage.string('auth.domain')
return style === 'default'
? { id: account, style, sameInstance }
: { username: account, style, sameInstance }
} else {
return null
}
}
return null
}
export { getHost, matchStatus, matchAccount }

View File

@ -1,11 +0,0 @@
import queryClient from '@helpers/queryClient'
import { store } from '@root/store'
import { InstanceLatest } from './migrations/instances/migration'
import { updateInstanceActive } from './slices/instancesSlice'
const initQuery = async ({ instance }: { instance: InstanceLatest }) => {
store.dispatch(updateInstanceActive(instance))
await queryClient.resetQueries()
}
export default initQuery

View File

@ -1,4 +0,0 @@
export type AppV0 = {
expoToken?: string
versionUpdate: boolean
}

View File

@ -1,27 +0,0 @@
import { ContextsV0 } from './v0'
import { ContextsV1 } from './v1'
import { ContextsV2 } from './v2'
import { ContextsV3 } from './v3'
const contextsMigration = {
1: (state: ContextsV0): ContextsV1 => {
return {
...state,
mePage: {
lists: { shown: false },
announcements: { shown: false, unread: 0 }
}
}
},
2: (state: ContextsV1): ContextsV2 => {
const { mePage, ...rest } = state
return rest
},
3: (state: ContextsV2): ContextsV3 => {
return { ...state, previousSegment: 'Local' }
}
}
export { ContextsV3 as ContextsLatest }
export default contextsMigration

View File

@ -1,13 +0,0 @@
export type ContextsV0 = {
storeReview: {
context: Readonly<number>
current: number
shown: boolean
}
publicRemoteNotice: {
context: Readonly<number>
current: number
hidden: boolean
}
previousTab: 'Tab-Local' | 'Tab-Public' | 'Tab-Notifications' | 'Tab-Me'
}

View File

@ -1,17 +0,0 @@
export type ContextsV1 = {
storeReview: {
context: Readonly<number>
current: number
shown: boolean
}
publicRemoteNotice: {
context: Readonly<number>
current: number
hidden: boolean
}
previousTab: 'Tab-Local' | 'Tab-Public' | 'Tab-Notifications' | 'Tab-Me'
mePage: {
lists: { shown: boolean }
announcements: { shown: boolean; unread: number }
}
}

View File

@ -1,13 +0,0 @@
export type ContextsV2 = {
storeReview: {
context: Readonly<number>
current: number
shown: boolean
}
publicRemoteNotice: {
context: Readonly<number>
current: number
hidden: boolean
}
previousTab: 'Tab-Local' | 'Tab-Public' | 'Tab-Notifications' | 'Tab-Me'
}

View File

@ -1,19 +0,0 @@
import { ScreenTabsStackParamList } from '@utils/navigation/navigators'
export type ContextsV3 = {
storeReview: {
context: Readonly<number>
current: number
shown: boolean
}
publicRemoteNotice: {
context: Readonly<number>
current: number
hidden: boolean
}
previousTab: Extract<
keyof ScreenTabsStackParamList,
'Tab-Local' | 'Tab-Public' | 'Tab-Notifications' | 'Tab-Me'
>
previousSegment: Extract<App.Pages, 'Local' | 'LocalPublic' | 'Trending'>
}

View File

@ -1,172 +0,0 @@
import { InstanceV3 } from './v3'
import { InstanceV4 } from './v4'
import { InstanceV5 } from './v5'
import { InstanceV6 } from './v6'
import { InstanceV7 } from './v7'
import { InstanceV8 } from './v8'
import { InstanceV9 } from './v9'
import { InstanceV10 } from './v10'
import { InstanceV11 } from './v11'
const instancesMigration = {
4: (state: InstanceV3): InstanceV4 => {
return {
instances: state.local.instances.map((instance, index) => {
const { notification, ...rest } = instance
return {
...rest,
active: state.local.activeIndex === index,
push: {
global: { loading: false, value: false },
decode: { loading: false, value: false },
alerts: {
follow: { loading: false, value: true },
favourite: { loading: false, value: true },
reblog: { loading: false, value: true },
mention: { loading: false, value: true },
poll: { loading: false, value: true }
},
keys: undefined
}
}
})
}
},
5: (state: InstanceV4): InstanceV5 => {
// @ts-ignore
if (state.instances.length && !state.instances[0].notifications_filter) {
return {
// @ts-ignore
instances: state.instances.map(instance => {
return {
...instance,
notifications_filter: {
follow: true,
favourite: true,
reblog: true,
mention: true,
poll: true,
follow_request: true
}
}
})
}
} else {
// @ts-ignore
return state
}
},
6: (state: InstanceV5): InstanceV6 => {
return {
// @ts-ignore
instances: state.instances.map(instance => {
return {
...instance,
configuration: undefined
}
})
}
},
7: (state: InstanceV6): InstanceV7 => {
return {
instances: state.instances.map(instance => {
return {
...instance,
timelinesLookback: {},
mePage: {
lists: { shown: false },
announcements: { shown: false, unread: 0 }
}
}
})
}
},
8: (state: InstanceV7): InstanceV8 => {
return {
instances: state.instances.map(instance => {
return {
...instance,
frequentEmojis: []
}
})
}
},
9: (state: InstanceV8): { instances: InstanceV9[] } => {
return {
// @ts-ignore
instances: state.instances.map(instance => {
return {
...instance,
version: '0'
}
})
}
},
10: (state: { instances: InstanceV9[] }): { instances: InstanceV10[] } => {
return {
instances: state.instances.map(instance => {
return {
...instance,
notifications_filter: {
...instance.notifications_filter,
status: true,
update: true
},
push: {
...instance.push,
alerts: {
...instance.push.alerts,
follow_request: {
loading: false,
value: true
},
status: {
loading: false,
value: true
}
}
}
}
})
}
},
11: (state: { instances: InstanceV10[] }): { instances: InstanceV11[] } => {
return {
instances: state.instances.map(instance => {
delete instance.timelinesLookback
return {
...instance,
followingPage: { showBoosts: true, showReplies: true },
mePage: { ...instance.mePage, followedTags: { shown: false } },
notifications_filter: {
...instance.notifications_filter,
'admin.sign_up': true,
'admin.report': true
},
push: {
...instance.push,
global: instance.push.global.value,
decode: instance.push.decode.value,
alerts: {
follow: instance.push.alerts.follow.value,
follow_request: instance.push.alerts.follow_request.value,
favourite: instance.push.alerts.favourite.value,
reblog: instance.push.alerts.reblog.value,
mention: instance.push.alerts.mention.value,
poll: instance.push.alerts.poll.value,
status: instance.push.alerts.status.value,
update: false,
'admin.sign_up': false,
'admin.report': false
}
}
}
})
}
}
}
export { InstanceV11 as InstanceLatest }
export default instancesMigration

View File

@ -1,89 +0,0 @@
import { ComposeStateDraft } from '@screens/Compose/utils/types'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
export type InstanceV10 = {
active: boolean
appData: {
clientId: string
clientSecret: string
}
url: string
token: string
uri: Mastodon.Instance['uri']
urls: Mastodon.Instance['urls']
account: {
id: Mastodon.Account['id']
acct: Mastodon.Account['acct']
avatarStatic: Mastodon.Account['avatar_static']
preferences: Mastodon.Preferences
}
version: string
configuration?: Mastodon.Instance['configuration']
filters: Mastodon.Filter<any>[]
notifications_filter: {
follow: boolean
follow_request: boolean
favourite: boolean
reblog: boolean
mention: boolean
poll: boolean
status: boolean
update: boolean
}
push: {
global: { loading: boolean; value: boolean }
decode: { loading: boolean; value: boolean }
alerts: {
follow: {
loading: boolean
value: Mastodon.PushSubscription['alerts']['follow']
}
follow_request: {
loading: boolean
value: Mastodon.PushSubscription['alerts']['follow_request']
}
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']
}
status: {
loading: boolean
value: Mastodon.PushSubscription['alerts']['status']
}
}
keys: {
auth?: string
public?: string // legacy
private?: string // legacy
}
}
timelinesLookback?: {
[key: string]: {
queryKey: QueryKeyTimeline
ids: Mastodon.Status['id'][]
}
}
mePage: {
lists: { shown: boolean }
announcements: { shown: boolean; unread: number }
}
drafts: ComposeStateDraft[]
frequentEmojis: {
emoji: Pick<Mastodon.Emoji, 'shortcode' | 'url' | 'static_url'>
score: number
count: number
lastUsed: number
}[]
}

View File

@ -1,60 +0,0 @@
import { ComposeStateDraft } from '@screens/Compose/utils/types'
export type InstanceV11 = {
active: boolean
appData: {
clientId: string
clientSecret: string
}
url: string
token: string
uri: Mastodon.Instance['uri']
urls: Mastodon.Instance['urls']
account: {
id: Mastodon.Account['id']
acct: Mastodon.Account['acct']
avatarStatic: Mastodon.Account['avatar_static']
preferences?: Mastodon.Preferences
}
version: string
configuration?: Mastodon.Instance['configuration']
filters: Mastodon.Filter<any>[]
notifications_filter: {
follow: boolean
follow_request: boolean
favourite: boolean
reblog: boolean
mention: boolean
poll: boolean
status: boolean
update: boolean
'admin.sign_up': boolean
'admin.report': boolean
}
push: {
global: boolean
decode: boolean
alerts: Mastodon.PushSubscription['alerts']
keys: {
auth?: string
public?: string // legacy
private?: string // legacy
}
}
followingPage: {
showBoosts: boolean
showReplies: boolean
}
mePage: {
followedTags: { shown: boolean }
lists: { shown: boolean }
announcements: { shown: boolean; unread: number }
}
drafts: ComposeStateDraft[]
frequentEmojis: {
emoji: Pick<Mastodon.Emoji, 'shortcode' | 'url' | 'static_url'>
score: number
count: number
lastUsed: number
}[]
}

View File

@ -1,33 +0,0 @@
type InstanceLocal = {
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
}
notification: {
readTime?: Mastodon.Notification['created_at']
latestTime?: Mastodon.Notification['created_at']
}
drafts: any[]
}
export type InstanceV3 = {
local: {
activeIndex: number | null
instances: InstanceLocal[]
}
remote: {
url: string
}
}

View File

@ -1,84 +0,0 @@
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
}
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 InstanceV4 = {
instances: Instance[]
}

View File

@ -1,93 +0,0 @@
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<any>[]
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

@ -1,66 +0,0 @@
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']
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<any>[]
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: boolean }
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 // legacy
private?: string // legacy
}
}
drafts: ComposeStateDraft[]
}
export type InstanceV6 = {
instances: Instance[]
}

View File

@ -1,77 +0,0 @@
import { ComposeStateDraft } from '@screens/Compose/utils/types'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
type Instance = {
active: boolean
appData: {
clientId: string
clientSecret: string
}
url: string
token: string
uri: Mastodon.Instance['uri']
urls: Mastodon.Instance['urls']
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<any>[]
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: boolean }
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 // legacy
private?: string // legacy
}
}
timelinesLookback?: {
[key: string]: {
queryKey: QueryKeyTimeline
ids: Mastodon.Status['id'][]
}
}
mePage: {
lists: { shown: boolean }
announcements: { shown: boolean; unread: number }
}
drafts: ComposeStateDraft[]
}
export type InstanceV7 = {
instances: Instance[]
}

View File

@ -1,83 +0,0 @@
import { ComposeStateDraft } from '@screens/Compose/utils/types'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
type Instance = {
active: boolean
appData: {
clientId: string
clientSecret: string
}
url: string
token: string
uri: Mastodon.Instance['uri']
urls: Mastodon.Instance['urls']
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<any>[]
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: boolean }
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 // legacy
private?: string // legacy
}
}
timelinesLookback?: {
[key: string]: {
queryKey: QueryKeyTimeline
ids: Mastodon.Status['id'][]
}
}
mePage: {
lists: { shown: boolean }
announcements: { shown: boolean; unread: number }
}
drafts: ComposeStateDraft[]
frequentEmojis: {
emoji: Pick<Mastodon.Emoji, 'shortcode' | 'url' | 'static_url'>
score: number
count: number
lastUsed: Date
}[]
}
export type InstanceV8 = {
instances: Instance[]
}

View File

@ -1,79 +0,0 @@
import { ComposeStateDraft } from '@screens/Compose/utils/types'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
export type InstanceV9 = {
active: boolean
appData: {
clientId: string
clientSecret: string
}
url: string
token: string
uri: Mastodon.Instance['uri']
urls: Mastodon.Instance['urls']
account: {
id: Mastodon.Account['id']
acct: Mastodon.Account['acct']
avatarStatic: Mastodon.Account['avatar_static']
preferences: Mastodon.Preferences
}
version: string
configuration?: Mastodon.Instance['configuration']
filters: Mastodon.Filter<any>[]
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: boolean }
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 // legacy
private?: string // legacy
}
}
timelinesLookback?: {
[key: string]: {
queryKey: QueryKeyTimeline
ids: Mastodon.Status['id'][]
}
}
mePage: {
lists: { shown: boolean }
announcements: { shown: boolean; unread: number }
}
drafts: ComposeStateDraft[]
frequentEmojis: {
emoji: Pick<Mastodon.Emoji, 'shortcode' | 'url' | 'static_url'>
score: number
count: number
lastUsed: number
}[]
}

View File

@ -1,33 +0,0 @@
import { SettingsV0 } from './v0'
import { SettingsV1 } from './v1'
import { SettingsV2 } from './v2'
import { SettingsV3 } from './v3'
import { SettingsV4 } from './v4'
const settingsMigration = {
1: (state: SettingsV0): SettingsV1 => {
return {
...state,
darkTheme: 'lighter'
}
},
2: (state: SettingsV1): SettingsV2 => {
return {
...state,
darkTheme: 'lighter',
staticEmoji: false
}
},
3: (state: SettingsV2): SettingsV3 => {
const { analytics, ...rest } = state
return rest
},
4: (state: SettingsV3): SettingsV4 => {
const { staticEmoji, ...rest } = state
return { ...rest, autoplayGifv: true }
}
}
export { SettingsV4 as SettingsLatest }
export default settingsMigration

View File

@ -1,7 +0,0 @@
export type SettingsV0 = {
fontsize: -1 | 0 | 1 | 2 | 3
language: string
theme: 'light' | 'dark' | 'auto'
browser: 'internal' | 'external'
analytics: boolean
}

View File

@ -1,8 +0,0 @@
export type SettingsV1 = {
fontsize: -1 | 0 | 1 | 2 | 3
language: string
theme: 'light' | 'dark' | 'auto'
darkTheme: 'lighter' | 'darker'
browser: 'internal' | 'external'
analytics: boolean
}

View File

@ -1,9 +0,0 @@
export type SettingsV2 = {
fontsize: -1 | 0 | 1 | 2 | 3
language: string
theme: 'light' | 'dark' | 'auto'
darkTheme: 'lighter' | 'darker'
browser: 'internal' | 'external'
staticEmoji: boolean
analytics: boolean
}

View File

@ -1,8 +0,0 @@
export type SettingsV3 = {
fontsize: -1 | 0 | 1 | 2 | 3
language: string
theme: 'light' | 'dark' | 'auto'
darkTheme: 'lighter' | 'darker'
browser: 'internal' | 'external'
staticEmoji: boolean
}

View File

@ -1,8 +0,0 @@
export type SettingsV4 = {
fontsize: -1 | 0 | 1 | 2 | 3
language: string
theme: 'light' | 'dark' | 'auto'
darkTheme: 'lighter' | 'darker'
browser: 'internal' | 'external'
autoplayGifv: boolean
}

View File

@ -0,0 +1,6 @@
import { createNavigationContainerRef } from '@react-navigation/native'
import { RootStackParamList } from '@utils/navigation/navigators'
const navigationRef = createNavigationContainerRef<RootStackParamList>()
export default navigationRef

View File

@ -1,17 +1,16 @@
import { StackActions, useNavigation } from '@react-navigation/native'
import { getInstanceActive } from '@utils/slices/instancesSlice'
import { useNavigation } from '@react-navigation/native'
import { useGlobalStorage } from '@utils/storage/actions'
import { useEffect } from 'react'
import { useSelector } from 'react-redux'
// Mostly used when switching account and sub pages were still querying the old instance
const usePopToTop = () => {
const navigation = useNavigation()
const instanceActive = useSelector(getInstanceActive)
const [accountActive] = useGlobalStorage.string('account.active')
return useEffect(() => {
navigation.dispatch(StackActions.popToTop())
}, [instanceActive])
// navigation.dispatch(StackActions.popToTop())
}, [accountActive])
}
export default usePopToTop

View File

@ -0,0 +1,94 @@
import i18n from '@i18n/index'
import { featureCheck } from '@utils/helpers/featureCheck'
import {
checkPermission,
PERMISSION_MANAGE_REPORTS,
PERMISSION_MANAGE_USERS
} from '@utils/helpers/permissions'
import queryClient from '@utils/queryHooks'
import { QueryKeyProfile } from '@utils/queryHooks/profile'
import { getAccountDetails, getGlobalStorage } from '@utils/storage/actions'
import * as Notifications from 'expo-notifications'
export const PUSH_DEFAULT = [
'follow',
'follow_request',
'favourite',
'reblog',
'mention',
'poll',
'update',
'status'
].filter(type => {
switch (type) {
case 'status':
return featureCheck('notification_type_status')
case 'update':
return featureCheck('notification_type_update')
default:
return true
}
}) as ['follow', 'follow_request', 'favourite', 'reblog', 'mention', 'poll', 'update', 'status']
export const PUSH_ADMIN = [
{ type: 'admin.sign_up', permission: PERMISSION_MANAGE_USERS },
{ type: 'admin.report', permission: PERMISSION_MANAGE_REPORTS }
].filter(({ type, permission }) => {
const queryKeyProfile: QueryKeyProfile = ['Profile']
const permissions = queryClient.getQueryData<Mastodon.Account>(queryKeyProfile)?.role?.permissions
switch (type) {
case 'admin.sign_up':
return (
featureCheck('notification_type_admin_signup') && checkPermission(permission, permissions)
)
case 'admin.report':
return (
featureCheck('notification_type_admin_report') && checkPermission(permission, permissions)
)
}
}) as { type: 'admin.sign_up' | 'admin.report'; permission: number }[]
export const setChannels = async (reset: boolean | undefined = false, specificAccount?: string) => {
const account = specificAccount || getGlobalStorage.string('account.active')
const accountDetails = getAccountDetails(['version', 'push'])
if (!account || !accountDetails) return null
const deleteChannel = async (type: string) =>
Notifications.deleteNotificationChannelAsync(`${account}_${type}`)
const setChannel = async (type: string) =>
Notifications.setNotificationChannelAsync(`${account}_${type}`, {
groupId: account,
name: i18n.t(`screenTabs:me.push.${type}.heading` as any),
importance: Notifications.AndroidImportance.DEFAULT,
bypassDnd: false,
showBadge: true,
enableLights: true,
enableVibrate: true
})
const channelGroup = await Notifications.getNotificationChannelGroupAsync(account)
if (channelGroup && !reset) {
return
}
if (!channelGroup) {
await Notifications.setNotificationChannelGroupAsync(account, { name: account })
}
if (!accountDetails.push.decode) {
await setChannel('default')
for (const push of PUSH_DEFAULT) {
await deleteChannel(push)
}
for (const { type } of PUSH_ADMIN) {
await deleteChannel(type)
}
} else {
await deleteChannel('default')
for (const push of PUSH_DEFAULT) {
await setChannel(push)
}
for (const { type } of PUSH_ADMIN) {
await setChannel(type)
}
}
}

View File

@ -0,0 +1,27 @@
import { isDevelopment } from '@utils/helpers/checkEnvironment'
import { setChannels } from '@utils/push/constants'
import { getGlobalStorage, setGlobalStorage } from '@utils/storage/actions'
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
export const updateExpoToken = async () => {
const expoToken = getGlobalStorage.string('app.expo_token')
if (Platform.OS === 'android') {
await setChannels()
}
if (expoToken?.length) {
return Promise.resolve()
} else {
if (isDevelopment) {
setGlobalStorage('app.expo_token', 'ExponentPushToken[DEVELOPMENT_1]')
return Promise.resolve()
}
return await Notifications.getExpoPushTokenAsync({
experienceId: '@xmflsct/tooot',
applicationId: 'com.xmflsct.app.tooot'
}).then(({ data }) => setGlobalStorage('app.expo_token', data))
}
}

View File

@ -1,30 +1,33 @@
import apiGeneral from '@api/general'
import apiTooot from '@api/tooot'
import { displayMessage } from '@components/Message'
import navigationRef from '@helpers/navigationRef'
import { useAppDispatch } from '@root/store'
import * as Sentry from '@sentry/react-native'
import { useQuery } from '@tanstack/react-query'
import { getExpoToken, retrieveExpoToken } from '@utils/slices/appSlice'
import { disableAllPushes, getInstances } from '@utils/slices/instancesSlice'
import apiGeneral from '@utils/api/general'
import apiTooot from '@utils/api/tooot'
import navigationRef from '@utils/navigation/navigationRef'
import {
getAccountDetails,
getGlobalStorage,
setAccountStorage,
useGlobalStorage
} from '@utils/storage/actions'
import { AxiosError } from 'axios'
import * as Notifications from 'expo-notifications'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { AppState } from 'react-native'
import { useSelector } from 'react-redux'
import { updateExpoToken } from './updateExpoToken'
const pushUseConnect = () => {
const { t } = useTranslation('screens')
const dispatch = useAppDispatch()
useEffect(() => {
dispatch(retrieveExpoToken())
updateExpoToken()
}, [])
const expoToken = useSelector(getExpoToken)
const instances = useSelector(getInstances, (prev, next) => prev.length === next.length)
const pushEnabled = instances.filter(instance => instance.push.global)
const [expoToken] = useGlobalStorage.string('app.expo_token')
const pushEnabledCount = getGlobalStorage.object('accounts')?.filter(account => {
return getAccountDetails(['push'], account)?.push?.global
}).length
const connectQuery = useQuery<any, AxiosError>(
['tooot', { endpoint: 'push/connect' }],
@ -68,19 +71,24 @@ const pushUseConnect = () => {
}
})
dispatch(disableAllPushes())
getGlobalStorage.object('accounts')?.forEach(account => {
const accountDetails = getAccountDetails(['push', 'auth.domain', 'auth.token'], account)
if (!accountDetails) return
instances.forEach(instance => {
if (instance.push.global) {
if (accountDetails.push.global) {
apiGeneral<{}>({
method: 'delete',
domain: instance.url,
domain: accountDetails['auth.domain'],
url: 'api/v1/push/subscription',
headers: {
Authorization: `Bearer ${instance.token}`
Authorization: `Bearer ${accountDetails['auth.token']}`
}
}).catch(() => console.log('error!!!'))
}
setAccountStorage(
[{ key: 'push', value: { ...accountDetails?.push, global: false } }],
account
)
})
}
}
@ -88,14 +96,14 @@ const pushUseConnect = () => {
)
useEffect(() => {
Sentry.setContext('Push', { expoToken, pushEnabledCount: pushEnabled.length })
Sentry.setContext('Push', { expoToken, pushEnabledCount })
if (expoToken && pushEnabled.length) {
if (expoToken && pushEnabledCount) {
connectQuery.refetch()
}
const appStateListener = AppState.addEventListener('change', state => {
if (expoToken && pushEnabled.length && state === 'active') {
if (expoToken && pushEnabledCount && state === 'active') {
Notifications.getBadgeCountAsync().then(count => {
if (count > 0) {
connectQuery.refetch()
@ -107,7 +115,7 @@ const pushUseConnect = () => {
return () => {
appStateListener.remove()
}
}, [expoToken, pushEnabled.length])
}, [expoToken, pushEnabledCount])
}
export default pushUseConnect

View File

@ -1,5 +1,5 @@
import apiInstance from '@api/instance'
import navigationRef from '@helpers/navigationRef'
import apiInstance from '@utils/api/instance'
import navigationRef from '@utils/navigation/navigationRef'
const pushUseNavigate = (id?: Mastodon.Notification['id']) => {
navigationRef.navigate('Screen-Tabs', {

View File

@ -1,15 +1,13 @@
import { displayMessage } from '@components/Message'
import queryClient from '@helpers/queryClient'
import initQuery from '@utils/initQuery'
import queryClient from '@utils/queryHooks'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { getInstances } from '@utils/slices/instancesSlice'
import { generateAccountKey, setAccount, useGlobalStorage } from '@utils/storage/actions'
import * as Notifications from 'expo-notifications'
import { useEffect } from 'react'
import { useSelector } from 'react-redux'
import pushUseNavigate from './useNavigate'
const pushUseReceive = () => {
const instances = useSelector(getInstances, (prev, next) => prev.length === next.length)
const [accounts] = useGlobalStorage.object('accounts')
useEffect(() => {
const subscription = Notifications.addNotificationReceivedListener(notification => {
@ -21,24 +19,25 @@ const pushUseReceive = () => {
accountId: string
}
const notificationIndex = instances.findIndex(
instance =>
instance.url === payloadData.instanceUrl && instance.account.id === payloadData.accountId
const currAccount = accounts?.find(
account =>
account ===
generateAccountKey({ domain: payloadData.instanceUrl, id: payloadData.accountId })
)
displayMessage({
duration: 'long',
message: notification.request.content.title!,
description: notification.request.content.body!,
onPress: () => {
if (notificationIndex !== -1) {
initQuery({ instance: instances[notificationIndex] })
if (currAccount) {
setAccount(currAccount)
}
pushUseNavigate(payloadData.notification_id)
}
})
})
return () => subscription.remove()
}, [instances])
}, [accounts])
}
export default pushUseReceive

View File

@ -1,14 +1,12 @@
import queryClient from '@helpers/queryClient'
import initQuery from '@utils/initQuery'
import queryClient from '@utils/queryHooks'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { getInstances } from '@utils/slices/instancesSlice'
import { generateAccountKey, setAccount, useGlobalStorage } from '@utils/storage/actions'
import * as Notifications from 'expo-notifications'
import { useEffect } from 'react'
import { useSelector } from 'react-redux'
import pushUseNavigate from './useNavigate'
const pushUseRespond = () => {
const instances = useSelector(getInstances, (prev, next) => prev.length === next.length)
const [accounts] = useGlobalStorage.object('accounts')
useEffect(() => {
const subscription = Notifications.addNotificationResponseReceivedListener(
@ -21,19 +19,19 @@ const pushUseRespond = () => {
accountId: string
}
const notificationIndex = instances.findIndex(
instance =>
instance.url === payloadData.instanceUrl &&
instance.account.id === payloadData.accountId
const currAccount = accounts?.find(
account =>
account ===
generateAccountKey({ domain: payloadData.instanceUrl, id: payloadData.accountId })
)
if (notificationIndex !== -1) {
initQuery({ instance: instances[notificationIndex] })
if (currAccount) {
setAccount(currAccount)
}
pushUseNavigate(payloadData.notification_id)
}
)
return () => subscription.remove()
}, [instances])
}, [accounts])
}
export default pushUseRespond

View File

@ -1,6 +1,6 @@
import apiInstance from '@api/instance'
import { AxiosError } from 'axios'
import { QueryFunctionContext, useQuery, UseQueryOptions } from '@tanstack/react-query'
import apiInstance from '@utils/api/instance'
import { AxiosError } from 'axios'
export type QueryKeyAccount = ['Account', { id: Mastodon.Account['id'] }]

View File

@ -1,12 +1,12 @@
import apiInstance from '@api/instance'
import { AxiosError } from 'axios'
import {
QueryFunctionContext,
useMutation,
UseMutationOptions,
useQuery,
UseQueryOptions
QueryFunctionContext,
useMutation,
UseMutationOptions,
useQuery,
UseQueryOptions
} from '@tanstack/react-query'
import apiInstance from '@utils/api/instance'
import { AxiosError } from 'axios'
type QueryKeyAnnouncement = ['Announcements', { showAll?: boolean }]

View File

@ -1,14 +1,14 @@
import apiGeneral from '@api/general'
import apiInstance from '@api/instance'
import {
QueryFunctionContext,
useMutation,
UseMutationOptions,
useQuery,
UseQueryOptions
} from '@tanstack/react-query'
import apiGeneral from '@utils/api/general'
import apiInstance from '@utils/api/instance'
import { AxiosError } from 'axios'
import * as AuthSession from 'expo-auth-session'
import {
QueryFunctionContext,
useMutation,
UseMutationOptions,
useQuery,
UseQueryOptions
} from '@tanstack/react-query'
export type QueryKeyApps = ['Apps']

View File

@ -1,6 +1,7 @@
import apiInstance from '@api/instance'
import { AxiosError } from 'axios'
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import apiInstance from '@utils/api/instance'
import { getAccountStorage, setAccountStorage } from '@utils/storage/actions'
import { AxiosError } from 'axios'
type QueryKeyEmojis = ['Emojis']
@ -12,13 +13,30 @@ const queryFunction = async () => {
return res.body
}
const useEmojisQuery = ({
options
}: {
options?: UseQueryOptions<Mastodon.Emoji[], AxiosError>
}) => {
const useEmojisQuery = (params?: { options?: UseQueryOptions<Mastodon.Emoji[], AxiosError> }) => {
const queryKey: QueryKeyEmojis = ['Emojis']
return useQuery(queryKey, queryFunction, options)
return useQuery(queryKey, queryFunction, {
...params?.options,
staleTime: Infinity,
cacheTime: Infinity,
onSuccess: data => {
if (!data.length) return
const currEmojis = getAccountStorage.object('emojis_frequent')
if (!Array.isArray(currEmojis)) {
setAccountStorage([{ key: 'emojis_frequent', value: [] }])
} else {
setAccountStorage([
{
key: 'emojis_frequent',
value: currEmojis?.filter(emoji =>
data.find(e => e.shortcode === emoji.emoji.shortcode)
)
}
])
}
}
})
}
export { useEmojisQuery }

View File

@ -0,0 +1,24 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import apiInstance from '@utils/api/instance'
import { AxiosError } from 'axios'
export type QueryKeyFilters = ['Filters']
const queryFunction = () =>
apiInstance<Mastodon.Filter<'v1'>[]>({
method: 'get',
url: 'filters'
}).then(res => res.body)
const useFiltersQuery = (params?: {
options: UseQueryOptions<Mastodon.Filter<'v1'>[], AxiosError>
}) => {
const queryKey: QueryKeyFilters = ['Filters']
return useQuery(queryKey, queryFunction, {
...params?.options,
staleTime: Infinity,
cacheTime: Infinity
})
}
export { useFiltersQuery }

View File

@ -0,0 +1,21 @@
import { QueryClient } from '@tanstack/react-query'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
retry: (failureCount, error: any) => {
if (error?.status === 404) {
return false
}
if (failureCount <= 3) {
return true
} else {
return false
}
}
}
}
})
export default queryClient

View File

@ -1,36 +1,66 @@
import apiGeneral from '@api/general'
import { AxiosError } from 'axios'
import { QueryFunctionContext, useQuery, UseQueryOptions } from '@tanstack/react-query'
import apiGeneral from '@utils/api/general'
import apiInstance from '@utils/api/instance'
import { featureCheck } from '@utils/helpers/featureCheck'
import { setAccountStorage } from '@utils/storage/actions'
import { AxiosError } from 'axios'
export type QueryKeyInstance = ['Instance', { domain?: string }]
export type QueryKeyInstance = ['Instance'] | ['Instance', { domain?: string }]
const queryFunction = async ({
queryKey
}: QueryFunctionContext<QueryKeyInstance>) => {
const { domain } = queryKey[1]
if (!domain) {
return Promise.reject()
const queryFunction = async ({ queryKey }: QueryFunctionContext<QueryKeyInstance>) => {
const domain = queryKey[1]?.domain
if (domain) {
return await apiGeneral<Mastodon.Instance<'v2'>>({
method: 'get',
domain,
url: 'api/v2/instance'
})
.then(res => res.body)
.catch(
async () =>
await apiGeneral<Mastodon.Instance<'v1'>>({
method: 'get',
domain,
url: 'api/v1/instance'
}).then(res => res.body)
)
} else {
const hasInstanceNewPath = featureCheck('instance_new_path')
return hasInstanceNewPath
? await apiInstance<Mastodon.Instance<'v2'>>({
method: 'get',
version: 'v2',
url: 'instance'
})
.then(res => res.body)
.catch(
async () =>
await apiInstance<Mastodon.Instance<'v1'>>({
method: 'get',
version: 'v1',
url: 'instance'
}).then(res => res.body)
)
: await apiInstance<Mastodon.Instance<'v1'>>({
method: 'get',
version: 'v1',
url: 'instance'
}).then(res => res.body)
}
const res = await apiGeneral<Mastodon.Instance>({
method: 'get',
domain: domain,
url: `api/v1/instance`
})
return res.body
}
const useInstanceQuery = ({
options,
...queryKeyParams
}: QueryKeyInstance[1] & {
options?: UseQueryOptions<
Mastodon.Instance & { publicAllow?: boolean },
AxiosError
>
}) => {
const queryKey: QueryKeyInstance = ['Instance', { ...queryKeyParams }]
return useQuery(queryKey, queryFunction, options)
const useInstanceQuery = (
params?: QueryKeyInstance[1] & {
options?: UseQueryOptions<Mastodon.Instance<any>, AxiosError>
}
) => {
const queryKey: QueryKeyInstance = params?.domain ? ['Instance', params] : ['Instance']
return useQuery(queryKey, queryFunction, {
...params?.options,
staleTime: Infinity,
cacheTime: Infinity,
onSuccess: data => setAccountStorage([{ key: 'version', value: data.version }])
})
}
export { useInstanceQuery }

View File

@ -1,15 +1,15 @@
import apiInstance from '@api/instance'
import { AxiosError } from 'axios'
import {
QueryFunctionContext,
useInfiniteQuery,
UseInfiniteQueryOptions,
useMutation,
UseMutationOptions,
useQuery,
UseQueryOptions
QueryFunctionContext,
useInfiniteQuery,
UseInfiniteQueryOptions,
useMutation,
UseMutationOptions,
useQuery,
UseQueryOptions
} from '@tanstack/react-query'
import { PagedResponse } from '@api/helpers'
import { PagedResponse } from '@utils/api/helpers'
import apiInstance from '@utils/api/instance'
import { AxiosError } from 'axios'
export type QueryKeyLists = ['Lists']

View File

@ -0,0 +1,27 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import apiInstance from '@utils/api/instance'
import { getAccountStorage, setAccountStorage } from '@utils/storage/actions'
import { AxiosError } from 'axios'
export type QueryKeyPreferences = ['Preferences']
const queryFunction = () =>
apiInstance<Mastodon.Preferences>({
method: 'get',
url: 'preferences'
}).then(res => res.body)
const usePreferencesQuery = (params?: {
options: UseQueryOptions<Mastodon.Preferences, AxiosError>
}) => {
const queryKey: QueryKeyPreferences = ['Preferences']
return useQuery(queryKey, queryFunction, {
...params?.options,
staleTime: Infinity,
cacheTime: Infinity,
initialData: getAccountStorage.object('preferences'),
onSuccess: data => setAccountStorage([{ key: 'preferences', value: data }])
})
}
export { usePreferencesQuery }

View File

@ -1,12 +1,12 @@
import apiInstance from '@api/instance'
import haptics from '@components/haptics'
import { displayMessage } from '@components/Message'
import queryClient from '@helpers/queryClient'
import { useMutation, useQuery, UseQueryOptions } from '@tanstack/react-query'
import apiInstance from '@utils/api/instance'
import queryClient from '@utils/queryHooks'
import { AxiosError } from 'axios'
import i18next from 'i18next'
import { RefObject } from 'react'
import FlashMessage from 'react-native-flash-message'
import { useMutation, useQuery, UseQueryOptions } from '@tanstack/react-query'
type AccountWithSource = Mastodon.Account & Required<Pick<Mastodon.Account, 'source'>>

View File

@ -1,12 +1,12 @@
import apiInstance from '@api/instance'
import { AxiosError } from 'axios'
import {
QueryFunctionContext,
useMutation,
UseMutationOptions,
useQuery,
UseQueryOptions
QueryFunctionContext,
useMutation,
UseMutationOptions,
useQuery,
UseQueryOptions
} from '@tanstack/react-query'
import apiInstance from '@utils/api/instance'
import { AxiosError } from 'axios'
export type QueryKeyRelationship = ['Relationship', { id: Mastodon.Account['id'] }]

View File

@ -1,6 +1,6 @@
import apiInstance from '@api/instance'
import { AxiosError } from 'axios'
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import apiInstance from '@utils/api/instance'
import { AxiosError } from 'axios'
export type QueryKeyRules = ['Rules']

View File

@ -1,6 +1,6 @@
import apiInstance from '@api/instance'
import { AxiosError } from 'axios'
import { QueryFunctionContext, useQuery, UseQueryOptions } from '@tanstack/react-query'
import apiInstance from '@utils/api/instance'
import { AxiosError } from 'axios'
export type QueryKeySearch = [
'Search',

View File

@ -1,6 +1,6 @@
import apiInstance from '@api/instance'
import { AxiosError } from 'axios'
import { QueryFunctionContext, useQuery, UseQueryOptions } from '@tanstack/react-query'
import apiInstance from '@utils/api/instance'
import { AxiosError } from 'axios'
export type QueryKeyStatus = ['Status', { id: Mastodon.Status['id'] }]

View File

@ -1,6 +1,6 @@
import apiInstance from '@api/instance'
import { AxiosError } from 'axios'
import { QueryFunctionContext, useQuery, UseQueryOptions } from '@tanstack/react-query'
import apiInstance from '@utils/api/instance'
import { AxiosError } from 'axios'
export type QueryKeyStatusesHistory = [
'StatusesHistory',

View File

@ -1,5 +1,3 @@
import apiInstance from '@api/instance'
import { AxiosError } from 'axios'
import {
QueryFunctionContext,
useInfiniteQuery,
@ -9,10 +7,11 @@ import {
useQuery,
UseQueryOptions
} from '@tanstack/react-query'
import { PagedResponse } from '@utils/api/helpers'
import apiInstance from '@utils/api/instance'
import { featureCheck } from '@utils/helpers/featureCheck'
import { AxiosError } from 'axios'
import { infinitePageParams } from './utils'
import { PagedResponse } from '@api/helpers'
import { useSelector } from 'react-redux'
import { checkInstanceFeature } from '@utils/slices/instancesSlice'
export type QueryKeyFollowedTags = ['FollowedTags']
const useFollowedTagsQuery = (
@ -23,7 +22,7 @@ const useFollowedTagsQuery = (
>
} | void
) => {
const canFollowTags = useSelector(checkInstanceFeature('follow_tags'))
const canFollowTags = featureCheck('follow_tags')
const queryKey: QueryKeyFollowedTags = ['FollowedTags']
return useInfiniteQuery(

View File

@ -1,21 +1,21 @@
import apiInstance from '@api/instance'
import haptics from '@components/haptics'
import queryClient from '@helpers/queryClient'
import { store } from '@root/store'
import { checkInstanceFeature, getInstanceNotificationsFilter } from '@utils/slices/instancesSlice'
import {
MutationOptions,
QueryFunctionContext,
useInfiniteQuery,
UseInfiniteQueryOptions,
useMutation
} from '@tanstack/react-query'
import { PagedResponse } from '@utils/api/helpers'
import apiInstance from '@utils/api/instance'
import { featureCheck } from '@utils/helpers/featureCheck'
import queryClient from '@utils/queryHooks'
import { getAccountStorage } from '@utils/storage/actions'
import { AxiosError } from 'axios'
import { uniqBy } from 'lodash'
import {
MutationOptions,
QueryFunctionContext,
useInfiniteQuery,
UseInfiniteQueryOptions,
useMutation
} from '@tanstack/react-query'
import deleteItem from './timeline/deleteItem'
import editItem from './timeline/editItem'
import updateStatusProperty from './timeline/updateStatusProperty'
import { PagedResponse } from '@api/helpers'
export type QueryKeyTimeline = [
'Timeline',
@ -82,7 +82,6 @@ const queryFunction = async ({ queryKey, pageParam }: QueryFunctionContext<Query
})
case 'Local':
console.log('local', params)
return apiInstance<Mastodon.Status[]>({
method: 'get',
url: 'timelines/public',
@ -100,7 +99,6 @@ const queryFunction = async ({ queryKey, pageParam }: QueryFunctionContext<Query
})
case 'Trending':
console.log('trending', params)
return apiInstance<Mastodon.Status[]>({
method: 'get',
url: 'trends/statuses',
@ -108,11 +106,8 @@ const queryFunction = async ({ queryKey, pageParam }: QueryFunctionContext<Query
})
case 'Notifications':
const rootStore = store.getState()
const notificationsFilter = getInstanceNotificationsFilter(rootStore)
const usePositiveFilter = checkInstanceFeature('notification_types_positive_filter')(
rootStore
)
const notificationsFilter = getAccountStorage.object('notifications')
const usePositiveFilter = featureCheck('notification_types_positive_filter')
return apiInstance<Mastodon.Notification[]>({
method: 'get',
url: 'notifications',

View File

@ -1,5 +1,5 @@
import queryClient from '@helpers/queryClient'
import { InfiniteData } from '@tanstack/react-query'
import queryClient from '@utils/queryHooks'
import { MutationVarsTimelineDeleteItem } from '../timeline'
const deleteItem = ({

View File

@ -1,5 +1,5 @@
import queryClient from '@helpers/queryClient'
import { InfiniteData } from '@tanstack/react-query'
import queryClient from '@utils/queryHooks'
import { MutationVarsTimelineEditItem } from '../timeline'
const editItem = ({

View File

@ -1,5 +1,5 @@
import queryClient from '@helpers/queryClient'
import { InfiniteData } from '@tanstack/react-query'
import queryClient from '@utils/queryHooks'
import { MutationVarsTimelineUpdateStatusProperty, TimelineData } from '../timeline'
import updateConversation from './update/conversation'
import updateNotification from './update/notification'

View File

@ -1,7 +1,7 @@
import apiTooot from '@api/tooot'
import haptics from '@components/haptics'
import { AxiosError } from 'axios'
import { QueryFunctionContext, useQuery, UseQueryOptions } from '@tanstack/react-query'
import apiTooot from '@utils/api/tooot'
import { AxiosError } from 'axios'
type Translations =
| {

View File

@ -1,13 +1,12 @@
import apiInstance from '@api/instance'
import { store } from '@root/store'
import { checkInstanceFeature } from '@utils/slices/instancesSlice'
import { AxiosError } from 'axios'
import { QueryFunctionContext, useQuery, UseQueryOptions } from '@tanstack/react-query'
import apiInstance from '@utils/api/instance'
import { featureCheck } from '@utils/helpers/featureCheck'
import { AxiosError } from 'axios'
export type QueryKeyTrends = ['Trends', { type: 'tags' | 'statuses' | 'links' }]
const queryFunction = ({ queryKey }: QueryFunctionContext<QueryKeyTrends>) => {
const trendsNewPath = checkInstanceFeature('trends_new_path')(store.getState())
const trendsNewPath = featureCheck('trends_new_path')
if (!trendsNewPath && queryKey[1].type !== 'tags') {
return []

View File

@ -1,14 +1,14 @@
import apiInstance from '@api/instance'
import {
QueryFunctionContext,
useInfiniteQuery,
UseInfiniteQueryOptions
} from '@tanstack/react-query'
import apiGeneral from '@utils/api/general'
import { PagedResponse } from '@utils/api/helpers'
import apiInstance from '@utils/api/instance'
import { getHost } from '@utils/helpers/urlMatcher'
import { TabSharedStackParamList } from '@utils/navigation/navigators'
import { AxiosError } from 'axios'
import {
QueryFunctionContext,
useInfiniteQuery,
UseInfiniteQueryOptions
} from '@tanstack/react-query'
import apiGeneral from '@api/general'
import { PagedResponse } from '@api/helpers'
import { getHost } from '@helpers/urlMatcher'
export type QueryKeyUsers = ['Users', TabSharedStackParamList['Tab-Shared-Users']]

View File

@ -1,4 +1,4 @@
import { PagedResponse } from '@api/helpers'
import { PagedResponse } from '@utils/api/helpers'
export const infinitePageParams = {
getPreviousPageParam: (firstPage: PagedResponse<any>) =>

View File

@ -1,58 +0,0 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import { RootState } from '@root/store'
import { isDevelopment } from '@utils/checkEnvironment'
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
import { setChannels } from './instances/push/utils'
import { getInstance } from './instancesSlice'
export const retrieveExpoToken = createAsyncThunk(
'app/expoToken',
async (_, { getState }): Promise<string> => {
const instance = getInstance(getState() as RootState)
const expoToken = getExpoToken(getState() as RootState)
if (Platform.OS === 'android') {
await setChannels(instance)
}
if (expoToken?.length) {
return expoToken
} else {
if (isDevelopment) {
return 'ExponentPushToken[DEVELOPMENT_1]'
}
const res = await Notifications.getExpoPushTokenAsync({
experienceId: '@xmflsct/tooot',
applicationId: 'com.xmflsct.app.tooot'
})
return res.data
}
}
)
export type AppState = {
expoToken?: string
}
export const appInitialState: AppState = {
expoToken: undefined
}
const appSlice = createSlice({
name: 'app',
initialState: appInitialState,
reducers: {},
extraReducers: builder => {
builder.addCase(retrieveExpoToken.fulfilled, (state, action) => {
if (action.payload) {
state.expoToken = action.payload
}
})
}
})
export const getExpoToken = (state: RootState) => state.app.expoToken
export default appSlice.reducer

View File

@ -1,62 +0,0 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { RootState } from '@root/store'
import { ContextsLatest } from '@utils/migrations/contexts/migration'
import Constants from 'expo-constants'
import * as StoreReview from 'expo-store-review'
export const contextsInitialState: ContextsLatest = {
// After 10 successful postings
storeReview: {
context: 10,
current: 0,
shown: false
},
// After public remote settings has been used once
publicRemoteNotice: {
context: 1,
current: 0,
hidden: false
},
previousTab: 'Tab-Me',
previousSegment: 'Local'
}
const contextsSlice = createSlice({
name: 'contexts',
initialState: contextsInitialState,
reducers: {
updateStoreReview: (state, action: PayloadAction<1>) => {
if (Constants.expoConfig?.extra?.environment === 'release') {
state.storeReview.current = state.storeReview.current + action.payload
if (state.storeReview.current === state.storeReview.context) {
StoreReview?.isAvailableAsync().then(() => StoreReview.requestReview())
}
}
},
updatePublicRemoteNotice: (state, action: PayloadAction<1>) => {
state.publicRemoteNotice.current = state.publicRemoteNotice.current + action.payload
if (state.publicRemoteNotice.current === state.publicRemoteNotice.context) {
state.publicRemoteNotice.hidden = true
}
},
updatePreviousTab: (state, action: PayloadAction<ContextsLatest['previousTab']>) => {
state.previousTab = action.payload
},
updatePreviousSegment: (state, action: PayloadAction<ContextsLatest['previousSegment']>) => {
state.previousSegment = action.payload
}
}
})
export const getPublicRemoteNotice = (state: RootState) => state.contexts.publicRemoteNotice
export const getPreviousTab = (state: RootState) => state.contexts.previousTab
export const getPreviousSegment = (state: RootState) => state.contexts.previousSegment
export const getContexts = (state: RootState) => state.contexts
export const {
updateStoreReview,
updatePublicRemoteNotice,
updatePreviousTab,
updatePreviousSegment
} = contextsSlice.actions
export default contextsSlice.reducer

View File

@ -1,133 +0,0 @@
import apiGeneral from '@api/general'
import { createAsyncThunk } from '@reduxjs/toolkit'
import { RootState } from '@root/store'
import { InstanceLatest } from '@utils/migrations/instances/migration'
const addInstance = createAsyncThunk(
'instances/add',
async ({
domain,
token,
instance,
appData
}: {
domain: InstanceLatest['url']
token: InstanceLatest['token']
instance: Mastodon.Instance
appData: InstanceLatest['appData']
}): Promise<{ type: 'add' | 'overwrite'; data: InstanceLatest }> => {
const { store } = require('@root/store')
const instances = (store.getState() as RootState).instances.instances
const {
body: { id, acct, avatar_static }
} = await apiGeneral<Mastodon.Account>({
method: 'get',
domain,
url: `api/v1/accounts/verify_credentials`,
headers: { Authorization: `Bearer ${token}` }
})
let type: 'add' | 'overwrite'
type = 'add'
if (
instances.length &&
instances.filter(instance => {
if (instance.url === domain && instance.account.id === id) {
return true
} else {
return false
}
}).length
) {
type = 'overwrite'
} else {
type = 'add'
}
const { body: preferences } = await apiGeneral<Mastodon.Preferences>({
method: 'get',
domain,
url: `api/v1/preferences`,
headers: { Authorization: `Bearer ${token}` }
}).catch(error => {
if (error?.status === 404) {
return Promise.resolve({ body: {} })
} else {
return Promise.reject()
}
})
const { body: filters } = await apiGeneral<Mastodon.Filter<any>[]>({
method: 'get',
domain,
url: `api/v1/filters`,
headers: { Authorization: `Bearer ${token}` }
})
return Promise.resolve({
type,
data: {
active: true,
appData,
url: domain,
token,
uri: instance.uri.replace(/^https?:\/\//, ''), // Pleroma includes schema
urls: instance.urls,
account: {
id,
acct,
avatarStatic: avatar_static,
preferences
},
version: instance.version,
...(instance.configuration && {
configuration: instance.configuration
}),
filters,
notifications_filter: {
follow: true,
follow_request: true,
favourite: true,
reblog: true,
mention: true,
poll: true,
status: true,
update: true,
'admin.sign_up': true,
'admin.report': true
},
push: {
global: false,
decode: false,
alerts: {
follow: true,
follow_request: true,
favourite: true,
reblog: true,
mention: true,
poll: true,
status: true,
update: true,
'admin.sign_up': false,
'admin.report': false
},
keys: { auth: undefined, public: undefined, private: undefined }
},
followingPage: {
showBoosts: true,
showReplies: true
},
mePage: {
followedTags: { shown: false },
lists: { shown: false },
announcements: { shown: false, unread: 0 }
},
drafts: [],
frequentEmojis: []
}
})
}
)
export default addInstance

View File

@ -1,15 +0,0 @@
import apiInstance from '@api/instance'
import queryClient from '@helpers/queryClient'
import { createAsyncThunk } from '@reduxjs/toolkit'
export const checkEmojis = createAsyncThunk(
'instances/checkEmojis',
async (): Promise<Mastodon.Emoji[]> => {
const res = await apiInstance<Mastodon.Emoji[]>({
method: 'get',
url: 'custom_emojis'
}).then(res => res.body)
queryClient.setQueryData(['Emojis'], res)
return res
}
)

View File

@ -1,106 +0,0 @@
import apiInstance from '@api/instance'
import apiTooot, { TOOOT_API_DOMAIN } from '@api/tooot'
import { displayMessage } from '@components/Message'
import { RootState } from '@root/store'
import * as Sentry from '@sentry/react-native'
import { InstanceLatest } from '@utils/migrations/instances/migration'
import { getInstance } from '@utils/slices/instancesSlice'
import * as Random from 'expo-random'
import i18next from 'i18next'
import { Platform } from 'react-native'
import base64 from 'react-native-base64'
import { setChannels } from './utils'
const subscribe = async ({
expoToken,
instanceUrl,
accountId,
accountFull,
serverKey,
auth
}: {
expoToken: string
instanceUrl: string
accountId: Mastodon.Account['id']
accountFull: string
serverKey: string
auth: string | null
}) => {
return apiTooot({
method: 'post',
url: `push/subscribe/${expoToken}/${instanceUrl}/${accountId}`,
body: { accountFull, serverKey, auth }
})
}
const pushRegister = async (
state: RootState,
expoToken: string
): Promise<InstanceLatest['push']['keys']['auth']> => {
const instance = getInstance(state)
const instanceUrl = instance?.url
const instanceUri = instance?.uri
const instanceAccount = instance?.account
const instancePush = instance?.push
if (!instanceUrl || !instanceUri || !instanceAccount || !instancePush) {
return Promise.reject()
}
const accountId = instanceAccount.id
const accountFull = `@${instanceAccount.acct}@${instanceUri}`
const randomPath = (Math.random() + 1).toString(36).substring(2)
const endpoint = `https://${TOOOT_API_DOMAIN}/push/send/${expoToken}/${instanceUrl}/${accountId}/${randomPath}`
const auth = base64.encodeFromByteArray(Random.getRandomBytes(16))
const alerts = instancePush.alerts
const formData = new FormData()
formData.append('subscription[endpoint]', endpoint)
formData.append(
'subscription[keys][p256dh]',
'BMn2PLpZrMefG981elzG6SB1EY9gU7QZwmtZ/a/J2vUeWG+zXgeskMPwHh4T/bxsD4l7/8QT94F57CbZqYRRfJo='
)
formData.append('subscription[keys][auth]', auth)
for (const [key, value] of Object.entries(alerts)) {
formData.append(`data[alerts][${key}]`, value.toString())
}
const res = await apiInstance<Mastodon.PushSubscription>({
method: 'post',
url: 'push/subscription',
body: formData
})
if (!res.body.server_key?.length) {
displayMessage({
type: 'danger',
duration: 'long',
message: i18next.t('screenTabs:me.push.missingServerKey.message'),
description: i18next.t('screenTabs:me.push.missingServerKey.description')
})
Sentry.setContext('Push server key', {
instance: instanceUri,
resBody: res.body
})
Sentry.captureMessage('Push register error')
return Promise.reject()
}
await subscribe({
expoToken,
instanceUrl,
accountId,
accountFull,
serverKey: res.body.server_key,
auth: instancePush.decode === false ? null : auth
})
if (Platform.OS === 'android') {
setChannels(instance, true)
}
return Promise.resolve(auth)
}
export default pushRegister

View File

@ -1,35 +0,0 @@
import apiInstance from '@api/instance'
import apiTooot from '@api/tooot'
import { RootState } from '@root/store'
import { getInstance } from '@utils/slices/instancesSlice'
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
const pushUnregister = async (state: RootState, expoToken: string) => {
const instance = getInstance(state)
const instanceUri = instance?.uri
const instanceAccount = instance?.account
if (!instance?.url || !instance.account.id) {
return Promise.reject()
}
await apiInstance<{}>({
method: 'delete',
url: 'push/subscription'
})
await apiTooot<{ endpoint: string; publicKey: string; auth: string }>({
method: 'delete',
url: `push/unsubscribe/${expoToken}/${instance.url}/${instance.account.id}`
})
if (Platform.OS === 'android') {
const accountFull = `@${instanceAccount?.acct}@${instanceUri}`
Notifications.deleteNotificationChannelGroupAsync(accountFull)
}
return
}
export default pushUnregister

View File

@ -1,120 +0,0 @@
import features from '@helpers/features'
import {
checkPermission,
PERMISSION_MANAGE_REPORTS,
PERMISSION_MANAGE_USERS
} from '@helpers/permissions'
import queryClient from '@helpers/queryClient'
import i18n from '@root/i18n/i18n'
import { InstanceLatest } from '@utils/migrations/instances/migration'
import { queryFunctionProfile, QueryKeyProfile } from '@utils/queryHooks/profile'
import { checkInstanceFeature } from '@utils/slices/instancesSlice'
import * as Notifications from 'expo-notifications'
import { useSelector } from 'react-redux'
export const usePushFeatures = () => {
const hasTypeStatus = useSelector(checkInstanceFeature('notification_type_status'))
const hasTypeUpdate = useSelector(checkInstanceFeature('notification_type_update'))
const hasTypeAdminSignup = useSelector(checkInstanceFeature('notification_type_admin_signup'))
const hasTypeAdminReport = useSelector(checkInstanceFeature('notification_type_admin_report'))
return { hasTypeStatus, hasTypeUpdate, hasTypeAdminSignup, hasTypeAdminReport }
}
export const PUSH_DEFAULT = ({
hasTypeUpdate,
hasTypeStatus
}: {
hasTypeUpdate: boolean
hasTypeStatus: boolean
}) =>
['follow', 'follow_request', 'favourite', 'reblog', 'mention', 'poll', 'update', 'status'].filter(
type => {
switch (type) {
case 'status':
return hasTypeStatus
case 'update':
return hasTypeUpdate
default:
return true
}
}
) as ['follow', 'follow_request', 'favourite', 'reblog', 'mention', 'poll', 'update', 'status']
export const PUSH_ADMIN = (
{
hasTypeAdminSignup,
hasTypeAdminReport
}: {
hasTypeAdminSignup: boolean
hasTypeAdminReport: boolean
},
permissions?: string | number | undefined
) =>
[
{ type: 'admin.sign_up', permission: PERMISSION_MANAGE_USERS },
{ type: 'admin.report', permission: PERMISSION_MANAGE_REPORTS }
].filter(({ type, permission }) => {
switch (type) {
case 'admin.sign_up':
return hasTypeAdminSignup && checkPermission(permission, permissions)
case 'admin.report':
return hasTypeAdminReport && checkPermission(permission, permissions)
}
}) as { type: 'admin.sign_up' | 'admin.report'; permission: number }[]
export const setChannels = async (instance: InstanceLatest, reset: boolean | undefined = false) => {
const account = `@${instance.account.acct}@${instance.uri}`
const deleteChannel = async (type: string) =>
Notifications.deleteNotificationChannelAsync(`${account}_${type}`)
const setChannel = async (type: string) =>
Notifications.setNotificationChannelAsync(`${account}_${type}`, {
groupId: account,
name: i18n.t(`screenTabs:me.push.${type}.heading` as any),
importance: Notifications.AndroidImportance.DEFAULT,
bypassDnd: false,
showBadge: true,
enableLights: true,
enableVibrate: true
})
const queryKey: QueryKeyProfile = ['Profile']
const profileQuery = await queryClient.fetchQuery(queryKey, queryFunctionProfile)
const channelGroup = await Notifications.getNotificationChannelGroupAsync(account)
if (channelGroup && !reset) {
return
}
if (!channelGroup) {
await Notifications.setNotificationChannelGroupAsync(account, { name: account })
}
const checkFeature = (feature: string) =>
features
.filter(f => f.feature === feature)
.filter(f => parseFloat(instance.version) >= f.version)?.length > 0
const checkFeatures = {
hasTypeStatus: checkFeature('notification_type_status'),
hasTypeUpdate: checkFeature('notification_type_update'),
hasTypeAdminSignup: checkFeature('notification_type_admin_signup'),
hasTypeAdminReport: checkFeature('notification_type_admin_report')
}
if (!instance.push.decode) {
await setChannel('default')
for (const push of PUSH_DEFAULT(checkFeatures)) {
await deleteChannel(push)
}
for (const { type } of PUSH_ADMIN(checkFeatures, profileQuery.role?.permissions)) {
await deleteChannel(type)
}
} else {
await deleteChannel('default')
for (const push of PUSH_DEFAULT(checkFeatures)) {
await setChannel(push)
}
for (const { type } of PUSH_ADMIN(checkFeatures, profileQuery.role?.permissions)) {
await setChannel(type)
}
}
}

View File

@ -1,38 +0,0 @@
import { createAsyncThunk } from '@reduxjs/toolkit'
import { InstanceLatest } from '@utils/migrations/instances/migration'
import * as AuthSession from 'expo-auth-session'
import { updateInstancePush } from './updatePush'
const removeInstance = createAsyncThunk(
'instances/remove',
async (instance: InstanceLatest, { dispatch }): Promise<InstanceLatest> => {
if (instance.push.global) {
dispatch(updateInstancePush(false))
}
let revoked = undefined
try {
revoked = await AuthSession.revokeAsync(
{
clientId: instance.appData.clientId,
clientSecret: instance.appData.clientSecret,
token: instance.token,
scopes: ['read', 'write', 'follow', 'push']
},
{
revocationEndpoint: `https://${instance.url}/oauth/revoke`
}
)
} catch {
console.warn('Revoking error')
}
if (!revoked) {
console.warn('Revoking error')
}
return Promise.resolve(instance)
}
)
export default removeInstance

View File

@ -1,20 +0,0 @@
import apiInstance from '@api/instance'
import { createAsyncThunk } from '@reduxjs/toolkit'
export const updateAccountPreferences = createAsyncThunk(
'instances/updateAccountPreferences',
async (): Promise<Mastodon.Preferences> => {
return apiInstance<Mastodon.Preferences>({
method: 'get',
url: `preferences`
})
.then(res => res.body)
.catch(error => {
if (error?.status === 404) {
return Promise.resolve({})
} else {
return Promise.reject()
}
})
}
)

View File

@ -1,12 +0,0 @@
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

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

View File

@ -1,25 +0,0 @@
import { createAsyncThunk } from '@reduxjs/toolkit'
import { RootState } from '@root/store'
import { InstanceLatest } from '@utils/migrations/instances/migration'
import pushRegister from './push/register'
import pushUnregister from './push/unregister'
export const updateInstancePush = createAsyncThunk(
'instances/updatePush',
async (
disable: boolean,
{ getState }
): Promise<InstanceLatest['push']['keys']['auth'] | undefined> => {
const state = getState() as RootState
const expoToken = state.app.expoToken
if (!expoToken) {
return Promise.reject()
}
if (disable) {
return await pushRegister(state, expoToken)
} else {
return await pushUnregister(state, expoToken)
}
}
)

View File

@ -1,25 +0,0 @@
import apiInstance from '@api/instance'
import { createAsyncThunk } from '@reduxjs/toolkit'
import { InstanceLatest } from '@utils/migrations/instances/migration'
export const updateInstancePushAlert = createAsyncThunk(
'instances/updatePushAlert',
async ({
alerts
}: {
alerts: InstanceLatest['push']['alerts']
}): Promise<InstanceLatest['push']['alerts']> => {
const formData = new FormData()
for (const [key, value] of Object.entries(alerts)) {
formData.append(`data[alerts][${key}]`, value.toString())
}
await apiInstance<Mastodon.PushSubscription>({
method: 'put',
url: 'push/subscription',
body: formData
})
return Promise.resolve(alerts)
}
)

View File

@ -1,40 +0,0 @@
import apiTooot from '@api/tooot'
import { createAsyncThunk } from '@reduxjs/toolkit'
import { RootState } from '@root/store'
import { InstanceLatest } from '@utils/migrations/instances/migration'
import { Platform } from 'react-native'
import { getInstance } from '../instancesSlice'
import { setChannels } from './push/utils'
export const updateInstancePushDecode = createAsyncThunk(
'instances/updatePushDecode',
async (
disable: boolean,
{ getState }
): Promise<{ disable: InstanceLatest['push']['decode'] }> => {
const state = getState() as RootState
const instance = getInstance(state)
if (!instance?.url || !instance.account.id || !instance.push.keys) {
return Promise.reject()
}
const expoToken = state.app.expoToken
if (!expoToken) {
return Promise.reject()
}
await apiTooot({
method: 'put',
url: `push/update-decode/${expoToken}/${instance.url}/${instance.account.id}`,
body: {
auth: !disable ? null : instance.push.keys.auth
}
})
if (Platform.OS === 'android') {
setChannels(instance, true)
}
return Promise.resolve({ disable })
}
)

View File

@ -1,390 +0,0 @@
import features from '@helpers/features'
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { RootState } from '@root/store'
import { ComposeStateDraft } from '@screens/Compose/utils/types'
import { InstanceLatest } from '@utils/migrations/instances/migration'
import addInstance from './instances/add'
import { checkEmojis } from './instances/checkEmojis'
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'
import { updateInstancePushDecode } from './instances/updatePushDecode'
export type InstancesState = {
instances: InstanceLatest[]
}
export const instancesInitialState: InstancesState = {
instances: []
}
const findInstanceActive = (instances: InstanceLatest[]) =>
instances.findIndex(instance => instance.active)
const instancesSlice = createSlice({
name: 'instances',
initialState: instancesInitialState,
reducers: {
updateInstanceActive: ({ instances }, action: PayloadAction<InstanceLatest>) => {
instances = instances.map(instance => {
instance.active =
instance.url === action.payload.url &&
instance.token === action.payload.token &&
instance.account.id === action.payload.account.id
return instance
})
},
updateInstanceAccount: (
{ instances },
action: PayloadAction<Pick<InstanceLatest['account'], 'acct' & 'avatarStatic'>>
) => {
const activeIndex = findInstanceActive(instances)
instances[activeIndex].account = {
...instances[activeIndex].account,
...action.payload
}
},
updateInstanceNotificationsFilter: (
{ instances },
action: PayloadAction<InstanceLatest['notifications_filter']>
) => {
const activeIndex = findInstanceActive(instances)
instances[activeIndex].notifications_filter = action.payload
},
updateInstanceDraft: ({ instances }, action: PayloadAction<ComposeStateDraft>) => {
const activeIndex = findInstanceActive(instances)
const draftIndex = instances[activeIndex].drafts.findIndex(
({ timestamp }) => timestamp === action.payload.timestamp
)
if (draftIndex === -1) {
instances[activeIndex].drafts.unshift(action.payload)
} else {
instances[activeIndex].drafts[draftIndex] = action.payload
}
},
removeInstanceDraft: ({ instances }, action: PayloadAction<ComposeStateDraft['timestamp']>) => {
const activeIndex = findInstanceActive(instances)
instances[activeIndex].drafts = instances[activeIndex].drafts?.filter(
draft => draft.timestamp !== action.payload
)
},
clearPushLoading: ({ instances }) => {
const activeIndex = findInstanceActive(instances)
},
disableAllPushes: ({ instances }) => {
instances = instances.map(instance => {
let newInstance = instance
newInstance.push.global = false
return newInstance
})
},
updateInstanceFollowingPage: (
{ instances },
action: PayloadAction<Partial<InstanceLatest['followingPage']>>
) => {
const activeIndex = findInstanceActive(instances)
instances[activeIndex].followingPage = {
...instances[activeIndex].followingPage,
...action.payload
}
},
updateInstanceMePage: (
{ instances },
action: PayloadAction<Partial<InstanceLatest['mePage']>>
) => {
const activeIndex = findInstanceActive(instances)
instances[activeIndex].mePage = {
...instances[activeIndex].mePage,
...action.payload
}
},
countInstanceEmoji: (
{ instances },
action: PayloadAction<InstanceLatest['frequentEmojis'][0]['emoji']>
) => {
const HALF_LIFE = 60 * 60 * 24 * 7 // 1 week
const calculateScore = (emoji: InstanceLatest['frequentEmojis'][0]): number => {
var seconds = (new Date().getTime() - emoji.lastUsed) / 1000
var score = emoji.count + 1
var order = Math.log(Math.max(score, 1)) / Math.LN10
var sign = score > 0 ? 1 : score === 0 ? 0 : -1
return (sign * order + seconds / HALF_LIFE) * 10
}
const activeIndex = findInstanceActive(instances)
const foundEmojiIndex = instances[activeIndex].frequentEmojis?.findIndex(
e => e.emoji.shortcode === action.payload.shortcode && e.emoji.url === action.payload.url
)
let newEmojisSort: InstanceLatest['frequentEmojis']
if (foundEmojiIndex > -1) {
newEmojisSort = instances[activeIndex].frequentEmojis
.map((e, i) =>
i === foundEmojiIndex
? {
...e,
score: calculateScore(e),
count: e.count + 1,
lastUsed: new Date().getTime()
}
: e
)
.sort((a, b) => b.score - a.score)
} else {
newEmojisSort = instances[activeIndex].frequentEmojis || []
const temp = {
emoji: action.payload,
score: 0,
count: 0,
lastUsed: new Date().getTime()
}
newEmojisSort.push({
...temp,
score: calculateScore(temp),
count: temp.count + 1
})
}
instances[activeIndex].frequentEmojis = newEmojisSort
.sort((a, b) => b.score - a.score)
.slice(0, 20)
}
},
extraReducers: builder => {
builder
.addCase(addInstance.fulfilled, (state, action) => {
switch (action.payload.type) {
case 'add':
state.instances.length &&
(state.instances = state.instances.map(instance => {
instance.active = false
return instance
}))
state.instances.push(action.payload.data)
break
case 'overwrite':
state.instances = state.instances.map(instance => {
if (
instance.url === action.payload.data.url &&
instance.account.id === action.payload.data.account.id
) {
return action.payload.data
} else {
instance.active = false
return instance
}
})
}
})
.addCase(addInstance.rejected, (state, action) => {
console.error(state.instances)
console.error(action.error)
})
.addCase(removeInstance.fulfilled, (state, action) => {
state.instances = state.instances.filter(instance => {
if (
instance.url === action.payload.url &&
instance.account.id === action.payload.account.id
) {
return false
} else {
return true
}
})
state.instances.length && (state.instances[state.instances.length - 1].active = true)
})
.addCase(removeInstance.rejected, (state, action) => {
console.error(state)
console.error(action.error)
})
// Update Instance Account Filters
.addCase(updateFilters.fulfilled, (state, action) => {
const activeIndex = findInstanceActive(state.instances)
state.instances[activeIndex].filters = action.payload
})
.addCase(updateFilters.rejected, (_, action) => {
console.error(action.error)
})
// Update Instance Account Preferences
.addCase(updateAccountPreferences.fulfilled, (state, action) => {
const activeIndex = findInstanceActive(state.instances)
state.instances[activeIndex].account.preferences = action.payload
})
.addCase(updateAccountPreferences.rejected, (_, action) => {
console.error(action.error)
})
// Update Instance Configuration
.addCase(updateConfiguration.fulfilled, (state, action) => {
const activeIndex = findInstanceActive(state.instances)
state.instances[activeIndex].version =
typeof action.payload.version === 'string' ? action.payload.version : '0'
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)
state.instances[activeIndex].push.global = action.meta.arg
state.instances[activeIndex].push.keys = { auth: action.payload }
})
// Update Instance Push Decode
.addCase(updateInstancePushDecode.fulfilled, (state, action) => {
const activeIndex = findInstanceActive(state.instances)
state.instances[activeIndex].push.decode = action.payload.disable
})
// Update Instance Push Individual Alert
.addCase(updateInstancePushAlert.fulfilled, (state, action) => {
const activeIndex = findInstanceActive(state.instances)
state.instances[activeIndex].push.alerts = action.payload
})
// Check if frequently used emojis still exist
.addCase(checkEmojis.fulfilled, (state, action) => {
if (!action.payload || !action.payload.length) return
const activeIndex = findInstanceActive(state.instances)
if (!Array.isArray(state.instances[activeIndex].frequentEmojis)) {
state.instances[activeIndex].frequentEmojis = []
}
state.instances[activeIndex].frequentEmojis = state.instances[
activeIndex
]?.frequentEmojis?.filter(emoji => {
return action.payload?.find(
e => e.shortcode === emoji.emoji.shortcode && e.url === emoji.emoji.url
)
})
})
.addCase(checkEmojis.rejected, (_, action) => {
console.error(action.error)
})
}
})
export const getInstanceActive = ({ instances: { instances } }: RootState) =>
findInstanceActive(instances)
export const getInstances = ({ instances: { instances } }: RootState) => instances
export const getInstance = ({ instances: { instances } }: RootState) =>
instances[findInstanceActive(instances)]
export const getInstanceUrl = ({ instances: { instances } }: RootState) =>
instances[findInstanceActive(instances)]?.url
export const getInstanceUri = ({ instances: { instances } }: RootState) =>
instances[findInstanceActive(instances)]?.uri
export const getInstanceUrls = ({ instances: { instances } }: RootState) =>
instances[findInstanceActive(instances)]?.urls
export const getInstanceVersion = ({ instances: { instances } }: RootState) =>
instances[findInstanceActive(instances)]?.version
export const checkInstanceFeature =
(feature: string) =>
({ instances: { instances } }: RootState): boolean => {
return (
features
.filter(f => f.feature === feature)
.filter(f => parseFloat(instances[findInstanceActive(instances)]?.version) >= f.version)
?.length > 0
)
}
/* Get Instance Configuration */
export const getInstanceConfigurationStatusMaxChars = ({ instances: { instances } }: RootState) =>
instances[findInstanceActive(instances)]?.configuration?.statuses.max_characters || 500
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 getInstanceConfigurationMediaAttachments = ({ instances: { instances } }: RootState) =>
instances[findInstanceActive(instances)]?.configuration?.media_attachments || {
supported_mime_types: [
'image/jpeg',
'image/png',
'image/gif',
'video/webm',
'video/mp4',
'video/quicktime',
'video/ogg',
'audio/wave',
'audio/wav',
'audio/x-wav',
'audio/x-pn-wave',
'audio/ogg',
'audio/vorbis',
'audio/mpeg',
'audio/mp3',
'audio/webm',
'audio/flac',
'audio/aac',
'audio/m4a',
'audio/x-m4a',
'audio/mp4',
'audio/3gpp',
'video/x-ms-asf'
],
image_size_limit: 10485760,
image_matrix_limit: 16777216,
video_size_limit: 41943040,
video_frame_rate_limit: 60,
video_matrix_limit: 2304000
}
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) =>
instances[findInstanceActive(instances)]?.notifications_filter
export const getInstancePush = ({ instances: { instances } }: RootState) =>
instances[findInstanceActive(instances)]?.push
export const getInstanceFollowingPage = ({ instances: { instances } }: RootState) =>
instances[findInstanceActive(instances)]?.followingPage
export const getInstanceMePage = ({ instances: { instances } }: RootState) =>
instances[findInstanceActive(instances)]?.mePage
export const getInstanceDrafts = ({ instances: { instances } }: RootState) =>
instances[findInstanceActive(instances)]?.drafts
export const getInstanceFrequentEmojis = ({ instances: { instances } }: RootState) =>
instances[findInstanceActive(instances)]?.frequentEmojis
export const {
updateInstanceActive,
updateInstanceAccount,
updateInstanceNotificationsFilter,
updateInstanceDraft,
removeInstanceDraft,
disableAllPushes,
updateInstanceFollowingPage,
updateInstanceMePage,
countInstanceEmoji
} = instancesSlice.actions
export default instancesSlice.reducer

View File

@ -1,65 +0,0 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { LOCALES } from '@root/i18n/locales'
import { RootState } from '@root/store'
import { SettingsLatest } from '@utils/migrations/settings/migration'
import * as Localization from 'expo-localization'
import { pickBy } from 'lodash'
export const settingsInitialState = {
fontsize: 0,
notification: {
enabled: false
},
language: Object.keys(pickBy(LOCALES, (_, key) => Localization.locale.startsWith(key)))
? Object.keys(pickBy(LOCALES, (_, key) => Localization.locale.startsWith(key)))[0]
: 'en',
theme: 'auto',
darkTheme: 'lighter',
browser: 'internal',
autoplayGifv: true
}
const settingsSlice = createSlice({
name: 'settings',
initialState: settingsInitialState as SettingsLatest,
reducers: {
changeFontsize: (state, action: PayloadAction<SettingsLatest['fontsize']>) => {
state.fontsize = action.payload
},
changeLanguage: (state, action: PayloadAction<NonNullable<SettingsLatest['language']>>) => {
state.language = action.payload
},
changeTheme: (state, action: PayloadAction<NonNullable<SettingsLatest['theme']>>) => {
state.theme = action.payload
},
changeDarkTheme: (state, action: PayloadAction<NonNullable<SettingsLatest['darkTheme']>>) => {
state.darkTheme = action.payload
},
changeBrowser: (state, action: PayloadAction<NonNullable<SettingsLatest['browser']>>) => {
state.browser = action.payload
},
changeAutoplayGifv: (
state,
action: PayloadAction<NonNullable<SettingsLatest['autoplayGifv']>>
) => {
state.autoplayGifv = action.payload
}
}
})
export const getSettingsFontsize = (state: RootState) => state.settings.fontsize || 0
export const getSettingsLanguage = (state: RootState) => state.settings.language
export const getSettingsTheme = (state: RootState) => state.settings.theme
export const getSettingsDarkTheme = (state: RootState) => state.settings.darkTheme
export const getSettingsBrowser = (state: RootState) => state.settings.browser
export const getSettingsAutoplayGifv = (state: RootState) => state.settings.autoplayGifv
export const {
changeFontsize,
changeLanguage,
changeTheme,
changeDarkTheme,
changeBrowser,
changeAutoplayGifv
} = settingsSlice.actions
export default settingsSlice.reducer

View File

@ -0,0 +1,14 @@
import { Audio, InterruptionModeAndroid, InterruptionModeIOS } from 'expo-av'
import log from './log'
const audio = () => {
log('log', 'audio', 'setting audio playback default options')
Audio.setAudioModeAsync({
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
interruptionModeAndroid: InterruptionModeAndroid.DoNotMix,
playsInSilentModeIOS: true,
staysActiveInBackground: false
})
}
export default audio

37
src/utils/startup/log.ts Normal file
View File

@ -0,0 +1,37 @@
import chalk from 'chalk'
const ctx = new chalk.Instance({ level: 3 })
const log = (type: 'log' | 'warn' | 'error', func: string, message: string) => {
switch (type) {
case 'log':
console.log(
ctx.bgBlue.white.bold(' Start up ') +
' ' +
ctx.bgBlueBright.black(` ${func} `) +
' ' +
message
)
break
case 'warn':
console.warn(
ctx.bgYellow.white.bold(' Start up ') +
' ' +
ctx.bgYellowBright.black(` ${func} `) +
' ' +
message
)
break
case 'error':
console.error(
ctx.bgRed.white.bold(' Start up ') +
' ' +
ctx.bgRedBright.black(` ${func} `) +
' ' +
message
)
break
}
}
export default log

View File

@ -0,0 +1,67 @@
import NetInfo from '@react-native-community/netinfo'
import { onlineManager } from '@tanstack/react-query'
import apiInstance from '@utils/api/instance'
import { storage } from '@utils/storage'
import { getAccountStorage, removeAccount, setAccountStorage } from '@utils/storage/actions'
import log from './log'
const netInfo = async (): Promise<{
connected?: boolean
corrupted?: string
} | void> => {
log('log', 'netInfo', 'initializing')
const netInfo = await NetInfo.fetch()
onlineManager.setEventListener(setOnline => {
return NetInfo.addEventListener(state => {
setOnline(!!state.isConnected)
})
})
if (netInfo.isConnected) {
log('log', 'netInfo', 'network connected')
if (storage.account) {
const domain = getAccountStorage.string('auth.domain')
const id = getAccountStorage.string('auth.account.id')
const account = `${domain}/${id}`
log('log', 'netInfo', 'checking locally stored credentials')
let resVerify: Mastodon.Account
try {
resVerify = await apiInstance<Mastodon.Account>({
method: 'get',
url: `accounts/verify_credentials`
}).then(res => res.body)
} catch (error: any) {
log('error', 'netInfo', 'local credential check failed')
if (error?.status && error.status == 401) {
removeAccount(account)
}
return Promise.resolve({ corrupted: error.data?.error })
}
log('log', 'netInfo', 'local credential check passed')
if (resVerify.id !== id) {
log('error', 'netInfo', 'local id does not match remote id')
removeAccount(account)
return Promise.resolve({ connected: true, corrupted: '' })
} else {
setAccountStorage([
{ key: 'auth.account.acct', value: resVerify.acct },
{ key: 'auth.account.avatar_static', value: resVerify.avatar_static }
])
return Promise.resolve({ connected: true })
}
} else {
log('log', 'netInfo', 'no local credential found')
return Promise.resolve()
}
} else {
log('warn', 'netInfo', 'network not connected')
return Promise.resolve()
}
}
export default netInfo

15
src/utils/startup/push.ts Normal file
View File

@ -0,0 +1,15 @@
import * as Notifications from 'expo-notifications'
import log from './log'
const push = () => {
log('log', 'Push', 'initializing')
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: false,
shouldPlaySound: false,
shouldSetBadge: false
})
})
}
export default push

View File

@ -0,0 +1,24 @@
import * as Sentry from '@sentry/react-native'
import { isDevelopment } from '@utils/helpers/checkEnvironment'
import log from './log'
export const routingInstrumentation = new Sentry.ReactNavigationInstrumentation()
const sentry = () => {
log('log', 'Sentry', 'initializing')
Sentry.init({
enabled: !isDevelopment,
dsn: 'https://53348b60ff844d52886e90251b3a5f41@o917354.ingest.sentry.io/6410576',
tracesSampleRate: 0.35,
integrations: [
new Sentry.ReactNativeTracing({
routingInstrumentation,
tracingOrigins: ['tooot.app']
})
],
autoSessionTracking: true
})
}
export default sentry

View File

@ -0,0 +1,12 @@
import * as Localization from 'expo-localization'
import log from './log'
const timezone = () => {
log('log', 'Timezone', Localization.getCalendars()[0].timeZone || 'unknown')
if ('__setDefaultTimeZone' in Intl.DateTimeFormat) {
// @ts-ignore
Intl.DateTimeFormat.__setDefaultTimeZone(Localization.getCalendars()[0].timeZone)
}
}
export default timezone

View File

@ -0,0 +1,3 @@
import { AccountV0 } from "./v0";
export { AccountV0 as StorageAccount }

View File

@ -0,0 +1,60 @@
import { ComposeStateDraft } from '@screens/Compose/utils/types'
type PushNotification = {
follow: boolean
follow_request: boolean
favourite: boolean
reblog: boolean
mention: boolean
poll: boolean
status: boolean
update: boolean
'admin.sign_up': boolean
'admin.report': boolean
}
export type AccountV0 = {
// string
'auth.clientId': string
'auth.clientSecret': string
'auth.token': string
'auth.domain': string
'auth.account.id': string
'auth.account.acct': string
'auth.account.avatar_static': string
version: string
// number
// boolean
// object
preferences?: Mastodon.Preferences
notifications: PushNotification
push: {
global: boolean
decode: boolean
alerts: PushNotification
key: string
}
page_local: {
showBoosts: boolean
showReplies: boolean
}
page_me: {
followedTags: {
shown: boolean
}
lists: {
shown: boolean
}
announcements: {
shown: boolean
unread: number
}
}
drafts: ComposeStateDraft[]
emojis_frequent: {
emoji: Pick<Mastodon.Emoji, 'url' | 'shortcode' | 'static_url'>
score: number
count: number
lastUsed: number
}[]
}

View File

@ -0,0 +1,228 @@
import queryClient from '@utils/queryHooks'
import { storage } from '@utils/storage'
import {
MMKV,
useMMKVBoolean,
useMMKVListener,
useMMKVNumber,
useMMKVObject,
useMMKVString
} from 'react-native-mmkv'
import { StorageAccount } from './account'
import { StorageGlobal } from './global'
export const getGlobalStorage = {
string: <T extends keyof StorageGlobal>(key: T) =>
storage.global.getString(key) as NonNullable<StorageGlobal[T]> extends string
? StorageGlobal[T]
: never,
number: <T extends keyof StorageGlobal>(key: T) =>
storage.global.getNumber(key) as NonNullable<StorageGlobal[T]> extends number
? StorageGlobal[T]
: never,
boolean: <T extends keyof StorageGlobal>(key: T) =>
storage.global.getBoolean(key) as NonNullable<StorageGlobal[T]> extends boolean
? StorageGlobal[T]
: never,
object: <T extends keyof StorageGlobal>(key: T) =>
JSON.parse(storage.global.getString(key) || '') as NonNullable<StorageGlobal[T]> extends object
? StorageGlobal[T]
: never
}
export const useGlobalStorage = {
string: <T extends keyof StorageGlobal>(key: T) =>
useMMKVString(key, storage.global) as NonNullable<StorageGlobal[T]> extends string
? [StorageGlobal[T], (valud: StorageGlobal[T]) => void]
: never,
number: <T extends keyof StorageGlobal>(key: T) =>
useMMKVNumber(key, storage.global) as NonNullable<StorageGlobal[T]> extends number
? [StorageGlobal[T], (valud: StorageGlobal[T]) => void]
: never,
boolean: <T extends keyof StorageGlobal>(key: T) =>
useMMKVBoolean(key, storage.global) as NonNullable<StorageGlobal[T]> extends boolean
? [StorageGlobal[T], (valud: StorageGlobal[T]) => void]
: never,
object: <T extends keyof StorageGlobal>(key: T) =>
useMMKVObject(key, storage.global) as NonNullable<StorageGlobal[T]> extends object
? [StorageGlobal[T], (valud: StorageGlobal[T]) => void]
: never
}
export const setGlobalStorage = <T extends keyof StorageGlobal>(
key: T,
value: StorageGlobal[T]
) => {
const checkValue = (): string | number | boolean => {
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return value
} else {
return JSON.stringify(value)
}
}
if (value !== undefined) {
storage.global.set(key, checkValue())
} else {
storage.global.delete(key)
}
}
export const useGlobalStorageListener = (key: keyof StorageGlobal, func: () => void) =>
useMMKVListener(keyChanged => {
if (keyChanged === key) func()
})
export const getAccountStorage = {
string: <T extends keyof StorageAccount>(key: T) =>
storage.account?.getString(key) as NonNullable<StorageAccount[T]> extends string
? StorageAccount[T]
: never,
number: <T extends keyof StorageAccount>(key: T) =>
storage.account?.getNumber(key) as NonNullable<StorageAccount[T]> extends number
? StorageAccount[T]
: never,
boolean: <T extends keyof StorageAccount>(key: T) =>
storage.account?.getBoolean(key) as NonNullable<StorageAccount[T]> extends boolean
? StorageAccount[T]
: never,
object: <T extends keyof StorageAccount>(key: T) => {
const value = storage.account?.getString(key)
if (value) {
return JSON.parse(value) as NonNullable<StorageAccount[T]> extends object
? StorageAccount[T]
: never
} else {
return undefined
}
}
}
export const useAccountStorage = {
string: <T extends keyof StorageAccount>(key: T) =>
useMMKVString(key, storage.account) as NonNullable<StorageAccount[T]> extends string
? [StorageAccount[T], (valud: StorageAccount[T]) => void]
: never,
number: <T extends keyof StorageAccount>(key: T) =>
useMMKVNumber(key, storage.account) as NonNullable<StorageAccount[T]> extends number
? [StorageAccount[T], (valud: StorageAccount[T]) => void]
: never,
boolean: <T extends keyof StorageAccount>(key: T) =>
useMMKVBoolean(key, storage.account) as NonNullable<StorageAccount[T]> extends boolean
? [StorageAccount[T], (valud: StorageAccount[T]) => void]
: never,
object: <T extends keyof StorageAccount>(key: T) =>
useMMKVObject(key, storage.account) as NonNullable<StorageAccount[T]> extends object
? [StorageAccount[T], (valud: StorageAccount[T]) => void]
: never
}
export const setAccountStorage = <T extends keyof StorageAccount>(
kvs: { key: T; value: StorageAccount[T] }[],
account?: string
) => {
let temp: MMKV
if (account) {
temp = new MMKV({ id: account })
} else {
if (!storage.account) {
return null
}
temp = storage.account
}
for (const { key, value } of kvs) {
const checkValue = (): string | number | boolean => {
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
switch (key) {
case 'version':
return value.match(new RegExp(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)/))?.[0] || '0'
default:
return value
}
} else {
return JSON.stringify(value)
}
}
if (value !== undefined) {
temp.set(key, checkValue())
} else {
temp.delete(key)
}
}
}
export const getAccountDetails = <T extends Array<keyof StorageAccount>>(
keys: T,
account?: string
): Pick<StorageAccount, T[number]> | null => {
let temp: MMKV
if (account) {
temp = new MMKV({ id: account })
} else {
if (!storage.account) {
return null
}
temp = storage.account
}
const result = {}
for (const key of keys) {
switch (key) {
case 'auth.clientId':
case 'auth.clientSecret':
case 'auth.token':
case 'auth.domain':
case 'auth.account.id':
case 'auth.account.acct':
case 'auth.account.avatar_static':
// @ts-ignore
result[key] = temp.getString(key)
break
case 'preferences':
case 'notifications':
case 'push':
case 'page_local':
case 'page_me':
case 'drafts':
case 'emojis_frequent':
const value = temp.getString(key)
if (value) {
// @ts-ignore
result[key] = JSON.parse(value)
}
break
}
}
// @ts-ignore
return result
}
export const generateAccountKey = ({
domain,
id
}: {
domain: Mastodon.Instance<'v1'>['uri'] | Mastodon.Instance<'v2'>['domain']
id: Mastodon.Account['id']
}) => `${domain}/${id}`
export const setAccount = async (account: string) => {
storage.account = new MMKV({ id: account })
setGlobalStorage('account.active', account)
await queryClient.resetQueries()
}
export const removeAccount = async (account: string) => {
const currAccounts: NonNullable<StorageGlobal['accounts']> = JSON.parse(
storage.global.getString('accounts') || '[]'
)
const nextAccounts: NonNullable<StorageGlobal['accounts']> = currAccounts.filter(
a => a !== account
)
storage.global.set('accounts', JSON.stringify(nextAccounts))
if (nextAccounts.length) {
await setAccount(nextAccounts[nextAccounts.length - 1])
} else {
storage.account = undefined
setGlobalStorage('account.active', undefined)
queryClient.clear()
}
const temp = new MMKV({ id: account })
temp.clearAll()
}

View File

@ -0,0 +1,3 @@
import { GlobalV0 } from "./v0";
export { GlobalV0 as StorageGlobal }

View File

@ -0,0 +1,24 @@
import { ScreenTabsStackParamList } from '@utils/navigation/navigators'
export type GlobalV0 = {
//// app
// string
'app.expo_token'?: string
'app.prev_tab'?: keyof ScreenTabsStackParamList
'app.prev_public_segment'?: Extract<App.Pages, 'Local' | 'LocalPublic' | 'Trending'>
'app.language'?: string
'app.theme'?: 'light' | 'dark' | 'auto'
'app.theme.dark'?: 'lighter' | 'darker'
'app.browser'?: 'internal' | 'external'
// number
'app.count_till_store_review'?: number
'app.font_size'?: -1 | 0 | 1 | 2 | 3
// boolean
'app.auto_play_gifv'?: boolean
//// account
// string
'account.active'?: string
// object
accounts?: string[]
}

View File

@ -0,0 +1,6 @@
import createSecureStore from '@neverdull-agency/expo-unlimited-secure-store'
import { MMKV } from 'react-native-mmkv'
export const storage: { global: MMKV; account?: MMKV } = { global: new MMKV(), account: undefined }
export const secureStorage = createSecureStore()

View File

@ -0,0 +1,114 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import log from '@utils/startup/log'
import { secureStorage, storage } from '@utils/storage'
import { MMKV } from 'react-native-mmkv'
export const hasMigratedFromAsyncStorage = storage.global.getBoolean('hasMigratedFromAsyncStorage')
export async function migrateFromAsyncStorage(): Promise<void> {
log('log', 'Migration', 'Migrating...')
const start = global.performance.now()
const keys = ['persist:app', 'persist:contexts', 'persist:settings'] as [
'persist:app',
'persist:contexts',
'persist:settings'
]
for (const key of keys) {
try {
const value = await AsyncStorage.getItem(key)
if (value != null) {
switch (key) {
case 'persist:app':
const storeApp = JSON.parse(value)
if (storeApp.expoToken?.length) {
storage.global.set('app.expo_token', storeApp.expoToken.replaceAll(`\"`, ``))
}
break
case 'persist:contexts':
const storeContexts = JSON.parse(value)
if (storeContexts.storeReview.current) {
storage.global.set(
'app.count_till_store_review',
storeContexts.storeReview.current || 0
)
}
storage.global.set('app.prev_tab', storeContexts.previousTab.replaceAll(`\"`, ``))
storage.global.set(
'app.prev_public_segment',
storeContexts.previousSegment.replaceAll(`\"`, ``)
)
break
case 'persist:settings':
const storeSettings = JSON.parse(value)
storage.global.set('app.font_size', storeSettings.fontsize || 0)
storage.global.set('app.language', storeSettings.language.replaceAll(`\"`, ``))
storage.global.set('app.theme', storeSettings.theme.replaceAll(`\"`, ``))
storage.global.set('app.theme.dark', storeSettings.darkTheme.replaceAll(`\"`, ``))
storage.global.set('app.browser', storeSettings.browser.replaceAll(`\"`, ``))
storage.global.set('app.auto_play_gifv', storeSettings.autoplayGifv || true)
break
}
// AsyncStorage.removeItem(key)
}
} catch (error) {
console.error(`Failed to migrate key "${key}" from AsyncStorage to MMKV!`, error)
throw error
}
}
try {
const value = await secureStorage.getItem('persist:instances')
if (value != null) {
const storeInstances: { instances: string } = JSON.parse(value)
const accounts: string[] = []
for (const instance of JSON.parse(storeInstances.instances)) {
const account = `${instance.uri}/${instance.account.id}`
const temp = new MMKV({ id: account })
temp.set('auth.clientId', instance.appData.clientId)
temp.set('auth.clientSecret', instance.appData.clientSecret)
temp.set('auth.token', instance.token)
temp.set('auth.domain', instance.uri)
temp.set('auth.account.id', instance.account.id)
temp.set('auth.account.acct', instance.account.acct)
temp.set('auth.account.avatar_static', instance.account.avatarStatic)
if (instance.account.preferences) {
temp.set('preferences', JSON.stringify(instance.account.preferences))
}
temp.set('notifications', JSON.stringify(instance.notifications_filter))
temp.set('push', JSON.stringify(instance.push))
temp.set('page_local', JSON.stringify(instance.followingPage))
temp.set('page_me', JSON.stringify(instance.mePage))
temp.set('drafts', JSON.stringify(instance.drafts))
temp.set('emojis_frequent', JSON.stringify(instance.frequentEmojis))
temp.set('version', instance.version)
if (instance.active) {
storage.global.set('account.active', account)
storage.account = temp
}
accounts.push(account)
}
storage.global.set('accounts', JSON.stringify(accounts))
// AsyncStorage.removeItem(key)
}
} catch (error) {
console.error('Failed to migrate instances from AsyncStorage to MMKV!', error)
throw error
}
storage.global.set('hasMigratedFromAsyncStorage', true)
const end = global.performance.now()
log('log', 'Migration', `Migrated in ${end - start}ms`)
}

View File

@ -1,10 +1,9 @@
import { useGlobalStorage } from '@utils/storage/actions'
import { StorageGlobal } from '@utils/storage/global'
import { ColorDefinitions, getColors, Theme } from '@utils/styles/themes'
import { throttle } from 'lodash'
import React, { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react'
import { Appearance } from 'react-native'
import { useSelector } from 'react-redux'
import { ColorDefinitions, getColors, Theme } from '@utils/styles/themes'
import { getSettingsDarkTheme, getSettingsTheme } from '@utils/slices/settingsSlice'
import { throttle } from 'lodash'
import { SettingsLatest } from '@utils/migrations/settings/migration'
type ContextType = {
mode: 'light' | 'dark'
@ -46,14 +45,14 @@ const useColorSchemeDelay = (delay = 500) => {
const determineTheme = (
osTheme: 'light' | 'dark' | null | undefined,
userTheme: SettingsLatest['theme'],
darkTheme: SettingsLatest['darkTheme']
userTheme: StorageGlobal['app.theme'],
darkTheme: StorageGlobal['app.theme.dark']
): 'light' | 'dark_lighter' | 'dark_darker' => {
enum DarkTheme {
lighter = 'dark_lighter',
darker = 'dark_darker'
}
const determineDarkTheme = DarkTheme[darkTheme]
const determineDarkTheme = DarkTheme[darkTheme || 'lighter']
switch (userTheme) {
case 'auto':
switch (osTheme) {
@ -66,19 +65,23 @@ const determineTheme = (
return 'light'
case 'dark':
return determineDarkTheme
default:
return determineDarkTheme
}
}
const ThemeManager: React.FC<PropsWithChildren> = ({ children }) => {
const osTheme = useColorSchemeDelay()
const userTheme = useSelector(getSettingsTheme)
const darkTheme = useSelector(getSettingsDarkTheme)
const [userTheme] = useGlobalStorage.string('app.theme')
const [darkTheme] = useGlobalStorage.string('app.theme.dark')
const [mode, setMode] = useState(userTheme === 'auto' ? osTheme || 'light' : userTheme)
const [mode, setMode] = useState<'light' | 'dark'>(
userTheme === 'auto' ? osTheme || 'light' : userTheme || 'light'
)
const [theme, setTheme] = useState<Theme>(determineTheme(osTheme, userTheme, darkTheme))
useEffect(() => {
setMode(userTheme === 'auto' ? osTheme || 'light' : userTheme)
setMode(userTheme === 'auto' ? osTheme || 'light' : userTheme || 'light')
}, [osTheme, userTheme])
useEffect(() => {
setTheme(determineTheme(osTheme, userTheme, darkTheme))

Some files were not shown because too many files have changed in this diff Show More