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:
runs-on: macos-12
steps:
- name: -- Step 0 -- Extract branch name
uses: tj-actions/branch-names@v6
- uses: tj-actions/branch-names@v6
id: branch
- name: -- Step 1 -- Checkout code
uses: actions/checkout@v3
- name: -- Step 2 -- Setup node
uses: actions/setup-node@v3
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'
- name: -- Step 3 -- Install node dependencies
run: yarn install
- name: -- Step 4 -- Install ruby dependencies
run: bundle install
- name: -- Step 5 -- Run fastlane
- run: corepack enable
- run: yarn install
- run: bundle install
- run: yarn app:build ios
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 }}
SENTRY_ENVIRONMENT: ${{ steps.branch.outputs.current_branch }}
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_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }}
GH_PAT_GET_RELEASE: ${{ secrets.GITHUB_TOKEN }}
run: yarn app:build ios
build-android:
runs-on: macos-12
steps:
- name: -- Step 0 -- Extract branch name
uses: tj-actions/branch-names@v6
- uses: tj-actions/branch-names@v6
id: branch
- name: -- Step 1 -- Checkout code
uses: actions/checkout@v3
- name: -- Step 2 -- Setup node
uses: actions/setup-node@v3
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'
- name: -- Step 3 -- Setup Java
uses: actions/setup-java@v3
- uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: '11'
- name: -- Step 4 -- Install node dependencies
run: yarn install
- name: -- Step 5 -- Install ruby dependencies
run: bundle install
- name: -- Step 6 -- Run fastlane
- run: corepack enable
- run: yarn install
- run: bundle install
- run: yarn app:build android
env:
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 }}
SUPPLY_JSON_KEY_DATA: ${{ secrets.SUPPLY_JSON_KEY_DATA }}
GH_PAT_GET_RELEASE: ${{ secrets.GITHUB_TOKEN }}
run: yarn app:build android
create-release:
runs-on: macos-12
needs: [build-ios, build-android]
steps:
- name: -- Step 0 -- Extract branch name
uses: tj-actions/branch-names@v6
- uses: tj-actions/branch-names@v6
id: branch
- name: -- Step 1 -- Checkout code
uses: actions/checkout@v3
- name: -- Step 2 -- Setup node
uses: actions/setup-node@v3
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'
- name: -- Step 3 -- Setup Java
uses: actions/setup-java@v3
- uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: '11'
- name: -- Step 4 -- Install node dependencies
run: yarn install
- name: -- Step 5 -- Install ruby dependencies
run: bundle install
- name: -- Step 6 -- Run fastlane
- run: corepack enable
- run: yarn install
- run: bundle install
- run: yarn app:build release
env:
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_KEY_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_KEY_PASSWORD }}
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/
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
index f52822c..ee98565 100755
--- 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 f52822c8bed928f387baf90fdb7342c7416a775a..6d9d480d18342832c4b07af2b10f4a63ff538e7b 100755
--- a/index.d.ts
+++ b/index.d.ts
@@ -5,11 +5,9 @@
// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
// Minimum TypeScript Version: 3.7
@ -17,12 +17,18 @@ index f52822c..ee98565 100755
export type ShareCallback = (share?: ShareData) => void;
@@ -28,7 +26,7 @@ interface ShareMenuReactView {
dismissExtension(error?: string): void;
openApp(): void;
continueInApp(extraData?: object): void;
@@ -25,10 +23,10 @@ interface ShareMenu {
}
interface ShareMenuReactView {
- dismissExtension(error?: string): void;
- openApp(): void;
- continueInApp(extraData?: object): void;
- 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;

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
index 9557fdb..ebdeb6f 100644
--- 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 9557fdbf2fbf97b7f7aeaf7ce86d301a8ced213d..ebdeb6f4de7846d3241101001755595c52a4b05e 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -1,12 +1,12 @@
apply plugin: 'com.android.library'
@ -19,10 +19,10 @@ index 9557fdb..ebdeb6f 100644
versionCode 1
versionName "1.0"
ndk {
diff --git a/node_modules/react-native-share-menu/ios/ReactShareViewController.swift b/node_modules/react-native-share-menu/ios/ReactShareViewController.swift
index f42bce6..ee36062 100644
--- 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 f42bce6ce7e3f48a7ddc83f3366b68fd0664b1a0..ee360622b1d03cc9661c78c6f210b84c3b19a725 100644
--- a/ios/ReactShareViewController.swift
+++ b/ios/ReactShareViewController.swift
@@ -13,7 +13,7 @@ class ReactShareViewController: ShareViewController, RCTBridgeDelegate, ReactSha
func sourceURL(for bridge: RCTBridge!) -> URL! {
#if DEBUG
@ -32,10 +32,10 @@ index f42bce6..ee36062 100644
#else
return Bundle.main.url(forResource: "main", withExtension: "jsbundle")
#endif
diff --git a/node_modules/react-native-share-menu/ios/ShareViewController.swift b/node_modules/react-native-share-menu/ios/ShareViewController.swift
index 12d8c92..64aa72b 100644
--- 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 12d8c92dda20fabd9e7b55fec57b3d867414063c..8a1db0de285b18a9358a10b2ca8293a8c7d56a8e 100644
--- a/ios/ShareViewController.swift
+++ b/ios/ShareViewController.swift
@@ -19,8 +19,8 @@ class ShareViewController: SLComposeServiceViewController {
var hostAppUrlScheme: String?
var sharedItems: [Any] = []
@ -78,7 +78,7 @@ index 12d8c92..64aa72b 100644
override func configurationItems() -> [Any]! {
// To add configuration options via table cells at the bottom of the sheet, return an array of SLComposeSheetConfigurationItem here.
return []
@@ -238,11 +235,10 @@ class ShareViewController: SLComposeServiceViewController {
@@ -238,7 +235,7 @@ class ShareViewController: SLComposeServiceViewController {
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.
@ -87,7 +87,3 @@ index 12d8c92..64aa72b 100644
}
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)
atomos (0.1.3)
aws-eventstream (1.2.0)
aws-partitions (1.653.0)
aws-sdk-core (3.166.0)
aws-partitions (1.687.0)
aws-sdk-core (3.168.4)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5)
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-sigv4 (~> 1.1)
aws-sdk-s3 (1.117.1)
aws-sdk-s3 (1.117.2)
aws-sdk-core (~> 3, >= 3.165.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4)
@ -86,7 +86,7 @@ GEM
escape (0.0.4)
ethon (0.15.0)
ffi (>= 1.15.0)
excon (0.93.1)
excon (0.95.0)
faraday (1.10.2)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
@ -116,7 +116,7 @@ GEM
faraday_middleware (1.2.0)
faraday (~> 1.0)
fastimage (2.2.6)
fastlane (2.210.1)
fastlane (2.211.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
@ -156,7 +156,7 @@ GEM
xcpretty (~> 0.3.0)
xcpretty-travis-formatter (>= 0.0.3)
fastlane-plugin-json (1.1.0)
fastlane-plugin-sentry (1.14.0)
fastlane-plugin-sentry (1.15.0)
os (~> 1.1, >= 1.1.4)
fastlane-plugin-versioning_android (0.1.0)
fastlane-plugin-yarn (1.2)
@ -164,9 +164,9 @@ GEM
fourflusher (2.3.1)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.29.0)
google-apis-core (>= 0.9.0, < 2.a)
google-apis-core (0.9.1)
google-apis-androidpublisher_v3 (0.32.0)
google-apis-core (>= 0.9.1, < 2.a)
google-apis-core (0.9.2)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
@ -175,8 +175,8 @@ GEM
retriable (>= 2.0, < 4.a)
rexml
webrick
google-apis-iamcredentials_v1 (0.15.0)
google-apis-core (>= 0.9.0, < 2.a)
google-apis-iamcredentials_v1 (0.16.0)
google-apis-core (>= 0.9.1, < 2.a)
google-apis-playcustomapp_v1 (0.12.0)
google-apis-core (>= 0.9.1, < 2.a)
google-apis-storage_v1 (0.19.0)
@ -187,7 +187,7 @@ GEM
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.0)
google-cloud-storage (1.43.0)
google-cloud-storage (1.44.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
@ -208,11 +208,11 @@ GEM
httpclient (2.8.3)
i18n (1.12.0)
concurrent-ruby (~> 1.0)
jmespath (1.6.1)
json (2.6.2)
jwt (2.5.0)
jmespath (1.6.2)
json (2.6.3)
jwt (2.6.0)
memoist (0.16.2)
mini_magick (4.11.0)
mini_magick (4.12.0)
mini_mime (1.1.2)
minitest (5.16.3)
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)
![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
@ -11,28 +11,16 @@ Please **do not** create a pull request to update translation. tooot's translati
## Special thanks
[@amrtf](https://crowdin.com/profile/amrtf) for Catalan and Spanish 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
[@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
[@jan-vandenberg](https://crowdin.com/profile/jan-vandenberg) for Dutch translation
[@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
- [@amrtf](https://crowdin.com/profile/amrtf) for Catalan and Spanish 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
- [@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
- [@jan-vandenberg](https://crowdin.com/profile/jan-vandenberg) for Dutch translation
- [@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.google.gms.google-services'
import com.android.build.OutputFile
import org.apache.tools.ant.taskdefs.condition.Os
@ -231,6 +232,7 @@ android {
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
}
}
namespace 'com.xmflsct.app.tooot'
// applicationVariants are e.g. debug, release
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"
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.SYSTEM_ALERT_WINDOW"/>
<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.WRITE_EXTERNAL_STORAGE"/>
<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">
<!-- [Custom] Expo 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()
}
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("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
// in the individual module build.gradle files

View File

@ -26,7 +26,7 @@ android.useAndroidX=true
android.enableJetifier=true
# 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.
# You can also override it from the CLI using

View File

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

View File

@ -1,5 +1,5 @@
module.exports = function (api) {
api.cache(true)
api.cache(false)
const plugins = [
'@babel/plugin-proposal-optional-chaining',
@ -8,11 +8,8 @@ module.exports = function (api) {
{
root: ['./'],
alias: {
'@assets': './assets',
'@root': './src',
'@api': './src/api',
'@helpers': './src/helpers',
'@components': './src/components',
'@i18n': './src/i18n',
'@screens': './src/screens',
'@utils': './src/utils'
}
@ -21,27 +18,9 @@ module.exports = function (api) {
'react-native-reanimated/plugin'
]
if (
process.env.NODE_ENV === 'production' ||
process.env.BABEL_ENV === 'production'
) {
if (process.env.NODE_ENV === 'production' || process.env.BABEL_ENV === 'production') {
plugins.push('transform-remove-console')
}
return {
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
}
return { presets: ['babel-preset-expo'], 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",
include_symbols: true,
output_directory: BUILD_DIRECTORY,
silent: false
silent: true
)
case ENVIRONMENT

View File

@ -1,3 +1,9 @@
Enjoy toooting! This version includes following improvements and fixes:
- Fixed wrongly update notification
- Fix some random crashes
- Auto fetch remote content in conversations!
- 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愉快此版本包括以下改进和修复
- 修复错误的升级通知
- 修复部分应用崩溃
- 主动获取对话的远程内容
- 自动加载上次我的关注的阅读位置
- 用其它已登陆的账户关注用户
- 可添加举报细节
- 新增暂停自动播放gif动画选项
- 隐藏用户的转嘟
- 下划线高亮正在关注的话题标签
- 支持GoToSocial

View File

@ -1,8 +1,4 @@
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)

View File

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

View File

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

View File

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

View File

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

View File

@ -31,6 +31,9 @@ declare namespace Mastodon {
source?: Source
suspended?: boolean
role?: Role
// Internal
_remote?: boolean
}
type Announcement = {
@ -264,14 +267,6 @@ declare namespace Mastodon {
}
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 = {
id: string
title: string
@ -281,6 +276,14 @@ declare namespace Mastodon {
keywords: FilterKeyword[]
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 }
@ -298,7 +301,48 @@ declare namespace Mastodon {
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
uri: string
title: string
@ -343,6 +387,9 @@ declare namespace Mastodon {
max_expiration: number
}
}
// Gotosocial
account_domain?: string
}
type Mention = {
@ -351,6 +398,9 @@ declare namespace Mastodon {
username: string
acct: string
url: string
// Internal
_remote?: boolean
}
type Notification =
@ -470,6 +520,11 @@ declare namespace Mastodon {
updated_at: string
}
type Rule = {
id: string
text: string
}
type Status = {
// Base
id: string
@ -509,6 +564,10 @@ declare namespace Mastodon {
language?: string
text?: string
filtered?: FilterResult[]
// Internal
_level?: number
_remote?: boolean
}
type StatusHistory = {

View File

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

View File

@ -1,31 +1,35 @@
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 { QueryClientProvider } from '@tanstack/react-query'
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 * as Localization from 'expo-localization'
import * as SplashScreen from 'expo-splash-screen'
import React, { useCallback, useEffect, useState } from 'react'
import { IntlProvider } from 'react-intl'
import { LogBox, Platform } from 'react-native'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import { MMKV } from 'react-native-mmkv'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { enableFreeze } from 'react-native-screens'
import { QueryClientProvider } from '@tanstack/react-query'
import { Provider } from 'react-redux'
import { PersistGate } from 'redux-persist/integration/react'
import i18n from './i18n'
import Screens from './screens'
Platform.select({
android: LogBox.ignoreLogs(['Setting a timer for a long period of time'])
@ -38,83 +42,95 @@ push()
timezone()
enableFreeze(true)
log('log', 'App', 'delay splash')
SplashScreen.preventAutoHideAsync()
const App: React.FC = () => {
log('log', 'App', 'rendering App')
const [appIsReady, setAppIsReady] = useState(false)
const [localCorrupt, setLocalCorrupt] = useState<string>()
const [hasMigrated, setHasMigrated] = useState<boolean>(versionStorageGlobal !== undefined)
useEffect(() => {
const delaySplash = async () => {
log('log', 'App', 'delay splash')
try {
await SplashScreen.preventAutoHideAsync()
} catch (e) {
console.warn(e)
const prepare = async () => {
if (!hasMigrated) {
try {
await migrateFromAsyncStorage()
setHasMigrated(true)
} 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 onBeforeLift = useCallback(async () => {
let netInfoRes = undefined
try {
netInfoRes = await netInfo()
} catch {}
if (netInfoRes && netInfoRes.corrupted && netInfoRes.corrupted.length) {
setLocalCorrupt(netInfoRes.corrupted)
}
log('log', 'App', 'hide splash')
try {
const onLayoutRootView = useCallback(async () => {
if (appIsReady) {
log('log', 'App', 'hide splash')
await SplashScreen.hideAsync()
return Promise.resolve()
} catch (e) {
console.warn(e)
return Promise.reject()
}
}, [])
}, [appIsReady])
if (!appIsReady) {
return null
}
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<GestureHandlerRootView style={{ flex: 1 }} onLayout={onLayoutRootView}>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<PersistGate
persistor={persistor}
onBeforeLift={onBeforeLift}
children={bootstrapped => {
log('log', 'App', 'bootstrapped')
if (bootstrapped) {
log('log', 'App', 'loading actual app :)')
log('log', 'App', `Locale: ${Localization.locale}`)
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>
<SafeAreaProvider>
<ActionSheetProvider>
<AccessibilityManager>
<ThemeManager>
<Screens localCorrupt={localCorrupt} />
</ThemeManager>
</AccessibilityManager>
</ActionSheetProvider>
</SafeAreaProvider>
</QueryClientProvider>
</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'
export interface Props {
account: Mastodon.Account
account: Partial<Mastodon.Account> & Pick<Mastodon.Account, 'id' | 'acct' | 'username' | 'url'>
props?: PressableProps
}
@ -42,11 +42,11 @@ const ComponentAccount: React.FC<PropsWithChildren & Props> = ({ account, props,
style={{
width: StyleConstants.Avatar.S,
height: StyleConstants.Avatar.S,
borderRadius: 6,
borderRadius: 8,
marginRight: StyleConstants.Spacing.S
}}
/>
<View>
<View style={{ flex: 1 }}>
<CustomText numberOfLines={1}>
<ParseEmojis
content={account.display_name || account.username}

View File

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

View File

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

View File

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

View File

@ -1,9 +1,9 @@
import { emojis } from '@components/Emojis'
import Icon from '@components/Icon'
import CustomText from '@components/Text'
import { useAppDispatch } from '@root/store'
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 layoutAnimation from '@utils/styles/layoutAnimation'
import { useTheme } from '@utils/styles/ThemeManager'
@ -19,16 +19,14 @@ import {
View
} from 'react-native'
import FastImage from 'react-native-fast-image'
import validUrl from 'valid-url'
import EmojisContext from './helpers/EmojisContext'
import EmojisContext from './Context'
const EmojisList = () => {
const dispatch = useAppDispatch()
const { reduceMotionEnabled } = useAccessibility()
const { t } = useTranslation()
const { t } = useTranslation(['common', 'screenCompose'])
const { emojisState, emojisDispatch } = useContext(EmojisContext)
const { colors, mode } = useTheme()
const { colors } = useTheme()
const addEmoji = (shortcode: string) => {
if (emojisState.targetIndex === -1) {
@ -69,31 +67,77 @@ const EmojisList = () => {
>
{item.map(emoji => {
const uri = reduceMotionEnabled ? emoji.static_url : emoji.url
if (validUrl.isHttpsUri(uri)) {
return (
<Pressable
key={emoji.shortcode}
onPress={() => {
addEmoji(`:${emoji.shortcode}:`)
dispatch(countInstanceEmoji(emoji))
}}
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>
)
} else {
return null
}
return (
<Pressable
key={emoji.shortcode}
onPress={() => {
addEmoji(`:${emoji.shortcode}:`)
const HALF_LIFE = 60 * 60 * 24 * 7 // 1 week
const calculateScore = (
emoji: StorageAccount['emojis_frequent'][number]
): number => {
var seconds = (new Date().getTime() - emoji.lastUsed) / 1000
var score = emoji.count + 1
var order = Math.log(Math.max(score, 1)) / Math.LN10
var sign = score > 0 ? 1 : score === 0 ? 0 : -1
return (sign * order + seconds / HALF_LIFE) * 10
}
const currentEmojis = getAccountStorage.object('emojis_frequent')
const foundEmojiIndex = currentEmojis?.findIndex(
e => e.emoji.shortcode === emoji.shortcode && e.emoji.url === emoji.url
)
let newEmojisSort: StorageAccount['emojis_frequent']
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>
)
@ -158,7 +202,6 @@ const EmojisList = () => {
onChangeText={setSearch}
autoCapitalize='none'
clearButtonMode='always'
keyboardAppearance={mode}
autoCorrect={false}
spellCheck={false}
/>

View File

@ -2,15 +2,13 @@ import EmojisButton from '@components/Emojis/Button'
import EmojisList from '@components/Emojis/List'
import { useAccessibility } from '@utils/accessibility/AccessibilityManager'
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 React, { createRef, PropsWithChildren, useEffect, useReducer, useState } from 'react'
import { useTranslation } from 'react-i18next'
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 { useSelector } from 'react-redux'
import EmojisContext, { Emojis, emojisReducer, EmojisState } from './Emojis/helpers/EmojisContext'
import EmojisContext, { Emojis, emojisReducer, EmojisState } from './Context'
export type Props = {
inputProps: EmojisState['inputProps']
@ -35,9 +33,9 @@ const ComponentEmojis: React.FC<Props & PropsWithChildren> = ({
emojisDispatch({ type: 'input', payload: inputProps })
}, [inputProps])
const { t } = useTranslation()
const { t } = useTranslation(['componentEmojis'])
const { data } = useEmojisQuery({})
const frequentEmojis = useSelector(getInstanceFrequentEmojis, () => true)
const [frequentEmojis] = useAccountStorage.object('emojis_frequent')
useEffect(() => {
if (data && data.length) {
let sortedEmojis: NonNullable<Emojis['current']> = []

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import { useTheme } from '@utils/styles/ThemeManager'
import React, { forwardRef, RefObject } from 'react'
import { Platform, TextInput, TextInputProps, View } from 'react-native'
import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'
import { EmojisState } from './Emojis/helpers/EmojisContext'
import { EmojisState } from './Emojis/Context'
import CustomText from './Text'
export type Props = {
@ -85,7 +85,6 @@ const ComponentInput = forwardRef(
multiline,
numberOfLines: Platform.OS === 'android' ? 5 : undefined
})}
keyboardAppearance={mode}
textAlignVertical='top'
{...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 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 { 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 { useTheme } from '@utils/styles/ThemeManager'
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 { Alert, Image, KeyboardAvoidingView, Platform, TextInput, View } from 'react-native'
import { ScrollView } from 'react-native-gesture-handler'
import { useSelector } from 'react-redux'
import { Placeholder } from 'rn-placeholder'
import validUrl from 'valid-url'
import InstanceInfo from './Info'
import parse from 'url-parse'
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 {
scrollViewRef?: RefObject<ScrollView>
@ -35,7 +39,7 @@ const ComponentInstance: React.FC<Props> = ({
disableHeaderImage,
goBack = false
}) => {
const { t } = useTranslation('componentInstance')
const { t } = useTranslation(['common', 'componentInstance'])
const { colors, mode } = useTheme()
const navigation = useNavigation<TabMeStackNavigationProp<'Tab-Me-Root' | 'Tab-Me-Switch'>>()
@ -44,11 +48,9 @@ const ComponentInstance: React.FC<Props> = ({
const whitelisted: boolean =
!!domain.length &&
!!errorCode &&
!!validUrl.isHttpsUri(`https://${domain}`) &&
!!(parse(`https://${domain}/`).hostname === domain) &&
errorCode === 401
const dispatch = useAppDispatch()
const instances = useSelector(getInstances, () => true)
const instanceQuery = useInstanceQuery({
domain,
options: {
@ -62,8 +64,6 @@ const ComponentInstance: React.FC<Props> = ({
}
})
const deprecateAuthFollow = useSelector(checkInstanceFeature('deprecate_auth_follow'))
const appsMutation = useAppsMutation({
retry: false,
onSuccess: async (data, variables) => {
@ -75,56 +75,145 @@ const ComponentInstance: React.FC<Props> = ({
const request = new AuthSession.AuthRequest({
clientId,
clientSecret,
scopes: deprecateAuthFollow
? ['read', 'write', 'push']
: ['read', 'write', 'follow', 'push'],
scopes: variables.scopes,
redirectUri
})
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(
{
clientId,
clientSecret,
scopes: ['read', 'write', 'follow', 'push'],
scopes: variables.scopes,
redirectUri,
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` }
)
queryClient.clear()
dispatch(
addInstance({
domain,
token: accessToken,
instance: instanceQuery.data!,
appData: { clientId, clientSecret }
})
const {
body: { id, acct, avatar_static }
} = await apiGeneral<Mastodon.Account>({
method: 'get',
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()
}
}
})
const scopes = featureCheck('deprecate_auth_follow')
? ['read', 'write', 'push']
: ['read', 'write', 'follow', 'push']
const processUpdate = useCallback(() => {
if (domain) {
if (instances && instances.filter(instance => instance.url === domain).length) {
Alert.alert(t('update.alert.title'), t('update.alert.message'), [
{
text: t('common:buttons.cancel'),
style: 'cancel'
},
{
text: t('common:buttons.continue'),
onPress: () => appsMutation.mutate({ domain })
}
])
const accounts = getGlobalStorage.object('accounts')
if (accounts?.filter(account => account.startsWith(`${domain}/`)).length) {
Alert.alert(
t('componentInstance:update.alert.title'),
t('componentInstance:update.alert.message'),
[
{
text: t('common:buttons.cancel'),
style: 'cancel'
},
{
text: t('common:buttons.continue'),
onPress: () => appsMutation.mutate({ domain, scopes })
}
]
)
} else {
appsMutation.mutate({ domain })
appsMutation.mutate({ domain, scopes })
}
}
}, [domain])
@ -204,12 +293,13 @@ const ComponentInstance: React.FC<Props> = ({
text === domain &&
instanceQuery.isSuccess &&
instanceQuery.data &&
instanceQuery.data.uri
// @ts-ignore
(instanceQuery.data.domain || instanceQuery.data.uri)
) {
processUpdate()
}
}}
placeholder={' ' + t('server.textInput.placeholder')}
placeholder={' ' + t('componentInstance:server.textInput.placeholder')}
placeholderTextColor={colors.secondary}
returnKeyType='go'
keyboardAppearance={mode}
@ -222,9 +312,10 @@ const ComponentInstance: React.FC<Props> = ({
/>
<Button
type='text'
content={t('server.button')}
content={t('componentInstance:server.button')}
onPress={processUpdate}
disabled={!instanceQuery.data?.uri && !whitelisted}
// @ts-ignore
disabled={!(instanceQuery.data?.domain || instanceQuery.data?.uri) && !whitelisted}
loading={instanceQuery.isFetching || appsMutation.isLoading}
/>
</View>
@ -239,37 +330,9 @@ const ComponentInstance: React.FC<Props> = ({
paddingTop: StyleConstants.Spacing.XS
}}
>
{t('server.whitelisted')}
{t('componentInstance:server.whitelisted')}
</CustomText>
) : (
<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>
)}
) : null}
<View
style={{
flexDirection: 'row',
@ -287,7 +350,7 @@ const ComponentInstance: React.FC<Props> = ({
}}
/>
<CustomText fontStyle='S' style={{ flex: 1, color: colors.secondary }}>
{t('server.disclaimer.base')}
{t('componentInstance:server.disclaimer.base')}
</CustomText>
</View>
<View
@ -312,7 +375,8 @@ const ComponentInstance: React.FC<Props> = ({
accessibilityRole='link'
>
<Trans
i18nKey='componentInstance:server.terms.base'
ns='componentInstance'
i18nKey='server.terms.base'
components={[
<CustomText
accessible

View File

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

View File

@ -1,8 +1,8 @@
import React from 'react'
import { View } from 'react-native'
import CustomText from '@components/Text'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import CustomText from '@components/Text'
import React from 'react'
import { View } from 'react-native'
export interface Props {
heading: string

View File

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

View File

@ -4,7 +4,6 @@ import { useTheme } from '@utils/styles/ThemeManager'
import React, { RefObject } from 'react'
import { AccessibilityInfo } from 'react-native'
import FlashMessage, { MessageType, showMessage } from 'react-native-flash-message'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import haptics from './haptics'
const displayMessage = ({
@ -53,7 +52,6 @@ const displayMessage = ({
const Message = React.forwardRef<FlashMessage>((_, ref) => {
const { colors, theme } = useTheme()
const insets = useSafeAreaInsets()
enum iconMapping {
success = 'CheckCircle',
@ -96,8 +94,7 @@ const Message = React.forwardRef<FlashMessage>((_, ref) => {
shadowOffset: { width: 0, height: 0 },
shadowOpacity: theme === 'light' ? 0.16 : 0.24,
shadowRadius: 4,
paddingRight: StyleConstants.Spacing.M * 2,
marginTop: ref ? undefined : insets.top
paddingRight: StyleConstants.Spacing.M * 2
}}
titleStyle={{
color: colors.primaryDefault,
@ -109,7 +106,7 @@ const Message = React.forwardRef<FlashMessage>((_, ref) => {
...StyleConstants.FontStyle.S
}}
// @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 { useAccessibility } from '@utils/accessibility/AccessibilityManager'
import { getSettingsFontsize } from '@utils/slices/settingsSlice'
import { useGlobalStorage } from '@utils/storage/actions'
import { StyleConstants } from '@utils/styles/constants'
import { adaptiveScale } from '@utils/styles/scaling'
import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react'
import { Platform, TextStyle } from 'react-native'
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_]+:)/)
export interface Props {
content: string
content?: string
emojis?: Mastodon.Emoji[]
size?: 'S' | 'M' | 'L'
adaptiveSize?: boolean
@ -21,79 +19,81 @@ export interface Props {
style?: TextStyle
}
const ParseEmojis = React.memo(
({ content, emojis, size = 'M', adaptiveSize = false, fontBold = false, style }: Props) => {
const { reduceMotionEnabled } = useAccessibility()
const ParseEmojis: React.FC<Props> = ({
content,
emojis,
size = 'M',
adaptiveSize = false,
fontBold = false,
style
}) => {
if (!content) return null
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 { reduceMotionEnabled } = useAccessibility()
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 (
<CustomText
style={[
{
color: colors.primaryDefault,
fontSize: adaptedFontsize,
lineHeight: adaptedLineheight
},
style
]}
fontWeight={fontBold ? 'Bold' : undefined}
>
{emojis ? (
content
.split(regexEmoji)
.filter(f => f)
.map((str, i) => {
if (str.match(regexEmoji)) {
const emojiShortcode = str.split(regexEmoji)[1]
const emojiIndex = emojis.findIndex(emoji => {
return emojiShortcode === `:${emoji.shortcode}:`
})
if (emojiIndex === -1) {
return <CustomText key={emojiShortcode + i}>{emojiShortcode}</CustomText>
} else {
const uri = reduceMotionEnabled
? 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
}
}
const { colors, theme } = useTheme()
return (
<CustomText
style={[
{
color: colors.primaryDefault,
fontSize: adaptedFontsize,
lineHeight: adaptedLineheight
},
style
]}
fontWeight={fontBold ? 'Bold' : undefined}
>
{emojis ? (
content
.split(regexEmoji)
.filter(f => f)
.map((str, i) => {
if (str.match(regexEmoji)) {
const emojiShortcode = str.split(regexEmoji)[1]
const emojiIndex = emojis.findIndex(emoji => {
return emojiShortcode === `:${emoji.shortcode}:`
})
if (emojiIndex === -1) {
return <CustomText key={emojiShortcode + i}>{emojiShortcode}</CustomText>
} 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>
)
}
})
) : (
<CustomText>{content}</CustomText>
)}
</CustomText>
)
},
(prev, next) => prev.content === next.content
)
} else {
return <CustomText key={i}>{str}</CustomText>
}
})
) : (
<CustomText>{content}</CustomText>
)}
</CustomText>
)
}
export default ParseEmojis

View File

@ -1,315 +1,302 @@
import Icon from '@components/Icon'
import openLink from '@components/openLink'
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 { StackNavigationProp } from '@react-navigation/stack'
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 layoutAnimation from '@utils/styles/layoutAnimation'
import { adaptiveScale } from '@utils/styles/scaling'
import { useTheme } from '@utils/styles/ThemeManager'
import { isEqual } from 'lodash'
import React, { useCallback, useState } from 'react'
import { ChildNode } from 'domhandler'
import { ElementType, parseDocument } from 'htmlparser2'
import i18next from 'i18next'
import React, { useContext, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Platform, Pressable, TextStyleIOS, 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
}
}
import { Platform, Pressable, Text, View } from 'react-native'
export interface Props {
content: string
size?: 'S' | 'M' | 'L'
textStyles?: TextStyleIOS
adaptiveSize?: boolean
emojis?: Mastodon.Emoji[]
mentions?: Mastodon.Mention[]
tags?: Mastodon.Tag[]
showFullLink?: boolean
numberOfLines?: number
expandHint?: string
highlighted?: boolean
disableDetails?: boolean
selectable?: boolean
setSpoilerExpanded?: React.Dispatch<React.SetStateAction<boolean>>
emojis?: Mastodon.Emoji[]
mentions?: Mastodon.Mention[]
}
const ParseHTML = React.memo(
({
content,
size = 'M',
textStyles,
adaptiveSize = false,
emojis,
mentions,
tags,
showFullLink = false,
numberOfLines = 10,
expandHint,
highlighted = false,
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 ParseHTML: React.FC<Props> = ({
content,
size = 'M',
adaptiveSize = false,
showFullLink = false,
numberOfLines = 10,
expandHint,
selectable = false,
setSpoilerExpanded,
emojis,
mentions
}) => {
const { status, highlighted, disableDetails, excludeMentions } = useContext(StatusContext)
const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>()
const route = useRoute()
const { colors, theme } = useTheme()
const { t } = useTranslation('componentParse')
if (!expandHint) {
expandHint = t('HTML.defaultHint')
const [adaptiveFontsize] = useGlobalStorage.number('app.font_size')
const adaptedFontsize = adaptiveScale(
StyleConstants.Font.Size[size],
adaptiveSize ? adaptiveFontsize : 0
)
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 (
<ParseEmojis
content={children?.toString()}
emojis={emojis}
key={index}
content={content}
emojis={status?.emojis || emojis}
size={size}
adaptiveSize={adaptiveSize}
/>
)
} else {
return null
}
}, [])
const rootComponent = useCallback(
({ children }: any) => {
const { t } = useTranslation('componentParse')
case ElementType.Tag:
switch (node.name) {
case 'a':
const classes = node.attribs.class
const href = node.attribs.href
if (classes) {
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 [expanded, setExpanded] = useState(highlighted)
const paramsHashtag = (params as { hashtag: Mastodon.Tag['name'] } | undefined)
?.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 (
<View style={{ overflow: 'hidden' }}>
{(!disableDetails && typeof totalLines === 'number') || numberOfLines === 1 ? (
<Pressable
accessibilityLabel={t('HTML.accessibilityHint')}
onPress={() => {
layoutAnimation()
setExpanded(!expanded)
if (setSpoilerExpanded) {
setSpoilerExpanded(!expanded)
const content = node.children.map(child => unwrapNode(child)).join('')
const shouldBeTag = status?.tags?.find(tag => `#${tag.name}` === content)
return (
<Text
key={index}
style={{ color: colors.blue }}
onPress={async () => {
if (!disableDetails) {
if (shouldBeTag) {
navigation.push('Tab-Shared-Hashtag', {
hashtag: content.substring(1)
})
} else {
await openLink(href, navigation)
}
}
}}
style={{
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
minHeight: 44,
backgroundColor: colors.backgroundDefault
}}
children={content}
/>
)
break
case 'br':
return (
<Text
key={index}
style={{ lineHeight: adaptedLineheight ? adaptedLineheight / 2 : undefined }}
>
<CustomText
style={{
textAlign: 'center',
...StyleConstants.FontStyle.S,
color: colors.primaryDefault,
marginRight: StyleConstants.Spacing.S
}}
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}
<CustomText
children={children}
onTextLayout={({ nativeEvent }) => {
if (numberOfLines === 1 || nativeEvent.lines.length >= numberOfLines + 5) {
setTotalLines(nativeEvent.lines.length)
}
}}
style={{
...textStyles,
height: numberOfLines === 1 && !expanded ? 0 : undefined
}}
numberOfLines={
typeof totalLines === 'number' ? (expanded ? 999 : numberOfLines) : undefined
}
selectable={selectable}
/>
</View>
)
},
[theme]
)
return (
<HTMLView
value={content}
TextComponent={textComponent}
RootComponent={rootComponent}
renderNode={renderNodeCallback}
{'\n'}
</Text>
)
case 'p':
if (index < document.children.length - 1) {
return (
<Text key={index}>
{node.children.map((c, i) => renderNode(c, i))}
<Text
style={{ lineHeight: adaptedLineheight ? adaptedLineheight / 2 : undefined }}
>
{'\n\n'}
</Text>
</Text>
)
} else {
return <Text key={index} children={node.children.map((c, i) => renderNode(c, i))} />
}
default:
return <Text key={index} children={node.children.map((c, i) => renderNode(c, i))} />
}
}
return null
}
return (
<View style={{ overflow: 'hidden' }}>
{(!disableDetails && typeof totalLines === 'number') || numberOfLines === 1 ? (
<Pressable
accessibilityLabel={t('HTML.accessibilityHint')}
onPress={() => {
layoutAnimation()
setExpanded(!expanded)
if (setSpoilerExpanded) {
setSpoilerExpanded(!expanded)
}
}}
style={{
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
minHeight: 44,
backgroundColor: colors.backgroundDefault
}}
>
<Text
style={{
textAlign: 'center',
...StyleConstants.FontStyle.S,
color: colors.primaryDefault,
marginRight: StyleConstants.Spacing.S
}}
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}
/>
)
},
(prev, next) => prev.content === next.content && isEqual(prev.emojis, next.emojis)
)
</View>
)
}
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 haptics from '@components/haptics'
import { displayMessage } from '@components/Message'
import { useQueryClient } from '@tanstack/react-query'
import { QueryKeyRelationship, useRelationshipMutation } from '@utils/queryHooks/relationship'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { StyleConstants } from '@utils/styles/constants'
@ -8,7 +9,6 @@ import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, View } from 'react-native'
import { useQueryClient } from '@tanstack/react-query'
export interface Props {
id: Mastodon.Account['id']
@ -16,7 +16,7 @@ export interface Props {
const RelationshipIncoming: React.FC<Props> = ({ id }) => {
const { theme } = useTheme()
const { t } = useTranslation()
const { t } = useTranslation(['common', 'componentRelationship'])
const queryKeyRelationship: QueryKeyRelationship = ['Relationship', { id }]
const queryKeyNotification: QueryKeyTimeline = ['Timeline', { page: 'Notifications' }]
@ -33,7 +33,7 @@ const RelationshipIncoming: React.FC<Props> = ({ id }) => {
type: 'error',
theme,
message: t('common:message.error.message', {
function: t(`relationship:${type}.function`)
function: t(`componentRelationship:${type}.function` as any)
}),
...(err.status &&
typeof err.status === 'number' &&

View File

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

View File

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

View File

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

View File

@ -1,14 +1,14 @@
import apiInstance from '@api/instance'
import GracefullyImage from '@components/GracefullyImage'
import { useNavigation } from '@react-navigation/native'
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 { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback } from 'react'
import { Pressable, View } from 'react-native'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import TimelineActions from './Shared/Actions'
import TimelineContent from './Shared/Content'
import StatusContext from './Shared/Context'
@ -41,10 +41,7 @@ const TimelineConversation: React.FC<Props> = ({ conversation, queryKey, highlig
const onPress = useCallback(() => {
if (conversation.last_status) {
conversation.unread && mutate()
navigation.push('Tab-Shared-Toot', {
toot: conversation.last_status,
rootQueryKey: queryKey
})
navigation.push('Tab-Shared-Toot', { toot: conversation.last_status })
}
}, [])
@ -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 TimelineHeaderDefault from '@components/Timeline/Shared/HeaderDefault'
import TimelinePoll from '@components/Timeline/Shared/Poll'
import removeHTML from '@helpers/removeHTML'
import { useNavigation } from '@react-navigation/native'
import { StackNavigationProp } from '@react-navigation/stack'
import { featureCheck } from '@utils/helpers/featureCheck'
import removeHTML from '@utils/helpers/removeHTML'
import { TabLocalStackParamList } from '@utils/navigation/navigators'
import { usePreferencesQuery } from '@utils/queryHooks/preferences'
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 { 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 { useSelector } from 'react-redux'
import * as ContextMenu from 'zeego/context-menu'
import StatusContext from './Shared/Context'
import TimelineFeedback from './Shared/Feedback'
@ -31,7 +32,6 @@ import TimelineTranslate from './Shared/Translate'
export interface Props {
item: Mastodon.Status & { _pinned?: boolean } // For account page, internal property
queryKey?: QueryKeyTimeline
rootQueryKey?: QueryKeyTimeline
highlighted?: boolean
disableDetails?: boolean
disableOnPress?: boolean
@ -42,7 +42,6 @@ export interface Props {
const TimelineDefault: React.FC<Props> = ({
item,
queryKey,
rootQueryKey,
highlighted = false,
disableDetails = false,
disableOnPress = false,
@ -50,7 +49,7 @@ const TimelineDefault: React.FC<Props> = ({
}) => {
const status = item.reblog ? item.reblog : item
const rawContent = useRef<string[]>([])
if (highlighted) {
if (highlighted || isConversation) {
rawContent.current = [
removeHTML(status.content),
status.spoiler_text ? removeHTML(status.spoiler_text) : ''
@ -60,16 +59,18 @@ const TimelineDefault: React.FC<Props> = ({
const { colors } = useTheme()
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(
instanceAccount?.preferences?.['reading:expand:spoilers'] || false
preferences?.['reading:expand:spoilers'] || false
)
const spoilerHidden = status.spoiler_text?.length
? !instanceAccount?.preferences?.['reading:expand:spoilers'] && !spoilerExpanded
? !preferences?.['reading:expand:spoilers'] && !spoilerExpanded
: false
const detectedLanguage = useRef<string>(status.language || '')
const excludeMentions = useRef<Mastodon.Mention[]>([])
const mainStyle: StyleProp<ViewStyle> = {
flex: 1,
@ -82,9 +83,9 @@ const TimelineDefault: React.FC<Props> = ({
const main = () => (
<>
{item.reblog ? (
<TimelineActioned action='reblog' />
<TimelineActioned action='reblog' rootStatus={item} />
) : item._pinned ? (
<TimelineActioned action='pinned' />
<TimelineActioned action='pinned' rootStatus={item} />
) : null}
<View
@ -128,13 +129,13 @@ const TimelineDefault: React.FC<Props> = ({
url: status.url || status.uri,
rawContent
})
const mStatus = menuStatus({ status, queryKey, rootQueryKey })
const mInstance = menuInstance({ status, queryKey, rootQueryKey })
const mStatus = menuStatus({ status, queryKey })
const mInstance = menuInstance({ status, queryKey })
if (!ownAccount) {
let filterResults: FilteredProps['filterResults'] = []
const [filterRevealed, setFilterRevealed] = useState(false)
const hasFilterServerSide = useSelector(checkInstanceFeature('filter_server_side'))
const hasFilterServerSide = featureCheck('filter_server_side')
if (hasFilterServerSide) {
if (status.filtered?.length) {
filterResults = status.filtered?.map(filter => filter.filter)
@ -160,18 +161,18 @@ const TimelineDefault: React.FC<Props> = ({
<StatusContext.Provider
value={{
queryKey,
rootQueryKey,
status,
reblogStatus: item.reblog ? item : undefined,
ownAccount,
spoilerHidden,
rawContent,
detectedLanguage,
excludeMentions,
highlighted,
inThread: queryKey?.[1].page === 'Toot',
disableDetails,
disableOnPress,
isConversation
isConversation,
isRemote: item._remote
}}
>
{disableOnPress ? (
@ -184,49 +185,58 @@ const TimelineDefault: React.FC<Props> = ({
accessible={highlighted ? false : true}
style={mainStyle}
disabled={highlighted}
onPress={() =>
navigation.push('Tab-Shared-Toot', {
toot: status,
rootQueryKey: queryKey
})
}
onPress={() => navigation.push('Tab-Shared-Toot', { toot: status })}
onLongPress={() => {}}
children={main()}
/>
</ContextMenu.Trigger>
<ContextMenu.Content>
{mShare.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>
{[mShare, mStatus, mInstance].map((menu, i) => (
<Fragment key={i}>
{menu.map((group, index) => (
<ContextMenu.Group key={index}>
{group.map(item => {
switch (item.type) {
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>
))}
{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>
</Fragment>
))}
</ContextMenu.Content>
</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
}
const TimelineEmpty = React.memo(
({ queryKey }: Props) => {
const { status, refetch } = useTimelineQuery({
...queryKey[1],
options: { notifyOnChangeProps: ['status'] }
})
const TimelineEmpty: React.FC<Props> = ({ queryKey }) => {
const { status, refetch } = useTimelineQuery({
...queryKey[1],
options: { notifyOnChangeProps: ['status'] }
})
const { colors } = useTheme()
const { t } = useTranslation('componentTimeline')
const { colors } = useTheme()
const { t } = useTranslation('componentTimeline')
const children = () => {
switch (status) {
case 'loading':
return <Circle size={StyleConstants.Font.Size.L} color={colors.secondary} />
case 'error':
return (
<>
<Icon name='Frown' size={StyleConstants.Font.Size.L} color={colors.primaryDefault} />
<CustomText
fontStyle='M'
style={{
marginTop: StyleConstants.Spacing.S,
marginBottom: StyleConstants.Spacing.L,
color: colors.primaryDefault
}}
>
{t('empty.error.message')}
</CustomText>
<Button type='text' content={t('empty.error.button')} onPress={() => refetch()} />
</>
)
case 'success':
return (
<>
<Icon
name='Smartphone'
size={StyleConstants.Font.Size.L}
color={colors.primaryDefault}
/>
<CustomText
fontStyle='M'
style={{
marginTop: StyleConstants.Spacing.S,
marginBottom: StyleConstants.Spacing.L,
color: colors.secondary
}}
>
{t('empty.success.message')}
</CustomText>
</>
)
}
const children = () => {
switch (status) {
case 'loading':
return <Circle size={StyleConstants.Font.Size.L} color={colors.secondary} />
case 'error':
return (
<>
<Icon name='Frown' size={StyleConstants.Font.Size.L} color={colors.primaryDefault} />
<CustomText
fontStyle='M'
style={{
marginTop: StyleConstants.Spacing.S,
marginBottom: StyleConstants.Spacing.L,
color: colors.primaryDefault
}}
>
{t('empty.error.message')}
</CustomText>
<Button type='text' content={t('empty.error.button')} onPress={() => refetch()} />
</>
)
case 'success':
return (
<>
<Icon
name='Smartphone'
size={StyleConstants.Font.Size.L}
color={colors.primaryDefault}
/>
<CustomText
fontStyle='M'
style={{
marginTop: StyleConstants.Spacing.S,
marginBottom: StyleConstants.Spacing.L,
color: colors.secondary
}}
>
{t('empty.success.message')}
</CustomText>
</>
)
}
return (
<View
style={{
flex: 1,
minHeight: '100%',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.backgroundDefault
}}
>
{children()}
</View>
)
},
() => true
)
}
return (
<View
style={{
flex: 1,
minHeight: '100%',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.backgroundDefault
}}
>
{children()}
</View>
)
}
export default TimelineEmpty

View File

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

View File

@ -11,14 +11,15 @@ import TimelineHeaderNotification from '@components/Timeline/Shared/HeaderNotifi
import TimelinePoll from '@components/Timeline/Shared/Poll'
import { useNavigation } from '@react-navigation/native'
import { StackNavigationProp } from '@react-navigation/stack'
import { featureCheck } from '@utils/helpers/featureCheck'
import { TabLocalStackParamList } from '@utils/navigation/navigators'
import { usePreferencesQuery } from '@utils/queryHooks/preferences'
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 { 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 { useSelector } from 'react-redux'
import * as ContextMenu from 'zeego/context-menu'
import StatusContext from './Shared/Context'
import TimelineFiltered, { FilteredProps, shouldFilter } from './Shared/Filtered'
@ -31,7 +32,8 @@ export interface Props {
}
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 account =
@ -40,25 +42,17 @@ const TimelineNotifications: React.FC<Props> = ({ notification, queryKey }) => {
: notification.status
? notification.status.account
: notification.account
const ownAccount = notification.account?.id === instanceAccount?.id
const ownAccount = notification.account?.id === accountId
const [spoilerExpanded, setSpoilerExpanded] = useState(
instanceAccount.preferences?.['reading:expand:spoilers'] || false
preferences?.['reading:expand:spoilers'] || false
)
const spoilerHidden = notification.status?.spoiler_text?.length
? !instanceAccount.preferences?.['reading:expand:spoilers'] && !spoilerExpanded
? !preferences?.['reading:expand:spoilers'] && !spoilerExpanded
: false
const { colors } = useTheme()
const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>()
const onPress = useCallback(() => {
notification.status &&
navigation.push('Tab-Shared-Toot', {
toot: notification.status,
rootQueryKey: queryKey
})
}, [])
const main = () => {
return (
<>
@ -67,6 +61,7 @@ const TimelineNotifications: React.FC<Props> = ({ notification, queryKey }) => {
action={notification.type}
isNotification
account={notification.account}
rootStatus={notification.status}
/>
) : null}
@ -117,7 +112,7 @@ const TimelineNotifications: React.FC<Props> = ({ notification, queryKey }) => {
if (!ownAccount) {
let filterResults: FilteredProps['filterResults'] = []
const [filterRevealed, setFilterRevealed] = useState(false)
const hasFilterServerSide = useSelector(checkInstanceFeature('filter_server_side'))
const hasFilterServerSide = featureCheck('filter_server_side')
if (notification.status) {
if (hasFilterServerSide) {
if (notification.status.filtered?.length) {
@ -131,7 +126,7 @@ const TimelineNotifications: React.FC<Props> = ({ notification, queryKey }) => {
}
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)}>
<TimelineFiltered filterResults={filterResults} />
</Pressable>
@ -157,44 +152,56 @@ const TimelineNotifications: React.FC<Props> = ({ notification, queryKey }) => {
backgroundColor: colors.backgroundDefault,
paddingBottom: notification.status ? 0 : StyleConstants.Spacing.Global.PagePadding
}}
onPress={onPress}
onPress={() =>
notification.status &&
navigation.push('Tab-Shared-Toot', { toot: notification.status })
}
onLongPress={() => {}}
children={main()}
/>
</ContextMenu.Trigger>
<ContextMenu.Content>
{mShare.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>
{[mShare, mStatus, mInstance].map((menu, i) => (
<Fragment key={i}>
{menu.map((group, index) => (
<ContextMenu.Group key={index}>
{group.map(item => {
switch (item.type) {
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>
))}
{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>
</Fragment>
))}
</ContextMenu.Content>
</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 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 { 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 { FlatList, LayoutChangeEvent, Platform, StyleSheet, Text, View } from 'react-native'
import { Circle } from 'react-native-animated-spinkit'
import { FlatList, Platform, Text, View } from 'react-native'
import Animated, {
Extrapolate,
interpolate,
runOnJS,
useAnimatedReaction,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withTiming
} from 'react-native-reanimated'
import { InfiniteData, useQueryClient } from '@tanstack/react-query'
export interface Props {
flRef: RefObject<FlatList<any>>
queryKey: QueryKeyTimeline
fetchingActive: React.MutableRefObject<boolean>
scrollY: Animated.SharedValue<number>
fetchingType: Animated.SharedValue<0 | 1 | 2>
disableRefresh?: boolean
readMarker?: 'read_marker_following'
}
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> = ({
flRef,
queryKey,
fetchingActive,
scrollY,
fetchingType,
disableRefresh = false
disableRefresh = false,
readMarker
}) => {
if (Platform.OS !== 'ios') {
return null
@ -44,86 +54,25 @@ const TimelineRefresh: React.FC<Props> = ({
return null
}
const fetchingLatestIndex = useRef(0)
const refetchActive = useRef(false)
const PREV_PER_BATCH = 1
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 } =
useTimelineQuery({
...queryKey[1],
options: {
getPreviousPageParam: firstPage =>
firstPage?.links?.prev && {
...(firstPage.links.prev.isOffset
? { offset: firstPage.links.prev.id }
: { min_id: firstPage.links.prev.id }),
// https://github.com/facebook/react-native/issues/25239#issuecomment-731100372
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 queryClient = useQueryClient()
const { refetch, isRefetching } = useTimelineQuery({ ...queryKey[1] })
useDerivedValue(() => {
if (prevActive.current || isRefetching) {
fetchingActive.current = true
} else {
fetchingActive.current = false
}
}, [prevActive.current, isRefetching])
const { t } = useTranslation('componentTimeline')
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 arrowY = useAnimatedStyle(() => ({
transform: [
@ -145,17 +94,9 @@ const TimelineRefresh: React.FC<Props> = ({
}))
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(
() => {
if (isFetching) {
if (fetchingActive.current) {
return false
}
switch (arrowStage.value) {
@ -188,10 +129,96 @@ const TimelineRefresh: React.FC<Props> = ({
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(
@ -202,95 +229,76 @@ const TimelineRefresh: React.FC<Props> = ({
fetchingType.value = 0
switch (data) {
case 1:
runOnJS(wrapperStartLatest)()
runOnJS(clearFirstPage)()
runOnJS(fetchPreviousPage)()
break
runOnJS(runFetchPrevious)()
return
case 2:
runOnJS(prepareRefetch)()
runOnJS(callRefetch)()
break
runOnJS(runFetchLatest)()
return
}
},
[]
)
const headerPadding = useAnimatedStyle(
() => ({
paddingTop:
fetchingLatestIndex.current !== 0 || (isFetching && !isLoading && !isFetchingNextPage)
? withTiming(StyleConstants.Spacing.M * 2.5)
: withTiming(0)
}),
[fetchingLatestIndex.current, isFetching, isFetchingNextPage, isLoading]
)
return (
<Animated.View style={headerPadding}>
<View style={styles.base}>
{isFetching ? (
<View style={styles.container2}>
<Circle size={StyleConstants.Font.Size.L} color={colors.secondary} />
</View>
) : (
<>
<View style={styles.container1}>
<Text
style={[styles.explanation, { color: colors.primaryDefault }]}
onLayout={onLayout}
children={t('refresh.fetchPreviousPage')}
/>
<Animated.View
style={[
{
position: 'absolute',
left: textRight + StyleConstants.Spacing.S
},
arrowY,
arrowTop
]}
children={
<Icon
name='ArrowLeft'
size={StyleConstants.Font.Size.M}
color={colors.primaryDefault}
/>
}
/>
</View>
<View style={styles.container2}>
<Text
style={[styles.explanation, { color: colors.primaryDefault }]}
onLayout={onLayout}
children={t('refresh.refetch')}
/>
</View>
</>
)}
<Animated.View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: CONTAINER_HEIGHT * 2,
alignItems: 'center'
}}
>
<View style={{ flex: 1, flexDirection: 'row', height: CONTAINER_HEIGHT }}>
<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.fetchPreviousPage')}
/>
<Animated.View
style={[
{
position: 'absolute',
left: textRight + StyleConstants.Spacing.S
},
arrowY,
arrowTop
]}
children={
<Icon
name='ArrowLeft'
size={StyleConstants.Font.Size.M}
color={colors.primaryDefault}
/>
}
/>
</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>
</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

View File

@ -14,11 +14,12 @@ export interface Props {
action: Mastodon.Notification['type'] | 'reblog' | 'pinned'
isNotification?: boolean
account?: Mastodon.Account // For notification
rootStatus?: Mastodon.Status
}
const TimelineActioned: React.FC<Props> = ({ action, isNotification, ...rest }) => {
const { status, reblogStatus } = useContext(StatusContext)
const account = rest.account || (reblogStatus ? reblogStatus.account : status?.account)
const { status } = useContext(StatusContext)
const account = rest.account || (rest.rootStatus || status)?.account
if (!account) return null
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 = () => {
switch (action) {

View File

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

View File

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

View File

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

View File

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

@ -31,7 +31,8 @@ const TimelineAvatar: React.FC<Props> = ({ account }) => {
})
})}
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 }}
dimension={

View File

@ -2,15 +2,16 @@ import ComponentAccount from '@components/Account'
import GracefullyImage from '@components/GracefullyImage'
import openLink from '@components/openLink'
import CustomText from '@components/Text'
import { matchAccount, matchStatus } from '@helpers/urlMatcher'
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 { useSearchQuery } from '@utils/queryHooks/search'
import { useStatusQuery } from '@utils/queryHooks/status'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
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 TimelineDefault from '../Default'
import StatusContext from './Context'
@ -20,96 +21,44 @@ const TimelineCard: React.FC = () => {
if (!status || !status.card) return null
const { colors } = useTheme()
const navigation = useNavigation()
const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>()
const [loading, setLoading] = useState(false)
const isStatus = matchStatus(status.card.url)
const match = urlMatcher(status.card.url)
const [foundStatus, setFoundStatus] = useState<Mastodon.Status>()
const isAccount = matchAccount(status.card.url)
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({
id: isStatus?.id || '',
options: { enabled: false }
status: match?.status ? { ...match.status, uri: status.card.url } : undefined,
options: { enabled: false, retry: false }
})
useEffect(() => {
if (isStatus) {
if (match?.status) {
setLoading(true)
if (isStatus.sameInstance) {
statusQuery
.refetch()
.then(res => {
res.data && setFoundStatus(res.data)
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))
}
statusQuery
.refetch()
.then(res => {
res.data && setFoundStatus(res.data)
setLoading(false)
})
.catch(() => setLoading(false))
}
}, [])
const accountQuery = useAccountQuery({
id: isAccount?.style === 'default' ? isAccount.id : '',
options: { enabled: false }
account: match?.account ? { ...match?.account, url: status.card.url } : undefined,
options: { enabled: false, retry: false }
})
useEffect(() => {
if (isAccount) {
if (match?.account) {
setLoading(true)
if (isAccount.sameInstance && isAccount.style === 'default') {
accountQuery
.refetch()
.then(res => {
res.data && setFoundAccount(res.data)
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))
}
accountQuery
.refetch()
.then(res => {
res.data && setFoundAccount(res.data)
setLoading(false)
})
.catch(() => setLoading(false))
}
}, [])
@ -128,10 +77,10 @@ const TimelineCard: React.FC = () => {
</View>
)
}
if (isStatus && foundStatus) {
if (match?.status && foundStatus) {
return <TimelineDefault item={foundStatus} disableDetails disableOnPress />
}
if (isAccount && foundAccount) {
if (match?.account && foundAccount) {
return <ComponentAccount account={foundAccount} />
}
return (
@ -192,12 +141,23 @@ const TimelineCard: React.FC = () => {
flex: 1,
flexDirection: 'row',
marginTop: StyleConstants.Spacing.M,
borderWidth: StyleSheet.hairlineWidth,
borderWidth: 1,
borderRadius: StyleConstants.Spacing.S,
overflow: 'hidden',
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()}
/>
)

View File

@ -1,14 +1,11 @@
import { ParseHTML } from '@components/Parse'
import CustomText from '@components/Text'
import { getInstanceAccount } from '@utils/slices/instancesSlice'
import { usePreferencesQuery } from '@utils/queryHooks/preferences'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next'
import { Platform, StyleSheet, View } from 'react-native'
import { Path, Svg } from 'react-native-svg'
import { useSelector } from 'react-redux'
import { isRtlLang } from 'rtl-detect'
import { View } from 'react-native'
import StatusContext from './Context'
export interface Props {
@ -17,35 +14,37 @@ export interface Props {
}
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
const { colors } = useTheme()
const { t } = useTranslation('componentTimeline')
const instanceAccount = useSelector(getInstanceAccount, () => true)
const { data: preferences } = usePreferencesQuery()
return (
<>
<View>
{/* <CustomText
children={excludeMentions?.current.map(mention => mention.username).join(' - ')}
style={{ color: colors.secondary }}
/> */}
{status.spoiler_text?.length ? (
<>
<ParseHTML
content={status.spoiler_text}
size={highlighted ? 'L' : 'M'}
adaptiveSize
emojis={status.emojis}
mentions={status.mentions}
tags={status.tags}
numberOfLines={999}
highlighted={highlighted}
disableDetails={disableDetails}
textStyles={
Platform.OS === 'ios' && status.language && isRtlLang(status.language)
? { writingDirection: 'rtl' }
: undefined
}
/>
{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')}
</CustomText>
) : null}
@ -53,11 +52,8 @@ const TimelineContent: React.FC<Props> = ({ notificationOwnToot = false, setSpoi
content={status.content}
size={highlighted ? 'L' : 'M'}
adaptiveSize
emojis={status.emojis}
mentions={status.mentions}
tags={status.tags}
numberOfLines={
instanceAccount.preferences?.['reading:expand:spoilers'] || inThread
preferences?.['reading:expand:spoilers'] || inThread
? notificationOwnToot
? 2
: 999
@ -65,13 +61,6 @@ const TimelineContent: React.FC<Props> = ({ notificationOwnToot = false, setSpoi
}
expandHint={t('shared.content.expandHint')}
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}
size={highlighted ? 'L' : 'M'}
adaptiveSize
emojis={status.emojis}
mentions={status.mentions}
tags={status.tags}
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 = {
queryKey?: QueryKeyTimeline
rootQueryKey?: QueryKeyTimeline
status?: Mastodon.Status
reblogStatus?: Mastodon.Status // When it is a reblog, pass the root status
ownAccount?: boolean
spoilerHidden?: boolean
rawContent?: React.MutableRefObject<string[]> // When highlighted, for translate, edit history
detectedLanguage?: React.MutableRefObject<string>
excludeMentions?: React.MutableRefObject<Mastodon.Mention[]>
highlighted?: boolean
inThread?: boolean
disableDetails?: boolean
disableOnPress?: boolean
isConversation?: boolean
isRemote?: boolean
}
const StatusContext = createContext<StatusContextType>({} as StatusContextType)

View File

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

View File

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

View File

@ -4,14 +4,13 @@ import menuStatus from '@components/contextMenu/status'
import Icon from '@components/Icon'
import { StyleConstants } from '@utils/styles/constants'
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 * as DropdownMenu from 'zeego/dropdown-menu'
import StatusContext from './Context'
const TimelineHeaderAndroid: React.FC = () => {
const { queryKey, rootQueryKey, status, disableDetails, disableOnPress, rawContent } =
useContext(StatusContext)
const { queryKey, status, disableDetails, disableOnPress, rawContent } = useContext(StatusContext)
if (Platform.OS !== 'android' || !status || disableDetails || disableOnPress) return null
@ -28,9 +27,9 @@ const TimelineHeaderAndroid: React.FC = () => {
type: 'status',
openChange,
account: status.account,
queryKey
...(status && { status })
})
const mStatus = menuStatus({ status, queryKey, rootQueryKey })
const mStatus = menuStatus({ status, queryKey })
return (
<View style={{ position: 'absolute', top: 0, right: 0 }}>
@ -52,34 +51,51 @@ const TimelineHeaderAndroid: React.FC = () => {
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{mShare.map((mGroup, index) => (
<DropdownMenu.Group key={index}>
{mGroup.map(menu => (
<DropdownMenu.Item key={menu.key} {...menu.item}>
<DropdownMenu.ItemTitle children={menu.title} />
</DropdownMenu.Item>
{[mShare, mAccount, mStatus].map((menu, i) => (
<Fragment key={i}>
{menu.map((group, index) => (
<DropdownMenu.Group key={index}>
{group.map(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>
))}
{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>
</Fragment>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { ParseEmojis } from '@components/Parse'
import CustomText from '@components/Text'
import { ParseEmojis } from '@root/components/Parse'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react'

View File

@ -2,38 +2,34 @@ import openLink from '@components/openLink'
import CustomText from '@components/Text'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react'
import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next'
import StatusContext from '../Context'
export interface Props {
application?: Mastodon.Application
const HeaderSharedApplication: React.FC = () => {
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

View File

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

View File

@ -1,36 +1,24 @@
import Icon from '@components/Icon'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react'
import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet } from 'react-native'
import StatusContext from '../Context'
export interface Props {
muted?: Mastodon.Status['muted']
const HeaderSharedMuted: React.FC = () => {
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

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 { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react'
import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet } from 'react-native'
import StatusContext from '../Context'
export interface Props {
visibility: Mastodon.Status['visibility']
const HeaderSharedVisibility: React.FC = () => {
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({
visibility: {
marginLeft: StyleConstants.Spacing.S

View File

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

View File

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

View File

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

View File

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

View File

@ -1,50 +1,53 @@
import { useNavigation } from '@react-navigation/native'
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'
const menuAt = ({ account }: { account: Mastodon.Account }): ContextMenu[][] => {
const menuAt = ({ account }: { account: Mastodon.Account }): ContextMenu => {
const { t } = useTranslation('componentContextMenu')
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>()
const navigationState = useNavState()
const menus: ContextMenu[][] = []
menus.push([
{
key: 'at-direct',
item: {
onSelect: () =>
navigation.navigate('Screen-Compose', {
type: 'conversation',
accts: [account.acct],
visibility: 'direct'
}),
disabled: false,
destructive: false,
hidden: false
return [
[
{
type: 'item',
key: 'at-direct',
props: {
onSelect: () =>
navigation.navigate('Screen-Compose', {
type: 'conversation',
accts: [account.acct],
visibility: 'direct',
navigationState
}),
disabled: false,
destructive: false,
hidden: false
},
title: t('at.direct'),
icon: 'envelope'
},
title: t('at.direct'),
icon: 'envelope'
},
{
key: 'at-public',
item: {
onSelect: () =>
navigation.navigate('Screen-Compose', {
type: 'conversation',
accts: [account.acct],
visibility: 'public'
}),
disabled: false,
destructive: false,
hidden: false
},
title: t('at.public'),
icon: 'at'
}
])
return menus
{
type: 'item',
key: 'at-public',
props: {
onSelect: () =>
navigation.navigate('Screen-Compose', {
type: 'conversation',
accts: [account.acct],
visibility: 'public',
navigationState
}),
disabled: false,
destructive: false,
hidden: false
},
title: t('at.public'),
icon: 'at'
}
]
]
}
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
item: { onSelect: () => void; disabled: boolean; destructive: boolean; hidden: boolean }
props: {
onSelect: () => void
disabled: boolean
destructive: boolean
hidden: boolean
}
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 { useQueryClient } from '@tanstack/react-query'
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 { Alert } from 'react-native'
import { useQueryClient } from '@tanstack/react-query'
import { useSelector } from 'react-redux'
import parse from 'url-parse'
const menuInstance = ({
status,
queryKey,
rootQueryKey
queryKey
}: {
status?: Mastodon.Status
queryKey?: QueryKeyTimeline
rootQueryKey?: QueryKeyTimeline
}): ContextMenu[][] => {
}): ContextMenu => {
if (!status || !queryKey) return []
const { t } = useTranslation('componentContextMenu')
const { t } = useTranslation(['common', 'componentContextMenu'])
const queryClient = useQueryClient()
const mutation = useTimelineMutation({
@ -25,38 +23,33 @@ const menuInstance = ({
displayMessage({
type: 'success',
message: t('common:message.success.message', {
function: t(`instance.block.action`, { instance })
function: t(`componentContextMenu:instance.block.action`, { instance })
})
})
queryClient.invalidateQueries(queryKey)
rootQueryKey && queryClient.invalidateQueries(rootQueryKey)
}
})
const menus: ContextMenu[][] = []
const menus: ContextMenu = []
const currentInstance = useSelector(getInstanceUrl)
const instance = status.uri && status.uri.split(new RegExp(/\/\/(.*?)\//))[1]
const instance = parse(status.uri).hostname
if (currentInstance !== instance && instance) {
if (instance !== getAccountStorage.string('auth.domain')) {
menus.push([
{
type: 'item',
key: 'instance-block',
item: {
props: {
onSelect: () =>
Alert.alert(
t('instance.block.alert.title', { instance }),
t('instance.block.alert.message'),
t('componentContextMenu:instance.block.alert.title', { instance }),
t('componentContextMenu:instance.block.alert.message'),
[
{
text: t('common:buttons.confirm'),
style: 'destructive',
onPress: () => {
mutation.mutate({
type: 'domainBlock',
queryKey,
domain: instance
})
mutation.mutate({ type: 'domainBlock', domain: instance })
}
},
{
@ -68,7 +61,7 @@ const menuInstance = ({
destructive: true,
hidden: false
},
title: t('instance.block.action', { instance }),
title: t('componentContextMenu:instance.block.action', { instance }),
icon: ''
}
])

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