Merge pull request #333 from tooot-app/main

Release v4.1
This commit is contained in:
xmflsct 2022-06-11 06:54:31 +02:00 committed by GitHub
commit 0d1b5b9a7d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
101 changed files with 2788 additions and 2062 deletions

View File

@ -6,7 +6,42 @@ on:
- release
jobs:
build:
build-ios:
runs-on: macos-12
steps:
- name: -- Step 0 -- Extract branch name
shell: bash
run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})"
id: branch
- name: -- Step 1 -- Checkout code
uses: actions/checkout@v2
- name: -- Step 2 -- Setup node
uses: actions/setup-node@v3
with:
node-version: 16
- name: -- Step 3 -- Install node dependencies
run: yarn install
- name: -- Step 4 -- Install ruby dependencies
run: bundle install
- name: -- Step 5 -- Run fastlane
env:
DEVELOPER_DIR: /Applications/Xcode_13.4.1.app/Contents/Developer
ENVIRONMENT: ${{ steps.branch.outputs.branch }}
LC_ALL: en_US.UTF-8
LANG: en_US.UTF-8
SENTRY_ORGANIZATION: ${{ secrets.SENTRY_ORGANIZATION }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
FASTLANE_USER: ${{ secrets.FASTLANE_USER }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }}
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }}
run: yarn app:build ios
build-android:
runs-on: macos-12
steps:
- name: -- Step 0 -- Extract branch name
@ -24,36 +59,50 @@ jobs:
with:
distribution: 'zulu'
java-version: '11'
- name: -- Step 4 -- Use Expo action
uses: expo/expo-github-action@v7
with:
expo-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: -- Step 5 -- Install node dependencies
- name: -- Step 4 -- Install node dependencies
run: yarn install
- name: -- Step 6 -- Install ruby dependencies
- name: -- Step 5 -- Install ruby dependencies
run: bundle install
- name: -- Step 7 -- Run fastlane
- name: -- Step 6 -- Run fastlane
env:
DEVELOPER_DIR: /Applications/Xcode_13.3.1.app/Contents/Developer
ENVIRONMENT: ${{ steps.branch.outputs.branch }}
LC_ALL: en_US.UTF-8
LANG: en_US.UTF-8
SENTRY_ORGANIZATION: ${{ secrets.SENTRY_ORGANIZATION }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
FASTLANE_USER: ${{ secrets.FASTLANE_USER }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }}
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }}
ANDROID_KEYSTORE: ${{ secrets.ANDROID_KEYSTORE }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEYSTORE_ALIAS: ${{ secrets.ANDROID_KEYSTORE_ALIAS }}
ANDROID_KEYSTORE_KEY_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_KEY_PASSWORD }}
SUPPLY_JSON_KEY_DATA: ${{ secrets.SUPPLY_JSON_KEY_DATA }}
run: yarn app:build android
create-release:
runs-on: macos-12
needs: [build-ios, build-android]
steps:
- name: -- Step 0 -- Extract branch name
shell: bash
run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})"
id: branch
- name: -- Step 1 -- Checkout code
uses: actions/checkout@v2
- name: -- Step 2 -- Setup node
uses: actions/setup-node@v3
with:
node-version: 16
- name: -- Step 3 -- Use Expo action
uses: expo/expo-github-action@v7
with:
expo-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: -- Step 4 -- Install node dependencies
run: yarn install
- name: -- Step 5 -- Install ruby dependencies
run: bundle install
- name: -- Step 6 -- Run fastlane
env:
ENVIRONMENT: ${{ steps.branch.outputs.branch }}
LC_ALL: en_US.UTF-8
LANG: en_US.UTF-8
GH_PAT_GET_RELEASE: ${{ secrets.GH_PAT_GET_RELEASE }}
FL_GITHUB_RELEASE_API_BEARER: ${{ secrets.GITHUB_TOKEN }}
run: yarn app:build
run: yarn app:build release

40
.gitignore vendored
View File

@ -5,13 +5,44 @@
coverage/
builds/
# @generated expo-cli sync-28e2ab0e9ece60556eaf932abe52d017ec33db50
# @generated expo-cli sync-e7dcf75f4e856f7b6f3239b3f3a7dd614ee755a8
# The following patterns were generated by expo-cli
# OSX
#
.DS_Store
# Xcode
#
build/
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
xcuserdata
*.xccheckout
*.moved-aside
DerivedData
*.hmap
*.ipa
*.xcuserstate
project.xcworkspace
# Android/IntelliJ
#
build/
.idea
.gradle
local.properties
*.iml
*.hprof
# node.js
#
node_modules/
npm-debug.log
yarn-error.log
@ -25,9 +56,12 @@ buck-out/
# Bundle artifacts
*.jsbundle
# CocoaPods
/ios/Pods/
# Expo
.expo/*
.expo-shared/*
.expo/
web-build/
dist/
# @end expo-cli

View File

@ -17,16 +17,16 @@ GEM
artifactory (3.0.15)
atomos (0.1.3)
aws-eventstream (1.2.0)
aws-partitions (1.582.0)
aws-sdk-core (3.130.2)
aws-partitions (1.598.0)
aws-sdk-core (3.131.1)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.525.0)
aws-sigv4 (~> 1.1)
jmespath (~> 1.0)
aws-sdk-kms (1.56.0)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.57.0)
aws-sdk-core (~> 3, >= 3.127.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.113.2)
aws-sdk-s3 (1.114.0)
aws-sdk-core (~> 3, >= 3.127.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4)
@ -106,8 +106,8 @@ GEM
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.0.3)
multipart-post (>= 1.2, < 3)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (1.0.1)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
@ -116,7 +116,7 @@ GEM
faraday_middleware (1.2.0)
faraday (~> 1.0)
fastimage (2.2.6)
fastlane (2.205.2)
fastlane (2.206.2)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
@ -163,9 +163,9 @@ GEM
fourflusher (2.3.1)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.19.0)
google-apis-androidpublisher_v3 (0.21.0)
google-apis-core (>= 0.4, < 2.a)
google-apis-core (0.4.2)
google-apis-core (0.5.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
@ -178,7 +178,7 @@ GEM
google-apis-core (>= 0.4, < 2.a)
google-apis-playcustomapp_v1 (0.7.0)
google-apis-core (>= 0.4, < 2.a)
google-apis-storage_v1 (0.13.0)
google-apis-storage_v1 (0.14.0)
google-apis-core (>= 0.4, < 2.a)
google-cloud-core (1.6.0)
google-cloud-env (~> 1.0)
@ -202,14 +202,14 @@ GEM
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.4)
http-cookie (1.0.5)
domain_name (~> 0.5)
httpclient (2.8.3)
i18n (1.10.0)
concurrent-ruby (~> 1.0)
jmespath (1.6.1)
json (2.6.1)
jwt (2.3.0)
json (2.6.2)
jwt (2.4.1)
memoist (0.16.2)
mini_magick (4.11.0)
mini_mime (1.1.2)
@ -226,7 +226,7 @@ GEM
plist (3.6.0)
public_suffix (4.0.7)
rake (13.0.6)
representable (3.1.1)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
@ -260,7 +260,7 @@ GEM
uber (0.1.0)
unf (0.1.4)
unf_ext
unf_ext (0.0.8.1)
unf_ext (0.0.8.2)
unicode-display_width (1.8.0)
webrick (1.7.0)
word_wrap (1.0.0)

View File

@ -15,3 +15,5 @@
[@luizpicolo](https://github.com/luizpicolo) for Brazilian Portuguese
[@duy@mas.to](https://mas.to/@duy) for Vietnamese translation
[@jk@mastodon.social](https://mastodon.social/@jk) for the famous Mastodon boop sound

View File

@ -22,11 +22,3 @@
## OTA release channels
* `MAJOR.MINOR-environment`. Environments include `release`, `candidate` and `development`.
## Major versions mapping to native module versions
| Version | Native module version | Expo version |
| :------:| :-------------------: | :----------: |
| `0-` | `210201` | `40.0.0` |
| `1-` | `210317` | `40.0.0` |
| `2.2` | `210916` | `41.0.0` |

Binary file not shown.

View File

@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<resources>
<string name="app_name">tooot</string>
<string name="expo_splash_screen_resize_mode">contain</string>

View File

@ -35,5 +35,13 @@ export default (): ExpoConfig => ({
googleServicesFile: './configs/google-services.json',
permissions: ['CAMERA', 'VIBRATE'],
blockedPermissions: ['USE_BIOMETRIC', 'USE_FINGERPRINT']
}
},
plugins: [
[
'expo-notifications',
{
sounds: ['./assets/sounds/boop.mp3']
}
]
]
})

BIN
assets/sounds/boop.mp3 Normal file

Binary file not shown.

View File

@ -1,10 +1,5 @@
fastlane_version "2.203.0"
skip_docs
ensure_env_vars(
env_vars: ["ENVIRONMENT", "SENTRY_ORGANIZATION", "SENTRY_PROJECT", "SENTRY_AUTH_TOKEN"]
)
VERSIONS = read_json( json_path: "./package.json" )[:versions]
ENVIRONMENT = ENV["ENVIRONMENT"]
VERSION = "#{VERSIONS[:major]}.#{VERSIONS[:minor]}"
@ -192,8 +187,8 @@ private_lane :build_android do
task: 'assemble',
build_type: 'release',
project_dir: "./android",
print_command: false,
print_command_output: false,
print_command: true,
print_command_output: true,
properties: {
"expoSDK" => VERSIONS[:expo],
"releaseChannel" => RELEASE_CHANNEL,
@ -218,15 +213,32 @@ private_lane :build_android do
end
end
lane :build do
lane :ios do
releaseExists = get_github_release(url: GITHUB_REPO, version: "v#{VERSION}", api_token: ENV['GH_PAT_GET_RELEASE'])
if releaseExists
puts("Release #{GITHUB_RELEASE} exists. Continue with building React Native only.")
else
puts("Release #{GITHUB_RELEASE} does not exist.")
cocoapods(clean_install: true, podfile: "./ios/Podfile")
build_ios
end
rocket
end
lane :android do
releaseExists = get_github_release(url: GITHUB_REPO, version: "v#{VERSION}", api_token: ENV['GH_PAT_GET_RELEASE'])
if releaseExists
puts("Release #{GITHUB_RELEASE} exists. Continue with building React Native only.")
else
puts("Release #{GITHUB_RELEASE} does not exist. Create new release as well as new native build.")
cocoapods(clean_install: true, podfile: "./ios/Podfile")
build_ios
build_android
end
rocket
end
lane :release do
releaseExists = get_github_release(url: GITHUB_REPO, version: "v#{VERSION}", api_token: ENV['GH_PAT_GET_RELEASE'])
if !releaseExists
case ENVIRONMENT
when "candidate"
github_release(prerelease: true)

View File

@ -1,5 +1,2 @@
Support editing toot
Integrated into system's share menu
Follow system's font weight setting (iOS)
Upload gif from keyboard (Android)
Added German and Italian
Support selecting multiple images
Long press toot to show options

View File

@ -1,5 +1,2 @@
支持编辑嘟文
支持系统图片分享
跟随系统字体粗细(苹果)
键盘上传gif图片安卓)
新增德语和意大利语
支持同时上传多张图片
长按嘟文弹出嘟文选项

View File

@ -35,7 +35,7 @@ PODS:
- EXJSONUtils
- EXNotifications (0.15.2):
- ExpoModulesCore
- Expo (45.0.4):
- Expo (45.0.5):
- ExpoModulesCore
- ExpoCrypto (10.2.0):
- ExpoModulesCore
@ -55,7 +55,7 @@ PODS:
- ReactCommon/turbomodule/core
- ExpoRandom (12.2.0):
- ExpoModulesCore
- ExpoWebBrowser (10.2.0):
- ExpoWebBrowser (10.2.1):
- ExpoModulesCore
- EXScreenCapture (4.2.0):
- ExpoModulesCore
@ -67,7 +67,7 @@ PODS:
- EXStoreReview (5.2.0):
- ExpoModulesCore
- EXStructuredHeaders (2.2.1)
- EXUpdates (0.13.1):
- EXUpdates (0.13.2):
- ASN1Decoder (~> 1.8)
- EASClient
- EXManifests
@ -415,13 +415,18 @@ PODS:
- React-Core
- react-native-cameraroll (4.1.2):
- React-Core
- react-native-image-keyboard (2.2.0):
- react-native-context-menu-view (1.5.4):
- React
- react-native-netinfo (8.3.0):
- react-native-language-detection (0.1.0):
- React
- react-native-netinfo (9.0.0):
- React-Core
- react-native-pager-view (5.4.11):
- React-Core
- react-native-safe-area-context (4.2.5):
- react-native-paste-input (0.4.2):
- React-Core
- Swime (= 3.0.6)
- react-native-safe-area-context (4.3.1):
- RCT-Folly
- RCTRequired
- RCTTypeSafety
@ -523,7 +528,7 @@ PODS:
- React-logger (= 0.68.2)
- React-perflogger (= 0.68.2)
- ReactCommon/turbomodule/core (= 0.68.2)
- RNCAsyncStorage (1.17.4):
- RNCAsyncStorage (1.17.6):
- React-Core
- RNFastImage (8.5.11):
- React-Core
@ -531,6 +536,15 @@ PODS:
- SDWebImageWebPCoder (~> 0.8.4)
- RNGestureHandler (2.4.2):
- React-Core
- RNImageCropPicker (0.37.3):
- React-Core
- React-RCTImage
- RNImageCropPicker/QBImagePickerController (= 0.37.3)
- TOCropViewController
- RNImageCropPicker/QBImagePickerController (0.37.3):
- React-Core
- React-RCTImage
- TOCropViewController
- RNReanimated (2.8.0):
- DoubleConversion
- FBLazyVector
@ -561,7 +575,7 @@ PODS:
- RNScreens (3.13.1):
- React-Core
- React-RCTImage
- RNSentry (3.4.2):
- RNSentry (3.4.3):
- React-Core
- Sentry (= 7.11.0)
- RNShareMenu (5.0.5):
@ -577,6 +591,8 @@ PODS:
- Sentry (7.11.0):
- Sentry/Core (= 7.11.0)
- Sentry/Core (7.11.0)
- Swime (3.0.6)
- TOCropViewController (2.6.1)
- Yoga (1.14.0)
DEPENDENCIES:
@ -638,9 +654,11 @@ DEPENDENCIES:
- "react-native-blur (from `../node_modules/@react-native-community/blur`)"
- react-native-blurhash (from `../node_modules/react-native-blurhash`)
- "react-native-cameraroll (from `../node_modules/@react-native-community/cameraroll`)"
- react-native-image-keyboard (from `../node_modules/react-native-image-keyboard`)
- react-native-context-menu-view (from `../node_modules/react-native-context-menu-view`)
- react-native-language-detection (from `../node_modules/react-native-language-detection`)
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
- react-native-pager-view (from `../node_modules/react-native-pager-view`)
- "react-native-paste-input (from `../node_modules/@mattermost/react-native-paste-input`)"
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
- "react-native-segmented-control (from `../node_modules/@react-native-community/segmented-control`)"
- React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`)
@ -658,6 +676,7 @@ DEPENDENCIES:
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
- RNFastImage (from `../node_modules/react-native-fast-image`)
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
- RNImageCropPicker (from `../node_modules/react-native-image-crop-picker`)
- RNReanimated (from `../node_modules/react-native-reanimated`)
- RNScreens (from `../node_modules/react-native-screens`)
- "RNSentry (from `../node_modules/@sentry/react-native`)"
@ -685,6 +704,8 @@ SPEC REPOS:
- SDWebImage
- SDWebImageWebPCoder
- Sentry
- Swime
- TOCropViewController
EXTERNAL SOURCES:
boost:
@ -795,12 +816,16 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-blurhash"
react-native-cameraroll:
:path: "../node_modules/@react-native-community/cameraroll"
react-native-image-keyboard:
:path: "../node_modules/react-native-image-keyboard"
react-native-context-menu-view:
:path: "../node_modules/react-native-context-menu-view"
react-native-language-detection:
:path: "../node_modules/react-native-language-detection"
react-native-netinfo:
:path: "../node_modules/@react-native-community/netinfo"
react-native-pager-view:
:path: "../node_modules/react-native-pager-view"
react-native-paste-input:
:path: "../node_modules/@mattermost/react-native-paste-input"
react-native-safe-area-context:
:path: "../node_modules/react-native-safe-area-context"
react-native-segmented-control:
@ -835,6 +860,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-fast-image"
RNGestureHandler:
:path: "../node_modules/react-native-gesture-handler"
RNImageCropPicker:
:path: "../node_modules/react-native-image-crop-picker"
RNReanimated:
:path: "../node_modules/react-native-reanimated"
RNScreens:
@ -866,7 +893,7 @@ SPEC CHECKSUMS:
EXJSONUtils: 2a74b8f40f1523cc3f92af99c91aa78201737a77
EXManifests: 0c6134b7b6f3236a93a778c3f44ba1cfb3f9fa3d
EXNotifications: ea9fc56d27d1fee229489c5d8f452c7f367c237e
Expo: 64d52669fa3b9342919b5b44b2b4f15f19b0cf76
Expo: b9fff0a1eac0f424fc68ea49b4347fb308e52e17
ExpoCrypto: d0d0f3e20875dc450b4ec88f0fb608da5c2c6c17
ExpoHaptics: ad58ec96a25e57579c14a47c7d71f0de0de8656a
ExpoImageManipulator: b55580bbc7b10099c7707949903e7176a8542ee8
@ -875,13 +902,13 @@ SPEC CHECKSUMS:
ExpoLocalization: 8f619bb6eec64575cd5220bfabbd7b4e2d6f33f8
ExpoModulesCore: e4278a668e8c13c0269ed8b8a4200989deea2973
ExpoRandom: 14df0976aa363a71a730ceb7655250f3047c0e42
ExpoWebBrowser: 818c519c3519cdd79780228039938fbd8236c885
ExpoWebBrowser: 4b5f9633e5f169dc948587cb6d26d2d1d1406187
EXScreenCapture: cbee2204f313038a1819d31ad99a31e15f8e0f59
EXSecureStore: aaae919d83aec2faf031e99398807edac0313285
EXSplashScreen: 34f460788db8d682883871708dddbfac72095bb7
EXStoreReview: e61fbd500624ee7363ab134ee247cff380a8b254
EXStructuredHeaders: 5d86829469399370a9fc7cb1e4391b09de87681d
EXUpdates: 08c3931d6a5f39686091130a10310266ae3ba5f9
EXUpdates: 08d69031f9ed1e918d50f041fa505fe67d6b4809
EXUpdatesInterface: 0b101ace1dbfa0f64260a5df31c71d03c66cca54
EXVideoThumbnails: 19e055dc3245b53c536da9e0ef9c618fd2118297
FBLazyVector: a7a655862f6b09625d11c772296b01cd5164b648
@ -918,10 +945,12 @@ SPEC CHECKSUMS:
react-native-blur: cad4d93b364f91e7b7931b3fa935455487e5c33c
react-native-blurhash: add4df9a937b4e021a24bc67a0714f13e0bd40b7
react-native-cameraroll: 2957f2bce63ae896a848fbe0d5352c1bd4d20866
react-native-image-keyboard: adbf5996b8592a7d8cb8d3e431a9607f9cf3b270
react-native-netinfo: 3671b091c4843fda5e153612866ef4024b8f5d62
react-native-context-menu-view: b0beca02aad4bd9f9d7d932bf437e0a03baa69ef
react-native-language-detection: 0e43195ad014974f1b7a31b64820eff34a243f2d
react-native-netinfo: 5b664b2945a8f02102b296f0f812bddd6827ed9c
react-native-pager-view: 7f00d63688f7df9fad86dfb0154814419cc5eb8d
react-native-safe-area-context: ebf8c413eb8b5f7c392a036a315eb7b46b96845f
react-native-paste-input: efbf0b08fa1673f0e3131da6ea01678c1bb8003e
react-native-safe-area-context: 6c12e3859b6f27b25de4fee8201cfb858432d8de
react-native-segmented-control: 65df6cd0619b780b3843d574a72d4c7cec396097
React-perflogger: a18b4f0bd933b8b24ecf9f3c54f9bf65180f3fe6
React-RCTActionSheet: 547fe42fdb4b6089598d79f8e1d855d7c23e2162
@ -935,17 +964,20 @@ SPEC CHECKSUMS:
React-RCTVibration: 79040b92bfa9c3c2d2cb4f57e981164ec7ab9374
React-runtimeexecutor: b960b687d2dfef0d3761fbb187e01812ebab8b23
ReactCommon: 095366164a276d91ea704ce53cb03825c487a3f2
RNCAsyncStorage: 9367a646dc24e3ab7b6874d79bc1bfd0832dce58
RNCAsyncStorage: 466b9df1a14bccda91da86e0b7d9a345d78e1673
RNFastImage: 945abf54742505d790d9024d230c69b1e866bc88
RNGestureHandler: 61628a2c859172551aa2100d3e73d1e57878392f
RNImageCropPicker: 44e2807bc410741f35d4c45b6586aedfe3da39d2
RNReanimated: 64573e25e078ae6bec03b891586d50b9ec284393
RNScreens: 40a2cb40a02a609938137a1e0acfbf8fc9eebf19
RNSentry: 2cd1daa124b0d9fd0dfc2cb6094fdd168cb579bc
RNSentry: 85f6525b5fe8d2ada065858026b338605b3c09da
RNShareMenu: c69282e50ac439737a86949a55c7b023b90027c8
RNSVG: 302bfc9905bd8122f08966dc2ce2d07b7b52b9f8
SDWebImage: 0905f1b7760fc8ac4198cae0036600d67478751e
SDWebImageWebPCoder: f93010f3f6c031e2f8fb3081ca4ee6966c539815
Sentry: 0c5cd63d714187b4a39c331c1f0eb04ba7868341
Swime: d7b2c277503b6cea317774aedc2dce05613f8b0b
TOCropViewController: edfd4f25713d56905ad1e0b9f5be3fbe0f59c863
Yoga: 99652481fcd320aefa4a7ef90095b95acd181952
PODFILE CHECKSUM: d6d20fa7c51228cebc309aed987ed7d8f4274844

View File

@ -1,3 +1,3 @@
{
"expo.jsEngine": "hermes"
}
}

View File

@ -6,6 +6,5 @@
*/
"NSCameraUsageDescription" = "Allow tooot to capture photo or video and attach it to your toot";
"NSPhotoLibraryAddUsageDescription" = "Allow tooot to save an image to your camera roll";
"NSPhotoLibraryUsageDescription" = "Allow tooot to access your camera roll to attach photos or videos to your toot";

