From 7c2a4a4b1106e2dad2bb534039e2f54607c4718d Mon Sep 17 00:00:00 2001 From: tbenr Date: Tue, 14 Jul 2020 13:01:26 +0200 Subject: [PATCH] audio messages --- android/app/src/main/AndroidManifest.xml | 2 + ios/Podfile.lock | 29 +- ios/StatusIm/Info.plist | 2 + ios/StatusImPR/Info.plist | 2 + nix/deps/gradle/proj.list | 2 + package.json | 2 + resources/images/icons/pause@2x.png | Bin 0 -> 216 bytes resources/images/icons/pause@3x.png | Bin 0 -> 420 bytes resources/images/icons/play@2x.png | Bin 0 -> 288 bytes resources/images/icons/play@3x.png | Bin 0 -> 400 bytes resources/images/icons/speech@2x.png | Bin 0 -> 502 bytes resources/images/icons/speech@3x.png | Bin 0 -> 762 bytes resources/images/ui/slider-thumb@2x.png | Bin 0 -> 12914 bytes resources/images/ui/slider-thumb@3x.png | Bin 0 -> 16475 bytes src/status_im/audio/core.cljs | 123 ++++ src/status_im/chat/models/input.cljs | 19 + src/status_im/constants.cljs | 1 + src/status_im/data_store/messages.cljs | 3 +- src/status_im/events.cljs | 5 + src/status_im/react_native/resources.cljs | 3 +- src/status_im/subs.cljs | 7 + src/status_im/transport/message/protocol.cljs | 4 + src/status_im/ui/components/colors.cljs | 1 + src/status_im/ui/components/permissions.cljs | 3 +- src/status_im/ui/components/slider.cljs | 9 + src/status_im/ui/components/svg.cljs | 9 +- .../ui/screens/chat/audio_message/views.cljs | 655 ++++++++++++++++++ .../ui/screens/chat/input/input.cljs | 5 +- .../ui/screens/chat/message/audio.cljs | 193 ++++++ .../ui/screens/chat/message/message.cljs | 13 +- .../ui/screens/chat/styles/message/audio.cljs | 44 ++ src/status_im/ui/screens/chat/views.cljs | 3 + src/status_im/utils/fs.cljs | 5 + status-go-version.json | 6 +- yarn.lock | 20 +- 35 files changed, 1151 insertions(+), 19 deletions(-) create mode 100755 resources/images/icons/pause@2x.png create mode 100755 resources/images/icons/pause@3x.png create mode 100755 resources/images/icons/play@2x.png create mode 100755 resources/images/icons/play@3x.png create mode 100644 resources/images/icons/speech@2x.png create mode 100644 resources/images/icons/speech@3x.png create mode 100755 resources/images/ui/slider-thumb@2x.png create mode 100755 resources/images/ui/slider-thumb@3x.png create mode 100644 src/status_im/audio/core.cljs create mode 100644 src/status_im/ui/components/slider.cljs create mode 100644 src/status_im/ui/screens/chat/audio_message/views.cljs create mode 100644 src/status_im/ui/screens/chat/message/audio.cljs create mode 100644 src/status_im/ui/screens/chat/styles/message/audio.cljs diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index cc5f6f072fb7..4254941dd844 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -21,6 +21,8 @@ + + diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 23b5bb556f69..72ba2aa8d398 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -255,6 +255,8 @@ PODS: - React - react-native-shake (3.4.0): - React + - react-native-slider (3.0.0): + - React - react-native-splash-screen (3.2.0): - React - react-native-webview (10.3.1): @@ -318,6 +320,8 @@ PODS: - React-cxxreact (= 0.62.2) - React-jsi (= 0.62.2) - ReactCommon/callinvoker (= 0.62.2) + - ReactNativeAudioToolkit (2.0.3): + - React - ReactNativeDarkMode (0.2.2): - React - RNCClipboard (1.2.2): @@ -354,8 +358,8 @@ PODS: - SQLCipher/common (3.4.2) - SQLCipher/standard (3.4.2): - SQLCipher/common - - SSZipArchive (2.2.2) - - TOCropViewController (2.5.2) + - SSZipArchive (2.2.3) + - TOCropViewController (2.5.3) - TouchID (4.4.1): - React - Yoga (1.14.0) @@ -406,6 +410,7 @@ DEPENDENCIES: - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) - react-native-shake (from `../node_modules/react-native-shake`) + - "react-native-slider (from `../node_modules/@react-native-community/slider`)" - react-native-splash-screen (from `../node_modules/react-native-splash-screen`) - react-native-webview (from `../node_modules/react-native-webview`) - React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`) @@ -419,6 +424,7 @@ DEPENDENCIES: - React-RCTVibration (from `../node_modules/react-native/Libraries/Vibration`) - ReactCommon/callinvoker (from `../node_modules/react-native/ReactCommon`) - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) + - "ReactNativeAudioToolkit (from `../node_modules/@react-native-community/audio-toolkit`)" - ReactNativeDarkMode (from `../node_modules/react-native-dark-mode`) - "RNCClipboard (from `../node_modules/@react-native-community/clipboard`)" - "RNCMaskedView (from `../node_modules/@react-native-community/masked-view`)" @@ -437,11 +443,8 @@ DEPENDENCIES: - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) SPEC REPOS: - https://github.com/CocoaPods/Specs.git: - - boost-for-react-native - - SQLCipher - - SSZipArchive trunk: + - boost-for-react-native - CocoaAsyncSocket - CocoaLibEvent - Flipper @@ -452,6 +455,8 @@ SPEC REPOS: - Flipper-RSocket - FlipperKit - OpenSSL-Universal + - SQLCipher + - SSZipArchive - TOCropViewController - YogaKit @@ -500,6 +505,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-safe-area-context" react-native-shake: :path: "../node_modules/react-native-shake" + react-native-slider: + :path: "../node_modules/@react-native-community/slider" react-native-splash-screen: :path: "../node_modules/react-native-splash-screen" react-native-webview: @@ -524,6 +531,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/Libraries/Vibration" ReactCommon: :path: "../node_modules/react-native/ReactCommon" + ReactNativeAudioToolkit: + :path: "../node_modules/@react-native-community/audio-toolkit" ReactNativeDarkMode: :path: "../node_modules/react-native-dark-mode" RNCClipboard: @@ -587,6 +596,7 @@ SPEC CHECKSUMS: react-native-netinfo: ddaca8bbb9e6e914b1a23787ccb879bc642931c9 react-native-safe-area-context: 60f654e00b6cc416573f6d5dbfce3839958eb57a react-native-shake: de052eaa3eadc4a326b8ddd7ac80c06e8d84528c + react-native-slider: 12bd76d3d568c9c5500825db54123d44b48e4ad4 react-native-splash-screen: 200d11d188e2e78cea3ad319964f6142b6384865 react-native-webview: 40bbeb6d011226f34cb83f845aeb0fdf515cfc5f React-RCTActionSheet: f41ea8a811aac770e0cc6e0ad6b270c644ea8b7c @@ -599,6 +609,7 @@ SPEC CHECKSUMS: React-RCTText: fae545b10cfdb3d247c36c56f61a94cfd6dba41d React-RCTVibration: 4356114dbcba4ce66991096e51a66e61eda51256 ReactCommon: ed4e11d27609d571e7eee8b65548efc191116eb3 + ReactNativeAudioToolkit: de9610f323e855ac6574be8c99621f3d57c5df06 ReactNativeDarkMode: 0178ffca3b10f6a7c9f49d6f9810232b328fa949 RNCClipboard: 8148e21ac347c51fd6cd4b683389094c216bb543 RNCMaskedView: 71fc32d971f03b7f03d6ab6b86b730c4ee64f5b6 @@ -612,12 +623,12 @@ SPEC CHECKSUMS: RNScreens: ac02d0e4529f08ced69f5580d416f968a6ec3a1d RNSVG: 8ba35cbeb385a52fd960fd28db9d7d18b4c2974f SQLCipher: f9fcf29b2e59ced7defc2a2bdd0ebe79b40d4990 - SSZipArchive: fa16b8cc4cdeceb698e5e5d9f67e9558532fbf23 - TOCropViewController: e9da34f484aedd4e5d5a8ab230ba217cfe16c729 + SSZipArchive: 62d4947b08730e4cda640473b0066d209ff033c9 + TOCropViewController: 20a14b6a7a098308bf369e7c8d700dc983a974e6 TouchID: ba4c656d849cceabc2e4eef722dea5e55959ecf4 Yoga: 3ebccbdd559724312790e7742142d062476b698e YogaKit: f782866e155069a2cca2517aafea43200b01fd5a PODFILE CHECKSUM: f66349c5bfb9c21ac968307ea5a2d6c2dd4091ed -COCOAPODS: 1.9.3 +COCOAPODS: 1.9.1 diff --git a/ios/StatusIm/Info.plist b/ios/StatusIm/Info.plist index 573ee89cb672..559a216a7caa 100644 --- a/ios/StatusIm/Info.plist +++ b/ios/StatusIm/Info.plist @@ -83,6 +83,8 @@ Location access is required for some DApps to function properly. NSPhotoLibraryUsageDescription Photos access is required to give you the ability to send images. + NSMicrophoneUsageDescription + Need microphone access for sending audio messages. UIAppFonts Inter-Bold.otf diff --git a/ios/StatusImPR/Info.plist b/ios/StatusImPR/Info.plist index 02441fc97d30..05ec5f0ef1df 100644 --- a/ios/StatusImPR/Info.plist +++ b/ios/StatusImPR/Info.plist @@ -83,6 +83,8 @@ Location access is required for some DApps to function properly. NSPhotoLibraryUsageDescription Photos access is required to give you the ability to send images. + NSMicrophoneUsageDescription + Need microphone access for sending audio messages. UIAppFonts Inter-Bold.otf diff --git a/nix/deps/gradle/proj.list b/nix/deps/gradle/proj.list index 8b3dde7b6b65..1cab0b71a9ae 100644 --- a/nix/deps/gradle/proj.list +++ b/nix/deps/gradle/proj.list @@ -1,10 +1,12 @@ app react-native-background-timer react-native-camera +react-native-community_audio-toolkit react-native-community_cameraroll react-native-community_clipboard react-native-community_masked-view react-native-community_netinfo +react-native-community_slider react-native-config react-native-dark-mode react-native-dialogs diff --git a/package.json b/package.json index aab8d91c84e3..a7ce615c2995 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,13 @@ "app:android": "react-native run-android" }, "dependencies": { + "@react-native-community/audio-toolkit": "git+https://github.com/tbenr/react-native-audio-toolkit.git#v2.0.3-status-v5", "@react-native-community/cameraroll": "^1.6.1", "@react-native-community/clipboard": "^1.2.2", "@react-native-community/hooks": "^2.5.1", "@react-native-community/masked-view": "^0.1.6", "@react-native-community/netinfo": "^4.4.0", + "@react-native-community/slider": "^3.0.0", "@react-navigation/bottom-tabs": "^5.1.1", "@react-navigation/native": "^5.2.3", "@react-navigation/stack": "^5.1.1", diff --git a/resources/images/icons/pause@2x.png b/resources/images/icons/pause@2x.png new file mode 100755 index 0000000000000000000000000000000000000000..83ceee86e324a4cfb2e6ba564f35d591ea4514bc GIT binary patch literal 216 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA0wn)(8}b0D`JOJ0Ar-gYPCLlkpupq$pQGnN z+Kxu$8@vU=?g=I~4jqAtYdbp?{}*noY`kBQ$>?`0rLn`9lPCMAwY{28v{MKSZo5+h# z|M&0m6_8fma>!QU>w|Ks-dXMWvgQ13i4rGxo{K3R@7MXeVc*Bc8~OHanG*O~vU}O1 zqtYL$t!+x?Rptu(sr0y_CA@A@>iYTZR$4Cm;_ffqV*7OMQ>_n;>8l(o1rj@BJNH_w z(B@ptspq<~<5-zY?#IwY7J4yVRv-87d!pO*c!At<;klb@{^TkN&phFxW;C($-T!8u zB+Cod`=Y))61ShOl%brYW!U|AnVC|6Wp>812j@4dEjQsk75Vs)*2(ISFxd%XD?dj_0vd$@? F2>`I8mU93A literal 0 HcmV?d00001 diff --git a/resources/images/icons/play@2x.png b/resources/images/icons/play@2x.png new file mode 100755 index 0000000000000000000000000000000000000000..58785d143bd2f5ea647d00f6a4b980635d3963a1 GIT binary patch literal 288 zcmV+*0pI?KP)^Dgg(ZgU!KkKpij#NT`HLKmrn28kV1?8$4|Hzid5TNxwUeKw2oioxoH4m23Rn`vH|PCErLLL7(O8Bj7f5wGyp(@yX2sio&!{JlmL2; zR{%N3djK)V9w5m{ju}@C0ASEbj`&%QW5D{cwj5Rikkm;VDgcB2`JUGRfj!Rv0000)?4COY3_wv7 zMNt%`X*wKnhKEDng3f>&?yqrXOZAaX7&q*hLKmS!@AT}mh*GppPXPy%4F>XCXqTZ$g1x(#ddz!g;`nPNz~Y zFHdM|8!oiDggVTkjYS3C zJ?tnJKaYX=qv*xrSMi5B6-wI>f2FByVj;(G6JJ=}lTDL!VdXZj^yadQ-{+&&l3aQk uP>B!hj@Kr><(M(UvD+w$q9}@@{D}t(-sFoB%nktn0000i zgD?<F=h z3|E2$3f;gCcYF^$UkwHnxCWGa_?N^#0|RL7YY%@-{3|ekmV^(K$f?1TMsW_VCxlK4T^!68Io?>fyg5_Ur^gpwaDZ?j@1- zd=;2r+hQ7+ZL78WFL8F+^I_3Sw`~4OX|VzIS7J;V7zR&KXl`kAMJhCbYY5>`2vZ6P zBAiA$G^O!v^oHr|-5B-EasEw#@pm9!G=|4&julbpzJ1c;agXk!R_kwnOEC<*-Q)@@ z<9lqfKUNinCH0qV-De0e>mi<1TI?*opedCn3_H`a)2Gq9(GQqYV1*63@O^Y sTq$EC3B+ctO!~4ca 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 cG7*vf7r!6Y)Y} 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

#|V$N&NbM5JUt(% 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 options))] + (when on-meter + (.on ^js recorder "meter" on-meter)) + (when on-ended + (.on ^js recorder "ended" on-ended)))) + +(defn new-player [audio options on-ended] + (let [player (new ^js Player + audio + (clj->js options))] + (when on-ended + (.on ^js player "ended" on-ended)))) + +(defn prepare-player [player on-prepared on-error] + (when (and player (.-canPrepare ^js player)) + (.prepare ^js player #(if % + (on-error {:error (.-err %) :message (.-message %)}) + (on-prepared))))) + +(defn prepare-recorder [recorder on-prepared on-error] + (when (and recorder (.-canPrepare ^js recorder)) + (.prepare ^js recorder (fn [err _] + (if err + (on-error {:error (.-err err) :message (.-message err)}) + (on-prepared)))))) + +(defn start-recording [recorder on-start on-error] + (when (and recorder + (or + (.-canRecord ^js recorder) + (.-canPrepare ^js recorder))) + (.record ^js recorder #(if % + (on-error {:error (.-err %) :message (.-message %)}) + (on-start))))) + +(defn stop-recording [recorder on-stop on-error] + (when (and recorder (.-isRecording ^js recorder)) + (.stop ^js recorder #(if % + (on-error {:error (.-err %) :message (.-message %)}) + (on-stop))))) + +(defn pause-recording [recorder on-pause on-error] + (when (and recorder (.-isRecording ^js recorder)) + (.pause ^js recorder #(if % + (on-error {:error (.-err %) :message (.-message %)}) + (on-pause))))) + +(defn start-playing [player on-start on-error] + (when (and player (.-canPlay ^js player)) + (.play ^js player #(if % + (on-error {:error (.-err %) :message (.-message %)}) + (on-start))))) + +(defn stop-playing [player on-stop on-error] + (when (and player (.-isPlaying ^js player)) + (.stop ^js player #(if % + (on-error {:error (.-err %) :message (.-message %)}) + (on-stop))))) + +(defn get-recorder-file-path [recorder] + (when recorder + (.-fsPath ^js recorder))) + +(defn get-player-duration [player] + (when (and player (.-canPlay ^js player)) + (.-duration ^js player))) + +(defn get-player-current-time [player] + (when (and player (.-canPlay ^js player)) + (.-currentTime ^js player))) + +(defn toggle-playpause-player [player on-play on-pause on-error] + (when (and player (.-canPlay ^js player)) + (.playPause ^js player (fn [error pause?] + (if error + (on-error {:error (.-err error) :message (.-message error)}) + (if pause? + (on-pause) + (on-play))))))) + +(defn seek-player [player value on-seek on-error] + (when (and player (.-canPlay ^js player)) + (.seek ^js player value #(if % + (on-error {:error (.-err %) :message (.-message %)}) + (on-seek))))) + +(defn canPlay? [player] + (and player (.-canPlay ^js player))) \ No newline at end of file diff --git a/src/status_im/chat/models/input.cljs b/src/status_im/chat/models/input.cljs index 56eb62a89c6c..3a24057687f8 100644 --- a/src/status_im/chat/models/input.cljs +++ b/src/status_im/chat/models/input.cljs @@ -126,6 +126,15 @@ :image-path (string/replace image-path #"file://" "") :text "Update to latest version to see a nice image here!"}))))) +(fx/defn send-audio-message + [cofx audio-path duration current-chat-id] + (when-not (string/blank? audio-path) + (chat.message/send-message cofx {:chat-id current-chat-id + :content-type constants/content-type-audio + :audio-path audio-path + :audio-duration-ms duration + :text "Update to latest version to listen to an audio here!"}))) + (fx/defn send-sticker-message [cofx {:keys [hash pack]} current-chat-id] (when-not (string/blank? hash) @@ -158,3 +167,13 @@ (.clear ref) (catch :default _ (log/debug "Cannot clear the reference"))))) + +(fx/defn open-audio-recorder + {:events [:chat.ui/open-audio-recorder]} + [{{:keys [current-chat-id] :as db} :db}] + {:db (assoc-in db [:chat/inputs current-chat-id :audio-recorder] true)}) + +(fx/defn close-audio-recorder + {:events [:chat.ui/close-audio-recorder]} + [{{:keys [current-chat-id] :as db} :db}] + {:db (update-in db [:chat/inputs current-chat-id] dissoc :audio-recorder)}) diff --git a/src/status_im/constants.cljs b/src/status_im/constants.cljs index bbe3382f9d08..73b38abf70c7 100644 --- a/src/status_im/constants.cljs +++ b/src/status_im/constants.cljs @@ -14,6 +14,7 @@ (def content-type-command 5) (def content-type-system-text 6) (def content-type-image 7) +(def content-type-audio 8) (def message-type-one-to-one 1) (def message-type-public-group 2) diff --git a/src/status_im/data_store/messages.cljs b/src/status_im/data_store/messages.cljs index c4a9ba5f4103..5c2a222dfc96 100644 --- a/src/status_im/data_store/messages.cljs +++ b/src/status_im/data_store/messages.cljs @@ -24,7 +24,8 @@ :contentType :content-type :clock :clock-value :quotedMessage :quoted-message - :outgoingStatus :outgoing-status}) + :outgoingStatus :outgoing-status + :audioDurationMs :audio-duration-ms}) (update :outgoing-status keyword) (update :command-parameters clojure.set/rename-keys {:transactionHash :transaction-hash diff --git a/src/status_im/events.cljs b/src/status_im/events.cljs index 010f516ccaf4..616bb19e6444 100644 --- a/src/status_im/events.cljs +++ b/src/status_im/events.cljs @@ -559,6 +559,11 @@ {}) (chat.input/send-sticker-message sticker current-chat-id)))) +(handlers/register-handler-fx + :chat/send-audio + (fn [{{:keys [current-chat-id]} :db :as cofx} [_ audio-path duration]] + (chat.input/send-audio-message cofx audio-path duration current-chat-id))) + (handlers/register-handler-fx :chat/disable-cooldown (fn [cofx _] diff --git a/src/status_im/react_native/resources.cljs b/src/status_im/react_native/resources.cljs index 4c467a3b2116..f46b4ac41b2f 100644 --- a/src/status_im/react_native/resources.cljs +++ b/src/status_im/react_native/resources.cljs @@ -39,7 +39,8 @@ :theme-dark (js/require "../resources/images/ui/theme-dark.png") :theme-light (js/require "../resources/images/ui/theme-light.png") :theme-system (js/require "../resources/images/ui/theme-system.png") - :notifications (js/require "../resources/images/ui/notifications.png")}) + :notifications (js/require "../resources/images/ui/notifications.png") + :slider-thumb (js/require "../resources/images/ui/slider-thumb.png")}) (defn get-theme-image [k] (get ui (when (colors/dark?) (keyword (str (name k) "-dark"))) (get ui k))) diff --git a/src/status_im/subs.cljs b/src/status_im/subs.cljs index 6ee959e07765..8dfec23fee6e 100644 --- a/src/status_im/subs.cljs +++ b/src/status_im/subs.cljs @@ -634,6 +634,13 @@ (fn [[chat-id inputs]] (get-in inputs [chat-id :input-text]))) +(re-frame/reg-sub + :chats/current-chat-audio-recorder-open? + :<- [:chats/current-chat-id] + :<- [:chat/inputs] + (fn [[chat-id inputs]] + (not (nil? (get-in inputs [chat-id :audio-recorder]))))) + (re-frame/reg-sub :chats/current-chat :<- [:chats/current-raw-chat] diff --git a/src/status_im/transport/message/protocol.cljs b/src/status_im/transport/message/protocol.cljs index eb5a5c697750..1dddfb45882a 100644 --- a/src/status_im/transport/message/protocol.cljs +++ b/src/status_im/transport/message/protocol.cljs @@ -10,6 +10,8 @@ response-to ens-name image-path + audio-path + audio-duration-ms message-type sticker content-type] @@ -22,6 +24,8 @@ :responseTo response-to :ensName ens-name :imagePath image-path + :audioPath audio-path + :audioDurationMs audio-duration-ms :sticker sticker :contentType content-type}] :on-success diff --git a/src/status_im/ui/components/colors.cljs b/src/status_im/ui/components/colors.cljs index fb97027b6f9e..d1c53bb73f1b 100644 --- a/src/status_im/ui/components/colors.cljs +++ b/src/status_im/ui/components/colors.cljs @@ -66,6 +66,7 @@ ;; RED (def red (:red light)) ;; Used to highlight errors or "dangerous" actions (def red-transparent-10 (alpha red 0.1)) ;;action-row ;; ttt finish +(def red-audio-recorder "#fa6565") ;; GREEN (def green "#44d058") ;; icon for successful inboud transaction diff --git a/src/status_im/ui/components/permissions.cljs b/src/status_im/ui/components/permissions.cljs index cb85e584018c..74ce8bc21933 100644 --- a/src/status_im/ui/components/permissions.cljs +++ b/src/status_im/ui/components/permissions.cljs @@ -5,7 +5,8 @@ (def permissions-map {:read-external-storage "android.permission.READ_EXTERNAL_STORAGE" :write-external-storage "android.permission.WRITE_EXTERNAL_STORAGE" - :camera "android.permission.CAMERA"}) + :camera "android.permission.CAMERA" + :record-audio "android.permission.RECORD_AUDIO"}) (defn all-granted? [permissions] (let [permission-vals (distinct (vals permissions))] diff --git a/src/status_im/ui/components/slider.cljs b/src/status_im/ui/components/slider.cljs new file mode 100644 index 000000000000..ff4b37a9c84d --- /dev/null +++ b/src/status_im/ui/components/slider.cljs @@ -0,0 +1,9 @@ +(ns status-im.ui.components.slider + (:require [reagent.core :as reagent] + [status-im.react-native.resources :as resources] + ["@react-native-community/slider" :default Slider])) + +(def slider-class (reagent/adapt-react-class Slider)) + +(defn slider [props] + [slider-class (merge {:thumb-image (resources/get-image :slider-thumb)} props)]) \ No newline at end of file diff --git a/src/status_im/ui/components/svg.cljs b/src/status_im/ui/components/svg.cljs index 12d850889f99..6e40395f6295 100644 --- a/src/status_im/ui/components/svg.cljs +++ b/src/status_im/ui/components/svg.cljs @@ -1,5 +1,12 @@ (ns status-im.ui.components.svg (:require [reagent.core :as reagent] - ["react-native-svg" :refer (SvgXml)])) + ["react-native" :refer (Animated)] + ["react-native-svg" :refer (SvgXml Svg Rect G)])) (def svgxml (reagent/adapt-react-class SvgXml)) +(def svg (reagent/adapt-react-class Svg)) +(def rect (reagent/adapt-react-class Rect)) +(def g (reagent/adapt-react-class G)) + +(def animated-rect + (reagent/adapt-react-class (.createAnimatedComponent Animated Rect))) \ No newline at end of file diff --git a/src/status_im/ui/screens/chat/audio_message/views.cljs b/src/status_im/ui/screens/chat/audio_message/views.cljs new file mode 100644 index 000000000000..5d39fb106b84 --- /dev/null +++ b/src/status_im/ui/screens/chat/audio_message/views.cljs @@ -0,0 +1,655 @@ +(ns status-im.ui.screens.chat.audio-message.views + (:require-macros [status-im.utils.views :refer [defview letsubs]]) + (:require + [goog.string :as gstring] + [reagent.core :as reagent] + [status-im.audio.core :as audio] + [status-im.ui.components.react :as react] + [re-frame.core :as re-frame] + [status-im.i18n :as i18n] + [quo.core :as quo] + [status-im.ui.screens.chat.input.send-button :as send-button] + [status-im.ui.screens.chat.styles.input.send-button :as send-button.style] + [status-im.ui.components.colors :as colors] + [status-im.ui.components.animation :as anim] + [status-im.ui.components.icons.vector-icons :as icons] + [status-im.ui.components.svg :as svg] + [status-im.utils.utils :as utils.utils] + [status-im.utils.fs :as fs])) + +;; permission management +(defn- request-record-audio-permissions [] + (re-frame/dispatch + [:request-permissions + {:permissions [:record-audio] + :on-allowed + #(re-frame/dispatch [:chat.ui/set-chat-ui-props + {:input-bottom-sheet :audio-message}]) + :on-denied + #(utils.utils/set-timeout + (fn [] + (utils.utils/show-popup (i18n/label :t/error) + (i18n/label "you have to give permission to send audio messages"))) + 50)}])) + + +;;----------------- +;; -- TESTUI stuff +;;----------------- + + +(defn test-input-button [audio-message-showing?] + [react/touchable-highlight + {:on-press + (fn [_] + (if audio-message-showing? + (re-frame/dispatch [:chat.ui/set-chat-ui-props + {:input-bottom-sheet nil}]) + (request-record-audio-permissions))) + :accessibility-label :show-audio-message-icon} + [icons/icon + :main-icons/warning + {:container-style {:margin 14 :margin-right 6} + :color (if audio-message-showing? colors/blue colors/gray)}]]) + +(defn show-panel-anim + [bottom-anim-value alpha-value] + (anim/start + (anim/parallel + [(anim/spring bottom-anim-value {:toValue 0 + :useNativeDriver true}) + (anim/timing alpha-value {:toValue 1 + :duration 500 + :useNativeDriver true})]))) + +(def visual-elements-num 83) +(def total-silence-db -160) +(def silence-db -35) +(def max-db 0) + +(defonce visual-current-values + (into [] (repeatedly visual-elements-num #(anim/create-value total-silence-db)))) +(defonce visual-target-value (anim/create-value total-silence-db)) + +(def visual-width 250) +(def visual-height 35) +(def visual-elements-margin 0) +(defview recording-visuals [] + {:component-did-mount (fn [] + (anim/start + (anim/anim-loop + (anim/parallel + (into [] (for [x (range visual-elements-num)] + (anim/timing (get visual-current-values x) {:toValue (if (= x (dec visual-elements-num)) + visual-target-value + (get visual-current-values (inc x))) + :duration 8 + :useNativeDriver true})))))))} + (let [element-space (quot visual-width visual-elements-num) + element-width (- element-space visual-elements-margin) + heights (into [] (for [x (range visual-elements-num)] + (anim/interpolate (get visual-current-values x) {:inputRange [total-silence-db silence-db 0] + :outputRange [1 1 visual-height]}))) + ys (into [] (for [x (range visual-elements-num)] + (anim/interpolate (get visual-current-values x) {:inputRange [total-silence-db silence-db 0] + :outputRange [(quot visual-height 2) (quot visual-height 2) 0]})))] + (into [svg/svg {:height visual-height :width visual-width}] + (for [x (range visual-elements-num)] + ^{:key (str "bar" x)} + [svg/animated-rect {:x (* x element-space) :y (get ys x) :rx 3 :ry 3 :width element-width :height (get heights x) :fill "white"}])))) + +(defn update-meter [meter-data] + (let [value (if meter-data + (.-value ^js meter-data) + total-silence-db)] + (anim/set-value visual-target-value value))) + +(defn update-info [state-ref info error] + (if info + (let [size (int (.-size info)) + b64s (* size 1.33) + b64kb (quot b64s 1024) + duration (:duration @state-ref) + s (when (and duration (not= duration -1)) + (utils.utils/format-decimals (/ duration 1000) 1)) + ratio (when (and duration (not= duration -1)) + (utils.utils/format-decimals (/ (/ b64s 1024) (/ duration 1000)) 2)) + msg (str s "s --- b64 size: " b64kb "kb --- kb/s: " ratio "kb")] + (reset! state-ref (merge @state-ref {:info msg}))) + (utils.utils/show-popup "fileinfo error" error))) + + +;; --- AUDIO stuff + + +(def base-filename "am.") +(def default-format "aac") + +(defonce recorder-ref (atom nil)) +(defonce player-ref (atom nil)) + +(defonce state-cb (atom #())) +(defonce record-ended-cb (atom #())) +(defonce meter-cb (atom #())) + +(defn destroy-recorder [] + (audio/destroy-recorder @recorder-ref) + (reset! recorder-ref nil)) + +(defn destroy-player [] + (audio/destroy-player @player-ref) + (reset! player-ref nil)) + +(defonce recording-timer (atom nil)) +(defonce recording-start-ts (atom nil)) +(defonce recording-backlog-ms (atom 0)) + +(defn update-timer [timer] + (let [s (quot + (if @recording-start-ts + (+ + (- (js/Date.now) @recording-start-ts) + @recording-backlog-ms) + @recording-backlog-ms) + 1000)] + (reset! timer (gstring/format "%d:%02d" (quot s 60) (mod s 60))))) + +(defn start-recording [rec-state-anim-value timer] + (anim/start (anim/timing rec-state-anim-value {:toValue 1 + :duration 100 + :useNativeDriver true})) + (reset! recording-start-ts (js/Date.now)) + (reset! recording-timer (utils.utils/set-interval #(update-timer timer) 1000)) + (audio/start-recording + @recorder-ref + @state-cb + #(utils.utils/show-popup (str "record " (:error %)) (:message %)))) + +(defn reload-recorder [options-ref] + (when @recorder-ref + (destroy-recorder)) + (reset! recorder-ref (audio/new-recorder @options-ref @meter-cb @state-cb)) + ;; we skip preparation since if a recorder is prepared, player wont play + (@state-cb)) + +(defn reload-player + ([options-ref] (reload-player options-ref nil)) + ([options-ref on-success] + (when @player-ref + (destroy-player)) + (reset! player-ref (audio/new-player + (:filename @options-ref) + {:autoDestroy false} + @state-cb)) + (audio/prepare-player + @player-ref + #(do (@state-cb) (when on-success (on-success))) + #(utils.utils/show-popup (str "prepare " (:error %)) (:message %))))) + +(defn start-playing [] + (audio/start-playing + @player-ref + @state-cb + #(utils.utils/show-popup (str "play " (:error %)) (:message %)))) + +(defn stop-recording [{:keys [options-ref on-success rec-state-anim-value timer]}] + (when @recording-timer + (utils.utils/clear-interval @recording-timer) + (reset! recording-backlog-ms 0) + (reset! recording-timer nil)) + (reset! timer "0:00") + (audio/stop-recording + @recorder-ref + #(do + (update-meter nil) + (reload-recorder options-ref) + (reload-player options-ref on-success)) + #(utils.utils/show-popup (str "stop recording " (:error %)) (:message %))) + (anim/start (anim/timing rec-state-anim-value {:toValue 0 + :duration 100 + :useNativeDriver true}))) + +(defn pause-recording [rec-state-anim-value timer] + (when @recording-timer + (utils.utils/clear-interval @recording-timer) + (reset! recording-backlog-ms (+ @recording-backlog-ms (- (js/Date.now) @recording-start-ts))) + (reset! recording-start-ts nil) + (reset! recording-timer nil) + (update-timer timer)) + (audio/pause-recording + @recorder-ref + #(do (update-meter nil) + (@state-cb)) + #(utils.utils/show-popup (str "pause recording " (:error %)) (:message %))) + (anim/start (anim/timing rec-state-anim-value {:toValue 0 + :duration 100 + :useNativeDriver true}))) + +(defn stop-playing [] + (audio/stop-playing + @player-ref + @state-cb + #(utils.utils/show-popup (str "stop playing " (:error %)) (:message %)))) + +#_(defn update-options [new-option options-ref] + (reset! options-ref (merge @options-ref new-option))) + +;; general state +;; - :recording +;; - :playing +;; - :ready-to-send +;; - :ready-to-record +#_(defn update-state [state-ref] + (let [player-state (audio/get-state @player-ref) + recorder-state (audio/get-state @recorder-ref) + output-file (or + (audio/get-recorder-file-path @recorder-ref) + (:output-file @state-ref)) + general (cond + (= recorder-state audio/RECORDING) :recording + (= player-state audio/PLAYING) :playing + (= player-state audio/PREPARED) :ready-to-send + :else :ready-to-record) + new-state {:general general + :working? (or + (= general :recording) + (= general :playing)) + :output-file output-file + :duration (audio/get-player-duration @player-ref) + :info (if (= general :recording) + "-recording-" + (:info @state-ref))}] + (when (and output-file (not= general :recording)) + (fs/stat output-file + #(update-info state-ref % nil) + #(update-info state-ref nil %))) + (when (not= @state-ref new-state) + (reset! state-ref new-state)))) + +#_(def options-btn-cont-style {:height 30 :margin-horizontal 4 :padding-horizontal 12}) +#_(def options-cont-style {:flex 1 + :flex-direction :row + :align-items :center + :justify-content :center + :margin-top 0}) + +#_(defview test-audio-message-view [] + (letsubs [panel-height [:chats/chat-panel-height] + bottom-anim-value (anim/create-value @panel-height) + alpha-value (anim/create-value 0) + state (reagent/atom nil) + rec-options (reagent/atom (merge + audio/default-recorder-options + {:filename (str base-filename default-format)}))] + {:component-did-mount (fn [] + (show-panel-anim bottom-anim-value alpha-value) + (reset! state-cb #(update-state state)) + (reset! record-ended-cb #(reload-player rec-options)) + (reset! meter-cb #(update-meter %)) + (reload-recorder rec-options) + (add-watch rec-options :watch (fn [] (reload-recorder rec-options)))) + :component-will-unmount (fn [] + (destroy-recorder) + (destroy-player) + (remove-watch rec-options :watch))} + [react/animated-view {:style {:background-color colors/white + :height panel-height + :transform [{:translateY bottom-anim-value}] + :opacity alpha-value}} + [react/view {:style {:flex 1 + :flex-direction :column + :align-items :center + :justify-content :center}} + + ;; options - extension\encoder + [react/view {:style options-cont-style} + + [quo/button {:container-style options-btn-cont-style + :theme (if (or (:working? @state) (string/ends-with? (:filename @rec-options) "mp4")) + :disabled + :main) + :type :secondary + :on-press #(update-options {:filename (str base-filename "mp4")} rec-options)} + "mp4"] + [quo/button {:container-style options-btn-cont-style + :theme (if (or (:working? @state) (string/ends-with? (:filename @rec-options) "aac")) + :disabled + :main) + :type :secondary + :on-press #(update-options {:filename (str base-filename "aac")} rec-options)} + "aac"] + [quo/button {:container-style options-btn-cont-style + :theme (if (or (:working? @state) (string/ends-with? (:filename @rec-options) "ogg")) + :disabled + :main) + :type :secondary + :on-press #(update-options {:filename (str base-filename "ogg")} rec-options)} + "ogg"] + [quo/button {:container-style options-btn-cont-style + :theme (if (or (:working? @state) (string/ends-with? (:filename @rec-options) "webm")) + :disabled + :main) + :type :secondary + :on-press #(update-options {:filename (str base-filename "webm")} rec-options)} + "webm"] + [quo/button {:container-style options-btn-cont-style + :theme (if (or (:working? @state) (string/ends-with? (:filename @rec-options) "amr")) + :disabled + :main) + :type :secondary + :on-press #(update-options {:filename (str base-filename "amr")} rec-options)} + "amr"]] + + ;; options - samplerate + [react/view {:style options-cont-style} + [quo/button {:container-style options-btn-cont-style + :theme (if (or (:working? @state) (= (:sampleRate @rec-options) 22050)) + :disabled + :main) + :type :secondary + :on-press #(update-options {:sampleRate 22050} rec-options)} + "22kHz"] + [quo/button {:container-style options-btn-cont-style + :theme (if (or (:working? @state) (= (:sampleRate @rec-options) 32000)) + :disabled + :main) + :type :secondary + :on-press #(update-options {:sampleRate 32000} rec-options)} + "32kHz"] + [quo/button {:container-style options-btn-cont-style + :theme (if (or (:working? @state) (= (:sampleRate @rec-options) 44100)) + :disabled + :main) + :type :secondary + :on-press #(update-options {:sampleRate 44100} rec-options)} + "44kHz"]] + + + ;; options - bitrate + + + [react/view {:style options-cont-style} + [quo/button {:container-style options-btn-cont-style + :theme (if (or (:working? @state) (= (:bitrate @rec-options) 32000)) + :disabled + :main) + :type :secondary + :on-press #(update-options {:bitrate 32000} rec-options)} + "32kbs"] + [quo/button {:container-style options-btn-cont-style + :theme (if (or (:working? @state) (= (:bitrate @rec-options) 48000)) + :disabled + :main) + :type :secondary + :on-press #(update-options {:bitrate 48000} rec-options)} + "48kbs"] + [quo/button {:container-style options-btn-cont-style + :theme (if (or (:working? @state) (= (:bitrate @rec-options) 64000)) + :disabled + :main) + :type :secondary + :on-press #(update-options {:bitrate 64000} rec-options)} + "64kbs"] + [quo/button {:container-style options-btn-cont-style + :theme (if (or (:working? @state) (= (:bitrate @rec-options) 96000)) + :disabled + :main) + :type :secondary + :on-press #(update-options {:bitrate 96000} rec-options)} + "96kbs"] + [quo/button {:container-style options-btn-cont-style + :theme (if (or (:working? @state) (= (:bitrate @rec-options) 128000)) + :disabled + :main) + :type :secondary + :on-press #(update-options {:bitrate 128000} rec-options)} + "128kbs"]] + + + ;; controls + + + [react/text {:style {:typography :main-medium}} (:info @state)] + [react/view {:style {:flex 1 + :flex-direction :row + :align-items :center + :justify-content :center + :margin-top 20}} + [quo/button {:theme (if (= (:general @state) :playing) + :disabled + :negative) + :on-press (if (= (:general @state) :recording) + #(stop-recording {:rec-options rec-options}) + start-recording)} + (if (= (:general @state) :recording) + "stop" + "rec")] + [quo/button {:theme (if (let [g (:general @state)] + (or + (= g :recording) + (= g :ready-to-record))) + :disabled + :positive) + :on-press (if (= (:general @state) :playing) + stop-playing + start-playing)} + (if (= (:general @state) :playing) + "stop" + "play")] + [quo/button {:theme (if (or + (:working? @state) + (not= (:general @state) :ready-to-send) + (string/blank? (:output-file @state))) + :disabled + :main) + :on-press #(re-frame/dispatch [:chat/send-audio (:output-file @state) (int (:duration @state))])} + "send msg"]] + ;; metering + [react/view (message.style/message-view {:outgoing true :last-in-group? true}) + [recording-visuals]]]])) + +;;----------------- +;; -- FINAL UI +;;----------------- + +(def metering-interval 100) + +;;ensure animation finishes before next meter update +(def metering-anim-duration (int (* metering-interval 0.9))) + +(defn new-update-meter [meter-data] + (let [value (if meter-data + (.-value ^js meter-data) + total-silence-db)] + (anim/start (anim/timing visual-target-value {:toValue value + :duration metering-anim-duration + :useNativeDriver true})))) + +(def rec-options (reagent/atom (merge + audio/default-recorder-options + {:filename (str base-filename default-format) + :meteringInterval metering-interval}))) + +;; general state +;; - :recording +;; - :playing +;; - :ready-to-send +;; - :ready-to-record +(defn new-update-state [state-ref] + (let [player-state (audio/get-state @player-ref) + recorder-state (audio/get-state @recorder-ref) + output-file (or + (audio/get-recorder-file-path @recorder-ref) + (:output-file @state-ref)) + general (cond + (= recorder-state audio/RECORDING) :recording + (= player-state audio/PLAYING) :playing + (= player-state audio/PREPARED) :ready-to-send + :else :ready-to-record) + new-state {:general general + :working? (or + (= general :recording) + (= general :playing)) + :output-file output-file + :duration (audio/get-player-duration @player-ref)}] + (when (not= @state-ref new-state) + (reset! state-ref new-state)))) + +#_(defview input-button [] + (letsubs [audio-recorder-open? [:chats/current-chat-audio-recorder-open?]] + [react/view + [react/touchable-highlight + {:on-press (if audio-recorder-open? + #(re-frame/dispatch [:chat.ui/close-audio-recorder]) + (fn [_] (print "show tip"))) + :on-long-press (when-not audio-recorder-open? + #(re-frame/dispatch [:chat.ui/open-audio-recorder])) + :on-press-out #(stop-recording rec-options) #_(do (print "OUT!") (re-frame/dispatch [:chat.ui/close-audio-recorder])) + :accessibility-label :show-audio-message-icon} + [icons/icon + :main-icons/warning + {:container-style {:margin 14 :margin-right 6} + :color colors/gray}]]])) + +(defn input-button [audio-message-showing?] + [quo/button + {:on-press (fn [_] + (if audio-message-showing? + (re-frame/dispatch [:chat.ui/set-chat-ui-props + {:input-bottom-sheet nil}]) + (request-record-audio-permissions)) + (js/setTimeout #(react/dismiss-keyboard!) 100)) + :accessibility-label :show-audio-message-icon + :type :icon + :theme (if audio-message-showing? :main :disabled)} + :main-icons/speech]) + +#_(defview audio-message-recorder-view [] + (letsubs [;;panel-height [:chats/chat-panel-height] + ;;bottom-anim-value (anim/create-value @panel-height) + ;;alpha-value (anim/create-value 0) + state (reagent/atom nil)] + {:component-did-mount (fn [] + #_(show-panel-anim bottom-anim-value alpha-value) + (reset! state-cb #(new-update-state state)) + (reset! record-ended-cb #(reload-player rec-options)) + (reload-recorder rec-options true) + #_(add-watch rec-options :watch (fn [] (reload-recorder rec-options)))) + :component-will-unmount (fn [] + (destroy-recorder) + (destroy-player) + #_(remove-watch rec-options :watch))} + [react/view {:style input.style/input-view} + [react/view (message.style/message-view {:outgoing true}) + (cond + (= (:general @state) :recording) [recording-visuals] + (= (:general @state) :ready-to-send) [react/view {:flex 1 + :flex-direction :column + :align-items :center + :justify-content :center + :margin-top 0} + [quo/button {:type :icon + :theme :icon + :accessibility-label :play-pause + :on-press #()} + :main-icons/arrow-left]] + :else nil)]])) + +(def rec-button-base-size 61) + +;; rec-state-anim-value 0 => stopped, 1 => recording +(defview rec-button-view [rec-state-anim-value state timer] + (letsubs [;meter-value (anim/create-value total-silence-db) + outer-scale (anim/interpolate visual-target-value {:inputRange [total-silence-db silence-db 0] + :outputRange [1 0.8 1.2]}) + inner-scale (anim/interpolate rec-state-anim-value {:inputRange [0 1] + :outputRange [1 0.5]}) + inner-radius (anim/interpolate rec-state-anim-value {:inputRange [0 1] + :outputRange [rec-button-base-size 16]})] + [react/touchable-highlight {:on-press #(if (= (:general @state) :recording) + (pause-recording rec-state-anim-value timer) + (start-recording rec-state-anim-value timer))} + [react/view {:style {:width rec-button-base-size + :height rec-button-base-size + :align-items "center"}} + [react/animated-view {:style {:position "absolute" + :width rec-button-base-size + :height rec-button-base-size + :top 0 + :border-width 4 + :transform [{:scale outer-scale}] + :border-color colors/red-audio-recorder + :border-radius rec-button-base-size}}] + [react/animated-view {:style {:position "absolute" + :top 6 + :left 6 + :bottom 6 + :right 6 + :transform [{:scale inner-scale}] + :border-radius inner-radius + :background-color colors/red-audio-recorder}}]]])) + +(defview audio-message-view [] + (letsubs [panel-height [:chats/chat-panel-height] + bottom-anim-value (anim/create-value @panel-height) + alpha-value (anim/create-value 0) + rec-state-anim-value (anim/create-value 0) + timer (reagent/atom "0:00") + state (reagent/atom nil)] + {:component-did-mount (fn [] + (show-panel-anim bottom-anim-value alpha-value) + (reset! state-cb #(new-update-state state)) + (reset! record-ended-cb #(reload-player rec-options)) + (reset! meter-cb #(new-update-meter %)) + (reload-recorder rec-options)) + :component-will-unmount (fn [] + (when (:output-file @state) + ; possible issue if message is not yet sent? + (fs/unlink (:output-file @state))) + (destroy-recorder) + (destroy-player) + (reset! state-cb nil) + (reset! record-ended-cb nil) + (reset! meter-cb nil))} + [react/animated-view {:style {:background-color colors/white + :height panel-height + :transform [{:translateY bottom-anim-value}] + :opacity alpha-value}} + [react/view {:style {:flex 1 + :flex-direction :column + :justify-content :space-around + :padding-vertical 40}} + [react/text {:style {:font-size 28 + :line-height 38 + :align-self :center}} @timer] + + [react/view {:style {:flex 1 + :max-height 80 + :flex-direction :row + :align-items :center + :justify-content :space-around + :align-self :stretch + :padding-horizontal 80}} + [react/animated-view {:style {:opacity rec-state-anim-value}} + [quo/button {:type :scale + :on-press #(stop-recording {:options-ref rec-options :rec-state-anim-value rec-state-anim-value :timer timer})} + [icons/icon :main-icons/close + {:container-style (merge send-button.style/send-message-container {:background-color colors/gray}) + :accessibility-label :send-message-button + :color colors/white-persist}]]] + [rec-button-view rec-state-anim-value state timer] + [react/animated-view {:style {:opacity rec-state-anim-value}} + [send-button/send-button-view false (fn [] (case (:general @state) + :ready-to-send (do + (re-frame/dispatch [:chat/send-audio + (:output-file @state) + (int (:duration @state))]) + (destroy-player) + (@state-cb)) + :recording (stop-recording {:options-ref rec-options + :rec-state-anim-value rec-state-anim-value + :on-success #(re-frame/dispatch [:chat/send-audio + (:output-file @state) + (int (:duration @state))]) + :timer timer}) + nil))]]]]])) diff --git a/src/status_im/ui/screens/chat/input/input.cljs b/src/status_im/ui/screens/chat/input/input.cljs index 8c8e7ccf065a..f2fa48106606 100644 --- a/src/status_im/ui/screens/chat/input/input.cljs +++ b/src/status_im/ui/screens/chat/input/input.cljs @@ -12,6 +12,7 @@ [status-im.ui.components.react :as react] [status-im.ui.components.icons.vector-icons :as vector-icons] [status-im.utils.config :as config] + [status-im.ui.screens.chat.audio-message.views :as audio-message] [status-im.ui.screens.chat.image.views :as image] [status-im.ui.screens.chat.stickers.views :as stickers] [status-im.ui.screens.chat.extensions.views :as extensions])) @@ -99,4 +100,6 @@ [extensions/button (= :extensions input-bottom-sheet)]) (when-not input-text-empty? [send-button/send-button-view input-text-empty? - #(re-frame/dispatch [:chat.ui/send-current-message])])]]))) + #(re-frame/dispatch [:chat.ui/send-current-message])]) + (when (and input-text-empty? (not reply-message) (not public?)) + [audio-message/input-button (= :audio-message input-bottom-sheet)])]]))) diff --git a/src/status_im/ui/screens/chat/message/audio.cljs b/src/status_im/ui/screens/chat/message/audio.cljs new file mode 100644 index 000000000000..723db7f1eee5 --- /dev/null +++ b/src/status_im/ui/screens/chat/message/audio.cljs @@ -0,0 +1,193 @@ +(ns status-im.ui.screens.chat.message.audio + (:require-macros [status-im.utils.views :refer [defview letsubs]]) + (:require [status-im.utils.utils :as utils] + [reagent.core :as reagent] + [goog.string :as gstring] + [status-im.audio.core :as audio] + [status-im.ui.screens.chat.styles.message.audio :as style] + [status-im.ui.components.colors :as colors] + [status-im.ui.components.icons.vector-icons :as icons] + [status-im.ui.components.react :as react] + [status-im.ui.components.slider :as slider])) + +(defn message-press-handlers [_] + ;;TBI save audio file? + ) + +(defonce player-ref (atom nil)) +(defonce current-player-message-id (atom nil)) +(defonce current-active-state-ref-ref (atom nil)) +(defonce progress-timer (atom nil)) + +(defn start-stop-progress-timer [{:keys [progress-ref]} start?] + (when @progress-timer + (utils/clear-interval @progress-timer) + (when-not start? + (reset! progress-timer nil))) + (when start? + (when @progress-timer + (utils/clear-interval @progress-timer)) + (reset! progress-timer (utils/set-interval + #(reset! progress-ref (audio/get-player-current-time @player-ref)) + 100)))) + +(defn update-state [{:keys [state-ref progress-ref message-id seek audio-duration-ms slider-seeking unloaded? error]}] + (let [player-state (audio/get-state @player-ref) + general (cond + (some? error) :error + (or unloaded? (not= message-id @current-player-message-id)) :not-loaded + (= player-state audio/PLAYING) :playing + (= player-state audio/PAUSED) :paused + (= player-state audio/SEEKING) :seeking + (= player-state audio/PREPARED) :ready-to-play + :else :preparing) + progress-ref (or progress-ref (:progress-ref @state-ref)) + slider-seeking (if (some? slider-seeking) + slider-seeking + (:slider-seeking @state-ref)) + new-state {:general general + :error-msg error + :duration (cond (not (#{:preparing :not-loaded :error} general)) + (audio/get-player-duration @player-ref) + + audio-duration-ms audio-duration-ms + + :else (:duration @state-ref)) + :slider-seeking slider-seeking + :progress-ref progress-ref + :seek (when (or + slider-seeking + (#{:preparing :not-loaded :error} general)) + (or seek (:seek @state-ref)))}] + (when (and (not= general :playing) (not slider-seeking) (some? seek)) + (reset! progress-ref seek)) + (when (and unloaded? progress-ref) + (reset! progress-ref 0)) + (when (not= @state-ref new-state) + (reset! state-ref new-state)))) + +(defn destroy-player [{:keys [message-id reloading?]}] + (when (and @player-ref (or reloading? + (= message-id @current-player-message-id))) + (audio/destroy-player @player-ref) + (reset! player-ref nil) + (when @current-active-state-ref-ref + (update-state {:state-ref @current-active-state-ref-ref :unloaded? true})) + (reset! current-player-message-id nil) + (reset! current-active-state-ref-ref nil))) + +(defonce last-seek (atom (js/Date.now))) + +(defn seek [{:keys [message-id] :as params} value immediate? on-success] + (if (and @player-ref (= message-id @current-player-message-id)) + (let [now (js/Date.now)] + (when (or immediate? (> (- now @last-seek) 200)) + (reset! last-seek (js/Date.now)) + (audio/seek-player + @player-ref + value + #(do + (update-state (merge params {:seek value})) + (when on-success (on-success))) + #(update-state (merge params {:error (:message %)}))))) + (update-state (merge params {:seek value})))) + +(defn reload-player-and-play [{:keys [message-id state-ref] :as params} base64-data on-success] + ;; to avoid reloading player while is initializing, + ;; we go ahead only if there is no player or + ;; if it is already prepared + (when (or (nil? @player-ref) (audio/canPlay? @player-ref)) + (when @player-ref + (destroy-player (merge params {:reloading? true}))) + (reset! player-ref (audio/new-player + base64-data + {:autoDestroy false} + #(seek params 0 true nil))) + (audio/prepare-player + @player-ref + #(when on-success (on-success)) + #(update-state (merge params {:error (:message %)}))) + (reset! current-player-message-id message-id) + (reset! current-active-state-ref-ref state-ref) + (update-state params))) + +(defn play-pause [{:keys [message-id state-ref] :as params} audio] + (if (not= message-id @current-player-message-id) + ;; player has audio from another message, we need to reload + (reload-player-and-play params + audio + ;; on-success: audio is loaded, do we have an existing value to seek to? + #(if-some [seek-time (:seek @state-ref)] + ;; seek and play on-success + (seek params + seek-time + true + (fn [] (play-pause params audio))) + + ;; nothing to seek to, play + (play-pause params audio))) + + ;; loaded audio corresponds to current message we can play + (when @player-ref + (audio/toggle-playpause-player + @player-ref + #(do + (start-stop-progress-timer params true) + (update-state params)) + #(do + (start-stop-progress-timer params false) + (update-state params)) + #(update-state (merge params {:error (:message %)})))))) + +(defn- play-pause-button [state-ref outgoing on-press] + (let [color (if outgoing colors/blue colors/white-persist)] + (if (= (:general @state-ref) :preparing) + [react/view {:style (style/play-pause-container outgoing true)} + [react/small-loading-indicator color]] + [react/touchable-highlight {:on-press on-press} + [icons/icon (case (:general @state-ref) + :playing :main-icons/pause + :main-icons/play) + {:container-style (style/play-pause-container outgoing false) + :accessibility-label :play-pause-audio-message-button + :color color}]]))) + +(defview message-content [{:keys [audio audio-duration-ms message-id outgoing]} timestamp-view] + (letsubs [state (reagent/atom nil) + progress (reagent/atom 0) + width [:dimensions/window-width]] + {:component-did-mount (fn [] + (update-state {:state-ref state + :audio-duration-ms audio-duration-ms + :message-id message-id + :unloaded? true + :progress-ref progress})) + :component-will-unmount (fn [] + (reset! state nil) + (destroy-player {:state-ref state :message-id message-id}))} + + (let [base-params {:state-ref state :message-id message-id}] + (if (= (:general @state) :error) + [react/text {:style {:typography :main-medium + :margin-bottom 16}} (:error-msg @state)] + [react/view (style/container width) + [react/view style/play-pause-slider-container + (play-pause-button state outgoing #(play-pause (merge base-params {:progress-ref progress}) audio)) + [react/view style/slider-container + [slider/slider (merge (style/slider outgoing) + {:minimum-value 0 + :maximum-value (:duration @state) + :value @progress + :on-value-change #(seek base-params % false nil) + :on-sliding-start #(seek (merge base-params {:slider-seeking true}) % true nil) + :on-sliding-complete #(seek (merge base-params {:slider-seeking false}) % true nil)})]]] + + [react/view style/times-container + [react/text {:style (style/timestamp outgoing)} + (let [time (cond + (= (:general @state) :playing) @progress + (some? (:seek @state)) (:seek @state) + :else (:duration @state)) + s (quot time 1000)] + (gstring/format "%02d:%02d" (quot s 60) (mod s 60)))] + timestamp-view]])))) diff --git a/src/status_im/ui/screens/chat/message/message.cljs b/src/status_im/ui/screens/chat/message/message.cljs index 85c9a4d70f3b..9e91aeab6491 100644 --- a/src/status_im/ui/screens/chat/message/message.cljs +++ b/src/status_im/ui/screens/chat/message/message.cljs @@ -5,6 +5,7 @@ [status-im.ui.components.colors :as colors] [status-im.ui.components.icons.vector-icons :as vector-icons] [status-im.ui.components.react :as react] + [status-im.ui.screens.chat.message.audio :as message.audio] [status-im.ui.screens.chat.message.command :as message.command] [status-im.ui.screens.chat.photos :as photos] [status-im.ui.screens.chat.sheets :as sheets] @@ -292,6 +293,12 @@ {:content (sheets/sticker-long-press message) :height 64}])})) +(defn message-content-audio + [message] + [react/touchable-highlight (message.audio/message-press-handlers message) + [message-bubble-wrapper message + [message.audio/message-content message [message-timestamp message false]]]]) + (defn chat-message [{:keys [public? content content-type] :as message}] (if (= content-type constants/content-type-command) [message.command/command-content message-content-wrapper message] @@ -316,4 +323,8 @@ (not public?)) [react/touchable-highlight (image-message-press-handlers message) [message-content-image message]] - [unknown-content-type message])))))]))) + (if (and (= content-type constants/content-type-audio) + ;; Disabling audio for public-chats + (not public?)) + [message-content-audio message] + [unknown-content-type message]))))))]))) diff --git a/src/status_im/ui/screens/chat/styles/message/audio.cljs b/src/status_im/ui/screens/chat/styles/message/audio.cljs new file mode 100644 index 000000000000..11c8f510857a --- /dev/null +++ b/src/status_im/ui/screens/chat/styles/message/audio.cljs @@ -0,0 +1,44 @@ +(ns status-im.ui.screens.chat.styles.message.audio + (:require [status-im.ui.components.colors :as colors] + [status-im.ui.screens.chat.styles.message.message :as message.style])) + +(defn container [window-width] + {:width (* window-width 0.60) + :flex-direction :column + :justify-content :space-between}) + +(def play-pause-slider-container + {:flex-direction :row + :align-items :center}) + +(def slider-container + {:flex-direction :column + :align-items :stretch + :flex-grow 1}) + +(defn slider [outgoing] + {:style {:margin-left 12 + :height 34} + :minimum-track-tint-color (if outgoing + colors/white + colors/blue) + :maximum-track-tint-color (if outgoing + colors/white-transparent + colors/gray-transparent-40)}) + +(defn play-pause-container [outgoing? loading?] + {:background-color (if outgoing? colors/white-persist colors/blue) + :width 28 + :height 28 + :padding (if loading? 4 2) + :border-radius 15}) + +(def times-container + {:flex-direction :row + :justify-content :space-between}) + +(defn timestamp [outgoing] + (merge (message.style/message-timestamp-text + false + outgoing + false) {:margin-left 40})) \ No newline at end of file diff --git a/src/status_im/ui/screens/chat/views.cljs b/src/status_im/ui/screens/chat/views.cljs index a60d018dba59..56878b9970c9 100644 --- a/src/status_im/ui/screens/chat/views.cljs +++ b/src/status_im/ui/screens/chat/views.cljs @@ -9,6 +9,7 @@ [status-im.ui.components.react :as react] [status-im.ui.screens.chat.sheets :as sheets] [status-im.ui.screens.chat.input.input :as input] + [status-im.ui.screens.chat.audio-message.views :as audio-message] [status-im.ui.screens.chat.message.message :as message] [status-im.ui.screens.chat.stickers.views :as stickers] [status-im.ui.screens.chat.styles.main :as style] @@ -174,6 +175,8 @@ [extensions/extensions-view] :images [image/image-view] + :audio-message + [audio-message/audio-message-view] [empty-bottom-sheet]))) (defview chat [] diff --git a/src/status_im/utils/fs.cljs b/src/status_im/utils/fs.cljs index ca7a3fdceb31..df2a951452d3 100644 --- a/src/status_im/utils/fs.cljs +++ b/src/status_im/utils/fs.cljs @@ -4,6 +4,11 @@ (defn move-file [src dst] (.moveFile react-native-fs src dst)) +(defn stat [path on-stat on-error] + (-> (.stat react-native-fs path) + (.then on-stat) + (.catch on-error))) + (defn read-file [path encoding on-read on-error] (-> (.readFile react-native-fs path encoding) (.then on-read) diff --git a/status-go-version.json b/status-go-version.json index 40d3298312bd..cd5f115ea9b7 100644 --- a/status-go-version.json +++ b/status-go-version.json @@ -2,7 +2,7 @@ "_comment": "DO NOT EDIT THIS FILE BY HAND. USE 'scripts/update-status-go.sh ' instead", "owner": "status-im", "repo": "status-go", - "version": "v0.55.1", - "commit-sha1": "6d5a93287b2c1b4d4d5d1178a3ecec870bf18b9e", - "src-sha256": "1wly0km9bxxv1wwj6jchqh4d4x2m86fxrdqixjzldy70vl6qbyqa" + "version": "feature/audio-messages", + "commit-sha1": "b9a7e6ff2356c80a1b541b0dff42872cd79d0592", + "src-sha256": "0jkbaqywxgpp0zz3jvprknfpxbdqkjhhp9nz912kng5qmq10fgqm" } diff --git a/yarn.lock b/yarn.lock index 8c958422bbfe..e6a2c96fae06 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1207,6 +1207,14 @@ "@types/yargs" "^15.0.0" chalk "^3.0.0" +"@react-native-community/audio-toolkit@git+https://github.com/tbenr/react-native-audio-toolkit.git#v2.0.3-status-v5": + version "2.0.3" + resolved "git+https://github.com/tbenr/react-native-audio-toolkit.git#353a9de5b95e82afb8443572f4dab8e29ab08ee7" + dependencies: + async "^2.6.3" + eventemitter3 "^1.2.0" + lodash "^4.17.15" + "@react-native-community/cameraroll@^1.6.1": version "1.6.2" resolved "https://registry.yarnpkg.com/@react-native-community/cameraroll/-/cameraroll-1.6.2.tgz#a4dedcf8ba7bc938f805dd07dd43a275edb1f411" @@ -1329,6 +1337,11 @@ resolved "https://registry.yarnpkg.com/@react-native-community/netinfo/-/netinfo-4.7.0.tgz#7482d36836cac69d0a0ae25581f65bc472639930" integrity sha512-a/sDB+AsLEUNmhAUlAaTYeXKyQdFGBUfatqKkX5jluBo2CB3OAuTHfm7rSjcaLB9EmG5iSq3fOTpync2E7EYTA== +"@react-native-community/slider@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@react-native-community/slider/-/slider-3.0.0.tgz#ffbf78689fc0572fb5c1e2ccb61b2ef074d3dcd2" + integrity sha512-deNc3JHBHz24YN+0DTAocXfrYFIFc1DvsIriMJSsJlR/MvsLzoq2+qwaEN+0/LJ37pstv85wZWY0pNugk4e41g== + "@react-navigation/bottom-tabs@^5.1.1": version "5.2.7" resolved "https://registry.yarnpkg.com/@react-navigation/bottom-tabs/-/bottom-tabs-5.2.7.tgz#6f3eca9ba323cd9e80dd4ceba1f1c8e84955091f" @@ -1821,7 +1834,7 @@ async-limiter@~1.0.0: resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd" integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ== -async@^2.4.0: +async@^2.4.0, async@^2.6.3: version "2.6.3" resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff" integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg== @@ -3270,6 +3283,11 @@ event-target-shim@^5.0.0, event-target-shim@^5.0.1: resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== +eventemitter3@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-1.2.0.tgz#1c86991d816ad1e504750e73874224ecf3bec508" + integrity sha1-HIaZHYFq0eUEdQ5zh0Ik7PO+xQg= + eventemitter3@^3.0.0: version "3.1.2" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.2.tgz#2d3d48f9c346698fce83a85d7d664e98535df6e7"