Merge pull request #502 from tooot-app/main

Test v4.6.5
This commit is contained in:
xmflsct 2022-12-04 01:25:17 +01:00 committed by GitHub
commit 5a4af08751
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
267 changed files with 8456 additions and 5112 deletions

View File

@ -11,18 +11,20 @@ Please **do not** create a pull request to update translation. tooot's translati
## Special thanks ## Special thanks
[@amrtf](https://crowdin.com/profile/amrtf) for Spanish translation [@amrtf](https://crowdin.com/profile/amrtf) for Catalan and Spanish translation
[@pat](https://piaille.fr/@pat) for French translation
[@forenta](https://github.com/forenta) for German translation [@forenta](https://github.com/forenta) for German translation
[@pat](https://piaille.fr/@pat) for French translation
[@andrigamerita](https://github.com/andrigamerita) for Italian translation [@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 [@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 [@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 [@luizpicolo](https://github.com/luizpicolo) for Brazilian Portuguese
[@janlindblom](https://github.com/janlindblom) for Swedish [@janlindblom](https://github.com/janlindblom) for Swedish

View File

@ -337,5 +337,3 @@ def isNewArchitectureEnabled() {
// - Set an environment variable `ORG_GRADLE_PROJECT_newArchEnabled=true` // - Set an environment variable `ORG_GRADLE_PROJECT_newArchEnabled=true`
return project.hasProperty("newArchEnabled") && project.newArchEnabled == "true" return project.hasProperty("newArchEnabled") && project.newArchEnabled == "true"
} }
apply plugin: 'com.google.gms.google-services'

View File

@ -1,46 +0,0 @@
{
"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-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-sqa4raeghhrieqt9guljhcul9b51dvna.apps.googleusercontent.com",
"client_type": 2,
"ios_info": {
"bundle_id": "com.xmflsct.app.mastodon"
}
}
]
}
}
}
],
"configuration_version": "1"
}

View File

@ -22,7 +22,6 @@ buildscript {
jcenter() jcenter()
} }
dependencies { dependencies {
classpath 'com.google.gms:google-services:4.3.3'
classpath("com.android.tools.build:gradle:7.2.1") classpath("com.android.tools.build:gradle:7.2.1")
classpath("com.facebook.react:react-native-gradle-plugin") classpath("com.facebook.react:react-native-gradle-plugin")
classpath("de.undercouch:gradle-download-task:5.0.1") classpath("de.undercouch:gradle-download-task:5.0.1")

View File

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

View File

@ -1,34 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CLIENT_ID</key>
<string>661638997772-65g8ce369ugck3ii4ulk6jhb3ijg51kl.apps.googleusercontent.com</string>
<key>REVERSED_CLIENT_ID</key>
<string>com.googleusercontent.apps.661638997772-65g8ce369ugck3ii4ulk6jhb3ijg51kl</string>
<key>API_KEY</key>
<string>AIzaSyAOS1Yq_uNVctG89LB6Dl1PVhb_FAQRbRg</string>
<key>GCM_SENDER_ID</key>
<string>661638997772</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>com.xmflsct.app.tooot</string>
<key>PROJECT_ID</key>
<string>xmflsct-mastodon-app</string>
<key>STORAGE_BUCKET</key>
<string>xmflsct-mastodon-app.appspot.com</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:661638997772:ios:c8d2e09264a344b09f8b29</string>
</dict>
</plist>

View File

@ -1,46 +0,0 @@
{
"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-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-sqa4raeghhrieqt9guljhcul9b51dvna.apps.googleusercontent.com",
"client_type": 2,
"ios_info": {
"bundle_id": "com.xmflsct.app.mastodon"
}
}
]
}
}
}
],
"configuration_version": "1"
}

View File

@ -15,12 +15,6 @@ target 'tooot' do
# Flags change depending on the env values. # Flags change depending on the env values.
flags = get_default_flags() flags = get_default_flags()
# https://stackoverflow.com/questions/72289521/swift-pods-cannot-yet-be-integrated-as-static-libraries-firebasecoreinternal-lib/72969220#72969220
pod 'Firebase', :modular_headers => true
pod 'FirebaseCore', :modular_headers => true
pod 'GoogleUtilities', :modular_headers => true
$RNFirebaseAsStaticFramework = true
use_react_native!( use_react_native!(
:path => config[:reactNativePath], :path => config[:reactNativePath],
:hermes_enabled => true, :hermes_enabled => true,

View File

@ -3,7 +3,7 @@ PODS:
- DoubleConversion (1.1.6) - DoubleConversion (1.1.6)
- EXApplication (5.0.1): - EXApplication (5.0.1):
- ExpoModulesCore - ExpoModulesCore
- EXAV (13.0.1): - EXAV (13.0.2):
- ExpoModulesCore - ExpoModulesCore
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- EXConstants (14.0.2): - EXConstants (14.0.2):
@ -12,18 +12,11 @@ PODS:
- ExpoModulesCore - ExpoModulesCore
- EXFileSystem (15.1.1): - EXFileSystem (15.1.1):
- ExpoModulesCore - ExpoModulesCore
- EXFirebaseAnalytics (8.0.0):
- EXFirebaseCore
- ExpoModulesCore
- Firebase/Core (= 9.5.0)
- EXFirebaseCore (6.0.0):
- ExpoModulesCore
- Firebase/Core (= 9.5.0)
- EXFont (11.0.1): - EXFont (11.0.1):
- ExpoModulesCore - ExpoModulesCore
- EXNotifications (0.17.0): - EXNotifications (0.17.0):
- ExpoModulesCore - ExpoModulesCore
- Expo (47.0.7): - Expo (47.0.8):
- ExpoModulesCore - ExpoModulesCore
- ExpoCrypto (12.0.0): - ExpoCrypto (12.0.0):
- ExpoModulesCore - ExpoModulesCore
@ -59,107 +52,8 @@ PODS:
- React-Core (= 0.70.6) - React-Core (= 0.70.6)
- React-jsi (= 0.70.6) - React-jsi (= 0.70.6)
- ReactCommon/turbomodule/core (= 0.70.6) - ReactCommon/turbomodule/core (= 0.70.6)
- Firebase (9.5.0):
- Firebase/Core (= 9.5.0)
- Firebase/Core (9.5.0):
- Firebase/CoreOnly
- FirebaseAnalytics (~> 9.5.0)
- Firebase/CoreOnly (9.5.0):
- FirebaseCore (= 9.5.0)
- FirebaseAnalytics (9.5.0):
- FirebaseAnalytics/AdIdSupport (= 9.5.0)
- FirebaseCore (~> 9.0)
- FirebaseInstallations (~> 9.0)
- GoogleUtilities/AppDelegateSwizzler (~> 7.7)
- GoogleUtilities/MethodSwizzler (~> 7.7)
- GoogleUtilities/Network (~> 7.7)
- "GoogleUtilities/NSData+zlib (~> 7.7)"
- nanopb (< 2.30910.0, >= 2.30908.0)
- FirebaseAnalytics/AdIdSupport (9.5.0):
- FirebaseCore (~> 9.0)
- FirebaseInstallations (~> 9.0)
- GoogleAppMeasurement (= 9.5.0)
- GoogleUtilities/AppDelegateSwizzler (~> 7.7)
- GoogleUtilities/MethodSwizzler (~> 7.7)
- GoogleUtilities/Network (~> 7.7)
- "GoogleUtilities/NSData+zlib (~> 7.7)"
- nanopb (< 2.30910.0, >= 2.30908.0)
- FirebaseCore (9.5.0):
- FirebaseCoreDiagnostics (~> 9.0)
- FirebaseCoreInternal (~> 9.0)
- GoogleUtilities/Environment (~> 7.7)
- GoogleUtilities/Logger (~> 7.7)
- FirebaseCoreDiagnostics (9.6.0):
- GoogleDataTransport (< 10.0.0, >= 9.1.4)
- GoogleUtilities/Environment (~> 7.7)
- GoogleUtilities/Logger (~> 7.7)
- nanopb (< 2.30910.0, >= 2.30908.0)
- FirebaseCoreInternal (9.6.0):
- "GoogleUtilities/NSData+zlib (~> 7.7)"
- FirebaseInstallations (9.6.0):
- FirebaseCore (~> 9.0)
- GoogleUtilities/Environment (~> 7.7)
- GoogleUtilities/UserDefaults (~> 7.7)
- PromisesObjC (~> 2.1)
- fmt (6.2.1) - fmt (6.2.1)
- glog (0.3.5) - glog (0.3.5)
- GoogleAppMeasurement (9.5.0):
- GoogleAppMeasurement/AdIdSupport (= 9.5.0)
- GoogleUtilities/AppDelegateSwizzler (~> 7.7)
- GoogleUtilities/MethodSwizzler (~> 7.7)
- GoogleUtilities/Network (~> 7.7)
- "GoogleUtilities/NSData+zlib (~> 7.7)"
- nanopb (< 2.30910.0, >= 2.30908.0)
- GoogleAppMeasurement/AdIdSupport (9.5.0):
- GoogleAppMeasurement/WithoutAdIdSupport (= 9.5.0)
- GoogleUtilities/AppDelegateSwizzler (~> 7.7)
- GoogleUtilities/MethodSwizzler (~> 7.7)
- GoogleUtilities/Network (~> 7.7)
- "GoogleUtilities/NSData+zlib (~> 7.7)"
- nanopb (< 2.30910.0, >= 2.30908.0)
- GoogleAppMeasurement/WithoutAdIdSupport (9.5.0):
- GoogleUtilities/AppDelegateSwizzler (~> 7.7)
- GoogleUtilities/MethodSwizzler (~> 7.7)
- GoogleUtilities/Network (~> 7.7)
- "GoogleUtilities/NSData+zlib (~> 7.7)"
- nanopb (< 2.30910.0, >= 2.30908.0)
- GoogleDataTransport (9.2.0):
- GoogleUtilities/Environment (~> 7.7)
- nanopb (< 2.30910.0, >= 2.30908.0)
- PromisesObjC (< 3.0, >= 1.2)
- GoogleUtilities (7.10.0):
- GoogleUtilities/AppDelegateSwizzler (= 7.10.0)
- GoogleUtilities/Environment (= 7.10.0)
- GoogleUtilities/ISASwizzler (= 7.10.0)
- GoogleUtilities/Logger (= 7.10.0)
- GoogleUtilities/MethodSwizzler (= 7.10.0)
- GoogleUtilities/Network (= 7.10.0)
- "GoogleUtilities/NSData+zlib (= 7.10.0)"
- GoogleUtilities/Reachability (= 7.10.0)
- GoogleUtilities/SwizzlerTestHelpers (= 7.10.0)
- GoogleUtilities/UserDefaults (= 7.10.0)
- GoogleUtilities/AppDelegateSwizzler (7.10.0):
- GoogleUtilities/Environment
- GoogleUtilities/Logger
- GoogleUtilities/Network
- GoogleUtilities/Environment (7.10.0):
- PromisesObjC (< 3.0, >= 1.2)
- GoogleUtilities/ISASwizzler (7.10.0)
- GoogleUtilities/Logger (7.10.0):
- GoogleUtilities/Environment
- GoogleUtilities/MethodSwizzler (7.10.0):
- GoogleUtilities/Logger
- GoogleUtilities/Network (7.10.0):
- GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (7.10.0)"
- GoogleUtilities/Reachability (7.10.0):
- GoogleUtilities/Logger
- GoogleUtilities/SwizzlerTestHelpers (7.10.0):
- GoogleUtilities/MethodSwizzler
- GoogleUtilities/UserDefaults (7.10.0):
- GoogleUtilities/Logger
- hermes-engine (0.70.6) - hermes-engine (0.70.6)
- libevent (2.1.12) - libevent (2.1.12)
- libwebp (1.2.4): - libwebp (1.2.4):
@ -171,12 +65,6 @@ PODS:
- libwebp/mux (1.2.4): - libwebp/mux (1.2.4):
- libwebp/demux - libwebp/demux
- libwebp/webp (1.2.4) - libwebp/webp (1.2.4)
- nanopb (2.30909.0):
- nanopb/decode (= 2.30909.0)
- nanopb/encode (= 2.30909.0)
- nanopb/decode (2.30909.0)
- nanopb/encode (2.30909.0)
- PromisesObjC (2.1.1)
- RCT-Folly (2021.07.22.00): - RCT-Folly (2021.07.22.00):
- boost - boost
- DoubleConversion - DoubleConversion
@ -409,17 +297,19 @@ PODS:
- React-Core - React-Core
- react-native-cameraroll (5.1.0): - react-native-cameraroll (5.1.0):
- React-Core - React-Core
- react-native-context-menu-view (1.5.4): - react-native-image-picker (4.10.2):
- React - React-Core
- react-native-image-picker (4.10.1): - react-native-ios-context-menu (1.15.1):
- React-Core - React-Core
- react-native-language-detection (0.1.0): - react-native-language-detection (0.1.0):
- React - React
- react-native-live-text-image-view (0.4.0): - react-native-live-text-image-view (0.4.0):
- React-Core - React-Core
- react-native-netinfo (9.3.6): - react-native-menu (0.7.2):
- React
- react-native-netinfo (9.3.7):
- React-Core - React-Core
- react-native-pager-view (6.1.1): - react-native-pager-view (6.1.2):
- React-Core - React-Core
- react-native-paste-input (0.5.1): - react-native-paste-input (0.5.1):
- React-Core - React-Core
@ -538,9 +428,9 @@ PODS:
- RNScreens (3.18.2): - RNScreens (3.18.2):
- React-Core - React-Core
- React-RCTImage - React-RCTImage
- RNSentry (4.8.0): - RNSentry (4.10.1):
- React-Core - React-Core
- Sentry (= 7.29.0) - Sentry/HybridSDK (= 7.31.2)
- RNShareMenu (6.0.0): - RNShareMenu (6.0.0):
- React - React
- RNSVG (13.6.0): - RNSVG (13.6.0):
@ -551,9 +441,7 @@ PODS:
- SDWebImageWebPCoder (0.9.1): - SDWebImageWebPCoder (0.9.1):
- libwebp (~> 1.0) - libwebp (~> 1.0)
- SDWebImage/Core (~> 5.13) - SDWebImage/Core (~> 5.13)
- Sentry (7.29.0): - Sentry/HybridSDK (7.31.2)
- Sentry/Core (= 7.29.0)
- Sentry/Core (7.29.0)
- Swime (3.0.6) - Swime (3.0.6)
- Yoga (1.14.0) - Yoga (1.14.0)
@ -565,8 +453,6 @@ DEPENDENCIES:
- EXConstants (from `../node_modules/expo-constants/ios`) - EXConstants (from `../node_modules/expo-constants/ios`)
- EXErrorRecovery (from `../node_modules/expo-error-recovery/ios`) - EXErrorRecovery (from `../node_modules/expo-error-recovery/ios`)
- EXFileSystem (from `../node_modules/expo-file-system/ios`) - EXFileSystem (from `../node_modules/expo-file-system/ios`)
- EXFirebaseAnalytics (from `../node_modules/expo-firebase-analytics/ios`)
- EXFirebaseCore (from `../node_modules/expo-firebase-core/ios`)
- EXFont (from `../node_modules/expo-font/ios`) - EXFont (from `../node_modules/expo-font/ios`)
- EXNotifications (from `../node_modules/expo-notifications/ios`) - EXNotifications (from `../node_modules/expo-notifications/ios`)
- Expo (from `../node_modules/expo`) - Expo (from `../node_modules/expo`)
@ -584,10 +470,7 @@ DEPENDENCIES:
- EXVideoThumbnails (from `../node_modules/expo-video-thumbnails/ios`) - EXVideoThumbnails (from `../node_modules/expo-video-thumbnails/ios`)
- FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`) - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`)
- FBReactNativeSpec (from `../node_modules/react-native/React/FBReactNativeSpec`) - FBReactNativeSpec (from `../node_modules/react-native/React/FBReactNativeSpec`)
- Firebase
- FirebaseCore
- glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`) - glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`)
- GoogleUtilities
- hermes-engine (from `../node_modules/react-native/sdks/hermes/hermes-engine.podspec`) - hermes-engine (from `../node_modules/react-native/sdks/hermes/hermes-engine.podspec`)
- libevent (~> 2.1.12) - libevent (~> 2.1.12)
- RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
@ -609,10 +492,11 @@ DEPENDENCIES:
- "react-native-blur (from `../node_modules/@react-native-community/blur`)" - "react-native-blur (from `../node_modules/@react-native-community/blur`)"
- react-native-blurhash (from `../node_modules/react-native-blurhash`) - react-native-blurhash (from `../node_modules/react-native-blurhash`)
- "react-native-cameraroll (from `../node_modules/@react-native-camera-roll/camera-roll`)" - "react-native-cameraroll (from `../node_modules/@react-native-camera-roll/camera-roll`)"
- react-native-context-menu-view (from `../node_modules/react-native-context-menu-view`)
- react-native-image-picker (from `../node_modules/react-native-image-picker`) - react-native-image-picker (from `../node_modules/react-native-image-picker`)
- react-native-ios-context-menu (from `../node_modules/react-native-ios-context-menu`)
- react-native-language-detection (from `../node_modules/react-native-language-detection`) - react-native-language-detection (from `../node_modules/react-native-language-detection`)
- react-native-live-text-image-view (from `../node_modules/react-native-live-text-image-view`) - react-native-live-text-image-view (from `../node_modules/react-native-live-text-image-view`)
- "react-native-menu (from `../node_modules/@react-native-menu/menu`)"
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
- react-native-pager-view (from `../node_modules/react-native-pager-view`) - react-native-pager-view (from `../node_modules/react-native-pager-view`)
- "react-native-paste-input (from `../node_modules/@mattermost/react-native-paste-input`)" - "react-native-paste-input (from `../node_modules/@mattermost/react-native-paste-input`)"
@ -643,20 +527,9 @@ DEPENDENCIES:
SPEC REPOS: SPEC REPOS:
trunk: trunk:
- Firebase
- FirebaseAnalytics
- FirebaseCore
- FirebaseCoreDiagnostics
- FirebaseCoreInternal
- FirebaseInstallations
- fmt - fmt
- GoogleAppMeasurement
- GoogleDataTransport
- GoogleUtilities
- libevent - libevent
- libwebp - libwebp
- nanopb
- PromisesObjC
- SDWebImage - SDWebImage
- SDWebImageWebPCoder - SDWebImageWebPCoder
- Sentry - Sentry
@ -677,10 +550,6 @@ EXTERNAL SOURCES:
:path: "../node_modules/expo-error-recovery/ios" :path: "../node_modules/expo-error-recovery/ios"
EXFileSystem: EXFileSystem:
:path: "../node_modules/expo-file-system/ios" :path: "../node_modules/expo-file-system/ios"
EXFirebaseAnalytics:
:path: "../node_modules/expo-firebase-analytics/ios"
EXFirebaseCore:
:path: "../node_modules/expo-firebase-core/ios"
EXFont: EXFont:
:path: "../node_modules/expo-font/ios" :path: "../node_modules/expo-font/ios"
EXNotifications: EXNotifications:
@ -755,14 +624,16 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-blurhash" :path: "../node_modules/react-native-blurhash"
react-native-cameraroll: react-native-cameraroll:
:path: "../node_modules/@react-native-camera-roll/camera-roll" :path: "../node_modules/@react-native-camera-roll/camera-roll"
react-native-context-menu-view:
:path: "../node_modules/react-native-context-menu-view"
react-native-image-picker: react-native-image-picker:
:path: "../node_modules/react-native-image-picker" :path: "../node_modules/react-native-image-picker"
react-native-ios-context-menu:
:path: "../node_modules/react-native-ios-context-menu"
react-native-language-detection: react-native-language-detection:
:path: "../node_modules/react-native-language-detection" :path: "../node_modules/react-native-language-detection"
react-native-live-text-image-view: react-native-live-text-image-view:
:path: "../node_modules/react-native-live-text-image-view" :path: "../node_modules/react-native-live-text-image-view"
react-native-menu:
:path: "../node_modules/@react-native-menu/menu"
react-native-netinfo: react-native-netinfo:
:path: "../node_modules/@react-native-community/netinfo" :path: "../node_modules/@react-native-community/netinfo"
react-native-pager-view: react-native-pager-view:
@ -822,15 +693,13 @@ SPEC CHECKSUMS:
boost: a7c83b31436843459a1961bfd74b96033dc77234 boost: a7c83b31436843459a1961bfd74b96033dc77234
DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54 DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54
EXApplication: 034b1c40a8e9fe1bff76a1e511ee90dff64ad834 EXApplication: 034b1c40a8e9fe1bff76a1e511ee90dff64ad834
EXAV: 766516466675fc5fdd7c500acced5934e8b00de2 EXAV: 9a45d37772c5329294c054a041dcc39931fc5032
EXConstants: 3c86653c422dd77e40d10cbbabb3025003977415 EXConstants: 3c86653c422dd77e40d10cbbabb3025003977415
EXErrorRecovery: ae43433feb0608a64dc5b1c8363b3e7769a9ea24 EXErrorRecovery: ae43433feb0608a64dc5b1c8363b3e7769a9ea24
EXFileSystem: 60602b6eefa6873f97172c684b7537c9760b50d6 EXFileSystem: 60602b6eefa6873f97172c684b7537c9760b50d6
EXFirebaseAnalytics: 58d70e698859b070b2450ad8664d7b5bc6c6e3e1
EXFirebaseCore: d0d88cb904e893af07f809ab08c0892489bc6956
EXFont: 319606bfe48c33b5b5063fb0994afdc496befe80 EXFont: 319606bfe48c33b5b5063fb0994afdc496befe80
EXNotifications: babce2a87b7922051354fcfe7a74dd279b7e272a EXNotifications: babce2a87b7922051354fcfe7a74dd279b7e272a
Expo: a37d568e9ae87645b74ed597dd0f0fd89e2daf2d Expo: 36b5f625d36728adbdd1934d4d57182f319ab832
ExpoCrypto: 51e7662c7f5bfeab25b7909b8a5d545ec15d4877 ExpoCrypto: 51e7662c7f5bfeab25b7909b8a5d545ec15d4877
ExpoHaptics: 5a56d30a87ea213dd00b09566dc4b441a4dff97f ExpoHaptics: 5a56d30a87ea213dd00b09566dc4b441a4dff97f
ExpoKeepAwake: 69b59d0a8d2b24de9f82759c39b3821fec030318 ExpoKeepAwake: 69b59d0a8d2b24de9f82759c39b3821fec030318
@ -845,22 +714,11 @@ SPEC CHECKSUMS:
EXVideoThumbnails: 8b3e48f3716679dd0cbf949217a31eab5c555799 EXVideoThumbnails: 8b3e48f3716679dd0cbf949217a31eab5c555799
FBLazyVector: 48289402952f4f7a4e235de70a9a590aa0b79ef4 FBLazyVector: 48289402952f4f7a4e235de70a9a590aa0b79ef4
FBReactNativeSpec: dd1186fd05255e3457baa2f4ca65e94c2cd1e3ac FBReactNativeSpec: dd1186fd05255e3457baa2f4ca65e94c2cd1e3ac
Firebase: 800f16f07af493d98d017446a315c27af0552f41
FirebaseAnalytics: 1b60984a408320dda637306f3f733699ef8473d7
FirebaseCore: 25c0400b670fd1e2f2104349cd3b5dcce8d9418f
FirebaseCoreDiagnostics: 99a495094b10a57eeb3ae8efa1665700ad0bdaa6
FirebaseCoreInternal: bca76517fe1ed381e989f5e7d8abb0da8d85bed3
FirebaseInstallations: 0a115432c4e223c5ab20b0dbbe4cbefa793a0e8e
fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9 fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9
glog: 04b94705f318337d7ead9e6d17c019bd9b1f6b1b glog: 04b94705f318337d7ead9e6d17c019bd9b1f6b1b
GoogleAppMeasurement: 6ee231473fbd75c11221dfce489894334024eead
GoogleDataTransport: 1c8145da7117bd68bbbed00cf304edb6a24de00f
GoogleUtilities: bad72cb363809015b1f7f19beb1f1cd23c589f95
hermes-engine: 2af7b7a59128f250adfd86f15aa1d5a2ecd39995 hermes-engine: 2af7b7a59128f250adfd86f15aa1d5a2ecd39995
libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913 libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913
libwebp: f62cb61d0a484ba548448a4bd52aabf150ff6eef libwebp: f62cb61d0a484ba548448a4bd52aabf150ff6eef
nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431
PromisesObjC: ab77feca74fa2823e7af4249b8326368e61014cb
RCT-Folly: 0080d0a6ebf2577475bda044aa59e2ca1f909cda RCT-Folly: 0080d0a6ebf2577475bda044aa59e2ca1f909cda
RCTRequired: e1866f61af7049eb3d8e08e8b133abd38bc1ca7a RCTRequired: e1866f61af7049eb3d8e08e8b133abd38bc1ca7a
RCTTypeSafety: 27c2ac1b00609a432ced1ae701247593f07f901e RCTTypeSafety: 27c2ac1b00609a432ced1ae701247593f07f901e
@ -879,12 +737,13 @@ SPEC CHECKSUMS:
react-native-blur: 50c9feabacbc5f49b61337ebc32192c6be7ec3c3 react-native-blur: 50c9feabacbc5f49b61337ebc32192c6be7ec3c3
react-native-blurhash: add4df9a937b4e021a24bc67a0714f13e0bd40b7 react-native-blurhash: add4df9a937b4e021a24bc67a0714f13e0bd40b7
react-native-cameraroll: a40b082318eb1ecd0336a2f29d9f74b7f2c8cae8 react-native-cameraroll: a40b082318eb1ecd0336a2f29d9f74b7f2c8cae8
react-native-context-menu-view: b0beca02aad4bd9f9d7d932bf437e0a03baa69ef react-native-image-picker: bf34f3f516d139ed3e24c5f5a381a91819e349ea
react-native-image-picker: f2ab1215d17bcfe27b0eb6417cc236fd1f4775e7 react-native-ios-context-menu: b170594b4448c0cd10c79e13432216bac99de1ac
react-native-language-detection: 0e43195ad014974f1b7a31b64820eff34a243f2d react-native-language-detection: 0e43195ad014974f1b7a31b64820eff34a243f2d
react-native-live-text-image-view: 483bacfdba464162b8cf176bba555364f18b584c react-native-live-text-image-view: 483bacfdba464162b8cf176bba555364f18b584c
react-native-netinfo: f80db8cac2151405633324cb645c60af098ee461 react-native-menu: 8e172cfcf0e42e92f028e7781eddf84d430cae24
react-native-pager-view: 3c66c4e2f3ab423643d07b2c7041f8ac48395f72 react-native-netinfo: 2517ad504b3d303e90d7a431b0fcaef76d207983
react-native-pager-view: 54bed894cecebe28cede54c01038d9d1e122de43
react-native-paste-input: 183ad7dc224e192719616f4258dde5b548627d08 react-native-paste-input: 183ad7dc224e192719616f4258dde5b548627d08
react-native-safe-area-context: 99b24a0c5acd0d5dcac2b1a7f18c49ea317be99a react-native-safe-area-context: 99b24a0c5acd0d5dcac2b1a7f18c49ea317be99a
react-native-segmented-control: 65df6cd0619b780b3843d574a72d4c7cec396097 react-native-segmented-control: 65df6cd0619b780b3843d574a72d4c7cec396097
@ -906,15 +765,15 @@ SPEC CHECKSUMS:
RNGestureHandler: 62232ba8f562f7dea5ba1b3383494eb5bf97a4d3 RNGestureHandler: 62232ba8f562f7dea5ba1b3383494eb5bf97a4d3
RNReanimated: ce445c233a6ff5600223484a88ad5704945d972a RNReanimated: ce445c233a6ff5600223484a88ad5704945d972a
RNScreens: 34cc502acf1b916c582c60003dc3089fa01dc66d RNScreens: 34cc502acf1b916c582c60003dc3089fa01dc66d
RNSentry: db7fd7b66efda28885e4e904a8b5e7349aec61c1 RNSentry: 3c27f3c57f16bab9835d9555add298571077e0c1
RNShareMenu: cb9dac548c8bf147d06f0bf07296ad51ea9f5fc3 RNShareMenu: cb9dac548c8bf147d06f0bf07296ad51ea9f5fc3
RNSVG: 3a79c0c4992213e4f06c08e62730c5e7b9e4dc17 RNSVG: 3a79c0c4992213e4f06c08e62730c5e7b9e4dc17
SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84 SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84
SDWebImageWebPCoder: 18503de6621dd2c420d680e33d46bf8e1d5169b0 SDWebImageWebPCoder: 18503de6621dd2c420d680e33d46bf8e1d5169b0
Sentry: 4272663eb0eda312024d795ca3f5a562a8ce5e18 Sentry: b15765d11769852fe78c9add942f7df60ed5dbf5
Swime: d7b2c277503b6cea317774aedc2dce05613f8b0b Swime: d7b2c277503b6cea317774aedc2dce05613f8b0b
Yoga: 99caf8d5ab45e9d637ee6e0174ec16fbbb01bcfc Yoga: 99caf8d5ab45e9d637ee6e0174ec16fbbb01bcfc
PODFILE CHECKSUM: e4191b63c8f15031b2365226730770e7978dca41 PODFILE CHECKSUM: 05bf71d31ba782dfda5a6b47d38e98a6f6bc079a
COCOAPODS: 1.11.3 COCOAPODS: 1.11.3

View File

@ -0,0 +1,2 @@
"NSPhotoLibraryAddUsageDescription" = "Permet que tooot desi imatges al carret de la càmera";
"NSPhotoLibraryUsageDescription" = "Permet que tooot desi imatges al carret de la càmera";

View File

@ -0,0 +1,2 @@
"NSPhotoLibraryAddUsageDescription" = "Sta tooot toe om afbeeldingen op te slaan in je filmrol";
"NSPhotoLibraryUsageDescription" = "Sta tooot toe om afbeeldingen op te slaan in je filmrol";

View File

