diff --git a/fastlane/metadata/android/it-IT/full_description.txt b/fastlane/metadata/android/it-IT/full_description.txt new file mode 120000 index 00000000..5305c810 --- /dev/null +++ b/fastlane/metadata/android/it-IT/full_description.txt @@ -0,0 +1 @@ +../../it/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/it-IT/short_description.txt b/fastlane/metadata/android/it-IT/short_description.txt new file mode 120000 index 00000000..20fae545 --- /dev/null +++ b/fastlane/metadata/android/it-IT/short_description.txt @@ -0,0 +1 @@ +../../it/subtitle.txt \ No newline at end of file diff --git a/fastlane/metadata/en-US/description.txt b/fastlane/metadata/en-US/description.txt index 9238b9bb..0e3deca8 100644 --- a/fastlane/metadata/en-US/description.txt +++ b/fastlane/metadata/en-US/description.txt @@ -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. \ No newline at end of file +If you have suggestions, please reach out to @tooot@xmflsct.com or support@tooot.app. diff --git a/fastlane/metadata/en-US/release_notes.txt b/fastlane/metadata/en-US/release_notes.txt index 862623d2..eae9e30e 100644 --- a/fastlane/metadata/en-US/release_notes.txt +++ b/fastlane/metadata/en-US/release_notes.txt @@ -1 +1,4 @@ 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) diff --git a/fastlane/metadata/it/description.txt b/fastlane/metadata/it/description.txt new file mode 100644 index 00000000..a001cce3 --- /dev/null +++ b/fastlane/metadata/it/description.txt @@ -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. \ No newline at end of file diff --git a/fastlane/metadata/it/subtitle.txt b/fastlane/metadata/it/subtitle.txt new file mode 100644 index 00000000..efd0d0ae --- /dev/null +++ b/fastlane/metadata/it/subtitle.txt @@ -0,0 +1 @@ +Client open source per Mastodon \ No newline at end of file diff --git a/fastlane/metadata/zh-Hans/description.txt b/fastlane/metadata/zh-Hans/description.txt index b6a299e0..1f990e77 100644 --- a/fastlane/metadata/zh-Hans/description.txt +++ b/fastlane/metadata/zh-Hans/description.txt @@ -1,11 +1,10 @@ -tooot是一个专门为中文用户社区所打造的开源、简洁长毛象客户端。使用此客户端需要已经拥有一个长毛象(https://joinmastodon.org/)账号。 +tooot起始于专注中文社区的简洁、开源长毛象手机客户端。使用此客户端需要已经拥有一个长毛象(https://joinmastodon.org/)账号。 tooot支持: -- iPad +- 跨平台,及iPadOS、MacOS - 多账号登录 - 黑暗或自适应模式 -- 可调整正文字体大小 +- 可调正文字体尺寸 - 消息推送 -等功能。 如有使用建议或意见,请联系@tooot@xmflsct.com或者support@tooot.app。 \ No newline at end of file diff --git a/fastlane/metadata/zh-Hans/release_notes.txt b/fastlane/metadata/zh-Hans/release_notes.txt index d9488dcd..1655a23d 100644 --- a/fastlane/metadata/zh-Hans/release_notes.txt +++ b/fastlane/metadata/zh-Hans/release_notes.txt @@ -1 +1,4 @@ toooting愉快!此版本包括以下改进和修复: +- 改进过滤体验,与v4.0以上版本一致 +- 支持查看用户的头像和横幅图片 +- 修复iPad部分尺寸问题(非优化) diff --git a/src/@types/mastodon.d.ts b/src/@types/mastodon.d.ts index fc61ed10..102739ca 100644 --- a/src/@types/mastodon.d.ts +++ b/src/@types/mastodon.d.ts @@ -263,7 +263,8 @@ declare namespace Mastodon { verified_at: string | null } - type Filter = { + type Filter = T extends 'v2' ? Filter_V2 : Filter_V1 + type Filter_V1 = { id: string phrase: string context: ('home' | 'notifications' | 'public' | 'thread' | 'account')[] @@ -271,6 +272,25 @@ declare namespace Mastodon { irreversible: 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 = { id: string @@ -461,7 +481,7 @@ declare namespace Mastodon { sensitive: boolean spoiler_text?: string media_attachments: Attachment[] - application: Application + application?: Application // Attributes mentions: Mention[] @@ -472,7 +492,7 @@ declare namespace Mastodon { reblogs_count: number favourites_count: number replies_count: number - edited_at?: string // FEATURE edit_post + edited_at?: string favourited: boolean reblogged: boolean muted: boolean @@ -488,6 +508,7 @@ declare namespace Mastodon { card?: Card language?: string text?: string + filtered?: FilterResult[] } type StatusHistory = { diff --git a/src/components/Timeline/Conversation.tsx b/src/components/Timeline/Conversation.tsx index 8e07d2c0..329123e4 100644 --- a/src/components/Timeline/Conversation.tsx +++ b/src/components/Timeline/Conversation.tsx @@ -98,19 +98,17 @@ const TimelineConversation: React.FC = ({ conversation, queryKey, highlig {conversation.last_status ? ( - <> - - - - + + + - + ) : null} diff --git a/src/components/Timeline/Default.tsx b/src/components/Timeline/Default.tsx index 8d209d14..c8a1d218 100644 --- a/src/components/Timeline/Default.tsx +++ b/src/components/Timeline/Default.tsx @@ -9,11 +9,12 @@ import TimelineCard from '@components/Timeline/Shared/Card' import TimelineContent from '@components/Timeline/Shared/Content' import TimelineHeaderDefault from '@components/Timeline/Shared/HeaderDefault' import TimelinePoll from '@components/Timeline/Shared/Poll' +import removeHTML from '@helpers/removeHTML' import { useNavigation } from '@react-navigation/native' import { StackNavigationProp } from '@react-navigation/stack' import { TabLocalStackParamList } from '@utils/navigation/navigators' 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 { useTheme } from '@utils/styles/ThemeManager' import React, { useRef, useState } from 'react' @@ -22,7 +23,7 @@ import { useSelector } from 'react-redux' import * as ContextMenu from 'zeego/context-menu' import StatusContext from './Shared/Context' import TimelineFeedback from './Shared/Feedback' -import TimelineFiltered, { shouldFilter } from './Shared/Filtered' +import TimelineFiltered, { FilteredProps, shouldFilter } from './Shared/Filtered' import TimelineFullConversation from './Shared/FullConversation' import TimelineHeaderAndroid from './Shared/HeaderAndroid' import TimelineTranslate from './Shared/Translate' @@ -47,12 +48,20 @@ const TimelineDefault: React.FC = ({ disableOnPress = false, isConversation = false }) => { + const status = item.reblog ? item.reblog : item + const rawContent = useRef([]) + if (highlighted) { + rawContent.current = [ + removeHTML(status.content), + status.spoiler_text ? removeHTML(status.spoiler_text) : '' + ].filter(c => c.length) + } + const { colors } = useTheme() const navigation = useNavigation>() const instanceAccount = useSelector(getInstanceAccount, () => true) - const status = item.reblog ? item.reblog : item const ownAccount = status.account?.id === instanceAccount?.id const [spoilerExpanded, setSpoilerExpanded] = useState( instanceAccount?.preferences?.['reading:expand:spoilers'] || false @@ -60,17 +69,8 @@ const TimelineDefault: React.FC = ({ const spoilerHidden = status.spoiler_text?.length ? !instanceAccount?.preferences?.['reading:expand:spoilers'] && !spoilerExpanded : false - const copiableContent = useRef<{ content: string; complete: boolean }>({ - content: '', - complete: false - }) const detectedLanguage = useRef(status.language || '') - const filtered = queryKey && shouldFilter({ copiableContent, status, queryKey }) - if (queryKey && filtered && !highlighted) { - return - } - const mainStyle: StyleProp = { flex: 1, padding: disableDetails @@ -103,8 +103,9 @@ const TimelineDefault: React.FC = ({ paddingTop: highlighted ? StyleConstants.Spacing.S : 0, paddingLeft: highlighted ? 0 - : (disableDetails ? StyleConstants.Avatar.XS : StyleConstants.Avatar.M) + - StyleConstants.Spacing.S, + : (disableDetails || isConversation + ? StyleConstants.Avatar.XS + : StyleConstants.Avatar.M) + StyleConstants.Spacing.S, ...(disableDetails && { marginTop: -StyleConstants.Spacing.S }) }} > @@ -115,9 +116,9 @@ const TimelineDefault: React.FC = ({ - - + + ) @@ -125,11 +126,36 @@ const TimelineDefault: React.FC = ({ visibility: status.visibility, type: 'status', url: status.url || status.uri, - copiableContent + rawContent }) const mStatus = menuStatus({ 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 ? ( + setFilterRevealed(!filterRevealed)}> + + + ) : null + } + } + return ( = ({ reblogStatus: item.reblog ? item : undefined, ownAccount, spoilerHidden, - copiableContent, + rawContent, detectedLanguage, highlighted, inThread: queryKey?.[1].page === 'Toot', diff --git a/src/components/Timeline/Notifications.tsx b/src/components/Timeline/Notifications.tsx index 81ee0d57..a947e0f0 100644 --- a/src/components/Timeline/Notifications.tsx +++ b/src/components/Timeline/Notifications.tsx @@ -13,7 +13,7 @@ import { useNavigation } from '@react-navigation/native' import { StackNavigationProp } from '@react-navigation/stack' import { TabLocalStackParamList } from '@utils/navigation/navigators' 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 { useTheme } from '@utils/styles/ThemeManager' import React, { useCallback, useRef, useState } from 'react' @@ -21,7 +21,7 @@ import { Pressable, View } from 'react-native' import { useSelector } from 'react-redux' import * as ContextMenu from 'zeego/context-menu' import StatusContext from './Shared/Context' -import TimelineFiltered, { shouldFilter } from './Shared/Filtered' +import TimelineFiltered, { FilteredProps, shouldFilter } from './Shared/Filtered' import TimelineFullConversation from './Shared/FullConversation' import TimelineHeaderAndroid from './Shared/HeaderAndroid' @@ -47,21 +47,6 @@ const TimelineNotifications: React.FC = ({ notification, queryKey }) => { const spoilerHidden = notification.status?.spoiler_text?.length ? !instanceAccount.preferences?.['reading:expand:spoilers'] && !spoilerExpanded : 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 - } const { colors } = useTheme() const navigation = useNavigation>() @@ -112,11 +97,11 @@ const TimelineNotifications: React.FC = ({ notification, queryKey }) => { + + ) : null} - - ) } @@ -124,20 +109,44 @@ const TimelineNotifications: React.FC = ({ notification, queryKey }) => { const mShare = menuShare({ visibility: notification.status?.visibility, type: 'status', - url: notification.status?.url || notification.status?.uri, - copiableContent + url: notification.status?.url || notification.status?.uri }) const mStatus = menuStatus({ 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 ? ( + setFilterRevealed(!filterRevealed)}> + + + ) : null + } + } + } + return ( diff --git a/src/components/Timeline/Refresh.tsx b/src/components/Timeline/Refresh.tsx index 9ac45e95..c7256596 100644 --- a/src/components/Timeline/Refresh.tsx +++ b/src/components/Timeline/Refresh.tsx @@ -55,7 +55,7 @@ const TimelineRefresh: React.FC = ({ firstPage?.links?.prev && { ...(firstPage.links.prev.isOffset ? { 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 limit: '3' }, diff --git a/src/components/Timeline/Shared/Actions.tsx b/src/components/Timeline/Shared/Actions.tsx index a803dd45..a2da0ea1 100644 --- a/src/components/Timeline/Shared/Actions.tsx +++ b/src/components/Timeline/Shared/Actions.tsx @@ -263,63 +263,57 @@ const TimelineActions: React.FC = () => { }, [status.bookmarked]) return ( - - - + + - + - + - - + ) } diff --git a/src/components/Timeline/Shared/Attachment.tsx b/src/components/Timeline/Shared/Attachment.tsx index 7f0128ed..e452a09d 100644 --- a/src/components/Timeline/Shared/Attachment.tsx +++ b/src/components/Timeline/Shared/Attachment.tsx @@ -70,7 +70,6 @@ const TimelineAttachment = () => { preview_url: attachment.preview_url, url: attachment.url, remote_url: attachment.remote_url, - blurhash: attachment.blurhash, width: attachment.meta?.original?.width, height: attachment.meta?.original?.height } @@ -90,7 +89,6 @@ const TimelineAttachment = () => { preview_url: attachment.preview_url, url: attachment.url, remote_url: attachment.remote_url, - blurhash: attachment.blurhash, width: attachment.meta?.original?.width, height: attachment.meta?.original?.height } diff --git a/src/components/Timeline/Shared/Avatar.tsx b/src/components/Timeline/Shared/Avatar.tsx index 385b83af..2dde436f 100644 --- a/src/components/Timeline/Shared/Avatar.tsx +++ b/src/components/Timeline/Shared/Avatar.tsx @@ -48,8 +48,7 @@ const TimelineAvatar: React.FC = ({ account }) => { style={{ borderRadius: StyleConstants.Avatar.M, overflow: 'hidden', - marginRight: StyleConstants.Spacing.S, - marginLeft: isConversation ? StyleConstants.Avatar.M - StyleConstants.Avatar.XS : undefined + marginRight: StyleConstants.Spacing.S }} /> ) diff --git a/src/components/Timeline/Shared/Context.tsx b/src/components/Timeline/Shared/Context.tsx index 218379d1..8917c2fb 100644 --- a/src/components/Timeline/Shared/Context.tsx +++ b/src/components/Timeline/Shared/Context.tsx @@ -1,7 +1,9 @@ import { QueryKeyTimeline } from '@utils/queryHooks/timeline' import { createContext } from 'react' -type ContextType = { +export type HighlightedStatusContextType = {} + +type StatusContextType = { queryKey?: QueryKeyTimeline rootQueryKey?: QueryKeyTimeline @@ -10,10 +12,7 @@ type ContextType = { reblogStatus?: Mastodon.Status // When it is a reblog, pass the root status ownAccount?: boolean spoilerHidden?: boolean - copiableContent?: React.MutableRefObject<{ - content: string - complete: boolean - }> + rawContent?: React.MutableRefObject // When highlighted, for translate, edit history detectedLanguage?: React.MutableRefObject highlighted?: boolean @@ -22,6 +21,6 @@ type ContextType = { disableOnPress?: boolean isConversation?: boolean } -const StatusContext = createContext({} as ContextType) +const StatusContext = createContext({} as StatusContextType) export default StatusContext diff --git a/src/components/Timeline/Shared/Filtered.tsx b/src/components/Timeline/Shared/Filtered.tsx index 28c582e5..0f72681d 100644 --- a/src/components/Timeline/Shared/Filtered.tsx +++ b/src/components/Timeline/Shared/Filtered.tsx @@ -1,129 +1,133 @@ import CustomText from '@components/Text' +import removeHTML from '@helpers/removeHTML' import { store } from '@root/store' 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 { useTheme } from '@utils/styles/ThemeManager' -import htmlparser2 from 'htmlparser2-without-node-native' import React from 'react' import { useTranslation } from 'react-i18next' import { View } from 'react-native' -const TimelineFiltered = React.memo( - ({ phrase }: { phrase: string }) => { - const { colors } = useTheme() - const { t } = useTranslation('componentTimeline') +export interface FilteredProps { + filterResults: { title: string; filter_action: Mastodon.Filter<'v2'>['filter_action'] }[] +} - return ( - - - {t('shared.filtered', { phrase })} - - - ) - }, - () => true -) +const TimelineFiltered: React.FC = ({ filterResults }) => { + const { colors } = useTheme() + const { t } = useTranslation('componentTimeline') -export const shouldFilter = ({ - copiableContent, - status, - queryKey -}: { - copiableContent: React.MutableRefObject<{ - content: string - complete: boolean - }> - status: Mastodon.Status - queryKey: QueryKeyTimeline -}): string | null => { - const page = queryKey[1] - const instance = getInstance(store.getState()) - const ownAccount = getInstanceAccount(store.getState())?.id === status.account?.id - - let shouldFilter: string | null = null - - if (!ownAccount) { - let rawContent = '' - const parser = new htmlparser2.Parser({ - ontext: (text: string) => { - if (!copiableContent.current.complete) { - copiableContent.current.content = copiableContent.current.content + text - } - - rawContent = rawContent + text - } - }) - if (status.spoiler_text) { - parser.write(status.spoiler_text) - rawContent = rawContent + `\n\n` + const main = () => { + if (!filterResults?.length) { + return <> } - parser.write(status.content) - parser.end() - - const checkFilter = (filter: Mastodon.Filter) => { - const escapedPhrase = filter.phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string - switch (filter.whole_word) { - case true: - if (new RegExp(`\\b${escapedPhrase}\\b`, 'i').test(rawContent)) { - shouldFilter = filter.phrase - } - break - case false: - if (new RegExp(escapedPhrase, 'i').test(rawContent)) { - shouldFilter = filter.phrase - } - break - } + 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')) + })} + + + ) } - instance?.filters?.forEach(filter => { - if (shouldFilter) { - return - } - if (filter.expires_at) { - if (new Date().getTime() > new Date(filter.expires_at).getTime()) { - return - } - } - - switch (page.page) { - case 'Following': - case 'Local': - case 'List': - case 'Account': - if (filter.context.includes('home')) { - checkFilter(filter) - } - break - case 'Notifications': - if (filter.context.includes('notifications')) { - checkFilter(filter) - } - break - case 'LocalPublic': - if (filter.context.includes('public')) { - checkFilter(filter) - } - break - case 'Toot': - if (filter.context.includes('thread')) { - checkFilter(filter) - } - } - }) - - copiableContent.current.complete = true } - return shouldFilter + return ( + + + {main()} + + + ) +} + +export const shouldFilter = ({ + queryKey, + status +}: { + queryKey: QueryKeyTimeline + status: Pick +}): FilteredProps['filterResults'] | undefined => { + const page = queryKey[1] + const instance = getInstance(store.getState()) + + let returnFilter: FilteredProps['filterResults'] | undefined + + const rawContentCombined = [ + removeHTML(status.content), + status.spoiler_text ? removeHTML(status.spoiler_text) : '' + ] + .filter(c => c.length) + .join(`\n`) + const checkFilter = (filter: Mastodon.Filter<'v1'>) => { + const escapedPhrase = filter.phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string + switch (filter.whole_word) { + case true: + if (new RegExp(`\\b${escapedPhrase}\\b`, 'i').test(rawContentCombined)) { + returnFilter = [{ title: filter.phrase, filter_action: 'warn' }] + } + break + case false: + if (new RegExp(escapedPhrase, 'i').test(rawContentCombined)) { + returnFilter = [{ title: filter.phrase, filter_action: 'warn' }] + } + break + } + } + instance?.filters?.forEach(filter => { + if (returnFilter) { + return + } + if (filter.expires_at) { + if (new Date().getTime() > new Date(filter.expires_at).getTime()) { + return + } + } + + switch (page.page) { + case 'Following': + case 'Local': + case 'List': + case 'Account': + if (filter.context.includes('home')) { + checkFilter(filter as Mastodon.Filter<'v1'>) + } + break + case 'Notifications': + if (filter.context.includes('notifications')) { + checkFilter(filter as Mastodon.Filter<'v1'>) + } + break + case 'LocalPublic': + if (filter.context.includes('public')) { + checkFilter(filter as Mastodon.Filter<'v1'>) + } + break + case 'Toot': + if (filter.context.includes('thread')) { + checkFilter(filter as Mastodon.Filter<'v1'>) + } + } + }) + + return returnFilter } export default TimelineFiltered diff --git a/src/components/Timeline/Shared/HeaderAndroid.tsx b/src/components/Timeline/Shared/HeaderAndroid.tsx index 7d67db1d..ece4ef79 100644 --- a/src/components/Timeline/Shared/HeaderAndroid.tsx +++ b/src/components/Timeline/Shared/HeaderAndroid.tsx @@ -10,7 +10,7 @@ import * as DropdownMenu from 'zeego/dropdown-menu' import StatusContext from './Context' const TimelineHeaderAndroid: React.FC = () => { - const { queryKey, rootQueryKey, status, disableDetails, disableOnPress } = + const { queryKey, rootQueryKey, status, disableDetails, disableOnPress, rawContent } = useContext(StatusContext) if (Platform.OS !== 'android' || !status || disableDetails || disableOnPress) return null @@ -21,7 +21,8 @@ const TimelineHeaderAndroid: React.FC = () => { const mShare = menuShare({ visibility: status.visibility, type: 'status', - url: status.url || status.uri + url: status.url || status.uri, + rawContent }) const mAccount = menuAccount({ type: 'status', diff --git a/src/components/Timeline/Shared/HeaderDefault.tsx b/src/components/Timeline/Shared/HeaderDefault.tsx index f4f699fa..44e28479 100644 --- a/src/components/Timeline/Shared/HeaderDefault.tsx +++ b/src/components/Timeline/Shared/HeaderDefault.tsx @@ -16,7 +16,7 @@ import HeaderSharedMuted from './HeaderShared/Muted' import HeaderSharedVisibility from './HeaderShared/Visibility' const TimelineHeaderDefault: React.FC = () => { - const { queryKey, rootQueryKey, status, copiableContent, highlighted, disableDetails } = + const { queryKey, rootQueryKey, status, highlighted, disableDetails, rawContent } = useContext(StatusContext) if (!status) return null @@ -28,7 +28,7 @@ const TimelineHeaderDefault: React.FC = () => { visibility: status.visibility, type: 'status', url: status.url || status.uri, - copiableContent + rawContent }) const mAccount = menuAccount({ type: 'status', diff --git a/src/components/Timeline/Shared/Translate.tsx b/src/components/Timeline/Shared/Translate.tsx index f8c62192..a4246612 100644 --- a/src/components/Timeline/Shared/Translate.tsx +++ b/src/components/Timeline/Shared/Translate.tsx @@ -13,38 +13,19 @@ import { Circle } from 'react-native-animated-spinkit' import StatusContext from './Context' const TimelineTranslate = () => { - const { status, highlighted, copiableContent, detectedLanguage } = useContext(StatusContext) - if (!status || !highlighted) return null + const { status, highlighted, rawContent, detectedLanguage } = useContext(StatusContext) + if (!status || !highlighted || !rawContent?.current.length) return null const { t } = useTranslation('componentTimeline') 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<{ language: string confidence: number }>({ language: status.language || '', confidence: 0 }) useEffect(() => { const detect = async () => { - const result = await detectLanguage(text.join('\n\n')) + const result = await detectLanguage(rawContent.current.join('\n\n')) if (result) { setDetected(result) if (detectedLanguage) { @@ -64,7 +45,7 @@ const TimelineTranslate = () => { const { refetch, data, isFetching, isSuccess, isError } = useTranslateQuery({ source: detected.language, target: targetLanguage, - text, + text: rawContent.current, options: { enabled } }) diff --git a/src/components/contextMenu/share.ts b/src/components/contextMenu/share.ts index 8d907040..1f6d63d6 100644 --- a/src/components/contextMenu/share.ts +++ b/src/components/contextMenu/share.ts @@ -7,10 +7,7 @@ const menuShare = ( params: | { visibility?: Mastodon.Status['visibility'] - copiableContent?: React.MutableRefObject<{ - content?: string | undefined - complete: boolean - }> + rawContent?: React.MutableRefObject type: 'status' url?: string } @@ -48,17 +45,17 @@ const menuShare = ( icon: 'square.and.arrow.up' }) } - if (params.type === 'status' && Platform.OS === 'ios') + if (params.type === 'status') menus[0].push({ key: 'copy', item: { onSelect: () => { - Clipboard.setString(params.copiableContent?.current.content || '') + Clipboard.setString(params.rawContent?.current.join(`\n\n`) || '') displayMessage({ type: 'success', message: t(`copy.succeed`) }) }, disabled: false, destructive: false, - hidden: !params.copiableContent?.current.content?.length + hidden: !params.rawContent?.current.length }, title: t('copy.action'), icon: 'doc.on.doc' diff --git a/src/helpers/features.json b/src/helpers/features.json index 28f74239..e23774ff 100644 --- a/src/helpers/features.json +++ b/src/helpers/features.json @@ -42,5 +42,9 @@ { "feature": "notification_type_admin_report", "version": 4.0 + }, + { + "feature": "filter_server_side", + "version": 4.0 } ] \ No newline at end of file diff --git a/src/helpers/removeHTML.ts b/src/helpers/removeHTML.ts index 8690061a..e8a3d4c1 100644 --- a/src/helpers/removeHTML.ts +++ b/src/helpers/removeHTML.ts @@ -6,6 +6,9 @@ const removeHTML = (text: string): string => { const parser = new htmlparser2.Parser({ ontext: (text: string) => { raw = raw + text + }, + onclosetag: (tag: string) => { + if (['p', 'br'].includes(tag)) raw = raw + `\n` } }) diff --git a/src/i18n/en/components/timeline.json b/src/i18n/en/components/timeline.json index 639c4a40..2e948368 100644 --- a/src/i18n/en/components/timeline.json +++ b/src/i18n/en/components/timeline.json @@ -91,7 +91,12 @@ "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", "translate": { "default": "Translate", diff --git a/src/screens/Actions.tsx b/src/screens/Actions.tsx index 0a8b3c3a..966b6426 100644 --- a/src/screens/Actions.tsx +++ b/src/screens/Actions.tsx @@ -24,7 +24,7 @@ const ScreenActions = ({ const insets = useSafeAreaInsets() const DEFAULT_VALUE = 350 - const screenHeight = Dimensions.get('screen').height + const screenHeight = Dimensions.get('window').height const panY = useSharedValue(DEFAULT_VALUE) useEffect(() => { panY.value = withTiming(0) diff --git a/src/screens/Announcements.tsx b/src/screens/Announcements.tsx index 01afb295..a2fe1b42 100644 --- a/src/screens/Announcements.tsx +++ b/src/screens/Announcements.tsx @@ -61,7 +61,7 @@ const ScreenAnnouncements: React.FC return ( { paddingBottom: StyleConstants.Spacing.M, marginHorizontal: StyleConstants.Spacing.Global.PagePadding, color: colors.primaryDefault, - borderBottomColor: colors.border, fontSize: adaptedFontsize, lineHeight: adaptedLineheight }} diff --git a/src/screens/ImagesViewer.tsx b/src/screens/ImagesViewer.tsx index 85a9a0b7..e6fa4227 100644 --- a/src/screens/ImagesViewer.tsx +++ b/src/screens/ImagesViewer.tsx @@ -25,7 +25,7 @@ const ZoomFlatList = createZoomListComponent(FlatList) const ScreenImagesViewer = ({ route: { - params: { imageUrls, id } + params: { imageUrls, id, hideCounter } }, navigation }: RootStackScreenProps<'Screen-ImagesViewer'>) => { @@ -34,8 +34,8 @@ const ScreenImagesViewer = ({ return null } - const SCREEN_WIDTH = Dimensions.get('screen').width - const SCREEN_HEIGHT = Dimensions.get('screen').height + const WINDOW_WIDTH = Dimensions.get('window').width + const WINDOW_HEIGHT = Dimensions.get('window').height const insets = useSafeAreaInsets() @@ -85,13 +85,13 @@ const ScreenImagesViewer = ({ }: { 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 imageWidth = item.width || 100 const imageHeight = item.height || 100 - const maxWidthScale = item.width ? (item.width / SCREEN_WIDTH / PixelRatio.get()) * 4 : 0 - const maxHeightScale = item.height ? (item.height / SCREEN_WIDTH / PixelRatio.get()) * 4 : 0 + const maxWidthScale = item.width ? (item.width / WINDOW_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]) return ( @@ -109,8 +109,8 @@ const ScreenImagesViewer = ({ children={ imageRatio - ? (SCREEN_HEIGHT / imageHeight) * imageWidth - : SCREEN_WIDTH, + ? (WINDOW_HEIGHT / imageHeight) * imageWidth + : WINDOW_WIDTH, height: screenRatio > imageRatio - ? SCREEN_HEIGHT - : (SCREEN_WIDTH / imageWidth) * imageHeight + ? WINDOW_HEIGHT + : (WINDOW_WIDTH / imageWidth) * imageHeight }} /> @@ -159,7 +159,9 @@ const ScreenImagesViewer = ({ }} > navigation.goBack()} /> - + {!hideCounter ? ( + + ) : null} ({ - length: SCREEN_WIDTH, - offset: SCREEN_WIDTH * index, + length: WINDOW_WIDTH, + offset: WINDOW_WIDTH * index, index })} /> diff --git a/src/screens/Tabs/Me/Push.tsx b/src/screens/Tabs/Me/Push.tsx index 04c56cac..2cdc4622 100644 --- a/src/screens/Tabs/Me/Push.tsx +++ b/src/screens/Tabs/Me/Push.tsx @@ -46,18 +46,14 @@ const TabMePush: React.FC = () => { useEffect(() => { const checkPush = async () => { - switch (Platform.OS) { - case 'ios': - const settings = await Notifications.getPermissionsAsync() - layoutAnimation() - setPushEnabled(settings.granted) - setPushCanAskAgain(settings.canAskAgain) - break - case 'android': - await setChannels(instance) - layoutAnimation() - dispatch(retrieveExpoToken()) - break + const permissions = await Notifications.getPermissionsAsync() + setPushEnabled(permissions.granted) + setPushCanAskAgain(permissions.canAskAgain) + layoutAnimation() + + if (Platform.OS === 'android') { + await setChannels(instance) + dispatch(retrieveExpoToken()) } } diff --git a/src/screens/Tabs/Me/SettingsFontsize.tsx b/src/screens/Tabs/Me/SettingsFontsize.tsx index 43c6e046..5f91cdeb 100644 --- a/src/screens/Tabs/Me/SettingsFontsize.tsx +++ b/src/screens/Tabs/Me/SettingsFontsize.tsx @@ -5,11 +5,7 @@ import CustomText from '@components/Text' import TimelineDefault from '@components/Timeline/Default' import { useAppDispatch } from '@root/store' import { TabMeStackScreenProps } from '@utils/navigation/navigators' -import { - changeFontsize, - getSettingsFontsize, - SettingsState -} from '@utils/slices/settingsSlice' +import { changeFontsize, getSettingsFontsize, SettingsState } from '@utils/slices/settingsSlice' import { StyleConstants } from '@utils/styles/constants' import { adaptiveScale } from '@utils/styles/scaling' import { useTheme } from '@utils/styles/ThemeManager' @@ -34,9 +30,7 @@ export const mapFontsizeToName = (size: SettingsState['fontsize']) => { } } -const TabMeSettingsFontsize: React.FC< - TabMeStackScreenProps<'Tab-Me-Settings-Fontsize'> -> = () => { +const TabMeSettingsFontsize: React.FC> = () => { const { colors, theme } = useTheme() const { t } = useTranslation('screenTabs') const initialSize = useSelector(getSettingsFontsize) @@ -86,8 +80,7 @@ const TabMeSettingsFontsize: React.FC< marginBottom: StyleConstants.Spacing.M, fontSize: adaptiveScale(StyleConstants.Font.Size.M, size), lineHeight: adaptiveScale(StyleConstants.Font.LineHeight.M, size), - color: - initialSize === size ? colors.primaryDefault : colors.secondary, + color: initialSize === size ? colors.primaryDefault : colors.secondary, borderWidth: StyleSheet.hairlineWidth, borderColor: colors.border }} diff --git a/src/screens/Tabs/Public/Root.tsx b/src/screens/Tabs/Public/Root.tsx index c1c07284..aaf0153f 100644 --- a/src/screens/Tabs/Public/Root.tsx +++ b/src/screens/Tabs/Public/Root.tsx @@ -88,7 +88,7 @@ const Root: React.FC null} onIndexChange={index => setSegment(index)} navigationState={{ index: segment, routes }} - initialLayout={{ width: Dimensions.get('screen').width }} + initialLayout={{ width: Dimensions.get('window').width }} /> ) } diff --git a/src/screens/Tabs/Shared/Account.tsx b/src/screens/Tabs/Shared/Account.tsx index c2becf37..0354e5c6 100644 --- a/src/screens/Tabs/Shared/Account.tsx +++ b/src/screens/Tabs/Shared/Account.tsx @@ -4,16 +4,16 @@ import { HeaderLeft, HeaderRight } from '@components/Header' import Timeline from '@components/Timeline' import TimelineDefault from '@components/Timeline/Default' import SegmentedControl from '@react-native-community/segmented-control' +import { useQueryClient } from '@tanstack/react-query' import { TabSharedStackScreenProps } from '@utils/navigation/navigators' import { useAccountQuery } from '@utils/queryHooks/account' import { QueryKeyTimeline } from '@utils/queryHooks/timeline' import { StyleConstants } from '@utils/styles/constants' 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 { Text, View } from 'react-native' import { useSharedValue } from 'react-native-reanimated' -import { useIsFetching } from '@tanstack/react-query' import * as DropdownMenu from 'zeego/dropdown-menu' import AccountAttachments from './Account/Attachments' import AccountHeader from './Account/Header' @@ -87,35 +87,29 @@ const TabSharedAccount: React.FC const scrollY = useSharedValue(0) + const queryClient = useQueryClient() const [queryKey, setQueryKey] = useState([ 'Timeline', { page: 'Account', account: account.id, exclude_reblogs: true, only_media: false } ]) 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(0) const ListHeaderComponent = useMemo(() => { return ( <> - {!data?.suspended && fetchedTimeline.current ? ( - - ) : null} + {!data?.suspended ? : null} {!data?.suspended ? ( { + setSegment(nativeEvent.selectedSegmentIndex) switch (nativeEvent.selectedSegmentIndex) { case 0: setQueryKey([ @@ -171,7 +165,7 @@ const TabSharedAccount: React.FC ) : null} ) - }, [data, fetchedTimeline.current, queryKey[1].page, mode]) + }, [segment, data, queryKey[1].page, mode]) return ( <> @@ -187,7 +181,9 @@ const TabSharedAccount: React.FC renderItem: ({ item }) => , onScroll: ({ nativeEvent }) => (scrollY.value = nativeEvent.contentOffset.y), ListHeaderComponent, - maintainVisibleContentPosition: undefined + maintainVisibleContentPosition: undefined, + onRefresh: () => queryClient.refetchQueries(queryKey), + refreshing: queryClient.getQueryState(queryKey)?.fetchStatus === 'fetching' }} /> )} diff --git a/src/screens/Tabs/Shared/Account/Attachments.tsx b/src/screens/Tabs/Shared/Account/Attachments.tsx index 64a98782..11f1c1d4 100644 --- a/src/screens/Tabs/Shared/Account/Attachments.tsx +++ b/src/screens/Tabs/Shared/Account/Attachments.tsx @@ -3,10 +3,10 @@ import Icon from '@components/Icon' import { useNavigation } from '@react-navigation/native' import { StackNavigationProp } from '@react-navigation/stack' 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 { 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 { FlatList } from 'react-native-gesture-handler' import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated' @@ -23,23 +23,14 @@ const AccountAttachments: React.FC = ({ account }) => { 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', account: account.id, exclude_reblogs: false, only_media: true - } - const { data, refetch } = useTimelineQuery({ - ...queryKeyParams, - options: { enabled: false } }) - useEffect(() => { - if (account?.id) { - refetch() - } - }, [account]) const flattenData = data?.pages ? data.pages diff --git a/src/screens/Tabs/Shared/Account/Header.tsx b/src/screens/Tabs/Shared/Account/Header.tsx index 5a4d9a6e..036ad855 100644 --- a/src/screens/Tabs/Shared/Account/Header.tsx +++ b/src/screens/Tabs/Shared/Account/Header.tsx @@ -1,47 +1,45 @@ -import Button from '@components/Button' import GracefullyImage from '@components/GracefullyImage' +import navigationRef from '@helpers/navigationRef' +import { getInstanceActive } from '@utils/slices/instancesSlice' import { useTheme } from '@utils/styles/ThemeManager' 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 { useSelector } from 'react-redux' export interface Props { account?: Mastodon.Account - edit?: boolean } -const AccountHeader = React.memo( - ({ account, edit }: Props) => { - const { colors } = useTheme() - const topInset = useSafeAreaInsets().top +const AccountHeader: React.FC = ({ account }) => { + const { colors } = useTheme() + const topInset = useSafeAreaInsets().top - return ( - - - {edit ? ( - -