View File

@ -12,6 +12,7 @@
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
34A37A6C820725DC6DDAA0EE /* libPods-ShareExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 1B3640FCDF7C4396A68A74D1 /* libPods-ShareExtension.a */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
4986628FD0DD4630BFE5F388 /* boop.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = DF8133F098604A10B0D94952 /* boop.mp3 */; };
5E36538325C9B8BD009F93EE /* RootViewColor.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5E36538225C9B8BD009F93EE /* RootViewColor.xcassets */; };
5EE088C926297820007E5FEC /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5EE088CB26297820007E5FEC /* InfoPlist.strings */; };
5EE44DD62600124E00A9BCED /* File.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EE44DD52600124E00A9BCED /* File.swift */; };
@ -71,6 +72,7 @@
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>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
DF8133F098604A10B0D94952 /* boop.mp3 */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; 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>"; };
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>"; };
@ -115,6 +117,7 @@
5E36538225C9B8BD009F93EE /* RootViewColor.xcassets */,
B96B72E5384D44A7B240B27E /* GoogleService-Info.plist */,
5EE088CB26297820007E5FEC /* InfoPlist.strings */,
DF8133F098604A10B0D94952 /* boop.mp3 */,
);
name = tooot;
sourceTree = "<group>";
@ -310,6 +313,7 @@
13B07FBD1A68108700A75B9A /* LaunchScreen.xib in Resources */,
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
DA8B5B7F0DED488CAC0FF169 /* GoogleService-Info.plist in Resources */,
4986628FD0DD4630BFE5F388 /* boop.mp3 in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -386,13 +390,17 @@
"${PODS_ROOT}/Target Support Files/Pods-tooot/Pods-tooot-resources.sh",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXUpdates/EXUpdates.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNImageCropPicker/QBImagePicker.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/AccessibilityResources.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/TOCropViewController/TOCropViewControllerBundle.bundle",
);
name = "[CP] Copy Pods Resources";
outputPaths = (
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXUpdates.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/QBImagePicker.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AccessibilityResources.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/TOCropViewControllerBundle.bundle",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;

View File

@ -1,92 +1,90 @@
<?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>CFBundleAllowMixedLocalizations</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>tooot</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.2</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>com.xmflsct.app.tooot</string>
<key>CFBundleURLSchemes</key>
<array>
<string>tooot-share</string>
<string>tooot</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>2102022230</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationCategoryType</key>
<string>public.app-category.social-networking</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
<key>NSCameraUsageDescription</key>
<string>Allow $(PRODUCT_NAME) to capture photo or video and attach it to your toot</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string></string>
<key>NSMainNibFile</key>
<string>LaunchScreen</string>
<key>NSMicrophoneUsageDescription</key>
<string>$(PRODUCT_NAME) DOES NOT need microphone permission. Please reject this request.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Allow $(PRODUCT_NAME) to save an image to your camera roll</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Allow $(PRODUCT_NAME) to access your camera roll to attach photos or videos to your toot</string>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UIRequiresFullScreen</key>
<false/>
<key>UIStatusBarHidden</key>
<true/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Automatic</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
<dict>
<key>CFBundleAllowMixedLocalizations</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>tooot</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.2</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>com.xmflsct.app.tooot</string>
<key>CFBundleURLSchemes</key>
<array>
<string>tooot-share</string>
<string>tooot</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>2102022230</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationCategoryType</key>
<string>public.app-category.social-networking</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
<key>NSLocationWhenInUseUsageDescription</key>
<string></string>
<key>NSMainNibFile</key>
<string>LaunchScreen</string>
<key>NSMicrophoneUsageDescription</key>
<string>$(PRODUCT_NAME) DOES NOT need microphone permission. Please reject this request.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Allow $(PRODUCT_NAME) to save an image to your camera roll</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Allow $(PRODUCT_NAME) to access your camera roll to attach photos or videos to your toot</string>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UIRequiresFullScreen</key>
<false/>
<key>UIStatusBarHidden</key>
<true/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Automatic</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>

View File

@ -1,18 +1,18 @@
<?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>EXUpdatesCheckOnLaunch</key>
<string>WIFI_ONLY</string>
<key>EXUpdatesEnabled</key>
<true/>
<key>EXUpdatesLaunchWaitMs</key>
<integer>0</integer>
<key>EXUpdatesReleaseChannel</key>
<string>0-development</string>
<key>EXUpdatesSDKVersion</key>
<string>0</string>
<key>EXUpdatesURL</key>
<string>https://exp.host/@xmflsct/tooot</string>
</dict>
</plist>
<dict>
<key>EXUpdatesCheckOnLaunch</key>
<string>WIFI_ONLY</string>
<key>EXUpdatesEnabled</key>
<true/>
<key>EXUpdatesLaunchWaitMs</key>
<integer>0</integer>
<key>EXUpdatesReleaseChannel</key>
<string>0-development</string>
<key>EXUpdatesSDKVersion</key>
<string>0</string>
<key>EXUpdatesURL</key>
<string>https://exp.host/@xmflsct/tooot</string>
</dict>
</plist>

BIN
ios/tooot/boop.mp3 Normal file

Binary file not shown.

View File

@ -1,24 +1,24 @@
<?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>aps-environment</key>
<string>development</string>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.xmflsct.app.tooot</string>
</array>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.personal-information.location</key>
<true/>
<key>com.apple.security.personal-information.photos-library</key>
<true/>
</dict>
</plist>
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.xmflsct.app.tooot</string>
</array>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.personal-information.location</key>
<true/>
<key>com.apple.security.personal-information.photos-library</key>
<true/>
</dict>
</plist>

View File

@ -6,6 +6,5 @@
*/
"NSCameraUsageDescription" = "允许tooot拍摄图片或视频以添加嘟文附件";
"NSPhotoLibraryAddUsageDescription" = "允许tooot保存图片至相册";
"NSPhotoLibraryUsageDescription" = "允许tooot读取相册图片或视频以添加嘟文附件";

View File

@ -1,10 +1,10 @@
{
"name": "tooot",
"versions": {
"native": "220508",
"native": "220603",
"major": 4,
"minor": 0,
"patch": 5,
"minor": 1,
"patch": 0,
"expo": "45.0.0"
},
"description": "tooot app for Mastodon",
@ -19,34 +19,35 @@
"android": "react-native run-android",
"iphone": "react-native run-ios",
"ipad": "react-native run-ios --simulator 'iPad mini (6th generation)'",
"app:build": "bundle exec fastlane build",
"app:build": "bundle exec fastlane",
"release": "scripts/release.sh",
"clean": "react-native-clean-project",
"postinstall": "patch-package"
},
"dependencies": {
"@expo/react-native-action-sheet": "3.13.0",
"@formatjs/intl-datetimeformat": "^6.0.1",
"@formatjs/intl-getcanonicallocales": "^2.0.1",
"@formatjs/intl-locale": "^3.0.1",
"@formatjs/intl-numberformat": "^8.0.1",
"@formatjs/intl-pluralrules": "^5.0.1",
"@formatjs/intl-relativetimeformat": "^11.0.1",
"@formatjs/intl-datetimeformat": "^6.0.2",
"@formatjs/intl-getcanonicallocales": "^2.0.2",
"@formatjs/intl-locale": "^3.0.2",
"@formatjs/intl-numberformat": "^8.0.2",
"@formatjs/intl-pluralrules": "^5.0.2",
"@formatjs/intl-relativetimeformat": "^11.0.2",
"@mattermost/react-native-paste-input": "^0.4.2",
"@neverdull-agency/expo-unlimited-secure-store": "1.0.10",
"@react-native-async-storage/async-storage": "1.17.4",
"@react-native-async-storage/async-storage": "1.17.6",
"@react-native-community/blur": "3.6.0",
"@react-native-community/cameraroll": "4.1.2",
"@react-native-community/netinfo": "8.3.0",
"@react-native-community/netinfo": "9.0.0",
"@react-native-community/segmented-control": "2.2.2",
"@react-navigation/bottom-tabs": "6.3.1",
"@react-navigation/native": "6.0.10",
"@react-navigation/native-stack": "6.6.2",
"@react-navigation/stack": "6.2.1",
"@reduxjs/toolkit": "1.8.2",
"@sentry/react-native": "3.4.2",
"@sentry/react-native": "3.4.3",
"@sharcoux/slider": "6.0.3",
"axios": "0.27.2",
"expo": "45.0.4",
"expo": "45.0.5",
"expo-auth-session": "3.6.1",
"expo-av": "11.2.3",
"expo-constants": "^13.1.1",
@ -55,7 +56,7 @@
"expo-file-system": "14.0.0",
"expo-firebase-analytics": "7.0.0",
"expo-haptics": "11.2.0",
"expo-image-manipulator": "10.3.1",
"expo-image-manipulator": "^10.3.1",
"expo-image-picker": "13.1.1",
"expo-linking": "3.1.0",
"expo-localization": "13.0.0",
@ -65,29 +66,31 @@
"expo-secure-store": "11.2.0",
"expo-splash-screen": "0.15.1",
"expo-store-review": "5.2.0",
"expo-updates": "0.13.1",
"expo-updates": "0.13.2",
"expo-video-thumbnails": "6.3.0",
"expo-web-browser": "10.2.0",
"expo-web-browser": "10.2.1",
"i18next": "21.8.8",
"li": "1.3.0",
"lodash": "4.17.21",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-i18next": "11.17.0",
"react-intl": "^6.0.3",
"react-intl": "^6.0.4",
"react-native": "0.68.2",
"react-native-animated-spinkit": "1.5.2",
"react-native-base64": "^0.2.1",
"react-native-blurhash": "1.1.10",
"react-native-context-menu-view": "xmflsct/react-native-context-menu-view",
"react-native-fast-image": "8.5.11",
"react-native-feather": "1.1.2",
"react-native-flash-message": "0.2.1",
"react-native-gesture-handler": "2.4.2",
"react-native-htmlview": "0.16.0",
"react-native-image-keyboard": "^2.2.0",
"react-native-image-crop-picker": "^0.37.3",
"react-native-language-detection": "^0.1.0",
"react-native-pager-view": "5.4.11",
"react-native-reanimated": "2.8.0",
"react-native-safe-area-context": "4.2.5",
"react-native-safe-area-context": "4.3.1",
"react-native-screens": "3.13.1",
"react-native-share-menu": "^5.0.5",
"react-native-svg": "12.3.0",
@ -97,7 +100,7 @@
"react-redux": "8.0.2",
"redux-persist": "6.0.0",
"rn-placeholder": "3.0.3",
"sentry-expo": "4.1.1",
"sentry-expo": "4.2.0",
"tslib": "2.4.0",
"valid-url": "1.0.9"
},
@ -110,7 +113,7 @@
"@types/lodash": "4.14.182",
"@types/react": "17.0.43",
"@types/react-dom": "17.0.14",
"@types/react-native": "0.67.7",
"@types/react-native": "0.67.8",
"@types/react-native-base64": "^0.2.0",
"@types/react-native-share-menu": "^5.0.2",
"@types/react-timeago": "4.1.3",

7
src/@types/app.d.ts vendored
View File

@ -13,11 +13,4 @@ declare namespace App {
| 'Conversations'
| 'Bookmarks'
| 'Favourites'
interface IImageInfo {
uri: string
width: number
height: number
type?: 'image' | 'video'
}
}

View File

@ -21,7 +21,6 @@ import * as SplashScreen from 'expo-splash-screen'
import React, { useCallback, useEffect, useState } from 'react'
import { LogBox, Platform } from 'react-native'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import 'react-native-image-keyboard'
import { enableFreeze } from 'react-native-screens'
import { QueryClientProvider } from 'react-query'
import { Provider } from 'react-redux'

View File

@ -178,8 +178,49 @@ const Screens: React.FC<Props> = ({ localCorrupt }) => {
}
let text: string | undefined = undefined
let images: { type: string; uri: string }[] = []
let video: { type: string; uri: string } | undefined = undefined
let media: { uri: string; mime: string }[] = []
const typesImage = ['png', 'jpg', 'jpeg', 'gif']
const typesVideo = ['mp4', 'm4v', 'mov', 'webm', 'mpeg']
const filterMedia = ({ uri, mime }: { uri: string; mime: string }) => {
if (mime.startsWith('image/')) {
if (!typesImage.includes(mime.split('/')[1])) {
console.warn('Image type not supported:', mime.split('/')[1])
displayMessage({
message: t('shareError.imageNotSupported', {
type: mime.split('/')[1]
}),
type: 'error',
theme
})
return
}
media.push({ uri, mime })
} else if (mime.startsWith('video/')) {
if (!typesVideo.includes(mime.split('/')[1])) {
console.warn('Video type not supported:', mime.split('/')[1])
displayMessage({
message: t('shareError.videoNotSupported', {
type: mime.split('/')[1]
}),
type: 'error',
theme
})
return
}
media.push({ uri, mime })
} else {
if (typesImage.includes(uri.split('.').pop() || '')) {
media.push({ uri, mime: 'image/jpg' })
return
}
if (typesVideo.includes(uri.split('.').pop() || '')) {
media.push({ uri, mime: 'video/mp4' })
return
}
text = !text ? uri : text.concat(text, `\n${uri}`)
}
}
switch (Platform.OS) {
case 'ios':
@ -187,55 +228,11 @@ const Screens: React.FC<Props> = ({ localCorrupt }) => {
return
}
item.data.forEach(d => {
if (typeof d === 'string') return
const typesImage = ['png', 'jpg', 'jpeg', 'gif']
const typesVideo = ['mp4', 'm4v', 'mov', 'webm']
const { mimeType, data } = d
if (mimeType.startsWith('image/')) {
if (!typesImage.includes(mimeType.split('/')[1])) {
console.warn(
'Image type not supported:',
mimeType.split('/')[1]
)
displayMessage({
message: t('shareError.imageNotSupported', {
type: mimeType.split('/')[1]
}),
type: 'error',
theme
})
return
}
images.push({ type: mimeType.split('/')[1], uri: data })
} else if (mimeType.startsWith('video/')) {
if (!typesVideo.includes(mimeType.split('/')[1])) {
console.warn(
'Video type not supported:',
mimeType.split('/')[1]
)
displayMessage({
message: t('shareError.videoNotSupported', {
type: mimeType.split('/')[1]
}),
type: 'error',
theme
})
return
}
video = { type: mimeType.split('/')[1], uri: data }
} else {
if (typesImage.includes(data.split('.').pop() || '')) {
images.push({ type: data.split('.').pop()!, uri: data })
return
}
if (typesVideo.includes(data.split('.').pop() || '')) {
video = { type: data.split('.').pop()!, uri: data }
return
}
text = !text ? data : text.concat(text, `\n${data}`)
for (const d of item.data) {
if (typeof d !== 'string') {
filterMedia({ uri: d.data, mime: d.mimeType })
}
})
}
break
case 'android':
if (!item.mimeType) {
@ -247,65 +244,16 @@ const Screens: React.FC<Props> = ({ localCorrupt }) => {
} else {
tempData = item.data
}
tempData.forEach(d => {
const typesImage = ['png', 'jpg', 'jpeg', 'gif']
const typesVideo = ['mp4', 'm4v', 'mov', 'webm', 'mpeg']
if (item.mimeType!.startsWith('image/')) {
if (!typesImage.includes(item.mimeType.split('/')[1])) {
console.warn(
'Image type not supported:',
item.mimeType.split('/')[1]
)
displayMessage({
message: t('shareError.imageNotSupported', {
type: item.mimeType.split('/')[1]
}),
type: 'error',
theme
})
return
}
images.push({ type: item.mimeType.split('/')[1], uri: d })
} else if (item.mimeType.startsWith('video/')) {
if (!typesVideo.includes(item.mimeType.split('/')[1])) {
console.warn(
'Video type not supported:',
item.mimeType.split('/')[1]
)
displayMessage({
message: t('shareError.videoNotSupported', {
type: item.mimeType.split('/')[1]
}),
type: 'error',
theme
})
return
}
video = { type: item.mimeType.split('/')[1], uri: d }
} else {
if (typesImage.includes(d.split('.').pop() || '')) {
images.push({ type: d.split('.').pop()!, uri: d })
return
}
if (typesVideo.includes(d.split('.').pop() || '')) {
video = { type: d.split('.').pop()!, uri: d }
return
}
text = !text ? d : text.concat(text, `\n${d}`)
}
})
for (const d of item.data) {
filterMedia({ uri: d, mime: item.mimeType })
}
break
}
if (!text && (!images || !images.length) && !video) {
if (!text && !media.length) {
return
} else {
navigationRef.navigate('Screen-Compose', {
type: 'share',
text,
images,
video
})
navigationRef.navigate('Screen-Compose', { type: 'share', text, media })
}
},
[instanceActive]

View File

@ -68,8 +68,8 @@ const apiGeneral = async <T = unknown>({
error.response.data.error
)
return Promise.reject({
status: error.response.status,
message: error.response.data.error
status: error?.response.status,
message: error?.response.data.error
})
} else if (error?.request) {
// The request was made but no response was received

View File

@ -110,8 +110,8 @@ const apiInstance = async <T = unknown>({
error.response.data.error
)
return Promise.reject({
status: error.response.status,
message: error.response.data.error
status: error?.response.status,
message: error?.response.data.error
})
} else if (error?.request) {
// The request was made but no response was received

View File

@ -66,7 +66,7 @@ const apiTooot = async <T = unknown>({
})
})
.catch(error => {
if (sentry && Math.random() < 0.1) {
if (sentry) {
Sentry.Native.setExtras({
API: 'tooot',
...(error?.response && { response: error.response }),
@ -85,8 +85,8 @@ const apiTooot = async <T = unknown>({
error.response.data.error
)
return Promise.reject({
status: error.response.status,
message: error.response.data.error
status: error?.response.status,
message: error?.response.data.error
})
} else if (error?.request) {
// The request was made but no response was received

View File

@ -0,0 +1,164 @@
import analytics from '@components/analytics'
import { displayMessage } from '@components/Message'
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[]
queryKey?: QueryKeyTimeline
rootQueryKey?: QueryKeyTimeline
id: Mastodon.Account['id']
}
const contextMenuAccount = ({
actions,
queryKey,
rootQueryKey,
id: accountId
}: Props) => {
const { theme } = useTheme()
const { t } = useTranslation('componentContextMenu')
const queryClient = useQueryClient()
const mutateion = useTimelineMutation({
onSuccess: (_, params) => {
const theParams = params as MutationVarsTimelineUpdateAccountProperty
displayMessage({
theme,
type: 'success',
message: t('common:message.success.message', {
function: t(`account.${theParams.payload.property}.action`)
})
})
},
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`)
}),
...(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
if (!ownAccount) {
switch (Platform.OS) {
case 'ios':
actions.push({
id: 'account',
title: t('account.title'),
inlineChildren: true,
actions: [
{
id: 'account-mute',
title: t('account.mute.action'),
systemIcon: 'eye.slash'
},
{
id: 'account-block',
title: t('account.block.action'),
systemIcon: 'xmark.circle',
destructive: true
},
{
id: 'account-reports',
title: t('account.reports.action'),
systemIcon: 'flag',
destructive: true
}
]
})
break
default:
actions.push(
{
id: 'account-mute',
title: t('account.mute.action'),
systemIcon: 'eye.slash'
},
{
id: 'account-block',
title: t('account.block.action'),
systemIcon: 'xmark.circle',
destructive: true
},
{
id: 'account-reports',
title: t('account.reports.action'),
systemIcon: 'flag',
destructive: true
}
)
break
}
}
return (id: string) => {
switch (id) {
case 'account-mute':
analytics('timeline_shared_headeractions_account_mute_press', {
page: queryKey && queryKey[1].page
})
mutateion.mutate({
type: 'updateAccountProperty',
queryKey,
id: accountId,
payload: { property: 'mute' }
})
break
case 'account-block':
analytics('timeline_shared_headeractions_account_block_press', {
page: queryKey && queryKey[1].page
})
mutateion.mutate({
type: 'updateAccountProperty',
queryKey,
id: accountId,
payload: { property: 'block' }
})
break
case 'account-report':
analytics('timeline_shared_headeractions_account_reports_press', {
page: queryKey && queryKey[1].page
})
mutateion.mutate({
type: 'updateAccountProperty',
queryKey,
id: accountId,
payload: { property: 'reports' }
})
break
}
}
}
export default contextMenuAccount

View File

@ -0,0 +1,108 @@
import analytics from '@components/analytics'
import { displayMessage } from '@components/Message'
import {
QueryKeyTimeline,
useTimelineMutation
} from '@utils/queryHooks/timeline'
import { getInstanceUrl } from '@utils/slices/instancesSlice'
import { useTheme } from '@utils/styles/ThemeManager'
import { useTranslation } from 'react-i18next'
import { Alert, 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[]
status: Mastodon.Status
queryKey: QueryKeyTimeline
rootQueryKey?: QueryKeyTimeline
}
const contextMenuInstance = ({
actions,
status,
queryKey,
rootQueryKey
}: Props) => {
const { t } = useTranslation('componentContextMenu')
const { theme } = useTheme()
const currentInstance = useSelector(getInstanceUrl)
const instance = status.uri && status.uri.split(new RegExp(/\/\/(.*?)\//))[1]
const queryClient = useQueryClient()
const mutation = useTimelineMutation({
onSettled: () => {
displayMessage({
theme,
type: 'success',
message: t('common:message.success.message', {
function: t(`instance.block.action`, { instance })
})
})
queryClient.invalidateQueries(queryKey)
rootQueryKey && queryClient.invalidateQueries(rootQueryKey)
}
})
if (currentInstance !== instance && instance) {
switch (Platform.OS) {
case 'ios':
actions.push({
id: 'instance',
title: t('instance.title'),
actions: [
{
id: 'instance-block',
title: t('instance.block.action', { instance }),
destructive: true
}
]
})
break
default:
actions.push({
id: 'instance-block',
title: t('instance.block.action', { instance }),
destructive: true
})
break
}
}
return (id: string) => {
switch (id) {
case '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

View File

@ -0,0 +1,38 @@
import analytics from '@components/analytics'
import { useTranslation } from 'react-i18next'
import { Platform, Share } from 'react-native'
import { ContextMenuAction } from 'react-native-context-menu-view'
export interface Props {
actions: ContextMenuAction[]
type: 'status' | 'account'
url: string
}
const contextMenuShare = ({ actions, type, url }: Props) => {
const { t } = useTranslation('componentContextMenu')
actions.push({
id: 'share',
title: t(`share.${type}.action`),
systemIcon: 'square.and.arrow.up'
})
return (id: string) => {
switch (id) {
case 'share':
analytics('timeline_shared_headeractions_share_press')
switch (Platform.OS) {
case 'ios':
Share.share({ url })
break
case 'android':
Share.share({ message: url })
break
}
break
}
}
}
export default contextMenuShare

View File

@ -0,0 +1,286 @@
import apiInstance from '@api/instance'
import analytics from '@components/analytics'
import { displayMessage } from '@components/Message'
import { useNavigation } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { RootStackParamList } from '@utils/navigation/navigators'
import {
MutationVarsTimelineUpdateStatusProperty,
QueryKeyTimeline,
useTimelineMutation
} from '@utils/queryHooks/timeline'
import {
checkInstanceFeature,
getInstanceAccount
} from '@utils/slices/instancesSlice'
import { useTheme } from '@utils/styles/ThemeManager'
import { useTranslation } from 'react-i18next'
import { Alert, 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[]
status: Mastodon.Status
queryKey: QueryKeyTimeline
rootQueryKey?: QueryKeyTimeline
}
const contextMenuStatus = ({
actions,
status,
queryKey,
rootQueryKey
}: Props) => {
const navigation =
useNavigation<
NativeStackNavigationProp<RootStackParamList, 'Screen-Tabs'>
>()
const { theme } = useTheme()
const { t } = useTranslation('componentContextMenu')
const queryClient = useQueryClient()
const mutation = useTimelineMutation({
onMutate: true,
onError: (err: any, params, oldData) => {
const theFunction = (params as MutationVarsTimelineUpdateStatusProperty)
.payload
? (params as MutationVarsTimelineUpdateStatusProperty).payload.property
: 'delete'
displayMessage({
theme,
type: 'error',
message: t('common:message.error.message', {
function: t(`status.${theFunction}.action`)
}),
...(err.status &&
typeof err.status === 'number' &&
err.data &&
err.data.error &&
typeof err.data.error === 'string' && {
description: err.data.error
})
})
queryClient.setQueryData(queryKey, oldData)
}
})
const instanceAccount = useSelector(
getInstanceAccount,
(prev, next) => prev.id === next.id
)
const ownAccount = instanceAccount?.id === status.account.id
if (ownAccount) {
const accountMenuItems: ContextMenuAction[] = [
{
id: 'status-delete',
title: t('status.delete.action'),
systemIcon: 'trash',
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-muted', {
context: status.muted.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-pinned', {
context: status.pinned.toString()
}),
systemIcon: status.pinned ? 'pin.slash' : 'pin'
})
}
switch (Platform.OS) {
case 'ios':
actions.push({
id: 'status',
title: t('status.title'),
inlineChildren: true,
actions: accountMenuItems
})
break
default:
actions.push(...accountMenuItems)
break
}
}
return async (id: string) => {
switch (id) {
case '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')
}
]
)
break
case '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
if (status.in_reply_to_id) {
replyToStatus = await apiInstance<Mastodon.Status>({
method: 'get',
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')
}
]
)
break
case '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
}
})
break
case '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 }),
queryKey,
rootQueryKey
})
})
break
case 'status-pin':
// Also note that reblogs cannot be pinned.
analytics('timeline_shared_headeractions_status_pin_press', {
page: queryKey && queryKey[1].page
})
mutation.mutate({
type: 'updateStatusProperty',
queryKey,
rootQueryKey,
id: status.id,
payload: {
property: 'pinned',
currentValue: status.pinned,
propertyCount: undefined,
countValue: undefined
}
})
break
}
}
}
export default contextMenuStatus

View File

@ -1,6 +1,5 @@
import Button from '@components/Button'
import Icon from '@components/Icon'
import { useAccessibility } from '@utils/accessibility/AccessibilityManager'
import { useAppsQuery } from '@utils/queryHooks/apps'
import { useInstanceQuery } from '@utils/queryHooks/instance'
import { getInstances } from '@utils/slices/instancesSlice'
@ -9,7 +8,7 @@ import { useTheme } from '@utils/styles/ThemeManager'
import * as WebBrowser from 'expo-web-browser'
import { debounce } from 'lodash'
import React, { RefObject, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Trans, useTranslation } from 'react-i18next'
import {
Alert,
Image,
@ -39,7 +38,6 @@ const ComponentInstance: React.FC<Props> = ({
}) => {
const { t } = useTranslation('componentInstance')
const { colors, mode } = useTheme()
const { screenReaderEnabled } = useAccessibility()
const instances = useSelector(getInstances, () => true)
const [domain, setDomain] = useState<string>()
@ -247,7 +245,7 @@ const ComponentInstance: React.FC<Props> = ({
style={{
flexDirection: 'row',
marginHorizontal: StyleConstants.Spacing.Global.PagePadding,
marginVertical: StyleConstants.Spacing.M
marginTop: StyleConstants.Spacing.M
}}
>
<Icon
@ -265,29 +263,59 @@ const ComponentInstance: React.FC<Props> = ({
<CustomText
fontStyle='S'
style={{ flex: 1, color: colors.secondary }}
accessibilityRole='link'
onPress={() => {
if (screenReaderEnabled) {
analytics('view_privacy')
WebBrowser.openBrowserAsync(
'https://tooot.app/privacy-policy'
)
}
}}
>
{t('server.disclaimer.base')}
<CustomText
accessible
style={{ color: colors.blue }}
onPress={() => {
analytics('view_privacy')
WebBrowser.openBrowserAsync(
'https://tooot.app/privacy-policy'
)
}}
>
{t('server.disclaimer.privacy')}
</CustomText>
</CustomText>
</View>
<View
style={{
flexDirection: 'row',
marginHorizontal: StyleConstants.Spacing.Global.PagePadding,
marginBottom: StyleConstants.Spacing.M
}}
>
<Icon
name='CheckSquare'
size={StyleConstants.Font.Size.S}
color={colors.secondary}
style={{
marginTop:
(StyleConstants.Font.LineHeight.S -
StyleConstants.Font.Size.S) /
2,
marginRight: StyleConstants.Spacing.XS
}}
/>
<CustomText
fontStyle='S'
style={{ flex: 1, color: colors.secondary }}
accessibilityRole='link'
>
<Trans
i18nKey='componentInstance:server.terms.base'
components={[
<CustomText
accessible
style={{ color: colors.blue }}
onPress={() => {
analytics('view_privacy')
WebBrowser.openBrowserAsync(
'https://tooot.app/privacy-policy'
)
}}
/>,
<CustomText
accessible
style={{ color: colors.blue }}
onPress={() => {
analytics('view_tos')
WebBrowser.openBrowserAsync(
'https://tooot.app/terms-of-service'
)
}}
/>
]}
/>
</CustomText>
</View>
</View>

View File

@ -49,7 +49,7 @@ const renderNode = ({
const href = node.attribs.href
if (classes) {
if (classes.includes('hashtag')) {
const tag = href.split(new RegExp(/\/tag\/(.*)|\/tags\/(.*)/))
const tag = href?.split(new RegExp(/\/tag\/(.*)|\/tags\/(.*)/))
const differentTag = routeParams?.hashtag
? routeParams.hashtag !== tag[1] && routeParams.hashtag !== tag[2]
: true
@ -107,7 +107,7 @@ const renderNode = ({
)
}
} else {
const domain = href.split(new RegExp(/:\/\/(.[^\/]+)/))
const domain = href?.split(new RegExp(/:\/\/(.[^\/]+)/))
// Need example here
const content =
node.children && node.children[0] && node.children[0].data
@ -124,11 +124,15 @@ const renderNode = ({
}}
onPress={async () => {
analytics('status_link_press')
!disableDetails && !shouldBeTag
? await openLink(href, navigation)
: navigation.push('Tab-Shared-Hashtag', {
if (!disableDetails) {
if (shouldBeTag) {
navigation.push('Tab-Shared-Hashtag', {
hashtag: content.substring(1)
})
} else {
await openLink(href, navigation)
}
}
}}
>
{(content && content !== href && content) ||

View File

@ -0,0 +1,32 @@
import { useEffect, useState } from 'react'
import { FormattedRelativeTime } from 'react-intl'
import { AppState } from 'react-native'
export interface Props {
type: 'past' | 'future'
time: string | number
}
const RelativeTime: React.FC<Props> = ({ type, time }) => {
const [now, setNow] = useState(new Date().getTime())
useEffect(() => {
const appStateListener = AppState.addEventListener('change', state => {
setNow(new Date().getTime())
})
return () => {
appStateListener.remove()
}
}, [])
return (
<FormattedRelativeTime
value={
((type === 'past' ? -1 : 1) * (now - new Date(time).getTime())) / 1000
}
updateIntervalInSeconds={1}
/>
)
}
export default RelativeTime

View File

@ -5,18 +5,11 @@ import { getInstanceActive } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { RefObject, useCallback, useRef } from 'react'
import {
FlatList,
FlatListProps,
Platform,
RefreshControl,
StyleSheet
} from 'react-native'
import { FlatList, FlatListProps, Platform, RefreshControl } from 'react-native'
import Animated, {
useAnimatedScrollHandler,
useSharedValue
} from 'react-native-reanimated'
import { useQueryClient } from 'react-query'
import { useSelector } from 'react-redux'
import TimelineEmpty from './Timeline/Empty'
import TimelineFooter from './Timeline/Footer'
@ -149,7 +142,6 @@ const Timeline: React.FC<Props> = ({
data={flattenData}
initialNumToRender={6}
maxToRenderPerBatch={3}
style={styles.flatList}
onEndReached={onEndReached}
onEndReachedThreshold={0.75}
ListFooterComponent={
@ -160,9 +152,13 @@ const Timeline: React.FC<Props> = ({
}
ListEmptyComponent={<TimelineEmpty queryKey={queryKey} />}
ItemSeparatorComponent={ItemSeparatorComponent}
maintainVisibleContentPosition={{
minIndexForVisible: 0
}}
maintainVisibleContentPosition={
isFetching
? {
minIndexForVisible: 0
}
: undefined
}
{...androidRefreshControl}
{...customProps}
/>
@ -170,10 +166,4 @@ const Timeline: React.FC<Props> = ({
)
}
const styles = StyleSheet.create({
flatList: {
minHeight: '100%'
}
})
export default Timeline

View File

@ -18,6 +18,7 @@ import { uniqBy } from 'lodash'
import React, { useCallback } from 'react'
import { Pressable, View } from 'react-native'
import { useSelector } from 'react-redux'
import TimelineContextMenu from './Shared/ContextMenu'
import TimelineFeedback from './Shared/Feedback'
import TimelineFiltered, { shouldFilter } from './Shared/Filtered'
import TimelineFullConversation from './Shared/FullConversation'
@ -73,96 +74,108 @@ const TimelineDefault: React.FC<Props> = ({
}, [])
return (
<Pressable
accessible={highlighted ? false : true}
style={{
padding: StyleConstants.Spacing.Global.PagePadding,
backgroundColor: colors.backgroundDefault,
paddingBottom:
disableDetails && disableOnPress
? StyleConstants.Spacing.Global.PagePadding
: 0
}}
onPress={onPress}
<TimelineContextMenu
status={actualStatus}
queryKey={queryKey}
rootQueryKey={rootQueryKey}
>
{item.reblog ? (
<TimelineActioned action='reblog' account={item.account} />
) : item._pinned ? (
<TimelineActioned action='pinned' account={item.account} />
) : null}
<View style={{ flex: 1, width: '100%', flexDirection: 'row' }}>
<TimelineAvatar
queryKey={disableOnPress ? undefined : queryKey}
account={actualStatus.account}
highlighted={highlighted}
/>
<TimelineHeaderDefault
queryKey={disableOnPress ? undefined : queryKey}
rootQueryKey={disableOnPress ? undefined : rootQueryKey}
status={actualStatus}
highlighted={highlighted}
/>
</View>
<View
<Pressable
accessible={highlighted ? false : true}
style={{
paddingTop: highlighted ? StyleConstants.Spacing.S : 0,
paddingLeft: highlighted
? 0
: StyleConstants.Avatar.M + StyleConstants.Spacing.S
padding: StyleConstants.Spacing.Global.PagePadding,
backgroundColor: colors.backgroundDefault,
paddingBottom:
disableDetails && disableOnPress
? StyleConstants.Spacing.Global.PagePadding
: 0
}}
onPress={onPress}
onLongPress={() => {}}
>
{typeof actualStatus.content === 'string' &&
actualStatus.content.length > 0 ? (
<TimelineContent
{item.reblog ? (
<TimelineActioned action='reblog' account={item.account} />
) : item._pinned ? (
<TimelineActioned action='pinned' account={item.account} />
) : null}
<View style={{ flex: 1, width: '100%', flexDirection: 'row' }}>
<TimelineAvatar
queryKey={disableOnPress ? undefined : queryKey}
account={actualStatus.account}
highlighted={highlighted}
/>
<TimelineHeaderDefault
queryKey={disableOnPress ? undefined : queryKey}
status={actualStatus}
highlighted={highlighted}
disableDetails={disableDetails}
/>
) : null}
{queryKey && actualStatus.poll ? (
<TimelinePoll
</View>
<View
style={{
paddingTop: highlighted ? StyleConstants.Spacing.S : 0,
paddingLeft: highlighted
? 0
: StyleConstants.Avatar.M + StyleConstants.Spacing.S
}}
>
{typeof actualStatus.content === 'string' &&
actualStatus.content.length > 0 ? (
<TimelineContent
status={actualStatus}
highlighted={highlighted}
disableDetails={disableDetails}
/>
) : null}
{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>
{queryKey && !disableDetails ? (
<TimelineActions
queryKey={queryKey}
rootQueryKey={rootQueryKey}
statusId={actualStatus.id}
poll={actualStatus.poll}
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}
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>
{queryKey && !disableDetails ? (
<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}
</Pressable>
</Pressable>
</TimelineContextMenu>
)
}

View File

@ -18,6 +18,7 @@ import { isEqual, uniqBy } from 'lodash'
import React, { useCallback } from 'react'
import { Pressable, View } from 'react-native'
import { useSelector } from 'react-redux'
import TimelineContextMenu from './Shared/ContextMenu'
import TimelineFiltered, { shouldFilter } from './Shared/Filtered'
import TimelineFullConversation from './Shared/FullConversation'
@ -58,103 +59,105 @@ const TimelineNotifications = React.memo(
}, [])
return (
<Pressable
style={{
padding: StyleConstants.Spacing.Global.PagePadding,
backgroundColor: colors.backgroundDefault,
paddingBottom: notification.status
? 0
: StyleConstants.Spacing.Global.PagePadding
}}
onPress={onPress}
>
{notification.type !== 'mention' ? (
<TimelineActioned
action={notification.type}
account={notification.account}
notification
/>
) : null}
<View
<TimelineContextMenu status={notification.status} queryKey={queryKey}>
<Pressable
style={{
opacity:
notification.type === 'follow' ||
notification.type === 'follow_request' ||
notification.type === 'mention' ||
notification.type === 'status'
? 1
: 0.5
padding: StyleConstants.Spacing.Global.PagePadding,
backgroundColor: colors.backgroundDefault,
paddingBottom: notification.status
? 0
: StyleConstants.Spacing.Global.PagePadding
}}
onPress={onPress}
onLongPress={() => {}}
>
<View style={{ flex: 1, width: '100%', flexDirection: 'row' }}>
<TimelineAvatar
queryKey={queryKey}
account={actualAccount}
highlighted={highlighted}
/>
<TimelineHeaderNotification
queryKey={queryKey}
notification={notification}
{notification.type !== 'mention' ? (
<TimelineActioned
action={notification.type}
account={notification.account}
notification
/>
) : null}
<View
style={{
opacity:
notification.type === 'follow' ||
notification.type === 'follow_request' ||
notification.type === 'mention' ||
notification.type === 'status'
? 1
: 0.5
}}
>
<View style={{ flex: 1, width: '100%', flexDirection: 'row' }}>
<TimelineAvatar
queryKey={queryKey}
account={actualAccount}
highlighted={highlighted}
/>
<TimelineHeaderNotification notification={notification} />
</View>
{notification.status ? (
<View
style={{
paddingTop: highlighted ? StyleConstants.Spacing.S : 0,
paddingLeft: highlighted
? 0
: StyleConstants.Avatar.M + StyleConstants.Spacing.S
}}
>
{notification.status.content.length > 0 ? (
<TimelineContent
status={notification.status}
highlighted={highlighted}
/>
) : null}
{notification.status.poll ? (
<TimelinePoll
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>
) : null}
</View>
{notification.status ? (
<View
style={{
paddingTop: highlighted ? StyleConstants.Spacing.S : 0,
paddingLeft: highlighted
? 0
: StyleConstants.Avatar.M + StyleConstants.Spacing.S
}}
>
{notification.status.content.length > 0 ? (
<TimelineContent
status={notification.status}
highlighted={highlighted}
/>
) : null}
{notification.status.poll ? (
<TimelinePoll
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>
<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}
</View>
{notification.status ? (
<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}
</Pressable>
</Pressable>
</TimelineContextMenu>
)
},
(prev, next) => isEqual(prev.notification, next.notification)

View File

@ -22,7 +22,7 @@ const TimelineActioned = React.memo(
const { colors } = useTheme()
const navigation =
useNavigation<StackNavigationProp<TabLocalStackParamList>>()
const name = account?.display_name || account.username
const name = account?.display_name || account?.username
const iconColor = colors.primaryDefault
const content = (content: string) => (

View File

@ -0,0 +1,82 @@
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 ContextMenu, {
ContextMenuAction,
ContextMenuProps
} from 'react-native-context-menu-view'
export interface Props {
status?: Mastodon.Status
queryKey?: QueryKeyTimeline
rootQueryKey?: QueryKeyTimeline
}
export const ContextMenuContext = createContext<ContextMenuAction[]>([])
const TimelineContextMenu: React.FC<Props & ContextMenuProps> = ({
children,
status,
queryKey,
rootQueryKey,
...props
}) => {
if (!status || !queryKey) {
return <>{children}</>
}
const actions: ContextMenuAction[] = []
const shareOnPress =
status.visibility !== 'direct'
? contextMenuShare({
actions,
type: 'status',
url: status.url || status.uri
})
: null
const statusOnPress = contextMenuStatus({
actions,
status,
queryKey,
rootQueryKey
})
const accountOnPress = contextMenuAccount({
actions,
queryKey,
rootQueryKey,
id: status.account.id
})
const instanceOnPress = contextMenuInstance({
actions,
status,
queryKey,
rootQueryKey
})
return (
<ContextMenuContext.Provider value={actions}>
<ContextMenu
actions={actions}
onPress={({ nativeEvent: { id } }) => {
for (const on of [
shareOnPress,
statusOnPress,
accountOnPress,
instanceOnPress
]) {
on && on(id)
}
}}
children={children}
{...props}
/>
</ContextMenuContext.Provider>
)
}
export default TimelineContextMenu

View File

@ -1,13 +1,12 @@
import Icon from '@components/Icon'
import { useNavigation } from '@react-navigation/native'
import { StackNavigationProp } from '@react-navigation/stack'
import { RootStackParamList } from '@utils/navigation/navigators'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react'
import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next'
import { 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'
@ -16,24 +15,19 @@ import HeaderSharedVisibility from './HeaderShared/Visibility'
export interface Props {
queryKey?: QueryKeyTimeline
rootQueryKey?: QueryKeyTimeline
status: Mastodon.Status
highlighted: boolean
}
const TimelineHeaderDefault = ({
queryKey,
rootQueryKey,
status,
highlighted
}: Props) => {
const { t } = useTranslation('componentTimeline')
const navigation = useNavigation<StackNavigationProp<RootStackParamList>>()
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: 5 }}>
<View style={{ flex: 7 }}>
<HeaderSharedAccount account={status.account} />
<View
style={{
@ -56,29 +50,27 @@ const TimelineHeaderDefault = ({
{queryKey ? (
<Pressable
accessibilityHint={t('shared.header.actions.accessibilityHint')}
accessibilityHint={t('accessibilityHint')}
style={{
flex: 1,
flexDirection: 'row',
justifyContent: 'center',
paddingBottom: StyleConstants.Spacing.S
marginBottom: StyleConstants.Spacing.L
}}
onPress={() =>
navigation.navigate('Screen-Actions', {
queryKey,
rootQueryKey,
status,
type: 'status'
})
}
children={
<Icon
name='MoreHorizontal'
color={colors.secondary}
size={StyleConstants.Font.Size.L}
/>
}
/>
>
<ContextMenu
dropdownMenuMode
actions={contextMenuContext}
onPress={() => {}}
children={
<Icon
name='MoreHorizontal'
color={colors.secondary}
size={StyleConstants.Font.Size.L}
/>
}
/>
</Pressable>
) : null}
</View>
)

View File

@ -3,14 +3,12 @@ import {
RelationshipIncoming,
RelationshipOutgoing
} from '@components/Relationship'
import { useNavigation } from '@react-navigation/native'
import { StackNavigationProp } from '@react-navigation/stack'
import { RootStackParamList } from '@utils/navigation/navigators'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useMemo } from 'react'
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'
@ -18,14 +16,14 @@ import HeaderSharedMuted from './HeaderShared/Muted'
import HeaderSharedVisibility from './HeaderShared/Visibility'
export interface Props {
queryKey: QueryKeyTimeline
notification: Mastodon.Notification
}
const TimelineHeaderNotification = ({ queryKey, notification }: Props) => {
const navigation = useNavigation<StackNavigationProp<RootStackParamList>>()
const TimelineHeaderNotification = ({ notification }: Props) => {
const { colors } = useTheme()
const contextMenuContext = useContext(ContextMenuContext)
const actions = useMemo(() => {
switch (notification.type) {
case 'follow':
@ -42,18 +40,18 @@ const TimelineHeaderNotification = ({ queryKey, notification }: Props) => {
justifyContent: 'center',
paddingBottom: StyleConstants.Spacing.S
}}
onPress={() =>
navigation.navigate('Screen-Actions', {
queryKey,
status: notification.status!,
type: 'status'
})
}
children={
<Icon
name='MoreHorizontal'
color={colors.secondary}
size={StyleConstants.Font.Size.L}
<ContextMenu
dropdownMenuMode
actions={contextMenuContext}
onPress={() => {}}
children={
<Icon
name='MoreHorizontal'
color={colors.secondary}
size={StyleConstants.Font.Size.L}
/>
}
/>
}
/>

View File

@ -27,7 +27,7 @@ const HeaderSharedAccount = React.memo(
numberOfLines={1}
>
<ParseEmojis
content={account?.display_name || account.username}
content={account?.display_name || account?.username}
emojis={account.emojis}
fontBold
/>

View File

@ -1,10 +1,11 @@
import Icon from '@components/Icon'
import RelativeTime from '@components/RelativeTime'
import CustomText from '@components/Text'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { FormattedDate, FormattedRelativeTime } from 'react-intl'
import { FormattedDate } from 'react-intl'
export interface Props {
created_at: Mastodon.Status['created_at'] | number
@ -31,12 +32,7 @@ const HeaderSharedCreated = React.memo(
/>
</>
) : (
<FormattedRelativeTime
value={
-(new Date().getTime() - new Date(actualTime).getTime()) / 1000
}
updateIntervalInSeconds={1}
/>
<RelativeTime type='past' time={actualTime} />
)}
</CustomText>
{edited_at ? (

View File

@ -4,6 +4,7 @@ import haptics from '@components/haptics'
import Icon from '@components/Icon'
import { displayMessage } from '@components/Message'
import { ParseEmojis } from '@components/Parse'
import RelativeTime from '@components/RelativeTime'
import CustomText from '@components/Text'
import {
MutationVarsTimelineUpdateStatusProperty,
@ -16,8 +17,7 @@ import { useTheme } from '@utils/styles/ThemeManager'
import { maxBy } from 'lodash'
import React, { useCallback, useMemo, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { FormattedRelativeTime } from 'react-intl'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { Pressable, View } from 'react-native'
import { useQueryClient } from 'react-query'
export interface Props {
@ -289,15 +289,7 @@ const TimelinePoll: React.FC<Props> = ({
return (
<Trans
i18nKey='componentTimeline:shared.poll.meta.expiration.until'
components={[
<FormattedRelativeTime
value={
(new Date(poll.expires_at).getTime() - new Date().getTime()) /
1000
}
updateIntervalInSeconds={1}
/>
]}
components={[<RelativeTime type='future' time={poll.expires_at} />]}
/>
)
}

View File

@ -6,10 +6,11 @@ import { getSettingsLanguage } from '@utils/slices/settingsSlice'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import * as Localization from 'expo-localization'
import React, { useState } from 'react'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Pressable } from 'react-native'
import { Circle } from 'react-native-animated-spinkit'
import detectLanguage from 'react-native-language-detection'
import { useSelector } from 'react-redux'
export interface Props {
@ -25,42 +26,55 @@ const TimelineTranslate = React.memo(
if (!highlighted) {
return null
}
if (!status.language) {
return null
}
const { t } = useTranslation('componentTimeline')
const { colors } = useTheme()
const tootLanguage = status.language.slice(0, 2)
const settingsLanguage = useSelector(getSettingsLanguage)
if (Localization.locale.includes(tootLanguage)) {
return null
}
if (settingsLanguage?.includes(tootLanguage)) {
return null
}
let text = status.spoiler_text
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].replaceAll(`:${emoji.shortcode}:`, ' ')
}
text[i] = text[i]
.replace(/(<([^>]+)>)/gi, ' ')
.replace(/@.*? /gi, ' ')
.replace(/#.*? /gi, ' ')
.replace(/http(s):\/\/.*? /gi, ' ')
}
const [detectedLanguage, setDetectedLanguage] = useState<string>('')
useEffect(() => {
const detect = async () => {
const result = await detectLanguage(text.join(`\n\n`))
setDetectedLanguage(result.detected.slice(0, 2))
}
detect()
}, [])
const settingsLanguage = useSelector(getSettingsLanguage)
const targetLanguage = settingsLanguage || Localization.locale || 'en'
const [enabled, setEnabled] = useState(false)
const { refetch, data, isLoading, isSuccess, isError } = useTranslateQuery({
source: status.language,
target: Localization.locale || settingsLanguage || 'en',
source: detectedLanguage,
target: targetLanguage,
text,
options: { enabled }
})
if (!detectedLanguage) {
return null
}
if (Localization.locale.slice(0, 2).includes(detectedLanguage)) {
return null
}
if (settingsLanguage?.slice(0, 2).includes(detectedLanguage)) {
return null
}
return (
<>
<Pressable
@ -74,13 +88,13 @@ const TimelineTranslate = React.memo(
if (enabled) {
if (!isSuccess) {
analytics('timeline_shared_translate_retry', {
language: status.language
language: detectedLanguage
})
refetch()
}
} else {
analytics('timeline_shared_translate', {
language: status.language
language: detectedLanguage
})
setEnabled(true)
}
@ -110,9 +124,7 @@ const TimelineTranslate = React.memo(
</CustomText>
<CustomText>
{__DEV__
? ` Source: ${status.language}; Target: ${
Localization.locale || settingsLanguage || 'en'
}`
? ` Source: ${detectedLanguage}; Target: ${targetLanguage}`
: undefined}
</CustomText>
{isLoading ? (
@ -138,7 +150,6 @@ const TimelineTranslate = React.memo(
)
},
(prev, next) =>
prev.status.language === next.status.language &&
prev.status.content === next.status.content &&
prev.status.spoiler_text === next.status.spoiler_text
)

View File

@ -1,17 +1,21 @@
import analytics from '@components/analytics'
import { ActionSheetOptions } from '@expo/react-native-action-sheet'
import * as ImageManipulator from 'expo-image-manipulator'
import * as ImagePicker from 'expo-image-picker'
import {
ImageInfo,
UIImagePickerPresentationStyle
} from 'expo-image-picker/build/ImagePicker.types'
import { store } from '@root/store'
import { getInstanceConfigurationStatusMaxAttachments } from '@utils/slices/instancesSlice'
import { manipulateAsync, SaveFormat } from 'expo-image-manipulator'
import * as ExpoImagePicker from 'expo-image-picker'
import i18next from 'i18next'
import { Alert, Linking, Platform } from 'react-native'
import ImagePicker, {
Image,
ImageOrVideo
} from 'react-native-image-crop-picker'
export interface Props {
mediaTypes?: ImagePicker.MediaTypeOptions
resize?: { width?: number; height?: number } // Resize mode contain
mediaType?: 'photo' | 'video'
resize?: { width?: number; height?: number }
maximum?: number
indicateMaximum?: boolean
showActionSheetWithOptions: (
options: ActionSheetOptions,
callback: (i?: number | undefined) => void | Promise<void>
@ -19,134 +23,194 @@ export interface Props {
}
const mediaSelector = async ({
mediaTypes = ImagePicker.MediaTypeOptions.All,
mediaType,
resize,
maximum,
indicateMaximum = false,
showActionSheetWithOptions
}: Props): Promise<ImageInfo> => {
return new Promise((resolve, reject) => {
const resolveResult = async (result: ImageInfo) => {
if (resize && result.type === 'image') {
let newResult: ImageManipulator.ImageResult
if (resize.width && resize.height) {
if (resize.width / resize.height > result.width / result.height) {
newResult = await ImageManipulator.manipulateAsync(result.uri, [
{ resize: { width: resize.width } }
])
} else {
newResult = await ImageManipulator.manipulateAsync(result.uri, [
{ resize: { height: resize.height } }
])
}: Props): Promise<({ uri: string } & Omit<ImageOrVideo, 'path'>)[]> => {
const checkLibraryPermission = async (): Promise<boolean> => {
const { status } =
await ExpoImagePicker.requestMediaLibraryPermissionsAsync()
if (status !== 'granted') {
Alert.alert(
i18next.t('componentMediaSelector:library.alert.title'),
i18next.t('componentMediaSelector:library.alert.message'),
[
{
text: i18next.t('common:buttons.cancel'),
style: 'cancel',
onPress: () =>
analytics('mediaSelector_nopermission', {
action: 'cancel'
})
},
{
text: i18next.t(
'componentMediaSelector:library.alert.buttons.settings'
),
style: 'default',
onPress: () => {
analytics('mediaSelector_nopermission', {
action: 'settings'
})
Linking.openURL('app-settings:')
}
}
]
)
return false
} else {
return true
}
}
const _maximum =
maximum ||
getInstanceConfigurationStatusMaxAttachments(store.getState()) ||
4
const options = () => {
switch (mediaType) {
case 'photo':
return [
i18next.t(
'componentMediaSelector:options.image',
indicateMaximum ? { context: 'max', max: _maximum } : undefined
),
i18next.t('common:buttons.cancel')
]
case 'video':
return [
i18next.t(
'componentMediaSelector:options.video',
indicateMaximum ? { context: 'max', max: 1 } : undefined
),
i18next.t('common:buttons.cancel')
]
default:
return [
i18next.t(
'componentMediaSelector:options.image',
indicateMaximum ? { context: 'max', max: _maximum } : undefined
),
i18next.t(
'componentMediaSelector:options.video',
indicateMaximum ? { context: 'max', max: 1 } : undefined
),
i18next.t('common:buttons.cancel')
]
}
}
return new Promise((resolve, reject) => {
const selectImage = async () => {
const images = await ImagePicker.openPicker({
mediaType: 'photo',
includeExif: false,
multiple: true,
minFiles: 1,
maxFiles: _maximum,
smartAlbums: ['UserLibrary'],
writeTempFile: false,
loadingLabelText: ''
}).catch(() => {})
if (!images) {
return reject()
}
// react-native-image-crop-picker may return HEIC as JPG that causes upload failure
if (Platform.OS === 'ios') {
for (const [index, image] of images.entries()) {
if (image.mime === 'image/heic') {
const converted = await manipulateAsync(image.sourceURL!, [], {
base64: false,
compress: 0.8,
format: SaveFormat.JPEG
})
images[index] = {
...images[index],
sourceURL: converted.uri,
mime: 'image/jpeg'
}
}
} else {
newResult = await ImageManipulator.manipulateAsync(result.uri, [
{ resize }
])
}
resolve({ ...newResult, cancelled: false })
}
if (!resize) {
return resolve(
images.map(image => ({
...image,
uri: image.sourceURL || `file://${image.path}`
}))
)
} else {
resolve(result)
const croppedImages: Image[] = []
for (const image of images) {
const croppedImage = await ImagePicker.openCropper({
mediaType: 'photo',
path: image.path,
width: resize.width,
height: resize.height,
cropperChooseText: i18next.t('common:buttons.apply'),
cropperCancelText: i18next.t('common:buttons.cancel'),
hideBottomControls: true
}).catch(() => {})
croppedImage && croppedImages.push(croppedImage)
}
return resolve(
croppedImages.map(image => ({
...image,
uri: `file://${image.path}`
}))
)
}
}
const selectVideo = async () => {
const video = await ImagePicker.openPicker({
mediaType: 'video',
includeExif: false,
loadingLabelText: ''
}).catch(() => {})
if (video) {
return resolve([
{ ...video, uri: video.sourceURL || `file://${video.path}` }
])
} else {
return reject()
}
}
showActionSheetWithOptions(
{
title: i18next.t('componentMediaSelector:title'),
options: [
i18next.t('componentMediaSelector:options.library'),
i18next.t('componentMediaSelector:options.photo'),
i18next.t('componentMediaSelector:options.cancel')
],
cancelButtonIndex: 2
options: options(),
cancelButtonIndex: mediaType ? 1 : 2
},
async buttonIndex => {
if (buttonIndex === 0) {
const { status } =
await ImagePicker.requestMediaLibraryPermissionsAsync()
if (status !== 'granted') {
Alert.alert(
i18next.t('componentMediaSelector:library.alert.title'),
i18next.t('componentMediaSelector:library.alert.message'),
[
{
text: i18next.t(
'componentMediaSelector:library.alert.buttons.cancel'
),
style: 'cancel',
onPress: () =>
analytics('mediaSelector_nopermission', {
action: 'cancel'
})
},
{
text: i18next.t(
'componentMediaSelector:library.alert.buttons.settings'
),
style: 'default',
onPress: () => {
analytics('mediaSelector_nopermission', {
action: 'settings'
})
Linking.openURL('app-settings:')
}
}
]
)
} else {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes,
exif: false,
presentationStyle:
Platform.OS === 'ios' && parseInt(Platform.Version) < 13
? UIImagePickerPresentationStyle.FULL_SCREEN
: UIImagePickerPresentationStyle.AUTOMATIC
})
if (!(await checkLibraryPermission())) {
return reject()
}
if (!result.cancelled) {
await resolveResult(result)
switch (mediaType) {
case 'photo':
if (buttonIndex === 0) {
await selectImage()
}
}
} else if (buttonIndex === 1) {
const { status } = await ImagePicker.requestCameraPermissionsAsync()
if (status !== 'granted') {
Alert.alert(
i18next.t('componentMediaSelector:photo.alert.title'),
i18next.t('componentMediaSelector:photo.alert.message'),
[
{
text: i18next.t(
'componentMediaSelector:photo.alert.buttons.cancel'
),
style: 'cancel',
onPress: () => {
analytics('compose_addattachment_camera_nopermission', {
action: 'cancel'
})
}
},
{
text: i18next.t(
'componentMediaSelector:photo.alert.buttons.settings'
),
style: 'default',
onPress: () => {
analytics('compose_addattachment_camera_nopermission', {
action: 'settings'
})
Linking.openURL('app-settings:')
}
}
]
)
} else {
const result = await ImagePicker.launchCameraAsync({
mediaTypes,
exif: false
})
if (!result.cancelled) {
await resolveResult(result)
break
case 'video':
if (buttonIndex === 0) {
await selectVideo()
}
}
break
default:
if (buttonIndex === 0) {
await selectImage()
} else if (buttonIndex === 1) {
await selectVideo()
}
break
}
}
)

View File

@ -0,0 +1,149 @@
import { store } from '@root/store'
import { getInstanceConfigurationMediaAttachments } from '@utils/slices/instancesSlice'
import { Action, manipulateAsync, SaveFormat } from 'expo-image-manipulator'
import i18next from 'i18next'
import { Platform } from 'react-native'
import ImagePicker from 'react-native-image-crop-picker'
export interface Props {
type: 'image' | 'video'
uri: string // This can be pure path or uri starting with file://
mime?: string
transform: {
imageFormat?: SaveFormat.JPEG | SaveFormat.PNG
resize?: boolean
width?: number
height?: number
}
}
const getFileExtension = (uri: string) => {
const extension = uri.split('.').pop()
// Using mime type standard of jpeg
return extension === 'jpg' ? 'jpeg' : extension
}
const mediaTransformation = async ({
type,
uri,
mime,
transform
}: Props): Promise<{
uri: string
mime: string
width: number
height: number
}> => {
const configurationMediaAttachments =
getInstanceConfigurationMediaAttachments(store.getState())
const fileExtension = getFileExtension(uri)
switch (type) {
case 'image':
if (mime === 'image/gif' || fileExtension === 'gif') {
return Promise.reject('GIFs should not be transformed')
}
let targetFormat: SaveFormat.JPEG | SaveFormat.PNG = SaveFormat.JPEG
const supportedImageTypes =
configurationMediaAttachments.supported_mime_types.filter(mime =>
mime.startsWith('image/')
)
// @ts-ignore
const transformations: Action[] = [
!transform.resize && (transform.width || transform.height)
? {
resize: { width: transform.width, height: transform.height }
}
: null
].filter(t => !!t)
if (mime) {
if (
mime !== `image/${fileExtension}` ||
!supportedImageTypes.includes(mime)
) {
targetFormat = transform.imageFormat || SaveFormat.JPEG
} else {
targetFormat = mime.split('/').pop() as any
}
} else {
if (!fileExtension) {
return Promise.reject('Unable to get file extension')
}
if (!supportedImageTypes.includes(`image/${fileExtension}`)) {
targetFormat = transform.imageFormat || SaveFormat.JPEG
} else {
targetFormat = fileExtension as any
}
}
const converted = await manipulateAsync(uri, transformations, {
base64: false,
compress: Platform.OS === 'ios' ? 0.8 : 1,
format: targetFormat
})
if (transform.resize) {
const resized = await ImagePicker.openCropper({
mediaType: 'photo',
path: converted.uri,
width: transform.width,
height: transform.height,
cropperChooseText: i18next.t('common:buttons.apply'),
cropperCancelText: i18next.t('common:buttons.cancel'),
hideBottomControls: true
})
if (!resized) {
return Promise.reject('Resize failed')
} else {
return {
uri: resized.path,
mime: resized.mime,
width: resized.width,
height: resized.height
}
}
} else {
return {
uri: converted.uri,
mime: transform.imageFormat || SaveFormat.JPEG,
width: converted.width,
height: converted.height
}
}
case 'video':
const supportedVideoTypes =
configurationMediaAttachments.supported_mime_types.filter(mime =>
mime.startsWith('video/')
)
if (mime) {
if (mime !== `video/${fileExtension}`) {
console.warn('Video mime type and file extension does not match')
}
if (!supportedVideoTypes.includes(mime)) {
return Promise.reject('Video file type is not supported')
}
} else {
if (!fileExtension) {
return Promise.reject('Unable to get file extension')
}
if (!supportedVideoTypes.includes(`video/${fileExtension}`)) {
return Promise.reject('Video file type is not supported')
}
}
return {
uri: uri,
mime: mime || `video/${fileExtension}`,
width: 0,
height: 0
}
break
}
}
export default mediaTransformation

View File

@ -0,0 +1,70 @@
{
"accessibilityHint": "Funktionen für diesen Tröt - wie z. B. Autor und Originaltröt",
"account": {
"title": "",
"mute": {
"action": "Profil stummschalten"
},
"block": {
"action": "Nutzer blockieren"
},
"reports": {
"action": "User melden"
}
},
"instance": {
"title": "",
"block": {
"action": "Instanz {{instance}} blockieren",
"alert": {
"title": "",
"message": "Üblicherweise kannst du einen User stummschalten oder blockieren.\nBlockierst du hingegegen eine Instanz, wird deren gesamter Inhalt samt Usern, die dir von dieser Instanz folgen, entfernt!",
"buttons": {
"confirm": "Bestätigen"
}
}
}
},
"share": {
"status": {
"action": "Tröt teilen"
},
"account": {
"action": "User verlinken"
}
},
"status": {
"title": "",
"edit": {
"action": "Tröt bearbeiten"
},
"delete": {
"action": "Tröt löschen",
"alert": {
"title": "Löschen bestätigen?",
"message": "",
"buttons": {
"confirm": "Bestätigen"
}
}
},
"deleteEdit": {
"action": "",
"alert": {
"title": "",
"message": "",
"buttons": {
"confirm": "Bestätigen"
}
}
},
"mute": {
"action-muted_false": "",
"action-muted_true": ""
},
"pin": {
"action-pinned_false": "",
"action-pinned_true": ""
}
}
}

View File

@ -11,8 +11,10 @@
"domains": "Domains"
},
"disclaimer": {
"base": "Der Login erfolgt über den Browser, so dass Ihre Kontoinformationen für die Toot-App nicht sichtbar sind. Weitere Informationen",
"privacy": "Datenschutzbestimmungen"
"base": "Der Login erfolgt über den Browser, so dass Ihre Kontoinformationen für die Toot-App nicht sichtbar sind."
},
"terms": {
"base": ""
}
},
"update": {

View File

@ -1,27 +1,17 @@
{
"title": "Datenquelle auswählen",
"options": {
"library": "Hochladen",
"photo": "Bild aufnehmen",
"cancel": "$t(common:buttons.cancel)"
"image": "",
"image_max": "",
"video": "",
"video_max": ""
},
"library": {
"alert": {
"title": "Kein Zugriff",
"message": "Für den Upload ist eine Zugriffsgenehmigung erforderlich",
"buttons": {
"settings": "Einstellungen bestätigen",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"photo": {
"alert": {
"title": "Zugriff verweigert",
"message": "Zugriff auf die Kamera erforderlich",
"buttons": {
"settings": "Einstellungen übernehmen",
"cancel": "$t(common:buttons.cancel)"
"settings": "Einstellungen bestätigen"
}
}
}

View File

@ -123,94 +123,6 @@
"delete": {
"function": "Nachricht löschen"
}
},
"actions": {
"accessibilityHint": "Funktionen für diesen Tröt - wie z. B. Autor und Originaltröt",
"account": {
"heading": "Über die Nutzerin/den Nutzer",
"mute": {
"function": "Profil stummschalten",
"button": "@{{acct}} stummschalten"
},
"block": {
"function": "Nutzer blockieren",
"button": "@{{acct}} blockieren"
},
"reports": {
"function": "User melden",
"button": "@{{acct}} melden"
}
},
"domain": {
"heading": "Über diese Instanz",
"block": {
"function": "Instanz blockieren",
"button": "Instanz {{domain}} blockieren"
},
"alert": {
"title": "{{domain}} wirklich blockieren?",
"message": "Üblicherweise kannst du einen User stummschalten oder blockieren.\nBlockierst du hingegegen eine Instanz, wird deren gesamter Inhalt samt Usern, die dir von dieser Instanz folgen, entfernt!",
"buttons": {
"confirm": "Blockierung bestätigen",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"share": {
"status": {
"heading": "Tröt teilen",
"button": "Link zu diesem Tröt teilen"
},
"account": {
"heading": "User verlinken",
"button": "Link zu diesem Benutzer teilen"
}
},
"status": {
"heading": "Über Toot",
"edit": {
"function": "Tröt bearbeiten",
"button": "Diesen Tröt bearbeiten"
},
"delete": {
"function": "Tröt löschen",
"button": "Diesen Tröt löschen",
"alert": {
"title": "Tröt sicher löschen?",
"message": "Bist du wirklich sicher, diesen Tröt löschen zu wollen? Sämtliche Boosts und Sterne werden samt der Antworten entfernt.",
"buttons": {
"confirm": "Löschen bestätigen",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"deleteEdit": {
"function": "Tröt löschen",
"button": "Diesen Tröt neu entwerfen",
"alert": {
"title": "Tröt sicher löschen?",
"message": "Bist du wirklich sicher, diesen Tröt neu zu entwerfen? Alle Boosts und Sterne werden entfernt - samt der Antworten.",
"buttons": {
"confirm": "Löschen bestätigen",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"mute": {
"function": "Tröt stummschalten",
"button": {
"positive": "Diesen Tröt sowie die Antworten stummschalten",
"negative": "Diesen Tröt sowie die Antworten nicht mehr stummschalten"
}
},
"pin": {
"function": "Anheften",
"button": {
"positive": "Diesen Tröt anheften",
"negative": "Diesen Tröt nicht mehr anheften"
}
}
}
}
},
"poll": {

View File

@ -8,6 +8,7 @@ export default {
screenImageViewer: require('./screens/imageViewer'),
screenTabs: require('./screens/tabs'),
componentContextMenu: require('./components/contextMenu'),
componentEmojis: require('./components/emojis'),
componentInstance: require('./components/instance'),
componentMediaSelector: require('./components/mediaSelector'),

View File

@ -0,0 +1,70 @@
{
"accessibilityHint": "Actions for this toot, such as its posted user, toot itself",
"account": {
"title": "User actions",
"mute": {
"action": "Mute user"
},
"block": {
"action": "Block user"
},
"reports": {
"action": "Report user"
}
},
"instance": {
"title": "Instance action",
"block": {
"action": "Block instance {{instance}}",
"alert": {
"title": "Confirm blocking instance {{instance}} ?",
"message": "Mostly you can mute or block certain user.\n\nAfter blocking instance, all its content including followers from this instance will be removed!",
"buttons": {
"confirm": "Confirm"
}
}
}
},
"share": {
"status": {
"action": "Share toot"
},
"account": {
"action": "Share user"
}
},
"status": {
"title": "Toot actions",
"edit": {
"action": "Edit toot"
},
"delete": {
"action": "Delete toot",
"alert": {
"title": "Confirm deleting?",
"message": "All boosts and favourites will be cleared, including all replies.",
"buttons": {
"confirm": "Confirm"
}
}
},
"deleteEdit": {
"action": "Delete toot and repost",
"alert": {
"title": "Confirm deleting and repost?",
"message": "All boosts and favourites will be cleared, including all replies.",
"buttons": {
"confirm": "Confirm"
}
}
},
"mute": {
"action-muted_false": "Mute toot and replies",
"action-muted_true": "Unmute toot and replies"
},
"pin": {
"action-pinned_false": "Pin toot",
"action-pinned_true": "Unpin toot"
}
}
}

View File

@ -11,8 +11,10 @@
"domains": "Universes"
},
"disclaimer": {
"base": "Logging in process uses system broswer that, your account information won't be visible to tooot app. Read more ",
"privacy": "privacy policy"
"base": "Logging in process uses system broswer that, your account information won't be visible to tooot app."
},
"terms": {
"base": "By logging in, you agree to the <0>privacy policy</0> and <1>terms of service</1>."
}
},
"update": {

View File

@ -1,27 +1,17 @@
{
"title": "Select media source",
"options": {
"library": "Upload from library",
"photo": "Take a photo",
"cancel": "$t(common:buttons.cancel)"
"image": "Upload photos",
"image_max": "Upload photos (max {{max}})",
"video": "Upload video",
"video_max": "Upload video (max {{max}})"
},
"library": {
"alert": {
"title": "No permission",
"message": "Require photo library read permission to upload",
"buttons": {
"settings": "Update setting",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"photo": {
"alert": {
"title": "No permission",
"message": "Require camera usage permission to upload",
"buttons": {
"settings": "Update setting",
"cancel": "$t(common:buttons.cancel)"
"settings": "Update setting"
}
}
}

View File

@ -123,94 +123,6 @@
"delete": {
"function": "Delete direct message"
}
},
"actions": {
"accessibilityHint": "Actions for this toot, such as its posted user, toot itself",
"account": {
"heading": "About user",
"mute": {
"function": "Mute user",
"button": "Mute @{{acct}}"
},
"block": {
"function": "Block user",
"button": "Block @{{acct}}"
},
"reports": {
"function": "Report user",
"button": "Report @{{acct}}"
}
},
"domain": {
"heading": "About instance",
"block": {
"function": "Block instance",
"button": "Block instance {{domain}}"
},
"alert": {
"title": "Confirm blocking {{domain}} ?",
"message": "Mostly you can mute or block certain user.\n\nAfter blocking instance, all its content including followers from this instance will be removed!",
"buttons": {
"confirm": "Confirm blocking",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"share": {
"status": {
"heading": "Share toot",
"button": "Share link to this toot"
},
"account": {
"heading": "Share user",
"button": "Share link to this user"
}
},
"status": {
"heading": "About toot",
"edit": {
"function": "Edit toot",
"button": "Edit this toot"
},
"delete": {
"function": "Delete toot",
"button": "Delete this toot",
"alert": {
"title": "Confirm deleting toot?",
"message": "Are you sure to delete this toot? All boosts and favourites will be cleared, including all replies.",
"buttons": {
"confirm": "Confirm deleting",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"deleteEdit": {
"function": "Delete toot",
"button": "Delete and re-draft",
"alert": {
"title": "Confirm deleting toot?",
"message": "Are you sure to delete and re-draft this toot? All boosts and favourites will be cleared, including all replies.",
"buttons": {
"confirm": "Confirm deleting",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"mute": {
"function": "Mute toot",
"button": {
"positive": "Mute this toot and replies",
"negative": "Unmute this toot and replies"
}
},
"pin": {
"function": "Pin",
"button": {
"positive": "Pin this toot",
"negative": "Unpin this toot"
}
}
}
}
},
"poll": {

View File

@ -60,8 +60,8 @@ i18n.use(initReactI18next).init({
returnEmptyString: false,
saveMissing: true,
missingKeyHandler: (ns, key) => {
console.log('i18n missing: ' + ns + ' : ' + key)
missingKeyHandler: (_, ns, key) => {
console.log('i18n missing', ns, key)
},
interpolation: {

View File

@ -0,0 +1,70 @@
{
"accessibilityHint": "Azioni per questo toot, per l'utente che l'ha pubblicato o per il toot stesso",
"account": {
"title": "",
"mute": {
"action": "Muta utente"
},
"block": {
"action": "Blocca utente"
},
"reports": {
"action": "Segnala utente"
}
},
"instance": {
"title": "",
"block": {
"action": "Blocca istanza {{instance}}",
"alert": {
"title": "",
"message": "Sarebbe meglio mutare o bloccare singoli utenti.\n\nSe blocchi un'istanza, tutti i suoi contenuti a te relativi, inclusi tutti i tuoi seguaci da questa, saranno rimossi.",
"buttons": {
"confirm": "Ho capito"
}
}
}
},
"share": {
"status": {
"action": "Condividi toot"
},
"account": {
"action": "Condividi utente"
}
},
"status": {
"title": "",
"edit": {
"action": "Modifica toot"
},
"delete": {
"action": "Cancella toot",
"alert": {
"title": "Conferma?",
"message": "",
"buttons": {
"confirm": "Ho capito"
}
}
},
"deleteEdit": {
"action": "",
"alert": {
"title": "",
"message": "",
"buttons": {
"confirm": "Ho capito"
}
}
},
"mute": {
"action-muted_false": "",
"action-muted_true": ""
},
"pin": {
"action-pinned_false": "",
"action-pinned_true": ""
}
}
}

View File

@ -11,8 +11,10 @@
"domains": "Universi"
},
"disclaimer": {
"base": "Per accedere, verrà aperta una pagina del browser di sistema. I dati di accesso del tuo account sono protetti. Leggi di più:",
"privacy": "politica sulla privacy"
"base": "Per accedere, verrà aperta una pagina del browser di sistema. I dati di accesso del tuo account sono protetti."
},
"terms": {
"base": ""
}
},
"update": {

View File

@ -1,27 +1,17 @@
{
"title": "Seleziona origine media",
"options": {
"library": "Carica da libreria locale",
"photo": "Scatta una foto",
"cancel": "$t(common:buttons.cancel)"
"image": "",
"image_max": "",
"video": "",
"video_max": ""
},
"library": {
"alert": {
"title": "Permesso non concesso",
"message": "È richiesto l'accesso ai file del dispositivo per il caricamento dalla libreria",
"buttons": {
"settings": "Correggi impostazioni",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"photo": {
"alert": {
"title": "Permesso non concesso",
"message": "È richiesto l'accesso alla fotocamera per scattare foto",
"buttons": {
"settings": "Correggi impostazioni",
"cancel": "$t(common:buttons.cancel)"
"settings": "Correggi impostazioni"
}
}
}

View File

@ -123,94 +123,6 @@
"delete": {
"function": "Cancella messaggio"
}
},
"actions": {
"accessibilityHint": "Azioni per questo toot, per l'utente che l'ha pubblicato o per il toot stesso",
"account": {
"heading": "Riguardo quest'utente",
"mute": {
"function": "Muta utente",
"button": "Muta @{{acct}}"
},
"block": {
"function": "Blocca utente",
"button": "Blocca @{{acct}}"
},
"reports": {
"function": "Segnala utente",
"button": "Segnala @{{acct}}"
}
},
"domain": {
"heading": "Riguardo questa istanza",
"block": {
"function": "Blocca istanza",
"button": "Blocca istanza {{domain}}"
},
"alert": {
"title": "Conferma blocco di {{domain}} ?",
"message": "Sarebbe meglio mutare o bloccare singoli utenti.\n\nSe blocchi un'istanza, tutti i suoi contenuti a te relativi, inclusi tutti i tuoi seguaci da questa, saranno rimossi.",
"buttons": {
"confirm": "Conferma blocco",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"share": {
"status": {
"heading": "Condividi toot",
"button": "Condividi il link a questo toot"
},
"account": {
"heading": "Condividi utente",
"button": "Share il link a questo utente"
}
},
"status": {
"heading": "Riguardo questo toot",
"edit": {
"function": "Modifica toot",
"button": "Modifica questo toot"
},
"delete": {
"function": "Cancella toot",
"button": "Cancella toot",
"alert": {
"title": "Cancellare il toot?",
"message": "Vuoi davvero cancellare questo toot? Tutti gli apprezzamenti, le ricondivisioni, e le risposte verranno persi.",
"buttons": {
"confirm": "Conferma",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"deleteEdit": {
"function": "Cancella toot",
"button": "Cancella e riscrivi toot",
"alert": {
"title": "Cancellare e riscrivere il toot?",
"message": "Vuoi davvero cancellare e riscrivere questo toot? Tutti gli apprezzamenti, le ricondivisioni, e le risposte verranno persi.",
"buttons": {
"confirm": "Conferma",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"mute": {
"function": "Muta toot",
"button": {
"positive": "Muta questo toot e le sue risposte",
"negative": "Smuta questo toot e le sue risposte"
}
},
"pin": {
"function": "Fissa in cima",
"button": {
"positive": "Fissa questo toot in cima al profilo",
"negative": "Togli questo toot dalla cima del profilo"
}
}
}
}
},
"poll": {

View File

@ -0,0 +1,70 @@
{
"accessibilityHint": "이 툿에 할 동작, 툿 자체나 포스트한 사용자",
"account": {
"title": "",
"mute": {
"action": "사용자 음소거"
},
"block": {
"action": "사용자 차단"
},
"reports": {
"action": "사용자 신고"
}
},
"instance": {
"title": "",
"block": {
"action": "인스턴스 {{instance}} 차단",
"alert": {
"title": "",
"message": "보통은 유저 음소거나 차단으로 충분해요.\n\n인스턴스를 차단하면, 팔로워를 포함하는 인스턴스의 모든 콘텐츠가 삭제됩니다!",
"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-muted_false": "",
"action-muted_true": ""
},
"pin": {
"action-pinned_false": "",
"action-pinned_true": ""
}
}
}

View File

@ -11,8 +11,10 @@
"domains": "연합"
},
"disclaimer": {
"base": "로그인 과정에서는 시스템 브라우저를 사용해, tooot 앱이 당신의 계정 정보를 볼 수 없어요.\n더 알아보기 ",
"privacy": "개인정보 정책"
"base": "로그인 과정에서는 시스템 브라우저를 사용해, tooot 앱이 당신의 계정 정보를 볼 수 없어요."
},
"terms": {
"base": ""
}
},
"update": {

View File

@ -1,27 +1,17 @@
{
"title": "미디어 소스 선택",
"options": {
"library": "라이브러리에서 업로드",
"photo": "사진 촬영",
"cancel": "$t(common:buttons.cancel)"
"image": "",
"image_max": "",
"video": "",
"video_max": ""
},
"library": {
"alert": {
"title": "권한 없음",
"message": "업로드를 위해 사진 라이브러리 권한이 필요해요",
"buttons": {
"settings": "설정 업데이트",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"photo": {
"alert": {
"title": "권한 없음",
"message": "업로드를 위해 카메라 사용 권한이 필요해요",
"buttons": {
"settings": "설정 업데이트",
"cancel": "$t(common:buttons.cancel)"
"settings": "설정 업데이트"
}
}
}

View File

@ -123,94 +123,6 @@
"delete": {
"function": "개인 메시지 삭제"
}
},
"actions": {
"accessibilityHint": "이 툿에 할 동작, 툿 자체나 포스트한 사용자",
"account": {
"heading": "사용자 정보",
"mute": {
"function": "사용자 음소거",
"button": "@{{acct}} 음소거"
},
"block": {
"function": "사용자 차단",
"button": "@{{acct}} 차단"
},
"reports": {
"function": "사용자 신고",
"button": "@{{acct}} 신고"
}
},
"domain": {
"heading": "인스턴스 정보",
"block": {
"function": "인스턴스 차단",
"button": "인스턴스 {{domain}} 차단"
},
"alert": {
"title": "{{domain}}을 정말 차단할까요?",
"message": "보통은 유저 음소거나 차단으로 충분해요.\n\n인스턴스를 차단하면, 팔로워를 포함하는 인스턴스의 모든 콘텐츠가 삭제됩니다!",
"buttons": {
"confirm": "차단 확인",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"share": {
"status": {
"heading": "툿 공유",
"button": "이 툿의 링크 공유"
},
"account": {
"heading": "사용자 공유",
"button": "이 사용자에게 링크 공유"
}
},
"status": {
"heading": "툿 정보",
"edit": {
"function": "",
"button": ""
},
"delete": {
"function": "툿 삭제",
"button": "이 툿 삭제",
"alert": {
"title": "툿을 정말 삭제할까요?",
"message": "",
"buttons": {
"confirm": "삭제 확인",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"deleteEdit": {
"function": "툿 삭제",
"button": "삭제하고 다시 쓰기",
"alert": {
"title": "툿을 정말 삭제할까요?",
"message": "이 툿을 삭제하고 다시 초안을 작성하시겠어요? 모든 답장, 부스트와 즐겨찾기가 지워져요.",
"buttons": {
"confirm": "삭제 확인",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"mute": {
"function": "툿 음소거",
"button": {
"positive": "이 툿과 답장 음소거",
"negative": "이 툿과 답장 음소거 해제"
}
},
"pin": {
"function": "고정",
"button": {
"positive": "이 툿 고정",
"negative": "이 툿 고정 해제"
}
}
}
}
},
"poll": {

View File

@ -0,0 +1,70 @@
{
"accessibilityHint": "Ações para este toot, como o seu usuário publicado",
"account": {
"title": "Ações do Usuário",
"mute": {
"action": "Silenciar usuário"
},
"block": {
"action": "Bloquear usuário"
},
"reports": {
"action": "Denunciar usuário"
}
},
"instance": {
"title": "Ação da Instância",
"block": {
"action": "Bloquear a instância {{instance}}",
"alert": {
"title": "Confirmar o bloqueio da instância {{instance}}?",
"message": "Na maioria das vezes, você pode silenciar ou bloquear determinado usuário.\n\nDepois de bloquear a instância, todo seu conteúdo, incluindo seguidores, será removido!",
"buttons": {
"confirm": "Confirmar"
}
}
}
},
"share": {
"status": {
"action": "Compartilhar toot"
},
"account": {
"action": "Compartilhar Usuário"
}
},
"status": {
"title": "Ações do toot",
"edit": {
"action": "Editar toot"
},
"delete": {
"action": "Remover toot",
"alert": {
"title": "Confirme a exclusão?",
"message": "Todos os boosts e favoritos serão limpos, incluindo todas as respostas.",
"buttons": {
"confirm": "Confirmar"
}
}
},
"deleteEdit": {
"action": "Excluir toot e repostar",
"alert": {
"title": "Confirmar exclusão e repostagem?",
"message": "Todos os boosts e favoritos serão limpos, incluindo todas as respostas.",
"buttons": {
"confirm": "Confirmar"
}
}
},
"mute": {
"action-muted_false": "Silenciar este toot e respostas",
"action-muted_true": "Desbloquear este toot e respostas"
},
"pin": {
"action-pinned_false": "Toot fixado",
"action-pinned_true": "Desafixar toot"
}
}
}

View File

@ -11,8 +11,10 @@
"domains": "Universo"
},
"disclaimer": {
"base": "O processo de login usa o navegador do sistema e as informações da sua conta não estarão visíveis para o aplicativo Tooot. Consulte Mais informação ",
"privacy": "política de privacidade"
"base": "O processo de login usa o navegador do sistema e as informações da sua conta não estarão visíveis para o aplicativo Tooot."
},
"terms": {
"base": "Ao fazer o login, você concorda com a <0>política de privacidade</0> e os <1>termos de serviço</1>."
}
},
"update": {

View File

@ -1,27 +1,17 @@
{
"title": "Selecionar fonte de mídia",
"options": {
"library": "Carregar da biblioteca",
"photo": "Tirar foto",
"cancel": "$t(common:buttons.cancel)"
"image": "Enviar fotos",
"image_max": "Carregar fotos (máx. {{max}})",
"video": "Enviar vídeo",
"video_max": "Carregar vídeo (máx. {{max}})"
},
"library": {
"alert": {
"title": "Sem permissão",
"message": "Exigir permissão de leitura da biblioteca de fotos para fazer upload",
"buttons": {
"settings": "Atualizar configurações",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"photo": {
"alert": {
"title": "Sem permissão",
"message": "Requer permissão de uso da câmera para fazer upload",
"buttons": {
"settings": "Atualizar configurações",
"cancel": "$t(common:buttons.cancel)"
"settings": "Atualizar configurações"
}
}
}

View File

@ -30,7 +30,7 @@
"default": "{{name}} boostou",
"notification": "{{name}} deu boost no teu toot"
},
"update": ""
"update": "Toot foi editado"
},
"actions": {
"reply": {
@ -46,7 +46,7 @@
},
"bookmarked": {
"accessibilityLabel": "Adicionar este toot aos favoritos",
"function": "Favoritos"
"function": "Salvos"
}
},
"actionsUsers": {
@ -123,94 +123,6 @@
"delete": {
"function": "Excluir mensagem direta"
}
},
"actions": {
"accessibilityHint": "Ações para este toot, como o seu usuário publicado",
"account": {
"heading": "Sobre o usuário",
"mute": {
"function": "Silenciar usuário",
"button": "Silenciar @{{acct}}"
},
"block": {
"function": "Bloquear usuário",
"button": "Bloquear @{{acct}}"
},
"reports": {
"function": "Denunciar usuário",
"button": "Reportar @{{acct}}"
}
},
"domain": {
"heading": "Sobre a instância",
"block": {
"function": "Bloquear instância",
"button": "Bloquear a instância {{domain}}"
},
"alert": {
"title": "Confirma bloquear {{domain}}?",
"message": "Na maioria das vezes, você pode silenciar ou bloquear determinado usuário.\n\nDepois de bloquear a instância, todo seu conteúdo, incluindo seguidores, será removido!",
"buttons": {
"confirm": "Confirmar bloqueio",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"share": {
"status": {
"heading": "Compartilhar toot",
"button": "Compartilhar o link para este mundo"
},
"account": {
"heading": "Compartilhar Usuário",
"button": "Compartilhar link para este usuário"
}
},
"status": {
"heading": "Sobre o toot",
"edit": {
"function": "Editar toot",
"button": "Editar este toot"
},
"delete": {
"function": "Remover toot",
"button": "Deletar este toot",
"alert": {
"title": "Confirmar exclusão?",
"message": "Tem certeza que deseja excluir este toot? Todos os boosts e favoritos serão apagados, incluindo todas as respostas.",
"buttons": {
"confirm": "Confirme a exclusão",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"deleteEdit": {
"function": "Remover toot",
"button": "Excluir e rascunhar",
"alert": {
"title": "Confirmar exclusão?",
"message": "Tem certeza que deseja excluir este toot? Todos os boosts e favoritos serão apagados, incluindo todas as respostas.",
"buttons": {
"confirm": "Confirme a exclusão",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"mute": {
"function": "Silenciar toot",
"button": {
"positive": "Silenciar este toot e respostas",
"negative": "Desbloquear este toot e respostas"
}
},
"pin": {
"function": "Fixar",
"button": {
"positive": "Fixar este toot",
"negative": "Desafixar este toot"
}
}
}
}
},
"poll": {

View File

@ -1,7 +1,7 @@
{
"content": {
"altText": {
"heading": ""
"heading": "Texto Alternativo"
},
"notificationsFilter": {
"heading": "Exibir notificações",
@ -12,8 +12,8 @@
"reblog": "$t(screenTabs:me.push.reblog.heading)",
"mention": "$t(screenTabs:me.push.mention.heading)",
"poll": "$t(screenTabs:me.push.poll.heading)",
"status": "",
"update": ""
"status": "Toot de usuários inscritos",
"update": "Toot foi editado"
}
}
}

View File

@ -15,7 +15,7 @@
"button": {
"default": "Toot",
"conversation": "Toot DM",
"reply": "Resposta de toot",
"reply": "Responder",
"deleteEdit": "Toot",
"edit": "Toot",
"share": "Toot"
@ -168,7 +168,7 @@
"header": {
"title": "Rascunho"
},
"warning": "",
"warning": "Os rascunhos são armazenados localmente e podem ser perdidos em eventos infortúnios. Aconselhamos não usar rascunhos para armazenamento de longo prazo.",
"content": {
"accessibilityHint": "Toque para editar este rascunho",
"textEmpty": "O conteúdo está vazio"

View File

@ -32,7 +32,7 @@
"me": {
"stacks": {
"bookmarks": {
"name": "Favoritos"
"name": "Salvos"
},
"conversations": {
"name": "Mensagens diretas"
@ -82,7 +82,7 @@
"demo": "<p>Esta é uma demonstração também😊. Você pode escolher entre várias opções abaixo.<br /><br />Esta configuração afeta apenas o conteúdo principal dos toots, mas não os tamanhos de outra fonte.</p>",
"availableSizes": "Tamanhos disponíveis",
"sizes": {
"S": "S",
"S": "P",
"M": "M - Padrão",
"L": "Grande",
"XL": "Extra grande",
@ -170,7 +170,7 @@
"heading": "Novo seguidor"
},
"follow_request": {
"heading": ""
"heading": "Solicitações de seguidores pendentes"
},
"favourite": {
"heading": "Favoritos"
@ -185,7 +185,7 @@
"heading": "Pesquisa atualizada"
},
"status": {
"heading": ""
"heading": "Toot de usuários inscritos"
},
"howitworks": "Saiba como funciona o roteamento"
},

View File

@ -0,0 +1,70 @@
{
"accessibilityHint": "Hành động với tút này, bao gồm đăng thủ công hay đăng tự động",
"account": {
"title": "Hành động người dùng",
"mute": {
"action": "Ẩn người này"
},
"block": {
"action": "Chặn người này"
},
"reports": {
"action": "Báo cáo"
}
},
"instance": {
"title": "Hành động máy chủ",
"block": {
"action": "Chặn {{instance}}",
"alert": {
"title": "Xác nhận chặn {{instance}} ?",
"message": "Bạn có thể ẩn hoặc chặn bất kỳ người nào.\n\nĐối với máy chủ, toàn bộ nội dung bao gồm người theo dõi bạn từ máy chủ đó cũng sẽ bị chặn!",
"buttons": {
"confirm": "Xác nhận"
}
}
}
},
"share": {
"status": {
"action": "Đăng lại"
},
"account": {
"action": "Chia sẻ"
}
},
"status": {
"title": "Hành động tút",
"edit": {
"action": "Sửa tút"
},
"delete": {
"action": "Xóa tút",
"alert": {
"title": "Tiếp tục xóa?",
"message": "Tất cả lượt thích và đăng lại sẽ bị mất, bao gồm cả những trả lời.",
"buttons": {
"confirm": "Xác nhận"
}
}
},
"deleteEdit": {
"action": "Xóa và đăng lại",
"alert": {
"title": "Tiếp tục xóa và đăng lại?",
"message": "Tất cả lượt thích và đăng lại sẽ bị mất, bao gồm cả những trả lời.",
"buttons": {
"confirm": "Xác nhận"
}
}
},
"mute": {
"action-muted_false": "Ẩn tút này",
"action-muted_true": "Bỏ ẩn tút này"
},
"pin": {
"action-pinned_false": "Tút ghim",
"action-pinned_true": "Bỏ ghim tút"
}
}
}

View File

@ -11,8 +11,10 @@
"domains": "Liên hợp"
},
"disclaimer": {
"base": "tooot sẽ không thấy được thông tin tài khoản của bạn. Tìm hiểu thêm ",
"privacy": "Bảo mật"
"base": "tooot sẽ không thấy được thông tin tài khoản của bạn."
},
"terms": {
"base": "Đăng nhập nghĩa là bạn đồng ý <0>chính sách bảo mật</0> và <1>điều khoản dịch vụ</1>."
}
},
"update": {

View File

@ -1,27 +1,17 @@
{
"title": "Chọn nguồn",
"options": {
"library": "Từ thiết bị",
"photo": "Chụp ảnh",
"cancel": "$t(common:buttons.cancel)"
"image": "Tải ảnh lên",
"image_max": "Tải ảnh lên (tối đa {{max}})",
"video": "Tải video lên",
"video_max": "Tải video lên (tối đa {{max}})"
},
"library": {
"alert": {
"title": "Chưa được cấp quyền",
"message": "Bạn cần cấp quyền đọc thư viện ảnh trước",
"buttons": {
"settings": "Cài đặt cập nhật",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"photo": {
"alert": {
"title": "Chưa cấp quyền",
"message": "Cần cấp quyền sử dụng camera trước",
"buttons": {
"settings": "Cài đặt cập nhật",
"cancel": "$t(common:buttons.cancel)"
"settings": "Cài đặt cập nhật"
}
}
}

View File

@ -123,94 +123,6 @@
"delete": {
"function": "Xóa nhắn riêng"
}
},
"actions": {
"accessibilityHint": "Hành động cho tút này, bao gồm đăng thủ công hay đăng tự động",
"account": {
"heading": "Đối với người dùng",
"mute": {
"function": "Ẩn người dùng",
"button": "Ẩn @{{acct}}"
},
"block": {
"function": "Chặn người dùng",
"button": "Chặn @{{acct}}"
},
"reports": {
"function": "Báo cáo người dùng",
"button": "Báo cáo @{{acct}}"
}
},
"domain": {
"heading": "Đối với máy chủ",
"block": {
"function": "Chặn máy chủ",
"button": "Chặn {{domain}}"
},
"alert": {
"title": "Bạn có chắc muốn chặn {{domain}}?",
"message": "Bạn có thể ẩn hoặc chặn bất kỳ người nào.\n\nĐối với máy chủ, toàn bộ nội dung bao gồm người theo dõi bạn từ máy chủ đó cũng sẽ bị chặn!",
"buttons": {
"confirm": "Tiếp tục chặn",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"share": {
"status": {
"heading": "Đăng lại",
"button": "Đăng lại URL tút"
},
"account": {
"heading": "Chia sẻ",
"button": "Chia sẻ URL người dùng này"
}
},
"status": {
"heading": "Đối với tút",
"edit": {
"function": "Sửa tút",
"button": "Sửa tút này"
},
"delete": {
"function": "Xóa tút",
"button": "Xóa tút này",
"alert": {
"title": "Vẫn xóa tút?",
"message": "Bạn có chắc muốn xóa tút này? Toàn bộ lượt thích, đăng lại và trả lời tút cũng sẽ bị xóa theo.",
"buttons": {
"confirm": "Xác nhận xóa",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"deleteEdit": {
"function": "Xóa tút",
"button": "Xóa & viết lại",
"alert": {
"title": "Xác nhận xóa tút?",
"message": "Bạn có chắc muốn xóa và viết lại tút này? Toàn bộ lượt thích, đăng lại và trả lời tút cũng sẽ bị xóa theo.",
"buttons": {
"confirm": "Xác nhận xóa",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"mute": {
"function": "Ẩn tút",
"button": {
"positive": "Ẩn tút này",
"negative": "Bỏ ẩn tút này"
}
},
"pin": {
"function": "Ghim",
"button": {
"positive": "Ghim tút này",
"negative": "Bỏ ghim tút này"
}
}
}
}
},
"poll": {

View File

@ -1,7 +1,7 @@
{
"content": {
"altText": {
"heading": ""
"heading": "Văn bản thay thế"
},
"notificationsFilter": {
"heading": "Những kiểu thông báo cho phép",

View File

@ -0,0 +1,70 @@
{
"accessibilityHint": "更多关于此条嘟文,例如发布者等",
"account": {
"title": "用户操作",
"mute": {
"action": "静音用户"
},
"block": {
"action": "屏蔽用户"
},
"reports": {
"action": "举报用户"
}
},
"instance": {
"title": "实例操作",
"block": {
"action": "屏蔽实例 {{instance}}",
"alert": {
"title": "确认屏蔽实例 {{instance}}",
"message": "多数情况下,隐藏或屏蔽特定用户即可。\n\n屏蔽之后来自此实例的所有内容将被移除。",
"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-muted_false": "静音嘟文及回复",
"action-muted_true": "取消静音嘟文及回复"
},
"pin": {
"action-pinned_false": "置顶嘟文",
"action-pinned_true": "取消置顶嘟文"
}
}
}

View File

@ -11,8 +11,10 @@
"domains": "连结总数"
},
"disclaimer": {
"base": "登录过程将使用系统浏览器你的账号登录信息tooot应用无法读取。详见 ",
"privacy": "隐私条款"
"base": "登录过程将使用系统浏览器你的账号登录信息tooot应用无法读取。"
},
"terms": {
"base": "登录即表示您同意<0>隐私政策</0>和<1>服务条款</1>。"
}
},
"update": {

View File

@ -1,27 +1,17 @@
{
"title": "选择媒体",
"options": {
"library": "从相册上传",
"photo": "拍摄照片",
"cancel": "$t(common:buttons.cancel)"
"image": "上传图片",
"image_max": "上传照片(上限 {{max}}",
"video": "上传视频",
"video_max": "上传视频(上限 {{max}}"
},
"library": {
"alert": {
"title": "无权限",
"message": "需要读取相册权限才能上传附件",
"buttons": {
"settings": "去更新设置",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"photo": {
"alert": {
"title": "无权限",
"message": "需要使用相机权限才能上传附件",
"buttons": {
"settings": "去更新设置",
"cancel": "$t(common:buttons.cancel)"
"settings": "去更新设置"
}
}
}

View File

@ -123,94 +123,6 @@
"delete": {
"function": "删除私信"
}
},
"actions": {
"accessibilityHint": "更多关于此条嘟文,例如发布者等",
"account": {
"heading": "关于用户",
"mute": {
"function": "隐藏 @{{acct}} 的嘟文",
"button": "隐藏 @{{acct}} 的嘟文"
},
"block": {
"function": "屏蔽 @{{acct}}",
"button": "屏蔽 @{{acct}}"
},
"reports": {
"function": "举报 @{{acct}}",
"button": "举报 @{{acct}}"
}
},
"domain": {
"heading": "关于社区",
"block": {
"function": "屏蔽社区",
"button": "屏蔽社区 {{domain}}"
},
"alert": {
"title": "确定要屏蔽 {{domain}} 吗?",
"message": "多数情况下,隐藏或屏蔽特定用户即可。\n\n屏蔽之后来自此社区的所有内容将不再出现在你的时间轴里。同时来自该社区的关注者将被移除。请谨慎使用。",
"buttons": {
"confirm": "确定屏蔽整个社区",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"share": {
"status": {
"heading": "分享嘟文",
"button": "分享此条嘟文的链接"
},
"account": {
"heading": "分享用户",
"button": "分享此用户的链接"
}
},
"status": {
"heading": "关于嘟文",
"edit": {
"function": "编辑嘟文",
"button": "编辑此条嘟文"
},
"delete": {
"function": "删除",
"button": "删除此条嘟文",
"alert": {
"title": "确认删除嘟文?",
"message": "确定要删除这条嘟文吗?所有相关的转嘟和喜欢都会被清除,回复将会失去关联。",
"buttons": {
"confirm": "确认删除",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"deleteEdit": {
"function": "删除并重新编辑",
"button": "删除并重新编辑此条嘟文",
"alert": {
"title": "确认删除嘟文?",
"message": "确定要删除这条嘟文并重新编辑它吗?所有相关的转嘟和喜欢都会被清除,回复将会失去关联。",
"buttons": {
"confirm": "删除并重新编辑",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"mute": {
"function": "静音",
"button": {
"positive": "静音此条嘟文及对话",
"negative": "取消静音此条嘟文及对话"
}
},
"pin": {
"function": "置顶",
"button": {
"positive": "置顶此条嘟文",
"negative": "取消置顶此条嘟文"
}
}
}
}
},
"poll": {

View File

@ -0,0 +1,70 @@
{
"accessibilityHint": "",
"account": {
"title": "",
"mute": {
"action": ""
},
"block": {
"action": ""
},
"reports": {
"action": ""
}
},
"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-muted_false": "",
"action-muted_true": ""
},
"pin": {
"action-pinned_false": "",
"action-pinned_true": ""
}
}
}

View File

@ -11,8 +11,10 @@
"domains": "串聯宇宙"
},
"disclaimer": {
"base": "將使用系統內建的瀏覽器來登入tooot app 無法讀取您的帳號資訊;詳見 ",
"privacy": "隱私權政策"
"base": ""
},
"terms": {
"base": ""
}
},
"update": {

View File

@ -1,27 +1,17 @@
{
"title": "選擇媒體來源",
"options": {
"library": "上傳",
"photo": "拍照",
"cancel": "$t(common:buttons.cancel)"
"image": "",
"image_max": "",
"video": "",
"video_max": ""
},
"library": {
"alert": {
"title": "權限不足",
"message": "上傳照片需要讀取的權限",
"buttons": {
"settings": "更新設定",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"photo": {
"alert": {
"title": "權限不足",
"message": "需要使用相機的權限來上傳",
"buttons": {
"settings": "更新設定",
"cancel": "$t(common:buttons.cancel)"
"settings": "更新設定"
}
}
}

View File

@ -123,94 +123,6 @@
"delete": {
"function": ""
}
},
"actions": {
"accessibilityHint": "",
"account": {
"heading": "",
"mute": {
"function": "",
"button": ""
},
"block": {
"function": "",
"button": ""
},
"reports": {
"function": "",
"button": ""
}
},
"domain": {
"heading": "",
"block": {
"function": "",
"button": ""
},
"alert": {
"title": "",
"message": "",
"buttons": {
"confirm": "",
"cancel": ""
}
}
},
"share": {
"status": {
"heading": "",
"button": ""
},
"account": {
"heading": "",
"button": ""
}
},
"status": {
"heading": "",
"edit": {
"function": "",
"button": ""
},
"delete": {
"function": "",
"button": "",
"alert": {
"title": "",
"message": "",
"buttons": {
"confirm": "",
"cancel": ""
}
}
},
"deleteEdit": {
"function": "",
"button": "",
"alert": {
"title": "",
"message": "",
"buttons": {
"confirm": "",
"cancel": ""
}
}
},
"mute": {
"function": "",
"button": {
"positive": "",
"negative": ""
}
},
"pin": {
"function": "",
"button": {
"positive": "",
"negative": ""
}
}
}
}
},
"poll": {

View File

@ -1,14 +1,7 @@
import analytics from '@components/analytics'
import Button from '@components/Button'
import { RootStackScreenProps } from '@utils/navigation/navigators'
import {
getInstanceAccount,
getInstanceUrl
} from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Dimensions, StyleSheet, View } from 'react-native'
import {
PanGestureHandler,
@ -28,47 +21,13 @@ import {
SafeAreaProvider,
useSafeAreaInsets
} from 'react-native-safe-area-context'
import { useSelector } from 'react-redux'
import ActionsAccount from './Actions/Account'
import ActionsAltText from './Actions/AltText'
import ActionsDomain from './Actions/Domain'
import ActionsNotificationsFilter from './Actions/NotificationsFilter'
import ActionsShare from './Actions/Share'
import ActionsStatus from './Actions/Status'
const ScreenActions = ({
route: { params },
navigation
}: RootStackScreenProps<'Screen-Actions'>) => {
const { t } = useTranslation()
const instanceAccount = useSelector(
getInstanceAccount,
(prev, next) => prev?.id === next?.id
)
let sameAccount = false
switch (params.type) {
case 'status':
console.log('media length', params.status.media_attachments.length)
sameAccount = instanceAccount?.id === params.status.account.id
break
case 'account':
sameAccount = instanceAccount?.id === params.account.id
break
}
const instanceDomain = useSelector(getInstanceUrl)
let sameDomain = true
let statusDomain: string
switch (params.type) {
case 'status':
statusDomain = params.status.uri
? params.status.uri.split(new RegExp(/\/\/(.*?)\//))[1]
: ''
sameDomain = instanceDomain === statusDomain
break
}
const { colors } = useTheme()
const insets = useSafeAreaInsets()
@ -106,72 +65,6 @@ const ScreenActions = ({
const actions = () => {
switch (params.type) {
case 'status':
return (
<>
{!sameAccount ? (
<ActionsAccount
queryKey={params.queryKey}
rootQueryKey={params.rootQueryKey}
account={params.status.account}
dismiss={dismiss}
/>
) : null}
{sameAccount && params.status ? (
<ActionsStatus
navigation={navigation}
queryKey={params.queryKey}
rootQueryKey={params.rootQueryKey}
status={params.status}
dismiss={dismiss}
/>
) : null}
{!sameDomain && statusDomain ? (
<ActionsDomain
queryKey={params.queryKey}
rootQueryKey={params.rootQueryKey}
domain={statusDomain}
dismiss={dismiss}
/>
) : null}
{params.status.visibility !== 'direct' ? (
<ActionsShare
url={params.status.url || params.status.uri}
type={params.type}
dismiss={dismiss}
/>
) : null}
<Button
type='text'
content={t('common:buttons.cancel')}
onPress={() => {
analytics('bottomsheet_acknowledge')
}}
style={styles.button}
/>
</>
)
case 'account':
return (
<>
{!sameAccount ? (
<ActionsAccount account={params.account} dismiss={dismiss} />
) : null}
<ActionsShare
url={params.account.url}
type={params.type}
dismiss={dismiss}
/>
<Button
type='text'
content={t('common:buttons.cancel')}
onPress={() => {
analytics('bottomsheet_acknowledge')
}}
style={styles.button}
/>
</>
)
case 'notifications_filter':
return <ActionsNotificationsFilter />
case 'alt_text':

View File

@ -49,7 +49,7 @@ const ActionsStatus: React.FC<Props> = ({
message: t('common:message.error.message', {
function: t(`shared.header.actions.status.${theFunction}.function`)
}),
...(err.status &&
...(err?.status &&
typeof err.status === 'number' &&
err.data &&
err.data.error &&

View File

@ -2,6 +2,7 @@ import analytics from '@components/analytics'
import Button from '@components/Button'
import haptics from '@components/haptics'
import { ParseHTML } from '@components/Parse'
import RelativeTime from '@components/RelativeTime'
import CustomText from '@components/Text'
import { BlurView } from '@react-native-community/blur'
import { useAccessibility } from '@utils/accessibility/AccessibilityManager'
@ -92,15 +93,7 @@ const ScreenAnnouncements: React.FC<
<Trans
i18nKey='screenAnnouncements:content.published'
components={[
<FormattedRelativeTime
value={
-(
new Date().getTime() -
new Date(item.published_at).getTime()
) / 1000
}
updateIntervalInSeconds={1}
/>
<RelativeTime type='past' time={item.published_at} />
]}
/>
</CustomText>

View File

@ -136,18 +136,6 @@ const ScreenCompose: React.FC<RootStackScreenProps<'Screen-Compose'>> = ({
])
useEffect(() => {
const uploadImage = async ({
type,
uri
}: {
type: 'image' | 'video'
uri: string
}) => {
await uploadAttachment({
composeDispatch,
imageInfo: { type, uri, width: 100, height: 100 }
})
}
switch (params?.type) {
case 'share':
if (params.text) {
@ -158,12 +146,13 @@ const ScreenCompose: React.FC<RootStackScreenProps<'Screen-Compose'>> = ({
disableDebounce: true
})
}
if (params.images?.length) {
params.images.forEach(image => {
uploadImage({ type: 'image', uri: image.uri })
})
} else if (params.video) {
uploadImage({ type: 'video', uri: params.video.uri })
if (params.media?.length) {
for (const m of params.media) {
uploadAttachment({
composeDispatch,
media: { ...m, width: 100, height: 100 }
})
}
}
break
case 'edit':

View File

@ -128,7 +128,9 @@ const ComposeEditAttachmentImage: React.FC<Props> = ({ index }) => {
height: imageDimensionis.height
}}
source={{
uri: theAttachmentLocal?.uri || theAttachmentRemote?.preview_url
uri: theAttachmentLocal?.uri
? theAttachmentLocal.uri
: theAttachmentRemote?.preview_url
}}
/>
<PanGestureHandler onGestureEvent={onGestureEvent}>

View File

@ -1,5 +1,4 @@
import * as Crypto from 'expo-crypto'
import { ImageInfo } from 'expo-image-picker/build/ImagePicker.types'
import * as VideoThumbnails from 'expo-video-thumbnails'
import { Dispatch } from 'react'
import { Alert } from 'react-native'
@ -8,6 +7,7 @@ import { ActionSheetOptions } from '@expo/react-native-action-sheet'
import i18next from 'i18next'
import apiInstance from '@api/instance'
import mediaSelector from '@components/mediaSelector'
import { ImageOrVideo } from 'react-native-image-crop-picker'
export interface Props {
composeDispatch: Dispatch<ComposeAction>
@ -19,39 +19,35 @@ export interface Props {
export const uploadAttachment = async ({
composeDispatch,
imageInfo
media
}: {
composeDispatch: Dispatch<ComposeAction>
imageInfo: Pick<ImageInfo, 'type' | 'uri' | 'width' | 'height'>
media: { uri: string } & Pick<ImageOrVideo, 'mime' | 'width' | 'height'>
}) => {
const hash = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
imageInfo.uri + Math.random()
media.uri + Math.random()
)
let attachmentType: string
switch (imageInfo.type) {
switch (media.mime.split('/')[0]) {
case 'image':
console.log('uri', imageInfo.uri)
attachmentType = `image/${imageInfo.uri.split('.')[1]}`
composeDispatch({
type: 'attachment/upload/start',
payload: {
local: { ...imageInfo, local_thumbnail: imageInfo.uri, hash },
local: { ...media, type: 'image', local_thumbnail: media.uri, hash },
uploading: true
}
})
break
case 'video':
attachmentType = `video/${imageInfo.uri.split('.')[1]}`
VideoThumbnails.getThumbnailAsync(imageInfo.uri)
VideoThumbnails.getThumbnailAsync(media.uri)
.then(({ uri, width, height }) =>
composeDispatch({
type: 'attachment/upload/start',
payload: {
local: {
...imageInfo,
...media,
type: 'video',
local_thumbnail: uri,
hash,
width,
@ -65,25 +61,24 @@ export const uploadAttachment = async ({
composeDispatch({
type: 'attachment/upload/start',
payload: {
local: { ...imageInfo, hash },
local: { ...media, type: 'video', hash },
uploading: true
}
})
)
break
default:
attachmentType = 'unknown'
composeDispatch({
type: 'attachment/upload/start',
payload: {
local: { ...imageInfo, hash },
local: { ...media, type: 'unknown', hash },
uploading: true
}
})
break
}
const uploadFailed = () => {
const uploadFailed = (message?: string) => {
composeDispatch({
type: 'attachment/upload/fail',
payload: hash
@ -92,7 +87,7 @@ export const uploadAttachment = async ({
i18next.t(
'screenCompose:content.root.actions.attachment.failed.alert.title'
),
undefined,
message,
[
{
text: i18next.t(
@ -106,14 +101,14 @@ export const uploadAttachment = async ({
const formData = new FormData()
formData.append('file', {
// @ts-ignore
uri: imageInfo.uri,
name: attachmentType,
type: attachmentType
})
uri: media.uri,
name: media.uri.match(new RegExp(/.*\/(.*)/))?.[1] || 'file.jpg',
type: media.mime
} as any)
return apiInstance<Mastodon.Attachment>({
method: 'post',
version: 'v2',
url: 'media',
body: formData
})
@ -121,14 +116,18 @@ export const uploadAttachment = async ({
if (res.body.id) {
composeDispatch({
type: 'attachment/upload/end',
payload: { remote: res.body, local: imageInfo }
payload: { remote: res.body, local: media }
})
} else {
uploadFailed()
}
})
.catch(() => {
uploadFailed()
.catch((err: any) => {
uploadFailed(
err?.message && typeof err?.message === 'string'
? err?.message.slice(0, 50)
: undefined
)
})
}
@ -136,8 +135,14 @@ const chooseAndUploadAttachment = async ({
composeDispatch,
showActionSheetWithOptions
}: Props): Promise<any> => {
const result = await mediaSelector({ showActionSheetWithOptions })
await uploadAttachment({ composeDispatch, imageInfo: result })
const result = await mediaSelector({
indicateMaximum: true,
showActionSheetWithOptions
})
for (const media of result) {
uploadAttachment({ composeDispatch, media })
await new Promise(res => setTimeout(res, 500))
}
}
export default chooseAndUploadAttachment

View File

@ -1,10 +1,11 @@
import CustomText from '@components/Text'
import PasteInput, { PastedFile } from '@mattermost/react-native-paste-input'
import { getInstanceConfigurationStatusMaxAttachments } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next'
import { Alert, TextInput } from 'react-native'
import { Alert } from 'react-native'
import { useSelector } from 'react-redux'
import formatText from '../../formatText'
import ComposeContext from '../../utils/createContext'
@ -21,7 +22,7 @@ const ComposeTextInput: React.FC = () => {
)
return (
<TextInput
<PasteInput
keyboardAppearance={mode}
style={{
...StyleConstants.FontStyle.M,
@ -62,8 +63,12 @@ const ComposeTextInput: React.FC = () => {
}}
ref={composeState.textInputFocus.refs.text}
scrollEnabled={false}
onImageChange={({ nativeEvent }) => {
if (composeState.attachments.uploads.length >= maxAttachments) {
disableCopyPaste={false}
onPaste={(error: string | null | undefined, files: PastedFile[]) => {
if (
composeState.attachments.uploads.length + files.length >
maxAttachments
) {
Alert.alert(
t(
'content.root.header.textInput.keyboardImage.exceedMaximum.title'
@ -80,12 +85,13 @@ const ComposeTextInput: React.FC = () => {
)
return
}
if (nativeEvent.linkUri) {
for (const file of files) {
uploadAttachment({
composeDispatch,
imageInfo: {
uri: nativeEvent.linkUri,
type: 'image',
media: {
uri: file.uri,
mime: file.type,
width: 100,
height: 100
}
@ -94,7 +100,7 @@ const ComposeTextInput: React.FC = () => {
}}
>
<CustomText>{composeState.text.formatted}</CustomText>
</TextInput>
</PasteInput>
)
}

View File

@ -1,6 +1,12 @@
import { ImageOrVideo } from 'react-native-image-crop-picker'
export type ExtendedAttachment = {
remote?: Mastodon.Attachment
local?: App.IImageInfo & { local_thumbnail?: string; hash?: string }
local?: { uri: string } & Pick<ImageOrVideo, 'width' | 'height' | 'mime'> & {
type: 'image' | 'video' | 'unknown'
local_thumbnail?: string
hash?: string
}
uploading?: boolean
}
@ -115,7 +121,10 @@ export type ComposeAction =
}
| {
type: 'attachment/upload/end'
payload: { remote: Mastodon.Attachment; local: ImageInfo }
payload: {
remote: Mastodon.Attachment
local: { uri: string } & Pick<ImageOrVideo, 'width' | 'height' | 'mime'>
}
}
| {
type: 'attachment/upload/fail'

View File

@ -3,7 +3,6 @@ import { MenuRow } from '@components/Menu'
import { useActionSheet } from '@expo/react-native-action-sheet'
import { useProfileMutation, useProfileQuery } from '@utils/queryHooks/profile'
import { useTheme } from '@utils/styles/ThemeManager'
import * as ImagePicker from 'expo-image-picker'
import React, { RefObject } from 'react'
import { useTranslation } from 'react-i18next'
import FlashMessage from 'react-native-flash-message'
@ -30,9 +29,13 @@ const ProfileAvatarHeader: React.FC<Props> = ({ type, messageRef }) => {
iconBack='ChevronRight'
onPress={async () => {
const image = await mediaSelector({
mediaType: 'photo',
maximum: 1,
showActionSheetWithOptions,
mediaTypes: ImagePicker.MediaTypeOptions.Images,
resize: { width: 400, height: 400 }
resize:
type === 'avatar'
? { width: 400, height: 400 }
: { width: 1500, height: 500 }
})
mutation.mutate({
theme,
@ -43,7 +46,7 @@ const ProfileAvatarHeader: React.FC<Props> = ({ type, messageRef }) => {
failed: true
},
type,
data: image.uri
data: image[0].uri
})
}}
/>

View File

@ -1,5 +1,3 @@
import analytics from '@components/analytics'
import { HeaderRight } from '@components/Header'
import Timeline from '@components/Timeline'
import TimelineDefault from '@components/Timeline/Default'
import SegmentedControl from '@react-native-community/segmented-control'
@ -23,8 +21,7 @@ const TabSharedAccount: React.FC<
> = ({
route: {
params: { account }
},
navigation
}
}) => {
const { t, i18n } = useTranslation('screenTabs')
const { colors, mode } = useTheme()
@ -33,33 +30,6 @@ const TabSharedAccount: React.FC<
const scrollY = useSharedValue(0)
useEffect(() => {
const updateHeaderRight = () =>
navigation.setOptions({
headerRight: () => (
<HeaderRight
accessibilityLabel={t('shared.account.actions.accessibilityLabel', {
user: data?.acct
})}
accessibilityHint={t('shared.account.actions.accessibilityHint')}
content='MoreHorizontal'
onPress={() => {
analytics('bottomsheet_open_press', {
page: 'account'
})
// @ts-ignore
navigation.navigate('Screen-Actions', {
type: 'account',
account
})
}}
background
/>
)
})
return updateHeaderRight()
}, [i18n.language])
const onScroll = useCallback(({ nativeEvent }) => {
scrollY.value = nativeEvent.contentOffset.y
}, [])

View File

@ -1,4 +1,6 @@
import { HeaderCenter, HeaderLeft } from '@components/Header'
import contextMenuAccount from '@components/ContextMenu/account'
import contextMenuShare from '@components/ContextMenu/share'
import { HeaderCenter, HeaderLeft, HeaderRight } from '@components/Header'
import { ParseEmojis } from '@components/Parse'
import CustomText from '@components/Text'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
@ -16,6 +18,7 @@ import { debounce } from 'lodash'
import React from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { Platform, TextInput, View } from 'react-native'
import ContextMenu, { ContextMenuAction } from 'react-native-context-menu-view'
const TabSharedRoot = ({
Stack
@ -36,7 +39,10 @@ const TabSharedRoot = ({
name='Tab-Shared-Account'
component={TabSharedAccount}
options={({
navigation
navigation,
route: {
params: { account }
}
}: TabSharedStackScreenProps<'Tab-Shared-Account'>) => {
return {
headerTransparent: true,
@ -46,7 +52,44 @@ const TabSharedRoot = ({
title: '',
headerLeft: () => (
<HeaderLeft onPress={() => navigation.goBack()} background />
)
),
headerRight: () => {
const actions: ContextMenuAction[] = []
const shareOnPress = contextMenuShare({
actions,
type: 'account',
url: account.url
})
const accountOnPress = contextMenuAccount({
actions,
id: account.id
})
return (
<ContextMenu
actions={actions}
onPress={({ nativeEvent: { id } }) => {
shareOnPress(id)
accountOnPress(id)
}}
dropdownMenuMode
>
<HeaderRight
accessibilityLabel={t(
'shared.account.actions.accessibilityLabel',
{ user: account.acct }
)}
accessibilityHint={t(
'shared.account.actions.accessibilityHint'
)}
content='MoreHorizontal'
onPress={() => {}}
background
/>
</ContextMenu>
)
}
}
}}
/>

View File

@ -7,16 +7,6 @@ import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
export type RootStackParamList = {
'Screen-Tabs': NavigatorScreenParams<ScreenTabsStackParamList>
'Screen-Actions':
| {
type: 'status'
queryKey: QueryKeyTimeline
rootQueryKey?: QueryKeyTimeline
status: Mastodon.Status
}
| {
type: 'account'
account: Mastodon.Account
}
| {
type: 'notifications_filter'
}
@ -52,8 +42,7 @@ export type RootStackParamList = {
| {
type: 'share'
text?: string
images?: { type: string; uri: string }[]
video?: { type: string; uri: string }
media?: { uri: string; mime: string }[]
}
| undefined
'Screen-ImagesViewer': {

View File

@ -71,11 +71,10 @@ const mutationFunction = async ({ type, data }: MutationVarsProfile) => {
})
} else if (type === 'avatar' || type === 'header') {
formData.append(type, {
// @ts-ignore
uri: data,
uri: `file://${data}`,
name: 'image/jpeg',
type: 'image/jpeg'
})
} as any)
} else {
// @ts-ignore
formData.append(type, data)

View File

@ -249,7 +249,7 @@ const instancesSlice = createSlice({
// Update Instance Configuration
.addCase(updateConfiguration.fulfilled, (state, action) => {
const activeIndex = findInstanceActive(state.instances)
state.instances[activeIndex].version = action.payload.version
state.instances[activeIndex].version = action.payload?.version || '0'
state.instances[activeIndex].configuration =
action.payload.configuration
})
@ -316,7 +316,7 @@ const instancesSlice = createSlice({
state.instances[activeIndex].frequentEmojis = state.instances[
activeIndex
].frequentEmojis?.filter(emoji => {
return action.payload.find(
return action.payload?.find(
e =>
e.shortcode === emoji.emoji.shortcode && e.url === emoji.emoji.url
)
@ -381,6 +381,43 @@ export const getInstanceConfigurationStatusCharsURL = ({
instances[findInstanceActive(instances)]?.configuration?.statuses
.characters_reserved_per_url || 23
export const getInstanceConfigurationMediaAttachments = ({
instances: { instances }
}: RootState) =>
instances[findInstanceActive(instances)]?.configuration
?.media_attachments || {
supported_mime_types: [
'image/jpeg',
'image/png',
'image/gif',
'video/webm',
'video/mp4',
'video/quicktime',
'video/ogg',
'audio/wave',
'audio/wav',
'audio/x-wav',
'audio/x-pn-wave',
'audio/ogg',
'audio/vorbis',
'audio/mpeg',
'audio/mp3',
'audio/webm',
'audio/flac',
'audio/aac',
'audio/m4a',
'audio/x-m4a',
'audio/mp4',
'audio/3gpp',
'video/x-ms-asf'
],
image_size_limit: 10485760,
image_matrix_limit: 16777216,
video_size_limit: 41943040,
video_frame_rate_limit: 60,
video_matrix_limit: 2304000
}
export const getInstanceConfigurationPoll = ({
instances: { instances }
}: RootState) =>

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