@ -17,7 +17,6 @@
5EE088C926297820007E5FEC /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5EE088CB26297820007E5FEC /* InfoPlist.strings */; }; 5EE088C926297820007E5FEC /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5EE088CB26297820007E5FEC /* InfoPlist.strings */; };
5EE44DD62600124E00A9BCED /* File.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EE44DD52600124E00A9BCED /* File.swift */; }; 5EE44DD62600124E00A9BCED /* File.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EE44DD52600124E00A9BCED /* File.swift */; };
96905EF65AED1B983A6B3ABC /* libPods-tooot.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-tooot.a */; }; 96905EF65AED1B983A6B3ABC /* libPods-tooot.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-tooot.a */; };
DA8B5B7F0DED488CAC0FF169 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = B96B72E5384D44A7B240B27E /* GoogleService-Info.plist */; };
E3BC22F5F8ABE515E14CF199 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D878F932AF7A9974E06E461 /* ExpoModulesProvider.swift */; }; E3BC22F5F8ABE515E14CF199 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D878F932AF7A9974E06E461 /* ExpoModulesProvider.swift */; };
E613A80B28282A01003C97D6 /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = E613A80A28282A01003C97D6 /* AppDelegate.mm */; }; E613A80B28282A01003C97D6 /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = E613A80A28282A01003C97D6 /* AppDelegate.mm */; };
E633A42B281EAEAB000E540F /* ShareExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = E633A420281EAEAB000E540F /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; E633A42B281EAEAB000E540F /* ShareExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = E633A420281EAEAB000E540F /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
@ -69,9 +68,9 @@
7A4D352CD337FB3A3BF06240 /* Pods-tooot.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-tooot.release.xcconfig"; path = "Target Support Files/Pods-tooot/Pods-tooot.release.xcconfig"; sourceTree = "<group>"; }; 7A4D352CD337FB3A3BF06240 /* Pods-tooot.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-tooot.release.xcconfig"; path = "Target Support Files/Pods-tooot/Pods-tooot.release.xcconfig"; sourceTree = "<group>"; };
9D878F932AF7A9974E06E461 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-tooot/ExpoModulesProvider.swift"; sourceTree = "<group>"; }; 9D878F932AF7A9974E06E461 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-tooot/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = tooot/SplashScreen.storyboard; sourceTree = "<group>"; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = tooot/SplashScreen.storyboard; sourceTree = "<group>"; };
B96B72E5384D44A7B240B27E /* GoogleService-Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "tooot/GoogleService-Info.plist"; sourceTree = "<group>"; };
DF8133F098604A10B0D94952 /* boop.mp3 */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = boop.mp3; path = tooot/boop.mp3; sourceTree = "<group>"; }; DF8133F098604A10B0D94952 /* boop.mp3 */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = boop.mp3; path = tooot/boop.mp3; sourceTree = "<group>"; };
E613A80A28282A01003C97D6 /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = tooot/AppDelegate.mm; sourceTree = "<group>"; }; E613A80A28282A01003C97D6 /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = tooot/AppDelegate.mm; sourceTree = "<group>"; };
E6217B7E293C1EBF00B1755E /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = "<group>"; };
E633A420281EAEAB000E540F /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; E633A420281EAEAB000E540F /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
E633A427281EAEAB000E540F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; E633A427281EAEAB000E540F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
E633A42F281EAF38000E540F /* ShareViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ShareViewController.swift; path = "../../node_modules/react-native-share-menu/ios/ShareViewController.swift"; sourceTree = "<group>"; }; E633A42F281EAF38000E540F /* ShareViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ShareViewController.swift; path = "../../node_modules/react-native-share-menu/ios/ShareViewController.swift"; sourceTree = "<group>"; };
@ -85,6 +84,7 @@
E69EBACC28DF28420057EDEC /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/InfoPlist.strings; sourceTree = "<group>"; }; E69EBACC28DF28420057EDEC /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/InfoPlist.strings; sourceTree = "<group>"; };
E69EBACD28DF284D0057EDEC /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/InfoPlist.strings"; sourceTree = "<group>"; }; E69EBACD28DF284D0057EDEC /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
E69EBACE28DF28560057EDEC /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/InfoPlist.strings; sourceTree = "<group>"; }; E69EBACE28DF28560057EDEC /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/InfoPlist.strings; sourceTree = "<group>"; };
E6A4895D293C1F740047951A /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/InfoPlist.strings; sourceTree = "<group>"; };
E6C8B26628F5F9FC0062CF2E /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = "<group>"; }; E6C8B26628F5F9FC0062CF2E /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
ED2971642150620600B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS12.0.sdk/System/Library/Frameworks/JavaScriptCore.framework; sourceTree = DEVELOPER_DIR; }; ED2971642150620600B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS12.0.sdk/System/Library/Frameworks/JavaScriptCore.framework; sourceTree = DEVELOPER_DIR; };
@ -122,7 +122,6 @@
13B07FB71A68108700A75B9A /* main.m */, 13B07FB71A68108700A75B9A /* main.m */,
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
5E36538225C9B8BD009F93EE /* RootViewColor.xcassets */, 5E36538225C9B8BD009F93EE /* RootViewColor.xcassets */,
B96B72E5384D44A7B240B27E /* GoogleService-Info.plist */,
5EE088CB26297820007E5FEC /* InfoPlist.strings */, 5EE088CB26297820007E5FEC /* InfoPlist.strings */,
DF8133F098604A10B0D94952 /* boop.mp3 */, DF8133F098604A10B0D94952 /* boop.mp3 */,
); );
@ -297,6 +296,8 @@
fr, fr,
es, es,
sv, sv,
nl,
ca,
); );
mainGroup = 83CBB9F61A601CBA00E9B192; mainGroup = 83CBB9F61A601CBA00E9B192;
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
@ -319,7 +320,6 @@
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
13B07FBD1A68108700A75B9A /* LaunchScreen.xib in Resources */, 13B07FBD1A68108700A75B9A /* LaunchScreen.xib in Resources */,
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
DA8B5B7F0DED488CAC0FF169 /* GoogleService-Info.plist in Resources */,
4986628FD0DD4630BFE5F388 /* boop.mp3 in Resources */, 4986628FD0DD4630BFE5F388 /* boop.mp3 in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -528,6 +528,8 @@
E66C0842291F095800DFFF60 /* fr */, E66C0842291F095800DFFF60 /* fr */,
E690AF692926B737002C38A8 /* es */, E690AF692926B737002C38A8 /* es */,
E63E7FF0292A828100C76FD4 /* sv */, E63E7FF0292A828100C76FD4 /* sv */,
E6217B7E293C1EBF00B1755E /* nl */,
E6A4895D293C1F740047951A /* ca */,
); );
name = InfoPlist.strings; name = InfoPlist.strings;
sourceTree = "<group>"; sourceTree = "<group>";

View File

@ -1,34 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CLIENT_ID</key>
<string>661638997772-65g8ce369ugck3ii4ulk6jhb3ijg51kl.apps.googleusercontent.com</string>
<key>REVERSED_CLIENT_ID</key>
<string>com.googleusercontent.apps.661638997772-65g8ce369ugck3ii4ulk6jhb3ijg51kl</string>
<key>API_KEY</key>
<string>AIzaSyAOS1Yq_uNVctG89LB6Dl1PVhb_FAQRbRg</string>
<key>GCM_SENDER_ID</key>
<string>661638997772</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>com.xmflsct.app.tooot</string>
<key>PROJECT_ID</key>
<string>xmflsct-mastodon-app</string>
<key>STORAGE_BUCKET</key>
<string>xmflsct-mastodon-app.appspot.com</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:661638997772:ios:c8d2e09264a344b09f8b29</string>
</dict>
</plist>

View File

