Merge pull request #645 from tooot-app/main

Release v4.8.0
This commit is contained in:
xmflsct 2023-01-09 22:55:52 +01:00 committed by GitHub
commit 36e61e9f95
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
453 changed files with 23312 additions and 23705 deletions

View File

@ -9,22 +9,18 @@ jobs:
build-ios: build-ios:
runs-on: macos-12 runs-on: macos-12
steps: steps:
- name: -- Step 0 -- Extract branch name - uses: tj-actions/branch-names@v6
uses: tj-actions/branch-names@v6
id: branch id: branch
- name: -- Step 1 -- Checkout code - uses: actions/checkout@v3
uses: actions/checkout@v3 - uses: actions/setup-node@v3
- name: -- Step 2 -- Setup node
uses: actions/setup-node@v3
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
- name: -- Step 3 -- Install node dependencies - run: corepack enable
run: yarn install - run: yarn install
- name: -- Step 4 -- Install ruby dependencies - run: bundle install
run: bundle install - run: yarn app:build ios
- name: -- Step 5 -- Run fastlane
env: env:
DEVELOPER_DIR: /Applications/Xcode_14.1.app/Contents/Developer DEVELOPER_DIR: /Applications/Xcode_14.2.app/Contents/Developer
ENVIRONMENT: ${{ steps.branch.outputs.current_branch }} ENVIRONMENT: ${{ steps.branch.outputs.current_branch }}
SENTRY_ENVIRONMENT: ${{ steps.branch.outputs.current_branch }} SENTRY_ENVIRONMENT: ${{ steps.branch.outputs.current_branch }}
LC_ALL: en_US.UTF-8 LC_ALL: en_US.UTF-8
@ -40,30 +36,24 @@ jobs:
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }} APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }}
GH_PAT_GET_RELEASE: ${{ secrets.GITHUB_TOKEN }} GH_PAT_GET_RELEASE: ${{ secrets.GITHUB_TOKEN }}
run: yarn app:build ios
build-android: build-android:
runs-on: macos-12 runs-on: macos-12
steps: steps:
- name: -- Step 0 -- Extract branch name - uses: tj-actions/branch-names@v6
uses: tj-actions/branch-names@v6
id: branch id: branch
- name: -- Step 1 -- Checkout code - uses: actions/checkout@v3
uses: actions/checkout@v3 - uses: actions/setup-node@v3
- name: -- Step 2 -- Setup node
uses: actions/setup-node@v3
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
- name: -- Step 3 -- Setup Java - uses: actions/setup-java@v3
uses: actions/setup-java@v3
with: with:
distribution: 'zulu' distribution: 'zulu'
java-version: '11' java-version: '11'
- name: -- Step 4 -- Install node dependencies - run: corepack enable
run: yarn install - run: yarn install
- name: -- Step 5 -- Install ruby dependencies - run: bundle install
run: bundle install - run: yarn app:build android
- name: -- Step 6 -- Run fastlane
env: env:
ENVIRONMENT: ${{ steps.branch.outputs.current_branch }} ENVIRONMENT: ${{ steps.branch.outputs.current_branch }}
SENTRY_ENVIRONMENT: ${{ steps.branch.outputs.current_branch }} SENTRY_ENVIRONMENT: ${{ steps.branch.outputs.current_branch }}
@ -75,31 +65,25 @@ jobs:
ANDROID_KEYSTORE_KEY_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_KEY_PASSWORD }} ANDROID_KEYSTORE_KEY_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_KEY_PASSWORD }}
SUPPLY_JSON_KEY_DATA: ${{ secrets.SUPPLY_JSON_KEY_DATA }} SUPPLY_JSON_KEY_DATA: ${{ secrets.SUPPLY_JSON_KEY_DATA }}
GH_PAT_GET_RELEASE: ${{ secrets.GITHUB_TOKEN }} GH_PAT_GET_RELEASE: ${{ secrets.GITHUB_TOKEN }}
run: yarn app:build android
create-release: create-release:
runs-on: macos-12 runs-on: macos-12
needs: [build-ios, build-android] needs: [build-ios, build-android]
steps: steps:
- name: -- Step 0 -- Extract branch name - uses: tj-actions/branch-names@v6
uses: tj-actions/branch-names@v6
id: branch id: branch
- name: -- Step 1 -- Checkout code - uses: actions/checkout@v3
uses: actions/checkout@v3 - uses: actions/setup-node@v3
- name: -- Step 2 -- Setup node
uses: actions/setup-node@v3
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
- name: -- Step 3 -- Setup Java - uses: actions/setup-java@v3
uses: actions/setup-java@v3
with: with:
distribution: 'zulu' distribution: 'zulu'
java-version: '11' java-version: '11'
- name: -- Step 4 -- Install node dependencies - run: corepack enable
run: yarn install - run: yarn install
- name: -- Step 5 -- Install ruby dependencies - run: bundle install
run: bundle install - run: yarn app:build release
- name: -- Step 6 -- Run fastlane
env: env:
ENVIRONMENT: ${{ steps.branch.outputs.current_branch }} ENVIRONMENT: ${{ steps.branch.outputs.current_branch }}
SENTRY_ENVIRONMENT: ${{ steps.branch.outputs.current_branch }} SENTRY_ENVIRONMENT: ${{ steps.branch.outputs.current_branch }}
@ -113,4 +97,3 @@ jobs:
ANDROID_KEYSTORE_ALIAS: ${{ secrets.ANDROID_KEYSTORE_ALIAS }} ANDROID_KEYSTORE_ALIAS: ${{ secrets.ANDROID_KEYSTORE_ALIAS }}
ANDROID_KEYSTORE_KEY_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_KEY_PASSWORD }} ANDROID_KEYSTORE_KEY_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_KEY_PASSWORD }}
FL_GITHUB_RELEASE_API_BEARER: ${{ secrets.GITHUB_TOKEN }} FL_GITHUB_RELEASE_API_BEARER: ${{ secrets.GITHUB_TOKEN }}
run: yarn app:build release

11
.gitignore vendored
View File

@ -66,4 +66,13 @@ buck-out/
web-build/ web-build/
dist/ dist/
# @end expo-cli # @end expo-cli
# yarn 3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

View File

@ -1,7 +1,7 @@
diff --git a/node_modules/@types/react-native-share-menu/index.d.ts b/node_modules/@types/react-native-share-menu/index.d.ts diff --git a/index.d.ts b/index.d.ts
index f52822c..ee98565 100755 index f52822c8bed928f387baf90fdb7342c7416a775a..6d9d480d18342832c4b07af2b10f4a63ff538e7b 100755
--- a/node_modules/@types/react-native-share-menu/index.d.ts --- a/index.d.ts
+++ b/node_modules/@types/react-native-share-menu/index.d.ts +++ b/index.d.ts
@@ -5,11 +5,9 @@ @@ -5,11 +5,9 @@
// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped // Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
// Minimum TypeScript Version: 3.7 // Minimum TypeScript Version: 3.7
@ -17,12 +17,18 @@ index f52822c..ee98565 100755
export type ShareCallback = (share?: ShareData) => void; export type ShareCallback = (share?: ShareData) => void;
@@ -28,7 +26,7 @@ interface ShareMenuReactView { @@ -25,10 +23,10 @@ interface ShareMenu {
dismissExtension(error?: string): void; }
openApp(): void;
continueInApp(extraData?: object): void; interface ShareMenuReactView {
- dismissExtension(error?: string): void;
- openApp(): void;
- continueInApp(extraData?: object): void;
- data(): Promise<{mimeType: string, data: string}>; - data(): Promise<{mimeType: string, data: string}>;
+ data(): Promise<{data: {mimeType: string; data: string}[]}>; + dismissExtension(error?: string): void
+ openApp(): void
+ continueInApp(extraData?: object): void
+ data(): Promise<{ data: { mimeType: string; data: string }[] }>
} }
export const ShareMenuReactView: ShareMenuReactView; export const ShareMenuReactView: ShareMenuReactView;

View File

@ -0,0 +1,13 @@
diff --git a/ios/EXAV/EXAudioSessionManager.m b/ios/EXAV/EXAudioSessionManager.m
index 81dce13366c3947b12c863f7b39c0237882a6c36..fa27e0a354d48a994ca46e19642a5e224d42d9a8 100644
--- a/ios/EXAV/EXAudioSessionManager.m
+++ b/ios/EXAV/EXAudioSessionManager.m
@@ -170,7 +170,7 @@ - (void)moduleDidBackground:(id)backgroundingModule
[_foregroundedModules compact];
// Any possible failures are silent
- [self _updateSessionConfiguration];
+ // [self _updateSessionConfiguration];
}
- (void)moduleDidForeground:(id)module

View File

@ -0,0 +1,23 @@
diff --git a/RNFastImage.podspec b/RNFastImage.podspec
index db0fada63fc06191f8620d336d244edde6c3dba3..b6ffe6c77ab1fd5b821525f6f0b7363a13cba3a0 100644
--- a/RNFastImage.podspec
+++ b/RNFastImage.podspec
@@ -16,6 +16,6 @@ Pod::Spec.new do |s|
s.source_files = "ios/**/*.{h,m}"
s.dependency 'React-Core'
- s.dependency 'SDWebImage', '~> 5.11.1'
- s.dependency 'SDWebImageWebPCoder', '~> 0.8.4'
+ s.dependency 'SDWebImage', '~> 5.14.3'
+ s.dependency 'SDWebImageWebPCoder', '~> 0.9.1'
end
diff --git a/android/build.gradle b/android/build.gradle
index 5b21cd59c40a5754f5d19c77e2a0eb0229925911..19d82f826e88125c5e6d87ee7c348fac621f548c 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -65,4 +65,5 @@ dependencies {
implementation "com.github.bumptech.glide:glide:${glideVersion}"
implementation "com.github.bumptech.glide:okhttp3-integration:${glideVersion}"
annotationProcessor "com.github.bumptech.glide:compiler:${glideVersion}"
+ implementation 'com.github.penfeizhou.android.animation:glide-plugin:2.12.0'
}

View File

@ -1,7 +1,7 @@
diff --git a/node_modules/react-native-share-menu/android/build.gradle b/node_modules/react-native-share-menu/android/build.gradle diff --git a/android/build.gradle b/android/build.gradle
index 9557fdb..ebdeb6f 100644 index 9557fdbf2fbf97b7f7aeaf7ce86d301a8ced213d..ebdeb6f4de7846d3241101001755595c52a4b05e 100644
--- a/node_modules/react-native-share-menu/android/build.gradle --- a/android/build.gradle
+++ b/node_modules/react-native-share-menu/android/build.gradle +++ b/android/build.gradle
@@ -1,12 +1,12 @@ @@ -1,12 +1,12 @@
apply plugin: 'com.android.library' apply plugin: 'com.android.library'
@ -19,10 +19,10 @@ index 9557fdb..ebdeb6f 100644
versionCode 1 versionCode 1
versionName "1.0" versionName "1.0"
ndk { ndk {
diff --git a/node_modules/react-native-share-menu/ios/ReactShareViewController.swift b/node_modules/react-native-share-menu/ios/ReactShareViewController.swift diff --git a/ios/ReactShareViewController.swift b/ios/ReactShareViewController.swift
index f42bce6..ee36062 100644 index f42bce6ce7e3f48a7ddc83f3366b68fd0664b1a0..ee360622b1d03cc9661c78c6f210b84c3b19a725 100644
--- a/node_modules/react-native-share-menu/ios/ReactShareViewController.swift --- a/ios/ReactShareViewController.swift
+++ b/node_modules/react-native-share-menu/ios/ReactShareViewController.swift +++ b/ios/ReactShareViewController.swift
@@ -13,7 +13,7 @@ class ReactShareViewController: ShareViewController, RCTBridgeDelegate, ReactSha @@ -13,7 +13,7 @@ class ReactShareViewController: ShareViewController, RCTBridgeDelegate, ReactSha
func sourceURL(for bridge: RCTBridge!) -> URL! { func sourceURL(for bridge: RCTBridge!) -> URL! {
#if DEBUG #if DEBUG
@ -32,10 +32,10 @@ index f42bce6..ee36062 100644
#else #else
return Bundle.main.url(forResource: "main", withExtension: "jsbundle") return Bundle.main.url(forResource: "main", withExtension: "jsbundle")
#endif #endif
diff --git a/node_modules/react-native-share-menu/ios/ShareViewController.swift b/node_modules/react-native-share-menu/ios/ShareViewController.swift diff --git a/ios/ShareViewController.swift b/ios/ShareViewController.swift
index 12d8c92..64aa72b 100644 index 12d8c92dda20fabd9e7b55fec57b3d867414063c..8a1db0de285b18a9358a10b2ca8293a8c7d56a8e 100644
--- a/node_modules/react-native-share-menu/ios/ShareViewController.swift --- a/ios/ShareViewController.swift
+++ b/node_modules/react-native-share-menu/ios/ShareViewController.swift +++ b/ios/ShareViewController.swift
@@ -19,8 +19,8 @@ class ShareViewController: SLComposeServiceViewController { @@ -19,8 +19,8 @@ class ShareViewController: SLComposeServiceViewController {
var hostAppUrlScheme: String? var hostAppUrlScheme: String?
var sharedItems: [Any] = [] var sharedItems: [Any] = []
@ -78,7 +78,7 @@ index 12d8c92..64aa72b 100644
override func configurationItems() -> [Any]! { override func configurationItems() -> [Any]! {
// To add configuration options via table cells at the bottom of the sheet, return an array of SLComposeSheetConfigurationItem here. // To add configuration options via table cells at the bottom of the sheet, return an array of SLComposeSheetConfigurationItem here.
return [] return []
@@ -238,11 +235,10 @@ class ShareViewController: SLComposeServiceViewController { @@ -238,7 +235,7 @@ class ShareViewController: SLComposeServiceViewController {
func completeRequest() { func completeRequest() {
// Inform the host that we're done, so it un-blocks its UI. Note: Alternatively you could call super's -didSelectPost, which will similarly complete the extension context. // Inform the host that we're done, so it un-blocks its UI. Note: Alternatively you could call super's -didSelectPost, which will similarly complete the extension context.
@ -87,7 +87,3 @@ index 12d8c92..64aa72b 100644
} }
func cancelRequest() { func cancelRequest() {
extensionContext!.cancelRequest(withError: NSError())
}
-
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

7
.yarnrc.yml Normal file
View File

@ -0,0 +1,7 @@
nodeLinker: node-modules
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-typescript.cjs
spec: "@yarnpkg/plugin-typescript"
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
spec: "@yarnpkg/plugin-interactive-tools"

View File

@ -17,16 +17,16 @@ GEM
artifactory (3.0.15) artifactory (3.0.15)
atomos (0.1.3) atomos (0.1.3)
aws-eventstream (1.2.0) aws-eventstream (1.2.0)
aws-partitions (1.653.0) aws-partitions (1.687.0)
aws-sdk-core (3.166.0) aws-sdk-core (3.168.4)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0) aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
jmespath (~> 1, >= 1.6.1) jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.59.0) aws-sdk-kms (1.61.0)
aws-sdk-core (~> 3, >= 3.165.0) aws-sdk-core (~> 3, >= 3.165.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.117.1) aws-sdk-s3 (1.117.2)
aws-sdk-core (~> 3, >= 3.165.0) aws-sdk-core (~> 3, >= 3.165.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4) aws-sigv4 (~> 1.4)
@ -86,7 +86,7 @@ GEM
escape (0.0.4) escape (0.0.4)
ethon (0.15.0) ethon (0.15.0)
ffi (>= 1.15.0) ffi (>= 1.15.0)
excon (0.93.1) excon (0.95.0)
faraday (1.10.2) faraday (1.10.2)
faraday-em_http (~> 1.0) faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0) faraday-em_synchrony (~> 1.0)
@ -116,7 +116,7 @@ GEM
faraday_middleware (1.2.0) faraday_middleware (1.2.0)
faraday (~> 1.0) faraday (~> 1.0)
fastimage (2.2.6) fastimage (2.2.6)
fastlane (2.210.1) fastlane (2.211.0)
CFPropertyList (>= 2.3, < 4.0.0) CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0) addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0) artifactory (~> 3.0)
@ -156,7 +156,7 @@ GEM
xcpretty (~> 0.3.0) xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3) xcpretty-travis-formatter (>= 0.0.3)
fastlane-plugin-json (1.1.0) fastlane-plugin-json (1.1.0)
fastlane-plugin-sentry (1.14.0) fastlane-plugin-sentry (1.15.0)
os (~> 1.1, >= 1.1.4) os (~> 1.1, >= 1.1.4)
fastlane-plugin-versioning_android (0.1.0) fastlane-plugin-versioning_android (0.1.0)
fastlane-plugin-yarn (1.2) fastlane-plugin-yarn (1.2)
@ -164,9 +164,9 @@ GEM
fourflusher (2.3.1) fourflusher (2.3.1)
fuzzy_match (2.0.4) fuzzy_match (2.0.4)
gh_inspector (1.1.3) gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.29.0) google-apis-androidpublisher_v3 (0.32.0)
google-apis-core (>= 0.9.0, < 2.a) google-apis-core (>= 0.9.1, < 2.a)
google-apis-core (0.9.1) google-apis-core (0.9.2)
addressable (~> 2.5, >= 2.5.1) addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a) googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a) httpclient (>= 2.8.1, < 3.a)
@ -175,8 +175,8 @@ GEM
retriable (>= 2.0, < 4.a) retriable (>= 2.0, < 4.a)
rexml rexml
webrick webrick
google-apis-iamcredentials_v1 (0.15.0) google-apis-iamcredentials_v1 (0.16.0)
google-apis-core (>= 0.9.0, < 2.a) google-apis-core (>= 0.9.1, < 2.a)
google-apis-playcustomapp_v1 (0.12.0) google-apis-playcustomapp_v1 (0.12.0)
google-apis-core (>= 0.9.1, < 2.a) google-apis-core (>= 0.9.1, < 2.a)
google-apis-storage_v1 (0.19.0) google-apis-storage_v1 (0.19.0)
@ -187,7 +187,7 @@ GEM
google-cloud-env (1.6.0) google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0) faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.0) google-cloud-errors (1.3.0)
google-cloud-storage (1.43.0) google-cloud-storage (1.44.0)
addressable (~> 2.8) addressable (~> 2.8)
digest-crc (~> 0.4) digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1) google-apis-iamcredentials_v1 (~> 0.1)
@ -208,11 +208,11 @@ GEM
httpclient (2.8.3) httpclient (2.8.3)
i18n (1.12.0) i18n (1.12.0)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
jmespath (1.6.1) jmespath (1.6.2)
json (2.6.2) json (2.6.3)
jwt (2.5.0) jwt (2.6.0)
memoist (0.16.2) memoist (0.16.2)
mini_magick (4.11.0) mini_magick (4.12.0)
mini_mime (1.1.2) mini_mime (1.1.2)
minitest (5.16.3) minitest (5.16.3)
molinillo (0.8.0) molinillo (0.8.0)

View File

