Merge branch 'main' into candidate

This commit is contained in:
xmflsct 2022-12-18 23:33:16 +01:00
commit 171cfd0ead
53 changed files with 500 additions and 529 deletions

View File

@ -0,0 +1 @@
../../it/description.txt

View File

@ -0,0 +1 @@
../../it/subtitle.txt

View File

@ -1,5 +1,10 @@
tooot is an open source, simple yet elegant Mastodon mobile client. tooot is an open source, simple yet elegant Mastodon mobile client. A Mastodon (https://joinmastodon.org/) account is required to use this app.
A Mastodon (https://joinmastodon.org/) account is required to use this app. tooot supports:
- Cross platform, including iPadOS and MacOS
- Multiple accounts
- Dark mode or adapt to system
- Adjustable toot font size
- Push notification
If you have suggestions, please reach out to @tooot@xmflsct.com or support@tooot.ap. If you have suggestions, please reach out to @tooot@xmflsct.com or support@tooot.app.

View File

@ -1 +1,4 @@
Enjoy toooting! This version includes following improvements and fixes: Enjoy toooting! This version includes following improvements and fixes:
- Align filter experience with v4.0 and above
- Supports enlarging user's avatar and banner
- Fix iPad weird sizing (not optimisation)

View File

@ -0,0 +1,10 @@
tooot è un client Mastodon semplice e open source. Per utilizzare questo client, devi disporre di un account Mastodon. (https://joinmastodon.org/).
Tooot supporta:
- Multipiattaforma, inclusi iPadOS e MacOS
- Accesso a più account
- Modalità scura o adattiva
- Dimensione del carattere del testo regolabile
- Notifiche push e altre funzioni
Per suggerimenti o commenti sull'utilizzo, contattare @tooot@xmflsct.com o support@tooot.app.

View File

@ -0,0 +1 @@
Client open source per Mastodon

View File

@ -1,11 +1,10 @@
tooot是一个专门为中文用户社区所打造的开源、简洁长毛象客户端。使用此客户端需要已经拥有一个长毛象https://joinmastodon.org/)账号。 tooot起始于专注中文社区的简洁、开源长毛象手机客户端。使用此客户端需要已经拥有一个长毛象https://joinmastodon.org/)账号。
tooot支持 tooot支持
- iPad - 跨平台,及iPadOS、MacOS
- 多账号登录 - 多账号登录
- 黑暗或自适应模式 - 黑暗或自适应模式
- 可调整正文字体大小 - 可调正文字体尺寸
- 消息推送 - 消息推送
等功能。
如有使用建议或意见,请联系@tooot@xmflsct.com或者support@tooot.app。 如有使用建议或意见,请联系@tooot@xmflsct.com或者support@tooot.app。

View File

@ -1 +1,4 @@
toooting愉快此版本包括以下改进和修复 toooting愉快此版本包括以下改进和修复
- 改进过滤体验与v4.0以上版本一致
- 支持查看用户的头像和横幅图片
- 修复iPad部分尺寸问题非优化

View File

@ -263,7 +263,8 @@ declare namespace Mastodon {
verified_at: string | null verified_at: string | null
} }
type Filter = { type Filter<T extends 'v1' | 'v2'> = T extends 'v2' ? Filter_V2 : Filter_V1
type Filter_V1 = {
id: string id: string
phrase: string phrase: string
context: ('home' | 'notifications' | 'public' | 'thread' | 'account')[] context: ('home' | 'notifications' | 'public' | 'thread' | 'account')[]
@ -271,6 +272,25 @@ declare namespace Mastodon {
irreversible: boolean irreversible: boolean
whole_word: boolean whole_word: boolean
} }
type Filter_V2 = {
id: string
title: string
context: ('home' | 'notifications' | 'public' | 'thread' | 'account')[]
expires_at?: string
filter_action: 'warn' | 'hide'
keywords: FilterKeyword[]
statuses: FilterStatus[]
}
type FilterKeyword = { id: string; keyword: string; whole_word: boolean }
type FilterStatus = { id: string; status_id: string }
type FilterResult = {
filter: Filter<'v2'>
keyword_matches?: FilterKeyword['keyword'][]
status_matches?: FilterStatus['id'][]
}
type List = { type List = {
id: string id: string
@ -461,7 +481,7 @@ declare namespace Mastodon {
sensitive: boolean sensitive: boolean
spoiler_text?: string spoiler_text?: string
media_attachments: Attachment[] media_attachments: Attachment[]
application: Application application?: Application
// Attributes // Attributes
mentions: Mention[] mentions: Mention[]
@ -472,7 +492,7 @@ declare namespace Mastodon {
reblogs_count: number reblogs_count: number
favourites_count: number favourites_count: number
replies_count: number replies_count: number
edited_at?: string // FEATURE edit_post edited_at?: string
favourited: boolean favourited: boolean
reblogged: boolean reblogged: boolean
muted: boolean muted: boolean
@ -488,6 +508,7 @@ declare namespace Mastodon {
card?: Card card?: Card
language?: string language?: string
text?: string text?: string
filtered?: FilterResult[]
} }
type StatusHistory = { type StatusHistory = {

View File

@ -98,7 +98,6 @@ const TimelineConversation: React.FC<Props> = ({ conversation, queryKey, highlig
</View> </View>
{conversation.last_status ? ( {conversation.last_status ? (
<>
<View <View
style={{ style={{
paddingTop: highlighted ? StyleConstants.Spacing.S : 0, paddingTop: highlighted ? StyleConstants.Spacing.S : 0,
@ -107,10 +106,9 @@ const TimelineConversation: React.FC<Props> = ({ conversation, queryKey, highlig
> >
<TimelineContent /> <TimelineContent />
<TimelinePoll /> <TimelinePoll />
</View>
<TimelineActions /> <TimelineActions />
</> </View>
) : null} ) : null}
</Pressable> </Pressable>
</StatusContext.Provider> </StatusContext.Provider>

View File

@ -9,11 +9,12 @@ import TimelineCard from '@components/Timeline/Shared/Card'
import TimelineContent from '@components/Timeline/Shared/Content' import TimelineContent from '@components/Timeline/Shared/Content'
import TimelineHeaderDefault from '@components/Timeline/Shared/HeaderDefault' import TimelineHeaderDefault from '@components/Timeline/Shared/HeaderDefault'
import TimelinePoll from '@components/Timeline/Shared/Poll' import TimelinePoll from '@components/Timeline/Shared/Poll'
import removeHTML from '@helpers/removeHTML'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import { StackNavigationProp } from '@react-navigation/stack' import { StackNavigationProp } from '@react-navigation/stack'
import { TabLocalStackParamList } from '@utils/navigation/navigators' import { TabLocalStackParamList } from '@utils/navigation/navigators'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline' import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { getInstanceAccount } from '@utils/slices/instancesSlice' import { checkInstanceFeature, getInstanceAccount } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import React, { useRef, useState } from 'react' import React, { useRef, useState } from 'react'
@ -22,7 +23,7 @@ import { useSelector } from 'react-redux'
import * as ContextMenu from 'zeego/context-menu' import * as ContextMenu from 'zeego/context-menu'
import StatusContext from './Shared/Context' import StatusContext from './Shared/Context'
import TimelineFeedback from './Shared/Feedback' import TimelineFeedback from './Shared/Feedback'
import TimelineFiltered, { shouldFilter } from './Shared/Filtered' import TimelineFiltered, { FilteredProps, shouldFilter } from './Shared/Filtered'
import TimelineFullConversation from './Shared/FullConversation' import TimelineFullConversation from './Shared/FullConversation'
import TimelineHeaderAndroid from './Shared/HeaderAndroid' import TimelineHeaderAndroid from './Shared/HeaderAndroid'
import TimelineTranslate from './Shared/Translate' import TimelineTranslate from './Shared/Translate'
@ -47,12 +48,20 @@ const TimelineDefault: React.FC<Props> = ({
disableOnPress = false, disableOnPress = false,
isConversation = false isConversation = false
}) => { }) => {
const status = item.reblog ? item.reblog : item
const rawContent = useRef<string[]>([])
if (highlighted) {
rawContent.current = [
removeHTML(status.content),
status.spoiler_text ? removeHTML(status.spoiler_text) : ''
].filter(c => c.length)
}
const { colors } = useTheme() const { colors } = useTheme()
const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>() const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>()
const instanceAccount = useSelector(getInstanceAccount, () => true) const instanceAccount = useSelector(getInstanceAccount, () => true)
const status = item.reblog ? item.reblog : item
const ownAccount = status.account?.id === instanceAccount?.id const ownAccount = status.account?.id === instanceAccount?.id
const [spoilerExpanded, setSpoilerExpanded] = useState( const [spoilerExpanded, setSpoilerExpanded] = useState(
instanceAccount?.preferences?.['reading:expand:spoilers'] || false instanceAccount?.preferences?.['reading:expand:spoilers'] || false
@ -60,17 +69,8 @@ const TimelineDefault: React.FC<Props> = ({
const spoilerHidden = status.spoiler_text?.length const spoilerHidden = status.spoiler_text?.length
? !instanceAccount?.preferences?.['reading:expand:spoilers'] && !spoilerExpanded ? !instanceAccount?.preferences?.['reading:expand:spoilers'] && !spoilerExpanded
: false : false
const copiableContent = useRef<{ content: string; complete: boolean }>({
content: '',
complete: false
})
const detectedLanguage = useRef<string>(status.language || '') const detectedLanguage = useRef<string>(status.language || '')
const filtered = queryKey && shouldFilter({ copiableContent, status, queryKey })
if (queryKey && filtered && !highlighted) {
return <TimelineFiltered phrase={filtered} />
}
const mainStyle: StyleProp<ViewStyle> = { const mainStyle: StyleProp<ViewStyle> = {
flex: 1, flex: 1,
padding: disableDetails padding: disableDetails
@ -103,8 +103,9 @@ const TimelineDefault: React.FC<Props> = ({
paddingTop: highlighted ? StyleConstants.Spacing.S : 0, paddingTop: highlighted ? StyleConstants.Spacing.S : 0,
paddingLeft: highlighted paddingLeft: highlighted
? 0 ? 0
: (disableDetails ? StyleConstants.Avatar.XS : StyleConstants.Avatar.M) + : (disableDetails || isConversation
StyleConstants.Spacing.S, ? StyleConstants.Avatar.XS
: StyleConstants.Avatar.M) + StyleConstants.Spacing.S,
...(disableDetails && { marginTop: -StyleConstants.Spacing.S }) ...(disableDetails && { marginTop: -StyleConstants.Spacing.S })
}} }}
> >
@ -115,9 +116,9 @@ const TimelineDefault: React.FC<Props> = ({
<TimelineFullConversation /> <TimelineFullConversation />
<TimelineTranslate /> <TimelineTranslate />
<TimelineFeedback /> <TimelineFeedback />
</View>
<TimelineActions /> <TimelineActions />
</View>
</> </>
) )
@ -125,11 +126,36 @@ const TimelineDefault: React.FC<Props> = ({
visibility: status.visibility, visibility: status.visibility,
type: 'status', type: 'status',
url: status.url || status.uri, url: status.url || status.uri,
copiableContent rawContent
}) })
const mStatus = menuStatus({ status, queryKey, rootQueryKey }) const mStatus = menuStatus({ status, queryKey, rootQueryKey })
const mInstance = menuInstance({ status, queryKey, rootQueryKey }) const mInstance = menuInstance({ status, queryKey, rootQueryKey })
if (!ownAccount) {
let filterResults: FilteredProps['filterResults'] = []
const [filterRevealed, setFilterRevealed] = useState(false)
const hasFilterServerSide = useSelector(checkInstanceFeature('filter_server_side'))
if (hasFilterServerSide) {
if (status.filtered?.length) {
filterResults = status.filtered?.map(filter => filter.filter)
}
} else {
if (queryKey) {
const checkFilter = shouldFilter({ queryKey, status })
if (checkFilter?.length) {
filterResults = checkFilter
}
}
}
if (queryKey && !highlighted && filterResults?.length && !filterRevealed) {
return !filterResults.filter(result => result.filter_action === 'hide').length ? (
<Pressable onPress={() => setFilterRevealed(!filterRevealed)}>
<TimelineFiltered filterResults={filterResults} />
</Pressable>
) : null
}
}
return ( return (
<StatusContext.Provider <StatusContext.Provider
value={{ value={{
@ -139,7 +165,7 @@ const TimelineDefault: React.FC<Props> = ({
reblogStatus: item.reblog ? item : undefined, reblogStatus: item.reblog ? item : undefined,
ownAccount, ownAccount,
spoilerHidden, spoilerHidden,
copiableContent, rawContent,
detectedLanguage, detectedLanguage,
highlighted, highlighted,
inThread: queryKey?.[1].page === 'Toot', inThread: queryKey?.[1].page === 'Toot',

View File

@ -13,7 +13,7 @@ import { useNavigation } from '@react-navigation/native'
import { StackNavigationProp } from '@react-navigation/stack' import { StackNavigationProp } from '@react-navigation/stack'
import { TabLocalStackParamList } from '@utils/navigation/navigators' import { TabLocalStackParamList } from '@utils/navigation/navigators'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline' import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { getInstanceAccount } from '@utils/slices/instancesSlice' import { checkInstanceFeature, getInstanceAccount } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useRef, useState } from 'react' import React, { useCallback, useRef, useState } from 'react'
@ -21,7 +21,7 @@ import { Pressable, View } from 'react-native'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import * as ContextMenu from 'zeego/context-menu' import * as ContextMenu from 'zeego/context-menu'
import StatusContext from './Shared/Context' import StatusContext from './Shared/Context'
import TimelineFiltered, { shouldFilter } from './Shared/Filtered' import TimelineFiltered, { FilteredProps, shouldFilter } from './Shared/Filtered'
import TimelineFullConversation from './Shared/FullConversation' import TimelineFullConversation from './Shared/FullConversation'
import TimelineHeaderAndroid from './Shared/HeaderAndroid' import TimelineHeaderAndroid from './Shared/HeaderAndroid'
@ -47,21 +47,6 @@ const TimelineNotifications: React.FC<Props> = ({ notification, queryKey }) => {
const spoilerHidden = notification.status?.spoiler_text?.length const spoilerHidden = notification.status?.spoiler_text?.length
? !instanceAccount.preferences?.['reading:expand:spoilers'] && !spoilerExpanded ? !instanceAccount.preferences?.['reading:expand:spoilers'] && !spoilerExpanded
: false : false
const copiableContent = useRef<{ content: string; complete: boolean }>({
content: '',
complete: false
})
const filtered =
notification.status &&
shouldFilter({
copiableContent,
status: notification.status,
queryKey
})
if (notification.status && filtered) {
return <TimelineFiltered phrase={filtered} />
}
const { colors } = useTheme() const { colors } = useTheme()
const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>() const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>()
@ -112,11 +97,11 @@ const TimelineNotifications: React.FC<Props> = ({ notification, queryKey }) => {
<TimelineAttachment /> <TimelineAttachment />
<TimelineCard /> <TimelineCard />
<TimelineFullConversation /> <TimelineFullConversation />
<TimelineActions />
</View> </View>
) : null} ) : null}
</View> </View>
<TimelineActions />
</> </>
) )
} }
@ -124,20 +109,44 @@ const TimelineNotifications: React.FC<Props> = ({ notification, queryKey }) => {
const mShare = menuShare({ const mShare = menuShare({
visibility: notification.status?.visibility, visibility: notification.status?.visibility,
type: 'status', type: 'status',
url: notification.status?.url || notification.status?.uri, url: notification.status?.url || notification.status?.uri
copiableContent
}) })
const mStatus = menuStatus({ status: notification.status, queryKey }) const mStatus = menuStatus({ status: notification.status, queryKey })
const mInstance = menuInstance({ status: notification.status, queryKey }) const mInstance = menuInstance({ status: notification.status, queryKey })
if (!ownAccount) {
let filterResults: FilteredProps['filterResults'] = []
const [filterRevealed, setFilterRevealed] = useState(false)
const hasFilterServerSide = useSelector(checkInstanceFeature('filter_server_side'))
if (notification.status) {
if (hasFilterServerSide) {
if (notification.status.filtered?.length) {
filterResults = notification.status.filtered.map(filter => filter.filter)
}
} else {
const checkFilter = shouldFilter({ queryKey, status: notification.status })
if (checkFilter?.length) {
filterResults = checkFilter
}
}
if (filterResults?.length && !filterRevealed) {
return !filterResults.filter(result => result.filter_action === 'hide').length ? (
<Pressable onPress={() => setFilterRevealed(!filterRevealed)}>
<TimelineFiltered filterResults={filterResults} />
</Pressable>
) : null
}
}
}
return ( return (
<StatusContext.Provider <StatusContext.Provider
value={{ value={{
queryKey, queryKey,
status, status,
ownAccount, ownAccount,
spoilerHidden, spoilerHidden
copiableContent
}} }}
> >
<ContextMenu.Root> <ContextMenu.Root>

View File

@ -55,7 +55,7 @@ const TimelineRefresh: React.FC<Props> = ({
firstPage?.links?.prev && { firstPage?.links?.prev && {
...(firstPage.links.prev.isOffset ...(firstPage.links.prev.isOffset
? { offset: firstPage.links.prev.id } ? { offset: firstPage.links.prev.id }
: { max_id: firstPage.links.prev.id }), : { min_id: firstPage.links.prev.id }),
// https://github.com/facebook/react-native/issues/25239#issuecomment-731100372 // https://github.com/facebook/react-native/issues/25239#issuecomment-731100372
limit: '3' limit: '3'
}, },

View File

@ -263,11 +263,6 @@ const TimelineActions: React.FC = () => {
}, [status.bookmarked]) }, [status.bookmarked])
return ( return (
<View
style={{
paddingLeft: highlighted ? 0 : StyleConstants.Avatar.M + StyleConstants.Spacing.S
}}
>
<View style={{ flexDirection: 'row' }}> <View style={{ flexDirection: 'row' }}>
<Pressable <Pressable
{...(highlighted {...(highlighted
@ -320,7 +315,6 @@ const TimelineActions: React.FC = () => {
children={childrenBookmark} children={childrenBookmark}
/> />
</View> </View>
</View>
) )
} }

View File

@ -70,7 +70,6 @@ const TimelineAttachment = () => {
preview_url: attachment.preview_url, preview_url: attachment.preview_url,
url: attachment.url, url: attachment.url,
remote_url: attachment.remote_url, remote_url: attachment.remote_url,
blurhash: attachment.blurhash,
width: attachment.meta?.original?.width, width: attachment.meta?.original?.width,
height: attachment.meta?.original?.height height: attachment.meta?.original?.height
} }
@ -90,7 +89,6 @@ const TimelineAttachment = () => {
preview_url: attachment.preview_url, preview_url: attachment.preview_url,
url: attachment.url, url: attachment.url,
remote_url: attachment.remote_url, remote_url: attachment.remote_url,
blurhash: attachment.blurhash,
width: attachment.meta?.original?.width, width: attachment.meta?.original?.width,
height: attachment.meta?.original?.height height: attachment.meta?.original?.height
} }

View File

@ -48,8 +48,7 @@ const TimelineAvatar: React.FC<Props> = ({ account }) => {
style={{ style={{
borderRadius: StyleConstants.Avatar.M, borderRadius: StyleConstants.Avatar.M,
overflow: 'hidden', overflow: 'hidden',
marginRight: StyleConstants.Spacing.S, marginRight: StyleConstants.Spacing.S
marginLeft: isConversation ? StyleConstants.Avatar.M - StyleConstants.Avatar.XS : undefined
}} }}
/> />
) )

View File

@ -1,7 +1,9 @@
import { QueryKeyTimeline } from '@utils/queryHooks/timeline' import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { createContext } from 'react' import { createContext } from 'react'
type ContextType = { export type HighlightedStatusContextType = {}
type StatusContextType = {
queryKey?: QueryKeyTimeline queryKey?: QueryKeyTimeline
rootQueryKey?: QueryKeyTimeline rootQueryKey?: QueryKeyTimeline
@ -10,10 +12,7 @@ type ContextType = {
reblogStatus?: Mastodon.Status // When it is a reblog, pass the root status reblogStatus?: Mastodon.Status // When it is a reblog, pass the root status
ownAccount?: boolean ownAccount?: boolean
spoilerHidden?: boolean spoilerHidden?: boolean
copiableContent?: React.MutableRefObject<{ rawContent?: React.MutableRefObject<string[]> // When highlighted, for translate, edit history
content: string
complete: boolean
}>
detectedLanguage?: React.MutableRefObject<string> detectedLanguage?: React.MutableRefObject<string>
highlighted?: boolean highlighted?: boolean
@ -22,6 +21,6 @@ type ContextType = {
disableOnPress?: boolean disableOnPress?: boolean
isConversation?: boolean isConversation?: boolean
} }
const StatusContext = createContext<ContextType>({} as ContextType) const StatusContext = createContext<StatusContextType>({} as StatusContextType)
export default StatusContext export default StatusContext

View File

@ -1,19 +1,46 @@
import CustomText from '@components/Text' import CustomText from '@components/Text'
import removeHTML from '@helpers/removeHTML'
import { store } from '@root/store' import { store } from '@root/store'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline' import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { getInstance, getInstanceAccount } from '@utils/slices/instancesSlice' import { getInstance } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import htmlparser2 from 'htmlparser2-without-node-native'
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { View } from 'react-native' import { View } from 'react-native'
const TimelineFiltered = React.memo( export interface FilteredProps {
({ phrase }: { phrase: string }) => { filterResults: { title: string; filter_action: Mastodon.Filter<'v2'>['filter_action'] }[]
}
const TimelineFiltered: React.FC<FilteredProps> = ({ filterResults }) => {
const { colors } = useTheme() const { colors } = useTheme()
const { t } = useTranslation('componentTimeline') const { t } = useTranslation('componentTimeline')
const main = () => {
if (!filterResults?.length) {
return <></>
}
switch (typeof filterResults[0]) {
case 'string': // v1 filter
return <>{t('shared.filtered.match', { context: 'v1', phrase: filterResults[0] })}</>
default:
return (
<>
{t('shared.filtered.match', {
context: 'v2',
count: filterResults.length,
filters: filterResults.map(result => result.title).join(t('common:separator'))
})}
<CustomText
style={{ color: colors.blue }}
children={`\n${t('shared.filtered.reveal')}`}
/>
</>
)
}
}
return ( return (
<View style={{ backgroundColor: colors.backgroundDefault }}> <View style={{ backgroundColor: colors.backgroundDefault }}>
<CustomText <CustomText
@ -25,67 +52,47 @@ const TimelineFiltered = React.memo(
paddingLeft: StyleConstants.Avatar.M + StyleConstants.Spacing.S paddingLeft: StyleConstants.Avatar.M + StyleConstants.Spacing.S
}} }}
> >
{t('shared.filtered', { phrase })} {main()}
</CustomText> </CustomText>
</View> </View>
) )
}, }
() => true
)
export const shouldFilter = ({ export const shouldFilter = ({
copiableContent, queryKey,
status, status
queryKey
}: { }: {
copiableContent: React.MutableRefObject<{
content: string
complete: boolean
}>
status: Mastodon.Status
queryKey: QueryKeyTimeline queryKey: QueryKeyTimeline
}): string | null => { status: Pick<Mastodon.Status, 'content' | 'spoiler_text'>
}): FilteredProps['filterResults'] | undefined => {
const page = queryKey[1] const page = queryKey[1]
const instance = getInstance(store.getState()) const instance = getInstance(store.getState())
const ownAccount = getInstanceAccount(store.getState())?.id === status.account?.id
let shouldFilter: string | null = null let returnFilter: FilteredProps['filterResults'] | undefined
if (!ownAccount) { const rawContentCombined = [
let rawContent = '' removeHTML(status.content),
const parser = new htmlparser2.Parser({ status.spoiler_text ? removeHTML(status.spoiler_text) : ''
ontext: (text: string) => { ]
if (!copiableContent.current.complete) { .filter(c => c.length)
copiableContent.current.content = copiableContent.current.content + text .join(`\n`)
} const checkFilter = (filter: Mastodon.Filter<'v1'>) => {
rawContent = rawContent + text
}
})
if (status.spoiler_text) {
parser.write(status.spoiler_text)
rawContent = rawContent + `\n\n`
}
parser.write(status.content)
parser.end()
const checkFilter = (filter: Mastodon.Filter) => {
const escapedPhrase = filter.phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string const escapedPhrase = filter.phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string
switch (filter.whole_word) { switch (filter.whole_word) {
case true: case true:
if (new RegExp(`\\b${escapedPhrase}\\b`, 'i').test(rawContent)) { if (new RegExp(`\\b${escapedPhrase}\\b`, 'i').test(rawContentCombined)) {
shouldFilter = filter.phrase returnFilter = [{ title: filter.phrase, filter_action: 'warn' }]
} }
break break
case false: case false:
if (new RegExp(escapedPhrase, 'i').test(rawContent)) { if (new RegExp(escapedPhrase, 'i').test(rawContentCombined)) {
shouldFilter = filter.phrase returnFilter = [{ title: filter.phrase, filter_action: 'warn' }]
} }
break break
} }
} }
instance?.filters?.forEach(filter => { instance?.filters?.forEach(filter => {
if (shouldFilter) { if (returnFilter) {
return return
} }
if (filter.expires_at) { if (filter.expires_at) {
@ -100,30 +107,27 @@ export const shouldFilter = ({
case 'List': case 'List':
case 'Account': case 'Account':
if (filter.context.includes('home')) { if (filter.context.includes('home')) {
checkFilter(filter) checkFilter(filter as Mastodon.Filter<'v1'>)
} }
break break
case 'Notifications': case 'Notifications':
if (filter.context.includes('notifications')) { if (filter.context.includes('notifications')) {
checkFilter(filter) checkFilter(filter as Mastodon.Filter<'v1'>)
} }
break break
case 'LocalPublic': case 'LocalPublic':
if (filter.context.includes('public')) { if (filter.context.includes('public')) {
checkFilter(filter) checkFilter(filter as Mastodon.Filter<'v1'>)
} }
break break
case 'Toot': case 'Toot':
if (filter.context.includes('thread')) { if (filter.context.includes('thread')) {
checkFilter(filter) checkFilter(filter as Mastodon.Filter<'v1'>)
} }
} }
}) })
copiableContent.current.complete = true return returnFilter
}
return shouldFilter
} }
export default TimelineFiltered export default TimelineFiltered

View File

@ -10,7 +10,7 @@ import * as DropdownMenu from 'zeego/dropdown-menu'
import StatusContext from './Context' import StatusContext from './Context'
const TimelineHeaderAndroid: React.FC = () => { const TimelineHeaderAndroid: React.FC = () => {
const { queryKey, rootQueryKey, status, disableDetails, disableOnPress } = const { queryKey, rootQueryKey, status, disableDetails, disableOnPress, rawContent } =
useContext(StatusContext) useContext(StatusContext)
if (Platform.OS !== 'android' || !status || disableDetails || disableOnPress) return null if (Platform.OS !== 'android' || !status || disableDetails || disableOnPress) return null
@ -21,7 +21,8 @@ const TimelineHeaderAndroid: React.FC = () => {
const mShare = menuShare({ const mShare = menuShare({
visibility: status.visibility, visibility: status.visibility,
type: 'status', type: 'status',
url: status.url || status.uri url: status.url || status.uri,
rawContent
}) })
const mAccount = menuAccount({ const mAccount = menuAccount({
type: 'status', type: 'status',

View File

@ -16,7 +16,7 @@ import HeaderSharedMuted from './HeaderShared/Muted'
import HeaderSharedVisibility from './HeaderShared/Visibility' import HeaderSharedVisibility from './HeaderShared/Visibility'
const TimelineHeaderDefault: React.FC = () => { const TimelineHeaderDefault: React.FC = () => {
const { queryKey, rootQueryKey, status, copiableContent, highlighted, disableDetails } = const { queryKey, rootQueryKey, status, highlighted, disableDetails, rawContent } =
useContext(StatusContext) useContext(StatusContext)
if (!status) return null if (!status) return null
@ -28,7 +28,7 @@ const TimelineHeaderDefault: React.FC = () => {
visibility: status.visibility, visibility: status.visibility,
type: 'status', type: 'status',
url: status.url || status.uri, url: status.url || status.uri,
copiableContent rawContent
}) })
const mAccount = menuAccount({ const mAccount = menuAccount({
type: 'status', type: 'status',

View File

@ -13,38 +13,19 @@ import { Circle } from 'react-native-animated-spinkit'
import StatusContext from './Context' import StatusContext from './Context'
const TimelineTranslate = () => { const TimelineTranslate = () => {
const { status, highlighted, copiableContent, detectedLanguage } = useContext(StatusContext) const { status, highlighted, rawContent, detectedLanguage } = useContext(StatusContext)
if (!status || !highlighted) return null if (!status || !highlighted || !rawContent?.current.length) return null
const { t } = useTranslation('componentTimeline') const { t } = useTranslation('componentTimeline')
const { colors } = useTheme() const { colors } = useTheme()
const backupTextProcessing = (): string[] => {
const text = status.spoiler_text ? [status.spoiler_text, status.content] : [status.content]
for (const i in text) {
for (const emoji of status.emojis) {
text[i] = text[i].replaceAll(`:${emoji.shortcode}:`, ' ')
}
text[i] = text[i]
.replace(/(<([^>]+)>)/gi, ' ')
.replace(/@.*? /gi, ' ')
.replace(/#.*? /gi, ' ')
.replace(/http(s):\/\/.*? /gi, ' ')
}
return text
}
const text = copiableContent?.current.content
? [copiableContent?.current.content]
: backupTextProcessing()
const [detected, setDetected] = useState<{ const [detected, setDetected] = useState<{
language: string language: string
confidence: number confidence: number
}>({ language: status.language || '', confidence: 0 }) }>({ language: status.language || '', confidence: 0 })
useEffect(() => { useEffect(() => {
const detect = async () => { const detect = async () => {
const result = await detectLanguage(text.join('\n\n')) const result = await detectLanguage(rawContent.current.join('\n\n'))
if (result) { if (result) {
setDetected(result) setDetected(result)
if (detectedLanguage) { if (detectedLanguage) {
@ -64,7 +45,7 @@ const TimelineTranslate = () => {
const { refetch, data, isFetching, isSuccess, isError } = useTranslateQuery({ const { refetch, data, isFetching, isSuccess, isError } = useTranslateQuery({
source: detected.language, source: detected.language,
target: targetLanguage, target: targetLanguage,
text, text: rawContent.current,
options: { enabled } options: { enabled }
}) })

View File

@ -7,10 +7,7 @@ const menuShare = (
params: params:
| { | {
visibility?: Mastodon.Status['visibility'] visibility?: Mastodon.Status['visibility']
copiableContent?: React.MutableRefObject<{ rawContent?: React.MutableRefObject<string[]>
content?: string | undefined
complete: boolean
}>
type: 'status' type: 'status'
url?: string url?: string
} }
@ -48,17 +45,17 @@ const menuShare = (
icon: 'square.and.arrow.up' icon: 'square.and.arrow.up'
}) })
} }
if (params.type === 'status' && Platform.OS === 'ios') if (params.type === 'status')
menus[0].push({ menus[0].push({
key: 'copy', key: 'copy',
item: { item: {
onSelect: () => { onSelect: () => {
Clipboard.setString(params.copiableContent?.current.content || '') Clipboard.setString(params.rawContent?.current.join(`\n\n`) || '')
displayMessage({ type: 'success', message: t(`copy.succeed`) }) displayMessage({ type: 'success', message: t(`copy.succeed`) })
}, },
disabled: false, disabled: false,
destructive: false, destructive: false,
hidden: !params.copiableContent?.current.content?.length hidden: !params.rawContent?.current.length
}, },
title: t('copy.action'), title: t('copy.action'),
icon: 'doc.on.doc' icon: 'doc.on.doc'

View File

@ -42,5 +42,9 @@
{ {
"feature": "notification_type_admin_report", "feature": "notification_type_admin_report",
"version": 4.0 "version": 4.0
},
{
"feature": "filter_server_side",
"version": 4.0
} }
] ]

View File

@ -6,6 +6,9 @@ const removeHTML = (text: string): string => {
const parser = new htmlparser2.Parser({ const parser = new htmlparser2.Parser({
ontext: (text: string) => { ontext: (text: string) => {
raw = raw + text raw = raw + text
},
onclosetag: (tag: string) => {
if (['p', 'br'].includes(tag)) raw = raw + `\n`
} }
}) })

View File

@ -91,7 +91,12 @@
"content": { "content": {
"expandHint": "Hidden content" "expandHint": "Hidden content"
}, },
"filtered": "Filtered: {{phrase}}.", "filtered": {
"reveal": "Show anyway",
"match_v1": "Filtered: {{phrase}}.",
"match_v2_one": "Filtered by {{filters}}.",
"match_v2_other": "Filtered by {{count}} filters, {{filters}}."
},
"fullConversation": "Read conversations", "fullConversation": "Read conversations",
"translate": { "translate": {
"default": "Translate", "default": "Translate",

View File

@ -24,7 +24,7 @@ const ScreenActions = ({
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const DEFAULT_VALUE = 350 const DEFAULT_VALUE = 350
const screenHeight = Dimensions.get('screen').height const screenHeight = Dimensions.get('window').height
const panY = useSharedValue(DEFAULT_VALUE) const panY = useSharedValue(DEFAULT_VALUE)
useEffect(() => { useEffect(() => {
panY.value = withTiming(0) panY.value = withTiming(0)

View File

@ -61,7 +61,7 @@ const ScreenAnnouncements: React.FC<RootStackScreenProps<'Screen-Announcements'>
<View <View
key={index} key={index}
style={{ style={{
width: Dimensions.get('screen').width, width: Dimensions.get('window').width,
padding: StyleConstants.Spacing.Global.PagePadding, padding: StyleConstants.Spacing.Global.PagePadding,
marginVertical: StyleConstants.Spacing.Global.PagePadding, marginVertical: StyleConstants.Spacing.Global.PagePadding,
justifyContent: 'center' justifyContent: 'center'
@ -200,7 +200,7 @@ const ScreenAnnouncements: React.FC<RootStackScreenProps<'Screen-Announcements'>
return ( return (
<View <View
style={{ style={{
width: Dimensions.get('screen').width, width: Dimensions.get('window').width,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center' alignItems: 'center'
}} }}

View File

@ -142,12 +142,12 @@ const ComposeDraftsList: React.FC<ScreenComposeStackScreenProps<'Screen-Compose-
key={index} key={index}
style={{ style={{
width: width:
(Dimensions.get('screen').width - (Dimensions.get('window').width -
StyleConstants.Spacing.Global.PagePadding * 2 - StyleConstants.Spacing.Global.PagePadding * 2 -
StyleConstants.Spacing.S * 3) / StyleConstants.Spacing.S * 3) /
4, 4,
height: height:
(Dimensions.get('screen').width - (Dimensions.get('window').width -
StyleConstants.Spacing.Global.PagePadding * 2 - StyleConstants.Spacing.Global.PagePadding * 2 -
StyleConstants.Spacing.S * 3) / StyleConstants.Spacing.S * 3) /
4, 4,

View File

@ -33,7 +33,6 @@ const ComposeTextInput: React.FC = () => {
paddingBottom: StyleConstants.Spacing.M, paddingBottom: StyleConstants.Spacing.M,
marginHorizontal: StyleConstants.Spacing.Global.PagePadding, marginHorizontal: StyleConstants.Spacing.Global.PagePadding,
color: colors.primaryDefault, color: colors.primaryDefault,
borderBottomColor: colors.border,
fontSize: adaptedFontsize, fontSize: adaptedFontsize,
lineHeight: adaptedLineheight lineHeight: adaptedLineheight
}} }}

View File

@ -25,7 +25,7 @@ const ZoomFlatList = createZoomListComponent(FlatList)
const ScreenImagesViewer = ({ const ScreenImagesViewer = ({
route: { route: {
params: { imageUrls, id } params: { imageUrls, id, hideCounter }
}, },
navigation navigation
}: RootStackScreenProps<'Screen-ImagesViewer'>) => { }: RootStackScreenProps<'Screen-ImagesViewer'>) => {
@ -34,8 +34,8 @@ const ScreenImagesViewer = ({
return null return null
} }
const SCREEN_WIDTH = Dimensions.get('screen').width const WINDOW_WIDTH = Dimensions.get('window').width
const SCREEN_HEIGHT = Dimensions.get('screen').height const WINDOW_HEIGHT = Dimensions.get('window').height
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
@ -85,13 +85,13 @@ const ScreenImagesViewer = ({
}: { }: {
item: RootStackScreenProps<'Screen-ImagesViewer'>['route']['params']['imageUrls'][0] item: RootStackScreenProps<'Screen-ImagesViewer'>['route']['params']['imageUrls'][0]
}) => { }) => {
const screenRatio = SCREEN_WIDTH / SCREEN_HEIGHT const screenRatio = WINDOW_WIDTH / WINDOW_HEIGHT
const imageRatio = item.width && item.height ? item.width / item.height : 1 const imageRatio = item.width && item.height ? item.width / item.height : 1
const imageWidth = item.width || 100 const imageWidth = item.width || 100
const imageHeight = item.height || 100 const imageHeight = item.height || 100
const maxWidthScale = item.width ? (item.width / SCREEN_WIDTH / PixelRatio.get()) * 4 : 0 const maxWidthScale = item.width ? (item.width / WINDOW_WIDTH / PixelRatio.get()) * 4 : 0
const maxHeightScale = item.height ? (item.height / SCREEN_WIDTH / PixelRatio.get()) * 4 : 0 const maxHeightScale = item.height ? (item.height / WINDOW_WIDTH / PixelRatio.get()) * 4 : 0
const max = Math.max.apply(Math, [maxWidthScale, maxHeightScale, 4]) const max = Math.max.apply(Math, [maxWidthScale, maxHeightScale, 4])
return ( return (
@ -109,8 +109,8 @@ const ScreenImagesViewer = ({
children={ children={
<View <View
style={{ style={{
width: SCREEN_WIDTH, width: WINDOW_WIDTH,
height: SCREEN_HEIGHT, height: WINDOW_HEIGHT,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center' justifyContent: 'center'
@ -121,12 +121,12 @@ const ScreenImagesViewer = ({
dimension={{ dimension={{
width: width:
screenRatio > imageRatio screenRatio > imageRatio
? (SCREEN_HEIGHT / imageHeight) * imageWidth ? (WINDOW_HEIGHT / imageHeight) * imageWidth
: SCREEN_WIDTH, : WINDOW_WIDTH,
height: height:
screenRatio > imageRatio screenRatio > imageRatio
? SCREEN_HEIGHT ? WINDOW_HEIGHT
: (SCREEN_WIDTH / imageWidth) * imageHeight : (WINDOW_WIDTH / imageWidth) * imageHeight
}} }}
/> />
</View> </View>
@ -159,7 +159,9 @@ const ScreenImagesViewer = ({
}} }}
> >
<HeaderLeft content='X' native={false} background onPress={() => navigation.goBack()} /> <HeaderLeft content='X' native={false} background onPress={() => navigation.goBack()} />
{!hideCounter ? (
<HeaderCenter inverted content={`${currentIndex + 1} / ${imageUrls.length}`} /> <HeaderCenter inverted content={`${currentIndex + 1} / ${imageUrls.length}`} />
) : null}
<HeaderRight <HeaderRight
accessibilityLabel={t('content.actions.accessibilityLabel')} accessibilityLabel={t('content.actions.accessibilityLabel')}
accessibilityHint={t('content.actions.accessibilityHint')} accessibilityHint={t('content.actions.accessibilityHint')}
@ -215,8 +217,8 @@ const ScreenImagesViewer = ({
}} }}
initialScrollIndex={initialIndex} initialScrollIndex={initialIndex}
getItemLayout={(_, index) => ({ getItemLayout={(_, index) => ({
length: SCREEN_WIDTH, length: WINDOW_WIDTH,
offset: SCREEN_WIDTH * index, offset: WINDOW_WIDTH * index,
index index
})} })}
/> />

View File

@ -46,18 +46,14 @@ const TabMePush: React.FC = () => {
useEffect(() => { useEffect(() => {
const checkPush = async () => { const checkPush = async () => {
switch (Platform.OS) { const permissions = await Notifications.getPermissionsAsync()
case 'ios': setPushEnabled(permissions.granted)
const settings = await Notifications.getPermissionsAsync() setPushCanAskAgain(permissions.canAskAgain)
layoutAnimation() layoutAnimation()
setPushEnabled(settings.granted)
setPushCanAskAgain(settings.canAskAgain) if (Platform.OS === 'android') {
break
case 'android':
await setChannels(instance) await setChannels(instance)
layoutAnimation()
dispatch(retrieveExpoToken()) dispatch(retrieveExpoToken())
break
} }
} }

View File

@ -5,11 +5,7 @@ import CustomText from '@components/Text'
import TimelineDefault from '@components/Timeline/Default' import TimelineDefault from '@components/Timeline/Default'
import { useAppDispatch } from '@root/store' import { useAppDispatch } from '@root/store'
import { TabMeStackScreenProps } from '@utils/navigation/navigators' import { TabMeStackScreenProps } from '@utils/navigation/navigators'
import { import { changeFontsize, getSettingsFontsize, SettingsState } from '@utils/slices/settingsSlice'
changeFontsize,
getSettingsFontsize,
SettingsState
} from '@utils/slices/settingsSlice'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { adaptiveScale } from '@utils/styles/scaling' import { adaptiveScale } from '@utils/styles/scaling'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
@ -34,9 +30,7 @@ export const mapFontsizeToName = (size: SettingsState['fontsize']) => {
} }
} }
const TabMeSettingsFontsize: React.FC< const TabMeSettingsFontsize: React.FC<TabMeStackScreenProps<'Tab-Me-Settings-Fontsize'>> = () => {
TabMeStackScreenProps<'Tab-Me-Settings-Fontsize'>
> = () => {
const { colors, theme } = useTheme() const { colors, theme } = useTheme()
const { t } = useTranslation('screenTabs') const { t } = useTranslation('screenTabs')
const initialSize = useSelector(getSettingsFontsize) const initialSize = useSelector(getSettingsFontsize)
@ -86,8 +80,7 @@ const TabMeSettingsFontsize: React.FC<
marginBottom: StyleConstants.Spacing.M, marginBottom: StyleConstants.Spacing.M,
fontSize: adaptiveScale(StyleConstants.Font.Size.M, size), fontSize: adaptiveScale(StyleConstants.Font.Size.M, size),
lineHeight: adaptiveScale(StyleConstants.Font.LineHeight.M, size), lineHeight: adaptiveScale(StyleConstants.Font.LineHeight.M, size),
color: color: initialSize === size ? colors.primaryDefault : colors.secondary,
initialSize === size ? colors.primaryDefault : colors.secondary,
borderWidth: StyleSheet.hairlineWidth, borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.border borderColor: colors.border
}} }}

View File

@ -88,7 +88,7 @@ const Root: React.FC<NativeStackScreenProps<TabPublicStackParamList, 'Tab-Public
renderTabBar={() => null} renderTabBar={() => null}
onIndexChange={index => setSegment(index)} onIndexChange={index => setSegment(index)}
navigationState={{ index: segment, routes }} navigationState={{ index: segment, routes }}
initialLayout={{ width: Dimensions.get('screen').width }} initialLayout={{ width: Dimensions.get('window').width }}
/> />
) )
} }

View File

@ -4,16 +4,16 @@ import { HeaderLeft, HeaderRight } from '@components/Header'
import Timeline from '@components/Timeline' import Timeline from '@components/Timeline'
import TimelineDefault from '@components/Timeline/Default' import TimelineDefault from '@components/Timeline/Default'
import SegmentedControl from '@react-native-community/segmented-control' import SegmentedControl from '@react-native-community/segmented-control'
import { useQueryClient } from '@tanstack/react-query'
import { TabSharedStackScreenProps } from '@utils/navigation/navigators' import { TabSharedStackScreenProps } from '@utils/navigation/navigators'
import { useAccountQuery } from '@utils/queryHooks/account' import { useAccountQuery } from '@utils/queryHooks/account'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline' import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import React, { useEffect, useMemo, useRef, useState } from 'react' import React, { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Text, View } from 'react-native' import { Text, View } from 'react-native'
import { useSharedValue } from 'react-native-reanimated' import { useSharedValue } from 'react-native-reanimated'
import { useIsFetching } from '@tanstack/react-query'
import * as DropdownMenu from 'zeego/dropdown-menu' import * as DropdownMenu from 'zeego/dropdown-menu'
import AccountAttachments from './Account/Attachments' import AccountAttachments from './Account/Attachments'
import AccountHeader from './Account/Header' import AccountHeader from './Account/Header'
@ -87,35 +87,29 @@ const TabSharedAccount: React.FC<TabSharedStackScreenProps<'Tab-Shared-Account'>
const scrollY = useSharedValue(0) const scrollY = useSharedValue(0)
const queryClient = useQueryClient()
const [queryKey, setQueryKey] = useState<QueryKeyTimeline>([ const [queryKey, setQueryKey] = useState<QueryKeyTimeline>([
'Timeline', 'Timeline',
{ page: 'Account', account: account.id, exclude_reblogs: true, only_media: false } { page: 'Account', account: account.id, exclude_reblogs: true, only_media: false }
]) ])
const page = queryKey[1] const page = queryKey[1]
const isFetchingTimeline = useIsFetching(queryKey)
const fetchedTimeline = useRef(false)
useEffect(() => {
if (!isFetchingTimeline && !fetchedTimeline.current) {
fetchedTimeline.current = true
}
}, [isFetchingTimeline, fetchedTimeline.current])
const [segment, setSegment] = useState<number>(0)
const ListHeaderComponent = useMemo(() => { const ListHeaderComponent = useMemo(() => {
return ( return (
<> <>
<View style={{ borderBottomWidth: 1, borderBottomColor: colors.border }}> <View style={{ borderBottomWidth: 1, borderBottomColor: colors.border }}>
<AccountHeader account={data} /> <AccountHeader account={data} />
<AccountInformation account={data} /> <AccountInformation account={data} />
{!data?.suspended && fetchedTimeline.current ? ( {!data?.suspended ? <AccountAttachments account={data} /> : null}
<AccountAttachments account={data} />
) : null}
</View> </View>
{!data?.suspended ? ( {!data?.suspended ? (
<SegmentedControl <SegmentedControl
appearance={mode} appearance={mode}
values={[t('shared.account.toots.default'), t('shared.account.toots.all')]} values={[t('shared.account.toots.default'), t('shared.account.toots.all')]}
selectedIndex={page.page === 'Account' ? 0 : 1} selectedIndex={segment}
onChange={({ nativeEvent }) => { onChange={({ nativeEvent }) => {
setSegment(nativeEvent.selectedSegmentIndex)
switch (nativeEvent.selectedSegmentIndex) { switch (nativeEvent.selectedSegmentIndex) {
case 0: case 0:
setQueryKey([ setQueryKey([
@ -171,7 +165,7 @@ const TabSharedAccount: React.FC<TabSharedStackScreenProps<'Tab-Shared-Account'>
) : null} ) : null}
</> </>
) )
}, [data, fetchedTimeline.current, queryKey[1].page, mode]) }, [segment, data, queryKey[1].page, mode])
return ( return (
<> <>
@ -187,7 +181,9 @@ const TabSharedAccount: React.FC<TabSharedStackScreenProps<'Tab-Shared-Account'>
renderItem: ({ item }) => <TimelineDefault item={item} queryKey={queryKey} />, renderItem: ({ item }) => <TimelineDefault item={item} queryKey={queryKey} />,
onScroll: ({ nativeEvent }) => (scrollY.value = nativeEvent.contentOffset.y), onScroll: ({ nativeEvent }) => (scrollY.value = nativeEvent.contentOffset.y),
ListHeaderComponent, ListHeaderComponent,
maintainVisibleContentPosition: undefined maintainVisibleContentPosition: undefined,
onRefresh: () => queryClient.refetchQueries(queryKey),
refreshing: queryClient.getQueryState(queryKey)?.fetchStatus === 'fetching'
}} }}
/> />
)} )}

View File

@ -3,10 +3,10 @@ import Icon from '@components/Icon'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import { StackNavigationProp } from '@react-navigation/stack' import { StackNavigationProp } from '@react-navigation/stack'
import { TabLocalStackParamList } from '@utils/navigation/navigators' import { TabLocalStackParamList } from '@utils/navigation/navigators'
import { QueryKeyTimeline, useTimelineQuery } from '@utils/queryHooks/timeline' import { useTimelineQuery } from '@utils/queryHooks/timeline'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useEffect } from 'react' import React, { useCallback } from 'react'
import { Dimensions, ListRenderItem, Pressable, View } from 'react-native' import { Dimensions, ListRenderItem, Pressable, View } from 'react-native'
import { FlatList } from 'react-native-gesture-handler' import { FlatList } from 'react-native-gesture-handler'
import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated' import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'
@ -23,23 +23,14 @@ const AccountAttachments: React.FC<Props> = ({ account }) => {
const DISPLAY_AMOUNT = 6 const DISPLAY_AMOUNT = 6
const width = (Dimensions.get('screen').width - StyleConstants.Spacing.Global.PagePadding * 2) / 4 const width = (Dimensions.get('window').width - StyleConstants.Spacing.Global.PagePadding * 2) / 4
const queryKeyParams: QueryKeyTimeline[1] = { const { data } = useTimelineQuery({
page: 'Account', page: 'Account',
account: account.id, account: account.id,
exclude_reblogs: false, exclude_reblogs: false,
only_media: true only_media: true
}
const { data, refetch } = useTimelineQuery({
...queryKeyParams,
options: { enabled: false }
}) })
useEffect(() => {
if (account?.id) {
refetch()
}
}, [account])
const flattenData = data?.pages const flattenData = data?.pages
? data.pages ? data.pages

View File

@ -1,47 +1,45 @@
import Button from '@components/Button'
import GracefullyImage from '@components/GracefullyImage' import GracefullyImage from '@components/GracefullyImage'
import navigationRef from '@helpers/navigationRef'
import { getInstanceActive } from '@utils/slices/instancesSlice'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react' import React from 'react'
import { Dimensions, View } from 'react-native' import { Dimensions, Image, Pressable } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useSelector } from 'react-redux'
export interface Props { export interface Props {
account?: Mastodon.Account account?: Mastodon.Account
edit?: boolean
} }
const AccountHeader = React.memo( const AccountHeader: React.FC<Props> = ({ account }) => {
({ account, edit }: Props) => {
const { colors } = useTheme() const { colors } = useTheme()
const topInset = useSafeAreaInsets().top const topInset = useSafeAreaInsets().top
useSelector(getInstanceActive)
return ( return (
<View> <Pressable
onPress={() => {
if (account) {
Image.getSize(account.header, (width, height) =>
navigationRef.navigate('Screen-ImagesViewer', {
imageUrls: [{ id: 'avatar', url: account.header, width, height }],
id: 'avatar',
hideCounter: true
})
)
}
}}
>
<GracefullyImage <GracefullyImage
uri={{ original: account?.header, static: account?.header_static }} uri={{ original: account?.header, static: account?.header_static }}
style={{ style={{
height: Dimensions.get('screen').width / 3 + topInset, height: Dimensions.get('window').width / 3 + topInset,
backgroundColor: colors.disabled backgroundColor: colors.disabled
}} }}
/> />
{edit ? ( </Pressable>
<View
style={{
position: 'absolute',
width: '100%',
height: '100%',
alignContent: 'center',
justifyContent: 'center',
alignItems: 'center'
}}
>
<Button type='icon' content='Edit' round onPress={() => {}} />
</View>
) : null}
</View>
)
},
(_, next) => next.account === undefined
) )
}
export default AccountHeader export default AccountHeader

View File

@ -1,28 +1,37 @@
import Button from '@components/Button'
import GracefullyImage from '@components/GracefullyImage' import GracefullyImage from '@components/GracefullyImage'
import navigationRef from '@helpers/navigationRef'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import { StackNavigationProp } from '@react-navigation/stack' import { StackNavigationProp } from '@react-navigation/stack'
import { TabLocalStackParamList } from '@utils/navigation/navigators' import { TabLocalStackParamList } from '@utils/navigation/navigators'
import { getInstanceActive } from '@utils/slices/instancesSlice' import { getInstanceActive } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import React from 'react' import React from 'react'
import { Pressable, View } from 'react-native' import { Pressable } from 'react-native'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
export interface Props { export interface Props {
account: Mastodon.Account | undefined account: Mastodon.Account | undefined
myInfo: boolean myInfo: boolean
edit?: boolean
} }
const AccountInformationAvatar: React.FC<Props> = ({ account, myInfo, edit }) => { const AccountInformationAvatar: React.FC<Props> = ({ account, myInfo }) => {
const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>() const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>()
useSelector(getInstanceActive) useSelector(getInstanceActive)
return ( return (
<Pressable <Pressable
disabled={!myInfo}
onPress={() => { onPress={() => {
myInfo && account && navigation.push('Tab-Shared-Account', { account }) if (account) {
if (myInfo) {
navigation.push('Tab-Shared-Account', { account })
return
} else {
navigationRef.navigate('Screen-ImagesViewer', {
imageUrls: [{ id: 'avatar', url: account.avatar }],
id: 'avatar',
hideCounter: true
})
}
}
}} }}
style={{ style={{
borderRadius: 8, borderRadius: 8,
@ -36,20 +45,6 @@ const AccountInformationAvatar: React.FC<Props> = ({ account, myInfo, edit }) =>
style={{ flex: 1 }} style={{ flex: 1 }}
uri={{ original: account?.avatar, static: account?.avatar_static }} uri={{ original: account?.avatar, static: account?.avatar_static }}
/> />
{edit ? (
<View
style={{
position: 'absolute',
width: '100%',
height: '100%',
alignContent: 'center',
justifyContent: 'center',
alignItems: 'center'
}}
>
<Button type='icon' content='Edit' round onPress={() => {}} />
</View>
) : null}
</Pressable> </Pressable>
) )
} }

View File

@ -4,11 +4,7 @@ import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react' import React from 'react'
import { Dimensions, StyleSheet, View } from 'react-native' import { Dimensions, StyleSheet, View } from 'react-native'
import Animated, { import Animated, { Extrapolate, interpolate, useAnimatedStyle } from 'react-native-reanimated'
Extrapolate,
interpolate,
useAnimatedStyle
} from 'react-native-reanimated'
import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useSafeAreaInsets } from 'react-native-safe-area-context'
export interface Props { export interface Props {
@ -22,7 +18,7 @@ const AccountNav = React.memo(
const headerHeight = useSafeAreaInsets().top + 44 const headerHeight = useSafeAreaInsets().top + 44
const nameY = const nameY =
Dimensions.get('screen').width / 3 + Dimensions.get('window').width / 3 +
StyleConstants.Avatar.L - StyleConstants.Avatar.L -
StyleConstants.Spacing.Global.PagePadding * 2 + StyleConstants.Spacing.Global.PagePadding * 2 +
StyleConstants.Spacing.M - StyleConstants.Spacing.M -
@ -35,12 +31,7 @@ const AccountNav = React.memo(
}) })
const styleMarginTop = useAnimatedStyle(() => { const styleMarginTop = useAnimatedStyle(() => {
return { return {
marginTop: interpolate( marginTop: interpolate(scrollY.value, [nameY, nameY + 20], [50, 0], Extrapolate.CLAMP)
scrollY.value,
[nameY, nameY + 20],
[50, 0],
Extrapolate.CLAMP
)
} }
}) })
@ -61,8 +52,7 @@ const AccountNav = React.memo(
flex: 1, flex: 1,
alignItems: 'center', alignItems: 'center',
overflow: 'hidden', overflow: 'hidden',
marginTop: marginTop: useSafeAreaInsets().top + (44 - StyleConstants.Font.Size.L) / 2
useSafeAreaInsets().top + (44 - StyleConstants.Font.Size.L) / 2
}} }}
> >
<Animated.View style={[{ flexDirection: 'row' }, styleMarginTop]}> <Animated.View style={[{ flexDirection: 'row' }, styleMarginTop]}>

View File

@ -5,8 +5,10 @@ import { TabSharedStackScreenProps } from '@utils/navigation/navigators'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline' import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { FlatList } from 'react-native' import { FlatList, View } from 'react-native'
import { InfiniteQueryObserver, useQueryClient } from '@tanstack/react-query' import { InfiniteQueryObserver, useQueryClient } from '@tanstack/react-query'
import ComponentSeparator from '@components/Separator'
import { StyleConstants } from '@utils/styles/constants'
const TabSharedToot: React.FC<TabSharedStackScreenProps<'Tab-Shared-Toot'>> = ({ const TabSharedToot: React.FC<TabSharedStackScreenProps<'Tab-Shared-Toot'>> = ({
navigation, navigation,
@ -98,94 +100,49 @@ const TabSharedToot: React.FC<TabSharedStackScreenProps<'Tab-Shared-Toot'>> = ({
queryKey={queryKey} queryKey={queryKey}
queryOptions={{ staleTime: 0, refetchOnMount: true }} queryOptions={{ staleTime: 0, refetchOnMount: true }}
customProps={{ customProps={{
renderItem: ({ item }) => { ItemSeparatorComponent: ({ leadingItem }) => {
const levels = {
current:
replyLevels.current.find(reply => reply.id === leadingItem.in_reply_to_id)?.level || 0
}
return ( return (
<ComponentSeparator
extraMarginLeft={
toot.id === leadingItem.id
? 0
: StyleConstants.Avatar.XS +
StyleConstants.Spacing.S +
Math.max(0, levels.current - 1) * 8
}
/>
)
},
renderItem: ({ item, index }) => {
const levels = {
previous:
replyLevels.current.find(
reply => reply.id === data.current?.[index - 1]?.in_reply_to_id
)?.level || 0,
current:
replyLevels.current.find(reply => reply.id === item.in_reply_to_id)?.level || 0,
next:
replyLevels.current.find(
reply => reply.id === data.current?.[index + 1]?.in_reply_to_id
)?.level || 0
}
return (
<View style={{ marginLeft: Math.max(0, levels.current - 1) * StyleConstants.Spacing.S }}>
<TimelineDefault <TimelineDefault
item={item} item={item}
queryKey={queryKey} queryKey={queryKey}
rootQueryKey={rootQueryKey} rootQueryKey={rootQueryKey}
highlighted={toot.id === item.id} highlighted={toot.id === item.id}
isConversation={toot.id !== item.id}
/> />
</View>
) )
}, },
// renderItem: ({ item, index }) => {
// const levels = {
// previous:
// replyLevels.current.find(
// reply => reply.id === data.current?.[index - 1]?.in_reply_to_id
// )?.level || 0,
// current:
// replyLevels.current.find(reply => reply.id === item.in_reply_to_id)?.level || 0,
// next:
// replyLevels.current.find(
// reply => reply.id === data.current?.[index + 1]?.in_reply_to_id
// )?.level || 0
// }
// return (
// <>
// <TimelineDefault
// item={item}
// queryKey={queryKey}
// rootQueryKey={rootQueryKey}
// highlighted={toot.id === item.id}
// isConversation={toot.id !== item.id}
// />
// {Array.from(Array(levels.current)).map((_, i) => {
// if (index < highlightIndex.current) return null
// if (
// levels.previous + 1 === levels.current ||
// (levels.previous && levels.current && levels.previous === levels.current)
// ) {
// return (
// <View
// key={i}
// style={{
// position: 'absolute',
// top: 0,
// left: StyleConstants.Spacing.Global.PagePadding / 2 + 8 * i,
// height:
// levels.current === levels.next
// ? StyleConstants.Spacing.Global.PagePadding
// : StyleConstants.Spacing.Global.PagePadding + StyleConstants.Avatar.XS,
// borderLeftColor: colors.border,
// borderLeftWidth: 1
// }}
// />
// )
// } else {
// return null
// }
// })}
// {Array.from(Array(levels.next)).map((_, i) => {
// if (index < highlightIndex.current) return null
// if (
// levels.current + 1 === levels.next ||
// (levels.current && levels.next && levels.current === levels.next)
// ) {
// return (
// <View
// key={i}
// style={{
// position: 'absolute',
// top:
// levels.current + 1 === levels.next && levels.next > i + 1
// ? StyleConstants.Spacing.Global.PagePadding + StyleConstants.Avatar.XS
// : StyleConstants.Spacing.Global.PagePadding,
// left: StyleConstants.Spacing.Global.PagePadding / 2 + 8 * i,
// height: 200,
// borderLeftColor: colors.border,
// borderLeftWidth: 1
// }}
// />
// )
// } else {
// return null
// }
// })}
// </>
// )
// },
onScrollToIndexFailed: error => { onScrollToIndexFailed: error => {
const offset = error.averageItemLength * error.index const offset = error.averageItemLength * error.index
flRef.current?.scrollToOffset({ offset }) flRef.current?.scrollToOffset({ offset })

View File

@ -19,7 +19,7 @@ export type InstanceV10 = {
} }
version: string version: string
configuration?: Mastodon.Instance['configuration'] configuration?: Mastodon.Instance['configuration']
filters: Mastodon.Filter[] filters: Mastodon.Filter<any>[]
notifications_filter: { notifications_filter: {
follow: boolean follow: boolean
follow_request: boolean follow_request: boolean

View File

@ -18,7 +18,7 @@ export type InstanceV11 = {
} }
version: string version: string
configuration?: Mastodon.Instance['configuration'] configuration?: Mastodon.Instance['configuration']
filters: Mastodon.Filter[] filters: Mastodon.Filter<any>[]
notifications_filter: { notifications_filter: {
follow: boolean follow: boolean
follow_request: boolean follow_request: boolean

View File

@ -17,7 +17,7 @@ type Instance = {
avatarStatic: Mastodon.Account['avatar_static'] avatarStatic: Mastodon.Account['avatar_static']
preferences: Mastodon.Preferences preferences: Mastodon.Preferences
} }
filters: Mastodon.Filter[] filters: Mastodon.Filter<any>[]
notifications_filter: { notifications_filter: {
follow: boolean follow: boolean
favourite: boolean favourite: boolean

View File

@ -18,7 +18,7 @@ type Instance = {
} }
max_toot_chars?: number // To be deprecated in v4 max_toot_chars?: number // To be deprecated in v4
configuration?: Mastodon.Instance['configuration'] configuration?: Mastodon.Instance['configuration']
filters: Mastodon.Filter[] filters: Mastodon.Filter<any>[]
notifications_filter: { notifications_filter: {
follow: boolean follow: boolean
favourite: boolean favourite: boolean

View File

@ -19,7 +19,7 @@ type Instance = {
} }
max_toot_chars?: number // To be deprecated in v4 max_toot_chars?: number // To be deprecated in v4
configuration?: Mastodon.Instance['configuration'] configuration?: Mastodon.Instance['configuration']
filters: Mastodon.Filter[] filters: Mastodon.Filter<any>[]
notifications_filter: { notifications_filter: {
follow: boolean follow: boolean
favourite: boolean favourite: boolean

View File

@ -19,7 +19,7 @@ type Instance = {
} }
max_toot_chars?: number // To be deprecated in v4 max_toot_chars?: number // To be deprecated in v4
configuration?: Mastodon.Instance['configuration'] configuration?: Mastodon.Instance['configuration']
filters: Mastodon.Filter[] filters: Mastodon.Filter<any>[]
notifications_filter: { notifications_filter: {
follow: boolean follow: boolean
favourite: boolean favourite: boolean

View File

@ -19,7 +19,7 @@ export type InstanceV9 = {
} }
version: string version: string
configuration?: Mastodon.Instance['configuration'] configuration?: Mastodon.Instance['configuration']
filters: Mastodon.Filter[] filters: Mastodon.Filter<any>[]
notifications_filter: { notifications_filter: {
follow: boolean follow: boolean
favourite: boolean favourite: boolean

View File

@ -51,14 +51,14 @@ export type RootStackParamList = {
'Screen-ImagesViewer': { 'Screen-ImagesViewer': {
imageUrls: { imageUrls: {
id: Mastodon.Attachment['id'] id: Mastodon.Attachment['id']
preview_url: Mastodon.AttachmentImage['preview_url'] preview_url?: Mastodon.AttachmentImage['preview_url']
url: Mastodon.AttachmentImage['url'] url: Mastodon.AttachmentImage['url']
remote_url?: Mastodon.AttachmentImage['remote_url'] remote_url?: Mastodon.AttachmentImage['remote_url']
blurhash: Mastodon.AttachmentImage['blurhash']
width?: number width?: number
height?: number height?: number
}[] }[]
id: Mastodon.Attachment['id'] id: Mastodon.Attachment['id']
hideCounter?: boolean
} }
'Screen-AccountSelection': { 'Screen-AccountSelection': {
component?: () => JSX.Element | undefined component?: () => JSX.Element | undefined

View File

@ -35,7 +35,11 @@ const pushUseConnect = () => {
}), }),
{ {
enabled: false, enabled: false,
retry: 10, retry: (failureCount, error) => {
if (error.status == 404) return false
return failureCount < 10
},
retryOnMount: false, retryOnMount: false,
refetchOnMount: false, refetchOnMount: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,

View File

@ -51,7 +51,7 @@ export type QueryKeyTimeline = [
const queryFunction = async ({ queryKey, pageParam }: QueryFunctionContext<QueryKeyTimeline>) => { const queryFunction = async ({ queryKey, pageParam }: QueryFunctionContext<QueryKeyTimeline>) => {
const page = queryKey[1] const page = queryKey[1]
let params: { [key: string]: string } = { ...pageParam, limit: 40 } let params: { [key: string]: string } = { limit: 40, ...pageParam }
switch (page.page) { switch (page.page) {
case 'Following': case 'Following':

View File

@ -52,7 +52,7 @@ const addInstance = createAsyncThunk(
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
}) })
const { body: filters } = await apiGeneral<Mastodon.Filter[]>({ const { body: filters } = await apiGeneral<Mastodon.Filter<any>[]>({
method: 'get', method: 'get',
domain, domain,
url: `api/v1/filters`, url: `api/v1/filters`,

View File

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

View File

@ -3,13 +3,13 @@ const Base = 4
export const StyleConstants = { export const StyleConstants = {
Font: { Font: {
Size: { S: 14, M: 16, L: 18 }, Size: { S: 14, M: 16, L: 18 },
LineHeight: { S: 20, M: 22, L: 28 }, LineHeight: { S: 18, M: 21, L: 26 },
Weight: { Normal: '400' as '400', Bold: '600' as '600' } Weight: { Normal: '400' as '400', Bold: '600' as '600' }
}, },
FontStyle: { FontStyle: {
S: { fontSize: 14, lineHeight: 20 }, S: { fontSize: 14, lineHeight: 18 },
M: { fontSize: 16, lineHeight: 22 }, M: { fontSize: 16, lineHeight: 21 },
L: { fontSize: 20, lineHeight: 28 } L: { fontSize: 20, lineHeight: 26 }
}, },
Spacing: { Spacing: {

View File

@ -1,15 +1,3 @@
// import { Dimensions } from 'react-native' const adaptiveScale = (size: number, factor: number = 0) => Math.round(size + size * (factor / 8))
// const { width } = Dimensions.get('screen')
// const guidelineBaseWidth = 375
// const guidelineBaseHeight = 667
// const scale = (size: number) => (width / guidelineBaseWidth) * size
// const verticalScale = (size: number) => (height / guidelineBaseHeight) * size
// const adaptiveScale = (size: number, factor: number = 0) =>
// size + (scale(size) - size) * factor
const adaptiveScale = (size: number, factor: number = 0) =>
size + size * (factor / 8)
export { adaptiveScale } export { adaptiveScale }