Compare commits

...

77 Commits

Author SHA1 Message Date
Mattermost Build
0fe72d1c10 Bump Version 1.48.1 build 381 (#5860) (#5861)
* Bump app version number to  1.48.1

* Bump app build number to  381

(cherry picked from commit f453c77ac6)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-12-03 11:46:26 +02:00
Elias Nahum
d3211b66a3 Fix android broken build cause by rudderstack (#5856)
* Fix android broken build cause by rudderstack

* update dependencies
2021-11-30 11:51:09 +02:00
Mattermost Build
39bdb69fdd Added YouTube query to Android Manifest (#5841) (#5855)
(cherry picked from commit 1c0d0bb1a4)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-11-30 11:32:42 +02:00
Mattermost Build
8222390c98 MM-36687, MM-38302, MM-37598 Fix push notifications with CRT (#5669) (#5853)
* initalised

* Removed unused packages

* Android: Added groupId for supporting both threadId & channelId

* Fixed ios condition check

* Removed commented code

* Removed unwanted condition

* Removed unused variable

* CRT reduced chunk size to 30, Android global threads showing GlobalThreads & iOS is_crt_enabled field is expected to be a boolean

* Update android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>

* Misc fixes

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
(cherry picked from commit 805b90205a)

Co-authored-by: Anurag Shivarathri <anurag6713@gmail.com>
2021-11-30 11:32:28 +02:00
Mattermost Build
456208d223 [MM-40160] Crt gallery fix (#5833) (#5852)
* Fix Gallery crash

* fix file type icons

* memoize gallery files

(cherry picked from commit 1b285dac49)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-11-30 11:32:17 +02:00
Mattermost Build
2e71fcc226 Bump app build number to 380 (#5828) (#5829)
(cherry picked from commit e4dafb4d3b)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-11-11 16:53:15 -03:00
Mattermost Build
04d7834024 replace account-group-outline with account-multiple-outline compass icon (#5826) (#5827)
(cherry picked from commit a570fdea24)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-11-11 16:25:49 -03:00
Mattermost Build
7888b971e1 Bump app build number to 379 (#5824) (#5825)
(cherry picked from commit 94f683d743)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-11-11 08:36:49 -03:00
Jesús Espino
49462bd4a0 Voicechannels (#5753)
* Some extra work on voice channels interface

* Fixing some TODOs

* Improving styling of call in channel

* Improve calls monitoring

* Replacing some of the fontawesome icons with the compass ones

* Improving the layout

* Migrating to webrtc2 for unified plan

* Add screen on and off behavior

* Adding incall manager plugin

* Moving everything into the products/calls folder

* Make products modules routes relatives

* Make products modules routes @mmproducts

* Removing initiator parameter

* Removing trickle parameter

* Simplifying code

* Removing underscore from private variables

* Removing underscore from private things

* More simplifications

* More simplifications

* More simplifications

* Changing sha sum for mmjstool

* Fixing typo

* Migrating simple-peer to typescript

* Migrating simple-peer to typescript

* Improving the size of the screen share

* Adding feature flag to disable the calls feature in mobile

* Fixing some tests

* Removing obsolte tests

* Added call ended support for the post messages

* Fixing some warnings in the tests

* Adding JoinCall tests

* Adding CallMessage tests

* Adding CurrentCall unit tests

* Adding CallAvatar unit tests

* Adding FloatingCallContainer unit tests

* Adding StartCall unit tests

* Adding EnableDisableCalls unit tests

* Adding CallDuration tests

* Improving CallDuration tests

* Adding CallScreen unit tests

* Adding CallOtherActions screen tests

* Fixing some dark theme styles

* Fixing tests

* More robustness around connecting/disconnecting

* Adding FormattedRelativeTime tests

* Adding tests for ChannelItem

* Adding tests for ChannelInfo

* Adding selectors tests

* Adding reducers unit tests

* Adding actions tests

* Removing most of the TODOs

* Removing another TODO

* Updating tests snapshots

* Removing the last TODO

* Fixed a small problem on pressing while a call is ongoing

* Remove all the inlined functions

* Replacing usage of isLandscape selector with useWindowDimensions

* Removed unnecesary makeStyleSheetFromTheme

* Removing unneded  properties from call_duration

* Fixing possible null channels return from getChannel selector

* Moving other inlined functions to its own constant

* Simplifiying enable/disable calls component

* Improving the behavior when you are in the call of the current channel

* Adding missing translation strings

* Simplified a bit the EnableDisableCalls component

* Moving other inlined functions to its own constant

* Updating snapshots

* Improving usage of makeStyleSheetFromTheme

* Moving data reformating from the rest client to the redux action

* Adding calls to the blocklist to the redux-persist

* Fixing tests

* Updating snapshots

* Update file icon name to the last compass icons version

* Fix loading state

* Only show the call connected if the websocket gets connected

* Taking into consideration the indicator bar to position the calls new bars

* Making the MoreMessagesButton component aware of calls components

* Updating snapshots

* Fixing tests

* Updating snapshot

* Fixing different use cases for start call channel menu

* Fixing tests

* Ask for confirmation to start a call when you are already in another call

* Update app/products/calls/components/floating_call_container.tsx

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>

* Memoizing userIds in join call

* Applying suggestion around combine the blocklist for calls with the one for typing

* Adding explicit types to the rest client

* Removing unneeded permission

* Making updateIntervalInSeconds prop optional in FormattedRelativeTime

* Making updateIntervalInSeconds prop optional in CallDuration

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-11-11 11:33:30 +01:00
Mattermost Build
eb8579e84c Fix options modal dismiss for OptionsModal (#5819) (#5822) 2021-11-10 09:27:57 -03:00
Puerco
41caf0d865 Automated cherry pick of #5816 on release-1.48 (#5817) 2021-11-08 19:54:47 +01:00
Mattermost Build
6a91b6544d Version 1.48.0 build 378 (#5811) (#5814)
* Bump app version number to  1.48.0

* Bump app build number to  378

(cherry picked from commit cbc4d5add2)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-11-04 09:24:44 -03:00
Mattermost Build
75628cdf2e Disable reachability test (#5812) (#5813)
(cherry picked from commit ec3b44498a)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-11-04 09:24:29 -03:00
Puerco
c8ee3bc722 Automated cherry pick of #5803 on release-1.48 (#5810)
* Translated using Weblate (Turkish)

Currently translated at 100.0% (778 of 778 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/tr/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (778 of 778 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/zh_Hans/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (778 of 778 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/sv/

* Translated using Weblate (Polish)

Currently translated at 100.0% (778 of 778 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/pl/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (778 of 778 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/hu/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (778 of 778 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/es/

* Translated using Weblate (English (Australia))

Currently translated at 100.0% (778 of 778 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/en_AU/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (778 of 778 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/nl/

Translated using Weblate (Dutch)

Currently translated at 100.0% (778 of 778 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/nl/

* Translated using Weblate (German)

Currently translated at 100.0% (778 of 778 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/de/

* Translated using Weblate (Korean)

Currently translated at 93.9% (730 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/ko/

* Translated using Weblate (Japanese)

Currently translated at 100.0% (778 of 778 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/ja/

Translated using Weblate (Japanese)

Currently translated at 100.0% (777 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/ja/

Co-authored-by: Kaya Zeren <kayazeren@gmail.com>
Co-authored-by: aeomin <lin@aeomin.net>
Co-authored-by: MArtin Johnson <martinjohnson@bahnhof.se>
Co-authored-by: master7 <marcin.karkosz@rajska.info>
Co-authored-by: Tóth Csaba // Online ERP Hungary Kft <csaba.toth@online-erp.hu>
Co-authored-by: Elias  Nahum <elias@mattermost.com>
Co-authored-by: Matthew Williams <Matthew.Williams@outlook.com.au>
Co-authored-by: Tom De Moor <tom@controlaltdieliet.be>
Co-authored-by: jprusch <rs@schaeferbarthold.de>
Co-authored-by: teamzamong <heekang@korea.ac.kr>
Co-authored-by: kaakaa <stooner.hoe@gmail.com>
2021-11-04 09:07:55 +01:00
Mattermost Build
3011992995 Update NOTICE.txt (#5805) (#5809)
(cherry picked from commit 800f3b5648)

Co-authored-by: Amy Blais <29708087+amyblais@users.noreply.github.com>
2021-11-03 11:34:33 -03:00
Mattermost Build
e23960f27d Updated Jump to to match webapp (#5797) (#5808)
Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
(cherry picked from commit fae944c208)

Co-authored-by: Carrie Warner (Mattermost) <74422101+cwarnermm@users.noreply.github.com>
2021-11-03 11:34:19 -03:00
Mattermost Build
84a292003e fix replaceAll with replace as is not available on some JSC (#5801) (#5806) 2021-11-02 04:14:15 -03:00
Elias Nahum
8287e620d8 Upgrade to rn 0.66.1 (#5727)
* Upgrade to rn 0.66.0

* Add keys to re-render post list and channel list

* Finish dep updates and rn to 0.66.1

* upgrade more dependencies

* Fix select_server tests
2021-10-31 13:57:07 -03:00
Kyriakos Z
094a34d05f MM-37934: adds CRT channel notification preferences (#5744)
* MM-37934: adds CRT channel notification preferences

Adds push notification preferences per channel for CRT replies.
These are only enabled when push notifications for root messages
are set to 'mention', in any other case we don't show the switch.

* Adds another test case

Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
2021-10-28 11:06:03 +03:00
Daniel Espino García
76284a78d5 MM-34194 Add multiselect to Apps Forms (#5400)
* Add multiselect to Apps Forms

* Address feedback

* Add selected information and change button name

* Fix styles

* Add missing semicolon and change currentList to currentSelected.

* Address UX feedback

* Fix test

* Address feedback

* Fix snapshots

* Potential fix for flaky test

* Fix iOS back button

* Address feedback

* Fix tests

* Add separator and scroll to bottom on selected items

* Use setTimeout for scroll and also scroll on unselected

* Fix lint

* Fix tsc

* Fix tests
2021-10-27 11:35:11 +02:00
master7
434f4c970e Translated using Weblate (Polish)
Currently translated at 100.0% (777 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/pl/
2021-10-25 11:50:26 -05:00
Tóth Csaba // Online ERP Hungary Kft
b7e9cd296b Translated using Weblate (Hungarian)
Currently translated at 100.0% (777 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/hu/
2021-10-25 11:50:26 -05:00
yeongeun.seo
f98897147d Translated using Weblate (Korean)
Currently translated at 93.1% (724 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/ko/
2021-10-25 11:50:26 -05:00
Anne-Laure Gaillard
0989ad9208 Translated using Weblate (French)
Currently translated at 99.7% (775 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/fr/

Translated using Weblate (French)

Currently translated at 99.6% (774 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/fr/
2021-10-25 11:50:26 -05:00
Joseph Baylon
3ddc34d737 Detox/E2E: Fix failures due to recent features (#5755) 2021-10-24 19:00:37 -07:00
Elias Nahum
de564513c3 Bump app build number to 377 (#5786) 2021-10-24 16:54:11 -03:00
Elias Nahum
dd866ffeb8 Fix iOS paste screenshot (#5785) 2021-10-24 16:51:24 -03:00
Elias Nahum
acffc7796f Bump to 1.47.2 Build 376 (#5783)
* Bump app version number to  1.47.2

* Bump app build number to  376
2021-10-24 15:01:23 -03:00
Elias Nahum
61c6f5693b Fix TextInput blur crash (#5780) 2021-10-24 14:43:44 -03:00
Anurag Shivarathri
fe12190f4b App crash in android sometimes while opening the notifications (#5776)
* Checks for condition before removing the notification

* Misc
2021-10-24 14:40:28 -03:00
Guillermo Vayá
2a4027f74a Added checklist to PR template (#5766)
Co-authored-by: = <=>
2021-10-20 12:54:04 +02:00
Elias Nahum
c3cb9a5d34 Bump Version 1.47.1 build 375 (#5769)
* Fastlane build and version

* Bump app version number to  1.47.1

* Bump app build number to  375
2021-10-19 16:14:34 -03:00
Matthew Birtch
7df996dcc6 updated ios app icon to flattened version as per marketing team request (#5767) 2021-10-19 16:14:08 -03:00
Elias Nahum
e5142f8524 Fix Post Input and drawers gesture conflict (#5762) 2021-10-18 17:55:25 -03:00
Anurag Shivarathri
789cf9ab54 MM-39348 CRT crash (#5759)
* Cancelling animation frame on unmounting the component

* avoid calling cancel animation twice

* Update app/components/post_draft/post_draft.js

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-10-18 14:26:33 -03:00
Tóth Csaba // Online ERP Hungary Kft
963d04545e Translated using Weblate (Hungarian)
Currently translated at 100.0% (777 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/hu/
2021-10-18 11:22:42 -05:00
prograde
c58c23a10e Translated using Weblate (Swedish)
Currently translated at 100.0% (777 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/sv/
2021-10-18 11:22:42 -05:00
MArtin Johnson
0708a4d81b Translated using Weblate (Swedish)
Currently translated at 100.0% (777 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/sv/
2021-10-18 11:22:42 -05:00
aeomin
d96ecb0fdb Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (777 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/zh_Hans/
2021-10-18 11:22:42 -05:00
Tom De Moor
fff455624e Translated using Weblate (Dutch)
Currently translated at 99.8% (776 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/nl/
2021-10-18 11:22:42 -05:00
kaakaa
f18abe11f6 Translated using Weblate (Japanese)
Currently translated at 100.0% (777 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/ja/
2021-10-18 11:22:42 -05:00
Diego Alvarez
a435c96f2e Translated using Weblate (Spanish)
Currently translated at 100.0% (777 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/es/
2021-10-18 11:22:42 -05:00
Elias Nahum
45efbe1c97 Translated using Weblate (Spanish)
Currently translated at 100.0% (777 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/es/
2021-10-18 11:22:42 -05:00
Elias Nahum
f8baaf8505 MM-39337 set allowFontScaling to TextInput components (#5751) 2021-10-14 13:42:09 -03:00
Joseph Baylon
ebb00a205c Detox/E2E: Update deps (#5747)
* Detox/E2E: Update deps

* Add package-lock.json files
2021-10-12 15:09:42 -07:00
Elisabeth Kulzer
c4cb7b0c3e Translations fix (#5746)
* Translated using Weblate (French)

Currently translated at 100.0% (773 of 773 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/fr/

* Translated using Weblate (Japanese)

Currently translated at 100.0% (773 of 773 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/ja/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (773 of 773 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/hu/

* Translated using Weblate (English (Australia))

Currently translated at 100.0% (773 of 773 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/en_AU/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (773 of 773 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/es/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (773 of 773 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/sv/

* Translated using Weblate (Polish)

Currently translated at 100.0% (773 of 773 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/pl/

* Translated using Weblate (Turkish)

Currently translated at 100.0% (773 of 773 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/tr/

* Translated using Weblate (German)

Currently translated at 100.0% (777 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/de/

Translated using Weblate (German)

Currently translated at 100.0% (777 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/de/

* Translated using Weblate (German)

Currently translated at 100.0% (777 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/de/

* Translated using Weblate (German)

Currently translated at 100.0% (777 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/de/

* Translated using Weblate (German)

Currently translated at 100.0% (777 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/de/

* Translated using Weblate (Polish)

Currently translated at 100.0% (777 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/pl/

* Translated using Weblate (Turkish)

Currently translated at 100.0% (777 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/tr/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (777 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/sv/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (777 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/hu/

* Translated using Weblate (English (Australia))

Currently translated at 100.0% (777 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/en_AU/

* Translated using Weblate (Spanish)

Currently translated at 99.4% (773 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/es/

* Translated using Weblate (French)

Currently translated at 99.4% (773 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/fr/

* Translated using Weblate (Italian)

Currently translated at 76.1% (592 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/it/

* Translated using Weblate (Japanese)

Currently translated at 99.4% (773 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/ja/

* Translated using Weblate (Korean)

Currently translated at 80.3% (624 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/ko/

* Translated using Weblate (Dutch)

Currently translated at 99.2% (771 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/nl/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 86.6% (673 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/pt_BR/

* Translated using Weblate (Romanian)

Currently translated at 85.9% (668 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/ro/

* Translated using Weblate (Russian)

Currently translated at 96.3% (749 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/ru/

* Translated using Weblate (Ukrainian)

Currently translated at 76.8% (597 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/uk/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 99.2% (771 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/zh_Hans/

* Translated using Weblate (Chinese (Traditional))

Currently translated at 76.0% (591 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/zh_Hant/

* Translated using Weblate (Bulgarian)

Currently translated at 96.9% (753 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/bg/

* Update translation files

Updated by "Remove blank strings" hook in Weblate.

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/

* Update translation files

Updated by "Remove blank strings" hook in Weblate.

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/

* Update translation files

Updated by "Remove blank strings" hook in Weblate.

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/

* Update translation files

Updated by "Remove blank strings" hook in Weblate.

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/

* Update translation files

Updated by "Remove blank strings" hook in Weblate.

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/

* Update translation files

Updated by "Remove blank strings" hook in Weblate.

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/

Co-authored-by: Nathanaël <contact@nathanaelhoun.fr>
Co-authored-by: kaakaa <stooner.hoe@gmail.com>
Co-authored-by: Tóth Csaba // Online ERP Hungary Kft <csaba.toth@online-erp.hu>
Co-authored-by: Matthew Williams <Matthew.Williams@outlook.com.au>
Co-authored-by: Elias  Nahum <elias@mattermost.com>
Co-authored-by: MArtin Johnson <martinjohnson@bahnhof.se>
Co-authored-by: master7 <marcin.karkosz@rajska.info>
Co-authored-by: Kaya Zeren <kayazeren@gmail.com>
Co-authored-by: jprusch <rs@schaeferbarthold.de>
Co-authored-by: JtheBAB <srast@bioc.uzh.ch>
Co-authored-by: Erik Pfeiffer <erik@pfeiffers.it>
Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
2021-10-12 18:12:53 +02:00
Weblate (bot)
729f098019 Translations update from Weblate (#5743)
* Translated using Weblate (French)

Currently translated at 100.0% (773 of 773 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/fr/

* Translated using Weblate (Japanese)

Currently translated at 100.0% (773 of 773 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/ja/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (773 of 773 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/hu/

* Translated using Weblate (English (Australia))

Currently translated at 100.0% (773 of 773 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/en_AU/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (773 of 773 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/es/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (773 of 773 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/sv/

* Translated using Weblate (Polish)

Currently translated at 100.0% (773 of 773 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/pl/

* Translated using Weblate (Turkish)

Currently translated at 100.0% (773 of 773 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/tr/

* Translated using Weblate (German)

Currently translated at 100.0% (777 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/de/

Translated using Weblate (German)

Currently translated at 100.0% (777 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/de/

* Translated using Weblate (German)

Currently translated at 100.0% (777 of 777 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/de/

Co-authored-by: Nathanaël <contact@nathanaelhoun.fr>
Co-authored-by: kaakaa <stooner.hoe@gmail.com>
Co-authored-by: Tóth Csaba // Online ERP Hungary Kft <csaba.toth@online-erp.hu>
Co-authored-by: Matthew Williams <Matthew.Williams@outlook.com.au>
Co-authored-by: Elias  Nahum <elias@mattermost.com>
Co-authored-by: MArtin Johnson <martinjohnson@bahnhof.se>
Co-authored-by: master7 <marcin.karkosz@rajska.info>
Co-authored-by: Kaya Zeren <kayazeren@gmail.com>
Co-authored-by: jprusch <rs@schaeferbarthold.de>
Co-authored-by: JtheBAB <srast@bioc.uzh.ch>
2021-10-12 09:22:14 +02:00
Anurag Shivarathri
856d8bd05f MM-34842 global threads options (#5630)
* fixes MM-37294 MM-37296 MM-37297

* Added conditions to check for post & thread existence

* Update app/mm-redux/selectors/entities/threads.ts

Co-authored-by: Kyriakos Z. <3829551+koox00@users.noreply.github.com>

* type fix

* Never disabling Mark All as unread

* Added follow/unfollow message for not yet thread posts

* Test case fix for mark all as unread enabled all the time

* Removed hardcoded condition

* Fixed MM-37509

* Updated snapshot for sidebar

* Global thread actions init

* Added options

* Update post_options.js

* Test cases fix

* Added border bottom for each thread option

* Update test case

* Reverting snapshot

* Updated snapshot

* Moved options to screens & removed redundants translations

* Reusing post_option for thread_option

* Component name changed to PostOption from ThreadOption

* Snapshot updated

* Removed factory

* Update app/screens/thread_options/index.ts

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>

* Update app/screens/thread_options/index.ts

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>

Co-authored-by: Kyriakos Z. <3829551+koox00@users.noreply.github.com>
Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-10-11 13:24:38 +05:30
Elias Nahum
5e0c75d772 Bump app build number to 374 (#5741) 2021-10-10 11:52:35 -03:00
Elias Nahum
93dbe4af9e Bump app build number to 373 (#5739) 2021-10-10 11:08:02 -03:00
Elias Nahum
16583c2a3b Fix send message with physical keyboard on iPad 15 (#5737) 2021-10-10 10:49:11 -03:00
Elias Nahum
42071314d3 Bump app build number to 372 (#5734) 2021-10-08 09:49:07 -03:00
Ben Lloyd Pearson
6586d1b283 Update Readme intro and add logo (#5729)
* Update intro and add logo

* Update metadata to new messaging

* Update README.md

Co-authored-by: Amy Blais <29708087+amyblais@users.noreply.github.com>

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
Co-authored-by: Amy Blais <29708087+amyblais@users.noreply.github.com>
2021-10-08 09:37:53 -03:00
Shaz Amjad
a3c00f59d6 Local only collapse state for mobile (#5721) 2021-10-08 09:32:31 -03:00
Shaz Amjad
000caf09e9 Hides favourites category if empty (#5722) 2021-10-08 09:32:19 -03:00
Shaz Amjad
bd74310f29 Cancel button restored (#5725) 2021-10-08 09:31:56 -03:00
Anurag Shivarathri
a81b56212e MM-38784, MM-38409 New messages line in threads screen & Pull to refresh for global threads screen (#5690)
* New messages line in threads & Pull to refresh for global threads

* Updated snapshot

* Using postlist's RefreshControl component

* Updated threadlist snapshot

* Reverting snapshot

* Updated Snapshots

* Snapshots updated

* Added 'thread last viewed at' to handle new messages line correctly

* Lint fix

* Updated snapshots

* Reverted comparision check

* Remove unused code

* Batching actions

* Do not add new message line for self messages

Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
2021-10-07 15:14:34 +05:30
Anurag Shivarathri
1a35250811 Excluding follow button in the header from being added to the stack (#5723) 2021-10-06 18:07:20 -04:00
Elias Nahum
f1c3538283 MM-39054 patch Android react-native-file-viewer (#5717) 2021-10-06 10:12:10 -03:00
Weblate (bot)
7dd3fbb75d Translated using Weblate (German) (#5719)
Currently translated at 100.0% (773 of 773 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/de/

Co-authored-by: JtheBAB <srast@bioc.uzh.ch>
2021-10-05 10:06:29 -05:00
Claudio Costa
5260e252a4 Show confirm modal for at here mentions (#5678) 2021-10-05 15:27:25 +02:00
Elias Nahum
e301c9d5d1 Bump app build number to 371 (#5715) 2021-10-04 15:56:51 -03:00
Weblate (bot)
0332b52ee2 Translations update from Weblate (#5713)
* Translated using Weblate (Swedish)

Currently translated at 100.0% (771 of 771 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/sv/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (771 of 771 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/es/

Co-authored-by: MArtin Johnson <martinjohnson@bahnhof.se>
Co-authored-by: Elias  Nahum <elias@mattermost.com>
2021-10-04 11:36:09 -05:00
Elias Nahum
a28a3826fa update dependencies (#5706) 2021-10-01 11:45:53 -03:00
Weblate (bot)
8447d7feea Translations update from Weblate (#5694)
* Translated using Weblate (German)

Currently translated at 100.0% (771 of 771 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/de/

* Translated using Weblate (French)

Currently translated at 100.0% (771 of 771 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/fr/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (771 of 771 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/nl/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (771 of 771 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/zh_Hans/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (771 of 771 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/hu/

* Translated using Weblate (English (Australia))

Currently translated at 100.0% (771 of 771 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/en_AU/

* Translated using Weblate (Japanese)

Currently translated at 100.0% (771 of 771 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/ja/

* Translated using Weblate (Polish)

Currently translated at 100.0% (771 of 771 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/pl/

* Translated using Weblate (Turkish)

Currently translated at 100.0% (771 of 771 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/tr/

Translated using Weblate (Turkish)

Currently translated at 100.0% (771 of 771 strings)

Translation: mattermost-languages-shipped/mattermost-mobile
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_master/tr/

Co-authored-by: JtheBAB <srast@bioc.uzh.ch>
Co-authored-by: Nathanaël <contact@nathanaelhoun.fr>
Co-authored-by: Tom De Moor <tom@controlaltdieliet.be>
Co-authored-by: aeomin <lin@aeomin.net>
Co-authored-by: Tóth Csaba // Online ERP Hungary Kft <csaba.toth@online-erp.hu>
Co-authored-by: Matthew Williams <Matthew.Williams@outlook.com.au>
Co-authored-by: kaakaa <stooner.hoe@gmail.com>
Co-authored-by: master7 <marcin.karkosz@rajska.info>
Co-authored-by: Kaya Zeren <kayazeren@gmail.com>
2021-09-30 09:50:44 -03:00
Elias Nahum
523777a207 Use @mattermost/react-native-paste-input to allow pasting of images & files (#5703)
* Use @mattermost/react-native-paste-input to allow pasting of images & files

* upgrade @mattermost/react-native-paste-input
2021-09-30 09:34:57 -03:00
Elias Nahum
d9e8b3e08e Bump app build number to 370 (#5700) 2021-09-28 11:38:35 -03:00
Shaz Amjad
b36dbf9b34 [MM-38683] Android Initialisation Bug + BottomSheet Correction (#5685)
* Fixes various init bugs for android

* Ignores Type Error

* MM-38683: Removes 'cancel' button.

* MM-38683: Removes some 'hideCancel' props.

* Android bottom margin

* Update options modal style

Co-authored-by: Martin Kraft <martin@upspin.org>
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-09-28 11:28:20 -03:00
Elias Nahum
7e9c574c3f Fix android extension crash (#5696) 2021-09-28 11:27:46 -03:00
Daniel Espino García
3668cb62d3 MM-34811 Add plugin oauth support (#5395)
* Add plugin oauth support

* Fix types

* Switch go to screen by show modal

* Add content width metadata to show correctly the sites

* Remove unneeded metadata

* Fix tsc

* Fix lint

Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
2021-09-27 18:17:56 +02:00
Daniel Espino García
a2ed6ba3bd Sync with App Command Parser with Webapp (#5631)
* Sync with App Command Parser with Webapp

* Add i18n

* Patch flaky test
2021-09-27 14:54:32 +02:00
Daniel Espino García
dd36545079 [MM-36041] Ensure post options and app commands always have the bindings for its channel (#5563)
* Ensure post options always have the bindings for its channel

* Mimic webapp model

* Address feedback

* Add return to validate bindings

* Address feedback

* Use empty bindings constant to avoid rerenderings

Co-authored-by: Michael Kochell <6913320+mickmister@users.noreply.github.com>
2021-09-24 10:32:58 -04:00
Daniel Espino García
edfd743699 [MM-38216] Add multiteam mentions and saved posts (#5677)
* Add multiteam mentions and saved posts

* Minor fix

* Fix long names

* Add tests, improve separation styling, revert changes on the flagged posts client, and omit the team name when the user only belongs to one team

* Fix separator on iOS

* Update snapshot

* Fix separator

* Fix snapshot

* Differentiate styling between iOS and Android

* Change channelTeamName to teamName
2021-09-24 09:35:01 -03:00
Daniel Espino García
6dbb537f22 Open forms in bindings on all locations (#5665) 2021-09-24 09:34:40 -03:00
Daniel Espino García
aa776e4ae6 Fix warnings on channel info row Formatted Text (#5644) 2021-09-23 21:55:34 +02:00
Amy Blais
1287357e7c Update NOTICE.txt (#5688) 2021-09-22 15:35:30 -03:00
Elias Nahum
ada6be9b7a Update dependencies (#5686)
* Update dependencies

* Fix unsigned builds
2021-09-22 13:54:12 -03:00
452 changed files with 37139 additions and 30443 deletions

View File

@@ -14,7 +14,7 @@ executors:
NODE_ENV: production
BABEL_ENV: production
docker:
- image: circleci/android:api-29-node
- image: circleci/android:api-30-node
working_directory: ~/mattermost-mobile
resource_class: <<parameters.resource_class>>
@@ -24,7 +24,7 @@ executors:
NODE_ENV: production
BABEL_ENV: production
macos:
xcode: "12.1.0"
xcode: "13.0.0"
working_directory: ~/mattermost-mobile
shell: /bin/bash --login -o pipefail

View File

@@ -57,7 +57,7 @@
"newlines-between": "always",
"pathGroups": [
{
"pattern": "@(@react-native-community|@react-native-cookies|@react-navigation|@rudderstack|@sentry|@testing-library|@storybook)/**",
"pattern": "@(@react-native-async-storage|@react-native-community|@react-native-cookies|@react-navigation|@rudderstack|@sentry|@testing-library|@storybook)/**",
"group": "external",
"position": "before"
},

View File

@@ -23,11 +23,10 @@ node_modules/react-native/flow/
[options]
emoji=true
esproposal.optional_chaining=enable
esproposal.nullish_coalescing=enable
exact_by_default=true
format.bracket_spacing=false
module.file_ext=.js
module.file_ext=.json
module.file_ext=.ios.js
@@ -64,4 +63,4 @@ untyped-import
untyped-type-import
[version]
^0.137.0
^0.158.0

View File

@@ -26,6 +26,7 @@ Place an '[x]' (no spaces) in all applicable fields. Please remove unrelated fie
- [ ] Added or updated unit tests (required for all new features)
- [ ] Has UI changes
- [ ] Includes text changes and localization file updates
- [ ] Have tested against the 5 core themes to ensure consistency between them.
#### Device Information
This PR was tested on: <!-- Device name(s), OS version(s) -->

View File

@@ -43,6 +43,41 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
---
## @mattermost/react-native-paste-input
This product contains '@mattermost/react-native-paste-input' by Mattermost.
React Native TextInput component have functionality to capture text input from a user by using the soft and hardware keyboards but lacks the ability to restrict copy & paste options as well as allwing pasting different files formats copied from other apps, like images & videos from the Photos gallery app.
* HOMEPAGE:
* https://github.com/mattermost/react-native-paste-input
* LICENSE: MIT
MIT License
Copyright (c) 2020 Elias Nahum
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## @react-native-community/async-storage
This product contains 'async-storage' by Krzysztof Borowy.
@@ -532,6 +567,29 @@ SOFTWARE.
---
## @types/redux-mock-store
This product contains '@types/redux-mock-store' by Redux.
A mock store for testing Redux async action creators and middleware. The mock store will create an array of dispatched actions which serve as an action log for tests.
* HOMEPAGE:
* https://github.com/reduxjs/redux-mock-store
* LICENSE: MIT
The MIT License (MIT)
Copyright (c) 2017 Arnaud Benard
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
---
## analytics-react-native
This product contains a modified version of 'analytics-react-native' by Segment.

View File

@@ -1,14 +1,14 @@
# Mattermost Mobile
# Mattermost Mobile App
[![Mattermost](https://user-images.githubusercontent.com/7205829/136108314-75cd2e1f-4147-4cfa-a16c-9b3b0313ea25.png)](https://mattermost.com)
- **Minimum Server versions:** Current ESR version (5.37.0)
- **Supported iOS versions:** 11+
- **Supported iOS versions:** 12.1+
- **Supported Android versions:** 7.0+
Mattermost is an open source Slack-alternative used by thousands of companies around the world in 14 languages. Learn more at [https://about.mattermost.com](https://about.mattermost.com).
You can download our apps from the [App Store](https://about.mattermost.com/mattermost-ios-app/) or [Google Play Store](https://about.mattermost.com/mattermost-android-app/), or [build them yourself](https://developers.mattermost.com/contribute/mobile/build-your-own/).
[Mattermost](https://mattermost.com) is an open source platform for secure collaboration across the entire software development lifecycle. This repo is for the mobile app that runs on Android and iOS. You can download our apps from the [App Store](https://about.mattermost.com/mattermost-ios-app/) or [Google Play Store](https://about.mattermost.com/mattermost-android-app/), or [build them yourself](https://developers.mattermost.com/contribute/mobile/build-your-own/).
We plan on releasing monthly updates with new features - check the [changelog](https://github.com/mattermost/mattermost-mobile/blob/master/CHANGELOG.md) for what features are currently supported!
New features are released monthly - check the [changelog](https://github.com/mattermost/mattermost-mobile/blob/master/CHANGELOG.md) for currently-supported features!
**Important:** If you self-compile the Mattermost Mobile apps you also need to deploy your own [Mattermost Push Notification Service](https://github.com/mattermost/mattermost-push-proxy/releases).

View File

@@ -119,21 +119,21 @@ def jscFlavor = 'org.webkit:android-jsc-intl:+'
*/
def enableHermes = project.ext.react.get("enableHermes", false);
/**
* Architectures to build native code for in debug.
*/
def nativeArchitectures = project.getProperties().get("reactNativeDebugArchitectures")
android {
compileSdkVersion rootProject.ext.compileSdkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
defaultConfig {
applicationId "com.mattermost.rnbeta"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
missingDimensionStrategy "RNNotifications.reactNativeVersion", "reactNative60"
versionCode 369
versionName "1.47.0"
versionCode 381
versionName "1.48.1"
multiDexEnabled = true
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
@@ -165,6 +165,11 @@ android {
debug {
minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
if (nativeArchitectures) {
ndk {
abiFilters nativeArchitectures.split(',')
}
}
}
unsigned.initWith(buildTypes.release)
unsigned {
@@ -230,7 +235,7 @@ dependencies {
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0"
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}") {
exclude group:'com.facebook.fbjni'
exclude group:'com.facebook.fbjni'
}
debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") {
exclude group:'com.facebook.flipper'
@@ -269,7 +274,7 @@ dependencies {
// Run this once to be able to run the application with BUCK
// puts all compile dependencies into folder libs for BUCK to use
task copyDownloadableDepsToLibs(type: Copy) {
from configurations.compile
from configurations.implementation
into 'libs'
}

View File

@@ -9,6 +9,10 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:name=".MainApplication"
@@ -78,5 +82,9 @@
</intent-filter>
</activity>
</application>
<queries>
<intent>
<action android:name="com.google.android.youtube.api.service.START" />
</intent>
</queries>
</manifest>

View File

@@ -15,7 +15,6 @@ import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
@@ -30,7 +29,6 @@ import static com.wix.reactnativenotifications.Defs.NOTIFICATION_RECEIVED_EVENT_
import com.mattermost.react_native_interface.ResolvePromise;
import org.json.JSONArray;
import org.json.JSONObject;
public class CustomPushNotification extends PushNotification {
@@ -61,7 +59,7 @@ public class CustomPushNotification extends PushNotification {
editor.apply();
}
Map<String, List<Integer>> inputMap = new HashMap<>();
Map<String, Map<String, JSONObject>> inputMap = new HashMap<>();
saveNotificationsMap(context, inputMap);
}
} catch (PackageManager.NameNotFoundException e) {
@@ -69,55 +67,70 @@ public class CustomPushNotification extends PushNotification {
}
}
public static void cancelNotification(Context context, String channelId, Integer notificationId) {
public static void cancelNotification(Context context, String channelId, String rootId, Integer notificationId, Boolean isCRTEnabled) {
if (!android.text.TextUtils.isEmpty(channelId)) {
Map<String, List<Integer>> notificationsInChannel = loadNotificationsMap(context);
List<Integer> notifications = notificationsInChannel.get(channelId);
final String notificationIdStr = notificationId.toString();
final Boolean isThreadNotification = isCRTEnabled && !android.text.TextUtils.isEmpty(rootId);
final String groupId = isThreadNotification ? rootId : channelId;
Map<String, Map<String, JSONObject>> notificationsInChannel = loadNotificationsMap(context);
Map<String, JSONObject> notifications = notificationsInChannel.get(groupId);
if (notifications == null) {
return;
}
final NotificationManager notificationManager = context.getSystemService(NotificationManager.class);
notificationManager.cancel(notificationId);
notifications.remove(notificationId);
notifications.remove(notificationIdStr);
final StatusBarNotification[] statusNotifications = notificationManager.getActiveNotifications();
boolean hasMore = false;
for (final StatusBarNotification status : statusNotifications) {
if (status.getNotification().extras.getString("channel_id").equals(channelId)) {
hasMore = true;
Bundle bundle = status.getNotification().extras;
if (isThreadNotification) {
hasMore = bundle.getString("root_id").equals(rootId);
} else if (isCRTEnabled) {
hasMore = !bundle.getString("root_id").equals(rootId);
} else {
hasMore = bundle.getString("channel_id").equals(channelId);
}
if (hasMore) {
break;
}
}
if (!hasMore) {
notificationsInChannel.remove(channelId);
notificationsInChannel.remove(groupId);
} else {
notificationsInChannel.put(groupId, notifications);
}
saveNotificationsMap(context, notificationsInChannel);
}
}
public static void clearChannelNotifications(Context context, String channelId) {
public static void clearChannelNotifications(Context context, String channelId, String rootId, Boolean isCRTEnabled) {
if (!android.text.TextUtils.isEmpty(channelId)) {
Map<String, List<Integer>> notificationsInChannel = loadNotificationsMap(context);
List<Integer> notifications = notificationsInChannel.get(channelId);
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
// rootId is available only when CRT is enabled & clearing the thread
final boolean isClearThread = isCRTEnabled && !android.text.TextUtils.isEmpty(rootId);
Map<String, Map<String, JSONObject>> notificationsInChannel = loadNotificationsMap(context);
String groupId = isClearThread ? rootId : channelId;
Map<String, JSONObject> notifications = notificationsInChannel.get(groupId);
if (notifications == null) {
return;
}
notificationsInChannel.remove(channelId);
notificationsInChannel.remove(groupId);
saveNotificationsMap(context, notificationsInChannel);
for (final Integer notificationId : notifications) {
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
notificationManager.cancel(notificationId);
}
notifications.forEach(
(notificationIdStr, post) -> notificationManager.cancel(Integer.valueOf(notificationIdStr))
);
}
}
public static void clearAllNotifications(Context context) {
if (context != null) {
Map<String, List<Integer>> notificationsInChannel = loadNotificationsMap(context);
Map<String, Map<String, JSONObject>> notificationsInChannel = loadNotificationsMap(context);
notificationsInChannel.clear();
saveNotificationsMap(context, notificationsInChannel);
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
@@ -132,6 +145,8 @@ public class CustomPushNotification extends PushNotification {
final String ackId = initialData.getString("ack_id");
final String postId = initialData.getString("post_id");
final String channelId = initialData.getString("channel_id");
final String rootId = initialData.getString("root_id");
final boolean isCRTEnabled = initialData.getString("is_crt_enabled") != null && initialData.getString("is_crt_enabled").equals("true");
final boolean isIdLoaded = initialData.getString("id_loaded") != null && initialData.getString("id_loaded").equals("true");
int notificationId = CustomPushNotificationHelper.MESSAGE_NOTIFICATION_ID;
if (postId != null) {
@@ -165,24 +180,41 @@ public class CustomPushNotification extends PushNotification {
if (type.equals(PUSH_TYPE_MESSAGE)) {
if (channelId != null) {
Map<String, List<Integer>> notificationsInChannel = loadNotificationsMap(mContext);
List<Integer> list = notificationsInChannel.get(channelId);
if (list == null) {
list = Collections.synchronizedList(new ArrayList(0));
}
try {
list.add(0, notificationId);
if (list.size() > 1) {
createSummary = false;
}
JSONObject post = new JSONObject();
if (!android.text.TextUtils.isEmpty(rootId)) {
post.put("root_id", rootId);
}
if (!android.text.TextUtils.isEmpty(postId)) {
post.put("post_id", postId);
}
if (createSummary) {
// Add the summary notification id as well
list.add(0, notificationId + 1);
}
final Boolean isThreadNotification = isCRTEnabled && post.has("root_id");
final String groupId = isThreadNotification ? rootId : channelId;
Map<String, Map<String, JSONObject>> notificationsInChannel = loadNotificationsMap(mContext);
Map<String, JSONObject> notifications = notificationsInChannel.get(groupId);
if (notifications == null) {
notifications = Collections.synchronizedMap(new HashMap<String, JSONObject>());
}
notificationsInChannel.put(channelId, list);
saveNotificationsMap(mContext, notificationsInChannel);
if (notifications.size() > 0) {
createSummary = false;
}
notifications.put(String.valueOf(notificationId), post);
if (createSummary) {
// Add the summary notification id as well
notifications.put(String.valueOf(notificationId + 1), new JSONObject());
}
notificationsInChannel.put(groupId, notifications);
saveNotificationsMap(mContext, notificationsInChannel);
} catch(Exception e) {
e.printStackTrace();
}
}
}
@@ -190,7 +222,7 @@ public class CustomPushNotification extends PushNotification {
}
break;
case PUSH_TYPE_CLEAR:
clearChannelNotifications(mContext, channelId);
clearChannelNotifications(mContext, channelId, rootId, isCRTEnabled);
break;
}
@@ -205,19 +237,11 @@ public class CustomPushNotification extends PushNotification {
Bundle data = mNotificationProps.asBundle();
final String channelId = data.getString("channel_id");
final String postId = data.getString("post_id");
Integer notificationId = CustomPushNotificationHelper.MESSAGE_NOTIFICATION_ID;
if (postId != null) {
notificationId = postId.hashCode();
}
final String rootId = data.getString("root_id");
final Boolean isCRTEnabled = data.getBoolean("is_crt_enabled");
if (channelId != null) {
Map<String, List<Integer>> notificationsInChannel = loadNotificationsMap(mContext);
List<Integer> notifications = notificationsInChannel.get(channelId);
notifications.remove(notificationId);
saveNotificationsMap(mContext, notificationsInChannel);
clearChannelNotifications(mContext, channelId);
clearChannelNotifications(mContext, channelId, rootId, isCRTEnabled);
}
}
@@ -250,7 +274,7 @@ public class CustomPushNotification extends PushNotification {
mJsIOHelper.sendEventToJS(NOTIFICATION_RECEIVED_EVENT_NAME, mNotificationProps.asBundle(), mAppLifecycleFacade.getRunningReactContext());
}
private static void saveNotificationsMap(Context context, Map<String, List<Integer>> inputMap) {
private static void saveNotificationsMap(Context context, Map<String, Map<String, JSONObject>> inputMap) {
SharedPreferences pSharedPref = context.getSharedPreferences(PUSH_NOTIFICATIONS, Context.MODE_PRIVATE);
if (pSharedPref != null && context != null) {
JSONObject json = new JSONObject(inputMap);
@@ -262,23 +286,41 @@ public class CustomPushNotification extends PushNotification {
}
}
private static Map<String, List<Integer>> loadNotificationsMap(Context context) {
Map<String, List<Integer>> outputMap = new HashMap<>();
/**
* Map Structure
*
* {
* channel_id1 | thread_id1: {
* notification_id1: {
* post_id: 'p1',
* root_id: 'r1',
* }
* }
* }
*
*/
private static Map<String, Map<String, JSONObject>> loadNotificationsMap(Context context) {
Map<String, Map<String, JSONObject>> outputMap = new HashMap<>();
if (context != null) {
SharedPreferences pSharedPref = context.getSharedPreferences(PUSH_NOTIFICATIONS, Context.MODE_PRIVATE);
try {
if (pSharedPref != null) {
String jsonString = pSharedPref.getString(NOTIFICATIONS_IN_CHANNEL, (new JSONObject()).toString());
JSONObject json = new JSONObject(jsonString);
Iterator<String> keysItr = json.keys();
while (keysItr.hasNext()) {
String key = keysItr.next();
JSONArray array = json.getJSONArray(key);
List<Integer> values = new ArrayList<>();
for (int i = 0; i < array.length(); ++i) {
values.add(array.getInt(i));
// Can be a channel_id or thread_id
Iterator<String> groupIdsItr = json.keys();
while (groupIdsItr.hasNext()) {
String groupId = groupIdsItr.next();
JSONObject notificationsJSONObj = json.getJSONObject(groupId);
Map<String, JSONObject> notifications = new HashMap<>();
Iterator<String> notificationIdKeys = notificationsJSONObj.keys();
while(notificationIdKeys.hasNext()) {
String notificationId = notificationIdKeys.next();
JSONObject post = notificationsJSONObj.getJSONObject(notificationId);
notifications.put(notificationId, post);
}
outputMap.put(key, values);
outputMap.put(groupId, notifications);
}
}
} catch (Exception e) {

View File

@@ -98,6 +98,16 @@ public class CustomPushNotificationHelper {
userInfoBundle = new Bundle();
}
String postId = bundle.getString("post_id");
if (postId != null) {
userInfoBundle.putString("post_id", postId);
}
String rootId = bundle.getString("root_id");
if (rootId != null) {
userInfoBundle.putString("root_id", rootId);
}
String channelId = bundle.getString("channel_id");
if (channelId != null) {
userInfoBundle.putString("channel_id", channelId);
@@ -145,13 +155,17 @@ public class CustomPushNotificationHelper {
String channelId = bundle.getString("channel_id");
String postId = bundle.getString("post_id");
String rootId = bundle.getString("root_id");
int notificationId = postId != null ? postId.hashCode() : MESSAGE_NOTIFICATION_ID;
NotificationPreferences notificationPreferences = NotificationPreferences.getInstance(context);
Boolean is_crt_enabled = bundle.getString("is_crt_enabled") != null && bundle.getString("is_crt_enabled").equals("true");
String groupId = is_crt_enabled && !android.text.TextUtils.isEmpty(rootId) ? rootId : channelId;
addNotificationExtras(notification, bundle);
setNotificationIcons(context, notification, bundle);
setNotificationMessagingStyle(context, notification, bundle);
setNotificationGroup(notification, channelId, createSummary);
setNotificationGroup(notification, groupId, createSummary);
setNotificationBadgeType(notification);
setNotificationSound(notification, notificationPreferences);
setNotificationVibrate(notification, notificationPreferences);

View File

@@ -57,7 +57,6 @@ private final ReactNativeHost mReactNativeHost =
// Packages that cannot be auto linked yet can be added manually here, for example:
// packages.add(new MyReactNativePackage());
packages.add(new RNNotificationsPackage(MainApplication.this));
packages.add(new RNPasteableTextInputPackage());
packages.add(
new TurboReactPackage() {
@Override

View File

@@ -19,6 +19,9 @@ public class NotificationDismissService extends IntentService {
final Bundle bundle = NotificationIntentAdapter.extractPendingNotificationDataFromIntent(intent);
final String channelId = bundle.getString("channel_id");
final String postId = bundle.getString("post_id");
final String rootId = bundle.getString("root_id");
final Boolean isCRTEnabled = bundle.getString("is_crt_enabled") != null && bundle.getString("is_crt_enabled").equals("true");
int notificationId = CustomPushNotificationHelper.MESSAGE_NOTIFICATION_ID;
if (postId != null) {
notificationId = postId.hashCode();
@@ -26,7 +29,7 @@ public class NotificationDismissService extends IntentService {
notificationId = channelId.hashCode();
}
CustomPushNotification.cancelNotification(context, channelId, notificationId);
CustomPushNotification.cancelNotification(context, channelId, rootId, notificationId, isCRTEnabled);
Log.i("ReactNative", "Dismiss notification");
}
}

View File

@@ -118,6 +118,10 @@ public class NotificationPreferencesModule extends ReactContextBaseJavaModule {
WritableMap map = Arguments.createMap();
Notification n = sbn.getNotification();
Bundle bundle = n.extras;
String postId = bundle.getString("post_id");
map.putString("post_id", postId);
String rootId = bundle.getString("root_id");
map.putString("root_id", rootId);
String channelId = bundle.getString("channel_id");
map.putString("channel_id", channelId);
result.pushMap(map);
@@ -126,8 +130,9 @@ public class NotificationPreferencesModule extends ReactContextBaseJavaModule {
}
@ReactMethod
public void removeDeliveredNotifications(String channelId) {
public void removeDeliveredNotifications(String channelId, String rootId, Boolean isCRTEnabled) {
final Context context = mApplication.getApplicationContext();
CustomPushNotification.clearChannelNotifications(context, channelId);
CustomPushNotification.clearChannelNotifications(context, channelId, rootId, isCRTEnabled);
}
}

View File

@@ -1,7 +0,0 @@
package com.mattermost.rnbeta;
import android.net.Uri;
public interface RNEditTextOnPasteListener {
void onPaste(Uri itemUri);
}

View File

@@ -1,98 +0,0 @@
package com.mattermost.rnbeta;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.net.Uri;
import android.os.Bundle;
import android.view.ActionMode;
import android.view.Menu;
import android.view.MenuItem;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.WritableMap;
public class RNPasteableActionCallback implements ActionMode.Callback {
private final RNPasteableEditText mEditText;
RNPasteableActionCallback(RNPasteableEditText editText) {
mEditText = editText;
}
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
Bundle config = MainApplication.instance.getManagedConfig();
if (config != null) {
WritableMap result = Arguments.fromBundle(config);
String copyPasteProtection = result.getString("copyAndPasteProtection");
assert copyPasteProtection != null;
if (copyPasteProtection.equals("true")) {
disableMenus(menu);
}
}
return true;
}
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
return false;
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
Uri uri = this.getUriInClipboard();
if (item.getItemId() == android.R.id.paste && uri != null) {
mEditText.getOnPasteListener().onPaste(uri);
mode.finish();
} else {
mEditText.onTextContextMenuItem(item.getItemId());
}
return true;
}
@Override
public void onDestroyActionMode(ActionMode mode) {
}
private void disableMenus(Menu menu) {
for (int i = 0; i < menu.size(); i++) {
MenuItem item = menu.getItem(i);
int id = item.getItemId();
boolean shouldDisableMenu = (
id == android.R.id.paste
|| id == android.R.id.copy
|| id == android.R.id.cut
);
item.setEnabled(!shouldDisableMenu);
}
}
private Uri getUriInClipboard() {
ClipboardManager clipboardManager = (ClipboardManager) mEditText.getContext().getSystemService(Context.CLIPBOARD_SERVICE);
ClipData clipData = clipboardManager.getPrimaryClip();
if (clipData == null) {
return null;
}
ClipData.Item item = clipData.getItemAt(0);
if (item == null) {
return null;
}
CharSequence chars = item.getText();
if (chars == null) {
return null;
}
String text = chars.toString();
if (text.length() > 0) {
return null;
}
return item.getUri();
}
}

View File

@@ -1,22 +0,0 @@
package com.mattermost.rnbeta;
import android.content.Context;
import com.facebook.react.views.textinput.ReactEditText;
public class RNPasteableEditText extends ReactEditText {
private RNEditTextOnPasteListener mOnPasteListener;
public RNPasteableEditText(Context context) {
super(context);
}
public void setOnPasteListener(RNEditTextOnPasteListener listener) {
mOnPasteListener = listener;
}
public RNEditTextOnPasteListener getOnPasteListener() {
return mOnPasteListener;
}
}

View File

@@ -1,163 +0,0 @@
package com.mattermost.rnbeta;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.ContentResolver;
import android.content.Context;
import android.content.res.AssetFileDescriptor;
import android.net.Uri;
import android.os.Build;
import android.util.Patterns;
import android.webkit.MimeTypeMap;
import android.webkit.URLUtil;
import androidx.annotation.RequiresApi;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.bridge.WritableArray;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.uimanager.events.RCTEventEmitter;
import com.mattermost.share.RealPathUtil;
import com.mattermost.share.ShareModule;
import java.io.FileNotFoundException;
import java.io.File;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.regex.Matcher;
public class RNPasteableEditTextOnPasteListener implements RNEditTextOnPasteListener {
private final RNPasteableEditText mEditText;
RNPasteableEditTextOnPasteListener(RNPasteableEditText editText) {
mEditText = editText;
}
@Override
public void onPaste(Uri itemUri) {
ReactContext reactContext = (ReactContext)mEditText.getContext();
String uri = itemUri.toString();
WritableArray images = null;
WritableMap error = null;
String uriMimeType = reactContext.getContentResolver().getType(itemUri);
if (uriMimeType == null) {
return;
}
// Special handle for Google docs
if (uri.equals("content://com.google.android.apps.docs.editors.kix.editors.clipboard")) {
ClipboardManager clipboardManager = (ClipboardManager) reactContext.getSystemService(Context.CLIPBOARD_SERVICE);
ClipData clipData = clipboardManager.getPrimaryClip();
if (clipData == null) {
return;
}
ClipData.Item item = clipData.getItemAt(0);
String htmlText = item.getHtmlText();
// Find uri from html
Matcher matcher = Patterns.WEB_URL.matcher(htmlText);
if (matcher.find()) {
uri = htmlText.substring(matcher.start(1), matcher.end());
}
}
if (uri.startsWith("http")) {
Thread pastImageFromUrlThread = new Thread(new RNPasteableImageFromUrl(reactContext, mEditText, uri));
pastImageFromUrlThread.start();
return;
}
uri = RealPathUtil.getRealPathFromURI(reactContext, itemUri);
if (uri == null) {
return;
}
// Get type
String extension = MimeTypeMap.getFileExtensionFromUrl(uri);
if (extension == null) {
return;
}
String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
if (mimeType == null) {
return;
}
// Get fileName
String fileName = URLUtil.guessFileName(uri, null, mimeType);
if (uri.contains(ShareModule.CACHE_DIR_NAME) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
uri = moveToImagesCache(uri, fileName);
}
if (uri == null) {
return;
}
// Get fileSize
long fileSize;
try {
ContentResolver contentResolver = reactContext.getContentResolver();
AssetFileDescriptor assetFileDescriptor = contentResolver.openAssetFileDescriptor(itemUri, "r");
if (assetFileDescriptor == null) {
return;
}
fileSize = assetFileDescriptor.getLength();
WritableMap image = Arguments.createMap();
image.putString("type", mimeType);
image.putDouble("fileSize", fileSize);
image.putString("fileName", fileName);
image.putString("uri", "file://" + uri);
images = Arguments.createArray();
images.pushMap(image);
} catch (FileNotFoundException e) {
error = Arguments.createMap();
error.putString("message", e.getMessage());
}
WritableMap event = Arguments.createMap();
event.putArray("data", images);
event.putMap("error", error);
reactContext
.getJSModule(RCTEventEmitter.class)
.receiveEvent(
mEditText.getId(),
"onPaste",
event
);
}
@RequiresApi(api = Build.VERSION_CODES.O)
private String moveToImagesCache(String src, String fileName) {
ReactContext ctx = (ReactContext)mEditText.getContext();
String cacheFolder = ctx.getCacheDir().getAbsolutePath() + "/Images/";
String dest = cacheFolder + fileName;
File folder = new File(cacheFolder);
try {
if (!folder.exists()) {
boolean created = folder.mkdirs();
if (!created) {
return null;
}
}
Files.move(Paths.get(src), Paths.get(dest));
} catch (FileAlreadyExistsException fileError) {
// Do nothing and return dest path
} catch (Exception err) {
return null;
}
return dest;
}
}

View File

@@ -1,76 +0,0 @@
package com.mattermost.rnbeta;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.bridge.WritableArray;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.uimanager.events.RCTEventEmitter;
import com.facebook.react.views.textinput.ReactEditText;
import java.io.IOException;
import java.net.URL;
import java.net.URLConnection;
public class RNPasteableImageFromUrl implements Runnable {
private final ReactContext mContext;
private final String mUri;
private final ReactEditText mTarget;
RNPasteableImageFromUrl(ReactContext context, ReactEditText target, String uri) {
mContext = context;
mUri = uri;
mTarget = target;
}
@Override
public void run() {
WritableArray images = null;
WritableMap error = null;
try {
URL url = new URL(mUri);
URLConnection u = url.openConnection();
// Get type
String mimeType = u.getHeaderField("Content-Type");
if (!mimeType.startsWith("image")) {
return;
}
// Get fileSize
long fileSize = Long.parseLong(u.getHeaderField("Content-Length"));
// Get fileName
String contentDisposition = u.getHeaderField("Content-Disposition");
int startIndex = contentDisposition.indexOf("filename=\"") + 10;
int endIndex = contentDisposition.length() - 1;
String fileName = contentDisposition.substring(startIndex, endIndex);
WritableMap image = Arguments.createMap();
image.putString("type", mimeType);
image.putDouble("fileSize", fileSize);
image.putString("fileName", fileName);
image.putString("uri", mUri);
images = Arguments.createArray();
images.pushMap(image);
} catch (IOException e) {
error = Arguments.createMap();
error.putString("message", e.getMessage());
}
WritableMap event = Arguments.createMap();
event.putArray("data", images);
event.putMap("error", error);
mContext
.getJSModule(RCTEventEmitter.class)
.receiveEvent(
mTarget.getId(),
"onPaste",
event
);
}
}

View File

@@ -1,85 +0,0 @@
package com.mattermost.rnbeta;
import androidx.annotation.NonNull;
import androidx.core.view.inputmethod.EditorInfoCompat;
import androidx.core.view.inputmethod.InputConnectionCompat;
import android.text.InputType;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
import com.facebook.react.common.MapBuilder;
import com.facebook.react.uimanager.ThemedReactContext;
import com.facebook.react.views.textinput.ReactEditText;
import com.facebook.react.views.textinput.ReactTextInputManager;
import java.util.Map;
import javax.annotation.Nullable;
public class RNPasteableTextInputManager extends ReactTextInputManager {
@Override
@NonNull
public String getName() {
return "PasteableTextInputAndroid";
}
@Override
@NonNull
public ReactEditText createViewInstance(ThemedReactContext context) {
RNPasteableEditText editText = new RNPasteableEditText(context) {
@Override
public InputConnection onCreateInputConnection(EditorInfo editorInfo) {
final InputConnection ic = super.onCreateInputConnection(editorInfo);
EditorInfoCompat.setContentMimeTypes(editorInfo,
new String [] {"image/*"});
final InputConnectionCompat.OnCommitContentListener callback =
(inputContentInfo, flags, opts) -> {
// read and display inputContentInfo asynchronously
if ((flags &
InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) {
try {
inputContentInfo.requestPermission();
}
catch (Exception e) {
return false;
}
}
this.getOnPasteListener().onPaste(inputContentInfo.getContentUri());
return true;
};
return InputConnectionCompat.createWrapper(ic, editorInfo, callback);
}
};
int inputType = editText.getInputType();
editText.setInputType(inputType & (~InputType.TYPE_TEXT_FLAG_MULTI_LINE));
editText.setReturnKeyType("done");
editText.setCustomInsertionActionModeCallback(new RNPasteableActionCallback(editText));
editText.setCustomSelectionActionModeCallback(new RNPasteableActionCallback(editText));
return editText;
}
@Override
protected void addEventEmitters(ThemedReactContext reactContext, ReactEditText editText) {
super.addEventEmitters(reactContext, editText);
RNPasteableEditText pasteableEditText = (RNPasteableEditText)editText;
pasteableEditText.setOnPasteListener(new RNPasteableEditTextOnPasteListener(pasteableEditText));
}
@Nullable
@Override
public Map<String, Object> getExportedCustomBubblingEventTypeConstants() {
Map<String, Object> map = super.getExportedCustomBubblingEventTypeConstants();
assert map != null;
map.put(
"onPaste",
MapBuilder.of(
"phasedRegistrationNames",
MapBuilder.of("bubbled", "onPaste")));
return map;
}
}

View File

@@ -1,28 +0,0 @@
package com.mattermost.rnbeta;
import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import javax.annotation.Nonnull;
public class RNPasteableTextInputPackage implements ReactPackage {
@Nonnull
@Override
public List<NativeModule> createNativeModules(@Nonnull ReactApplicationContext reactContext) {
return Collections.emptyList();
}
@Nonnull
@Override
public List<ViewManager> createViewManagers(@Nonnull ReactApplicationContext reactContext) {
return Arrays.asList(
new RNPasteableTextInputManager()
);
}
}

View File

@@ -2,15 +2,15 @@
buildscript {
ext {
buildToolsVersion = "29.0.3"
buildToolsVersion = "30.0.2"
minSdkVersion = 24
compileSdkVersion = 29
targetSdkVersion = 29
compileSdkVersion = 30
targetSdkVersion = 30
supportLibVersion = "28.0.0"
kotlinVersion = "1.3.61"
kotlinVersion = "1.5.30"
firebaseVersion = "21.0.0"
RNNKotlinVersion = kotlinVersion
ndkVersion = "21.1.6352462"
ndkVersion = "21.4.7075529"
}
repositories {
@@ -20,7 +20,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.1.0'
classpath 'com.android.tools.build:gradle:4.2.2'
classpath 'com.google.gms:google-services:4.2.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
@@ -52,9 +52,6 @@ allprojects {
maven {
url "https://www.jitpack.io"
}
maven {
url ("https://dl.bintray.com/rudderstack/rudderstack")
}
maven {
url "$rootDir/../node_modules/detox/Detox-android"
}

View File

@@ -30,4 +30,4 @@ android.useAndroidX=true
android.enableJetifier=true
# Version of flipper SDK to use with React Native
FLIPPER_VERSION=0.75.1
FLIPPER_VERSION=0.99.0

View File

@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-6.9-all.zip

View File

@@ -457,6 +457,13 @@ export function showOverlay(name, passProps, options = {}) {
overlay: {
interceptTouchOutside: false,
},
...Platform.select({
android: {
statusBar: {
drawBehind: true,
},
},
}),
};
Navigation.showOverlay({

View File

@@ -390,7 +390,7 @@ export function markAsViewedAndReadBatch(state, channelId, prevChannelId = '', m
type: ChannelTypes.SET_UNREAD_MSG_COUNT,
data: {
channelId,
count: unreadMessageCount,
count: isCollapsedThreadsEnabled(state) ? unreadMessageCountRoot : unreadMessageCount,
},
}, {
type: ChannelTypes.DECREMENT_UNREAD_MSG_COUNT,

View File

@@ -2,6 +2,16 @@
// See LICENSE.txt for license information.
import {ViewTypes} from '@constants';
export function updateThreadLastViewedAt(threadId: string, lastViewedAt: number) {
return {
type: ViewTypes.THREAD_LAST_VIEWED_AT,
data: {
threadId,
lastViewedAt,
},
};
}
export const handleViewingGlobalThreadsScreen = () => (
{
type: ViewTypes.VIEWING_GLOBAL_THREADS_SCREEN,

View File

@@ -1,7 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fetchAppBindings} from '@mm-redux/actions/apps';
import {fetchAppBindings, fetchThreadAppBindings} from '@mm-redux/actions/apps';
import {getThreadAppsBindingsChannelId} from '@mm-redux/selectors/entities/apps';
import {getCurrentChannelId} from '@mm-redux/selectors/entities/common';
import {getCurrentUserId} from '@mm-redux/selectors/entities/users';
import {ActionResult, DispatchFunc, GetStateFunc} from '@mm-redux/types/actions';
@@ -10,9 +11,17 @@ import {appsEnabled} from '@utils/apps';
export function handleRefreshAppsBindings() {
return (dispatch: DispatchFunc, getState: GetStateFunc): ActionResult => {
const state = getState();
if (appsEnabled(state)) {
dispatch(fetchAppBindings(getCurrentUserId(state), getCurrentChannelId(state)));
if (!appsEnabled(state)) {
return {data: true};
}
dispatch(fetchAppBindings(getCurrentUserId(state), getCurrentChannelId(state)));
const threadChannelID = getThreadAppsBindingsChannelId(state);
if (threadChannelID) {
dispatch(fetchThreadAppBindings(getCurrentUserId(state), threadChannelID));
}
return {data: true};
};
}

View File

@@ -36,7 +36,7 @@ describe('Websocket Chanel Events', () => {
});
afterAll(async () => {
Actions.close()();
Actions.close();
mockServer.stop();
await TestHelper.tearDown();
});

View File

@@ -28,7 +28,7 @@ describe('Websocket General Events', () => {
});
afterAll(async () => {
Actions.close()();
Actions.close();
mockServer.stop();
await TestHelper.tearDown();
});

View File

@@ -11,7 +11,7 @@ import {getThreads} from '@mm-redux/actions/threads';
import {getProfilesByIds, getStatusesByIds} from '@mm-redux/actions/users';
import {General} from '@mm-redux/constants';
import {getCurrentChannelId, getCurrentChannelStats} from '@mm-redux/selectors/entities/channels';
import {getConfig} from '@mm-redux/selectors/entities/general';
import {getConfig, getFeatureFlagValue} from '@mm-redux/selectors/entities/general';
import {getPostIdsInChannel} from '@mm-redux/selectors/entities/posts';
import {isCollapsedThreadsEnabled} from '@mm-redux/selectors/entities/preferences';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
@@ -23,6 +23,20 @@ import {TeamMembership} from '@mm-redux/types/teams';
import {WebSocketMessage} from '@mm-redux/types/websocket';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {removeUserFromList} from '@mm-redux/utils/user_utils';
import {loadCalls} from '@mmproducts/calls/store/actions/calls';
import {
handleCallStarted,
handleCallUserConnected,
handleCallUserDisconnected,
handleCallUserMuted,
handleCallUserUnmuted,
handleCallUserVoiceOn,
handleCallUserVoiceOff,
handleCallChannelEnabled,
handleCallChannelDisabled,
handleCallScreenOn,
handleCallScreenOff,
} from '@mmproducts/calls/store/actions/websockets';
import {getChannelSinceValue} from '@utils/channels';
import websocketClient from '@websocket';
@@ -147,6 +161,10 @@ export function doReconnect(now: number) {
const {data: me}: any = await dispatch(loadMe(null, null, true));
if (!me.error) {
if (getFeatureFlagValue(getState(), 'CallsMobile') === 'true') {
dispatch(loadCalls());
}
const roles = [];
if (me.roles?.length) {
@@ -325,7 +343,7 @@ function handleClose(connectFailCount: number) {
}
function handleEvent(msg: WebSocketMessage) {
return (dispatch: DispatchFunc) => {
return (dispatch: DispatchFunc, getState: GetStateFunc) => {
switch (msg.event) {
case WebsocketEvents.POSTED:
case WebsocketEvents.EPHEMERAL_MESSAGE:
@@ -421,6 +439,33 @@ function handleEvent(msg: WebSocketMessage) {
return dispatch(handleSidebarCategoryOrderUpdated(msg));
}
if (getFeatureFlagValue(getState(), 'CallsMobile') === 'true') {
switch (msg.event) {
case WebsocketEvents.CALLS_CHANNEL_ENABLED:
return dispatch(handleCallChannelEnabled(msg));
case WebsocketEvents.CALLS_CHANNEL_DISABLED:
return dispatch(handleCallChannelDisabled(msg));
case WebsocketEvents.CALLS_USER_CONNECTED:
return dispatch(handleCallUserConnected(msg));
case WebsocketEvents.CALLS_USER_DISCONNECTED:
return dispatch(handleCallUserDisconnected(msg));
case WebsocketEvents.CALLS_USER_MUTED:
return dispatch(handleCallUserMuted(msg));
case WebsocketEvents.CALLS_USER_UNMUTED:
return dispatch(handleCallUserUnmuted(msg));
case WebsocketEvents.CALLS_USER_VOICE_ON:
return dispatch(handleCallUserVoiceOn(msg));
case WebsocketEvents.CALLS_USER_VOICE_OFF:
return dispatch(handleCallUserVoiceOff(msg));
case WebsocketEvents.CALLS_CALL_START:
return dispatch(handleCallStarted(msg));
case WebsocketEvents.CALLS_SCREEN_ON:
return dispatch(handleCallScreenOn(msg));
case WebsocketEvents.CALLS_SCREEN_OFF:
return dispatch(handleCallScreenOff(msg));
}
}
return {data: true};
};
}

View File

@@ -26,7 +26,7 @@ describe('Websocket Integration Events', () => {
});
afterAll(async () => {
Actions.close()();
Actions.close();
mockServer.stop();
await TestHelper.tearDown();
});

View File

@@ -34,7 +34,7 @@ describe('Websocket Post Events', () => {
});
afterAll(async () => {
Actions.close()();
Actions.close();
mockServer.stop();
await TestHelper.tearDown();
});

View File

@@ -26,7 +26,7 @@ describe('Websocket Reaction Events', () => {
});
afterAll(async () => {
Actions.close()();
Actions.close();
mockServer.stop();
await TestHelper.tearDown();
});

View File

@@ -35,7 +35,7 @@ describe('Websocket Team Events', () => {
});
afterAll(async () => {
Actions.close()();
Actions.close();
mockServer.stop();
await TestHelper.tearDown();
});

View File

@@ -1,10 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {batchActions} from 'redux-batched-actions';
import {updateThreadLastViewedAt} from '@actions/views/threads';
import {handleThreadArrived, handleReadChanged, handleAllMarkedRead, handleFollowChanged, getThread as fetchThread} from '@mm-redux/actions/threads';
import {getCurrentUserId} from '@mm-redux/selectors/entities/common';
import {getSelectedPost} from '@mm-redux/selectors/entities/posts';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
import {getThread} from '@mm-redux/selectors/entities/threads';
import {ActionResult, DispatchFunc, GetStateFunc} from '@mm-redux/types/actions';
import {ActionResult, DispatchFunc, GenericAction, GetStateFunc} from '@mm-redux/types/actions';
import {WebSocketMessage} from '@mm-redux/types/websocket';
export function handleThreadUpdated(msg: WebSocketMessage) {
@@ -27,21 +31,31 @@ export function handleThreadReadChanged(msg: WebSocketMessage) {
const thread = getThread(state, msg.data.thread_id);
// Mark only following threads as read.
if (thread?.is_following) {
dispatch(
handleReadChanged(
msg.data.thread_id,
msg.broadcast.team_id,
msg.data.channel_id,
{
lastViewedAt: msg.data.timestamp,
prevUnreadMentions: thread.unread_mentions,
newUnreadMentions: msg.data.unread_mentions,
prevUnreadReplies: thread.unread_replies,
newUnreadReplies: msg.data.unread_replies,
},
),
);
if (thread) {
const actions: GenericAction[] = [];
const selectedPost = getSelectedPost(state);
if (selectedPost?.id !== thread.id) {
actions.push(updateThreadLastViewedAt(thread.id, msg.data.timestamp));
}
if (thread.is_following) {
actions.push(
handleReadChanged(
msg.data.thread_id,
msg.broadcast.team_id,
msg.data.channel_id,
{
lastViewedAt: msg.data.timestamp,
prevUnreadMentions: thread.unread_mentions,
newUnreadMentions: msg.data.unread_mentions,
prevUnreadReplies: thread.unread_replies,
newUnreadReplies: msg.data.unread_replies,
},
),
);
}
if (actions.length) {
dispatch(batchActions(actions));
}
}
} else {
dispatch(handleAllMarkedRead(msg.broadcast.team_id));

View File

@@ -33,7 +33,7 @@ describe('Websocket User Events', () => {
});
afterAll(async () => {
Actions.close()();
Actions.close();
mockServer.stop();
await TestHelper.tearDown();
});

View File

@@ -64,7 +64,7 @@ describe('Actions.Websocket', () => {
});
afterAll(async () => {
Actions.close()();
Actions.close();
mockServer.stop();
await TestHelper.tearDown();
});
@@ -166,7 +166,7 @@ describe('Actions.Websocket doReconnect', () => {
});
afterAll(async () => {
Actions.close()();
Actions.close();
await TestHelper.tearDown();
});
@@ -373,7 +373,7 @@ describe('Actions.Websocket notVisibleUsersActions', () => {
const user4 = TestHelper.fakeUserWithId();
const user5 = TestHelper.fakeUserWithId();
it('should do nothing if the known users and the profiles list are the same', async () => {
it.skip('should do nothing if the known users and the profiles list are the same', async () => {
const profiles = {
[me.id]: me,
[user.id]: user,
@@ -397,7 +397,7 @@ describe('Actions.Websocket notVisibleUsersActions', () => {
expect(actions.length).toEqual(0);
});
it('should do nothing if there are known users in my memberships but not in the profiles list', async () => {
it.skip('should do nothing if there are known users in my memberships but not in the profiles list', async () => {
const profiles = {
[me.id]: me,
[user3.id]: user3,
@@ -419,7 +419,7 @@ describe('Actions.Websocket notVisibleUsersActions', () => {
expect(actions.length).toEqual(0);
});
it('should remove the users if there are unknown users in the profiles list', async () => {
it.skip('should remove the users if there are unknown users in the profiles list', async () => {
const profiles = {
[me.id]: me,
[user.id]: user,
@@ -502,7 +502,7 @@ describe('Actions.Websocket handleUserTypingEvent', () => {
const expectedActionsTypes = [
WebsocketEvents.TYPING,
UserTypes.RECEIVED_STATUSES,
'BATCHING_REDUCER.BATCH',
];
await testStore.dispatch(Actions.handleUserTypingEvent(msg));

View File

@@ -290,6 +290,10 @@ export default class ClientBase {
return `${this.url}/plugins/com.mattermost.apps`;
}
getCallsRoute() {
return `${this.url}/plugins/com.mattermost.calls`;
}
// Client Helpers
handleRedirectProtocol = (url: string, response: RNFetchBlobFetchRepsonse) => {
const serverUrl = this.getUrl();

View File

@@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import ClientCalls, {ClientCallsMix} from '@mmproducts/calls/client/rest';
import mix from '@utils/mix';
import ClientApps, {ClientAppsMix} from './apps';
@@ -34,7 +35,8 @@ interface Client extends ClientBase,
ClientSharedChannelsMix,
ClientTeamsMix,
ClientTosMix,
ClientUsersMix
ClientUsersMix,
ClientCallsMix
{}
class Client extends mix(ClientBase).with(
@@ -52,6 +54,7 @@ class Client extends mix(ClientBase).with(
ClientTeams,
ClientTos,
ClientUsers,
ClientCalls,
) {}
const Client4 = new Client();

View File

@@ -250,8 +250,13 @@ const ClientPosts = (superclass: any) => class extends superclass {
searchPostsWithParams = async (teamId: string, params: any) => {
analytics.trackAPI('api_posts_search', {team_id: teamId});
let route = `${this.getPostsRoute()}/search`;
if (teamId) {
route = `${this.getTeamRoute(teamId)}/posts/search`;
}
return this.doFetch(
`${this.getTeamRoute(teamId)}/posts/search`,
route,
{method: 'post', body: JSON.stringify(params)},
);
};

View File

@@ -104,7 +104,6 @@ const ClientUsers = (superclass: any) => class extends superclass {
getKnownUsers = async () => {
analytics.trackAPI('api_get_known_users');
return this.doFetch(
`${this.getUsersRoute()}/known`,
{method: 'get'},

View File

@@ -0,0 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FormattedRelativeTime should match snapshot 1`] = `
<Text>
a few seconds ago
</Text>
`;

View File

@@ -113,7 +113,6 @@ export default class AtMention extends React.PureComponent {
BottomSheet.showBottomSheetWithOptions({
options: [actionText, cancelText],
cancelButtonIndex: 1,
}, (value) => {
if (value !== 1) {
this.handleCopyMention();

View File

@@ -232,7 +232,7 @@ export default class AttachmentButton extends PureComponent {
if (hasPermission) {
try {
const res = await DocumentPicker.pick({type: [browseFileTypes]});
const res = await DocumentPicker.pickSingle({type: [browseFileTypes]});
emmProvider.inBackgroundSince = null;
if (Platform.OS === 'android') {
// For android we need to retrieve the realPath in case the file being imported is from the cloud

View File

@@ -66,7 +66,7 @@ const GroupMentionItem = (props) => {
>
<View style={style.rowPicture}>
<CompassIcon
name='account-group-outline'
name='account-multiple-outline'
style={style.rowIcon}
/>
</View>

View File

@@ -2341,7 +2341,6 @@ exports[`components/autocomplete/emoji_suggestion should match snapshot 2`] = `
"zzz",
]
}
disableVirtualization={false}
extraData={
Object {
"active": true,
@@ -4682,19 +4681,13 @@ exports[`components/autocomplete/emoji_suggestion should match snapshot 2`] = `
],
}
}
horizontal={false}
initialListSize={10}
initialNumToRender={10}
keyExtractor={[Function]}
keyboardShouldPersistTaps="always"
maxToRenderPerBatch={10}
nestedScrollEnabled={false}
numColumns={1}
onEndReachedThreshold={2}
pageSize={10}
removeClippedSubviews={true}
renderItem={[Function]}
scrollEventThrottle={50}
style={
Array [
Object {
@@ -4708,7 +4701,5 @@ exports[`components/autocomplete/emoji_suggestion should match snapshot 2`] = `
]
}
testID="emoji_suggestion.list"
updateCellsBatchingPeriod={50}
windowSize={21}
/>
`;

View File

@@ -13,7 +13,6 @@ exports[`components/autocomplete/slash_suggestion should match snapshot 1`] = `
},
]
}
disableVirtualization={false}
extraData={
Object {
"active": true,
@@ -29,17 +28,11 @@ exports[`components/autocomplete/slash_suggestion should match snapshot 1`] = `
"lastCommandRequest": 1234,
}
}
horizontal={false}
initialNumToRender={10}
keyExtractor={[Function]}
keyboardShouldPersistTaps="always"
maxToRenderPerBatch={10}
nestedScrollEnabled={false}
numColumns={1}
onEndReachedThreshold={2}
removeClippedSubviews={true}
renderItem={[Function]}
scrollEventThrottle={50}
style={
Array [
Object {
@@ -54,7 +47,5 @@ exports[`components/autocomplete/slash_suggestion should match snapshot 1`] = `
]
}
testID="slash_suggestion.list"
updateCellsBatchingPeriod={50}
windowSize={21}
/>
`;

View File

@@ -33,7 +33,11 @@ describe('AppCommandParser', () => {
...reduxTestState,
entities: {
...reduxTestState.entities,
apps: {bindings},
apps: {
bindings,
threadBindings: bindings,
threadBindingsForms: {},
},
},
} as any;
const testStore = await mockStore(initialState);
@@ -415,6 +419,60 @@ describe('AppCommandParser', () => {
command: '/jira issue create --project == test',
submit: {expectError: 'Multiple `=` signs are not allowed.'},
},
{
title: 'rest field',
command: '/jira issue rest hello world',
autocomplete: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.Rest);
expect(parsed.binding?.label).toBe('rest');
expect(parsed.incomplete).toBe('hello world');
expect(parsed.values?.summary).toBe(undefined);
}},
submit: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.Rest);
expect(parsed.binding?.label).toBe('rest');
expect(parsed.values?.summary).toBe('hello world');
}},
},
{
title: 'rest field with other field',
command: '/jira issue rest --verbose true hello world',
autocomplete: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.Rest);
expect(parsed.binding?.label).toBe('rest');
expect(parsed.incomplete).toBe('hello world');
expect(parsed.values?.summary).toBe(undefined);
expect(parsed.values?.verbose).toBe('true');
}},
submit: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.Rest);
expect(parsed.binding?.label).toBe('rest');
expect(parsed.values?.summary).toBe('hello world');
expect(parsed.values?.verbose).toBe('true');
}},
},
{
title: 'rest field as flag with other field',
command: '/jira issue rest --summary "hello world" --verbose true',
autocomplete: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.EndValue);
expect(parsed.binding?.label).toBe('rest');
expect(parsed.incomplete).toBe('true');
expect(parsed.values?.summary).toBe('hello world');
expect(parsed.values?.verbose).toBe(undefined);
}},
submit: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.EndValue);
expect(parsed.binding?.label).toBe('rest');
expect(parsed.values?.summary).toBe('hello world');
expect(parsed.values?.verbose).toBe('true');
}},
},
{
title: 'error: rest after rest field flag',
command: '/jira issue rest --summary "hello world" --verbose true hello world',
submit: {expectError: 'Unable to identify argument.'},
},
];
table.forEach((tc) => {
@@ -504,6 +562,13 @@ describe('AppCommandParser', () => {
IconData: 'Create icon',
Description: 'Create a new Jira issue',
},
{
Suggestion: 'rest',
Complete: 'jira issue rest',
Hint: 'rest hint',
IconData: 'rest icon',
Description: 'rest description',
},
]);
});
@@ -524,6 +589,14 @@ describe('AppCommandParser', () => {
IconData: 'Create icon',
Description: 'Create a new Jira issue',
},
{
Suggestion: 'rest',
Complete: 'JiRa IsSuE rest',
Hint: 'rest hint',
IconData: 'rest icon',
Description: 'rest description',
},
]);
});
@@ -855,7 +928,7 @@ describe('AppCommandParser', () => {
context: {
app_id: 'jira',
channel_id: 'current_channel_id',
location: '/command',
location: '/command/jira/issue/create',
root_id: 'root_id',
team_id: 'team_id',
},

View File

@@ -4,6 +4,7 @@
/* eslint-disable max-lines */
import {
AppsTypes,
AppCallRequest,
AppBinding,
AppField,
@@ -32,7 +33,6 @@ import {
EXECUTE_CURRENT_COMMAND_ITEM_ID,
COMMAND_SUGGESTION_ERROR,
getExecuteSuggestion,
displayError,
createCallRequest,
selectUserByUsername,
getUserByUsername,
@@ -45,10 +45,13 @@ import {
filterEmptyOptions,
autocompleteUsersInChannel,
autocompleteChannels,
ExtendedAutocompleteSuggestion,
getChannelSuggestions,
getUserSuggestions,
inTextMentionSuggestions,
ExtendedAutocompleteSuggestion,
getAppCommandForm,
getAppRHSCommandForm,
makeRHSAppBindingSelector,
} from './app_command_parser_dependencies';
export interface Store {
@@ -74,6 +77,7 @@ export enum ParseState {
EndQuotedValue = 'EndQuotedValue',
EndTickedValue = 'EndTickedValue',
Error = 'Error',
Rest = 'Rest',
}
interface FormsCache {
@@ -85,6 +89,7 @@ interface Intl {
}
const getCommandBindings = makeAppBindingsSelector(AppBindingLocations.COMMAND);
const getRHSCommandBindings = makeRHSAppBindingSelector(AppBindingLocations.COMMAND);
export class ParsedCommand {
state = ParseState.Start;
@@ -299,12 +304,20 @@ export class ParsedCommand {
// Positional parameter.
this.position++;
// eslint-disable-next-line no-loop-func
const field = fields.find((f: AppField) => f.position === this.position);
let field = fields.find((f: AppField) => f.position === this.position);
if (!field) {
return this.asError(this.intl.formatMessage({
id: 'apps.error.parser.no_argument_pos_x',
defaultMessage: 'Unable to identify argument.',
}));
field = fields.find((f) => f.position === -1 && f.type === AppFieldTypes.TEXT);
if (!field || this.values[field.name]) {
return this.asError(this.intl.formatMessage({
id: 'apps.error.parser.no_argument_pos_x',
defaultMessage: 'Unable to identify argument.',
}));
}
this.incompleteStart = this.i;
this.incomplete = '';
this.field = field;
this.state = ParseState.Rest;
break;
}
this.field = field;
this.state = ParseState.StartValue;
@@ -314,6 +327,28 @@ export class ParsedCommand {
break;
}
case ParseState.Rest: {
if (!this.field) {
return this.asError(this.intl.formatMessage({
id: 'apps.error.parser.missing_field_value',
defaultMessage: 'Field value is missing.',
}));
}
if (autocompleteMode && c === '') {
return this;
}
if (c === '') {
this.values[this.field.name] = this.incomplete;
return this;
}
this.i++;
this.incomplete += c;
break;
}
case ParseState.ParameterSeparator: {
this.incompleteStart = this.i;
switch (c) {
@@ -470,7 +505,7 @@ export class ParsedCommand {
if (this.incompleteStart === this.i - 1) {
return this.asError(this.intl.formatMessage({
id: 'apps.error.parser.empty_value',
defaultMessage: 'empty values are not allowed',
defaultMessage: 'Empty values are not allowed.',
}));
}
this.i++;
@@ -510,7 +545,7 @@ export class ParsedCommand {
if (this.incompleteStart === this.i - 1) {
return this.asError(this.intl.formatMessage({
id: 'apps.error.parser.empty_value',
defaultMessage: 'empty values are not allowed',
defaultMessage: 'Empty values are not allowed.',
}));
}
this.i++;
@@ -544,13 +579,13 @@ export class ParsedCommand {
(!autocompleteMode && this.incomplete !== 'true' && this.incomplete !== 'false'))) {
// reset back where the value started, and treat as a new parameter
this.i = this.incompleteStart;
this.values![this.field.name] = 'true';
this.values[this.field.name] = 'true';
this.state = ParseState.StartParameter;
} else {
if (autocompleteMode && c === '') {
return this;
}
this.values![this.field.name] = this.incomplete;
this.values[this.field.name] = this.incomplete;
this.incomplete = '';
this.incompleteStart = this.i;
if (c === '') {
@@ -572,8 +607,6 @@ export class AppCommandParser {
private rootPostID?: string;
private intl: Intl;
forms: {[location: string]: AppForm} = {};
constructor(store: Store|null, intl: Intl, channelID: string, teamID = '', rootPostID = '') {
this.store = store || getStore() as Store;
this.channelID = channelID;
@@ -619,57 +652,59 @@ export class AppCommandParser {
}
private async addDefaultAndReadOnlyValues(parsed: ParsedCommand) {
await Promise.all(parsed.form?.fields?.map(async (f) => {
if (!parsed.form?.fields) {
return;
}
await Promise.all(parsed.form.fields.map(async (f) => {
if (!f.value) {
return;
}
if (!f.readonly || f.name in parsed.values) {
return;
}
switch (f.type) {
case AppFieldTypes.TEXT:
parsed.values[f.name] = f.value as string;
break;
case AppFieldTypes.BOOL:
parsed.values[f.name] = 'true';
break;
case AppFieldTypes.USER: {
const userID = (f.value as AppSelectOption).value;
let user = selectUser(this.store.getState(), userID);
if (!user) {
const dispatchResult = await this.store.dispatch(getUser(userID));
if ('error' in dispatchResult) {
// Silently fail on default value
break;
if (f.readonly || !(f.name in parsed.values)) {
switch (f.type) {
case AppFieldTypes.TEXT:
parsed.values[f.name] = f.value as string;
break;
case AppFieldTypes.BOOL:
parsed.values[f.name] = 'true';
break;
case AppFieldTypes.USER: {
const userID = (f.value as AppSelectOption).value;
let user = selectUser(this.store.getState(), userID);
if (!user) {
const dispatchResult = await this.store.dispatch(getUser(userID));
if ('error' in dispatchResult) {
// Silently fail on default value
break;
}
user = dispatchResult.data;
}
user = dispatchResult.data;
parsed.values[f.name] = user.username;
break;
}
parsed.values[f.name] = user.username;
break;
}
case AppFieldTypes.CHANNEL: {
const channelID = (f.value as AppSelectOption).label;
let channel = selectChannel(this.store.getState(), channelID);
if (!channel) {
const dispatchResult = await this.store.dispatch(getChannel(channelID));
if ('error' in dispatchResult) {
// Silently fail on default value
break;
case AppFieldTypes.CHANNEL: {
const channelID = (f.value as AppSelectOption).label;
let channel = selectChannel(this.store.getState(), channelID);
if (!channel) {
const dispatchResult = await this.store.dispatch(getChannel(channelID));
if ('error' in dispatchResult) {
// Silently fail on default value
break;
}
channel = dispatchResult.data;
}
channel = dispatchResult.data;
parsed.values[f.name] = channel.name;
break;
}
parsed.values[f.name] = channel.name;
break;
}
case AppFieldTypes.STATIC_SELECT:
case AppFieldTypes.DYNAMIC_SELECT:
parsed.values[f.name] = (f.value as AppSelectOption).value;
break;
case AppFieldTypes.MARKDOWN:
case AppFieldTypes.STATIC_SELECT:
case AppFieldTypes.DYNAMIC_SELECT:
parsed.values[f.name] = (f.value as AppSelectOption).value;
break;
case AppFieldTypes.MARKDOWN:
// Do nothing
// Do nothing
}
}
}) || []);
}
@@ -926,8 +961,11 @@ export class AppCommandParser {
// getCommandBindings returns the commands in the redux store.
// They are grouped by app id since each app has one base command
private getCommandBindings = (): AppBinding[] => {
const bindings = getCommandBindings(this.store.getState());
return bindings;
const state = this.store.getState();
if (this.rootPostID) {
return getRHSCommandBindings(state);
}
return getCommandBindings(state);
}
// getChannel gets the channel in which the user is typing the command
@@ -937,9 +975,6 @@ export class AppCommandParser {
}
public setChannelContext = (channelID: string, teamID = '', rootPostID?: string) => {
if (this.channelID !== channelID || this.rootPostID !== rootPostID || this.teamID !== teamID) {
this.forms = {};
}
this.channelID = channelID;
this.rootPostID = rootPostID;
this.teamID = teamID;
@@ -981,7 +1016,7 @@ export class AppCommandParser {
}
context.channel_id = channel.id;
context.team_id = this.teamID || channel.team_id || getCurrentTeamId(this.store.getState());
context.team_id = channel.team_id || getCurrentTeamId(this.store.getState());
return context;
}
@@ -989,7 +1024,10 @@ export class AppCommandParser {
// fetchForm unconditionaly retrieves the form for the given binding (subcommand)
private fetchForm = async (binding: AppBinding): Promise<{form?: AppForm; error?: string} | undefined> => {
if (!binding.call) {
return undefined;
return {error: this.intl.formatMessage({
id: 'apps.error.parser.missing_call',
defaultMessage: 'Missing binding call.',
})};
}
const payload = createCallRequest(
@@ -1033,28 +1071,25 @@ export class AppCommandParser {
public getForm = async (location: string, binding: AppBinding): Promise<{form?: AppForm; error?: string} | undefined> => {
const rootID = this.rootPostID || '';
const key = `${this.channelID}-${rootID}-${location}`;
const form = this.forms[key];
const form = this.rootPostID ? getAppRHSCommandForm(this.store.getState(), key) : getAppCommandForm(this.store.getState(), key);
if (form) {
return {form};
}
this.forms = {};
const fetched = await this.fetchForm(binding);
if (fetched?.form) {
this.forms[key] = fetched.form;
let actionType: string = AppsTypes.RECEIVED_APP_COMMAND_FORM;
if (this.rootPostID) {
actionType = AppsTypes.RECEIVED_APP_RHS_COMMAND_FORM;
}
this.store.dispatch({
data: {form: fetched.form, location: key},
type: actionType,
});
}
return fetched;
}
// displayError shows an error that was caught by the parser
private displayError = (err: any): void => {
let errStr = err as string;
if (err.message) {
errStr = err.message;
}
displayError(this.intl, errStr);
}
// getSuggestionsForSubCommands returns suggestions for a subcommand's name
private getCommandSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => {
if (!parsed.binding?.bindings?.length) {
@@ -1104,6 +1139,14 @@ export class AppCommandParser {
case ParseState.EndTickedValue:
case ParseState.TickValue:
return this.getValueSuggestions(parsed, '`');
case ParseState.Rest: {
const execute = getExecuteSuggestion(parsed);
const value = await this.getValueSuggestions(parsed);
if (execute) {
return [execute, ...value];
}
return value;
}
}
return [];
}
@@ -1332,11 +1375,11 @@ export class AppCommandParser {
});
return [{
Complete: '',
Suggestion: this.intl.formatMessage({
Suggestion: '',
Hint: this.intl.formatMessage({
id: 'apps.suggestion.dynamic.error',
defaultMessage: 'Dynamic select error',
}),
Hint: '',
IconData: COMMAND_SUGGESTION_ERROR,
Description: errMsg,
}];

View File

@@ -2,7 +2,6 @@
// See LICENSE.txt for license information.
import {intlShape} from 'react-intl';
import {Alert} from 'react-native';
import {getUserByUsername, getUser, autocompleteUsers} from '@mm-redux/actions/users';
import {getCurrentTeamId, getCurrentTeam} from '@mm-redux/selectors/entities/teams';
@@ -37,6 +36,8 @@ export type {
DoAppCallResult,
} from 'types/actions/apps';
export {AppsTypes} from '@mm-redux/action_types';
export type {AutocompleteSuggestion};
export type {
@@ -65,7 +66,7 @@ export {
COMMAND_SUGGESTION_USER,
} from '@mm-redux/constants/apps';
export {makeAppBindingsSelector} from '@mm-redux/selectors/entities/apps';
export {makeAppBindingsSelector, makeRHSAppBindingSelector, getAppCommandForm, getAppRHSCommandForm} from '@mm-redux/selectors/entities/apps';
export {getPost} from '@mm-redux/selectors/entities/posts';
export {getChannel as selectChannel, getCurrentChannel, getChannelByName as selectChannelByName} from '@mm-redux/selectors/entities/channels';
@@ -112,14 +113,6 @@ export const getExecuteSuggestion = (_: ParsedCommand): AutocompleteSuggestion |
return null;
};
export const displayError = (intl: typeof intlShape, body: string) => {
const title = intl.formatMessage({
id: 'mobile.general.error.title',
defaultMessage: 'Error',
});
Alert.alert(title, body);
};
export const errorMessage = (intl: typeof intlShape, error: string, _command: string, _position: number): string => { // eslint-disable-line @typescript-eslint/no-unused-vars
return intl.formatMessage({
id: 'apps.error.parser',

View File

@@ -181,6 +181,37 @@ export const createCommand: AppBinding = {
} as AppForm,
};
export const restCommand: AppBinding = {
app_id: 'jira',
label: 'rest',
location: 'rest',
description: 'rest description',
icon: 'rest icon',
hint: 'rest hint',
form: {
call: {
path: '/create-issue',
},
fields: [
{
name: 'summary',
label: 'summary',
description: 'The Jira issue summary',
type: AppFieldTypes.TEXT,
hint: 'The thing is working great!',
position: -1,
},
{
name: 'verbose',
label: 'verbose',
description: 'display details',
type: AppFieldTypes.BOOL,
hint: 'yes or no!',
},
],
} as AppForm,
};
export const testBindings: AppBinding[] = [
{
app_id: '',
@@ -204,6 +235,7 @@ export const testBindings: AppBinding[] = [
bindings: [
viewCommand,
createCommand,
restCommand,
],
}],
},

View File

@@ -13,18 +13,11 @@ exports[`components/autocomplete/app_slash_suggestion should match snapshot 1`]
},
]
}
disableVirtualization={false}
horizontal={false}
initialNumToRender={10}
keyExtractor={[Function]}
keyboardShouldPersistTaps="always"
maxToRenderPerBatch={10}
nestedScrollEnabled={false}
numColumns={1}
onEndReachedThreshold={2}
removeClippedSubviews={true}
renderItem={[Function]}
scrollEventThrottle={50}
style={
Array [
Object {
@@ -39,7 +32,5 @@ exports[`components/autocomplete/app_slash_suggestion should match snapshot 1`]
]
}
testID="app_slash_suggestion.list"
updateCellsBatchingPeriod={50}
windowSize={21}
/>
`;

View File

@@ -27,7 +27,12 @@ const makeStore = async (bindings: AppBinding[]) => {
...reduxTestState,
entities: {
...reduxTestState.entities,
apps: {bindings},
apps: {
bindings,
bindingsForms: {},
threadBindings: bindings,
threadBindingsForms: {},
},
},
} as any;
const testStore = await mockStore(initialState);
@@ -103,7 +108,7 @@ describe('components/autocomplete/app_slash_suggestion', () => {
expect(wrapper.state('dataSource')).toEqual([]);
});
test('should show commands from app sub commands', async (done) => {
test('should show commands from app sub commands', (done?: jest.DoneCallback) => {
const props: Props = {
...baseProps,
};
@@ -126,7 +131,9 @@ describe('components/autocomplete/app_slash_suggestion', () => {
setTimeout(() => {
expect(wrapper.state('dataSource')).toEqual(expected);
done();
if (done) {
done();
}
});
});

View File

@@ -155,13 +155,15 @@ const SlashSuggestionItem = (props: Props) => {
</View>
<View style={style.suggestionContainer}>
<Text style={style.suggestionName}>{`${suggestionText}`}</Text>
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={style.suggestionDescription}
>
{description}
</Text>
{Boolean(description) &&
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={style.suggestionDescription}
>
{description}
</Text>
}
</View>
</View>
</TouchableWithFeedback>

View File

@@ -48,7 +48,7 @@ export default class SpecialMentionItem extends PureComponent {
<View style={style.row}>
<View style={style.rowPicture}>
<CompassIcon
name='account-group-outline'
name='account-multiple-outline'
style={style.rowIcon}
/>
</View>

View File

@@ -1,10 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import PropTypes from 'prop-types';
import React, {PureComponent} from 'react';
import {intlShape} from 'react-intl';
import {Text, View} from 'react-native';
import {Text, View, Platform} from 'react-native';
import {goToScreen} from '@actions/navigation';
import CompassIcon from '@components/compass_icon';
@@ -12,33 +11,46 @@ import FormattedText from '@components/formatted_text';
import Markdown from '@components/markdown';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {ViewTypes} from '@constants';
import {ActionResult} from '@mm-redux/types/actions';
import {Channel} from '@mm-redux/types/channels';
import {DialogOption} from '@mm-redux/types/integrations';
import {Theme} from '@mm-redux/types/theme';
import {UserProfile} from '@mm-redux/types/users';
import {displayUsername} from '@mm-redux/utils/user_utils';
import {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown';
import {preventDoubleTap} from '@utils/tap';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
export default class AutocompleteSelector extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
setAutocompleteSelector: PropTypes.func.isRequired,
}).isRequired,
getDynamicOptions: PropTypes.func,
label: PropTypes.string,
placeholder: PropTypes.string.isRequired,
dataSource: PropTypes.string,
options: PropTypes.arrayOf(PropTypes.object),
selected: PropTypes.object,
optional: PropTypes.bool,
showRequiredAsterisk: PropTypes.bool,
teammateNameDisplay: PropTypes.string,
theme: PropTypes.object.isRequired,
onSelected: PropTypes.func,
helpText: PropTypes.node,
errorText: PropTypes.node,
roundedBorders: PropTypes.bool,
disabled: PropTypes.bool,
};
type Selection = DialogOption | Channel | UserProfile | DialogOption[] | Channel[] | UserProfile[];
type Props = {
actions: {
setAutocompleteSelector: (dataSource: any, onSelect: any, options: any, getDynamicOptions: any) => Promise<ActionResult>;
};
getDynamicOptions?: (term: string) => Promise<ActionResult>;
label?: string;
placeholder?: string;
dataSource?: string;
options?: DialogOption[];
selected?: DialogOption | DialogOption[];
optional?: boolean;
showRequiredAsterisk?: boolean;
teammateNameDisplay?: string;
theme: Theme;
onSelected?: ((item: DialogOption) => void) | ((item: DialogOption[]) => void);
helpText?: string;
errorText?: string;
roundedBorders?: boolean;
disabled?: boolean;
isMultiselect?: boolean;
}
type State = {
selectedText: string;
selected?: DialogOption | DialogOption[];
}
export default class AutocompleteSelector extends PureComponent<Props, State> {
static contextTypes = {
intl: intlShape,
};
@@ -49,26 +61,45 @@ export default class AutocompleteSelector extends PureComponent {
roundedBorders: true,
};
constructor(props) {
constructor(props: Props) {
super(props);
this.state = {
selectedText: null,
selectedText: '',
};
}
static getDerivedStateFromProps(props, state) {
if (props.selected && props.selected !== state.selected) {
static getDerivedStateFromProps(props: Props, state: State) {
if (!props.selected || props.selected === state.selected) {
return null;
}
if (!props.isMultiselect) {
return {
selectedText: props.selected.text,
selectedText: (props.selected as DialogOption).text,
selected: props.selected,
};
}
return null;
const options = props.selected as DialogOption[];
let selectedText = '';
const selected: DialogOption[] = [];
options.forEach((option) => {
if (selectedText !== '') {
selectedText += ', ';
}
selectedText += option.text;
selected.push(option);
});
return {
selectedText,
selected,
};
}
handleSelect = (selected) => {
handleSelect = (selected: Selection) => {
if (!selected) {
return;
}
@@ -78,34 +109,113 @@ export default class AutocompleteSelector extends PureComponent {
teammateNameDisplay,
} = this.props;
let selectedText;
let selectedValue;
if (dataSource === ViewTypes.DATA_SOURCE_USERS) {
selectedText = displayUsername(selected, teammateNameDisplay);
selectedValue = selected.id;
} else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) {
selectedText = selected.display_name;
selectedValue = selected.id;
} else {
selectedText = selected.text;
selectedValue = selected.value;
if (!this.props.isMultiselect) {
let selectedText: string;
let selectedValue: string;
switch (dataSource) {
case ViewTypes.DATA_SOURCE_USERS: {
const typedSelected = selected as UserProfile;
selectedText = displayUsername(typedSelected, teammateNameDisplay || '');
selectedValue = typedSelected.id;
break;
}
case ViewTypes.DATA_SOURCE_CHANNELS: {
const typedSelected = selected as Channel;
selectedText = typedSelected.display_name;
selectedValue = typedSelected.id;
break;
}
default: {
const typedSelected = selected as DialogOption;
selectedText = typedSelected.text;
selectedValue = typedSelected.value;
}
}
this.setState({selectedText});
if (this.props.onSelected) {
(this.props.onSelected as (opt: DialogOption) => void)({text: selectedText, value: selectedValue});
}
return;
}
let selectedText = '';
const selectedOptions: DialogOption[] = [];
switch (dataSource) {
case ViewTypes.DATA_SOURCE_USERS: {
const typedSelected = selected as UserProfile[];
typedSelected.forEach((option) => {
if (selectedText !== '') {
selectedText += ', ';
}
const text = displayUsername(option, teammateNameDisplay || '');
selectedText += text;
selectedOptions.push({text, value: option.id});
});
break;
}
case ViewTypes.DATA_SOURCE_CHANNELS: {
const typedSelected = selected as Channel[];
typedSelected.forEach((option) => {
if (selectedText !== '') {
selectedText += ', ';
}
const text = option.display_name;
selectedText += text;
selectedOptions.push({text, value: option.id});
});
break;
}
default: {
const typedSelected = selected as DialogOption[];
typedSelected.forEach((option) => {
if (selectedText !== '') {
selectedText += ', ';
}
selectedText += option.text;
selectedOptions.push(option);
});
break;
}
}
this.setState({selectedText});
if (this.props.onSelected) {
this.props.onSelected({text: selectedText, value: selectedValue});
(this.props.onSelected as (opt: DialogOption[]) => void)(selectedOptions);
}
};
goToSelectorScreen = preventDoubleTap(() => {
goToSelectorScreen = preventDoubleTap(async () => {
const closeButton = await CompassIcon.getImageSource(Platform.select({ios: 'arrow-back-ios', default: 'arrow-left'}), 24, this.props.theme.sidebarHeaderTextColor);
const {formatMessage} = this.context.intl;
const {actions, dataSource, options, placeholder, getDynamicOptions} = this.props;
const {actions, dataSource, options, placeholder, getDynamicOptions, theme} = this.props;
const screen = 'SelectorScreen';
const title = placeholder || formatMessage({id: 'mobile.action_menu.select', defaultMessage: 'Select an option'});
const buttonName = formatMessage({id: 'mobile.forms.select.done', defaultMessage: 'Done'});
actions.setAutocompleteSelector(dataSource, this.handleSelect, options, getDynamicOptions);
goToScreen(screen, title);
let screenOptions = {};
if (this.props.isMultiselect) {
screenOptions = {
topBar: {
leftButtons: [{
id: 'close-dialog',
icon: closeButton,
}],
rightButtons: [{
id: 'submit-form',
showAsAction: 'always',
text: buttonName,
}],
leftButtonColor: theme.sidebarHeaderTextColor,
rightButtonColor: theme.sidebarHeaderTextColor,
},
};
}
goToScreen(screen, title, {isMultiselect: this.props.isMultiselect, selected: this.state.selected}, screenOptions);
});
render() {
@@ -126,6 +236,8 @@ export default class AutocompleteSelector extends PureComponent {
const textStyles = getMarkdownTextStyles(theme);
const blockStyles = getMarkdownBlockStyles(theme);
const chevron = Platform.select({ios: 'chevron-right', default: 'chevron-down'});
let text = placeholder || intl.formatMessage({id: 'mobile.action_menu.select', defaultMessage: 'Select an option'});
let selectedStyle = style.dropdownPlaceholder;
@@ -215,8 +327,8 @@ export default class AutocompleteSelector extends PureComponent {
{text}
</Text>
<CompassIcon
name='chevron-down'
color={changeOpacity(theme.centerChannelColor, 0.5)}
name={chevron}
color={changeOpacity(theme.centerChannelColor, 0.32)}
style={style.icon}
/>
</View>
@@ -228,7 +340,7 @@ export default class AutocompleteSelector extends PureComponent {
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
const input = {
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.1),
@@ -263,8 +375,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
},
icon: {
position: 'absolute',
top: 13,
top: 6,
right: 12,
fontSize: 28,
},
labelContainer: {
flexDirection: 'row',

View File

@@ -2,23 +2,29 @@
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
import {setAutocompleteSelector} from '@actions/views/post';
import {getTeammateNameDisplaySetting, getTheme} from '@mm-redux/selectors/entities/preferences';
import AutocompleteSelector from './autocomplete_selector';
function mapStateToProps(state) {
import type {Action, ActionResult, GenericAction} from '@mm-redux/types/actions';
import type {GlobalState} from '@mm-redux/types/store';
function mapStateToProps(state: GlobalState) {
return {
teammateNameDisplay: getTeammateNameDisplaySetting(state),
theme: getTheme(state),
};
}
function mapDispatchToProps(dispatch) {
type Actions = {
setAutocompleteSelector: (dataSource: any, onSelect: any, options: any, getDynamicOptions: any) => Promise<ActionResult>;
}
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators({
actions: bindActionCreators<ActionCreatorsMapObject<Action>, Actions>({
setAutocompleteSelector,
}, dispatch),
};

View File

@@ -86,6 +86,7 @@ export interface AvatarsProps {
breakAt?: number;
style?: StyleProp<ViewStyle>;
theme: Theme;
listTitle?: JSX.Element;
}
export default class Avatars extends PureComponent<AvatarsProps> {
@@ -94,11 +95,12 @@ export default class Avatars extends PureComponent<AvatarsProps> {
};
showParticipantsList = () => {
const {userIds} = this.props;
const {userIds, listTitle} = this.props;
const screen = 'ParticipantsList';
const passProps = {
userIds,
listTitle,
};
showModalOverCurrentContext(screen, passProps);

View File

@@ -60,9 +60,7 @@ exports[`ChannelLoader should match snapshot 1`] = `
}
>
<ActivityIndicator
animating={true}
color="#FFFFFF"
hidesWhenStopped={true}
size="small"
/>
</View>

View File

@@ -23,6 +23,8 @@ describe('ChannelLoader', () => {
test('should call setTimeout and setInterval for showIndicator and retryLoad on mount', () => {
shallow(<ChannelLoader {...baseProps}/>);
const setTimeout = jest.spyOn(global, 'setTimeout');
const setInterval = jest.spyOn(global, 'setInterval');
expect(setTimeout).not.toHaveBeenCalled();
expect(setInterval).not.toHaveBeenCalled();
@@ -42,6 +44,8 @@ describe('ChannelLoader', () => {
retryLoad: jest.fn(),
};
const wrapper = shallow(<ChannelLoader {...props}/>);
const clearTimeout = jest.spyOn(global, 'clearTimeout');
const clearInterval = jest.spyOn(global, 'clearInterval');
const instance = wrapper.instance();
instance.componentWillUnmount();

View File

@@ -20,15 +20,11 @@ exports[`CustomList should match snapshot with FlatList 1`] = `
},
]
}
disableVirtualization={false}
horizontal={false}
initialNumToRender={15}
keyExtractor={[Function]}
keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="always"
maxToRenderPerBatch={16}
numColumns={1}
onEndReachedThreshold={2}
onLayout={[Function]}
onScroll={[Function]}
removeClippedSubviews={true}
@@ -41,8 +37,6 @@ exports[`CustomList should match snapshot with FlatList 1`] = `
"flex": 1,
}
}
updateCellsBatchingPeriod={50}
windowSize={21}
/>
`;
@@ -56,16 +50,12 @@ exports[`CustomList should match snapshot with SectionList 1`] = `
"flexGrow": 1,
}
}
data={Array []}
disableVirtualization={false}
extraData={false}
horizontal={false}
initialNumToRender={15}
keyExtractor={[Function]}
keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="always"
maxToRenderPerBatch={16}
onEndReachedThreshold={2}
onLayout={[Function]}
onScroll={[Function]}
removeClippedSubviews={true}
@@ -89,8 +79,6 @@ exports[`CustomList should match snapshot with SectionList 1`] = `
"flex": 1,
}
}
updateCellsBatchingPeriod={50}
windowSize={21}
/>
`;

View File

@@ -52,33 +52,35 @@ export default class ChannelListRow extends React.PureComponent {
}
return (
<CustomListRow
id={this.props.id}
onPress={this.props.onPress ? this.onPress : null}
enabled={this.props.enabled}
selectable={this.props.selectable}
selected={this.props.selected}
testID={testID}
>
<View
style={style.container}
testID={itemTestID}
<View style={style.outerContainer}>
<CustomListRow
id={this.props.id}
onPress={this.props.onPress ? this.onPress : null}
enabled={this.props.enabled}
selectable={this.props.selectable}
selected={this.props.selected}
testID={testID}
>
<View style={style.titleContainer}>
<CompassIcon
name={icon}
style={style.icon}
/>
<Text
style={style.displayName}
testID={channelDisplayNameTestID}
>
{this.props.channel.display_name}
</Text>
<View
style={style.container}
testID={itemTestID}
>
<View style={style.titleContainer}>
<CompassIcon
name={icon}
style={style.icon}
/>
<Text
style={style.displayName}
testID={channelDisplayNameTestID}
>
{this.props.channel.display_name}
</Text>
</View>
{purpose}
</View>
{purpose}
</View>
</CustomListRow>
</CustomListRow>
</View>
);
}
}
@@ -101,7 +103,12 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
container: {
flex: 1,
flexDirection: 'column',
},
outerContainer: {
flex: 1,
flexDirection: 'row',
paddingHorizontal: 15,
overflow: 'hidden',
},
purpose: {
marginTop: 7,

View File

@@ -42,21 +42,23 @@ export default class OptionListRow extends React.PureComponent {
const style = getStyleFromTheme(theme);
return (
<CustomListRow
id={value}
onPress={this.onPress}
enabled={enabled}
selectable={selectable}
selected={selected}
>
<View style={style.textContainer}>
<View>
<Text style={style.optionText}>
{text}
</Text>
<View style={style.container}>
<CustomListRow
id={value}
onPress={this.onPress}
enabled={enabled}
selectable={selectable}
selected={selected}
>
<View style={style.textContainer}>
<View>
<Text style={style.optionText}>
{text}
</Text>
</View>
</View>
</View>
</CustomListRow>
</CustomListRow>
</View>
);
}
}

View File

@@ -6,8 +6,8 @@ exports[`UserListRow should match snapshot 1`] = `
Object {
"flex": 1,
"flexDirection": "row",
"marginHorizontal": 10,
"overflow": "hidden",
"paddingHorizontal": 15,
}
}
>
@@ -142,8 +142,8 @@ exports[`UserListRow should match snapshot for currentUser with (you) populated
Object {
"flex": 1,
"flexDirection": "row",
"marginHorizontal": 10,
"overflow": "hidden",
"paddingHorizontal": 15,
}
}
>
@@ -278,8 +278,8 @@ exports[`UserListRow should match snapshot for deactivated user 1`] = `
Object {
"flex": 1,
"flexDirection": "row",
"marginHorizontal": 10,
"overflow": "hidden",
"paddingHorizontal": 15,
}
}
>
@@ -427,8 +427,8 @@ exports[`UserListRow should match snapshot for guest user 1`] = `
Object {
"flex": 1,
"flexDirection": "row",
"marginHorizontal": 10,
"overflow": "hidden",
"paddingHorizontal": 15,
}
}
>
@@ -563,8 +563,8 @@ exports[`UserListRow should match snapshot for remote user 1`] = `
Object {
"flex": 1,
"flexDirection": "row",
"marginHorizontal": 10,
"overflow": "hidden",
"paddingHorizontal": 15,
}
}
>

View File

@@ -165,7 +165,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
container: {
flex: 1,
flexDirection: 'row',
marginHorizontal: 10,
paddingHorizontal: 15,
overflow: 'hidden',
},
profileContainer: {

View File

@@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/custom_status/clear_button should match snapshot 1`] = `
<ForwardRef
<TouchableOpacity
onPress={[Function]}
style={
Array [
@@ -27,5 +27,5 @@ exports[`components/custom_status/clear_button should match snapshot 1`] = `
}
}
/>
</ForwardRef>
</TouchableOpacity>
`;

View File

@@ -74,6 +74,7 @@ exports[`EditChannelInfo should match snapshot 1`] = `
}
>
<TextInputWithLocalizedPlaceholder
allowFontScaling={true}
autoCapitalize="none"
autoCorrect={false}
disableFullscreenUI={true}
@@ -140,6 +141,7 @@ exports[`EditChannelInfo should match snapshot 1`] = `
}
>
<TextInputWithLocalizedPlaceholder
allowFontScaling={true}
autoCapitalize="none"
autoCorrect={false}
blurOnSubmit={false}
@@ -229,6 +231,7 @@ exports[`EditChannelInfo should match snapshot 1`] = `
}
>
<TextInputWithLocalizedPlaceholder
allowFontScaling={true}
autoCapitalize="none"
autoCorrect={false}
blurOnSubmit={false}

View File

@@ -279,6 +279,7 @@ export default class EditChannelInfo extends PureComponent {
onPress={() => {
this.onTypeSelect(General.OPEN_CHANNEL);
}}
testID='edit_channel_info.type.public.action'
>
<FormattedText
style={style.touchableText}
@@ -305,6 +306,7 @@ export default class EditChannelInfo extends PureComponent {
onPress={() => {
this.onTypeSelect(General.PRIVATE_CHANNEL);
}}
testID='edit_channel_info.type.private.action'
>
<FormattedText
style={style.touchableText}
@@ -334,6 +336,7 @@ export default class EditChannelInfo extends PureComponent {
</View>
<View style={style.inputContainer}>
<TextInputWithLocalizedPlaceholder
allowFontScaling={true}
testID='edit_channel_info.name.input'
ref={this.nameInput}
value={displayName}
@@ -364,6 +367,7 @@ export default class EditChannelInfo extends PureComponent {
</View>
<View style={style.inputContainer}>
<TextInputWithLocalizedPlaceholder
allowFontScaling={true}
testID='edit_channel_info.purpose.input'
ref={this.purposeInput}
value={purpose}
@@ -407,6 +411,7 @@ export default class EditChannelInfo extends PureComponent {
</View>
<View style={style.inputContainer}>
<TextInputWithLocalizedPlaceholder
allowFontScaling={true}
testID='edit_channel_info.header.input'
ref={this.headerInput}
value={header}

View File

@@ -87,15 +87,10 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
>
<SectionList
ListFooterComponent={[Function]}
data={Array []}
disableVirtualization={false}
getItemLayout={[Function]}
horizontal={false}
initialNumToRender={50}
keyExtractor={[Function]}
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="always"
maxToRenderPerBatch={10}
nativeID="emojiPicker"
onEndReached={[Function]}
onEndReachedThreshold={0}
@@ -106,7 +101,6 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
removeClippedSubviews={true}
renderItem={[Function]}
renderSectionHeader={[Function]}
scrollEventThrottle={50}
sections={
Array [
Object {
@@ -12888,7 +12882,6 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
]
}
showsVerticalScrollIndicator={false}
stickySectionHeadersEnabled={true}
style={
Array [
Object {},
@@ -12897,8 +12890,6 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
},
]
}
updateCellsBatchingPeriod={50}
windowSize={21}
/>
<KeyboardTrackingView
normalList={true}
@@ -12926,7 +12917,7 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
}
}
>
<ForwardRef
<TouchableOpacity
onPress={[Function]}
style={
Object {
@@ -12951,8 +12942,8 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
]
}
/>
</ForwardRef>
<ForwardRef
</TouchableOpacity>
<TouchableOpacity
onPress={[Function]}
style={
Object {
@@ -12975,8 +12966,8 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
]
}
/>
</ForwardRef>
<ForwardRef
</TouchableOpacity>
<TouchableOpacity
onPress={[Function]}
style={
Object {
@@ -12999,8 +12990,8 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
]
}
/>
</ForwardRef>
<ForwardRef
</TouchableOpacity>
<TouchableOpacity
onPress={[Function]}
style={
Object {
@@ -13023,8 +13014,8 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
]
}
/>
</ForwardRef>
<ForwardRef
</TouchableOpacity>
<TouchableOpacity
onPress={[Function]}
style={
Object {
@@ -13047,8 +13038,8 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
]
}
/>
</ForwardRef>
<ForwardRef
</TouchableOpacity>
<TouchableOpacity
onPress={[Function]}
style={
Object {
@@ -13071,8 +13062,8 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
]
}
/>
</ForwardRef>
<ForwardRef
</TouchableOpacity>
<TouchableOpacity
onPress={[Function]}
style={
Object {
@@ -13095,8 +13086,8 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
]
}
/>
</ForwardRef>
<ForwardRef
</TouchableOpacity>
<TouchableOpacity
onPress={[Function]}
style={
Object {
@@ -13119,8 +13110,8 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
]
}
/>
</ForwardRef>
<ForwardRef
</TouchableOpacity>
<TouchableOpacity
onPress={[Function]}
style={
Object {
@@ -13143,8 +13134,8 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
]
}
/>
</ForwardRef>
<ForwardRef
</TouchableOpacity>
<TouchableOpacity
onPress={[Function]}
style={
Object {
@@ -13167,7 +13158,7 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
]
}
/>
</ForwardRef>
</TouchableOpacity>
</View>
</View>
</KeyboardTrackingView>

View File

@@ -60,17 +60,17 @@ describe('components/emoji_picker/emoji_picker.ios', () => {
];
testCases.forEach((testCase) => {
test(`'${testCase.input}' should return '${testCase.output}'`, async () => {
test(`'${testCase.input}' should return '${testCase.output}'`, () => {
expect(filterEmojiSearchInput(testCase.input)).toEqual(testCase.output);
});
});
test('should match snapshot', async () => {
test('should match snapshot', () => {
const wrapper = shallowWithIntl(<EmojiPicker {...baseProps}/>);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('searchEmojis should return the right values on fuse', async () => {
test('searchEmojis should return the right values on fuse', () => {
const input = '1';
const output = ['100', '1234', '1st_place_medal', '+1', '-1', 'clock1', 'clock10', 'clock1030', 'clock11', 'clock1130', 'clock12', 'clock1230', 'clock130', 'u7121', 'u7981'];
@@ -79,7 +79,7 @@ describe('components/emoji_picker/emoji_picker.ios', () => {
expect(result).toEqual(output);
});
test('should rebuild emojis emojis when emojis change', async () => {
test('should rebuild emojis emojis when emojis change', () => {
const wrapper = shallowWithIntl(<EmojiPicker {...baseProps}/>);
const instance = wrapper.instance();
const renderableEmojis = jest.spyOn(instance, 'renderableEmojis');
@@ -92,7 +92,7 @@ describe('components/emoji_picker/emoji_picker.ios', () => {
expect(renderableEmojis).toHaveBeenCalledWith(baseProps.emojisBySection, baseProps.deviceWidth);
});
test('should set rebuilt emojis when rebuildEmojis is true and searchBarAnimationComplete is true', async () => {
test('should set rebuilt emojis when rebuildEmojis is true and searchBarAnimationComplete is true', () => {
const wrapper = shallowWithIntl(<EmojiPicker {...baseProps}/>);
const instance = wrapper.instance();
instance.setState = jest.fn();
@@ -107,7 +107,7 @@ describe('components/emoji_picker/emoji_picker.ios', () => {
expect(instance.rebuildEmojis).toBe(false);
});
test('should not set rebuilt emojis when rebuildEmojis is false and searchBarAnimationComplete is true', async () => {
test('should not set rebuilt emojis when rebuildEmojis is false and searchBarAnimationComplete is true', () => {
const wrapper = shallowWithIntl(<EmojiPicker {...baseProps}/>);
const instance = wrapper.instance();
instance.setState = jest.fn();
@@ -120,7 +120,7 @@ describe('components/emoji_picker/emoji_picker.ios', () => {
expect(instance.setState).not.toHaveBeenCalled();
});
test('should not set rebuilt emojis when rebuildEmojis is true and searchBarAnimationComplete is false', async () => {
test('should not set rebuilt emojis when rebuildEmojis is true and searchBarAnimationComplete is false', () => {
const wrapper = shallowWithIntl(<EmojiPicker {...baseProps}/>);
const instance = wrapper.instance();
instance.setState = jest.fn();

View File

@@ -0,0 +1,62 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import moment from 'moment';
import React from 'react';
import FormattedRelativeTime from './formatted_relative_time';
jest.mock('react', () => ({
...jest.requireActual('react'),
useEffect: (f) => f(),
}));
describe('FormattedRelativeTime', () => {
const baseProps = {
value: moment.now() - 15000,
updateIntervalInSeconds: 10000,
};
test('should match snapshot', () => {
const wrapper = shallow(<FormattedRelativeTime {...baseProps}/>);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match string in the past', () => {
const props = {...baseProps, value: moment.now() - ((10 * 60 * 60 * 1000) + (30 * 60 * 1000) + (25 * 1000) + 500)};
const wrapper = shallow(<FormattedRelativeTime {...props}/>);
expect(wrapper.getElement().props.children).toBe('11 hours ago');
});
test('should match string in the future', () => {
const props = {...baseProps, value: moment.now() + 15500};
const wrapper = shallow(<FormattedRelativeTime {...props}/>);
expect(wrapper.getElement().props.children).toBe('in a few seconds');
});
test('should re-render after updateIntervalInSeconds', () => {
jest.useFakeTimers();
const props = {...baseProps, value: moment.now(), updateIntervalInSeconds: 120};
const wrapper = shallow(<FormattedRelativeTime {...props}/>);
expect(wrapper.getElement().props.children).toBe('a few seconds ago');
jest.advanceTimersByTime(60000);
expect(wrapper.getElement().props.children).toBe('a few seconds ago');
jest.advanceTimersByTime(120000);
expect(wrapper.getElement().props.children).toBe('2 minutes ago');
jest.useRealTimers();
});
test('should not re-render if updateIntervalInSeconds is not passed', () => {
jest.useFakeTimers();
const props = {value: baseProps.value};
const wrapper = shallow(<FormattedRelativeTime {...props}/>);
expect(wrapper.getElement().props.children).toBe('a few seconds ago');
jest.advanceTimersByTime(120000000000);
expect(wrapper.getElement().props.children).toBe('a few seconds ago');
jest.useRealTimers();
});
});

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment-timezone';
import React, {useEffect, useState} from 'react';
import {Text, TextProps} from 'react-native';
import type {UserTimezone} from '@mm-redux/types/users';
type FormattedRelativeTimeProps = TextProps & {
timezone?: UserTimezone | string;
value: number | string | Date;
updateIntervalInSeconds?: number;
}
const FormattedRelativeTime = ({timezone, value, updateIntervalInSeconds, ...props}: FormattedRelativeTimeProps) => {
const getFormattedRelativeTime = () => {
let zone = timezone;
if (typeof timezone === 'object') {
zone = timezone.useAutomaticTimezone ? timezone.automaticTimezone : timezone.manualTimezone;
}
return timezone ? moment.tz(value, zone as string).fromNow() : moment(value).fromNow();
};
const [formattedTime, setFormattedTime] = useState(getFormattedRelativeTime());
useEffect(() => {
if (updateIntervalInSeconds) {
const interval = setInterval(() => setFormattedTime(getFormattedRelativeTime()), updateIntervalInSeconds * 1000);
return () => {
clearInterval(interval);
};
}
return () => null;
}, [updateIntervalInSeconds]);
return (
<Text {...props}>
{formattedTime}
</Text>
);
};
export default FormattedRelativeTime;

View File

@@ -5,9 +5,14 @@ import React from 'react';
import {injectIntl, intlShape} from 'react-intl';
import {Alert, FlatList} from 'react-native';
import {goToScreen} from '@actions/navigation';
import {THREAD} from '@constants/screen';
import EventEmitter from '@mm-redux/utils/event_emitter';
import ThreadList from './thread_list';
import type {ActionResult} from '@mm-redux/types/actions';
import type {Post} from '@mm-redux/types/posts';
import type {Team} from '@mm-redux/types/teams';
import type {Theme} from '@mm-redux/types/theme';
import type {ThreadsState, UserThread} from '@mm-redux/types/threads';
@@ -16,10 +21,12 @@ import type {$ID} from '@mm-redux/types/utilities';
type Props = {
actions: {
getPostThread: (postId: string) => void;
getThreads: (userId: $ID<UserProfile>, teamId: $ID<Team>, before?: $ID<UserThread>, after?: $ID<UserThread>, perPage?: number, deleted?: boolean, unread?: boolean) => Promise<ActionResult>;
handleViewingGlobalThreadsAll: () => void;
handleViewingGlobalThreadsUnreads: () => void;
markAllThreadsInTeamRead: (userId: $ID<UserProfile>, teamId: $ID<Team>) => void;
selectPost: (postId: string) => void;
};
allThreadIds: $ID<UserThread>[];
intl: typeof intlShape;
@@ -38,6 +45,7 @@ function GlobalThreadsList({actions, allThreadIds, intl, teamId, theme, threadCo
const listRef = React.useRef<FlatList>(null);
const [isLoading, setIsLoading] = React.useState<boolean>(true);
const [isRefreshing, setIsRefreshing] = React.useState<boolean>(false);
const scrollToTop = () => {
listRef.current?.scrollToOffset({offset: 0});
@@ -79,6 +87,16 @@ function GlobalThreadsList({actions, allThreadIds, intl, teamId, theme, threadCo
}
};
const onRefresh = async () => {
if (!isLoading) {
if (!isRefreshing) {
setIsRefreshing(true);
}
await loadThreads('', '', viewingUnreads);
setIsRefreshing(false);
}
};
const markAllAsRead = () => {
Alert.alert(
intl.formatMessage({
@@ -108,13 +126,32 @@ function GlobalThreadsList({actions, allThreadIds, intl, teamId, theme, threadCo
);
};
const goToThread = React.useCallback((post: Post) => {
actions.getPostThread(post.id);
actions.selectPost(post.id);
const passProps = {
channelId: post.channel_id,
rootId: post.id,
};
goToScreen(THREAD, '', passProps);
}, []);
React.useEffect(() => {
EventEmitter.on('goToThread', goToThread);
return () => {
EventEmitter.off('goToThread', goToThread);
};
}, []);
return (
<ThreadList
haveUnreads={haveUnreads}
isLoading={isLoading}
isRefreshing={isRefreshing}
listRef={listRef}
loadMoreThreads={loadMoreThreads}
markAllAsRead={markAllAsRead}
onRefresh={onRefresh}
testID={'global_threads'}
theme={theme}
threadIds={ids}

View File

@@ -4,7 +4,9 @@
import {connect} from 'react-redux';
import {bindActionCreators, Dispatch} from 'redux';
import {getPostThread} from '@actions/views/post';
import {handleViewingGlobalThreadsAll, handleViewingGlobalThreadsUnreads} from '@actions/views/threads';
import {selectPost} from '@mm-redux/actions/posts';
import {getThreads, markAllThreadsInTeamRead} from '@mm-redux/actions/threads';
import {getCurrentUserId} from '@mm-redux/selectors/entities/common';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
@@ -32,10 +34,12 @@ function mapStateToProps(state: GlobalState) {
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
getPostThread,
getThreads,
handleViewingGlobalThreadsAll,
handleViewingGlobalThreadsUnreads,
markAllThreadsInTeamRead,
selectPost,
}, dispatch),
};
}

View File

@@ -51,7 +51,7 @@ exports[`Global Thread Footer Should render for channel view and unfollow the th
>
2 replies
</Text>
<ForwardRef
<TouchableOpacity
onPress={[Function]}
style={
Object {
@@ -75,7 +75,7 @@ exports[`Global Thread Footer Should render for channel view and unfollow the th
>
Following
</Text>
</ForwardRef>
</TouchableOpacity>
</View>
`;

View File

@@ -1,7 +1,8 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Global Thread Item Should render thread item with unread messages dot 1`] = `
<ForwardRef
<TouchableHighlight
onLongPress={[Function]}
onPress={[Function]}
testID="thread_item.post1.item"
underlayColor="rgba(28,88,217,0.08)"
@@ -180,11 +181,12 @@ exports[`Global Thread Item Should render thread item with unread messages dot 1
/>
</View>
</View>
</ForwardRef>
</TouchableHighlight>
`;
exports[`Global Thread Item Should show unread mentions count 1`] = `
<ForwardRef
<TouchableHighlight
onLongPress={[Function]}
onPress={[Function]}
testID="thread_item.post1.item"
underlayColor="rgba(28,88,217,0.08)"
@@ -378,5 +380,5 @@ exports[`Global Thread Item Should show unread mentions count 1`] = `
/>
</View>
</View>
</ForwardRef>
</TouchableHighlight>
`;

View File

@@ -4,8 +4,7 @@
import {connect} from 'react-redux';
import {bindActionCreators, Dispatch} from 'redux';
import {getPost, getPostThread} from '@actions/views/post';
import {selectPost} from '@mm-redux/actions/posts';
import {getPost} from '@actions/views/post';
import {getChannel} from '@mm-redux/selectors/entities/channels';
import {getPost as getPostSelector} from '@mm-redux/selectors/entities/posts';
import {getThread} from '@mm-redux/selectors/entities/threads';
@@ -30,8 +29,6 @@ function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
getPost,
getPostThread,
selectPost,
}, dispatch),
};
}

View File

@@ -5,13 +5,12 @@ import {shallow} from 'enzyme';
import React from 'react';
import {Text} from 'react-native';
import * as navigationActions from '@actions/navigation';
import {THREAD} from '@constants/screen';
import {Preferences} from '@mm-redux/constants';
import {Channel} from '@mm-redux/types/channels';
import {Post} from '@mm-redux/types/posts';
import {UserThread} from '@mm-redux/types/threads';
import {UserProfile} from '@mm-redux/types/users';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {intl} from '@test/intl-test-helper';
import {ThreadItem} from './thread_item';
@@ -99,7 +98,7 @@ describe('Global Thread Item', () => {
});
test('Should goto threads when pressed on thread item', () => {
const goToScreen = jest.spyOn(navigationActions, 'goToScreen');
EventEmitter.emit = jest.fn();
const wrapper = shallow(
<ThreadItem
{...baseProps}
@@ -108,6 +107,6 @@ describe('Global Thread Item', () => {
const threadItem = wrapper.find({testID: `${testIDPrefix}.item`});
expect(threadItem.exists()).toBeTruthy();
threadItem.simulate('press');
expect(goToScreen).toHaveBeenCalledWith(THREAD, expect.anything(), expect.anything());
expect(EventEmitter.emit).toHaveBeenCalledWith('goToThread', expect.anything());
});
});

View File

@@ -3,29 +3,28 @@
import React from 'react';
import {injectIntl, intlShape} from 'react-intl';
import {View, Text, TouchableHighlight} from 'react-native';
import {Keyboard, Text, TouchableHighlight, View} from 'react-native';
import {goToScreen} from '@actions/navigation';
import {showModalOverCurrentContext} from '@actions/navigation';
import FriendlyDate from '@components/friendly_date';
import RemoveMarkdown from '@components/remove_markdown';
import {GLOBAL_THREADS, THREAD} from '@constants/screen';
import {GLOBAL_THREADS} from '@constants/screen';
import {Posts, Preferences} from '@mm-redux/constants';
import {Channel} from '@mm-redux/types/channels';
import {Post} from '@mm-redux/types/posts';
import {UserThread} from '@mm-redux/types/threads';
import {UserProfile} from '@mm-redux/types/users';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {displayUsername} from '@mm-redux/utils/user_utils';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import ThreadFooter from '../thread_footer';
import type {Channel} from '@mm-redux/types/channels';
import type {Post} from '@mm-redux/types/posts';
import type {Theme} from '@mm-redux/types/theme';
import type {UserThread} from '@mm-redux/types/threads';
import type {UserProfile} from '@mm-redux/types/users';
export type DispatchProps = {
actions: {
getPost: (postId: string) => void;
getPostThread: (postId: string) => void;
selectPost: (postId: string) => void;
};
}
@@ -66,13 +65,18 @@ function ThreadItem({actions, channel, intl, post, threadId, testID, theme, thre
const threadStarterName = displayUsername(threadStarter, Preferences.DISPLAY_PREFER_FULL_NAME);
const showThread = () => {
actions.getPostThread(postItem.id);
actions.selectPost(postItem.id);
EventEmitter.emit('goToThread', postItem);
};
const showThreadOptions = () => {
const screen = 'GlobalThreadOptions';
const passProps = {
channelId: postItem.channel_id,
rootId: postItem.id,
rootId: post.id,
};
goToScreen(THREAD, '', passProps);
Keyboard.dismiss();
requestAnimationFrame(() => {
showModalOverCurrentContext(screen, passProps);
});
};
const testIDPrefix = `${testID}.${postItem?.id}`;
@@ -134,6 +138,7 @@ function ThreadItem({actions, channel, intl, post, threadId, testID, theme, thre
return (
<TouchableHighlight
underlayColor={changeOpacity(theme.buttonBg, 0.08)}
onLongPress={showThreadOptions}
onPress={showThread}
testID={`${testIDPrefix}.item`}
>

View File

@@ -86,68 +86,98 @@ exports[`Global Thread List Should render threads with functional tabs & mark al
viewUnreadThreads={[MockFunction]}
viewingUnreads={true}
/>
<FlatList
ListEmptyComponent={
<EmptyState
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"formatDate": [Function],
"formatHTMLMessage": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatPlural": [Function],
"formatRelative": [Function],
"formatTime": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralFormat": [Function],
"getRelativeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"now": [Function],
"onError": [Function],
"textComponent": "span",
"timeZone": null,
<PostListRefreshControl
enabled={true}
isInverted={false}
onRefresh={[MockFunction]}
refreshing={false}
theme={
Object {
"awayIndicator": "#ffbc1f",
"buttonBg": "#1c58d9",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3f4350",
"codeTheme": "github",
"dndIndicator": "#d24b4e",
"errorTextColor": "#d24b4e",
"linkColor": "#386fe5",
"mentionBg": "#ffffff",
"mentionColor": "#1e325c",
"mentionHighlightBg": "#ffd470",
"mentionHighlightLink": "#1b1d22",
"newMessageSeparator": "#cc8f00",
"onlineIndicator": "#3db887",
"sidebarBg": "#1e325c",
"sidebarHeaderBg": "#192a4d",
"sidebarHeaderTextColor": "#ffffff",
"sidebarTeamBarBg": "#14213e",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#5d89ea",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#28427b",
"sidebarUnreadText": "#ffffff",
"type": "Denim",
}
}
>
<FlatList
ListEmptyComponent={
<EmptyState
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"formatDate": [Function],
"formatHTMLMessage": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatPlural": [Function],
"formatRelative": [Function],
"formatTime": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralFormat": [Function],
"getRelativeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"now": [Function],
"onError": [Function],
"textComponent": "span",
"timeZone": null,
}
}
isUnreads={true}
/>
}
ListFooterComponent={null}
contentContainerStyle={
Object {
"flexGrow": 1,
}
isUnreads={true}
/>
}
ListFooterComponent={null}
contentContainerStyle={
Object {
"flexGrow": 1,
}
}
data={
Array [
"thread1",
]
}
disableVirtualization={false}
horizontal={false}
initialNumToRender={10}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
numColumns={1}
onEndReached={[Function]}
onEndReachedThreshold={2}
removeClippedSubviews={true}
renderItem={[Function]}
scrollEventThrottle={50}
scrollIndicatorInsets={
Object {
"right": 1,
data={
Array [
"thread1",
]
}
}
updateCellsBatchingPeriod={50}
windowSize={21}
/>
initialNumToRender={10}
keyExtractor={[Function]}
onEndReached={[Function]}
onEndReachedThreshold={2}
onScroll={[Function]}
removeClippedSubviews={true}
renderItem={[Function]}
scrollIndicatorInsets={
Object {
"right": 1,
}
}
/>
</PostListRefreshControl>
</View>
`;

View File

@@ -25,9 +25,11 @@ describe('Global Thread List', () => {
haveUnreads: true,
intl,
isLoading: false,
isRefreshing: false,
listRef: React.useRef<FlatList>(null),
loadMoreThreads: jest.fn(),
markAllAsRead,
onRefresh: jest.fn(),
testID,
theme: Preferences.THEMES.denim,
threadIds: ['thread1'],

View File

@@ -2,12 +2,13 @@
// See LICENSE.txt for license information.
import React from 'react';
import {injectIntl, intlShape} from 'react-intl';
import {FlatList, Platform, View} from 'react-native';
import {FlatList, NativeSyntheticEvent, NativeScrollEvent, Platform, View} from 'react-native';
import EmptyState from '@components/global_threads/empty_state';
import ThreadItem from '@components/global_threads/thread_item';
import Loading from '@components/loading';
import {INITIAL_BATCH_TO_RENDER} from '@components/post_list/post_list_config';
import CustomRefreshControl from '@components/post_list/post_list_refresh_control';
import {ViewTypes} from '@constants';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@@ -21,9 +22,11 @@ export type Props = {
haveUnreads: boolean;
intl: typeof intlShape;
isLoading: boolean;
isRefreshing: boolean;
loadMoreThreads: () => Promise<void>;
listRef: React.RefObject<FlatList>;
markAllAsRead: () => void;
onRefresh: () => void;
testID: string;
theme: Theme;
threadIds: $ID<UserThread>[];
@@ -32,9 +35,11 @@ export type Props = {
viewingUnreads: boolean;
};
function ThreadList({haveUnreads, intl, isLoading, loadMoreThreads, listRef, markAllAsRead, testID, theme, threadIds, viewAllThreads, viewUnreadThreads, viewingUnreads}: Props) {
function ThreadList({haveUnreads, intl, isLoading, isRefreshing, loadMoreThreads, listRef, markAllAsRead, onRefresh, testID, theme, threadIds, viewAllThreads, viewUnreadThreads, viewingUnreads}: Props) {
const style = getStyleSheet(theme);
const [offsetY, setOffsetY] = React.useState(0);
const handleEndReached = React.useCallback(() => {
loadMoreThreads();
}, [loadMoreThreads, viewingUnreads]);
@@ -51,6 +56,17 @@ function ThreadList({haveUnreads, intl, isLoading, loadMoreThreads, listRef, mar
);
}, [theme]);
const onScroll = React.useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (Platform.OS === 'android') {
const {y} = event.nativeEvent.contentOffset;
if (y === 0) {
setOffsetY(y);
} else if (offsetY === 0 && y !== 0) {
setOffsetY(y);
}
}
}, [offsetY]);
const renderHeader = () => {
if (!viewingUnreads && !threadIds.length) {
return null;
@@ -96,21 +112,30 @@ function ThreadList({haveUnreads, intl, isLoading, loadMoreThreads, listRef, mar
return (
<View style={style.container}>
{renderHeader()}
<FlatList
contentContainerStyle={style.messagesContainer}
data={threadIds}
keyExtractor={keyExtractor}
ListEmptyComponent={renderEmptyList()}
ListFooterComponent={renderFooter()}
onEndReached={handleEndReached}
onEndReachedThreshold={2}
ref={listRef}
renderItem={renderPost}
initialNumToRender={INITIAL_BATCH_TO_RENDER}
maxToRenderPerBatch={Platform.select({android: 5})}
removeClippedSubviews={true}
scrollIndicatorInsets={style.listScrollIndicator}
/>
<CustomRefreshControl
enabled={offsetY === 0}
isInverted={false}
refreshing={isRefreshing}
onRefresh={onRefresh}
theme={theme}
>
<FlatList
contentContainerStyle={style.messagesContainer}
data={threadIds}
keyExtractor={keyExtractor}
ListEmptyComponent={renderEmptyList()}
ListFooterComponent={renderFooter()}
onEndReached={handleEndReached}
onEndReachedThreshold={2}
onScroll={onScroll}
ref={listRef}
renderItem={renderPost}
initialNumToRender={INITIAL_BATCH_TO_RENDER}
maxToRenderPerBatch={Platform.select({android: 5})}
removeClippedSubviews={true}
scrollIndicatorInsets={style.listScrollIndicator}
/>
</CustomRefreshControl>
</View>
);
}

View File

@@ -10,7 +10,7 @@ exports[`Global Thread List Header Should render threads with functional tabs &
}
>
<View>
<ForwardRef
<TouchableOpacity
onPress={[MockFunction]}
testID="thread_list.all_threads"
>
@@ -33,8 +33,8 @@ exports[`Global Thread List Header Should render threads with functional tabs &
All Your Threads
</Text>
</View>
</ForwardRef>
<ForwardRef
</TouchableOpacity>
<TouchableOpacity
onPress={[MockFunction]}
testID="thread_list.unread_threads"
>
@@ -62,10 +62,10 @@ exports[`Global Thread List Header Should render threads with functional tabs &
/>
</View>
</View>
</ForwardRef>
</TouchableOpacity>
</View>
<View>
<ForwardRef
<TouchableOpacity
disabled={false}
onPress={[MockFunction]}
testID="thread_list.mark_all_read"
@@ -78,7 +78,7 @@ exports[`Global Thread List Header Should render threads with functional tabs &
]
}
/>
</ForwardRef>
</TouchableOpacity>
</View>
</View>
`;

View File

@@ -1,10 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import AsyncStorage from '@react-native-community/async-storage';
import AsyncStorage from '@react-native-async-storage/async-storage';
import {PureComponent} from 'react';
import {Dimensions} from 'react-native';
import {Dimensions, EmitterSubscription} from 'react-native';
import {DeviceTypes} from '@constants';
import mattermostManaged from '@mattermost-managed';
@@ -12,6 +12,7 @@ import EventEmitter from '@mm-redux/utils/event_emitter';
// TODO: Use permanentSidebar and splitView hooks instead
export default class ImageViewPort extends PureComponent {
dimensionsListener: EmitterSubscription | undefined;
mounted = false;
state = {
isSplitView: false,
@@ -23,13 +24,13 @@ export default class ImageViewPort extends PureComponent {
this.handlePermanentSidebar();
this.handleDimensions();
EventEmitter.on(DeviceTypes.PERMANENT_SIDEBAR_SETTINGS, this.handlePermanentSidebar);
Dimensions.addEventListener('change', this.handleDimensions);
this.dimensionsListener = Dimensions.addEventListener('change', this.handleDimensions);
}
componentWillUnmount() {
this.mounted = false;
EventEmitter.off(DeviceTypes.PERMANENT_SIDEBAR_SETTINGS, this.handlePermanentSidebar);
Dimensions.removeEventListener('change', this.handleDimensions);
this.dimensionsListener?.remove();
}
handleDimensions = () => {

View File

@@ -78,7 +78,6 @@ export default class MarkdownCodeBlock extends React.PureComponent {
const actionText = formatMessage({id: 'mobile.markdown.code.copy_code', defaultMessage: 'Copy Code'});
BottomSheet.showBottomSheetWithOptions({
options: [actionText, cancelText],
cancelButtonIndex: 1,
}, (value) => {
if (value !== 1) {
this.handleCopyCode();

View File

@@ -151,7 +151,6 @@ export default class MarkdownImage extends ImageViewPort {
const actionText = formatMessage({id: 'mobile.markdown.link.copy_url', defaultMessage: 'Copy URL'});
BottomSheet.showBottomSheetWithOptions({
options: [actionText, cancelText],
cancelButtonIndex: 1,
}, (value) => {
if (value !== 1) {
this.handleLinkCopy();

View File

@@ -9,7 +9,7 @@ import {intlShape} from 'react-intl';
import {Alert, Text} from 'react-native';
import urlParse from 'url-parse';
import {dismissAllModals, popToRoot} from '@actions/navigation';
import {dismissAllModals, popToRoot, showModal} from '@actions/navigation';
import Config from '@assets/config';
import {DeepLinkTypes} from '@constants';
import {getCurrentServerUrl} from '@init/credentials';
@@ -59,13 +59,20 @@ export default class MarkdownLink extends PureComponent {
const match = matchDeepLink(url, serverURL, siteURL);
if (match) {
if (match.type === DeepLinkTypes.CHANNEL) {
switch (match.type) {
case DeepLinkTypes.CHANNEL:
await handleSelectChannelByName(match.channelName, match.teamName, errorBadChannel, intl);
await dismissAllModals();
await popToRoot();
} else if (match.type === DeepLinkTypes.PERMALINK) {
break;
case DeepLinkTypes.PERMALINK: {
const teamName = match.teamName === PERMALINK_GENERIC_TEAM_NAME_REDIRECT ? currentTeamName : match.teamName;
showPermalink(intl, teamName, match.postId);
break;
}
case DeepLinkTypes.PLUGIN:
showModal('PluginInternal', match.id, {link: url});
break;
}
} else {
const onError = () => {
@@ -130,7 +137,6 @@ export default class MarkdownLink extends PureComponent {
const actionText = formatMessage({id: 'mobile.markdown.link.copy_url', defaultMessage: 'Copy URL'});
BottomSheet.showBottomSheetWithOptions({
options: [actionText, cancelText],
cancelButtonIndex: 1,
}, (value) => {
if (value !== 1) {
this.handleLinkCopy();

View File

@@ -45,14 +45,14 @@ export default class MarkdownTable extends React.PureComponent {
}
componentDidMount() {
Dimensions.addEventListener('change', this.setMaxPreviewColumns);
this.dimensionsListener = Dimensions.addEventListener('change', this.setMaxPreviewColumns);
const window = Dimensions.get('window');
this.setMaxPreviewColumns({window});
}
componentWillUnmount() {
Dimensions.removeEventListener('change', this.setMaxPreviewColumns);
this.dimensionsListener?.remove();
}
setMaxPreviewColumns = ({window}) => {

View File

@@ -181,10 +181,10 @@ const NetworkIndicator = ({
}
});
AppState.addEventListener('change', handleAppStateChange);
const listener = AppState.addEventListener('change', handleAppStateChange);
return () => {
AppState.removeEventListener('change', handleAppStateChange);
listener.remove();
if (clearNotificationTimeout.current && AppState.currentState !== 'active') {
clearTimeout(clearNotificationTimeout.current);
}

View File

@@ -1,13 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PasteableTextInput should render pasteable text input 1`] = `
<TextInput
allowFontScaling={true}
onPaste={[Function]}
rejectResponderTermination={true}
screenId="Channel"
underlineColorAndroid="transparent"
>
My Text
</TextInput>
`;

View File

@@ -1,77 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import PropTypes from 'prop-types';
import React from 'react';
import {NativeEventEmitter, NativeModules, Platform, TextInput} from 'react-native';
import {PASTE_FILES} from '@constants/post_draft';
import EventEmitter from '@mm-redux/utils/event_emitter';
const {OnPasteEventManager} = NativeModules;
const OnPasteEventEmitter = new NativeEventEmitter(OnPasteEventManager);
export class PasteableTextInput extends React.PureComponent {
static propTypes = {
...TextInput.PropTypes,
forwardRef: PropTypes.any,
screenId: PropTypes.string.isRequired,
}
componentDidMount() {
this.subscription = OnPasteEventEmitter.addListener('onPaste', this.onPaste);
}
componentWillUnmount() {
if (this.subscription) {
this.subscription.remove();
}
}
getLastSubscriptionKey = () => {
const subscriptions = OnPasteEventEmitter._subscriber._subscriptionsForType.onPaste?.filter((sub) => sub); // eslint-disable-line no-underscore-dangle
return subscriptions?.length && subscriptions[subscriptions.length - 1].key;
}
onPaste = (event) => {
const lastSubscriptionKey = this.getLastSubscriptionKey();
if (this.subscription.key !== lastSubscriptionKey) {
return;
}
let data = null;
let error = null;
if (Platform.OS === 'android') {
const {nativeEvent} = event;
data = nativeEvent.data;
error = nativeEvent.error;
} else {
data = event;
}
EventEmitter.emit(PASTE_FILES, error, data, this.props.screenId);
}
render() {
const {testID, forwardRef, ...props} = this.props;
return (
<TextInput
testID={testID}
{...props}
onPaste={this.onPaste}
ref={forwardRef}
/>
);
}
}
const WrappedPasteableTextInput = (props, ref) => (
<PasteableTextInput
{...props}
forwardRef={ref}
/>
);
export default React.forwardRef(WrappedPasteableTextInput);

View File

@@ -1,62 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import {NativeEventEmitter} from 'react-native';
import {PASTE_FILES} from '@constants/post_draft';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {PasteableTextInput} from './index';
const nativeEventEmitter = new NativeEventEmitter();
describe('PasteableTextInput', () => {
const emit = jest.spyOn(EventEmitter, 'emit');
test('should render pasteable text input', () => {
const onPaste = jest.fn();
const text = 'My Text';
const component = shallow(
<PasteableTextInput
onPaste={onPaste}
screenId='Channel'
>{text}</PasteableTextInput>,
);
expect(component).toMatchSnapshot();
});
test('should call onPaste props if native onPaste trigger', () => {
const event = {someData: 'data'};
const text = 'My Text';
shallow(
<PasteableTextInput screenId='Channel'>{text}</PasteableTextInput>,
);
nativeEventEmitter.emit('onPaste', event);
expect(emit).toHaveBeenCalledWith(PASTE_FILES, null, event, 'Channel');
});
test('should remove onPaste listener when unmount', () => {
const mockRemove = jest.fn();
const text = 'My Text';
const component = shallow(
<PasteableTextInput screenId='Channel'>{text}</PasteableTextInput>,
);
component.instance().subscription.remove = mockRemove;
component.instance().componentWillUnmount();
expect(mockRemove).toHaveBeenCalled();
});
test('should emit PASTE_FILES event only for last subscription', () => {
const component1 = shallow(<PasteableTextInput screenId='Channel'/>);
const instance1 = component1.instance();
const component2 = shallow(<PasteableTextInput screenId='Thread'/>);
const instance2 = component2.instance();
instance1.onPaste();
expect(emit).not.toHaveBeenCalled();
instance2.onPaste();
expect(emit).toHaveBeenCalledTimes(1);
});
});

View File

@@ -68,7 +68,9 @@ exports[`PostDraft Should render the Archived for channelIsArchived 1`] = `
<View
accessibilityRole="button"
accessible={true}
collapsable={false}
focusable={true}
nativeID="animatedComponent"
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
@@ -172,7 +174,9 @@ exports[`PostDraft Should render the Archived for deactivatedChannel 1`] = `
<View
accessibilityRole="button"
accessible={true}
collapsable={false}
focusable={true}
nativeID="animatedComponent"
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
@@ -213,6 +217,8 @@ exports[`PostDraft Should render the DraftInput 1`] = `
inverted={false}
>
<View
collapsable={false}
nativeID="animatedComponent"
style={
Object {
"bottom": 0,
@@ -278,33 +284,52 @@ exports[`PostDraft Should render the DraftInput 1`] = `
}
>
<View>
<TextInput
<PasteInput
accessible={true}
allowFontScaling={true}
autoCapitalize="sentences"
autoCompleteType="off"
blurOnSubmit={false}
disableCopyPaste={false}
disableFullscreenUI={true}
focusable={true}
keyboardAppearance="light"
keyboardType="default"
mostRecentEventCount={0}
multiline={true}
onBlur={[Function]}
onChange={[Function]}
onChangeText={[Function]}
onClick={[Function]}
onEndEditing={[Function]}
onFocus={[Function]}
onPaste={[Function]}
onPressIn={[Function]}
onPressOut={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onScroll={[Function]}
onSelectionChange={[Function]}
onStartShouldSetResponder={[Function]}
placeholder="Write to "
placeholderTextColor="rgba(63,67,80,0.5)"
rejectResponderTermination={true}
screenId="NavigationScreen1"
style={
Object {
"color": "#3f4350",
"fontSize": 15,
"lineHeight": 20,
"maxHeight": 150,
"minHeight": 30,
"paddingBottom": 6,
"paddingHorizontal": 12,
"paddingTop": 6,
}
Array [
Object {
"color": "#3f4350",
"fontSize": 15,
"lineHeight": 20,
"maxHeight": 150,
"minHeight": 30,
"paddingBottom": 6,
"paddingHorizontal": 12,
"paddingTop": 6,
},
]
}
testID="post_draft.post.input"
textContentType="none"
@@ -319,8 +344,10 @@ exports[`PostDraft Should render the DraftInput 1`] = `
}
>
<View
collapsable={false}
forwardedRef={[Function]}
isInteraction={true}
nativeID="animatedComponent"
style={
Object {
"display": "flex",
@@ -349,8 +376,10 @@ exports[`PostDraft Should render the DraftInput 1`] = `
</RCTScrollView>
</View>
<View
collapsable={false}
forwardedRef={[Function]}
isInteraction={true}
nativeID="animatedComponent"
style={
Object {
"height": 0,
@@ -390,8 +419,15 @@ exports[`PostDraft Should render the DraftInput 1`] = `
testID="post_draft.quick_actions"
>
<View
accessibilityState={
Object {
"disabled": false,
}
}
accessible={true}
collapsable={false}
focusable={true}
nativeID="animatedComponent"
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
@@ -416,8 +452,15 @@ exports[`PostDraft Should render the DraftInput 1`] = `
/>
</View>
<View
accessibilityState={
Object {
"disabled": false,
}
}
accessible={true}
collapsable={false}
focusable={true}
nativeID="animatedComponent"
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
@@ -442,8 +485,15 @@ exports[`PostDraft Should render the DraftInput 1`] = `
/>
</View>
<View
accessibilityState={
Object {
"disabled": false,
}
}
accessible={true}
collapsable={false}
focusable={true}
nativeID="animatedComponent"
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
@@ -463,13 +513,20 @@ exports[`PostDraft Should render the DraftInput 1`] = `
>
<Icon
color="rgba(63,67,80,0.64)"
name="file-document-outline"
name="file-text-outline"
size={24}
/>
</View>
<View
accessibilityState={
Object {
"disabled": false,
}
}
accessible={true}
collapsable={false}
focusable={true}
nativeID="animatedComponent"
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
@@ -494,8 +551,15 @@ exports[`PostDraft Should render the DraftInput 1`] = `
/>
</View>
<View
accessibilityState={
Object {
"disabled": false,
}
}
accessible={true}
collapsable={false}
focusable={true}
nativeID="animatedComponent"
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}

View File

@@ -329,12 +329,13 @@ export default class DraftInput extends PureComponent {
const notificationsToChannel = enableConfirmNotificationsToChannel && useChannelMentions;
const notificationsToGroups = enableConfirmNotificationsToChannel && useGroupMentions;
const toAllOrChannel = DraftUtils.textContainsAtAllAtChannel(value);
const groupMentions = (!toAllOrChannel && notificationsToGroups) ? DraftUtils.groupsMentionedInText(groupsWithAllowReference, value) : [];
const toHere = DraftUtils.textContainsAtHere(value);
const groupMentions = (!toAllOrChannel && !toHere && notificationsToGroups) ? DraftUtils.groupsMentionedInText(groupsWithAllowReference, value) : [];
if (value.indexOf('/') === 0) {
this.sendCommand(value);
} else if (notificationsToChannel && membersCount > NOTIFY_ALL_MEMBERS && toAllOrChannel) {
this.showSendToAllOrChannelAlert(membersCount, value);
} else if (notificationsToChannel && membersCount > NOTIFY_ALL_MEMBERS && (toAllOrChannel || toHere)) {
this.showSendToAllOrChannelOrHereAlert(membersCount, value, toHere && !toAllOrChannel);
} else if (groupMentions.length > 0) {
const {groupMentionsSet, memberNotifyCount, channelTimezoneCount} = DraftUtils.mapGroupMentions(channelMemberCountsByGroup, groupMentions);
if (memberNotifyCount > 0) {
@@ -364,11 +365,11 @@ export default class DraftInput extends PureComponent {
}
}
showSendToAllOrChannelAlert = (membersCount, msg) => {
showSendToAllOrChannelOrHereAlert = (membersCount, msg, atHere) => {
const {formatMessage} = this.context.intl;
const {channelTimezoneCount} = this.state;
const {isTimezoneEnabled} = this.props;
const notifyAllMessage = DraftUtils.buildChannelWideMentionMessage(formatMessage, membersCount, isTimezoneEnabled, channelTimezoneCount);
const notifyAllMessage = DraftUtils.buildChannelWideMentionMessage(formatMessage, membersCount, isTimezoneEnabled, channelTimezoneCount, atHere);
const cancel = () => {
this.setInputValue(msg);
this.setState({sendingMessage: false});

View File

@@ -110,6 +110,31 @@ describe('DraftInput', () => {
expect(Alert.alert).toHaveBeenCalledWith('Confirm sending notifications to entire channel', expect.anything(), expect.anything());
});
test('should send an alert when sending a message with a @here mention', () => {
const wrapper = shallowWithIntl(
<DraftInput
{...baseProps}
ref={ref}
/>,
);
const message = '@here';
const instance = wrapper.instance();
expect(instance.input).toEqual({current: null});
instance.input = {
current: {
getValue: () => message,
setValue: jest.fn(),
changeDraft: jest.fn(),
resetTextInput: jest.fn(),
},
};
instance.handleSendMessage();
jest.runOnlyPendingTimers();
expect(Alert.alert).toBeCalled();
expect(Alert.alert).toHaveBeenCalledWith('Confirm sending notifications to entire channel', expect.anything(), expect.anything());
});
test('should send an alert when sending a message with a group mention with group with count more than NOTIFY_ALL', () => {
const wrapper = shallowWithIntl(
<DraftInput

View File

@@ -41,6 +41,9 @@ export default class PostDraft extends PureComponent {
componentWillUnmount() {
EventEmitter.off(UPDATE_NATIVE_SCROLLVIEW, this.updateNativeScrollView);
if (this.resetScrollView) {
cancelAnimationFrame(this.resetScrollView);
}
}
handleInputQuickAction = (value) => {
@@ -51,9 +54,10 @@ export default class PostDraft extends PureComponent {
updateNativeScrollView = (scrollViewNativeID) => {
if (this.keyboardTracker?.current && this.props.scrollViewNativeID === scrollViewNativeID) {
const resetScrollView = requestAnimationFrame(() => {
this.keyboardTracker.current.resetScrollView(scrollViewNativeID);
cancelAnimationFrame(resetScrollView);
this.resetScrollView = requestAnimationFrame(() => {
this.keyboardTracker.current?.resetScrollView(scrollViewNativeID);
cancelAnimationFrame(this.resetScrollView);
this.resetScrollView = null;
});
}
};

View File

@@ -1,15 +1,20 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PostInput should match, full snapshot 1`] = `
<ForwardRef(WrappedPasteableTextInput)
<ForwardRef
allowFontScaling={true}
autoCompleteType="off"
blurOnSubmit={false}
disableCopyPaste={false}
disableFullscreenUI={true}
keyboardAppearance="light"
keyboardType="default"
multiline={true}
onChangeText={[Function]}
onEndEditing={[Function]}
onPaste={[Function]}
onPressIn={[Function]}
onPressOut={[Function]}
onSelectionChange={[Function]}
placeholder="Write to Test Channel"
placeholderTextColor="rgba(63,67,80,0.5)"

View File

@@ -4,12 +4,13 @@
import PropTypes from 'prop-types';
import React, {PureComponent} from 'react';
import {intlShape} from 'react-intl';
import {Alert, AppState, findNodeHandle, Keyboard, NativeModules, Platform} from 'react-native';
import {Alert, AppState, DeviceEventEmitter, findNodeHandle, Keyboard, NativeModules, Platform} from 'react-native';
import PasteableTextInput from '@components/pasteable_text_input';
import {NavigationTypes} from '@constants';
import DEVICE from '@constants/device';
import {INSERT_TO_COMMENT, INSERT_TO_DRAFT} from '@constants/post_draft';
import {INSERT_TO_COMMENT, INSERT_TO_DRAFT, PASTE_FILES} from '@constants/post_draft';
import mattermostManaged from '@mattermost-managed';
import PasteableTextInput from '@mattermost/react-native-paste-input';
import {debounce} from '@mm-redux/actions/helpers';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {t} from '@utils/i18n';
@@ -55,6 +56,7 @@ export default class PostInput extends PureComponent {
this.state = {
keyboardType: 'default',
longMessageAlertShown: false,
disableCopyAndPaste: mattermostManaged.getCachedConfig()?.copyAndPasteProtection === 'true',
};
}
@@ -62,10 +64,11 @@ export default class PostInput extends PureComponent {
const event = this.props.rootId ? INSERT_TO_COMMENT : INSERT_TO_DRAFT;
EventEmitter.on(event, this.handleInsertTextToDraft);
EventEmitter.on(NavigationTypes.BLUR_POST_DRAFT, this.blur);
AppState.addEventListener('change', this.handleAppStateChange);
this.appStateListener = AppState.addEventListener('change', this.handleAppStateChange);
this.managedListener = mattermostManaged.addEventListener('managedConfigDidChange', this.onManagedConfigurationChange);
if (Platform.OS === 'android') {
Keyboard.addListener('keyboardDidHide', this.handleAndroidKeyboard);
this.keyboardListener = Keyboard.addListener('keyboardDidHide', this.handleAndroidKeyboard);
}
}
@@ -73,10 +76,11 @@ export default class PostInput extends PureComponent {
const event = this.props.rootId ? INSERT_TO_COMMENT : INSERT_TO_DRAFT;
EventEmitter.off(NavigationTypes.BLUR_POST_DRAFT, this.blur);
EventEmitter.off(event, this.handleInsertTextToDraft);
AppState.removeEventListener('change', this.handleAppStateChange);
mattermostManaged.removeEventListener(this.managedListener);
this.appStateListener.remove();
if (Platform.OS === 'android') {
Keyboard.removeListener('keyboardDidHide', this.handleAndroidKeyboard);
this.keyboardListener?.remove();
}
this.changeDraft(this.getValue());
@@ -249,11 +253,20 @@ export default class PostInput extends PureComponent {
}
this.value = completed;
this.input.current.setNativeProps({
text: completed,
});
};
onManagedConfigurationChange = (config) => {
this.setState({disableCopyAndPaste: config.copyAndPasteProtection === 'true'});
}
onPaste = (error, files) => {
EventEmitter.emit(PASTE_FILES, error, files, this.props.screenId);
}
resetTextInput = () => {
if (this.input.current) {
this.input.current.setNativeProps({
@@ -272,6 +285,18 @@ export default class PostInput extends PureComponent {
}
}
onPressIn = () => {
if (Platform.OS === 'ios') {
DeviceEventEmitter.emit(NavigationTypes.DRAWER, 'locked-closed');
}
};
onPressOut = () => {
if (Platform.OS === 'ios') {
DeviceEventEmitter.emit(NavigationTypes.DRAWER, 'unlocked');
}
};
render() {
const {formatMessage} = this.context.intl;
const {testID, channelDisplayName, screenId, isLandscape, theme} = this.props;
@@ -285,8 +310,10 @@ export default class PostInput extends PureComponent {
return (
<PasteableTextInput
allowFontScaling={true}
testID={testID}
ref={this.input}
disableCopyPaste={this.state.disableCopyAndPaste}
style={{...style.input, maxHeight}}
onChangeText={this.handleTextChange}
onSelectionChange={this.handlePostDraftSelectionChanged}
@@ -297,11 +324,14 @@ export default class PostInput extends PureComponent {
underlineColorAndroid='transparent'
keyboardType={this.state.keyboardType}
onEndEditing={this.handleEndEditing}
onPaste={this.onPaste}
disableFullscreenUI={true}
textContentType='none'
autoCompleteType='off'
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
screenId={screenId}
onPressIn={this.onPressIn}
onPressOut={this.onPressOut}
/>
);
}

View File

@@ -2,8 +2,15 @@
exports[`CameraButton should match snapshot 1`] = `
<View
accessibilityState={
Object {
"disabled": false,
}
}
accessible={true}
collapsable={false}
focusable={true}
nativeID="animatedComponent"
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}

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