@ -1,8 +1,8 @@
# [tooot](https://tooot.app/) app for Mastodon # [tooot](https://tooot.app/) app for Mastodon compatible platforms
[![GPL-3.0](https://img.shields.io/github/license/tooot-app/push)](LICENSE) ![GitHub issues](https://img.shields.io/github/issues/tooot-app/app) ![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/tooot-app/app?include_prereleases) [![Crowdin](https://badges.crowdin.net/tooot/localized.svg)](https://crowdin.tooot.app/project/tooot) [![GPL-3.0](https://img.shields.io/github/license/tooot-app/push)](LICENSE) ![GitHub issues](https://img.shields.io/github/issues/tooot-app/app) ![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/tooot-app/app?include_prereleases) [![Crowdin](https://badges.crowdin.net/tooot/localized.svg)](https://crowdin.tooot.app/project/tooot)
![GitHub Workflow Status (candidate)](https://img.shields.io/github/workflow/status/tooot-app/app/build/candidate?label=build%20candidate) ![GitHub Workflow Status (release)](https://img.shields.io/github/workflow/status/tooot-app/app/build/release?label=build%20release) ![GitHub Workflow Status (candidate)](https://img.shields.io/github/actions/workflow/status/tooot-app/app/build.yml?branch=candidate&label=build%20candidate) ![GitHub Workflow Status (release)](https://img.shields.io/github/actions/workflow/status/tooot-app/app/build.yml?branch=release&label=build%20release)
## Contribute to translation ## Contribute to translation
@ -11,28 +11,16 @@ Please **do not** create a pull request to update translation. tooot's translati
## Special thanks ## Special thanks
[@amrtf](https://crowdin.com/profile/amrtf) for Catalan and Spanish translation - [@amrtf](https://crowdin.com/profile/amrtf) for Catalan and Spanish translation
- [@forenta](https://github.com/forenta) for German translation
[@forenta](https://github.com/forenta) for German translation - [@pat](https://piaille.fr/@pat) for French translation
- [@andrigamerita](https://github.com/andrigamerita) for Italian translation
[@pat](https://piaille.fr/@pat) for French translation - [@Hikaru](https://github.com/Hikali-47041) and [@la_la](https://mstdn.jp/@la_la_la) for Japanese translation
- [@hellojaccc](https://github.com/hellojaccc) for Korean translation
[@andrigamerita](https://github.com/andrigamerita) for Italian translation - [@jan-vandenberg](https://crowdin.com/profile/jan-vandenberg) for Dutch translation
- [@luizpicolo](https://github.com/luizpicolo) for Brazilian Portuguese
[@Hikaru](https://github.com/Hikali-47041) and [@la_la](https://mstdn.jp/@la_la_la) for Japanese translation - [@janlindblom](https://github.com/janlindblom) for Swedish
- [@ihoryan](https://crowdin.com/profile/ihoryan) for Ukrainian
[@hellojaccc](https://github.com/hellojaccc) for Korean translation - [@duy@mas.to](https://mas.to/@duy) for Vietnamese translation
- [@jimmyorz](https://github.com/jimmyorz) for Traditional Chinese translation
[@jan-vandenberg](https://crowdin.com/profile/jan-vandenberg) for Dutch translation - [@jk@mastodon.social](https://mastodon.social/@jk) for the famous Mastodon boop sound
[@luizpicolo](https://github.com/luizpicolo) for Brazilian Portuguese
[@janlindblom](https://github.com/janlindblom) for Swedish
[@ihoryan](https://crowdin.com/profile/ihoryan) for Ukrainian
[@duy@mas.to](https://mas.to/@duy) for Vietnamese translation
[@jimmyorz](https://github.com/jimmyorz) for Traditional Chinese translation
[@jk@mastodon.social](https://mastodon.social/@jk) for the famous Mastodon boop sound

View File

@ -1,4 +1,5 @@
apply plugin: "com.android.application" apply plugin: "com.android.application"
apply plugin: 'com.google.gms.google-services'
import com.android.build.OutputFile import com.android.build.OutputFile
import org.apache.tools.ant.taskdefs.condition.Os import org.apache.tools.ant.taskdefs.condition.Os
@ -231,6 +232,7 @@ android {
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
} }
} }
namespace 'com.xmflsct.app.tooot'
// applicationVariants are e.g. debug, release // applicationVariants are e.g. debug, release
applicationVariants.all { variant -> applicationVariants.all { variant ->

View File

@ -0,0 +1,55 @@
{
"project_info": {
"project_number": "661638997772",
"project_id": "xmflsct-mastodon-app",
"storage_bucket": "xmflsct-mastodon-app.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:661638997772:android:4fd02851f757f8fa9f8b29",
"android_client_info": {
"package_name": "com.xmflsct.app.tooot"
}
},
"oauth_client": [
{
"client_id": "661638997772-erabggnp958v10r0tvsrh3pg880qnvqn.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "com.xmflsct.app.tooot",
"certificate_hash": "53162f104230ee8b7b1372e4f378e2b9607ca16f"
}
},
{
"client_id": "661638997772-6aiqk97aema0rt280i7nfar3ha2mlgno.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyDUw4s-mhQsHvs4hdIsldsi68ZIygM5MC4"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "661638997772-6aiqk97aema0rt280i7nfar3ha2mlgno.apps.googleusercontent.com",
"client_type": 3
},
{
"client_id": "661638997772-65g8ce369ugck3ii4ulk6jhb3ijg51kl.apps.googleusercontent.com",
"client_type": 2,
"ios_info": {
"bundle_id": "com.xmflsct.app.tooot",
"app_store_id": "1549772269"
}
}
]
}
}
}
],
"configuration_version": "1"
}

View File

@ -1,5 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" package="com.xmflsct.app.tooot"> xmlns:tools="http://schemas.android.com/tools"
package="com.xmflsct.app.tooot">
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.READ_PHONE_STATE"/> <uses-permission android:name="android.permission.READ_PHONE_STATE"/>
@ -9,6 +10,7 @@
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:requestLegacyExternalStorage="true"> <application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:requestLegacyExternalStorage="true">
<!-- [Custom] Expo Notifications --> <!-- [Custom] Expo Notifications -->
<meta-data android:name="expo.modules.notifications.default_notification_icon" android:resource="@drawable/ic_stat_notifications" /> <meta-data android:name="expo.modules.notifications.default_notification_icon" android:resource="@drawable/ic_stat_notifications" />

View File

@ -22,9 +22,10 @@ buildscript {
jcenter() jcenter()
} }
dependencies { dependencies {
classpath("com.android.tools.build:gradle:7.2.1") classpath('com.android.tools.build:gradle:7.2.2')
classpath("com.facebook.react:react-native-gradle-plugin") classpath("com.facebook.react:react-native-gradle-plugin")
classpath("de.undercouch:gradle-download-task:5.0.1") classpath("de.undercouch:gradle-download-task:5.0.1")
classpath 'com.google.gms:google-services:4.3.14'
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files // in the individual module build.gradle files

View File

@ -26,7 +26,7 @@ android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true
# Version of flipper SDK to use with React Native # Version of flipper SDK to use with React Native
FLIPPER_VERSION=0.125.0 FLIPPER_VERSION=0.176.1
# Use this property to specify which architecture you want to build. # Use this property to specify which architecture you want to build.
# You can also override it from the CLI using # You can also override it from the CLI using

View File

@ -15,8 +15,9 @@ export default (): ExpoConfig => ({
}, },
android: { android: {
package: 'com.xmflsct.app.tooot', package: 'com.xmflsct.app.tooot',
permissions: ['CAMERA', 'VIBRATE'], permissions: ['NOTIFICATIONS', 'CAMERA', 'VIBRATE'],
blockedPermissions: ['USE_BIOMETRIC', 'USE_FINGERPRINT'] blockedPermissions: ['USE_BIOMETRIC', 'USE_FINGERPRINT'],
googleServicesFile: './android/app/google-services.json'
}, },
plugins: [ plugins: [
[ [

View File

@ -1,5 +1,5 @@
module.exports = function (api) { module.exports = function (api) {
api.cache(true) api.cache(false)
const plugins = [ const plugins = [
'@babel/plugin-proposal-optional-chaining', '@babel/plugin-proposal-optional-chaining',
@ -8,11 +8,8 @@ module.exports = function (api) {
{ {
root: ['./'], root: ['./'],
alias: { alias: {
'@assets': './assets',
'@root': './src',
'@api': './src/api',
'@helpers': './src/helpers',
'@components': './src/components', '@components': './src/components',
'@i18n': './src/i18n',
'@screens': './src/screens', '@screens': './src/screens',
'@utils': './src/utils' '@utils': './src/utils'
} }
@ -21,27 +18,9 @@ module.exports = function (api) {
'react-native-reanimated/plugin' 'react-native-reanimated/plugin'
] ]
if ( if (process.env.NODE_ENV === 'production' || process.env.BABEL_ENV === 'production') {
process.env.NODE_ENV === 'production' ||
process.env.BABEL_ENV === 'production'
) {
plugins.push('transform-remove-console') plugins.push('transform-remove-console')
} }
return { return { presets: ['babel-preset-expo'], plugins }
presets: [
'babel-preset-expo',
[
'@babel/preset-react',
{
importSource: '@welldone-software/why-did-you-render',
runtime: 'automatic',
development:
process.env.NODE_ENV === 'development' ||
process.env.BABEL_ENV === 'development'
}
]
],
plugins
}
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

View File

@ -36,7 +36,7 @@ private_lane :build_ios do
export_method: "app-store", export_method: "app-store",
include_symbols: true, include_symbols: true,
output_directory: BUILD_DIRECTORY, output_directory: BUILD_DIRECTORY,
silent: false silent: true
) )
case ENVIRONMENT case ENVIRONMENT

View File

@ -1,3 +1,9 @@
Enjoy toooting! This version includes following improvements and fixes: Enjoy toooting! This version includes following improvements and fixes:
- Fixed wrongly update notification - Auto fetch remote content in conversations!
- Fix some random crashes - Remember last read position in timeline!
- Follow a user with other logged in accounts
- Allowing adding more context of reports
- Option to disable autoplay gif
- Hide boosts from users
- Followed hashtags are underlined
- Support GoToSocial

View File

@ -1,3 +1,9 @@
toooting愉快此版本包括以下改进和修复 toooting愉快此版本包括以下改进和修复
- 修复错误的升级通知 - 主动获取对话的远程内容
- 修复部分应用崩溃 - 自动加载上次我的关注的阅读位置
- 用其它已登陆的账户关注用户
- 可添加举报细节
- 新增暂停自动播放gif动画选项
- 隐藏用户的转嘟
- 下划线高亮正在关注的话题标签
- 支持GoToSocial

View File

@ -1,8 +1,4 @@
import { registerRootComponent } from 'expo' import { registerRootComponent } from 'expo'
import App from './src/App'
import App from '@root/App'
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in the Expo client or in a native build,
// the environment is set up appropriately
registerRootComponent(App) registerRootComponent(App)

View File

@ -19,6 +19,7 @@ target 'tooot' do
:path => config[:reactNativePath], :path => config[:reactNativePath],
:hermes_enabled => true, :hermes_enabled => true,
:fabric_enabled => flags[:fabric_enabled], :fabric_enabled => flags[:fabric_enabled],
# :flipper_configuration => FlipperConfiguration.enabled(["Debug"], { 'Flipper' => '0.159.0' }),
:flipper_configuration => FlipperConfiguration.disabled, :flipper_configuration => FlipperConfiguration.disabled,
# An absolute path to your application root. # An absolute path to your application root.
:app_path => "#{Pod::Config.instance.installation_root}/.." :app_path => "#{Pod::Config.instance.installation_root}/.."

View File

@ -3,10 +3,10 @@ PODS:
- DoubleConversion (1.1.6) - DoubleConversion (1.1.6)
- EXApplication (5.0.1): - EXApplication (5.0.1):
- ExpoModulesCore - ExpoModulesCore
- EXAV (13.0.2): - EXAV (13.1.0):
- ExpoModulesCore - ExpoModulesCore
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- EXConstants (14.0.2): - EXConstants (14.1.0):
- ExpoModulesCore - ExpoModulesCore
- EXErrorRecovery (4.0.1): - EXErrorRecovery (4.0.1):
- ExpoModulesCore - ExpoModulesCore
@ -16,34 +16,37 @@ PODS:
- ExpoModulesCore - ExpoModulesCore
- EXNotifications (0.17.0): - EXNotifications (0.17.0):
- ExpoModulesCore - ExpoModulesCore
- Expo (47.0.8): - Expo (47.0.12):
- ExpoModulesCore - ExpoModulesCore
- ExpoCrypto (12.0.0): - ExpoCrypto (12.1.0):
- ExpoModulesCore - ExpoModulesCore
- ExpoHaptics (12.0.1): - ExpoHaptics (12.1.0):
- ExpoModulesCore - ExpoModulesCore
- ExpoKeepAwake (11.0.1): - ExpoKeepAwake (11.0.1):
- ExpoModulesCore - ExpoModulesCore
- ExpoLocalization (14.0.0): - ExpoLocalization (14.0.0):
- ExpoModulesCore - ExpoModulesCore
- ExpoModulesCore (1.0.3): - ExpoModulesCore (1.1.1):
- React-Core - React-Core
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- ExpoRandom (13.0.0): - ExpoRandom (13.0.0):
- ExpoModulesCore - ExpoModulesCore
- ExpoStoreReview (6.0.0): - ExpoStoreReview (6.1.0):
- ExpoModulesCore
- ExpoVideoThumbnails (7.1.0):
- ExpoModulesCore - ExpoModulesCore
- ExpoWebBrowser (12.0.0): - ExpoWebBrowser (12.0.0):
- ExpoModulesCore - ExpoModulesCore
- EXScreenCapture (5.0.0): - EXScreenCapture (5.0.0):
- ExpoModulesCore - ExpoModulesCore
- EXScreenOrientation (5.0.1):
- ExpoModulesCore
- React-Core
- EXSecureStore (12.0.0): - EXSecureStore (12.0.0):
- ExpoModulesCore - ExpoModulesCore
- EXSplashScreen (0.17.5): - EXSplashScreen (0.17.5):
- ExpoModulesCore - ExpoModulesCore
- React-Core - React-Core
- EXVideoThumbnails (7.0.0):
- ExpoModulesCore
- FBLazyVector (0.70.6) - FBLazyVector (0.70.6)
- FBReactNativeSpec (0.70.6): - FBReactNativeSpec (0.70.6):
- RCT-Folly (= 2021.07.22.00) - RCT-Folly (= 2021.07.22.00)
@ -65,6 +68,9 @@ PODS:
- libwebp/mux (1.2.4): - libwebp/mux (1.2.4):
- libwebp/demux - libwebp/demux
- libwebp/webp (1.2.4) - libwebp/webp (1.2.4)
- MMKV (1.2.14):
- MMKVCore (~> 1.2.14)
- MMKVCore (1.2.14)
- RCT-Folly (2021.07.22.00): - RCT-Folly (2021.07.22.00):
- boost - boost
- DoubleConversion - DoubleConversion
@ -295,16 +301,19 @@ PODS:
- React-Core - React-Core
- react-native-blurhash (1.1.10): - react-native-blurhash (1.1.10):
- React-Core - React-Core
- react-native-cameraroll (5.2.0): - react-native-cameraroll (5.2.1):
- React-Core - React-Core
- react-native-image-picker (4.10.2): - react-native-image-picker (4.10.3):
- React-Core - React-Core
- react-native-ios-context-menu (1.15.1): - react-native-ios-context-menu (1.15.1):
- React-Core - React-Core
- react-native-language-detection (0.2.2): - react-native-language-detection (0.2.2):
- React - React
- react-native-menu (0.7.2): - react-native-menu (0.7.3):
- React - React
- react-native-mmkv (2.5.1):
- MMKV (>= 1.2.13)
- React-Core
- react-native-netinfo (9.3.7): - react-native-netinfo (9.3.7):
- React-Core - React-Core
- react-native-pager-view (6.1.2): - react-native-pager-view (6.1.2):
@ -392,7 +401,7 @@ PODS:
- React-Core - React-Core
- RNFastImage (8.6.3): - RNFastImage (8.6.3):
- React-Core - React-Core
- SDWebImage (~> 5.14.2) - SDWebImage (~> 5.14.3)
- SDWebImageWebPCoder (~> 0.9.1) - SDWebImageWebPCoder (~> 0.9.1)
- RNGestureHandler (2.8.0): - RNGestureHandler (2.8.0):
- React-Core - React-Core
@ -433,9 +442,9 @@ PODS:
- React - React
- RNSVG (13.6.0): - RNSVG (13.6.0):
- React-Core - React-Core
- SDWebImage (5.14.2): - SDWebImage (5.14.3):
- SDWebImage/Core (= 5.14.2) - SDWebImage/Core (= 5.14.3)
- SDWebImage/Core (5.14.2) - SDWebImage/Core (5.14.3)
- SDWebImageWebPCoder (0.9.1): - SDWebImageWebPCoder (0.9.1):
- libwebp (~> 1.0) - libwebp (~> 1.0)
- SDWebImage/Core (~> 5.13) - SDWebImage/Core (~> 5.13)
@ -461,11 +470,12 @@ DEPENDENCIES:
- ExpoModulesCore (from `../node_modules/expo-modules-core`) - ExpoModulesCore (from `../node_modules/expo-modules-core`)
- ExpoRandom (from `../node_modules/expo-random/ios`) - ExpoRandom (from `../node_modules/expo-random/ios`)
- ExpoStoreReview (from `../node_modules/expo-store-review/ios`) - ExpoStoreReview (from `../node_modules/expo-store-review/ios`)
- ExpoVideoThumbnails (from `../node_modules/expo-video-thumbnails/ios`)
- ExpoWebBrowser (from `../node_modules/expo-web-browser/ios`) - ExpoWebBrowser (from `../node_modules/expo-web-browser/ios`)
- EXScreenCapture (from `../node_modules/expo-screen-capture/ios`) - EXScreenCapture (from `../node_modules/expo-screen-capture/ios`)
- EXScreenOrientation (from `../node_modules/expo-screen-orientation/ios`)
- EXSecureStore (from `../node_modules/expo-secure-store/ios`) - EXSecureStore (from `../node_modules/expo-secure-store/ios`)
- EXSplashScreen (from `../node_modules/expo-splash-screen/ios`) - EXSplashScreen (from `../node_modules/expo-splash-screen/ios`)
- EXVideoThumbnails (from `../node_modules/expo-video-thumbnails/ios`)
- FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`) - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`)
- FBReactNativeSpec (from `../node_modules/react-native/React/FBReactNativeSpec`) - FBReactNativeSpec (from `../node_modules/react-native/React/FBReactNativeSpec`)
- glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`) - glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`)
@ -494,6 +504,7 @@ DEPENDENCIES:
- react-native-ios-context-menu (from `../node_modules/react-native-ios-context-menu`) - react-native-ios-context-menu (from `../node_modules/react-native-ios-context-menu`)
- react-native-language-detection (from `../node_modules/react-native-language-detection`) - react-native-language-detection (from `../node_modules/react-native-language-detection`)
- "react-native-menu (from `../node_modules/@react-native-menu/menu`)" - "react-native-menu (from `../node_modules/@react-native-menu/menu`)"
- react-native-mmkv (from `../node_modules/react-native-mmkv`)
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
- react-native-pager-view (from `../node_modules/react-native-pager-view`) - react-native-pager-view (from `../node_modules/react-native-pager-view`)
- "react-native-paste-input (from `../node_modules/@mattermost/react-native-paste-input`)" - "react-native-paste-input (from `../node_modules/@mattermost/react-native-paste-input`)"
@ -527,6 +538,8 @@ SPEC REPOS:
- fmt - fmt
- libevent - libevent
- libwebp - libwebp
- MMKV
- MMKVCore
- SDWebImage - SDWebImage
- SDWebImageWebPCoder - SDWebImageWebPCoder
- Sentry - Sentry
@ -567,16 +580,18 @@ EXTERNAL SOURCES:
:path: "../node_modules/expo-random/ios" :path: "../node_modules/expo-random/ios"
ExpoStoreReview: ExpoStoreReview:
:path: "../node_modules/expo-store-review/ios" :path: "../node_modules/expo-store-review/ios"
ExpoVideoThumbnails:
:path: "../node_modules/expo-video-thumbnails/ios"
ExpoWebBrowser: ExpoWebBrowser:
:path: "../node_modules/expo-web-browser/ios" :path: "../node_modules/expo-web-browser/ios"
EXScreenCapture: EXScreenCapture:
:path: "../node_modules/expo-screen-capture/ios" :path: "../node_modules/expo-screen-capture/ios"
EXScreenOrientation:
:path: "../node_modules/expo-screen-orientation/ios"
EXSecureStore: EXSecureStore:
:path: "../node_modules/expo-secure-store/ios" :path: "../node_modules/expo-secure-store/ios"
EXSplashScreen: EXSplashScreen:
:path: "../node_modules/expo-splash-screen/ios" :path: "../node_modules/expo-splash-screen/ios"
EXVideoThumbnails:
:path: "../node_modules/expo-video-thumbnails/ios"
FBLazyVector: FBLazyVector:
:path: "../node_modules/react-native/Libraries/FBLazyVector" :path: "../node_modules/react-native/Libraries/FBLazyVector"
FBReactNativeSpec: FBReactNativeSpec:
@ -629,6 +644,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-language-detection" :path: "../node_modules/react-native-language-detection"
react-native-menu: react-native-menu:
:path: "../node_modules/@react-native-menu/menu" :path: "../node_modules/@react-native-menu/menu"
react-native-mmkv:
:path: "../node_modules/react-native-mmkv"
react-native-netinfo: react-native-netinfo:
:path: "../node_modules/@react-native-community/netinfo" :path: "../node_modules/@react-native-community/netinfo"
react-native-pager-view: react-native-pager-view:
@ -688,25 +705,26 @@ SPEC CHECKSUMS:
boost: a7c83b31436843459a1961bfd74b96033dc77234 boost: a7c83b31436843459a1961bfd74b96033dc77234
DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54 DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54
EXApplication: 034b1c40a8e9fe1bff76a1e511ee90dff64ad834 EXApplication: 034b1c40a8e9fe1bff76a1e511ee90dff64ad834
EXAV: 9a45d37772c5329294c054a041dcc39931fc5032 EXAV: 4b92292fb107520a25956bea940a94a3bb4911ca
EXConstants: 3c86653c422dd77e40d10cbbabb3025003977415 EXConstants: 44f7d347d0432a66f469d0ce1dc4e3a0ca1b8b2d
EXErrorRecovery: ae43433feb0608a64dc5b1c8363b3e7769a9ea24 EXErrorRecovery: ae43433feb0608a64dc5b1c8363b3e7769a9ea24
EXFileSystem: 60602b6eefa6873f97172c684b7537c9760b50d6 EXFileSystem: 60602b6eefa6873f97172c684b7537c9760b50d6
EXFont: 319606bfe48c33b5b5063fb0994afdc496befe80 EXFont: 319606bfe48c33b5b5063fb0994afdc496befe80
EXNotifications: babce2a87b7922051354fcfe7a74dd279b7e272a EXNotifications: babce2a87b7922051354fcfe7a74dd279b7e272a
Expo: 36b5f625d36728adbdd1934d4d57182f319ab832 Expo: f48d305fda3e4e501d686e6bad7d8c8373828279
ExpoCrypto: 51e7662c7f5bfeab25b7909b8a5d545ec15d4877 ExpoCrypto: 6eb2a5ede7d95b7359a5f0391ee0c5d2ecd144b3
ExpoHaptics: 5a56d30a87ea213dd00b09566dc4b441a4dff97f ExpoHaptics: 129d3f8d44c2205adcdf8db760602818463d5437
ExpoKeepAwake: 69b59d0a8d2b24de9f82759c39b3821fec030318 ExpoKeepAwake: 69b59d0a8d2b24de9f82759c39b3821fec030318
ExpoLocalization: e202d1e2a4950df17ac8d0889d65a1ffd7532d7e ExpoLocalization: e202d1e2a4950df17ac8d0889d65a1ffd7532d7e
ExpoModulesCore: b5d21c8880afda6fb6ee95469f9ac2ec9b98e995 ExpoModulesCore: 485dff3a59b036a33b6050c0a5aea3cf1037fdd1
ExpoRandom: 58b7e0a5fe1adf1cb6dc1cbe503a6fe9524f36ce ExpoRandom: 58b7e0a5fe1adf1cb6dc1cbe503a6fe9524f36ce
ExpoStoreReview: ff6d631f2949eb7e4b2d14146ef6af25a16d770d ExpoStoreReview: 713336ff504db3a6983475bf7c67519cc5efc86f
ExpoVideoThumbnails: 424db02cedfbbe2d498bcb2712ea4ba8a9dcb453
ExpoWebBrowser: 073e50f16669d498fb49063b9b7fe780b24f7fda ExpoWebBrowser: 073e50f16669d498fb49063b9b7fe780b24f7fda
EXScreenCapture: d9f1ec31042dfef109290d06c2b4789b7444d16d EXScreenCapture: d9f1ec31042dfef109290d06c2b4789b7444d16d
EXScreenOrientation: 07e5aeff07bce09a2b214981e612d87fd7719997
EXSecureStore: daec0117c922a67c658cb229152a9e252e5c1750 EXSecureStore: daec0117c922a67c658cb229152a9e252e5c1750
EXSplashScreen: 3e989924f61a8dd07ee4ea584c6ba14be9b51949 EXSplashScreen: 3e989924f61a8dd07ee4ea584c6ba14be9b51949
EXVideoThumbnails: 8b3e48f3716679dd0cbf949217a31eab5c555799
FBLazyVector: 48289402952f4f7a4e235de70a9a590aa0b79ef4 FBLazyVector: 48289402952f4f7a4e235de70a9a590aa0b79ef4
FBReactNativeSpec: dd1186fd05255e3457baa2f4ca65e94c2cd1e3ac FBReactNativeSpec: dd1186fd05255e3457baa2f4ca65e94c2cd1e3ac
fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9 fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9
@ -714,6 +732,8 @@ SPEC CHECKSUMS:
hermes-engine: 2af7b7a59128f250adfd86f15aa1d5a2ecd39995 hermes-engine: 2af7b7a59128f250adfd86f15aa1d5a2ecd39995
libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913 libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913
libwebp: f62cb61d0a484ba548448a4bd52aabf150ff6eef libwebp: f62cb61d0a484ba548448a4bd52aabf150ff6eef
MMKV: 9c4663aa7ca255d478ff10f2f5cb7d17c1651ccd
MMKVCore: 89f5c8a66bba2dcd551779dea4d412eeec8ff5bb
RCT-Folly: 0080d0a6ebf2577475bda044aa59e2ca1f909cda RCT-Folly: 0080d0a6ebf2577475bda044aa59e2ca1f909cda
RCTRequired: e1866f61af7049eb3d8e08e8b133abd38bc1ca7a RCTRequired: e1866f61af7049eb3d8e08e8b133abd38bc1ca7a
RCTTypeSafety: 27c2ac1b00609a432ced1ae701247593f07f901e RCTTypeSafety: 27c2ac1b00609a432ced1ae701247593f07f901e
@ -731,11 +751,12 @@ SPEC CHECKSUMS:
React-logger: 1623c216abaa88974afce404dc8f479406bbc3a0 React-logger: 1623c216abaa88974afce404dc8f479406bbc3a0
react-native-blur: 50c9feabacbc5f49b61337ebc32192c6be7ec3c3 react-native-blur: 50c9feabacbc5f49b61337ebc32192c6be7ec3c3
react-native-blurhash: add4df9a937b4e021a24bc67a0714f13e0bd40b7 react-native-blurhash: add4df9a937b4e021a24bc67a0714f13e0bd40b7
react-native-cameraroll: 0ff04cc4e0ff5f19a94ff4313e5c8bc4503cd86d react-native-cameraroll: f94bf9f46c998963ecd2bb6e9a3f9cca59b6d9f1
react-native-image-picker: bf34f3f516d139ed3e24c5f5a381a91819e349ea react-native-image-picker: 60f4246eb5bb7187fc15638a8c1f13abd3820695
react-native-ios-context-menu: b170594b4448c0cd10c79e13432216bac99de1ac react-native-ios-context-menu: b170594b4448c0cd10c79e13432216bac99de1ac
react-native-language-detection: f414937fa715108ab50a6269a3de0bcb95e4ceb0 react-native-language-detection: f414937fa715108ab50a6269a3de0bcb95e4ceb0
react-native-menu: 8e172cfcf0e42e92f028e7781eddf84d430cae24 react-native-menu: 9d7d6f819cc7fa14a15cf86888c53f3240d86f1b
react-native-mmkv: 69b9c003f10afdd01addf7c6ee784ce42ee2eff3
react-native-netinfo: 2517ad504b3d303e90d7a431b0fcaef76d207983 react-native-netinfo: 2517ad504b3d303e90d7a431b0fcaef76d207983
react-native-pager-view: 54bed894cecebe28cede54c01038d9d1e122de43 react-native-pager-view: 54bed894cecebe28cede54c01038d9d1e122de43
react-native-paste-input: 88709b4fd586ea8cc56ba5e2fc4cdfe90597730c react-native-paste-input: 88709b4fd586ea8cc56ba5e2fc4cdfe90597730c
@ -755,19 +776,19 @@ SPEC CHECKSUMS:
ReactCommon: 349be31adeecffc7986a0de875d7fb0dcf4e251c ReactCommon: 349be31adeecffc7986a0de875d7fb0dcf4e251c
RNCAsyncStorage: 8616bd5a58af409453ea4e1b246521bb76578d60 RNCAsyncStorage: 8616bd5a58af409453ea4e1b246521bb76578d60
RNCClipboard: 2834e1c4af68697089cdd455ee4a4cdd198fa7dd RNCClipboard: 2834e1c4af68697089cdd455ee4a4cdd198fa7dd
RNFastImage: c5dd1b551779c5826fe43b7d36788385da2021e2 RNFastImage: 756ab178acb5e3f11d8b0a931956fbd9da8d6e54
RNGestureHandler: 62232ba8f562f7dea5ba1b3383494eb5bf97a4d3 RNGestureHandler: 62232ba8f562f7dea5ba1b3383494eb5bf97a4d3
RNReanimated: ce445c233a6ff5600223484a88ad5704945d972a RNReanimated: ce445c233a6ff5600223484a88ad5704945d972a
RNScreens: 34cc502acf1b916c582c60003dc3089fa01dc66d RNScreens: 34cc502acf1b916c582c60003dc3089fa01dc66d
RNSentry: 4c09f4dd9740cb9b33e94303de5b6d0dbeb0737d RNSentry: 4c09f4dd9740cb9b33e94303de5b6d0dbeb0737d
RNShareMenu: cb9dac548c8bf147d06f0bf07296ad51ea9f5fc3 RNShareMenu: cb9dac548c8bf147d06f0bf07296ad51ea9f5fc3
RNSVG: 3a79c0c4992213e4f06c08e62730c5e7b9e4dc17 RNSVG: 3a79c0c4992213e4f06c08e62730c5e7b9e4dc17
SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84 SDWebImage: 9c36e66c8ce4620b41a7407698dda44211a96764
SDWebImageWebPCoder: 18503de6621dd2c420d680e33d46bf8e1d5169b0 SDWebImageWebPCoder: 18503de6621dd2c420d680e33d46bf8e1d5169b0
Sentry: 08884c523575ec0f6690d94ed3ccb0246a1600bf Sentry: 08884c523575ec0f6690d94ed3ccb0246a1600bf
Swime: d7b2c277503b6cea317774aedc2dce05613f8b0b Swime: d7b2c277503b6cea317774aedc2dce05613f8b0b
Yoga: 99caf8d5ab45e9d637ee6e0174ec16fbbb01bcfc Yoga: 99caf8d5ab45e9d637ee6e0174ec16fbbb01bcfc
PODFILE CHECKSUM: 05bf71d31ba782dfda5a6b47d38e98a6f6bc079a PODFILE CHECKSUM: 08742f25aa1cdb93d6d5d5efeafd8803ba02b689
COCOAPODS: 1.11.3 COCOAPODS: 1.11.3

View File

@ -86,5 +86,7 @@
<string>Automatic</string> <string>Automatic</string>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<false/> <false/>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

@ -1,6 +1,6 @@
{ {
"name": "tooot", "name": "tooot",
"version": "4.7.2", "version": "4.8.0",
"description": "tooot for Mastodon", "description": "tooot for Mastodon",
"author": "xmflsct <me@xmflsct.com>", "author": "xmflsct <me@xmflsct.com>",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
@ -14,8 +14,7 @@
"iphone": "react-native run-ios --simulator 'iPhone 14 Pro'", "iphone": "react-native run-ios --simulator 'iPhone 14 Pro'",
"ipad": "react-native run-ios --simulator 'iPad Pro (11-inch) (4th generation)'", "ipad": "react-native run-ios --simulator 'iPad Pro (11-inch) (4th generation)'",
"app:build": "bundle exec fastlane", "app:build": "bundle exec fastlane",
"clean": "react-native-clean-project", "clean": "react-native-clean-project"
"postinstall": "patch-package"
}, },
"dependencies": { "dependencies": {
"@expo/react-native-action-sheet": "^4.0.1", "@expo/react-native-action-sheet": "^4.0.1",
@ -28,58 +27,59 @@
"@mattermost/react-native-paste-input": "^0.5.2", "@mattermost/react-native-paste-input": "^0.5.2",
"@neverdull-agency/expo-unlimited-secure-store": "^1.0.10", "@neverdull-agency/expo-unlimited-secure-store": "^1.0.10",
"@react-native-async-storage/async-storage": "~1.17.11", "@react-native-async-storage/async-storage": "~1.17.11",
"@react-native-camera-roll/camera-roll": "^5.2.0", "@react-native-camera-roll/camera-roll": "^5.2.1",
"@react-native-clipboard/clipboard": "^1.11.1", "@react-native-clipboard/clipboard": "^1.11.1",
"@react-native-community/blur": "^4.3.0", "@react-native-community/blur": "^4.3.0",
"@react-native-community/netinfo": "9.3.7", "@react-native-community/netinfo": "9.3.7",
"@react-native-community/segmented-control": "^2.2.2", "@react-native-community/segmented-control": "^2.2.2",
"@react-native-menu/menu": "^0.7.2", "@react-native-firebase/app": "^16.5.0",
"@react-navigation/bottom-tabs": "^6.5.1", "@react-native-menu/menu": "^0.7.3",
"@react-navigation/bottom-tabs": "^6.5.2",
"@react-navigation/native": "^6.1.1", "@react-navigation/native": "^6.1.1",
"@react-navigation/native-stack": "^6.9.6", "@react-navigation/native-stack": "^6.9.7",
"@react-navigation/stack": "^6.3.9", "@react-navigation/stack": "^6.3.10",
"@reduxjs/toolkit": "^1.9.1",
"@sentry/react-native": "4.12.0", "@sentry/react-native": "4.12.0",
"@sharcoux/slider": "^6.1.1", "@sharcoux/slider": "^6.1.1",
"@tanstack/react-query": "^4.20.4", "@tanstack/react-query": "^4.20.9",
"axios": "^1.2.1", "axios": "^1.2.2",
"diff": "^5.1.0", "diff": "^5.1.0",
"expo": "^47.0.8", "expo": "^47.0.12",
"expo-auth-session": "^3.7.3", "expo-auth-session": "^3.8.0",
"expo-av": "^13.0.2", "expo-av": "^13.1.0",
"expo-constants": "^14.0.2", "expo-constants": "^14.1.0",
"expo-crypto": "^12.0.0", "expo-crypto": "^12.1.0",
"expo-file-system": "^15.1.1", "expo-file-system": "^15.1.1",
"expo-haptics": "^12.0.1", "expo-haptics": "^12.1.0",
"expo-linking": "^3.2.3", "expo-linking": "^3.3.0",
"expo-localization": "^14.0.0", "expo-localization": "^14.0.0",
"expo-notifications": "^0.17.0", "expo-notifications": "^0.17.0",
"expo-random": "^13.0.0", "expo-random": "^13.0.0",
"expo-screen-capture": "^5.0.0", "expo-screen-capture": "^5.0.0",
"expo-screen-orientation": "^5.0.1",
"expo-secure-store": "^12.0.0", "expo-secure-store": "^12.0.0",
"expo-splash-screen": "^0.17.5", "expo-splash-screen": "^0.17.5",
"expo-store-review": "^6.0.0", "expo-store-review": "^6.1.0",
"expo-video-thumbnails": "^7.0.0", "expo-video-thumbnails": "^7.1.0",
"expo-web-browser": "~12.0.0", "expo-web-browser": "~12.0.0",
"i18next": "^22.4.5", "htmlparser2": "^8.0.1",
"i18next": "^22.4.8",
"linkify-it": "^4.0.1", "linkify-it": "^4.0.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^12.1.1", "react-i18next": "^12.1.4",
"react-intl": "^6.2.5", "react-intl": "^6.2.5",
"react-native": "0.70.6", "react-native": "^0.70.6",
"react-native-animated-spinkit": "^1.5.2", "react-native-animated-spinkit": "^1.5.2",
"react-native-base64": "^0.2.1",
"react-native-blurhash": "^1.1.10", "react-native-blurhash": "^1.1.10",
"react-native-fast-image": "^8.6.3", "react-native-fast-image": "^8.6.3",
"react-native-feather": "^1.1.2", "react-native-feather": "^1.1.2",
"react-native-flash-message": "^0.3.1", "react-native-flash-message": "^0.4.0",
"react-native-gesture-handler": "~2.8.0", "react-native-gesture-handler": "~2.8.0",
"react-native-htmlview": "^0.16.0", "react-native-image-picker": "^4.10.3",
"react-native-image-picker": "^4.10.2",
"react-native-ios-context-menu": "^1.15.1", "react-native-ios-context-menu": "^1.15.1",
"react-native-language-detection": "^0.2.2", "react-native-language-detection": "^0.2.2",
"react-native-mmkv": "^2.5.1",
"react-native-pager-view": "^6.1.2", "react-native-pager-view": "^6.1.2",
"react-native-reanimated": "^2.13.0", "react-native-reanimated": "^2.13.0",
"react-native-reanimated-zoom": "^0.3.3", "react-native-reanimated-zoom": "^0.3.3",
@ -90,37 +90,35 @@
"react-native-swipe-list-view": "^3.2.9", "react-native-swipe-list-view": "^3.2.9",
"react-native-tab-view": "^3.3.4", "react-native-tab-view": "^3.3.4",
"react-redux": "^8.0.5", "react-redux": "^8.0.5",
"redux-persist": "^6.0.0",
"rn-placeholder": "^3.0.3", "rn-placeholder": "^3.0.3",
"rtl-detect": "^1.0.4", "url-parse": "^1.5.10",
"valid-url": "^1.0.9", "zeego": "^1.0.2"
"zeego": "^0.5.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.20.5", "@babel/core": "^7.20.12",
"@babel/plugin-proposal-optional-chaining": "^7.18.9", "@babel/plugin-proposal-optional-chaining": "^7.20.7",
"@babel/preset-react": "^7.18.6",
"@babel/preset-typescript": "^7.18.6", "@babel/preset-typescript": "^7.18.6",
"@expo/config": "^7.0.3", "@expo/config": "^7.0.3",
"@types/diff": "^5.0.2", "@types/diff": "^5.0.2",
"@types/linkify-it": "^3.0.2", "@types/linkify-it": "^3.0.2",
"@types/lodash": "^4.14.191", "@types/lodash": "^4.14.191",
"@types/react": "~18.0.26", "@types/react": "^18.0.26",
"@types/react-dom": "~18.0.9", "@types/react-dom": "^18.0.10",
"@types/react-native": "~0.70.8", "@types/react-native": "^0.70.8",
"@types/react-native-base64": "^0.2.0",
"@types/react-native-share-menu": "^5.0.2", "@types/react-native-share-menu": "^5.0.2",
"@types/react-timeago": "^4.1.3", "@types/url-parse": "^1.4.8",
"@types/valid-url": "^1.0.3",
"@welldone-software/why-did-you-render": "^7.0.1",
"babel-plugin-module-resolver": "^4.1.0", "babel-plugin-module-resolver": "^4.1.0",
"babel-plugin-transform-remove-console": "^6.9.4", "babel-plugin-transform-remove-console": "^6.9.4",
"chalk": "^4.1.2", "chalk": "^4.1.2",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"expo-cli": "^6.0.8",
"patch-package": "^6.5.0",
"postinstall-postinstall": "^2.1.0",
"react-native-clean-project": "^4.0.1", "react-native-clean-project": "^4.0.1",
"typescript": "^4.9.4" "typescript": "^4.9.4"
},
"packageManager": "yarn@3.3.1",
"resolutions": {
"react-native-fast-image@^8.6.3": "patch:react-native-fast-image@npm%3A8.6.3#./.yarn/patches/react-native-fast-image-npm-8.6.3-03ee2d23c0.patch",
"expo-av@^13.0.2": "patch:expo-av@npm%3A13.0.2#./.yarn/patches/expo-av-npm-13.0.2-7a651776f1.patch",
"react-native-share-menu@^6.0.0": "patch:react-native-share-menu@npm%3A6.0.0#./.yarn/patches/react-native-share-menu-npm-6.0.0-f1094c3204.patch",
"@types/react-native-share-menu@^5.0.2": "patch:@types/react-native-share-menu@npm%3A5.0.2#./.yarn/patches/@types-react-native-share-menu-npm-5.0.2-373df17ecc.patch"
} }
} }

View File

@ -1,14 +0,0 @@
diff --git a/node_modules/expo-av/ios/EXAV/EXAudioSessionManager.m b/node_modules/expo-av/ios/EXAV/EXAudioSessionManager.m
index 81dce13..8664b90 100644
--- a/node_modules/expo-av/ios/EXAV/EXAudioSessionManager.m
+++ b/node_modules/expo-av/ios/EXAV/EXAudioSessionManager.m
@@ -168,9 +168,6 @@ - (void)moduleDidBackground:(id)backgroundingModule
// compact doesn't work, that's why we need the `|| !pointer` above
// http://www.openradar.me/15396578
[_foregroundedModules compact];
-
- // Any possible failures are silent
- [self _updateSessionConfiguration];
}
- (void)moduleDidForeground:(id)module

View File

@ -1,23 +0,0 @@
diff --git a/node_modules/react-native-fast-image/RNFastImage.podspec b/node_modules/react-native-fast-image/RNFastImage.podspec
index db0fada..b23cd91 100644
--- a/node_modules/react-native-fast-image/RNFastImage.podspec
+++ b/node_modules/react-native-fast-image/RNFastImage.podspec
@@ -16,6 +16,6 @@ Pod::Spec.new do |s|
s.source_files = "ios/**/*.{h,m}"
s.dependency 'React-Core'
- s.dependency 'SDWebImage', '~> 5.11.1'
- s.dependency 'SDWebImageWebPCoder', '~> 0.8.4'
+ s.dependency 'SDWebImage', '~> 5.14.2'
+ s.dependency 'SDWebImageWebPCoder', '~> 0.9.1'
end
diff --git a/node_modules/react-native-fast-image/android/build.gradle b/node_modules/react-native-fast-image/android/build.gradle
index 5b21cd5..19d82f8 100644
--- a/node_modules/react-native-fast-image/android/build.gradle
+++ b/node_modules/react-native-fast-image/android/build.gradle
@@ -65,4 +65,5 @@ dependencies {
implementation "com.github.bumptech.glide:glide:${glideVersion}"
implementation "com.github.bumptech.glide:okhttp3-integration:${glideVersion}"
annotationProcessor "com.github.bumptech.glide:compiler:${glideVersion}"
+ implementation 'com.github.penfeizhou.android.animation:glide-plugin:2.12.0'
}

View File

@ -1,22 +0,0 @@
diff --git a/node_modules/react-native-htmlview/HTMLView.js b/node_modules/react-native-htmlview/HTMLView.js
index 43f8b7e..728112b 100644
--- a/node_modules/react-native-htmlview/HTMLView.js
+++ b/node_modules/react-native-htmlview/HTMLView.js
@@ -1,7 +1,7 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import htmlToElement from './htmlToElement';
-import {Linking, Platform, StyleSheet, View, ViewPropTypes} from 'react-native';
+import {Linking, Platform, StyleSheet, View} from 'react-native';
const boldStyle = {fontWeight: 'bold'};
const italicStyle = {fontStyle: 'italic'};
@@ -146,7 +146,7 @@ HtmlView.propTypes = {
renderNode: PropTypes.func,
RootComponent: PropTypes.func,
rootComponentProps: PropTypes.object,
- style: ViewPropTypes.style,
+ style: PropTypes.any,
stylesheet: PropTypes.object,
TextComponent: PropTypes.func,
textComponentProps: PropTypes.object,

9
react-native.config.js Normal file
View File

@ -0,0 +1,9 @@
module.exports = {
dependencies: {
'@react-native-firebase/app': {
platforms: {
ios: null
}
}
}
}

View File

@ -1,8 +1,44 @@
import 'i18next' import 'i18next'
import common from '../i18n/en/common.json'
import screens from '../i18n/en/screens.json'
import screenAccountSelection from '../i18n/en/screens/accountSelection.json'
import screenAnnouncements from '../i18n/en/screens/announcements.json'
import screenCompose from '../i18n/en/screens/compose.json'
import screenImageViewer from '../i18n/en/screens/imageViewer.json'
import screenTabs from '../i18n/en/screens/tabs.json'
import componentContextMenu from '../i18n/en/components/contextMenu.json'
import componentEmojis from '../i18n/en/components/emojis.json'
import componentInstance from '../i18n/en/components/instance.json'
import componentMediaSelector from '../i18n/en/components/mediaSelector.json'
import componentParse from '../i18n/en/components/parse.json'
import componentRelationship from '../i18n/en/components/relationship.json'
import componentTimeline from '../i18n/en/components/timeline.json'
declare module 'i18next' { declare module 'i18next' {
interface CustomTypeOptions { interface CustomTypeOptions {
defaultNS: 'common', defaultNS: 'common'
resources: {
common: typeof common
screens: typeof screens
screenAccountSelection: typeof screenAccountSelection
screenAnnouncements: typeof screenAnnouncements
screenCompose: typeof screenCompose
screenImageViewer: typeof screenImageViewer
screenTabs: typeof screenTabs
componentContextMenu: typeof componentContextMenu
componentEmojis: typeof componentEmojis
componentInstance: typeof componentInstance
componentMediaSelector: typeof componentMediaSelector
componentParse: typeof componentParse
componentRelationship: typeof componentRelationship
componentTimeline: typeof componentTimeline
}
returnNull: false returnNull: false
returnEmptyString: false
} }
} }

View File

@ -31,6 +31,9 @@ declare namespace Mastodon {
source?: Source source?: Source
suspended?: boolean suspended?: boolean
role?: Role role?: Role
// Internal
_remote?: boolean
} }
type Announcement = { type Announcement = {
@ -264,14 +267,6 @@ declare namespace Mastodon {
} }
type Filter<T extends 'v1' | 'v2'> = T extends 'v2' ? Filter_V2 : Filter_V1 type Filter<T extends 'v1' | 'v2'> = T extends 'v2' ? Filter_V2 : Filter_V1
type Filter_V1 = {
id: string
phrase: string
context: ('home' | 'notifications' | 'public' | 'thread' | 'account')[]
expires_at?: string
irreversible: boolean
whole_word: boolean
}
type Filter_V2 = { type Filter_V2 = {
id: string id: string
title: string title: string
@ -281,6 +276,14 @@ declare namespace Mastodon {
keywords: FilterKeyword[] keywords: FilterKeyword[]
statuses: FilterStatus[] statuses: FilterStatus[]
} }
type Filter_V1 = {
id: string
phrase: string
context: ('home' | 'notifications' | 'public' | 'thread' | 'account')[]
expires_at?: string
irreversible: boolean
whole_word: boolean
}
type FilterKeyword = { id: string; keyword: string; whole_word: boolean } type FilterKeyword = { id: string; keyword: string; whole_word: boolean }
@ -298,7 +301,48 @@ declare namespace Mastodon {
replies_policy: 'none' | 'list' | 'followed' replies_policy: 'none' | 'list' | 'followed'
} }
type Instance = { type Instance<T extends 'v1' | 'v2'> = T extends 'v2' ? Instance_V2 : Instance_V1
type Instance_V2 = {
domain: string
title: string
version: string
source_url: string
description: string
usage: { users: { active_month: number } }
thumbnail: { url: string; blurhash?: string; versions?: { '@1x'?: string; '@2x'?: string } }
languages: string[]
configuration: {
urls: { streaming_api: string }
accounts: { max_featured_tags: number }
statuses: {
max_characters: number
max_media_attachments: number
characters_reserved_per_url: number
}
media_attachments: {
supported_mime_types: string[]
image_size_limit: number
image_matrix_limit: number
video_size_limit: number
video_frame_rate_limit: number
video_matrix_limit: number
}
polls: {
max_options: number
max_characters_per_option: number
min_expiration: number
max_expiration: number
}
translation: { enabled: boolean }
registrations: { enabled: boolean; approval_required: boolean; message?: string }
contact: { email: string; account: Account }
rules: Rule[]
}
// Gotosocial
account_domain?: string
}
type Instance_V1 = {
// Base // Base
uri: string uri: string
title: string title: string
@ -343,6 +387,9 @@ declare namespace Mastodon {
max_expiration: number max_expiration: number
} }
} }
// Gotosocial
account_domain?: string
} }
type Mention = { type Mention = {
@ -351,6 +398,9 @@ declare namespace Mastodon {
username: string username: string
acct: string acct: string
url: string url: string
// Internal
_remote?: boolean
} }
type Notification = type Notification =
@ -470,6 +520,11 @@ declare namespace Mastodon {
updated_at: string updated_at: string
} }
type Rule = {
id: string
text: string
}
type Status = { type Status = {
// Base // Base
id: string id: string
@ -509,6 +564,10 @@ declare namespace Mastodon {
language?: string language?: string
text?: string text?: string
filtered?: FilterResult[] filtered?: FilterResult[]
// Internal
_level?: number
_remote?: boolean
} }
type StatusHistory = { type StatusHistory = {

View File

@ -1,7 +1,5 @@
declare module 'gl-react-blurhash' declare module 'gl-react-blurhash'
declare module 'htmlparser2-without-node-native'
declare module 'react-native-feather' declare module 'react-native-feather'
declare module 'react-native-htmlview'
declare module 'react-native-toast-message' declare module 'react-native-toast-message'
declare module 'rtl-detect' declare module 'rtl-detect'

View File

@ -1,31 +1,35 @@
import { ActionSheetProvider } from '@expo/react-native-action-sheet' import { ActionSheetProvider } from '@expo/react-native-action-sheet'
import getLanguage from '@helpers/getLanguage'
import queryClient from '@helpers/queryClient'
import i18n from '@root/i18n/i18n'
import Screens from '@root/Screens'
import audio from '@root/startup/audio'
import dev from '@root/startup/dev'
import log from '@root/startup/log'
import netInfo from '@root/startup/netInfo'
import push from '@root/startup/push'
import sentry from '@root/startup/sentry'
import timezone from '@root/startup/timezone'
import { persistor, store } from '@root/store'
import * as Sentry from '@sentry/react-native' import * as Sentry from '@sentry/react-native'
import { QueryClientProvider } from '@tanstack/react-query'
import AccessibilityManager from '@utils/accessibility/AccessibilityManager' import AccessibilityManager from '@utils/accessibility/AccessibilityManager'
import { changeLanguage } from '@utils/slices/settingsSlice' import getLanguage from '@utils/helpers/getLanguage'
import { queryClient } from '@utils/queryHooks'
import audio from '@utils/startup/audio'
import { dev } from '@utils/startup/dev'
import log from '@utils/startup/log'
import netInfo from '@utils/startup/netInfo'
import push from '@utils/startup/push'
import sentry from '@utils/startup/sentry'
import timezone from '@utils/startup/timezone'
import { storage } from '@utils/storage'
import {
getGlobalStorage,
removeAccount,
setAccount,
setGlobalStorage
} from '@utils/storage/actions'
import { migrateFromAsyncStorage, versionStorageGlobal } from '@utils/storage/migrations/toMMKV'
import ThemeManager from '@utils/styles/ThemeManager' import ThemeManager from '@utils/styles/ThemeManager'
import * as Localization from 'expo-localization' import * as Localization from 'expo-localization'
import * as SplashScreen from 'expo-splash-screen' import * as SplashScreen from 'expo-splash-screen'
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import { IntlProvider } from 'react-intl'
import { LogBox, Platform } from 'react-native' import { LogBox, Platform } from 'react-native'
import { GestureHandlerRootView } from 'react-native-gesture-handler' import { GestureHandlerRootView } from 'react-native-gesture-handler'
import { MMKV } from 'react-native-mmkv'
import { SafeAreaProvider } from 'react-native-safe-area-context' import { SafeAreaProvider } from 'react-native-safe-area-context'
import { enableFreeze } from 'react-native-screens' import { enableFreeze } from 'react-native-screens'
import { QueryClientProvider } from '@tanstack/react-query' import i18n from './i18n'
import { Provider } from 'react-redux' import Screens from './screens'
import { PersistGate } from 'redux-persist/integration/react'
Platform.select({ Platform.select({
android: LogBox.ignoreLogs(['Setting a timer for a long period of time']) android: LogBox.ignoreLogs(['Setting a timer for a long period of time'])
@ -38,83 +42,95 @@ push()
timezone() timezone()
enableFreeze(true) enableFreeze(true)
log('log', 'App', 'delay splash')
SplashScreen.preventAutoHideAsync()
const App: React.FC = () => { const App: React.FC = () => {
log('log', 'App', 'rendering App') log('log', 'App', 'rendering App')
const [appIsReady, setAppIsReady] = useState(false)
const [localCorrupt, setLocalCorrupt] = useState<string>() const [localCorrupt, setLocalCorrupt] = useState<string>()
const [hasMigrated, setHasMigrated] = useState<boolean>(versionStorageGlobal !== undefined)
useEffect(() => { useEffect(() => {
const delaySplash = async () => { const prepare = async () => {
log('log', 'App', 'delay splash') if (!hasMigrated) {
try { try {
await SplashScreen.preventAutoHideAsync() await migrateFromAsyncStorage()
} catch (e) { setHasMigrated(true)
console.warn(e) } catch {}
} else {
log('log', 'App', 'loading from MMKV')
const account = getGlobalStorage.string('account.active')
if (account) {
const storageAccount = new MMKV({ id: account })
const token = storageAccount.getString('auth.token')
if (token) {
log('log', 'App', `Binding storage of ${account}`)
storage.account = storageAccount
} else {
log('log', 'App', `Token not found for ${account}`)
removeAccount(account)
}
} else {
log('log', 'App', 'No active account available')
const accounts = getGlobalStorage.object('accounts')
if (accounts?.length) {
log('log', 'App', `Setting active account ${accounts[accounts.length - 1]}`)
setAccount(accounts[accounts.length - 1])
} else {
setGlobalStorage('account.active', undefined)
}
}
} }
let netInfoRes = undefined
try {
netInfoRes = await netInfo()
} catch {}
if (netInfoRes && netInfoRes.corrupted && netInfoRes.corrupted.length) {
setLocalCorrupt(netInfoRes.corrupted)
}
log('log', 'App', `locale: ${Localization.locale}`)
const language = getLanguage()
if (!language) {
if (Platform.OS !== 'ios') {
setGlobalStorage('app.language', 'en')
}
i18n.changeLanguage('en')
} else {
i18n.changeLanguage(language)
}
setAppIsReady(true)
} }
delaySplash()
prepare()
}, []) }, [])
const onLayoutRootView = useCallback(async () => {
const onBeforeLift = useCallback(async () => { if (appIsReady) {
let netInfoRes = undefined log('log', 'App', 'hide splash')
try {
netInfoRes = await netInfo()
} catch {}
if (netInfoRes && netInfoRes.corrupted && netInfoRes.corrupted.length) {
setLocalCorrupt(netInfoRes.corrupted)
}
log('log', 'App', 'hide splash')
try {
await SplashScreen.hideAsync() await SplashScreen.hideAsync()
return Promise.resolve()
} catch (e) {
console.warn(e)
return Promise.reject()
} }
}, []) }, [appIsReady])
if (!appIsReady) {
return null
}
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }} onLayout={onLayoutRootView}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Provider store={store}> <SafeAreaProvider>
<PersistGate <ActionSheetProvider>
persistor={persistor} <AccessibilityManager>
onBeforeLift={onBeforeLift} <ThemeManager>
children={bootstrapped => { <Screens localCorrupt={localCorrupt} />
log('log', 'App', 'bootstrapped') </ThemeManager>
if (bootstrapped) { </AccessibilityManager>
log('log', 'App', 'loading actual app :)') </ActionSheetProvider>
log('log', 'App', `Locale: ${Localization.locale}`) </SafeAreaProvider>
const language = getLanguage()
if (!language) {
if (Platform.OS !== 'ios') {
store.dispatch(changeLanguage('en'))
}
i18n.changeLanguage('en')
} else {
i18n.changeLanguage(language)
}
return (
<IntlProvider locale={language}>
<SafeAreaProvider>
<ActionSheetProvider>
<AccessibilityManager>
<ThemeManager>
<Screens localCorrupt={localCorrupt} />
</ThemeManager>
</AccessibilityManager>
</ActionSheetProvider>
</SafeAreaProvider>
</IntlProvider>
)
} else {
return null
}
}}
/>
</Provider>
</QueryClientProvider> </QueryClientProvider>
</GestureHandlerRootView> </GestureHandlerRootView>
) )

View File

@ -1,348 +0,0 @@
import { HeaderLeft } from '@components/Header'
import { displayMessage, Message } from '@components/Message'
import navigationRef from '@helpers/navigationRef'
import { NavigationContainer } from '@react-navigation/native'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import ScreenAccountSelection from '@screens/AccountSelection'
import ScreenActions from '@screens/Actions'
import ScreenAnnouncements from '@screens/Announcements'
import ScreenCompose from '@screens/Compose'
import ScreenImagesViewer from '@screens/ImagesViewer'
import ScreenTabs from '@screens/Tabs'
import * as Sentry from '@sentry/react-native'
import initQuery from '@utils/initQuery'
import { RootStackParamList } from '@utils/navigation/navigators'
import pushUseConnect from '@utils/push/useConnect'
import pushUseReceive from '@utils/push/useReceive'
import pushUseRespond from '@utils/push/useRespond'
import { updatePreviousTab } from '@utils/slices/contextsSlice'
import { checkEmojis } from '@utils/slices/instances/checkEmojis'
import { updateAccountPreferences } from '@utils/slices/instances/updateAccountPreferences'
import { updateConfiguration } from '@utils/slices/instances/updateConfiguration'
import { updateFilters } from '@utils/slices/instances/updateFilters'
import { getInstanceActive, getInstances } from '@utils/slices/instancesSlice'
import { useTheme } from '@utils/styles/ThemeManager'
import { themes } from '@utils/styles/themes'
import * as Linking from 'expo-linking'
import { addScreenshotListener } from 'expo-screen-capture'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Alert, Platform, StatusBar } from 'react-native'
import ShareMenu from 'react-native-share-menu'
import { useSelector } from 'react-redux'
import { useAppDispatch } from './store'
const Stack = createNativeStackNavigator<RootStackParamList>()
export interface Props {
localCorrupt?: string
}
const Screens: React.FC<Props> = ({ localCorrupt }) => {
const { t } = useTranslation('screens')
const dispatch = useAppDispatch()
const instanceActive = useSelector(getInstanceActive)
const { colors, theme } = useTheme()
const routeRef = useRef<{ name?: string; params?: {} }>()
// Push hooks
const instances = useSelector(getInstances, (prev, next) => prev.length === next.length)
pushUseConnect()
pushUseReceive()
pushUseRespond()
// Prevent screenshot alert
useEffect(() => {
const screenshotListener = addScreenshotListener(() =>
Alert.alert(t('screenshot.title'), t('screenshot.message'), [
{ text: t('common:buttons.confirm'), style: 'destructive' }
])
)
Platform.select({ ios: screenshotListener })
return () => screenshotListener.remove()
}, [])
// On launch display login credentials corrupt information
useEffect(() => {
const showLocalCorrect = () => {
if (localCorrupt) {
displayMessage({
message: t('localCorrupt.message'),
description: localCorrupt.length ? localCorrupt : undefined,
type: 'danger'
})
// @ts-ignore
navigationRef.navigate('Screen-Tabs', {
screen: 'Tab-Me'
})
}
}
return showLocalCorrect()
}, [localCorrupt])
// Lazily update users's preferences, for e.g. composing default visibility
useEffect(() => {
if (instanceActive !== -1) {
dispatch(updateConfiguration())
dispatch(updateFilters())
dispatch(updateAccountPreferences())
dispatch(checkEmojis())
}
}, [instanceActive])
// Callbacks
const navigationContainerOnReady = useCallback(() => {
const currentRoute = navigationRef.getCurrentRoute()
routeRef.current = {
name: currentRoute?.name,
params: currentRoute?.params ? JSON.stringify(currentRoute.params) : undefined
}
}, [])
const navigationContainerOnStateChange = useCallback(() => {
const previousRoute = routeRef.current
const currentRoute = navigationRef.getCurrentRoute()
const matchTabName = currentRoute?.name?.match(/(Tab-.*)-Root/)
if (matchTabName) {
//@ts-ignore
dispatch(updatePreviousTab(matchTabName[1]))
}
if (previousRoute?.name !== currentRoute?.name) {
Sentry.setContext('page', {
previous: previousRoute,
current: currentRoute
})
}
routeRef.current = currentRoute
}, [])
// Deep linking for compose
const [deeplinked, setDeeplinked] = useState(false)
useEffect(() => {
const getUrlAsync = async () => {
setDeeplinked(true)
const initialUrl = await Linking.parseInitialURLAsync()
if (initialUrl.path) {
const paths = initialUrl.path.split('/')
if (paths && paths.length) {
const instanceIndex = instances.findIndex(
instance => paths[0] === `@${instance.account.acct}@${instance.uri}`
)
if (instanceIndex !== -1 && instanceActive !== instanceIndex) {
initQuery({ instance: instances[instanceIndex] })
}
}
}
if (initialUrl.hostname === 'compose') {
navigationRef.navigate('Screen-Compose')
}
}
if (!deeplinked) {
getUrlAsync()
}
}, [instanceActive, instances, deeplinked])
// Share Extension
const handleShare = useCallback(
(
item?:
| {
data: { mimeType: string; data: string }[]
mimeType: undefined
}
| { data: string | string[]; mimeType: string }
) => {
if (instanceActive < 0) {
return
}
if (!item || !item.data) {
return
}
let text: string | undefined = undefined
let media: { uri: string; mime: string }[] = []
const typesImage = ['png', 'jpg', 'jpeg', 'gif']
const typesVideo = ['mp4', 'm4v', 'mov', 'webm', 'mpeg']
const filterMedia = ({ uri, mime }: { uri: string; mime: string }) => {
if (mime.startsWith('image/')) {
if (!typesImage.includes(mime.split('/')[1])) {
console.warn('Image type not supported:', mime.split('/')[1])
displayMessage({
message: t('shareError.imageNotSupported', {
type: mime.split('/')[1]
}),
type: 'danger'
})
return
}
media.push({ uri, mime })
} else if (mime.startsWith('video/')) {
if (!typesVideo.includes(mime.split('/')[1])) {
console.warn('Video type not supported:', mime.split('/')[1])
displayMessage({
message: t('shareError.videoNotSupported', {
type: mime.split('/')[1]
}),
type: 'danger'
})
return
}
media.push({ uri, mime })
} else {
if (typesImage.includes(uri.split('.').pop() || '')) {
media.push({ uri, mime: 'image/jpg' })
return
}
if (typesVideo.includes(uri.split('.').pop() || '')) {
media.push({ uri, mime: 'video/mp4' })
return
}
text = !text ? uri : text.concat(text, `\n${uri}`)
}
}
switch (Platform.OS) {
case 'ios':
if (!Array.isArray(item.data) || !item.data) {
return
}
for (const d of item.data) {
if (typeof d !== 'string') {
filterMedia({ uri: d.data, mime: d.mimeType })
}
}
break
case 'android':
if (!item.mimeType) {
return
}
if (Array.isArray(item.data)) {
for (const d of item.data) {
filterMedia({ uri: d, mime: item.mimeType })
}
} else {
filterMedia({ uri: item.data, mime: item.mimeType })
}
break
}
if (!text && !media.length) {
return
} else {
if (instances.length > 1) {
navigationRef.navigate('Screen-AccountSelection', {
share: { text, media }
})
} else {
navigationRef.navigate('Screen-Compose', {
type: 'share',
text,
media
})
}
}
},
[]
)
useEffect(() => {
ShareMenu.getInitialShare(handleShare)
}, [])
useEffect(() => {
const listener = ShareMenu.addNewShareListener(handleShare)
return () => {
listener.remove()
}
}, [])
return (
<>
<StatusBar
backgroundColor={colors.backgroundDefault}
{...(Platform.OS === 'android' && {
barStyle: theme === 'light' ? 'dark-content' : 'light-content'
})}
/>
<NavigationContainer
ref={navigationRef}
theme={themes[theme]}
onReady={navigationContainerOnReady}
onStateChange={navigationContainerOnStateChange}
>
<Stack.Navigator initialRouteName='Screen-Tabs'>
<Stack.Screen
name='Screen-Tabs'
component={ScreenTabs}
options={{ headerShown: false }}
/>
<Stack.Screen
name='Screen-Actions'
component={ScreenActions}
options={{
presentation: 'transparentModal',
animation: 'fade',
headerShown: false
}}
/>
<Stack.Screen
name='Screen-Announcements'
component={ScreenAnnouncements}
options={({ navigation }) => ({
presentation: 'transparentModal',
animation: 'fade',
headerShown: true,
headerShadowVisible: false,
headerTransparent: true,
headerStyle: { backgroundColor: 'transparent' },
headerLeft: () => <HeaderLeft content='X' onPress={() => navigation.goBack()} />,
title: t('screenAnnouncements:heading')
})}
/>
<Stack.Screen
name='Screen-Compose'
component={ScreenCompose}
options={{
headerShown: false,
presentation: 'fullScreenModal'
}}
/>
<Stack.Screen
name='Screen-ImagesViewer'
component={ScreenImagesViewer}
options={{ headerShown: false, animation: 'fade' }}
/>
<Stack.Screen
name='Screen-AccountSelection'
component={ScreenAccountSelection}
options={({ navigation }) => ({
title: t('screenAccountSelection:heading'),
headerShadowVisible: false,
presentation: 'modal',
gestureEnabled: false,
headerLeft: () => (
<HeaderLeft
type='text'
content={t('common:buttons.cancel')}
onPress={() => navigation.goBack()}
/>
)
})}
/>
</Stack.Navigator>
<Message />
</NavigationContainer>
</>
)
}
export default React.memo(Screens, () => true)

View File

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

View File

@ -11,7 +11,7 @@ import Icon from './Icon'
import CustomText from './Text' import CustomText from './Text'
export interface Props { export interface Props {
account: Mastodon.Account account: Partial<Mastodon.Account> & Pick<Mastodon.Account, 'id' | 'acct' | 'username' | 'url'>
props?: PressableProps props?: PressableProps
} }
@ -42,11 +42,11 @@ const ComponentAccount: React.FC<PropsWithChildren & Props> = ({ account, props,
style={{ style={{
width: StyleConstants.Avatar.S, width: StyleConstants.Avatar.S,
height: StyleConstants.Avatar.S, height: StyleConstants.Avatar.S,
borderRadius: 6, borderRadius: 8,
marginRight: StyleConstants.Spacing.S marginRight: StyleConstants.Spacing.S
}} }}
/> />
<View> <View style={{ flex: 1 }}>
<CustomText numberOfLines={1}> <CustomText numberOfLines={1}>
<ParseEmojis <ParseEmojis
content={account.display_name || account.username} content={account.display_name || account.username}

View File

@ -1,32 +1,30 @@
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import initQuery from '@utils/initQuery' import { ReadableAccountType, setAccount } from '@utils/storage/actions'
import { InstanceLatest } from '@utils/migrations/instances/migration'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import React from 'react' import React from 'react'
import Button from './Button' import Button from './Button'
import haptics from './haptics' import haptics from './haptics'
interface Props { interface Props {
instance: InstanceLatest account: ReadableAccountType
selected?: boolean
additionalActions?: () => void additionalActions?: () => void
} }
const AccountButton: React.FC<Props> = ({ instance, selected = false, additionalActions }) => { const AccountButton: React.FC<Props> = ({ account, additionalActions }) => {
const navigation = useNavigation() const navigation = useNavigation()
return ( return (
<Button <Button
type='text' type='text'
selected={selected} selected={account.active}
style={{ style={{
marginBottom: StyleConstants.Spacing.M, marginBottom: StyleConstants.Spacing.M,
marginRight: StyleConstants.Spacing.M marginRight: StyleConstants.Spacing.M
}} }}
content={`@${instance.account.acct}@${instance.uri}${selected ? ' ✓' : ''}`} content={account.acct}
onPress={() => { onPress={() => {
haptics('Light') haptics('Light')
initQuery({ instance }) setAccount(account.key)
navigation.goBack() navigation.goBack()
if (additionalActions) { if (additionalActions) {
additionalActions() additionalActions()

View File

@ -1,7 +1,7 @@
import Icon from '@components/Icon' import Icon from '@components/Icon'
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, { useMemo, useState } from 'react' import React, { useState } from 'react'
import { AccessibilityProps, Pressable, StyleProp, View, ViewStyle } from 'react-native' import { AccessibilityProps, Pressable, StyleProp, View, ViewStyle } from 'react-native'
import { Flow } from 'react-native-animated-spinkit' import { Flow } from 'react-native-animated-spinkit'
import CustomText from './Text' import CustomText from './Text'
@ -48,18 +48,16 @@ const Button: React.FC<Props> = ({
overlay = false, overlay = false,
onPress onPress
}) => { }) => {
const { colors, theme } = useTheme() const { colors } = useTheme()
const loadingSpinkit = useMemo( const loadingSpinkit = () =>
() => ( loading ? (
<View style={{ position: 'absolute' }}> <View style={{ position: 'absolute' }}>
<Flow size={StyleConstants.Font.Size[size]} color={colors.secondary} /> <Flow size={StyleConstants.Font.Size[size]} color={colors.secondary} />
</View> </View>
), ) : null
[theme]
)
const mainColor = useMemo(() => { const mainColor = () => {
if (selected) { if (selected) {
return colors.blue return colors.blue
} else if (overlay) { } else if (overlay) {
@ -73,29 +71,21 @@ const Button: React.FC<Props> = ({
return colors.primaryDefault return colors.primaryDefault
} }
} }
}, [theme, disabled, loading, selected]) }
const colorBackground = useMemo(() => { const children = () => {
if (overlay) {
return colors.backgroundOverlayInvert
} else {
return colors.backgroundDefault
}
}, [theme])
const children = useMemo(() => {
switch (type) { switch (type) {
case 'icon': case 'icon':
return ( return (
<> <>
<Icon <Icon
name={content} name={content}
color={mainColor} color={mainColor()}
strokeWidth={strokeWidth} strokeWidth={strokeWidth}
style={{ opacity: loading ? 0 : 1 }} style={{ opacity: loading ? 0 : 1 }}
size={StyleConstants.Font.Size[size] * (size === 'L' ? 1.25 : 1)} size={StyleConstants.Font.Size[size] * (size === 'L' ? 1.25 : 1)}
/> />
{loading ? loadingSpinkit : null} {loadingSpinkit()}
</> </>
) )
case 'text': case 'text':
@ -103,19 +93,19 @@ const Button: React.FC<Props> = ({
<> <>
<CustomText <CustomText
style={{ style={{
color: mainColor, color: mainColor(),
fontSize: StyleConstants.Font.Size[size] * (size === 'L' ? 1.25 : 1), fontSize: StyleConstants.Font.Size[size] * (size === 'L' ? 1.25 : 1),
opacity: loading ? 0 : 1 opacity: loading ? 0 : 1
}} }}
fontWeight={fontBold ? 'Bold' : 'Normal'} fontWeight={fontBold || selected ? 'Bold' : 'Normal'}
children={content} children={content}
testID='text' testID='text'
/> />
{loading ? loadingSpinkit : null} {loadingSpinkit()}
</> </>
) )
} }
}, [theme, content, loading, disabled]) }
const [layoutHeight, setLayoutHeight] = useState<number | undefined>() const [layoutHeight, setLayoutHeight] = useState<number | undefined>()
@ -135,9 +125,9 @@ const Button: React.FC<Props> = ({
borderRadius: 100, borderRadius: 100,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
borderWidth: overlay ? 0 : 1, borderWidth: overlay ? 0 : selected ? 1.5 : 1,
borderColor: mainColor, borderColor: mainColor(),
backgroundColor: colorBackground, backgroundColor: overlay ? colors.backgroundOverlayInvert : colors.backgroundDefault,
paddingVertical: StyleConstants.Spacing[spacing], paddingVertical: StyleConstants.Spacing[spacing],
paddingHorizontal: StyleConstants.Spacing[spacing] + StyleConstants.Spacing.XS, paddingHorizontal: StyleConstants.Spacing[spacing] + StyleConstants.Spacing.XS,
width: round && layoutHeight ? layoutHeight : undefined width: round && layoutHeight ? layoutHeight : undefined
@ -149,7 +139,7 @@ const Button: React.FC<Props> = ({
})} })}
testID='base' testID='base'
onPress={onPress} onPress={onPress}
children={children} children={children()}
disabled={selected || disabled || loading} disabled={selected || disabled || loading}
/> />
) )

View File

@ -4,7 +4,7 @@ import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import React, { useContext } from 'react' import React, { useContext } from 'react'
import { Keyboard, Pressable, View } from 'react-native' import { Keyboard, Pressable, View } from 'react-native'
import EmojisContext from './helpers/EmojisContext' import EmojisContext from './Context'
const EmojisButton: React.FC = () => { const EmojisButton: React.FC = () => {
const { colors } = useTheme() const { colors } = useTheme()

View File

@ -1,9 +1,9 @@
import { emojis } from '@components/Emojis' import { emojis } from '@components/Emojis'
import Icon from '@components/Icon' import Icon from '@components/Icon'
import CustomText from '@components/Text' import CustomText from '@components/Text'
import { useAppDispatch } from '@root/store'
import { useAccessibility } from '@utils/accessibility/AccessibilityManager' import { useAccessibility } from '@utils/accessibility/AccessibilityManager'
import { countInstanceEmoji } from '@utils/slices/instancesSlice' import { StorageAccount } from '@utils/storage/account'
import { getAccountStorage, setAccountStorage } from '@utils/storage/actions'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import layoutAnimation from '@utils/styles/layoutAnimation' import layoutAnimation from '@utils/styles/layoutAnimation'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
@ -19,16 +19,14 @@ import {
View View
} from 'react-native' } from 'react-native'
import FastImage from 'react-native-fast-image' import FastImage from 'react-native-fast-image'
import validUrl from 'valid-url' import EmojisContext from './Context'
import EmojisContext from './helpers/EmojisContext'
const EmojisList = () => { const EmojisList = () => {
const dispatch = useAppDispatch()
const { reduceMotionEnabled } = useAccessibility() const { reduceMotionEnabled } = useAccessibility()
const { t } = useTranslation() const { t } = useTranslation(['common', 'screenCompose'])
const { emojisState, emojisDispatch } = useContext(EmojisContext) const { emojisState, emojisDispatch } = useContext(EmojisContext)
const { colors, mode } = useTheme() const { colors } = useTheme()
const addEmoji = (shortcode: string) => { const addEmoji = (shortcode: string) => {
if (emojisState.targetIndex === -1) { if (emojisState.targetIndex === -1) {
@ -69,31 +67,77 @@ const EmojisList = () => {
> >
{item.map(emoji => { {item.map(emoji => {
const uri = reduceMotionEnabled ? emoji.static_url : emoji.url const uri = reduceMotionEnabled ? emoji.static_url : emoji.url
if (validUrl.isHttpsUri(uri)) { return (
return ( <Pressable
<Pressable key={emoji.shortcode}
key={emoji.shortcode} onPress={() => {
onPress={() => { addEmoji(`:${emoji.shortcode}:`)
addEmoji(`:${emoji.shortcode}:`)
dispatch(countInstanceEmoji(emoji)) const HALF_LIFE = 60 * 60 * 24 * 7 // 1 week
}} const calculateScore = (
style={{ padding: StyleConstants.Spacing.S }} emoji: StorageAccount['emojis_frequent'][number]
> ): number => {
<FastImage var seconds = (new Date().getTime() - emoji.lastUsed) / 1000
accessibilityLabel={t('common:customEmoji.accessibilityLabel', { var score = emoji.count + 1
emoji: emoji.shortcode var order = Math.log(Math.max(score, 1)) / Math.LN10
})} var sign = score > 0 ? 1 : score === 0 ? 0 : -1
accessibilityHint={t( return (sign * order + seconds / HALF_LIFE) * 10
'screenCompose:content.root.footer.emojis.accessibilityHint' }
)}
source={{ uri }} const currentEmojis = getAccountStorage.object('emojis_frequent')
style={{ width: 32, height: 32 }} const foundEmojiIndex = currentEmojis?.findIndex(
/> e => e.emoji.shortcode === emoji.shortcode && e.emoji.url === emoji.url
</Pressable> )
)
} else { let newEmojisSort: StorageAccount['emojis_frequent']
return null if (foundEmojiIndex === -1) {
} newEmojisSort = currentEmojis || []
const temp = {
emoji,
score: 0,
count: 0,
lastUsed: new Date().getTime()
}
newEmojisSort.push({
...temp,
score: calculateScore(temp),
count: temp.count + 1
})
} else {
newEmojisSort =
currentEmojis
?.map((e, i) =>
i === foundEmojiIndex
? {
...e,
score: calculateScore(e),
count: e.count + 1,
lastUsed: new Date().getTime()
}
: e
)
.sort((a, b) => b.score - a.score) || []
}
setAccountStorage([
{
key: 'emojis_frequent',
value: newEmojisSort.sort((a, b) => b.score - a.score).slice(0, 20)
}
])
}}
style={{ padding: StyleConstants.Spacing.S }}
>
<FastImage
accessibilityLabel={t('common:customEmoji.accessibilityLabel', {
emoji: emoji.shortcode
})}
accessibilityHint={t('screenCompose:content.root.footer.emojis.accessibilityHint')}
source={{ uri }}
style={{ width: 32, height: 32 }}
/>
</Pressable>
)
})} })}
</View> </View>
) )
@ -158,7 +202,6 @@ const EmojisList = () => {
onChangeText={setSearch} onChangeText={setSearch}
autoCapitalize='none' autoCapitalize='none'
clearButtonMode='always' clearButtonMode='always'
keyboardAppearance={mode}
autoCorrect={false} autoCorrect={false}
spellCheck={false} spellCheck={false}
/> />

View File

@ -2,15 +2,13 @@ import EmojisButton from '@components/Emojis/Button'
import EmojisList from '@components/Emojis/List' import EmojisList from '@components/Emojis/List'
import { useAccessibility } from '@utils/accessibility/AccessibilityManager' import { useAccessibility } from '@utils/accessibility/AccessibilityManager'
import { useEmojisQuery } from '@utils/queryHooks/emojis' import { useEmojisQuery } from '@utils/queryHooks/emojis'
import { getInstanceFrequentEmojis } from '@utils/slices/instancesSlice' import { useAccountStorage } from '@utils/storage/actions'
import { chunk, forEach, groupBy, sortBy } from 'lodash' import { chunk, forEach, groupBy, sortBy } from 'lodash'
import React, { createRef, PropsWithChildren, useEffect, useReducer, useState } from 'react' import React, { createRef, PropsWithChildren, useEffect, useReducer, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Keyboard, KeyboardAvoidingView, View } from 'react-native' import { Keyboard, KeyboardAvoidingView, View } from 'react-native'
import FastImage from 'react-native-fast-image'
import { Edge, SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' import { Edge, SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useSelector } from 'react-redux' import EmojisContext, { Emojis, emojisReducer, EmojisState } from './Context'
import EmojisContext, { Emojis, emojisReducer, EmojisState } from './Emojis/helpers/EmojisContext'
export type Props = { export type Props = {
inputProps: EmojisState['inputProps'] inputProps: EmojisState['inputProps']
@ -35,9 +33,9 @@ const ComponentEmojis: React.FC<Props & PropsWithChildren> = ({
emojisDispatch({ type: 'input', payload: inputProps }) emojisDispatch({ type: 'input', payload: inputProps })
}, [inputProps]) }, [inputProps])
const { t } = useTranslation() const { t } = useTranslation(['componentEmojis'])
const { data } = useEmojisQuery({}) const { data } = useEmojisQuery({})
const frequentEmojis = useSelector(getInstanceFrequentEmojis, () => true) const [frequentEmojis] = useAccountStorage.object('emojis_frequent')
useEffect(() => { useEffect(() => {
if (data && data.length) { if (data && data.length) {
let sortedEmojis: NonNullable<Emojis['current']> = [] let sortedEmojis: NonNullable<Emojis['current']> = []

View File

@ -1,6 +1,6 @@
import { useAccessibility } from '@utils/accessibility/AccessibilityManager' import { useAccessibility } from '@utils/accessibility/AccessibilityManager'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import React, { useMemo, useState } from 'react' import React, { useState } from 'react'
import { import {
AccessibilityProps, AccessibilityProps,
Image, Image,
@ -10,8 +10,8 @@ import {
View, View,
ViewStyle ViewStyle
} from 'react-native' } from 'react-native'
import FastImage, { ImageStyle } from 'react-native-fast-image'
import { Blurhash } from 'react-native-blurhash' import { Blurhash } from 'react-native-blurhash'
import FastImage, { ImageStyle } from 'react-native-fast-image'
// blurhas -> if blurhash, show before any loading succeed // blurhas -> if blurhash, show before any loading succeed
// original -> load original // original -> load original
@ -55,17 +55,12 @@ const GracefullyImage = ({
const { colors } = useTheme() const { colors } = useTheme()
const [imageLoaded, setImageLoaded] = useState(false) const [imageLoaded, setImageLoaded] = useState(false)
const [currentUri, setCurrentUri] = useState<string | undefined>(uri.original || uri.remote)
const source = { const source = {
uri: reduceMotionEnabled && uri.static ? uri.static : uri.original uri: reduceMotionEnabled && uri.static ? uri.static : currentUri
}
const onLoad = () => {
setImageLoaded(true)
if (setImageDimensions && source.uri) {
Image.getSize(source.uri, (width, height) => setImageDimensions({ width, height }))
}
} }
const blurhashView = useMemo(() => { const blurhashView = () => {
if (hidden || !imageLoaded) { if (hidden || !imageLoaded) {
if (blurhash) { if (blurhash) {
return <Blurhash decodeAsync blurhash={blurhash} style={styles.placeholder} /> return <Blurhash decodeAsync blurhash={blurhash} style={styles.placeholder} />
@ -75,7 +70,7 @@ const GracefullyImage = ({
} else { } else {
return null return null
} }
}, [hidden, imageLoaded]) }
return ( return (
<Pressable <Pressable
@ -92,13 +87,21 @@ const GracefullyImage = ({
/> />
) : null} ) : null}
<FastImage <FastImage
source={{ source={source}
uri: reduceMotionEnabled && uri.static ? uri.static : uri.original
}}
style={[{ flex: 1 }, imageStyle]} style={[{ flex: 1 }, imageStyle]}
onLoad={onLoad} onLoad={() => {
setImageLoaded(true)
if (setImageDimensions && source.uri) {
Image.getSize(source.uri, (width, height) => setImageDimensions({ width, height }))
}
}}
onError={() => {
if (uri.original && uri.original === currentUri && uri.remote) {
setCurrentUri(uri.remote)
}
}}
/> />
{blurhashView} {blurhashView()}
</Pressable> </Pressable>
) )
} }

View File

@ -3,7 +3,7 @@ import { StackNavigationProp } from '@react-navigation/stack'
import { TabLocalStackParamList } from '@utils/navigation/navigators' import { TabLocalStackParamList } from '@utils/navigation/navigators'
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, { PropsWithChildren, useCallback, useState } from 'react' import React, { PropsWithChildren, useState } from 'react'
import { Dimensions, Pressable, View } from 'react-native' import { Dimensions, Pressable, View } from 'react-native'
import Sparkline from './Sparkline' import Sparkline from './Sparkline'
import CustomText from './Text' import CustomText from './Text'
@ -21,9 +21,9 @@ const ComponentHashtag: React.FC<PropsWithChildren & Props> = ({
const { colors } = useTheme() const { colors } = useTheme()
const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>() const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>()
const onPress = useCallback(() => { const onPress = () => {
navigation.push('Tab-Shared-Hashtag', { hashtag: hashtag.name }) navigation.push('Tab-Shared-Hashtag', { hashtag: hashtag.name })
}, []) }
const padding = StyleConstants.Spacing.Global.PagePadding const padding = StyleConstants.Spacing.Global.PagePadding
const width = Dimensions.get('window').width / 4 const width = Dimensions.get('window').width / 4

View File

@ -2,7 +2,7 @@ import Icon from '@components/Icon'
import CustomText from '@components/Text' import CustomText from '@components/Text'
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, { useMemo } from 'react' import React from 'react'
import { Pressable } from 'react-native' import { Pressable } from 'react-native'
export interface Props { export interface Props {
@ -21,9 +21,9 @@ const HeaderLeft: React.FC<Props> = ({
background = false, background = false,
onPress onPress
}) => { }) => {
const { colors, theme } = useTheme() const { colors } = useTheme()
const children = useMemo(() => { const children = () => {
switch (type) { switch (type) {
case 'icon': case 'icon':
return ( return (
@ -35,31 +35,23 @@ const HeaderLeft: React.FC<Props> = ({
) )
case 'text': case 'text':
return ( return (
<CustomText <CustomText fontStyle='M' style={{ color: colors.primaryDefault }} children={content} />
fontStyle='M'
style={{ color: colors.primaryDefault }}
children={content}
/>
) )
} }
}, [theme]) }
return ( return (
<Pressable <Pressable
onPress={onPress} onPress={onPress}
children={children} children={children()}
style={{ style={{
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
backgroundColor: background backgroundColor: background ? colors.backgroundOverlayDefault : undefined,
? colors.backgroundOverlayDefault
: undefined,
minHeight: 44, minHeight: 44,
minWidth: 44, minWidth: 44,
marginLeft: native marginLeft: native ? -StyleConstants.Spacing.S : StyleConstants.Spacing.S,
? -StyleConstants.Spacing.S
: StyleConstants.Spacing.S,
...(type === 'icon' && { ...(type === 'icon' && {
borderRadius: 100 borderRadius: 100
}), }),

View File

@ -2,7 +2,7 @@ import Icon from '@components/Icon'
import CustomText from '@components/Text' import CustomText from '@components/Text'
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, { useMemo } from 'react' import React from 'react'
import { AccessibilityProps, Pressable, View } from 'react-native' import { AccessibilityProps, Pressable, View } from 'react-native'
import { Flow } from 'react-native-animated-spinkit' import { Flow } from 'react-native-animated-spinkit'
@ -18,6 +18,7 @@ export interface Props {
loading?: boolean loading?: boolean
disabled?: boolean disabled?: boolean
destructive?: boolean
onPress: () => void onPress: () => void
} }
@ -34,23 +35,19 @@ const HeaderRight: React.FC<Props> = ({
background = false, background = false,
loading, loading,
disabled, disabled,
destructive = false,
onPress onPress
}) => { }) => {
const { colors, theme } = useTheme() const { colors, theme } = useTheme()
const loadingSpinkit = useMemo( const loadingSpinkit = () =>
() => ( loading ? (
<View style={{ position: 'absolute' }}> <View style={{ position: 'absolute' }}>
<Flow <Flow size={StyleConstants.Font.Size.M * 1.25} color={colors.secondary} />
size={StyleConstants.Font.Size.M * 1.25}
color={colors.secondary}
/>
</View> </View>
), ) : null
[theme]
)
const children = useMemo(() => { const children = () => {
switch (type) { switch (type) {
case 'icon': case 'icon':
return ( return (
@ -59,9 +56,9 @@ const HeaderRight: React.FC<Props> = ({
name={content} name={content}
style={{ opacity: loading ? 0 : 1 }} style={{ opacity: loading ? 0 : 1 }}
size={StyleConstants.Spacing.M * 1.25} size={StyleConstants.Spacing.M * 1.25}
color={disabled ? colors.secondary : colors.primaryDefault} color={disabled ? colors.secondary : destructive ? colors.red : colors.primaryDefault}
/> />
{loading && loadingSpinkit} {loadingSpinkit()}
</> </>
) )
case 'text': case 'text':
@ -69,17 +66,22 @@ const HeaderRight: React.FC<Props> = ({
<> <>
<CustomText <CustomText
fontStyle='M' fontStyle='M'
fontWeight={destructive ? 'Bold' : 'Normal'}
style={{ style={{
color: disabled ? colors.secondary : colors.primaryDefault, color: disabled
? colors.secondary
: destructive
? colors.red
: colors.primaryDefault,
opacity: loading ? 0 : 1 opacity: loading ? 0 : 1
}} }}
children={content} children={content}
/> />
{loading && loadingSpinkit} {loadingSpinkit()}
</> </>
) )
} }
}, [theme, loading, disabled]) }
return ( return (
<Pressable <Pressable
@ -88,20 +90,16 @@ const HeaderRight: React.FC<Props> = ({
accessibilityRole='button' accessibilityRole='button'
accessibilityState={accessibilityState} accessibilityState={accessibilityState}
onPress={onPress} onPress={onPress}
children={children} children={children()}
disabled={disabled || loading} disabled={disabled || loading}
style={{ style={{
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
backgroundColor: background backgroundColor: background ? colors.backgroundOverlayDefault : undefined,
? colors.backgroundOverlayDefault
: undefined,
minHeight: 44, minHeight: 44,
minWidth: 44, minWidth: 44,
marginRight: native marginRight: native ? -StyleConstants.Spacing.S : StyleConstants.Spacing.S,
? -StyleConstants.Spacing.S
: StyleConstants.Spacing.S,
...(type === 'icon' && { ...(type === 'icon' && {
borderRadius: 100 borderRadius: 100
}), }),

View File

@ -1,5 +1,5 @@
import HeaderLeft from '@components/Header/Left'
import HeaderCenter from '@components/Header/Center' import HeaderCenter from '@components/Header/Center'
import HeaderLeft from '@components/Header/Left'
import HeaderRight from '@components/Header/Right' import HeaderRight from '@components/Header/Right'
export { HeaderLeft, HeaderCenter, HeaderRight } export { HeaderLeft, HeaderCenter, HeaderRight }

View File

@ -3,7 +3,7 @@ import { useTheme } from '@utils/styles/ThemeManager'
import React, { forwardRef, RefObject } from 'react' import React, { forwardRef, RefObject } from 'react'
import { Platform, TextInput, TextInputProps, View } from 'react-native' import { Platform, TextInput, TextInputProps, View } from 'react-native'
import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated' import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'
import { EmojisState } from './Emojis/helpers/EmojisContext' import { EmojisState } from './Emojis/Context'
import CustomText from './Text' import CustomText from './Text'
export type Props = { export type Props = {
@ -85,7 +85,6 @@ const ComponentInput = forwardRef(
multiline, multiline,
numberOfLines: Platform.OS === 'android' ? 5 : undefined numberOfLines: Platform.OS === 'android' ? 5 : undefined
})} })}
keyboardAppearance={mode}
textAlignVertical='top' textAlignVertical='top'
{...props} {...props}
/> />

View File

@ -1,66 +0,0 @@
import CustomText from '@components/Text'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react'
import { View, ViewStyle } from 'react-native'
import { PlaceholderLine } from 'rn-placeholder'
export interface Props {
style?: ViewStyle
header: string
content?: string
potentialWidth?: number
}
const InstanceInfo = React.memo(
({ style, header, content, potentialWidth }: Props) => {
const { colors } = useTheme()
return (
<View
style={[
{
flex: 1,
marginTop: StyleConstants.Spacing.M,
paddingLeft: StyleConstants.Spacing.Global.PagePadding,
paddingRight: StyleConstants.Spacing.Global.PagePadding
},
style
]}
accessible
>
<CustomText
fontStyle='S'
style={{
marginBottom: StyleConstants.Spacing.XS,
color: colors.primaryDefault
}}
fontWeight='Bold'
children={header}
/>
{content ? (
<CustomText
fontStyle='M'
style={{ color: colors.primaryDefault }}
children={content}
/>
) : (
<PlaceholderLine
width={
potentialWidth
? potentialWidth * StyleConstants.Font.Size.M
: undefined
}
height={StyleConstants.Font.LineHeight.M}
color={colors.shimmerDefault}
noMargin
style={{ borderRadius: 0 }}
/>
)}
</View>
)
},
(prev, next) => prev.content === next.content
)
export default InstanceInfo

View File

@ -1,9 +1,21 @@
import Button from '@components/Button' import Button from '@components/Button'
import Icon from '@components/Icon' import Icon from '@components/Icon'
import browserPackage from '@helpers/browserPackage' import { useNavigation } from '@react-navigation/native'
import apiGeneral from '@utils/api/general'
import browserPackage from '@utils/helpers/browserPackage'
import { featureCheck } from '@utils/helpers/featureCheck'
import { TabMeStackNavigationProp } from '@utils/navigation/navigators'
import { queryClient } from '@utils/queryHooks'
import { redirectUri, useAppsMutation } from '@utils/queryHooks/apps' import { redirectUri, useAppsMutation } from '@utils/queryHooks/apps'
import { useInstanceQuery } from '@utils/queryHooks/instance' import { useInstanceQuery } from '@utils/queryHooks/instance'
import { checkInstanceFeature, getInstances } from '@utils/slices/instancesSlice' import { StorageAccount } from '@utils/storage/account'
import {
generateAccountKey,
getGlobalStorage,
setAccount,
setAccountStorage,
setGlobalStorage
} from '@utils/storage/actions'
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 * as AuthSession from 'expo-auth-session' import * as AuthSession from 'expo-auth-session'
@ -13,16 +25,8 @@ import React, { RefObject, useCallback, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { Alert, Image, KeyboardAvoidingView, Platform, TextInput, View } from 'react-native' import { Alert, Image, KeyboardAvoidingView, Platform, TextInput, View } from 'react-native'
import { ScrollView } from 'react-native-gesture-handler' import { ScrollView } from 'react-native-gesture-handler'
import { useSelector } from 'react-redux' import parse from 'url-parse'
import { Placeholder } from 'rn-placeholder'
import validUrl from 'valid-url'
import InstanceInfo from './Info'
import CustomText from '../Text' import CustomText from '../Text'
import { useNavigation } from '@react-navigation/native'
import { TabMeStackNavigationProp } from '@utils/navigation/navigators'
import queryClient from '@helpers/queryClient'
import { useAppDispatch } from '@root/store'
import addInstance from '@utils/slices/instances/add'
export interface Props { export interface Props {
scrollViewRef?: RefObject<ScrollView> scrollViewRef?: RefObject<ScrollView>
@ -35,7 +39,7 @@ const ComponentInstance: React.FC<Props> = ({
disableHeaderImage, disableHeaderImage,
goBack = false goBack = false
}) => { }) => {
const { t } = useTranslation('componentInstance') const { t } = useTranslation(['common', 'componentInstance'])
const { colors, mode } = useTheme() const { colors, mode } = useTheme()
const navigation = useNavigation<TabMeStackNavigationProp<'Tab-Me-Root' | 'Tab-Me-Switch'>>() const navigation = useNavigation<TabMeStackNavigationProp<'Tab-Me-Root' | 'Tab-Me-Switch'>>()
@ -44,11 +48,9 @@ const ComponentInstance: React.FC<Props> = ({
const whitelisted: boolean = const whitelisted: boolean =
!!domain.length && !!domain.length &&
!!errorCode && !!errorCode &&
!!validUrl.isHttpsUri(`https://${domain}`) && !!(parse(`https://${domain}/`).hostname === domain) &&
errorCode === 401 errorCode === 401
const dispatch = useAppDispatch()
const instances = useSelector(getInstances, () => true)
const instanceQuery = useInstanceQuery({ const instanceQuery = useInstanceQuery({
domain, domain,
options: { options: {
@ -62,8 +64,6 @@ const ComponentInstance: React.FC<Props> = ({
} }
}) })
const deprecateAuthFollow = useSelector(checkInstanceFeature('deprecate_auth_follow'))
const appsMutation = useAppsMutation({ const appsMutation = useAppsMutation({
retry: false, retry: false,
onSuccess: async (data, variables) => { onSuccess: async (data, variables) => {
@ -75,56 +75,145 @@ const ComponentInstance: React.FC<Props> = ({
const request = new AuthSession.AuthRequest({ const request = new AuthSession.AuthRequest({
clientId, clientId,
clientSecret, clientSecret,
scopes: deprecateAuthFollow scopes: variables.scopes,
? ['read', 'write', 'push']
: ['read', 'write', 'follow', 'push'],
redirectUri redirectUri
}) })
await request.makeAuthUrlAsync(discovery) await request.makeAuthUrlAsync(discovery)
const promptResult = await request.promptAsync(discovery) const promptResult = await request.promptAsync(discovery, await browserPackage())
if (promptResult?.type === 'success') { if (promptResult.type === 'success') {
const { accessToken } = await AuthSession.exchangeCodeAsync( const { accessToken } = await AuthSession.exchangeCodeAsync(
{ {
clientId, clientId,
clientSecret, clientSecret,
scopes: ['read', 'write', 'follow', 'push'], scopes: variables.scopes,
redirectUri, redirectUri,
code: promptResult.params.code, code: promptResult.params.code,
extraParams: { grant_type: 'authorization_code' } extraParams: {
client_id: clientId,
client_secret: clientSecret,
grant_type: 'authorization_code',
...(request.codeVerifier && { code_verifier: request.codeVerifier })
}
}, },
{ tokenEndpoint: `https://${variables.domain}/oauth/token` } { tokenEndpoint: `https://${variables.domain}/oauth/token` }
) )
queryClient.clear() queryClient.clear()
dispatch(
addInstance({ const {
domain, body: { id, acct, avatar_static }
token: accessToken, } = await apiGeneral<Mastodon.Account>({
instance: instanceQuery.data!, method: 'get',
appData: { clientId, clientSecret } domain,
}) url: `api/v1/accounts/verify_credentials`,
headers: { Authorization: `Bearer ${accessToken}` }
})
const accounts = getGlobalStorage.object('accounts')
const accountKey = generateAccountKey({ domain, id })
const account = accounts?.find(account => account === accountKey)
const accountDetails: StorageAccount = {
'auth.clientId': clientId,
'auth.clientSecret': clientSecret,
'auth.token': accessToken,
'auth.domain': domain,
'auth.account.id': id,
'auth.account.acct': acct,
'auth.account.domain':
(instanceQuery.data as Mastodon.Instance_V2)?.domain ||
instanceQuery.data?.account_domain ||
((instanceQuery.data as Mastodon.Instance_V1)?.uri
? parse((instanceQuery.data as Mastodon.Instance_V1).uri).hostname
: undefined) ||
(instanceQuery.data as Mastodon.Instance_V1)?.uri,
'auth.account.avatar_static': avatar_static,
version: instanceQuery.data?.version || '0',
preferences: undefined,
notifications: {
follow: true,
follow_request: true,
favourite: true,
reblog: true,
mention: true,
poll: true,
status: true,
update: true,
'admin.sign_up': true,
'admin.report': true
},
push: {
global: false,
decode: false,
alerts: {
follow: true,
follow_request: true,
favourite: true,
reblog: true,
mention: true,
poll: true,
status: true,
update: true,
'admin.sign_up': false,
'admin.report': false
},
key: Math.random().toString(36).slice(2, 12)
},
page_local: {
showBoosts: true,
showReplies: true
},
page_me: {
followedTags: { shown: false },
lists: { shown: false },
announcements: { shown: false, unread: 0 }
},
drafts: [],
emojis_frequent: []
}
setAccountStorage(
Object.keys(accountDetails).map((key: keyof StorageAccount) => ({
key,
value: accountDetails[key]
})),
accountKey
) )
if (!account) {
setGlobalStorage('accounts', accounts?.concat([accountKey]))
}
setAccount(accountKey)
goBack && navigation.goBack() goBack && navigation.goBack()
} }
} }
}) })
const scopes = featureCheck('deprecate_auth_follow')
? ['read', 'write', 'push']
: ['read', 'write', 'follow', 'push']
const processUpdate = useCallback(() => { const processUpdate = useCallback(() => {
if (domain) { if (domain) {
if (instances && instances.filter(instance => instance.url === domain).length) { const accounts = getGlobalStorage.object('accounts')
Alert.alert(t('update.alert.title'), t('update.alert.message'), [ if (accounts?.filter(account => account.startsWith(`${domain}/`)).length) {
{ Alert.alert(
text: t('common:buttons.cancel'), t('componentInstance:update.alert.title'),
style: 'cancel' t('componentInstance:update.alert.message'),
}, [
{ {
text: t('common:buttons.continue'), text: t('common:buttons.cancel'),
onPress: () => appsMutation.mutate({ domain }) style: 'cancel'
} },
]) {
text: t('common:buttons.continue'),
onPress: () => appsMutation.mutate({ domain, scopes })
}
]
)
} else { } else {
appsMutation.mutate({ domain }) appsMutation.mutate({ domain, scopes })
} }
} }
}, [domain]) }, [domain])
@ -204,12 +293,13 @@ const ComponentInstance: React.FC<Props> = ({
text === domain && text === domain &&
instanceQuery.isSuccess && instanceQuery.isSuccess &&
instanceQuery.data && instanceQuery.data &&
instanceQuery.data.uri // @ts-ignore
(instanceQuery.data.domain || instanceQuery.data.uri)
) { ) {
processUpdate() processUpdate()
} }
}} }}
placeholder={' ' + t('server.textInput.placeholder')} placeholder={' ' + t('componentInstance:server.textInput.placeholder')}
placeholderTextColor={colors.secondary} placeholderTextColor={colors.secondary}
returnKeyType='go' returnKeyType='go'
keyboardAppearance={mode} keyboardAppearance={mode}
@ -222,9 +312,10 @@ const ComponentInstance: React.FC<Props> = ({
/> />
<Button <Button
type='text' type='text'
content={t('server.button')} content={t('componentInstance:server.button')}
onPress={processUpdate} onPress={processUpdate}
disabled={!instanceQuery.data?.uri && !whitelisted} // @ts-ignore
disabled={!(instanceQuery.data?.domain || instanceQuery.data?.uri) && !whitelisted}
loading={instanceQuery.isFetching || appsMutation.isLoading} loading={instanceQuery.isFetching || appsMutation.isLoading}
/> />
</View> </View>
@ -239,37 +330,9 @@ const ComponentInstance: React.FC<Props> = ({
paddingTop: StyleConstants.Spacing.XS paddingTop: StyleConstants.Spacing.XS
}} }}
> >
{t('server.whitelisted')} {t('componentInstance:server.whitelisted')}
</CustomText> </CustomText>
) : ( ) : null}
<Placeholder>
<InstanceInfo
header={t('server.information.name')}
content={instanceQuery.data?.title || undefined}
potentialWidth={2}
/>
<View style={{ flex: 1, flexDirection: 'row' }}>
<InstanceInfo
style={{ alignItems: 'flex-start' }}
header={t('server.information.accounts')}
content={instanceQuery.data?.stats?.user_count?.toString() || undefined}
potentialWidth={4}
/>
<InstanceInfo
style={{ alignItems: 'center' }}
header={t('server.information.statuses')}
content={instanceQuery.data?.stats?.status_count?.toString() || undefined}
potentialWidth={4}
/>
<InstanceInfo
style={{ alignItems: 'flex-end' }}
header={t('server.information.domains')}
content={instanceQuery.data?.stats?.domain_count?.toString() || undefined}
potentialWidth={4}
/>
</View>
</Placeholder>
)}
<View <View
style={{ style={{
flexDirection: 'row', flexDirection: 'row',
@ -287,7 +350,7 @@ const ComponentInstance: React.FC<Props> = ({
}} }}
/> />
<CustomText fontStyle='S' style={{ flex: 1, color: colors.secondary }}> <CustomText fontStyle='S' style={{ flex: 1, color: colors.secondary }}>
{t('server.disclaimer.base')} {t('componentInstance:server.disclaimer.base')}
</CustomText> </CustomText>
</View> </View>
<View <View
@ -312,7 +375,8 @@ const ComponentInstance: React.FC<Props> = ({
accessibilityRole='link' accessibilityRole='link'
> >
<Trans <Trans
i18nKey='componentInstance:server.terms.base' ns='componentInstance'
i18nKey='server.terms.base'
components={[ components={[
<CustomText <CustomText
accessible accessible

View File

@ -1,6 +1,6 @@
import { StyleConstants } from '@utils/styles/constants'
import React from 'react' import React from 'react'
import { View } from 'react-native' import { View } from 'react-native'
import { StyleConstants } from '@utils/styles/constants'
export interface Props { export interface Props {
children: React.ReactNode children: React.ReactNode

View File

@ -1,8 +1,8 @@
import React from 'react' import CustomText from '@components/Text'
import { View } from 'react-native'
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 CustomText from '@components/Text' import React from 'react'
import { View } from 'react-native'
export interface Props { export interface Props {
heading: string heading: string

View File

@ -4,7 +4,7 @@ import { useAccessibility } from '@utils/accessibility/AccessibilityManager'
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 { ColorDefinitions } from '@utils/styles/themes' import { ColorDefinitions } from '@utils/styles/themes'
import React, { useMemo } from 'react' import React from 'react'
import { View } from 'react-native' import { View } from 'react-native'
import { Flow } from 'react-native-animated-spinkit' import { Flow } from 'react-native-animated-spinkit'
import { State, Switch, TapGestureHandler } from 'react-native-gesture-handler' import { State, Switch, TapGestureHandler } from 'react-native-gesture-handler'
@ -47,15 +47,6 @@ const MenuRow: React.FC<Props> = ({
const { colors, theme } = useTheme() const { colors, theme } = useTheme()
const { screenReaderEnabled } = useAccessibility() const { screenReaderEnabled } = useAccessibility()
const loadingSpinkit = useMemo(
() => (
<View style={{ position: 'absolute' }}>
<Flow size={StyleConstants.Font.Size.M * 1.25} color={colors.secondary} />
</View>
),
[theme]
)
return ( return (
<View <View
style={{ minHeight: 50 }} style={{ minHeight: 50 }}
@ -157,7 +148,11 @@ const MenuRow: React.FC<Props> = ({
style={{ marginLeft: 8, opacity: loading ? 0 : 1 }} style={{ marginLeft: 8, opacity: loading ? 0 : 1 }}
/> />
) : null} ) : null}
{loading && loadingSpinkit} {loading ? (
<View style={{ position: 'absolute' }}>
<Flow size={StyleConstants.Font.Size.M * 1.25} color={colors.secondary} />
</View>
) : null}
</View> </View>
) : null} ) : null}
</View> </View>

View File

@ -4,7 +4,6 @@ import { useTheme } from '@utils/styles/ThemeManager'
import React, { RefObject } from 'react' import React, { RefObject } from 'react'
import { AccessibilityInfo } from 'react-native' import { AccessibilityInfo } from 'react-native'
import FlashMessage, { MessageType, showMessage } from 'react-native-flash-message' import FlashMessage, { MessageType, showMessage } from 'react-native-flash-message'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import haptics from './haptics' import haptics from './haptics'
const displayMessage = ({ const displayMessage = ({
@ -53,7 +52,6 @@ const displayMessage = ({
const Message = React.forwardRef<FlashMessage>((_, ref) => { const Message = React.forwardRef<FlashMessage>((_, ref) => {
const { colors, theme } = useTheme() const { colors, theme } = useTheme()
const insets = useSafeAreaInsets()
enum iconMapping { enum iconMapping {
success = 'CheckCircle', success = 'CheckCircle',
@ -96,8 +94,7 @@ const Message = React.forwardRef<FlashMessage>((_, ref) => {
shadowOffset: { width: 0, height: 0 }, shadowOffset: { width: 0, height: 0 },
shadowOpacity: theme === 'light' ? 0.16 : 0.24, shadowOpacity: theme === 'light' ? 0.16 : 0.24,
shadowRadius: 4, shadowRadius: 4,
paddingRight: StyleConstants.Spacing.M * 2, paddingRight: StyleConstants.Spacing.M * 2
marginTop: ref ? undefined : insets.top
}} }}
titleStyle={{ titleStyle={{
color: colors.primaryDefault, color: colors.primaryDefault,
@ -109,7 +106,7 @@ const Message = React.forwardRef<FlashMessage>((_, ref) => {
...StyleConstants.FontStyle.S ...StyleConstants.FontStyle.S
}} }}
// @ts-ignore // @ts-ignore
textProps={{ numberOfLines: 2 }} textProps={{ numberOfLines: 3 }}
/> />
) )
}) })

View File

@ -1,4 +0,0 @@
import ParseEmojis from './Parse/Emojis'
import ParseHTML from './Parse/HTML'
export { ParseEmojis, ParseHTML }

View File

@ -1,19 +1,17 @@
import CustomText from '@components/Text' import CustomText from '@components/Text'
import { useAccessibility } from '@utils/accessibility/AccessibilityManager' import { useAccessibility } from '@utils/accessibility/AccessibilityManager'
import { getSettingsFontsize } from '@utils/slices/settingsSlice' import { useGlobalStorage } from '@utils/storage/actions'
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'
import React from 'react' import React from 'react'
import { Platform, TextStyle } from 'react-native' import { Platform, TextStyle } from 'react-native'
import FastImage from 'react-native-fast-image' import FastImage from 'react-native-fast-image'
import { useSelector } from 'react-redux'
import validUrl from 'valid-url'
const regexEmoji = new RegExp(/(:[A-Za-z0-9_]+:)/) const regexEmoji = new RegExp(/(:[A-Za-z0-9_]+:)/)
export interface Props { export interface Props {
content: string content?: string
emojis?: Mastodon.Emoji[] emojis?: Mastodon.Emoji[]
size?: 'S' | 'M' | 'L' size?: 'S' | 'M' | 'L'
adaptiveSize?: boolean adaptiveSize?: boolean
@ -21,79 +19,81 @@ export interface Props {
style?: TextStyle style?: TextStyle
} }
const ParseEmojis = React.memo( const ParseEmojis: React.FC<Props> = ({
({ content, emojis, size = 'M', adaptiveSize = false, fontBold = false, style }: Props) => { content,
const { reduceMotionEnabled } = useAccessibility() emojis,
size = 'M',
adaptiveSize = false,
fontBold = false,
style
}) => {
if (!content) return null
const adaptiveFontsize = useSelector(getSettingsFontsize) const { reduceMotionEnabled } = useAccessibility()
const adaptedFontsize = adaptiveScale(
StyleConstants.Font.Size[size],
adaptiveSize ? adaptiveFontsize : 0
)
const adaptedLineheight = adaptiveScale(
StyleConstants.Font.LineHeight[size],
adaptiveSize ? adaptiveFontsize : 0
)
const { colors, theme } = useTheme() const [adaptiveFontsize] = useGlobalStorage.number('app.font_size')
const adaptedFontsize = adaptiveScale(
StyleConstants.Font.Size[size],
adaptiveSize ? adaptiveFontsize : 0
)
const adaptedLineheight = adaptiveScale(
StyleConstants.Font.LineHeight[size],
adaptiveSize ? adaptiveFontsize : 0
)
return ( const { colors, theme } = useTheme()
<CustomText
style={[ return (
{ <CustomText
color: colors.primaryDefault, style={[
fontSize: adaptedFontsize, {
lineHeight: adaptedLineheight color: colors.primaryDefault,
}, fontSize: adaptedFontsize,
style lineHeight: adaptedLineheight
]} },
fontWeight={fontBold ? 'Bold' : undefined} style
> ]}
{emojis ? ( fontWeight={fontBold ? 'Bold' : undefined}
content >
.split(regexEmoji) {emojis ? (
.filter(f => f) content
.map((str, i) => { .split(regexEmoji)
if (str.match(regexEmoji)) { .filter(f => f)
const emojiShortcode = str.split(regexEmoji)[1] .map((str, i) => {
const emojiIndex = emojis.findIndex(emoji => { if (str.match(regexEmoji)) {
return emojiShortcode === `:${emoji.shortcode}:` const emojiShortcode = str.split(regexEmoji)[1]
}) const emojiIndex = emojis.findIndex(emoji => {
if (emojiIndex === -1) { return emojiShortcode === `:${emoji.shortcode}:`
return <CustomText key={emojiShortcode + i}>{emojiShortcode}</CustomText> })
} else { if (emojiIndex === -1) {
const uri = reduceMotionEnabled return <CustomText key={emojiShortcode + i}>{emojiShortcode}</CustomText>
? emojis[emojiIndex].static_url
: emojis[emojiIndex].url
if (validUrl.isHttpsUri(uri)) {
return (
<CustomText key={emojiShortcode + i}>
{i === 0 ? ' ' : undefined}
<FastImage
source={{ uri }}
style={{
width: adaptedFontsize,
height: adaptedFontsize,
transform: [{ translateY: Platform.OS === 'ios' ? -1 : 2 }]
}}
/>
</CustomText>
)
} else {
return null
}
}
} else { } else {
return <CustomText key={i}>{str}</CustomText> const uri = reduceMotionEnabled
? emojis[emojiIndex].static_url
: emojis[emojiIndex].url
return (
<CustomText key={emojiShortcode + i}>
{i === 0 ? ' ' : undefined}
<FastImage
source={{ uri: uri.trim() }}
style={{
width: adaptedFontsize,
height: adaptedFontsize,
transform: [{ translateY: Platform.OS === 'ios' ? -1 : 2 }]
}}
/>
</CustomText>
)
} }
}) } else {
) : ( return <CustomText key={i}>{str}</CustomText>
<CustomText>{content}</CustomText> }
)} })
</CustomText> ) : (
) <CustomText>{content}</CustomText>
}, )}
(prev, next) => prev.content === next.content </CustomText>
) )
}
export default ParseEmojis export default ParseEmojis

View File

@ -1,315 +1,302 @@
import Icon from '@components/Icon' import Icon from '@components/Icon'
import openLink from '@components/openLink' import openLink from '@components/openLink'
import ParseEmojis from '@components/Parse/Emojis' import ParseEmojis from '@components/Parse/Emojis'
import CustomText from '@components/Text' import StatusContext from '@components/Timeline/Shared/Context'
import { useNavigation, useRoute } from '@react-navigation/native' import { useNavigation, useRoute } 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 { getSettingsFontsize } from '@utils/slices/settingsSlice' import { useAccountStorage, useGlobalStorage } from '@utils/storage/actions'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import layoutAnimation from '@utils/styles/layoutAnimation' import layoutAnimation from '@utils/styles/layoutAnimation'
import { adaptiveScale } from '@utils/styles/scaling' import { adaptiveScale } from '@utils/styles/scaling'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import { isEqual } from 'lodash' import { ChildNode } from 'domhandler'
import React, { useCallback, useState } from 'react' import { ElementType, parseDocument } from 'htmlparser2'
import i18next from 'i18next'
import React, { useContext, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Platform, Pressable, TextStyleIOS, View } from 'react-native' import { Platform, Pressable, Text, View } from 'react-native'
import HTMLView from 'react-native-htmlview'
import { useSelector } from 'react-redux'
// Prevent going to the same hashtag multiple times
const renderNode = ({
routeParams,
colors,
node,
index,
adaptedFontsize,
adaptedLineheight,
navigation,
mentions,
tags,
showFullLink,
disableDetails
}: {
routeParams?: any
colors: any
node: any
index: number
adaptedFontsize: number
adaptedLineheight: number
navigation: StackNavigationProp<TabLocalStackParamList>
mentions?: Mastodon.Mention[]
tags?: Mastodon.Tag[]
showFullLink: boolean
disableDetails: boolean
}) => {
switch (node.name) {
case 'a':
const classes = node.attribs.class
const href = node.attribs.href
if (classes) {
if (classes.includes('hashtag')) {
const tag = href?.split(new RegExp(/\/tag\/(.*)|\/tags\/(.*)/))
const differentTag = routeParams?.hashtag
? routeParams.hashtag !== tag[1] && routeParams.hashtag !== tag[2]
: true
return (
<CustomText
accessible
key={index}
style={{
color: colors.blue,
fontSize: adaptedFontsize,
lineHeight: adaptedLineheight
}}
onPress={() => {
!disableDetails &&
differentTag &&
navigation.push('Tab-Shared-Hashtag', {
hashtag: tag[1] || tag[2]
})
}}
>
{node.children[0].data}
{node.children[1]?.children[0].data}
</CustomText>
)
} else if (classes.includes('mention') && mentions) {
const accountIndex = mentions.findIndex(mention => mention.url === href)
const differentAccount = routeParams?.account
? routeParams.account.id !== mentions[accountIndex]?.id
: true
return (
<CustomText
key={index}
style={{
color: accountIndex !== -1 ? colors.blue : colors.primaryDefault,
fontSize: adaptedFontsize,
lineHeight: adaptedLineheight
}}
onPress={() => {
accountIndex !== -1 &&
!disableDetails &&
differentAccount &&
navigation.push('Tab-Shared-Account', {
account: mentions[accountIndex]
})
}}
>
{node.children[0].data}
{node.children[1]?.children[0].data}
</CustomText>
)
}
} else {
const domain = href?.split(new RegExp(/:\/\/(.[^\/]+\/.{3})/))
// Need example here
const content = node.children && node.children[0] && node.children[0].data
const shouldBeTag = tags && tags.filter(tag => `#${tag.name}` === content).length > 0
return (
<CustomText
key={index}
style={{
color: colors.blue,
alignItems: 'center',
fontSize: adaptedFontsize,
lineHeight: adaptedLineheight
}}
onPress={async () => {
if (!disableDetails) {
if (shouldBeTag) {
navigation.push('Tab-Shared-Hashtag', {
hashtag: content.substring(1)
})
} else {
await openLink(href, navigation)
}
}
}}
>
{content && content !== href ? content : showFullLink ? href : domain?.[1]}
{!shouldBeTag ? '...' : null}
</CustomText>
)
}
break
case 'p':
if (!node.children.length) {
return <View key={index} /> // bug when the tag is empty
}
break
}
}
export interface Props { export interface Props {
content: string content: string
size?: 'S' | 'M' | 'L' size?: 'S' | 'M' | 'L'
textStyles?: TextStyleIOS
adaptiveSize?: boolean adaptiveSize?: boolean
emojis?: Mastodon.Emoji[]
mentions?: Mastodon.Mention[]
tags?: Mastodon.Tag[]
showFullLink?: boolean showFullLink?: boolean
numberOfLines?: number numberOfLines?: number
expandHint?: string expandHint?: string
highlighted?: boolean
disableDetails?: boolean
selectable?: boolean selectable?: boolean
setSpoilerExpanded?: React.Dispatch<React.SetStateAction<boolean>> setSpoilerExpanded?: React.Dispatch<React.SetStateAction<boolean>>
emojis?: Mastodon.Emoji[]
mentions?: Mastodon.Mention[]
} }
const ParseHTML = React.memo( const ParseHTML: React.FC<Props> = ({
({ content,
content, size = 'M',
size = 'M', adaptiveSize = false,
textStyles, showFullLink = false,
adaptiveSize = false, numberOfLines = 10,
emojis, expandHint,
mentions, selectable = false,
tags, setSpoilerExpanded,
showFullLink = false, emojis,
numberOfLines = 10, mentions
expandHint, }) => {
highlighted = false, const { status, highlighted, disableDetails, excludeMentions } = useContext(StatusContext)
disableDetails = false,
selectable = false,
setSpoilerExpanded
}: Props) => {
const adaptiveFontsize = useSelector(getSettingsFontsize)
const adaptedFontsize = adaptiveScale(
StyleConstants.Font.Size[size],
adaptiveSize ? adaptiveFontsize : 0
)
const adaptedLineheight = adaptiveScale(
StyleConstants.Font.LineHeight[size],
adaptiveSize ? adaptiveFontsize : 0
)
const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>() const [adaptiveFontsize] = useGlobalStorage.number('app.font_size')
const route = useRoute() const adaptedFontsize = adaptiveScale(
const { colors, theme } = useTheme() StyleConstants.Font.Size[size],
const { t } = useTranslation('componentParse') adaptiveSize ? adaptiveFontsize : 0
if (!expandHint) { )
expandHint = t('HTML.defaultHint') const adaptedLineheight =
Platform.OS === 'ios'
? adaptiveScale(StyleConstants.Font.LineHeight[size], adaptiveSize ? adaptiveFontsize : 0)
: undefined
const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>()
const { params } = useRoute()
const { colors } = useTheme()
const { t } = useTranslation('componentParse')
if (!expandHint) {
expandHint = t('HTML.defaultHint')
}
if (disableDetails) {
numberOfLines = 4
}
const [followedTags] = useAccountStorage.object('followed_tags')
const [totalLines, setTotalLines] = useState<number>()
const [expanded, setExpanded] = useState(highlighted)
const document = parseDocument(content)
const unwrapNode = (node: ChildNode): string => {
switch (node.type) {
case ElementType.Text:
return node.data
case ElementType.Tag:
if (node.name === 'span') {
if (node.attribs.class?.includes('invisible') && !showFullLink) return ''
if (node.attribs.class?.includes('ellipsis') && !showFullLink)
return node.children.map(child => unwrapNode(child)).join('') + '...'
}
return node.children.map(child => unwrapNode(child)).join('')
default:
return ''
} }
}
const prevMentionRemoved = useRef<boolean>(false)
const renderNode = (node: ChildNode, index: number) => {
switch (node.type) {
case ElementType.Text:
let content: string = node.data
if (prevMentionRemoved.current) {
prevMentionRemoved.current = false // Removing empty spaces appeared between tags and mentions
if (node.data.trim().length) {
content = excludeMentions?.current.length
? node.data.replace(new RegExp(/^\s+/), '')
: node.data
} else {
content = node.data.trim()
}
}
if (disableDetails) {
numberOfLines = 4
}
const renderNodeCallback = useCallback(
(node: any, index: any) =>
renderNode({
routeParams: route.params,
colors,
node,
index,
adaptedFontsize,
adaptedLineheight,
navigation,
mentions,
tags,
showFullLink,
disableDetails
}),
[]
)
const textComponent = useCallback(({ children }: any) => {
if (children) {
return ( return (
<ParseEmojis <ParseEmojis
content={children?.toString()} key={index}
emojis={emojis} content={content}
emojis={status?.emojis || emojis}
size={size} size={size}
adaptiveSize={adaptiveSize} adaptiveSize={adaptiveSize}
/> />
) )
} else { case ElementType.Tag:
return null switch (node.name) {
} case 'a':
}, []) const classes = node.attribs.class
const rootComponent = useCallback( const href = node.attribs.href
({ children }: any) => { if (classes) {
const { t } = useTranslation('componentParse') if (classes.includes('hashtag')) {
const children = node.children.map(unwrapNode).join('')
const tag =
href.match(new RegExp(/\/tags?\/(.*)/, 'i'))?.[1]?.toLowerCase() ||
children.match(new RegExp(/#(\S+)/))?.[1]?.toLowerCase()
const [totalLines, setTotalLines] = useState<number>() const paramsHashtag = (params as { hashtag: Mastodon.Tag['name'] } | undefined)
const [expanded, setExpanded] = useState(highlighted) ?.hashtag
const sameHashtag = paramsHashtag === tag
const isFollowing = followedTags?.find(t => t.name === tag)
return (
<Text
key={index}
style={[
{ color: tag?.length ? colors.blue : colors.red },
isFollowing
? {
textDecorationColor: tag?.length ? colors.blue : colors.red,
textDecorationLine: 'underline',
textDecorationStyle: 'dotted'
}
: null
]}
onPress={() =>
tag?.length &&
!disableDetails &&
!sameHashtag &&
navigation.push('Tab-Shared-Hashtag', { hashtag: tag })
}
children={children}
/>
)
}
if (classes.includes('mention') && (status?.mentions?.length || mentions?.length)) {
const matchedMention = (status?.mentions || mentions || []).find(
mention => mention.url === href
)
if (
matchedMention &&
excludeMentions?.current.find(eM => eM.id === matchedMention.id)
) {
prevMentionRemoved.current = true
return null
}
const paramsAccount = (params as { account: Mastodon.Account } | undefined)?.account
const sameAccount = paramsAccount?.id === matchedMention?.id
return (
<Text
key={index}
style={{ color: matchedMention ? colors.blue : undefined }}
onPress={() =>
matchedMention &&
!disableDetails &&
!sameAccount &&
navigation.push('Tab-Shared-Account', { account: matchedMention })
}
children={node.children.map(unwrapNode).join('')}
/>
)
}
}
return ( const content = node.children.map(child => unwrapNode(child)).join('')
<View style={{ overflow: 'hidden' }}> const shouldBeTag = status?.tags?.find(tag => `#${tag.name}` === content)
{(!disableDetails && typeof totalLines === 'number') || numberOfLines === 1 ? ( return (
<Pressable <Text
accessibilityLabel={t('HTML.accessibilityHint')} key={index}
onPress={() => { style={{ color: colors.blue }}
layoutAnimation() onPress={async () => {
setExpanded(!expanded) if (!disableDetails) {
if (setSpoilerExpanded) { if (shouldBeTag) {
setSpoilerExpanded(!expanded) navigation.push('Tab-Shared-Hashtag', {
hashtag: content.substring(1)
})
} else {
await openLink(href, navigation)
}
} }
}} }}
style={{ children={content}
flexDirection: 'row', />
justifyContent: 'center', )
alignItems: 'center', break
minHeight: 44, case 'br':
backgroundColor: colors.backgroundDefault return (
}} <Text
key={index}
style={{ lineHeight: adaptedLineheight ? adaptedLineheight / 2 : undefined }}
> >
<CustomText {'\n'}
style={{ </Text>
textAlign: 'center', )
...StyleConstants.FontStyle.S, case 'p':
color: colors.primaryDefault, if (index < document.children.length - 1) {
marginRight: StyleConstants.Spacing.S return (
}} <Text key={index}>
children={t('HTML.expanded', { {node.children.map((c, i) => renderNode(c, i))}
hint: expandHint, <Text
moreLines: style={{ lineHeight: adaptedLineheight ? adaptedLineheight / 2 : undefined }}
numberOfLines > 1 && typeof totalLines === 'number' >
? t('HTML.moreLines', { count: totalLines - numberOfLines }) {'\n\n'}
: '' </Text>
})} </Text>
/> )
<Icon } else {
name={expanded ? 'Minimize2' : 'Maximize2'} return <Text key={index} children={node.children.map((c, i) => renderNode(c, i))} />
color={colors.primaryDefault} }
strokeWidth={2} default:
size={StyleConstants.Font.Size[size]} return <Text key={index} children={node.children.map((c, i) => renderNode(c, i))} />
/> }
</Pressable> }
) : null} return null
<CustomText }
children={children} return (
onTextLayout={({ nativeEvent }) => { <View style={{ overflow: 'hidden' }}>
if (numberOfLines === 1 || nativeEvent.lines.length >= numberOfLines + 5) { {(!disableDetails && typeof totalLines === 'number') || numberOfLines === 1 ? (
setTotalLines(nativeEvent.lines.length) <Pressable
} accessibilityLabel={t('HTML.accessibilityHint')}
}} onPress={() => {
style={{ layoutAnimation()
...textStyles, setExpanded(!expanded)
height: numberOfLines === 1 && !expanded ? 0 : undefined if (setSpoilerExpanded) {
}} setSpoilerExpanded(!expanded)
numberOfLines={ }
typeof totalLines === 'number' ? (expanded ? 999 : numberOfLines) : undefined }}
} style={{
selectable={selectable} flexDirection: 'row',
/> justifyContent: 'center',
</View> alignItems: 'center',
) minHeight: 44,
}, backgroundColor: colors.backgroundDefault
[theme] }}
) >
<Text
return ( style={{
<HTMLView textAlign: 'center',
value={content} ...StyleConstants.FontStyle.S,
TextComponent={textComponent} color: colors.primaryDefault,
RootComponent={rootComponent} marginRight: StyleConstants.Spacing.S
renderNode={renderNodeCallback} }}
children={t('HTML.expanded', {
hint: expandHint,
moreLines:
numberOfLines > 1 && typeof totalLines === 'number'
? t('HTML.moreLines', { count: totalLines - numberOfLines })
: ''
})}
/>
<Icon
name={expanded ? 'Minimize2' : 'Maximize2'}
color={colors.primaryDefault}
strokeWidth={2}
size={StyleConstants.Font.Size[size]}
/>
</Pressable>
) : null}
<Text
children={document.children.map(renderNode)}
onTextLayout={({ nativeEvent }) => {
if (numberOfLines === 1 || nativeEvent.lines.length >= numberOfLines + 5) {
setTotalLines(nativeEvent.lines.length)
}
}}
style={{
fontSize: adaptedFontsize,
lineHeight: adaptedLineheight,
...(Platform.OS === 'ios' &&
status?.language &&
i18next.dir(status.language) === 'rtl' &&
({ writingDirection: 'rtl' } as { writingDirection: 'rtl' })),
height: numberOfLines === 1 && !expanded ? 0 : undefined
}}
numberOfLines={
typeof totalLines === 'number' ? (expanded ? 999 : numberOfLines) : undefined
}
selectable={selectable}
/> />
) </View>
}, )
(prev, next) => prev.content === next.content && isEqual(prev.emojis, next.emojis) }
)
export default ParseHTML export default ParseHTML

View File

@ -0,0 +1,4 @@
import ParseEmojis from './Emojis'
import ParseHTML from './HTML'
export { ParseEmojis, ParseHTML }

View File

@ -1,6 +1,7 @@
import Button from '@components/Button' import Button from '@components/Button'
import haptics from '@components/haptics' import haptics from '@components/haptics'
import { displayMessage } from '@components/Message' import { displayMessage } from '@components/Message'
import { useQueryClient } from '@tanstack/react-query'
import { QueryKeyRelationship, useRelationshipMutation } from '@utils/queryHooks/relationship' import { QueryKeyRelationship, useRelationshipMutation } from '@utils/queryHooks/relationship'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline' import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
@ -8,7 +9,6 @@ import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { StyleSheet, View } from 'react-native' import { StyleSheet, View } from 'react-native'
import { useQueryClient } from '@tanstack/react-query'
export interface Props { export interface Props {
id: Mastodon.Account['id'] id: Mastodon.Account['id']
@ -16,7 +16,7 @@ export interface Props {
const RelationshipIncoming: React.FC<Props> = ({ id }) => { const RelationshipIncoming: React.FC<Props> = ({ id }) => {
const { theme } = useTheme() const { theme } = useTheme()
const { t } = useTranslation() const { t } = useTranslation(['common', 'componentRelationship'])
const queryKeyRelationship: QueryKeyRelationship = ['Relationship', { id }] const queryKeyRelationship: QueryKeyRelationship = ['Relationship', { id }]
const queryKeyNotification: QueryKeyTimeline = ['Timeline', { page: 'Notifications' }] const queryKeyNotification: QueryKeyTimeline = ['Timeline', { page: 'Notifications' }]
@ -33,7 +33,7 @@ const RelationshipIncoming: React.FC<Props> = ({ id }) => {
type: 'error', type: 'error',
theme, theme,
message: t('common:message.error.message', { message: t('common:message.error.message', {
function: t(`relationship:${type}.function`) function: t(`componentRelationship:${type}.function` as any)
}), }),
...(err.status && ...(err.status &&
typeof err.status === 'number' && typeof err.status === 'number' &&

View File

@ -1,20 +1,19 @@
import Button from '@components/Button' import Button from '@components/Button'
import haptics from '@components/haptics' import haptics from '@components/haptics'
import { displayMessage } from '@components/Message' import { displayMessage } from '@components/Message'
import { useRoute } from '@react-navigation/native'
import { useQueryClient } from '@tanstack/react-query'
import { featureCheck } from '@utils/helpers/featureCheck'
import { import {
QueryKeyRelationship, QueryKeyRelationship,
useRelationshipMutation, useRelationshipMutation,
useRelationshipQuery useRelationshipQuery
} from '@utils/queryHooks/relationship' } from '@utils/queryHooks/relationship'
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 { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useQueryClient } from '@tanstack/react-query'
import { useSelector } from 'react-redux'
import { checkInstanceFeature } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants'
import { View } from 'react-native' import { View } from 'react-native'
import { useRoute } from '@react-navigation/native'
export interface Props { export interface Props {
id: Mastodon.Account['id'] id: Mastodon.Account['id']
@ -22,9 +21,9 @@ export interface Props {
const RelationshipOutgoing: React.FC<Props> = ({ id }: Props) => { const RelationshipOutgoing: React.FC<Props> = ({ id }: Props) => {
const { theme } = useTheme() const { theme } = useTheme()
const { t } = useTranslation('componentRelationship') const { t } = useTranslation(['common', 'componentRelationship'])
const canFollowNotify = useSelector(checkInstanceFeature('account_follow_notify')) const canFollowNotify = featureCheck('account_follow_notify')
const query = useRelationshipQuery({ id }) const query = useRelationshipQuery({ id })
@ -44,7 +43,7 @@ const RelationshipOutgoing: React.FC<Props> = ({ id }: Props) => {
theme, theme,
type: 'error', type: 'error',
message: t('common:message.error.message', { message: t('common:message.error.message', {
function: t(`${action}.function`) function: t(`componentRelationship:${action}.function` as any)
}), }),
...(err.status && ...(err.status &&
typeof err.status === 'number' && typeof err.status === 'number' &&
@ -61,15 +60,15 @@ const RelationshipOutgoing: React.FC<Props> = ({ id }: Props) => {
let onPress: () => void let onPress: () => void
if (query.isError) { if (query.isError) {
content = t('button.error') content = t('componentRelationship:button.error')
onPress = () => {} onPress = () => {}
} else { } else {
if (query.data?.blocked_by) { if (query.data?.blocked_by) {
content = t('button.blocked_by') content = t('componentRelationship:button.blocked_by')
onPress = () => {} onPress = () => {}
} else { } else {
if (query.data?.blocking) { if (query.data?.blocking) {
content = t('button.blocking') content = t('componentRelationship:button.blocking')
onPress = () => { onPress = () => {
mutation.mutate({ mutation.mutate({
id, id,
@ -82,7 +81,7 @@ const RelationshipOutgoing: React.FC<Props> = ({ id }: Props) => {
} }
} else { } else {
if (query.data?.following) { if (query.data?.following) {
content = t('button.following') content = t('componentRelationship:button.following')
onPress = () => { onPress = () => {
mutation.mutate({ mutation.mutate({
id, id,
@ -95,7 +94,7 @@ const RelationshipOutgoing: React.FC<Props> = ({ id }: Props) => {
} }
} else { } else {
if (query.data?.requested) { if (query.data?.requested) {
content = t('button.requested') content = t('componentRelationship:button.requested')
onPress = () => { onPress = () => {
mutation.mutate({ mutation.mutate({
id, id,
@ -107,7 +106,7 @@ const RelationshipOutgoing: React.FC<Props> = ({ id }: Props) => {
}) })
} }
} else { } else {
content = t('button.default') content = t('componentRelationship:button.default')
onPress = () => { onPress = () => {
mutation.mutate({ mutation.mutate({
id, id,

View File

@ -11,9 +11,15 @@ export interface Props {
multiple?: boolean multiple?: boolean
options: { selected: boolean; content: string }[] options: { selected: boolean; content: string }[]
setOptions: React.Dispatch<React.SetStateAction<{ selected: boolean; content: string }[]>> setOptions: React.Dispatch<React.SetStateAction<{ selected: boolean; content: string }[]>>
disabled?: boolean
} }
const Selections: React.FC<Props> = ({ multiple = false, options, setOptions }) => { const Selections: React.FC<Props> = ({
multiple = false,
options,
setOptions,
disabled = false
}) => {
const { colors } = useTheme() const { colors } = useTheme()
const isSelected = (index: number): string => const isSelected = (index: number): string =>
@ -22,10 +28,11 @@ const Selections: React.FC<Props> = ({ multiple = false, options, setOptions })
: `${multiple ? 'Square' : 'Circle'}` : `${multiple ? 'Square' : 'Circle'}`
return ( return (
<> <View>
{options.map((option, index) => ( {options.map((option, index) => (
<Pressable <Pressable
key={index} key={index}
disabled={disabled}
style={{ flex: 1, paddingVertical: StyleConstants.Spacing.S }} style={{ flex: 1, paddingVertical: StyleConstants.Spacing.S }}
onPress={() => { onPress={() => {
if (multiple) { if (multiple) {
@ -56,15 +63,18 @@ const Selections: React.FC<Props> = ({ multiple = false, options, setOptions })
}} }}
name={isSelected(index)} name={isSelected(index)}
size={StyleConstants.Font.Size.M} size={StyleConstants.Font.Size.M}
color={colors.primaryDefault} color={disabled ? colors.disabled : colors.primaryDefault}
/> />
<CustomText style={{ flex: 1 }}> <CustomText style={{ flex: 1 }}>
<ParseEmojis content={option.content} /> <ParseEmojis
content={option.content}
style={{ color: disabled ? colors.disabled : colors.primaryDefault }}
/>
</CustomText> </CustomText>
</View> </View>
</Pressable> </Pressable>
))} ))}
</> </View>
) )
} }

View File

@ -1,7 +1,7 @@
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 from 'react' import React from 'react'
import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native' import { StyleProp, View, ViewStyle } from 'react-native'
export interface Props { export interface Props {
extraMarginLeft?: number extraMarginLeft?: number
@ -23,7 +23,7 @@ const ComponentSeparator: React.FC<Props> = ({
{ {
backgroundColor: colors.backgroundDefault, backgroundColor: colors.backgroundDefault,
borderTopColor: colors.border, borderTopColor: colors.border,
borderTopWidth: StyleSheet.hairlineWidth, borderTopWidth: 1,
marginLeft: StyleConstants.Spacing.Global.PagePadding + extraMarginLeft, marginLeft: StyleConstants.Spacing.Global.PagePadding + extraMarginLeft,
marginRight: StyleConstants.Spacing.Global.PagePadding + extraMarginRight marginRight: StyleConstants.Spacing.Global.PagePadding + extraMarginRight
} }

View File

@ -1,14 +1,14 @@
import apiInstance from '@api/instance'
import GracefullyImage from '@components/GracefullyImage' import GracefullyImage from '@components/GracefullyImage'
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 { useMutation, useQueryClient } from '@tanstack/react-query'
import apiInstance from '@utils/api/instance'
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 { 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 } from 'react' import React, { useCallback } from 'react'
import { Pressable, View } from 'react-native' import { Pressable, View } from 'react-native'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import TimelineActions from './Shared/Actions' import TimelineActions from './Shared/Actions'
import TimelineContent from './Shared/Content' import TimelineContent from './Shared/Content'
import StatusContext from './Shared/Context' import StatusContext from './Shared/Context'
@ -41,10 +41,7 @@ const TimelineConversation: React.FC<Props> = ({ conversation, queryKey, highlig
const onPress = useCallback(() => { const onPress = useCallback(() => {
if (conversation.last_status) { if (conversation.last_status) {
conversation.unread && mutate() conversation.unread && mutate()
navigation.push('Tab-Shared-Toot', { navigation.push('Tab-Shared-Toot', { toot: conversation.last_status })
toot: conversation.last_status,
rootQueryKey: queryKey
})
} }
}, []) }, [])
@ -115,4 +112,4 @@ const TimelineConversation: React.FC<Props> = ({ conversation, queryKey, highlig
) )
} }
export default TimelineConversation export default React.memo(TimelineConversation, () => true)

View File

@ -9,17 +9,18 @@ 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 { featureCheck } from '@utils/helpers/featureCheck'
import removeHTML from '@utils/helpers/removeHTML'
import { TabLocalStackParamList } from '@utils/navigation/navigators' import { TabLocalStackParamList } from '@utils/navigation/navigators'
import { usePreferencesQuery } from '@utils/queryHooks/preferences'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline' import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { checkInstanceFeature, getInstanceAccount } from '@utils/slices/instancesSlice' import { useAccountStorage } from '@utils/storage/actions'
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, { Fragment, useRef, useState } from 'react'
import { Pressable, StyleProp, View, ViewStyle } from 'react-native' import { Pressable, StyleProp, View, ViewStyle } from 'react-native'
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'
@ -31,7 +32,6 @@ import TimelineTranslate from './Shared/Translate'
export interface Props { export interface Props {
item: Mastodon.Status & { _pinned?: boolean } // For account page, internal property item: Mastodon.Status & { _pinned?: boolean } // For account page, internal property
queryKey?: QueryKeyTimeline queryKey?: QueryKeyTimeline
rootQueryKey?: QueryKeyTimeline
highlighted?: boolean highlighted?: boolean
disableDetails?: boolean disableDetails?: boolean
disableOnPress?: boolean disableOnPress?: boolean
@ -42,7 +42,6 @@ export interface Props {
const TimelineDefault: React.FC<Props> = ({ const TimelineDefault: React.FC<Props> = ({
item, item,
queryKey, queryKey,
rootQueryKey,
highlighted = false, highlighted = false,
disableDetails = false, disableDetails = false,
disableOnPress = false, disableOnPress = false,
@ -50,7 +49,7 @@ const TimelineDefault: React.FC<Props> = ({
}) => { }) => {
const status = item.reblog ? item.reblog : item const status = item.reblog ? item.reblog : item
const rawContent = useRef<string[]>([]) const rawContent = useRef<string[]>([])
if (highlighted) { if (highlighted || isConversation) {
rawContent.current = [ rawContent.current = [
removeHTML(status.content), removeHTML(status.content),
status.spoiler_text ? removeHTML(status.spoiler_text) : '' status.spoiler_text ? removeHTML(status.spoiler_text) : ''
@ -60,16 +59,18 @@ const TimelineDefault: React.FC<Props> = ({
const { colors } = useTheme() const { colors } = useTheme()
const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>() const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>()
const instanceAccount = useSelector(getInstanceAccount, () => true) const [accountId] = useAccountStorage.string('auth.account.id')
const { data: preferences } = usePreferencesQuery()
const ownAccount = status.account?.id === instanceAccount?.id const ownAccount = status.account?.id === accountId
const [spoilerExpanded, setSpoilerExpanded] = useState( const [spoilerExpanded, setSpoilerExpanded] = useState(
instanceAccount?.preferences?.['reading:expand:spoilers'] || false preferences?.['reading:expand:spoilers'] || false
) )
const spoilerHidden = status.spoiler_text?.length const spoilerHidden = status.spoiler_text?.length
? !instanceAccount?.preferences?.['reading:expand:spoilers'] && !spoilerExpanded ? !preferences?.['reading:expand:spoilers'] && !spoilerExpanded
: false : false
const detectedLanguage = useRef<string>(status.language || '') const detectedLanguage = useRef<string>(status.language || '')
const excludeMentions = useRef<Mastodon.Mention[]>([])
const mainStyle: StyleProp<ViewStyle> = { const mainStyle: StyleProp<ViewStyle> = {
flex: 1, flex: 1,
@ -82,9 +83,9 @@ const TimelineDefault: React.FC<Props> = ({
const main = () => ( const main = () => (
<> <>
{item.reblog ? ( {item.reblog ? (
<TimelineActioned action='reblog' /> <TimelineActioned action='reblog' rootStatus={item} />
) : item._pinned ? ( ) : item._pinned ? (
<TimelineActioned action='pinned' /> <TimelineActioned action='pinned' rootStatus={item} />
) : null} ) : null}
<View <View
@ -128,13 +129,13 @@ const TimelineDefault: React.FC<Props> = ({
url: status.url || status.uri, url: status.url || status.uri,
rawContent rawContent
}) })
const mStatus = menuStatus({ status, queryKey, rootQueryKey }) const mStatus = menuStatus({ status, queryKey })
const mInstance = menuInstance({ status, queryKey, rootQueryKey }) const mInstance = menuInstance({ status, queryKey })
if (!ownAccount) { if (!ownAccount) {
let filterResults: FilteredProps['filterResults'] = [] let filterResults: FilteredProps['filterResults'] = []
const [filterRevealed, setFilterRevealed] = useState(false) const [filterRevealed, setFilterRevealed] = useState(false)
const hasFilterServerSide = useSelector(checkInstanceFeature('filter_server_side')) const hasFilterServerSide = featureCheck('filter_server_side')
if (hasFilterServerSide) { if (hasFilterServerSide) {
if (status.filtered?.length) { if (status.filtered?.length) {
filterResults = status.filtered?.map(filter => filter.filter) filterResults = status.filtered?.map(filter => filter.filter)
@ -160,18 +161,18 @@ const TimelineDefault: React.FC<Props> = ({
<StatusContext.Provider <StatusContext.Provider
value={{ value={{
queryKey, queryKey,
rootQueryKey,
status, status,
reblogStatus: item.reblog ? item : undefined,
ownAccount, ownAccount,
spoilerHidden, spoilerHidden,
rawContent, rawContent,
detectedLanguage, detectedLanguage,
excludeMentions,
highlighted, highlighted,
inThread: queryKey?.[1].page === 'Toot', inThread: queryKey?.[1].page === 'Toot',
disableDetails, disableDetails,
disableOnPress, disableOnPress,
isConversation isConversation,
isRemote: item._remote
}} }}
> >
{disableOnPress ? ( {disableOnPress ? (
@ -184,49 +185,58 @@ const TimelineDefault: React.FC<Props> = ({
accessible={highlighted ? false : true} accessible={highlighted ? false : true}
style={mainStyle} style={mainStyle}
disabled={highlighted} disabled={highlighted}
onPress={() => onPress={() => navigation.push('Tab-Shared-Toot', { toot: status })}
navigation.push('Tab-Shared-Toot', {
toot: status,
rootQueryKey: queryKey
})
}
onLongPress={() => {}} onLongPress={() => {}}
children={main()} children={main()}
/> />
</ContextMenu.Trigger> </ContextMenu.Trigger>
<ContextMenu.Content> <ContextMenu.Content>
{mShare.map((mGroup, index) => ( {[mShare, mStatus, mInstance].map((menu, i) => (
<ContextMenu.Group key={index}> <Fragment key={i}>
{mGroup.map(menu => ( {menu.map((group, index) => (
<ContextMenu.Item key={menu.key} {...menu.item}> <ContextMenu.Group key={index}>
<ContextMenu.ItemTitle children={menu.title} /> {group.map(item => {
<ContextMenu.ItemIcon iosIconName={menu.icon} /> switch (item.type) {
</ContextMenu.Item> case 'item':
return (
<ContextMenu.Item key={item.key} {...item.props}>
<ContextMenu.ItemTitle children={item.title} />
{item.icon ? (
<ContextMenu.ItemIcon ios={{ name: item.icon }} />
) : null}
</ContextMenu.Item>
)
case 'sub':
return (
// @ts-ignore
<ContextMenu.Sub key={item.key}>
<ContextMenu.SubTrigger
key={item.trigger.key}
{...item.trigger.props}
>
<ContextMenu.ItemTitle children={item.trigger.title} />
{item.trigger.icon ? (
<ContextMenu.ItemIcon ios={{ name: item.trigger.icon }} />
) : null}
</ContextMenu.SubTrigger>
<ContextMenu.SubContent>
{item.items.map(sub => (
<ContextMenu.Item key={sub.key} {...sub.props}>
<ContextMenu.ItemTitle children={sub.title} />
{sub.icon ? (
<ContextMenu.ItemIcon ios={{ name: sub.icon }} />
) : null}
</ContextMenu.Item>
))}
</ContextMenu.SubContent>
</ContextMenu.Sub>
)
}
})}
</ContextMenu.Group>
))} ))}
</ContextMenu.Group> </Fragment>
))}
{mStatus.map((mGroup, index) => (
<ContextMenu.Group key={index}>
{mGroup.map(menu => (
<ContextMenu.Item key={menu.key} {...menu.item}>
<ContextMenu.ItemTitle children={menu.title} />
<ContextMenu.ItemIcon iosIconName={menu.icon} />
</ContextMenu.Item>
))}
</ContextMenu.Group>
))}
{mInstance.map((mGroup, index) => (
<ContextMenu.Group key={index}>
{mGroup.map(menu => (
<ContextMenu.Item key={menu.key} {...menu.item}>
<ContextMenu.ItemTitle children={menu.title} />
<ContextMenu.ItemIcon iosIconName={menu.icon} />
</ContextMenu.Item>
))}
</ContextMenu.Group>
))} ))}
</ContextMenu.Content> </ContextMenu.Content>
</ContextMenu.Root> </ContextMenu.Root>
@ -237,4 +247,4 @@ const TimelineDefault: React.FC<Props> = ({
) )
} }
export default TimelineDefault export default React.memo(TimelineDefault, () => true)

View File

@ -13,74 +13,71 @@ export interface Props {
queryKey: QueryKeyTimeline queryKey: QueryKeyTimeline
} }
const TimelineEmpty = React.memo( const TimelineEmpty: React.FC<Props> = ({ queryKey }) => {
({ queryKey }: Props) => { const { status, refetch } = useTimelineQuery({
const { status, refetch } = useTimelineQuery({ ...queryKey[1],
...queryKey[1], options: { notifyOnChangeProps: ['status'] }
options: { notifyOnChangeProps: ['status'] } })
})
const { colors } = useTheme() const { colors } = useTheme()
const { t } = useTranslation('componentTimeline') const { t } = useTranslation('componentTimeline')
const children = () => { const children = () => {
switch (status) { switch (status) {
case 'loading': case 'loading':
return <Circle size={StyleConstants.Font.Size.L} color={colors.secondary} /> return <Circle size={StyleConstants.Font.Size.L} color={colors.secondary} />
case 'error': case 'error':
return ( return (
<> <>
<Icon name='Frown' size={StyleConstants.Font.Size.L} color={colors.primaryDefault} /> <Icon name='Frown' size={StyleConstants.Font.Size.L} color={colors.primaryDefault} />
<CustomText <CustomText
fontStyle='M' fontStyle='M'
style={{ style={{
marginTop: StyleConstants.Spacing.S, marginTop: StyleConstants.Spacing.S,
marginBottom: StyleConstants.Spacing.L, marginBottom: StyleConstants.Spacing.L,
color: colors.primaryDefault color: colors.primaryDefault
}} }}
> >
{t('empty.error.message')} {t('empty.error.message')}
</CustomText> </CustomText>
<Button type='text' content={t('empty.error.button')} onPress={() => refetch()} /> <Button type='text' content={t('empty.error.button')} onPress={() => refetch()} />
</> </>
) )
case 'success': case 'success':
return ( return (
<> <>
<Icon <Icon
name='Smartphone' name='Smartphone'
size={StyleConstants.Font.Size.L} size={StyleConstants.Font.Size.L}
color={colors.primaryDefault} color={colors.primaryDefault}
/> />
<CustomText <CustomText
fontStyle='M' fontStyle='M'
style={{ style={{
marginTop: StyleConstants.Spacing.S, marginTop: StyleConstants.Spacing.S,
marginBottom: StyleConstants.Spacing.L, marginBottom: StyleConstants.Spacing.L,
color: colors.secondary color: colors.secondary
}} }}
> >
{t('empty.success.message')} {t('empty.success.message')}
</CustomText> </CustomText>
</> </>
) )
}
} }
return ( }
<View return (
style={{ <View
flex: 1, style={{
minHeight: '100%', flex: 1,
justifyContent: 'center', minHeight: '100%',
alignItems: 'center', justifyContent: 'center',
backgroundColor: colors.backgroundDefault alignItems: 'center',
}} backgroundColor: colors.backgroundDefault
> }}
{children()} >
</View> {children()}
) </View>
}, )
() => true }
)
export default TimelineEmpty export default TimelineEmpty

View File

@ -13,49 +13,38 @@ export interface Props {
disableInfinity: boolean disableInfinity: boolean
} }
const TimelineFooter = React.memo( const TimelineFooter: React.FC<Props> = ({ queryKey, disableInfinity }) => {
({ queryKey, disableInfinity }: Props) => { const { hasNextPage } = useTimelineQuery({
const { hasNextPage } = useTimelineQuery({ ...queryKey[1],
...queryKey[1], options: { enabled: !disableInfinity, notifyOnChangeProps: ['hasNextPage'] }
options: { })
enabled: !disableInfinity,
notifyOnChangeProps: ['hasNextPage'],
getNextPageParam: lastPage =>
lastPage?.links?.next && {
...(lastPage.links.next.isOffset
? { offset: lastPage.links.next.id }
: { max_id: lastPage.links.next.id })
}
}
})
const { colors } = useTheme() const { colors } = useTheme()
return ( return (
<View <View
style={{ style={{
flex: 1, flex: 1,
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'center', justifyContent: 'center',
padding: StyleConstants.Spacing.M padding: StyleConstants.Spacing.M
}} }}
> >
{!disableInfinity && hasNextPage ? ( {!disableInfinity && hasNextPage ? (
<Circle size={StyleConstants.Font.Size.L} color={colors.secondary} /> <Circle size={StyleConstants.Font.Size.L} color={colors.secondary} />
) : ( ) : (
<CustomText fontStyle='S' style={{ color: colors.secondary }}> <CustomText fontStyle='S' style={{ color: colors.secondary }}>
<Trans <Trans
i18nKey='componentTimeline:end.message' ns='componentTimeline'
components={[ i18nKey='end.message'
<Icon name='Coffee' size={StyleConstants.Font.Size.S} color={colors.secondary} /> components={[
]} <Icon name='Coffee' size={StyleConstants.Font.Size.S} color={colors.secondary} />
/> ]}
</CustomText> />
)} </CustomText>
</View> )}
) </View>
}, )
() => true }
)
export default TimelineFooter export default TimelineFooter

View File

@ -11,14 +11,15 @@ import TimelineHeaderNotification from '@components/Timeline/Shared/HeaderNotifi
import TimelinePoll from '@components/Timeline/Shared/Poll' import TimelinePoll from '@components/Timeline/Shared/Poll'
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 { featureCheck } from '@utils/helpers/featureCheck'
import { TabLocalStackParamList } from '@utils/navigation/navigators' import { TabLocalStackParamList } from '@utils/navigation/navigators'
import { usePreferencesQuery } from '@utils/queryHooks/preferences'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline' import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { checkInstanceFeature, getInstanceAccount } from '@utils/slices/instancesSlice' import { useAccountStorage } from '@utils/storage/actions'
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, { Fragment, useState } from 'react'
import { Pressable, View } from 'react-native' import { Pressable, View } from 'react-native'
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, { FilteredProps, shouldFilter } from './Shared/Filtered' import TimelineFiltered, { FilteredProps, shouldFilter } from './Shared/Filtered'
@ -31,7 +32,8 @@ export interface Props {
} }
const TimelineNotifications: React.FC<Props> = ({ notification, queryKey }) => { const TimelineNotifications: React.FC<Props> = ({ notification, queryKey }) => {
const instanceAccount = useSelector(getInstanceAccount, () => true) const [accountId] = useAccountStorage.string('auth.account.id')
const { data: preferences } = usePreferencesQuery()
const status = notification.status?.reblog ? notification.status.reblog : notification.status const status = notification.status?.reblog ? notification.status.reblog : notification.status
const account = const account =
@ -40,25 +42,17 @@ const TimelineNotifications: React.FC<Props> = ({ notification, queryKey }) => {
: notification.status : notification.status
? notification.status.account ? notification.status.account
: notification.account : notification.account
const ownAccount = notification.account?.id === instanceAccount?.id const ownAccount = notification.account?.id === accountId
const [spoilerExpanded, setSpoilerExpanded] = useState( const [spoilerExpanded, setSpoilerExpanded] = useState(
instanceAccount.preferences?.['reading:expand:spoilers'] || false preferences?.['reading:expand:spoilers'] || false
) )
const spoilerHidden = notification.status?.spoiler_text?.length const spoilerHidden = notification.status?.spoiler_text?.length
? !instanceAccount.preferences?.['reading:expand:spoilers'] && !spoilerExpanded ? !preferences?.['reading:expand:spoilers'] && !spoilerExpanded
: false : false
const { colors } = useTheme() const { colors } = useTheme()
const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>() const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>()
const onPress = useCallback(() => {
notification.status &&
navigation.push('Tab-Shared-Toot', {
toot: notification.status,
rootQueryKey: queryKey
})
}, [])
const main = () => { const main = () => {
return ( return (
<> <>
@ -67,6 +61,7 @@ const TimelineNotifications: React.FC<Props> = ({ notification, queryKey }) => {
action={notification.type} action={notification.type}
isNotification isNotification
account={notification.account} account={notification.account}
rootStatus={notification.status}
/> />
) : null} ) : null}
@ -117,7 +112,7 @@ const TimelineNotifications: React.FC<Props> = ({ notification, queryKey }) => {
if (!ownAccount) { if (!ownAccount) {
let filterResults: FilteredProps['filterResults'] = [] let filterResults: FilteredProps['filterResults'] = []
const [filterRevealed, setFilterRevealed] = useState(false) const [filterRevealed, setFilterRevealed] = useState(false)
const hasFilterServerSide = useSelector(checkInstanceFeature('filter_server_side')) const hasFilterServerSide = featureCheck('filter_server_side')
if (notification.status) { if (notification.status) {
if (hasFilterServerSide) { if (hasFilterServerSide) {
if (notification.status.filtered?.length) { if (notification.status.filtered?.length) {
@ -131,7 +126,7 @@ const TimelineNotifications: React.FC<Props> = ({ notification, queryKey }) => {
} }
if (filterResults?.length && !filterRevealed) { if (filterResults?.length && !filterRevealed) {
return !filterResults.filter(result => result.filter_action === 'hide').length ? ( return !filterResults.filter(result => result.filter_action === 'hide')?.length ? (
<Pressable onPress={() => setFilterRevealed(!filterRevealed)}> <Pressable onPress={() => setFilterRevealed(!filterRevealed)}>
<TimelineFiltered filterResults={filterResults} /> <TimelineFiltered filterResults={filterResults} />
</Pressable> </Pressable>
@ -157,44 +152,56 @@ const TimelineNotifications: React.FC<Props> = ({ notification, queryKey }) => {
backgroundColor: colors.backgroundDefault, backgroundColor: colors.backgroundDefault,
paddingBottom: notification.status ? 0 : StyleConstants.Spacing.Global.PagePadding paddingBottom: notification.status ? 0 : StyleConstants.Spacing.Global.PagePadding
}} }}
onPress={onPress} onPress={() =>
notification.status &&
navigation.push('Tab-Shared-Toot', { toot: notification.status })
}
onLongPress={() => {}} onLongPress={() => {}}
children={main()} children={main()}
/> />
</ContextMenu.Trigger> </ContextMenu.Trigger>
<ContextMenu.Content> <ContextMenu.Content>
{mShare.map((mGroup, index) => ( {[mShare, mStatus, mInstance].map((menu, i) => (
<ContextMenu.Group key={index}> <Fragment key={i}>
{mGroup.map(menu => ( {menu.map((group, index) => (
<ContextMenu.Item key={menu.key} {...menu.item}> <ContextMenu.Group key={index}>
<ContextMenu.ItemTitle children={menu.title} /> {group.map(item => {
<ContextMenu.ItemIcon iosIconName={menu.icon} /> switch (item.type) {
</ContextMenu.Item> case 'item':
return (
<ContextMenu.Item key={item.key} {...item.props}>
<ContextMenu.ItemTitle children={item.title} />
{item.icon ? <ContextMenu.ItemIcon ios={{ name: item.icon }} /> : null}
</ContextMenu.Item>
)
case 'sub':
return (
// @ts-ignore
<ContextMenu.Sub key={item.key}>
<ContextMenu.SubTrigger key={item.trigger.key} {...item.trigger.props}>
<ContextMenu.ItemTitle children={item.trigger.title} />
{item.trigger.icon ? (
<ContextMenu.ItemIcon ios={{ name: item.trigger.icon }} />
) : null}
</ContextMenu.SubTrigger>
<ContextMenu.SubContent>
{item.items.map(sub => (
<ContextMenu.Item key={sub.key} {...sub.props}>
<ContextMenu.ItemTitle children={sub.title} />
{sub.icon ? (
<ContextMenu.ItemIcon ios={{ name: sub.icon }} />
) : null}
</ContextMenu.Item>
))}
</ContextMenu.SubContent>
</ContextMenu.Sub>
)
}
})}
</ContextMenu.Group>
))} ))}
</ContextMenu.Group> </Fragment>
))}
{mStatus.map((mGroup, index) => (
<ContextMenu.Group key={index}>
{mGroup.map(menu => (
<ContextMenu.Item key={menu.key} {...menu.item}>
<ContextMenu.ItemTitle children={menu.title} />
<ContextMenu.ItemIcon iosIconName={menu.icon} />
</ContextMenu.Item>
))}
</ContextMenu.Group>
))}
{mInstance.map((mGroup, index) => (
<ContextMenu.Group key={index}>
{mGroup.map(menu => (
<ContextMenu.Item key={menu.key} {...menu.item}>
<ContextMenu.ItemTitle children={menu.title} />
<ContextMenu.ItemIcon iosIconName={menu.icon} />
</ContextMenu.Item>
))}
</ContextMenu.Group>
))} ))}
</ContextMenu.Content> </ContextMenu.Content>
</ContextMenu.Root> </ContextMenu.Root>
@ -203,4 +210,4 @@ const TimelineNotifications: React.FC<Props> = ({ notification, queryKey }) => {
) )
} }
export default TimelineNotifications export default React.memo(TimelineNotifications, () => true)

View File

@ -1,29 +1,37 @@
import haptics from '@components/haptics' import haptics from '@components/haptics'
import Icon from '@components/Icon' import Icon from '@components/Icon'
import { QueryKeyTimeline, TimelineData, useTimelineQuery } from '@utils/queryHooks/timeline' import { InfiniteData, useQueryClient } from '@tanstack/react-query'
import { PagedResponse } from '@utils/api/helpers'
import {
queryFunctionTimeline,
QueryKeyTimeline,
useTimelineQuery
} from '@utils/queryHooks/timeline'
import { setAccountStorage } from '@utils/storage/actions'
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, { RefObject, useCallback, useRef, useState } from 'react' import React, { RefObject, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { FlatList, LayoutChangeEvent, Platform, StyleSheet, Text, View } from 'react-native' import { FlatList, Platform, Text, View } from 'react-native'
import { Circle } from 'react-native-animated-spinkit'
import Animated, { import Animated, {
Extrapolate, Extrapolate,
interpolate, interpolate,
runOnJS, runOnJS,
useAnimatedReaction, useAnimatedReaction,
useAnimatedStyle, useAnimatedStyle,
useDerivedValue,
useSharedValue, useSharedValue,
withTiming withTiming
} from 'react-native-reanimated' } from 'react-native-reanimated'
import { InfiniteData, useQueryClient } from '@tanstack/react-query'
export interface Props { export interface Props {
flRef: RefObject<FlatList<any>> flRef: RefObject<FlatList<any>>
queryKey: QueryKeyTimeline queryKey: QueryKeyTimeline
fetchingActive: React.MutableRefObject<boolean>
scrollY: Animated.SharedValue<number> scrollY: Animated.SharedValue<number>
fetchingType: Animated.SharedValue<0 | 1 | 2> fetchingType: Animated.SharedValue<0 | 1 | 2>
disableRefresh?: boolean disableRefresh?: boolean
readMarker?: 'read_marker_following'
} }
const CONTAINER_HEIGHT = StyleConstants.Spacing.M * 2.5 const CONTAINER_HEIGHT = StyleConstants.Spacing.M * 2.5
@ -33,9 +41,11 @@ export const SEPARATION_Y_2 = -(CONTAINER_HEIGHT * 1.5 + StyleConstants.Font.Siz
const TimelineRefresh: React.FC<Props> = ({ const TimelineRefresh: React.FC<Props> = ({
flRef, flRef,
queryKey, queryKey,
fetchingActive,
scrollY, scrollY,
fetchingType, fetchingType,
disableRefresh = false disableRefresh = false,
readMarker
}) => { }) => {
if (Platform.OS !== 'ios') { if (Platform.OS !== 'ios') {
return null return null
@ -44,86 +54,25 @@ const TimelineRefresh: React.FC<Props> = ({
return null return null
} }
const fetchingLatestIndex = useRef(0) const PREV_PER_BATCH = 1
const refetchActive = useRef(false) const prevActive = useRef<boolean>(false)
const prevCache = useRef<(Mastodon.Status | Mastodon.Notification | Mastodon.Conversation)[]>()
const prevStatusId = useRef<Mastodon.Status['id']>()
const { refetch, isFetching, isLoading, fetchPreviousPage, hasPreviousPage, isFetchingNextPage } = const queryClient = useQueryClient()
useTimelineQuery({ const { refetch, isRefetching } = useTimelineQuery({ ...queryKey[1] })
...queryKey[1],
options: { useDerivedValue(() => {
getPreviousPageParam: firstPage => if (prevActive.current || isRefetching) {
firstPage?.links?.prev && { fetchingActive.current = true
...(firstPage.links.prev.isOffset } else {
? { offset: firstPage.links.prev.id } fetchingActive.current = false
: { min_id: firstPage.links.prev.id }), }
// https://github.com/facebook/react-native/issues/25239#issuecomment-731100372 }, [prevActive.current, isRefetching])
limit: '3'
},
select: data => {
if (refetchActive.current) {
data.pageParams = [data.pageParams[0]]
data.pages = [data.pages[0]]
refetchActive.current = false
}
return data
},
onSuccess: () => {
if (fetchingLatestIndex.current > 0) {
if (fetchingLatestIndex.current > 5) {
clearFirstPage()
fetchingLatestIndex.current = 0
} else {
if (hasPreviousPage) {
fetchPreviousPage()
fetchingLatestIndex.current++
} else {
clearFirstPage()
fetchingLatestIndex.current = 0
}
}
}
}
}
})
const { t } = useTranslation('componentTimeline') const { t } = useTranslation('componentTimeline')
const { colors } = useTheme() const { colors } = useTheme()
const queryClient = useQueryClient()
const clearFirstPage = () => {
queryClient.setQueryData<InfiniteData<TimelineData> | undefined>(queryKey, data => {
if (data?.pages[0] && data.pages[0].body.length === 0) {
return {
pages: data.pages.slice(1),
pageParams: data.pageParams.slice(1)
}
} else {
return data
}
})
}
const prepareRefetch = () => {
refetchActive.current = true
queryClient.setQueryData<InfiniteData<TimelineData> | undefined>(queryKey, data => {
if (data) {
data.pageParams = [undefined]
const newFirstPage: TimelineData = { body: [] }
for (let page of data.pages) {
// @ts-ignore
newFirstPage.body.push(...page.body)
if (newFirstPage.body.length > 10) break
}
data.pages = [newFirstPage]
}
return data
})
}
const callRefetch = async () => {
await refetch()
setTimeout(() => flRef.current?.scrollToOffset({ offset: 1 }), 50)
}
const [textRight, setTextRight] = useState(0) const [textRight, setTextRight] = useState(0)
const arrowY = useAnimatedStyle(() => ({ const arrowY = useAnimatedStyle(() => ({
transform: [ transform: [
@ -145,17 +94,9 @@ const TimelineRefresh: React.FC<Props> = ({
})) }))
const arrowStage = useSharedValue(0) const arrowStage = useSharedValue(0)
const onLayout = useCallback(
({ nativeEvent }: LayoutChangeEvent) => {
if (nativeEvent.layout.x + nativeEvent.layout.width > textRight) {
setTextRight(nativeEvent.layout.x + nativeEvent.layout.width)
}
},
[textRight]
)
useAnimatedReaction( useAnimatedReaction(
() => { () => {
if (isFetching) { if (fetchingActive.current) {
return false return false
} }
switch (arrowStage.value) { switch (arrowStage.value) {
@ -188,10 +129,96 @@ const TimelineRefresh: React.FC<Props> = ({
runOnJS(haptics)('Light') runOnJS(haptics)('Light')
} }
}, },
[isFetching] [fetchingActive.current]
) )
const wrapperStartLatest = () => {
fetchingLatestIndex.current = 1 const fetchAndScrolled = useSharedValue(false)
const runFetchPrevious = async () => {
if (prevActive.current) return
const firstPage =
queryClient.getQueryData<
InfiniteData<
PagedResponse<(Mastodon.Status | Mastodon.Notification | Mastodon.Conversation)[]>
>
>(queryKey)?.pages[0]
prevActive.current = true
prevStatusId.current = firstPage?.body[0]?.id
await queryFunctionTimeline({
queryKey,
pageParam: firstPage?.links?.prev,
meta: {}
})
.then(res => {
if (!res.body.length) return
queryClient.setQueryData<
InfiniteData<
PagedResponse<(Mastodon.Status | Mastodon.Notification | Mastodon.Conversation)[]>
>
>(queryKey, old => {
if (!old) return old
prevCache.current = res.body.slice(0, -PREV_PER_BATCH)
return {
...old,
pages: [{ ...res, body: res.body.slice(-PREV_PER_BATCH) }, ...old.pages]
}
})
return res.body.length - PREV_PER_BATCH
})
.then(async nextLength => {
if (!nextLength) {
prevActive.current = false
return
}
for (let [index] of Array(Math.ceil(nextLength / PREV_PER_BATCH)).entries()) {
if (!fetchAndScrolled.value && index < 3 && scrollY.value > 15) {
fetchAndScrolled.value = true
flRef.current?.scrollToOffset({ offset: scrollY.value - 15, animated: true })
}
await new Promise(promise => setTimeout(promise, 64))
queryClient.setQueryData<
InfiniteData<
PagedResponse<(Mastodon.Status | Mastodon.Notification | Mastodon.Conversation)[]>
>
>(queryKey, old => {
if (!old) return old
return {
...old,
pages: old.pages.map((page, index) => {
if (index === 0) {
const insert = prevCache.current?.slice(-PREV_PER_BATCH)
prevCache.current = prevCache.current?.slice(0, -PREV_PER_BATCH)
if (insert) {
return { ...page, body: [...insert, ...page.body] }
} else {
return page
}
} else {
return page
}
})
}
})
}
prevActive.current = false
})
}
const runFetchLatest = async () => {
queryClient.invalidateQueries(queryKey)
if (readMarker) {
setAccountStorage([{ key: readMarker, value: undefined }])
}
await refetch()
setTimeout(() => flRef.current?.scrollToOffset({ offset: 0 }), 50)
} }
useAnimatedReaction( useAnimatedReaction(
@ -202,95 +229,76 @@ const TimelineRefresh: React.FC<Props> = ({
fetchingType.value = 0 fetchingType.value = 0
switch (data) { switch (data) {
case 1: case 1:
runOnJS(wrapperStartLatest)() runOnJS(runFetchPrevious)()
runOnJS(clearFirstPage)() return
runOnJS(fetchPreviousPage)()
break
case 2: case 2:
runOnJS(prepareRefetch)() runOnJS(runFetchLatest)()
runOnJS(callRefetch)() return
break
} }
}, },
[] []
) )
const headerPadding = useAnimatedStyle(
() => ({
paddingTop:
fetchingLatestIndex.current !== 0 || (isFetching && !isLoading && !isFetchingNextPage)
? withTiming(StyleConstants.Spacing.M * 2.5)
: withTiming(0)
}),
[fetchingLatestIndex.current, isFetching, isFetchingNextPage, isLoading]
)
return ( return (
<Animated.View style={headerPadding}> <Animated.View
<View style={styles.base}> style={{
{isFetching ? ( position: 'absolute',
<View style={styles.container2}> top: 0,
<Circle size={StyleConstants.Font.Size.L} color={colors.secondary} /> left: 0,
</View> right: 0,
) : ( height: CONTAINER_HEIGHT * 2,
<> alignItems: 'center'
<View style={styles.container1}> }}
<Text >
style={[styles.explanation, { color: colors.primaryDefault }]} <View style={{ flex: 1, flexDirection: 'row', height: CONTAINER_HEIGHT }}>
onLayout={onLayout} <Text
children={t('refresh.fetchPreviousPage')} style={{
/> fontSize: StyleConstants.Font.Size.S,
<Animated.View lineHeight: CONTAINER_HEIGHT,
style={[ color: colors.primaryDefault
{ }}
position: 'absolute', onLayout={({ nativeEvent }) => {
left: textRight + StyleConstants.Spacing.S if (nativeEvent.layout.x + nativeEvent.layout.width > textRight) {
}, setTextRight(nativeEvent.layout.x + nativeEvent.layout.width)
arrowY, }
arrowTop }}
]} children={t('refresh.fetchPreviousPage')}
children={ />
<Icon <Animated.View
name='ArrowLeft' style={[
size={StyleConstants.Font.Size.M} {
color={colors.primaryDefault} position: 'absolute',
/> left: textRight + StyleConstants.Spacing.S
} },
/> arrowY,
</View> arrowTop
<View style={styles.container2}> ]}
<Text children={
style={[styles.explanation, { color: colors.primaryDefault }]} <Icon
onLayout={onLayout} name='ArrowLeft'
children={t('refresh.refetch')} size={StyleConstants.Font.Size.M}
/> color={colors.primaryDefault}
</View> />
</> }
)} />
</View>
<View style={{ height: CONTAINER_HEIGHT, justifyContent: 'center' }}>
<Text
style={{
fontSize: StyleConstants.Font.Size.S,
lineHeight: CONTAINER_HEIGHT,
color: colors.primaryDefault
}}
onLayout={({ nativeEvent }) => {
if (nativeEvent.layout.x + nativeEvent.layout.width > textRight) {
setTextRight(nativeEvent.layout.x + nativeEvent.layout.width)
}
}}
children={t('refresh.refetch')}
/>
</View> </View>
</Animated.View> </Animated.View>
) )
} }
const styles = StyleSheet.create({
base: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: CONTAINER_HEIGHT * 2,
alignItems: 'center'
},
container1: {
flex: 1,
flexDirection: 'row',
height: CONTAINER_HEIGHT
},
container2: { height: CONTAINER_HEIGHT, justifyContent: 'center' },
explanation: {
fontSize: StyleConstants.Font.Size.S,
lineHeight: CONTAINER_HEIGHT
}
})
export default TimelineRefresh export default TimelineRefresh

View File

@ -14,11 +14,12 @@ export interface Props {
action: Mastodon.Notification['type'] | 'reblog' | 'pinned' action: Mastodon.Notification['type'] | 'reblog' | 'pinned'
isNotification?: boolean isNotification?: boolean
account?: Mastodon.Account // For notification account?: Mastodon.Account // For notification
rootStatus?: Mastodon.Status
} }
const TimelineActioned: React.FC<Props> = ({ action, isNotification, ...rest }) => { const TimelineActioned: React.FC<Props> = ({ action, isNotification, ...rest }) => {
const { status, reblogStatus } = useContext(StatusContext) const { status } = useContext(StatusContext)
const account = rest.account || (reblogStatus ? reblogStatus.account : status?.account) const account = rest.account || (rest.rootStatus || status)?.account
if (!account) return null if (!account) return null
const { t } = useTranslation('componentTimeline') const { t } = useTranslation('componentTimeline')
@ -36,7 +37,8 @@ const TimelineActioned: React.FC<Props> = ({ action, isNotification, ...rest })
/> />
) )
const onPress = () => navigation.push('Tab-Shared-Account', { account }) const onPress = () =>
navigation.push('Tab-Shared-Account', { account })
const children = () => { const children = () => {
switch (action) { switch (action) {

View File

@ -2,34 +2,33 @@ import Icon from '@components/Icon'
import { displayMessage } from '@components/Message' import { displayMessage } from '@components/Message'
import CustomText from '@components/Text' import CustomText from '@components/Text'
import { useActionSheet } from '@expo/react-native-action-sheet' import { useActionSheet } from '@expo/react-native-action-sheet'
import { androidActionSheetStyles } from '@helpers/androidActionSheetStyles'
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 { RootStackParamList } from '@utils/navigation/navigators' import { useQueryClient } from '@tanstack/react-query'
import { androidActionSheetStyles } from '@utils/helpers/androidActionSheetStyles'
import { RootStackParamList, useNavState } from '@utils/navigation/navigators'
import { import {
MutationVarsTimelineUpdateStatusProperty, MutationVarsTimelineUpdateStatusProperty,
QueryKeyTimeline, QueryKeyTimeline,
useTimelineMutation useTimelineMutation
} from '@utils/queryHooks/timeline' } from '@utils/queryHooks/timeline'
import { getInstanceAccount } from '@utils/slices/instancesSlice' import { useAccountStorage } from '@utils/storage/actions'
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 { uniqBy } from 'lodash' import { uniqBy } from 'lodash'
import React, { useCallback, useContext, useMemo } from 'react' import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Pressable, StyleSheet, View } from 'react-native' import { Pressable, StyleSheet, View } from 'react-native'
import { useQueryClient } from '@tanstack/react-query'
import { useSelector } from 'react-redux'
import StatusContext from './Context' import StatusContext from './Context'
const TimelineActions: React.FC = () => { const TimelineActions: React.FC = () => {
const { queryKey, rootQueryKey, status, reblogStatus, ownAccount, highlighted, disableDetails } = const { queryKey, status, ownAccount, highlighted, disableDetails } = useContext(StatusContext)
useContext(StatusContext)
if (!queryKey || !status || disableDetails) return null if (!queryKey || !status || disableDetails) return null
const navigationState = useNavState()
const navigation = useNavigation<StackNavigationProp<RootStackParamList>>() const navigation = useNavigation<StackNavigationProp<RootStackParamList>>()
const { t } = useTranslation('componentTimeline') const { t } = useTranslation(['common', 'componentTimeline'])
const { colors, theme } = useTheme() const { colors } = useTheme()
const iconColor = colors.secondary const iconColor = colors.secondary
const queryClient = useQueryClient() const queryClient = useQueryClient()
@ -39,28 +38,29 @@ const TimelineActions: React.FC = () => {
const theParams = params as MutationVarsTimelineUpdateStatusProperty const theParams = params as MutationVarsTimelineUpdateStatusProperty
if ( if (
// Un-bookmark from bookmarks page // Un-bookmark from bookmarks page
(queryKey[1].page === 'Bookmarks' && theParams.payload.property === 'bookmarked') || (queryKey[1].page === 'Bookmarks' && theParams.payload.type === 'bookmarked') ||
// Un-favourite from favourites page // Un-favourite from favourites page
(queryKey[1].page === 'Favourites' && theParams.payload.property === 'favourited') (queryKey[1].page === 'Favourites' && theParams.payload.type === 'favourited')
) { ) {
queryClient.invalidateQueries(queryKey) queryClient.invalidateQueries(queryKey)
} else if (theParams.payload.property === 'favourited') { } else if (theParams.payload.type === 'favourited') {
// When favourited, update favourited page // When favourited, update favourited page
const tempQueryKey: QueryKeyTimeline = ['Timeline', { page: 'Favourites' }] const tempQueryKey: QueryKeyTimeline = ['Timeline', { page: 'Favourites' }]
queryClient.invalidateQueries(tempQueryKey) queryClient.invalidateQueries(tempQueryKey)
} else if (theParams.payload.property === 'bookmarked') { } else if (theParams.payload.type === 'bookmarked') {
// When bookmarked, update bookmark page // When bookmarked, update bookmark page
const tempQueryKey: QueryKeyTimeline = ['Timeline', { page: 'Bookmarks' }] const tempQueryKey: QueryKeyTimeline = ['Timeline', { page: 'Bookmarks' }]
queryClient.invalidateQueries(tempQueryKey) queryClient.invalidateQueries(tempQueryKey)
} }
}, },
onError: (err: any, params, oldData) => { onError: (err: any, params) => {
const correctParam = params as MutationVarsTimelineUpdateStatusProperty const correctParam = params as MutationVarsTimelineUpdateStatusProperty
displayMessage({ displayMessage({
theme,
type: 'error', type: 'error',
message: t('common:message.error.message', { message: t('common:message.error.message', {
function: t(`shared.actions.${correctParam.payload.property}.function`) function: t(
`componentTimeline:shared.actions.${correctParam.payload.type}.function` as any
)
}), }),
...(err.status && ...(err.status &&
typeof err.status === 'number' && typeof err.status === 'number' &&
@ -74,30 +74,30 @@ const TimelineActions: React.FC = () => {
} }
}) })
const instanceAccount = useSelector(getInstanceAccount, () => true) const [accountId] = useAccountStorage.string('auth.account.id')
const onPressReply = useCallback(() => { const onPressReply = () => {
const accts = uniqBy( const accts = uniqBy(
([status.account] as Mastodon.Account[] & Mastodon.Mention[]) ([status.account] as Mastodon.Account[] & Mastodon.Mention[])
.concat(status.mentions) .concat(status.mentions)
.filter(d => d?.id !== instanceAccount?.id), .filter(d => d?.id !== accountId),
d => d?.id d => d?.id
).map(d => d?.acct) ).map(d => d?.acct)
navigation.navigate('Screen-Compose', { navigation.navigate('Screen-Compose', {
type: 'reply', type: 'reply',
incomingStatus: status, incomingStatus: status,
accts, accts,
queryKey navigationState
}) })
}, [status.replies_count]) }
const { showActionSheetWithOptions } = useActionSheet() const { showActionSheetWithOptions } = useActionSheet()
const onPressReblog = useCallback(() => { const onPressReblog = () => {
if (!status.reblogged) { if (!status.reblogged) {
showActionSheetWithOptions( showActionSheetWithOptions(
{ {
title: t('shared.actions.reblogged.options.title'), title: t('componentTimeline:shared.actions.reblogged.options.title'),
options: [ options: [
t('shared.actions.reblogged.options.public'), t('componentTimeline:shared.actions.reblogged.options.public'),
t('shared.actions.reblogged.options.unlisted'), t('componentTimeline:shared.actions.reblogged.options.unlisted'),
t('common:buttons.cancel') t('common:buttons.cancel')
], ],
cancelButtonIndex: 2, cancelButtonIndex: 2,
@ -108,32 +108,22 @@ const TimelineActions: React.FC = () => {
case 0: case 0:
mutation.mutate({ mutation.mutate({
type: 'updateStatusProperty', type: 'updateStatusProperty',
queryKey, status,
rootQueryKey,
id: status.id,
isReblog: !!reblogStatus,
payload: { payload: {
property: 'reblogged', type: 'reblogged',
currentValue: status.reblogged, visibility: 'public',
propertyCount: 'reblogs_count', to: true
countValue: status.reblogs_count,
visibility: 'public'
} }
}) })
break break
case 1: case 1:
mutation.mutate({ mutation.mutate({
type: 'updateStatusProperty', type: 'updateStatusProperty',
queryKey, status,
rootQueryKey,
id: status.id,
isReblog: !!reblogStatus,
payload: { payload: {
property: 'reblogged', type: 'reblogged',
currentValue: status.reblogged, visibility: 'unlisted',
propertyCount: 'reblogs_count', to: true
countValue: status.reblogs_count,
visibility: 'unlisted'
} }
}) })
break break
@ -143,91 +133,72 @@ const TimelineActions: React.FC = () => {
} else { } else {
mutation.mutate({ mutation.mutate({
type: 'updateStatusProperty', type: 'updateStatusProperty',
queryKey, status,
rootQueryKey,
id: status.id,
isReblog: !!reblogStatus,
payload: { payload: {
property: 'reblogged', type: 'reblogged',
currentValue: status.reblogged, visibility: 'public',
propertyCount: 'reblogs_count', to: false
countValue: status.reblogs_count,
visibility: 'public'
} }
}) })
} }
}, [status.reblogged, status.reblogs_count]) }
const onPressFavourite = useCallback(() => { const onPressFavourite = () => {
mutation.mutate({ mutation.mutate({
type: 'updateStatusProperty', type: 'updateStatusProperty',
queryKey, status,
rootQueryKey,
id: status.id,
isReblog: !!reblogStatus,
payload: { payload: {
property: 'favourited', type: 'favourited',
currentValue: status.favourited, to: !status.favourited
propertyCount: 'favourites_count',
countValue: status.favourites_count
} }
}) })
}, [status.favourited, status.favourites_count]) }
const onPressBookmark = useCallback(() => { const onPressBookmark = () => {
mutation.mutate({ mutation.mutate({
type: 'updateStatusProperty', type: 'updateStatusProperty',
queryKey, status,
rootQueryKey,
id: status.id,
isReblog: !!reblogStatus,
payload: { payload: {
property: 'bookmarked', type: 'bookmarked',
currentValue: status.bookmarked, to: !status.bookmarked
propertyCount: undefined,
countValue: undefined
} }
}) })
}, [status.bookmarked]) }
const childrenReply = useMemo( const childrenReply = () => (
() => ( <>
<> <Icon name='MessageCircle' color={iconColor} size={StyleConstants.Font.Size.L} />
<Icon name='MessageCircle' color={iconColor} size={StyleConstants.Font.Size.L} /> {status.replies_count > 0 ? (
{status.replies_count > 0 ? ( <CustomText
<CustomText fontStyle='S'
style={{ style={{
color: colors.secondary, color: colors.secondary,
fontSize: StyleConstants.Font.Size.M, marginLeft: StyleConstants.Spacing.XS
marginLeft: StyleConstants.Spacing.XS }}
}} >
> {status.replies_count}
{status.replies_count} </CustomText>
</CustomText> ) : null}
) : null} </>
</>
),
[status.replies_count]
) )
const childrenReblog = useMemo(() => { const childrenReblog = () => {
const color = (state: boolean) => (state ? colors.green : colors.secondary) const color = (state: boolean) => (state ? colors.green : colors.secondary)
const disabled =
status.visibility === 'direct' || (status.visibility === 'private' && !ownAccount)
return ( return (
<> <>
<Icon <Icon
name='Repeat' name='Repeat'
color={ color={disabled ? colors.disabled : color(status.reblogged)}
status.visibility === 'direct' || (status.visibility === 'private' && !ownAccount) crossOut={disabled}
? colors.disabled
: color(status.reblogged)
}
size={StyleConstants.Font.Size.L} size={StyleConstants.Font.Size.L}
/> />
{status.reblogs_count > 0 ? ( {status.reblogs_count > 0 ? (
<CustomText <CustomText
fontStyle='S'
style={{ style={{
color: color:
status.visibility === 'private' && !ownAccount status.visibility === 'private' && !ownAccount
? colors.disabled ? colors.disabled
: color(status.reblogged), : color(status.reblogged),
fontSize: StyleConstants.Font.Size.M,
marginLeft: StyleConstants.Spacing.XS marginLeft: StyleConstants.Spacing.XS
}} }}
> >
@ -236,58 +207,56 @@ const TimelineActions: React.FC = () => {
) : null} ) : null}
</> </>
) )
}, [status.reblogged, status.reblogs_count]) }
const childrenFavourite = useMemo(() => { const childrenFavourite = () => {
const color = (state: boolean) => (state ? colors.red : colors.secondary) const color = (state: boolean) => (state ? colors.red : colors.secondary)
return ( return (
<> <>
<Icon name='Heart' color={color(status.favourited)} size={StyleConstants.Font.Size.L} /> <Icon name='Heart' color={color(status.favourited)} size={StyleConstants.Font.Size.L} />
{status.favourites_count > 0 ? ( {status.favourites_count > 0 ? (
<CustomText <CustomText
style={{ fontStyle='S'
color: color(status.favourited), style={{ color: color(status.favourited), marginLeft: StyleConstants.Spacing.XS }}
fontSize: StyleConstants.Font.Size.M,
marginLeft: StyleConstants.Spacing.XS,
marginTop: 0
}}
> >
{status.favourites_count} {status.favourites_count}
</CustomText> </CustomText>
) : null} ) : null}
</> </>
) )
}, [status.favourited, status.favourites_count]) }
const childrenBookmark = useMemo(() => { const childrenBookmark = () => {
const color = (state: boolean) => (state ? colors.yellow : colors.secondary) const color = (state: boolean) => (state ? colors.yellow : colors.secondary)
return ( return (
<Icon name='Bookmark' color={color(status.bookmarked)} size={StyleConstants.Font.Size.L} /> <Icon name='Bookmark' color={color(status.bookmarked)} size={StyleConstants.Font.Size.L} />
) )
}, [status.bookmarked]) }
return ( return (
<View style={{ flexDirection: 'row' }}> <View style={{ flexDirection: 'row' }}>
<Pressable <Pressable
{...(highlighted {...(highlighted
? { ? {
accessibilityLabel: t('shared.actions.reply.accessibilityLabel'), accessibilityLabel: t('componentTimeline:shared.actions.reply.accessibilityLabel'),
accessibilityRole: 'button' accessibilityRole: 'button'
} }
: { accessibilityLabel: '' })} : { accessibilityLabel: '' })}
style={styles.action} style={styles.action}
onPress={onPressReply} onPress={onPressReply}
children={childrenReply} children={childrenReply()}
/> />
<Pressable <Pressable
{...(highlighted {...(highlighted
? { ? {
accessibilityLabel: t('shared.actions.reblogged.accessibilityLabel'), accessibilityLabel: t(
'componentTimeline:shared.actions.reblogged.accessibilityLabel'
),
accessibilityRole: 'button' accessibilityRole: 'button'
} }
: { accessibilityLabel: '' })} : { accessibilityLabel: '' })}
style={styles.action} style={styles.action}
onPress={onPressReblog} onPress={onPressReblog}
children={childrenReblog} children={childrenReblog()}
disabled={ disabled={
status.visibility === 'direct' || (status.visibility === 'private' && !ownAccount) status.visibility === 'direct' || (status.visibility === 'private' && !ownAccount)
} }
@ -296,25 +265,29 @@ const TimelineActions: React.FC = () => {
<Pressable <Pressable
{...(highlighted {...(highlighted
? { ? {
accessibilityLabel: t('shared.actions.favourited.accessibilityLabel'), accessibilityLabel: t(
'componentTimeline:shared.actions.favourited.accessibilityLabel'
),
accessibilityRole: 'button' accessibilityRole: 'button'
} }
: { accessibilityLabel: '' })} : { accessibilityLabel: '' })}
style={styles.action} style={styles.action}
onPress={onPressFavourite} onPress={onPressFavourite}
children={childrenFavourite} children={childrenFavourite()}
/> />
<Pressable <Pressable
{...(highlighted {...(highlighted
? { ? {
accessibilityLabel: t('shared.actions.bookmarked.accessibilityLabel'), accessibilityLabel: t(
'componentTimeline:shared.actions.bookmarked.accessibilityLabel'
),
accessibilityRole: 'button' accessibilityRole: 'button'
} }
: { accessibilityLabel: '' })} : { accessibilityLabel: '' })}
style={styles.action} style={styles.action}
onPress={onPressBookmark} onPress={onPressBookmark}
children={childrenBookmark} children={childrenBookmark()}
/> />
</View> </View>
) )
@ -326,8 +299,7 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
minHeight: StyleConstants.Font.Size.L + StyleConstants.Spacing.S * 3, paddingVertical: StyleConstants.Spacing.S * 1.5
marginHorizontal: StyleConstants.Spacing.S
} }
}) })

View File

@ -7,13 +7,12 @@ import AttachmentVideo from '@components/Timeline/Shared/Attachment/Video'
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 { RootStackParamList } from '@utils/navigation/navigators' import { RootStackParamList } from '@utils/navigation/navigators'
import { getInstanceAccount } from '@utils/slices/instancesSlice' import { usePreferencesQuery } from '@utils/queryHooks/preferences'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import layoutAnimation from '@utils/styles/layoutAnimation' import layoutAnimation from '@utils/styles/layoutAnimation'
import React, { useContext, useState } from 'react' import React, { useContext, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Pressable, View } from 'react-native' import { Pressable, View } from 'react-native'
import { useSelector } from 'react-redux'
import StatusContext from './Context' import StatusContext from './Context'
const TimelineAttachment = () => { const TimelineAttachment = () => {
@ -28,13 +27,10 @@ const TimelineAttachment = () => {
const { t } = useTranslation('componentTimeline') const { t } = useTranslation('componentTimeline')
const account = useSelector( const { data: preferences } = usePreferencesQuery()
getInstanceAccount,
(prev, next) =>
prev.preferences?.['reading:expand:media'] === next.preferences?.['reading:expand:media']
)
const defaultSensitive = () => { const defaultSensitive = () => {
switch (account.preferences?.['reading:expand:media']) { switch (preferences?.['reading:expand:media']) {
case 'show_all': case 'show_all':
return false return false
case 'hide_all': case 'hide_all':

View File

@ -1,8 +1,7 @@
import Button from '@components/Button' import Button from '@components/Button'
import { useNavigation } from '@react-navigation/native'
import { StackNavigationProp } from '@react-navigation/stack'
import { RootStackParamList } from '@utils/navigation/navigators'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTranslation } from 'react-i18next'
import { Alert } from 'react-native'
export interface Props { export interface Props {
sensitiveShown: boolean sensitiveShown: boolean
@ -14,7 +13,7 @@ const AttachmentAltText: React.FC<Props> = ({ sensitiveShown, text }) => {
return null return null
} }
const navigation = useNavigation<StackNavigationProp<RootStackParamList>>() const { t } = useTranslation('componentTimeline')
return !sensitiveShown ? ( return !sensitiveShown ? (
<Button <Button
@ -28,7 +27,7 @@ const AttachmentAltText: React.FC<Props> = ({ sensitiveShown, text }) => {
type='text' type='text'
content='ALT' content='ALT'
fontBold fontBold
onPress={() => navigation.navigate('Screen-Actions', { type: 'alt_text', text })} onPress={() => Alert.alert(t('shared.attachment.altText'), text)}
/> />
) : null ) : null
} }

View File

@ -1,12 +1,14 @@
import Button from '@components/Button' import Button from '@components/Button'
import { useAccessibility } from '@utils/accessibility/AccessibilityManager'
import { useGlobalStorage } from '@utils/storage/actions'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { ResizeMode, Video, VideoFullscreenUpdate } from 'expo-av' import { ResizeMode, Video, VideoFullscreenUpdate } from 'expo-av'
import { Platform } from 'expo-modules-core'
import * as ScreenOrientation from 'expo-screen-orientation'
import React, { useRef, useState } from 'react' import React, { useRef, useState } from 'react'
import { Pressable, View } from 'react-native' import { Pressable, View } from 'react-native'
import { Blurhash } from 'react-native-blurhash' import { Blurhash } from 'react-native-blurhash'
import AttachmentAltText from './AltText' import AttachmentAltText from './AltText'
import { Platform } from 'expo-modules-core'
import { useAccessibility } from '@utils/accessibility/AccessibilityManager'
import { aspectRatio } from './dimensions' import { aspectRatio } from './dimensions'
export interface Props { export interface Props {
@ -25,6 +27,7 @@ const AttachmentVideo: React.FC<Props> = ({
gifv = false gifv = false
}) => { }) => {
const { reduceMotionEnabled } = useAccessibility() const { reduceMotionEnabled } = useAccessibility()
const [autoplayGifv] = useGlobalStorage.boolean('app.auto_play_gifv')
const videoPlayer = useRef<Video>(null) const videoPlayer = useRef<Video>(null)
const [videoLoading, setVideoLoading] = useState(false) const [videoLoading, setVideoLoading] = useState(false)
@ -60,7 +63,7 @@ const AttachmentVideo: React.FC<Props> = ({
resizeMode={videoResizeMode} resizeMode={videoResizeMode}
{...(gifv {...(gifv
? { ? {
shouldPlay: reduceMotionEnabled ? false : true, shouldPlay: reduceMotionEnabled || !autoplayGifv ? false : true,
isMuted: true, isMuted: true,
isLooping: true, isLooping: true,
source: { uri: video.url } source: { uri: video.url }
@ -70,14 +73,21 @@ const AttachmentVideo: React.FC<Props> = ({
posterStyle: { resizeMode: ResizeMode.COVER } posterStyle: { resizeMode: ResizeMode.COVER }
})} })}
useNativeControls={false} useNativeControls={false}
onFullscreenUpdate={event => { onFullscreenUpdate={async ({ fullscreenUpdate }) => {
if (event.fullscreenUpdate === VideoFullscreenUpdate.PLAYER_DID_DISMISS) { switch (fullscreenUpdate) {
Platform.OS === 'android' && setVideoResizeMode(ResizeMode.COVER) case VideoFullscreenUpdate.PLAYER_DID_PRESENT:
if (gifv && !reduceMotionEnabled) { Platform.OS === 'android' && (await ScreenOrientation.unlockAsync())
videoPlayer.current?.playAsync() break
} else { case VideoFullscreenUpdate.PLAYER_WILL_DISMISS:
videoPlayer.current?.pauseAsync() Platform.OS === 'android' &&
} (await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT))
Platform.OS === 'android' && setVideoResizeMode(ResizeMode.COVER)
if (gifv && !reduceMotionEnabled && autoplayGifv) {
videoPlayer.current?.playAsync()
} else {
videoPlayer.current?.pauseAsync()
}
break
} }
}} }}
onPlaybackStatusUpdate={event => { onPlaybackStatusUpdate={event => {
@ -106,7 +116,7 @@ const AttachmentVideo: React.FC<Props> = ({
video.blurhash ? ( video.blurhash ? (
<Blurhash blurhash={video.blurhash} style={{ width: '100%', height: '100%' }} /> <Blurhash blurhash={video.blurhash} style={{ width: '100%', height: '100%' }} />
) : null ) : null
) : !gifv || (gifv && reduceMotionEnabled) ? ( ) : !gifv || (gifv && (reduceMotionEnabled || !autoplayGifv)) ? (
<Button <Button
round round
overlay overlay
@ -119,6 +129,21 @@ const AttachmentVideo: React.FC<Props> = ({
) : null} ) : null}
<AttachmentAltText sensitiveShown={sensitiveShown} text={video.description} /> <AttachmentAltText sensitiveShown={sensitiveShown} text={video.description} />
</Pressable> </Pressable>
{gifv && !autoplayGifv ? (
<Button
style={{
position: 'absolute',
left: StyleConstants.Spacing.S,
bottom: StyleConstants.Spacing.S
}}
overlay
size='S'
type='text'
content='GIF'
fontBold
onPress={() => {}}
/>
) : null}
</View> </View>
) )
} }

View File

@ -31,7 +31,8 @@ const TimelineAvatar: React.FC<Props> = ({ account }) => {
}) })
})} })}
onPress={() => onPress={() =>
!disableOnPress && navigation.push('Tab-Shared-Account', { account: actualAccount }) !disableOnPress &&
navigation.push('Tab-Shared-Account', { account: actualAccount })
} }
uri={{ original: actualAccount.avatar, static: actualAccount.avatar_static }} uri={{ original: actualAccount.avatar, static: actualAccount.avatar_static }}
dimension={ dimension={

View File

@ -2,15 +2,16 @@ import ComponentAccount from '@components/Account'
import GracefullyImage from '@components/GracefullyImage' import GracefullyImage from '@components/GracefullyImage'
import openLink from '@components/openLink' import openLink from '@components/openLink'
import CustomText from '@components/Text' import CustomText from '@components/Text'
import { matchAccount, matchStatus } from '@helpers/urlMatcher'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import { StackNavigationProp } from '@react-navigation/stack'
import { urlMatcher } from '@utils/helpers/urlMatcher'
import { TabLocalStackParamList } from '@utils/navigation/navigators'
import { useAccountQuery } from '@utils/queryHooks/account' import { useAccountQuery } from '@utils/queryHooks/account'
import { useSearchQuery } from '@utils/queryHooks/search'
import { useStatusQuery } from '@utils/queryHooks/status' import { useStatusQuery } from '@utils/queryHooks/status'
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, { useContext, useEffect, useState } from 'react' import React, { useContext, useEffect, useState } from 'react'
import { Pressable, StyleSheet, View } from 'react-native' import { Pressable, View } from 'react-native'
import { Circle } from 'react-native-animated-spinkit' import { Circle } from 'react-native-animated-spinkit'
import TimelineDefault from '../Default' import TimelineDefault from '../Default'
import StatusContext from './Context' import StatusContext from './Context'
@ -20,96 +21,44 @@ const TimelineCard: React.FC = () => {
if (!status || !status.card) return null if (!status || !status.card) return null
const { colors } = useTheme() const { colors } = useTheme()
const navigation = useNavigation() const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const isStatus = matchStatus(status.card.url) const match = urlMatcher(status.card.url)
const [foundStatus, setFoundStatus] = useState<Mastodon.Status>() const [foundStatus, setFoundStatus] = useState<Mastodon.Status>()
const isAccount = matchAccount(status.card.url)
const [foundAccount, setFoundAccount] = useState<Mastodon.Account>() const [foundAccount, setFoundAccount] = useState<Mastodon.Account>()
const searchQuery = useSearchQuery({
type: (() => {
if (isStatus) return 'statuses'
if (isAccount) return 'accounts'
})(),
term: (() => {
if (isStatus) {
if (isStatus.sameInstance) {
return
} else {
return status.card.url
}
}
if (isAccount) {
if (isAccount.sameInstance) {
if (isAccount.style === 'default') {
return
} else {
return isAccount.username
}
} else {
return status.card.url
}
}
})(),
limit: 1,
options: { enabled: false }
})
const statusQuery = useStatusQuery({ const statusQuery = useStatusQuery({
id: isStatus?.id || '', status: match?.status ? { ...match.status, uri: status.card.url } : undefined,
options: { enabled: false } options: { enabled: false, retry: false }
}) })
useEffect(() => { useEffect(() => {
if (isStatus) { if (match?.status) {
setLoading(true) setLoading(true)
if (isStatus.sameInstance) { statusQuery
statusQuery .refetch()
.refetch() .then(res => {
.then(res => { res.data && setFoundStatus(res.data)
res.data && setFoundStatus(res.data) setLoading(false)
setLoading(false) })
}) .catch(() => setLoading(false))
.catch(() => setLoading(false))
} else {
searchQuery
.refetch()
.then(res => {
const status = (res.data as any)?.statuses?.[0]
status && setFoundStatus(status)
setLoading(false)
})
.catch(() => setLoading(false))
}
} }
}, []) }, [])
const accountQuery = useAccountQuery({ const accountQuery = useAccountQuery({
id: isAccount?.style === 'default' ? isAccount.id : '', account: match?.account ? { ...match?.account, url: status.card.url } : undefined,
options: { enabled: false } options: { enabled: false, retry: false }
}) })
useEffect(() => { useEffect(() => {
if (isAccount) { if (match?.account) {
setLoading(true) setLoading(true)
if (isAccount.sameInstance && isAccount.style === 'default') { accountQuery
accountQuery .refetch()
.refetch() .then(res => {
.then(res => { res.data && setFoundAccount(res.data)
res.data && setFoundAccount(res.data) setLoading(false)
setLoading(false) })
}) .catch(() => setLoading(false))
.catch(() => setLoading(false))
} else {
searchQuery
.refetch()
.then(res => {
const account = (res.data as any)?.accounts?.[0]
account && setFoundAccount(account)
setLoading(false)
})
.catch(() => setLoading(false))
}
} }
}, []) }, [])
@ -128,10 +77,10 @@ const TimelineCard: React.FC = () => {
</View> </View>
) )
} }
if (isStatus && foundStatus) { if (match?.status && foundStatus) {
return <TimelineDefault item={foundStatus} disableDetails disableOnPress /> return <TimelineDefault item={foundStatus} disableDetails disableOnPress />
} }
if (isAccount && foundAccount) { if (match?.account && foundAccount) {
return <ComponentAccount account={foundAccount} /> return <ComponentAccount account={foundAccount} />
} }
return ( return (
@ -192,12 +141,23 @@ const TimelineCard: React.FC = () => {
flex: 1, flex: 1,
flexDirection: 'row', flexDirection: 'row',
marginTop: StyleConstants.Spacing.M, marginTop: StyleConstants.Spacing.M,
borderWidth: StyleSheet.hairlineWidth, borderWidth: 1,
borderRadius: StyleConstants.Spacing.S, borderRadius: StyleConstants.Spacing.S,
overflow: 'hidden', overflow: 'hidden',
borderColor: colors.border borderColor: colors.border
}} }}
onPress={async () => status.card && (await openLink(status.card.url, navigation))} onPress={async () => {
if (match?.status && foundStatus) {
navigation.push('Tab-Shared-Toot', { toot: foundStatus })
return
}
if (match?.account && foundAccount) {
navigation.push('Tab-Shared-Account', { account: foundAccount })
return
}
status.card?.url && (await openLink(status.card.url, navigation))
}}
children={cardContent()} children={cardContent()}
/> />
) )

View File

@ -1,14 +1,11 @@
import { ParseHTML } from '@components/Parse' import { ParseHTML } from '@components/Parse'
import CustomText from '@components/Text' import CustomText from '@components/Text'
import { getInstanceAccount } from '@utils/slices/instancesSlice' import { usePreferencesQuery } from '@utils/queryHooks/preferences'
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, { useContext } from 'react' import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Platform, StyleSheet, View } from 'react-native' import { View } from 'react-native'
import { Path, Svg } from 'react-native-svg'
import { useSelector } from 'react-redux'
import { isRtlLang } from 'rtl-detect'
import StatusContext from './Context' import StatusContext from './Context'
export interface Props { export interface Props {
@ -17,35 +14,37 @@ export interface Props {
} }
const TimelineContent: React.FC<Props> = ({ notificationOwnToot = false, setSpoilerExpanded }) => { const TimelineContent: React.FC<Props> = ({ notificationOwnToot = false, setSpoilerExpanded }) => {
const { status, highlighted, inThread, disableDetails } = useContext(StatusContext) const { status, highlighted, inThread } = useContext(StatusContext)
if (!status || typeof status.content !== 'string' || !status.content.length) return null if (!status || typeof status.content !== 'string' || !status.content.length) return null
const { colors } = useTheme() const { colors } = useTheme()
const { t } = useTranslation('componentTimeline') const { t } = useTranslation('componentTimeline')
const instanceAccount = useSelector(getInstanceAccount, () => true)
const { data: preferences } = usePreferencesQuery()
return ( return (
<> <View>
{/* <CustomText
children={excludeMentions?.current.map(mention => mention.username).join(' - ')}
style={{ color: colors.secondary }}
/> */}
{status.spoiler_text?.length ? ( {status.spoiler_text?.length ? (
<> <>
<ParseHTML <ParseHTML
content={status.spoiler_text} content={status.spoiler_text}
size={highlighted ? 'L' : 'M'} size={highlighted ? 'L' : 'M'}
adaptiveSize adaptiveSize
emojis={status.emojis}
mentions={status.mentions}
tags={status.tags}
numberOfLines={999} numberOfLines={999}
highlighted={highlighted}
disableDetails={disableDetails}
textStyles={
Platform.OS === 'ios' && status.language && isRtlLang(status.language)
? { writingDirection: 'rtl' }
: undefined
}
/> />
{inThread ? ( {inThread ? (
<CustomText fontStyle='S' style={{ textAlign: 'center', color: colors.secondary, paddingVertical: StyleConstants.Spacing.XS }}> <CustomText
fontStyle='S'
style={{
textAlign: 'center',
color: colors.secondary,
paddingVertical: StyleConstants.Spacing.XS
}}
>
{t('shared.content.expandHint')} {t('shared.content.expandHint')}
</CustomText> </CustomText>
) : null} ) : null}
@ -53,11 +52,8 @@ const TimelineContent: React.FC<Props> = ({ notificationOwnToot = false, setSpoi
content={status.content} content={status.content}
size={highlighted ? 'L' : 'M'} size={highlighted ? 'L' : 'M'}
adaptiveSize adaptiveSize
emojis={status.emojis}
mentions={status.mentions}
tags={status.tags}
numberOfLines={ numberOfLines={
instanceAccount.preferences?.['reading:expand:spoilers'] || inThread preferences?.['reading:expand:spoilers'] || inThread
? notificationOwnToot ? notificationOwnToot
? 2 ? 2
: 999 : 999
@ -65,13 +61,6 @@ const TimelineContent: React.FC<Props> = ({ notificationOwnToot = false, setSpoi
} }
expandHint={t('shared.content.expandHint')} expandHint={t('shared.content.expandHint')}
setSpoilerExpanded={setSpoilerExpanded} setSpoilerExpanded={setSpoilerExpanded}
highlighted={highlighted}
disableDetails={disableDetails}
textStyles={
Platform.OS === 'ios' && status.language && isRtlLang(status.language)
? { writingDirection: 'rtl' }
: undefined
}
/> />
</> </>
) : ( ) : (
@ -79,19 +68,10 @@ const TimelineContent: React.FC<Props> = ({ notificationOwnToot = false, setSpoi
content={status.content} content={status.content}
size={highlighted ? 'L' : 'M'} size={highlighted ? 'L' : 'M'}
adaptiveSize adaptiveSize
emojis={status.emojis}
mentions={status.mentions}
tags={status.tags}
numberOfLines={highlighted || inThread ? 999 : notificationOwnToot ? 2 : undefined} numberOfLines={highlighted || inThread ? 999 : notificationOwnToot ? 2 : undefined}
disableDetails={disableDetails}
textStyles={
Platform.OS === 'ios' && status.language && isRtlLang(status.language)
? { writingDirection: 'rtl' }
: undefined
}
/> />
)} )}
</> </View>
) )
} }

View File

@ -5,21 +5,21 @@ export type HighlightedStatusContextType = {}
type StatusContextType = { type StatusContextType = {
queryKey?: QueryKeyTimeline queryKey?: QueryKeyTimeline
rootQueryKey?: QueryKeyTimeline
status?: Mastodon.Status status?: Mastodon.Status
reblogStatus?: Mastodon.Status // When it is a reblog, pass the root status
ownAccount?: boolean ownAccount?: boolean
spoilerHidden?: boolean spoilerHidden?: boolean
rawContent?: React.MutableRefObject<string[]> // When highlighted, for translate, edit history rawContent?: React.MutableRefObject<string[]> // When highlighted, for translate, edit history
detectedLanguage?: React.MutableRefObject<string> detectedLanguage?: React.MutableRefObject<string>
excludeMentions?: React.MutableRefObject<Mastodon.Mention[]>
highlighted?: boolean highlighted?: boolean
inThread?: boolean inThread?: boolean
disableDetails?: boolean disableDetails?: boolean
disableOnPress?: boolean disableOnPress?: boolean
isConversation?: boolean isConversation?: boolean
isRemote?: boolean
} }
const StatusContext = createContext<StatusContextType>({} as StatusContextType) const StatusContext = createContext<StatusContextType>({} as StatusContextType)

View File

@ -19,7 +19,7 @@ const TimelineFeedback = () => {
const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>() const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>()
const { data } = useStatusHistory({ const { data } = useStatusHistory({
id: status.id, status,
options: { enabled: status.edited_at !== undefined } options: { enabled: status.edited_at !== undefined }
}) })
@ -82,7 +82,7 @@ const TimelineFeedback = () => {
style={[styles.text, { marginRight: 0, color: colors.blue }]} style={[styles.text, { marginRight: 0, color: colors.blue }]}
onPress={() => onPress={() =>
navigation.push('Tab-Shared-History', { navigation.push('Tab-Shared-History', {
id: status.id, status,
detectedLanguage: detectedLanguage?.current || status.language || '' detectedLanguage: detectedLanguage?.current || status.language || ''
}) })
} }

View File

@ -1,8 +1,8 @@
import CustomText from '@components/Text' import CustomText from '@components/Text'
import removeHTML from '@helpers/removeHTML' import removeHTML from '@utils/helpers/removeHTML'
import { store } from '@root/store' import { queryClient } from '@utils/queryHooks'
import { QueryKeyFilters } from '@utils/queryHooks/filters'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline' import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
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 React from 'react' import React from 'react'
@ -15,7 +15,7 @@ export interface FilteredProps {
const TimelineFiltered: React.FC<FilteredProps> = ({ filterResults }) => { const TimelineFiltered: React.FC<FilteredProps> = ({ filterResults }) => {
const { colors } = useTheme() const { colors } = useTheme()
const { t } = useTranslation('componentTimeline') const { t } = useTranslation(['common', 'componentTimeline'])
const main = () => { const main = () => {
if (!filterResults?.length) { if (!filterResults?.length) {
@ -23,18 +23,27 @@ const TimelineFiltered: React.FC<FilteredProps> = ({ filterResults }) => {
} }
switch (typeof filterResults[0]) { switch (typeof filterResults[0]) {
case 'string': // v1 filter case 'string': // v1 filter
return <>{t('shared.filtered.match', { context: 'v1', phrase: filterResults[0] })}</> return (
<>
{t('componentTimeline:shared.filtered.match', {
defaultValue: 'v1',
context: 'v1',
phrase: filterResults[0]
})}
</>
)
default: default:
return ( return (
<> <>
{t('shared.filtered.match', { {t('componentTimeline:shared.filtered.match', {
defaultValue: 'v2',
context: 'v2', context: 'v2',
count: filterResults.length, count: filterResults.length,
filters: filterResults.map(result => result.title).join(t('common:separator')) filters: filterResults.map(result => result.title).join(t('common:separator'))
})} })}
<CustomText <CustomText
style={{ color: colors.blue }} style={{ color: colors.blue }}
children={`\n${t('shared.filtered.reveal')}`} children={`\n${t('componentTimeline:shared.filtered.reveal')}`}
/> />
</> </>
) )
@ -66,7 +75,6 @@ export const shouldFilter = ({
status: Pick<Mastodon.Status, 'content' | 'spoiler_text'> status: Pick<Mastodon.Status, 'content' | 'spoiler_text'>
}): FilteredProps['filterResults'] | undefined => { }): FilteredProps['filterResults'] | undefined => {
const page = queryKey[1] const page = queryKey[1]
const instance = getInstance(store.getState())
let returnFilter: FilteredProps['filterResults'] | undefined let returnFilter: FilteredProps['filterResults'] | undefined
@ -91,7 +99,8 @@ export const shouldFilter = ({
break break
} }
} }
instance?.filters?.forEach(filter => { const queryKeyFilters: QueryKeyFilters = ['Filters']
queryClient.getQueryData<Mastodon.Filter<'v1'>[]>(queryKeyFilters)?.forEach(filter => {
if (returnFilter) { if (returnFilter) {
return return
} }

View File

@ -4,14 +4,13 @@ import menuStatus from '@components/contextMenu/status'
import Icon from '@components/Icon' import Icon from '@components/Icon'
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, { useContext, useState } from 'react' import React, { Fragment, useContext, useState } from 'react'
import { Platform, View } from 'react-native' import { Platform, View } from 'react-native'
import * as DropdownMenu from 'zeego/dropdown-menu' 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, rawContent } = const { queryKey, 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
@ -28,9 +27,9 @@ const TimelineHeaderAndroid: React.FC = () => {
type: 'status', type: 'status',
openChange, openChange,
account: status.account, account: status.account,
queryKey ...(status && { status })
}) })
const mStatus = menuStatus({ status, queryKey, rootQueryKey }) const mStatus = menuStatus({ status, queryKey })
return ( return (
<View style={{ position: 'absolute', top: 0, right: 0 }}> <View style={{ position: 'absolute', top: 0, right: 0 }}>
@ -52,34 +51,51 @@ const TimelineHeaderAndroid: React.FC = () => {
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content> <DropdownMenu.Content>
{mShare.map((mGroup, index) => ( {[mShare, mAccount, mStatus].map((menu, i) => (
<DropdownMenu.Group key={index}> <Fragment key={i}>
{mGroup.map(menu => ( {menu.map((group, index) => (
<DropdownMenu.Item key={menu.key} {...menu.item}> <DropdownMenu.Group key={index}>
<DropdownMenu.ItemTitle children={menu.title} /> {group.map(item => {
</DropdownMenu.Item> switch (item.type) {
case 'item':
return (
<DropdownMenu.Item key={item.key} {...item.props}>
<DropdownMenu.ItemTitle children={item.title} />
{item.icon ? (
<DropdownMenu.ItemIcon ios={{ name: item.icon }} />
) : null}
</DropdownMenu.Item>
)
case 'sub':
return (
// @ts-ignore
<DropdownMenu.Sub key={item.key}>
<DropdownMenu.SubTrigger
key={item.trigger.key}
{...item.trigger.props}
>
<DropdownMenu.ItemTitle children={item.trigger.title} />
{item.trigger.icon ? (
<DropdownMenu.ItemIcon ios={{ name: item.trigger.icon }} />
) : null}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
{item.items.map(sub => (
<DropdownMenu.Item key={sub.key} {...sub.props}>
<DropdownMenu.ItemTitle children={sub.title} />
{sub.icon ? (
<DropdownMenu.ItemIcon ios={{ name: sub.icon }} />
) : null}
</DropdownMenu.Item>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
)
}
})}
</DropdownMenu.Group>
))} ))}
</DropdownMenu.Group> </Fragment>
))}
{mAccount.map((mGroup, index) => (
<DropdownMenu.Group key={index}>
{mGroup.map(menu => (
<DropdownMenu.Item key={menu.key} {...menu.item}>
<DropdownMenu.ItemTitle children={menu.title} />
</DropdownMenu.Item>
))}
</DropdownMenu.Group>
))}
{mStatus.map((mGroup, index) => (
<DropdownMenu.Group key={index}>
{mGroup.map(menu => (
<DropdownMenu.Item key={menu.key} {...menu.item}>
<DropdownMenu.ItemTitle children={menu.title} />
</DropdownMenu.Item>
))}
</DropdownMenu.Group>
))} ))}
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>

View File

@ -2,13 +2,13 @@ import Icon from '@components/Icon'
import { displayMessage } from '@components/Message' import { displayMessage } from '@components/Message'
import { ParseEmojis } from '@components/Parse' import { ParseEmojis } from '@components/Parse'
import CustomText from '@components/Text' import CustomText from '@components/Text'
import { useQueryClient } from '@tanstack/react-query'
import { useTimelineMutation } from '@utils/queryHooks/timeline' import { useTimelineMutation } 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, { useContext } from 'react' import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Pressable, View } from 'react-native' import { Pressable, View } from 'react-native'
import { useQueryClient } from '@tanstack/react-query'
import StatusContext from './Context' import StatusContext from './Context'
import HeaderSharedCreated from './HeaderShared/Created' import HeaderSharedCreated from './HeaderShared/Created'
import HeaderSharedMuted from './HeaderShared/Muted' import HeaderSharedMuted from './HeaderShared/Muted'
@ -22,7 +22,7 @@ const HeaderConversation = ({ conversation }: Props) => {
if (!queryKey) return null if (!queryKey) return null
const { colors, theme } = useTheme() const { colors, theme } = useTheme()
const { t } = useTranslation('componentTimeline') const { t } = useTranslation(['common', 'componentTimeline'])
const queryClient = useQueryClient() const queryClient = useQueryClient()
const mutation = useTimelineMutation({ const mutation = useTimelineMutation({
@ -32,7 +32,7 @@ const HeaderConversation = ({ conversation }: Props) => {
theme, theme,
type: 'error', type: 'error',
message: t('common:message.error.message', { message: t('common:message.error.message', {
function: t(`shared.header.conversation.delete.function`) function: t(`componentTimeline:shared.header.conversation.delete.function`)
}), }),
...(err.status && ...(err.status &&
typeof err.status === 'number' && typeof err.status === 'number' &&
@ -53,7 +53,7 @@ const HeaderConversation = ({ conversation }: Props) => {
numberOfLines={1} numberOfLines={1}
style={{ ...StyleConstants.FontStyle.M, color: colors.secondary }} style={{ ...StyleConstants.FontStyle.M, color: colors.secondary }}
> >
<CustomText>{t('shared.header.conversation.withAccounts')}</CustomText> <CustomText>{t('componentTimeline:shared.header.conversation.withAccounts')}</CustomText>
{conversation.accounts.map((account, index) => ( {conversation.accounts.map((account, index) => (
<CustomText key={account.id} numberOfLines={1}> <CustomText key={account.id} numberOfLines={1}>
{index !== 0 ? t('common:separator') : undefined} {index !== 0 ? t('common:separator') : undefined}
@ -73,13 +73,8 @@ const HeaderConversation = ({ conversation }: Props) => {
marginBottom: StyleConstants.Spacing.S marginBottom: StyleConstants.Spacing.S
}} }}
> >
{conversation.last_status?.created_at ? ( {conversation.last_status?.created_at ? <HeaderSharedCreated /> : null}
<HeaderSharedCreated <HeaderSharedMuted />
created_at={conversation.last_status?.created_at}
edited_at={conversation.last_status?.edited_at}
/>
) : null}
<HeaderSharedMuted muted={conversation.last_status?.muted} />
</View> </View>
</View> </View>
@ -89,7 +84,6 @@ const HeaderConversation = ({ conversation }: Props) => {
mutation.mutate({ mutation.mutate({
type: 'deleteItem', type: 'deleteItem',
source: 'conversations', source: 'conversations',
queryKey,
id: conversation.id id: conversation.id
}) })
} }

View File

@ -4,7 +4,7 @@ import menuStatus from '@components/contextMenu/status'
import Icon from '@components/Icon' import Icon from '@components/Icon'
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, { useContext, useState } from 'react' import React, { Fragment, useContext, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Platform, Pressable, View } from 'react-native' import { Platform, Pressable, View } from 'react-native'
import * as DropdownMenu from 'zeego/dropdown-menu' import * as DropdownMenu from 'zeego/dropdown-menu'
@ -13,11 +13,11 @@ import HeaderSharedAccount from './HeaderShared/Account'
import HeaderSharedApplication from './HeaderShared/Application' import HeaderSharedApplication from './HeaderShared/Application'
import HeaderSharedCreated from './HeaderShared/Created' import HeaderSharedCreated from './HeaderShared/Created'
import HeaderSharedMuted from './HeaderShared/Muted' import HeaderSharedMuted from './HeaderShared/Muted'
import HeaderSharedReplies from './HeaderShared/Replies'
import HeaderSharedVisibility from './HeaderShared/Visibility' import HeaderSharedVisibility from './HeaderShared/Visibility'
const TimelineHeaderDefault: React.FC = () => { const TimelineHeaderDefault: React.FC = () => {
const { queryKey, rootQueryKey, status, highlighted, disableDetails, rawContent } = const { queryKey, status, disableDetails, rawContent, isRemote } = useContext(StatusContext)
useContext(StatusContext)
if (!status) return null if (!status) return null
const { colors } = useTheme() const { colors } = useTheme()
@ -34,9 +34,9 @@ const TimelineHeaderDefault: React.FC = () => {
type: 'status', type: 'status',
openChange, openChange,
account: status.account, account: status.account,
queryKey ...(status && { status })
}) })
const mStatus = menuStatus({ status, queryKey, rootQueryKey }) const mStatus = menuStatus({ status, queryKey })
return ( return (
<View style={{ flex: 1, flexDirection: 'row' }}> <View style={{ flex: 1, flexDirection: 'row' }}>
@ -56,14 +56,19 @@ const TimelineHeaderDefault: React.FC = () => {
: { marginTop: StyleConstants.Spacing.XS, marginBottom: StyleConstants.Spacing.S }) : { marginTop: StyleConstants.Spacing.XS, marginBottom: StyleConstants.Spacing.S })
}} }}
> >
<HeaderSharedCreated {isRemote ? (
created_at={status.created_at} <Icon
edited_at={status.edited_at} name='Wifi'
highlighted={highlighted} size={StyleConstants.Font.Size.M}
/> color={colors.secondary}
<HeaderSharedVisibility visibility={status.visibility} /> style={{ marginRight: StyleConstants.Spacing.S }}
<HeaderSharedMuted muted={status.muted} /> />
<HeaderSharedApplication application={status.application} /> ) : null}
<HeaderSharedCreated />
<HeaderSharedVisibility />
<HeaderSharedMuted />
<HeaderSharedReplies />
<HeaderSharedApplication />
</View> </View>
</View> </View>
@ -82,37 +87,51 @@ const TimelineHeaderDefault: React.FC = () => {
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content> <DropdownMenu.Content>
{mShare.map((mGroup, index) => ( {[mShare, mAccount, mStatus].map((menu, i) => (
<DropdownMenu.Group key={index}> <Fragment key={i}>
{mGroup.map(menu => ( {menu.map((group, index) => (
<DropdownMenu.Item key={menu.key} {...menu.item}> <DropdownMenu.Group key={index}>
<DropdownMenu.ItemTitle children={menu.title} /> {group.map(item => {
<DropdownMenu.ItemIcon iosIconName={menu.icon} /> switch (item.type) {
</DropdownMenu.Item> case 'item':
return (
<DropdownMenu.Item key={item.key} {...item.props}>
<DropdownMenu.ItemTitle children={item.title} />
{item.icon ? (
<DropdownMenu.ItemIcon ios={{ name: item.icon }} />
) : null}
</DropdownMenu.Item>
)
case 'sub':
return (
// @ts-ignore
<DropdownMenu.Sub key={item}>
<DropdownMenu.SubTrigger
key={item.trigger.key}
{...item.trigger.props}
>
<DropdownMenu.ItemTitle children={item.trigger.title} />
{item.trigger.icon ? (
<DropdownMenu.ItemIcon ios={{ name: item.trigger.icon }} />
) : null}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
{item.items.map(sub => (
<DropdownMenu.Item key={sub.key} {...sub.props}>
<DropdownMenu.ItemTitle children={sub.title} />
{sub.icon ? (
<DropdownMenu.ItemIcon ios={{ name: sub.icon }} />
) : null}
</DropdownMenu.Item>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
)
}
})}
</DropdownMenu.Group>
))} ))}
</DropdownMenu.Group> </Fragment>
))}
{mAccount.map((mGroup, index) => (
<DropdownMenu.Group key={index}>
{mGroup.map(menu => (
<DropdownMenu.Item key={menu.key} {...menu.item}>
<DropdownMenu.ItemTitle children={menu.title} />
<DropdownMenu.ItemIcon iosIconName={menu.icon} />
</DropdownMenu.Item>
))}
</DropdownMenu.Group>
))}
{mStatus.map((mGroup, index) => (
<DropdownMenu.Group key={index}>
{mGroup.map(menu => (
<DropdownMenu.Item key={menu.key} {...menu.item}>
<DropdownMenu.ItemTitle children={menu.title} />
<DropdownMenu.ItemIcon iosIconName={menu.icon} />
</DropdownMenu.Item>
))}
</DropdownMenu.Group>
))} ))}
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>

View File

@ -5,15 +5,14 @@ import menuShare from '@components/contextMenu/share'
import menuStatus from '@components/contextMenu/status' import menuStatus from '@components/contextMenu/status'
import Icon from '@components/Icon' import Icon from '@components/Icon'
import { RelationshipIncoming, RelationshipOutgoing } from '@components/Relationship' import { RelationshipIncoming, RelationshipOutgoing } from '@components/Relationship'
import browserPackage from '@helpers/browserPackage' import browserPackage from '@utils/helpers/browserPackage'
import { getInstanceUrl } from '@utils/slices/instancesSlice' import { getAccountStorage } from '@utils/storage/actions'
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 * as WebBrowser from 'expo-web-browser' import * as WebBrowser from 'expo-web-browser'
import React, { useContext, useState } from 'react' import React, { Fragment, useContext, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Platform, Pressable, View } from 'react-native' import { Platform, Pressable, View } from 'react-native'
import { useSelector } from 'react-redux'
import * as DropdownMenu from 'zeego/dropdown-menu' import * as DropdownMenu from 'zeego/dropdown-menu'
import StatusContext from './Context' import StatusContext from './Context'
import HeaderSharedAccount from './HeaderShared/Account' import HeaderSharedAccount from './HeaderShared/Account'
@ -42,13 +41,11 @@ const TimelineHeaderNotification: React.FC<Props> = ({ notification }) => {
type: 'status', type: 'status',
openChange, openChange,
account: status?.account, account: status?.account,
queryKey ...(status && { status })
}) })
const mStatus = menuStatus({ status, queryKey }) const mStatus = menuStatus({ status, queryKey })
const mInstance = menuInstance({ status, queryKey }) const mInstance = menuInstance({ status, queryKey })
const url = useSelector(getInstanceUrl)
const actions = () => { const actions = () => {
switch (notification.type) { switch (notification.type) {
case 'follow': case 'follow':
@ -62,7 +59,9 @@ const TimelineHeaderNotification: React.FC<Props> = ({ notification }) => {
content={t('shared.actions.openReport')} content={t('shared.actions.openReport')}
onPress={async () => onPress={async () =>
WebBrowser.openAuthSessionAsync( WebBrowser.openAuthSessionAsync(
`https://${url}/admin/reports/${notification.report.id}`, `https://${getAccountStorage.string('auth.domain')}/admin/reports/${
notification.report.id
}`,
'tooot://tooot', 'tooot://tooot',
{ {
...(await browserPackage()), ...(await browserPackage()),
@ -74,7 +73,7 @@ const TimelineHeaderNotification: React.FC<Props> = ({ notification }) => {
/> />
) )
default: default:
if (status) { if (status && Platform.OS !== 'android') {
return ( return (
<Pressable <Pressable
style={{ flex: 1, alignItems: 'center' }} style={{ flex: 1, alignItems: 'center' }}
@ -89,48 +88,53 @@ const TimelineHeaderNotification: React.FC<Props> = ({ notification }) => {
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content> <DropdownMenu.Content>
{mShare.map((mGroup, index) => ( {[mShare, mStatus, mAccount, mInstance].map((menu, i) => (
<DropdownMenu.Group key={index}> <Fragment key={i}>
{mGroup.map(menu => ( {menu.map((group, index) => (
<DropdownMenu.Item key={menu.key} {...menu.item}> <DropdownMenu.Group key={index}>
<DropdownMenu.ItemTitle children={menu.title} /> {group.map(item => {
<DropdownMenu.ItemIcon iosIconName={menu.icon} /> switch (item.type) {
</DropdownMenu.Item> case 'item':
return (
<DropdownMenu.Item key={item.key} {...item.props}>
<DropdownMenu.ItemTitle children={item.title} />
{item.icon ? (
<DropdownMenu.ItemIcon ios={{ name: item.icon }} />
) : null}
</DropdownMenu.Item>
)
case 'sub':
return (
// @ts-ignore
<DropdownMenu.Sub key={item.key}>
<DropdownMenu.SubTrigger
key={item.trigger.key}
{...item.trigger.props}
>
<DropdownMenu.ItemTitle children={item.trigger.title} />
{item.trigger.icon ? (
<DropdownMenu.ItemIcon
ios={{ name: item.trigger.icon }}
/>
) : null}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
{item.items.map(sub => (
<DropdownMenu.Item key={sub.key} {...sub.props}>
<DropdownMenu.ItemTitle children={sub.title} />
{sub.icon ? (
<DropdownMenu.ItemIcon ios={{ name: sub.icon }} />
) : null}
</DropdownMenu.Item>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
)
}
})}
</DropdownMenu.Group>
))} ))}
</DropdownMenu.Group> </Fragment>
))}
{mAccount.map((mGroup, index) => (
<DropdownMenu.Group key={index}>
{mGroup.map(menu => (
<DropdownMenu.Item key={menu.key} {...menu.item}>
<DropdownMenu.ItemTitle children={menu.title} />
<DropdownMenu.ItemIcon iosIconName={menu.icon} />
</DropdownMenu.Item>
))}
</DropdownMenu.Group>
))}
{mStatus.map((mGroup, index) => (
<DropdownMenu.Group key={index}>
{mGroup.map(menu => (
<DropdownMenu.Item key={menu.key} {...menu.item}>
<DropdownMenu.ItemTitle children={menu.title} />
<DropdownMenu.ItemIcon iosIconName={menu.icon} />
</DropdownMenu.Item>
))}
</DropdownMenu.Group>
))}
{mInstance.map((mGroup, index) => (
<DropdownMenu.Group key={index}>
{mGroup.map(menu => (
<DropdownMenu.Item key={menu.key} {...menu.item}>
<DropdownMenu.ItemTitle children={menu.title} />
<DropdownMenu.ItemIcon iosIconName={menu.icon} />
</DropdownMenu.Item>
))}
</DropdownMenu.Group>
))} ))}
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
@ -175,31 +179,24 @@ const TimelineHeaderNotification: React.FC<Props> = ({ notification }) => {
marginBottom: StyleConstants.Spacing.S marginBottom: StyleConstants.Spacing.S
}} }}
> >
<HeaderSharedCreated <HeaderSharedCreated />
created_at={notification.status?.created_at || notification.created_at} {notification.status?.visibility ? <HeaderSharedVisibility /> : null}
edited_at={notification.status?.edited_at} <HeaderSharedMuted />
/> <HeaderSharedApplication />
{notification.status?.visibility ? (
<HeaderSharedVisibility visibility={notification.status.visibility} />
) : null}
<HeaderSharedMuted muted={notification.status?.muted} />
<HeaderSharedApplication application={notification.status?.application} />
</View> </View>
</View> </View>
{Platform.OS !== 'android' ? ( <View
<View style={[
style={[ { marginLeft: StyleConstants.Spacing.M },
{ marginLeft: StyleConstants.Spacing.M }, notification.type === 'follow' ||
notification.type === 'follow' || notification.type === 'follow_request' ||
notification.type === 'follow_request' || notification.type === 'admin.report'
notification.type === 'admin.report' ? { flexShrink: 1 }
? { flexShrink: 1 } : { flex: 1 }
: { flex: 1 } ]}
]} children={actions()}
children={actions()} />
/>
) : null}
</View> </View>
) )
} }

View File

@ -1,5 +1,5 @@
import { ParseEmojis } from '@components/Parse'
import CustomText from '@components/Text' import CustomText from '@components/Text'
import { ParseEmojis } from '@root/components/Parse'
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 from 'react' import React from 'react'

View File

@ -2,38 +2,34 @@ import openLink from '@components/openLink'
import CustomText from '@components/Text' import CustomText from '@components/Text'
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 from 'react' import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import StatusContext from '../Context'
export interface Props { const HeaderSharedApplication: React.FC = () => {
application?: Mastodon.Application const { status, isConversation } = useContext(StatusContext)
const { colors } = useTheme()
const { t } = useTranslation('componentTimeline')
return !isConversation && status?.application?.name && status.application.name !== 'Web' ? (
<CustomText
fontStyle='S'
accessibilityRole='link'
onPress={async () => {
status.application?.website && (await openLink(status.application.website))
}}
style={{
flex: 1,
marginLeft: StyleConstants.Spacing.S,
color: colors.secondary
}}
numberOfLines={1}
>
{t('shared.header.shared.application', {
application: status.application.name
})}
</CustomText>
) : null
} }
const HeaderSharedApplication = React.memo(
({ application }: Props) => {
const { colors } = useTheme()
const { t } = useTranslation('componentTimeline')
return application && application.name !== 'Web' ? (
<CustomText
fontStyle='S'
accessibilityRole='link'
onPress={async () => {
application.website && (await openLink(application.website))
}}
style={{
marginLeft: StyleConstants.Spacing.S,
color: colors.secondary
}}
numberOfLines={1}
>
{t('shared.header.shared.application', {
application: application.name
})}
</CustomText>
) : null
},
() => true
)
export default HeaderSharedApplication export default HeaderSharedApplication

View File

@ -3,53 +3,46 @@ import RelativeTime from '@components/RelativeTime'
import CustomText from '@components/Text' import CustomText from '@components/Text'
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 from 'react' import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { FormattedDate } from 'react-intl' import { FormattedDate } from 'react-intl'
import StatusContext from '../Context'
export interface Props { export interface Props {
created_at: Mastodon.Status['created_at'] | number created_at?: Mastodon.Status['created_at'] | number
edited_at?: Mastodon.Status['edited_at']
highlighted?: boolean
} }
const HeaderSharedCreated = React.memo( const HeaderSharedCreated: React.FC<Props> = ({ created_at }) => {
({ created_at, edited_at, highlighted = false }: Props) => { const { status, highlighted } = useContext(StatusContext)
const { t } = useTranslation('componentTimeline') const { t } = useTranslation('componentTimeline')
const { colors } = useTheme() const { colors } = useTheme()
const actualTime = edited_at || created_at if (!status) return null
return ( const actualTime = created_at || status.edited_at || status.created_at
<>
<CustomText fontStyle='S' style={{ color: colors.secondary }}> return (
{highlighted ? ( <>
<> <CustomText fontStyle='S' style={{ color: colors.secondary }}>
<FormattedDate {highlighted ? (
value={new Date(actualTime)} <>
dateStyle='medium' <FormattedDate value={new Date(actualTime)} dateStyle='medium' timeStyle='short' />
timeStyle='short' </>
/> ) : (
</> <RelativeTime time={actualTime} />
) : ( )}
<RelativeTime time={actualTime} /> </CustomText>
)} {status.edited_at && !highlighted ? (
</CustomText> <Icon
{edited_at ? ( accessibilityLabel={t('shared.header.shared.edited.accessibilityLabel')}
<Icon name='Edit'
accessibilityLabel={t( size={StyleConstants.Font.Size.S}
'shared.header.shared.edited.accessibilityLabel' color={colors.secondary}
)} style={{ marginLeft: StyleConstants.Spacing.S }}
name='Edit' />
size={StyleConstants.Font.Size.S} ) : null}
color={colors.secondary} </>
style={{ marginLeft: StyleConstants.Spacing.S }} )
/> }
) : null}
</>
)
},
(prev, next) => prev.edited_at === next.edited_at
)
export default HeaderSharedCreated export default HeaderSharedCreated

View File

@ -1,36 +1,24 @@
import Icon from '@components/Icon' import Icon from '@components/Icon'
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 from 'react' import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { StyleSheet } from 'react-native' import StatusContext from '../Context'
export interface Props { const HeaderSharedMuted: React.FC = () => {
muted?: Mastodon.Status['muted'] const { status } = useContext(StatusContext)
const { t } = useTranslation('componentTimeline')
const { colors } = useTheme()
return status?.muted ? (
<Icon
accessibilityLabel={t('shared.header.shared.muted.accessibilityLabel')}
name='VolumeX'
size={StyleConstants.Font.Size.M}
color={colors.secondary}
style={{ marginLeft: StyleConstants.Spacing.S }}
/>
) : null
} }
const HeaderSharedMuted = React.memo(
({ muted }: Props) => {
const { t } = useTranslation('componentTimeline')
const { colors } = useTheme()
return muted ? (
<Icon
accessibilityLabel={t('shared.header.shared.muted.accessibilityLabel')}
name='VolumeX'
size={StyleConstants.Font.Size.S}
color={colors.secondary}
style={styles.visibility}
/>
) : null
},
() => true
)
const styles = StyleSheet.create({
visibility: {
marginLeft: StyleConstants.Spacing.S
}
})
export default HeaderSharedMuted export default HeaderSharedMuted

View File

@ -0,0 +1,59 @@
import CustomText from '@components/Text'
import { useNavigation } from '@react-navigation/native'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { Fragment, useContext } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import StatusContext from '../Context'
const HeaderSharedReplies: React.FC = () => {
const { status, rawContent, excludeMentions, isConversation } = useContext(StatusContext)
if (!isConversation) return null
const navigation = useNavigation<any>()
const { t } = useTranslation(['common', 'componentTimeline'])
const { colors } = useTheme()
const mentionsBeginning = rawContent?.current?.[0]
.match(new RegExp(/^(?:@\S+\s+)+/))?.[0]
?.match(new RegExp(/@\S+/, 'g'))
excludeMentions &&
(excludeMentions.current =
mentionsBeginning?.length && status?.mentions
? status.mentions.filter(mention => mentionsBeginning.includes(`@${mention.username}`))
: [])
return excludeMentions?.current.length ? (
<CustomText
fontStyle='S'
style={{ flex: 1, marginLeft: StyleConstants.Spacing.S, color: colors.secondary }}
numberOfLines={1}
>
<Trans
ns='componentTimeline'
i18nKey='shared.header.shared.replies'
components={[
<>
{excludeMentions.current.map((mention, index) => (
<Fragment key={index}>
{index > 0 ? t('common:separator') : null}
<CustomText
style={{ color: colors.blue, paddingLeft: StyleConstants.Spacing.S }}
children={`@${mention.username}`}
onPress={() =>
navigation.push('Tab-Shared-Account', {
account: mention,
isRemote: status?._remote
})
}
/>
</Fragment>
))}
</>
]}
/>
</CustomText>
) : null
}
export default HeaderSharedReplies

View File

@ -1,57 +1,52 @@
import Icon from '@components/Icon' import Icon from '@components/Icon'
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 from 'react' import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { StyleSheet } from 'react-native' import { StyleSheet } from 'react-native'
import StatusContext from '../Context'
export interface Props { const HeaderSharedVisibility: React.FC = () => {
visibility: Mastodon.Status['visibility'] const { status } = useContext(StatusContext)
const { t } = useTranslation('componentTimeline')
const { colors } = useTheme()
switch (status?.visibility) {
case 'unlisted':
return (
<Icon
accessibilityLabel={t('shared.header.shared.visibility.private.accessibilityLabel')}
name='Unlock'
size={StyleConstants.Font.Size.S}
color={colors.secondary}
style={styles.visibility}
/>
)
case 'private':
return (
<Icon
accessibilityLabel={t('shared.header.shared.visibility.private.accessibilityLabel')}
name='Lock'
size={StyleConstants.Font.Size.S}
color={colors.secondary}
style={styles.visibility}
/>
)
case 'direct':
return (
<Icon
accessibilityLabel={t('shared.header.shared.visibility.direct.accessibilityLabel')}
name='Mail'
size={StyleConstants.Font.Size.S}
color={colors.secondary}
style={styles.visibility}
/>
)
default:
return null
}
} }
const HeaderSharedVisibility = React.memo(
({ visibility }: Props) => {
const { t } = useTranslation('componentTimeline')
const { colors } = useTheme()
switch (visibility) {
case 'unlisted':
return (
<Icon
accessibilityLabel={t('shared.header.shared.visibility.private.accessibilityLabel')}
name='Unlock'
size={StyleConstants.Font.Size.S}
color={colors.secondary}
style={styles.visibility}
/>
)
case 'private':
return (
<Icon
accessibilityLabel={t('shared.header.shared.visibility.private.accessibilityLabel')}
name='Lock'
size={StyleConstants.Font.Size.S}
color={colors.secondary}
style={styles.visibility}
/>
)
case 'direct':
return (
<Icon
accessibilityLabel={t('shared.header.shared.visibility.direct.accessibilityLabel')}
name='Mail'
size={StyleConstants.Font.Size.S}
color={colors.secondary}
style={styles.visibility}
/>
)
default:
return null
}
},
() => true
)
const styles = StyleSheet.create({ const styles = StyleSheet.create({
visibility: { visibility: {
marginLeft: StyleConstants.Spacing.S marginLeft: StyleConstants.Spacing.S

View File

@ -5,6 +5,8 @@ import { displayMessage } from '@components/Message'
import { ParseEmojis } from '@components/Parse' import { ParseEmojis } from '@components/Parse'
import RelativeTime from '@components/RelativeTime' import RelativeTime from '@components/RelativeTime'
import CustomText from '@components/Text' import CustomText from '@components/Text'
import { useQueryClient } from '@tanstack/react-query'
import { useNavState } from '@utils/navigation/navigators'
import { import {
MutationVarsTimelineUpdateStatusProperty, MutationVarsTimelineUpdateStatusProperty,
useTimelineMutation useTimelineMutation
@ -13,42 +15,36 @@ import updateStatusProperty from '@utils/queryHooks/timeline/updateStatusPropert
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 { maxBy } from 'lodash' import { maxBy } from 'lodash'
import React, { useCallback, useContext, useMemo, useState } from 'react' import React, { useContext, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { Pressable, View } from 'react-native' import { Pressable, View } from 'react-native'
import { useQueryClient } from '@tanstack/react-query'
import StatusContext from './Context' import StatusContext from './Context'
const TimelinePoll: React.FC = () => { const TimelinePoll: React.FC = () => {
const { const { queryKey, status, ownAccount, spoilerHidden, disableDetails, highlighted } =
queryKey, useContext(StatusContext)
rootQueryKey,
status,
reblogStatus,
ownAccount,
spoilerHidden,
disableDetails
} = useContext(StatusContext)
if (!queryKey || !status || !status.poll) return null if (!queryKey || !status || !status.poll) return null
const poll = status.poll const poll = status.poll
const { colors, theme } = useTheme() const { colors, theme } = useTheme()
const { t } = useTranslation('componentTimeline') const { t } = useTranslation(['common', 'componentTimeline'])
const [allOptions, setAllOptions] = useState(new Array(status.poll.options.length).fill(false)) const [allOptions, setAllOptions] = useState(new Array(status.poll.options.length).fill(false))
const navigationState = useNavState()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const mutation = useTimelineMutation({ const mutation = useTimelineMutation({
onSuccess: ({ body }, params) => { onSuccess: ({ body }, params) => {
const theParams = params as MutationVarsTimelineUpdateStatusProperty const theParams = params as MutationVarsTimelineUpdateStatusProperty
queryClient.cancelQueries(queryKey) queryClient.cancelQueries(queryKey)
rootQueryKey && queryClient.cancelQueries(rootQueryKey)
haptics('Success') haptics('Success')
switch (theParams.payload.property) { switch (theParams.payload.type) {
case 'poll': case 'poll':
theParams.payload.data = body as unknown as Mastodon.Poll updateStatusProperty(
updateStatusProperty(theParams) { ...theParams, poll: body as unknown as Mastodon.Poll },
navigationState
)
break break
} }
}, },
@ -59,7 +55,7 @@ const TimelinePoll: React.FC = () => {
type: 'error', type: 'error',
message: t('common:message.error.message', { message: t('common:message.error.message', {
// @ts-ignore // @ts-ignore
function: t(`shared.poll.meta.button.${theParams.payload.type}`) function: t(`componentTimeline:shared.poll.meta.button.${theParams.payload.type}` as any)
}), }),
...(err.status && ...(err.status &&
typeof err.status === 'number' && typeof err.status === 'number' &&
@ -73,7 +69,7 @@ const TimelinePoll: React.FC = () => {
} }
}) })
const pollButton = useMemo(() => { const pollButton = () => {
if (!poll.expired) { if (!poll.expired) {
if (!ownAccount && !poll.voted) { if (!ownAccount && !poll.voted) {
return ( return (
@ -82,62 +78,51 @@ const TimelinePoll: React.FC = () => {
onPress={() => onPress={() =>
mutation.mutate({ mutation.mutate({
type: 'updateStatusProperty', type: 'updateStatusProperty',
queryKey, status,
rootQueryKey,
id: status.id,
isReblog: !!reblogStatus,
payload: { payload: {
property: 'poll', type: 'poll',
id: poll.id, action: 'vote',
type: 'vote',
options: allOptions options: allOptions
} }
}) })
} }
type='text' type='text'
content={t('shared.poll.meta.button.vote')} content={t('componentTimeline:shared.poll.meta.button.vote')}
loading={mutation.isLoading} loading={mutation.isLoading}
disabled={allOptions.filter(o => o !== false).length === 0} disabled={allOptions.filter(o => o !== false).length === 0}
/> />
</View> </View>
) )
} else { } else if (highlighted) {
return ( return (
<View style={{ marginRight: StyleConstants.Spacing.S }}> <View style={{ marginRight: StyleConstants.Spacing.S }}>
<Button <Button
onPress={() => onPress={() =>
mutation.mutate({ mutation.mutate({
type: 'updateStatusProperty', type: 'updateStatusProperty',
queryKey, status,
rootQueryKey,
id: status.id,
isReblog: !!reblogStatus,
payload: { payload: {
property: 'poll', type: 'poll',
id: poll.id, action: 'refresh'
type: 'refresh'
} }
}) })
} }
type='text' type='text'
content={t('shared.poll.meta.button.refresh')} content={t('componentTimeline:shared.poll.meta.button.refresh')}
loading={mutation.isLoading} loading={mutation.isLoading}
/> />
</View> </View>
) )
} }
} }
}, [theme, poll.expired, poll.voted, allOptions, mutation.isLoading]) }
const isSelected = useCallback( const isSelected = (index: number): string =>
(index: number): string => allOptions[index]
allOptions[index] ? `Check${poll.multiple ? 'Square' : 'Circle'}`
? `Check${poll.multiple ? 'Square' : 'Circle'}` : `${poll.multiple ? 'Square' : 'Circle'}`
: `${poll.multiple ? 'Square' : 'Circle'}`,
[allOptions]
)
const pollBodyDisallow = useMemo(() => { const pollBodyDisallow = () => {
const maxValue = maxBy(poll.options, option => option.votes_count)?.votes_count const maxValue = maxBy(poll.options, option => option.votes_count)?.votes_count
return poll.options.map((option, index) => ( return poll.options.map((option, index) => (
<View key={index} style={{ flex: 1, paddingVertical: StyleConstants.Spacing.S }}> <View key={index} style={{ flex: 1, paddingVertical: StyleConstants.Spacing.S }}>
@ -182,7 +167,7 @@ const TimelinePoll: React.FC = () => {
borderTopRightRadius: 10, borderTopRightRadius: 10,
borderBottomRightRadius: 10, borderBottomRightRadius: 10,
marginTop: StyleConstants.Spacing.XS, marginTop: StyleConstants.Spacing.XS,
marginBottom: StyleConstants.Spacing.S, marginBottom: StyleConstants.Spacing.XS,
width: `${Math.round( width: `${Math.round(
(option.votes_count / (poll.voters_count || poll.votes_count)) * 100 (option.votes_count / (poll.voters_count || poll.votes_count)) * 100
)}%`, )}%`,
@ -191,8 +176,8 @@ const TimelinePoll: React.FC = () => {
/> />
</View> </View>
)) ))
}, [theme, poll.options]) }
const pollBodyAllow = useMemo(() => { const pollBodyAllow = () => {
return poll.options.map((option, index) => ( return poll.options.map((option, index) => (
<Pressable <Pressable
key={index} key={index}
@ -229,26 +214,30 @@ const TimelinePoll: React.FC = () => {
</View> </View>
</Pressable> </Pressable>
)) ))
}, [theme, allOptions]) }
const pollVoteCounts = () => { const pollVoteCounts = () => {
if (poll.voters_count !== null) { if (poll.voters_count !== null) {
return t('shared.poll.meta.count.voters', { count: poll.voters_count }) + ' • ' return t('componentTimeline:shared.poll.meta.count.voters', { count: poll.voters_count })
} else if (poll.votes_count !== null) { } else if (poll.votes_count !== null) {
return t('shared.poll.meta.count.votes', { count: poll.votes_count }) + ' • ' return t('componentTimeline:shared.poll.meta.count.votes', { count: poll.votes_count })
} }
} }
const pollExpiration = () => { const pollExpiration = () => {
if (poll.expired) { if (poll.expired) {
return t('shared.poll.meta.expiration.expired') return t('componentTimeline:shared.poll.meta.expiration.expired')
} else { } else {
if (poll.expires_at) { if (poll.expires_at) {
return ( return (
<Trans <>
i18nKey='componentTimeline:shared.poll.meta.expiration.until' {' • '}
components={[<RelativeTime time={poll.expires_at} />]} <Trans
/> ns='componentTimeline'
i18nKey='shared.poll.meta.expiration.until'
components={[<RelativeTime time={poll.expires_at} />]}
/>
</>
) )
} }
} }
@ -258,7 +247,7 @@ const TimelinePoll: React.FC = () => {
return ( return (
<View style={{ marginTop: StyleConstants.Spacing.M }}> <View style={{ marginTop: StyleConstants.Spacing.M }}>
{poll.expired || poll.voted ? pollBodyDisallow : pollBodyAllow} {poll.expired || poll.voted ? pollBodyDisallow() : pollBodyAllow()}
<View <View
style={{ style={{
flex: 1, flex: 1,
@ -267,7 +256,7 @@ const TimelinePoll: React.FC = () => {
marginTop: StyleConstants.Spacing.XS marginTop: StyleConstants.Spacing.XS
}} }}
> >
{pollButton} {pollButton()}
<CustomText fontStyle='S' style={{ flexShrink: 1, color: colors.secondary }}> <CustomText fontStyle='S' style={{ flexShrink: 1, color: colors.secondary }}>
{pollVoteCounts()} {pollVoteCounts()}
{pollExpiration()} {pollExpiration()}

View File

@ -1,7 +1,7 @@
import { ParseHTML } from '@components/Parse' import { ParseHTML } from '@components/Parse'
import CustomText from '@components/Text' import CustomText from '@components/Text'
import detectLanguage from '@helpers/detectLanguage' import detectLanguage from '@utils/helpers/detectLanguage'
import getLanguage from '@helpers/getLanguage' import getLanguage from '@utils/helpers/getLanguage'
import { useTranslateQuery } from '@utils/queryHooks/translate' import { useTranslateQuery } from '@utils/queryHooks/translate'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
@ -16,7 +16,7 @@ const TimelineTranslate = () => {
const { status, highlighted, rawContent, detectedLanguage } = useContext(StatusContext) const { status, highlighted, rawContent, detectedLanguage } = useContext(StatusContext)
if (!status || !highlighted || !rawContent?.current.length) 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 [detected, setDetected] = useState<{ const [detected, setDetected] = useState<{
@ -101,15 +101,15 @@ const TimelineTranslate = () => {
}} }}
> >
{isError {isError
? t('shared.translate.failed') ? t('componentTimeline:shared.translate.failed')
: isSuccess : isSuccess
? typeof data?.error === 'string' ? typeof data?.error === 'string'
? t(`shared.translate.${data.error}`) ? t(`componentTimeline:shared.translate.${data.error}` as any)
: t('shared.translate.succeed', { : t('componentTimeline:shared.translate.succeed', {
provider: data?.provider, provider: data?.provider,
source: data?.sourceLanguage source: data?.sourceLanguage
}) })
: t('shared.translate.default')} : t('componentTimeline:shared.translate.default')}
</CustomText> </CustomText>
{isFetching ? ( {isFetching ? (
<Circle <Circle

View File

@ -1,19 +1,24 @@
import ComponentSeparator from '@components/Separator' import ComponentSeparator from '@components/Separator'
import TimelineDefault from '@components/Timeline/Default'
import { useScrollToTop } from '@react-navigation/native' import { useScrollToTop } from '@react-navigation/native'
import { UseInfiniteQueryOptions } from '@tanstack/react-query' import { UseInfiniteQueryOptions } from '@tanstack/react-query'
import { QueryKeyTimeline, useTimelineQuery } from '@utils/queryHooks/timeline' import { QueryKeyTimeline, useTimelineQuery } from '@utils/queryHooks/timeline'
import { getInstanceActive } from '@utils/slices/instancesSlice' import { flattenPages } from '@utils/queryHooks/utils'
import {
getAccountStorage,
setAccountStorage,
useGlobalStorageListener
} from '@utils/storage/actions'
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, { RefObject, useCallback, useRef } from 'react' import React, { RefObject, useRef } from 'react'
import { FlatList, FlatListProps, Platform, RefreshControl } from 'react-native' import { FlatList, FlatListProps, Platform, RefreshControl } from 'react-native'
import Animated, { useAnimatedScrollHandler, useSharedValue } from 'react-native-reanimated' import Animated, { useAnimatedScrollHandler, useSharedValue } from 'react-native-reanimated'
import { useSelector } from 'react-redux' import TimelineEmpty from './Empty'
import TimelineEmpty from './Timeline/Empty' import TimelineFooter from './Footer'
import TimelineFooter from './Timeline/Footer' import TimelineRefresh, { SEPARATION_Y_1, SEPARATION_Y_2 } from './Refresh'
import TimelineRefresh, { SEPARATION_Y_1, SEPARATION_Y_2 } from './Timeline/Refresh'
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList) const AnimatedFlatList = Animated.createAnimatedComponent(FlatList<any>)
export interface Props { export interface Props {
flRef?: RefObject<FlatList<any>> flRef?: RefObject<FlatList<any>>
@ -24,7 +29,8 @@ export interface Props {
> >
disableRefresh?: boolean disableRefresh?: boolean
disableInfinity?: boolean disableInfinity?: boolean
customProps: Partial<FlatListProps<any>> & Pick<FlatListProps<any>, 'renderItem'> readMarker?: 'read_marker_following'
customProps?: Partial<FlatListProps<any>>
} }
const Timeline: React.FC<Props> = ({ const Timeline: React.FC<Props> = ({
@ -33,6 +39,7 @@ const Timeline: React.FC<Props> = ({
queryOptions, queryOptions,
disableRefresh = false, disableRefresh = false,
disableInfinity = false, disableInfinity = false,
readMarker = undefined,
customProps customProps
}) => { }) => {
const { colors } = useTheme() const { colors } = useTheme()
@ -45,24 +52,12 @@ const Timeline: React.FC<Props> = ({
notifyOnChangeProps: Platform.select({ notifyOnChangeProps: Platform.select({
ios: ['dataUpdatedAt', 'isFetching'], ios: ['dataUpdatedAt', 'isFetching'],
android: ['dataUpdatedAt', 'isFetching', 'isLoading'] android: ['dataUpdatedAt', 'isFetching', 'isLoading']
}), })
getNextPageParam: lastPage =>
lastPage?.links?.next && {
...(lastPage.links.next.isOffset
? { offset: lastPage.links.next.id }
: { max_id: lastPage.links.next.id })
}
} }
}) })
const flattenData = data?.pages ? data.pages?.flatMap(page => [...page.body]) : []
const onEndReached = useCallback(
() => !disableInfinity && !isFetchingNextPage && fetchNextPage(),
[isFetchingNextPage]
)
const flRef = useRef<FlatList>(null) const flRef = useRef<FlatList>(null)
const fetchingActive = useRef<boolean>(false)
const scrollY = useSharedValue(0) const scrollY = useSharedValue(0)
const fetchingType = useSharedValue<0 | 1 | 2>(0) const fetchingType = useSharedValue<0 | 1 | 2>(0)
@ -85,6 +80,32 @@ const Timeline: React.FC<Props> = ({
[isFetching] [isFetching]
) )
const viewabilityConfigCallbackPairs = useRef<
Pick<FlatListProps<any>, 'viewabilityConfigCallbackPairs'>['viewabilityConfigCallbackPairs']
>(
readMarker
? [
{
viewabilityConfig: {
minimumViewTime: 300,
itemVisiblePercentThreshold: 80,
waitForInteraction: true
},
onViewableItemsChanged: ({ viewableItems }) => {
const marker = readMarker ? getAccountStorage.string(readMarker) : undefined
const firstItemId = viewableItems.filter(item => item.isViewable)[0]?.item.id
if (!fetchingActive.current && firstItemId && firstItemId > (marker || '0')) {
setAccountStorage([{ key: readMarker, value: firstItemId }])
} else {
// setAccountStorage([{ key: readMarker, value: '109519141378761752' }])
}
}
}
]
: undefined
)
const androidRefreshControl = Platform.select({ const androidRefreshControl = Platform.select({
android: { android: {
refreshControl: ( refreshControl: (
@ -93,38 +114,45 @@ const Timeline: React.FC<Props> = ({
colors={[colors.primaryDefault]} colors={[colors.primaryDefault]}
progressBackgroundColor={colors.backgroundDefault} progressBackgroundColor={colors.backgroundDefault}
refreshing={isFetching || isLoading} refreshing={isFetching || isLoading}
onRefresh={() => refetch()} onRefresh={() => {
if (readMarker) {
setAccountStorage([{ key: readMarker, value: undefined }])
}
refetch()
}}
/> />
) )
} }
}) })
useScrollToTop(flRef) useScrollToTop(flRef)
useSelector(getInstanceActive, (prev, next) => { useGlobalStorageListener('account.active', () =>
if (prev !== next) { flRef.current?.scrollToOffset({ offset: 0, animated: false })
flRef.current?.scrollToOffset({ offset: 0, animated: false }) )
}
return prev === next
})
return ( return (
<> <>
<TimelineRefresh <TimelineRefresh
flRef={flRef} flRef={flRef}
queryKey={queryKey} queryKey={queryKey}
fetchingActive={fetchingActive}
scrollY={scrollY} scrollY={scrollY}
fetchingType={fetchingType} fetchingType={fetchingType}
disableRefresh={disableRefresh} disableRefresh={disableRefresh}
readMarker={readMarker}
/> />
<AnimatedFlatList <AnimatedFlatList
ref={customFLRef || flRef} ref={customFLRef || flRef}
scrollEventThrottle={16} scrollEventThrottle={16}
onScroll={onScroll} onScroll={onScroll}
windowSize={7} windowSize={7}
data={flattenData} data={flattenPages(data)}
{...(customProps?.renderItem
? { renderItem: customProps.renderItem }
: { renderItem: ({ item }) => <TimelineDefault item={item} queryKey={queryKey} /> })}
initialNumToRender={6} initialNumToRender={6}
maxToRenderPerBatch={3} maxToRenderPerBatch={3}
onEndReached={onEndReached} onEndReached={() => !disableInfinity && !isFetchingNextPage && fetchNextPage()}
onEndReachedThreshold={0.75} onEndReachedThreshold={0.75}
ListFooterComponent={ ListFooterComponent={
<TimelineFooter queryKey={queryKey} disableInfinity={disableInfinity} /> <TimelineFooter queryKey={queryKey} disableInfinity={disableInfinity} />
@ -139,13 +167,12 @@ const Timeline: React.FC<Props> = ({
/> />
) )
} }
maintainVisibleContentPosition={ viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs.current}
isFetching {...(!isLoading && {
? { maintainVisibleContentPosition: {
minIndexForVisible: 0 minIndexForVisible: 0
} }
: undefined })}
}
{...androidRefreshControl} {...androidRefreshControl}
{...customProps} {...customProps}
/> />

View File

@ -2,7 +2,10 @@ import haptics from '@components/haptics'
import { displayMessage } from '@components/Message' import { displayMessage } from '@components/Message'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { TabSharedStackParamList } from '@utils/navigation/navigators' import { useQueryClient } from '@tanstack/react-query'
import apiInstance from '@utils/api/instance'
import { TabSharedStackParamList, useNavState } from '@utils/navigation/navigators'
import { useAccountQuery } from '@utils/queryHooks/account'
import { import {
QueryKeyRelationship, QueryKeyRelationship,
useRelationshipMutation, useRelationshipMutation,
@ -10,39 +13,30 @@ import {
} from '@utils/queryHooks/relationship' } from '@utils/queryHooks/relationship'
import { import {
MutationVarsTimelineUpdateAccountProperty, MutationVarsTimelineUpdateAccountProperty,
QueryKeyTimeline,
useTimelineMutation useTimelineMutation
} from '@utils/queryHooks/timeline' } from '@utils/queryHooks/timeline'
import { getInstanceAccount } from '@utils/slices/instancesSlice' import { getAccountStorage, getReadableAccounts, useAccountStorage } from '@utils/storage/actions'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Alert, Platform } from 'react-native' import { Alert, Platform } from 'react-native'
import { useQueryClient } from '@tanstack/react-query'
import { useSelector } from 'react-redux'
const menuAccount = ({ const menuAccount = ({
type, type,
openChange, openChange,
account, account,
queryKey, status
rootQueryKey
}: { }: {
type: 'status' | 'account' // Where the action is coming from type: 'status' | 'account' // Where the action is coming from
openChange: boolean openChange: boolean
account?: Pick<Mastodon.Account, 'id' | 'username'> account?: Partial<Mastodon.Account> & Pick<Mastodon.Account, 'id' | 'username' | 'acct' | 'url'>
queryKey?: QueryKeyTimeline status?: Mastodon.Status
rootQueryKey?: QueryKeyTimeline }): ContextMenu => {
}): ContextMenu[][] => {
if (!account) return []
const navigation = const navigation =
useNavigation<NativeStackNavigationProp<TabSharedStackParamList, any, undefined>>() useNavigation<NativeStackNavigationProp<TabSharedStackParamList, any, undefined>>()
const { t } = useTranslation('componentContextMenu') const navState = useNavState()
const { t } = useTranslation(['common', 'componentContextMenu', 'componentRelationship'])
const menus: ContextMenu[][] = [[]] const menus: ContextMenu = [[]]
const instanceAccount = useSelector(getInstanceAccount)
const ownAccount = instanceAccount?.id === account.id
const [enabled, setEnabled] = useState(openChange) const [enabled, setEnabled] = useState(openChange)
useEffect(() => { useEffect(() => {
@ -50,21 +44,32 @@ const menuAccount = ({
setEnabled(true) setEnabled(true)
} }
}, [openChange, enabled]) }, [openChange, enabled])
const { data, isFetched } = useRelationshipQuery({ id: account.id, options: { enabled } }) const { data: fetchedAccount } = useAccountQuery({ account, _local: true, options: { enabled } })
const actualAccount = status?._remote ? fetchedAccount : account
const { data, isFetched } = useRelationshipQuery({
id: actualAccount?.id,
options: { enabled: !!actualAccount?.id && enabled }
})
const ownAccount = useAccountStorage.string('auth.account.id')['0'] === actualAccount?.id
const queryClient = useQueryClient() const queryClient = useQueryClient()
const timelineMutation = useTimelineMutation({ const timelineMutation = useTimelineMutation({
onSuccess: (_, params) => { onSuccess: (_, params) => {
queryClient.refetchQueries(['Relationship', { id: account.id }]) queryClient.refetchQueries(['Relationship', { id: actualAccount?.id }])
const theParams = params as MutationVarsTimelineUpdateAccountProperty const theParams = params as MutationVarsTimelineUpdateAccountProperty
displayMessage({ displayMessage({
type: 'success', type: 'success',
message: t('common:message.success.message', { message: t('common:message.success.message', {
function: t(`account.${theParams.payload.property}.action`, { function: t(
...(theParams.payload.property !== 'reports' && { `componentContextMenu:account.${theParams.payload.property}.action`,
context: (theParams.payload.currentValue || false).toString() theParams.payload.property !== 'reports'
}) ? {
}) defaultValue: 'false',
context: (theParams.payload.currentValue || false).toString()
}
: { defaultValue: 'false' }
)
}) })
}) })
}, },
@ -73,11 +78,15 @@ const menuAccount = ({
displayMessage({ displayMessage({
type: 'danger', type: 'danger',
message: t('common:message.error.message', { message: t('common:message.error.message', {
function: t(`account.${theParams.payload.property}.action`, { function: t(
...(theParams.payload.property !== 'reports' && { `componentContextMenu:account.${theParams.payload.property}.action`,
context: (theParams.payload.currentValue || false).toString() theParams.payload.property !== 'reports'
}) ? {
}) defaultValue: 'false',
context: (theParams.payload.currentValue || false).toString()
}
: { defaultValue: 'false' }
)
}), }),
...(err.status && ...(err.status &&
typeof err.status === 'number' && typeof err.status === 'number' &&
@ -89,25 +98,28 @@ const menuAccount = ({
}) })
}, },
onSettled: () => { onSettled: () => {
queryKey && queryClient.invalidateQueries(queryKey) for (const key of navState) {
rootQueryKey && queryClient.invalidateQueries(rootQueryKey) queryClient.invalidateQueries(key)
}
} }
}) })
const queryKeyRelationship: QueryKeyRelationship = ['Relationship', { id: account.id }] const queryKeyRelationship: QueryKeyRelationship = ['Relationship', { id: actualAccount?.id }]
const relationshipMutation = useRelationshipMutation({ const relationshipMutation = useRelationshipMutation({
onSuccess: (res, { payload: { action } }) => { onSuccess: (res, { payload: { action } }) => {
haptics('Success') haptics('Success')
queryClient.setQueryData<Mastodon.Relationship[]>(queryKeyRelationship, [res]) queryClient.setQueryData<Mastodon.Relationship[]>(queryKeyRelationship, [res])
if (action === 'block') { if (action === 'block') {
const queryKey = ['Timeline', { page: 'Following' }] queryClient.invalidateQueries({
queryClient.invalidateQueries({ queryKey, exact: false }) queryKey: ['Timeline', { page: 'Following' }],
exact: false
})
} }
}, },
onError: (err: any, { payload: { action } }) => { onError: (err: any, { payload: { action } }) => {
displayMessage({ displayMessage({
type: 'danger', type: 'danger',
message: t('common:message.error.message', { message: t('common:message.error.message', {
function: t(`${action}.function`) function: t(`componentContextMenu:${action}.function` as any)
}), }),
...(err.status && ...(err.status &&
typeof err.status === 'number' && typeof err.status === 'number' &&
@ -120,14 +132,18 @@ const menuAccount = ({
} }
}) })
if (!account) return []
if (!ownAccount && Platform.OS !== 'android' && type !== 'account') { if (!ownAccount && Platform.OS !== 'android' && type !== 'account') {
menus[0].push({ menus[0].push({
type: 'item',
key: 'account-following', key: 'account-following',
item: { props: {
onSelect: () => onSelect: () =>
data && data &&
actualAccount &&
relationshipMutation.mutate({ relationshipMutation.mutate({
id: account.id, id: actualAccount.id,
type: 'outgoing', type: 'outgoing',
payload: { action: 'follow', state: !data?.requested ? data.following : true } payload: { action: 'follow', state: !data?.requested ? data.following : true }
}), }),
@ -136,7 +152,8 @@ const menuAccount = ({
hidden: false hidden: false
}, },
title: !data?.requested title: !data?.requested
? t('account.following.action', { ? t('componentContextMenu:account.following.action', {
defaultValue: 'false',
context: (data?.following || false).toString() context: (data?.following || false).toString()
}) })
: t('componentRelationship:button.requested'), : t('componentRelationship:button.requested'),
@ -147,109 +164,199 @@ const menuAccount = ({
: 'person.badge.minus' : 'person.badge.minus'
}) })
} }
if (!ownAccount) { if (!ownAccount) {
menus[0].push({ menus[0].push({
type: 'item',
key: 'account-list', key: 'account-list',
item: { props: {
onSelect: () => navigation.navigate('Tab-Shared-Account-In-Lists', { account }), onSelect: () => navigation.navigate('Tab-Shared-Account-In-Lists', { account }),
disabled: Platform.OS !== 'android' ? !data || !isFetched : false, disabled: Platform.OS !== 'android' ? !data || !isFetched : false,
destructive: false, destructive: false,
hidden: !isFetched || !data?.following hidden: !isFetched || !data?.following
}, },
title: t('account.inLists'), title: t('componentContextMenu:account.inLists'),
icon: 'checklist' icon: 'checklist'
}) })
menus[0].push({ menus[0].push({
key: 'account-mute', type: 'item',
item: { key: 'account-show-boosts',
props: {
onSelect: () => onSelect: () =>
actualAccount &&
relationshipMutation.mutate({
id: actualAccount.id,
type: 'outgoing',
payload: { action: 'follow', state: false, reblogs: !data?.showing_reblogs }
}),
disabled: Platform.OS !== 'android' ? !data || !isFetched : false,
destructive: false,
hidden: !isFetched || !data?.following
},
title: t('componentContextMenu:account.showBoosts.action', {
defaultValue: 'false',
context: (data?.showing_reblogs || false).toString()
}),
icon: data?.showing_reblogs ? 'rectangle.on.rectangle.slash' : 'rectangle.on.rectangle'
})
menus[0].push({
type: 'item',
key: 'account-mute',
props: {
onSelect: () =>
actualAccount &&
timelineMutation.mutate({ timelineMutation.mutate({
type: 'updateAccountProperty', type: 'updateAccountProperty',
queryKey, id: actualAccount.id,
id: account.id,
payload: { property: 'mute', currentValue: data?.muting } payload: { property: 'mute', currentValue: data?.muting }
}), }),
disabled: Platform.OS !== 'android' ? !data || !isFetched : false, disabled: Platform.OS !== 'android' ? !data || !isFetched : false,
destructive: false, destructive: false,
hidden: false hidden: false
}, },
title: t('account.mute.action', { title: t('componentContextMenu:account.mute.action', {
defaultValue: 'false',
context: (data?.muting || false).toString() context: (data?.muting || false).toString()
}), }),
icon: data?.muting ? 'eye' : 'eye.slash' icon: data?.muting ? 'eye' : 'eye.slash'
}) })
}
!ownAccount && const followAs = () => {
if (type !== 'account') return
const accounts = getReadableAccounts()
menus[0].push({
type: 'sub',
key: 'account-follow-as',
trigger: {
key: 'account-follow-as',
props: { destructive: false, disabled: false, hidden: !accounts.length },
title: t('componentContextMenu:account.followAs.trigger'),
icon: 'person.badge.plus'
},
items: accounts.map(a => ({
key: `account-${a.key}`,
props: {
onSelect: async () => {
const lookup = await apiInstance<Mastodon.Account>({
account: a.key,
method: 'get',
url: 'accounts/lookup',
params: {
acct:
account.acct === account.username
? `${account.acct}@${getAccountStorage.string('auth.account.domain')}`
: account.acct
}
}).then(res => res.body)
await apiInstance({
account: a.key,
method: 'post',
url: `accounts/${lookup.id}/follow`
})
.then(() =>
displayMessage({
type: 'success',
message: t('componentContextMenu:account.followAs.succeed', {
context: account.locked ? 'locked' : 'default',
defaultValue: 'default',
target: account.acct,
source: a.acct
})
})
)
.catch(err =>
displayMessage({
type: 'error',
message: t('common:message.error.message', {
function: t('componentContextMenu:account.followAs.failed')
}),
...(err.status &&
typeof err.status === 'number' &&
err.data &&
err.data.error &&
typeof err.data.error === 'string' && {
description: err.data.error
})
})
)
},
disabled: false,
destructive: false,
hidden: a.active
},
title: a.acct
}))
})
}
followAs()
menus.push([ menus.push([
{ {
key: 'account-block', type: 'sub',
item: { key: 'account-block-report',
onSelect: () => trigger: {
Alert.alert(t('account.block.alert.title', { username: account.username }), undefined, [ key: 'account-block-report',
{ props: { destructive: true, disabled: false, hidden: false },
text: t('common:buttons.confirm'), title: t('componentContextMenu:account.blockReport'),
style: 'destructive', icon: 'hand.raised'
onPress: () =>
timelineMutation.mutate({
type: 'updateAccountProperty',
queryKey,
id: account.id,
payload: { property: 'block', currentValue: data?.blocking }
})
},
{
text: t('common:buttons.cancel')
}
]),
disabled: Platform.OS !== 'android' ? !data || !isFetched : false,
destructive: !data?.blocking,
hidden: false
}, },
title: t('account.block.action', { items: [
context: (data?.blocking || false).toString() {
}), key: 'account-block',
icon: data?.blocking ? 'checkmark.circle' : 'xmark.circle' props: {
}, onSelect: () =>
{ Alert.alert(
key: 'account-reports', t('componentContextMenu:account.block.alert.title', {
item: { username: actualAccount?.username
onSelect: () => }),
Alert.alert( undefined,
t('account.reports.alert.title', { username: account.username }), [
undefined, {
[ text: t('common:buttons.confirm'),
{ style: 'destructive',
text: t('common:buttons.confirm'), onPress: () =>
style: 'destructive', actualAccount &&
onPress: () => { timelineMutation.mutate({
timelineMutation.mutate({ type: 'updateAccountProperty',
type: 'updateAccountProperty', id: actualAccount.id,
queryKey, payload: { property: 'block', currentValue: data?.blocking }
id: account.id, })
payload: { property: 'reports' } },
}) {
timelineMutation.mutate({ text: t('common:buttons.cancel')
type: 'updateAccountProperty', }
queryKey, ]
id: account.id, ),
payload: { property: 'block', currentValue: false } disabled: Platform.OS !== 'android' ? !data || !isFetched : false,
}) destructive: !data?.blocking,
} hidden: false
}, },
{ title: t('componentContextMenu:account.block.action', {
text: t('common:buttons.cancel') defaultValue: 'false',
} context: (data?.blocking || false).toString()
] }),
), icon: data?.blocking ? 'checkmark.circle' : 'xmark.circle'
disabled: false, },
destructive: true, {
hidden: false key: 'account-reports',
}, props: {
title: t('account.reports.action'), onSelect: () =>
icon: 'flag' actualAccount &&
navigation.navigate('Tab-Shared-Report', {
account: actualAccount,
status
}),
disabled: Platform.OS !== 'android' ? !data || !isFetched : false,
destructive: true,
hidden: false
},
title: t('componentContextMenu:account.reports.action'),
icon: 'flag'
}
]
} }
]) ])
}
return menus return menus
} }

View File

@ -1,50 +1,53 @@
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { RootStackParamList } from '@utils/navigation/navigators' import { RootStackParamList, useNavState } from '@utils/navigation/navigators'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const menuAt = ({ account }: { account: Mastodon.Account }): ContextMenu[][] => { const menuAt = ({ account }: { account: Mastodon.Account }): ContextMenu => {
const { t } = useTranslation('componentContextMenu') const { t } = useTranslation('componentContextMenu')
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>() const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>()
const navigationState = useNavState()
const menus: ContextMenu[][] = [] return [
[
menus.push([ {
{ type: 'item',
key: 'at-direct', key: 'at-direct',
item: { props: {
onSelect: () => onSelect: () =>
navigation.navigate('Screen-Compose', { navigation.navigate('Screen-Compose', {
type: 'conversation', type: 'conversation',
accts: [account.acct], accts: [account.acct],
visibility: 'direct' visibility: 'direct',
}), navigationState
disabled: false, }),
destructive: false, disabled: false,
hidden: false destructive: false,
hidden: false
},
title: t('at.direct'),
icon: 'envelope'
}, },
title: t('at.direct'), {
icon: 'envelope' type: 'item',
}, key: 'at-public',
{ props: {
key: 'at-public', onSelect: () =>
item: { navigation.navigate('Screen-Compose', {
onSelect: () => type: 'conversation',
navigation.navigate('Screen-Compose', { accts: [account.acct],
type: 'conversation', visibility: 'public',
accts: [account.acct], navigationState
visibility: 'public' }),
}), disabled: false,
disabled: false, destructive: false,
destructive: false, hidden: false
hidden: false },
}, title: t('at.public'),
title: t('at.public'), icon: 'at'
icon: 'at' }
} ]
]) ]
return menus
} }
export default menuAt export default menuAt

View File

@ -1,6 +1,53 @@
type ContextMenu = { // type ContextMenu = (
// | {
// type: 'group'
// key: string
// items: ContextMenuItem[]
// }
// | {
// type: 'sub'
// key: string
// trigger: {
// key: string
// props: {
// disabled: boolean
// destructive: boolean
// hidden: boolean
// }
// title: string
// icon?: string
// }
// items: ContextMenuItem[]
// }
// )[]
type ContextMenu = (ContextMenuItem | ContextMenuSub)[][]
type ContextMenuItem = {
type: 'item'
key: string key: string
item: { onSelect: () => void; disabled: boolean; destructive: boolean; hidden: boolean } props: {
onSelect: () => void
disabled: boolean
destructive: boolean
hidden: boolean
}
title: string title: string
icon: string icon?: string
}
type ContextMenuSub = {
type: 'sub'
key: string
trigger: {
key: string
props: {
disabled: boolean
destructive: boolean
hidden: boolean
}
title: string
icon?: string
}
items: Omit<ContextMenuItem, 'type'>[]
} }

View File

@ -1,23 +1,21 @@
import { displayMessage } from '@components/Message' import { displayMessage } from '@components/Message'
import { useQueryClient } from '@tanstack/react-query'
import { QueryKeyTimeline, useTimelineMutation } from '@utils/queryHooks/timeline' import { QueryKeyTimeline, useTimelineMutation } from '@utils/queryHooks/timeline'
import { getInstanceUrl } from '@utils/slices/instancesSlice' import { getAccountStorage } from '@utils/storage/actions'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Alert } from 'react-native' import { Alert } from 'react-native'
import { useQueryClient } from '@tanstack/react-query' import parse from 'url-parse'
import { useSelector } from 'react-redux'
const menuInstance = ({ const menuInstance = ({
status, status,
queryKey, queryKey
rootQueryKey
}: { }: {
status?: Mastodon.Status status?: Mastodon.Status
queryKey?: QueryKeyTimeline queryKey?: QueryKeyTimeline
rootQueryKey?: QueryKeyTimeline }): ContextMenu => {
}): ContextMenu[][] => {
if (!status || !queryKey) return [] if (!status || !queryKey) return []
const { t } = useTranslation('componentContextMenu') const { t } = useTranslation(['common', 'componentContextMenu'])
const queryClient = useQueryClient() const queryClient = useQueryClient()
const mutation = useTimelineMutation({ const mutation = useTimelineMutation({
@ -25,38 +23,33 @@ const menuInstance = ({
displayMessage({ displayMessage({
type: 'success', type: 'success',
message: t('common:message.success.message', { message: t('common:message.success.message', {
function: t(`instance.block.action`, { instance }) function: t(`componentContextMenu:instance.block.action`, { instance })
}) })
}) })
queryClient.invalidateQueries(queryKey) queryClient.invalidateQueries(queryKey)
rootQueryKey && queryClient.invalidateQueries(rootQueryKey)
} }
}) })
const menus: ContextMenu[][] = [] const menus: ContextMenu = []
const currentInstance = useSelector(getInstanceUrl) const instance = parse(status.uri).hostname
const instance = status.uri && status.uri.split(new RegExp(/\/\/(.*?)\//))[1]
if (currentInstance !== instance && instance) { if (instance !== getAccountStorage.string('auth.domain')) {
menus.push([ menus.push([
{ {
type: 'item',
key: 'instance-block', key: 'instance-block',
item: { props: {
onSelect: () => onSelect: () =>
Alert.alert( Alert.alert(
t('instance.block.alert.title', { instance }), t('componentContextMenu:instance.block.alert.title', { instance }),
t('instance.block.alert.message'), t('componentContextMenu:instance.block.alert.message'),
[ [
{ {
text: t('common:buttons.confirm'), text: t('common:buttons.confirm'),
style: 'destructive', style: 'destructive',
onPress: () => { onPress: () => {
mutation.mutate({ mutation.mutate({ type: 'domainBlock', domain: instance })
type: 'domainBlock',
queryKey,
domain: instance
})
} }
}, },
{ {
@ -68,7 +61,7 @@ const menuInstance = ({
destructive: true, destructive: true,
hidden: false hidden: false
}, },
title: t('instance.block.action', { instance }), title: t('componentContextMenu:instance.block.action', { instance }),
icon: '' icon: ''
} }
]) ])

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