@ -1,6 +1,6 @@
{ {
"name": "tooot", "name": "tooot",
"version": "4.6.4", "version": "4.6.5",
"description": "tooot for Mastodon", "description": "tooot for Mastodon",
"author": "xmflsct <me@xmflsct.com>", "author": "xmflsct <me@xmflsct.com>",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
@ -19,35 +19,35 @@
}, },
"dependencies": { "dependencies": {
"@expo/react-native-action-sheet": "^4.0.1", "@expo/react-native-action-sheet": "^4.0.1",
"@formatjs/intl-datetimeformat": "^6.3.1", "@formatjs/intl-datetimeformat": "^6.4.3",
"@formatjs/intl-getcanonicallocales": "^2.0.4", "@formatjs/intl-getcanonicallocales": "^2.0.5",
"@formatjs/intl-locale": "^3.0.7", "@formatjs/intl-locale": "^3.0.11",
"@formatjs/intl-numberformat": "^8.2.0", "@formatjs/intl-numberformat": "^8.3.3",
"@formatjs/intl-pluralrules": "^5.1.4", "@formatjs/intl-pluralrules": "^5.1.8",
"@formatjs/intl-relativetimeformat": "^11.1.4", "@formatjs/intl-relativetimeformat": "^11.1.8",
"@mattermost/react-native-paste-input": "^0.5.1", "@mattermost/react-native-paste-input": "^0.5.1",
"@neverdull-agency/expo-unlimited-secure-store": "^1.0.10", "@neverdull-agency/expo-unlimited-secure-store": "^1.0.10",
"@react-native-async-storage/async-storage": "~1.17.11", "@react-native-async-storage/async-storage": "~1.17.11",
"@react-native-camera-roll/camera-roll": "^5.1.0", "@react-native-camera-roll/camera-roll": "^5.1.0",
"@react-native-clipboard/clipboard": "^1.11.1", "@react-native-clipboard/clipboard": "^1.11.1",
"@react-native-community/blur": "^4.3.0", "@react-native-community/blur": "^4.3.0",
"@react-native-community/netinfo": "9.3.6", "@react-native-community/netinfo": "9.3.7",
"@react-native-community/segmented-control": "^2.2.2", "@react-native-community/segmented-control": "^2.2.2",
"@react-navigation/bottom-tabs": "^6.4.1", "@react-native-menu/menu": "^0.7.2",
"@react-navigation/native": "^6.0.14", "@react-navigation/bottom-tabs": "^6.4.3",
"@react-navigation/native-stack": "^6.9.2", "@react-navigation/native": "^6.0.16",
"@react-navigation/stack": "^6.3.5", "@react-navigation/native-stack": "^6.9.4",
"@reduxjs/toolkit": "^1.9.0", "@react-navigation/stack": "^6.3.7",
"@sentry/react-native": "4.8.0", "@reduxjs/toolkit": "^1.9.1",
"@sharcoux/slider": "^6.0.3", "@sentry/react-native": "4.10.1",
"@sharcoux/slider": "^6.1.1",
"axios": "^0.27.2", "axios": "^0.27.2",
"expo": "^47.0.7", "expo": "^47.0.8",
"expo-auth-session": "^3.7.2", "expo-auth-session": "^3.7.3",
"expo-av": "^13.0.1", "expo-av": "^13.0.2",
"expo-constants": "^14.0.2", "expo-constants": "^14.0.2",
"expo-crypto": "^12.0.0", "expo-crypto": "^12.0.0",
"expo-file-system": "^15.1.1", "expo-file-system": "^15.1.1",
"expo-firebase-analytics": "^8.0.0",
"expo-haptics": "^12.0.1", "expo-haptics": "^12.0.1",
"expo-linking": "^3.2.3", "expo-linking": "^3.2.3",
"expo-localization": "^14.0.0", "expo-localization": "^14.0.0",
@ -66,21 +66,21 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^12.0.0", "react-i18next": "^12.0.0",
"react-intl": "^6.2.1", "react-intl": "^6.2.5",
"react-native": "0.70.6", "react-native": "0.70.6",
"react-native-animated-spinkit": "^1.5.2", "react-native-animated-spinkit": "^1.5.2",
"react-native-base64": "^0.2.1", "react-native-base64": "^0.2.1",
"react-native-blurhash": "^1.1.10", "react-native-blurhash": "^1.1.10",
"react-native-context-menu-view": "xmflsct/react-native-context-menu-view",
"react-native-fast-image": "^8.6.3", "react-native-fast-image": "^8.6.3",
"react-native-feather": "^1.1.2", "react-native-feather": "^1.1.2",
"react-native-flash-message": "^0.3.1", "react-native-flash-message": "^0.3.1",
"react-native-gesture-handler": "~2.8.0", "react-native-gesture-handler": "~2.8.0",
"react-native-htmlview": "^0.16.0", "react-native-htmlview": "^0.16.0",
"react-native-image-picker": "^4.10.1", "react-native-image-picker": "^4.10.2",
"react-native-ios-context-menu": "^1.15.1",
"react-native-language-detection": "^0.1.0", "react-native-language-detection": "^0.1.0",
"react-native-live-text-image-view": "^0.4.0", "react-native-live-text-image-view": "^0.4.0",
"react-native-pager-view": "^6.1.1", "react-native-pager-view": "^6.1.2",
"react-native-reanimated": "^2.13.0", "react-native-reanimated": "^2.13.0",
"react-native-reanimated-zoom": "^0.3.3", "react-native-reanimated-zoom": "^0.3.3",
"react-native-safe-area-context": "^4.4.1", "react-native-safe-area-context": "^4.4.1",
@ -88,24 +88,25 @@
"react-native-share-menu": "^6.0.0", "react-native-share-menu": "^6.0.0",
"react-native-svg": "^13.6.0", "react-native-svg": "^13.6.0",
"react-native-swipe-list-view": "^3.2.9", "react-native-swipe-list-view": "^3.2.9",
"react-native-tab-view": "^3.3.0", "react-native-tab-view": "^3.3.2",
"react-query": "^3.39.2", "react-query": "^3.39.2",
"react-redux": "^8.0.5", "react-redux": "^8.0.5",
"redux-persist": "^6.0.0", "redux-persist": "^6.0.0",
"rn-placeholder": "^3.0.3", "rn-placeholder": "^3.0.3",
"valid-url": "^1.0.9" "valid-url": "^1.0.9",
"zeego": "^0.5.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.20.2", "@babel/core": "^7.20.5",
"@babel/plugin-proposal-optional-chaining": "^7.18.9", "@babel/plugin-proposal-optional-chaining": "^7.18.9",
"@babel/preset-react": "^7.18.6", "@babel/preset-react": "^7.18.6",
"@babel/preset-typescript": "^7.18.6", "@babel/preset-typescript": "^7.18.6",
"@expo/config": "^7.0.3", "@expo/config": "^7.0.3",
"@types/linkify-it": "^3.0.2", "@types/linkify-it": "^3.0.2",
"@types/lodash": "^4.14.189", "@types/lodash": "^4.14.191",
"@types/react": "~18.0.25", "@types/react": "~18.0.26",
"@types/react-dom": "~18.0.9", "@types/react-dom": "~18.0.9",
"@types/react-native": "~0.70.6", "@types/react-native": "~0.70.7",
"@types/react-native-base64": "^0.2.0", "@types/react-native-base64": "^0.2.0",
"@types/react-native-share-menu": "^5.0.2", "@types/react-native-share-menu": "^5.0.2",
"@types/react-timeago": "^4.1.3", "@types/react-timeago": "^4.1.3",

View File

@ -274,6 +274,7 @@ declare namespace Mastodon {
type List = { type List = {
id: string id: string
title: string title: string
replies_policy: 'none' | 'list' | 'followed'
} }
type Instance = { type Instance = {

View File

@ -1,5 +1,4 @@
import { ActionSheetProvider } from '@expo/react-native-action-sheet' import { ActionSheetProvider } from '@expo/react-native-action-sheet'
import getLanguage from '@helpers/getLanguage'
import queryClient from '@helpers/queryClient' import queryClient from '@helpers/queryClient'
import i18n from '@root/i18n/i18n' import i18n from '@root/i18n/i18n'
import Screens from '@root/Screens' import Screens from '@root/Screens'
@ -13,11 +12,12 @@ import timezone from '@root/startup/timezone'
import { persistor, store } from '@root/store' import { persistor, store } from '@root/store'
import * as Sentry from '@sentry/react-native' import * as Sentry from '@sentry/react-native'
import AccessibilityManager from '@utils/accessibility/AccessibilityManager' import AccessibilityManager from '@utils/accessibility/AccessibilityManager'
import { changeLanguage } from '@utils/slices/settingsSlice' import { changeLanguage, getSettingsLanguage } from '@utils/slices/settingsSlice'
import ThemeManager from '@utils/styles/ThemeManager' import ThemeManager from '@utils/styles/ThemeManager'
import * as Localization from 'expo-localization' import * as Localization from 'expo-localization'
import * as SplashScreen from 'expo-splash-screen' import * as SplashScreen from 'expo-splash-screen'
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import { IntlProvider } from 'react-intl'
import { LogBox, Platform } from 'react-native' import { LogBox, Platform } from 'react-native'
import { GestureHandlerRootView } from 'react-native-gesture-handler' import { GestureHandlerRootView } from 'react-native-gesture-handler'
import { SafeAreaProvider } from 'react-native-safe-area-context' import { SafeAreaProvider } from 'react-native-safe-area-context'
@ -85,7 +85,7 @@ const App: React.FC = () => {
if (bootstrapped) { if (bootstrapped) {
log('log', 'App', 'loading actual app :)') log('log', 'App', 'loading actual app :)')
log('log', 'App', `Locale: ${Localization.locale}`) log('log', 'App', `Locale: ${Localization.locale}`)
const language = getLanguage() const language = getSettingsLanguage(store.getState())
if (!language) { if (!language) {
if (Platform.OS !== 'ios') { if (Platform.OS !== 'ios') {
store.dispatch(changeLanguage('en')) store.dispatch(changeLanguage('en'))
@ -96,15 +96,17 @@ const App: React.FC = () => {
} }
return ( return (
<SafeAreaProvider> <IntlProvider locale={language}>
<ActionSheetProvider> <SafeAreaProvider>
<AccessibilityManager> <ActionSheetProvider>
<ThemeManager> <AccessibilityManager>
<Screens localCorrupt={localCorrupt} /> <ThemeManager>
</ThemeManager> <Screens localCorrupt={localCorrupt} />
</AccessibilityManager> </ThemeManager>
</ActionSheetProvider> </AccessibilityManager>
</SafeAreaProvider> </ActionSheetProvider>
</SafeAreaProvider>
</IntlProvider>
) )
} else { } else {
return null return null

View File

@ -1,4 +1,3 @@
import analytics from '@components/analytics'
import { HeaderLeft } from '@components/Header' import { HeaderLeft } from '@components/Header'
import { displayMessage, Message } from '@components/Message' import { displayMessage, Message } from '@components/Message'
import navigationRef from '@helpers/navigationRef' import navigationRef from '@helpers/navigationRef'
@ -28,7 +27,6 @@ import * as Linking from 'expo-linking'
import { addScreenshotListener } from 'expo-screen-capture' import { addScreenshotListener } from 'expo-screen-capture'
import React, { useCallback, useEffect, useRef, useState } from 'react' import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { IntlProvider } from 'react-intl'
import { Alert, Platform, StatusBar } from 'react-native' import { Alert, Platform, StatusBar } from 'react-native'
import ShareMenu from 'react-native-share-menu' import ShareMenu from 'react-native-share-menu'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
@ -113,7 +111,6 @@ const Screens: React.FC<Props> = ({ localCorrupt }) => {
} }
if (previousRoute?.name !== currentRoute?.name) { if (previousRoute?.name !== currentRoute?.name) {
analytics('screen_view', { screen_name: currentRoute?.name })
Sentry.setContext('page', { Sentry.setContext('page', {
previous: previousRoute, previous: previousRoute,
current: currentRoute current: currentRoute
@ -273,7 +270,7 @@ const Screens: React.FC<Props> = ({ localCorrupt }) => {
}, []) }, [])
return ( return (
<IntlProvider locale={i18n.language}> <>
<StatusBar <StatusBar
backgroundColor={colors.backgroundDefault} backgroundColor={colors.backgroundDefault}
{...(Platform.OS === 'android' && { {...(Platform.OS === 'android' && {
@ -350,7 +347,7 @@ const Screens: React.FC<Props> = ({ localCorrupt }) => {
<Message /> <Message />
</NavigationContainer> </NavigationContainer>
</IntlProvider> </>
) )
} }

View File

@ -7,30 +7,23 @@ const handleError = (error: any) => {
// The request was made and the server responded with a status code // The request was made and the server responded with a status code
// that falls out of the range of 2xx // that falls out of the range of 2xx
console.error( console.error(
ctx.bold(' API instance '), ctx.bold(' API '),
ctx.bold('response'), ctx.bold('response'),
error.response.status, error.response.status,
error?.response.data?.error || error?.response.message || 'Unknown error' error?.response.data?.error || error?.response.message || 'Unknown error'
) )
return Promise.reject({ return Promise.reject({
status: error?.response.status, status: error?.response.status,
message: message: error?.response.data?.error || error?.response.message || 'Unknown error'
error?.response.data?.error ||
error?.response.message ||
'Unknown error'
}) })
} else if (error?.request) { } else if (error?.request) {
// The request was made but no response was received // The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of // `error.request` is an instance of XMLHttpRequest in the browser and an instance of
// http.ClientRequest in node.js // http.ClientRequest in node.js
console.error(ctx.bold(' API instance '), ctx.bold('request'), error) console.error(ctx.bold(' API '), ctx.bold('request'), error)
return Promise.reject() return Promise.reject()
} else { } else {
console.error( console.error(ctx.bold(' API '), ctx.bold('internal'), error?.message)
ctx.bold(' API instance '),
ctx.bold('internal'),
error?.message
)
return Promise.reject() return Promise.reject()
} }
} }

View File

@ -13,10 +13,7 @@ export type Params = {
} }
headers?: { [key: string]: string } headers?: { [key: string]: string }
body?: FormData body?: FormData
extras?: Omit< extras?: Omit<AxiosRequestConfig, 'method' | 'url' | 'params' | 'headers' | 'data'>
AxiosRequestConfig,
'method' | 'url' | 'params' | 'headers' | 'data'
>
} }
export type InstanceResponse<T = unknown> = { export type InstanceResponse<T = unknown> = {
@ -35,9 +32,7 @@ const apiInstance = async <T = unknown>({
}: Params): Promise<InstanceResponse<T>> => { }: Params): Promise<InstanceResponse<T>> => {
const { store } = require('@root/store') const { store } = require('@root/store')
const state = store.getState() as RootState const state = store.getState() as RootState
const instanceActive = state.instances.instances.findIndex( const instanceActive = state.instances.instances.findIndex(instance => instance.active)
instance => instance.active
)
let domain let domain
let token let token
@ -45,21 +40,19 @@ const apiInstance = async <T = unknown>({
domain = state.instances.instances[instanceActive].url domain = state.instances.instances[instanceActive].url
token = state.instances.instances[instanceActive].token token = state.instances.instances[instanceActive].token
} else { } else {
console.warn( console.warn(ctx.bgRed.white.bold(' API ') + ' ' + 'No instance domain is provided')
ctx.bgRed.white.bold(' API ') + ' ' + 'No instance domain is provided'
)
return Promise.reject() return Promise.reject()
} }
console.log( console.log(
ctx.bgGreen.bold(' API instance ') + ctx.bgGreen.bold(' API instance ') +
' ' + ' ' +
domain + domain +
' ' + ' ' +
method + method +
ctx.green(' -> ') + ctx.green(' -> ') +
`/${url}` + `/${url}` +
(params ? ctx.green(' -> ') : ''), (params ? ctx.green(' -> ') : ''),
params ? params : '' params ? params : ''
) )
@ -70,10 +63,7 @@ const apiInstance = async <T = unknown>({
url, url,
params, params,
headers: { headers: {
'Content-Type': 'Content-Type': body && body instanceof FormData ? 'multipart/form-data' : 'application/json',
body && body instanceof FormData
? 'multipart/form-data'
: 'application/json',
Accept: '*/*', Accept: '*/*',
...userAgent, ...userAgent,
...headers, ...headers,
@ -87,10 +77,10 @@ const apiInstance = async <T = unknown>({
.then(response => { .then(response => {
let prev let prev
let next let next
if (response.headers.link) { if (response.headers?.link) {
const headersLinks = li.parse(response.headers.link) const headersLinks = li.parse(response.headers?.link)
prev = headersLinks.prev?.match(/_id=([0-9]*)/)[1] prev = headersLinks.prev?.match(/_id=([0-9]*)/)?.[1]
next = headersLinks.next?.match(/_id=([0-9]*)/)[1] next = headersLinks.next?.match(/_id=([0-9]*)/)?.[1]
} }
return Promise.resolve({ return Promise.resolve({
body: response.data, body: response.data,

View File

@ -4,49 +4,47 @@ import { StackNavigationProp } from '@react-navigation/stack'
import { TabLocalStackParamList } from '@utils/navigation/navigators' import { TabLocalStackParamList } from '@utils/navigation/navigators'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback } from 'react' import React, { PropsWithChildren } from 'react'
import { Pressable, View } from 'react-native' import { Pressable, View } from 'react-native'
import analytics from './analytics'
import GracefullyImage from './GracefullyImage' import GracefullyImage from './GracefullyImage'
import CustomText from './Text' import CustomText from './Text'
export interface Props { export interface Props {
account: Mastodon.Account account: Mastodon.Account
onPress?: () => void Component?: typeof View | typeof Pressable
origin?: string props?: {}
} }
const ComponentAccount: React.FC<Props> = ({ const ComponentAccount: React.FC<PropsWithChildren & Props> = ({
account, account,
onPress: customOnPress, Component,
origin props,
children
}) => { }) => {
const { colors } = useTheme() const { colors } = useTheme()
const navigation = const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>()
useNavigation<StackNavigationProp<TabLocalStackParamList>>()
const onPress = useCallback(() => { if (!props) {
analytics('search_account_press', { page: origin }) props = { onPress: () => navigation.push('Tab-Shared-Account', { account }) }
navigation.push('Tab-Shared-Account', { account }) }
}, [])
return ( return React.createElement(
<Pressable Component || Pressable,
accessibilityRole='button' {
style={{ ...props,
style: {
flex: 1, flex: 1,
paddingHorizontal: StyleConstants.Spacing.Global.PagePadding, paddingHorizontal: StyleConstants.Spacing.Global.PagePadding,
paddingVertical: StyleConstants.Spacing.M, paddingVertical: StyleConstants.Spacing.M,
flexDirection: 'row', flexDirection: 'row',
alignSelf: 'flex-start', justifyContent: 'space-between',
alignItems: 'center' alignItems: 'center'
}} }
onPress={customOnPress || onPress} },
> <View style={{ flex: 1, flexDirection: 'row', alignItems: 'center' }}>
<GracefullyImage <GracefullyImage
uri={{ original: account.avatar, static: account.avatar_static }} uri={{ original: account.avatar, static: account.avatar_static }}
style={{ style={{
alignSelf: 'flex-start',
width: StyleConstants.Avatar.S, width: StyleConstants.Avatar.S,
height: StyleConstants.Avatar.S, height: StyleConstants.Avatar.S,
borderRadius: 6, borderRadius: 6,
@ -72,7 +70,8 @@ const ComponentAccount: React.FC<Props> = ({
@{account.acct} @{account.acct}
</CustomText> </CustomText>
</View> </View>
</Pressable> </View>,
children
) )
} }

View File

@ -1,185 +0,0 @@
import analytics from '@components/analytics'
import { displayMessage } from '@components/Message'
import { useRelationshipQuery } from '@utils/queryHooks/relationship'
import {
MutationVarsTimelineUpdateAccountProperty,
QueryKeyTimeline,
useTimelineMutation
} from '@utils/queryHooks/timeline'
import { getInstanceAccount } from '@utils/slices/instancesSlice'
import { useTheme } from '@utils/styles/ThemeManager'
import { useTranslation } from 'react-i18next'
import { Platform } from 'react-native'
import { ContextMenuAction } from 'react-native-context-menu-view'
import { useQueryClient } from 'react-query'
import { useSelector } from 'react-redux'
export interface Props {
actions: ContextMenuAction[]
type: 'status' | 'account' // Do not need to fetch relationship in timeline
queryKey?: QueryKeyTimeline
rootQueryKey?: QueryKeyTimeline
id: Mastodon.Account['id']
}
const contextMenuAccount = ({ actions, type, queryKey, rootQueryKey, id: accountId }: Props) => {
const { theme } = useTheme()
const { t } = useTranslation('componentContextMenu')
const queryClient = useQueryClient()
const mutation = useTimelineMutation({
onSuccess: (_, params) => {
queryClient.refetchQueries(['Relationship', { id: accountId }])
const theParams = params as MutationVarsTimelineUpdateAccountProperty
displayMessage({
theme,
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()
})
})
})
})
},
onError: (err: any, params) => {
const theParams = params as MutationVarsTimelineUpdateAccountProperty
displayMessage({
theme,
type: 'error',
message: t('common:message.error.message', {
function: t(`account.${theParams.payload.property}.action`, {
...(theParams.payload.property !== 'reports' && {
context: (theParams.payload.currentValue || false).toString()
})
})
}),
...(err.status &&
typeof err.status === 'number' &&
err.data &&
err.data.error &&
typeof err.data.error === 'string' && {
description: err.data.error
})
})
},
onSettled: () => {
queryKey && queryClient.invalidateQueries(queryKey)
rootQueryKey && queryClient.invalidateQueries(rootQueryKey)
}
})
const instanceAccount = useSelector(getInstanceAccount, (prev, next) => prev.id === next.id)
const ownAccount = instanceAccount?.id === accountId
const { data: relationship } = useRelationshipQuery({
id: accountId,
options: { enabled: type === 'account' }
})
if (!ownAccount) {
actions.push({
id: 'account-mute',
title: t('account.mute.action', {
context: (relationship?.muting || false).toString()
}),
systemIcon: 'eye.slash'
})
switch (Platform.OS) {
case 'ios':
actions.push({
id: 'account',
title: t('account.title'),
actions: [
{
id: 'account-block',
title: t('account.block.action', {
context: (relationship?.blocking || false).toString()
}),
systemIcon: 'xmark.circle',
destructive: true
},
{
id: 'account-reports',
title: t('account.reports.action'),
systemIcon: 'flag',
destructive: true
}
]
})
break
default:
actions.push(
{
id: 'account-block',
title: t('account.block.action', {
context: (relationship?.blocking || false).toString()
}),
systemIcon: 'xmark.circle',
destructive: true
},
{
id: 'account-reports',
title: t('account.reports.action'),
systemIcon: 'flag',
destructive: true
}
)
break
}
}
return (index: number) => {
if (typeof index !== 'number' || !actions[index]) {
return // For Android
}
if (actions[index].id === 'account-mute') {
analytics('timeline_shared_headeractions_account_mute_press', {
page: queryKey && queryKey[1].page
})
mutation.mutate({
type: 'updateAccountProperty',
queryKey,
id: accountId,
payload: { property: 'mute', currentValue: relationship?.muting }
})
}
if (
actions[index].id === 'account-block' ||
(actions[index].id === 'account' && actions[index].actions?.[0].id === 'account-block')
) {
analytics('timeline_shared_headeractions_account_block_press', {
page: queryKey && queryKey[1].page
})
mutation.mutate({
type: 'updateAccountProperty',
queryKey,
id: accountId,
payload: { property: 'block', currentValue: relationship?.blocking }
})
}
if (
actions[index].id === 'account-reports' ||
(actions[index].id === 'account' && actions[index].actions?.[0].id === 'account-reports')
) {
analytics('timeline_shared_headeractions_account_reports_press', {
page: queryKey && queryKey[1].page
})
mutation.mutate({
type: 'updateAccountProperty',
queryKey,
id: accountId,
payload: { property: 'reports' }
})
mutation.mutate({
type: 'updateAccountProperty',
queryKey,
id: accountId,
payload: { property: 'block', currentValue: false }
})
}
}
}
export default contextMenuAccount

View File

@ -1,27 +1,25 @@
import analytics from '@components/analytics'
import { displayMessage } from '@components/Message' import { displayMessage } from '@components/Message'
import { QueryKeyTimeline, useTimelineMutation } from '@utils/queryHooks/timeline' import { QueryKeyTimeline, useTimelineMutation } from '@utils/queryHooks/timeline'
import { getInstanceUrl } from '@utils/slices/instancesSlice' import { getInstanceUrl } from '@utils/slices/instancesSlice'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Alert, Platform } from 'react-native' import { Alert } from 'react-native'
import { ContextMenuAction } from 'react-native-context-menu-view'
import { useQueryClient } from 'react-query' import { useQueryClient } from 'react-query'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
export interface Props { const menuInstance = ({
actions: ContextMenuAction[] status,
status: Mastodon.Status queryKey,
queryKey: QueryKeyTimeline rootQueryKey
}: {
status?: Mastodon.Status
queryKey?: QueryKeyTimeline
rootQueryKey?: QueryKeyTimeline rootQueryKey?: QueryKeyTimeline
} }): ContextMenu[][] => {
if (!status || !queryKey) return []
const contextMenuInstance = ({ actions, status, queryKey, rootQueryKey }: Props) => {
const { t } = useTranslation('componentContextMenu')
const { theme } = useTheme() const { theme } = useTheme()
const { t } = useTranslation('componentContextMenu')
const currentInstance = useSelector(getInstanceUrl)
const instance = status?.uri && status.uri.split(new RegExp(/\/\/(.*?)\//))[1]
const queryClient = useQueryClient() const queryClient = useQueryClient()
const mutation = useTimelineMutation({ const mutation = useTimelineMutation({
@ -38,67 +36,48 @@ const contextMenuInstance = ({ actions, status, queryKey, rootQueryKey }: Props)
} }
}) })
const menus: ContextMenu[][] = []
const currentInstance = useSelector(getInstanceUrl)
const instance = status.uri && status.uri.split(new RegExp(/\/\/(.*?)\//))[1]
if (currentInstance !== instance && instance) { if (currentInstance !== instance && instance) {
switch (Platform.OS) { menus.push([
case 'ios': {
actions.push({ key: 'instance-block',
id: 'instance', item: {
title: t('instance.title'), onSelect: () =>
actions: [ Alert.alert(
{ t('instance.block.alert.title', { instance }),
id: 'instance-block', t('instance.block.alert.message'),
title: t('instance.block.action', { instance }), [
destructive: true {
} text: t('instance.block.alert.buttons.confirm'),
] style: 'destructive',
}) onPress: () => {
break mutation.mutate({
default: type: 'domainBlock',
actions.push({ queryKey,
id: 'instance-block', domain: instance
title: t('instance.block.action', { instance }), })
destructive: true }
}) },
break {
} text: t('common:buttons.cancel')
}
]
),
disabled: false,
destructive: true,
hidden: false
},
title: t('instance.block.action', { instance }),
icon: ''
}
])
} }
return (index: number) => { return menus
if (typeof index !== 'number' || !actions[index]) {
return // For Android
}
if (
actions[index].id === 'instance-block' ||
(actions[index].id === 'instance' && actions[index].actions?.[0].id === 'instance-block')
) {
analytics('timeline_shared_headeractions_domain_block_press', {
page: queryKey[1].page
})
Alert.alert(
t('instance.block.alert.title', { instance }),
t('instance.block.alert.message'),
[
{
text: t('instance.block.alert.buttons.confirm'),
style: 'destructive',
onPress: () => {
analytics('timeline_shared_headeractions_domain_block_confirm', {
page: queryKey && queryKey[1].page
})
mutation.mutate({
type: 'domainBlock',
queryKey,
domain: instance
})
}
},
{
text: t('common:buttons.cancel')
}
]
)
}
}
} }
export default contextMenuInstance export default menuInstance

View File

@ -1,64 +1,76 @@
import analytics from '@components/analytics'
import { displayMessage } from '@components/Message' import { displayMessage } from '@components/Message'
import Clipboard from '@react-native-clipboard/clipboard' import Clipboard from '@react-native-clipboard/clipboard'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Platform, Share } from 'react-native' import { Platform, Share } from 'react-native'
import { ContextMenuAction } from 'react-native-context-menu-view'
export interface Props { const menuShare = (
copiableContent?: React.MutableRefObject<{ params:
content?: string | undefined | {
complete: boolean visibility?: Mastodon.Status['visibility']
}> copiableContent?: React.MutableRefObject<{
actions: ContextMenuAction[] content?: string | undefined
type: 'status' | 'account' complete: boolean
url: string }>
} type: 'status'
url?: string
}
| {
type: 'account'
url: string
}
): ContextMenu[][] => {
if (params.type === 'status' && params.visibility === 'direct') return []
const contextMenuShare = ({ copiableContent, actions, type, url }: Props) => {
const { theme } = useTheme() const { theme } = useTheme()
const { t } = useTranslation('componentContextMenu') const { t } = useTranslation('componentContextMenu')
actions.push({ const menus: ContextMenu[][] = [[]]
id: 'share',
title: t(`share.${type}.action`), if (params.url) {
systemIcon: 'square.and.arrow.up' const url = params.url
}) menus[0].push({
Platform.OS !== 'android' && key: 'share',
type === 'status' && item: {
actions.push({ onSelect: () => {
id: 'copy', switch (Platform.OS) {
title: t(`copy.action`), case 'ios':
systemIcon: 'doc.on.doc', Share.share({ url })
disabled: !copiableContent?.current.content?.length break
case 'android':
Share.share({ message: url })
break
}
},
disabled: false,
destructive: false,
hidden: false
},
title: t(`share.${params.type}.action`),
icon: 'square.and.arrow.up'
})
}
if (params.type === 'status' && Platform.OS === 'ios')
menus[0].push({
key: 'copy',
item: {
onSelect: () => {
Clipboard.setString(params.copiableContent?.current.content || '')
displayMessage({
theme,
type: 'success',
message: t(`copy.succeed`)
})
},
disabled: false,
destructive: false,
hidden: !params.copiableContent?.current.content?.length
},
title: t('copy.action'),
icon: 'doc.on.doc'
}) })
return (index: number) => { return menus
if (typeof index !== 'number' || !actions[index]) {
return // For Android
}
if (actions[index].id === 'copy') {
analytics('timeline_shared_headeractions_copy_press')
Clipboard.setString(copiableContent?.current.content || '')
displayMessage({
theme,
type: 'success',
message: t(`copy.succeed`)
})
}
if (actions[index].id === 'share') {
analytics('timeline_shared_headeractions_share_press')
switch (Platform.OS) {
case 'ios':
Share.share({ url })
break
case 'android':
Share.share({ message: url })
break
}
}
}
} }
export default contextMenuShare export default menuShare

View File

@ -1,5 +1,4 @@
import apiInstance from '@api/instance' import apiInstance from '@api/instance'
import analytics from '@components/analytics'
import { displayMessage } from '@components/Message' import { displayMessage } from '@components/Message'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { NativeStackNavigationProp } from '@react-navigation/native-stack'
@ -13,18 +12,20 @@ import { checkInstanceFeature, getInstanceAccount } from '@utils/slices/instance
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Alert } from 'react-native' import { Alert } from 'react-native'
import { ContextMenuAction } from 'react-native-context-menu-view'
import { useQueryClient } from 'react-query' import { useQueryClient } from 'react-query'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
export interface Props { const menuStatus = ({
actions: ContextMenuAction[] status,
status: Mastodon.Status queryKey,
queryKey: QueryKeyTimeline rootQueryKey
}: {
status?: Mastodon.Status
queryKey?: QueryKeyTimeline
rootQueryKey?: QueryKeyTimeline rootQueryKey?: QueryKeyTimeline
} }): ContextMenu[][] => {
if (!status || !queryKey) return []
const contextMenuStatus = ({ actions, status, queryKey, rootQueryKey }: Props) => {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Screen-Tabs'>>() const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Screen-Tabs'>>()
const { theme } = useTheme() const { theme } = useTheme()
const { t } = useTranslation('componentContextMenu') const { t } = useTranslation('componentContextMenu')
@ -54,96 +55,19 @@ const contextMenuStatus = ({ actions, status, queryKey, rootQueryKey }: Props) =
} }
}) })
const menus: ContextMenu[][] = []
const instanceAccount = useSelector(getInstanceAccount, (prev, next) => prev.id === next.id) const instanceAccount = useSelector(getInstanceAccount, (prev, next) => prev.id === next.id)
const ownAccount = instanceAccount?.id === status?.account?.id const ownAccount = instanceAccount?.id === status.account?.id
const canEditPost = useSelector(checkInstanceFeature('edit_post'))
if (ownAccount) { if (ownAccount) {
const accountMenuItems: ContextMenuAction[] = [ menus.push([
{ {
id: 'status-delete', key: 'status-edit',
title: t('status.delete.action'), item: {
systemIcon: 'trash', onSelect: async () => {
destructive: true
},
{
id: 'status-delete-edit',
title: t('status.deleteEdit.action'),
systemIcon: 'pencil.and.outline',
destructive: true
},
{
id: 'status-mute',
title: t('status.mute.action', {
context: (status.muted || false).toString()
}),
systemIcon: status.muted ? 'speaker' : 'speaker.slash'
}
]
const canEditPost = useSelector(checkInstanceFeature('edit_post'))
if (canEditPost) {
accountMenuItems.unshift({
id: 'status-edit',
title: t('status.edit.action'),
systemIcon: 'square.and.pencil'
})
}
if (status.visibility === 'public' || status.visibility === 'unlisted') {
accountMenuItems.push({
id: 'status-pin',
title: t('status.pin.action', {
context: (status.pinned || false).toString()
}),
systemIcon: status.pinned ? 'pin.slash' : 'pin'
})
}
actions.push(...accountMenuItems)
}
return async (index: number) => {
if (typeof index !== 'number' || !actions[index]) {
return // For Android
}
if (actions[index].id === 'status-delete') {
analytics('timeline_shared_headeractions_status_delete_press', {
page: queryKey && queryKey[1].page
})
Alert.alert(t('status.delete.alert.title'), t('status.delete.alert.message'), [
{
text: t('status.delete.alert.buttons.confirm'),
style: 'destructive',
onPress: async () => {
analytics('timeline_shared_headeractions_status_delete_confirm', {
page: queryKey && queryKey[1].page
})
mutation.mutate({
type: 'deleteItem',
source: 'statuses',
queryKey,
rootQueryKey,
id: status.id
})
}
},
{
text: t('common:buttons.cancel')
}
])
}
if (actions[index].id === 'status-delete-edit') {
analytics('timeline_shared_headeractions_status_deleteedit_press', {
page: queryKey && queryKey[1].page
})
Alert.alert(t('status.deleteEdit.alert.title'), t('status.deleteEdit.alert.message'), [
{
text: t('status.deleteEdit.alert.buttons.confirm'),
style: 'destructive',
onPress: async () => {
analytics('timeline_shared_headeractions_status_deleteedit_confirm', {
page: queryKey && queryKey[1].page
})
let replyToStatus: Mastodon.Status | undefined = undefined let replyToStatus: Mastodon.Status | undefined = undefined
if (status.in_reply_to_id) { if (status.in_reply_to_id) {
replyToStatus = await apiInstance<Mastodon.Status>({ replyToStatus = await apiInstance<Mastodon.Status>({
@ -151,96 +75,166 @@ const contextMenuStatus = ({ actions, status, queryKey, rootQueryKey }: Props) =
url: `statuses/${status.in_reply_to_id}` url: `statuses/${status.in_reply_to_id}`
}).then(res => res.body) }).then(res => res.body)
} }
mutation apiInstance<{
.mutateAsync({ id: Mastodon.Status['id']
type: 'deleteItem', text: NonNullable<Mastodon.Status['text']>
source: 'statuses', spoiler_text: Mastodon.Status['spoiler_text']
}>({
method: 'get',
url: `statuses/${status.id}/source`
}).then(res => {
navigation.navigate('Screen-Compose', {
type: 'edit',
incomingStatus: {
...status,
text: res.body.text,
spoiler_text: res.body.spoiler_text
},
...(replyToStatus && { replyToStatus }),
queryKey, queryKey,
id: status.id rootQueryKey
}) })
.then(res => { })
navigation.navigate('Screen-Compose', {
type: 'deleteEdit',
incomingStatus: res.body as Mastodon.Status,
...(replyToStatus && { replyToStatus }),
queryKey
})
})
}
},
{
text: t('common:buttons.cancel')
}
])
}
if (actions[index].id === 'status-mute') {
analytics('timeline_shared_headeractions_status_mute_press', {
page: queryKey && queryKey[1].page
})
mutation.mutate({
type: 'updateStatusProperty',
queryKey,
rootQueryKey,
id: status.id,
payload: {
property: 'muted',
currentValue: status.muted,
propertyCount: undefined,
countValue: undefined
}
})
}
if (actions[index].id === 'status-edit') {
analytics('timeline_shared_headeractions_status_edit_press', {
page: queryKey && queryKey[1].page
})
let replyToStatus: Mastodon.Status | undefined = undefined
if (status.in_reply_to_id) {
replyToStatus = await apiInstance<Mastodon.Status>({
method: 'get',
url: `statuses/${status.in_reply_to_id}`
}).then(res => res.body)
}
apiInstance<{
id: Mastodon.Status['id']
text: NonNullable<Mastodon.Status['text']>
spoiler_text: Mastodon.Status['spoiler_text']
}>({
method: 'get',
url: `statuses/${status.id}/source`
}).then(res => {
navigation.navigate('Screen-Compose', {
type: 'edit',
incomingStatus: {
...status,
text: res.body.text,
spoiler_text: res.body.spoiler_text
}, },
...(replyToStatus && { replyToStatus }), disabled: false,
queryKey, destructive: false,
rootQueryKey hidden: !canEditPost
}) },
}) title: t('status.edit.action'),
} icon: 'square.and.pencil'
if (actions[index].id === 'status-pin') { },
// Also note that reblogs cannot be pinned. {
analytics('timeline_shared_headeractions_status_pin_press', { key: 'status-delete-edit',
page: queryKey && queryKey[1].page item: {
}) onSelect: () =>
mutation.mutate({ Alert.alert(t('status.deleteEdit.alert.title'), t('status.deleteEdit.alert.message'), [
type: 'updateStatusProperty', {
queryKey, text: t('status.deleteEdit.alert.buttons.confirm'),
rootQueryKey, style: 'destructive',
id: status.id, onPress: async () => {
payload: { let replyToStatus: Mastodon.Status | undefined = undefined
property: 'pinned', if (status.in_reply_to_id) {
currentValue: status.pinned, replyToStatus = await apiInstance<Mastodon.Status>({
propertyCount: undefined, method: 'get',
countValue: undefined url: `statuses/${status.in_reply_to_id}`
} }).then(res => res.body)
}) }
} mutation
.mutateAsync({
type: 'deleteItem',
source: 'statuses',
queryKey,
id: status.id
})
.then(res => {
navigation.navigate('Screen-Compose', {
type: 'deleteEdit',
incomingStatus: res.body as Mastodon.Status,
...(replyToStatus && { replyToStatus }),
queryKey
})
})
}
},
{
text: t('common:buttons.cancel')
}
]),
disabled: false,
destructive: true,
hidden: false
},
title: t('status.deleteEdit.action'),
icon: 'pencil.and.outline'
},
{
key: 'status-delete',
item: {
onSelect: () =>
Alert.alert(t('status.delete.alert.title'), t('status.delete.alert.message'), [
{
text: t('status.delete.alert.buttons.confirm'),
style: 'destructive',
onPress: async () => {
mutation.mutate({
type: 'deleteItem',
source: 'statuses',
queryKey,
rootQueryKey,
id: status.id
})
}
},
{
text: t('common:buttons.cancel'),
style: 'default'
}
]),
disabled: false,
destructive: true,
hidden: false
},
title: t('status.delete.action'),
icon: 'trash'
}
])
menus.push([
{
key: 'status-mute',
item: {
onSelect: () =>
mutation.mutate({
type: 'updateStatusProperty',
queryKey,
rootQueryKey,
id: status.id,
payload: {
property: 'muted',
currentValue: status.muted,
propertyCount: undefined,
countValue: undefined
}
}),
disabled: false,
destructive: false,
hidden: false
},
title: t('status.mute.action', {
context: (status.muted || false).toString()
}),
icon: status.muted ? 'speaker' : 'speaker.slash'
},
{
key: 'status-pin',
item: {
onSelect: () =>
// Also note that reblogs cannot be pinned.
mutation.mutate({
type: 'updateStatusProperty',
queryKey,
rootQueryKey,
id: status.id,
payload: {
property: 'pinned',
currentValue: status.pinned,
propertyCount: undefined,
countValue: undefined
}
}),
disabled: false,
destructive: false,
hidden: status.visibility !== 'public' && status.visibility !== 'unlisted'
},
title: t('status.pin.action', {
context: (status.pinned || false).toString()
}),
icon: status.pinned ? 'pin.slash' : 'pin'
}
])
} }
return menus
} }
export default contextMenuStatus export default menuStatus

View File

@ -3,40 +3,60 @@ import { StackNavigationProp } from '@react-navigation/stack'
import { TabLocalStackParamList } from '@utils/navigation/navigators' import { TabLocalStackParamList } from '@utils/navigation/navigators'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback } from 'react' import React, { useCallback, useState } from 'react'
import { Pressable } from 'react-native' import { Dimensions, Pressable } from 'react-native'
import analytics from './analytics' import Sparkline from './Sparkline'
import CustomText from './Text' import CustomText from './Text'
export interface Props { export interface Props {
hashtag: Mastodon.Tag hashtag: Mastodon.Tag
onPress?: () => void onPress?: () => void
origin?: string
} }
const ComponentHashtag: React.FC<Props> = ({ const ComponentHashtag: React.FC<Props> = ({ hashtag, onPress: customOnPress }) => {
hashtag,
onPress: customOnPress,
origin
}) => {
const { colors } = useTheme() const { colors } = useTheme()
const navigation = const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>()
useNavigation<StackNavigationProp<TabLocalStackParamList>>()
const onPress = useCallback(() => { const onPress = useCallback(() => {
analytics('search_account_press', { page: origin })
navigation.push('Tab-Shared-Hashtag', { hashtag: hashtag.name }) navigation.push('Tab-Shared-Hashtag', { hashtag: hashtag.name })
}, []) }, [])
const padding = StyleConstants.Spacing.Global.PagePadding
const width = Dimensions.get('window').width / 4
const [height, setHeight] = useState<number>(0)
return ( return (
<Pressable <Pressable
accessibilityRole='button' accessibilityRole='button'
style={{ padding: StyleConstants.Spacing.S * 1.5 }} style={{
flex: 1,
flexDirection: 'row',
justifyContent: 'space-between',
padding
}}
onPress={customOnPress || onPress} onPress={customOnPress || onPress}
onLayout={({
nativeEvent: {
layout: { height }
}
}) => setHeight(height - padding * 2 - 1)}
> >
<CustomText fontStyle='M' style={{ color: colors.primaryDefault }}> <CustomText
fontStyle='M'
style={{
flexShrink: 1,
color: colors.primaryDefault,
paddingRight: StyleConstants.Spacing.M
}}
numberOfLines={1}
>
#{hashtag.name} #{hashtag.name}
</CustomText> </CustomText>
<Sparkline
data={hashtag.history.map(h => parseInt(h.uses)).reverse()}
width={width}
height={height}
/>
</Pressable> </Pressable>
) )
} }

View File

@ -1,5 +1,6 @@
import Button from '@components/Button' import Button from '@components/Button'
import Icon from '@components/Icon' import Icon from '@components/Icon'
import browserPackage from '@helpers/browserPackage'
import { useAppsQuery } from '@utils/queryHooks/apps' import { useAppsQuery } from '@utils/queryHooks/apps'
import { useInstanceQuery } from '@utils/queryHooks/instance' import { useInstanceQuery } from '@utils/queryHooks/instance'
import { getInstances } from '@utils/slices/instancesSlice' import { getInstances } from '@utils/slices/instancesSlice'
@ -9,18 +10,10 @@ import * as WebBrowser from 'expo-web-browser'
import { debounce } from 'lodash' import { debounce } from 'lodash'
import React, { RefObject, useCallback, useMemo, useState } from 'react' import React, { RefObject, useCallback, useMemo, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { import { Alert, Image, KeyboardAvoidingView, Platform, TextInput, View } from 'react-native'
Alert,
Image,
KeyboardAvoidingView,
Platform,
TextInput,
View
} from 'react-native'
import { ScrollView } from 'react-native-gesture-handler' import { ScrollView } from 'react-native-gesture-handler'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { Placeholder } from 'rn-placeholder' import { Placeholder } from 'rn-placeholder'
import analytics from './analytics'
import InstanceAuth from './Instance/Auth' import InstanceAuth from './Instance/Auth'
import InstanceInfo from './Instance/Info' import InstanceInfo from './Instance/Info'
import CustomText from './Text' import CustomText from './Text'
@ -65,18 +58,14 @@ const ComponentInstance: React.FC<Props> = ({
const processUpdate = useCallback(() => { const processUpdate = useCallback(() => {
if (domain) { if (domain) {
analytics('instance_login') if (instances && instances.filter(instance => instance.url === domain).length) {
if (
instances &&
instances.filter(instance => instance.url === domain).length
) {
Alert.alert(t('update.alert.title'), t('update.alert.message'), [ Alert.alert(t('update.alert.title'), t('update.alert.message'), [
{ {
text: t('update.alert.buttons.cancel'), text: t('common:buttons.cancel'),
style: 'cancel' style: 'cancel'
}, },
{ {
text: t('update.alert.buttons.continue'), text: t('common:buttons.continue'),
onPress: () => { onPress: () => {
appsQuery.refetch() appsQuery.refetch()
} }
@ -142,9 +131,7 @@ const ComponentInstance: React.FC<Props> = ({
borderBottomWidth: 1, borderBottomWidth: 1,
...StyleConstants.FontStyle.M, ...StyleConstants.FontStyle.M,
color: colors.primaryDefault, color: colors.primaryDefault,
borderBottomColor: instanceQuery.isError borderBottomColor: instanceQuery.isError ? colors.red : colors.border
? colors.red
: colors.border
}} }}
editable={false} editable={false}
defaultValue='https://' defaultValue='https://'
@ -156,9 +143,7 @@ const ComponentInstance: React.FC<Props> = ({
...StyleConstants.FontStyle.M, ...StyleConstants.FontStyle.M,
marginRight: StyleConstants.Spacing.M, marginRight: StyleConstants.Spacing.M,
color: colors.primaryDefault, color: colors.primaryDefault,
borderBottomColor: instanceQuery.isError borderBottomColor: instanceQuery.isError ? colors.red : colors.border
? colors.red
: colors.border
}} }}
onChangeText={onChangeText} onChangeText={onChangeText}
autoCapitalize='none' autoCapitalize='none'
@ -166,7 +151,6 @@ const ComponentInstance: React.FC<Props> = ({
keyboardType='url' keyboardType='url'
textContentType='URL' textContentType='URL'
onSubmitEditing={({ nativeEvent: { text } }) => { onSubmitEditing={({ nativeEvent: { text } }) => {
analytics('instance_textinput_submit', { match: text === domain })
if ( if (
text === domain && text === domain &&
instanceQuery.isSuccess && instanceQuery.isSuccess &&
@ -182,11 +166,7 @@ const ComponentInstance: React.FC<Props> = ({
keyboardAppearance={mode} keyboardAppearance={mode}
{...(scrollViewRef && { {...(scrollViewRef && {
onFocus: () => onFocus: () =>
setTimeout( setTimeout(() => scrollViewRef.current?.scrollTo({ y: 0, animated: true }), 150)
() =>
scrollViewRef.current?.scrollTo({ y: 0, animated: true }),
150
)
})} })}
autoCorrect={false} autoCorrect={false}
spellCheck={false} spellCheck={false}
@ -211,27 +191,19 @@ const ComponentInstance: React.FC<Props> = ({
<InstanceInfo <InstanceInfo
style={{ alignItems: 'flex-start' }} style={{ alignItems: 'flex-start' }}
header={t('server.information.accounts')} header={t('server.information.accounts')}
content={ content={instanceQuery.data?.stats?.user_count?.toString() || undefined}
instanceQuery.data?.stats?.user_count?.toString() || undefined
}
potentialWidth={4} potentialWidth={4}
/> />
<InstanceInfo <InstanceInfo
style={{ alignItems: 'center' }} style={{ alignItems: 'center' }}
header={t('server.information.statuses')} header={t('server.information.statuses')}
content={ content={instanceQuery.data?.stats?.status_count?.toString() || undefined}
instanceQuery.data?.stats?.status_count?.toString() ||
undefined
}
potentialWidth={4} potentialWidth={4}
/> />
<InstanceInfo <InstanceInfo
style={{ alignItems: 'flex-end' }} style={{ alignItems: 'flex-end' }}
header={t('server.information.domains')} header={t('server.information.domains')}
content={ content={instanceQuery.data?.stats?.domain_count?.toString() || undefined}
instanceQuery.data?.stats?.domain_count?.toString() ||
undefined
}
potentialWidth={4} potentialWidth={4}
/> />
</View> </View>
@ -248,17 +220,11 @@ const ComponentInstance: React.FC<Props> = ({
size={StyleConstants.Font.Size.S} size={StyleConstants.Font.Size.S}
color={colors.secondary} color={colors.secondary}
style={{ style={{
marginTop: marginTop: (StyleConstants.Font.LineHeight.S - StyleConstants.Font.Size.S) / 2,
(StyleConstants.Font.LineHeight.S -
StyleConstants.Font.Size.S) /
2,
marginRight: StyleConstants.Spacing.XS marginRight: StyleConstants.Spacing.XS
}} }}
/> />
<CustomText <CustomText fontStyle='S' style={{ flex: 1, color: colors.secondary }}>
fontStyle='S'
style={{ flex: 1, color: colors.secondary }}
>
{t('server.disclaimer.base')} {t('server.disclaimer.base')}
</CustomText> </CustomText>
</View> </View>
@ -274,10 +240,7 @@ const ComponentInstance: React.FC<Props> = ({
size={StyleConstants.Font.Size.S} size={StyleConstants.Font.Size.S}
color={colors.secondary} color={colors.secondary}
style={{ style={{
marginTop: marginTop: (StyleConstants.Font.LineHeight.S - StyleConstants.Font.Size.S) / 2,
(StyleConstants.Font.LineHeight.S -
StyleConstants.Font.Size.S) /
2,
marginRight: StyleConstants.Spacing.XS marginRight: StyleConstants.Spacing.XS
}} }}
/> />
@ -292,22 +255,20 @@ const ComponentInstance: React.FC<Props> = ({
<CustomText <CustomText
accessible accessible
style={{ color: colors.blue }} style={{ color: colors.blue }}
onPress={() => { onPress={async () =>
analytics('view_privacy') WebBrowser.openBrowserAsync('https://tooot.app/privacy-policy', {
WebBrowser.openBrowserAsync( browserPackage: await browserPackage()
'https://tooot.app/privacy-policy' })
) }
}}
/>, />,
<CustomText <CustomText
accessible accessible
style={{ color: colors.blue }} style={{ color: colors.blue }}
onPress={() => { onPress={async () =>
analytics('view_tos') WebBrowser.openBrowserAsync('https://tooot.app/terms-of-service', {
WebBrowser.openBrowserAsync( browserPackage: await browserPackage()
'https://tooot.app/terms-of-service' })
) }
}}
/> />
]} ]}
/> />

View File

@ -1,3 +1,4 @@
import browserPackage from '@helpers/browserPackage'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import { useAppDispatch } from '@root/store' import { useAppDispatch } from '@root/store'
import { InstanceLatest } from '@utils/migrations/instances/migration' import { InstanceLatest } from '@utils/migrations/instances/migration'
@ -24,14 +25,11 @@ const InstanceAuth = React.memo(
useProxy: false useProxy: false
}) })
const navigation = const navigation = useNavigation<TabMeStackNavigationProp<'Tab-Me-Root' | 'Tab-Me-Switch'>>()
useNavigation<TabMeStackNavigationProp<'Tab-Me-Root' | 'Tab-Me-Switch'>>()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const deprecateAuthFollow = useSelector( const deprecateAuthFollow = useSelector(checkInstanceFeature('deprecate_auth_follow'))
checkInstanceFeature('deprecate_auth_follow')
)
const [request, response, promptAsync] = AuthSession.useAuthRequest( const [request, response, promptAsync] = AuthSession.useAuthRequest(
{ {
clientId: appData.clientId, clientId: appData.clientId,
@ -48,7 +46,7 @@ const InstanceAuth = React.memo(
useEffect(() => { useEffect(() => {
;(async () => { ;(async () => {
if (request?.clientId) { if (request?.clientId) {
await promptAsync() await promptAsync({ browserPackage: await browserPackage() }).catch(e => console.log(e))
} }
})() })()
}, [request]) }, [request])

View File

@ -1,5 +1,5 @@
import React from 'react' import React from 'react'
import { StyleSheet, View } from 'react-native' import { View } from 'react-native'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
export interface Props { export interface Props {
@ -7,14 +7,16 @@ export interface Props {
} }
const MenuContainer: React.FC<Props> = ({ children }) => { const MenuContainer: React.FC<Props> = ({ children }) => {
return <View style={styles.base}>{children}</View> return (
<View
style={{
paddingHorizontal: StyleConstants.Spacing.Global.PagePadding,
marginBottom: StyleConstants.Spacing.Global.PagePadding
}}
>
{children}
</View>
)
} }
const styles = StyleSheet.create({
base: {
paddingHorizontal: StyleConstants.Spacing.Global.PagePadding,
marginBottom: StyleConstants.Spacing.Global.PagePadding
}
})
export default MenuContainer export default MenuContainer

View File

@ -5,7 +5,7 @@ import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import { ColorDefinitions } from '@utils/styles/themes' import { ColorDefinitions } from '@utils/styles/themes'
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { View } from 'react-native' import { Text, View } from 'react-native'
import { Flow } from 'react-native-animated-spinkit' import { Flow } from 'react-native-animated-spinkit'
import { State, Switch, TapGestureHandler } from 'react-native-gesture-handler' import { State, Switch, TapGestureHandler } from 'react-native-gesture-handler'
@ -65,6 +65,7 @@ const MenuRow: React.FC<Props> = ({
> >
<TapGestureHandler <TapGestureHandler
onHandlerStateChange={async ({ nativeEvent }) => { onHandlerStateChange={async ({ nativeEvent }) => {
if (typeof iconBack !== 'string') return // Let icon back handles the gesture
if (nativeEvent.state === State.ACTIVE && !loading) { if (nativeEvent.state === State.ACTIVE && !loading) {
if (screenReaderEnabled && switchOnValueChange) { if (screenReaderEnabled && switchOnValueChange) {
switchOnValueChange() switchOnValueChange()
@ -79,12 +80,13 @@ const MenuRow: React.FC<Props> = ({
style={{ style={{
flex: 1, flex: 1,
flexDirection: 'row', flexDirection: 'row',
paddingTop: StyleConstants.Spacing.S justifyContent: 'space-between',
marginTop: StyleConstants.Spacing.S
}} }}
> >
<View <View
style={{ style={{
flexGrow: 3, flex: 3,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center' alignItems: 'center'
}} }}

View File

@ -125,7 +125,7 @@ const Message = React.forwardRef<FlashMessage>((_, ref) => {
shadowOpacity: theme === 'light' ? 0.16 : 0.24, shadowOpacity: theme === 'light' ? 0.16 : 0.24,
shadowRadius: 4, shadowRadius: 4,
paddingRight: StyleConstants.Spacing.M * 2, paddingRight: StyleConstants.Spacing.M * 2,
marginTop: insets.top marginTop: ref ? undefined : insets.top
}} }}
titleStyle={{ titleStyle={{
color: colors.primaryDefault, color: colors.primaryDefault,

View File

@ -1,4 +1,3 @@
import analytics from '@components/analytics'
import Icon from '@components/Icon' import Icon from '@components/Icon'
import openLink from '@components/openLink' import openLink from '@components/openLink'
import ParseEmojis from '@components/Parse/Emojis' import ParseEmojis from '@components/Parse/Emojis'
@ -63,7 +62,6 @@ const renderNode = ({
lineHeight: adaptedLineheight lineHeight: adaptedLineheight
}} }}
onPress={() => { onPress={() => {
analytics('status_hashtag_press')
!disableDetails && !disableDetails &&
differentTag && differentTag &&
navigation.push('Tab-Shared-Hashtag', { navigation.push('Tab-Shared-Hashtag', {
@ -89,7 +87,6 @@ const renderNode = ({
lineHeight: adaptedLineheight lineHeight: adaptedLineheight
}} }}
onPress={() => { onPress={() => {
analytics('status_mention_press')
accountIndex !== -1 && accountIndex !== -1 &&
!disableDetails && !disableDetails &&
differentAccount && differentAccount &&
@ -118,7 +115,6 @@ const renderNode = ({
lineHeight: adaptedLineheight lineHeight: adaptedLineheight
}} }}
onPress={async () => { onPress={async () => {
analytics('status_link_press')
if (!disableDetails) { if (!disableDetails) {
if (shouldBeTag) { if (shouldBeTag) {
navigation.push('Tab-Shared-Hashtag', { navigation.push('Tab-Shared-Hashtag', {
@ -172,6 +168,7 @@ export interface Props {
highlighted?: boolean highlighted?: boolean
disableDetails?: boolean disableDetails?: boolean
selectable?: boolean selectable?: boolean
setSpoilerExpanded?: React.Dispatch<React.SetStateAction<boolean>>
} }
const ParseHTML = React.memo( const ParseHTML = React.memo(
@ -187,7 +184,8 @@ const ParseHTML = React.memo(
expandHint, expandHint,
highlighted = false, highlighted = false,
disableDetails = false, disableDetails = false,
selectable = false selectable = false,
setSpoilerExpanded
}: Props) => { }: Props) => {
const adaptiveFontsize = useSelector(getSettingsFontsize) const adaptiveFontsize = useSelector(getSettingsFontsize)
const adaptedFontsize = adaptiveScale( const adaptedFontsize = adaptiveScale(
@ -255,9 +253,11 @@ const ParseHTML = React.memo(
<Pressable <Pressable
accessibilityLabel={t('HTML.accessibilityHint')} accessibilityLabel={t('HTML.accessibilityHint')}
onPress={() => { onPress={() => {
analytics('status_readmore', { totalLines, expanded })
layoutAnimation() layoutAnimation()
setExpanded(!expanded) setExpanded(!expanded)
if (setSpoilerExpanded) {
setSpoilerExpanded(!expanded)
}
}} }}
style={{ style={{
flexDirection: 'row', flexDirection: 'row',

View File

@ -1,11 +1,7 @@
import analytics from '@components/analytics'
import Button from '@components/Button' import Button from '@components/Button'
import haptics from '@components/haptics' import haptics from '@components/haptics'
import { displayMessage } from '@components/Message' import { displayMessage } from '@components/Message'
import { import { QueryKeyRelationship, useRelationshipMutation } from '@utils/queryHooks/relationship'
QueryKeyRelationship,
useRelationshipMutation
} from '@utils/queryHooks/relationship'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline' import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
@ -23,17 +19,12 @@ const RelationshipIncoming: React.FC<Props> = ({ id }) => {
const { t } = useTranslation() const { t } = useTranslation()
const queryKeyRelationship: QueryKeyRelationship = ['Relationship', { id }] const queryKeyRelationship: QueryKeyRelationship = ['Relationship', { id }]
const queryKeyNotification: QueryKeyTimeline = [ const queryKeyNotification: QueryKeyTimeline = ['Timeline', { page: 'Notifications' }]
'Timeline',
{ page: 'Notifications' }
]
const queryClient = useQueryClient() const queryClient = useQueryClient()
const mutation = useRelationshipMutation({ const mutation = useRelationshipMutation({
onSuccess: res => { onSuccess: res => {
haptics('Success') haptics('Success')
queryClient.setQueryData<Mastodon.Relationship[]>(queryKeyRelationship, [ queryClient.setQueryData<Mastodon.Relationship[]>(queryKeyRelationship, [res])
res
])
queryClient.refetchQueries(queryKeyNotification) queryClient.refetchQueries(queryKeyNotification)
}, },
onError: (err: any, { type }) => { onError: (err: any, { type }) => {
@ -62,28 +53,26 @@ const RelationshipIncoming: React.FC<Props> = ({ id }) => {
type='icon' type='icon'
content='X' content='X'
loading={mutation.isLoading} loading={mutation.isLoading}
onPress={() => { onPress={() =>
analytics('relationship_incoming_press_reject')
mutation.mutate({ mutation.mutate({
id, id,
type: 'incoming', type: 'incoming',
payload: { action: 'reject' } payload: { action: 'reject' }
}) })
}} }
/> />
<Button <Button
round round
type='icon' type='icon'
content='Check' content='Check'
loading={mutation.isLoading} loading={mutation.isLoading}
onPress={() => { onPress={() =>
analytics('relationship_incoming_press_authorize')
mutation.mutate({ mutation.mutate({
id, id,
type: 'incoming', type: 'incoming',
payload: { action: 'authorize' } payload: { action: 'authorize' }
}) })
}} }
style={styles.approve} style={styles.approve}
/> />
</View> </View>

View File

@ -1,4 +1,3 @@
import analytics from '@components/analytics'
import Button from '@components/Button' import Button from '@components/Button'
import haptics from '@components/haptics' import haptics from '@components/haptics'
import { displayMessage } from '@components/Message' import { displayMessage } from '@components/Message'
@ -29,10 +28,7 @@ const RelationshipOutgoing = React.memo(
const mutation = useRelationshipMutation({ const mutation = useRelationshipMutation({
onSuccess: (res, { payload: { action } }) => { onSuccess: (res, { payload: { action } }) => {
haptics('Success') haptics('Success')
queryClient.setQueryData<Mastodon.Relationship[]>( queryClient.setQueryData<Mastodon.Relationship[]>(queryKeyRelationship, [res])
queryKeyRelationship,
[res]
)
if (action === 'block') { if (action === 'block') {
const queryKey: QueryKeyTimeline = ['Timeline', { page: 'Following' }] const queryKey: QueryKeyTimeline = ['Timeline', { page: 'Following' }]
queryClient.invalidateQueries(queryKey) queryClient.invalidateQueries(queryKey)
@ -64,17 +60,12 @@ const RelationshipOutgoing = React.memo(
onPress = () => {} onPress = () => {}
} else { } else {
if (query.data?.blocked_by) { if (query.data?.blocked_by) {
analytics('relationship_outgoing_blocked_by')
content = t('button.blocked_by') content = t('button.blocked_by')
onPress = () => { onPress = () => {}
analytics('relationship_outgoing_blocked_by_press')
}
} else { } else {
if (query.data?.blocking) { if (query.data?.blocking) {
analytics('relationship_outgoing_blocking')
content = t('button.blocking') content = t('button.blocking')
onPress = () => { onPress = () => {
analytics('relationship_outgoing_blocking_press')
mutation.mutate({ mutation.mutate({
id, id,
type: 'outgoing', type: 'outgoing',
@ -86,10 +77,8 @@ const RelationshipOutgoing = React.memo(
} }
} else { } else {
if (query.data?.following) { if (query.data?.following) {
analytics('relationship_outgoing_following')
content = t('button.following') content = t('button.following')
onPress = () => { onPress = () => {
analytics('relationship_outgoing_following_press')
mutation.mutate({ mutation.mutate({
id, id,
type: 'outgoing', type: 'outgoing',
@ -101,10 +90,8 @@ const RelationshipOutgoing = React.memo(
} }
} else { } else {
if (query.data?.requested) { if (query.data?.requested) {
analytics('relationship_outgoing_requested')
content = t('button.requested') content = t('button.requested')
onPress = () => { onPress = () => {
analytics('relationship_outgoing_requested_press')
mutation.mutate({ mutation.mutate({
id, id,
type: 'outgoing', type: 'outgoing',
@ -115,10 +102,8 @@ const RelationshipOutgoing = React.memo(
}) })
} }
} else { } else {
analytics('relationship_outgoing_default')
content = t('button.default') content = t('button.default')
onPress = () => { onPress = () => {
analytics('relationship_outgoing_default_press')
mutation.mutate({ mutation.mutate({
id, id,
type: 'outgoing', type: 'outgoing',

View File

@ -9,7 +9,7 @@ export interface Props {
const RelativeTime: React.FC<Props> = ({ time }) => { const RelativeTime: React.FC<Props> = ({ time }) => {
const [now, setNow] = useState(new Date().getTime()) const [now, setNow] = useState(new Date().getTime())
useEffect(() => { useEffect(() => {
const appStateListener = AppState.addEventListener('change', state => { const appStateListener = AppState.addEventListener('change', () => {
setNow(new Date().getTime()) setNow(new Date().getTime())
}) })

View File

@ -0,0 +1,71 @@
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react'
import { Pressable, View } from 'react-native'
import haptics from './haptics'
import Icon from './Icon'
import { ParseEmojis } from './Parse'
import CustomText from './Text'
export interface Props {
multiple?: boolean
options: { selected: boolean; content: string }[]
setOptions: React.Dispatch<React.SetStateAction<{ selected: boolean; content: string }[]>>
}
const Selections: React.FC<Props> = ({ multiple = false, options, setOptions }) => {
const { colors } = useTheme()
const isSelected = (index: number): string =>
options[index].selected
? `Check${multiple ? 'Square' : 'Circle'}`
: `${multiple ? 'Square' : 'Circle'}`
return (
<>
{options.map((option, index) => (
<Pressable
key={index}
style={{ flex: 1, paddingVertical: StyleConstants.Spacing.S }}
onPress={() => {
if (multiple) {
haptics('Light')
setOptions(options.map((o, i) => (i === index ? { ...o, selected: !o.selected } : o)))
} else {
if (!option.selected) {
haptics('Light')
setOptions(
options.map((o, i) => {
if (i === index) {
return { ...o, selected: true }
} else {
return { ...o, selected: false }
}
})
)
}
}
}}
>
<View style={{ flex: 1, flexDirection: 'row' }}>
<Icon
style={{
paddingTop: StyleConstants.Font.LineHeight.M - StyleConstants.Font.Size.M,
marginRight: StyleConstants.Spacing.S
}}
name={isSelected(index)}
size={StyleConstants.Font.Size.M}
color={colors.primaryDefault}
/>
<CustomText style={{ flex: 1 }}>
<ParseEmojis content={option.content} />
</CustomText>
</View>
</Pressable>
))}
</>
)
}
export default Selections

View File

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

View File

@ -0,0 +1,87 @@
import { useTheme } from '@utils/styles/ThemeManager'
import { maxBy, minBy } from 'lodash'
import React from 'react'
import { Platform } from 'react-native'
import Svg, { G, Path } from 'react-native-svg'
export interface Props {
data: number[]
width: number
height: number
margin?: number
}
const Sparkline: React.FC<Props> = ({ data, width, height, margin = 0 }) => {
const { colors } = useTheme()
const dataToPoints = ({
data,
width,
height
}: {
data: number[]
width: number
height: number
}): { x: number; y: number }[] => {
const max = maxBy(data) || 0
const min = minBy(data) || 0
const len = data.length
const vfactor = (height - margin * 2) / (max - min || 2)
const hfactor = (width - margin * 2) / (len - (len > 1 ? 1 : 0))
return data.map((d, i) => ({
x: i * hfactor + margin,
y: (max === min ? 1 : max - d) * vfactor + margin
}))
}
const points = dataToPoints({ data, width, height })
const divisor = 0.25
let prev: { x: number; y: number }
const curve = (p: { x: number; y: number }) => {
let res
if (!prev) {
res = [p.x, p.y]
} else {
const len = (p.x - prev.x) * divisor
res = [
'C',
prev.x + len, // x1
prev.y, // y1
p.x - len, // x2
p.y, // y2
p.x, // x
p.y // y
]
}
prev = p
return res
}
const linePoints = points.map(p => curve(p)).reduce((a, b) => a.concat(b))
const closePolyPoints = [
'L' + points[points.length - 1].x,
height - margin,
margin,
height - margin,
margin,
points[0].y
]
const fillPoints = linePoints.concat(closePolyPoints)
return (
<Svg height={Platform.OS !== 'android' ? 'auto' : 24} width={width}>
<G>
<Path d={'M' + fillPoints.join(' ')} fill={colors.blue} fillOpacity={0.1} />
<Path
d={'M' + linePoints.join(' ')}
stroke={colors.blue}
strokeWidth={1}
strokeLinejoin='round'
strokeLinecap='round'
/>
</G>
</Svg>
)
}
export default Sparkline

View File

@ -1,95 +1,55 @@
import apiInstance from '@api/instance' import apiInstance from '@api/instance'
import analytics from '@components/analytics'
import GracefullyImage from '@components/GracefullyImage' import GracefullyImage from '@components/GracefullyImage'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import { StackNavigationProp } from '@react-navigation/stack' import { StackNavigationProp } from '@react-navigation/stack'
import { TabLocalStackParamList } from '@utils/navigation/navigators' import { TabLocalStackParamList } from '@utils/navigation/navigators'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline' import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { getInstanceAccount } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import { isEqual } from 'lodash'
import React, { useCallback } from 'react' import React, { useCallback } from 'react'
import { Pressable, View } from 'react-native' import { Pressable, View } from 'react-native'
import { useMutation, useQueryClient } from 'react-query' import { useMutation, useQueryClient } from 'react-query'
import { useSelector } from 'react-redux'
import TimelineActions from './Shared/Actions' import TimelineActions from './Shared/Actions'
import TimelineContent from './Shared/Content' import TimelineContent from './Shared/Content'
import StatusContext from './Shared/Context'
import TimelineHeaderConversation from './Shared/HeaderConversation' import TimelineHeaderConversation from './Shared/HeaderConversation'
import TimelinePoll from './Shared/Poll' import TimelinePoll from './Shared/Poll'
const Avatars: React.FC<{ accounts: Mastodon.Account[] }> = ({ accounts }) => {
return (
<View
style={{
borderRadius: 4,
overflow: 'hidden',
marginRight: StyleConstants.Spacing.S,
width: StyleConstants.Avatar.M,
height: StyleConstants.Avatar.M,
flexDirection: 'row',
flexWrap: 'wrap'
}}
>
{accounts.slice(0, 4).map(account => (
<GracefullyImage
key={account.id}
uri={{ original: account.avatar, static: account.avatar_static }}
dimension={{
width: StyleConstants.Avatar.M,
height:
accounts.length > 2
? StyleConstants.Avatar.M / 2
: StyleConstants.Avatar.M
}}
style={{ flex: 1, flexBasis: '50%' }}
/>
))}
</View>
)
}
export interface Props { export interface Props {
conversation: Mastodon.Conversation conversation: Mastodon.Conversation
queryKey: QueryKeyTimeline queryKey: QueryKeyTimeline
highlighted?: boolean highlighted?: boolean
} }
const TimelineConversation = React.memo( const TimelineConversation: React.FC<Props> = ({ conversation, queryKey, highlighted = false }) => {
({ conversation, queryKey, highlighted = false }: Props) => { const { colors } = useTheme()
const instanceAccount = useSelector(
getInstanceAccount,
(prev, next) => prev?.id === next?.id
)
const { colors } = useTheme()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const fireMutation = useCallback(() => { const fireMutation = useCallback(() => {
return apiInstance<Mastodon.Conversation>({ return apiInstance<Mastodon.Conversation>({
method: 'post', method: 'post',
url: `conversations/${conversation.id}/read` url: `conversations/${conversation.id}/read`
})
}, [])
const { mutate } = useMutation(fireMutation, {
onSettled: () => {
queryClient.invalidateQueries(queryKey)
}
}) })
}, [])
const { mutate } = useMutation(fireMutation, {
onSettled: () => {
queryClient.invalidateQueries(queryKey)
}
})
const navigation = const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>()
useNavigation<StackNavigationProp<TabLocalStackParamList>>() const onPress = useCallback(() => {
const onPress = useCallback(() => { if (conversation.last_status) {
analytics('timeline_conversation_press') conversation.unread && mutate()
if (conversation.last_status) { navigation.push('Tab-Shared-Toot', {
conversation.unread && mutate() toot: conversation.last_status,
navigation.push('Tab-Shared-Toot', { rootQueryKey: queryKey
toot: conversation.last_status, })
rootQueryKey: queryKey }
}) }, [])
}
}, [])
return ( return (
<StatusContext.Provider value={{ queryKey, status: conversation.last_status }}>
<Pressable <Pressable
style={[ style={[
{ {
@ -102,19 +62,39 @@ const TimelineConversation = React.memo(
conversation.unread && { conversation.unread && {
borderLeftWidth: StyleConstants.Spacing.XS, borderLeftWidth: StyleConstants.Spacing.XS,
borderLeftColor: colors.blue, borderLeftColor: colors.blue,
paddingLeft: paddingLeft: StyleConstants.Spacing.Global.PagePadding - StyleConstants.Spacing.XS
StyleConstants.Spacing.Global.PagePadding -
StyleConstants.Spacing.XS
} }
]} ]}
onPress={onPress} onPress={onPress}
> >
<View style={{ flex: 1, width: '100%', flexDirection: 'row' }}> <View style={{ flex: 1, width: '100%', flexDirection: 'row' }}>
<Avatars accounts={conversation.accounts} /> <View
<TimelineHeaderConversation style={{
queryKey={queryKey} borderRadius: 4,
conversation={conversation} overflow: 'hidden',
/> marginRight: StyleConstants.Spacing.S,
width: StyleConstants.Avatar.M,
height: StyleConstants.Avatar.M,
flexDirection: 'row',
flexWrap: 'wrap'
}}
>
{conversation.accounts.slice(0, 4).map(account => (
<GracefullyImage
key={account.id}
uri={{ original: account.avatar, static: account.avatar_static }}
dimension={{
width: StyleConstants.Avatar.M,
height:
conversation.accounts.length > 2
? StyleConstants.Avatar.M / 2
: StyleConstants.Avatar.M
}}
style={{ flex: 1, flexBasis: '50%' }}
/>
))}
</View>
<TimelineHeaderConversation conversation={conversation} />
</View> </View>
{conversation.last_status ? ( {conversation.last_status ? (
@ -122,40 +102,19 @@ const TimelineConversation = React.memo(
<View <View
style={{ style={{
paddingTop: highlighted ? StyleConstants.Spacing.S : 0, paddingTop: highlighted ? StyleConstants.Spacing.S : 0,
paddingLeft: highlighted paddingLeft: highlighted ? 0 : StyleConstants.Avatar.M + StyleConstants.Spacing.S
? 0
: StyleConstants.Avatar.M + StyleConstants.Spacing.S
}} }}
> >
<TimelineContent <TimelineContent />
status={conversation.last_status} <TimelinePoll />
highlighted={highlighted}
/>
{conversation.last_status.poll ? (
<TimelinePoll
queryKey={queryKey}
statusId={conversation.last_status.id}
poll={conversation.last_status.poll}
reblog={false}
sameAccount={
conversation.last_status.id === instanceAccount?.id
}
/>
) : null}
</View> </View>
<TimelineActions
queryKey={queryKey} <TimelineActions />
status={conversation.last_status}
highlighted={highlighted}
accts={conversation.accounts.map(account => account.acct)}
reblog={false}
/>
</> </>
) : null} ) : null}
</Pressable> </Pressable>
) </StatusContext.Provider>
}, )
(prev, next) => isEqual(prev.conversation, next.conversation) }
)
export default TimelineConversation export default TimelineConversation

View File

@ -1,11 +1,12 @@
import analytics from '@components/analytics' import menuInstance from '@components/contextMenu/instance'
import menuShare from '@components/contextMenu/share'
import menuStatus from '@components/contextMenu/status'
import TimelineActioned from '@components/Timeline/Shared/Actioned' import TimelineActioned from '@components/Timeline/Shared/Actioned'
import TimelineActions from '@components/Timeline/Shared/Actions' import TimelineActions from '@components/Timeline/Shared/Actions'
import TimelineAttachment from '@components/Timeline/Shared/Attachment' import TimelineAttachment from '@components/Timeline/Shared/Attachment'
import TimelineAvatar from '@components/Timeline/Shared/Avatar' import TimelineAvatar from '@components/Timeline/Shared/Avatar'
import TimelineCard from '@components/Timeline/Shared/Card' import TimelineCard from '@components/Timeline/Shared/Card'
import TimelineContent from '@components/Timeline/Shared/Content' import TimelineContent from '@components/Timeline/Shared/Content'
// @ts-ignore
import TimelineHeaderDefault from '@components/Timeline/Shared/HeaderDefault' import TimelineHeaderDefault from '@components/Timeline/Shared/HeaderDefault'
import TimelinePoll from '@components/Timeline/Shared/Poll' import TimelinePoll from '@components/Timeline/Shared/Poll'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
@ -15,21 +16,21 @@ import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { getInstanceAccount } from '@utils/slices/instancesSlice' import { getInstanceAccount } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import { uniqBy } from 'lodash' import React, { useRef, useState } from 'react'
import React, { useRef } from 'react' import { Pressable, StyleProp, View, ViewStyle } from 'react-native'
import { Platform, Pressable, StyleProp, View, ViewStyle } from 'react-native'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import TimelineContextMenu from './Shared/ContextMenu' import * as ContextMenu from 'zeego/context-menu'
import StatusContext from './Shared/Context'
import TimelineFeedback from './Shared/Feedback' import TimelineFeedback from './Shared/Feedback'
import TimelineFiltered, { shouldFilter } from './Shared/Filtered' import TimelineFiltered, { shouldFilter } from './Shared/Filtered'
import TimelineFullConversation from './Shared/FullConversation' import TimelineFullConversation from './Shared/FullConversation'
import TimelineHeaderAndroid from './Shared/HeaderAndroid'
import TimelineTranslate from './Shared/Translate' import TimelineTranslate from './Shared/Translate'
export interface Props { export interface Props {
item: Mastodon.Status & { _pinned?: boolean } // For account page, internal property item: Mastodon.Status & { _pinned?: boolean } // For account page, internal property
queryKey?: QueryKeyTimeline queryKey?: QueryKeyTimeline
rootQueryKey?: QueryKeyTimeline rootQueryKey?: QueryKeyTimeline
origin?: string
highlighted?: boolean highlighted?: boolean
disableDetails?: boolean disableDetails?: boolean
disableOnPress?: boolean disableOnPress?: boolean
@ -40,40 +41,33 @@ const TimelineDefault: React.FC<Props> = ({
item, item,
queryKey, queryKey,
rootQueryKey, rootQueryKey,
origin,
highlighted = false, highlighted = false,
disableDetails = false, disableDetails = false,
disableOnPress = false disableOnPress = false
}) => { }) => {
const { colors } = useTheme() const { colors } = useTheme()
const instanceAccount = useSelector(getInstanceAccount, () => true)
const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>() const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>()
const actualStatus = item.reblog ? item.reblog : item const instanceAccount = useSelector(getInstanceAccount, () => true)
const ownAccount = actualStatus.account?.id === instanceAccount?.id
const status = item.reblog ? item.reblog : item
const ownAccount = status.account?.id === instanceAccount?.id
const [spoilerExpanded, setSpoilerExpanded] = useState(
instanceAccount.preferences['reading:expand:spoilers'] || false
)
const spoilerHidden = status.spoiler_text?.length
? !instanceAccount.preferences['reading:expand:spoilers'] && !spoilerExpanded
: false
const copiableContent = useRef<{ content: string; complete: boolean }>({ const copiableContent = useRef<{ content: string; complete: boolean }>({
content: '', content: '',
complete: false complete: false
}) })
const filtered = queryKey && shouldFilter({ copiableContent, status: actualStatus, queryKey }) const filtered = queryKey && shouldFilter({ copiableContent, status, queryKey })
if (queryKey && filtered && !highlighted) { if (queryKey && filtered && !highlighted) {
return <TimelineFiltered phrase={filtered} /> return <TimelineFiltered phrase={filtered} />
} }
const onPress = () => {
if (highlighted) return
analytics('timeline_default_press', {
page: queryKey ? queryKey[1].page : origin
})
navigation.push('Tab-Shared-Toot', {
toot: actualStatus,
rootQueryKey: queryKey
})
}
const mainStyle: StyleProp<ViewStyle> = { const mainStyle: StyleProp<ViewStyle> = {
padding: StyleConstants.Spacing.Global.PagePadding, padding: StyleConstants.Spacing.Global.PagePadding,
backgroundColor: colors.backgroundDefault, backgroundColor: colors.backgroundDefault,
@ -82,22 +76,14 @@ const TimelineDefault: React.FC<Props> = ({
const main = () => ( const main = () => (
<> <>
{item.reblog ? ( {item.reblog ? (
<TimelineActioned action='reblog' account={item.account} /> <TimelineActioned action='reblog' />
) : item._pinned ? ( ) : item._pinned ? (
<TimelineActioned action='pinned' account={item.account} /> <TimelineActioned action='pinned' />
) : null} ) : null}
<View style={{ flex: 1, width: '100%', flexDirection: 'row' }}> <View style={{ flex: 1, width: '100%', flexDirection: 'row' }}>
<TimelineAvatar <TimelineAvatar />
queryKey={disableOnPress ? undefined : queryKey} <TimelineHeaderDefault />
account={actualStatus.account}
highlighted={highlighted}
/>
<TimelineHeaderDefault
queryKey={disableOnPress ? undefined : queryKey}
status={actualStatus}
highlighted={highlighted}
/>
</View> </View>
<View <View
@ -106,73 +92,103 @@ const TimelineDefault: React.FC<Props> = ({
paddingLeft: highlighted ? 0 : StyleConstants.Avatar.M + StyleConstants.Spacing.S paddingLeft: highlighted ? 0 : StyleConstants.Avatar.M + StyleConstants.Spacing.S
}} }}
> >
{typeof actualStatus.content === 'string' && actualStatus.content.length > 0 ? ( <TimelineContent setSpoilerExpanded={setSpoilerExpanded} />
<TimelineContent <TimelinePoll />
status={actualStatus} <TimelineAttachment />
highlighted={highlighted} <TimelineCard />
disableDetails={disableDetails} <TimelineFullConversation />
/> <TimelineTranslate />
) : null} <TimelineFeedback />
{queryKey && actualStatus.poll ? (
<TimelinePoll
queryKey={queryKey}
rootQueryKey={rootQueryKey}
statusId={actualStatus.id}
poll={actualStatus.poll}
reblog={item.reblog ? true : false}
sameAccount={ownAccount}
/>
) : null}
{!disableDetails &&
Array.isArray(actualStatus.media_attachments) &&
actualStatus.media_attachments.length ? (
<TimelineAttachment status={actualStatus} />
) : null}
{!disableDetails && actualStatus.card ? <TimelineCard card={actualStatus.card} /> : null}
{!disableDetails ? (
<TimelineFullConversation queryKey={queryKey} status={actualStatus} />
) : null}
<TimelineTranslate status={actualStatus} highlighted={highlighted} />
<TimelineFeedback status={actualStatus} highlighted={highlighted} />
</View> </View>
{queryKey && !disableDetails ? ( <TimelineActions />
<TimelineActions
queryKey={queryKey}
rootQueryKey={rootQueryKey}
highlighted={highlighted}
status={actualStatus}
ownAccount={ownAccount}
accts={uniqBy(
([actualStatus.account] as Mastodon.Account[] & Mastodon.Mention[])
.concat(actualStatus.mentions)
.filter(d => d?.id !== instanceAccount?.id),
d => d?.id
).map(d => d?.acct)}
reblog={item.reblog ? true : false}
/>
) : null}
</> </>
) )
return disableOnPress ? ( const mShare = menuShare({
<View style={mainStyle}>{main()}</View> visibility: status.visibility,
) : ( type: 'status',
<TimelineContextMenu url: status.url || status.uri,
copiableContent={copiableContent} copiableContent
status={actualStatus} })
queryKey={queryKey} const mStatus = menuStatus({ status, queryKey, rootQueryKey })
rootQueryKey={rootQueryKey} const mInstance = menuInstance({ status, queryKey, rootQueryKey })
return (
<StatusContext.Provider
value={{
queryKey,
rootQueryKey,
status,
isReblog: !!item.reblog,
ownAccount,
spoilerHidden,
copiableContent,
highlighted,
disableDetails,
disableOnPress
}}
> >
<Pressable {disableOnPress ? (
accessible={highlighted ? false : true} <View style={mainStyle}>{main()}</View>
style={mainStyle} ) : (
onPress={onPress} <>
onLongPress={() => {}} <ContextMenu.Root>
> <ContextMenu.Trigger>
{main()} <Pressable
</Pressable> accessible={highlighted ? false : true}
</TimelineContextMenu> style={mainStyle}
disabled={highlighted}
onPress={() =>
navigation.push('Tab-Shared-Toot', {
toot: status,
rootQueryKey: queryKey
})
}
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>
))}
</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>
))}
</ContextMenu.Content>
</ContextMenu.Root>
<TimelineHeaderAndroid />
</>
)}
</StatusContext.Provider>
) )
} }

View File

@ -1,4 +1,3 @@
import analytics from '@components/analytics'
import Button from '@components/Button' import Button from '@components/Button'
import Icon from '@components/Icon' import Icon from '@components/Icon'
import CustomText from '@components/Text' import CustomText from '@components/Text'
@ -27,20 +26,11 @@ const TimelineEmpty = React.memo(
const children = () => { const children = () => {
switch (status) { switch (status) {
case 'loading': case 'loading':
return ( return <Circle size={StyleConstants.Font.Size.L} color={colors.secondary} />
<Circle
size={StyleConstants.Font.Size.L}
color={colors.secondary}
/>
)
case 'error': case 'error':
return ( return (
<> <>
<Icon <Icon name='Frown' size={StyleConstants.Font.Size.L} color={colors.primaryDefault} />
name='Frown'
size={StyleConstants.Font.Size.L}
color={colors.primaryDefault}
/>
<CustomText <CustomText
fontStyle='M' fontStyle='M'
style={{ style={{
@ -51,14 +41,7 @@ const TimelineEmpty = React.memo(
> >
{t('empty.error.message')} {t('empty.error.message')}
</CustomText> </CustomText>
<Button <Button type='text' content={t('empty.error.button')} onPress={() => refetch()} />
type='text'
content={t('empty.error.button')}
onPress={() => {
analytics('timeline_error_press_refetch')
refetch()
}}
/>
</> </>
) )
case 'success': case 'success':
@ -74,7 +57,7 @@ const TimelineEmpty = React.memo(
style={{ style={{
marginTop: StyleConstants.Spacing.S, marginTop: StyleConstants.Spacing.S,
marginBottom: StyleConstants.Spacing.L, marginBottom: StyleConstants.Spacing.L,
color: colors.primaryDefault color: colors.secondary
}} }}
> >
{t('empty.success.message')} {t('empty.success.message')}

View File

@ -1,11 +1,12 @@
import analytics from '@components/analytics' import menuInstance from '@components/contextMenu/instance'
import menuShare from '@components/contextMenu/share'
import menuStatus from '@components/contextMenu/status'
import TimelineActioned from '@components/Timeline/Shared/Actioned' import TimelineActioned from '@components/Timeline/Shared/Actioned'
import TimelineActions from '@components/Timeline/Shared/Actions' import TimelineActions from '@components/Timeline/Shared/Actions'
import TimelineAttachment from '@components/Timeline/Shared/Attachment' import TimelineAttachment from '@components/Timeline/Shared/Attachment'
import TimelineAvatar from '@components/Timeline/Shared/Avatar' import TimelineAvatar from '@components/Timeline/Shared/Avatar'
import TimelineCard from '@components/Timeline/Shared/Card' import TimelineCard from '@components/Timeline/Shared/Card'
import TimelineContent from '@components/Timeline/Shared/Content' import TimelineContent from '@components/Timeline/Shared/Content'
// @ts-ignore
import TimelineHeaderNotification from '@components/Timeline/Shared/HeaderNotification' import TimelineHeaderNotification from '@components/Timeline/Shared/HeaderNotification'
import TimelinePoll from '@components/Timeline/Shared/Poll' import TimelinePoll from '@components/Timeline/Shared/Poll'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
@ -15,13 +16,14 @@ import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { getInstanceAccount } from '@utils/slices/instancesSlice' import { getInstanceAccount } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import { isEqual, uniqBy } from 'lodash' import React, { useCallback, useRef, useState } from 'react'
import React, { useCallback, useRef } from 'react' import { Pressable, View } from 'react-native'
import { Platform, Pressable, View } from 'react-native'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import TimelineContextMenu from './Shared/ContextMenu' import * as ContextMenu from 'zeego/context-menu'
import StatusContext from './Shared/Context'
import TimelineFiltered, { shouldFilter } from './Shared/Filtered' import TimelineFiltered, { shouldFilter } from './Shared/Filtered'
import TimelineFullConversation from './Shared/FullConversation' import TimelineFullConversation from './Shared/FullConversation'
import TimelineHeaderAndroid from './Shared/HeaderAndroid'
export interface Props { export interface Props {
notification: Mastodon.Notification notification: Mastodon.Notification
@ -34,6 +36,17 @@ const TimelineNotifications: React.FC<Props> = ({
queryKey, queryKey,
highlighted = false highlighted = false
}) => { }) => {
const instanceAccount = useSelector(getInstanceAccount, () => true)
const status = notification.status
const account = notification.status ? notification.status.account : notification.account
const ownAccount = notification.account?.id === instanceAccount?.id
const [spoilerExpanded, setSpoilerExpanded] = useState(
instanceAccount.preferences['reading:expand:spoilers'] || false
)
const spoilerHidden = notification.status?.spoiler_text?.length
? !instanceAccount.preferences['reading:expand:spoilers'] && !spoilerExpanded
: false
const copiableContent = useRef<{ content: string; complete: boolean }>({ const copiableContent = useRef<{ content: string; complete: boolean }>({
content: '', content: '',
complete: false complete: false
@ -51,13 +64,9 @@ const TimelineNotifications: React.FC<Props> = ({
} }
const { colors } = useTheme() const { colors } = useTheme()
const instanceAccount = useSelector(getInstanceAccount, (prev, next) => prev?.id === next?.id)
const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>() const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>()
const actualAccount = notification.status ? notification.status.account : notification.account
const onPress = useCallback(() => { const onPress = useCallback(() => {
analytics('timeline_notification_press')
notification.status && notification.status &&
navigation.push('Tab-Shared-Toot', { navigation.push('Tab-Shared-Toot', {
toot: notification.status, toot: notification.status,
@ -69,11 +78,7 @@ const TimelineNotifications: React.FC<Props> = ({
return ( return (
<> <>
{notification.type !== 'mention' ? ( {notification.type !== 'mention' ? (
<TimelineActioned <TimelineActioned action={notification.type} isNotification account={account} />
action={notification.type}
account={notification.account}
notification
/>
) : null} ) : null}
<View <View
@ -88,8 +93,8 @@ const TimelineNotifications: React.FC<Props> = ({
}} }}
> >
<View style={{ flex: 1, width: '100%', flexDirection: 'row' }}> <View style={{ flex: 1, width: '100%', flexDirection: 'row' }}>
<TimelineAvatar queryKey={queryKey} account={actualAccount} highlighted={highlighted} /> <TimelineAvatar account={account} />
<TimelineHeaderNotification queryKey={queryKey} notification={notification} /> <TimelineHeaderNotification notification={notification} />
</View> </View>
{notification.status ? ( {notification.status ? (
@ -99,75 +104,92 @@ const TimelineNotifications: React.FC<Props> = ({
paddingLeft: highlighted ? 0 : StyleConstants.Avatar.M + StyleConstants.Spacing.S paddingLeft: highlighted ? 0 : StyleConstants.Avatar.M + StyleConstants.Spacing.S
}} }}
> >
{notification.status.content.length > 0 ? ( <TimelineContent setSpoilerExpanded={setSpoilerExpanded} />
<TimelineContent status={notification.status} highlighted={highlighted} /> <TimelinePoll />
) : null} <TimelineAttachment />
{notification.status.poll ? ( <TimelineCard />
<TimelinePoll <TimelineFullConversation />
queryKey={queryKey}
statusId={notification.status.id}
poll={notification.status.poll}
reblog={false}
sameAccount={notification.account.id === instanceAccount?.id}
/>
) : null}
{notification.status.media_attachments.length > 0 ? (
<TimelineAttachment status={notification.status} />
) : null}
{notification.status.card ? <TimelineCard card={notification.status.card} /> : null}
<TimelineFullConversation queryKey={queryKey} status={notification.status} />
</View> </View>
) : null} ) : null}
</View> </View>
{notification.status ? ( <TimelineActions />
<TimelineActions
queryKey={queryKey}
status={notification.status}
highlighted={highlighted}
accts={uniqBy(
([notification.status.account] as Mastodon.Account[] & Mastodon.Mention[])
.concat(notification.status.mentions)
.filter(d => d?.id !== instanceAccount?.id),
d => d?.id
).map(d => d?.acct)}
reblog={false}
/>
) : null}
</> </>
) )
} }
return Platform.OS === 'android' ? ( const mShare = menuShare({
<Pressable visibility: notification.status?.visibility,
style={{ type: 'status',
padding: StyleConstants.Spacing.Global.PagePadding, url: notification.status?.url || notification.status?.uri,
backgroundColor: colors.backgroundDefault, copiableContent
paddingBottom: notification.status ? 0 : StyleConstants.Spacing.Global.PagePadding })
const mStatus = menuStatus({ status: notification.status, queryKey })
const mInstance = menuInstance({ status: notification.status, queryKey })
return (
<StatusContext.Provider
value={{
queryKey,
status,
isReblog: !!status?.reblog,
ownAccount,
spoilerHidden,
copiableContent,
highlighted
}} }}
onPress={onPress}
onLongPress={() => {}}
> >
{main()} <ContextMenu.Root>
</Pressable> <ContextMenu.Trigger>
) : ( <Pressable
<TimelineContextMenu style={{
copiableContent={copiableContent} padding: StyleConstants.Spacing.Global.PagePadding,
status={notification.status} backgroundColor: colors.backgroundDefault,
queryKey={queryKey} paddingBottom: notification.status ? 0 : StyleConstants.Spacing.Global.PagePadding
> }}
<Pressable onPress={onPress}
style={{ onLongPress={() => {}}
padding: StyleConstants.Spacing.Global.PagePadding, children={main()}
backgroundColor: colors.backgroundDefault, />
paddingBottom: notification.status ? 0 : StyleConstants.Spacing.Global.PagePadding </ContextMenu.Trigger>
}}
onPress={onPress} <ContextMenu.Content>
onLongPress={() => {}} {mShare.map((mGroup, index) => (
> <ContextMenu.Group key={index}>
{main()} {mGroup.map(menu => (
</Pressable> <ContextMenu.Item key={menu.key} {...menu.item}>
</TimelineContextMenu> <ContextMenu.ItemTitle children={menu.title} />
<ContextMenu.ItemIcon iosIconName={menu.icon} />
</ContextMenu.Item>
))}
</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>
))}
</ContextMenu.Content>
</ContextMenu.Root>
<TimelineHeaderAndroid />
</StatusContext.Provider>
) )
} }

View File

@ -1,4 +1,3 @@
import analytics from '@components/analytics'
import Icon from '@components/Icon' import Icon from '@components/Icon'
import { ParseEmojis } from '@components/Parse' import { ParseEmojis } from '@components/Parse'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
@ -6,167 +5,164 @@ import { StackNavigationProp } from '@react-navigation/stack'
import { TabLocalStackParamList } from '@utils/navigation/navigators' import { TabLocalStackParamList } from '@utils/navigation/navigators'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useMemo } from 'react' import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Pressable, StyleSheet, View } from 'react-native' import { Pressable, StyleSheet, View } from 'react-native'
import StatusContext from './Context'
export interface Props { export interface Props {
account: Mastodon.Account action: Mastodon.Notification['type'] | 'reblog' | 'pinned'
action: Mastodon.Notification['type'] | ('reblog' | 'pinned') isNotification?: boolean
notification?: boolean account?: Mastodon.Account
} }
const TimelineActioned = React.memo( const TimelineActioned: React.FC<Props> = ({ action, isNotification, ...rest }) => {
({ account, action, notification = false }: Props) => { const { status } = useContext(StatusContext)
const { t } = useTranslation('componentTimeline') const account = isNotification ? rest.account : status?.account
const { colors } = useTheme() if (!status || !account) return null
const navigation =
useNavigation<StackNavigationProp<TabLocalStackParamList>>()
const name = account?.display_name || account?.username
const iconColor = colors.primaryDefault
const content = (content: string) => ( const { t } = useTranslation('componentTimeline')
<ParseEmojis content={content} emojis={account.emojis} size='S' /> const { colors } = useTheme()
) const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>()
const name = account?.display_name || account?.username
const iconColor = colors.primaryDefault
const onPress = useCallback(() => { const content = (content: string) => (
analytics('timeline_shared_actioned_press', { action }) <ParseEmojis content={content} emojis={account.emojis} size='S' />
navigation.push('Tab-Shared-Account', { account }) )
}, [])
const children = () => { const onPress = () => navigation.push('Tab-Shared-Account', { account })
switch (action) {
case 'pinned': const children = () => {
return ( switch (action) {
<> case 'pinned':
<Icon return (
name='Anchor' <>
size={StyleConstants.Font.Size.S} <Icon
color={iconColor} name='Anchor'
style={styles.icon} size={StyleConstants.Font.Size.S}
/> color={iconColor}
{content(t('shared.actioned.pinned'))} style={styles.icon}
</> />
) {content(t('shared.actioned.pinned'))}
case 'favourite': </>
return ( )
<> case 'favourite':
<Icon return (
name='Heart' <>
size={StyleConstants.Font.Size.S} <Icon
color={iconColor} name='Heart'
style={styles.icon} size={StyleConstants.Font.Size.S}
/> color={iconColor}
<Pressable onPress={onPress}> style={styles.icon}
{content(t('shared.actioned.favourite', { name }))} />
</Pressable> <Pressable onPress={onPress}>
</> {content(t('shared.actioned.favourite', { name }))}
) </Pressable>
case 'follow': </>
return ( )
<> case 'follow':
<Icon return (
name='UserPlus' <>
size={StyleConstants.Font.Size.S} <Icon
color={iconColor} name='UserPlus'
style={styles.icon} size={StyleConstants.Font.Size.S}
/> color={iconColor}
<Pressable onPress={onPress}> style={styles.icon}
{content(t('shared.actioned.follow', { name }))} />
</Pressable> <Pressable onPress={onPress}>
</> {content(t('shared.actioned.follow', { name }))}
) </Pressable>
case 'follow_request': </>
return ( )
<> case 'follow_request':
<Icon return (
name='UserPlus' <>
size={StyleConstants.Font.Size.S} <Icon
color={iconColor} name='UserPlus'
style={styles.icon} size={StyleConstants.Font.Size.S}
/> color={iconColor}
<Pressable onPress={onPress}> style={styles.icon}
{content(t('shared.actioned.follow_request', { name }))} />
</Pressable> <Pressable onPress={onPress}>
</> {content(t('shared.actioned.follow_request', { name }))}
) </Pressable>
case 'poll': </>
return ( )
<> case 'poll':
<Icon return (
name='BarChart2' <>
size={StyleConstants.Font.Size.S} <Icon
color={iconColor} name='BarChart2'
style={styles.icon} size={StyleConstants.Font.Size.S}
/> color={iconColor}
{content(t('shared.actioned.poll'))} style={styles.icon}
</> />
) {content(t('shared.actioned.poll'))}
case 'reblog': </>
return ( )
<> case 'reblog':
<Icon return (
name='Repeat' <>
size={StyleConstants.Font.Size.S} <Icon
color={iconColor} name='Repeat'
style={styles.icon} size={StyleConstants.Font.Size.S}
/> color={iconColor}
<Pressable onPress={onPress}> style={styles.icon}
{content( />
notification <Pressable onPress={onPress}>
? t('shared.actioned.reblog.notification', { name }) {content(
: t('shared.actioned.reblog.default', { name }) isNotification
)} ? t('shared.actioned.reblog.notification', { name })
</Pressable> : t('shared.actioned.reblog.default', { name })
</> )}
) </Pressable>
case 'status': </>
return ( )
<> case 'status':
<Icon return (
name='Activity' <>
size={StyleConstants.Font.Size.S} <Icon
color={iconColor} name='Activity'
style={styles.icon} size={StyleConstants.Font.Size.S}
/> color={iconColor}
<Pressable onPress={onPress}> style={styles.icon}
{content(t('shared.actioned.status', { name }))} />
</Pressable> <Pressable onPress={onPress}>
</> {content(t('shared.actioned.status', { name }))}
) </Pressable>
case 'update': </>
return ( )
<> case 'update':
<Icon return (
name='BarChart2' <>
size={StyleConstants.Font.Size.S} <Icon
color={iconColor} name='BarChart2'
style={styles.icon} size={StyleConstants.Font.Size.S}
/> color={iconColor}
{content(t('shared.actioned.update'))} style={styles.icon}
</> />
) {content(t('shared.actioned.update'))}
default: </>
return <></> )
} default:
return <></>
} }
}
return ( return (
<View <View
style={{ style={{
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginBottom: StyleConstants.Spacing.S, marginBottom: StyleConstants.Spacing.S,
paddingLeft: StyleConstants.Avatar.M - StyleConstants.Font.Size.S, paddingLeft: StyleConstants.Avatar.M - StyleConstants.Font.Size.S,
paddingRight: StyleConstants.Spacing.Global.PagePadding paddingRight: StyleConstants.Spacing.Global.PagePadding
}} }}
> children={children()}
{children()} />
</View> )
) }
},
() => true
)
const styles = StyleSheet.create({ const styles = StyleSheet.create({
icon: { icon: {

View File

@ -1,4 +1,3 @@
import analytics from '@components/analytics'
import Icon from '@components/Icon' import Icon from '@components/Icon'
import { displayMessage } from '@components/Message' import { displayMessage } from '@components/Message'
import CustomText from '@components/Text' import CustomText from '@components/Text'
@ -11,32 +10,22 @@ import {
QueryKeyTimeline, QueryKeyTimeline,
useTimelineMutation useTimelineMutation
} from '@utils/queryHooks/timeline' } from '@utils/queryHooks/timeline'
import { getInstanceAccount } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useMemo } from 'react' import { uniqBy } from 'lodash'
import React, { useCallback, useContext, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Pressable, StyleSheet, View } from 'react-native' import { Pressable, StyleSheet, View } from 'react-native'
import { useQueryClient } from 'react-query' import { useQueryClient } from 'react-query'
import { useSelector } from 'react-redux'
import StatusContext from './Context'
export interface Props { const TimelineActions: React.FC = () => {
queryKey: QueryKeyTimeline const { queryKey, rootQueryKey, status, isReblog, ownAccount, highlighted, disableDetails } =
rootQueryKey?: QueryKeyTimeline useContext(StatusContext)
highlighted: boolean if (!queryKey || !status || disableDetails) return null
status: Mastodon.Status
ownAccount?: boolean
accts: Mastodon.Account['acct'][] // When replying to conversations
reblog: boolean
}
const TimelineActions: React.FC<Props> = ({
queryKey,
rootQueryKey,
highlighted,
status,
ownAccount = false,
accts,
reblog
}) => {
const navigation = useNavigation<StackNavigationProp<RootStackParamList>>() const navigation = useNavigation<StackNavigationProp<RootStackParamList>>()
const { t } = useTranslation('componentTimeline') const { t } = useTranslation('componentTimeline')
const { colors, theme } = useTheme() const { colors, theme } = useTheme()
@ -84,11 +73,14 @@ const TimelineActions: React.FC<Props> = ({
} }
}) })
const instanceAccount = useSelector(getInstanceAccount, () => true)
const onPressReply = useCallback(() => { const onPressReply = useCallback(() => {
analytics('timeline_shared_actions_reply_press', { const accts = uniqBy(
page: queryKey[1].page, ([status.account] as Mastodon.Account[] & Mastodon.Mention[])
count: status.replies_count .concat(status.mentions)
}) .filter(d => d?.id !== instanceAccount?.id),
d => d?.id
).map(d => d?.acct)
navigation.navigate('Screen-Compose', { navigation.navigate('Screen-Compose', {
type: 'reply', type: 'reply',
incomingStatus: status, incomingStatus: status,
@ -112,17 +104,12 @@ const TimelineActions: React.FC<Props> = ({
(selectedIndex: number) => { (selectedIndex: number) => {
switch (selectedIndex) { switch (selectedIndex) {
case 0: case 0:
analytics('timeline_shared_actions_reblog_public_press', {
page: queryKey[1].page,
count: status.reblogs_count,
current: status.reblogged
})
mutation.mutate({ mutation.mutate({
type: 'updateStatusProperty', type: 'updateStatusProperty',
queryKey, queryKey,
rootQueryKey, rootQueryKey,
id: status.id, id: status.id,
reblog, isReblog,
payload: { payload: {
property: 'reblogged', property: 'reblogged',
currentValue: status.reblogged, currentValue: status.reblogged,
@ -133,17 +120,12 @@ const TimelineActions: React.FC<Props> = ({
}) })
break break
case 1: case 1:
analytics('timeline_shared_actions_reblog_unlisted_press', {
page: queryKey[1].page,
count: status.reblogs_count,
current: status.reblogged
})
mutation.mutate({ mutation.mutate({
type: 'updateStatusProperty', type: 'updateStatusProperty',
queryKey, queryKey,
rootQueryKey, rootQueryKey,
id: status.id, id: status.id,
reblog, isReblog,
payload: { payload: {
property: 'reblogged', property: 'reblogged',
currentValue: status.reblogged, currentValue: status.reblogged,
@ -157,17 +139,12 @@ const TimelineActions: React.FC<Props> = ({
} }
) )
} else { } else {
analytics('timeline_shared_actions_reblog_press', {
page: queryKey[1].page,
count: status.reblogs_count,
current: status.reblogged
})
mutation.mutate({ mutation.mutate({
type: 'updateStatusProperty', type: 'updateStatusProperty',
queryKey, queryKey,
rootQueryKey, rootQueryKey,
id: status.id, id: status.id,
reblog, isReblog,
payload: { payload: {
property: 'reblogged', property: 'reblogged',
currentValue: status.reblogged, currentValue: status.reblogged,
@ -179,17 +156,12 @@ const TimelineActions: React.FC<Props> = ({
} }
}, [status.reblogged, status.reblogs_count]) }, [status.reblogged, status.reblogs_count])
const onPressFavourite = useCallback(() => { const onPressFavourite = useCallback(() => {
analytics('timeline_shared_actions_favourite_press', {
page: queryKey[1].page,
count: status.favourites_count,
current: status.favourited
})
mutation.mutate({ mutation.mutate({
type: 'updateStatusProperty', type: 'updateStatusProperty',
queryKey, queryKey,
rootQueryKey, rootQueryKey,
id: status.id, id: status.id,
reblog, isReblog,
payload: { payload: {
property: 'favourited', property: 'favourited',
currentValue: status.favourited, currentValue: status.favourited,
@ -199,16 +171,12 @@ const TimelineActions: React.FC<Props> = ({
}) })
}, [status.favourited, status.favourites_count]) }, [status.favourited, status.favourites_count])
const onPressBookmark = useCallback(() => { const onPressBookmark = useCallback(() => {
analytics('timeline_shared_actions_bookmark_press', {
page: queryKey[1].page,
current: status.bookmarked
})
mutation.mutate({ mutation.mutate({
type: 'updateStatusProperty', type: 'updateStatusProperty',
queryKey, queryKey,
rootQueryKey, rootQueryKey,
id: status.id, id: status.id,
reblog, isReblog,
payload: { payload: {
property: 'bookmarked', property: 'bookmarked',
currentValue: status.bookmarked, currentValue: status.bookmarked,

View File

@ -1,4 +1,3 @@
import analytics from '@components/analytics'
import Button from '@components/Button' import Button from '@components/Button'
import haptics from '@components/haptics' import haptics from '@components/haptics'
import AttachmentAudio from '@components/Timeline/Shared/Attachment/Audio' import AttachmentAudio from '@components/Timeline/Shared/Attachment/Audio'
@ -11,51 +10,140 @@ import { RootStackParamList } from '@utils/navigation/navigators'
import { getInstanceAccount } from '@utils/slices/instancesSlice' import { getInstanceAccount } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import layoutAnimation from '@utils/styles/layoutAnimation' import layoutAnimation from '@utils/styles/layoutAnimation'
import React, { useState } from 'react' import React, { useContext, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Pressable, View } from 'react-native' import { Pressable, View } from 'react-native'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import StatusContext from './Context'
export interface Props { const TimelineAttachment = () => {
status: Pick<Mastodon.Status, 'media_attachments' | 'sensitive'> const { status, disableDetails } = useContext(StatusContext)
} if (
!status ||
disableDetails ||
!Array.isArray(status.media_attachments) ||
!status.media_attachments.length
)
return null
const TimelineAttachment = React.memo( const { t } = useTranslation('componentTimeline')
({ status }: Props) => {
const { t } = useTranslation('componentTimeline')
const account = useSelector( const account = useSelector(
getInstanceAccount, getInstanceAccount,
(prev, next) => (prev, next) =>
prev.preferences['reading:expand:media'] === next.preferences['reading:expand:media'] prev.preferences['reading:expand:media'] === next.preferences['reading:expand:media']
) )
const defaultSensitive = () => { const defaultSensitive = () => {
switch (account.preferences['reading:expand:media']) { switch (account.preferences['reading:expand:media']) {
case 'show_all': case 'show_all':
return false return false
case 'hide_all': case 'hide_all':
return true return true
default: default:
return status.sensitive return status.sensitive
}
} }
const [sensitiveShown, setSensitiveShown] = useState(defaultSensitive()) }
const [sensitiveShown, setSensitiveShown] = useState(defaultSensitive())
// @ts-ignore // @ts-ignore
const imageUrls: RootStackParamList['Screen-ImagesViewer']['imageUrls'] = const imageUrls: RootStackParamList['Screen-ImagesViewer']['imageUrls'] = status.media_attachments
status.media_attachments .map(attachment => {
.map(attachment => { switch (attachment.type) {
case 'image':
return {
id: attachment.id,
preview_url: attachment.preview_url,
url: attachment.url,
remote_url: attachment.remote_url,
blurhash: attachment.blurhash,
width: attachment.meta?.original?.width,
height: attachment.meta?.original?.height
}
default:
if (
attachment.preview_url?.endsWith('.jpg') ||
attachment.preview_url?.endsWith('.jpeg') ||
attachment.preview_url?.endsWith('.png') ||
attachment.preview_url?.endsWith('.gif') ||
attachment.remote_url?.endsWith('.jpg') ||
attachment.remote_url?.endsWith('.jpeg') ||
attachment.remote_url?.endsWith('.png') ||
attachment.remote_url?.endsWith('.gif')
) {
return {
id: attachment.id,
preview_url: attachment.preview_url,
url: attachment.url,
remote_url: attachment.remote_url,
blurhash: attachment.blurhash,
width: attachment.meta?.original?.width,
height: attachment.meta?.original?.height
}
}
}
})
.filter(i => i)
const navigation = useNavigation<StackNavigationProp<RootStackParamList>>()
const navigateToImagesViewer = (id: string) => {
navigation.navigate('Screen-ImagesViewer', { imageUrls, id })
}
return (
<View>
<View
style={{
marginTop: StyleConstants.Spacing.S,
flex: 1,
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
alignContent: 'stretch'
}}
>
{status.media_attachments.map((attachment, index) => {
switch (attachment.type) { switch (attachment.type) {
case 'image': case 'image':
return { return (
id: attachment.id, <AttachmentImage
preview_url: attachment.preview_url, key={index}
url: attachment.url, total={status.media_attachments.length}
remote_url: attachment.remote_url, index={index}
blurhash: attachment.blurhash, sensitiveShown={sensitiveShown}
width: attachment.meta?.original?.width, image={attachment}
height: attachment.meta?.original?.height navigateToImagesViewer={navigateToImagesViewer}
} />
)
case 'video':
return (
<AttachmentVideo
key={index}
total={status.media_attachments.length}
index={index}
sensitiveShown={sensitiveShown}
video={attachment}
/>
)
case 'gifv':
return (
<AttachmentVideo
key={index}
total={status.media_attachments.length}
index={index}
sensitiveShown={sensitiveShown}
video={attachment}
gifv
/>
)
case 'audio':
return (
<AttachmentAudio
key={index}
total={status.media_attachments.length}
index={index}
sensitiveShown={sensitiveShown}
audio={attachment}
/>
)
default: default:
if ( if (
attachment.preview_url?.endsWith('.jpg') || attachment.preview_url?.endsWith('.jpg') ||
@ -67,178 +155,74 @@ const TimelineAttachment = React.memo(
attachment.remote_url?.endsWith('.png') || attachment.remote_url?.endsWith('.png') ||
attachment.remote_url?.endsWith('.gif') attachment.remote_url?.endsWith('.gif')
) { ) {
return {
id: attachment.id,
preview_url: attachment.preview_url,
url: attachment.url,
remote_url: attachment.remote_url,
blurhash: attachment.blurhash,
width: attachment.meta?.original?.width,
height: attachment.meta?.original?.height
}
}
}
})
.filter(i => i)
const navigation = useNavigation<StackNavigationProp<RootStackParamList>>()
const navigateToImagesViewer = (id: string) => {
navigation.navigate('Screen-ImagesViewer', { imageUrls, id })
}
return (
<View>
<View
style={{
marginTop: StyleConstants.Spacing.S,
flex: 1,
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
alignContent: 'stretch'
}}
>
{status.media_attachments.map((attachment, index) => {
switch (attachment.type) {
case 'image':
return ( return (
<AttachmentImage <AttachmentImage
key={index} key={index}
total={status.media_attachments.length} total={status.media_attachments.length}
index={index} index={index}
sensitiveShown={sensitiveShown} sensitiveShown={sensitiveShown}
// @ts-ignore
image={attachment} image={attachment}
navigateToImagesViewer={navigateToImagesViewer} navigateToImagesViewer={navigateToImagesViewer}
/> />
) )
case 'video': } else {
return ( return (
<AttachmentVideo <AttachmentUnsupported
key={index} key={index}
total={status.media_attachments.length} total={status.media_attachments.length}
index={index} index={index}
sensitiveShown={sensitiveShown} sensitiveShown={sensitiveShown}
video={attachment} attachment={attachment}
/> />
) )
case 'gifv': }
return ( }
<AttachmentVideo })}
key={index} </View>
total={status.media_attachments.length}
index={index}
sensitiveShown={sensitiveShown}
video={attachment}
gifv
/>
)
case 'audio':
return (
<AttachmentAudio
key={index}
total={status.media_attachments.length}
index={index}
sensitiveShown={sensitiveShown}
audio={attachment}
/>
)
default:
if (
attachment.preview_url?.endsWith('.jpg') ||
attachment.preview_url?.endsWith('.jpeg') ||
attachment.preview_url?.endsWith('.png') ||
attachment.preview_url?.endsWith('.gif') ||
attachment.remote_url?.endsWith('.jpg') ||
attachment.remote_url?.endsWith('.jpeg') ||
attachment.remote_url?.endsWith('.png') ||
attachment.remote_url?.endsWith('.gif')
) {
return (
<AttachmentImage
key={index}
total={status.media_attachments.length}
index={index}
sensitiveShown={sensitiveShown}
// @ts-ignore
image={attachment}
navigateToImagesViewer={navigateToImagesViewer}
/>
)
} else {
return (
<AttachmentUnsupported
key={index}
total={status.media_attachments.length}
index={index}
sensitiveShown={sensitiveShown}
attachment={attachment}
/>
)
}
}
})}
</View>
{defaultSensitive() && {defaultSensitive() &&
(sensitiveShown ? ( (sensitiveShown ? (
<Pressable <Pressable
style={{ style={{
position: 'absolute', position: 'absolute',
width: '100%', width: '100%',
height: '100%', height: '100%',
flex: 1, flex: 1,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center' alignItems: 'center'
}} }}
> >
<Button
type='text'
content={t('shared.attachment.sensitive.button')}
overlay
onPress={() => {
analytics('timeline_shared_attachment_blurview_press_show')
layoutAnimation()
setSensitiveShown(false)
haptics('Light')
}}
/>
</Pressable>
) : (
<Button <Button
type='icon' type='text'
content='EyeOff' content={t('shared.attachment.sensitive.button')}
round
overlay overlay
onPress={() => { onPress={() => {
analytics('timeline_shared_attachment_blurview_press_hide') layoutAnimation()
setSensitiveShown(true) setSensitiveShown(false)
haptics('Light') haptics('Light')
}} }}
style={{
position: 'absolute',
top: StyleConstants.Spacing.S * 2,
left: StyleConstants.Spacing.S
}}
/> />
))} </Pressable>
</View> ) : (
) <Button
}, type='icon'
(prev, next) => { content='EyeOff'
let isEqual = true round
overlay
if (prev.status.media_attachments.length !== next.status.media_attachments.length) { onPress={() => {
isEqual = false setSensitiveShown(true)
return isEqual haptics('Light')
} }}
style={{
prev.status.media_attachments.forEach((attachment, index) => { position: 'absolute',
if (attachment.preview_url !== next.status.media_attachments[index].preview_url) { top: StyleConstants.Spacing.S * 2,
isEqual = false left: StyleConstants.Spacing.S
} }}
}) />
))}
return isEqual </View>
} )
) }
export default TimelineAttachment export default TimelineAttachment

View File

@ -1,4 +1,3 @@
import analytics from '@components/analytics'
import Button from '@components/Button' import Button from '@components/Button'
import GracefullyImage from '@components/GracefullyImage' import GracefullyImage from '@components/GracefullyImage'
import { Slider } from '@sharcoux/slider' import { Slider } from '@sharcoux/slider'
@ -25,7 +24,6 @@ const AttachmentAudio: React.FC<Props> = ({ total, index, sensitiveShown, audio
const [audioPlaying, setAudioPlaying] = useState(false) const [audioPlaying, setAudioPlaying] = useState(false)
const [audioPosition, setAudioPosition] = useState(0) const [audioPosition, setAudioPosition] = useState(0)
const playAudio = useCallback(async () => { const playAudio = useCallback(async () => {
analytics('timeline_shared_attachment_audio_play_press', { id: audio.id })
if (!audioPlayer) { if (!audioPlayer) {
const { sound } = await Audio.Sound.createAsync( const { sound } = await Audio.Sound.createAsync(
{ uri: audio.url }, { uri: audio.url },
@ -41,7 +39,6 @@ const AttachmentAudio: React.FC<Props> = ({ total, index, sensitiveShown, audio
} }
}, [audioPlayer, audioPosition]) }, [audioPlayer, audioPosition])
const pauseAudio = useCallback(async () => { const pauseAudio = useCallback(async () => {
analytics('timeline_shared_attachment_audio_pause_press', { id: audio.id })
audioPlayer!.pauseAsync() audioPlayer!.pauseAsync()
setAudioPlaying(false) setAudioPlaying(false)
}, [audioPlayer]) }, [audioPlayer])

View File

@ -1,4 +1,3 @@
import analytics from '@components/analytics'
import GracefullyImage from '@components/GracefullyImage' import GracefullyImage from '@components/GracefullyImage'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import React from 'react' import React from 'react'
@ -34,27 +33,17 @@ const AttachmentImage = ({
hidden={sensitiveShown} hidden={sensitiveShown}
uri={{ original: image.preview_url, remote: image.remote_url }} uri={{ original: image.preview_url, remote: image.remote_url }}
blurhash={image.blurhash} blurhash={image.blurhash}
onPress={() => { onPress={() => navigateToImagesViewer(image.id)}
analytics('timeline_shared_attachment_image_press', {
id: image.id
})
navigateToImagesViewer(image.id)
}}
style={{ style={{
aspectRatio: aspectRatio:
total > 1 || total > 1 || !image.meta?.original?.width || !image.meta?.original?.height
!image.meta?.original?.width ||
!image.meta?.original?.height
? attachmentAspectRatio({ total, index }) ? attachmentAspectRatio({ total, index })
: image.meta.original.height / image.meta.original.width > 1 : image.meta.original.height / image.meta.original.width > 1
? 1 ? 1
: image.meta.original.width / image.meta.original.height : image.meta.original.width / image.meta.original.height
}} }}
/> />
<AttachmentAltText <AttachmentAltText sensitiveShown={sensitiveShown} text={image.description} />
sensitiveShown={sensitiveShown}
text={image.description}
/>
</View> </View>
) )
} }

View File

@ -1,4 +1,3 @@
import analytics from '@components/analytics'
import Button from '@components/Button' import Button from '@components/Button'
import openLink from '@components/openLink' import openLink from '@components/openLink'
import CustomText from '@components/Text' import CustomText from '@components/Text'
@ -18,12 +17,7 @@ export interface Props {
attachment: Mastodon.AttachmentUnknown attachment: Mastodon.AttachmentUnknown
} }
const AttachmentUnsupported: React.FC<Props> = ({ const AttachmentUnsupported: React.FC<Props> = ({ total, index, sensitiveShown, attachment }) => {
total,
index,
sensitiveShown,
attachment
}) => {
const { t } = useTranslation('componentTimeline') const { t } = useTranslation('componentTimeline')
const { colors } = useTheme() const { colors } = useTheme()
@ -55,9 +49,7 @@ const AttachmentUnsupported: React.FC<Props> = ({
style={{ style={{
textAlign: 'center', textAlign: 'center',
marginBottom: StyleConstants.Spacing.S, marginBottom: StyleConstants.Spacing.S,
color: attachment.blurhash color: attachment.blurhash ? colors.backgroundDefault : colors.primaryDefault
? colors.backgroundDefault
: colors.primaryDefault
}} }}
> >
{t('shared.attachment.unsupported.text')} {t('shared.attachment.unsupported.text')}
@ -69,17 +61,13 @@ const AttachmentUnsupported: React.FC<Props> = ({
size='S' size='S'
overlay overlay
onPress={() => { onPress={() => {
analytics('timeline_shared_attachment_unsupported_press')
attachment.remote_url && openLink(attachment.remote_url) attachment.remote_url && openLink(attachment.remote_url)
}} }}
/> />
) : null} ) : null}
</> </>
) : null} ) : null}
<AttachmentAltText <AttachmentAltText sensitiveShown={sensitiveShown} text={attachment.description} />
sensitiveShown={sensitiveShown}
text={attachment.description}
/>
</View> </View>
) )
} }

View File

@ -5,7 +5,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'
import { AppState, AppStateStatus, Pressable, View } from 'react-native' import { AppState, AppStateStatus, Pressable, View } from 'react-native'
import { Blurhash } from 'react-native-blurhash' import { Blurhash } from 'react-native-blurhash'
import attachmentAspectRatio from './aspectRatio' import attachmentAspectRatio from './aspectRatio'
import analytics from '@components/analytics'
import AttachmentAltText from './AltText' import AttachmentAltText from './AltText'
import { Platform } from 'expo-modules-core' import { Platform } from 'expo-modules-core'
@ -30,13 +29,6 @@ const AttachmentVideo: React.FC<Props> = ({
const [videoPosition, setVideoPosition] = useState<number>(0) const [videoPosition, setVideoPosition] = useState<number>(0)
const [videoResizeMode, setVideoResizeMode] = useState<ResizeMode>(ResizeMode.COVER) const [videoResizeMode, setVideoResizeMode] = useState<ResizeMode>(ResizeMode.COVER)
const playOnPress = useCallback(async () => { const playOnPress = useCallback(async () => {
analytics('timeline_shared_attachment_video_length', {
length: video.meta?.length
})
analytics('timeline_shared_attachment_vide_play_press', {
id: video.id,
timestamp: Date.now()
})
setVideoLoading(true) setVideoLoading(true)
if (!videoLoaded) { if (!videoLoaded) {
await videoPlayer.current?.loadAsync({ uri: video.url }) await videoPlayer.current?.loadAsync({ uri: video.url })

View File

@ -1,56 +1,49 @@
import analytics from '@components/analytics'
import GracefullyImage from '@components/GracefullyImage' import GracefullyImage from '@components/GracefullyImage'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import { StackNavigationProp } from '@react-navigation/stack' import { StackNavigationProp } from '@react-navigation/stack'
import { TabLocalStackParamList } from '@utils/navigation/navigators' import { TabLocalStackParamList } from '@utils/navigation/navigators'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import React, { useCallback } from 'react' import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import StatusContext from './Context'
export interface Props { export interface Props {
queryKey?: QueryKeyTimeline account?: Mastodon.Account
account: Mastodon.Account
highlighted: boolean
} }
const TimelineAvatar = React.memo( const TimelineAvatar: React.FC<Props> = ({ account }) => {
({ queryKey, account, highlighted }: Props) => { const { status, highlighted, disableOnPress } = useContext(StatusContext)
const { t } = useTranslation('componentTimeline') const actualAccount = account || status?.account
const navigation = if (!actualAccount) return null
useNavigation<StackNavigationProp<TabLocalStackParamList>>()
// Need to fix go back root
const onPress = useCallback(() => {
analytics('timeline_shared_avatar_press', {
page: queryKey && queryKey[1].page
})
queryKey && navigation.push('Tab-Shared-Account', { account })
}, [])
return ( const { t } = useTranslation('componentTimeline')
<GracefullyImage const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>()
{...(highlighted && {
accessibilityLabel: t('shared.avatar.accessibilityLabel', { return (
name: account.display_name <GracefullyImage
}), {...(highlighted && {
accessibilityHint: t('shared.avatar.accessibilityHint', { accessibilityLabel: t('shared.avatar.accessibilityLabel', {
name: account.display_name name: actualAccount.display_name
}) }),
})} accessibilityHint: t('shared.avatar.accessibilityHint', {
onPress={onPress} name: actualAccount.display_name
uri={{ original: account?.avatar, static: account?.avatar_static }} })
dimension={{ })}
width: StyleConstants.Avatar.M, onPress={() =>
height: StyleConstants.Avatar.M !disableOnPress && navigation.push('Tab-Shared-Account', { account: actualAccount })
}} }
style={{ uri={{ original: actualAccount.avatar, static: actualAccount.avatar_static }}
borderRadius: StyleConstants.Avatar.M, dimension={{
overflow: 'hidden', width: StyleConstants.Avatar.M,
marginRight: StyleConstants.Spacing.S height: StyleConstants.Avatar.M
}} }}
/> style={{
) borderRadius: StyleConstants.Avatar.M,
} overflow: 'hidden',
) marginRight: StyleConstants.Spacing.S
}}
/>
)
}
export default TimelineAvatar export default TimelineAvatar

View File

@ -1,5 +1,4 @@
import ComponentAccount from '@components/Account' import ComponentAccount from '@components/Account'
import analytics from '@components/analytics'
import GracefullyImage from '@components/GracefullyImage' import GracefullyImage from '@components/GracefullyImage'
import openLink from '@components/openLink' import openLink from '@components/openLink'
import CustomText from '@components/Text' import CustomText from '@components/Text'
@ -10,23 +9,23 @@ import { useSearchQuery } from '@utils/queryHooks/search'
import { useStatusQuery } from '@utils/queryHooks/status' import { useStatusQuery } from '@utils/queryHooks/status'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import React, { useEffect, useState } from 'react' import React, { useContext, useEffect, useState } from 'react'
import { Pressable, StyleSheet, View } from 'react-native' import { Pressable, StyleSheet, Text, View } from 'react-native'
import { Circle } from 'react-native-animated-spinkit' import { Circle } from 'react-native-animated-spinkit'
import TimelineDefault from '../Default' import TimelineDefault from '../Default'
import StatusContext from './Context'
export interface Props { const TimelineCard: React.FC = () => {
card: Pick<Mastodon.Card, 'url' | 'image' | 'blurhash' | 'title' | 'description'> const { status, spoilerHidden, disableDetails } = useContext(StatusContext)
} if (!status || !status.card) return null
const TimelineCard = React.memo(({ card }: Props) => {
const { colors } = useTheme() const { colors } = useTheme()
const navigation = useNavigation() const navigation = useNavigation()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const isStatus = matchStatus(card.url) const isStatus = matchStatus(status.card.url)
const [foundStatus, setFoundStatus] = useState<Mastodon.Status>() const [foundStatus, setFoundStatus] = useState<Mastodon.Status>()
const isAccount = matchAccount(card.url) const isAccount = matchAccount(status.card.url)
const [foundAccount, setFoundAccount] = useState<Mastodon.Account>() const [foundAccount, setFoundAccount] = useState<Mastodon.Account>()
const searchQuery = useSearchQuery({ const searchQuery = useSearchQuery({
@ -39,7 +38,7 @@ const TimelineCard = React.memo(({ card }: Props) => {
if (isStatus.sameInstance) { if (isStatus.sameInstance) {
return return
} else { } else {
return card.url return status.card.url
} }
} }
if (isAccount) { if (isAccount) {
@ -50,7 +49,7 @@ const TimelineCard = React.memo(({ card }: Props) => {
return isAccount.username return isAccount.username
} }
} else { } else {
return card.url return status.card.url
} }
} }
})(), })(),
@ -130,17 +129,17 @@ const TimelineCard = React.memo(({ card }: Props) => {
) )
} }
if (isStatus && foundStatus) { if (isStatus && foundStatus) {
return <TimelineDefault item={foundStatus} disableDetails disableOnPress origin='card' /> return <TimelineDefault item={foundStatus} disableDetails disableOnPress />
} }
if (isAccount && foundAccount) { if (isAccount && foundAccount) {
return <ComponentAccount account={foundAccount} origin='card' /> return <ComponentAccount account={foundAccount} />
} }
return ( return (
<> <>
{card.image ? ( {status.card?.image ? (
<GracefullyImage <GracefullyImage
uri={{ original: card.image }} uri={{ original: status.card.image }}
blurhash={card.blurhash} blurhash={status.card.blurhash}
style={{ flexBasis: StyleConstants.Font.LineHeight.M * 5 }} style={{ flexBasis: StyleConstants.Font.LineHeight.M * 5 }}
imageStyle={{ borderTopLeftRadius: 6, borderBottomLeftRadius: 6 }} imageStyle={{ borderTopLeftRadius: 6, borderBottomLeftRadius: 6 }}
/> />
@ -156,9 +155,9 @@ const TimelineCard = React.memo(({ card }: Props) => {
fontWeight='Bold' fontWeight='Bold'
testID='title' testID='title'
> >
{card.title} {status.card?.title}
</CustomText> </CustomText>
{card.description ? ( {status.card?.description ? (
<CustomText <CustomText
fontStyle='S' fontStyle='S'
numberOfLines={1} numberOfLines={1}
@ -168,17 +167,19 @@ const TimelineCard = React.memo(({ card }: Props) => {
}} }}
testID='description' testID='description'
> >
{card.description} {status.card.description}
</CustomText> </CustomText>
) : null} ) : null}
<CustomText fontStyle='S' numberOfLines={1} style={{ color: colors.secondary }}> <CustomText fontStyle='S' numberOfLines={1} style={{ color: colors.secondary }}>
{card.url} {status.card?.url}
</CustomText> </CustomText>
</View> </View>
</> </>
) )
} }
if (spoilerHidden || disableDetails) return null
return ( return (
<Pressable <Pressable
accessible accessible
@ -193,13 +194,10 @@ const TimelineCard = React.memo(({ card }: Props) => {
overflow: 'hidden', overflow: 'hidden',
borderColor: colors.border borderColor: colors.border
}} }}
onPress={async () => { onPress={async () => status.card && (await openLink(status.card.url, navigation))}
analytics('timeline_shared_card_press') children={cardContent()}
await openLink(card.url, navigation)
}}
children={cardContent}
/> />
) )
}) }
export default TimelineCard export default TimelineCard

View File

@ -1,52 +1,36 @@
import { ParseHTML } from '@components/Parse' import { ParseHTML } from '@components/Parse'
import { getInstanceAccount } from '@utils/slices/instancesSlice' import { getInstanceAccount } from '@utils/slices/instancesSlice'
import React from 'react' import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import StatusContext from './Context'
export interface Props { export interface Props {
status: Pick<Mastodon.Status, 'content' | 'spoiler_text' | 'emojis'> & { setSpoilerExpanded?: React.Dispatch<React.SetStateAction<boolean>>
mentions?: Mastodon.Status['mentions']
tags?: Mastodon.Status['tags']
}
highlighted?: boolean
disableDetails?: boolean
} }
const TimelineContent = React.memo( const TimelineContent: React.FC<Props> = ({ setSpoilerExpanded }) => {
({ status, highlighted = false, disableDetails = false }: Props) => { const { status, highlighted, disableDetails } = useContext(StatusContext)
const { t } = useTranslation('componentTimeline') if (!status || typeof status.content !== 'string' || !status.content.length) return null
const instanceAccount = useSelector(getInstanceAccount, () => true)
return ( const { t } = useTranslation('componentTimeline')
<> const instanceAccount = useSelector(getInstanceAccount, () => true)
{status.spoiler_text ? (
<> return (
<ParseHTML <>
content={status.spoiler_text} {status.spoiler_text?.length ? (
size={highlighted ? 'L' : 'M'} <>
adaptiveSize <ParseHTML
emojis={status.emojis} content={status.spoiler_text}
mentions={status.mentions} size={highlighted ? 'L' : 'M'}
tags={status.tags} adaptiveSize
numberOfLines={999} emojis={status.emojis}
highlighted={highlighted} mentions={status.mentions}
disableDetails={disableDetails} tags={status.tags}
/> numberOfLines={999}
<ParseHTML highlighted={highlighted}
content={status.content} disableDetails={disableDetails}
size={highlighted ? 'L' : 'M'} />
adaptiveSize
emojis={status.emojis}
mentions={status.mentions}
tags={status.tags}
numberOfLines={instanceAccount.preferences['reading:expand:spoilers'] ? 999 : 1}
expandHint={t('shared.content.expandHint')}
highlighted={highlighted}
disableDetails={disableDetails}
/>
</>
) : (
<ParseHTML <ParseHTML
content={status.content} content={status.content}
size={highlighted ? 'L' : 'M'} size={highlighted ? 'L' : 'M'}
@ -54,16 +38,27 @@ const TimelineContent = React.memo(
emojis={status.emojis} emojis={status.emojis}
mentions={status.mentions} mentions={status.mentions}
tags={status.tags} tags={status.tags}
numberOfLines={highlighted ? 999 : undefined} numberOfLines={instanceAccount.preferences['reading:expand:spoilers'] ? 999 : 1}
expandHint={t('shared.content.expandHint')}
setSpoilerExpanded={setSpoilerExpanded}
highlighted={highlighted}
disableDetails={disableDetails} disableDetails={disableDetails}
/> />
)} </>
</> ) : (
) <ParseHTML
}, content={status.content}
(prev, next) => size={highlighted ? 'L' : 'M'}
prev.status.content === next.status.content && adaptiveSize
prev.status.spoiler_text === next.status.spoiler_text emojis={status.emojis}
) mentions={status.mentions}
tags={status.tags}
numberOfLines={highlighted ? 999 : undefined}
disableDetails={disableDetails}
/>
)}
</>
)
}
export default TimelineContent export default TimelineContent

View File

@ -0,0 +1,24 @@
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { createContext } from 'react'
type ContextType = {
queryKey?: QueryKeyTimeline
rootQueryKey?: QueryKeyTimeline
status?: Mastodon.Status
isReblog?: boolean
ownAccount?: boolean
spoilerHidden?: boolean
copiableContent?: React.MutableRefObject<{
content: string
complete: boolean
}>
highlighted?: boolean
disableDetails?: boolean
disableOnPress?: boolean
}
const StatusContext = createContext<ContextType>({} as ContextType)
export default StatusContext

View File

@ -1,84 +0,0 @@
import contextMenuAccount from '@components/ContextMenu/account'
import contextMenuInstance from '@components/ContextMenu/instance'
import contextMenuShare from '@components/ContextMenu/share'
import contextMenuStatus from '@components/ContextMenu/status'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import React from 'react'
import { createContext } from 'react'
import { Platform } from 'react-native'
import ContextMenu, { ContextMenuAction, ContextMenuProps } from 'react-native-context-menu-view'
export interface Props {
copiableContent: React.MutableRefObject<{
content: string
complete: boolean
}>
status?: Mastodon.Status
queryKey?: QueryKeyTimeline
rootQueryKey?: QueryKeyTimeline
}
export const ContextMenuContext = createContext<ContextMenuAction[]>([])
const TimelineContextMenu: React.FC<Props & ContextMenuProps> = ({
children,
copiableContent,
status,
queryKey,
rootQueryKey,
...props
}) => {
if (!status || !queryKey || Platform.OS === 'android') {
return <>{children}</>
}
const actions: ContextMenuAction[] = []
const shareOnPress =
status.visibility !== 'direct'
? contextMenuShare({
copiableContent,
actions,
type: 'status',
url: status.url || status.uri
})
: null
const statusOnPress = contextMenuStatus({
actions,
status,
queryKey,
rootQueryKey
})
const accountOnPress = status?.account?.id
? contextMenuAccount({
actions,
type: 'status',
queryKey,
rootQueryKey,
id: status.account.id
})
: null
const instanceOnPress = contextMenuInstance({
actions,
status,
queryKey,
rootQueryKey
})
return (
<ContextMenuContext.Provider value={actions}>
<ContextMenu
actions={actions}
onPress={({ nativeEvent: { index } }) => {
for (const on of [shareOnPress, statusOnPress, accountOnPress, instanceOnPress]) {
on && on(index)
}
}}
children={children}
{...props}
/>
</ContextMenuContext.Provider>
)
}
export default TimelineContextMenu

View File

@ -1,4 +1,3 @@
import analytics from '@components/analytics'
import CustomText from '@components/Text' import CustomText from '@components/Text'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import { StackNavigationProp } from '@react-navigation/stack' import { StackNavigationProp } from '@react-navigation/stack'
@ -6,133 +5,92 @@ import { TabLocalStackParamList } from '@utils/navigation/navigators'
import { useStatusHistory } from '@utils/queryHooks/statusesHistory' import { useStatusHistory } from '@utils/queryHooks/statusesHistory'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react' import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { StyleSheet, View } from 'react-native' import { StyleSheet, View } from 'react-native'
import StatusContext from './Context'
export interface Props { const TimelineFeedback = () => {
status: Pick< const { status, highlighted } = useContext(StatusContext)
Mastodon.Status, if (!status || !highlighted) return null
'id' | 'edited_at' | 'reblogs_count' | 'favourites_count'
>
highlighted: boolean
}
const TimelineFeedback = React.memo( const { t } = useTranslation('componentTimeline')
({ status, highlighted }: Props) => { const { colors } = useTheme()
if (!highlighted) { const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>()
return null
}
const { t } = useTranslation('componentTimeline') const { data } = useStatusHistory({
const { colors } = useTheme() id: status.id,
const navigation = options: { enabled: status.edited_at !== undefined }
useNavigation<StackNavigationProp<TabLocalStackParamList>>() })
const { data } = useStatusHistory({ return (
id: status.id, <View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
options: { enabled: status.edited_at !== undefined } <View style={{ flexDirection: 'row' }}>
}) {status.reblogs_count > 0 ? (
<CustomText
return ( accessibilityLabel={t('shared.actionsUsers.reblogged_by.accessibilityLabel', {
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}> count: status.reblogs_count
<View style={{ flexDirection: 'row' }}> })}
{status.reblogs_count > 0 ? ( accessibilityHint={t('shared.actionsUsers.reblogged_by.accessibilityHint')}
<CustomText accessibilityRole='button'
accessibilityLabel={t( style={[styles.text, { color: colors.blue }]}
'shared.actionsUsers.reblogged_by.accessibilityLabel', onPress={() =>
{ navigation.push('Tab-Shared-Users', {
count: status.reblogs_count reference: 'statuses',
} id: status.id,
)} type: 'reblogged_by',
accessibilityHint={t(
'shared.actionsUsers.reblogged_by.accessibilityHint'
)}
accessibilityRole='button'
style={[styles.text, { color: colors.blue }]}
onPress={() => {
analytics('timeline_shared_feedback_press_reblog', {
count: status.reblogs_count
})
navigation.push('Tab-Shared-Users', {
reference: 'statuses',
id: status.id,
type: 'reblogged_by',
count: status.reblogs_count
})
}}
>
{t('shared.actionsUsers.reblogged_by.text', {
count: status.reblogs_count count: status.reblogs_count
})} })
</CustomText> }
) : null} >
{status.favourites_count > 0 ? ( {t('shared.actionsUsers.reblogged_by.text', {
<CustomText count: status.reblogs_count
accessibilityLabel={t( })}
'shared.actionsUsers.favourited_by.accessibilityLabel', </CustomText>
{ ) : null}
count: status.reblogs_count {status.favourites_count > 0 ? (
} <CustomText
)} accessibilityLabel={t('shared.actionsUsers.favourited_by.accessibilityLabel', {
accessibilityHint={t( count: status.reblogs_count
'shared.actionsUsers.favourited_by.accessibilityHint' })}
)} accessibilityHint={t('shared.actionsUsers.favourited_by.accessibilityHint')}
accessibilityRole='button' accessibilityRole='button'
style={[styles.text, { color: colors.blue }]} style={[styles.text, { color: colors.blue }]}
onPress={() => { onPress={() =>
analytics('timeline_shared_feedback_press_favourite', { navigation.push('Tab-Shared-Users', {
count: status.favourites_count reference: 'statuses',
}) id: status.id,
navigation.push('Tab-Shared-Users', { type: 'favourited_by',
reference: 'statuses',
id: status.id,
type: 'favourited_by',
count: status.favourites_count
})
}}
>
{t('shared.actionsUsers.favourited_by.text', {
count: status.favourites_count count: status.favourites_count
})} })
</CustomText> }
) : null} >
</View> {t('shared.actionsUsers.favourited_by.text', {
<View> count: status.favourites_count
{data && data.length > 1 ? ( })}
<CustomText </CustomText>
accessibilityLabel={t( ) : null}
'shared.actionsUsers.history.accessibilityLabel',
{
count: data.length - 1
}
)}
accessibilityHint={t(
'shared.actionsUsers.history.accessibilityHint'
)}
accessibilityRole='button'
style={[styles.text, { marginRight: 0, color: colors.blue }]}
onPress={() => {
analytics('timeline_shared_feedback_press_history', {
count: data.length - 1
})
navigation.push('Tab-Shared-History', { id: status.id })
}}
>
{t('shared.actionsUsers.history.text', {
count: data.length - 1
})}
</CustomText>
) : null}
</View>
</View> </View>
) <View>
}, {data && data.length > 1 ? (
(prev, next) => <CustomText
prev.status.edited_at === next.status.edited_at && accessibilityLabel={t('shared.actionsUsers.history.accessibilityLabel', {
prev.status.reblogs_count === next.status.reblogs_count && count: data.length - 1
prev.status.favourites_count === next.status.favourites_count })}
) accessibilityHint={t('shared.actionsUsers.history.accessibilityHint')}
accessibilityRole='button'
style={[styles.text, { marginRight: 0, color: colors.blue }]}
onPress={() => navigation.push('Tab-Shared-History', { id: status.id })}
>
{t('shared.actionsUsers.history.text', {
count: data.length - 1
})}
</CustomText>
) : null}
</View>
</View>
)
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
text: { text: {

View File

@ -1,39 +1,32 @@
import CustomText from '@components/Text' import CustomText from '@components/Text'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react' import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import StatusContext from './Context'
export interface Props { const TimelineFullConversation = () => {
queryKey?: QueryKeyTimeline const { queryKey, status, disableDetails } = useContext(StatusContext)
status: Mastodon.Status if (!status || disableDetails) return null
const { t } = useTranslation('componentTimeline')
const { colors } = useTheme()
return queryKey &&
queryKey[1].page !== 'Toot' &&
status.in_reply_to_account_id &&
(status.mentions.length === 0 ||
status.mentions.filter(mention => mention.id !== status.in_reply_to_account_id).length) ? (
<CustomText
fontStyle='S'
style={{
color: colors.blue,
marginTop: StyleConstants.Spacing.S
}}
>
{t('shared.fullConversation')}
</CustomText>
) : null
} }
const TimelineFullConversation = React.memo(
({ queryKey, status }: Props) => {
const { t } = useTranslation('componentTimeline')
const { colors } = useTheme()
return queryKey &&
queryKey[1].page !== 'Toot' &&
status.in_reply_to_account_id &&
(status.mentions.length === 0 ||
status.mentions.filter(
mention => mention.id !== status.in_reply_to_account_id
).length) ? (
<CustomText
fontStyle='S'
style={{
color: colors.blue,
marginTop: StyleConstants.Spacing.S
}}
>
{t('shared.fullConversation')}
</CustomText>
) : null
},
() => true
)
export default TimelineFullConversation export default TimelineFullConversation

View File

@ -0,0 +1,101 @@
import menuAccount from '@components/contextMenu/account'
import menuInstance from '@components/contextMenu/instance'
import menuShare from '@components/contextMenu/share'
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 { 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 } =
useContext(StatusContext)
if (Platform.OS !== 'android' || !status || disableDetails || disableOnPress) return null
const { colors } = useTheme()
const [openChange, setOpenChange] = useState(false)
const mShare = menuShare({
visibility: status.visibility,
type: 'status',
url: status.url || status.uri
})
const mAccount = menuAccount({
type: 'status',
openChange,
account: status.account,
queryKey
})
const mStatus = menuStatus({ status, queryKey, rootQueryKey })
const mInstance = menuInstance({ status, queryKey, rootQueryKey })
return (
<View style={{ position: 'absolute', top: 0, right: 0 }}>
{queryKey ? (
<DropdownMenu.Root onOpenChange={setOpenChange}>
<DropdownMenu.Trigger>
<View style={{ padding: StyleConstants.Spacing.L }}>
<Icon
name='MoreHorizontal'
color={colors.secondary}
size={StyleConstants.Font.Size.L}
/>
</View>
</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>
))}
</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>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
) : null}
</View>
)
}
export default TimelineHeaderAndroid

View File

@ -1,51 +1,26 @@
import analytics from '@components/analytics'
import Icon from '@components/Icon' import Icon from '@components/Icon'
import { displayMessage } from '@components/Message' import { displayMessage } from '@components/Message'
import { ParseEmojis } from '@components/Parse' import { ParseEmojis } from '@components/Parse'
import CustomText from '@components/Text' import CustomText from '@components/Text'
import { import { useTimelineMutation } from '@utils/queryHooks/timeline'
QueryKeyTimeline,
useTimelineMutation
} from '@utils/queryHooks/timeline'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useMemo } from 'react' import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Pressable, View } from 'react-native' import { Pressable, View } from 'react-native'
import { useQueryClient } from 'react-query' import { useQueryClient } from 'react-query'
import StatusContext from './Context'
import HeaderSharedCreated from './HeaderShared/Created' import HeaderSharedCreated from './HeaderShared/Created'
import HeaderSharedMuted from './HeaderShared/Muted' import HeaderSharedMuted from './HeaderShared/Muted'
const Names = ({ accounts }: { accounts: Mastodon.Account[] }) => {
const { t } = useTranslation('componentTimeline')
const { colors } = useTheme()
return (
<CustomText
numberOfLines={1}
style={{ ...StyleConstants.FontStyle.M, color: colors.secondary }}
>
<CustomText>{t('shared.header.conversation.withAccounts')}</CustomText>
{accounts.map((account, index) => (
<CustomText key={account.id} numberOfLines={1}>
{index !== 0 ? t('common:separator') : undefined}
<ParseEmojis
content={account.display_name || account.username}
emojis={account.emojis}
fontBold
/>
</CustomText>
))}
</CustomText>
)
}
export interface Props { export interface Props {
queryKey: QueryKeyTimeline
conversation: Mastodon.Conversation conversation: Mastodon.Conversation
} }
const HeaderConversation = ({ queryKey, conversation }: Props) => { const HeaderConversation = ({ conversation }: Props) => {
const { queryKey } = useContext(StatusContext)
if (!queryKey) return null
const { colors, theme } = useTheme() const { colors, theme } = useTheme()
const { t } = useTranslation('componentTimeline') const { t } = useTranslation('componentTimeline')
@ -71,31 +46,25 @@ const HeaderConversation = ({ queryKey, conversation }: Props) => {
} }
}) })
const actionOnPress = useCallback(() => {
analytics('timeline_conversation_delete_press')
mutation.mutate({
type: 'deleteItem',
source: 'conversations',
queryKey,
id: conversation.id
})
}, [])
const actionChildren = useMemo(
() => (
<Icon
name='Trash'
color={colors.secondary}
size={StyleConstants.Font.Size.L}
/>
),
[]
)
return ( return (
<View style={{ flex: 1, flexDirection: 'row' }}> <View style={{ flex: 1, flexDirection: 'row' }}>
<View style={{ flex: 3 }}> <View style={{ flex: 3 }}>
<Names accounts={conversation.accounts} /> <CustomText
numberOfLines={1}
style={{ ...StyleConstants.FontStyle.M, color: colors.secondary }}
>
<CustomText>{t('shared.header.conversation.withAccounts')}</CustomText>
{conversation.accounts.map((account, index) => (
<CustomText key={account.id} numberOfLines={1}>
{index !== 0 ? t('common:separator') : undefined}
<ParseEmojis
content={account.display_name || account.username}
emojis={account.emojis}
fontBold
/>
</CustomText>
))}
</CustomText>
<View <View
style={{ style={{
flexDirection: 'row', flexDirection: 'row',
@ -116,8 +85,15 @@ const HeaderConversation = ({ queryKey, conversation }: Props) => {
<Pressable <Pressable
style={{ flex: 1, flexDirection: 'row', justifyContent: 'center' }} style={{ flex: 1, flexDirection: 'row', justifyContent: 'center' }}
onPress={actionOnPress} onPress={() =>
children={actionChildren} mutation.mutate({
type: 'deleteItem',
source: 'conversations',
queryKey,
id: conversation.id
})
}
children={<Icon name='Trash' color={colors.secondary} size={StyleConstants.Font.Size.L} />}
/> />
</View> </View>
) )

View File

@ -1,114 +0,0 @@
import contextMenuAccount from '@components/ContextMenu/account'
import contextMenuInstance from '@components/ContextMenu/instance'
import contextMenuShare from '@components/ContextMenu/share'
import contextMenuStatus from '@components/ContextMenu/status'
import Icon from '@components/Icon'
import { useActionSheet } from '@expo/react-native-action-sheet'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { Pressable, View } from 'react-native'
import { ContextMenuAction } from 'react-native-context-menu-view'
import HeaderSharedAccount from './HeaderShared/Account'
import HeaderSharedApplication from './HeaderShared/Application'
import HeaderSharedCreated from './HeaderShared/Created'
import HeaderSharedMuted from './HeaderShared/Muted'
import HeaderSharedVisibility from './HeaderShared/Visibility'
export interface Props {
queryKey?: QueryKeyTimeline
status: Mastodon.Status
highlighted: boolean
}
const TimelineHeaderDefault = ({ queryKey, status, highlighted }: Props) => {
if (!queryKey) return null
const { t } = useTranslation('componentContextMenu')
const { colors } = useTheme()
const actions: ContextMenuAction[] = []
const shareOnPress =
status.visibility !== 'direct'
? contextMenuShare({
actions,
type: 'status',
url: status.url || status.uri
})
: null
const statusOnPress = contextMenuStatus({
actions,
status,
queryKey
})
const accountOnPress = contextMenuAccount({
actions,
type: 'status',
queryKey,
id: status.account.id
})
const instanceOnPress = contextMenuInstance({
actions,
status,
queryKey
})
const { showActionSheetWithOptions } = useActionSheet()
return (
<View style={{ flex: 1, flexDirection: 'row' }}>
<View style={{ flex: 7 }}>
<HeaderSharedAccount account={status.account} />
<View
style={{
flexDirection: 'row',
alignItems: 'center',
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} />
</View>
</View>
{queryKey ? (
<Pressable
accessibilityHint={t('accessibilityHint')}
style={{ flex: 1, flexBasis: StyleConstants.Font.Size.L }}
onPress={() =>
showActionSheetWithOptions(
{
options: actions.map(action => action.title),
cancelButtonIndex: 999,
destructiveButtonIndex: actions
.map((action, index) => (action.destructive ? index : 999))
.filter(num => num !== 999)
},
index => {
if (index !== undefined) {
for (const on of [shareOnPress, statusOnPress, accountOnPress, instanceOnPress]) {
on && on(index)
}
}
}
)
}
>
<Icon name='MoreHorizontal' color={colors.secondary} size={StyleConstants.Font.Size.L} />
</Pressable>
) : null}
</View>
)
}
export default TimelineHeaderDefault

View File

@ -1,75 +0,0 @@
import Icon from '@components/Icon'
import { QueryKeyTimeline } 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 ContextMenu from 'react-native-context-menu-view'
import { ContextMenuContext } from './ContextMenu'
import HeaderSharedAccount from './HeaderShared/Account'
import HeaderSharedApplication from './HeaderShared/Application'
import HeaderSharedCreated from './HeaderShared/Created'
import HeaderSharedMuted from './HeaderShared/Muted'
import HeaderSharedVisibility from './HeaderShared/Visibility'
export interface Props {
queryKey?: QueryKeyTimeline
status: Mastodon.Status
highlighted: boolean
}
const TimelineHeaderDefault = ({ queryKey, status, highlighted }: Props) => {
const { t } = useTranslation('componentContextMenu')
const { colors } = useTheme()
const contextMenuContext = useContext(ContextMenuContext)
return (
<View style={{ flex: 1, flexDirection: 'row' }}>
<View style={{ flex: 7 }}>
<HeaderSharedAccount account={status.account} />
<View
style={{
flexDirection: 'row',
alignItems: 'center',
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} />
</View>
</View>
{queryKey ? (
<Pressable
accessibilityHint={t('accessibilityHint')}
style={{ flex: 1, flexBasis: StyleConstants.Font.Size.L }}
>
<ContextMenu
style={{ flex: 1, alignItems: 'center' }}
dropdownMenuMode
actions={contextMenuContext}
onPress={() => {}}
children={
<Icon
name='MoreHorizontal'
color={colors.secondary}
size={StyleConstants.Font.Size.L}
/>
}
/>
</Pressable>
) : null}
</View>
)
}
export default TimelineHeaderDefault

View File

@ -0,0 +1,132 @@
import menuAccount from '@components/contextMenu/account'
import menuInstance from '@components/contextMenu/instance'
import menuShare from '@components/contextMenu/share'
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 { useTranslation } from 'react-i18next'
import { Platform, Pressable, View } from 'react-native'
import * as DropdownMenu from 'zeego/dropdown-menu'
import StatusContext from './Context'
import HeaderSharedAccount from './HeaderShared/Account'
import HeaderSharedApplication from './HeaderShared/Application'
import HeaderSharedCreated from './HeaderShared/Created'
import HeaderSharedMuted from './HeaderShared/Muted'
import HeaderSharedVisibility from './HeaderShared/Visibility'
const TimelineHeaderDefault: React.FC = () => {
const { queryKey, rootQueryKey, status, copiableContent, highlighted, disableDetails } =
useContext(StatusContext)
if (!status) return null
const { colors } = useTheme()
const { t } = useTranslation('componentContextMenu')
const [openChange, setOpenChange] = useState(false)
const mShare = menuShare({
visibility: status.visibility,
type: 'status',
url: status.url || status.uri,
copiableContent
})
const mAccount = menuAccount({
type: 'status',
openChange,
account: status.account,
queryKey
})
const mStatus = menuStatus({ status, queryKey, rootQueryKey })
const mInstance = menuInstance({ status, queryKey, rootQueryKey })
return (
<View style={{ flex: 1, flexDirection: 'row' }}>
<View style={{ flex: 7 }}>
<HeaderSharedAccount account={status.account} />
<View
style={{
flexDirection: 'row',
alignItems: 'center',
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} />
</View>
</View>
{Platform.OS !== 'android' && !disableDetails ? (
<Pressable
accessibilityHint={t('accessibilityHint')}
style={{ flex: 1, alignItems: 'center' }}
>
<DropdownMenu.Root onOpenChange={setOpenChange}>
<DropdownMenu.Trigger>
<Icon
name='MoreHorizontal'
color={colors.secondary}
size={StyleConstants.Font.Size.L}
/>
</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>
))}
</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>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</Pressable>
) : null}
</View>
)
}
export default TimelineHeaderDefault

View File

@ -1,157 +0,0 @@
import contextMenuAccount from '@components/ContextMenu/account'
import contextMenuInstance from '@components/ContextMenu/instance'
import contextMenuShare from '@components/ContextMenu/share'
import contextMenuStatus from '@components/ContextMenu/status'
import Icon from '@components/Icon'
import { RelationshipIncoming, RelationshipOutgoing } from '@components/Relationship'
import { useActionSheet } from '@expo/react-native-action-sheet'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useMemo } from 'react'
import { Pressable, View } from 'react-native'
import { ContextMenuAction } from 'react-native-context-menu-view'
import HeaderSharedAccount from './HeaderShared/Account'
import HeaderSharedApplication from './HeaderShared/Application'
import HeaderSharedCreated from './HeaderShared/Created'
import HeaderSharedMuted from './HeaderShared/Muted'
import HeaderSharedVisibility from './HeaderShared/Visibility'
export interface Props {
queryKey: QueryKeyTimeline
notification: Mastodon.Notification
}
const TimelineHeaderNotification = ({ queryKey, notification }: Props) => {
const { colors } = useTheme()
const contextMenuActions: ContextMenuAction[] = []
const status = notification.status
const shareOnPress =
status && status?.visibility !== 'direct'
? contextMenuShare({
actions: contextMenuActions,
type: 'status',
url: status.url || status.uri
})
: null
const statusOnPress =
status &&
contextMenuStatus({
actions: contextMenuActions,
status: status,
queryKey
})
const accountOnPress =
status &&
contextMenuAccount({
actions: contextMenuActions,
type: 'status',
queryKey,
id: status.account.id
})
const instanceOnPress =
status &&
contextMenuInstance({
actions: contextMenuActions,
status: status,
queryKey
})
const { showActionSheetWithOptions } = useActionSheet()
const actions = useMemo(() => {
switch (notification.type) {
case 'follow':
return <RelationshipOutgoing id={notification.account.id} />
case 'follow_request':
return <RelationshipIncoming id={notification.account.id} />
default:
if (notification.status) {
return (
<Pressable
style={{ flex: 1, flexBasis: StyleConstants.Font.Size.L }}
onPress={() =>
showActionSheetWithOptions(
{
options: contextMenuActions.map(action => action.title),
cancelButtonIndex: 999,
destructiveButtonIndex: contextMenuActions
.map((action, index) => (action.destructive ? index : 999))
.filter(num => num !== 999)
},
index => {
if (index !== undefined) {
for (const on of [
shareOnPress,
statusOnPress,
accountOnPress,
instanceOnPress
]) {
on && on(index)
}
}
}
)
}
children={
<Icon
name='MoreHorizontal'
color={colors.secondary}
size={StyleConstants.Font.Size.L}
/>
}
/>
)
}
}
}, [notification.type])
return (
<View style={{ flex: 1, flexDirection: 'row' }}>
<View
style={{
flex: notification.type === 'follow' || notification.type === 'follow_request' ? 1 : 4
}}
>
<HeaderSharedAccount
account={notification.status ? notification.status.account : notification.account}
{...((notification.type === 'follow' || notification.type === 'follow_request') && {
withoutName: true
})}
/>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
marginTop: StyleConstants.Spacing.XS,
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} />
</View>
</View>
<View
style={[
{ marginLeft: StyleConstants.Spacing.M },
notification.type === 'follow' || notification.type === 'follow_request'
? { flexShrink: 1 }
: { flex: 1 }
]}
>
{actions}
</View>
</View>
)
}
export default TimelineHeaderNotification

View File

@ -1,105 +0,0 @@
import Icon from '@components/Icon'
import { RelationshipIncoming, RelationshipOutgoing } from '@components/Relationship'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useContext, useMemo } from 'react'
import { Pressable, View } from 'react-native'
import ContextMenu from 'react-native-context-menu-view'
import { ContextMenuContext } from './ContextMenu'
import HeaderSharedAccount from './HeaderShared/Account'
import HeaderSharedApplication from './HeaderShared/Application'
import HeaderSharedCreated from './HeaderShared/Created'
import HeaderSharedMuted from './HeaderShared/Muted'
import HeaderSharedVisibility from './HeaderShared/Visibility'
export interface Props {
queryKey: QueryKeyTimeline
notification: Mastodon.Notification
}
const TimelineHeaderNotification = ({ notification }: Props) => {
const { colors } = useTheme()
const contextMenuContext = useContext(ContextMenuContext)
const actions = useMemo(() => {
switch (notification.type) {
case 'follow':
return <RelationshipOutgoing id={notification.account.id} />
case 'follow_request':
return <RelationshipIncoming id={notification.account.id} />
default:
if (notification.status) {
return (
<Pressable
style={{ flex: 1, flexBasis: StyleConstants.Font.Size.L }}
children={
<ContextMenu
style={{ flex: 1, alignItems: 'center' }}
dropdownMenuMode
actions={contextMenuContext}
onPress={() => {}}
children={
<Icon
name='MoreHorizontal'
color={colors.secondary}
size={StyleConstants.Font.Size.L}
/>
}
/>
}
/>
)
}
}
}, [notification.type])
return (
<View style={{ flex: 1, flexDirection: 'row' }}>
<View
style={{
flex: notification.type === 'follow' || notification.type === 'follow_request' ? 1 : 4
}}
>
<HeaderSharedAccount
account={notification.status ? notification.status.account : notification.account}
{...((notification.type === 'follow' || notification.type === 'follow_request') && {
withoutName: true
})}
/>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
marginTop: StyleConstants.Spacing.XS,
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} />
</View>
</View>
<View
style={[
{ marginLeft: StyleConstants.Spacing.M },
notification.type === 'follow' || notification.type === 'follow_request'
? { flexShrink: 1 }
: { flex: 1 }
]}
>
{actions}
</View>
</View>
)
}
export default TimelineHeaderNotification

View File

@ -0,0 +1,165 @@
import menuAccount from '@components/contextMenu/account'
import menuInstance from '@components/contextMenu/instance'
import menuShare from '@components/contextMenu/share'
import menuStatus from '@components/contextMenu/status'
import Icon from '@components/Icon'
import { RelationshipIncoming, RelationshipOutgoing } from '@components/Relationship'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useContext, useState } from 'react'
import { Platform, Pressable, View } from 'react-native'
import * as DropdownMenu from 'zeego/dropdown-menu'
import StatusContext from './Context'
import HeaderSharedAccount from './HeaderShared/Account'
import HeaderSharedApplication from './HeaderShared/Application'
import HeaderSharedCreated from './HeaderShared/Created'
import HeaderSharedMuted from './HeaderShared/Muted'
import HeaderSharedVisibility from './HeaderShared/Visibility'
export type Props = {
notification: Mastodon.Notification
}
const TimelineHeaderNotification: React.FC<Props> = ({ notification }) => {
const { queryKey, status } = useContext(StatusContext)
const { colors } = useTheme()
const [openChange, setOpenChange] = useState(false)
const mShare = menuShare({
visibility: status?.visibility,
type: 'status',
url: status?.url || status?.uri
})
const mAccount = menuAccount({
type: 'status',
openChange,
account: status?.account,
queryKey
})
const mStatus = menuStatus({ status, queryKey })
const mInstance = menuInstance({ status, queryKey })
const actions = () => {
switch (notification.type) {
case 'follow':
return <RelationshipOutgoing id={notification.account.id} />
case 'follow_request':
return <RelationshipIncoming id={notification.account.id} />
default:
if (status) {
return (
<Pressable
style={{ flex: 1, alignItems: 'center' }}
children={
<DropdownMenu.Root onOpenChange={setOpenChange}>
<DropdownMenu.Trigger>
<Icon
name='MoreHorizontal'
color={colors.secondary}
size={StyleConstants.Font.Size.L}
/>
</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>
))}
</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>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
}
/>
)
}
}
}
return (
<View style={{ flex: 1, flexDirection: 'row' }}>
<View
style={{
flex: notification.type === 'follow' || notification.type === 'follow_request' ? 1 : 4
}}
>
<HeaderSharedAccount
account={notification.status ? notification.status.account : notification.account}
{...((notification.type === 'follow' || notification.type === 'follow_request') && {
withoutName: true
})}
/>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
marginTop: StyleConstants.Spacing.XS,
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} />
</View>
</View>
{Platform.OS !== 'android' ? (
<View
style={[
{ marginLeft: StyleConstants.Spacing.M },
notification.type === 'follow' || notification.type === 'follow_request'
? { flexShrink: 1 }
: { flex: 1 }
]}
children={actions()}
/>
) : null}
</View>
)
}
export default TimelineHeaderNotification

View File

@ -1,4 +1,3 @@
import analytics from '@components/analytics'
import openLink from '@components/openLink' import openLink from '@components/openLink'
import CustomText from '@components/Text' import CustomText from '@components/Text'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
@ -20,9 +19,6 @@ const HeaderSharedApplication = React.memo(
fontStyle='S' fontStyle='S'
accessibilityRole='link' accessibilityRole='link'
onPress={async () => { onPress={async () => {
analytics('timeline_shared_header_application_press', {
application
})
application.website && (await openLink(application.website)) application.website && (await openLink(application.website))
}} }}
style={{ style={{

View File

@ -1,4 +1,3 @@
import analytics from '@components/analytics'
import Button from '@components/Button' import Button from '@components/Button'
import haptics from '@components/haptics' import haptics from '@components/haptics'
import Icon from '@components/Icon' import Icon from '@components/Icon'
@ -8,41 +7,28 @@ import RelativeTime from '@components/RelativeTime'
import CustomText from '@components/Text' import CustomText from '@components/Text'
import { import {
MutationVarsTimelineUpdateStatusProperty, MutationVarsTimelineUpdateStatusProperty,
QueryKeyTimeline,
useTimelineMutation useTimelineMutation
} from '@utils/queryHooks/timeline' } from '@utils/queryHooks/timeline'
import updateStatusProperty from '@utils/queryHooks/timeline/updateStatusProperty' import updateStatusProperty from '@utils/queryHooks/timeline/updateStatusProperty'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import { maxBy } from 'lodash' import { maxBy } from 'lodash'
import React, { useCallback, useMemo, useState } from 'react' import React, { useCallback, useContext, useMemo, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { Pressable, View } from 'react-native' import { Pressable, View } from 'react-native'
import { useQueryClient } from 'react-query' import { useQueryClient } from 'react-query'
import StatusContext from './Context'
export interface Props { const TimelinePoll: React.FC = () => {
queryKey: QueryKeyTimeline const { queryKey, rootQueryKey, status, isReblog, ownAccount, spoilerHidden, disableDetails } =
rootQueryKey?: QueryKeyTimeline useContext(StatusContext)
statusId: Mastodon.Status['id'] if (!queryKey || !status || !status.poll) return null
poll: NonNullable<Mastodon.Status['poll']> const poll = status.poll
reblog: boolean
sameAccount: boolean
}
const TimelinePoll: React.FC<Props> = ({
queryKey,
rootQueryKey,
statusId,
poll,
reblog,
sameAccount
}) => {
const { colors, theme } = useTheme() const { colors, theme } = useTheme()
const { t, i18n } = useTranslation('componentTimeline') const { t, i18n } = useTranslation('componentTimeline')
const [allOptions, setAllOptions] = useState( const [allOptions, setAllOptions] = useState(new Array(status.poll.options.length).fill(false))
new Array(poll.options.length).fill(false)
)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const mutation = useTimelineMutation({ const mutation = useTimelineMutation({
@ -82,18 +68,17 @@ const TimelinePoll: React.FC<Props> = ({
const pollButton = useMemo(() => { const pollButton = useMemo(() => {
if (!poll.expired) { if (!poll.expired) {
if (!sameAccount && !poll.voted) { if (!ownAccount && !poll.voted) {
return ( return (
<View style={{ marginRight: StyleConstants.Spacing.S }}> <View style={{ marginRight: StyleConstants.Spacing.S }}>
<Button <Button
onPress={() => { onPress={() =>
analytics('timeline_shared_vote_vote_press')
mutation.mutate({ mutation.mutate({
type: 'updateStatusProperty', type: 'updateStatusProperty',
queryKey, queryKey,
rootQueryKey, rootQueryKey,
id: statusId, id: status.id,
reblog, isReblog,
payload: { payload: {
property: 'poll', property: 'poll',
id: poll.id, id: poll.id,
@ -101,7 +86,7 @@ const TimelinePoll: React.FC<Props> = ({
options: allOptions options: allOptions
} }
}) })
}} }
type='text' type='text'
content={t('shared.poll.meta.button.vote')} content={t('shared.poll.meta.button.vote')}
loading={mutation.isLoading} loading={mutation.isLoading}
@ -113,21 +98,20 @@ const TimelinePoll: React.FC<Props> = ({
return ( return (
<View style={{ marginRight: StyleConstants.Spacing.S }}> <View style={{ marginRight: StyleConstants.Spacing.S }}>
<Button <Button
onPress={() => { onPress={() =>
analytics('timeline_shared_vote_refresh_press')
mutation.mutate({ mutation.mutate({
type: 'updateStatusProperty', type: 'updateStatusProperty',
queryKey, queryKey,
rootQueryKey, rootQueryKey,
id: statusId, id: status.id,
reblog, isReblog,
payload: { payload: {
property: 'poll', property: 'poll',
id: poll.id, id: poll.id,
type: 'refresh' type: 'refresh'
} }
}) })
}} }
type='text' type='text'
content={t('shared.poll.meta.button.refresh')} content={t('shared.poll.meta.button.refresh')}
loading={mutation.isLoading} loading={mutation.isLoading}
@ -136,14 +120,7 @@ const TimelinePoll: React.FC<Props> = ({
) )
} }
} }
}, [ }, [theme, i18n.language, poll.expired, poll.voted, allOptions, mutation.isLoading])
theme,
i18n.language,
poll.expired,
poll.voted,
allOptions,
mutation.isLoading
])
const isSelected = useCallback( const isSelected = useCallback(
(index: number): string => (index: number): string =>
@ -154,20 +131,13 @@ const TimelinePoll: React.FC<Props> = ({
) )
const pollBodyDisallow = useMemo(() => { const pollBodyDisallow = useMemo(() => {
const maxValue = maxBy( const maxValue = maxBy(poll.options, option => option.votes_count)?.votes_count
poll.options,
option => option.votes_count
)?.votes_count
return poll.options.map((option, index) => ( return poll.options.map((option, index) => (
<View <View key={index} style={{ flex: 1, paddingVertical: StyleConstants.Spacing.S }}>
key={index}
style={{ flex: 1, paddingVertical: StyleConstants.Spacing.S }}
>
<View style={{ flex: 1, flexDirection: 'row' }}> <View style={{ flex: 1, flexDirection: 'row' }}>
<Icon <Icon
style={{ style={{
paddingTop: paddingTop: StyleConstants.Font.LineHeight.M - StyleConstants.Font.Size.M,
StyleConstants.Font.LineHeight.M - StyleConstants.Font.Size.M,
marginRight: StyleConstants.Spacing.S marginRight: StyleConstants.Spacing.S
}} }}
name={ name={
@ -176,9 +146,7 @@ const TimelinePoll: React.FC<Props> = ({
}` as any }` as any
} }
size={StyleConstants.Font.Size.M} size={StyleConstants.Font.Size.M}
color={ color={poll.own_votes?.includes(index) ? colors.blue : colors.disabled}
poll.own_votes?.includes(index) ? colors.blue : colors.disabled
}
/> />
<CustomText style={{ flex: 1 }}> <CustomText style={{ flex: 1 }}>
<ParseEmojis content={option.title} emojis={poll.emojis} /> <ParseEmojis content={option.title} emojis={poll.emojis} />
@ -194,11 +162,7 @@ const TimelinePoll: React.FC<Props> = ({
}} }}
> >
{poll.votes_count {poll.votes_count
? Math.round( ? Math.round((option.votes_count / (poll.voters_count || poll.votes_count)) * 100)
(option.votes_count /
(poll.voters_count || poll.votes_count)) *
100
)
: 0} : 0}
% %
</CustomText> </CustomText>
@ -213,11 +177,9 @@ const TimelinePoll: React.FC<Props> = ({
marginTop: StyleConstants.Spacing.XS, marginTop: StyleConstants.Spacing.XS,
marginBottom: StyleConstants.Spacing.S, marginBottom: StyleConstants.Spacing.S,
width: `${Math.round( width: `${Math.round(
(option.votes_count / (poll.voters_count || poll.votes_count)) * (option.votes_count / (poll.voters_count || poll.votes_count)) * 100
100
)}%`, )}%`,
backgroundColor: backgroundColor: option.votes_count === maxValue ? colors.blue : colors.disabled
option.votes_count === maxValue ? colors.blue : colors.disabled
}} }}
/> />
</View> </View>
@ -229,21 +191,15 @@ const TimelinePoll: React.FC<Props> = ({
key={index} key={index}
style={{ flex: 1, paddingVertical: StyleConstants.Spacing.S }} style={{ flex: 1, paddingVertical: StyleConstants.Spacing.S }}
onPress={() => { onPress={() => {
analytics('timeline_shared_vote_option_press')
!allOptions[index] && haptics('Light') !allOptions[index] && haptics('Light')
if (poll.multiple) { if (poll.multiple) {
setAllOptions(allOptions.map((o, i) => (i === index ? !o : o))) setAllOptions(allOptions.map((o, i) => (i === index ? !o : o)))
} else { } else {
{ {
const otherOptions = const otherOptions = allOptions[index] === false ? false : undefined
allOptions[index] === false ? false : undefined
setAllOptions( setAllOptions(
allOptions.map((o, i) => allOptions.map((o, i) =>
i === index i === index ? !o : otherOptions !== undefined ? otherOptions : o
? !o
: otherOptions !== undefined
? otherOptions
: o
) )
) )
} }
@ -253,8 +209,7 @@ const TimelinePoll: React.FC<Props> = ({
<View style={{ flex: 1, flexDirection: 'row' }}> <View style={{ flex: 1, flexDirection: 'row' }}>
<Icon <Icon
style={{ style={{
paddingTop: paddingTop: StyleConstants.Font.LineHeight.M - StyleConstants.Font.Size.M,
StyleConstants.Font.LineHeight.M - StyleConstants.Font.Size.M,
marginRight: StyleConstants.Spacing.S marginRight: StyleConstants.Spacing.S
}} }}
name={isSelected(index)} name={isSelected(index)}
@ -271,13 +226,9 @@ const TimelinePoll: React.FC<Props> = ({
const pollVoteCounts = () => { const pollVoteCounts = () => {
if (poll.voters_count !== null) { if (poll.voters_count !== null) {
return ( return t('shared.poll.meta.count.voters', { count: poll.voters_count }) + ' • '
t('shared.poll.meta.count.voters', { count: poll.voters_count }) + ' • '
)
} else if (poll.votes_count !== null) { } else if (poll.votes_count !== null) {
return ( return t('shared.poll.meta.count.votes', { count: poll.votes_count }) + ' • '
t('shared.poll.meta.count.votes', { count: poll.votes_count }) + ' • '
)
} }
} }
@ -296,6 +247,8 @@ const TimelinePoll: React.FC<Props> = ({
} }
} }
if (spoilerHidden || disableDetails) return null
return ( return (
<View style={{ marginTop: StyleConstants.Spacing.M }}> <View style={{ marginTop: StyleConstants.Spacing.M }}>
{poll.expired || poll.voted ? pollBodyDisallow : pollBodyAllow} {poll.expired || poll.voted ? pollBodyDisallow : pollBodyAllow}
@ -308,10 +261,7 @@ const TimelinePoll: React.FC<Props> = ({
}} }}
> >
{pollButton} {pollButton}
<CustomText <CustomText fontStyle='S' style={{ flexShrink: 1, color: colors.secondary }}>
fontStyle='S'
style={{ flexShrink: 1, color: colors.secondary }}
>
{pollVoteCounts()} {pollVoteCounts()}
{pollExpiration()} {pollExpiration()}
</CustomText> </CustomText>

View File

@ -1,4 +1,3 @@
import analytics from '@components/analytics'
import { ParseHTML } from '@components/Parse' import { ParseHTML } from '@components/Parse'
import CustomText from '@components/Text' import CustomText from '@components/Text'
import getLanguage from '@helpers/getLanguage' import getLanguage from '@helpers/getLanguage'
@ -6,137 +5,119 @@ import { useTranslateQuery } from '@utils/queryHooks/translate'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import * as Localization from 'expo-localization' import * as Localization from 'expo-localization'
import React, { useEffect, useState } from 'react' import React, { useContext, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Pressable } from 'react-native' import { Pressable } from 'react-native'
import { Circle } from 'react-native-animated-spinkit' import { Circle } from 'react-native-animated-spinkit'
import detectLanguage from 'react-native-language-detection' import detectLanguage from 'react-native-language-detection'
import StatusContext from './Context'
export interface Props { const TimelineTranslate = () => {
highlighted: boolean const { status, highlighted } = useContext(StatusContext)
status: Pick<Mastodon.Status, 'language' | 'spoiler_text' | 'content' | 'emojis'> if (!status || !highlighted) return null
}
const TimelineTranslate = React.memo( const { t } = useTranslation('componentTimeline')
({ highlighted, status }: Props) => { const { colors } = useTheme()
if (!highlighted) {
return null const text = status.spoiler_text ? [status.spoiler_text, status.content] : [status.content]
for (const i in text) {
for (const emoji of status.emojis) {
text[i] = text[i].replaceAll(`:${emoji.shortcode}:`, ' ')
} }
text[i] = text[i]
.replace(/(<([^>]+)>)/gi, ' ')
.replace(/@.*? /gi, ' ')
.replace(/#.*? /gi, ' ')
.replace(/http(s):\/\/.*? /gi, ' ')
}
const { t } = useTranslation('componentTimeline') const [detectedLanguage, setDetectedLanguage] = useState<string>('')
const { colors } = useTheme() useEffect(() => {
const detect = async () => {
const text = status.spoiler_text ? [status.spoiler_text, status.content] : [status.content] const result = await detectLanguage(text.join(`\n\n`)).catch(() => {
// No need to log language detection failure
for (const i in text) { })
for (const emoji of status.emojis) { result?.detected && setDetectedLanguage(result.detected.slice(0, 2))
text[i] = text[i].replaceAll(`:${emoji.shortcode}:`, ' ')
}
text[i] = text[i]
.replace(/(<([^>]+)>)/gi, ' ')
.replace(/@.*? /gi, ' ')
.replace(/#.*? /gi, ' ')
.replace(/http(s):\/\/.*? /gi, ' ')
} }
detect()
}, [])
const [detectedLanguage, setDetectedLanguage] = useState<string>('') const settingsLanguage = getLanguage()
useEffect(() => { const targetLanguage = settingsLanguage?.startsWith('en')
const detect = async () => { ? Localization.locale || settingsLanguage || 'en'
const result = await detectLanguage(text.join(`\n\n`)).catch(() => { : settingsLanguage || Localization.locale || 'en'
// No need to log language detection failure
})
result?.detected && setDetectedLanguage(result.detected.slice(0, 2))
}
detect()
}, [])
const settingsLanguage = getLanguage() const [enabled, setEnabled] = useState(false)
const targetLanguage = settingsLanguage?.startsWith('en') const { refetch, data, isLoading, isSuccess, isError } = useTranslateQuery({
? Localization.locale || settingsLanguage || 'en' source: detectedLanguage,
: settingsLanguage || Localization.locale || 'en' target: targetLanguage,
text,
options: { enabled }
})
const [enabled, setEnabled] = useState(false) if (!detectedLanguage) {
const { refetch, data, isLoading, isSuccess, isError } = useTranslateQuery({ return null
source: detectedLanguage, }
target: targetLanguage, if (Localization.locale.slice(0, 2).includes(detectedLanguage)) {
text, return null
options: { enabled } }
}) if (settingsLanguage?.slice(0, 2).includes(detectedLanguage)) {
return null
}
if (!detectedLanguage) { return (
return null <>
} <Pressable
if (Localization.locale.slice(0, 2).includes(detectedLanguage)) { style={{
return null flexDirection: 'row',
} alignItems: 'center',
if (settingsLanguage?.slice(0, 2).includes(detectedLanguage)) { paddingVertical: StyleConstants.Spacing.S,
return null paddingBottom: isSuccess ? 0 : undefined
} }}
onPress={() => {
return ( if (enabled) {
<> if (!isSuccess) {
<Pressable refetch()
style={{
flexDirection: 'row',
alignItems: 'center',
paddingVertical: StyleConstants.Spacing.S,
paddingBottom: isSuccess ? 0 : undefined
}}
onPress={() => {
if (enabled) {
if (!isSuccess) {
analytics('timeline_shared_translate_retry', {
language: detectedLanguage
})
refetch()
}
} else {
analytics('timeline_shared_translate', {
language: detectedLanguage
})
setEnabled(true)
} }
} else {
setEnabled(true)
}
}}
>
<CustomText
fontStyle='M'
style={{
color: isLoading || isSuccess ? colors.secondary : isError ? colors.red : colors.blue
}} }}
> >
<CustomText {isError
fontStyle='M' ? t('shared.translate.failed')
style={{ : isSuccess
color: isLoading || isSuccess ? colors.secondary : isError ? colors.red : colors.blue ? typeof data?.error === 'string'
}} ? t(`shared.translate.${data.error}`)
> : t('shared.translate.succeed', {
{isError provider: data?.provider,
? t('shared.translate.failed') source: data?.sourceLanguage
: isSuccess })
? typeof data?.error === 'string' : t('shared.translate.default')}
? t(`shared.translate.${data.error}`) </CustomText>
: t('shared.translate.succeed', { <CustomText>
provider: data?.provider, {__DEV__ ? ` Source: ${detectedLanguage}; Target: ${targetLanguage}` : undefined}
source: data?.sourceLanguage </CustomText>
}) {isLoading ? (
: t('shared.translate.default')} <Circle
</CustomText> size={StyleConstants.Font.Size.M}
<CustomText> color={colors.disabled}
{__DEV__ ? ` Source: ${detectedLanguage}; Target: ${targetLanguage}` : undefined} style={{ marginLeft: StyleConstants.Spacing.S }}
</CustomText> />
{isLoading ? ( ) : null}
<Circle </Pressable>
size={StyleConstants.Font.Size.M} {data && data.error === undefined
color={colors.disabled} ? data.text.map((d, i) => <ParseHTML key={i} content={d} size={'M'} numberOfLines={999} />)
style={{ marginLeft: StyleConstants.Spacing.S }} : null}
/> </>
) : null} )
</Pressable> }
{data && data.error === undefined
? data.text.map((d, i) => (
<ParseHTML key={i} content={d} size={'M'} numberOfLines={999} />
))
: null}
</>
)
},
(prev, next) =>
prev.status.content === next.status.content &&
prev.status.spoiler_text === next.status.spoiler_text
)
export default TimelineTranslate export default TimelineTranslate

View File

@ -1,7 +0,0 @@
import * as Analytics from 'expo-firebase-analytics'
const analytics = (event: string, params?: { [key: string]: any }) => {
Analytics.logEvent(event, params).catch(() => {})
}
export default analytics

View File

@ -0,0 +1,238 @@
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 {
QueryKeyRelationship,
useRelationshipMutation,
useRelationshipQuery
} from '@utils/queryHooks/relationship'
import {
MutationVarsTimelineUpdateAccountProperty,
QueryKeyTimeline,
useTimelineMutation
} from '@utils/queryHooks/timeline'
import { getInstanceAccount } from '@utils/slices/instancesSlice'
import { useTheme } from '@utils/styles/ThemeManager'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Platform } from 'react-native'
import { useQueryClient } from 'react-query'
import { useSelector } from 'react-redux'
const menuAccount = ({
type,
openChange,
account,
queryKey,
rootQueryKey
}: {
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 []
const navigation =
useNavigation<NativeStackNavigationProp<TabSharedStackParamList, any, undefined>>()
const { theme } = useTheme()
const { t } = useTranslation('componentContextMenu')
const menus: ContextMenu[][] = [[]]
const instanceAccount = useSelector(getInstanceAccount, (prev, next) => prev.id === next.id)
const ownAccount = instanceAccount?.id === account.id
const [enabled, setEnabled] = useState(openChange)
useEffect(() => {
if (!ownAccount && enabled === false && openChange === true) {
setEnabled(true)
}
}, [openChange, enabled])
const { data, isFetching } = useRelationshipQuery({ id: account.id, options: { enabled } })
const queryClient = useQueryClient()
const timelineMutation = useTimelineMutation({
onSuccess: (_, params) => {
queryClient.refetchQueries(['Relationship', { id: account.id }])
const theParams = params as MutationVarsTimelineUpdateAccountProperty
displayMessage({
theme,
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()
})
})
})
})
},
onError: (err: any, params) => {
const theParams = params as MutationVarsTimelineUpdateAccountProperty
displayMessage({
theme,
type: 'error',
message: t('common:message.error.message', {
function: t(`account.${theParams.payload.property}.action`, {
...(theParams.payload.property !== 'reports' && {
context: (theParams.payload.currentValue || false).toString()
})
})
}),
...(err.status &&
typeof err.status === 'number' &&
err.data &&
err.data.error &&
typeof err.data.error === 'string' && {
description: err.data.error
})
})
},
onSettled: () => {
queryKey && queryClient.invalidateQueries(queryKey)
rootQueryKey && queryClient.invalidateQueries(rootQueryKey)
}
})
const queryKeyRelationship: QueryKeyRelationship = ['Relationship', { id: account.id }]
const relationshipMutation = useRelationshipMutation({
onSuccess: (res, { payload: { action } }) => {
haptics('Success')
queryClient.setQueryData<Mastodon.Relationship[]>(queryKeyRelationship, [res])
if (action === 'block') {
const queryKey: QueryKeyTimeline = ['Timeline', { page: 'Following' }]
queryClient.invalidateQueries(queryKey)
}
},
onError: (err: any, { payload: { action } }) => {
displayMessage({
theme,
type: 'error',
message: t('common:message.error.message', {
function: t(`${action}.function`)
}),
...(err.status &&
typeof err.status === 'number' &&
err.data &&
err.data.error &&
typeof err.data.error === 'string' && {
description: err.data.error
})
})
}
})
if (!ownAccount && Platform.OS !== 'android' && type !== 'account') {
menus[0].push({
key: 'account-following',
item: {
onSelect: () =>
data &&
relationshipMutation.mutate({
id: account.id,
type: 'outgoing',
payload: { action: 'follow', state: !data?.requested ? data.following : true }
}),
disabled: !data || isFetching,
destructive: false,
hidden: false
},
title: !data?.requested
? t('account.following.action', {
context: (data?.following || false).toString()
})
: t('componentRelationship:button.requested'),
icon: !data?.requested
? data?.following
? 'person.badge.minus'
: 'person.badge.plus'
: 'person.badge.minus'
})
}
if (!ownAccount) {
menus[0].push({
key: 'account-list',
item: {
onSelect: () => navigation.navigate('Tab-Shared-Account-In-Lists', { account }),
disabled: Platform.OS !== 'android' ? !data || isFetching : false,
destructive: false,
hidden: isFetching ? false : !data?.following
},
title: t('account.inLists'),
icon: 'checklist'
})
menus[0].push({
key: 'account-mute',
item: {
onSelect: () =>
timelineMutation.mutate({
type: 'updateAccountProperty',
queryKey,
id: account.id,
payload: { property: 'mute', currentValue: data?.muting }
}),
disabled: Platform.OS !== 'android' ? !data || isFetching : false,
destructive: false,
hidden: false
},
title: t('account.mute.action', {
context: (data?.muting || false).toString()
}),
icon: data?.muting ? 'eye' : 'eye.slash'
})
}
!ownAccount &&
menus.push([
{
key: 'account-block',
item: {
onSelect: () =>
timelineMutation.mutate({
type: 'updateAccountProperty',
queryKey,
id: account.id,
payload: { property: 'block', currentValue: data?.blocking }
}),
disabled: Platform.OS !== 'android' ? !data || isFetching : false,
destructive: !data?.blocking,
hidden: false
},
title: t('account.block.action', {
context: (data?.blocking || false).toString()
}),
icon: data?.blocking ? 'checkmark.circle' : 'xmark.circle'
},
{
key: 'account-reports',
item: {
onSelect: () => {
timelineMutation.mutate({
type: 'updateAccountProperty',
queryKey,
id: account.id,
payload: { property: 'reports' }
})
timelineMutation.mutate({
type: 'updateAccountProperty',
queryKey,
id: account.id,
payload: { property: 'block', currentValue: false }
})
},
disabled: false,
destructive: true,
hidden: false
},
title: t('account.reports.action'),
icon: 'flag'
}
])
return menus
}
export default menuAccount

6
src/components/contextMenu/index.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
type ContextMenu = {
key: string
item: { onSelect: () => void; disabled: boolean; destructive: boolean; hidden: boolean }
title: string
icon: string
}

View File

@ -1,4 +1,5 @@
import apiInstance from '@api/instance' import apiInstance from '@api/instance'
import browserPackage from '@helpers/browserPackage'
import navigationRef from '@helpers/navigationRef' import navigationRef from '@helpers/navigationRef'
import { matchAccount, matchStatus } from '@helpers/urlMatcher' import { matchAccount, matchStatus } from '@helpers/urlMatcher'
import { store } from '@root/store' import { store } from '@root/store'
@ -91,7 +92,8 @@ const openLink = async (url: string, navigation?: any) => {
case 'internal': case 'internal':
await WebBrowser.openBrowserAsync(encodeURI(url), { await WebBrowser.openBrowserAsync(encodeURI(url), {
dismissButtonStyle: 'close', dismissButtonStyle: 'close',
enableBarCollapsing: true enableBarCollapsing: true,
browserPackage: await browserPackage()
}) })
break break
case 'external': case 'external':

View File

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

View File

@ -24,6 +24,11 @@
"version": 3.5, "version": 3.5,
"reference": "https://github.com/mastodon/mastodon/releases/tag/v3.5.0" "reference": "https://github.com/mastodon/mastodon/releases/tag/v3.5.0"
}, },
{
"feature": "trends_new_path",
"version": 3.5,
"reference": "https://github.com/mastodon/mastodon/releases/tag/v3.5.0"
},
{ {
"feature": "follow_tags", "feature": "follow_tags",
"version": 4.0, "version": 4.0,

30
src/i18n/ca/common.json Normal file
View File

@ -0,0 +1,30 @@
{
"buttons": {
"OK": "D'acord",
"apply": "Aplica",
"cancel": "Cancel·la",
"discard": "Descarta",
"continue": "Continua",
"delete": "Esborra",
"done": "Fet"
},
"customEmoji": {
"accessibilityLabel": "Emoji personalitzat {{emoji}}"
},
"message": {
"success": {
"message": "{{function}} amb èxit"
},
"warning": {
"message": ""
},
"error": {
"message": "{{function}} ha fallat, torna-ho a intentar"
}
},
"separator": ", ",
"discard": {
"title": "Canvis no desats",
"message": "Els canvis no han sigut desats. Vols descartar-los?"
}
}

View File

@ -0,0 +1,81 @@
{
"accessibilityHint": "Accions per aquesta publicació, com el seu usuari o la mateixa publicació",
"account": {
"title": "Accions d'usuari",
"following": {
"action_false": "Segueix l'usuari",
"action_true": "Deixa de seguir l'usuari"
},
"inLists": "Gestionar usuari de llistes",
"mute": {
"action_false": "Silencia l'usuari",
"action_true": "Deixa de silenciar l'usuari"
},
"block": {
"action_false": "Bloqueja l'usuari",
"action_true": "Deixa de bloquejar l'usuari"
},
"reports": {
"action": "Denuncia i bloqueja l'usuari"
}
},
"copy": {
"action": "Copia la publicació",
"succeed": "Copiat"
},
"instance": {
"title": "Acció de la instància",
"block": {
"action": "Bloquejar la instància {{instance}}",
"alert": {
"title": "Confirma el bloqueig de la instància {{instance}}?",
"message": "Pots silenciar o bloquejar a un usuari.\n\nDesprés de bloquejar una instància, tot el seu contingut, amb els seus seguidors, seran esborrats!",
"buttons": {
"confirm": "Confirma"
}
}
}
},
"share": {
"status": {
"action": "Comparteix la publicació"
},
"account": {
"action": "Comparteix l'usuari"
}
},
"status": {
"title": "Accions de la publicació",
"edit": {
"action": "Edita la publicació"
},
"delete": {
"action": "Elimina la publicació",
"alert": {
"title": "Confirma l'eliminació?",
"message": "Tots els impulsos i favorits s'esborraran, incloses totes les respostes.",
"buttons": {
"confirm": "Confirma"
}
}
},
"deleteEdit": {
"action": "Elimina la publicació i torna a publicar",
"alert": {
"title": "Confirma l'eliminació i tornar a publicar?",
"message": "Tots els impulsos i favorits s'esborraran, incloses totes les respostes.",
"buttons": {
"confirm": "Confirma"
}
}
},
"mute": {
"action_false": "Silencia la publicació i les respostes",
"action_true": "Deixa de silenciar la publicació i les respostes"
},
"pin": {
"action_false": "Fixa la publicació",
"action_true": "Deixa de fixar la publicació"
}
}
}

View File

@ -0,0 +1,3 @@
{
"frequentUsed": "D'ús freqüent"
}

View File

@ -0,0 +1,26 @@
{
"server": {
"textInput": {
"placeholder": "Domini de la instància"
},
"button": "Inicia la sessió",
"information": {
"name": "Nom",
"accounts": "Usuaris",
"statuses": "Publicacions",
"domains": "Universos"
},
"disclaimer": {
"base": "L'inici de sessió fa servir el navegador del sistema. Per tant, el tooot no accedirà a la informació del compte."
},
"terms": {
"base": "En iniciar la sessió, acceptes la <0>política de privacitat</0> i les <1>condicions del servei</1>."
}
},
"update": {
"alert": {
"title": "Iniciada la sessió a aquesta instància",
"message": "Pots iniciar la sessió a un altre compte, mantenint els existents connectats"
}
}
}

View File

@ -0,0 +1,10 @@
{
"title": "Selecciona origen multimèdia",
"message": "Les dades multimèdia EXIF no s'han penjat",
"options": {
"image": "Penja fotos",
"image_max": "Penja fotos (màx. {{max}})",
"video": "Penja vídeo",
"video_max": "Penja vídeo (màx. {{max}})"
}
}

View File

@ -0,0 +1,8 @@
{
"HTML": {
"accessibilityHint": "Prem per expandir o contraure el contingut",
"expanded": "{{hint}}{{moreLines}}",
"moreLines": " ({{count}} línies més)",
"defaultHint": "Publicació llarga"
}
}

View File

@ -0,0 +1,16 @@
{
"follow": {
"function": "Segueix l'usuari"
},
"block": {
"function": "Bloqueja l'usuari"
},
"button": {
"error": "Error en carregar",
"blocked_by": "Bloquejat per l'usuari",
"blocking": "Desbloquejar",
"following": "Deixa de seguir",
"requested": "Retirar la sol·licitud",
"default": "Segueix"
}
}

View File

@ -0,0 +1,152 @@
{
"empty": {
"error": {
"message": "Error en carregar",
"button": "Torna-ho a provar"
},
"success": {
"message": "Cronologia buida"
}
},
"end": {
"message": "Ja estàs, vols una tassa de <0 />?"
},
"lookback": {
"message": "Llegit a"
},
"refresh": {
"fetchPreviousPage": "Més recent des d'aquí",
"refetch": "A l'últim"
},
"shared": {
"actioned": {
"pinned": "Fixat",
"favourite": "{{name}} ha marcat la teva publicació com a favorita",
"status": "{{name}} ha publicat",
"follow": "{{name}} et segueix",
"follow_request": "{{name}} ha sol·licitat seguir-te",
"poll": "S'ha acabat una enquesta en què havies participat",
"reblog": {
"default": "{{name}} ha impulsat",
"notification": "{{name}} ha impulsat la teva publicació"
},
"update": "L'impuls ha sigut editat"
},
"actions": {
"reply": {
"accessibilityLabel": "Respon a aquesta publicació"
},
"reblogged": {
"accessibilityLabel": "Impulsa aquesta publicació",
"function": "Impulsa la publicació",
"options": {
"title": "Escull la visibilitat de l'impuls",
"public": "Impuls públic",
"unlisted": "Impuls no llistat"
}
},
"favourited": {
"accessibilityLabel": "Afegeix aquesta publicació a favorits",
"function": "Marca la publicació com a favorita"
},
"bookmarked": {
"accessibilityLabel": "Afegeix aquesta publicació a marcadors",
"function": "Afegeix la publicació a marcadors"
}
},
"actionsUsers": {
"reblogged_by": {
"accessibilityLabel": "{{count}} usuaris han impulsat aquesta publicació",
"accessibilityHint": "Prem per conèixer els usuaris",
"text": "$t(screenTabs:shared.users.statuses.reblogged_by)"
},
"favourited_by": {
"accessibilityLabel": "{{count}} usuaris han marcat com a favorits aquesta publicació",
"accessibilityHint": "Prem per conèixer els usuaris",
"text": "$t(screenTabs:shared.users.statuses.favourited_by)"
},
"history": {
"accessibilityLabel": "Aquesta publicació ha sigut editada unes {{count}} vegades",
"accessibilityHint": "Prem per veure tot l'historial d'edicions",
"text_one": "{{count}} edició",
"text_other": "{{count}} edicions"
}
},
"attachment": {
"sensitive": {
"button": "Mostra contingut sensible"
},
"unsupported": {
"text": "Error en carregar",
"button": "Prova amb l'enllaç remot"
}
},
"avatar": {
"accessibilityLabel": "Avatar de {{name}}",
"accessibilityHint": "Prem per anar a la pàgina de {{name}}"
},
"content": {
"expandHint": "Contingut ocult"
},
"filtered": "Filtrat: {{phrase}}.",
"fullConversation": "Llegeix conversacions",
"translate": {
"default": "Tradueix",
"succeed": "Traduït per {{provider}} amb {{source}}",
"failed": "Error al traduir",
"source_not_supported": "L'idioma de la publicació no està suportada",
"target_not_supported": "Aquest idioma no està suportat"
},
"header": {
"shared": {
"account": {
"name": {
"accessibilityHint": "Nom d'usuari"
},
"account": {
"accessibilityHint": "Compte d'usuari"
}
},
"application": "Amb {{application}}",
"edited": {
"accessibilityLabel": "Publicació editada"
},
"muted": {
"accessibilityLabel": "Publicació silenciada"
},
"visibility": {
"direct": {
"accessibilityLabel": "La publicació és un missatge directe"
},
"private": {
"accessibilityLabel": "La publicació és visible només per als seguidors"
}
}
},
"conversation": {
"withAccounts": "Amb",
"delete": {
"function": "Esborra el missatge directe"
}
}
},
"poll": {
"meta": {
"button": {
"vote": "Vota",
"refresh": "Actualitza"
},
"count": {
"voters_one": "{{count}} usuari ha votat",
"voters_other": "{{count}} usuaris han votat",
"votes_one": "{{count}} vot",
"votes_other": "{{count}} vots"
},
"expiration": {
"expired": "Votació finalitzada",
"until": "Finalitza <0 />"
}
}
}
}
}

18
src/i18n/ca/index.ts Normal file
View File

@ -0,0 +1,18 @@
export default {
common: require('./common'),
screens: require('./screens'),
screenActions: require('./screens/actions'),
screenAnnouncements: require('./screens/announcements'),
screenCompose: require('./screens/compose'),
screenImageViewer: require('./screens/imageViewer'),
screenTabs: require('./screens/tabs'),
componentContextMenu: require('./components/contextMenu'),
componentEmojis: require('./components/emojis'),
componentInstance: require('./components/instance'),
componentMediaSelector: require('./components/mediaSelector'),
componentParse: require('./components/parse'),
componentRelationship: require('./components/relationship'),
componentTimeline: require('./components/timeline')
}

18
src/i18n/ca/screens.json Normal file
View File

@ -0,0 +1,18 @@
{
"screenshot": {
"title": "Protecció de la privacitat",
"message": "Si us plau, no revelis la identitat d'altres usuaris, així com el nom d'usuari, avatar, etc. Gràcies!",
"button": "Confirma"
},
"localCorrupt": {
"message": "La sessió ha sigut expirada. Si us plau, torna a iniciar la sessió"
},
"pushError": {
"message": "Error del servei push",
"description": "Si us plau, torna a habilitar les notificacions push a la configuració"
},
"shareError": {
"imageNotSupported": "Format d'imatge {{type}} no suportat",
"videoNotSupported": "Format de vídeo {{type}} no suportat"
}
}

View File

@ -0,0 +1,6 @@
{
"heading": "Comparteix amb...",
"content": {
"select_account": "Selecciona el compte"
}
}

View File

@ -0,0 +1,20 @@
{
"content": {
"altText": {
"heading": "Text alternatiu"
},
"notificationsFilter": {
"heading": "Mostra els tipus de notificació",
"content": {
"follow": "$t(screenTabs:me.push.follow.heading)",
"follow_request": "Sol·licitud de seguiment",
"favourite": "$t(screenTabs:me.push.favourite.heading)",
"reblog": "$t(screenTabs:me.push.reblog.heading)",
"mention": "$t(screenTabs:me.push.mention.heading)",
"poll": "$t(screenTabs:me.push.poll.heading)",
"status": "Publicació d'usuaris subscrits",
"update": "L'impuls ha sigut editat"
}
}
}
}

View File

@ -0,0 +1,10 @@
{
"heading": "Avisos",
"content": {
"published": "S'ha publicat <0 />",
"button": {
"read": "Llegit",
"unread": "Marca com a llegit"
}
}
}

View File

@ -0,0 +1,175 @@
{
"heading": {
"left": {
"button": "Cancel·la",
"alert": {
"title": "Voleu cancel·lar l'edició?",
"buttons": {
"save": "Desa l'esborrany",
"delete": "Esborra l'esborrany",
"cancel": "Cancel·la"
}
}
},
"right": {
"button": {
"default": "Publicació",
"conversation": "Envia un missatge directe",
"reply": "Resposta de la publicació",
"deleteEdit": "Publicació",
"edit": "Publicació",
"share": "Publicació"
},
"alert": {
"default": {
"title": "Error en publicar",
"button": "Torna a provar"
},
"removeReply": {
"title": "No s'ha pogut trobar la publicació resposta",
"description": "La publicació resposta és possible que hagi sigut esborrada. Vols eliminar-ho de la teva referència?",
"confirm": "Elimina la referència"
}
}
}
},
"content": {
"root": {
"header": {
"postingAs": "Publicant la publicació com a @{{acct}}@{{domain}}",
"spoilerInput": {
"placeholder": "Missatge d'alerta d'espòiler"
},
"textInput": {
"placeholder": "Què et passa pel cap?",
"keyboardImage": {
"exceedMaximum": {
"title": "S'ha arribat al nombre màxim d'adjunts",
"OK": "$t(common:buttons.OK)"
}
}
}
},
"footer": {
"attachments": {
"sensitive": "Marca els adjunts com a contingut sensible",
"remove": {
"accessibilityLabel": "Esborra l'adjunt afegit, número {{attachment}}"
},
"edit": {
"accessibilityLabel": "Edita l'adjunt afegit, número {{attachment}}"
},
"upload": {
"accessibilityLabel": "Afegeix més adjunts"
}
},
"emojis": {
"accessibilityHint": "Toca per a afegir emojis a la publicació"
},
"poll": {
"option": {
"placeholder": {
"accessibilityLabel": "Resposta {{index}}",
"single": "Resposta única",
"multiple": "Resposta múltiple"
}
},
"quantity": {
"reduce": {
"accessibilityLabel": "Rebaixa el nombre de respostes a {{amount}}",
"accessibilityHint": "S'ha arribat al nombre mínim de respostes, ara mateix n'hi ha {{amount}}"
},
"increase": {
"accessibilityLabel": "Augmentar el nombre de respostes a {{amount}}",
"accessibilityHint": "S'ha arribat al nombre màxim de respostes, ara mateix n'hi ha {{amount}}"
}
},
"multiple": {
"heading": "Tipus d'enquesta",
"options": {
"single": "Elecció única",
"multiple": "Elecció múltiple"
}
},
"expiration": {
"heading": "Caducitat",
"options": {
"300": "5 minuts",
"1800": "30 minuts",
"3600": "1 hora",
"21600": "6 hores",
"86400": "1 dia",
"259200": "3 dies",
"604800": "7 dies"
}
}
}
},
"actions": {
"attachment": {
"accessibilityLabel": "Puja fitxer",
"accessibilityHint": "La funció d'enquesta serà desactivada si hi ha algun fitxer adjunt",
"failed": {
"alert": {
"title": "Ha fallat la pujada",
"button": "Torna-ho a provar"
}
}
},
"poll": {
"accessibilityLabel": "Afegeix una enquesta",
"accessibilityHint": "La funció d'adjuntar fitxers serà desactivada si hi ha una enquesta"
},
"visibility": {
"accessibilityLabel": "La visibilitat de la publicació és {{visibility}}",
"title": "Visibilitat de la publicació",
"options": {
"public": "Públic",
"unlisted": "Sense llistar",
"private": "Només seguidors",
"direct": "Missatge directe"
}
},
"spoiler": {
"accessibilityLabel": "Espòiler"
},
"emoji": {
"accessibilityLabel": "Afegeix un emoji",
"accessibilityHint": "Obre el panell d'emojis, llisca horitzontalment per canviar de pàgina"
}
},
"drafts_one": "Esborrany ({{count}})",
"drafts_other": "Esborranys ({{count}})"
},
"editAttachment": {
"header": {
"title": "Edita el fitxer adjunt",
"right": {
"accessibilityLabel": "Desa canvis",
"failed": {
"title": "Error a l'editar",
"button": "Torna-ho a provar"
}
}
},
"content": {
"altText": {
"heading": "Descriu el contingut per a persones amb discapacitat visual",
"placeholder": "Pots afegir una descripció, també conegut com a text alternatiu, als teus fitxers perquè siguin accessibles a més persones, també aquelles amb discapacitat visual.\n\nLes bones descripcions han de ser concises, però que s'expressin tot el que surt als fitxers amb exactitud per poder entendre el seu context."
},
"imageFocus": "Arrossega el cercle per a canviar el seu punt d'atenció"
}
},
"draftsList": {
"header": {
"title": "Esborrany"
},
"warning": "Els esborranys només estan desats en aquest dispositiu i es poden perdre. No es recomana desar-los durant molt de temps.",
"content": {
"accessibilityHint": "S'ha desat l'esborrany, prem per a editar",
"textEmpty": "Sense contingut"
},
"checkAttachment": "Comprovant els fitxers adjunts al servidor..."
}
}
}

View File

@ -0,0 +1,16 @@
{
"content": {
"actions": {
"accessibilityLabel": "Més accions per aquesta imatge",
"accessibilityHint": "Pots desar o compartir aquesta imatge"
},
"options": {
"save": "Desa la imatge",
"share": "Comparteix la imatge"
},
"save": {
"succeed": "Imatge desada",
"failed": "Error al desar la imatge"
}
}
}

View File

@ -0,0 +1,379 @@
{
"tabs": {
"local": {
"name": "Seguint"
},
"public": {
"name": "",
"segments": {
"left": "Federat",
"right": "Local"
}
},
"notifications": {
"name": "Notificacions"
},
"me": {
"name": "Sobre mi"
}
},
"common": {
"search": {
"accessibilityLabel": "Cerca",
"accessibilityHint": "Cerca per a etiquetes, usuaris o publicacions"
}
},
"notifications": {
"filter": {
"accessibilityLabel": "Filtra",
"accessibilityHint": "Filtra els tipus de notificacions"
}
},
"me": {
"stacks": {
"bookmarks": {
"name": "Marcadors"
},
"conversations": {
"name": "Missatges directes"
},
"favourites": {
"name": "Favorits"
},
"fontSize": {
"name": "Mida de la font de la publicació"
},
"language": {
"name": "Idioma"
},
"list": {
"name": "Llista: {{list}}"
},
"listAccounts": {
"name": "Usuaris a la llista: {{list}}"
},
"listAdd": {
"name": "Afegeix a la llista"
},
"listEdit": {
"name": "Edita els detalls de la llista"
},
"lists": {
"name": "Llistes"
},
"push": {
"name": "Notificacions push"
},
"profile": {
"name": "Edita el teu perfil"
},
"profileName": {
"name": "Edita el nom"
},
"profileNote": {
"name": "Edita la descripció"
},
"profileFields": {
"name": "Editar les metadades"
},
"settings": {
"name": "Configuració de l'aplicació"
},
"webSettings": {
"name": "Més configuracions del compte"
},
"switch": {
"name": "Canvia de compte"
}
},
"fontSize": {
"demo": "<p>Això és una publicació de prova😊. Pots escollir entre moltes opcions<br /><br />Aquesta configuració només afecta el contingut principal de les publicacions, però altres mides de la font.</p>",
"sizes": {
"S": "S",
"M": "M - Per defecte",
"L": "L",
"XL": "XL",
"XXL": "XXL"
}
},
"listAccounts": {
"heading": "Gestiona els usuaris",
"error": "Esborra l'usuari de la llista",
"empty": "Cap usuari afegit en aquesta llista"
},
"listEdit": {
"heading": "Edita els detalls de la llista",
"title": "Títol",
"repliesPolicy": {
"heading": "Mostra respostes a:",
"options": {
"none": "Ningú",
"list": "Membres de la llista",
"followed": "Qualsevol usuari seguit"
}
}
},
"listDelete": {
"heading": "Esborra la llista",
"confirm": {
"title": "Vol esborrar la llista \"{{list}}\"?",
"message": "Aquesta acció no es pot desfer."
}
},
"profile": {
"feedback": {
"succeed": "{{type}} actualitzat",
"failed": "Error a l'actualitzar {{type}}. Si us plau, torna-ho a provar."
},
"root": {
"name": {
"title": "Nom"
},
"avatar": {
"title": "Avatar",
"description": "Es reduirà a 400x400px"
},
"header": {
"title": "Capçalera",
"description": "Es reduirà a 1500x500px"
},
"note": {
"title": "Descripció"
},
"fields": {
"title": "Metadades",
"total_one": "{{count}} camp",
"total_other": "{{count}} camps"
},
"visibility": {
"title": "Visibilitat de la publicació",
"options": {
"public": "Públic",
"unlisted": "Sense llistar",
"private": "Només als seguidors"
}
},
"sensitive": {
"title": "Publica contingut multimèdia sensible"
},
"lock": {
"title": "Fes el compte privat",
"description": "Caldrà l'aprovació manual de seguidors nous"
},
"bot": {
"title": "Compte bot",
"description": "Aquest compte executa principalment accions automatitzades i no podrà ser monitorat"
}
},
"fields": {
"group": "Grup {{index}}",
"label": "Etiqueta",
"content": "Contingut"
},
"mediaSelectionFailed": "Ha fallat el processament d'imatge. Si us plau, torneu-ho a provar."
},
"push": {
"notAvailable": "El seu telèfon no suporta les notificacions push de tooot",
"enable": {
"direct": "Habilita les notificacions push",
"settings": "Activa'ls a la configuració"
},
"global": {
"heading": "Activa per {{acct}}",
"description": "Els missatges s'envien a través del servidor del tooot"
},
"decode": {
"heading": "Mostra els detalls del missatge",
"description": "Els missatges que s'envien a través del servidor del tooot estan encriptades, però pots escollir per desencriptar-los en el servidor. El nostre servidor és de codi obert i tenim una política de zero registres."
},
"default": {
"heading": "Per defecte"
},
"follow": {
"heading": "Nou seguidor"
},
"follow_request": {
"heading": "Sol·licitud de seguiment"
},
"favourite": {
"heading": "Favorits"
},
"reblog": {
"heading": "Impulsat"
},
"mention": {
"heading": "T'ha mencionat"
},
"poll": {
"heading": "Actualització d'una votació"
},
"status": {
"heading": "Publicació d'usuaris subscrits"
},
"howitworks": "Aprèn com funciona"
},
"root": {
"announcements": {
"content": {
"unread": "{{amount}} sense llegir",
"read": "Tots llegits",
"empty": "Cap"
}
},
"push": {
"content": {
"enabled": "Habilitat",
"disabled": "Deshabilitat"
}
},
"update": {
"title": "Actualitza a la última versió"
},
"logout": {
"button": "Tanca la sessió",
"alert": {
"title": "Vol tancar la sessió?",
"message": "Després de tancar la sessió, hauràs de tornar a iniciar la sessió",
"buttons": {
"logout": "Tanca la sessió"
}
}
}
},
"settings": {
"fontsize": {
"heading": "$t(me.stacks.fontSize.name)",
"content": {
"S": "$t(me.fontSize.sizes.S)",
"M": "$t(me.fontSize.sizes.M)",
"L": "$t(me.fontSize.sizes.L)",
"XL": "$t(me.fontSize.sizes.XL)",
"XXL": "$t(me.fontSize.sizes.XXL)"
}
},
"language": {
"heading": "$t(me.stacks.language.name)"
},
"theme": {
"heading": "Aspecte",
"options": {
"auto": "Com el sistema",
"light": "Mode clar",
"dark": "Mode fosc"
}
},
"darkTheme": {
"heading": "Tema fosc",
"options": {
"lighter": "Més clar",
"darker": "Més fosc"
}
},
"browser": {
"heading": "Obertura d'enllaços",
"options": {
"internal": "Dins de l'aplicació",
"external": "Utilitza el navegador del sistema"
}
},
"staticEmoji": {
"heading": "Utilitza emojis estàtics",
"description": "Si l'aplicació falla freqüentment en visualitzar la llista d'emojis, pots intentar fer servir els emojis estàtics."
},
"feedback": {
"heading": "Peticions de característiques"
},
"support": {
"heading": "Dona suport al tooot"
},
"review": {
"heading": "Valora al tooot"
},
"contact": {
"heading": "Contacta al tooot"
},
"version": "Versió v{{version}}",
"instanceVersion": "Versió del Mastodon v{{version}}"
},
"switch": {
"existing": "Escull la sessió",
"new": "Inicia la sessió a la instància"
}
},
"shared": {
"account": {
"actions": {
"accessibilityLabel": "Accions per l'usuari {{user}}",
"accessibilityHint": "Pots silenciar, bloquejar, reportar o compartir aquest usuari"
},
"followed_by": " et segueix",
"moved": "S'ha traslladat",
"created_at": "Es va unir el dia {{date}}",
"summary": {
"statuses_count": "{{count}} publicacions",
"following_count": "$t(shared.users.accounts.following)",
"followers_count": "$t(shared.users.accounts.followers)"
},
"toots": {
"default": "Publicacions",
"all": "Publicacions i respostes"
},
"suspended": "Compte suspès pels moderadors del teu servidor"
},
"accountInLists": {
"name": "Llistes de @{{username}}",
"inLists": "En les llistes",
"notInLists": "Altres llistes"
},
"attachments": {
"name": "Multimèdia de <0 /><1></1>"
},
"hashtag": {
"follow": "Segueix",
"unfollow": "Deixa de seguir"
},
"history": {
"name": "Edita l'historial"
},
"search": {
"header": {
"prefix": "Cercant",
"placeholder": "alguna cosa..."
},
"empty": {
"general": "Escriu per cercar <bold>$t(screenTabs:shared.search.sections.accounts)</bold>, <bold>$t(screenTabs:shared.search.sections.hashtags)</bold> o <bold>$t(screenTabs:shared.search.sections.statuses)</bold>",
"advanced": {
"header": "Cerca avançada",
"example": {
"account": "$t(shared.search.header.prefix) $t(shared.search.sections.accounts)",
"hashtag": "$t(shared.search.header.prefix) $t(shared.search.sections.hashtags)",
"statusLink": "$t(shared.search.header.prefix) $t(shared.search.sections.statuses)",
"accountLink": "$t(shared.search.header.prefix) $t(shared.search.sections.accounts)"
}
},
"trending": {
"tags": ""
}
},
"sections": {
"accounts": "Usuari",
"hashtags": "Etiqueta",
"statuses": "Publicació"
},
"notFound": "No s'ha trobat <bold>{{searchTerm}}</bold> relacionat {{type}}"
},
"toot": {
"name": "Discussions"
},
"users": {
"accounts": {
"following": "{{count}} seguits",
"followers": "{{count}} seguidors"
},
"statuses": {
"reblogged_by": "{{count}} impulsats",
"favourited_by": "{{count}} favorits"
}
}
}
}

30
src/i18n/cs/common.json Normal file
View File

@ -0,0 +1,30 @@
{
"buttons": {
"OK": "",
"apply": "",
"cancel": "",
"discard": "",
"continue": "",
"delete": "",
"done": ""
},
"customEmoji": {
"accessibilityLabel": ""
},
"message": {
"success": {
"message": ""
},
"warning": {
"message": ""
},
"error": {
"message": ""
}
},
"separator": "",
"discard": {
"title": "",
"message": ""
}
}

View File

@ -0,0 +1,81 @@
{
"accessibilityHint": "",
"account": {
"title": "",
"following": {
"action_false": "",
"action_true": ""
},
"inLists": "",
"mute": {
"action_false": "",
"action_true": ""
},
"block": {
"action_false": "",
"action_true": ""
},
"reports": {
"action": ""
}
},
"copy": {
"action": "",
"succeed": ""
},
"instance": {
"title": "",
"block": {
"action": "",
"alert": {
"title": "",
"message": "",
"buttons": {
"confirm": ""
}
}
}
},
"share": {
"status": {
"action": ""
},
"account": {
"action": ""
}
},
"status": {
"title": "",
"edit": {
"action": ""
},
"delete": {
"action": "",
"alert": {
"title": "",
"message": "",
"buttons": {
"confirm": ""
}
}
},
"deleteEdit": {
"action": "",
"alert": {
"title": "",
"message": "",
"buttons": {
"confirm": ""
}
}
},
"mute": {
"action_false": "",
"action_true": ""
},
"pin": {
"action_false": "",
"action_true": ""
}
}
}

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1,26 @@
{
"server": {
"textInput": {
"placeholder": ""
},
"button": "",
"information": {
"name": "",
"accounts": "",
"statuses": "",
"domains": ""
},
"disclaimer": {
"base": ""
},
"terms": {
"base": ""
}
},
"update": {
"alert": {
"title": "",
"message": ""
}
}
}

View File

@ -0,0 +1,10 @@
{
"title": "",
"message": "",
"options": {
"image": "",
"image_max": "",
"video": "",
"video_max": ""
}
}

View File

@ -0,0 +1,8 @@
{
"HTML": {
"accessibilityHint": "",
"expanded": "",
"moreLines": "",
"defaultHint": ""
}
}

View File

@ -0,0 +1,16 @@
{
"follow": {
"function": ""
},
"block": {
"function": ""
},
"button": {
"error": "",
"blocked_by": "",
"blocking": "",
"following": "",
"requested": "",
"default": ""
}
}

View File

@ -0,0 +1,152 @@
{
"empty": {
"error": {
"message": "",
"button": ""
},
"success": {
"message": ""
}
},
"end": {
"message": ""
},
"lookback": {
"message": ""
},
"refresh": {
"fetchPreviousPage": "",
"refetch": ""
},
"shared": {
"actioned": {
"pinned": "",
"favourite": "",
"status": "",
"follow": "",
"follow_request": "",
"poll": "",
"reblog": {
"default": "",
"notification": ""
},
"update": ""
},
"actions": {
"reply": {
"accessibilityLabel": ""
},
"reblogged": {
"accessibilityLabel": "",
"function": "",
"options": {
"title": "",
"public": "",
"unlisted": ""
}
},
"favourited": {
"accessibilityLabel": "",
"function": ""
},
"bookmarked": {
"accessibilityLabel": "",
"function": ""
}
},
"actionsUsers": {
"reblogged_by": {
"accessibilityLabel": "",
"accessibilityHint": "",
"text": ""
},
"favourited_by": {
"accessibilityLabel": "",
"accessibilityHint": "",
"text": ""
},
"history": {
"accessibilityLabel": "",
"accessibilityHint": "",
"text_one": "",
"text_other": ""
}
},
"attachment": {
"sensitive": {
"button": ""
},
"unsupported": {
"text": "",
"button": ""
}
},
"avatar": {
"accessibilityLabel": "",
"accessibilityHint": ""
},
"content": {
"expandHint": ""
},
"filtered": "",
"fullConversation": "",
"translate": {
"default": "",
"succeed": "",
"failed": "",
"source_not_supported": "",
"target_not_supported": ""
},
"header": {
"shared": {
"account": {
"name": {
"accessibilityHint": ""
},
"account": {
"accessibilityHint": ""
}
},
"application": "",
"edited": {
"accessibilityLabel": ""
},
"muted": {
"accessibilityLabel": ""
},
"visibility": {
"direct": {
"accessibilityLabel": ""
},
"private": {
"accessibilityLabel": ""
}
}
},
"conversation": {
"withAccounts": "",
"delete": {
"function": ""
}
}
},
"poll": {
"meta": {
"button": {
"vote": "",
"refresh": ""
},
"count": {
"voters_one": "",
"voters_other": "",
"votes_one": "",
"votes_other": ""
},
"expiration": {
"expired": "",
"until": ""
}
}
}
}
}

18
src/i18n/cs/screens.json Normal file
View File

@ -0,0 +1,18 @@
{
"screenshot": {
"title": "",
"message": "",
"button": ""
},
"localCorrupt": {
"message": ""
},
"pushError": {
"message": "",
"description": ""
},
"shareError": {
"imageNotSupported": "",
"videoNotSupported": ""
}
}

View File

@ -0,0 +1,6 @@
{
"heading": "",
"content": {
"select_account": ""
}
}

View File

@ -0,0 +1,20 @@
{
"content": {
"altText": {
"heading": ""
},
"notificationsFilter": {
"heading": "",
"content": {
"follow": "",
"follow_request": "",
"favourite": "",
"reblog": "",
"mention": "",
"poll": "",
"status": "",
"update": ""
}
}
}
}

View File

@ -0,0 +1,10 @@
{
"heading": "",
"content": {
"published": "",
"button": {
"read": "",
"unread": ""
}
}
}

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