chore: Merge 4.47.0 into master (#5611)

This commit is contained in:
Diego Mello 2024-03-12 10:45:30 -03:00 committed by GitHub
parent e742288405
commit d34ff3d71d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
334 changed files with 28176 additions and 14335 deletions

View File

@ -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

View File

@ -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'

View File

@ -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"),

View File

@ -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;

View File

@ -0,0 +1,3 @@
export default {
openPicker: jest.fn().mockImplementation(() => Promise.resolve())
};

View File

@ -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(() => ({

View File

@ -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

View File

@ -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

View File

@ -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]

View File

@ -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>
);

View File

@ -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']);

View File

@ -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
};
}

View File

@ -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
};
}

View File

@ -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}

View File

@ -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('')}

View File

@ -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' />

View File

@ -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'
}
]}
>

View File

@ -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>

View File

@ -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>

View File

@ -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),

View File

@ -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
});
}

View File

@ -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;

View File

@ -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;

View File

@ -1,4 +0,0 @@
import React from 'react';
const MessageboxContext = React.createContext<any>(null);
export default MessageboxContext;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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>
);
}
}

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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 };

View File

@ -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;

View File

@ -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

View File

@ -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
}
});

View File

@ -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();
});
});
});

View File

@ -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>
);
};

View File

@ -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

View File

@ -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;
};

View File

@ -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>
);
};

View File

@ -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>
</>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
</>
);
};

View File

@ -0,0 +1 @@
export * from './Autocomplete';

View File

@ -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;
};

View File

@ -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' />;
};

View File

@ -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
}
});

View File

@ -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;
};

View File

@ -0,0 +1,3 @@
export * from './ActionsButton';
export * from './BaseButton';
export * from './MicOrSendButton';

View File

@ -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 />
</>
);
};

View File

@ -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
}
});

View File

@ -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>

View File

@ -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;

View File

@ -0,0 +1,4 @@
import React from 'react';
import { View } from 'react-native';
export const Gap = () => <View style={{ width: 12 }} />;

View File

@ -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;
}

View File

@ -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}
/>
);
};

View File

@ -0,0 +1 @@
export * from './Quotes';

View File

@ -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' />
);

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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
}
});

View File

@ -0,0 +1 @@
export * from './RecordAudio';

View File

@ -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}`;
};

View File

@ -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
}
});

View File

@ -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>
);

View File

@ -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'
/>
</>
);
};

View File

@ -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>
);
};

View File

@ -0,0 +1,4 @@
import React from 'react';
import { View } from 'react-native';
export const EmptySpace = () => <View style={{ flex: 1 }} />;

View File

@ -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'
/>
</>
);
};

View File

@ -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>
);
};

View File

@ -0,0 +1 @@
export * from './Toolbar';

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -0,0 +1,2 @@
export * from './Left';
export * from './Right';

View File

@ -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';

View File

@ -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': '```'
};

View File

@ -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>
);
};

View File

@ -0,0 +1,3 @@
import { TAutocompleteItem } from '../interfaces';
export const fetchIsAllOrHere = (item: TAutocompleteItem) => item.id === 'all' || item.id === 'here';

View File

@ -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