chore: Merge 4.47.0 into master (#5611)
This commit is contained in:
parent
e742288405
commit
d34ff3d71d
|
@ -6,7 +6,7 @@ orbs:
|
|||
|
||||
macos: &macos
|
||||
macos:
|
||||
xcode: "14.2.0"
|
||||
xcode: "15.2.0"
|
||||
resource_class: macos.m1.medium.gen1
|
||||
|
||||
bash-env: &bash-env
|
||||
|
@ -19,7 +19,9 @@ android-env: &android-env
|
|||
|
||||
install-npm-modules: &install-npm-modules
|
||||
name: Install NPM modules
|
||||
command: yarn
|
||||
command: |
|
||||
yarn global add node-gyp
|
||||
yarn
|
||||
|
||||
restore-npm-cache-linux: &restore-npm-cache-linux
|
||||
name: Restore NPM cache
|
||||
|
@ -54,15 +56,13 @@ save-gems-cache: &save-gems-cache
|
|||
update-fastlane-ios: &update-fastlane-ios
|
||||
name: Update Fastlane
|
||||
command: |
|
||||
echo "ruby-2.7.7" > ~/.ruby-version
|
||||
bundle install
|
||||
working_directory: ios
|
||||
|
||||
update-fastlane-android: &update-fastlane-android
|
||||
name: Update Fastlane
|
||||
command: |
|
||||
echo "ruby-2.7.7" > ~/.ruby-version
|
||||
bundle install
|
||||
bundle install --path gems
|
||||
working_directory: android
|
||||
|
||||
save-gradle-cache: &save-gradle-cache
|
||||
|
@ -78,6 +78,27 @@ restore_cache: &restore-gradle-cache
|
|||
# COMMANDS
|
||||
commands:
|
||||
|
||||
manage-ruby:
|
||||
description: "Manage ruby version"
|
||||
steps:
|
||||
- restore_cache:
|
||||
name: Restore ruby
|
||||
key: ruby-v2-{{ checksum ".ruby-version" }}
|
||||
- run:
|
||||
name: Install ruby
|
||||
command: |
|
||||
echo "ruby-2.7.7" > ~/.ruby-version
|
||||
if [ -d ~/.rbenv/versions/2.7.7 ]; then
|
||||
echo "Ruby already installed"
|
||||
else
|
||||
rbenv install 2.7.7
|
||||
fi
|
||||
- save_cache:
|
||||
name: Save ruby cache
|
||||
key: ruby-v2-{{ checksum ".ruby-version" }}
|
||||
paths:
|
||||
- ~/.rbenv/versions/2.7.7
|
||||
|
||||
manage-pods:
|
||||
description: "Restore/Get/Save cache of pods libs"
|
||||
steps:
|
||||
|
@ -204,6 +225,7 @@ commands:
|
|||
- checkout
|
||||
- restore_cache: *restore-gems-cache
|
||||
- restore_cache: *restore-npm-cache-mac
|
||||
- manage-ruby
|
||||
- run: *install-npm-modules
|
||||
- run: *update-fastlane-ios
|
||||
- manage-pods
|
||||
|
@ -328,6 +350,7 @@ commands:
|
|||
at: ios
|
||||
- restore_cache: *restore-gems-cache
|
||||
- restore_cache: *restore-npm-cache-mac
|
||||
- manage-ruby
|
||||
- run: *install-npm-modules
|
||||
- run: *update-fastlane-ios
|
||||
- manage-pods
|
||||
|
@ -381,7 +404,7 @@ jobs:
|
|||
- run:
|
||||
name: Test
|
||||
command: |
|
||||
yarn test -w 8
|
||||
yarn test --runInBand
|
||||
|
||||
- run:
|
||||
name: Codecov
|
||||
|
@ -394,7 +417,7 @@ jobs:
|
|||
android-build-experimental:
|
||||
<<: *defaults
|
||||
docker:
|
||||
- image: cimg/android:2022.03.1-node
|
||||
- image: cimg/android:2023.11-node
|
||||
environment:
|
||||
<<: *android-env
|
||||
<<: *bash-env
|
||||
|
@ -406,7 +429,7 @@ jobs:
|
|||
android-automatic-build-experimental:
|
||||
<<: *defaults
|
||||
docker:
|
||||
- image: circleci/android:api-29-node
|
||||
- image: cimg/android:2023.11-node
|
||||
environment:
|
||||
<<: *android-env
|
||||
<<: *bash-env
|
||||
|
@ -417,7 +440,7 @@ jobs:
|
|||
android-build-official:
|
||||
<<: *defaults
|
||||
docker:
|
||||
- image: cimg/android:2022.03.1-node
|
||||
- image: cimg/android:2023.11-node
|
||||
environment:
|
||||
<<: *android-env
|
||||
<<: *bash-env
|
||||
|
@ -428,7 +451,7 @@ jobs:
|
|||
android-internal-app-sharing-experimental:
|
||||
<<: *defaults
|
||||
docker:
|
||||
- image: cimg/android:2022.03.1-node
|
||||
- image: cimg/android:2023.11-node
|
||||
|
||||
steps:
|
||||
- upload-to-internal-app-sharing
|
||||
|
@ -436,7 +459,7 @@ jobs:
|
|||
android-google-play-beta-experimental:
|
||||
<<: *defaults
|
||||
docker:
|
||||
- image: cimg/android:2022.03.1-node
|
||||
- image: cimg/android:2023.11-node
|
||||
|
||||
steps:
|
||||
- upload-to-google-play-beta:
|
||||
|
@ -445,14 +468,14 @@ jobs:
|
|||
android-google-play-production-experimental:
|
||||
<<: *defaults
|
||||
docker:
|
||||
- image: cimg/android:2022.03.1-node
|
||||
- image: cimg/android:2023.11-node
|
||||
steps:
|
||||
- upload-to-google-play-production
|
||||
|
||||
android-google-play-beta-official:
|
||||
<<: *defaults
|
||||
docker:
|
||||
- image: cimg/android:2022.03.1-node
|
||||
- image: cimg/android:2023.11-node
|
||||
|
||||
steps:
|
||||
- upload-to-google-play-beta:
|
||||
|
@ -575,6 +598,7 @@ jobs:
|
|||
- checkout
|
||||
- restore_cache: *restore-gems-cache
|
||||
- restore_cache: *restore-npm-cache-mac
|
||||
- manage-ruby
|
||||
- run: *install-npm-modules
|
||||
- run: *update-fastlane-ios
|
||||
- save_cache: *save-npm-cache-mac
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
name: organize translations
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'app/i18n/locales/**.json'
|
||||
|
||||
jobs:
|
||||
organize-and-commit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
- name: Run script to organize JSON keys
|
||||
run: node scripts/organize-translations.js
|
||||
|
||||
- name: Get changed files
|
||||
id: git-check
|
||||
uses: tj-actions/changed-files@v42
|
||||
with:
|
||||
files: |
|
||||
**.json
|
||||
|
||||
- name: List all changed files
|
||||
if: steps.git-check.outputs.any_changed == 'true'
|
||||
env:
|
||||
ALL_CHANGED_FILES: ${{ steps.git-check.outputs.all_changed_files }}
|
||||
run: |
|
||||
for file in ${ALL_CHANGED_FILES}; do
|
||||
echo "$file was changed"
|
||||
done
|
||||
|
||||
- name: Commit and push if changes
|
||||
if: steps.git-check.outputs.any_changed == 'true'
|
||||
uses: EndBug/add-and-commit@v9
|
||||
with:
|
||||
message: 'action: organized translations'
|
|
@ -23,13 +23,13 @@ const getStories = () => {
|
|||
require("../app/containers/BackgroundContainer/index.stories.tsx"),
|
||||
require("../app/containers/Button/Button.stories.tsx"),
|
||||
require("../app/containers/Chip/Chip.stories.tsx"),
|
||||
require("../app/containers/CollapsibleText/CollapsibleText.stories.tsx"),
|
||||
require("../app/containers/HeaderButton/HeaderButtons.stories.tsx"),
|
||||
require("../app/containers/List/List.stories.tsx"),
|
||||
require("../app/containers/LoginServices/LoginServices.stories.tsx"),
|
||||
require("../app/containers/markdown/Markdown.stories.tsx"),
|
||||
require("../app/containers/markdown/new/NewMarkdown.stories.tsx"),
|
||||
require("../app/containers/message/Components/CollapsibleQuote/CollapsibleQuote.stories.tsx"),
|
||||
require("../app/containers/CollapsibleText/CollapsibleText.stories.tsx"),
|
||||
require("../app/containers/message/Message.stories.tsx"),
|
||||
require("../app/containers/ReactionsList/ReactionsList.stories.tsx"),
|
||||
require("../app/containers/RoomHeader/RoomHeader.stories.tsx"),
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
export const RectButton = ({ children }) => children;
|
||||
export const State = () => 'View';
|
||||
export const LongPressGestureHandler = ({ children }) => children;
|
||||
export const BorderlessButton = ({ children }) => children;
|
||||
export const PanGestureHandler = ({ children }) => children;
|
|
@ -0,0 +1,3 @@
|
|||
export default {
|
||||
openPicker: jest.fn().mockImplementation(() => Promise.resolve())
|
||||
};
|
|
@ -1,6 +1,7 @@
|
|||
export class MMKVLoader {
|
||||
// eslint-disable-next-line no-useless-constructor
|
||||
constructor() {
|
||||
console.log('MMKVLoader constructor mock');
|
||||
// console.log('MMKVLoader constructor mock');
|
||||
}
|
||||
|
||||
setProcessingMode = jest.fn().mockImplementation(() => ({
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
export default {
|
||||
NavigationActions: () => {}
|
||||
};
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -1,43 +1,45 @@
|
|||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.5)
|
||||
CFPropertyList (3.0.7)
|
||||
base64
|
||||
nkf
|
||||
rexml
|
||||
addressable (2.8.0)
|
||||
public_suffix (>= 2.0.2, < 5.0)
|
||||
addressable (2.8.6)
|
||||
public_suffix (>= 2.0.2, < 6.0)
|
||||
artifactory (3.0.15)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.2.0)
|
||||
aws-partitions (1.600.0)
|
||||
aws-sdk-core (3.131.2)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
aws-partitions (~> 1, >= 1.525.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.894.0)
|
||||
aws-sdk-core (3.191.3)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.8)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.57.0)
|
||||
aws-sdk-core (~> 3, >= 3.127.0)
|
||||
aws-sdk-kms (1.77.0)
|
||||
aws-sdk-core (~> 3, >= 3.191.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.114.0)
|
||||
aws-sdk-core (~> 3, >= 3.127.0)
|
||||
aws-sdk-s3 (1.143.0)
|
||||
aws-sdk-core (~> 3, >= 3.191.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.4)
|
||||
aws-sigv4 (1.5.0)
|
||||
aws-sigv4 (~> 1.8)
|
||||
aws-sigv4 (1.8.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.2.0)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.6.4)
|
||||
digest-crc (0.6.5)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.5.20190701)
|
||||
unf (>= 0.0.5, < 1.0.0)
|
||||
dotenv (2.7.6)
|
||||
domain_name (0.6.20240107)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (0.92.3)
|
||||
faraday (1.10.0)
|
||||
excon (0.109.0)
|
||||
faraday (1.10.3)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
faraday-excon (~> 1.1)
|
||||
|
@ -65,8 +67,8 @@ GEM
|
|||
faraday-retry (1.0.3)
|
||||
faraday_middleware (1.2.0)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.2.6)
|
||||
fastlane (2.206.2)
|
||||
fastimage (2.3.0)
|
||||
fastlane (2.219.0)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
|
@ -85,20 +87,22 @@ GEM
|
|||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, < 2.0.0)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
http-cookie (~> 1.0.5)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.1.0, < 3)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multipart-post (~> 2.0.0)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
naturally (~> 2.2)
|
||||
optparse (~> 0.1.1)
|
||||
optparse (>= 0.1.1)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.3)
|
||||
simctl (~> 1.6.3)
|
||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||
terminal-table (>= 1.4.5, < 2.0.0)
|
||||
terminal-table (~> 3)
|
||||
tty-screen (>= 0.6.3, < 1.0.0)
|
||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
|
@ -106,9 +110,9 @@ GEM
|
|||
xcpretty (~> 0.3.0)
|
||||
xcpretty-travis-formatter (>= 0.0.3)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.22.0)
|
||||
google-apis-core (>= 0.5, < 2.a)
|
||||
google-apis-core (0.6.0)
|
||||
google-apis-androidpublisher_v3 (0.54.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-core (0.11.3)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
httpclient (>= 2.8.1, < 3.a)
|
||||
|
@ -116,31 +120,29 @@ GEM
|
|||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
rexml
|
||||
webrick
|
||||
google-apis-iamcredentials_v1 (0.12.0)
|
||||
google-apis-core (>= 0.6, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.9.0)
|
||||
google-apis-core (>= 0.6, < 2.a)
|
||||
google-apis-storage_v1 (0.15.0)
|
||||
google-apis-core (>= 0.5, < 2.a)
|
||||
google-cloud-core (1.6.0)
|
||||
google-cloud-env (~> 1.0)
|
||||
google-apis-iamcredentials_v1 (0.17.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.13.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-storage_v1 (0.31.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-cloud-core (1.6.1)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-errors (1.2.0)
|
||||
google-cloud-storage (1.36.2)
|
||||
google-cloud-errors (1.3.1)
|
||||
google-cloud-storage (1.47.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-iamcredentials_v1 (~> 0.1)
|
||||
google-apis-storage_v1 (~> 0.1)
|
||||
google-apis-storage_v1 (~> 0.31.0)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
mini_mime (~> 1.0)
|
||||
googleauth (1.2.0)
|
||||
googleauth (1.8.1)
|
||||
faraday (>= 0.17.3, < 3.a)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
memoist (~> 0.16)
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (>= 0.16, < 2.a)
|
||||
|
@ -148,55 +150,52 @@ GEM
|
|||
http-cookie (1.0.5)
|
||||
domain_name (~> 0.5)
|
||||
httpclient (2.8.3)
|
||||
jmespath (1.6.1)
|
||||
json (2.6.2)
|
||||
jwt (2.4.1)
|
||||
memoist (0.16.2)
|
||||
mini_magick (4.11.0)
|
||||
mini_mime (1.1.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.7.1)
|
||||
jwt (2.8.0)
|
||||
base64
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.15.0)
|
||||
multipart-post (2.0.0)
|
||||
multipart-post (2.4.0)
|
||||
nanaimo (0.3.0)
|
||||
naturally (2.2.1)
|
||||
optparse (0.1.1)
|
||||
nkf (0.2.0)
|
||||
optparse (0.4.0)
|
||||
os (1.1.4)
|
||||
plist (3.6.0)
|
||||
public_suffix (4.0.7)
|
||||
rake (13.0.6)
|
||||
plist (3.7.1)
|
||||
public_suffix (5.0.4)
|
||||
rake (13.1.0)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.1.2)
|
||||
rexml (3.2.5)
|
||||
rexml (3.2.6)
|
||||
rouge (2.0.7)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.3.2)
|
||||
security (0.1.3)
|
||||
signet (0.17.0)
|
||||
signet (0.19.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
multi_json (~> 1.10)
|
||||
simctl (1.6.8)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (1.8.0)
|
||||
unicode-display_width (~> 1.1, >= 1.1.1)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
trailblazer-option (0.1.2)
|
||||
tty-cursor (0.7.1)
|
||||
tty-screen (0.8.1)
|
||||
tty-screen (0.8.2)
|
||||
tty-spinner (0.9.3)
|
||||
tty-cursor (~> 0.7)
|
||||
uber (0.1.0)
|
||||
unf (0.1.4)
|
||||
unf_ext
|
||||
unf_ext (0.0.8.2)
|
||||
unicode-display_width (1.8.0)
|
||||
webrick (1.7.0)
|
||||
unicode-display_width (2.5.0)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.22.0)
|
||||
xcodeproj (1.24.0)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
atomos (~> 0.1.3)
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
|
@ -216,4 +215,4 @@ DEPENDENCIES
|
|||
fastlane
|
||||
|
||||
BUNDLED WITH
|
||||
2.3.11
|
||||
2.4.21
|
||||
|
|
|
@ -147,7 +147,7 @@ android {
|
|||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode VERSIONCODE as Integer
|
||||
versionName "4.46.1"
|
||||
versionName "4.47.0"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
if (!isFoss) {
|
||||
manifestPlaceholders = [BugsnagAPIKey: BugsnagAPIKey as String]
|
||||
|
|
|
@ -58,15 +58,13 @@ const App = memo(({ root, isMasterDetail }: { root: string; isMasterDetail: bool
|
|||
}}
|
||||
>
|
||||
<Stack.Navigator screenOptions={{ headerShown: false, animationEnabled: false }}>
|
||||
<>
|
||||
{root === RootEnum.ROOT_LOADING ? <Stack.Screen name='AuthLoading' component={AuthLoadingView} /> : null}
|
||||
{root === RootEnum.ROOT_OUTSIDE ? <Stack.Screen name='OutsideStack' component={OutsideStack} /> : null}
|
||||
{root === RootEnum.ROOT_INSIDE && isMasterDetail ? (
|
||||
<Stack.Screen name='MasterDetailStack' component={MasterDetailStack} />
|
||||
) : null}
|
||||
{root === RootEnum.ROOT_INSIDE && !isMasterDetail ? <Stack.Screen name='InsideStack' component={InsideStack} /> : null}
|
||||
{root === RootEnum.ROOT_SET_USERNAME ? <Stack.Screen name='SetUsernameStack' component={SetUsernameStack} /> : null}
|
||||
</>
|
||||
{root === RootEnum.ROOT_LOADING ? <Stack.Screen name='AuthLoading' component={AuthLoadingView} /> : null}
|
||||
{root === RootEnum.ROOT_OUTSIDE ? <Stack.Screen name='OutsideStack' component={OutsideStack} /> : null}
|
||||
{root === RootEnum.ROOT_INSIDE && isMasterDetail ? (
|
||||
<Stack.Screen name='MasterDetailStack' component={MasterDetailStack} />
|
||||
) : null}
|
||||
{root === RootEnum.ROOT_INSIDE && !isMasterDetail ? <Stack.Screen name='InsideStack' component={InsideStack} /> : null}
|
||||
{root === RootEnum.ROOT_SET_USERNAME ? <Stack.Screen name='SetUsernameStack' component={SetUsernameStack} /> : null}
|
||||
</Stack.Navigator>
|
||||
</NavigationContainer>
|
||||
);
|
||||
|
|
|
@ -96,4 +96,6 @@ export const VIDEO_CONF = createRequestTypes('VIDEO_CONF', [
|
|||
'ACCEPT_CALL',
|
||||
'SET_CALLING'
|
||||
]);
|
||||
export const TROUBLESHOOTING_NOTIFICATION = createRequestTypes('TROUBLESHOOTING_NOTIFICATION', ['INIT', 'SET']);
|
||||
export const SUPPORTED_VERSIONS = createRequestTypes('SUPPORTED_VERSIONS', ['SET']);
|
||||
export const IN_APP_FEEDBACK = createRequestTypes('IN_APP_FEEDBACK', ['SET', 'REMOVE', 'CLEAR']);
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
import { Action } from 'redux';
|
||||
|
||||
import { IN_APP_FEEDBACK } from './actionsTypes';
|
||||
|
||||
interface IInAppFeedbackAction {
|
||||
msgId: string;
|
||||
}
|
||||
|
||||
export type TInAppFeedbackAction = IInAppFeedbackAction & Action;
|
||||
|
||||
export function setInAppFeedback(msgId: string): TInAppFeedbackAction {
|
||||
return {
|
||||
type: IN_APP_FEEDBACK.SET,
|
||||
msgId
|
||||
};
|
||||
}
|
||||
|
||||
export function removeInAppFeedback(msgId: string): TInAppFeedbackAction {
|
||||
return {
|
||||
type: IN_APP_FEEDBACK.REMOVE,
|
||||
msgId
|
||||
};
|
||||
}
|
||||
|
||||
export function clearInAppFeedback(): Action {
|
||||
return {
|
||||
type: IN_APP_FEEDBACK.CLEAR
|
||||
};
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import { Action } from 'redux';
|
||||
|
||||
import { TROUBLESHOOTING_NOTIFICATION } from './actionsTypes';
|
||||
import { ITroubleshootingNotification } from '../reducers/troubleshootingNotification';
|
||||
|
||||
type TSetTroubleshootingNotification = Action & { payload: Partial<ITroubleshootingNotification> };
|
||||
|
||||
export type TActionTroubleshootingNotification = Action & TSetTroubleshootingNotification;
|
||||
|
||||
export function initTroubleshootingNotification(): Action {
|
||||
return {
|
||||
type: TROUBLESHOOTING_NOTIFICATION.INIT
|
||||
};
|
||||
}
|
||||
|
||||
export function setTroubleshootingNotification(payload: Partial<ITroubleshootingNotification>): TSetTroubleshootingNotification {
|
||||
return {
|
||||
type: TROUBLESHOOTING_NOTIFICATION.SET,
|
||||
payload
|
||||
};
|
||||
}
|
|
@ -10,10 +10,10 @@ import styles from './styles';
|
|||
import Seek from './Seek';
|
||||
import PlaybackSpeed from './PlaybackSpeed';
|
||||
import PlayButton from './PlayButton';
|
||||
import EventEmitter from '../../lib/methods/helpers/events';
|
||||
import audioPlayer, { AUDIO_FOCUSED } from '../../lib/methods/audioPlayer';
|
||||
import AudioManager from '../../lib/methods/AudioManager';
|
||||
import { AUDIO_PLAYBACK_SPEED, AVAILABLE_SPEEDS } from './constants';
|
||||
import { TDownloadState } from '../../lib/methods/handleMediaDownload';
|
||||
import { emitter } from '../../lib/methods/helpers';
|
||||
import { TAudioState } from './types';
|
||||
import { useUserPreferences } from '../../lib/methods';
|
||||
|
||||
|
@ -86,15 +86,15 @@ const AudioPlayer = ({
|
|||
};
|
||||
|
||||
const setPosition = async (time: number) => {
|
||||
await audioPlayer.setPositionAsync(audioUri.current, time);
|
||||
await AudioManager.setPositionAsync(audioUri.current, time);
|
||||
};
|
||||
|
||||
const togglePlayPause = async () => {
|
||||
try {
|
||||
if (!paused) {
|
||||
await audioPlayer.pauseAudio(audioUri.current);
|
||||
await AudioManager.pauseAudio();
|
||||
} else {
|
||||
await audioPlayer.playAudio(audioUri.current);
|
||||
await AudioManager.playAudio(audioUri.current);
|
||||
}
|
||||
} catch {
|
||||
// Do nothing
|
||||
|
@ -102,7 +102,7 @@ const AudioPlayer = ({
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
audioPlayer.setRateAsync(audioUri.current, playbackSpeed);
|
||||
AudioManager.setRateAsync(audioUri.current, playbackSpeed);
|
||||
}, [playbackSpeed]);
|
||||
|
||||
const onPress = () => {
|
||||
|
@ -116,11 +116,13 @@ const AudioPlayer = ({
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
InteractionManager.runAfterInteractions(async () => {
|
||||
audioUri.current = await audioPlayer.loadAudio({ msgId, rid, uri: fileUri });
|
||||
audioPlayer.setOnPlaybackStatusUpdate(audioUri.current, onPlaybackStatusUpdate);
|
||||
audioPlayer.setRateAsync(audioUri.current, playbackSpeed);
|
||||
});
|
||||
if (fileUri) {
|
||||
InteractionManager.runAfterInteractions(async () => {
|
||||
audioUri.current = await AudioManager.loadAudio({ msgId, rid, uri: fileUri });
|
||||
AudioManager.setOnPlaybackStatusUpdate(audioUri.current, onPlaybackStatusUpdate);
|
||||
AudioManager.setRateAsync(audioUri.current, playbackSpeed);
|
||||
});
|
||||
}
|
||||
}, [fileUri]);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -133,20 +135,26 @@ const AudioPlayer = ({
|
|||
|
||||
useEffect(() => {
|
||||
const unsubscribeFocus = navigation.addListener('focus', () => {
|
||||
audioPlayer.setOnPlaybackStatusUpdate(audioUri.current, onPlaybackStatusUpdate);
|
||||
AudioManager.setOnPlaybackStatusUpdate(audioUri.current, onPlaybackStatusUpdate);
|
||||
AudioManager.addAudioRendered(audioUri.current);
|
||||
});
|
||||
const unsubscribeBlur = navigation.addListener('blur', () => {
|
||||
AudioManager.removeAudioRendered(audioUri.current);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeFocus();
|
||||
unsubscribeBlur();
|
||||
};
|
||||
}, [navigation]);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = EventEmitter.addEventListener(AUDIO_FOCUSED, ({ audioFocused }: { audioFocused: string }) => {
|
||||
setFocused(audioFocused === audioUri.current);
|
||||
});
|
||||
const audioFocusedEventHandler = (audioFocused: string) => {
|
||||
setFocused(!!audioFocused && audioFocused === audioUri.current);
|
||||
};
|
||||
emitter.on('audioFocused', audioFocusedEventHandler);
|
||||
return () => {
|
||||
EventEmitter.removeListener(AUDIO_FOCUSED, listener);
|
||||
emitter.off('audioFocused', audioFocusedEventHandler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
@ -162,7 +170,7 @@ const AudioPlayer = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.audioContainer, { backgroundColor: colors.surfaceTint, borderColor: colors.strokeExtraLight }]}>
|
||||
<View style={[styles.audioContainer, { backgroundColor: colors.surfaceLight, borderColor: colors.strokeExtraLight }]}>
|
||||
<PlayButton disabled={disabled} audioState={audioState} onPress={onPress} />
|
||||
<Seek currentTime={currentTime} duration={duration} loaded={!disabled && isDownloaded} onChangeTime={setPosition} />
|
||||
{audioState === 'playing' || focused ? <PlaybackSpeed /> : null}
|
||||
|
|
|
@ -44,10 +44,10 @@ export const EmojiSearch = ({ onBlur, onChangeText, bottomSheet }: IEmojiSearchB
|
|||
textContentType='none'
|
||||
blurOnSubmit
|
||||
placeholder={I18n.t('Search_emoji')}
|
||||
placeholderTextColor={colors.auxiliaryText}
|
||||
placeholderTextColor={colors.fontAnnotation}
|
||||
underlineColorAndroid='transparent'
|
||||
onChangeText={handleTextChange}
|
||||
inputStyle={[styles.input, { backgroundColor: colors.textInputSecondaryBackground }]}
|
||||
inputStyle={[styles.input, { backgroundColor: colors.surfaceNeutral }]}
|
||||
containerStyle={styles.textInputContainer}
|
||||
value={searchText}
|
||||
onClearInput={() => handleTextChange('')}
|
||||
|
|
|
@ -5,17 +5,19 @@ import { useTheme } from '../../theme';
|
|||
import { CustomIcon } from '../CustomIcon';
|
||||
import styles from './styles';
|
||||
import { IFooterProps } from './interfaces';
|
||||
|
||||
const BUTTON_HIT_SLOP = { top: 15, right: 15, bottom: 15, left: 15 };
|
||||
import { isIOS } from '../../lib/methods/helpers';
|
||||
|
||||
const Footer = ({ onSearchPressed, onBackspacePressed }: IFooterProps): React.ReactElement => {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<View style={[styles.footerContainer, { borderTopColor: colors.borderColor }]}>
|
||||
<View style={[styles.footerContainer, { borderTopColor: colors.strokeExtraLight }]}>
|
||||
<Pressable
|
||||
onPress={onSearchPressed}
|
||||
hitSlop={BUTTON_HIT_SLOP}
|
||||
style={({ pressed }) => [styles.footerButtonsContainer, { opacity: pressed ? 0.7 : 1 }]}
|
||||
android_ripple={{ color: colors.buttonBackgroundSecondaryPress }}
|
||||
style={({ pressed }) => [
|
||||
styles.footerButtonsContainer,
|
||||
{ backgroundColor: isIOS && pressed ? colors.buttonBackgroundSecondaryPress : 'transparent' }
|
||||
]}
|
||||
testID='emoji-picker-search'
|
||||
>
|
||||
<CustomIcon color={colors.auxiliaryTintColor} size={24} name='search' />
|
||||
|
@ -23,8 +25,11 @@ const Footer = ({ onSearchPressed, onBackspacePressed }: IFooterProps): React.Re
|
|||
|
||||
<Pressable
|
||||
onPress={onBackspacePressed}
|
||||
hitSlop={BUTTON_HIT_SLOP}
|
||||
style={({ pressed }) => [styles.footerButtonsContainer, { opacity: pressed ? 0.7 : 1 }]}
|
||||
android_ripple={{ color: colors.buttonBackgroundSecondaryPress }}
|
||||
style={({ pressed }) => [
|
||||
styles.footerButtonsContainer,
|
||||
{ backgroundColor: isIOS && pressed ? colors.buttonBackgroundSecondaryPress : 'transparent' }
|
||||
]}
|
||||
testID='emoji-picker-backspace'
|
||||
>
|
||||
<CustomIcon color={colors.auxiliaryTintColor} size={24} name='backspace' />
|
||||
|
|
|
@ -14,11 +14,11 @@ export const PressableEmoji = ({ emoji, onPress }: { emoji: IEmoji; onPress: (em
|
|||
key={typeof emoji === 'string' ? emoji : emoji.name}
|
||||
onPress={() => onPress(emoji)}
|
||||
testID={`emoji-${typeof emoji === 'string' ? emoji : emoji.name}`}
|
||||
android_ripple={{ color: colors.bannerBackground, borderless: true, radius: EMOJI_BUTTON_SIZE / 2 }}
|
||||
android_ripple={{ color: colors.buttonBackgroundSecondaryPress, borderless: true, radius: EMOJI_BUTTON_SIZE / 2 }}
|
||||
style={({ pressed }: { pressed: boolean }) => [
|
||||
styles.emojiButton,
|
||||
{
|
||||
backgroundColor: isIOS && pressed ? colors.bannerBackground : 'transparent'
|
||||
backgroundColor: isIOS && pressed ? colors.buttonBackgroundSecondaryPress : 'transparent'
|
||||
}
|
||||
]}
|
||||
>
|
||||
|
|
|
@ -17,20 +17,20 @@ const TabBar = ({ activeTab, tabs, goToPage }: ITabBarProps): React.ReactElement
|
|||
key={tab}
|
||||
onPress={() => goToPage?.(i)}
|
||||
testID={`emoji-picker-tab-${tab}`}
|
||||
android_ripple={{ color: colors.bannerBackground }}
|
||||
android_ripple={{ color: colors.buttonBackgroundSecondaryPress }}
|
||||
style={({ pressed }: { pressed: boolean }) => [
|
||||
styles.tab,
|
||||
{
|
||||
backgroundColor: isIOS && pressed ? colors.bannerBackground : 'transparent'
|
||||
backgroundColor: isIOS && pressed ? colors.buttonBackgroundSecondaryPress : 'transparent'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<CustomIcon name={tab} size={24} color={activeTab === i ? colors.tintColor : colors.auxiliaryTintColor} />
|
||||
<CustomIcon name={tab} size={24} color={activeTab === i ? colors.strokeHighlight : colors.fontSecondaryInfo} />
|
||||
<View
|
||||
style={
|
||||
activeTab === i
|
||||
? [styles.activeTabLine, { backgroundColor: colors.tintColor }]
|
||||
: [styles.tabLine, { backgroundColor: colors.borderColor }]
|
||||
? [styles.activeTabLine, { backgroundColor: colors.strokeHighlight }]
|
||||
: [styles.tabLine, { backgroundColor: colors.strokeExtraLight }]
|
||||
}
|
||||
/>
|
||||
</Pressable>
|
||||
|
|
|
@ -81,7 +81,7 @@ const EmojiPicker = ({
|
|||
keyboardShouldPersistTaps: 'always',
|
||||
keyboardDismissMode: 'none'
|
||||
}}
|
||||
style={{ backgroundColor: colors.messageboxBackground }}
|
||||
style={{ backgroundColor: colors.surfaceLight }}
|
||||
>
|
||||
{categories.tabs.map((tab: any, i) => renderCategory(tab.category, i, tab.tabLabel))}
|
||||
</ScrollableTabView>
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import React, { ElementType, memo, useEffect } from 'react';
|
||||
import { Easing, Notifier, NotifierRoot } from 'react-native-notifier';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import NotifierComponent, { INotifierComponent } from './NotifierComponent';
|
||||
import EventEmitter from '../../lib/methods/helpers/events';
|
||||
import Navigation from '../../lib/navigation/appNavigation';
|
||||
import { getActiveRoute } from '../../lib/methods/helpers/navigation';
|
||||
import { useAppSelector } from '../../lib/hooks';
|
||||
import { setInAppFeedback } from '../../actions/inAppFeedback';
|
||||
|
||||
export const INAPP_NOTIFICATION_EMITTER = 'NotificationInApp';
|
||||
|
||||
|
@ -15,6 +17,8 @@ const InAppNotification = memo(() => {
|
|||
appState: state.app.ready && state.app.foreground ? 'foreground' : 'background'
|
||||
}));
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const show = (
|
||||
notification: INotifierComponent['notification'] & {
|
||||
customComponent?: ElementType;
|
||||
|
@ -30,7 +34,13 @@ const InAppNotification = memo(() => {
|
|||
const state = Navigation.navigationRef.current?.getRootState();
|
||||
const route = getActiveRoute(state);
|
||||
if (payload?.rid || notification.customNotification) {
|
||||
if (payload?.rid === subscribedRoom || route?.name === 'JitsiMeetView' || payload?.message?.t === 'videoconf') return;
|
||||
if (route?.name === 'JitsiMeetView' || payload?.message?.t === 'videoconf') return;
|
||||
|
||||
if (payload?.rid === subscribedRoom) {
|
||||
const msgId = payload._id;
|
||||
dispatch(setInAppFeedback(msgId));
|
||||
return;
|
||||
}
|
||||
|
||||
Notifier.showNotification({
|
||||
showEasing: Easing.inOut(Easing.quad),
|
||||
|
|
|
@ -24,10 +24,11 @@ export interface IMessageActionsProps {
|
|||
room: TSubscriptionModel;
|
||||
tmid?: string;
|
||||
user: Pick<ILoggedUser, 'id'>;
|
||||
editInit: (message: TAnyMessageModel) => void;
|
||||
reactionInit: (message: TAnyMessageModel) => void;
|
||||
editInit: (messageId: string) => void;
|
||||
reactionInit: (messageId: string) => void;
|
||||
onReactionPress: (shortname: IEmoji, messageId: string) => void;
|
||||
replyInit: (message: TAnyMessageModel, mention: boolean) => void;
|
||||
replyInit: (messageId: string) => void;
|
||||
quoteInit: (messageId: string) => void;
|
||||
jumpToMessage?: (messageUrl?: string, isFromReply?: boolean) => Promise<void>;
|
||||
isMasterDetail: boolean;
|
||||
isReadOnly: boolean;
|
||||
|
@ -63,6 +64,7 @@ const MessageActions = React.memo(
|
|||
reactionInit,
|
||||
onReactionPress,
|
||||
replyInit,
|
||||
quoteInit,
|
||||
jumpToMessage,
|
||||
isReadOnly,
|
||||
Message_AllowDeleting,
|
||||
|
@ -180,14 +182,14 @@ const MessageActions = React.memo(
|
|||
|
||||
const getPermalink = (message: TAnyMessageModel) => getPermalinkMessage(message);
|
||||
|
||||
const handleReply = (message: TAnyMessageModel) => {
|
||||
const handleReply = (messageId: string) => {
|
||||
logEvent(events.ROOM_MSG_ACTION_REPLY);
|
||||
replyInit(message, true);
|
||||
replyInit(messageId);
|
||||
};
|
||||
|
||||
const handleEdit = (message: TAnyMessageModel) => {
|
||||
const handleEdit = (messageId: string) => {
|
||||
logEvent(events.ROOM_MSG_ACTION_EDIT);
|
||||
editInit(message);
|
||||
editInit(messageId);
|
||||
};
|
||||
|
||||
const handleCreateDiscussion = (message: TAnyMessageModel) => {
|
||||
|
@ -263,9 +265,9 @@ const MessageActions = React.memo(
|
|||
}
|
||||
};
|
||||
|
||||
const handleQuote = (message: TAnyMessageModel) => {
|
||||
const handleQuote = (messageId: string) => {
|
||||
logEvent(events.ROOM_MSG_ACTION_QUOTE);
|
||||
replyInit(message, false);
|
||||
quoteInit(messageId);
|
||||
};
|
||||
|
||||
const handleReplyInDM = async (message: TAnyMessageModel) => {
|
||||
|
@ -278,7 +280,7 @@ const MessageActions = React.memo(
|
|||
name: getRoomTitle(room),
|
||||
t: room.t,
|
||||
roomUserId: getUidDirectMessage(room),
|
||||
replyInDM: message
|
||||
messageId: message.id
|
||||
};
|
||||
Navigation.replace('RoomView', params);
|
||||
}
|
||||
|
@ -311,7 +313,7 @@ const MessageActions = React.memo(
|
|||
if (emoji) {
|
||||
onReactionPress(emoji, message.id);
|
||||
} else {
|
||||
setTimeout(() => reactionInit(message), ACTION_SHEET_ANIMATION_DURATION);
|
||||
setTimeout(() => reactionInit(message.id), ACTION_SHEET_ANIMATION_DURATION);
|
||||
}
|
||||
hideActionSheet();
|
||||
};
|
||||
|
@ -391,7 +393,7 @@ const MessageActions = React.memo(
|
|||
options.push({
|
||||
title: I18n.t('Quote'),
|
||||
icon: 'quote',
|
||||
onPress: () => handleQuote(message)
|
||||
onPress: () => handleQuote(message.id)
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -400,7 +402,7 @@ const MessageActions = React.memo(
|
|||
options.push({
|
||||
title: I18n.t('Reply_in_Thread'),
|
||||
icon: 'threads',
|
||||
onPress: () => handleReply(message)
|
||||
onPress: () => handleReply(message.id)
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -459,7 +461,7 @@ const MessageActions = React.memo(
|
|||
options.push({
|
||||
title: I18n.t('Edit'),
|
||||
icon: 'edit',
|
||||
onPress: () => handleEdit(message),
|
||||
onPress: () => handleEdit(message.id),
|
||||
enabled: isEditAllowed
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,49 +0,0 @@
|
|||
import FastImage from 'react-native-fast-image';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { TouchableOpacity } from 'react-native';
|
||||
|
||||
import { themes } from '../../../lib/constants';
|
||||
import { CustomIcon } from '../../CustomIcon';
|
||||
import { useTheme } from '../../../theme';
|
||||
import ActivityIndicator from '../../ActivityIndicator';
|
||||
import MessageboxContext from '../Context';
|
||||
import styles from '../styles';
|
||||
|
||||
interface IMessageBoxCommandsPreviewItem {
|
||||
item: {
|
||||
type: string;
|
||||
id: string;
|
||||
value: string;
|
||||
};
|
||||
}
|
||||
|
||||
const Item = ({ item }: IMessageBoxCommandsPreviewItem) => {
|
||||
const context = useContext(MessageboxContext);
|
||||
const { onPressCommandPreview } = context;
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.commandPreview}
|
||||
onPress={() => onPressCommandPreview(item)}
|
||||
testID={`command-preview-item${item.id}`}
|
||||
>
|
||||
{item.type === 'image' ? (
|
||||
<FastImage
|
||||
style={styles.commandPreviewImage}
|
||||
source={{ uri: item.value }}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
onLoadStart={() => setLoading(true)}
|
||||
onLoad={() => setLoading(false)}
|
||||
>
|
||||
{loading ? <ActivityIndicator /> : null}
|
||||
</FastImage>
|
||||
) : (
|
||||
<CustomIcon name='attach' size={36} color={themes[theme].actionTintColor} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
export default Item;
|
|
@ -1,48 +0,0 @@
|
|||
import { dequal } from 'dequal';
|
||||
import React from 'react';
|
||||
import { FlatList } from 'react-native';
|
||||
|
||||
import { themes } from '../../../lib/constants';
|
||||
import { IPreviewItem } from '../../../definitions';
|
||||
import { useTheme } from '../../../theme';
|
||||
import styles from '../styles';
|
||||
import Item from './Item';
|
||||
|
||||
interface IMessageBoxCommandsPreview {
|
||||
commandPreview: IPreviewItem[];
|
||||
showCommandPreview: boolean;
|
||||
}
|
||||
|
||||
const CommandsPreview = React.memo(
|
||||
({ commandPreview, showCommandPreview }: IMessageBoxCommandsPreview) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
if (!showCommandPreview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
testID='commandbox-container'
|
||||
style={[styles.mentionList, { backgroundColor: themes[theme].messageboxBackground }]}
|
||||
data={commandPreview}
|
||||
renderItem={({ item }) => <Item item={item} />}
|
||||
keyExtractor={(item: any) => item.id}
|
||||
keyboardShouldPersistTaps='always'
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
/>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
if (prevProps.showCommandPreview !== nextProps.showCommandPreview) {
|
||||
return false;
|
||||
}
|
||||
if (!dequal(prevProps.commandPreview, nextProps.commandPreview)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
export default CommandsPreview;
|
|
@ -1,4 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
const MessageboxContext = React.createContext<any>(null);
|
||||
export default MessageboxContext;
|
|
@ -1,20 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { CancelEditingButton, ToggleEmojiButton } from './buttons';
|
||||
|
||||
interface IMessageBoxLeftButtons {
|
||||
showEmojiKeyboard: boolean;
|
||||
openEmoji(): void;
|
||||
closeEmoji(): void;
|
||||
editing: boolean;
|
||||
editCancel(): void;
|
||||
}
|
||||
|
||||
const LeftButtons = React.memo(({ showEmojiKeyboard, editing, editCancel, openEmoji, closeEmoji }: IMessageBoxLeftButtons) => {
|
||||
if (editing) {
|
||||
return <CancelEditingButton onPress={editCancel} />;
|
||||
}
|
||||
return <ToggleEmojiButton show={showEmojiKeyboard} open={openEmoji} close={closeEmoji} />;
|
||||
});
|
||||
|
||||
export default LeftButtons;
|
|
@ -1,37 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Text, TouchableOpacity } from 'react-native';
|
||||
|
||||
import styles from '../styles';
|
||||
import I18n from '../../../i18n';
|
||||
import { themes } from '../../../lib/constants';
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
interface IMessageBoxFixedMentionItem {
|
||||
item: {
|
||||
username: string;
|
||||
};
|
||||
onPress: Function;
|
||||
}
|
||||
|
||||
const FixedMentionItem = ({ item, onPress }: IMessageBoxFixedMentionItem) => {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.mentionItem,
|
||||
{
|
||||
backgroundColor: themes[theme].auxiliaryBackground,
|
||||
borderTopColor: themes[theme].separatorColor
|
||||
}
|
||||
]}
|
||||
onPress={() => onPress(item)}
|
||||
>
|
||||
<Text style={[styles.fixedMentionAvatar, { color: themes[theme].titleText }]}>{item.username}</Text>
|
||||
<Text style={[styles.mentionText, { color: themes[theme].titleText }]}>
|
||||
{item.username === 'here' ? I18n.t('Notify_active_in_this_room') : I18n.t('Notify_all_in_this_room')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
export default FixedMentionItem;
|
|
@ -1,20 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Text } from 'react-native';
|
||||
|
||||
import { IEmoji } from '../../../definitions/IEmoji';
|
||||
import shortnameToUnicode from '../../../lib/methods/helpers/shortnameToUnicode';
|
||||
import CustomEmoji from '../../EmojiPicker/CustomEmoji';
|
||||
import styles from '../styles';
|
||||
|
||||
interface IMessageBoxMentionEmoji {
|
||||
item: IEmoji;
|
||||
}
|
||||
|
||||
const MentionEmoji = ({ item }: IMessageBoxMentionEmoji) => {
|
||||
if (typeof item === 'string') {
|
||||
return <Text style={styles.mentionItemEmoji}>{shortnameToUnicode(`:${item}:`)}</Text>;
|
||||
}
|
||||
return <CustomEmoji style={styles.mentionItemCustomEmoji} emoji={item} />;
|
||||
};
|
||||
|
||||
export default MentionEmoji;
|
|
@ -1,49 +0,0 @@
|
|||
import React, { useContext } from 'react';
|
||||
import { ActivityIndicator, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
import { themes } from '../../../lib/constants';
|
||||
import I18n from '../../../i18n';
|
||||
import { CustomIcon } from '../../CustomIcon';
|
||||
import { useTheme } from '../../../theme';
|
||||
import sharedStyles from '../../../views/Styles';
|
||||
import MessageboxContext from '../Context';
|
||||
import styles from '../styles';
|
||||
import { MENTIONS_TRACKING_TYPE_CANNED } from '../constants';
|
||||
|
||||
interface IMentionHeaderList {
|
||||
trackingType: string;
|
||||
hasMentions: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const MentionHeaderList = ({ trackingType, hasMentions, loading }: IMentionHeaderList) => {
|
||||
const { theme } = useTheme();
|
||||
const context = useContext(MessageboxContext);
|
||||
const { onPressNoMatchCanned } = context;
|
||||
|
||||
if (trackingType === MENTIONS_TRACKING_TYPE_CANNED) {
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.wrapMentionHeaderListRow}>
|
||||
<ActivityIndicator style={styles.loadingPaddingHeader} size='small' />
|
||||
<Text style={[styles.mentionHeaderList, { color: themes[theme].auxiliaryText }]}>{I18n.t('Searching')}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasMentions) {
|
||||
return (
|
||||
<TouchableOpacity style={[styles.wrapMentionHeaderListRow, styles.mentionNoMatchHeader]} onPress={onPressNoMatchCanned}>
|
||||
<Text style={[styles.mentionHeaderListNoMatchFound, { color: themes[theme].auxiliaryText }]}>
|
||||
{I18n.t('No_match_found')} <Text style={sharedStyles.textSemibold}>{I18n.t('Check_canned_responses')}</Text>
|
||||
</Text>
|
||||
<CustomIcon name='chevron-right' size={24} color={themes[theme].auxiliaryText} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default MentionHeaderList;
|
|
@ -1,107 +0,0 @@
|
|||
import React, { useContext } from 'react';
|
||||
import { Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
import { themes } from '../../../lib/constants';
|
||||
import { IEmoji } from '../../../definitions/IEmoji';
|
||||
import { useTheme } from '../../../theme';
|
||||
import Avatar from '../../Avatar';
|
||||
import { MENTIONS_TRACKING_TYPE_CANNED, MENTIONS_TRACKING_TYPE_COMMANDS, MENTIONS_TRACKING_TYPE_EMOJIS } from '../constants';
|
||||
import MessageboxContext from '../Context';
|
||||
import styles from '../styles';
|
||||
import FixedMentionItem from './FixedMentionItem';
|
||||
import MentionEmoji from './MentionEmoji';
|
||||
|
||||
interface IMessageBoxMentionItem {
|
||||
item: {
|
||||
name: string;
|
||||
command: string;
|
||||
username: string;
|
||||
t: string;
|
||||
id: string;
|
||||
shortcut: string;
|
||||
text: string;
|
||||
} & IEmoji;
|
||||
trackingType: string;
|
||||
}
|
||||
|
||||
const MentionItemContent = React.memo(({ trackingType, item }: IMessageBoxMentionItem) => {
|
||||
const { theme } = useTheme();
|
||||
switch (trackingType) {
|
||||
case MENTIONS_TRACKING_TYPE_EMOJIS:
|
||||
return (
|
||||
<>
|
||||
<MentionEmoji item={item} />
|
||||
<Text style={[styles.mentionText, { color: themes[theme].titleText }]}>:{item.name || item}:</Text>
|
||||
</>
|
||||
);
|
||||
case MENTIONS_TRACKING_TYPE_COMMANDS:
|
||||
return (
|
||||
<>
|
||||
<View style={[styles.slash, { backgroundColor: themes[theme].borderColor }]}>
|
||||
<Text style={{ color: themes[theme].tintColor }}>/</Text>
|
||||
</View>
|
||||
<Text style={[styles.mentionText, { color: themes[theme].titleText }]}>{item.id}</Text>
|
||||
</>
|
||||
);
|
||||
case MENTIONS_TRACKING_TYPE_CANNED:
|
||||
return (
|
||||
<>
|
||||
<Text style={[styles.cannedItem, { color: themes[theme].titleText }]}>!{item.shortcut}</Text>
|
||||
<Text numberOfLines={1} style={[styles.cannedMentionText, { color: themes[theme].auxiliaryTintColor }]}>
|
||||
{item.text}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
<Avatar style={styles.avatar} text={item.username || item.name} size={30} type={item.t} />
|
||||
<Text style={[styles.mentionText, { color: themes[theme].titleText }]}>{item.username || item.name || item}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const MentionItem = ({ item, trackingType }: IMessageBoxMentionItem) => {
|
||||
const context = useContext(MessageboxContext);
|
||||
const { theme } = useTheme();
|
||||
const { onPressMention } = context;
|
||||
|
||||
const defineTestID = (type: string) => {
|
||||
switch (type) {
|
||||
case MENTIONS_TRACKING_TYPE_EMOJIS:
|
||||
return `mention-item-${item.name || item}`;
|
||||
case MENTIONS_TRACKING_TYPE_COMMANDS:
|
||||
return `mention-item-${item.command || item}`;
|
||||
case MENTIONS_TRACKING_TYPE_CANNED:
|
||||
return `mention-item-${item.shortcut || item}`;
|
||||
default:
|
||||
return `mention-item-${item.username || item.name || item}`;
|
||||
}
|
||||
};
|
||||
|
||||
const testID = defineTestID(trackingType);
|
||||
|
||||
if (item.username === 'all' || item.username === 'here') {
|
||||
return <FixedMentionItem item={item} onPress={onPressMention} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.mentionItem,
|
||||
{
|
||||
backgroundColor: themes[theme].auxiliaryBackground,
|
||||
borderTopColor: themes[theme].separatorColor
|
||||
}
|
||||
]}
|
||||
onPress={() => onPressMention(item)}
|
||||
testID={testID}
|
||||
>
|
||||
<MentionItemContent item={item} trackingType={trackingType} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
export default MentionItem;
|
|
@ -1,55 +0,0 @@
|
|||
import React from 'react';
|
||||
import { FlatList, View } from 'react-native';
|
||||
import { dequal } from 'dequal';
|
||||
|
||||
import MentionHeaderList from './MentionHeaderList';
|
||||
import styles from '../styles';
|
||||
import MentionItem from './MentionItem';
|
||||
import { themes } from '../../../lib/constants';
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
interface IMessageBoxMentions {
|
||||
mentions: any[];
|
||||
trackingType: string;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const Mentions = React.memo(
|
||||
({ mentions, trackingType, loading }: IMessageBoxMentions) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
if (!trackingType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View testID='messagebox-container'>
|
||||
<FlatList
|
||||
style={[styles.mentionList, { backgroundColor: themes[theme].auxiliaryBackground }]}
|
||||
ListHeaderComponent={() => (
|
||||
<MentionHeaderList trackingType={trackingType} hasMentions={mentions.length > 0} loading={loading} />
|
||||
)}
|
||||
data={mentions}
|
||||
extraData={mentions}
|
||||
renderItem={({ item }) => <MentionItem item={item} trackingType={trackingType} />}
|
||||
keyExtractor={item => item.rid || item.name || item.command || item.shortcut || item}
|
||||
keyboardShouldPersistTaps='always'
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
if (prevProps.loading !== nextProps.loading) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.trackingType !== nextProps.trackingType) {
|
||||
return false;
|
||||
}
|
||||
if (!dequal(prevProps.mentions, nextProps.mentions)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
export default Mentions;
|
|
@ -1,255 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Text, View } from 'react-native';
|
||||
import { Audio, InterruptionModeAndroid, InterruptionModeIOS } from 'expo-av';
|
||||
import { BorderlessButton } from 'react-native-gesture-handler';
|
||||
import { getInfoAsync } from 'expo-file-system';
|
||||
import { activateKeepAwake, deactivateKeepAwake } from 'expo-keep-awake';
|
||||
|
||||
import styles from './styles';
|
||||
import I18n from '../../i18n';
|
||||
import { themes } from '../../lib/constants';
|
||||
import { CustomIcon } from '../CustomIcon';
|
||||
import { events, logEvent } from '../../lib/methods/helpers/log';
|
||||
import { TSupportedThemes } from '../../theme';
|
||||
|
||||
interface IMessageBoxRecordAudioProps {
|
||||
theme: TSupportedThemes;
|
||||
permissionToUpload: boolean;
|
||||
recordingCallback: Function;
|
||||
onFinish: Function;
|
||||
onStart: Function;
|
||||
}
|
||||
|
||||
const RECORDING_EXTENSION = '.aac';
|
||||
const RECORDING_SETTINGS = {
|
||||
android: {
|
||||
// Settings related to audio encoding.
|
||||
extension: RECORDING_EXTENSION,
|
||||
outputFormat: Audio.RECORDING_OPTION_ANDROID_OUTPUT_FORMAT_AAC_ADTS,
|
||||
audioEncoder: Audio.RECORDING_OPTION_ANDROID_AUDIO_ENCODER_AAC,
|
||||
// Settings related to audio quality.
|
||||
sampleRate: Audio.RECORDING_OPTIONS_PRESET_LOW_QUALITY.android.sampleRate,
|
||||
numberOfChannels: Audio.RECORDING_OPTIONS_PRESET_LOW_QUALITY.android.numberOfChannels,
|
||||
bitRate: Audio.RECORDING_OPTIONS_PRESET_LOW_QUALITY.android.bitRate
|
||||
},
|
||||
ios: {
|
||||
// Settings related to audio encoding.
|
||||
extension: RECORDING_EXTENSION,
|
||||
audioQuality: Audio.RECORDING_OPTION_IOS_AUDIO_QUALITY_MEDIUM,
|
||||
outputFormat: Audio.RECORDING_OPTION_IOS_OUTPUT_FORMAT_MPEG4AAC,
|
||||
// Settings related to audio quality.
|
||||
sampleRate: Audio.RECORDING_OPTIONS_PRESET_LOW_QUALITY.ios.sampleRate,
|
||||
numberOfChannels: Audio.RECORDING_OPTIONS_PRESET_LOW_QUALITY.ios.numberOfChannels,
|
||||
bitRate: Audio.RECORDING_OPTIONS_PRESET_LOW_QUALITY.ios.bitRate
|
||||
},
|
||||
web: {}
|
||||
};
|
||||
|
||||
const RECORDING_MODE = {
|
||||
allowsRecordingIOS: true,
|
||||
playsInSilentModeIOS: true,
|
||||
staysActiveInBackground: true,
|
||||
shouldDuckAndroid: true,
|
||||
playThroughEarpieceAndroid: false,
|
||||
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
|
||||
interruptionModeAndroid: InterruptionModeAndroid.DoNotMix
|
||||
};
|
||||
|
||||
const formatTime = function (time: number) {
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = time % 60;
|
||||
const min = minutes < 10 ? `0${minutes}` : minutes;
|
||||
const sec = seconds < 10 ? `0${seconds}` : seconds;
|
||||
return `${min}:${sec}`;
|
||||
};
|
||||
|
||||
export default class RecordAudio extends React.PureComponent<IMessageBoxRecordAudioProps, any> {
|
||||
private isRecorderBusy: boolean;
|
||||
private recording!: Audio.Recording;
|
||||
private LastDuration: number;
|
||||
|
||||
constructor(props: IMessageBoxRecordAudioProps) {
|
||||
super(props);
|
||||
this.isRecorderBusy = false;
|
||||
this.LastDuration = 0;
|
||||
this.state = {
|
||||
isRecording: false,
|
||||
isRecorderActive: false,
|
||||
recordingDurationMillis: 0
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const { recordingCallback } = this.props;
|
||||
const { isRecorderActive } = this.state;
|
||||
|
||||
recordingCallback(isRecorderActive);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.recording) {
|
||||
this.cancelRecordingAudio();
|
||||
}
|
||||
}
|
||||
|
||||
get duration() {
|
||||
const { recordingDurationMillis } = this.state;
|
||||
return formatTime(Math.floor(recordingDurationMillis / 1000));
|
||||
}
|
||||
|
||||
get GetLastDuration() {
|
||||
return formatTime(Math.floor(this.LastDuration / 1000));
|
||||
}
|
||||
|
||||
isRecordingPermissionGranted = async () => {
|
||||
try {
|
||||
const permission = await Audio.getPermissionsAsync();
|
||||
if (permission.status === 'granted') {
|
||||
return true;
|
||||
}
|
||||
await Audio.requestPermissionsAsync();
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
onRecordingStatusUpdate = (status: Audio.RecordingStatus) => {
|
||||
this.setState({
|
||||
isRecording: status.isRecording,
|
||||
recordingDurationMillis: status.durationMillis
|
||||
});
|
||||
this.LastDuration = status.durationMillis;
|
||||
};
|
||||
|
||||
startRecordingAudio = async () => {
|
||||
const { onStart } = this.props;
|
||||
onStart();
|
||||
|
||||
logEvent(events.ROOM_AUDIO_RECORD);
|
||||
if (!this.isRecorderBusy) {
|
||||
this.isRecorderBusy = true;
|
||||
this.LastDuration = 0;
|
||||
try {
|
||||
const canRecord = await this.isRecordingPermissionGranted();
|
||||
if (canRecord) {
|
||||
await Audio.setAudioModeAsync(RECORDING_MODE);
|
||||
|
||||
this.setState({ isRecorderActive: true });
|
||||
this.recording = new Audio.Recording();
|
||||
await this.recording.prepareToRecordAsync(RECORDING_SETTINGS);
|
||||
this.recording.setOnRecordingStatusUpdate(this.onRecordingStatusUpdate);
|
||||
|
||||
await this.recording.startAsync();
|
||||
activateKeepAwake();
|
||||
} else {
|
||||
await Audio.requestPermissionsAsync();
|
||||
}
|
||||
} catch (error) {
|
||||
logEvent(events.ROOM_AUDIO_RECORD_F);
|
||||
}
|
||||
this.isRecorderBusy = false;
|
||||
}
|
||||
};
|
||||
|
||||
finishRecordingAudio = async () => {
|
||||
logEvent(events.ROOM_AUDIO_FINISH);
|
||||
if (!this.isRecorderBusy) {
|
||||
const { onFinish } = this.props;
|
||||
|
||||
this.isRecorderBusy = true;
|
||||
try {
|
||||
await this.recording.stopAndUnloadAsync();
|
||||
|
||||
const fileURI = this.recording.getURI();
|
||||
const fileData = await getInfoAsync(fileURI as string);
|
||||
const fileInfo = {
|
||||
name: `${Date.now()}.aac`,
|
||||
mime: 'audio/aac',
|
||||
type: 'audio/aac',
|
||||
store: 'Uploads',
|
||||
path: fileURI,
|
||||
size: fileData.exists ? fileData.size : null
|
||||
};
|
||||
|
||||
onFinish(fileInfo);
|
||||
} catch (error) {
|
||||
logEvent(events.ROOM_AUDIO_FINISH_F);
|
||||
}
|
||||
this.setState({ isRecording: false, isRecorderActive: false, recordingDurationMillis: 0 });
|
||||
deactivateKeepAwake();
|
||||
this.isRecorderBusy = false;
|
||||
}
|
||||
};
|
||||
|
||||
cancelRecordingAudio = async () => {
|
||||
logEvent(events.ROOM_AUDIO_CANCEL);
|
||||
if (!this.isRecorderBusy) {
|
||||
this.isRecorderBusy = true;
|
||||
try {
|
||||
await this.recording.stopAndUnloadAsync();
|
||||
} catch (error) {
|
||||
logEvent(events.ROOM_AUDIO_CANCEL_F);
|
||||
}
|
||||
this.setState({ isRecording: false, isRecorderActive: false, recordingDurationMillis: 0 });
|
||||
deactivateKeepAwake();
|
||||
this.isRecorderBusy = false;
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { theme, permissionToUpload } = this.props;
|
||||
const { isRecording, isRecorderActive } = this.state;
|
||||
if (!permissionToUpload) {
|
||||
return null;
|
||||
}
|
||||
if (!isRecording && !isRecorderActive) {
|
||||
return (
|
||||
<BorderlessButton onPress={this.startRecordingAudio} style={styles.actionButton} testID='messagebox-send-audio'>
|
||||
<View accessible accessibilityLabel={I18n.t('Send_audio_message')} accessibilityRole='button'>
|
||||
<CustomIcon name='microphone' size={24} color={themes[theme].auxiliaryTintColor} />
|
||||
</View>
|
||||
</BorderlessButton>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isRecording && isRecorderActive) {
|
||||
return (
|
||||
<View style={styles.recordingContent}>
|
||||
<View style={styles.textArea}>
|
||||
<BorderlessButton onPress={this.cancelRecordingAudio} style={styles.actionButton}>
|
||||
<View accessible accessibilityLabel={I18n.t('Cancel_recording')} accessibilityRole='button'>
|
||||
<CustomIcon size={24} color={themes[theme].dangerColor} name='delete' />
|
||||
</View>
|
||||
</BorderlessButton>
|
||||
<Text style={[styles.recordingDurationText, { color: themes[theme].titleText }]}>{this.GetLastDuration}</Text>
|
||||
</View>
|
||||
<BorderlessButton onPress={this.finishRecordingAudio} style={styles.actionButton}>
|
||||
<View accessible accessibilityLabel={I18n.t('Finish_recording')} accessibilityRole='button'>
|
||||
<CustomIcon size={24} color={themes[theme].tintColor} name='send-filled' />
|
||||
</View>
|
||||
</BorderlessButton>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.recordingContent}>
|
||||
<View style={styles.textArea}>
|
||||
<BorderlessButton onPress={this.cancelRecordingAudio} style={styles.actionButton}>
|
||||
<View accessible accessibilityLabel={I18n.t('Cancel_recording')} accessibilityRole='button'>
|
||||
<CustomIcon size={24} color={themes[theme].dangerColor} name='delete' />
|
||||
</View>
|
||||
</BorderlessButton>
|
||||
<Text style={[styles.recordingDurationText, { color: themes[theme].titleText }]}>{this.duration}</Text>
|
||||
<CustomIcon size={24} color={themes[theme].dangerColor} name='record' />
|
||||
</View>
|
||||
<BorderlessButton onPress={this.finishRecordingAudio} style={styles.actionButton}>
|
||||
<View accessible accessibilityLabel={I18n.t('Finish_recording')} accessibilityRole='button'>
|
||||
<CustomIcon size={24} color={themes[theme].tintColor} name='send-filled' />
|
||||
</View>
|
||||
</BorderlessButton>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,92 +0,0 @@
|
|||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import moment from 'moment';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { MarkdownPreview } from '../markdown';
|
||||
import { CustomIcon } from '../CustomIcon';
|
||||
import sharedStyles from '../../views/Styles';
|
||||
import { themes } from '../../lib/constants';
|
||||
import { IMessage } from '../../definitions/IMessage';
|
||||
import { useTheme } from '../../theme';
|
||||
import { IApplicationState } from '../../definitions';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
paddingTop: 10
|
||||
},
|
||||
messageContainer: {
|
||||
flex: 1,
|
||||
marginHorizontal: 10,
|
||||
paddingHorizontal: 15,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 4
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center'
|
||||
},
|
||||
username: {
|
||||
fontSize: 16,
|
||||
...sharedStyles.textMedium,
|
||||
flexShrink: 1
|
||||
},
|
||||
time: {
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
marginLeft: 6,
|
||||
...sharedStyles.textRegular,
|
||||
fontWeight: '300'
|
||||
},
|
||||
close: {
|
||||
marginRight: 10
|
||||
}
|
||||
});
|
||||
|
||||
interface IMessageBoxReplyPreview {
|
||||
replying: boolean;
|
||||
message: IMessage;
|
||||
Message_TimeFormat: string;
|
||||
close(): void;
|
||||
baseUrl: string;
|
||||
username: string;
|
||||
getCustomEmoji: Function;
|
||||
useRealName: boolean;
|
||||
}
|
||||
|
||||
const ReplyPreview = React.memo(
|
||||
({ message, Message_TimeFormat, replying, close, useRealName }: IMessageBoxReplyPreview) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
if (!replying) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const time = moment(message.ts).format(Message_TimeFormat);
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: themes[theme].messageboxBackground }]}>
|
||||
<View style={[styles.messageContainer, { backgroundColor: themes[theme].chatComponentBackground }]}>
|
||||
<View style={styles.header}>
|
||||
<Text numberOfLines={1} style={[styles.username, { color: themes[theme].tintColor }]}>
|
||||
{useRealName ? message.u?.name : message.u?.username}
|
||||
</Text>
|
||||
<Text style={[styles.time, { color: themes[theme].auxiliaryText }]}>{time}</Text>
|
||||
</View>
|
||||
<MarkdownPreview msg={message.msg} />
|
||||
</View>
|
||||
<CustomIcon name='close' color={themes[theme].auxiliaryText} size={20} style={styles.close} onPress={close} />
|
||||
</View>
|
||||
);
|
||||
},
|
||||
(prevProps: IMessageBoxReplyPreview, nextProps: IMessageBoxReplyPreview) =>
|
||||
prevProps.replying === nextProps.replying && prevProps.message.id === nextProps.message.id
|
||||
);
|
||||
|
||||
const mapStateToProps = (state: IApplicationState) => ({
|
||||
Message_TimeFormat: state.settings.Message_TimeFormat as string,
|
||||
baseUrl: state.server.server,
|
||||
useRealName: state.settings.UI_Use_Real_Name as boolean
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(ReplyPreview);
|
|
@ -1,25 +0,0 @@
|
|||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
|
||||
import { isIOS } from '../../lib/methods/helpers';
|
||||
import { ActionsButton, SendButton } from './buttons';
|
||||
import styles from './styles';
|
||||
|
||||
interface IMessageBoxRightButtons {
|
||||
showSend: boolean;
|
||||
submit(): void;
|
||||
showMessageBoxActions(): void;
|
||||
isActionsEnabled: boolean;
|
||||
}
|
||||
|
||||
const RightButtons = React.memo(({ showSend, submit, showMessageBoxActions, isActionsEnabled }: IMessageBoxRightButtons) => {
|
||||
if (showSend) {
|
||||
return <SendButton onPress={submit} />;
|
||||
}
|
||||
if (isActionsEnabled) {
|
||||
return <ActionsButton onPress={showMessageBoxActions} />;
|
||||
}
|
||||
return !isIOS ? <View style={styles.buttonsWhitespace} /> : null;
|
||||
});
|
||||
|
||||
export default RightButtons;
|
|
@ -1,13 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import BaseButton from './BaseButton';
|
||||
|
||||
interface IActionsButton {
|
||||
onPress(): void;
|
||||
}
|
||||
|
||||
const ActionsButton = ({ onPress }: IActionsButton) => (
|
||||
<BaseButton onPress={onPress} testID='messagebox-actions' accessibilityLabel='Message_actions' icon='add' />
|
||||
);
|
||||
|
||||
export default ActionsButton;
|
|
@ -1,34 +0,0 @@
|
|||
import { BorderlessButton } from 'react-native-gesture-handler';
|
||||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
|
||||
import styles from '../styles';
|
||||
import i18n from '../../../i18n';
|
||||
import { CustomIcon, TIconsName } from '../../CustomIcon';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { themes } from '../../../lib/constants';
|
||||
|
||||
interface IBaseButton {
|
||||
onPress(): void;
|
||||
testID: string;
|
||||
accessibilityLabel: string;
|
||||
icon: TIconsName;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const BaseButton = ({ accessibilityLabel, icon, color, ...props }: IBaseButton) => {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<BorderlessButton {...props} style={styles.actionButton}>
|
||||
<View
|
||||
accessible
|
||||
accessibilityLabel={accessibilityLabel ? i18n.t(accessibilityLabel) : accessibilityLabel}
|
||||
accessibilityRole='button'
|
||||
>
|
||||
<CustomIcon name={icon} size={24} color={color || themes[theme].auxiliaryTintColor} />
|
||||
</View>
|
||||
</BorderlessButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default BaseButton;
|
|
@ -1,13 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import BaseButton from './BaseButton';
|
||||
|
||||
interface ICancelEditingButton {
|
||||
onPress(): void;
|
||||
}
|
||||
|
||||
const CancelEditingButton = ({ onPress }: ICancelEditingButton) => (
|
||||
<BaseButton onPress={onPress} testID='messagebox-cancel-editing' accessibilityLabel='Cancel_editing' icon='close' />
|
||||
);
|
||||
|
||||
export default CancelEditingButton;
|
|
@ -1,24 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { themes } from '../../../lib/constants';
|
||||
import { useTheme } from '../../../theme';
|
||||
import BaseButton from './BaseButton';
|
||||
|
||||
interface ISendButton {
|
||||
onPress(): void;
|
||||
}
|
||||
|
||||
const SendButton = ({ onPress }: ISendButton) => {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<BaseButton
|
||||
onPress={onPress}
|
||||
testID='messagebox-send-message'
|
||||
accessibilityLabel='Send_message'
|
||||
icon='send-filled'
|
||||
color={themes[theme].tintColor}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SendButton;
|
|
@ -1,20 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import BaseButton from './BaseButton';
|
||||
|
||||
interface IToggleEmojiButton {
|
||||
show: boolean;
|
||||
open(): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
const ToggleEmojiButton = ({ show, open, close }: IToggleEmojiButton) => {
|
||||
if (show) {
|
||||
return (
|
||||
<BaseButton onPress={close} testID='messagebox-close-emoji' accessibilityLabel='Close_emoji_selector' icon='keyboard' />
|
||||
);
|
||||
}
|
||||
return <BaseButton onPress={open} testID='messagebox-open-emoji' accessibilityLabel='Open_emoji_selector' icon='emoji' />;
|
||||
};
|
||||
|
||||
export default ToggleEmojiButton;
|
|
@ -1,6 +0,0 @@
|
|||
import CancelEditingButton from './CancelEditingButton';
|
||||
import ToggleEmojiButton from './ToggleEmojiButton';
|
||||
import SendButton from './SendButton';
|
||||
import ActionsButton from './ActionsButton';
|
||||
|
||||
export { CancelEditingButton, ToggleEmojiButton, SendButton, ActionsButton };
|
|
@ -1,9 +0,0 @@
|
|||
export const MENTIONS_TRACKING_TYPE_USERS = '@';
|
||||
export const MENTIONS_TRACKING_TYPE_EMOJIS = ':';
|
||||
export const MENTIONS_TRACKING_TYPE_COMMANDS = '/';
|
||||
export const MENTIONS_TRACKING_TYPE_ROOMS = '#';
|
||||
export const MENTIONS_TRACKING_TYPE_CANNED = '!';
|
||||
export const MENTIONS_COUNT_TO_DISPLAY = 4;
|
||||
export const MAX_EMOJIS_TO_DISPLAY = 20;
|
||||
|
||||
export const TIMEOUT_CLOSE_EMOJI = 300;
|
|
@ -1,4 +0,0 @@
|
|||
// Match query string from the message to replace it with the suggestion
|
||||
const getMentionRegexp = (): any => /[^@:#/!]*$/;
|
||||
|
||||
export default getMentionRegexp;
|
File diff suppressed because it is too large
Load Diff
|
@ -1,161 +0,0 @@
|
|||
import { StyleSheet } from 'react-native';
|
||||
|
||||
import { isIOS } from '../../lib/methods/helpers';
|
||||
import sharedStyles from '../../views/Styles';
|
||||
|
||||
const MENTION_HEIGHT = 50;
|
||||
const SCROLLVIEW_MENTION_HEIGHT = 4 * MENTION_HEIGHT;
|
||||
|
||||
export default StyleSheet.create({
|
||||
composer: {
|
||||
flexDirection: 'column',
|
||||
borderTopWidth: 1
|
||||
},
|
||||
textArea: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexGrow: 0
|
||||
},
|
||||
textBoxInput: {
|
||||
textAlignVertical: 'center',
|
||||
maxHeight: 240,
|
||||
flexGrow: 1,
|
||||
width: 1,
|
||||
// paddingVertical: 12, needs to be paddingTop/paddingBottom because of iOS/Android's TextInput differences on rendering
|
||||
paddingTop: 12,
|
||||
paddingBottom: 12,
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
fontSize: 16,
|
||||
letterSpacing: 0,
|
||||
...sharedStyles.textRegular
|
||||
},
|
||||
actionButton: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 60,
|
||||
height: 48
|
||||
},
|
||||
wrapMentionHeaderList: {
|
||||
height: MENTION_HEIGHT,
|
||||
justifyContent: 'center'
|
||||
},
|
||||
wrapMentionHeaderListRow: {
|
||||
height: MENTION_HEIGHT,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12
|
||||
},
|
||||
loadingPaddingHeader: {
|
||||
paddingRight: 12
|
||||
},
|
||||
mentionHeaderList: {
|
||||
fontSize: 14,
|
||||
...sharedStyles.textMedium
|
||||
},
|
||||
mentionHeaderListNoMatchFound: {
|
||||
fontSize: 14,
|
||||
...sharedStyles.textRegular
|
||||
},
|
||||
mentionNoMatchHeader: {
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
mentionList: {
|
||||
maxHeight: MENTION_HEIGHT * 4
|
||||
},
|
||||
mentionItem: {
|
||||
height: MENTION_HEIGHT,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 5
|
||||
},
|
||||
mentionItemCustomEmoji: {
|
||||
margin: 8,
|
||||
width: 30,
|
||||
height: 30
|
||||
},
|
||||
mentionItemEmoji: {
|
||||
width: 46,
|
||||
height: 36,
|
||||
fontSize: isIOS ? 30 : 25,
|
||||
...sharedStyles.textAlignCenter
|
||||
},
|
||||
fixedMentionAvatar: {
|
||||
width: 46,
|
||||
fontSize: 14,
|
||||
...sharedStyles.textBold,
|
||||
...sharedStyles.textAlignCenter
|
||||
},
|
||||
mentionText: {
|
||||
fontSize: 14,
|
||||
...sharedStyles.textRegular
|
||||
},
|
||||
cannedMentionText: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
paddingRight: 12,
|
||||
...sharedStyles.textRegular
|
||||
},
|
||||
cannedItem: {
|
||||
fontSize: 14,
|
||||
...sharedStyles.textBold,
|
||||
paddingLeft: 12,
|
||||
paddingRight: 8
|
||||
},
|
||||
emojiKeyboardContainer: {
|
||||
flex: 1,
|
||||
borderTopWidth: 1
|
||||
},
|
||||
slash: {
|
||||
height: 30,
|
||||
width: 30,
|
||||
padding: 5,
|
||||
paddingHorizontal: 12,
|
||||
marginHorizontal: 10,
|
||||
borderRadius: 4
|
||||
},
|
||||
commandPreviewImage: {
|
||||
justifyContent: 'center',
|
||||
margin: 3,
|
||||
width: 120,
|
||||
height: 80,
|
||||
borderRadius: 4
|
||||
},
|
||||
commandPreview: {
|
||||
height: 100,
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center'
|
||||
},
|
||||
avatar: {
|
||||
margin: 8
|
||||
},
|
||||
scrollViewMention: {
|
||||
maxHeight: SCROLLVIEW_MENTION_HEIGHT
|
||||
},
|
||||
recordingContent: {
|
||||
flexDirection: 'row',
|
||||
flex: 1,
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
recordingDurationText: {
|
||||
width: 60,
|
||||
fontSize: 16,
|
||||
...sharedStyles.textRegular
|
||||
},
|
||||
buttonsWhitespace: {
|
||||
width: 15
|
||||
},
|
||||
sendToChannelButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 18
|
||||
},
|
||||
sendToChannelText: {
|
||||
fontSize: 12,
|
||||
marginLeft: 4,
|
||||
...sharedStyles.textRegular
|
||||
}
|
||||
});
|
|
@ -0,0 +1,445 @@
|
|||
import React from 'react';
|
||||
import { act, fireEvent, render, screen, userEvent } from '@testing-library/react-native';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { MessageComposerContainer } from './MessageComposerContainer';
|
||||
import { setPermissions } from '../../actions/permissions';
|
||||
import { addSettings } from '../../actions/settings';
|
||||
import { selectServerRequest } from '../../actions/server';
|
||||
import { setUser } from '../../actions/login';
|
||||
import { mockedStore } from '../../reducers/mockedStore';
|
||||
import { IPermissionsState } from '../../reducers/permissions';
|
||||
import { IMessage } from '../../definitions';
|
||||
import { colors } from '../../lib/constants';
|
||||
import { IRoomContext, RoomContext } from '../../views/RoomView/context';
|
||||
|
||||
const initialStoreState = () => {
|
||||
const baseUrl = 'https://open.rocket.chat';
|
||||
mockedStore.dispatch(selectServerRequest(baseUrl, '6.4.0'));
|
||||
mockedStore.dispatch(setUser({ id: 'abc', username: 'rocket.cat', name: 'Rocket Cat', roles: ['user'] }));
|
||||
|
||||
const permissions: IPermissionsState = { 'mobile-upload-file': ['user'] };
|
||||
mockedStore.dispatch(setPermissions(permissions));
|
||||
mockedStore.dispatch(addSettings({ Message_AudioRecorderEnabled: true }));
|
||||
};
|
||||
initialStoreState();
|
||||
|
||||
const initialContext = {
|
||||
rid: 'rid',
|
||||
tmid: undefined,
|
||||
sharing: false,
|
||||
action: null,
|
||||
selectedMessages: [],
|
||||
editCancel: jest.fn(),
|
||||
editRequest: jest.fn(),
|
||||
onSendMessage: jest.fn(),
|
||||
onRemoveQuoteMessage: jest.fn()
|
||||
};
|
||||
|
||||
const Render = ({ context }: { context?: Partial<IRoomContext> }) => (
|
||||
<Provider store={mockedStore}>
|
||||
<RoomContext.Provider value={{ ...initialContext, ...context }}>
|
||||
<MessageComposerContainer />
|
||||
</RoomContext.Provider>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
describe('MessageComposer', () => {
|
||||
test('renders correctly', () => {
|
||||
render(<Render />);
|
||||
expect(screen.getByTestId('message-composer-input')).toBeOnTheScreen();
|
||||
expect(screen.getByTestId('message-composer-actions')).toBeOnTheScreen();
|
||||
expect(screen.getByTestId('message-composer-send-audio')).toBeOnTheScreen();
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('renders correctly with audio recorder disabled', () => {
|
||||
mockedStore.dispatch(addSettings({ Message_AudioRecorderEnabled: false }));
|
||||
render(<Render />);
|
||||
expect(screen.queryByTestId('message-composer-send-audio')).not.toBeOnTheScreen();
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('renders correctly without audio upload permissions', () => {
|
||||
mockedStore.dispatch(setPermissions({}));
|
||||
render(<Render />);
|
||||
expect(screen.queryByTestId('message-composer-send-audio')).not.toBeOnTheScreen();
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('renders correctly with audio recorder disabled and without audio upload permissions', () => {
|
||||
mockedStore.dispatch(addSettings({ Message_AudioRecorderEnabled: false }));
|
||||
mockedStore.dispatch(setPermissions({}));
|
||||
render(<Render />);
|
||||
expect(screen.queryByTestId('message-composer-send-audio')).not.toBeOnTheScreen();
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('renders toolbar when focused', async () => {
|
||||
initialStoreState();
|
||||
render(<Render />);
|
||||
expect(screen.getByTestId('message-composer-actions')).toBeOnTheScreen();
|
||||
expect(screen.getByTestId('message-composer-send-audio')).toBeOnTheScreen();
|
||||
expect(screen.queryByTestId('message-composer-open-emoji')).not.toBeOnTheScreen();
|
||||
expect(screen.queryByTestId('message-composer-open-markdown')).not.toBeOnTheScreen();
|
||||
expect(screen.queryByTestId('message-composer-mention')).not.toBeOnTheScreen();
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
});
|
||||
expect(screen.getByTestId('message-composer-actions')).toBeOnTheScreen();
|
||||
expect(screen.getByTestId('message-composer-send-audio')).toBeOnTheScreen();
|
||||
expect(screen.getByTestId('message-composer-open-emoji')).toBeOnTheScreen();
|
||||
expect(screen.getByTestId('message-composer-open-markdown')).toBeOnTheScreen();
|
||||
expect(screen.getByTestId('message-composer-mention')).toBeOnTheScreen();
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('send message', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSendMessage = jest.fn();
|
||||
render(<Render context={{ onSendMessage }} />);
|
||||
expect(screen.getByTestId('message-composer-send-audio')).toBeOnTheScreen();
|
||||
|
||||
await user.type(screen.getByTestId('message-composer-input'), 'test');
|
||||
expect(screen.queryByTestId('message-composer-send-audio')).not.toBeOnTheScreen();
|
||||
expect(screen.getByTestId('message-composer-send')).toBeOnTheScreen();
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent.press(screen.getByTestId('message-composer-send'));
|
||||
});
|
||||
|
||||
expect(onSendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(onSendMessage).toHaveBeenCalledWith('test', undefined);
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('Toolbar', () => {
|
||||
test('tap actions', async () => {
|
||||
render(<Render />);
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
await fireEvent.press(screen.getByTestId('message-composer-actions'));
|
||||
});
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('tap emoji', async () => {
|
||||
render(<Render />);
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
await fireEvent.press(screen.getByTestId('message-composer-open-emoji'));
|
||||
});
|
||||
expect(screen.getByTestId('message-composer-close-emoji')).toBeOnTheScreen();
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('Markdown', () => {
|
||||
test('tap markdown', async () => {
|
||||
render(<Render />);
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
await fireEvent.press(screen.getByTestId('message-composer-open-markdown'));
|
||||
});
|
||||
expect(screen.getByTestId('message-composer-close-markdown')).toBeOnTheScreen();
|
||||
expect(screen.getByTestId('message-composer-bold')).toBeOnTheScreen();
|
||||
expect(screen.getByTestId('message-composer-italic')).toBeOnTheScreen();
|
||||
expect(screen.getByTestId('message-composer-strike')).toBeOnTheScreen();
|
||||
expect(screen.getByTestId('message-composer-code')).toBeOnTheScreen();
|
||||
expect(screen.getByTestId('message-composer-code-block')).toBeOnTheScreen();
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('tap bold', async () => {
|
||||
const onSendMessage = jest.fn();
|
||||
render(<Render context={{ onSendMessage }} />);
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
await fireEvent.press(screen.getByTestId('message-composer-open-markdown'));
|
||||
await fireEvent.press(screen.getByTestId('message-composer-bold'));
|
||||
await fireEvent.press(screen.getByTestId('message-composer-send'));
|
||||
});
|
||||
expect(onSendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(onSendMessage).toHaveBeenCalledWith('**', undefined);
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('type test and tap bold', async () => {
|
||||
const onSendMessage = jest.fn();
|
||||
render(<Render context={{ onSendMessage }} />);
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent.changeText(screen.getByTestId('message-composer-input'), 'test');
|
||||
await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
await fireEvent(screen.getByTestId('message-composer-input'), 'selectionChange', {
|
||||
nativeEvent: { selection: { start: 0, end: 4 } }
|
||||
});
|
||||
await fireEvent.press(screen.getByTestId('message-composer-open-markdown'));
|
||||
await fireEvent.press(screen.getByTestId('message-composer-bold'));
|
||||
await fireEvent.press(screen.getByTestId('message-composer-send'));
|
||||
});
|
||||
expect(onSendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(onSendMessage).toHaveBeenCalledWith('*test*', undefined);
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('tap italic', async () => {
|
||||
const onSendMessage = jest.fn();
|
||||
render(<Render context={{ onSendMessage }} />);
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
await fireEvent.press(screen.getByTestId('message-composer-open-markdown'));
|
||||
await fireEvent.press(screen.getByTestId('message-composer-italic'));
|
||||
await fireEvent.press(screen.getByTestId('message-composer-send'));
|
||||
});
|
||||
expect(onSendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(onSendMessage).toHaveBeenCalledWith('__', undefined);
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('type test and tap italic', async () => {
|
||||
const onSendMessage = jest.fn();
|
||||
render(<Render context={{ onSendMessage }} />);
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent.changeText(screen.getByTestId('message-composer-input'), 'test');
|
||||
await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
await fireEvent(screen.getByTestId('message-composer-input'), 'selectionChange', {
|
||||
nativeEvent: { selection: { start: 0, end: 4 } }
|
||||
});
|
||||
await fireEvent.press(screen.getByTestId('message-composer-open-markdown'));
|
||||
await fireEvent.press(screen.getByTestId('message-composer-italic'));
|
||||
await fireEvent.press(screen.getByTestId('message-composer-send'));
|
||||
});
|
||||
expect(onSendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(onSendMessage).toHaveBeenCalledWith('_test_', undefined);
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('tap strike', async () => {
|
||||
const onSendMessage = jest.fn();
|
||||
render(<Render context={{ onSendMessage }} />);
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
await fireEvent.press(screen.getByTestId('message-composer-open-markdown'));
|
||||
await fireEvent.press(screen.getByTestId('message-composer-strike'));
|
||||
await fireEvent.press(screen.getByTestId('message-composer-send'));
|
||||
});
|
||||
expect(onSendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(onSendMessage).toHaveBeenCalledWith('~~', undefined);
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('type test and tap strike', async () => {
|
||||
const onSendMessage = jest.fn();
|
||||
render(<Render context={{ onSendMessage }} />);
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent.changeText(screen.getByTestId('message-composer-input'), 'test');
|
||||
await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
await fireEvent(screen.getByTestId('message-composer-input'), 'selectionChange', {
|
||||
nativeEvent: { selection: { start: 0, end: 4 } }
|
||||
});
|
||||
await fireEvent.press(screen.getByTestId('message-composer-open-markdown'));
|
||||
await fireEvent.press(screen.getByTestId('message-composer-strike'));
|
||||
await fireEvent.press(screen.getByTestId('message-composer-send'));
|
||||
});
|
||||
expect(onSendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(onSendMessage).toHaveBeenCalledWith('~test~', undefined);
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('tap code', async () => {
|
||||
const onSendMessage = jest.fn();
|
||||
render(<Render context={{ onSendMessage }} />);
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
await fireEvent.press(screen.getByTestId('message-composer-open-markdown'));
|
||||
await fireEvent.press(screen.getByTestId('message-composer-code'));
|
||||
await fireEvent.press(screen.getByTestId('message-composer-send'));
|
||||
});
|
||||
expect(onSendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(onSendMessage).toHaveBeenCalledWith('``', undefined);
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('type test and tap code', async () => {
|
||||
const onSendMessage = jest.fn();
|
||||
render(<Render context={{ onSendMessage }} />);
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent.changeText(screen.getByTestId('message-composer-input'), 'test');
|
||||
await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
await fireEvent(screen.getByTestId('message-composer-input'), 'selectionChange', {
|
||||
nativeEvent: { selection: { start: 0, end: 4 } }
|
||||
});
|
||||
await fireEvent.press(screen.getByTestId('message-composer-open-markdown'));
|
||||
await fireEvent.press(screen.getByTestId('message-composer-code'));
|
||||
await fireEvent.press(screen.getByTestId('message-composer-send'));
|
||||
});
|
||||
expect(onSendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(onSendMessage).toHaveBeenCalledWith('`test`', undefined);
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('tap code-block', async () => {
|
||||
const onSendMessage = jest.fn();
|
||||
render(<Render context={{ onSendMessage }} />);
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
await fireEvent.press(screen.getByTestId('message-composer-open-markdown'));
|
||||
await fireEvent.press(screen.getByTestId('message-composer-code-block'));
|
||||
await fireEvent.press(screen.getByTestId('message-composer-send'));
|
||||
});
|
||||
expect(onSendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(onSendMessage).toHaveBeenCalledWith('``````', undefined);
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('type test and tap code-block', async () => {
|
||||
const onSendMessage = jest.fn();
|
||||
render(<Render context={{ onSendMessage }} />);
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent.changeText(screen.getByTestId('message-composer-input'), 'test');
|
||||
await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
await fireEvent(screen.getByTestId('message-composer-input'), 'selectionChange', {
|
||||
nativeEvent: { selection: { start: 0, end: 4 } }
|
||||
});
|
||||
await fireEvent.press(screen.getByTestId('message-composer-open-markdown'));
|
||||
await fireEvent.press(screen.getByTestId('message-composer-code-block'));
|
||||
await fireEvent.press(screen.getByTestId('message-composer-send'));
|
||||
});
|
||||
expect(onSendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(onSendMessage).toHaveBeenCalledWith('```test```', undefined);
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
test('tap mention', async () => {
|
||||
const onSendMessage = jest.fn();
|
||||
render(<Render context={{ onSendMessage }} />);
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
await fireEvent.press(screen.getByTestId('message-composer-mention'));
|
||||
await fireEvent.press(screen.getByTestId('message-composer-send'));
|
||||
});
|
||||
expect(onSendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(onSendMessage).toHaveBeenCalledWith('@', undefined);
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit message', () => {
|
||||
const onSendMessage = jest.fn();
|
||||
const editCancel = jest.fn();
|
||||
const editRequest = jest.fn();
|
||||
const id = 'messageId';
|
||||
beforeEach(() => {
|
||||
render(<Render context={{ rid: 'rid', selectedMessages: [id], action: 'edit', onSendMessage, editCancel, editRequest }} />);
|
||||
});
|
||||
test('init', async () => {
|
||||
await screen.findByTestId('message-composer');
|
||||
expect(screen.getByTestId('message-composer')).toHaveStyle({ backgroundColor: colors.light.statusBackgroundWarning2 });
|
||||
expect(screen.getByTestId('message-composer-actions')).toBeOnTheScreen();
|
||||
expect(screen.queryByTestId('message-composer-send-audio')).not.toBeOnTheScreen();
|
||||
expect(screen.getByTestId('message-composer-cancel-edit')).toBeOnTheScreen();
|
||||
});
|
||||
test('cancel', async () => {
|
||||
await screen.findByTestId('message-composer');
|
||||
expect(screen.getByTestId('message-composer')).toHaveStyle({ backgroundColor: colors.light.statusBackgroundWarning2 });
|
||||
await act(async () => {
|
||||
await fireEvent.press(screen.getByTestId('message-composer-cancel-edit'));
|
||||
});
|
||||
expect(editCancel).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId('message-composer-actions')).toBeOnTheScreen();
|
||||
expect(screen.queryByTestId('message-composer-send-audio')).not.toBeOnTheScreen();
|
||||
expect(screen.getByTestId('message-composer-cancel-edit')).toBeOnTheScreen();
|
||||
});
|
||||
test('send', async () => {
|
||||
await screen.findByTestId('message-composer');
|
||||
expect(screen.getByTestId('message-composer')).toHaveStyle({ backgroundColor: colors.light.statusBackgroundWarning2 });
|
||||
await act(async () => {
|
||||
await fireEvent.press(screen.getByTestId('message-composer-send'));
|
||||
});
|
||||
expect(editRequest).toHaveBeenCalledTimes(1);
|
||||
expect(editRequest).toHaveBeenCalledWith({ id, msg: `Message ${id}`, rid: 'rid' });
|
||||
});
|
||||
});
|
||||
|
||||
const messageIds = ['abc', 'def'];
|
||||
jest.mock('./hooks/useMessage', () => ({
|
||||
useMessage: (messageId: string) => {
|
||||
if (!messageIds.includes(messageId)) {
|
||||
return null;
|
||||
}
|
||||
const message = {
|
||||
id: messageId,
|
||||
msg: 'quote this',
|
||||
u: {
|
||||
username: 'rocket.cat'
|
||||
}
|
||||
} as IMessage;
|
||||
return message;
|
||||
}
|
||||
}));
|
||||
|
||||
jest.mock('../../lib/store/auxStore', () => ({
|
||||
store: {
|
||||
getState: () => mockedStore.getState()
|
||||
}
|
||||
}));
|
||||
|
||||
describe('Quote', () => {
|
||||
test('Add quote `abc`', async () => {
|
||||
render(<Render context={{ action: 'quote', selectedMessages: ['abc'] }} />);
|
||||
await act(async () => {
|
||||
await screen.findByTestId('composer-quote-abc');
|
||||
expect(screen.queryByTestId('composer-quote-abc')).toBeOnTheScreen();
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
test('Add quote `def`', async () => {
|
||||
render(<Render context={{ action: 'quote', selectedMessages: ['abc', 'def'] }} />);
|
||||
await act(async () => {
|
||||
await screen.findByTestId('composer-quote-abc');
|
||||
expect(screen.queryByTestId('composer-quote-abc')).toBeOnTheScreen();
|
||||
expect(screen.queryByTestId('composer-quote-def')).toBeOnTheScreen();
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
test('Remove a quote', async () => {
|
||||
const onRemoveQuoteMessage = jest.fn();
|
||||
render(<Render context={{ action: 'quote', selectedMessages: ['abc', 'def'], onRemoveQuoteMessage }} />);
|
||||
await act(async () => {
|
||||
await screen.findByTestId('composer-quote-def');
|
||||
await fireEvent.press(screen.getByTestId('composer-quote-remove-def'));
|
||||
});
|
||||
expect(onRemoveQuoteMessage).toHaveBeenCalledTimes(1);
|
||||
expect(onRemoveQuoteMessage).toHaveBeenCalledWith('def');
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Audio', () => {
|
||||
test('tap record', async () => {
|
||||
render(<Render />);
|
||||
expect(screen.getByTestId('message-composer-send-audio')).toBeOnTheScreen();
|
||||
await act(async () => {
|
||||
await fireEvent.press(screen.getByTestId('message-composer-send-audio'));
|
||||
});
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,251 @@
|
|||
import React, { ReactElement, useRef, useImperativeHandle, useCallback } from 'react';
|
||||
import { View, StyleSheet, NativeModules } from 'react-native';
|
||||
import { KeyboardAccessoryView } from 'react-native-ui-lib/keyboard';
|
||||
import { useBackHandler } from '@react-native-community/hooks';
|
||||
import { Q } from '@nozbe/watermelondb';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
|
||||
import { useRoomContext } from '../../views/RoomView/context';
|
||||
import { Autocomplete, Toolbar, EmojiSearchbar, ComposerInput, Left, Right, Quotes, SendThreadToChannel } from './components';
|
||||
import { MIN_HEIGHT, TIMEOUT_CLOSE_EMOJI_KEYBOARD } from './constants';
|
||||
import {
|
||||
MessageInnerContext,
|
||||
useAlsoSendThreadToChannel,
|
||||
useMessageComposerApi,
|
||||
useRecordingAudio,
|
||||
useShowEmojiKeyboard,
|
||||
useShowEmojiSearchbar
|
||||
} from './context';
|
||||
import { IComposerInput, ITrackingView } from './interfaces';
|
||||
import { isIOS } from '../../lib/methods/helpers';
|
||||
import shortnameToUnicode from '../../lib/methods/helpers/shortnameToUnicode';
|
||||
import { useTheme } from '../../theme';
|
||||
import { EventTypes } from '../EmojiPicker/interfaces';
|
||||
import { IEmoji } from '../../definitions';
|
||||
import database from '../../lib/database';
|
||||
import { sanitizeLikeString } from '../../lib/database/utils';
|
||||
import { generateTriggerId } from '../../lib/methods';
|
||||
import { Services } from '../../lib/services';
|
||||
import log from '../../lib/methods/helpers/log';
|
||||
import { prepareQuoteMessage } from './helpers';
|
||||
import { RecordAudio } from './components/RecordAudio';
|
||||
import { useKeyboardListener } from './hooks';
|
||||
import { emitter } from '../../lib/methods/helpers/emitter';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
borderTopWidth: 1,
|
||||
paddingHorizontal: 16,
|
||||
minHeight: MIN_HEIGHT
|
||||
},
|
||||
input: {
|
||||
flexDirection: 'row'
|
||||
}
|
||||
});
|
||||
|
||||
require('./components/EmojiKeyboard');
|
||||
|
||||
export const MessageComposer = ({
|
||||
forwardedRef,
|
||||
children
|
||||
}: {
|
||||
forwardedRef: any;
|
||||
children?: ReactElement;
|
||||
}): ReactElement | null => {
|
||||
const composerInputRef = useRef(null);
|
||||
const composerInputComponentRef = useRef<IComposerInput>({
|
||||
getTextAndClear: () => '',
|
||||
getText: () => '',
|
||||
getSelection: () => ({ start: 0, end: 0 }),
|
||||
setInput: () => {},
|
||||
onAutocompleteItemSelected: () => {}
|
||||
});
|
||||
const trackingViewRef = useRef<ITrackingView>({ resetTracking: () => {}, getNativeProps: () => ({ trackingViewHeight: 0 }) });
|
||||
const { colors, theme } = useTheme();
|
||||
const { rid, tmid, action, selectedMessages, sharing, editRequest, onSendMessage } = useRoomContext();
|
||||
const showEmojiKeyboard = useShowEmojiKeyboard();
|
||||
const showEmojiSearchbar = useShowEmojiSearchbar();
|
||||
const alsoSendThreadToChannel = useAlsoSendThreadToChannel();
|
||||
const {
|
||||
openSearchEmojiKeyboard,
|
||||
closeEmojiKeyboard,
|
||||
closeSearchEmojiKeyboard,
|
||||
setTrackingViewHeight,
|
||||
setAlsoSendThreadToChannel
|
||||
} = useMessageComposerApi();
|
||||
const recordingAudio = useRecordingAudio();
|
||||
useKeyboardListener(trackingViewRef);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
trackingViewRef.current?.resetTracking();
|
||||
}, [recordingAudio])
|
||||
);
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
closeEmojiKeyboardAndAction,
|
||||
getText: composerInputComponentRef.current?.getText,
|
||||
setInput: composerInputComponentRef.current?.setInput
|
||||
}));
|
||||
|
||||
useBackHandler(() => {
|
||||
if (showEmojiSearchbar) {
|
||||
closeSearchEmojiKeyboard();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const closeEmojiKeyboardAndAction = (action?: Function, params?: any) => {
|
||||
if (showEmojiKeyboard) {
|
||||
closeEmojiKeyboard();
|
||||
}
|
||||
setTimeout(() => action && action(params), showEmojiKeyboard && isIOS ? TIMEOUT_CLOSE_EMOJI_KEYBOARD : undefined);
|
||||
};
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!rid) return;
|
||||
|
||||
if (alsoSendThreadToChannel) {
|
||||
setAlsoSendThreadToChannel(false);
|
||||
}
|
||||
|
||||
if (sharing) {
|
||||
onSendMessage?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const textFromInput = composerInputComponentRef.current.getTextAndClear();
|
||||
|
||||
if (action === 'edit') {
|
||||
return editRequest?.({ id: selectedMessages[0], msg: textFromInput, rid });
|
||||
}
|
||||
|
||||
if (action === 'quote') {
|
||||
const quoteMessage = await prepareQuoteMessage(textFromInput, selectedMessages);
|
||||
onSendMessage?.(quoteMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// Slash command
|
||||
if (textFromInput[0] === '/') {
|
||||
const db = database.active;
|
||||
const commandsCollection = db.get('slash_commands');
|
||||
const command = textFromInput.replace(/ .*/, '').slice(1);
|
||||
const likeString = sanitizeLikeString(command);
|
||||
const slashCommand = await commandsCollection.query(Q.where('id', Q.like(`${likeString}%`))).fetch();
|
||||
if (slashCommand.length > 0) {
|
||||
try {
|
||||
const messageWithoutCommand = textFromInput.replace(/([^\s]+)/, '').trim();
|
||||
const [{ appId }] = slashCommand;
|
||||
const triggerId = generateTriggerId(appId);
|
||||
await Services.runSlashCommand(command, rid, messageWithoutCommand, triggerId, tmid);
|
||||
} catch (e) {
|
||||
log(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Text message
|
||||
onSendMessage?.(textFromInput, alsoSendThreadToChannel);
|
||||
};
|
||||
|
||||
const onKeyboardItemSelected = (_keyboardId: string, params: { eventType: EventTypes; emoji: IEmoji }) => {
|
||||
const { eventType, emoji } = params;
|
||||
const text = composerInputComponentRef.current.getText();
|
||||
let newText = '';
|
||||
// if input has an active cursor
|
||||
const { start, end } = composerInputComponentRef.current.getSelection();
|
||||
const cursor = Math.max(start, end);
|
||||
let newCursor;
|
||||
|
||||
switch (eventType) {
|
||||
case EventTypes.BACKSPACE_PRESSED:
|
||||
const emojiRegex = /\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff]/;
|
||||
let charsToRemove = 1;
|
||||
const lastEmoji = text.substr(cursor > 0 ? cursor - 2 : text.length - 2, cursor > 0 ? cursor : text.length);
|
||||
// Check if last character is an emoji
|
||||
if (emojiRegex.test(lastEmoji)) charsToRemove = 2;
|
||||
newText =
|
||||
text.substr(0, (cursor > 0 ? cursor : text.length) - charsToRemove) + text.substr(cursor > 0 ? cursor : text.length);
|
||||
newCursor = cursor - charsToRemove;
|
||||
composerInputComponentRef.current.setInput(newText, { start: newCursor, end: newCursor });
|
||||
break;
|
||||
case EventTypes.EMOJI_PRESSED:
|
||||
let emojiText = '';
|
||||
if (typeof emoji === 'string') {
|
||||
emojiText = shortnameToUnicode(`:${emoji}:`);
|
||||
} else {
|
||||
emojiText = `:${emoji.name}:`;
|
||||
}
|
||||
newText = `${text.substr(0, cursor)}${emojiText}${text.substr(cursor)}`;
|
||||
newCursor = cursor + emojiText.length;
|
||||
composerInputComponentRef.current.setInput(newText, { start: newCursor, end: newCursor });
|
||||
break;
|
||||
case EventTypes.SEARCH_PRESSED:
|
||||
openSearchEmojiKeyboard();
|
||||
break;
|
||||
default:
|
||||
// Do nothing
|
||||
}
|
||||
};
|
||||
|
||||
const onEmojiSelected = (emoji: IEmoji) => {
|
||||
onKeyboardItemSelected('EmojiKeyboard', { eventType: EventTypes.EMOJI_PRESSED, emoji });
|
||||
};
|
||||
|
||||
const onKeyboardResigned = () => {
|
||||
if (!showEmojiSearchbar) {
|
||||
closeEmojiKeyboard();
|
||||
}
|
||||
};
|
||||
|
||||
const onHeightChanged = (height: number) => {
|
||||
setTrackingViewHeight(height);
|
||||
emitter.emit(`setComposerHeight${tmid ? 'Thread' : ''}`, height);
|
||||
};
|
||||
|
||||
const backgroundColor = action === 'edit' ? colors.statusBackgroundWarning2 : colors.surfaceLight;
|
||||
|
||||
const renderContent = () => {
|
||||
if (recordingAudio) {
|
||||
return <RecordAudio />;
|
||||
}
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor, borderTopColor: colors.strokeLight }]} testID='message-composer'>
|
||||
<View style={styles.input}>
|
||||
<Left />
|
||||
<ComposerInput ref={composerInputComponentRef} inputRef={composerInputRef} />
|
||||
<Right />
|
||||
</View>
|
||||
<Quotes />
|
||||
<Toolbar />
|
||||
<EmojiSearchbar />
|
||||
<SendThreadToChannel />
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<MessageInnerContext.Provider value={{ sendMessage: handleSendMessage, onEmojiSelected, closeEmojiKeyboardAndAction }}>
|
||||
<KeyboardAccessoryView
|
||||
ref={(ref: ITrackingView) => (trackingViewRef.current = ref)}
|
||||
renderContent={renderContent}
|
||||
kbInputRef={composerInputRef}
|
||||
kbComponent={showEmojiKeyboard ? 'EmojiKeyboard' : null}
|
||||
kbInitialProps={{ theme }}
|
||||
onKeyboardResigned={onKeyboardResigned}
|
||||
onItemSelected={onKeyboardItemSelected}
|
||||
trackInteractive
|
||||
requiresSameParentToManageScrollView
|
||||
addBottomView
|
||||
bottomViewColor={backgroundColor}
|
||||
iOSScrollBehavior={NativeModules.KeyboardTrackingViewTempManager?.KeyboardTrackingScrollBehaviorFixedOffset}
|
||||
onHeightChanged={onHeightChanged}
|
||||
/>
|
||||
<Autocomplete onPress={item => composerInputComponentRef.current.onAutocompleteItemSelected(item)} />
|
||||
</MessageInnerContext.Provider>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,13 @@
|
|||
import React, { ReactElement, forwardRef } from 'react';
|
||||
|
||||
import { MessageComposerProvider } from './context';
|
||||
import { IMessageComposerContainerProps, IMessageComposerRef } from './interfaces';
|
||||
import { MessageComposer } from './MessageComposer';
|
||||
|
||||
export const MessageComposerContainer = forwardRef<IMessageComposerRef, IMessageComposerContainerProps>(
|
||||
({ children }, ref): ReactElement => (
|
||||
<MessageComposerProvider>
|
||||
<MessageComposer forwardedRef={ref}>{children}</MessageComposer>
|
||||
</MessageComposerProvider>
|
||||
)
|
||||
);
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,70 @@
|
|||
import React, { ReactElement } from 'react';
|
||||
import { View, FlatList } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { useAutocompleteParams, useKeyboardHeight, useTrackingViewHeight } from '../../context';
|
||||
import { AutocompleteItem } from './AutocompleteItem';
|
||||
import { useAutocomplete } from '../../hooks';
|
||||
import { IAutocompleteItemProps } from '../../interfaces';
|
||||
import { AutocompletePreview } from './AutocompletePreview';
|
||||
import { useRoomContext } from '../../../../views/RoomView/context';
|
||||
import { useStyle } from './styles';
|
||||
|
||||
export const Autocomplete = ({ onPress }: { onPress: IAutocompleteItemProps['onPress'] }): ReactElement | null => {
|
||||
const { rid } = useRoomContext();
|
||||
const trackingViewHeight = useTrackingViewHeight();
|
||||
const keyboardHeight = useKeyboardHeight();
|
||||
const { bottom } = useSafeAreaInsets();
|
||||
const { text, type, params } = useAutocompleteParams();
|
||||
const items = useAutocomplete({
|
||||
rid,
|
||||
text,
|
||||
type,
|
||||
commandParams: params
|
||||
});
|
||||
const [styles, colors] = useStyle();
|
||||
const viewBottom = trackingViewHeight + keyboardHeight + (keyboardHeight > 0 ? 0 : bottom) - 4;
|
||||
|
||||
if (items.length === 0 || !type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type !== '/preview') {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.root,
|
||||
{
|
||||
bottom: viewBottom
|
||||
}
|
||||
]}
|
||||
>
|
||||
<FlatList
|
||||
contentContainerStyle={styles.listContentContainer}
|
||||
data={items}
|
||||
renderItem={({ item }) => <AutocompleteItem item={item} onPress={onPress} />}
|
||||
keyboardShouldPersistTaps='always'
|
||||
testID='autocomplete'
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === '/preview') {
|
||||
return (
|
||||
<View style={[styles.root, { backgroundColor: colors.surfaceLight, bottom: viewBottom }]}>
|
||||
<FlatList
|
||||
contentContainerStyle={styles.listContentContainer}
|
||||
style={styles.list}
|
||||
horizontal
|
||||
data={items}
|
||||
renderItem={({ item }) => <AutocompletePreview item={item} onPress={onPress} />}
|
||||
keyboardShouldPersistTaps='always'
|
||||
testID='autocomplete'
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
|
@ -0,0 +1,39 @@
|
|||
import React from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
|
||||
import sharedStyles from '../../../../views/Styles';
|
||||
import { IAutocompleteCannedResponse } from '../../interfaces';
|
||||
import I18n from '../../../../i18n';
|
||||
import { CustomIcon } from '../../../CustomIcon';
|
||||
import { NO_CANNED_RESPONSES } from '../../constants';
|
||||
import { useStyle } from './styles';
|
||||
|
||||
export const AutocompleteCannedResponse = ({ item }: { item: IAutocompleteCannedResponse }) => {
|
||||
const [styles, colors] = useStyle();
|
||||
if (item.id === NO_CANNED_RESPONSES) {
|
||||
return (
|
||||
<View style={styles.canned}>
|
||||
<View style={styles.cannedTitle}>
|
||||
<Text style={styles.cannedTitleText}>
|
||||
{I18n.t('No_match_found')} <Text style={sharedStyles.textSemibold}>{I18n.t('Check_canned_responses')}</Text>
|
||||
</Text>
|
||||
<CustomIcon name='chevron-right' size={24} color={colors.fontHint} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.canned}>
|
||||
<View style={styles.cannedTitle}>
|
||||
<Text style={styles.cannedTitleText} numberOfLines={1}>
|
||||
{item.title}
|
||||
</Text>
|
||||
</View>
|
||||
{item.subtitle ? (
|
||||
<View style={styles.cannedSubtitle}>
|
||||
<Text style={styles.cannedSubtitleText}>{item.subtitle}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
|
||||
import { IAutocompleteEmoji } from '../../interfaces';
|
||||
import { Emoji } from '../../../EmojiPicker/Emoji';
|
||||
import { useStyle } from './styles';
|
||||
|
||||
export const AutocompleteEmoji = ({ item }: { item: IAutocompleteEmoji }) => {
|
||||
const [styles] = useStyle();
|
||||
return (
|
||||
<>
|
||||
<Emoji emoji={item.emoji} />
|
||||
<View style={styles.emoji}>
|
||||
<View style={styles.emojiTitle}>
|
||||
<Text style={styles.emojiText} numberOfLines={1}>
|
||||
{typeof item.emoji === 'string' ? `:${item.emoji}:` : `:${item.emoji.name}:`}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,42 @@
|
|||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { RectButton } from 'react-native-gesture-handler';
|
||||
|
||||
import { IAutocompleteItemProps, TAutocompleteItem } from '../../interfaces';
|
||||
import { AutocompleteUserRoom } from './AutocompleteUserRoom';
|
||||
import { AutocompleteEmoji } from './AutocompleteEmoji';
|
||||
import { AutocompleteSlashCommand } from './AutocompleteSlashCommand';
|
||||
import { AutocompleteCannedResponse } from './AutocompleteCannedResponse';
|
||||
import { AutocompleteItemLoading } from './AutocompleteItemLoading';
|
||||
import { useStyle } from './styles';
|
||||
|
||||
const getTestIDSuffix = (item: TAutocompleteItem) => {
|
||||
if ('title' in item) {
|
||||
return item.title;
|
||||
}
|
||||
if ('emoji' in item) {
|
||||
return item.emoji;
|
||||
}
|
||||
return item.id;
|
||||
};
|
||||
|
||||
export const AutocompleteItem = ({ item, onPress }: IAutocompleteItemProps) => {
|
||||
const [styles, colors] = useStyle();
|
||||
return (
|
||||
<RectButton
|
||||
onPress={() => onPress(item)}
|
||||
underlayColor={colors.buttonBackgroundPrimaryPress}
|
||||
style={{ backgroundColor: colors.surfaceLight }}
|
||||
rippleColor={colors.buttonBackgroundPrimaryPress}
|
||||
testID={`autocomplete-item-${getTestIDSuffix(item)}`}
|
||||
>
|
||||
<View style={styles.item}>
|
||||
{item.type === '@' || item.type === '#' ? <AutocompleteUserRoom item={item} /> : null}
|
||||
{item.type === ':' ? <AutocompleteEmoji item={item} /> : null}
|
||||
{item.type === '/' ? <AutocompleteSlashCommand item={item} /> : null}
|
||||
{item.type === '!' ? <AutocompleteCannedResponse item={item} /> : null}
|
||||
{item.type === 'loading' ? <AutocompleteItemLoading /> : null}
|
||||
</View>
|
||||
</RectButton>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,25 @@
|
|||
import React from 'react';
|
||||
import SkeletonPlaceholder from 'react-native-skeleton-placeholder';
|
||||
import { View } from 'react-native';
|
||||
|
||||
import { useTheme } from '../../../../theme';
|
||||
|
||||
export const AutocompleteItemLoading = ({ preview = false }: { preview?: boolean }): React.ReactElement => {
|
||||
const { colors } = useTheme();
|
||||
if (preview) {
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<SkeletonPlaceholder borderRadius={4} backgroundColor={colors.surfaceNeutral}>
|
||||
<SkeletonPlaceholder.Item height={80} width={80} />
|
||||
</SkeletonPlaceholder>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<SkeletonPlaceholder borderRadius={4} backgroundColor={colors.surfaceNeutral}>
|
||||
<SkeletonPlaceholder.Item height={20} />
|
||||
</SkeletonPlaceholder>
|
||||
</View>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,36 @@
|
|||
import React from 'react';
|
||||
import { RectButton } from 'react-native-gesture-handler';
|
||||
import FastImage from 'react-native-fast-image';
|
||||
|
||||
import { IAutocompleteItemProps } from '../../interfaces';
|
||||
import { CustomIcon } from '../../../CustomIcon';
|
||||
import { AutocompleteItemLoading } from './AutocompleteItemLoading';
|
||||
import { useStyle } from './styles';
|
||||
|
||||
export const AutocompletePreview = ({ item, onPress }: IAutocompleteItemProps) => {
|
||||
const [styles, colors] = useStyle();
|
||||
|
||||
let content;
|
||||
if (item.type === 'loading') {
|
||||
content = <AutocompleteItemLoading preview />;
|
||||
}
|
||||
if (item.type === '/preview') {
|
||||
content =
|
||||
item.preview.type === 'image' ? (
|
||||
<FastImage style={styles.previewImage} source={{ uri: item.preview.value }} resizeMode={FastImage.resizeMode.cover} />
|
||||
) : (
|
||||
<CustomIcon name='attach' size={36} color={colors.fontInfo} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RectButton
|
||||
onPress={() => onPress(item)}
|
||||
underlayColor={colors.buttonBackgroundPrimaryPress}
|
||||
style={styles.previewItem}
|
||||
rippleColor={colors.buttonBackgroundPrimaryPress}
|
||||
>
|
||||
{content}
|
||||
</RectButton>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,24 @@
|
|||
import React from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
|
||||
import { IAutocompleteSlashCommand } from '../../interfaces';
|
||||
import I18n from '../../../../i18n';
|
||||
import { useStyle } from './styles';
|
||||
|
||||
export const AutocompleteSlashCommand = ({ item }: { item: IAutocompleteSlashCommand }) => {
|
||||
const [styles] = useStyle();
|
||||
return (
|
||||
<View style={styles.slashItem}>
|
||||
<View style={styles.slashTitle}>
|
||||
<Text style={styles.slashTitleText} numberOfLines={1}>
|
||||
/{item.title}
|
||||
</Text>
|
||||
</View>
|
||||
{item.subtitle ? (
|
||||
<View style={styles.slashSubtitle}>
|
||||
<Text style={styles.slashSubtitleText}>{I18n.isTranslated(item.subtitle) ? I18n.t(item.subtitle) : item.subtitle}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,38 @@
|
|||
import React from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
|
||||
import { IAutocompleteUserRoom } from '../../interfaces';
|
||||
import Avatar from '../../../Avatar';
|
||||
import RoomTypeIcon from '../../../RoomTypeIcon';
|
||||
import { fetchIsAllOrHere } from '../../helpers';
|
||||
import I18n from '../../../../i18n';
|
||||
import { useStyle } from './styles';
|
||||
|
||||
export const AutocompleteUserRoom = ({ item }: { item: IAutocompleteUserRoom }) => {
|
||||
const [styles] = useStyle();
|
||||
const isAllOrHere = fetchIsAllOrHere(item);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isAllOrHere ? <Avatar rid={item.id} text={item.subtitle} size={36} type={item.t} /> : null}
|
||||
<View style={[styles.userRoom, { paddingLeft: isAllOrHere ? 0 : 12 }]}>
|
||||
<View style={styles.userRoomHeader}>
|
||||
{!isAllOrHere ? (
|
||||
<RoomTypeIcon userId={item.id} type={item.t} status={item.status} size={16} teamMain={item.teamMain} />
|
||||
) : null}
|
||||
<View style={{ paddingLeft: isAllOrHere ? 0 : 2 }}>
|
||||
<Text style={styles.userRoomTitleText} numberOfLines={1}>
|
||||
{isAllOrHere ? `@${item.title}` : item.title}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{item.type === '#' ? null : (
|
||||
<View style={styles.userRoomSubtitle}>
|
||||
<Text style={styles.userRoomSubtitleText}>{item.subtitle}</Text>
|
||||
{item.outside ? <Text style={styles.userRoomOutsideText}>{I18n.t('Not_in_channel')}</Text> : null}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export * from './Autocomplete';
|
|
@ -0,0 +1,60 @@
|
|||
import sharedStyles from '../../../../views/Styles';
|
||||
import { useTheme } from '../../../../theme';
|
||||
|
||||
const MAX_HEIGHT = 216;
|
||||
|
||||
export const useStyle = () => {
|
||||
const { colors } = useTheme();
|
||||
const styles = {
|
||||
root: {
|
||||
maxHeight: MAX_HEIGHT,
|
||||
left: 8,
|
||||
right: 8,
|
||||
backgroundColor: colors.surfaceNeutral,
|
||||
position: 'absolute',
|
||||
borderRadius: 4,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2
|
||||
},
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 2,
|
||||
elevation: 4
|
||||
},
|
||||
listContentContainer: {
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
list: { margin: 8 },
|
||||
item: {
|
||||
minHeight: 48,
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 6,
|
||||
alignItems: 'center'
|
||||
},
|
||||
slashItem: { flex: 1, justifyContent: 'center' },
|
||||
slashTitle: { flex: 1, flexDirection: 'row', alignItems: 'center' },
|
||||
slashTitleText: { ...sharedStyles.textBold, fontSize: 14, color: colors.fontDefault },
|
||||
slashSubtitle: { flex: 1, flexDirection: 'row', alignItems: 'center', paddingTop: 2 },
|
||||
slashSubtitleText: { ...sharedStyles.textRegular, fontSize: 14, color: colors.fontSecondaryInfo, flex: 1 },
|
||||
previewItem: { backgroundColor: colors.surfaceLight, paddingRight: 4 },
|
||||
previewImage: { height: 80, minWidth: 80, borderRadius: 4 },
|
||||
emoji: { flex: 1, justifyContent: 'center', paddingLeft: 12 },
|
||||
emojiTitle: { flex: 1, flexDirection: 'row', alignItems: 'center' },
|
||||
emojiText: { ...sharedStyles.textBold, fontSize: 14, color: colors.fontDefault },
|
||||
canned: { flex: 1, justifyContent: 'center' },
|
||||
cannedTitle: { flex: 1, flexDirection: 'row', alignItems: 'center' },
|
||||
cannedTitleText: { ...sharedStyles.textRegular, flex: 1, fontSize: 14, color: colors.fontHint },
|
||||
cannedSubtitle: { flex: 1, flexDirection: 'row', alignItems: 'center', paddingTop: 2 },
|
||||
cannedSubtitleText: { ...sharedStyles.textRegular, fontSize: 14, color: colors.fontSecondaryInfo, flex: 1 },
|
||||
userRoom: { flex: 1, justifyContent: 'center' },
|
||||
userRoomHeader: { flex: 1, flexDirection: 'row', alignItems: 'center' },
|
||||
userRoomTitleText: { ...sharedStyles.textBold, fontSize: 14, color: colors.fontDefault },
|
||||
userRoomSubtitle: { flex: 1, flexDirection: 'row', alignItems: 'center', paddingTop: 2 },
|
||||
userRoomSubtitleText: { ...sharedStyles.textRegular, fontSize: 14, color: colors.fontSecondaryInfo, flex: 1 },
|
||||
userRoomOutsideText: { ...sharedStyles.textRegular, fontSize: 12, color: colors.fontSecondaryInfo }
|
||||
} as const;
|
||||
return [styles, colors] as const;
|
||||
};
|
|
@ -0,0 +1,81 @@
|
|||
import React, { useContext } from 'react';
|
||||
|
||||
import { getSubscriptionByRoomId } from '../../../../lib/database/services/Subscription';
|
||||
import { BaseButton } from './BaseButton';
|
||||
import { TActionSheetOptionsItem, useActionSheet } from '../../../ActionSheet';
|
||||
import { MessageInnerContext } from '../../context';
|
||||
import I18n from '../../../../i18n';
|
||||
import Navigation from '../../../../lib/navigation/appNavigation';
|
||||
import { useAppSelector, usePermissions } from '../../../../lib/hooks';
|
||||
import { useCanUploadFile, useChooseMedia } from '../../hooks';
|
||||
import { useRoomContext } from '../../../../views/RoomView/context';
|
||||
|
||||
export const ActionsButton = () => {
|
||||
const { rid, tmid, t } = useRoomContext();
|
||||
const { closeEmojiKeyboardAndAction } = useContext(MessageInnerContext);
|
||||
const permissionToUpload = useCanUploadFile(rid);
|
||||
const [permissionToViewCannedResponses] = usePermissions(['view-canned-responses'], rid);
|
||||
const { takePhoto, takeVideo, chooseFromLibrary, chooseFile } = useChooseMedia({
|
||||
rid,
|
||||
tmid,
|
||||
permissionToUpload
|
||||
});
|
||||
const { showActionSheet } = useActionSheet();
|
||||
const isMasterDetail = useAppSelector(state => state.app.isMasterDetail);
|
||||
|
||||
const createDiscussion = async () => {
|
||||
if (!rid) return;
|
||||
const subscription = await getSubscriptionByRoomId(rid);
|
||||
const params = { channel: subscription, showCloseModal: true };
|
||||
if (isMasterDetail) {
|
||||
Navigation.navigate('ModalStackNavigator', { screen: 'CreateDiscussionView', params });
|
||||
} else {
|
||||
Navigation.navigate('NewMessageStackNavigator', { screen: 'CreateDiscussionView', params });
|
||||
}
|
||||
};
|
||||
|
||||
const onPress = () => {
|
||||
const options: TActionSheetOptionsItem[] = [];
|
||||
if (t === 'l' && permissionToViewCannedResponses) {
|
||||
options.push({
|
||||
title: I18n.t('Canned_Responses'),
|
||||
icon: 'canned-response',
|
||||
onPress: () => Navigation.navigate('CannedResponsesListView', { rid })
|
||||
});
|
||||
}
|
||||
if (permissionToUpload) {
|
||||
options.push(
|
||||
{
|
||||
title: I18n.t('Take_a_photo'),
|
||||
icon: 'camera-photo',
|
||||
onPress: () => takePhoto()
|
||||
},
|
||||
{
|
||||
title: I18n.t('Take_a_video'),
|
||||
icon: 'camera',
|
||||
onPress: () => takeVideo()
|
||||
},
|
||||
{
|
||||
title: I18n.t('Choose_from_library'),
|
||||
icon: 'image',
|
||||
onPress: () => chooseFromLibrary()
|
||||
},
|
||||
{
|
||||
title: I18n.t('Choose_file'),
|
||||
icon: 'attach',
|
||||
onPress: () => chooseFile()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
options.push({
|
||||
title: I18n.t('Create_Discussion'),
|
||||
icon: 'discussions',
|
||||
onPress: () => createDiscussion()
|
||||
});
|
||||
|
||||
closeEmojiKeyboardAndAction(showActionSheet, { options });
|
||||
};
|
||||
|
||||
return <BaseButton onPress={onPress} testID='message-composer-actions' accessibilityLabel='Message_actions' icon='add' />;
|
||||
};
|
|
@ -0,0 +1,42 @@
|
|||
import { BorderlessButton } from 'react-native-gesture-handler';
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
|
||||
import I18n from '../../../../i18n';
|
||||
import { CustomIcon, TIconsName } from '../../../CustomIcon';
|
||||
import { useTheme } from '../../../../theme';
|
||||
|
||||
export interface IBaseButton {
|
||||
testID: string;
|
||||
accessibilityLabel: string;
|
||||
icon: TIconsName;
|
||||
color?: string;
|
||||
onPress(): void;
|
||||
}
|
||||
|
||||
export const hitSlop = {
|
||||
top: 16,
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
left: 16
|
||||
};
|
||||
|
||||
export const BaseButton = ({ accessibilityLabel, icon, color, testID, onPress }: IBaseButton) => {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<BorderlessButton style={styles.button} onPress={() => onPress()} testID={testID} hitSlop={hitSlop}>
|
||||
<View accessible accessibilityLabel={I18n.t(accessibilityLabel)} accessibilityRole='button'>
|
||||
<CustomIcon name={icon} size={24} color={color || colors.fontSecondaryInfo} />
|
||||
</View>
|
||||
</BorderlessButton>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 24,
|
||||
height: 24
|
||||
}
|
||||
});
|
|
@ -0,0 +1,76 @@
|
|||
import { Audio } from 'expo-av';
|
||||
import React, { useContext } from 'react';
|
||||
import { Alert } from 'react-native';
|
||||
import { PermissionStatus } from 'expo-camera';
|
||||
|
||||
import i18n from '../../../../i18n';
|
||||
import { useAppSelector } from '../../../../lib/hooks';
|
||||
import { openAppSettings } from '../../../../lib/methods/helpers/openAppSettings';
|
||||
import { useTheme } from '../../../../theme';
|
||||
import { useRoomContext } from '../../../../views/RoomView/context';
|
||||
import { MessageInnerContext, useMessageComposerApi, useMicOrSend } from '../../context';
|
||||
import { useCanUploadFile } from '../../hooks';
|
||||
import { BaseButton } from './BaseButton';
|
||||
|
||||
export const MicOrSendButton = (): React.ReactElement | null => {
|
||||
const { rid, sharing } = useRoomContext();
|
||||
const micOrSend = useMicOrSend();
|
||||
const { sendMessage } = useContext(MessageInnerContext);
|
||||
const permissionToUpload = useCanUploadFile(rid);
|
||||
const { Message_AudioRecorderEnabled } = useAppSelector(state => state.settings);
|
||||
const { colors } = useTheme();
|
||||
const { setRecordingAudio } = useMessageComposerApi();
|
||||
|
||||
const requestPermissionAndStartToRecordAudio = () =>
|
||||
Audio.requestPermissionsAsync()
|
||||
.then(({ granted }) => setRecordingAudio(granted))
|
||||
.catch(() => {});
|
||||
|
||||
const startRecording = async () => {
|
||||
const { status, granted, canAskAgain } = await Audio.getPermissionsAsync();
|
||||
if (granted) return setRecordingAudio(true);
|
||||
if (status === PermissionStatus.UNDETERMINED) return requestPermissionAndStartToRecordAudio();
|
||||
if (canAskAgain) return requestPermissionAndStartToRecordAudio();
|
||||
|
||||
Alert.alert(
|
||||
i18n.t('Microphone_access_needed_to_record_audio'),
|
||||
i18n.t('Go_to_your_device_settings_and_allow_microphone'),
|
||||
[
|
||||
{
|
||||
text: i18n.t('Cancel'),
|
||||
style: 'cancel'
|
||||
},
|
||||
{
|
||||
text: i18n.t('Settings'),
|
||||
onPress: openAppSettings
|
||||
}
|
||||
],
|
||||
{ cancelable: false }
|
||||
);
|
||||
};
|
||||
|
||||
if (micOrSend === 'send' || sharing) {
|
||||
return (
|
||||
<BaseButton
|
||||
onPress={sendMessage}
|
||||
testID='message-composer-send'
|
||||
accessibilityLabel='Send_message'
|
||||
icon='send-filled'
|
||||
color={colors.strokeHighlight}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (Message_AudioRecorderEnabled && permissionToUpload) {
|
||||
return (
|
||||
<BaseButton
|
||||
onPress={startRecording}
|
||||
testID='message-composer-send-audio'
|
||||
accessibilityLabel='Send_audio_message'
|
||||
icon='microphone'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
export * from './ActionsButton';
|
||||
export * from './BaseButton';
|
||||
export * from './MicOrSendButton';
|
|
@ -0,0 +1,24 @@
|
|||
import React from 'react';
|
||||
|
||||
import { BaseButton } from './Buttons';
|
||||
import { useRoomContext } from '../../../views/RoomView/context';
|
||||
import { Gap } from './Gap';
|
||||
|
||||
export const CancelEdit = () => {
|
||||
const { action, editCancel } = useRoomContext();
|
||||
|
||||
if (action !== 'edit') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<BaseButton
|
||||
onPress={() => editCancel?.()}
|
||||
testID='message-composer-cancel-edit'
|
||||
accessibilityLabel='Cancel_editing'
|
||||
icon='close'
|
||||
/>
|
||||
<Gap />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,363 @@
|
|||
import React, { forwardRef, memo, useCallback, useEffect, useImperativeHandle } from 'react';
|
||||
import { TextInput, StyleSheet, TextInputProps, InteractionManager } from 'react-native';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { RouteProp, useFocusEffect, useRoute } from '@react-navigation/native';
|
||||
|
||||
import I18n from '../../../i18n';
|
||||
import { IAutocompleteItemProps, IComposerInput, IComposerInputProps, IInputSelection, TSetInput } from '../interfaces';
|
||||
import { useAutocompleteParams, useFocused, useMessageComposerApi } from '../context';
|
||||
import { fetchIsAllOrHere, getMentionRegexp } from '../helpers';
|
||||
import { useSubscription, useAutoSaveDraft } from '../hooks';
|
||||
import sharedStyles from '../../../views/Styles';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { userTyping } from '../../../actions/room';
|
||||
import { getRoomTitle, parseJson } from '../../../lib/methods/helpers';
|
||||
import { MAX_HEIGHT, MIN_HEIGHT, NO_CANNED_RESPONSES, MARKDOWN_STYLES } from '../constants';
|
||||
import database from '../../../lib/database';
|
||||
import Navigation from '../../../lib/navigation/appNavigation';
|
||||
import { emitter } from '../../../lib/methods/helpers/emitter';
|
||||
import { useRoomContext } from '../../../views/RoomView/context';
|
||||
import { getMessageById } from '../../../lib/database/services/Message';
|
||||
import { generateTriggerId } from '../../../lib/methods';
|
||||
import { Services } from '../../../lib/services';
|
||||
import log from '../../../lib/methods/helpers/log';
|
||||
import { useAppSelector, usePrevious } from '../../../lib/hooks';
|
||||
import { ChatsStackParamList } from '../../../stacks/types';
|
||||
import { loadDraftMessage } from '../../../lib/methods/draftMessage';
|
||||
|
||||
const defaultSelection: IInputSelection = { start: 0, end: 0 };
|
||||
|
||||
export const ComposerInput = memo(
|
||||
forwardRef<IComposerInput, IComposerInputProps>(({ inputRef }, ref) => {
|
||||
const { colors, theme } = useTheme();
|
||||
const { rid, tmid, sharing, action, selectedMessages, setQuotesAndText } = useRoomContext();
|
||||
const focused = useFocused();
|
||||
const { setFocused, setMicOrSend, setAutocompleteParams } = useMessageComposerApi();
|
||||
const autocompleteType = useAutocompleteParams()?.type;
|
||||
const textRef = React.useRef('');
|
||||
const firstRender = React.useRef(false);
|
||||
const selectionRef = React.useRef<IInputSelection>(defaultSelection);
|
||||
const dispatch = useDispatch();
|
||||
const subscription = useSubscription(rid);
|
||||
const isMasterDetail = useAppSelector(state => state.app.isMasterDetail);
|
||||
let placeholder = tmid ? I18n.t('Add_thread_reply') : '';
|
||||
if (subscription && !tmid) {
|
||||
placeholder = I18n.t('Message_roomname', { roomName: (subscription.t === 'd' ? '@' : '#') + getRoomTitle(subscription) });
|
||||
}
|
||||
const route = useRoute<RouteProp<ChatsStackParamList, 'RoomView'>>();
|
||||
const usedCannedResponse = route.params?.usedCannedResponse;
|
||||
const prevAction = usePrevious(action);
|
||||
|
||||
useAutoSaveDraft(textRef.current);
|
||||
|
||||
// Draft/Canned Responses
|
||||
useEffect(() => {
|
||||
const setDraftMessage = async () => {
|
||||
const draftMessage = await loadDraftMessage({ rid, tmid });
|
||||
if (draftMessage) {
|
||||
const parsedDraft = parseJson(draftMessage);
|
||||
if (parsedDraft?.msg || parsedDraft?.quotes) {
|
||||
setQuotesAndText?.(parsedDraft.msg, parsedDraft.quotes);
|
||||
} else {
|
||||
setInput(draftMessage);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (sharing) return;
|
||||
if (usedCannedResponse) setInput(usedCannedResponse);
|
||||
if (action !== 'edit' && !firstRender.current) {
|
||||
firstRender.current = true;
|
||||
setDraftMessage();
|
||||
}
|
||||
}, [action, rid, tmid, usedCannedResponse, firstRender.current]);
|
||||
|
||||
// Edit/quote
|
||||
useEffect(() => {
|
||||
const fetchMessageAndSetInput = async () => {
|
||||
const message = await getMessageById(selectedMessages[0]);
|
||||
if (message) {
|
||||
setInput(message?.msg || '');
|
||||
}
|
||||
};
|
||||
|
||||
if (sharing) return;
|
||||
|
||||
if (prevAction === 'edit' && action !== 'edit') {
|
||||
setInput('');
|
||||
return;
|
||||
}
|
||||
if (action === 'edit' && selectedMessages[0]) {
|
||||
focus();
|
||||
fetchMessageAndSetInput();
|
||||
return;
|
||||
}
|
||||
if (action === 'quote' && selectedMessages.length) {
|
||||
focus();
|
||||
}
|
||||
}, [action, selectedMessages]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const task = InteractionManager.runAfterInteractions(() => {
|
||||
emitter.on('addMarkdown', ({ style }) => {
|
||||
const { start, end } = selectionRef.current;
|
||||
const text = textRef.current;
|
||||
const markdown = MARKDOWN_STYLES[style];
|
||||
const newText = `${text.substr(0, start)}${markdown}${text.substr(start, end - start)}${markdown}${text.substr(end)}`;
|
||||
setInput(newText, {
|
||||
start: start + markdown.length,
|
||||
end: start === end ? start + markdown.length : end + markdown.length
|
||||
});
|
||||
});
|
||||
emitter.on('toolbarMention', () => {
|
||||
if (autocompleteType) {
|
||||
return;
|
||||
}
|
||||
const { start, end } = selectionRef.current;
|
||||
const text = textRef.current;
|
||||
const newText = `${text.substr(0, start)}@${text.substr(start, end - start)}${text.substr(end)}`;
|
||||
setInput(newText, { start: start + 1, end: start === end ? start + 1 : end + 1 });
|
||||
setAutocompleteParams({ text: '', type: '@' });
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
emitter.off('addMarkdown');
|
||||
emitter.off('toolbarMention');
|
||||
task?.cancel();
|
||||
};
|
||||
}, [rid, tmid, autocompleteType])
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getTextAndClear: () => {
|
||||
const text = textRef.current;
|
||||
setInput('');
|
||||
return text;
|
||||
},
|
||||
getText: () => textRef.current,
|
||||
getSelection: () => selectionRef.current,
|
||||
setInput,
|
||||
onAutocompleteItemSelected
|
||||
}));
|
||||
|
||||
const setInput: TSetInput = (text, selection) => {
|
||||
textRef.current = text;
|
||||
if (inputRef.current) {
|
||||
inputRef.current.setNativeProps({ text });
|
||||
}
|
||||
if (selection) {
|
||||
// setSelection won't trigger onSelectionChange, so we need it to be ran after new text is set
|
||||
setTimeout(() => {
|
||||
inputRef.current?.setSelection?.(selection.start, selection.end);
|
||||
selectionRef.current = selection;
|
||||
}, 50);
|
||||
}
|
||||
setMicOrSend(text.length === 0 ? 'mic' : 'send');
|
||||
};
|
||||
|
||||
const focus = () => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const onChangeText: TextInputProps['onChangeText'] = text => {
|
||||
textRef.current = text;
|
||||
debouncedOnChangeText(text);
|
||||
setInput(text);
|
||||
};
|
||||
|
||||
const onSelectionChange: TextInputProps['onSelectionChange'] = e => {
|
||||
selectionRef.current = e.nativeEvent.selection;
|
||||
};
|
||||
|
||||
const onFocus: TextInputProps['onFocus'] = () => {
|
||||
setFocused(true);
|
||||
};
|
||||
|
||||
const onBlur: TextInputProps['onBlur'] = () => {
|
||||
setFocused(false);
|
||||
stopAutocomplete();
|
||||
};
|
||||
|
||||
const onAutocompleteItemSelected: IAutocompleteItemProps['onPress'] = async item => {
|
||||
if (item.type === 'loading') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If it's slash command preview, we need to execute the command
|
||||
if (item.type === '/preview') {
|
||||
try {
|
||||
if (!rid) return;
|
||||
const db = database.active;
|
||||
const commandsCollection = db.get('slash_commands');
|
||||
const commandRecord = await commandsCollection.find(item.text);
|
||||
const { appId } = commandRecord;
|
||||
const triggerId = generateTriggerId(appId);
|
||||
Services.executeCommandPreview(item.text, item.params, rid, item.preview, triggerId, tmid);
|
||||
} catch (e) {
|
||||
log(e);
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
stopAutocomplete();
|
||||
setInput('', { start: 0, end: 0 });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's canned response, but there's no canned responses, we open the canned responses view
|
||||
if (item.type === '!' && item.id === NO_CANNED_RESPONSES) {
|
||||
const params = { rid };
|
||||
if (isMasterDetail) {
|
||||
Navigation.navigate('ModalStackNavigator', { screen: 'CannedResponsesListView', params });
|
||||
} else {
|
||||
Navigation.navigate('CannedResponsesListView', params);
|
||||
}
|
||||
stopAutocomplete();
|
||||
return;
|
||||
}
|
||||
|
||||
const text = textRef.current;
|
||||
const { start, end } = selectionRef.current;
|
||||
const cursor = Math.max(start, end);
|
||||
const regexp = getMentionRegexp();
|
||||
let result = text.substr(0, cursor).replace(regexp, '');
|
||||
// Remove the ! after select the canned response
|
||||
if (item.type === '!') {
|
||||
const lastIndexOfExclamation = text.lastIndexOf('!', cursor);
|
||||
result = text.substr(0, lastIndexOfExclamation).replace(regexp, '');
|
||||
}
|
||||
let mention = '';
|
||||
switch (item.type) {
|
||||
case '@':
|
||||
mention = fetchIsAllOrHere(item) ? item.title : item.subtitle || item.title;
|
||||
break;
|
||||
case '#':
|
||||
mention = item.subtitle ? item.subtitle : '';
|
||||
break;
|
||||
case ':':
|
||||
mention = `${typeof item.emoji === 'string' ? item.emoji : item.emoji.name}:`;
|
||||
break;
|
||||
case '/':
|
||||
mention = item.title;
|
||||
break;
|
||||
case '!':
|
||||
mention = item.subtitle ? item.subtitle : '';
|
||||
break;
|
||||
default:
|
||||
mention = '';
|
||||
}
|
||||
const newText = `${result}${mention} ${text.slice(cursor)}`;
|
||||
|
||||
const newCursor = result.length + mention.length + 1;
|
||||
setInput(newText, { start: newCursor, end: newCursor });
|
||||
focus();
|
||||
requestAnimationFrame(() => {
|
||||
stopAutocomplete();
|
||||
});
|
||||
};
|
||||
|
||||
const stopAutocomplete = () => {
|
||||
setAutocompleteParams({ text: '', type: null, params: '' });
|
||||
};
|
||||
|
||||
const debouncedOnChangeText = useDebouncedCallback(async (text: string) => {
|
||||
const isTextEmpty = text.length === 0;
|
||||
handleTyping(!isTextEmpty);
|
||||
if (isTextEmpty || !focused) {
|
||||
stopAutocomplete();
|
||||
return;
|
||||
}
|
||||
const { start, end } = selectionRef.current;
|
||||
const cursor = Math.max(start, end);
|
||||
const whiteSpaceOrBreakLineRegex = /[\s\n]+/;
|
||||
const txt =
|
||||
cursor < text.length ? text.substr(0, cursor).split(whiteSpaceOrBreakLineRegex) : text.split(whiteSpaceOrBreakLineRegex);
|
||||
const lastWord = txt[txt.length - 1];
|
||||
const autocompleteText = lastWord.substring(1);
|
||||
|
||||
if (!lastWord) {
|
||||
stopAutocomplete();
|
||||
return;
|
||||
}
|
||||
if (!sharing && text.match(/^\//)) {
|
||||
const commandParameter = text.match(/^\/([a-z0-9._-]+) (.+)/im);
|
||||
if (commandParameter) {
|
||||
const db = database.active;
|
||||
const [, command, params] = commandParameter;
|
||||
const commandsCollection = db.get('slash_commands');
|
||||
try {
|
||||
const commandRecord = await commandsCollection.find(command);
|
||||
if (commandRecord.providesPreview) {
|
||||
setAutocompleteParams({ params, text: command, type: '/preview' });
|
||||
}
|
||||
return;
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
setAutocompleteParams({ text: autocompleteText, type: '/' });
|
||||
return;
|
||||
}
|
||||
if (lastWord.match(/^#/)) {
|
||||
setAutocompleteParams({ text: autocompleteText, type: '#' });
|
||||
return;
|
||||
}
|
||||
if (lastWord.match(/^@/)) {
|
||||
setAutocompleteParams({ text: autocompleteText, type: '@' });
|
||||
return;
|
||||
}
|
||||
if (lastWord.match(/^:/)) {
|
||||
setAutocompleteParams({ text: autocompleteText, type: ':' });
|
||||
return;
|
||||
}
|
||||
if (lastWord.match(/^!/) && subscription?.t === 'l') {
|
||||
setAutocompleteParams({ text: autocompleteText, type: '!' });
|
||||
return;
|
||||
}
|
||||
|
||||
stopAutocomplete();
|
||||
}, 300);
|
||||
|
||||
const handleTyping = (isTyping: boolean) => {
|
||||
if (sharing || !rid) return;
|
||||
dispatch(userTyping(rid, isTyping));
|
||||
};
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
style={[styles.textInput, { color: colors.fontDefault }]}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.fontAnnotation}
|
||||
ref={component => (inputRef.current = component)}
|
||||
blurOnSubmit={false}
|
||||
onChangeText={onChangeText}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
underlineColorAndroid='transparent'
|
||||
defaultValue=''
|
||||
multiline
|
||||
keyboardAppearance={theme === 'light' ? 'light' : 'dark'}
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
testID={`message-composer-input${tmid ? '-thread' : sharing ? '-share' : ''}`}
|
||||
/>
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
textInput: {
|
||||
flex: 1,
|
||||
minHeight: MIN_HEIGHT,
|
||||
maxHeight: MAX_HEIGHT,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 12,
|
||||
fontSize: 16,
|
||||
textAlignVertical: 'center',
|
||||
...sharedStyles.textRegular,
|
||||
lineHeight: 22
|
||||
}
|
||||
});
|
|
@ -3,13 +3,12 @@ import { View } from 'react-native';
|
|||
import { KeyboardRegistry } from 'react-native-ui-lib/keyboard';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import store from '../../lib/store';
|
||||
import EmojiPicker from '../EmojiPicker';
|
||||
import styles from './styles';
|
||||
import { ThemeContext, TSupportedThemes } from '../../theme';
|
||||
import { EventTypes } from '../EmojiPicker/interfaces';
|
||||
import { IEmoji } from '../../definitions';
|
||||
import { colors } from '../../lib/constants';
|
||||
import store from '../../../lib/store';
|
||||
import EmojiPicker from '../../EmojiPicker';
|
||||
import { ThemeContext, TSupportedThemes } from '../../../theme';
|
||||
import { EventTypes } from '../../EmojiPicker/interfaces';
|
||||
import { IEmoji } from '../../../definitions';
|
||||
import { colors } from '../../../lib/constants';
|
||||
|
||||
const EmojiKeyboard = ({ theme }: { theme: TSupportedThemes }) => {
|
||||
const onItemClicked = (eventType: EventTypes, emoji?: IEmoji) => {
|
||||
|
@ -24,10 +23,7 @@ const EmojiKeyboard = ({ theme }: { theme: TSupportedThemes }) => {
|
|||
colors: colors[theme]
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={[styles.emojiKeyboardContainer, { borderTopColor: colors[theme].borderColor }]}
|
||||
testID='messagebox-keyboard-emoji'
|
||||
>
|
||||
<View style={{ flex: 1 }} testID='message-composer-keyboard-emoji'>
|
||||
<EmojiPicker onItemClicked={onItemClicked} isEmojiKeyboard={true} />
|
||||
</View>
|
||||
</ThemeContext.Provider>
|
|
@ -1,30 +1,87 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { View, Text, Pressable, FlatList, StyleSheet } from 'react-native';
|
||||
|
||||
import { useTheme } from '../../theme';
|
||||
import I18n from '../../i18n';
|
||||
import { CustomIcon } from '../CustomIcon';
|
||||
import { IEmoji } from '../../definitions';
|
||||
import { useFrequentlyUsedEmoji } from '../../lib/hooks';
|
||||
import { addFrequentlyUsed, searchEmojis } from '../../lib/methods';
|
||||
import { useDebounce } from '../../lib/methods/helpers';
|
||||
import sharedStyles from '../../views/Styles';
|
||||
import { PressableEmoji } from '../EmojiPicker/PressableEmoji';
|
||||
import { EmojiSearch } from '../EmojiPicker/EmojiSearch';
|
||||
import { EMOJI_BUTTON_SIZE } from '../EmojiPicker/styles';
|
||||
import { events, logEvent } from '../../lib/methods/helpers/log';
|
||||
import { MessageInnerContext, useMessageComposerApi, useShowEmojiSearchbar } from '../context';
|
||||
import { useTheme } from '../../../theme';
|
||||
import I18n from '../../../i18n';
|
||||
import { CustomIcon } from '../../CustomIcon';
|
||||
import { IEmoji } from '../../../definitions';
|
||||
import { useFrequentlyUsedEmoji } from '../../../lib/hooks';
|
||||
import { addFrequentlyUsed, searchEmojis } from '../../../lib/methods';
|
||||
import { useDebounce } from '../../../lib/methods/helpers';
|
||||
import sharedStyles from '../../../views/Styles';
|
||||
import { PressableEmoji } from '../../EmojiPicker/PressableEmoji';
|
||||
import { EmojiSearch } from '../../EmojiPicker/EmojiSearch';
|
||||
import { EMOJI_BUTTON_SIZE } from '../../EmojiPicker/styles';
|
||||
|
||||
const BUTTON_HIT_SLOP = { top: 4, right: 4, bottom: 4, left: 4 };
|
||||
|
||||
export const EmojiSearchbar = (): React.ReactElement | null => {
|
||||
const { colors } = useTheme();
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const showEmojiSearchbar = useShowEmojiSearchbar();
|
||||
const { openEmojiKeyboard, closeEmojiKeyboard } = useMessageComposerApi();
|
||||
const { onEmojiSelected } = useContext(MessageInnerContext);
|
||||
const { frequentlyUsed } = useFrequentlyUsedEmoji(true);
|
||||
const [emojis, setEmojis] = useState<IEmoji[]>([]);
|
||||
|
||||
const handleTextChange = useDebounce(async (text: string) => {
|
||||
setSearchText(text);
|
||||
const result = await searchEmojis(text);
|
||||
setEmojis(result);
|
||||
}, 300);
|
||||
|
||||
const handleEmojiSelected = (emoji: IEmoji) => {
|
||||
onEmojiSelected(emoji);
|
||||
addFrequentlyUsed(emoji);
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: IEmoji }) => <PressableEmoji emoji={item} onPress={handleEmojiSelected} />;
|
||||
|
||||
if (!showEmojiSearchbar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: Use RNGH
|
||||
return (
|
||||
<View style={{ backgroundColor: colors.surfaceLight }}>
|
||||
<FlatList
|
||||
horizontal
|
||||
data={searchText ? emojis : frequentlyUsed}
|
||||
renderItem={renderItem}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
ListEmptyComponent={() => (
|
||||
<View style={styles.emptyContainer} testID='no-results-found'>
|
||||
<Text style={[styles.emptyText, { color: colors.fontHint }]}>{I18n.t('No_results_found')}</Text>
|
||||
</View>
|
||||
)}
|
||||
keyExtractor={item => (typeof item === 'string' ? item : item.name)}
|
||||
contentContainerStyle={styles.listContainer}
|
||||
keyboardShouldPersistTaps='always'
|
||||
/>
|
||||
<View style={styles.searchContainer}>
|
||||
<Pressable
|
||||
style={({ pressed }: { pressed: boolean }) => [styles.backButton, { opacity: pressed ? 0.7 : 1 }]}
|
||||
onPress={openEmojiKeyboard}
|
||||
hitSlop={BUTTON_HIT_SLOP}
|
||||
testID='openback-emoji-keyboard'
|
||||
>
|
||||
<CustomIcon name='chevron-left' size={24} color={colors.fontHint} />
|
||||
</Pressable>
|
||||
<View style={styles.inputContainer}>
|
||||
<EmojiSearch onBlur={closeEmojiKeyboard} onChangeText={handleTextChange} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listContainer: {
|
||||
height: EMOJI_BUTTON_SIZE,
|
||||
margin: 8,
|
||||
flexGrow: 1
|
||||
},
|
||||
container: {
|
||||
borderTopWidth: 1
|
||||
},
|
||||
searchContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
|
@ -52,65 +109,3 @@ const styles = StyleSheet.create({
|
|||
flex: 1
|
||||
}
|
||||
});
|
||||
|
||||
interface IEmojiSearchBarProps {
|
||||
openEmoji: () => void;
|
||||
closeEmoji: () => void;
|
||||
onEmojiSelected: (emoji: IEmoji) => void;
|
||||
}
|
||||
|
||||
const EmojiSearchBar = ({ openEmoji, closeEmoji, onEmojiSelected }: IEmojiSearchBarProps): React.ReactElement => {
|
||||
const { colors } = useTheme();
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const { frequentlyUsed } = useFrequentlyUsedEmoji(true);
|
||||
const [emojis, setEmojis] = useState<IEmoji[]>([]);
|
||||
|
||||
const handleTextChange = useDebounce(async (text: string) => {
|
||||
logEvent(events.MB_SB_EMOJI_SEARCH);
|
||||
setSearchText(text);
|
||||
const result = await searchEmojis(text);
|
||||
setEmojis(result);
|
||||
}, 300);
|
||||
|
||||
const handleEmojiSelected = (emoji: IEmoji) => {
|
||||
logEvent(events.MB_SB_EMOJI_SELECTED);
|
||||
onEmojiSelected(emoji);
|
||||
addFrequentlyUsed(emoji);
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: IEmoji }) => <PressableEmoji emoji={item} onPress={handleEmojiSelected} />;
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { borderTopColor: colors.borderColor, backgroundColor: colors.messageboxBackground }]}>
|
||||
<FlatList
|
||||
horizontal
|
||||
data={searchText ? emojis : frequentlyUsed}
|
||||
renderItem={renderItem}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
ListEmptyComponent={() => (
|
||||
<View style={styles.emptyContainer} testID='no-results-found'>
|
||||
<Text style={[styles.emptyText, { color: colors.auxiliaryText }]}>{I18n.t('No_results_found')}</Text>
|
||||
</View>
|
||||
)}
|
||||
keyExtractor={item => (typeof item === 'string' ? item : item.name)}
|
||||
contentContainerStyle={styles.listContainer}
|
||||
keyboardShouldPersistTaps='always'
|
||||
/>
|
||||
<View style={styles.searchContainer}>
|
||||
<Pressable
|
||||
style={({ pressed }: { pressed: boolean }) => [styles.backButton, { opacity: pressed ? 0.7 : 1 }]}
|
||||
onPress={openEmoji}
|
||||
hitSlop={BUTTON_HIT_SLOP}
|
||||
testID='openback-emoji-keyboard'
|
||||
>
|
||||
<CustomIcon name='chevron-left' size={24} color={colors.auxiliaryTintColor} />
|
||||
</Pressable>
|
||||
<View style={styles.inputContainer}>
|
||||
<EmojiSearch onBlur={closeEmoji} onChangeText={handleTextChange} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiSearchBar;
|
|
@ -0,0 +1,4 @@
|
|||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
|
||||
export const Gap = () => <View style={{ width: 12 }} />;
|
|
@ -0,0 +1,95 @@
|
|||
import React from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
import moment from 'moment';
|
||||
|
||||
import { useTheme } from '../../../../theme';
|
||||
import sharedStyles from '../../../../views/Styles';
|
||||
import { useRoomContext } from '../../../../views/RoomView/context';
|
||||
import { BaseButton } from '../Buttons';
|
||||
import { useMessage } from '../../hooks';
|
||||
import { useAppSelector } from '../../../../lib/hooks';
|
||||
import { MarkdownPreview } from '../../../markdown';
|
||||
|
||||
export const Quote = ({ messageId }: { messageId: string }) => {
|
||||
const [styles, colors] = useStyle();
|
||||
const message = useMessage(messageId);
|
||||
const useRealName = useAppSelector(({ settings }) => settings.UI_Use_Real_Name);
|
||||
const { onRemoveQuoteMessage } = useRoomContext();
|
||||
|
||||
let username = '';
|
||||
let msg = '';
|
||||
let time = '';
|
||||
|
||||
if (message) {
|
||||
username = useRealName ? message.u?.name || message.u?.username || '' : message.u?.username || '';
|
||||
msg = message.msg || '';
|
||||
time = message.ts ? moment(message.ts).format('LT') : '';
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.root} testID={`composer-quote-${message.id}`}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.title}>
|
||||
<Text style={styles.username} numberOfLines={1}>
|
||||
{username}
|
||||
</Text>
|
||||
<Text style={styles.time}>{time}</Text>
|
||||
</View>
|
||||
<BaseButton
|
||||
icon='close'
|
||||
color={colors.fontDefault}
|
||||
onPress={() => onRemoveQuoteMessage?.(message.id)}
|
||||
accessibilityLabel='Remove_quote_message'
|
||||
testID={`composer-quote-remove-${message.id}`}
|
||||
/>
|
||||
</View>
|
||||
<MarkdownPreview style={[styles.message]} numberOfLines={1} msg={msg} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
function useStyle() {
|
||||
const { colors } = useTheme();
|
||||
const style = {
|
||||
root: {
|
||||
backgroundColor: colors.surfaceTint,
|
||||
height: 64,
|
||||
width: 320,
|
||||
borderColor: colors.strokeExtraLight,
|
||||
borderLeftColor: colors.strokeMedium,
|
||||
borderWidth: 1,
|
||||
borderTopRightRadius: 4,
|
||||
borderBottomRightRadius: 4,
|
||||
paddingLeft: 16,
|
||||
padding: 8,
|
||||
marginRight: 8
|
||||
},
|
||||
header: { flexDirection: 'row', alignItems: 'center' },
|
||||
title: { flexDirection: 'row', flex: 1, alignItems: 'center' },
|
||||
username: {
|
||||
...sharedStyles.textBold,
|
||||
color: colors.fontTitlesLabels,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
flexShrink: 1,
|
||||
paddingRight: 4
|
||||
},
|
||||
time: {
|
||||
...sharedStyles.textRegular,
|
||||
color: colors.fontAnnotation,
|
||||
fontSize: 12,
|
||||
lineHeight: 16
|
||||
},
|
||||
message: {
|
||||
...sharedStyles.textRegular,
|
||||
color: colors.fontDefault,
|
||||
fontSize: 14,
|
||||
lineHeight: 20
|
||||
}
|
||||
} as const;
|
||||
return [style, colors] as const;
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import { FlatList } from 'react-native';
|
||||
|
||||
import { Quote } from './Quote';
|
||||
import { useRoomContext } from '../../../../views/RoomView/context';
|
||||
|
||||
export const Quotes = (): React.ReactElement | null => {
|
||||
const { selectedMessages, action } = useRoomContext();
|
||||
const nQuotesRef = useRef(0);
|
||||
const listRef = useRef<FlatList>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (nQuotesRef.current && nQuotesRef.current < selectedMessages.length) {
|
||||
setTimeout(() => {
|
||||
listRef.current?.scrollToEnd({ animated: true });
|
||||
}, 100);
|
||||
}
|
||||
nQuotesRef.current = selectedMessages.length;
|
||||
}, [selectedMessages.length]);
|
||||
|
||||
if (action !== 'quote') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={selectedMessages}
|
||||
renderItem={({ item }) => <Quote messageId={item} />}
|
||||
horizontal
|
||||
keyExtractor={item => item}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export * from './Quotes';
|
|
@ -0,0 +1,7 @@
|
|||
import React, { ReactElement } from 'react';
|
||||
|
||||
import { BaseButton, IBaseButton } from '../Buttons';
|
||||
|
||||
export const CancelButton = ({ onPress }: { onPress: IBaseButton['onPress'] }): ReactElement => (
|
||||
<BaseButton onPress={onPress} testID='message-composer-delete-audio' accessibilityLabel='Cancel' icon='delete' />
|
||||
);
|
|
@ -0,0 +1,43 @@
|
|||
import React, { forwardRef, useImperativeHandle } from 'react';
|
||||
import { FontVariant, Text } from 'react-native';
|
||||
import { Audio } from 'expo-av';
|
||||
|
||||
import sharedStyles from '../../../../views/Styles';
|
||||
import { useTheme } from '../../../../theme';
|
||||
import { formatTime } from './utils';
|
||||
|
||||
export interface IDurationRef {
|
||||
onRecordingStatusUpdate: (status: Audio.RecordingStatus) => void;
|
||||
}
|
||||
|
||||
export const Duration = forwardRef<IDurationRef>((_, ref) => {
|
||||
const [styles] = useStyle();
|
||||
const [duration, setDuration] = React.useState('00:00');
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onRecordingStatusUpdate
|
||||
}));
|
||||
|
||||
const onRecordingStatusUpdate = (status: Audio.RecordingStatus) => {
|
||||
if (!status.isRecording) {
|
||||
return;
|
||||
}
|
||||
setDuration(formatTime(Math.floor(status.durationMillis / 1000)));
|
||||
};
|
||||
|
||||
return <Text style={styles.text}>{duration}</Text>;
|
||||
});
|
||||
|
||||
function useStyle() {
|
||||
const { colors } = useTheme();
|
||||
const styles = {
|
||||
text: {
|
||||
marginLeft: 12,
|
||||
fontSize: 16,
|
||||
...sharedStyles.textRegular,
|
||||
color: colors.fontDefault,
|
||||
fontVariant: ['tabular-nums'] as FontVariant[]
|
||||
}
|
||||
} as const;
|
||||
return [styles, colors] as const;
|
||||
}
|
|
@ -0,0 +1,205 @@
|
|||
import { View, Text } from 'react-native';
|
||||
import React, { ReactElement, useEffect, useRef } from 'react';
|
||||
import { Audio } from 'expo-av';
|
||||
import { getInfoAsync } from 'expo-file-system';
|
||||
import { useKeepAwake } from 'expo-keep-awake';
|
||||
import { shallowEqual } from 'react-redux';
|
||||
|
||||
import { useTheme } from '../../../../theme';
|
||||
import { BaseButton } from '../Buttons';
|
||||
import { CustomIcon } from '../../../CustomIcon';
|
||||
import sharedStyles from '../../../../views/Styles';
|
||||
import { ReviewButton } from './ReviewButton';
|
||||
import { useMessageComposerApi } from '../../context';
|
||||
import { sendFileMessage } from '../../../../lib/methods';
|
||||
import { RECORDING_EXTENSION, RECORDING_MODE, RECORDING_SETTINGS } from '../../../../lib/constants';
|
||||
import { useAppSelector } from '../../../../lib/hooks';
|
||||
import log from '../../../../lib/methods/helpers/log';
|
||||
import { IUpload } from '../../../../definitions';
|
||||
import { useRoomContext } from '../../../../views/RoomView/context';
|
||||
import { useCanUploadFile } from '../../hooks';
|
||||
import { Duration, IDurationRef } from './Duration';
|
||||
import AudioPlayer from '../../../AudioPlayer';
|
||||
import { CancelButton } from './CancelButton';
|
||||
import i18n from '../../../../i18n';
|
||||
|
||||
export const RecordAudio = (): ReactElement | null => {
|
||||
const [styles, colors] = useStyle();
|
||||
const recordingRef = useRef<Audio.Recording>();
|
||||
const durationRef = useRef<IDurationRef>({} as IDurationRef);
|
||||
const numberOfTriesRef = useRef(0);
|
||||
const [status, setStatus] = React.useState<'recording' | 'reviewing'>('recording');
|
||||
const { setRecordingAudio } = useMessageComposerApi();
|
||||
const { rid, tmid } = useRoomContext();
|
||||
const server = useAppSelector(state => state.server.server);
|
||||
const user = useAppSelector(state => ({ id: state.login.user.id, token: state.login.user.token }), shallowEqual);
|
||||
const permissionToUpload = useCanUploadFile(rid);
|
||||
useKeepAwake();
|
||||
|
||||
useEffect(() => {
|
||||
const record = async () => {
|
||||
try {
|
||||
await Audio.setAudioModeAsync(RECORDING_MODE);
|
||||
recordingRef.current = new Audio.Recording();
|
||||
await recordingRef.current.prepareToRecordAsync(RECORDING_SETTINGS);
|
||||
recordingRef.current.setOnRecordingStatusUpdate(durationRef.current.onRecordingStatusUpdate);
|
||||
await recordingRef.current.startAsync();
|
||||
} catch (error: any) {
|
||||
// error only occurs on iOS devices
|
||||
if (error?.code === 'E_AUDIO_RECORDERNOTCREATED') {
|
||||
if (numberOfTriesRef.current <= 5) {
|
||||
recordingRef.current = undefined;
|
||||
numberOfTriesRef.current += 1;
|
||||
setTimeout(() => {
|
||||
record();
|
||||
}, 100);
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
record();
|
||||
|
||||
return () => {
|
||||
try {
|
||||
recordingRef.current?.stopAndUnloadAsync();
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cancelRecording = async () => {
|
||||
try {
|
||||
await recordingRef.current?.stopAndUnloadAsync();
|
||||
} catch {
|
||||
// Do nothing
|
||||
} finally {
|
||||
setRecordingAudio(false);
|
||||
}
|
||||
};
|
||||
|
||||
const goReview = async () => {
|
||||
try {
|
||||
await recordingRef.current?.stopAndUnloadAsync();
|
||||
setStatus('reviewing');
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
};
|
||||
|
||||
const sendAudio = async () => {
|
||||
try {
|
||||
if (!rid) return;
|
||||
setRecordingAudio(false);
|
||||
const fileURI = recordingRef.current?.getURI();
|
||||
const fileData = await getInfoAsync(fileURI as string);
|
||||
const fileInfo = {
|
||||
name: `${Date.now()}${RECORDING_EXTENSION}`,
|
||||
mime: 'audio/aac',
|
||||
type: 'audio/aac',
|
||||
store: 'Uploads',
|
||||
path: fileURI,
|
||||
size: fileData.exists ? fileData.size : null
|
||||
} as IUpload;
|
||||
|
||||
if (fileInfo) {
|
||||
if (permissionToUpload) {
|
||||
await sendFileMessage(rid, fileInfo, tmid, server, user);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log(e);
|
||||
}
|
||||
};
|
||||
|
||||
if (!rid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status === 'reviewing') {
|
||||
return (
|
||||
<View style={styles.review}>
|
||||
<View style={styles.audioPlayer}>
|
||||
<AudioPlayer fileUri={recordingRef.current?.getURI() ?? ''} rid={rid} downloadState='downloaded' />
|
||||
</View>
|
||||
<View style={styles.buttons}>
|
||||
<CancelButton onPress={cancelRecording} />
|
||||
<View style={{ flex: 1 }} />
|
||||
<BaseButton
|
||||
onPress={sendAudio}
|
||||
testID='message-composer-send'
|
||||
accessibilityLabel='Send_message'
|
||||
icon='send-filled'
|
||||
color={colors.buttonBackgroundPrimaryDefault}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.recording}>
|
||||
<View style={styles.duration}>
|
||||
<CustomIcon name='microphone' size={24} color={colors.fontDanger} />
|
||||
<Duration ref={durationRef} />
|
||||
</View>
|
||||
<View style={styles.buttons}>
|
||||
<CancelButton onPress={cancelRecording} />
|
||||
<View style={styles.recordingNote}>
|
||||
<Text style={styles.recordingNoteText}>{i18n.t('Recording_audio_in_progress')}</Text>
|
||||
</View>
|
||||
<ReviewButton onPress={goReview} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
function useStyle() {
|
||||
const { colors } = useTheme();
|
||||
const style = {
|
||||
review: {
|
||||
borderTopWidth: 1,
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 12,
|
||||
backgroundColor: colors.surfaceLight,
|
||||
borderTopColor: colors.strokeLight
|
||||
},
|
||||
recording: {
|
||||
borderTopWidth: 1,
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 8,
|
||||
backgroundColor: colors.surfaceLight,
|
||||
borderTopColor: colors.strokeLight
|
||||
},
|
||||
duration: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 24,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
},
|
||||
audioPlayer: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 8
|
||||
},
|
||||
buttons: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
},
|
||||
recordingNote: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
},
|
||||
recordingNoteText: {
|
||||
fontSize: 14,
|
||||
...sharedStyles.textRegular,
|
||||
color: colors.fontSecondaryInfo
|
||||
}
|
||||
} as const;
|
||||
return [style, colors] as const;
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import { StyleSheet, View } from 'react-native';
|
||||
import React, { ReactElement } from 'react';
|
||||
import { BorderlessButton } from 'react-native-gesture-handler';
|
||||
|
||||
import { useTheme } from '../../../../theme';
|
||||
import { CustomIcon } from '../../../CustomIcon';
|
||||
import { hitSlop } from '../Buttons';
|
||||
|
||||
export const ReviewButton = ({ onPress }: { onPress: Function }): ReactElement => {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<BorderlessButton
|
||||
style={[
|
||||
styles.button,
|
||||
{
|
||||
backgroundColor: colors.buttonBackgroundPrimaryDefault
|
||||
}
|
||||
]}
|
||||
onPress={() => onPress()}
|
||||
hitSlop={hitSlop}
|
||||
>
|
||||
<View accessible accessibilityLabel={'Cancel_recording'} accessibilityRole='button'>
|
||||
<CustomIcon name={'arrow-right'} size={24} color={colors.fontWhite} />
|
||||
</View>
|
||||
</BorderlessButton>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16
|
||||
}
|
||||
});
|
|
@ -0,0 +1 @@
|
|||
export * from './RecordAudio';
|
|
@ -0,0 +1,7 @@
|
|||
export const formatTime = function (time: number) {
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = time % 60;
|
||||
const min = minutes < 10 ? `0${minutes}` : minutes;
|
||||
const sec = seconds < 10 ? `0${seconds}` : seconds;
|
||||
return `${min}:${sec}`;
|
||||
};
|
|
@ -0,0 +1,97 @@
|
|||
import { TouchableWithoutFeedback } from 'react-native-gesture-handler';
|
||||
import { StyleSheet, Text } from 'react-native';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { Q } from '@nozbe/watermelondb';
|
||||
|
||||
import { useRoomContext } from '../../../views/RoomView/context';
|
||||
import { useAlsoSendThreadToChannel, useMessageComposerApi, useShowEmojiSearchbar } from '../context';
|
||||
import { CustomIcon } from '../../CustomIcon';
|
||||
import { useTheme } from '../../../theme';
|
||||
import sharedStyles from '../../../views/Styles';
|
||||
import I18n from '../../../i18n';
|
||||
import { useAppSelector } from '../../../lib/hooks';
|
||||
import database from '../../../lib/database';
|
||||
import { compareServerVersion } from '../../../lib/methods/helpers';
|
||||
|
||||
export const SendThreadToChannel = (): React.ReactElement | null => {
|
||||
const alsoSendThreadToChannel = useAlsoSendThreadToChannel();
|
||||
const { setAlsoSendThreadToChannel } = useMessageComposerApi();
|
||||
const showEmojiSearchbar = useShowEmojiSearchbar();
|
||||
const { tmid } = useRoomContext();
|
||||
const { colors } = useTheme();
|
||||
const subscription = useRef<Subscription>();
|
||||
const alsoSendThreadToChannelUserPref = useAppSelector(state => state.login.user.alsoSendThreadToChannel);
|
||||
const serverVersion = useAppSelector(state => state.server.version);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tmid) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (compareServerVersion(serverVersion, 'lowerThan', '5.0.0')) {
|
||||
setAlsoSendThreadToChannel(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (alsoSendThreadToChannelUserPref === 'always') {
|
||||
setAlsoSendThreadToChannel(true);
|
||||
return;
|
||||
}
|
||||
if (alsoSendThreadToChannelUserPref === 'never') {
|
||||
setAlsoSendThreadToChannel(false);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* "default" sends a to channel only in the first message of the thread.
|
||||
* We check if the thread exists by observing/subscribing to the query with tmid.
|
||||
* If it doesn't exist, it means that this is the first message of the thread. So it's true.
|
||||
* Otherwise, it's false.
|
||||
* */
|
||||
if (alsoSendThreadToChannelUserPref === 'default') {
|
||||
const db = database.active;
|
||||
const observable = db.get('threads').query(Q.where('id', tmid)).observe();
|
||||
subscription.current = observable.subscribe(result => {
|
||||
setAlsoSendThreadToChannel(!result.length);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
subscription.current?.unsubscribe();
|
||||
};
|
||||
}, [tmid, alsoSendThreadToChannelUserPref, serverVersion, setAlsoSendThreadToChannel]);
|
||||
|
||||
if (!tmid || showEmojiSearchbar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableWithoutFeedback
|
||||
style={styles.container}
|
||||
onPress={() => setAlsoSendThreadToChannel(!alsoSendThreadToChannel)}
|
||||
testID='message-composer-send-to-channel'
|
||||
>
|
||||
<CustomIcon
|
||||
testID={alsoSendThreadToChannel ? 'send-to-channel-checked' : 'send-to-channel-unchecked'}
|
||||
name={alsoSendThreadToChannel ? 'checkbox-checked' : 'checkbox-unchecked'}
|
||||
size={24}
|
||||
color={alsoSendThreadToChannel ? colors.buttonBackgroundPrimaryDefault : colors.strokeDark}
|
||||
/>
|
||||
<Text style={[styles.text, { color: colors.fontSecondaryInfo }]}>{I18n.t('Message_composer_Send_to_channel')}</Text>
|
||||
</TouchableWithoutFeedback>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12
|
||||
},
|
||||
text: {
|
||||
fontSize: 14,
|
||||
marginLeft: 8,
|
||||
...sharedStyles.textRegular
|
||||
}
|
||||
});
|
|
@ -0,0 +1,13 @@
|
|||
import React, { ReactElement } from 'react';
|
||||
import { View } from 'react-native';
|
||||
|
||||
export const Container = ({ children }: { children: (ReactElement | null)[] }): ReactElement => (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 12
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
|
@ -0,0 +1,43 @@
|
|||
import React, { ReactElement } from 'react';
|
||||
|
||||
import { ActionsButton, BaseButton } from '..';
|
||||
import { useMessageComposerApi } from '../../context';
|
||||
import { Gap } from '../Gap';
|
||||
import { emitter } from '../../../../lib/methods/helpers/emitter';
|
||||
import { useRoomContext } from '../../../../views/RoomView/context';
|
||||
|
||||
export const Default = (): ReactElement | null => {
|
||||
const { sharing } = useRoomContext();
|
||||
const { openEmojiKeyboard, setMarkdownToolbar } = useMessageComposerApi();
|
||||
|
||||
return (
|
||||
<>
|
||||
{sharing ? null : (
|
||||
<>
|
||||
<ActionsButton />
|
||||
<Gap />
|
||||
</>
|
||||
)}
|
||||
<BaseButton
|
||||
onPress={openEmojiKeyboard}
|
||||
testID='message-composer-open-emoji'
|
||||
accessibilityLabel='Open_emoji_selector'
|
||||
icon='emoji'
|
||||
/>
|
||||
<Gap />
|
||||
<BaseButton
|
||||
onPress={() => setMarkdownToolbar(true)}
|
||||
testID='message-composer-open-markdown'
|
||||
accessibilityLabel='Open_markdown_tools'
|
||||
icon='text-format'
|
||||
/>
|
||||
<Gap />
|
||||
<BaseButton
|
||||
onPress={() => emitter.emit('toolbarMention')}
|
||||
testID='message-composer-mention'
|
||||
accessibilityLabel='Open_mention_autocomplete'
|
||||
icon='mention'
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,26 @@
|
|||
import React, { ReactElement } from 'react';
|
||||
|
||||
import { MicOrSendButton, ActionsButton, BaseButton } from '..';
|
||||
import { useMessageComposerApi } from '../../context';
|
||||
import { Container } from './Container';
|
||||
import { EmptySpace } from './EmptySpace';
|
||||
import { Gap } from '../Gap';
|
||||
|
||||
export const EmojiKeyboard = (): ReactElement => {
|
||||
const { closeEmojiKeyboard } = useMessageComposerApi();
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<ActionsButton />
|
||||
<Gap />
|
||||
<BaseButton
|
||||
onPress={closeEmojiKeyboard}
|
||||
testID='message-composer-close-emoji'
|
||||
accessibilityLabel='Close_emoji_selector'
|
||||
icon='keyboard'
|
||||
/>
|
||||
<EmptySpace />
|
||||
<MicOrSendButton />
|
||||
</Container>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,4 @@
|
|||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
|
||||
export const EmptySpace = () => <View style={{ flex: 1 }} />;
|
|
@ -0,0 +1,44 @@
|
|||
import React, { ReactElement } from 'react';
|
||||
|
||||
import { BaseButton } from '..';
|
||||
import { useMessageComposerApi } from '../../context';
|
||||
import { Gap } from '../Gap';
|
||||
import { TMarkdownStyle } from '../../interfaces';
|
||||
import { emitter } from '../../../../lib/methods/helpers/emitter';
|
||||
|
||||
export const Markdown = (): ReactElement => {
|
||||
const { setMarkdownToolbar } = useMessageComposerApi();
|
||||
|
||||
const onPress = (style: TMarkdownStyle) => emitter.emit('addMarkdown', { style });
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseButton
|
||||
onPress={() => setMarkdownToolbar(false)}
|
||||
testID='message-composer-close-markdown'
|
||||
accessibilityLabel='Close'
|
||||
icon='close'
|
||||
/>
|
||||
<Gap />
|
||||
<BaseButton onPress={() => onPress('bold')} testID='message-composer-bold' accessibilityLabel='Bold' icon='bold' />
|
||||
<Gap />
|
||||
<BaseButton onPress={() => onPress('italic')} testID='message-composer-italic' accessibilityLabel='Italic' icon='italic' />
|
||||
<Gap />
|
||||
<BaseButton
|
||||
onPress={() => onPress('strike')}
|
||||
testID='message-composer-strike'
|
||||
accessibilityLabel='Strikethrough'
|
||||
icon='strike'
|
||||
/>
|
||||
<Gap />
|
||||
<BaseButton onPress={() => onPress('code')} testID='message-composer-code' accessibilityLabel='Inline_code' icon='code' />
|
||||
<Gap />
|
||||
<BaseButton
|
||||
onPress={() => onPress('code-block')}
|
||||
testID='message-composer-code-block'
|
||||
accessibilityLabel='Code_block'
|
||||
icon='code-block'
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,38 @@
|
|||
import React, { ReactElement } from 'react';
|
||||
|
||||
import { useFocused, useShowEmojiKeyboard, useShowEmojiSearchbar, useShowMarkdownToolbar } from '../../context';
|
||||
import { Markdown } from './Markdown';
|
||||
import { Default } from './Default';
|
||||
import { EmojiKeyboard } from './EmojiKeyboard';
|
||||
import { Container } from './Container';
|
||||
import { MicOrSendButton } from '../Buttons';
|
||||
import { EmptySpace } from './EmptySpace';
|
||||
import { CancelEdit } from '../CancelEdit';
|
||||
|
||||
export const Toolbar = (): ReactElement | null => {
|
||||
const focused = useFocused();
|
||||
const showEmojiKeyboard = useShowEmojiKeyboard();
|
||||
const showEmojiSearchbar = useShowEmojiSearchbar();
|
||||
const showMarkdownToolbar = useShowMarkdownToolbar();
|
||||
|
||||
if (showEmojiSearchbar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (showEmojiKeyboard) {
|
||||
return <EmojiKeyboard />;
|
||||
}
|
||||
|
||||
if (!focused) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{showMarkdownToolbar ? <Markdown /> : <Default />}
|
||||
<EmptySpace />
|
||||
<CancelEdit />
|
||||
<MicOrSendButton />
|
||||
</Container>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export * from './Toolbar';
|
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
|
||||
import { useFocused, useShowEmojiKeyboard, useShowEmojiSearchbar } from '../../context';
|
||||
import { ActionsButton } from '../Buttons';
|
||||
import { MIN_HEIGHT } from '../../constants';
|
||||
import { useRoomContext } from '../../../../views/RoomView/context';
|
||||
|
||||
export const Left = () => {
|
||||
const { sharing } = useRoomContext();
|
||||
const focused = useFocused();
|
||||
const showEmojiKeyboard = useShowEmojiKeyboard();
|
||||
const showEmojiSearchbar = useShowEmojiSearchbar();
|
||||
if (focused || showEmojiKeyboard || showEmojiSearchbar || sharing) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View style={{ height: MIN_HEIGHT, paddingRight: 12, justifyContent: 'center' }}>
|
||||
<ActionsButton />
|
||||
</View>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
|
||||
import { useFocused, useShowEmojiKeyboard, useShowEmojiSearchbar } from '../../context';
|
||||
import { MicOrSendButton } from '../Buttons';
|
||||
import { MIN_HEIGHT } from '../../constants';
|
||||
import { CancelEdit } from '../CancelEdit';
|
||||
|
||||
export const Right = () => {
|
||||
const focused = useFocused();
|
||||
const showEmojiKeyboard = useShowEmojiKeyboard();
|
||||
const showEmojiSearchbar = useShowEmojiSearchbar();
|
||||
if (focused || showEmojiKeyboard || showEmojiSearchbar) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View style={{ height: MIN_HEIGHT, paddingLeft: 12, alignItems: 'center', flexDirection: 'row' }}>
|
||||
<CancelEdit />
|
||||
<MicOrSendButton />
|
||||
</View>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,2 @@
|
|||
export * from './Left';
|
||||
export * from './Right';
|
|
@ -0,0 +1,8 @@
|
|||
export * from './Autocomplete';
|
||||
export * from './Buttons';
|
||||
export * from './ComposerInput';
|
||||
export * from './EmojiSearchbar';
|
||||
export * from './Toolbar';
|
||||
export * from './Unfocused';
|
||||
export * from './Quotes';
|
||||
export * from './SendThreadToChannel';
|
|
@ -0,0 +1,35 @@
|
|||
import { Options } from 'react-native-image-crop-picker';
|
||||
|
||||
import { TMarkdownStyle } from './interfaces';
|
||||
|
||||
export const IMAGE_PICKER_CONFIG = {
|
||||
cropping: true,
|
||||
avoidEmptySpaceAroundImage: false,
|
||||
freeStyleCropEnabled: true,
|
||||
forceJpg: true
|
||||
};
|
||||
|
||||
export const LIBRARY_PICKER_CONFIG: Options = {
|
||||
multiple: true,
|
||||
compressVideoPreset: 'Passthrough',
|
||||
mediaType: 'any'
|
||||
};
|
||||
|
||||
export const VIDEO_PICKER_CONFIG: Options = {
|
||||
mediaType: 'video'
|
||||
};
|
||||
|
||||
export const TIMEOUT_CLOSE_EMOJI_KEYBOARD = 300;
|
||||
|
||||
export const MIN_HEIGHT = 48;
|
||||
export const MAX_HEIGHT = 200;
|
||||
|
||||
export const NO_CANNED_RESPONSES = 'no-canned-responses';
|
||||
|
||||
export const MARKDOWN_STYLES: Record<TMarkdownStyle, string> = {
|
||||
bold: '*',
|
||||
italic: '_',
|
||||
strike: '~',
|
||||
code: '`',
|
||||
'code-block': '```'
|
||||
};
|
|
@ -0,0 +1,199 @@
|
|||
import React, { createContext, ReactElement, useContext, useMemo, useReducer } from 'react';
|
||||
|
||||
import { IEmoji } from '../../definitions';
|
||||
import { IAutocompleteBase, TMicOrSend } from './interfaces';
|
||||
import { animateNextTransition } from '../../lib/methods/helpers';
|
||||
|
||||
type TMessageComposerContextApi = {
|
||||
setKeyboardHeight: (height: number) => void;
|
||||
setTrackingViewHeight: (height: number) => void;
|
||||
openEmojiKeyboard(): void;
|
||||
closeEmojiKeyboard(): void;
|
||||
openSearchEmojiKeyboard(): void;
|
||||
closeSearchEmojiKeyboard(): void;
|
||||
setFocused(focused: boolean): void;
|
||||
setMicOrSend(micOrSend: TMicOrSend): void;
|
||||
setMarkdownToolbar(showMarkdownToolbar: boolean): void;
|
||||
setAlsoSendThreadToChannel(alsoSendThreadToChannel: boolean): void;
|
||||
setRecordingAudio(recordingAudio: boolean): void;
|
||||
setAutocompleteParams(params: IAutocompleteBase): void;
|
||||
};
|
||||
|
||||
const FocusedContext = createContext<State['focused']>({} as State['focused']);
|
||||
const MicOrSendContext = createContext<State['micOrSend']>({} as State['micOrSend']);
|
||||
const ShowMarkdownToolbarContext = createContext<State['showMarkdownToolbar']>({} as State['showMarkdownToolbar']);
|
||||
const ShowEmojiKeyboardContext = createContext<State['showEmojiKeyboard']>({} as State['showEmojiKeyboard']);
|
||||
const ShowEmojiSearchbarContext = createContext<State['showEmojiSearchbar']>({} as State['showEmojiSearchbar']);
|
||||
const KeyboardHeightContext = createContext<State['keyboardHeight']>({} as State['keyboardHeight']);
|
||||
const TrackingViewHeightContext = createContext<State['trackingViewHeight']>({} as State['trackingViewHeight']);
|
||||
const AlsoSendThreadToChannelContext = createContext<State['alsoSendThreadToChannel']>({} as State['alsoSendThreadToChannel']);
|
||||
const RecordingAudioContext = createContext<State['recordingAudio']>({} as State['recordingAudio']);
|
||||
const AutocompleteParamsContext = createContext<State['autocompleteParams']>({} as State['autocompleteParams']);
|
||||
const MessageComposerContextApi = createContext<TMessageComposerContextApi>({} as TMessageComposerContextApi);
|
||||
|
||||
export const useMessageComposerApi = (): TMessageComposerContextApi => useContext(MessageComposerContextApi);
|
||||
export const useFocused = (): State['focused'] => useContext(FocusedContext);
|
||||
export const useMicOrSend = (): State['micOrSend'] => useContext(MicOrSendContext);
|
||||
export const useShowMarkdownToolbar = (): State['showMarkdownToolbar'] => useContext(ShowMarkdownToolbarContext);
|
||||
export const useShowEmojiKeyboard = (): State['showEmojiKeyboard'] => useContext(ShowEmojiKeyboardContext);
|
||||
export const useShowEmojiSearchbar = (): State['showEmojiSearchbar'] => useContext(ShowEmojiSearchbarContext);
|
||||
export const useKeyboardHeight = (): State['keyboardHeight'] => useContext(KeyboardHeightContext);
|
||||
export const useTrackingViewHeight = (): State['trackingViewHeight'] => useContext(TrackingViewHeightContext);
|
||||
export const useAlsoSendThreadToChannel = (): State['alsoSendThreadToChannel'] => useContext(AlsoSendThreadToChannelContext);
|
||||
export const useRecordingAudio = (): State['recordingAudio'] => useContext(RecordingAudioContext);
|
||||
export const useAutocompleteParams = (): State['autocompleteParams'] => useContext(AutocompleteParamsContext);
|
||||
|
||||
// TODO: rename
|
||||
type TMessageInnerContext = {
|
||||
sendMessage(): void;
|
||||
onEmojiSelected(emoji: IEmoji): void;
|
||||
// TODO: action should be required
|
||||
closeEmojiKeyboardAndAction(action?: Function, params?: any): void;
|
||||
};
|
||||
|
||||
// TODO: rename
|
||||
export const MessageInnerContext = createContext<TMessageInnerContext>({
|
||||
sendMessage: () => {},
|
||||
onEmojiSelected: () => {},
|
||||
closeEmojiKeyboardAndAction: () => {}
|
||||
});
|
||||
|
||||
type State = {
|
||||
showEmojiKeyboard: boolean;
|
||||
showEmojiSearchbar: boolean;
|
||||
focused: boolean;
|
||||
trackingViewHeight: number;
|
||||
keyboardHeight: number;
|
||||
micOrSend: TMicOrSend;
|
||||
showMarkdownToolbar: boolean;
|
||||
alsoSendThreadToChannel: boolean;
|
||||
recordingAudio: boolean;
|
||||
autocompleteParams: IAutocompleteBase;
|
||||
};
|
||||
|
||||
type Actions =
|
||||
| { type: 'updateEmojiKeyboard'; showEmojiKeyboard: boolean }
|
||||
| { type: 'updateEmojiSearchbar'; showEmojiSearchbar: boolean }
|
||||
| { type: 'updateFocused'; focused: boolean }
|
||||
| { type: 'updateTrackingViewHeight'; trackingViewHeight: number }
|
||||
| { type: 'updateKeyboardHeight'; keyboardHeight: number }
|
||||
| { type: 'openEmojiKeyboard' }
|
||||
| { type: 'closeEmojiKeyboard' }
|
||||
| { type: 'openSearchEmojiKeyboard' }
|
||||
| { type: 'closeSearchEmojiKeyboard' }
|
||||
| { type: 'setMicOrSend'; micOrSend: TMicOrSend }
|
||||
| { type: 'setMarkdownToolbar'; showMarkdownToolbar: boolean }
|
||||
| { type: 'setAlsoSendThreadToChannel'; alsoSendThreadToChannel: boolean }
|
||||
| { type: 'setRecordingAudio'; recordingAudio: boolean }
|
||||
| { type: 'setAutocompleteParams'; params: IAutocompleteBase };
|
||||
|
||||
const reducer = (state: State, action: Actions): State => {
|
||||
switch (action.type) {
|
||||
case 'updateEmojiKeyboard':
|
||||
return { ...state, showEmojiKeyboard: action.showEmojiKeyboard };
|
||||
case 'updateEmojiSearchbar':
|
||||
return { ...state, showEmojiSearchbar: action.showEmojiSearchbar };
|
||||
case 'updateFocused':
|
||||
animateNextTransition();
|
||||
return { ...state, focused: action.focused };
|
||||
case 'updateTrackingViewHeight':
|
||||
return { ...state, trackingViewHeight: action.trackingViewHeight };
|
||||
case 'updateKeyboardHeight':
|
||||
return { ...state, keyboardHeight: action.keyboardHeight };
|
||||
case 'openEmojiKeyboard':
|
||||
return { ...state, showEmojiKeyboard: true, showEmojiSearchbar: false };
|
||||
case 'openSearchEmojiKeyboard':
|
||||
return { ...state, showEmojiKeyboard: false, showEmojiSearchbar: true };
|
||||
case 'closeEmojiKeyboard':
|
||||
return { ...state, showEmojiKeyboard: false, showEmojiSearchbar: false };
|
||||
case 'closeSearchEmojiKeyboard':
|
||||
return { ...state, showEmojiSearchbar: false };
|
||||
case 'setMicOrSend':
|
||||
return { ...state, micOrSend: action.micOrSend };
|
||||
case 'setMarkdownToolbar':
|
||||
animateNextTransition();
|
||||
return { ...state, showMarkdownToolbar: action.showMarkdownToolbar };
|
||||
case 'setAlsoSendThreadToChannel':
|
||||
return { ...state, alsoSendThreadToChannel: action.alsoSendThreadToChannel };
|
||||
case 'setRecordingAudio':
|
||||
animateNextTransition();
|
||||
return { ...state, recordingAudio: action.recordingAudio };
|
||||
case 'setAutocompleteParams':
|
||||
return { ...state, autocompleteParams: action.params };
|
||||
}
|
||||
};
|
||||
|
||||
export const MessageComposerProvider = ({ children }: { children: ReactElement }): ReactElement => {
|
||||
const [state, dispatch] = useReducer(reducer, {
|
||||
keyboardHeight: 0,
|
||||
trackingViewHeight: 0,
|
||||
autocompleteParams: { text: '', type: null }
|
||||
} as State);
|
||||
|
||||
const api = useMemo(() => {
|
||||
const setFocused = (focused: boolean) => dispatch({ type: 'updateFocused', focused });
|
||||
|
||||
const setKeyboardHeight = (keyboardHeight: number) => dispatch({ type: 'updateKeyboardHeight', keyboardHeight });
|
||||
|
||||
const setTrackingViewHeight = (trackingViewHeight: number) =>
|
||||
dispatch({ type: 'updateTrackingViewHeight', trackingViewHeight });
|
||||
|
||||
const openEmojiKeyboard = () => dispatch({ type: 'openEmojiKeyboard' });
|
||||
|
||||
const closeEmojiKeyboard = () => dispatch({ type: 'closeEmojiKeyboard' });
|
||||
|
||||
const openSearchEmojiKeyboard = () => dispatch({ type: 'openSearchEmojiKeyboard' });
|
||||
|
||||
const closeSearchEmojiKeyboard = () => dispatch({ type: 'closeSearchEmojiKeyboard' });
|
||||
|
||||
const setMicOrSend = (micOrSend: TMicOrSend) => dispatch({ type: 'setMicOrSend', micOrSend });
|
||||
|
||||
const setMarkdownToolbar = (showMarkdownToolbar: boolean) => dispatch({ type: 'setMarkdownToolbar', showMarkdownToolbar });
|
||||
|
||||
const setAlsoSendThreadToChannel = (alsoSendThreadToChannel: boolean) =>
|
||||
dispatch({ type: 'setAlsoSendThreadToChannel', alsoSendThreadToChannel });
|
||||
|
||||
const setRecordingAudio = (recordingAudio: boolean) => dispatch({ type: 'setRecordingAudio', recordingAudio });
|
||||
|
||||
const setAutocompleteParams = (params: IAutocompleteBase) => dispatch({ type: 'setAutocompleteParams', params });
|
||||
|
||||
return {
|
||||
setFocused,
|
||||
setKeyboardHeight,
|
||||
setTrackingViewHeight,
|
||||
openEmojiKeyboard,
|
||||
closeEmojiKeyboard,
|
||||
openSearchEmojiKeyboard,
|
||||
closeSearchEmojiKeyboard,
|
||||
setMicOrSend,
|
||||
setMarkdownToolbar,
|
||||
setAlsoSendThreadToChannel,
|
||||
setRecordingAudio,
|
||||
setAutocompleteParams
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MessageComposerContextApi.Provider value={api}>
|
||||
<ShowEmojiKeyboardContext.Provider value={state.showEmojiKeyboard}>
|
||||
<ShowEmojiSearchbarContext.Provider value={state.showEmojiSearchbar}>
|
||||
<FocusedContext.Provider value={state.focused}>
|
||||
<KeyboardHeightContext.Provider value={state.keyboardHeight}>
|
||||
<TrackingViewHeightContext.Provider value={state.trackingViewHeight}>
|
||||
<ShowMarkdownToolbarContext.Provider value={state.showMarkdownToolbar}>
|
||||
<AlsoSendThreadToChannelContext.Provider value={state.alsoSendThreadToChannel}>
|
||||
<RecordingAudioContext.Provider value={state.recordingAudio}>
|
||||
<AutocompleteParamsContext.Provider value={state.autocompleteParams}>
|
||||
<MicOrSendContext.Provider value={state.micOrSend}>{children}</MicOrSendContext.Provider>
|
||||
</AutocompleteParamsContext.Provider>
|
||||
</RecordingAudioContext.Provider>
|
||||
</AlsoSendThreadToChannelContext.Provider>
|
||||
</ShowMarkdownToolbarContext.Provider>
|
||||
</TrackingViewHeightContext.Provider>
|
||||
</KeyboardHeightContext.Provider>
|
||||
</FocusedContext.Provider>
|
||||
</ShowEmojiSearchbarContext.Provider>
|
||||
</ShowEmojiKeyboardContext.Provider>
|
||||
</MessageComposerContextApi.Provider>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
import { TAutocompleteItem } from '../interfaces';
|
||||
|
||||
export const fetchIsAllOrHere = (item: TAutocompleteItem) => item.id === 'all' || item.id === 'here';
|
|
@ -1,6 +1,6 @@
|
|||
import { ImageOrVideo } from 'react-native-image-crop-picker';
|
||||
|
||||
import { isIOS } from '../../lib/methods/helpers';
|
||||
import { isIOS } from '../../../lib/methods/helpers';
|
||||
|
||||
const regex = new RegExp(/\.[^/.]+$/); // Check from last '.' of the string
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue