diff --git a/CHANGES.md b/CHANGES.md index fa9d863d3..f62115f7c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,43 @@ +## Changes in 1.9.15 (2023-01-10) + +✨ Features + +- Threads: Load the thread list using server-side sorting and pagination ([#6059](https://github.com/vector-im/element-ios/issues/6059)) +- Rich Text Composer: added link creation/editing feature. ([#7159](https://github.com/vector-im/element-ios/issues/7159)) +- Rich Text Composer: added inline code formatting feature. ([#7177](https://github.com/vector-im/element-ios/issues/7177)) +- Voice Broadcast: allow to react on Voice Broadcast. ([#7179](https://github.com/vector-im/element-ios/issues/7179)) + +🙌 Improvements + +- Labs: VoiceBroadcast: Add backward and forward buttons for playback ([#7146](https://github.com/vector-im/element-ios/pull/7146)) +- Update the room description in the rooms list in case of live broadcast (incoming or outgoing) ([#7160](https://github.com/vector-im/element-ios/pull/7160)) +- Labs: VoiceBroadcast: Link the live icon color to the recording state ([#7163](https://github.com/vector-im/element-ios/pull/7163)) +- Add old device data from user's account data events. ([#7164](https://github.com/vector-im/element-ios/pull/7164)) +- Labs: VoiceBroadcast: Replace the player timeline ([#7165](https://github.com/vector-im/element-ios/pull/7165)) +- Labs: VoiceBroadcast: Update Voice Broadcast recorder cell by adjusting some padding values ([#7175](https://github.com/vector-im/element-ios/pull/7175)) +- Labs: VoiceBroadcast: Update live badge layout for recorder and player cells ([#7178](https://github.com/vector-im/element-ios/pull/7178)) +- Updates on the UI/UX to conform the device manager to the design. ([#7180](https://github.com/vector-im/element-ios/pull/7180)) +- Labs: VoiceBroadcast: Handle potential crash whereas a voice broadcast is in progress ([#7188](https://github.com/vector-im/element-ios/pull/7188)) +- Polls: show decryption errors in timeline during aggregations. ([#7206](https://github.com/vector-im/element-ios/pull/7206)) +- Device Manager: change fallback display name for sessions. ([#7214](https://github.com/vector-im/element-ios/pull/7214)) +- Ignore the voice broadcast chunks at the notifications level ([#7230](https://github.com/vector-im/element-ios/pull/7230)) +- Polls: render the poll ended event in the timeline. ([#7231](https://github.com/vector-im/element-ios/pull/7231)) +- Upgrade MatrixSDK version ([v0.24.7](https://github.com/matrix-org/matrix-ios-sdk/releases/tag/v0.24.7)). +- Updated fastlane script to use Xcode v 14.2. ([#7182](https://github.com/vector-im/element-ios/issues/7182)) + +🐛 Bugfixes + +- Labs: Crash on new voice broadcast if the room has avatar ([#7173](https://github.com/vector-im/element-ios/pull/7173)) +- Fix hidden live location timeline tiles after text messages ([#7220](https://github.com/vector-im/element-ios/pull/7220)) +- Fix an issue preventing temporary audio files to be deleted. ([#7244](https://github.com/vector-im/element-ios/pull/7244)) +- App Layout: wrap Space names to 1 line only in the bottom sheet ([#6579](https://github.com/vector-im/element-ios/issues/6579)) +- Timeline: fixed navigation back from replies. ([#7003](https://github.com/vector-im/element-ios/issues/7003)) +- Timeline: fixed an issue where formatted links appeared in black. ([#7109](https://github.com/vector-im/element-ios/issues/7109)) +- Voice Broadcast: Pause voice broadcast listening on new voice broadcast recording ([#7192](https://github.com/vector-im/element-ios/issues/7192)) +- Direct Message: fixed a crash when a new DM room is created ([#7232](https://github.com/vector-im/element-ios/issues/7232)) +- Voice Broadcast: Prevent sending voice message during a voice broadcast recording ([#7235](https://github.com/vector-im/element-ios/issues/7235)) + + ## Changes in 1.9.14 (2022-12-13) 🙌 Improvements diff --git a/Config/AppVersion.xcconfig b/Config/AppVersion.xcconfig index 8e4d7f63e..d4061ccd2 100644 --- a/Config/AppVersion.xcconfig +++ b/Config/AppVersion.xcconfig @@ -16,5 +16,5 @@ // // Version -MARKETING_VERSION = 2.2.0 +MARKETING_VERSION = 2.3.0 CURRENT_PROJECT_VERSION = 20220714163152 diff --git a/Podfile b/Podfile index 9bd443e78..ffeb438d9 100644 --- a/Podfile +++ b/Podfile @@ -16,7 +16,7 @@ use_frameworks! # - `{ :specHash => {sdk spec hash}` to depend on specific pod options (:git => …, :podspec => …) for MatrixSDK repo. Used by Fastfile during CI # # Warning: our internal tooling depends on the name of this variable name, so be sure not to change it -$matrixSDKVersion = '= 0.24.6' +$matrixSDKVersion = '= 0.24.7' # $matrixSDKVersion = :local # $matrixSDKVersion = { :branch => 'develop'} # $matrixSDKVersion = { :specHash => { git: 'https://git.io/fork123', branch: 'fix' } } diff --git a/Podfile.lock b/Podfile.lock index 8b16f0e83..dbc139f68 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -38,7 +38,6 @@ PODS: - DTFoundation/Core - DTFoundation/UIKit (1.7.18): - DTFoundation/Core - - DTTJailbreakDetection (0.4.0) - FLEX (4.5.0) - FlowCommoniOS (1.12.2) - GBDeviceInfo (7.1.0): @@ -56,12 +55,9 @@ PODS: - LoggerAPI (1.9.200): - Logging (~> 1.1) - Logging (1.4.0) - - MatomoTracker (7.4.1): - - MatomoTracker/Core (= 7.4.1) - - MatomoTracker/Core (7.4.1) - - MatrixSDK (0.24.6): - - MatrixSDK/Core (= 0.24.6) - - MatrixSDK/Core (0.24.6): + - MatrixSDK (0.24.7): + - MatrixSDK/Core (= 0.24.7) + - MatrixSDK/Core (0.24.7): - AFNetworking (~> 4.0.0) - GZIP (~> 1.3.0) - libbase58 (~> 0.1.4) @@ -69,12 +65,12 @@ PODS: - OLMKit (~> 3.2.5) - Realm (= 10.27.0) - SwiftyBeaver (= 1.9.5) - - MatrixSDK/CryptoSDK (0.24.6): - - MatrixSDKCrypto (= 0.1.6) - - MatrixSDK/JingleCallStack (0.24.6): + - MatrixSDK/CryptoSDK (0.24.7): + - MatrixSDKCrypto (= 0.1.7) + - MatrixSDK/JingleCallStack (0.24.7): - JitsiMeetSDK (= 5.0.2) - MatrixSDK/Core - - MatrixSDKCrypto (0.1.6) + - MatrixSDKCrypto (0.1.7) - OLMKit (3.2.12): - OLMKit/olmc (= 3.2.12) - OLMKit/olmcpp (= 3.2.12) @@ -119,7 +115,6 @@ DEPENDENCIES: - DSBottomSheet (~> 0.3) - DSWaveformImage (~> 6.1.1) - DTCoreText (~> 1.6.25) - - DTTJailbreakDetection (~> 0.4.0) - FLEX (~> 4.5.0) - FlowCommoniOS (~> 1.12.0) - GBDeviceInfo (~> 7.1.0) @@ -127,9 +122,8 @@ DEPENDENCIES: - KeychainAccess (~> 4.2.2) - KTCenterFlowLayout (~> 1.3.1) - libPhoneNumber-iOS (~> 0.9.13) - - MatomoTracker (~> 7.4.1) - - MatrixSDK (from `https://dl-gitlab.example.com/bwmessenger/bundesmessenger/bundesmessenger-ios-sdk`, tag `v0.24.6_bwi_beta`) - - MatrixSDK/JingleCallStack (from `https://dl-gitlab.example.com/bwmessenger/bundesmessenger/bundesmessenger-ios-sdk`, tag `v0.24.6_bwi_beta`) + - MatrixSDK (= 0.24.7) + - MatrixSDK/JingleCallStack (= 0.24.7) - OLMKit - PostHog (~> 1.4.4) - ReadMoreTextView (~> 3.0.1) @@ -158,7 +152,6 @@ SPEC REPOS: - DSWaveformImage - DTCoreText - DTFoundation - - DTTJailbreakDetection - FLEX - FlowCommoniOS - GBDeviceInfo @@ -172,7 +165,7 @@ SPEC REPOS: - libPhoneNumber-iOS - LoggerAPI - Logging - - MatomoTracker + - MatrixSDK - MatrixSDKCrypto - OLMKit - PostHog @@ -197,17 +190,11 @@ EXTERNAL SOURCES: AnalyticsEvents: :branch: release/swift :git: https://github.com/matrix-org/matrix-analytics-events.git - MatrixSDK: - :git: https://dl-gitlab.example.com/bwmessenger/bundesmessenger/bundesmessenger-ios-sdk - :tag: v0.24.6_bwi_beta CHECKOUT OPTIONS: AnalyticsEvents: :commit: 53ad46ba1ea1ee8f21139dda3c351890846a202f :git: https://github.com/matrix-org/matrix-analytics-events.git - MatrixSDK: - :git: https://dl-gitlab.example.com/bwmessenger/bundesmessenger/bundesmessenger-ios-sdk - :tag: v0.24.6_bwi_beta SPEC CHECKSUMS: AFNetworking: 7864c38297c79aaca1500c33288e429c3451fdce @@ -220,7 +207,6 @@ SPEC CHECKSUMS: DSWaveformImage: 3c718a0cf99291887ee70d1d0c18d80101d3d9ce DTCoreText: ec749e013f2e1f76de5e7c7634642e600a7467ce DTFoundation: a53f8cda2489208cbc71c648be177f902ee17536 - DTTJailbreakDetection: 5e356c5badc17995f65a83ed9483f787a0057b71 FLEX: e51461dd6f0bfb00643c262acdfea5d5d12c596b FlowCommoniOS: ca92071ab526dc89905495a37844fd7e78d1a7f2 GBDeviceInfo: 5d62fa85bdcce3ed288d83c28789adf1173e4376 @@ -234,9 +220,8 @@ SPEC CHECKSUMS: libPhoneNumber-iOS: 0a32a9525cf8744fe02c5206eb30d571e38f7d75 LoggerAPI: ad9c4a6f1e32f518fdb43a1347ac14d765ab5e3d Logging: beeb016c9c80cf77042d62e83495816847ef108b - MatomoTracker: 24a846c9d3aa76933183fe9d47fd62c9efa863fb - MatrixSDK: 2bd63890d709683741452de2f215cfcda840fe64 - MatrixSDKCrypto: b9e9bced53510f063bb203ccbec919f08d8f2641 + MatrixSDK: 895929fad10b7ec9aa96d557403b44c5e3522211 + MatrixSDKCrypto: 2bd9ca41b2c644839f4e680a64897d56b3f95392 OLMKit: da115f16582e47626616874e20f7bb92222c7a51 PostHog: 4b6321b521569092d4ef3a02238d9435dbaeb99f ReadMoreTextView: 19147adf93abce6d7271e14031a00303fe28720d @@ -256,6 +241,6 @@ SPEC CHECKSUMS: zxcvbn-ios: fef98b7c80f1512ff0eec47ac1fa399fc00f7e3c ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: 2046a454e97a09b21239c91eeeabb97af1089baf +PODFILE CHECKSUM: 56782e2abd382278b3c5b23824ca74994fd0a97e COCOAPODS: 1.11.3 diff --git a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved index 211a0a842..7af877671 100644 --- a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,7 +32,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/matrix-org/matrix-wysiwyg-composer-swift", "state" : { - "revision" : "38ad28bedbe63b3587126158245659b6c989ec2c" + "revision" : "534ee5bae5e8de69ed398937b5edb7b5f21551d2" } }, { diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_backward_30s.imageset/Contents.json b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_backward_30s.imageset/Contents.json new file mode 100644 index 000000000..bc57405fb --- /dev/null +++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_backward_30s.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "voice_broadcast_backward_30s.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_backward_30s.imageset/voice_broadcast_backward_30s.svg b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_backward_30s.imageset/voice_broadcast_backward_30s.svg new file mode 100644 index 000000000..cfebb0829 --- /dev/null +++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_backward_30s.imageset/voice_broadcast_backward_30s.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_forward_30s.imageset/Contents.json b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_forward_30s.imageset/Contents.json new file mode 100644 index 000000000..7a027074b --- /dev/null +++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_forward_30s.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "voice_broadcast_forward_30s.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_forward_30s.imageset/voice_broadcast_forward_30s.svg b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_forward_30s.imageset/voice_broadcast_forward_30s.svg new file mode 100644 index 000000000..fc63f83ae --- /dev/null +++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_forward_30s.imageset/voice_broadcast_forward_30s.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_slider_max_track.imageset/Contents.json b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_slider_max_track.imageset/Contents.json new file mode 100644 index 000000000..03c7aa158 --- /dev/null +++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_slider_max_track.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "voice_broadcast_slider_max_track.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_slider_max_track.imageset/voice_broadcast_slider_max_track.svg b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_slider_max_track.imageset/voice_broadcast_slider_max_track.svg new file mode 100644 index 000000000..1730c4783 --- /dev/null +++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_slider_max_track.imageset/voice_broadcast_slider_max_track.svg @@ -0,0 +1,3 @@ + + + diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_slider_min_track.imageset/Contents.json b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_slider_min_track.imageset/Contents.json new file mode 100644 index 000000000..42ea4f6e3 --- /dev/null +++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_slider_min_track.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "voice_broadcast_slider_min_track.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_slider_min_track.imageset/voice_broadcast_slider_min_track.svg b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_slider_min_track.imageset/voice_broadcast_slider_min_track.svg new file mode 100644 index 000000000..5cb3d3427 --- /dev/null +++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_slider_min_track.imageset/voice_broadcast_slider_min_track.svg @@ -0,0 +1,3 @@ + + + diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_slider_thumb.imageset/Contents.json b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_slider_thumb.imageset/Contents.json new file mode 100644 index 000000000..d50700f87 --- /dev/null +++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_slider_thumb.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "voice_broadcast_slider_thumb.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "voice_broadcast_slider_thumb@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "voice_broadcast_slider_thumb@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_slider_thumb.imageset/voice_broadcast_slider_thumb.png b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_slider_thumb.imageset/voice_broadcast_slider_thumb.png new file mode 100644 index 000000000..15a878c65 Binary files /dev/null and b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_slider_thumb.imageset/voice_broadcast_slider_thumb.png differ diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_slider_thumb.imageset/voice_broadcast_slider_thumb@2x.png b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_slider_thumb.imageset/voice_broadcast_slider_thumb@2x.png new file mode 100644 index 000000000..eb8ac9760 Binary files /dev/null and b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_slider_thumb.imageset/voice_broadcast_slider_thumb@2x.png differ diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_slider_thumb.imageset/voice_broadcast_slider_thumb@3x.png b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_slider_thumb.imageset/voice_broadcast_slider_thumb@3x.png new file mode 100644 index 000000000..204118c58 Binary files /dev/null and b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_slider_thumb.imageset/voice_broadcast_slider_thumb@3x.png differ diff --git a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_spinner.imageset/Contents.json b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_spinner.imageset/Contents.json index f00919cff..470106acf 100644 --- a/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_spinner.imageset/Contents.json +++ b/Riot/Assets/Images.xcassets/VoiceBroadcast/voice_broadcast_spinner.imageset/Contents.json @@ -2,16 +2,7 @@ "images" : [ { "filename" : "voice_broadcast_spinner.svg", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" + "idiom" : "universal" } ], "info" : { diff --git a/Riot/Assets/de.lproj/Localizable.strings b/Riot/Assets/de.lproj/Localizable.strings index 036670075..76df430d9 100644 --- a/Riot/Assets/de.lproj/Localizable.strings +++ b/Riot/Assets/de.lproj/Localizable.strings @@ -127,3 +127,5 @@ /* New file message from a specific person, not referencing a room. */ "LOCATION_FROM_USER" = "%@ hat den eigenen Standort geteilt"; +/* New voice broadcast from a specific person, not referencing a room. */ +"VOICE_BROADCAST_FROM_USER" = "%@ begann eine Sprachübertragung"; diff --git a/Riot/Assets/de.lproj/Vector.strings b/Riot/Assets/de.lproj/Vector.strings index 0fae134b5..c493e0217 100644 --- a/Riot/Assets/de.lproj/Vector.strings +++ b/Riot/Assets/de.lproj/Vector.strings @@ -438,7 +438,7 @@ // Widget Integration Manager "widget_integration_need_to_be_able_to_invite" = "Du musst Benutzer einladen dürfen, um dies zu tun."; "widget_integration_unable_to_create" = "Erstellen des Widgets nicht möglich."; -"widget_integration_failed_to_send_request" = "Senden der Anfrage fehlgeschlagen."; +"widget_integration_failed_to_send_request" = "Übertragung der Anfrage fehlgeschlagen."; "widget_integration_room_not_recognised" = "Dieser Raum wurde nicht erkannt."; "widget_integration_positive_power_level" = "Berechtigungslevel muss eine positive Zahl sein."; "widget_integration_must_be_in_room" = "Du bist nicht in diesem Raum."; @@ -922,7 +922,7 @@ "manage_session_trusted" = "Von dir vertraut"; "manage_session_not_trusted" = "Nicht vertraut"; "manage_session_sign_out" = "Von dieser Sitzung abmelden"; -"widget_picker_manage_integrations" = "Integrationen verwalten…"; +"widget_picker_manage_integrations" = "Integrationen verwalten …"; // Room widget permissions "room_widget_permission_title" = "Widget laden"; "room_widget_permission_creator_info_title" = "Dieses Widget wurde hinzugefügt von:"; @@ -1506,12 +1506,12 @@ "poll_edit_form_add_option" = "Option hinzufügen"; "poll_edit_form_option_number" = "Option %lu"; "poll_edit_form_question_or_topic" = "Frage oder Thematik"; -"room_event_action_end_poll" = "Abstimmung beenden"; -"room_event_action_remove_poll" = "Abstimmung entfernen"; +"room_event_action_end_poll" = "Umfrage beenden"; +"room_event_action_remove_poll" = "Umfrage entfernen"; // Mark: - Polls -"poll_edit_form_create_poll" = "Abstimmung erstellen"; +"poll_edit_form_create_poll" = "Umfrage erstellen"; "settings_labs_enabled_polls" = "Umfragen"; "share_extension_send_now" = "Jetzt senden"; "accessibility_button_label" = "Knopf"; @@ -1538,7 +1538,7 @@ "poll_edit_form_poll_question_or_topic" = "Frage oder Thema der Umfrage"; "poll_edit_form_input_placeholder" = "Schreib etwas"; "analytics_prompt_terms_link_upgrade" = "hier"; -"poll_timeline_not_closed_title" = "Beenden der Abstimmung fehlgeschlagen"; +"poll_timeline_not_closed_title" = "Beenden der Umfrage fehlgeschlagen"; "poll_timeline_vote_not_registered_subtitle" = "Wir konnten deine Stimme leider nicht erfassen. Versuche es bitte erneut"; "poll_timeline_total_final_results" = "Es wurden %lu Stimmen abgegeben"; "poll_timeline_total_final_results_one_vote" = "Es wurde 1 Stimme abgegeben"; @@ -1547,7 +1547,7 @@ "poll_timeline_not_closed_subtitle" = "Versuche es bitte erneut"; "poll_timeline_vote_not_registered_title" = "Stimme nicht erfasst"; "poll_edit_form_post_failure_subtitle" = "Versuche es bitte erneut"; -"poll_edit_form_post_failure_title" = "Absenden der Abstimmung fehlgeschlagen"; +"poll_edit_form_post_failure_title" = "Absenden der Umfrage fehlgeschlagen"; "share_extension_low_quality_video_message" = "Für eine bessere Qualität sende es in %@ oder sende es in niedriger Qualität."; "share_extension_low_quality_video_title" = "Das Video wird in niedriger Qualität gesendet werden"; "analytics_prompt_stop" = "Teilen beenden"; @@ -1590,9 +1590,9 @@ "poll_edit_form_update_failure_subtitle" = "Bitte erneut versuchen"; "poll_edit_form_poll_type" = "Abstimmungsart"; "poll_edit_form_poll_type_closed_description" = "Die Ergebnisse werden erst sichtbar, sobald du die Umfrage beendest"; -"poll_edit_form_poll_type_closed" = "Abgeschlossene Abstimmung"; +"poll_edit_form_poll_type_closed" = "Versteckte Umfrage"; "poll_edit_form_poll_type_open_description" = "Abstimmende können die Ergebnisse nach Stimmabgabe sehen"; -"poll_edit_form_poll_type_open" = "Laufende Abstimmung"; +"poll_edit_form_poll_type_open" = "Offene Umfrage"; "poll_edit_form_update_failure_title" = "Aktualisierung der Umfrage fehlgeschlagen"; "threads_empty_tip" = "Hinweis: Tippe auf eine Nachricht und wähle „Thread“ um einen neuen zu starten."; "threads_empty_info_my" = "Antworte auf einen laufenden Thread oder tippe auf eine Nachricht und wähle „Thread“ um einen neuen zu starten."; @@ -2571,7 +2571,6 @@ "user_inactive_session_item_with_date" = "Inaktiv seit 90+ Tagen (%@)"; "user_inactive_session_item" = "Inaktiv seit 90+ Tagen"; "user_other_session_unverified_sessions_header_subtitle" = "Für besonders sichere Kommunikation verifiziere deine Sitzungen oder melde dich von ihnen ab, falls du sie nicht mehr identifizieren kannst."; -"user_other_session_security_recommendation_title" = "Sicherheitsempfehlung"; "user_sessions_overview_link_device" = "Verbinde ein Gerät"; // MARK: User sessions management @@ -2646,7 +2645,7 @@ // Mark: - Voice broadcast "voice_broadcast_unauthorized_title" = "Sprachübertragung kann nicht gestartet werden"; -"settings_labs_enable_voice_broadcast" = "Sprachübertragung (in aktiver Entwicklung)"; +"settings_labs_enable_voice_broadcast" = "Sprachübertragung"; "voice_broadcast_playback_loading_error" = "Wiedergabe der Sprachübertragung nicht möglich."; "deselect_all" = "Alle abwählen"; "user_other_session_menu_select_sessions" = "Sitzungen auswählen"; @@ -2670,3 +2669,36 @@ // Unverified sessions "key_verification_alert_title" = "Du hast nicht verifizierte Sitzungen"; +"launch_loading_processing_response" = "Verarbeite Daten\n%@ %%"; +"launch_loading_server_syncing_nth_attempt" = "Synchronisiere mit dem Server\n(%@ Versuch)"; + +// MARK: - Launch loading + +"launch_loading_server_syncing" = "Synchronisiere mit dem Server"; +"user_other_session_permanently_unverified_additional_info" = "Diese Sitzung unterstützt keine Verschlüsselung und kann deshalb nicht verifiziert werden."; +"voice_broadcast_time_left" = "%@ übrig"; +"voice_broadcast_buffering" = "Puffere …"; +"voice_broadcast_stop_alert_agree_button" = "Ja, beende"; +"voice_broadcast_stop_alert_description" = "Möchtest du die Übertragung wirklich beenden? Dies wird die Übertragung beenden und die vollständige Aufnahme im Raum bereitstellen."; +"voice_broadcast_stop_alert_title" = "Live-Übertragung beenden?"; +"password_policy_weak_pwd_error" = "Dieses Passwort ist zu schwach. Es muss mindestens 8 Zeichen enthalten, davon mindestens ein Zeichen jeder Art: Großbuchstabe, Kleinbuchstabe, Ziffer und Sonderzeichen."; +"password_policy_pwd_in_dict_error" = "Dieses Passwort wurde in einem Wörterbuch gefunden und nicht erlaubt."; + +// MARK: Password policy errors +"password_policy_too_short_pwd_error" = "Zu kurzes Passwort"; +"user_session_permanently_unverified_session_description" = "Diese Sitzung unterstützt keine Verschlüsselung, weshalb sie nicht verifiziert werden kann.\n\nDu wirst dich mit dieser Sitzung nicht an Unterhaltungen in Räumen mit aktivierter Verschlüsselung beteiligen können.\n\nAus Sicherheits- und Datenschutzgründen, wird die Nutzung von verschlüsselungsfähigen Matrix-Anwendungen empfohlen."; + +// Links +"wysiwyg_composer_link_action_text" = "Text"; +"wysiwyg_composer_format_action_link" = "Als Link formatieren"; +"wysiwyg_composer_link_action_edit_title" = "Link bearbeiten"; +"wysiwyg_composer_link_action_create_title" = "Link erstellen"; +"wysiwyg_composer_link_action_link" = "Link"; +"wysiwyg_composer_format_action_inline_code" = "Als Inline-Code formatieren"; +"notice_voice_broadcast_ended_by_you" = "Du hast eine Sprachübertragung beendet."; +"notice_voice_broadcast_ended" = "%@ beendete eine Sprachübertragung."; +"notice_voice_broadcast_live" = "Echtzeitübertragung"; +"user_other_session_security_recommendation_title" = "Andere Sitzungen"; +"voice_message_broadcast_in_progress_title" = "Kann Sprachnachricht nicht beginnen"; +"poll_timeline_decryption_error" = "Aufgrund von Entschlüsselungsfehlern könnten einige Stimmen nicht gezählt werden"; +"voice_message_broadcast_in_progress_message" = "Du kannst kein Gespräch beginnen, da du im Moment eine Sprachübertragung aufzeichnest. Bitte beende deine Sprachübertragung, um ein Gespräch zu beginnen"; diff --git a/Riot/Assets/en.lproj/Localizable.strings b/Riot/Assets/en.lproj/Localizable.strings index 13c5758d6..e343edeb6 100644 --- a/Riot/Assets/en.lproj/Localizable.strings +++ b/Riot/Assets/en.lproj/Localizable.strings @@ -83,6 +83,9 @@ /* Sticker from a specific person, not referencing a room. */ "STICKER_FROM_USER" = "%@ sent a sticker"; +/* New voice broadcast from a specific person, not referencing a room. */ +"VOICE_BROADCAST_FROM_USER" = "%@ started a voice broadcast"; + /** Notification messages **/ /* New message indicator on unknown room */ diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 8ae662459..04d0cd693 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -802,7 +802,7 @@ Tap the + to start adding people."; "settings_labs_enable_new_client_info_feature" = "Record the client name, version, and url to recognise sessions more easily in session manager"; "settings_labs_enable_new_app_layout" = "New Application Layout"; "settings_labs_enable_wysiwyg_composer" = "Try out the rich text editor"; -"settings_labs_enable_voice_broadcast" = "Voice broadcast (under active development)"; +"settings_labs_enable_voice_broadcast" = "Voice broadcast"; "settings_version" = "Version %@"; "settings_olm_version" = "Olm Version %@"; @@ -2197,6 +2197,8 @@ Tap the + to start adding people."; "voice_message_remaining_recording_time" = "%@s left"; "voice_message_stop_locked_mode_recording" = "Tap on your recording to stop or listen"; "voice_message_lock_screen_placeholder" = "Voice message"; +"voice_message_broadcast_in_progress_title" = "Can't start voice message"; +"voice_message_broadcast_in_progress_message" = "You can't start a voice message as you are currently recording a live broadcast. Please end your live broadcast in order to start recording a voice message"; // Mark: - Voice broadcast "voice_broadcast_unauthorized_title" = "Can't start a new voice broadcast"; @@ -2347,6 +2349,10 @@ Tap the + to start adding people."; "poll_timeline_not_closed_subtitle" = "Please try again"; +"poll_timeline_decryption_error" = "Due to decryption errors, some votes may not be counted"; + +"poll_timeline_ended_text" = "Ended the poll"; + // MARK: - Location sharing "location_sharing_title" = "Location"; @@ -2474,7 +2480,7 @@ To enable access, tap Settings> Location and select Always"; "user_session_rename_session_title" = "Renaming sessions"; "user_session_rename_session_description" = "Other users in direct messages and rooms that you join are able to view a full list of your sessions.\n\nThis provides them with confidence that they are really speaking to you, but it also means they can see the session name you enter here."; -"user_other_session_security_recommendation_title" = "Security recommendation"; +"user_other_session_security_recommendation_title" = "Other sessions"; "user_other_session_unverified_sessions_header_subtitle" = "Verify your sessions for enhanced secure messaging or sign out from those you don’t recognize or use anymore."; "user_other_session_current_session_details" = "Your current session"; "user_other_session_verified_sessions_header_subtitle" = "For best security, sign out from any session that you don’t recognize or use anymore."; @@ -2549,6 +2555,14 @@ To enable access, tap Settings> Location and select Always"; "wysiwyg_composer_format_action_italic" = "Apply italic format"; "wysiwyg_composer_format_action_underline" = "Apply strikethrough format"; "wysiwyg_composer_format_action_strikethrough" = "Apply underline format"; +"wysiwyg_composer_format_action_link" = "Apply link format"; +"wysiwyg_composer_format_action_inline_code" = "Apply inline code format"; + +// Links +"wysiwyg_composer_link_action_text" = "Text"; +"wysiwyg_composer_link_action_link" = "Link"; +"wysiwyg_composer_link_action_create_title" = "Create a link"; +"wysiwyg_composer_link_action_edit_title" = "Edit link"; // MARK: - MatrixKit @@ -2690,6 +2704,9 @@ To enable access, tap Settings> Location and select Always"; "notice_crypto_error_unknown_inbound_session_id" = "The sender's session has not sent us the keys for this message."; "notice_sticker" = "sticker"; "notice_in_reply_to" = "In reply to"; +"notice_voice_broadcast_live" = "Live broadcast"; +"notice_voice_broadcast_ended" = "%@ ended a voice broadcast."; +"notice_voice_broadcast_ended_by_you" = "You ended a voice broadcast."; // room display name "room_displayname_empty_room" = "Empty room"; diff --git a/Riot/Assets/eo.lproj/InfoPlist.strings b/Riot/Assets/eo.lproj/InfoPlist.strings index f6470d19a..982dfdbf3 100644 --- a/Riot/Assets/eo.lproj/InfoPlist.strings +++ b/Riot/Assets/eo.lproj/InfoPlist.strings @@ -4,6 +4,6 @@ "NSCalendarsUsageDescription" = "Vidu viajn planitajn renkontiĝojn en la aplikaĵo."; "NSContactsUsageDescription" = "Por trovi kontaktojn, kiuj jam estas ĉe Matrix, Element povas sendi retpoŝtadresojn kaj telefonnumerojn el via adresaro al via elektita identigila servilo de Matrix. Kiam eblas, personaj datumoj estas haketitaj antaŭ forsendo – bonvolu kontroli la politikon pri privateco de via identiga servilo por pliaj detaloj."; "NSMicrophoneUsageDescription" = "La mikrofono estas uzata por filmi, kaj ankaŭ por voki."; -"NSPhotoLibraryUsageDescription" = "La fotujo estas uzata por sendi fotojn kaj filmojn."; +"NSPhotoLibraryUsageDescription" = "Permesu aliron al fotoj por alŝuti fotojn kaj filmetojn el via biblioteko."; // Permissions usage explanations -"NSCameraUsageDescription" = "La filmilo estas uzata por foti kaj filmi, kaj ankaŭ por vidvoki."; +"NSCameraUsageDescription" = "La filmilo estas uzata por fari vidvokojn, aŭ preni kaj alŝuti fotojn kaj filmetojn."; diff --git a/Riot/Assets/eo.lproj/Vector.strings b/Riot/Assets/eo.lproj/Vector.strings index b0ae5a061..2e91114ed 100644 --- a/Riot/Assets/eo.lproj/Vector.strings +++ b/Riot/Assets/eo.lproj/Vector.strings @@ -589,7 +589,7 @@ "create_room_section_footer_encryption" = "Ne eblas malŝalti ĉifradon poste."; "create_room_enable_encryption" = "Ŝalti ĉifradon"; "create_room_section_header_encryption" = "Ĉifrado de ĉambro"; -"create_room_placeholder_topic" = "Temo"; +"create_room_placeholder_topic" = "Pri kio temas ĉi tiu ĉambro?"; "create_room_section_header_topic" = "Temo de ĉambro (nedeviga)"; "create_room_placeholder_name" = "Nomo"; "create_room_section_header_name" = "Nomo de ĉambro"; @@ -2032,3 +2032,4 @@ "login_error_resource_limit_exceeded_message_default" = "Ĉi tiu hejmservilo atingis unu el siaj rimedaj limoj."; "login_error_resource_limit_exceeded_title" = "Rimeda limo estas atingita"; "login_error_forgot_password_is_not_supported" = "Forgesado de pasvorto nun ne estas subtenata"; +"identity_server_settings_alert_change" = "Ĉu malkonektu de identiga servilo %1$@ kaj anstataŭe konektu al %2$@?"; diff --git a/Riot/Assets/et.lproj/Vector.strings b/Riot/Assets/et.lproj/Vector.strings index fd122e847..d84b26c76 100644 --- a/Riot/Assets/et.lproj/Vector.strings +++ b/Riot/Assets/et.lproj/Vector.strings @@ -2545,7 +2545,6 @@ "user_other_session_verified_sessions_header_subtitle" = "Parima turvalisuse nimel logi välja neist sessioonidest, mida sa enam ei kasuta või ei tunne ära."; "user_other_session_current_session_details" = "Sinu praegune sessioon"; "user_other_session_unverified_sessions_header_subtitle" = "Turvalise sõnumvahetuse nimel verifitseeri kõik oma sessioonid ning logi neist välja, mida sa enam ei kasuta või ei tunne enam ära."; -"user_other_session_security_recommendation_title" = "Turvalisusega seotud soovitused"; "user_other_session_verified_additional_info" = "See sessioon on valmis turvaliseks sõnumivahetuseks."; "user_other_session_unverified_additional_info" = "Parima turvalisuse ja töökindluse nimel verifitseeri see sessioon või logi ta võrgust välja."; "user_session_verification_unknown_additional_info" = "Selle sessiooni olekut ei saa tuvastada enne kui oled ta verifitseerinud."; @@ -2574,7 +2573,7 @@ /* The placeholder will be replaces with manage_session_name_info_link */ "manage_session_name_info" = "Palun arvesta, et sessioonide nimed on näha ka kõikidele osapooltele, kellega sa suhtled. %@"; "manage_session_name_hint" = "Sinu enda kirjutatud sessiooninimede alusel on sul oma seadmeid lihtsam ära tunda."; -"settings_labs_enable_voice_broadcast" = "Ringhäälingukõne (aktiivses arenduses)"; +"settings_labs_enable_voice_broadcast" = "Ringhäälingukõne"; "settings_labs_enable_wysiwyg_composer" = "Proovi vormindatud teksti alusel töötavat tekstitoimetit"; "authentication_qr_login_failure_retry" = "Proovi uuesti"; "authentication_qr_login_failure_request_timed_out" = "Sidumine ei lõppenud etteantud aja jooksul."; @@ -2608,3 +2607,36 @@ // Unverified sessions "key_verification_alert_title" = "Sul on verifitseerimata sessioone"; +"user_other_session_permanently_unverified_additional_info" = "Seda sessiooni ei saa verifitseerida, sest seal puudub krüptimise tugi."; +"voice_broadcast_time_left" = "aega jäänud %@"; +"launch_loading_processing_response" = "Töötleme andmeid\n%@ %%"; +"launch_loading_server_syncing_nth_attempt" = "Sünkroniseerime andmeid serveriga\n(katse: %@)"; + +// MARK: - Launch loading + +"launch_loading_server_syncing" = "Sünkroniseerimine serveriga"; +"voice_broadcast_buffering" = "Andmed on puhverdamisel…"; +"voice_broadcast_stop_alert_agree_button" = "Jah, lõpetame"; +"voice_broadcast_stop_alert_description" = "Kas sa oled kindel, et soovid otseeetri lõpetada? Sellega ringhäälingukõne salvestamine lõppeb ja salvestis on kättesaadav kõigile jututoas."; +"voice_broadcast_stop_alert_title" = "Kas lõpetame otseeetri?"; +"password_policy_pwd_in_dict_error" = "See salasõna leidub levinud salasõnade sõnastikus ning seda sa ei saa kasutada."; +"password_policy_weak_pwd_error" = "See salasõna on liiga lihtne. Ta peaks olema vähemalt 8 tähemärki pikk ning seal peaks leiduma vähemalt üks väiketäht, suurtäht, number ja erimärk."; + +// MARK: Password policy errors +"password_policy_too_short_pwd_error" = "Liiga lühike salasõna"; +"user_session_permanently_unverified_session_description" = "Seda sessiooni ei saa verifitseerida, sest seal puudub krüptimise tugi.\n\nSelle sessiooniga ei saa sa osaleda krüptitud jututubades.\n\nParima turvalisuse ja privaatsuse nimel palun kasuta selliseid Matrix'i kliente, mis toetavad krüptimist."; +"wysiwyg_composer_format_action_link" = "Muuda lingi vormingut"; + +// Links +"wysiwyg_composer_link_action_text" = "Tekst"; +"wysiwyg_composer_link_action_link" = "Link"; +"wysiwyg_composer_link_action_edit_title" = "Muuda linki"; +"wysiwyg_composer_link_action_create_title" = "Loo link"; +"wysiwyg_composer_format_action_inline_code" = "Kasuta lõimitud koodi vormingut"; +"notice_voice_broadcast_ended_by_you" = "Sa lõpetasid ringhäälingukõne."; +"notice_voice_broadcast_ended" = "%@ lõpetas ringhäälingukõne."; +"notice_voice_broadcast_live" = "Ringhäälingukõne on eetris"; +"user_other_session_security_recommendation_title" = "Muud sessioonid"; +"poll_timeline_decryption_error" = "Krüptimisvigade tõttu jääb osa hääli lugemata"; +"voice_message_broadcast_in_progress_title" = "Häälsõnumi salvestamine või esitamine ei õnnestu"; +"voice_message_broadcast_in_progress_message" = "Kuna sa hetkel salvestad ringhäälingukõnet, siis häälsõnumi salvestamine või esitamine ei õnnestu. Selleks palun lõpeta ringhäälingukõne"; diff --git a/Riot/Assets/fr.lproj/Vector.strings b/Riot/Assets/fr.lproj/Vector.strings index 4f371613b..ed4a3820f 100644 --- a/Riot/Assets/fr.lproj/Vector.strings +++ b/Riot/Assets/fr.lproj/Vector.strings @@ -2542,7 +2542,6 @@ // First item is client name and second item is session display name "user_session_name" = "%@ : %@"; "user_other_session_unverified_sessions_header_subtitle" = "Vérifiez vos sessions pour renforcer la sécurité de votre messagerie, ou déconnectez celles que vous ne reconnaissez ou utilisez plus."; -"user_other_session_security_recommendation_title" = "Recommandations de sécurité"; "user_session_push_notifications_message" = "Lorsqu’activé, cette session recevra des notifications push."; "user_session_push_notifications" = "Notifications push"; "user_sessions_overview_current_session_section_title" = "Session courante"; diff --git a/Riot/Assets/hu.lproj/Vector.strings b/Riot/Assets/hu.lproj/Vector.strings index 78e52a6fd..53debbedb 100644 --- a/Riot/Assets/hu.lproj/Vector.strings +++ b/Riot/Assets/hu.lproj/Vector.strings @@ -2493,7 +2493,7 @@ // First item is client name and second item is session display name "user_session_name" = "%@: %@"; -"user_session_unverified_additional_info" = "Az aktuális munkamenet készen áll a biztonságos üzenetküldésre."; +"user_session_unverified_additional_info" = "Ellenőrizd az aktuális munkamenetet a biztonságos üzenetküldéshez."; "user_session_verified_additional_info" = "Az aktuális munkamenet készen áll a biztonságos üzenetküldésre."; "user_session_learn_more" = "Tudj meg többet"; "user_session_view_details" = "Részletek megtekintése"; @@ -2565,7 +2565,6 @@ "user_other_session_verified_sessions_header_subtitle" = "A legjobb biztonság érdekében jelentkezz ki minden olyan munkamenetből, melyet már nem ismersz fel vagy nem használsz."; "user_other_session_current_session_details" = "Jelenlegi munkamenet"; "user_other_session_unverified_sessions_header_subtitle" = "Erősítse meg a munkameneteit a még biztonságosabb csevegéshez vagy jelentkezzen ki ezekből, ha nem ismeri fel vagy már nem használja őket."; -"user_other_session_security_recommendation_title" = "Biztonsági javaslat"; "user_session_push_notifications_message" = "Ha be van kapcsolva az eszközre Push értesítések lesznek küldve."; "user_session_push_notifications" = "Push értesítések"; "user_other_session_verified_additional_info" = "Ez a munkamenet beállítva a biztonságos üzenetküldéshez."; @@ -2625,7 +2624,7 @@ "authentication_qr_login_start_subtitle" = "Használd a kamerát ezen az eszközön a másik eszközödön megjelenő QR kód beolvasására:"; "authentication_qr_login_start_title" = "QR kód beolvasása"; "authentication_login_with_qr" = "Belépés QR kóddal"; -"settings_labs_enable_voice_broadcast" = "Hang közvetítés (aktív fejlesztés alatt)"; +"settings_labs_enable_voice_broadcast" = "Hang közvetítés"; "wysiwyg_composer_start_action_voice_broadcast" = "Hang közvetítés"; "voice_broadcast_playback_loading_error" = "A hang közvetítés nem játszható le."; "voice_broadcast_already_in_progress_message" = "Egy hang közvetítés már folyamatban van. Először fejezd be a jelenlegi közvetítést egy új indításához."; @@ -2656,3 +2655,36 @@ "voice_broadcast_tile" = "Hang közvetítés"; "voice_broadcast_live" = "Élő"; "key_verification_alert_body" = "Tekintsd át, hogy meggyőződj arról, hogy a fiókod biztonságban van."; +"user_session_permanently_unverified_session_description" = "Ez a munkamenet nem támogatja a titkosítást, így nem lehet ellenőrizni sem.\n\nEzzel a munkamenettel nem tudsz részt venni olyan szobákban ahol a titkosítás be van kapcsolva.\n\nA biztonság és a adatbiztonsági okokból javasolt olyan Matrix kliens használata ami támogatja a titkosítást."; +"user_other_session_permanently_unverified_additional_info" = "Ez a munkamenet nem támogatja a titkosítást, így nem lehet ellenőrizni sem."; +"voice_broadcast_stop_alert_agree_button" = "Igen, befejez"; +"voice_broadcast_stop_alert_description" = "Biztos, hogy befejezed az élő közvetítést? Ez befejezi a közvetítést és a felvétel az egész szoba számára elérhető lesz."; +"voice_broadcast_stop_alert_title" = "Megszakítod az élő közvetítést?"; +"voice_broadcast_buffering" = "Pufferelés…"; +"voice_broadcast_time_left" = "%@ van vissza"; +"launch_loading_processing_response" = "Adat feldolgozása\n%@ %%"; +"launch_loading_server_syncing_nth_attempt" = "Szinkronizálás a szerverrel\n(%@ próbálkozás)"; + +// MARK: - Launch loading + +"launch_loading_server_syncing" = "Szinkronizálás a szerverrel"; +"password_policy_pwd_in_dict_error" = "Ez a jelszó megtalálható a szótárban ezért nem engedélyezett."; +"password_policy_weak_pwd_error" = "Ez a jelszó túl gyenge. Legalább 8 karakternek kell lennie és minden típusból legalább egy: nagybetű, kisbetű, szám és speciális karakter."; + +// MARK: Password policy errors +"password_policy_too_short_pwd_error" = "A jelszó túl rövid"; +"wysiwyg_composer_link_action_edit_title" = "Hivatkozás szerkesztése"; +"wysiwyg_composer_link_action_create_title" = "Hivatkozás készítése"; +"wysiwyg_composer_link_action_link" = "Hivatkozás"; + +// Links +"wysiwyg_composer_link_action_text" = "Szöveg"; +"wysiwyg_composer_format_action_link" = "Hivatkozás"; +"wysiwyg_composer_format_action_inline_code" = "Beágyazott kód formátum alkalmazása"; +"notice_voice_broadcast_ended_by_you" = "A hang közvetítést befejezted."; +"notice_voice_broadcast_ended" = "%@ befejezte a hang közvetítést."; +"notice_voice_broadcast_live" = "Élő közvetítés"; +"user_other_session_security_recommendation_title" = "További munkamenetek"; +"poll_timeline_decryption_error" = "Visszafejtési hibák miatt néhány szavazat nem kerül beszámításra"; +"voice_message_broadcast_in_progress_message" = "Nem lehet hang üzenetet indítani élő közvetítés felvétele közben. Az élő közvetítés bejezése szükséges a hang üzenet indításához"; +"voice_message_broadcast_in_progress_title" = "Hang üzenetet nem lehet elindítani"; diff --git a/Riot/Assets/id.lproj/Localizable.strings b/Riot/Assets/id.lproj/Localizable.strings index 4b2a6be15..da5e860c5 100644 --- a/Riot/Assets/id.lproj/Localizable.strings +++ b/Riot/Assets/id.lproj/Localizable.strings @@ -172,3 +172,6 @@ /* Multiple unread messages from two plus people (ie. for 4+ people: 'others' replaces the third person) */ "MSGS_FROM_TWO_PLUS_USERS" = "%@ pesan baru dari %@, %@ dan lainnya"; + +/* New voice broadcast from a specific person, not referencing a room. */ +"VOICE_BROADCAST_FROM_USER" = "%@ memulai sebuah siaran suara"; diff --git a/Riot/Assets/id.lproj/Vector.strings b/Riot/Assets/id.lproj/Vector.strings index 8894d91b4..d6449680c 100644 --- a/Riot/Assets/id.lproj/Vector.strings +++ b/Riot/Assets/id.lproj/Vector.strings @@ -690,7 +690,7 @@ "identity_server_settings_description" = "Anda saat ini menggunakan %@ untuk menemukan dan dapat ditemukan oleh kontak yang Anda tahu."; // AuthenticatedSessionViewControllerFactory -"authenticated_session_flow_not_supported" = "Aplikasi ini tidak mendukung mekanisme otentikasi di homeserver Anda."; +"authenticated_session_flow_not_supported" = "Aplikasi ini tidak mendukung mekanisme autentikasi di homeserver Anda."; "security_settings_coming_soon" = "Maaf. Aksi ini belum tersedia di %@ iOS. Mohon gunakan klien Matrix yang lain untuk menyiapkannya. %@ iOS akan menggunakannya."; "security_settings_blacklist_unverified_devices_description" = "Verifikasi semua sesi pengguna untuk menandainya sebagai terpercaya dan kirim pesan ke mereka."; "security_settings_crosssigning_info_exists" = "Akun Anda memiliki identitas penandatanganan silang, tetapi belum dipercayai oleh sesi ini. Selesaikan keamanan sesi ini."; @@ -765,7 +765,7 @@ "room_intro_cell_information_dm_sentence1_part1" = "Ini adalah awal dari pesan langsung Anda dengan "; "room_intro_cell_information_room_without_topic_sentence2_part2" = " supaya orang tahu tentang ruangan ini."; "invite_friends_share_text" = "Hai, bicara kepada saya di %@: %@"; -"biometrics_usage_reason" = "Otentikasi diperlukan untuk mengakses aplikasi ini"; +"biometrics_usage_reason" = "Autentikasi diperlukan untuk mengakses aplikasi ini"; "pin_protection_kick_user_alert_message" = "Terlalu banyak kesalahan, Anda telah dikeluarkan"; "secrets_setup_recovery_passphrase_confirm_information" = "Masukkan Frasa Keamanan Anda untuk mengkonfirmasi."; "secrets_recovery_with_key_information_verify_device" = "Gunakan Kunci Keamanan Anda untuk memverifikasi perangkat ini."; @@ -792,7 +792,7 @@ "bug_crash_report_description" = "Jelaskan apa yang Anda lakukan sebelum crashnya:"; "directory_server_type_homeserver" = "Ketik sebuah homeserver untuk menampilkan daftar ruangan publik"; "room_details_access_section_anyone_apart_from_guest" = "Siapa saja yang tahu tautan ruangan, selain dari tamu"; -"security_settings_complete_security_alert_message" = "Anda seharusnya menyelesaikan keamanan di sesi Anda saat ini dulu."; +"security_settings_complete_security_alert_message" = "Anda seharusnya menyelesaikan keamanan di sesi Anda saat ini dahulu."; "key_verification_scan_confirmation_scanned_device_information" = "Apakah perangkat yang lain menampilkan perisai yang sama?"; "key_verification_verify_qr_code_information" = "Pindai kodenya untuk memverifikasi dengan sesama dengan aman."; "error_not_supported_on_mobile" = "Anda tidak melakukannya dari %@ mobile."; @@ -848,7 +848,7 @@ "key_verification_scan_confirmation_scanned_user_information" = "Apakah %@ menampilkan perisai yang sama?"; "key_verification_verify_qr_code_scan_other_code_success_message" = "Kode QR berhasil divalidasi."; "key_verification_verify_qr_code_information_other_device" = "Pindai kode di bawah untuk memverifikasi:"; -"key_verification_bootstrap_not_setup_message" = "Anda harus mem-bootstrap penandatanganan-silang dulu."; +"key_verification_bootstrap_not_setup_message" = "Anda harus mem-bootstrap penandatanganan silang dahulu."; "device_verification_self_verify_wait_recover_secrets_checking_availability" = "Memeriksa untuk kemampuan verifikasi lain ..."; "key_verification_self_verify_current_session_alert_message" = "Pengguna yang lain mungkin tidak mempercayainya."; "device_verification_cancelled" = "Pengguna yang lain membatalkan verifikasinya."; @@ -1549,7 +1549,7 @@ "settings_discovery_three_pids_management_information_part1" = "Kelola alamat email atau nomor telepon apa saja yang pengguna lain dapat menggunakan untuk menemukan Anda dan menggunakannya untuk mengundang Anda ke ruangan. Tambahkan atau hapus alamat email atau nomor telepon dari daftar ini di "; "room_preview_unlinked_email_warning" = "Undangan ini telah dikirim ke %@, yang tidak diasosiasikan dengan akun ini. Anda mungkin ingin masuk ke akun yang lain, atau tambahkan email ini ke akun Anda."; "unknown_devices_alert" = "Ruangan ini berisi sesi tidak dikenal yang belum diverifikasi.\nIni berarti tidak ada jaminan bahwa sesi tersebut adalah milik pengguna yang mereka klaim.\nKami menyarankan Anda memverifikasinya untuk setiap sesi sebelum melanjutkan, tetapi Anda dapat mengirim ulang pesan tanpa memverifikasi jika Anda ingin."; -"room_warning_about_encryption" = "Enkripsi ujung ke ujung masih dalam beta dan mungkin tidak dapat diandalkan.\n\nAnda seharusnya tidak mempercayainya dulu untuk mengamankan data.\n\nPerangkat masih belum dapat mendekripsi riwayat sebelum mereka bergabung ke ruangannya.\n\nPesan terenkripsi masih belum terlihat di klien yang belum mengimplementasikan enkripsi."; +"room_warning_about_encryption" = "Enkripsi ujung ke ujung masih dalam beta dan mungkin tidak dapat diandalkan.\n\nAnda seharusnya tidak mempercayainya dahulu untuk mengamankan data.\n\nPerangkat masih belum dapat mendekripsi riwayat sebelum mereka bergabung ke ruangannya.\n\nPesan terenkripsi masih belum terlihat di klien yang belum mengimplementasikan enkripsi."; "auth_add_email_and_phone_warning" = "Pendaftaran dengan email dan nomor telepon sekaligus belum didukung sampai API-nya sudah ada. Hanya nomor telepon yang akan diperhitungkan. Anda dapat menambahkan email Anda di profil Anda di pengaturan."; "auth_reset_password_success_message" = "Kata sandi akun Matrix Anda telah diatur ulang.\n\nAnda telah dikeluarkan dari semua sesi dan tidak akan menerima lagi notifikasi push. Untuk mengaktifkan ulang notifikasi, masuk ulang di setiap perangkat."; "spaces_add_rooms_coming_soon_title" = "Penambahan ruangan akan segera datang"; @@ -1754,7 +1754,7 @@ "home_context_menu_make_room" = "Pindah ke Ruangan"; "home_context_menu_make_dm" = "Pindah ke Orang"; "event_formatter_message_deleted" = "Pesan dihapus"; -"settings_labs_enable_threads" = "Perpesanan utasan"; +"settings_labs_enable_threads" = "Utasan pesan"; "message_from_a_thread" = "Dari sebuah utasan"; "threads_empty_show_all_threads" = "Tampilkan semua utasan"; "threads_empty_tip" = "Tip: Ketuk pada sebuah pesan dan gunakan “Utasan” untuk memulai yang baru."; @@ -1873,7 +1873,7 @@ "login_error_forbidden" = "Nama pengguna/kata sandi tidak absah"; "login_error_registration_is_not_supported" = "Pendaftaran saat ini tidak didukung"; "login_error_do_not_support_login_flows" = "Saat ini kami tidak mendukung salah satu atau semua alur masuk yang ditentukan oleh homeserver ini"; -"login_error_no_login_flow" = "Kami gagal untuk menerima informasi otentikasi dari homeserver ini"; +"login_error_no_login_flow" = "Kami gagal untuk menerima informasi autentikasi dari homeserver ini"; "login_error_title" = "Login Gagal"; "login_prompt_email_token" = "Harap masukkan token validasi email Anda:"; "login_email_placeholder" = "Alamat email"; @@ -2205,8 +2205,8 @@ "room_no_power_to_create_conference_call" = "Anda membutuhkan izin untuk mengundang untuk memulai konferensi di ruangan ini"; "room_left_for_dm" = "Anda keluar"; "room_left" = "Anda meninggalkan ruangan ini"; -"room_error_timeline_event_not_found" = "Aplikasi ini sedang mencoba untuk memuat titik tertenu di linimasa ruangan ini tetapi tidak dapat menemukannya"; -"room_error_timeline_event_not_found_title" = "Gagal untuk memuat posisi linimasa"; +"room_error_timeline_event_not_found" = "Aplikasi ini sedang mencoba untuk memuat titik tertentu di lini masa ruangan ini tetapi tidak dapat menemukannya"; +"room_error_timeline_event_not_found_title" = "Gagal memuat posisi lini masa"; "room_error_cannot_load_timeline" = "Gagal untuk memuat linimasa"; "room_error_topic_edition_not_authorized" = "Anda tidak diizinkan untuk mengubah topik ruangan ini"; "room_error_name_edition_not_authorized" = "Anda tidak diizinkan untuk mengubah nama ruangan ini"; @@ -2270,8 +2270,8 @@ // Encryption information "room_event_encryption_info_title" = "Informasi enkripsi ujung-ke-ujung\n\n"; -"device_details_delete_prompt_message" = "Operasi ini membutuhkan otentikasi tambahan.\nUntuk melanjutkan, silakan masukkan kata sandi Anda."; -"device_details_delete_prompt_title" = "Otentikasi"; +"device_details_delete_prompt_message" = "Operasi ini membutuhkan autentikasi tambahan.\nUntuk melanjutkan, silakan masukkan kata sandi Anda."; +"device_details_delete_prompt_title" = "Autentikasi"; "device_details_rename_prompt_message" = "Nama publik sesi dapat dilihat oleh orang yang berkomunikasi dengan Anda"; "device_details_rename_prompt_title" = "Nama Sesi"; "device_details_last_seen_format" = "%@ @ %@\n"; @@ -2752,7 +2752,6 @@ "user_inactive_session_item_with_date" = "Tidak aktif selama 90+ hari (%@)"; "user_inactive_session_item" = "Tidak aktif selama 90+ hari"; "user_other_session_unverified_sessions_header_subtitle" = "Verifikasi sesi Anda untuk perpesanan aman yang terbaik atau keluarkan sesi yang Anda tidak kenal atau gunakan lagi."; -"user_other_session_security_recommendation_title" = "Saran keamanan"; "user_sessions_overview_link_device" = "Tautkan sebuah perangkat"; // MARK: User sessions management @@ -2840,7 +2839,7 @@ // Mark: - Voice broadcast "voice_broadcast_unauthorized_title" = "Tidak dapat memulai sebuah siaran suara baru"; -"settings_labs_enable_voice_broadcast" = "Siaran suara (dalam pengembangan aktif)"; +"settings_labs_enable_voice_broadcast" = "Siaran suara"; "deselect_all" = "Batalkan Semua Pilihan"; "user_other_session_menu_select_sessions" = "Pilih sesi"; "user_other_session_selected_count" = "%@ dipilih"; @@ -2863,3 +2862,36 @@ // Unverified sessions "key_verification_alert_title" = "Anda punya sesi yang belum diverifikasi"; +"user_other_session_permanently_unverified_additional_info" = "Sesi ini tidak mendukung enkripsi jadi tidak dapat diverifikasi."; +"voice_broadcast_time_left" = "Tersisa %@"; +"launch_loading_processing_response" = "Memroses data\n%@ %%"; +"launch_loading_server_syncing_nth_attempt" = "Menyinkron dengan server\n(%@ percobaan)"; + +// MARK: - Launch loading + +"launch_loading_server_syncing" = "Menyinkron dengan server"; +"voice_broadcast_buffering" = "Memuat…"; +"voice_broadcast_stop_alert_agree_button" = "Ya, batalkan"; +"voice_broadcast_stop_alert_description" = "Apakah Anda ingin menghentikan siaran langsung Anda? Ini akan mengakhiri siarannya, dan rekamanan lengkap akan tersedia dalam ruangan."; +"voice_broadcast_stop_alert_title" = "Berhenti menyiarkan langsung?"; +"password_policy_pwd_in_dict_error" = "Kata sandi ini telah ditemukan dalam sebuah kamus dan tidak diperbolehkan."; +"password_policy_weak_pwd_error" = "Kata sandi ini terlalu lemah. Kata sandi harus berisi setidaknya 8 karakter, dengan setidaknya satu karakter dari setiap jenis: huruf besar, huruf kecil, angka, dan karakter spesial."; + +// MARK: Password policy errors +"password_policy_too_short_pwd_error" = "Kata sandi terlalu pendek"; +"user_session_permanently_unverified_session_description" = "Sesi ini tidak mendukung enkripsi, sehingga tidak dapat diverifikasi.\n\nAnda tidak akan dapat berpartisipasi dalan ruangan di mana enkripsi diaktifkan saat menggunakan sesi ini.\n\nUntuk keamanan dan privasi terbaik, disarankan untuk menggunakan klien Matrix yang mendukung enkripsi."; +"wysiwyg_composer_link_action_edit_title" = "Suntiing tautan"; +"wysiwyg_composer_link_action_create_title" = "Buat sebuah tautan"; +"wysiwyg_composer_link_action_link" = "Tautan"; + +// Links +"wysiwyg_composer_link_action_text" = "Teks"; +"wysiwyg_composer_format_action_link" = "Terapkan format tautan"; +"wysiwyg_composer_format_action_inline_code" = "Terapkan format kode dalam baris"; +"notice_voice_broadcast_ended_by_you" = "Anda mengakhiri sebuah siaran suara."; +"notice_voice_broadcast_ended" = "%@ mengakhiri sebuah siaran suara."; +"notice_voice_broadcast_live" = "Siaran langsung"; +"user_other_session_security_recommendation_title" = "Sesi lainnya"; +"poll_timeline_decryption_error" = "Karena kesalahan enkripsi, beberapa suara mungkin tidak terhitung"; +"voice_message_broadcast_in_progress_message" = "Anda tidak dapat memulai sebuah pesan suara selagi Anda merekam sebuah siaran langsung. Silakan mengakhiri siaran langsung Anda untuk memulai merekam sebuah pesan suara"; +"voice_message_broadcast_in_progress_title" = "Tidak dapat memulai pesan suara"; diff --git a/Riot/Assets/it.lproj/Vector.strings b/Riot/Assets/it.lproj/Vector.strings index bc2e62e7c..35bd94c35 100644 --- a/Riot/Assets/it.lproj/Vector.strings +++ b/Riot/Assets/it.lproj/Vector.strings @@ -2525,7 +2525,6 @@ "user_inactive_session_item" = "Inattiva da 90+ giorni"; "user_inactive_session_item_with_date" = "Inattiva da 90+ giorni (%@)"; "user_other_session_unverified_sessions_header_subtitle" = "Verifica le tue sessioni per avere conversazioni più sicure o disconnetti quelle che non riconosci o che non usi più."; -"user_other_session_security_recommendation_title" = "Consiglio di sicurezza"; "user_sessions_overview_link_device" = "Collega un dispositivo"; // MARK: User sessions management @@ -2605,7 +2604,7 @@ "manage_session_name_info" = "Ricorda che i nomi di sessione sono anche visibili alle persone con cui comunichi. %@"; "manage_session_name_hint" = "I nomi di sessione personalizzati possono aiutarti a riconoscere i tuoi dispositivi più facilmente."; "settings_labs_enable_wysiwyg_composer" = "Prova l'editor in rich text"; -"settings_labs_enable_voice_broadcast" = "Trasmissione vocale (in sviluppo attivo)"; +"settings_labs_enable_voice_broadcast" = "Trasmissione vocale"; "wysiwyg_composer_start_action_voice_broadcast" = "Trasmissione vocale"; "voice_broadcast_playback_loading_error" = "Impossibile avviare questa trasmissione vocale."; "voice_broadcast_already_in_progress_message" = "Stai già registrando una trasmissione vocale. Termina quella in corso per iniziarne una nuova."; @@ -2636,3 +2635,33 @@ // Unverified sessions "key_verification_alert_title" = "Hai sessioni non verificate"; +"user_other_session_permanently_unverified_additional_info" = "Questa sessione non supporta la crittografia, perciò non può essere verificata."; +"voice_broadcast_buffering" = "Buffer..."; +"voice_broadcast_time_left" = "%@ rimasti"; +"launch_loading_processing_response" = "Elaborazione dati\n%@ %%"; +"launch_loading_server_syncing_nth_attempt" = "Sincronizzazione con il server\n(%@ tentativo)"; + +// MARK: - Launch loading + +"launch_loading_server_syncing" = "Sincronizzazione con il server"; +"voice_broadcast_stop_alert_agree_button" = "Sì, ferma"; +"voice_broadcast_stop_alert_description" = "Vuoi davvero fermare la tua trasmissione in diretta? Verrà terminata la trasmissione e la registrazione completa sarà disponibile nella stanza."; +"voice_broadcast_stop_alert_title" = "Fermare la trasmissione in diretta?"; +"password_policy_pwd_in_dict_error" = "Questa password è stata trovata in un dizionario, perciò non è permessa."; +"password_policy_weak_pwd_error" = "Questa password è troppo debole. Deve contenere almeno 8 caratteri, con almeno un carattere di ogni tipo: maiuscole, minuscole, numeri e caratteri speciali."; + +// MARK: Password policy errors +"password_policy_too_short_pwd_error" = "Password troppo corta"; +"user_session_permanently_unverified_session_description" = "Questa sessione non supporta la crittografia, perciò non può essere verificata.\n\nNon potrai partecipare in stanze dove la crittografia è attiva mentre usi questa sessione.\n\nPer maggiore sicurezza e privacy, è consigliabile usare i client di Matrix che supportano la crittografia."; +"wysiwyg_composer_link_action_edit_title" = "Modifica collegamento"; +"wysiwyg_composer_link_action_create_title" = "Crea un collegamento"; +"wysiwyg_composer_link_action_link" = "Collegamento"; + +// Links +"wysiwyg_composer_link_action_text" = "Testo"; +"wysiwyg_composer_format_action_link" = "Applica formato collegamento"; +"notice_voice_broadcast_ended_by_you" = "Hai terminato una trasmissione vocale."; +"notice_voice_broadcast_ended" = "%@ ha terminato una trasmissione vocale."; +"notice_voice_broadcast_live" = "Trasmissione in diretta"; +"wysiwyg_composer_format_action_inline_code" = "Applica formato codice interlinea"; +"user_other_session_security_recommendation_title" = "Altre sessioni"; diff --git a/Riot/Assets/nl.lproj/Vector.strings b/Riot/Assets/nl.lproj/Vector.strings index 0b3fd5868..ab294231c 100644 --- a/Riot/Assets/nl.lproj/Vector.strings +++ b/Riot/Assets/nl.lproj/Vector.strings @@ -2724,7 +2724,6 @@ "user_other_session_verified_sessions_header_subtitle" = "Voor de beste beveiliging log je uit bij elke sessie die je niet meer herkent of gebruikt."; "user_other_session_current_session_details" = "Jouw huidige sessie"; "user_other_session_unverified_sessions_header_subtitle" = "Verifieer je sessies voor verbeterde beveiligde berichtenuitwisseling of meld je af bij sessies die je niet meer herkent of gebruikt."; -"user_other_session_security_recommendation_title" = "Beveiligingsaanbeveling"; "user_session_push_notifications_message" = "Indien ingeschakeld, ontvangt deze sessie pushmeldingen."; "user_session_push_notifications" = "Pushmeldingen"; "user_other_session_verified_additional_info" = "Deze sessie is klaar voor beveiligde berichtenuitwisseling."; @@ -2775,7 +2774,7 @@ /* The placeholder will be replaces with manage_session_name_info_link */ "manage_session_name_info" = "Houd er rekening mee dat sessienamen ook zichtbaar zijn voor mensen met wie je communiceert. %@"; "manage_session_name_hint" = "Met aangepaste sessienamen kan je jouw apparaten gemakkelijker herkennen."; -"settings_labs_enable_voice_broadcast" = "Voice-uitzending (in actieve ontwikkeling)"; +"settings_labs_enable_voice_broadcast" = "Voice-uitzending"; "settings_labs_enable_wysiwyg_composer" = "Probeer de rich-text-editor (platte tekst-modus komt binnenkort)"; "settings_labs_enable_new_session_manager" = "Nieuwe sessiemanager"; "room_first_message_placeholder" = "Stuur je eerste bericht…"; diff --git a/Riot/Assets/pt_BR.lproj/Vector.strings b/Riot/Assets/pt_BR.lproj/Vector.strings index 7e2cc90f2..582951c39 100644 --- a/Riot/Assets/pt_BR.lproj/Vector.strings +++ b/Riot/Assets/pt_BR.lproj/Vector.strings @@ -1604,7 +1604,7 @@ "home_context_menu_make_room" = "Mover para Salas"; "home_context_menu_make_dm" = "Mover para Pessoas"; "event_formatter_message_deleted" = "Mensagem deletada"; -"settings_labs_enable_threads" = "Mensageria com threads"; +"settings_labs_enable_threads" = "Mensagens com threads"; "message_from_a_thread" = "De uma thread"; "threads_empty_show_all_threads" = "Mostrar todas as threads"; "threads_empty_tip" = "Dica: Toque numa mensagem e use “Thread” para começar uma."; @@ -2526,7 +2526,6 @@ "user_inactive_session_item_with_date" = "Inativa por 90+ dias (%@)"; "user_inactive_session_item" = "Inativa por 90+ dias"; "user_other_session_unverified_sessions_header_subtitle" = "Verifique suas sessões para mensageria de segurança melhorada ou faça signout daquelas que você não reconhece ou usa mais."; -"user_other_session_security_recommendation_title" = "Recomendação de segurança"; "user_sessions_overview_link_device" = "Linkar um dispositivo"; // MARK: User sessions management @@ -2614,7 +2613,7 @@ // Mark: - Voice broadcast "voice_broadcast_unauthorized_title" = "Não dá para começar um novo broadcast de voz"; -"settings_labs_enable_voice_broadcast" = "Broadcast de voz (sob desenvolvimento ativo)"; +"settings_labs_enable_voice_broadcast" = "Broadcast de voz"; "deselect_all" = "Desselecionar Todas(os)"; "user_other_session_menu_select_sessions" = "Selecionar sessões"; "user_other_session_selected_count" = "%@ selecionadas"; @@ -2624,3 +2623,46 @@ "manage_session_sign_out_other_sessions" = "Fazer signout de todas as outras sessões"; "voice_broadcast_tile" = "Broadcast de voz"; "voice_broadcast_live" = "Ao vivo"; +"user_session_rename_session_description" = "Outras(os) usuárias(os) em mensagens diretas e salas a que você se junta são capazes de visualizar uma lista completa de suas sessões.\n\nIsto as/os provê com confiança que elas(es) são estão realmente falando com você, mas também significa que elas(es) veem o nome da sessão que você entrar aqui."; +"user_session_rename_session_title" = "Renomear sessões"; +"user_session_inactive_session_description" = "Sessões inativas são sessões que você não tem usado em algum tempo, mas elas continuam a receber chaves de encriptação.\n\nRemover sessões inativas melhora segurança e performance, e torna mais fácil para você identificar se uma nova sessão é suspeita."; +"user_session_inactive_session_title" = "Sessões inativas"; +"user_session_unverified_session_description" = "Sessões não-verificadas são sessões que você tem feito login com suas credenciais mas não têm sido verificadas cruzado.\n\nVocê devia especialmente se certificar que você reconhece estas sessões já que elas podiam representar um uso não-autorizado de sua conta."; +"user_session_unverified_session_title" = "Sessão não-verificada"; +"user_session_verified_session_description" = "Sessões verificadas são onde quer que você esteja usando Element depois de entrar sua frasepasse ou confirmar sua identidade com uma outra sessões verificada.\n\nIsto significa que você tem todas as chaves necessárias para destrancar suas mensagens encriptadas e confirmar a outras(os) usuárias(os) que você confia nesta sessão."; +"user_session_verified_session_title" = "Sessões verificadas"; +"user_session_got_it" = "Entendido"; +"user_other_session_permanently_unverified_additional_info" = "Esta sessão não suporta encriptação e assim não pode ser verificada."; +"voice_broadcast_time_left" = "%@ restando"; +"launch_loading_processing_response" = "Processando dados\n%@ %%"; +"launch_loading_server_syncing_nth_attempt" = "Sincando com o servidor\n(%@ tentativa)"; + +// MARK: - Launch loading + +"launch_loading_server_syncing" = "Sincando com o servidor"; +"key_verification_alert_body" = "Revise para assegurar que sua conta está segura."; + +// Unverified sessions +"key_verification_alert_title" = "Você tem sessões não-verificadas"; +"voice_broadcast_stop_alert_agree_button" = "Sim, parar"; +"voice_broadcast_stop_alert_description" = "Tem certeza que você quer parar seu broadcast ao vivo? Isto vai terminar o broadcast, e a gravação completa vai estar disponível na sala."; +"voice_broadcast_stop_alert_title" = "Parar broadcasting ao vivo?"; +"voice_broadcast_buffering" = "Buffering…"; +"user_session_permanently_unverified_session_description" = "Esta sessão não suporta encriptação, então ela não pode ser verificada.\n\nVocê não vai ser capaz de participar em salas onde encriptação é habilitada quando usando esta sessão.\n\nPara a melhor segurança e privacidade, é recomendado usar clientes Matrix que suportam encriptação."; +"password_policy_pwd_in_dict_error" = "Esta senha tem sido encontrada em um dicionário, e não é permitida."; +"password_policy_weak_pwd_error" = "Esta senha é fraca demais. Ela deve conter ao menos 8 caracteres, com ao menos um caractere de cada tipo: caractere maiúsculo, minúsculo, dígito, e especial."; + +// MARK: Password policy errors +"password_policy_too_short_pwd_error" = "Senha curta demais"; +"notice_voice_broadcast_ended_by_you" = "Você terminou um broadcast de voz."; +"notice_voice_broadcast_ended" = "%@ terminou um broadcast de voz."; +"notice_voice_broadcast_live" = "Broadcast ao vivo"; +"wysiwyg_composer_link_action_edit_title" = "Editar link"; +"wysiwyg_composer_link_action_create_title" = "Criar um link"; +"wysiwyg_composer_link_action_link" = "Link"; + +// Links +"wysiwyg_composer_link_action_text" = "Texto"; +"wysiwyg_composer_format_action_inline_code" = "Aplicar formato de código inline"; +"wysiwyg_composer_format_action_link" = "Aplicar formato de link"; +"user_other_session_security_recommendation_title" = "Outras sessões"; diff --git a/Riot/Assets/ru.lproj/Vector.strings b/Riot/Assets/ru.lproj/Vector.strings index c02dfd82d..d473c0e07 100644 --- a/Riot/Assets/ru.lproj/Vector.strings +++ b/Riot/Assets/ru.lproj/Vector.strings @@ -44,7 +44,7 @@ "auth_optional_phone_placeholder" = "Номер телефона (не обязательно)"; "auth_phone_placeholder" = "Номер телефона"; "auth_repeat_password_placeholder" = "Повторите пароль"; -"auth_repeat_new_password_placeholder" = "Подтвердите свой новый пароль"; +"auth_repeat_new_password_placeholder" = "Подтвердите свой новый пароль учётной записи Matrix"; "auth_invalid_login_param" = "Неверное имя пользователя и/или пароль"; "auth_invalid_user_name" = "Имена пользователей могут содержать только буквы, цифры, точки, дефисы и символы подчеркивания"; "auth_invalid_password" = "Пароль слишком короткий (мин. 6 символов)"; @@ -65,7 +65,7 @@ "auth_untrusted_id_server" = "Этот сервер идентификации не является доверенным"; "auth_password_dont_match" = "Пароли не совпадают"; "auth_username_in_use" = "Имя пользователя занято"; -"auth_forgot_password" = "Забыли пароль?"; +"auth_forgot_password" = "Забыли пароль учётной записи Matrix?"; "auth_email_not_found" = "Не удалось отправить email: этот адрес электронной почты не найден"; "auth_use_server_options" = "Использовать пользовательские параметры сервера (дополнительно)"; "auth_email_validation_message" = "Проверьте электронную почту, чтобы продолжить регистрацию"; @@ -73,14 +73,14 @@ "auth_msisdn_validation_message" = "Мы отправили SMS с кодом активации. Введите этот код ниже."; "auth_msisdn_validation_error" = "Не удалось проверить номер телефона."; "auth_recaptcha_message" = "Этот домашний сервер хочет проверить, что вы не робот"; -"auth_reset_password_message" = "Чтобы сбросить пароль, введите адрес электронной почты, связанный с вашей учетной записью:"; +"auth_reset_password_message" = "Чтобы сбросить пароль учётной записи Matrix, введите адрес электронной почты, связанный с вашей учетной записью:"; "auth_reset_password_missing_email" = "Необходимо ввести адрес электронной почты, связанный с вашей учетной записью."; "auth_reset_password_missing_password" = "Необходимо ввести новый пароль."; "auth_reset_password_email_validation_message" = "Письмо было отправлено на %@. После перехода по ссылке из письма, нажмите ниже."; "auth_reset_password_next_step_button" = "Я подтвердил свой адрес электронной почты"; "auth_reset_password_error_unauthorized" = "Не удалось проверить адрес электронной почты: убедитесь, что вы нажали на ссылку в письме"; "auth_reset_password_error_not_found" = "Ваш адрес электронной почты, кажется, не связан с Matrix ID на этом домашнем сервере."; -"auth_reset_password_success_message" = "Ваш пароль был сброшен.\n\nВы вышли со всех сессий и больше не будете получать push-уведомления. Чтобы вновь активировать уведомления, заново авторизуйтесь на каждом устройстве."; +"auth_reset_password_success_message" = "Ваш пароль учётной записи Matrix был сброшен.\n\nВы вышли со всех сессий и больше не будете получать push-уведомления. Чтобы вновь активировать уведомления, заново авторизуйтесь на каждом устройстве."; "auth_add_email_and_phone_warning" = "Регистрация с электронной почтой и номером телефона одновременно не поддерживается до тех пор, пока не появится API. Будет учитываться только номер телефона. Вы можете добавить свою электронную почту в свой профиль в настройках."; // Chat creation "room_creation_title" = "Новый чат"; @@ -166,8 +166,8 @@ "room_creation_appearance" = "Внешний вид"; "directory_cell_description" = "%tu комнат"; "directory_search_results_title" = "Просмотр результатов поиска"; -"directory_search_results" = "%tu результатов поиска для %@"; -"directory_search_results_more_than" = ">%tu результатов поиска для %@"; +"directory_search_results" = "%1$tu результатов поиска для %2$@"; +"directory_search_results_more_than" = ">%1$tu результатов поиска для %2$@"; "room_participants_invite_malformed_id" = "Неверный идентификатор. Должен быть адрес электронной почты или идентификатор Matrix, например '@thomas:matrix.org'"; "room_participants_now" = "сейчас"; "room_participants_ago" = "назад"; @@ -196,7 +196,7 @@ "room_event_action_redact" = "Удалить"; "room_event_action_more" = "Больше"; "room_event_action_share" = "Поделиться"; -"room_event_action_permalink" = "Постоянная ссылка"; +"room_event_action_permalink" = "Скопировать ссылку на сообщение"; "room_event_action_view_source" = "Посмотреть источник"; "room_event_action_report" = "Сообщить о недопустимом контенте"; "room_event_action_report_prompt_reason" = "Причина сообщениея о недопустимом контенте"; @@ -504,7 +504,7 @@ "room_do_not_have_permission_to_post" = "У вас нет разрешения на публикацию в этой комнате"; "settings_flair" = "Покажите настроение, где это разрешено"; "room_details_flair_section" = "Показать настроение для сообществ"; -"room_event_action_kick_prompt_reason" = "Причина по которой этот пользователь будет выкинут"; +"room_event_action_kick_prompt_reason" = "Причина исключения пользователя"; "room_event_action_ban_prompt_reason" = "Причина по которой этот пользователь будет забанен"; // GDPR "gdpr_consent_not_given_alert_message" = "Для продолжения использования сервера %@ вы должны принять условия и положения."; @@ -675,7 +675,7 @@ "settings_labs_message_reaction" = "Реагировать на сообщения с Emoji"; "settings_key_backup_button_connect" = "Подключите этот сеанс к резервному копированию ключей"; "close" = "Закрыть"; -"auth_forgot_password_error_no_configured_identity_server" = "Сервер идентификации не настроен: добавьте один для сброса пароля."; +"auth_forgot_password_error_no_configured_identity_server" = "Сервер идентификации не настроен: добавьте один для сброса пароля учётной записи Matrix."; "auth_softlogout_signed_out" = "Вы вышли"; "auth_softlogout_sign_in" = "Войти"; "auth_softlogout_clear_data" = "Очистить личные данные"; @@ -820,9 +820,9 @@ "auth_add_email_message_2" = "Установите адрес электронной почты для восстановления учетной записи, а затем ее можно будет найти людям, которые вас знают."; "auth_add_phone_message_2" = "Задайте телефон, и позже его могут найти люди, которые вас знают."; "auth_add_email_phone_message_2" = "Установка адреса электронной почты для восстановления учетной записи. Используйте позже электронную почту или телефон, чтобы люди, которые вас знают, могли их по желанию найти."; -"auth_email_is_required" = "Сервер идентификации не настроен, поэтому вы не можете добавить адрес электронной почты, чтобы в будущем сбросить пароль."; -"auth_phone_is_required" = "Сервер идентификации не настроен, поэтому вы не можете добавить номер телефона, чтобы в будущем сбросить пароль."; -"auth_reset_password_error_is_required" = "Сервер идентификации не настроен: добавьте ID-Сервер в параметры сервера для сброса пароля."; +"auth_email_is_required" = "Сервер идентификации не настроен, поэтому вы не можете добавить адрес электронной почты, чтобы в будущем сбросить пароль учётной записи Matrix."; +"auth_phone_is_required" = "Сервер идентификации не настроен, поэтому вы не можете добавить номер телефона, чтобы в будущем сбросить пароль учётной записи Matrix."; +"auth_reset_password_error_is_required" = "Сервер идентификации не настроен: добавьте ID-Сервер в параметры сервера для сброса пароля учётной записи Matrix."; "room_accessiblity_scroll_to_bottom" = "Прокрутить вниз"; "room_accessibility_search" = "Поиск"; "room_accessibility_integrations" = "Интеграция"; @@ -2168,3 +2168,35 @@ "authentication_qr_login_start_subtitle" = "Используйте камеру на этом устройстве, чтобы сканировать QR-код, отображённый на вашем другом устройстве:"; "authentication_qr_login_start_title" = "Сканировать QR-код"; "authentication_terms_policy_url_error" = "Не получилось найти выбранные правила. Пожалуйста, попробуйте снова позже."; +"threads_empty_tip" = "Подсказка: Нажмите на сообщение и используйте «Поток», чтобы начать переписку."; +"threads_empty_info_my" = "Ответьте в действующий поток, или нажмите на «Поток», чтобы начать новый."; +"threads_empty_info_all" = "Потоки помогают придерживаться темы разговора и легко отслеживаются."; +"threads_empty_title" = "Организуйте свои обсуждения при помощи потоков"; +"room_first_message_placeholder" = "Отправьте своё первое сообщение…"; +"room_participants_leave_processing" = "Выход"; +"authentication_qr_login_failure_retry" = "Попробовать снова"; +"authentication_qr_login_failure_request_timed_out" = "Привязка не была совершена за нужное время."; +"authentication_qr_login_failure_request_denied" = "Запрос был отклонён на другом устройстве."; +"authentication_qr_login_failure_invalid_qr" = "Недействительный QR-код."; +"authentication_qr_login_failure_title" = "Не удалось привязать"; +"authentication_qr_login_loading_signed_in" = "Вы вошли с другого устройства."; +"authentication_qr_login_loading_waiting_signin" = "Ожидание входа устройства."; +"authentication_qr_login_loading_connecting_device" = "Соединение с устройством"; +"authentication_qr_login_confirm_alert" = "Пожалуйста убедитесь в том, что вы знаете о происхождении этого кода. Привязав устройства, вы дадите кому-то полный доступ к вашей учётной записи."; +"authentication_qr_login_confirm_subtitle" = "Убедитесь, что код снизу совпадает с кодом на вашем другом устройстве:"; +"authentication_qr_login_confirm_title" = "Безопасное соединение установлено"; +"authentication_qr_login_scan_subtitle" = "Разместите QR-код в квадрате снизу"; +"authentication_qr_login_scan_title" = "Сканировать QR-код"; +"authentication_qr_login_display_step2" = "Виберите «Войти при помощи QR-кода»"; +"authentication_qr_login_display_step1" = "Откройте Element на вашем другом устройстве"; +"authentication_qr_login_display_subtitle" = "Сканируйте QR-код снизу со своего устройства, с которого вы вышли."; +"authentication_qr_login_display_title" = "Привязать устройство"; +"authentication_qr_login_start_display_qr" = "Показать QR-код на этом устройстве"; +"authentication_qr_login_start_need_alternative" = "Нуждаетесь в альтернативном методе?"; +"authentication_qr_login_start_step4" = "Выберите «Показать QR-код на этом устройстве»"; +"password_policy_pwd_in_dict_error" = "Этот пароль был найден в словаре и не разрешен."; +"password_policy_weak_pwd_error" = "Этот пароль слишком слаб. Он должен содержать не менее 8 символов, по крайней мере по одному символу каждого типа: прописные, строчные, цифры и специальные символы."; + +// MARK: Password policy errors +"password_policy_too_short_pwd_error" = "Очень короткий пароль"; +"settings_enable_room_message_bubbles" = "Сообщения пузырями"; diff --git a/Riot/Assets/sk.lproj/Vector.strings b/Riot/Assets/sk.lproj/Vector.strings index 9f1cc6b65..87edea36c 100644 --- a/Riot/Assets/sk.lproj/Vector.strings +++ b/Riot/Assets/sk.lproj/Vector.strings @@ -2748,7 +2748,6 @@ "user_inactive_session_item_with_date" = "Neaktívna viac ako 90 dní (%@)"; "user_inactive_session_item" = "Neaktívna viac ako 90 dní"; "user_other_session_unverified_sessions_header_subtitle" = "Overte si relácie pre vylepšené bezpečné zasielanie správ alebo sa odhláste z tých, ktoré už nepoznáte alebo nepoužívate."; -"user_other_session_security_recommendation_title" = "Bezpečnostné odporúčania"; "user_sessions_overview_link_device" = "Prepojiť zariadenie"; // MARK: User sessions management @@ -2835,7 +2834,7 @@ // Mark: - Voice broadcast "voice_broadcast_unauthorized_title" = "Nie je možné spustiť nové hlasové vysielanie"; -"settings_labs_enable_voice_broadcast" = "Hlasové vysielanie (v štádiu aktívneho vývoja)"; +"settings_labs_enable_voice_broadcast" = "Hlasové vysielanie"; "voice_broadcast_playback_loading_error" = "Toto hlasové vysielanie nie je možné prehrať."; "deselect_all" = "Zrušiť výber všetkých"; "user_other_session_selected_count" = "%@ vybratých"; @@ -2859,3 +2858,36 @@ // Unverified sessions "key_verification_alert_title" = "Máte neoverené relácie"; +"user_other_session_permanently_unverified_additional_info" = "Táto relácia nepodporuje šifrovanie, a preto ju nemožno overiť."; +"voice_broadcast_time_left" = "%@ ostáva"; +"launch_loading_processing_response" = "Spracovanie údajov\n%@ %%"; +"launch_loading_server_syncing_nth_attempt" = "Synchronizácia so serverom\n(%@ pokus)"; + +// MARK: - Launch loading + +"launch_loading_server_syncing" = "Synchronizácia so serverom"; +"voice_broadcast_buffering" = "Načítavanie do vyrovnávacej pamäte…"; +"voice_broadcast_stop_alert_agree_button" = "Áno, zastaviť"; +"voice_broadcast_stop_alert_description" = "Určite chcete zastaviť vysielanie naživo? Tým sa vysielanie ukončí a v miestnosti bude k dispozícii celý záznam."; +"voice_broadcast_stop_alert_title" = "Zastaviť vysielanie naživo?"; +"password_policy_pwd_in_dict_error" = "Toto heslo bolo nájdené v slovníku a nie je povolené."; +"password_policy_weak_pwd_error" = "Toto heslo je príliš slabé. Musí obsahovať aspoň 8 znakov, pričom musí obsahovať aspoň jeden znak z každého typu: veľké a malé písmená, číslice a špeciálny symbol."; + +// MARK: Password policy errors +"password_policy_too_short_pwd_error" = "Príliš krátke heslo"; +"user_session_permanently_unverified_session_description" = "Táto relácia nepodporuje šifrovanie, takže ju nemožno overiť.\n\nPri používaní tejto relácie sa nebudete môcť zúčastňovať konverzácií v miestnostiach, kde je zapnuté šifrovanie.\n\nNa dosiahnutie čo najlepšieho zabezpečenia a súkromia sa odporúča používať Matrix klientov, ktoré podporujú šifrovanie."; +"wysiwyg_composer_link_action_edit_title" = "Upraviť odkaz"; +"wysiwyg_composer_link_action_create_title" = "Vytvoriť odkaz"; +"wysiwyg_composer_link_action_link" = "Odkaz"; + +// Links +"wysiwyg_composer_link_action_text" = "Text"; +"wysiwyg_composer_format_action_link" = "Použiť formát odkazu"; +"wysiwyg_composer_format_action_inline_code" = "Použiť formát riadkového kódu"; +"notice_voice_broadcast_ended_by_you" = "Ukončili ste hlasové vysielanie."; +"notice_voice_broadcast_ended" = "%@ ukončil/a hlasové vysielanie."; +"notice_voice_broadcast_live" = "Živé vysielanie"; +"user_other_session_security_recommendation_title" = "Iné relácie"; +"poll_timeline_decryption_error" = "Z dôvodu chýb v dešifrovaní sa niektoré hlasy nemusia započítať"; +"voice_message_broadcast_in_progress_message" = "Nemôžete spustiť hlasovú správu, pretože práve nahrávate živé vysielanie. Ukončite prosím živé vysielanie, aby ste mohli začať nahrávať hlasovú správu"; +"voice_message_broadcast_in_progress_title" = "Nemožno spustiť hlasovú správu"; diff --git a/Riot/Assets/sq.lproj/Vector.strings b/Riot/Assets/sq.lproj/Vector.strings index 8d6e3cbfd..f484f7275 100644 --- a/Riot/Assets/sq.lproj/Vector.strings +++ b/Riot/Assets/sq.lproj/Vector.strings @@ -2486,7 +2486,6 @@ "user_other_session_verified_sessions_header_subtitle" = "Për sigurinë më të mirë, dilni nga çfarëdo sesioni që nuk e njihni apo përdorni më."; "user_other_session_current_session_details" = "Sesioni juaj i tanishëm"; "user_other_session_unverified_sessions_header_subtitle" = "Verifikoni sesionet tuaj, për shkëmbim më të sigurt mesazhesh, ose dilni prej atyre që nuk i njihni, apo përdorni më."; -"user_other_session_security_recommendation_title" = "Rekomandim sigurie"; "user_session_push_notifications_message" = "Kur aktivizohet, ky sesion do të marrë njoftime push."; "user_session_push_notifications" = "Njoftime Push"; "user_other_session_verified_additional_info" = "Ky sesion është gati për shkëmbim të sigurt mesazhesh."; @@ -2595,8 +2594,8 @@ /* The placeholder will be replaces with manage_session_name_info_link */ "manage_session_name_info" = "Ju lutemi, kini parasysh se emrat e sesioneve janë të dukshëm edhe për personat me të cilët komunikoni.%@"; "manage_session_name_hint" = "Emra vetjakë sesionesh mund t’ju ndihmojnë të njihni më kollaj pajisjet tuaja."; -"settings_labs_enable_voice_broadcast" = "Aktivizoni transmetim zanor (nën zhvillim aktiv)"; -"settings_labs_enable_wysiwyg_composer" = "Provoni përpunuesin e teksteve të pasur (për tekst të thjeshtë vjen së shpejti)"; +"settings_labs_enable_voice_broadcast" = "Aktivizoni transmetim zanor"; +"settings_labs_enable_wysiwyg_composer" = "Provoni përpunuesin e teksteve të pasur"; "settings_labs_enable_new_app_layout" = "Skemë e Re Aplikacioni"; "settings_labs_enable_new_client_info_feature" = "Regjistro emrin, versionin dhe URL-në e klientit, për të dalluar më kollaj sesionit te përgjegjës sesionesh"; "settings_labs_enable_new_session_manager" = "Përgjegjës i ri sesionesh"; @@ -2630,3 +2629,50 @@ "invite_to" = "Ftojeni te %@"; "all_chats_empty_list_placeholder_title" = "S’ka gjë tjetër për të parë."; "all_chats_edit_layout_add_filters_message" = "Filtroni automatikisht mesazhet tuaj në kategori që caktoni vetë"; +"user_session_rename_session_description" = "Përdorues të tjerë në mesazhe të drejtpërdrejtë dhe dhoma ku jeni në gjendje të shihni një listë të plotë të sesioneve tuaja.\n\nKjo u jep besim atyre se po flasin vërtet me ju, por do të thotë edhe se mund të shohin emrin e sesionit që jepni këtu."; +"user_session_inactive_session_description" = "Sesione jo aktive janë sesione që keni ca kohë që s’i keni përdorur, por që vazhdojnë të marrin kyçe fshehtëzimi.\n\nHeqja e sesioneve jo aktive përmirëson sigurinë dhe punimin dhe e bëjnë të lehtë për ju të identifikoni, nëse një sesion i ri është i dyshimtë."; +"user_session_unverified_session_description" = "Sesione të paverifikuar janë sesione ku keni bërë hyrjen me kredencialet tuaja, por që nuk janë ndër-verifikuar.\n\nDuhet ta bëni veçanërisht të qartë se i njihni këto sesione, ngaqë mund të përfaqësojnë përdorim të paautorizuar të llogarisë tuaj."; +"user_session_verified_session_description" = "Sesione të verifikuar janë ata kudo që përdorni Element-in pasi të keni dhënë frazëkalimin tuaj, ose pasi të keni ripohuar identitetin tuaj përmes një tjetër sesioni të verifikuar.\n\nKjo do të thotë se zotëroni krejt kyçet e nevojshëm për të shkyçur mesazhet tuaj të fshehtëzuar dhe ripohuar përdoruesve të tjerë se e besoni këtë sesion."; +"launch_loading_server_syncing_nth_attempt" = "Po njëkohësohet me shërbyesin\n(Përpjekja e %@)"; +"user_session_rename_session_title" = "Riemërtim sesionesh"; +"user_session_inactive_session_title" = "Sesione jo aktive"; +"user_session_unverified_session_title" = "Sesione të paverifikuar"; +"user_session_verified_session_title" = "Sesione të verifikuar"; +"user_session_got_it" = "E mora vesh"; +"user_other_session_permanently_unverified_additional_info" = "Ky sesion s’mbulon fshehtëzim, ndaj s’mund të verifikohet."; +"user_sessions_hide_location_info" = "Fshihe adresën IP"; +"user_sessions_show_location_info" = "Shfaq adresë IP"; +"voice_broadcast_time_left" = "Edhe %@"; +"voice_broadcast_tile" = "Transmetim zanor"; +"launch_loading_processing_response" = "Po përpunohen të dhëna\n%@ %%"; + +// MARK: - Launch loading + +"launch_loading_server_syncing" = "Po njëkohësohet me shërbyesin"; +"key_verification_alert_body" = "Shqyrtojini, që të siguroheni se llogaria juaj është e parrezik."; + +// Unverified sessions +"key_verification_alert_title" = "Keni sesione të paverifikuar"; +"manage_session_sign_out_other_sessions" = "Dilni prej krejt sesioneve të tjerë"; +"user_other_session_menu_sign_out_sessions" = "Dilni nga %@ sesione"; +"voice_broadcast_stop_alert_agree_button" = "Po, ndaleni"; +"voice_broadcast_stop_alert_description" = "Jeni i sigurt se doni të ndalet transmetimi juaj i drejtpërdrejtë? Kjo do të ndalë transmetimin dhe regjistrimi i plotë do të jetë i passhëm te dhoma."; +"voice_broadcast_stop_alert_title" = "Të ndalet transmetimi i drejtpërdrejtë?"; +"password_policy_pwd_in_dict_error" = "Ky fjalëkalim gjendet në një fjalor dhe nuk lejohet."; +"password_policy_weak_pwd_error" = "Ky fjalëkalim është shumë i shkurtër. Duhet të përmbajë të paktën 8 shenja, me të paktën një shenjë nga çdo lloj: të mëdha, të vogla, shifra dhe shenja speciale."; + +// MARK: Password policy errors +"password_policy_too_short_pwd_error" = "Fjalëkalim shumë i shkurtër"; +"user_session_permanently_unverified_session_description" = "Ky sesion nuk mbulon fshehtëzim, ndaj s’mund të verifikohet.\n\nS’do të jeni në gjendje të merrni pjesë në dhoma ku fshehtëzimi është i aktivizuar, kur përdorni këtë sesion.\n\nPër sigurinë dhe privatësinë më të mirë, rekomandohet të përdorni klientë Matrix që mbulojnë fshehtëzimin."; +"wysiwyg_composer_link_action_edit_title" = "Përpunoni një lidhje"; +"wysiwyg_composer_link_action_create_title" = "Krijoni një lidhje"; +"wysiwyg_composer_link_action_link" = "Lidhje"; + +// Links +"wysiwyg_composer_link_action_text" = "Tekst"; +"wysiwyg_composer_format_action_inline_code" = "Apliko formatim kodi brendazi"; +"wysiwyg_composer_format_action_link" = "Apliko formatim lidhjeje"; +"notice_voice_broadcast_ended_by_you" = "Përfunduar një transmetim zanor."; +"notice_voice_broadcast_ended" = "%@ përfundoi një transmetim zanor."; +"notice_voice_broadcast_live" = "Transmetim i drejtëpërdrejtë"; +"user_other_session_security_recommendation_title" = "Sesione të tjerë"; diff --git a/Riot/Assets/th.lproj/Localizable.strings b/Riot/Assets/th.lproj/Localizable.strings index a6272922c..a1f012f4d 100644 --- a/Riot/Assets/th.lproj/Localizable.strings +++ b/Riot/Assets/th.lproj/Localizable.strings @@ -95,3 +95,60 @@ /* New message from a specific person in a named room */ "MSG_FROM_USER_IN_ROOM" = "%@ ได้โพสต์ใน %@"; + +/* Group call from user, CallKit caller name */ +"GROUP_CALL_FROM_USER" = "%@ (การโทรแบบกลุ่ม)"; + +/* A user added a Jitsi call to a room */ +"GROUP_CALL_STARTED" = "เริ่มการโทรแบบกลุ่มแล้ว"; + +/* A user's membership has updated in an unknown way */ +"USER_MEMBERSHIP_UPDATED" = "%@ ได้อัพเดตรูปโปรไฟล์"; + +/* A user has change their avatar */ +"USER_UPDATED_AVATAR" = "%@ เปลี่ยนอวาตาร์ของเขา"; + +/* A user has change their name to a new name which we don't know */ +"GENERIC_USER_UPDATED_DISPLAYNAME" = "%@ เปลี่ยนชื่อของเขา"; + +/** Membership Updates **/ + +/* A user has change their name to a new name */ +"USER_UPDATED_DISPLAYNAME" = "%@ เปลี่ยนชื่อเป็น %@"; + +/* A user has reacted to a message, but the reaction content is unknown */ +"GENERIC_REACTION_FROM_USER" = "%@ ส่งความรู้สึก"; + +/** Reactions **/ + +/* A user has reacted to a message, including the reaction e.g. "Alice reacted 👍". */ +"REACTION_FROM_USER" = "%@ รู้สึก %@"; + +/* New file message from a specific person, not referencing a room. */ +"LOCATION_FROM_USER" = "%@ แบ่งปันตำแหน่งของเขา"; + +/* New file message from a specific person, not referencing a room. */ +"FILE_FROM_USER" = "%@ ส่งไฟล์ %@"; + +/* New voice message from a specific person, not referencing a room. */ +"VOICE_MESSAGE_FROM_USER" = "%@ ส่งข้อความเสียง"; + +/* New audio message from a specific person, not referencing a room. */ +"AUDIO_FROM_USER" = "%@ ส่งไฟล์เสียง %@"; + +/* New video message from a specific person, not referencing a room. */ +"VIDEO_FROM_USER" = "%@ ส่งวีดีโอ"; + +/** Media Messages **/ + +/* New image message from a specific person, not referencing a room. */ +"PICTURE_FROM_USER" = "%@ ส่งรูปภาพ"; + +/* New message reply from a specific person in a named room. */ +"REPLY_FROM_USER_IN_ROOM_TITLE" = "%@ ตอบกลับใน %@"; + +/* New message reply from a specific person, not referencing a room. */ +"REPLY_FROM_USER_TITLE" = "%@ ตอบกลับ"; +/** General **/ + +"Notification" = "การแจ้งเตือน"; diff --git a/Riot/Assets/uk.lproj/Vector.strings b/Riot/Assets/uk.lproj/Vector.strings index a38d76c6f..96e8166d7 100644 --- a/Riot/Assets/uk.lproj/Vector.strings +++ b/Riot/Assets/uk.lproj/Vector.strings @@ -2495,7 +2495,7 @@ "location_sharing_live_list_item_last_update_invalid" = "Час останнього оновлення невідомий"; "location_sharing_live_list_item_last_update" = "Оновлено %@ тому"; "location_sharing_live_list_item_sharing_expired" = "Надсилання завершено"; -"location_sharing_live_list_item_time_left" = "%@ виходить"; +"location_sharing_live_list_item_time_left" = "Залишилося %@"; "location_sharing_live_viewer_title" = "Місце перебування"; "location_sharing_live_map_callout_title" = "Поділитися місцем перебування"; "room_access_settings_screen_upgrade_alert_note" = "Зауважте, що оновлення створить нову версію кімнати. Усі поточні повідомлення залишаться в цій архівованій кімнаті."; @@ -2750,7 +2750,6 @@ "user_inactive_session_item_with_date" = "Неактивний понад 90 днів (%@)"; "user_inactive_session_item" = "Неактивний понад 90 днів"; "user_other_session_unverified_sessions_header_subtitle" = "Перевірте свої сеанси для посилення безпеки обміну повідомленнями або вийдіть з тих, які ви більше не розпізнаєте або не використовуєте."; -"user_other_session_security_recommendation_title" = "Поради з безпеки"; "user_sessions_overview_link_device" = "Пов'язати пристрій"; // MARK: User sessions management @@ -2838,7 +2837,7 @@ // Mark: - Voice broadcast "voice_broadcast_unauthorized_title" = "Не вдалося розпочати нову голосову трансляцію"; -"settings_labs_enable_voice_broadcast" = "Голосові трансляції (в активній розробці)"; +"settings_labs_enable_voice_broadcast" = "Голосові трансляції"; "deselect_all" = "Скасувати вибір усіх"; "user_other_session_menu_select_sessions" = "Вибрати сеанси"; "user_other_session_selected_count" = "Вибрано %@"; @@ -2861,3 +2860,36 @@ // Unverified sessions "key_verification_alert_title" = "У вас є не звірені сеанси"; +"user_other_session_permanently_unverified_additional_info" = "Цей сеанс не підтримує шифрування, і його не можна звірити."; +"voice_broadcast_time_left" = "Залишилося %@"; +"launch_loading_processing_response" = "Обробка даних\n%@ %%"; +"launch_loading_server_syncing_nth_attempt" = "Синхронізація з сервером\n(%@ спроба)"; + +// MARK: - Launch loading + +"launch_loading_server_syncing" = "Синхронізація з сервером"; +"voice_broadcast_buffering" = "Буферизація..."; +"voice_broadcast_stop_alert_agree_button" = "Так, припинити"; +"voice_broadcast_stop_alert_description" = "Ви впевнені, що хочете припинити голосову трансляцію? На цьому трансляція завершиться, і повний запис буде доступний у кімнаті."; +"voice_broadcast_stop_alert_title" = "Припинити голосову трансляцію?"; +"password_policy_pwd_in_dict_error" = "Цей пароль знайдений у словнику і недопустимий."; +"password_policy_weak_pwd_error" = "Цей пароль занадто слабкий. Він повинен містити щонайменше 8 символів, причому хоча б по одному символу кожного типу: великі букви, малі букви, цифри та спеціальні символи."; + +// MARK: Password policy errors +"password_policy_too_short_pwd_error" = "Пароль закороткий"; +"user_session_permanently_unverified_session_description" = "Цей сеанс не підтримує шифрування, тому його неможливо звірити.\n\nПід час користування цим сеансом ви не зможете брати участь у кімнатах, в яких увімкнено шифрування.\n\nДля найкращої безпеки та приватності радимо користуватися клієнтами Matrix, які підтримують шифрування."; +"wysiwyg_composer_link_action_edit_title" = "Змінити посилання"; +"wysiwyg_composer_link_action_create_title" = "Створити посилання"; +"wysiwyg_composer_link_action_link" = "Посилання"; + +// Links +"wysiwyg_composer_link_action_text" = "Текст"; +"wysiwyg_composer_format_action_link" = "Застосувати формат посилання"; +"wysiwyg_composer_format_action_inline_code" = "Застосовувати вбудований формат коду"; +"notice_voice_broadcast_ended_by_you" = "Ви завершили голосову трансляцію."; +"notice_voice_broadcast_ended" = "%@ завершує голосову трансляцію."; +"notice_voice_broadcast_live" = "Трансляція наживо"; +"user_other_session_security_recommendation_title" = "Інші сеанси"; +"poll_timeline_decryption_error" = "Через помилки під час розшифрування деякі голоси можуть бути не враховані"; +"voice_message_broadcast_in_progress_title" = "Неможливо розпочати запис голосового повідомлення"; +"voice_message_broadcast_in_progress_message" = "Ви не можете розпочати запис голосового повідомлення, оскільки зараз триває запис трансляції наживо. Будь ласка, завершіть трансляцію, щоб розпочати запис голосового повідомлення"; diff --git a/Riot/Assets/zh_Hans.lproj/Vector.strings b/Riot/Assets/zh_Hans.lproj/Vector.strings index 307ccbc18..d7a775d4b 100644 --- a/Riot/Assets/zh_Hans.lproj/Vector.strings +++ b/Riot/Assets/zh_Hans.lproj/Vector.strings @@ -20,7 +20,7 @@ "cancel" = "取消"; "save" = "保存"; "join" = "加入"; -"decline" = "取消"; +"decline" = "拒绝"; "accept" = "接受"; "preview" = "预览"; "camera" = "摄像头"; @@ -522,7 +522,7 @@ "room_resource_usage_limit_reached_message_contact_3" = " 以提高限制。"; // String for App Store "store_short_description" = "安全、去中心化的聊天及 VoIP 应用"; -"store_full_description" = "Element 是一种新型的通讯与协作应用:\n\n1. 使您可以掌控您的隐私\n2. 使您与 Matrix 网络中的任何人交流,甚至可以通过集成功能与如 Slack 之类的其他应用通讯\n3. 保护您免受广告,大数据挖掘和封闭服务的侵害\n4. 通过端到端加密保证安全,通过交叉签名验证其他人\n\nElement 与其他通讯与协作应用完全不同,因为它是去中心化且开源的。\n\nElement 允许您自托管——或者选择托管商——因此,您能拥有数据和会话的隐私权,所有权和控制权。它允许您访问开放网络;因此,您可以与 Element 用户以外的人交流。并且它非常安全。\n\nElement 之所以可以做到这些,是因为它在 Matrix 上运行——开放,去中心化通讯的标准。\n\n通过让您选择由谁来托管您的会话,Element 让您掌控一切。在 Element 应用中,您可以选择不同的托管方式:\n\n1. 在由 Matrix 开发者托管的 matrix.org 公共服务器上获取免费账户,或从志愿者托管的上千个公共服务器中选择\n2. 在您自己的硬件上运行服务器,自托管您的会话\n3. 通过订阅 Element Matrix Services 托管平台,简单地在自定义服务器上注册账户\n\n为什么选择 Element?\n\n掌控您的数据:您来决定存放您的数据和消息的位置。拥有并控制它的是您,而不是挖掘您的数据或与第三方分享的巨型企业。\n\n开放通讯与协作:您可以与 Matrix 网络中的任何人聊天,不论他们使用 Element 还是其他 Matrix 应用,甚至/即使他们在使用不同的通讯系统,例如 Slack,IRC 或 XMPP。\n\n超级安全:支持真正的端到端加密(仅有会话中的人可以解密消息),还有能够验证会话参与方的设备的交叉签名。\n\n完善的通讯方式:消息,语音和视频通话,文件共享,屏幕共享和大量集成功能,机器人和挂件。建立房间与社区,保持联系并完成工作。\n\n随时随地:消息历史可在您的全部设备和 https://app.element.io 网页端之间完全同步,无论您在哪里,都可以保持联系。"; +"store_full_description" = "Element 是一种新型的通讯与协作应用:\n\n1. 使您可以掌控您的隐私\n2. 使您与 Matrix 网络中的任何人交流,甚至可以通过集成功能与如 Slack 之类的其他应用通讯\n3. 保护您免受广告,大数据挖掘和封闭服务的侵害\n4. 通过端到端加密保证安全,通过交叉签名验证其他人\n\nElement 与其他通讯与协作应用完全不同,因为它是去中心化且开源的。\n\nElement 允许您自托管——或者选择托管商——因此,您能拥有数据和会话的隐私权,所有权和控制权。它允许您访问开放网络;因此,您可以与 Element 用户以外的人交流。并且它非常安全。\n\nElement 之所以可以做到这些,是因为它在 Matrix 上运行——开放,去中心化通讯的标准。\n\n通过让您选择由谁来托管您的会话,Element 让您掌控一切。在 Element 应用中,您可以选择不同的托管方式:\n\n1. 在由 Matrix 开发者托管的 matrix.org 公共服务器上获取免费账户,或从志愿者托管的上千个公共服务器中选择\n2. 在您自己的硬件上运行服务器,自托管您的会话\n3. 通过订阅 Element Matrix Services 托管平台,简单地在自定义服务器上注册账户\n\n为什么选择 Element?\n\n掌控您的数据:您来决定存放您的数据和消息的位置。拥有并控制它的是您,而不是挖掘您的数据或与第三方分享的巨型企业。\n\n开放通讯与协作:您可以与 Matrix 网络中的任何人聊天,不论他们使用 Element 还是其他 Matrix 应用,甚至/即使他们在使用不同的通讯系统,例如 Slack、IRC 或 XMPP。\n\n超级安全:支持真正的端到端加密(仅有会话中的人可以解密消息),还有能够验证会话参与方的设备的交叉签名。\n\n完善的通讯方式:消息,语音和视频通话,文件共享,屏幕共享和大量集成功能,机器人和挂件。建立房间与社区,保持联系并完成工作。\n\n随时随地:消息历史可在您的全部设备和 https://app.element.io 网页端之间完全同步,无论您在哪里,都可以保持联系。"; "auth_accept_policies" = "请查看并接受此主页服务器的服务条款:"; "room_replacement_information" = "这个房间已被替换,不再有效。"; "settings_flair" = "在允许的地方显示个性徽章"; @@ -1090,7 +1090,7 @@ "more" = "更多"; "switch" = "开关"; "joined" = "已加入"; -"store_promotional_text" = "在开放网络上保护隐私的聊天和协作应用程序。分散权力让你掌控一切。没有数据挖掘,没有后门,也没有第三方访问。"; +"store_promotional_text" = "在开放网络上保护隐私的聊天和协作应用程序。去中心化让你掌控一切。没有数据挖掘,没有后门,也没有第三方访问。"; "social_login_button_title_sign_up" = "使用 %@ 注册"; "social_login_button_title_sign_in" = "使用 %@ 登录"; "social_login_button_title_continue" = "使用 %@ 继续"; @@ -2215,3 +2215,19 @@ "authentication_forgot_password_input_message" = "%@将给你发一条验证链接"; "authentication_forgot_password_input_title" = "输入你的电子邮件"; "authentication_verify_email_waiting_button" = "重发电子邮件"; +"ignore_user" = "忽略用户"; +"authentication_qr_login_start_need_alternative" = "需要替代方法?"; +"authentication_qr_login_start_step4" = "选择“在此设备显示QR码”"; +"authentication_qr_login_start_display_qr" = "在此设备显示QR码"; +"user_sessions_overview_link_device" = "关联设备"; +"authentication_qr_login_display_title" = "关联设备"; +"authentication_qr_login_start_step3" = "选择“关联设备”"; +"authentication_qr_login_start_step2" = "前往设置->安全与隐私"; +"authentication_qr_login_start_step1" = "打开你的其他设备上的Element"; +"authentication_qr_login_start_subtitle" = "用此设备的相机扫描显示在你的其他设备上的QR码:"; +"authentication_qr_login_start_title" = "扫描QR码"; +"authentication_choose_password_signout_all_devices" = "登出全部设备"; +"authentication_login_with_qr" = "用QR码登录"; +"onboarding_congratulations_home_button" = "带我到主页"; +"onboarding_use_case_message" = "我们将帮助你连接"; +"invite_to" = "邀请到%@"; diff --git a/Riot/Assets/zh_Hant.lproj/InfoPlist.strings b/Riot/Assets/zh_Hant.lproj/InfoPlist.strings index 26c3b7ea0..0fd9e3c84 100644 --- a/Riot/Assets/zh_Hant.lproj/InfoPlist.strings +++ b/Riot/Assets/zh_Hant.lproj/InfoPlist.strings @@ -1,5 +1,9 @@ // Permissions usage explanations -"NSCameraUsageDescription" = "相機權限會用來拍攝照片與影片,以及進行視訊通話。"; -"NSPhotoLibraryUsageDescription" = "照片圖庫的權限會用來傳送照片與影片。"; -"NSMicrophoneUsageDescription" = "Element需要麥克風的權限來拍攝影片、照片以及進行通話。"; -"NSContactsUsageDescription" = "為了要顯示您的聯絡人中哪些人已在使用 Element 或 Matrix,我們將會傳送聯絡資訊內的電子郵件位址與電話給您的 Matrix 身份伺服器。New Vector 不會儲存這些資訊,也不會將這些資訊用於其他目的。請檢視應用程式設定的隱私權政策頁面來取得更多資訊。"; +"NSCameraUsageDescription" = "相機權限會用來拍攝照片、影片,與進行視訊通話。"; +"NSPhotoLibraryUsageDescription" = "允許讀取照片圖庫權限並用來傳送照片與影片。"; +"NSMicrophoneUsageDescription" = "Element 需要麥克風的權限來進行語音通話、視訊通話與錄製語音訊息。"; +"NSContactsUsageDescription" = "這將會分享給身份伺服器以便在 Matrix 尋找您的聯絡人。"; +"NSLocationAlwaysAndWhenInUseUsageDescription" = "當您分享您的位置給其他人時,Element 需要權限來顯示地圖。"; +"NSLocationWhenInUseUsageDescription" = "當您分享您的位置給其他人時,Element 需要權限來顯示地圖。"; +"NSFaceIDUsageDescription" = "已啟用 Face ID 來使用您的應用程式。"; +"NSCalendarsUsageDescription" = "檢視您已排定的會議。"; diff --git a/Riot/Assets/zh_Hant.lproj/Localizable.strings b/Riot/Assets/zh_Hant.lproj/Localizable.strings index 6f3abfe1c..d0d698910 100644 --- a/Riot/Assets/zh_Hant.lproj/Localizable.strings +++ b/Riot/Assets/zh_Hant.lproj/Localizable.strings @@ -1,5 +1,5 @@ /* New message from a specific person, not referencing a room */ -"MSG_FROM_USER" = "從 %@ 來的訊息"; +"MSG_FROM_USER" = "%@ 傳來的訊息"; /* New message from a specific person in a named room */ "MSG_FROM_USER_IN_ROOM" = "%@ 在 %@ 貼文"; /* New message from a specific person, not referencing a room. Content included. */ @@ -51,3 +51,81 @@ "SINGLE_UNREAD" = "您收到了一則訊息"; /* Message title for a specific person in a named room */ "MSG_FROM_USER_IN_ROOM_TITLE" = "%@ 從 %@"; + +/* New message reply from a specific person in a named room. */ +"REPLY_FROM_USER_IN_ROOM_TITLE" = "%@ 在 %@ 已回覆"; + +/* New message reply from a specific person, not referencing a room. */ +"REPLY_FROM_USER_TITLE" = "%@ 已回覆"; +/** General **/ + +"Notification" = "通知"; + +/** Key verification **/ + +"KEY_VERIFICATION_REQUEST_FROM_USER" = "%@ 請求驗證"; + +/* Group call from user, CallKit caller name */ +"GROUP_CALL_FROM_USER" = "%@ (群組通話)"; + +/* A user added a Jitsi call to a room */ +"GROUP_CALL_STARTED" = "群組通話開始"; + +/* A user's membership has updated in an unknown way */ +"USER_MEMBERSHIP_UPDATED" = "%@ 更新了簡介"; + +/* A user has change their name to a new name which we don't know */ +"GENERIC_USER_UPDATED_DISPLAYNAME" = "%@ 變更名稱"; + +/** Membership Updates **/ + +/* A user has change their name to a new name */ +"USER_UPDATED_DISPLAYNAME" = "%@ 變更名稱為 %@"; + +/* A user has change their avatar */ +"USER_UPDATED_AVATAR" = "%@ 變更頭像"; + +/* A user has reacted to a message, but the reaction content is unknown */ +"GENERIC_REACTION_FROM_USER" = "%@ 送出一個反應"; + +/** Reactions **/ + +/* A user has reacted to a message, including the reaction e.g. "Alice reacted 👍". */ +"REACTION_FROM_USER" = "%@ 覺得 %@"; + +/* New message with hidden content due to PIN enabled */ +"MESSAGE_PROTECTED" = "新訊息"; + +/* New message indicator on a room */ +"MESSAGE_IN_X" = "在 %@ 的訊息"; + +/* New message indicator from a DM */ +"MESSAGE_FROM_X" = "來自 %@ 的訊息"; + +/** Notification messages **/ + +/* New message indicator on unknown room */ +"MESSAGE" = "訊息"; + +/* Sticker from a specific person, not referencing a room. */ +"STICKER_FROM_USER" = "%@ 戳你一下"; + +/* New file message from a specific person, not referencing a room. */ +"LOCATION_FROM_USER" = "%@ 已分享他的位置"; + +/* New file message from a specific person, not referencing a room. */ +"FILE_FROM_USER" = "%@ 傳送一個檔案 %@"; + +/* New voice message from a specific person, not referencing a room. */ +"VOICE_MESSAGE_FROM_USER" = "%@ 傳送一個語音訊息"; + +/* New audio message from a specific person, not referencing a room. */ +"AUDIO_FROM_USER" = "%@ 傳送一個音訊檔案 %@"; + +/* New video message from a specific person, not referencing a room. */ +"VIDEO_FROM_USER" = "%@ 傳送一個影片"; + +/** Media Messages **/ + +/* New image message from a specific person, not referencing a room. */ +"PICTURE_FROM_USER" = "%@ 傳送一張圖片"; diff --git a/Riot/Assets/zh_Hant.lproj/Vector.strings b/Riot/Assets/zh_Hant.lproj/Vector.strings index 28ec3da61..25c64fe45 100644 --- a/Riot/Assets/zh_Hant.lproj/Vector.strings +++ b/Riot/Assets/zh_Hant.lproj/Vector.strings @@ -88,7 +88,7 @@ "bug_report_send" = "傳送"; "room_event_action_resend" = "重新傳送"; "room_event_action_view_source" = "檢視來源"; -"room_event_action_permalink" = "永久連結"; +"room_event_action_permalink" = "複製訊息永久連結"; "room_event_action_quote" = "引用"; "room_participants_online" = "線上"; "room_details_favourite_tag" = "我的最愛"; @@ -164,9 +164,9 @@ "e2e_room_key_request_title" = "加密金鑰請求"; "e2e_room_key_request_share_without_verifying" = "不驗證就分享"; "e2e_room_key_request_ignore_request" = "忽略請求"; -"auth_reset_password_message" = "要重設您的密碼,輸入連結到您的帳號的電子郵件地址:"; +"auth_reset_password_message" = "為了重設密碼,請輸入您的電子郵件地址:"; "auth_reset_password_email_validation_message" = "電子郵件已傳送至 %@。您必須跟隨其中包含了連結,點按下面的連結。"; -"auth_reset_password_success_message" = "您的密碼已重設。\n\n您已在所有工作階段上登出,並且不會再收到推送通知。要重新啟用通知,再次於每個裝置上登入。"; +"auth_reset_password_success_message" = "您的密碼已重設。\n\n您已登出所有工作階段,並且不會再收到推送通知。如要重新啟用通知,請於每個裝置重新登入。"; "auth_add_email_and_phone_warning" = "直到 API 存在之前,尚不支援同時使用電子郵件地址和電話號碼註冊,因此只有電話號碼會被採用,但您可以在基本資料中新增電子郵件地址。"; // Chat creation "room_creation_title" = "新的聊天"; @@ -298,7 +298,7 @@ // Room Preview "room_preview_invitation_format" = "您已經透過 %@ 的邀請而加入聊天室"; "room_preview_subtitle" = "這是該聊天室的預覽。聊天室互動已被禁用。"; -"room_preview_unlinked_email_warning" = "該邀請已傳送至 %@, 但和此帳號沒有關聯。你或許會希望使用其他帳號登入,或把該電子郵件加入到你的帳戶。"; +"room_preview_unlinked_email_warning" = "該邀請已傳送至 %@, 但和此帳號沒有關聯。你或許會希望使用其他帳號登入,或把該電子郵件加入到你的帳戶。"; // Settings "settings_title" = "設定"; "room_preview_try_join_an_unknown_room" = "你正在嘗試訪問%@。您要加入已參加討論嗎?"; @@ -437,7 +437,7 @@ "group_participants_remove_prompt_title" = "確認"; "group_participants_remove_prompt_msg" = "您確定要從該群組移除 %@ 嗎?"; "group_participants_invite_prompt_title" = "確認"; -"group_participants_invite_prompt_msg" = "您確定要邀請 %@ 加入此群組嗎?"; +"group_participants_invite_prompt_msg" = "您確定要邀請 %@ 加入此群組嗎?"; "group_participants_invite_another_user" = "使用使用者 ID 或名稱搜尋/邀請使用者"; "group_participants_invite_malformed_id_title" = "邀請錯誤"; "group_participants_invite_malformed_id" = "ID 格式錯誤。一個 Matrix ID 看起來應該像 '@localpart:domain'"; @@ -472,7 +472,7 @@ "camera_access_not_granted" = "%@ 沒有使用相機的權限,請修改隱私權設定"; "large_badge_value_k_format" = "%.1fK"; // Call -"call_incoming_voice_prompt" = "來自 %@ 的語音通話"; +"call_incoming_voice_prompt" = "來自 %@ 的語音通話"; "call_incoming_video_prompt" = "來自 %@ 的視訊通話"; "call_incoming_voice" = "收到來電…"; "call_incoming_video" = "收到視訊來電…"; @@ -500,8 +500,8 @@ "bug_crash_report_description" = "請描述您在崩潰前做了什麼:"; "bug_report_logs_description" = "為了診斷問題,此用戶端的記錄檔將會隨此錯誤報告送出。 如果您只想傳送上面的文字,請取消:"; "widget_integration_missing_room_id" = "在請求中遺失 room_id 。"; -"widget_integration_missing_user_id" = "在請求中遺失 user_id 。"; -"widget_integration_room_not_visible" = "%@ 聊天室不可見。"; +"widget_integration_missing_user_id" = "在請求中遺失 user_id 。"; +"widget_integration_room_not_visible" = "%@ 聊天室為隱藏。"; // Share extension "share_extension_auth_prompt" = "登入主應用程式以分享內容"; "share_extension_failed_to_encrypt" = "傳送失敗。 檢查主應用程式對此聊天室的加密設定"; @@ -601,10 +601,10 @@ "auth_softlogout_signed_out" = "你已登出"; "auth_autodiscover_invalid_response" = "無效的自家伺服器發現回應"; "auth_accept_policies" = "請查看並接受此自家伺服器的策略:"; -"auth_reset_password_error_is_required" = "未配置身份伺服器:在伺服器選項中添加一個以便日後重置密碼。"; -"auth_forgot_password_error_no_configured_identity_server" = "未配置身份伺服器:添加一台以便重置密碼。"; -"auth_phone_is_required" = "沒有配置身份伺服器,因此您無法添加電話號碼以便將來重設密碼。"; -"auth_email_is_required" = "沒有配置身份伺服器,因此您無法添加電子郵件地址以便將來重設密碼。"; +"auth_reset_password_error_is_required" = "未設置身份伺服器:在伺服器選項中新增一個來重置密碼。"; +"auth_forgot_password_error_no_configured_identity_server" = "未設置身份伺服器:新增設置來重置密碼。"; +"auth_phone_is_required" = "沒有設置身份伺服器,因此無法新增電話號碼以便將來重設密碼。"; +"auth_email_is_required" = "沒有設置身份伺服器,因此無法新增電子郵件地址以便將來重設密碼。"; "auth_add_email_phone_message_2" = "設置一個電子郵件以便日後恢復帳戶和使以後可以由認識您的人發現你。"; "auth_add_email_message_2" = "設置一個電子郵件以便日後恢復帳戶和使以後可以由認識您的人發現你。"; "auth_add_phone_message_2" = "設置一個電話號碼,以後可以由認識您的人發現你。"; @@ -687,7 +687,7 @@ // MARK: - Call Transfer "call_transfer_title" = "傳輸"; "room_info_list_section_other" = "其他"; -"create_room_placeholder_topic" = "主題"; +"create_room_placeholder_topic" = "聊天室主題為何?"; "create_room_placeholder_name" = "名稱"; "biometrics_cant_unlocked_alert_message_retry" = "重試"; "pin_protection_reset_alert_action_reset" = "重設"; @@ -944,7 +944,7 @@ "room_event_encryption_info_block" = "黑名單"; "room_event_encryption_info_unblock" = "解除黑名單"; "room_event_encryption_verify_title" = "驗證裝置\n\n"; -"room_event_encryption_verify_message" = "若要檢查這個裝置是可被信任的,請透過其他方法聯絡所有者(例如面對面或是在電話中),並詢問在其使用者設定中以下金鑰是否是一致的:\n\n\n\t裝置名稱:%@\n\t裝置 ID:%@\n\t裝置金鑰:%@\n\n若相同,請點選下面的「驗證確認」按鈕。如果不相同,表示有人從中攔截這個裝置,您可能要點選「黑名單」按鈕。\n\n未來驗證手續會更加簡單,若有不便敬請見諒。"; +"room_event_encryption_verify_message" = "若要檢查這個裝置是可被信任的,請透過其他方法聯絡所有者(例如面對面或是在電話中),並詢問在其使用者設定中以下金鑰是否是一致的:\n\n\t裝置名稱:%@\n\t裝置 ID:%@\n\t裝置金鑰:%@\n\n若相同,請點選下面的「驗證確認」按鈕。如果不相同,表示有人從中攔截這個裝置,您可能要點選「黑名單」按鈕。\n\n未來驗證手續會更加簡單,若有不便敬請見諒。"; "room_event_encryption_verify_ok" = "驗證確認"; // Account "account_save_changes" = "儲存修改"; @@ -976,7 +976,7 @@ "microphone_access_not_granted_for_call" = "電話需要使用麥克風權限,但是 %@ 沒有存取權限"; "local_contacts_access_not_granted" = "從本機的聯絡資訊探索使用者,需要存取聯絡資訊的權限,但是 %@ 沒有存取權限"; "local_contacts_access_discovery_warning_title" = "使用者探索"; -"local_contacts_access_discovery_warning" = "%@ 要從您的聯絡資訊上傳電子郵件位址跟電話號碼來探索使用者"; +"local_contacts_access_discovery_warning" = "為了找查您的通訊錄中是否已有 Matrix 帳號的聯絡人,%@ 將會從您的通訊錄中傳送電子郵件與電話號碼到您所選擇的身分伺服器中。個人資料會在傳送前雜湊內容(無法解讀的內容),若需要更多細節,請查閱您的身分伺服器的隱私權政策。"; // Country picker "country_picker_title" = "選擇國家"; // Language picker @@ -997,8 +997,8 @@ "notice_display_name_set" = "%@ 設定了自己的顯示名稱為 %@"; "notice_display_name_changed_from" = "%@ 將自己的顯示名稱從 %@ 改為 %@"; "notice_display_name_removed" = "%@ 移除了自己的顯示名稱"; -"notice_topic_changed" = "%@ 已經變更主題為:%@"; -"notice_room_name_changed" = "%@ 將房間名稱變更為 %@"; +"notice_topic_changed" = "%@ 已變更主題為 %@。"; +"notice_room_name_changed" = "%@ 將聊天室名稱變更為 %@。"; "notice_placed_voice_call" = "%@ 開始了語音通話"; "notice_placed_video_call" = "%@ 開始了視訊通話"; "notice_answered_video_call" = "%@ 接聽了通話"; @@ -1099,7 +1099,7 @@ "notice_event_redacted_by" = " 由 %@"; "notice_event_redacted_reason" = " [理由:%@]"; "notice_profile_change_redacted" = "%@ 已更新他的個人檔案 %@"; -"notice_room_created" = "%@ 創建了該聊天室"; +"notice_room_created" = "%@ 已建立與設定此聊天室。"; "notice_room_join_rule" = "加入規則: %@"; "notice_room_power_level_intro" = "聊天室成員們的權限级别是:"; "notice_event_redacted" = "<撤回%@>"; @@ -1122,7 +1122,7 @@ "device_details_name" = "名稱\n"; "device_details_identifier" = "裝置代碼\n"; "device_details_last_seen" = "上次使用\n"; -"device_details_rename_prompt_message" = "裝置名稱:"; +"device_details_rename_prompt_message" = "公開名稱可見於與您聊天的對象"; "login_error_resource_limit_exceeded_title" = "超過資源限制"; "login_error_resource_limit_exceeded_message_default" = "此家伺服器已經超過其中一項資源限制。"; "login_error_resource_limit_exceeded_message_monthly_active_user" = "此家伺服器已經達到其每月活躍使用者限制。"; @@ -1142,3 +1142,131 @@ "stop" = "停止"; "joining" = "正在加入"; "enable" = "啓用"; +"service_terms_modal_policy_checkbox_accessibility_hint" = "確認接受 %@"; +/* The placeholder will show the homeserver's domain */ +"authentication_terms_message" = "請閱讀 %@ 的條款與政策"; +"authentication_terms_title" = "隱私權政策"; +"authentication_verify_msisdn_invalid_phone_number" = "無效的電話號碼或格式"; +"authentication_verify_msisdn_waiting_button" = "重新傳送驗證碼"; +/* The placeholder will show the phone number that was entered. */ +"authentication_verify_msisdn_waiting_message" = "驗證碼已傳送到 %@"; +"authentication_verify_msisdn_waiting_title" = "確認與驗證您的電話號碼"; +"authentication_verify_msisdn_otp_text_field_placeholder" = "驗證碼"; +"authentication_verify_msisdn_text_field_placeholder" = "電話號碼"; +/* The placeholder will show the homeserver's domain */ +"authentication_verify_msisdn_input_message" = "%@ 需要驗證您的帳號"; +"authentication_verify_msisdn_input_title" = "輸入您的電話號碼"; +"authentication_choose_password_not_verified_message" = "檢查您的郵件收件夾"; +"authentication_choose_password_not_verified_title" = "郵件信箱尚未確認與驗證"; +"authentication_choose_password_submit_button" = "重新設定密碼"; +"authentication_choose_password_signout_all_devices" = "登出所有裝置"; +"authentication_choose_password_text_field_placeholder" = "新密碼"; +"authentication_choose_password_input_message" = "確認至少 8 字元或更多"; +"authentication_choose_password_input_title" = "選擇新密碼"; +"authentication_forgot_password_waiting_button" = "重新寄送"; +/* The placeholder will show the email address that was entered. */ +"authentication_forgot_password_waiting_message" = "依照指示已寄送到 %@"; +"authentication_forgot_password_waiting_title" = "檢查或確認您的郵件信箱。"; +"authentication_forgot_password_text_field_placeholder" = "郵件信箱"; +/* The placeholder will show the homeserver's domain */ +"authentication_forgot_password_input_message" = "%@ 將會傳送驗證連結給您"; +"authentication_forgot_password_input_title" = "輸入您的電子郵件信箱"; +"authentication_verify_email_waiting_button" = "重新寄送"; +"authentication_verify_email_waiting_hint" = "還未收到信件?"; +/* The placeholder will show the email address that was entered. */ +"authentication_verify_email_waiting_message" = "依照指示已寄送到 %@"; +"authentication_verify_email_waiting_title" = "驗證您的郵件信箱。"; +"authentication_verify_email_text_field_placeholder" = "郵件信箱"; +/* The placeholder will show the homeserver's domain */ +"authentication_verify_email_input_message" = "%@ 需要驗證您的帳號"; +"authentication_verify_email_input_title" = "輸入您的電子郵件信箱"; +"authentication_cancel_flow_confirmation_message" = "您的帳號尚未建立完成,確定要退出註冊過程?"; +"authentication_server_selection_generic_error" = "這個 URL 無法找到伺服器,請確認它是否正確。"; +"authentication_server_selection_server_url" = "主伺服器 URL"; +"authentication_server_selection_register_message" = "屬於您伺服器的位置為何?是存放您所有資訊的主要地方"; +"authentication_server_selection_register_title" = "選擇您的主伺服器"; +"authentication_server_selection_login_message" = "屬於您伺服器的位置為何?"; +"authentication_server_selection_login_title" = "連線到主伺服器"; +"authentication_login_with_qr" = "透過 QR code 登入"; +"authentication_server_info_title_login" = "您的對話訊息將會被保存的位置"; +"authentication_login_forgot_password" = "忘記密碼"; +"authentication_login_username" = "使用者帳號 / 電子郵件 / 電話號碼"; +"authentication_login_title" = "歡迎回來!"; +"authentication_server_info_title" = "您的對話訊息將會被保存的位置"; +"authentication_registration_password_footer" = "至少 8 字元或更多"; +/* The placeholder will show the full Matrix ID that has been entered. */ +"authentication_registration_username_footer_available" = "其他人可以找到您 %@"; +"authentication_registration_username_footer" = "選定後就無法在之後變更修改"; +"authentication_registration_username" = "使用者帳號"; + +// MARK: Authentication +"authentication_registration_title" = "建立您的帳號"; +"onboarding_celebration_button" = "開始吧"; +"onboarding_celebration_message" = "隨時都可更新您的個人簡介"; +"onboarding_celebration_title" = "看起來不錯喔!"; +"onboarding_avatar_accessibility_label" = "簡介圖片"; +"onboarding_avatar_message" = "是時候將名字與臉孔聯繫在一起了"; +"onboarding_avatar_title" = "新增簡介圖片"; +"onboarding_display_name_max_length" = "您的顯示名稱必須小於 256 字元"; +"onboarding_display_name_hint" = "您可以之後再變更"; +"onboarding_display_name_placeholder" = "顯示名稱"; +"onboarding_display_name_message" = "當您傳送訊息這將會被顯示。"; +"onboarding_display_name_title" = "選擇顯示名稱"; +"onboarding_personalization_skip" = "略過此步驟"; +"onboarding_personalization_save" = "儲存並繼續"; +"onboarding_congratulations_home_button" = "帶我回家"; +"onboarding_congratulations_personalize_button" = "個人簡介"; +/* The placeholder string contains the user's matrix ID */ +"onboarding_congratulations_message" = "您的帳號 %@ 已建立了"; +"onboarding_congratulations_title" = "恭喜!"; +"onboarding_use_case_existing_server_button" = "連線到伺服器"; +"onboarding_use_case_existing_server_message" = "尋找加入一個存在的伺服器?"; +"onboarding_use_case_skip_button" = "略過這個問題"; +/* The placeholder string contains onboarding_use_case_skip_button as a tappable action */ +"onboarding_use_case_not_sure_yet" = "還不確定? %@"; +"onboarding_use_case_community_messaging" = "社群"; +"onboarding_use_case_work_messaging" = "團隊"; +"onboarding_use_case_personal_messaging" = "朋友與家人"; +"onboarding_use_case_message" = "我們會協助您連線"; +"onboarding_use_case_title" = "您最常聊天的對象是誰?"; +"onboarding_splash_page_4_message" = "Element 也非常適合工作場域使用。它受到世界上最安全的組織所信任。"; +"onboarding_splash_page_4_title_no_pun" = "與您的團隊通訊。"; +"onboarding_splash_page_3_message" = "端到端加密與不需要電話號碼。無廣告或資料蒐集。"; +"onboarding_splash_page_1_message" = "為您提供與在家中面對面交談時相同的隱私等級、安全且獨立的通訊。"; +"onboarding_splash_page_3_title" = "安全通訊中。"; +"onboarding_splash_page_2_message" = "選擇您的對話將保存在哪裡,一切將由您獨立掌控。透過 Matrix 連線。"; +"onboarding_splash_page_2_title" = "一切都在您的掌控之中。"; +"onboarding_splash_page_1_title" = "掌握您的對話。"; +"onboarding_splash_login_button_title" = "我已經有帳號了"; + +// MARK: Onboarding +"onboarding_splash_register_button_title" = "建立帳號"; +"accessibility_button_label" = "按鈕"; +"callbar_only_single_active_group" = "點擊加入群組通話 (%@)"; +"callbar_only_multiple_paused" = "%@ 通暫停通話"; +"callbar_only_single_paused" = "暫停通話"; +"callbar_active_and_multiple_paused" = "1 個正在進行通話 (%@) · %@ 個暫停的通話"; +"callbar_active_and_single_paused" = "1 個正在進行通話 (%@) · 1 個暫停的通話"; + +// Call Bar +"callbar_only_single_active" = "點擊返回通話 (%@)"; +"saving" = "儲存中"; + +// Activities +"loading" = "讀取中"; +"invite_to" = "邀請到 %@"; +"confirm" = "確認"; +"edit" = "編輯"; +"suggest" = "建議"; +"add" = "新增"; +"existing" = "已存在"; +"new_word" = "新增"; + +// GDPR +"gdpr_consent_not_given_alert_message" = "如要繼續使用 %@ 服務伺服器,您必須檢視與同意條款與條件。"; +"settings_callkit_info" = "在鎖定畫面接聽來電。顯示您的 %@ 通話於系統通話紀錄。若啟用 iCloud,通話紀錄將被分享給 Apple。"; +"room_many_users_are_typing" = "%@, %@ 與其他人正在輸入 …"; +/* The placeholder %1$tu will be replaced with a number and %2$@ with the user's search terms. Note the > at the start indicates "more than 20 results". */ +"directory_search_results_more_than" = ">%1$tu 個搜尋結果關於 %2$@"; +/* The placeholder %1$tu will be replaced with a number and %2$@ with the user's search terms. */ +"directory_search_results" = "%1$tu 個搜尋結果關於 %2$@"; diff --git a/Riot/Categories/MXEvent.swift b/Riot/Categories/MXEvent.swift index 5b1a85263..f0d10b3da 100644 --- a/Riot/Categories/MXEvent.swift +++ b/Riot/Categories/MXEvent.swift @@ -46,4 +46,14 @@ extension MXEvent { return self } } + + @objc + var isTimelinePollEvent: Bool { + switch eventType { + case .pollStart, .pollEnd: + return true + default: + return false + } + } } diff --git a/Riot/Categories/MXRoom+VoiceBroadcast.swift b/Riot/Categories/MXRoom+VoiceBroadcast.swift new file mode 100644 index 000000000..6d74cfaaf --- /dev/null +++ b/Riot/Categories/MXRoom+VoiceBroadcast.swift @@ -0,0 +1,54 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import MatrixSDK + +extension MXRoom { + + func stopUncompletedVoiceBroadcastIfNeeded() { + // Detection of a potential uncompleted VoiceBroadcast + // Check whether a VoiceBroadcast is in progress on the current session for this room whereas no VoiceBroadcast Service is available. + self.lastVoiceBroadcastStateEvent { event in + guard let event = event, + event.stateKey == self.mxSession.myUserId, + let eventDeviceId = event.content[VoiceBroadcastSettings.voiceBroadcastContentKeyDeviceId] as? String, + eventDeviceId == self.mxSession.myDeviceId, + let voiceBroadcastInfo = VoiceBroadcastInfo(fromJSON: event.content), + voiceBroadcastInfo.state != VoiceBroadcastInfoState.stopped.rawValue, + self.mxSession.voiceBroadcastService == nil else { + return + } + + self.mxSession.getOrCreateVoiceBroadcastService(for: self) { service in + guard let service = service else { + return + } + + service.stopVoiceBroadcast(lastChunkSequence: 0, + voiceBroadcastId: voiceBroadcastInfo.voiceBroadcastId ?? event.eventId) { response in + MXLog.debug("[MXRoom] stopUncompletedVoiceBroadcastIfNeeded stopVoiceBroadcast with response : \(response)") + self.mxSession.tearDownVoiceBroadcastService() + } + } + } + } + + func lastVoiceBroadcastStateEvent(completion: @escaping (MXEvent?) -> Void) { + self.state { roomState in + completion(roomState?.stateEvents(with: .custom(VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType))?.last) + } + } +} diff --git a/Riot/Categories/Sequence.swift b/Riot/Categories/Sequence.swift new file mode 100644 index 000000000..f3e1dfb15 --- /dev/null +++ b/Riot/Categories/Sequence.swift @@ -0,0 +1,28 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +extension Sequence { + func group(by keyPath: KeyPath) -> [GroupID: [Element]] { + var result: [GroupID: [Element]] = .init() + + for item in self { + let groupId = item[keyPath: keyPath] + result[groupId] = (result[groupId] ?? []) + [item] + } + + return result + } +} diff --git a/Riot/Generated/Images.swift b/Riot/Generated/Images.swift index fc22049bd..e9738b560 100644 --- a/Riot/Generated/Images.swift +++ b/Riot/Generated/Images.swift @@ -358,11 +358,16 @@ internal class Asset: NSObject { internal static let tabHome = ImageAsset(name: "tab_home") internal static let tabPeople = ImageAsset(name: "tab_people") internal static let tabRooms = ImageAsset(name: "tab_rooms") + internal static let voiceBroadcastBackward30s = ImageAsset(name: "voice_broadcast_backward_30s") + internal static let voiceBroadcastForward30s = ImageAsset(name: "voice_broadcast_forward_30s") internal static let voiceBroadcastLive = ImageAsset(name: "voice_broadcast_live") internal static let voiceBroadcastPause = ImageAsset(name: "voice_broadcast_pause") internal static let voiceBroadcastPlay = ImageAsset(name: "voice_broadcast_play") internal static let voiceBroadcastRecord = ImageAsset(name: "voice_broadcast_record") internal static let voiceBroadcastRecordPause = ImageAsset(name: "voice_broadcast_record_pause") + internal static let voiceBroadcastSliderMaxTrack = ImageAsset(name: "voice_broadcast_slider_max_track") + internal static let voiceBroadcastSliderMinTrack = ImageAsset(name: "voice_broadcast_slider_min_track") + internal static let voiceBroadcastSliderThumb = ImageAsset(name: "voice_broadcast_slider_thumb") internal static let voiceBroadcastSpinner = ImageAsset(name: "voice_broadcast_spinner") internal static let voiceBroadcastStop = ImageAsset(name: "voice_broadcast_stop") internal static let voiceBroadcastTileLive = ImageAsset(name: "voice_broadcast_tile_live") diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index 122ebaae5..1dde36ec4 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -4323,6 +4323,18 @@ public class VectorL10n: NSObject { public static var noticeVideoAttachment: String { return VectorL10n.tr("Vector", "notice_video_attachment") } + /// %@ ended a voice broadcast. + public static func noticeVoiceBroadcastEnded(_ p1: String) -> String { + return VectorL10n.tr("Vector", "notice_voice_broadcast_ended", p1) + } + /// You ended a voice broadcast. + public static var noticeVoiceBroadcastEndedByYou: String { + return VectorL10n.tr("Vector", "notice_voice_broadcast_ended_by_you") + } + /// Live broadcast + public static var noticeVoiceBroadcastLive: String { + return VectorL10n.tr("Vector", "notice_voice_broadcast_live") + } /// Always notify public static var notificationSettingsAlwaysNotify: String { return VectorL10n.tr("Vector", "notification_settings_always_notify") @@ -4827,6 +4839,14 @@ public class VectorL10n: NSObject { public static var pollEditFormUpdateFailureTitle: String { return VectorL10n.tr("Vector", "poll_edit_form_update_failure_title") } + /// Due to decryption errors, some votes may not be counted + public static var pollTimelineDecryptionError: String { + return VectorL10n.tr("Vector", "poll_timeline_decryption_error") + } + /// Ended the poll + public static var pollTimelineEndedText: String { + return VectorL10n.tr("Vector", "poll_timeline_ended_text") + } /// Please try again public static var pollTimelineNotClosedSubtitle: String { return VectorL10n.tr("Vector", "poll_timeline_not_closed_subtitle") @@ -7567,7 +7587,7 @@ public class VectorL10n: NSObject { public static var settingsLabsEnableThreads: String { return VectorL10n.tr("Vector", "settings_labs_enable_threads") } - /// Voice broadcast (under active development) + /// Voice broadcast public static var settingsLabsEnableVoiceBroadcast: String { return VectorL10n.tr("Vector", "settings_labs_enable_voice_broadcast") } @@ -8727,7 +8747,7 @@ public class VectorL10n: NSObject { public static var userOtherSessionPermanentlyUnverifiedAdditionalInfo: String { return VectorL10n.tr("Vector", "user_other_session_permanently_unverified_additional_info") } - /// Security recommendation + /// Other sessions public static var userOtherSessionSecurityRecommendationTitle: String { return VectorL10n.tr("Vector", "user_other_session_security_recommendation_title") } @@ -9195,6 +9215,14 @@ public class VectorL10n: NSObject { public static var voiceBroadcastUnauthorizedTitle: String { return VectorL10n.tr("Vector", "voice_broadcast_unauthorized_title") } + /// You can't start a voice message as you are currently recording a live broadcast. Please end your live broadcast in order to start recording a voice message + public static var voiceMessageBroadcastInProgressMessage: String { + return VectorL10n.tr("Vector", "voice_message_broadcast_in_progress_message") + } + /// Can't start voice message + public static var voiceMessageBroadcastInProgressTitle: String { + return VectorL10n.tr("Vector", "voice_message_broadcast_in_progress_title") + } /// Voice message public static var voiceMessageLockScreenPlaceholder: String { return VectorL10n.tr("Vector", "voice_message_lock_screen_placeholder") @@ -9311,10 +9339,18 @@ public class VectorL10n: NSObject { public static var wysiwygComposerFormatActionBold: String { return VectorL10n.tr("Vector", "wysiwyg_composer_format_action_bold") } + /// Apply inline code format + public static var wysiwygComposerFormatActionInlineCode: String { + return VectorL10n.tr("Vector", "wysiwyg_composer_format_action_inline_code") + } /// Apply italic format public static var wysiwygComposerFormatActionItalic: String { return VectorL10n.tr("Vector", "wysiwyg_composer_format_action_italic") } + /// Apply link format + public static var wysiwygComposerFormatActionLink: String { + return VectorL10n.tr("Vector", "wysiwyg_composer_format_action_link") + } /// Apply underline format public static var wysiwygComposerFormatActionStrikethrough: String { return VectorL10n.tr("Vector", "wysiwyg_composer_format_action_strikethrough") @@ -9323,6 +9359,22 @@ public class VectorL10n: NSObject { public static var wysiwygComposerFormatActionUnderline: String { return VectorL10n.tr("Vector", "wysiwyg_composer_format_action_underline") } + /// Create a link + public static var wysiwygComposerLinkActionCreateTitle: String { + return VectorL10n.tr("Vector", "wysiwyg_composer_link_action_create_title") + } + /// Edit link + public static var wysiwygComposerLinkActionEditTitle: String { + return VectorL10n.tr("Vector", "wysiwyg_composer_link_action_edit_title") + } + /// Link + public static var wysiwygComposerLinkActionLink: String { + return VectorL10n.tr("Vector", "wysiwyg_composer_link_action_link") + } + /// Text + public static var wysiwygComposerLinkActionText: String { + return VectorL10n.tr("Vector", "wysiwyg_composer_link_action_text") + } /// Attachments public static var wysiwygComposerStartActionAttachments: String { return VectorL10n.tr("Vector", "wysiwyg_composer_start_action_attachments") diff --git a/Riot/Modules/Common/Recents/Service/MatrixSDK/RecentsListService.swift b/Riot/Modules/Common/Recents/Service/MatrixSDK/RecentsListService.swift index 938361d00..069aff9a8 100644 --- a/Riot/Modules/Common/Recents/Service/MatrixSDK/RecentsListService.swift +++ b/Riot/Modules/Common/Recents/Service/MatrixSDK/RecentsListService.swift @@ -30,6 +30,7 @@ public class RecentsListService: NSObject, RecentsListServiceProtocol { public private(set) var query: String? public private(set) var space: MXSpace? private var fetchersCreated: Bool = false + private var uncompletedVoiceBroadcastCleaningDone: Bool = false // MARK: - Fetchers @@ -757,6 +758,7 @@ public class RecentsListService: NSObject, RecentsListServiceProtocol { forSection: section, totalCountsChanged: totalCountsChanged) } } else { + stopUncompletedVoiceBroadcastIfNeeded() multicastDelegate.invoke { $0.recentsListServiceDidChangeData?(self, totalCountsChanged: totalCountsChanged) } } @@ -784,6 +786,31 @@ extension RecentsListService: MXRoomListDataFetcherDelegate { } +// MARK: - VoiceBroadcast +extension RecentsListService { + + private func stopUncompletedVoiceBroadcastIfNeeded() { + guard uncompletedVoiceBroadcastCleaningDone == false, + let breadcrumbsFetcher = breadcrumbsRoomListDataFetcher else { + return + } + // We limit for the moment the uncompleted voice broadcast cleaning to the breadcrumbs rooms list + stopUncompletedVoiceBroadcastIfNeeded(for: breadcrumbsFetcher) + uncompletedVoiceBroadcastCleaningDone = true + } + + private func stopUncompletedVoiceBroadcastIfNeeded(for fetcher: MXRoomListDataFetcher) { + fetcher.data?.rooms.forEach({ roomSummary in + guard let roomSummary = roomSummary as? MXRoomSummary, + let room = roomSummary.room else { + return + } + + room.stopUncompletedVoiceBroadcastIfNeeded() + }) + } +} + // MARK: - FetcherTypes private struct FetcherTypes: OptionSet { diff --git a/Riot/Modules/Common/Recents/Service/Mock/MockRecentsListService.swift b/Riot/Modules/Common/Recents/Service/Mock/MockRecentsListService.swift index 77cecac6f..bc95f1f94 100644 --- a/Riot/Modules/Common/Recents/Service/Mock/MockRecentsListService.swift +++ b/Riot/Modules/Common/Recents/Service/Mock/MockRecentsListService.swift @@ -229,4 +229,7 @@ public class MockRecentsListService: NSObject, RecentsListServiceProtocol { multicastDelegate.invoke({ $0.recentsListServiceDidChangeData?(self, totalCountsChanged: true) }) } + public func stopUncompletedVoiceBroadcastIfNeeded(for listData: MatrixSDK.MXRoomListData?) { + // nothing here + } } diff --git a/Riot/Modules/Common/Recents/Views/RecentTableViewCell.m b/Riot/Modules/Common/Recents/Views/RecentTableViewCell.m index 2967f6e98..ea2be2eae 100644 --- a/Riot/Modules/Common/Recents/Views/RecentTableViewCell.m +++ b/Riot/Modules/Common/Recents/Views/RecentTableViewCell.m @@ -80,10 +80,7 @@ // Manage lastEventAttributedTextMessage optional property if (!roomCellData.roomSummary.spaceChildInfo && [roomCellData respondsToSelector:@selector(lastEventAttributedTextMessage)]) { - // Force the default text color for the last message (cancel highlighted message color) - NSMutableAttributedString *lastEventDescription = [[NSMutableAttributedString alloc] initWithAttributedString:roomCellData.lastEventAttributedTextMessage]; - [lastEventDescription addAttribute:NSForegroundColorAttributeName value:ThemeService.shared.theme.textSecondaryColor range:NSMakeRange(0, lastEventDescription.length)]; - self.lastEventDescription.attributedText = lastEventDescription; + self.lastEventDescription.attributedText = roomCellData.lastEventAttributedTextMessage; } else { diff --git a/Riot/Modules/MatrixKit/MatrixKit-Bridging-Header.h b/Riot/Modules/MatrixKit/MatrixKit-Bridging-Header.h index 909854dc6..635a2037c 100644 --- a/Riot/Modules/MatrixKit/MatrixKit-Bridging-Header.h +++ b/Riot/Modules/MatrixKit/MatrixKit-Bridging-Header.h @@ -16,3 +16,4 @@ #import "MXKImageView.h" #import "MXKRoomBubbleCellData.h" #import "UserIndicatorCancel.h" +#import "VoiceBroadcastInfo.h" diff --git a/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m index 0e7f5461c..862c2ba42 100644 --- a/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m +++ b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m @@ -1916,8 +1916,15 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; MXRoomSummary *summary = room.summary; if (summary) { + NSString *eventId = summary.lastMessage.eventId; + if (!eventId) + { + MXLogFailure(@"[MXKAccount] onDateTimeFormatUpdate: Missing event id"); + continue; + } + dispatch_group_enter(dispatchGroup); - [summary.mxSession eventWithEventId:summary.lastMessage.eventId + [summary.mxSession eventWithEventId:eventId inRoom:summary.roomId success:^(MXEvent *event) { diff --git a/Riot/Modules/MatrixKit/Models/MXKAppSettings.m b/Riot/Modules/MatrixKit/Models/MXKAppSettings.m index 40cf2ebd3..ed523160e 100644 --- a/Riot/Modules/MatrixKit/Models/MXKAppSettings.m +++ b/Riot/Modules/MatrixKit/Models/MXKAppSettings.m @@ -148,6 +148,8 @@ static NSString *const kMXAppGroupID = @"group.org.matrix"; kMXEventTypeStringKeyVerificationDone, kMXEventTypeStringPollStart, kMXEventTypeStringPollStartMSC3381, + kMXEventTypeStringPollEnd, + kMXEventTypeStringPollEndMSC3381, kMXEventTypeStringBeaconInfo, kMXEventTypeStringBeaconInfoMSC3672 ].mutableCopy; @@ -181,6 +183,8 @@ static NSString *const kMXAppGroupID = @"group.org.matrix"; kMXEventTypeStringKeyVerificationDone, kMXEventTypeStringPollStart, kMXEventTypeStringPollStartMSC3381, + kMXEventTypeStringPollEnd, + kMXEventTypeStringPollEndMSC3381, kMXEventTypeStringBeaconInfo, kMXEventTypeStringBeaconInfoMSC3672 ].mutableCopy; diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m index ed649ebf7..d8f55bf14 100644 --- a/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m @@ -2881,10 +2881,21 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { return NO; } - if (event.eventType == MXEventTypePollStart) { + if (event.isTimelinePollEvent) { return YES; } + // Specific case for voice broadcast event + if (event.eventType == MXEventTypeCustom && + [event.type isEqualToString:VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType]) { + + // Ensures that we only support reactions for a start event + VoiceBroadcastInfo* voiceBroadcastInfo = [VoiceBroadcastInfo modelFromJSON: event.content]; + if ([VoiceBroadcastInfo isStartedFor: voiceBroadcastInfo.state]) { + return YES; + } + } + BOOL isRoomMessage = (event.eventType == MXEventTypeRoomMessage); if (!isRoomMessage) { diff --git a/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m index 63f86617a..21be58eb1 100644 --- a/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m +++ b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m @@ -1606,6 +1606,23 @@ static NSString *const kHTMLATagRegexPattern = @"( } break; } + case MXEventTypePollEnd: + { + if (event.isEditEvent) + { + return nil; + } + + MXEvent* pollStartedEvent = [self->mxSession.store eventWithEventId:event.relatesTo.eventId inRoom:event.roomId]; + + if (pollStartedEvent) { + displayText = [MXEventContentPollStart modelFromJSON:pollStartedEvent.content].question; + } else { + displayText = [VectorL10n pollTimelineEndedText]; + } + + break; + } case MXEventTypePollStart: { if (event.isEditEvent) @@ -1983,8 +2000,8 @@ static NSString *const kHTMLATagRegexPattern = @"( } // Replace
In reply to - // By
['In reply to' from resources] - // To disable the link and to localize the "In reply to" string + // By
['In reply to' from resources] + // To localize the "In reply to" string // This link is the first HTML node of the html string if (inReplyToTextRange.location != NSNotFound) @@ -1992,11 +2009,6 @@ static NSString *const kHTMLATagRegexPattern = @"( html = [html stringByReplacingCharactersInRange:inReplyToTextRange withString:[VectorL10n noticeInReplyTo]]; } - if (inReplyToLinkRange.location != NSNotFound) - { - html = [html stringByReplacingCharactersInRange:inReplyToLinkRange withString:@"#"]; - } - return html; } diff --git a/Riot/Modules/Room/CellData/RoomBubbleCellData.m b/Riot/Modules/Room/CellData/RoomBubbleCellData.m index 712604203..219f67481 100644 --- a/Riot/Modules/Room/CellData/RoomBubbleCellData.m +++ b/Riot/Modules/Room/CellData/RoomBubbleCellData.m @@ -152,6 +152,7 @@ NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotificat break; } case MXEventTypePollStart: + case MXEventTypePollEnd: { self.tag = RoomBubbleCellDataTagPoll; self.collapsable = NO; @@ -295,6 +296,14 @@ NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotificat { [self updateBeaconInfoSummaryWithId:eventId andEvent:event]; } + + // Handle here the case where an audio chunk of a voice broadcast have been decrypted with delay + // We take the opportunity of this update to disable the display of this chunk in the room timeline + if (event.eventType == MXEventTypeRoomMessage && event.content[VoiceBroadcastSettings.voiceBroadcastContentKeyChunkType]) { + self.tag = RoomBubbleCellDataTagVoiceBroadcastNoDisplay; + self.collapsable = NO; + self.collapsed = NO; + } return retVal; } @@ -626,9 +635,11 @@ NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotificat { __block NSInteger firstVisibleComponentIndex = NSNotFound; - BOOL isPoll = (self.events.firstObject.eventType == MXEventTypePollStart); + MXEvent *firstEvent = self.events.firstObject; + BOOL isPoll = firstEvent.isTimelinePollEvent; + BOOL isVoiceBroadcast = (firstEvent.eventType == MXEventTypeCustom && [firstEvent.type isEqualToString: VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType]); - if ((isPoll || self.attachment) && self.bubbleComponents.count) + if ((isPoll || self.attachment || isVoiceBroadcast) && self.bubbleComponents.count) { firstVisibleComponentIndex = 0; } @@ -1183,6 +1194,10 @@ NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotificat shouldAddEvent = NO; break; case MXEventTypePollStart: + case MXEventTypePollEnd: + shouldAddEvent = NO; + break; + case MXEventTypeBeaconInfo: shouldAddEvent = NO; break; case MXEventTypeCustom: diff --git a/Riot/Modules/Room/RoomCoordinator.swift b/Riot/Modules/Room/RoomCoordinator.swift index 35caf9084..f249b863c 100644 --- a/Riot/Modules/Room/RoomCoordinator.swift +++ b/Riot/Modules/Room/RoomCoordinator.swift @@ -138,9 +138,9 @@ final class RoomCoordinator: NSObject, RoomCoordinatorProtocol { // Add `roomViewController` to the NavigationRouter, only if it has been explicitly set as parameter if let navigationRouter = self.parameters.navigationRouter { if navigationRouter.modules.isEmpty == false { - navigationRouter.push(self.roomViewController, animated: true, popCompletion: nil) + navigationRouter.push(self, animated: true, popCompletion: nil) } else { - navigationRouter.setRootModule(self.roomViewController, popCompletion: nil) + navigationRouter.setRootModule(self, popCompletion: nil) } } } diff --git a/Riot/Modules/Room/RoomViewController.h b/Riot/Modules/Room/RoomViewController.h index 5b162b58c..14bab4c05 100644 --- a/Riot/Modules/Room/RoomViewController.h +++ b/Riot/Modules/Room/RoomViewController.h @@ -34,6 +34,7 @@ @class ThreadsCoordinatorBridgePresenter; @class LiveLocationSharingBannerView; @class VoiceBroadcastService; +@class ComposerLinkActionBridgePresenter; NS_ASSUME_NONNULL_BEGIN @@ -122,6 +123,8 @@ extern NSTimeInterval const kResizeComposerAnimationDuration; @property (nonatomic) CGFloat wysiwygTranslation; +@property (nonatomic, strong, nullable) ComposerLinkActionBridgePresenter *composerLinkActionBridgePresenter; + /** Retrieve the live data source in cases where the timeline is not live. diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index af70b5aeb..a5c28e3c0 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -1078,6 +1078,9 @@ static CGSize kThreadListBarButtonItemImageSize; // Set room title view [self refreshRoomTitle]; + + // Stop any pending voice broadcast if needed + [self stopUncompletedVoiceBroadcastIfNeeded]; } else { @@ -2470,6 +2473,9 @@ static CGSize kThreadListBarButtonItemImageSize; return; } + // Prevents listening a VB when recording a new one + [VoiceBroadcastPlaybackProvider.shared pausePlaying]; + // Request the voice broadcast service to start recording - No service is returned if someone else is already broadcasting in the room [session getOrCreateVoiceBroadcastServiceFor:self.roomDataSource.room completion:^(VoiceBroadcastService *voiceBroadcastService) { if (voiceBroadcastService) { @@ -3999,7 +4005,7 @@ static CGSize kThreadListBarButtonItemImageSize; }]]; } - if (!isJitsiCallEvent && selectedEvent.eventType != MXEventTypePollStart && + if (!isJitsiCallEvent && !selectedEvent.isTimelinePollEvent && selectedEvent.eventType != MXEventTypeBeaconInfo) { [self.eventMenuBuilder addItemWithType:EventMenuItemTypeQuote @@ -4020,7 +4026,7 @@ static CGSize kThreadListBarButtonItemImageSize; } if (selectedEvent.sentState == MXEventSentStateSent && - selectedEvent.eventType != MXEventTypePollStart && + !selectedEvent.isTimelinePollEvent && // Forwarding of live-location shares still to be implemented selectedEvent.eventType != MXEventTypeBeaconInfo) { @@ -4036,7 +4042,7 @@ static CGSize kThreadListBarButtonItemImageSize; }]]; } - if (!isJitsiCallEvent && BWIBuildSettings.shared.messageDetailsAllowShare && selectedEvent.eventType != MXEventTypePollStart && + if (!isJitsiCallEvent && BWIBuildSettings.shared.messageDetailsAllowShare && !selectedEvent.isTimelinePollEvent && selectedEvent.eventType != MXEventTypeBeaconInfo) { [self.eventMenuBuilder addItemWithType:EventMenuItemTypeShare @@ -7185,6 +7191,7 @@ static CGSize kThreadListBarButtonItemImageSize; case MXEventTypeKeyVerificationDone: case MXEventTypeKeyVerificationCancel: case MXEventTypePollStart: + case MXEventTypePollEnd: case MXEventTypeBeaconInfo: result = NO; break; @@ -7942,6 +7949,20 @@ static CGSize kThreadListBarButtonItemImageSize; }]; } +- (BOOL)voiceMessageControllerDidRequestRecording:(VoiceMessageController *)voiceMessageController +{ + MXSession* session = self.roomDataSource.mxSession; + // Check whether the user is not already broadcasting here or in another room + if (session.voiceBroadcastService) + { + [self showAlertWithTitle:[VectorL10n voiceMessageBroadcastInProgressTitle] message:[VectorL10n voiceMessageBroadcastInProgressMessage]]; + + return NO; + } + + return YES; +} + - (void)voiceMessageController:(VoiceMessageController *)voiceMessageController didRequestSendForFileAtURL:(NSURL *)url duration:(NSUInteger)duration diff --git a/Riot/Modules/Room/RoomViewController.swift b/Riot/Modules/Room/RoomViewController.swift index 9204eeae9..561cc382a 100644 --- a/Riot/Modules/Room/RoomViewController.swift +++ b/Riot/Modules/Room/RoomViewController.swift @@ -15,6 +15,7 @@ // import UIKit +import WysiwygComposer extension RoomViewController { // MARK: - Override @@ -243,6 +244,13 @@ extension RoomViewController { roomInputToolbarContainer.superview?.isHidden = isHidden } } + + @objc func didSendLinkAction(_ linkAction: LinkActionWrapper) { + let presenter = ComposerLinkActionBridgePresenter(linkAction: linkAction) + presenter.delegate = self + composerLinkActionBridgePresenter = presenter + presenter.present(from: self, animated: true) + } } // MARK: - Private Helpers @@ -281,3 +289,37 @@ private extension RoomViewController { } } } + +extension RoomViewController: ComposerLinkActionBridgePresenterDelegate { + func didRequestLinkOperation(_ linkOperation: WysiwygLinkOperation) { + dismissPresenter { [weak self] in + self?.wysiwygInputToolbar?.performLinkOperation(linkOperation) + } + } + + func didDismissInteractively() { + cleanup() + } + + func didCancel() { + dismissPresenter(completion: nil) + } + + private func dismissPresenter(completion: (() -> Void)?) { + self.composerLinkActionBridgePresenter?.dismiss(animated: true) { [weak self] in + completion?() + self?.cleanup() + } + } + + private func cleanup() { + composerLinkActionBridgePresenter = nil + } +} + +// MARK: - VoiceBroadcast +extension RoomViewController { + @objc func stopUncompletedVoiceBroadcastIfNeeded() { + self.roomDataSource?.room.stopUncompletedVoiceBroadcastIfNeeded() + } +} diff --git a/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/Poll/PollPlainCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/Poll/PollPlainCell.swift index 70cf4370f..13ed8711d 100644 --- a/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/Poll/PollPlainCell.swift +++ b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/Poll/PollPlainCell.swift @@ -23,11 +23,13 @@ class PollPlainCell: SizableBaseRoomCell, RoomCellReactionsDisplayable, RoomCell override func render(_ cellData: MXKCellData!) { super.render(cellData) - guard let contentView = roomCellContentView?.innerContentView, - let bubbleData = cellData as? RoomBubbleCellData, - let event = bubbleData.events.last, - event.eventType == __MXEventType.pollStart, - let controller = TimelinePollProvider.shared.buildTimelinePollVCForEvent(event) else { + guard + let contentView = roomCellContentView?.innerContentView, + let bubbleData = cellData as? RoomBubbleCellData, + let event = bubbleData.events.last, + event.isTimelinePollEvent, + let controller = TimelinePollProvider.shared.buildTimelinePollVCForEvent(event) + else { return } diff --git a/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Playback/VoiceBroadcastPlaybackPlainCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Playback/VoiceBroadcastPlaybackPlainCell.swift index f673bebee..85a697fb1 100644 --- a/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Playback/VoiceBroadcastPlaybackPlainCell.swift +++ b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Playback/VoiceBroadcastPlaybackPlainCell.swift @@ -55,6 +55,14 @@ class VoiceBroadcastPlaybackPlainCell: SizableBaseRoomCell, RoomCellReactionsDis delegate.cell(self, didRecognizeAction: kMXKRoomBubbleCellTapOnContentView, userInfo: [kMXKRoomBubbleCellEventKey: event]) } + + // The normal flow for long press on cell content views doesn't work for bubbles without attributed strings + override func onLongPressGesture(_ longPressGestureRecognizer: UILongPressGestureRecognizer!) { + guard let event = self.event else { + return + } + delegate.cell(self, didRecognizeAction: kMXKRoomBubbleCellLongPressOnEvent, userInfo: [kMXKRoomBubbleCellEventKey: event]) + } } extension VoiceBroadcastPlaybackPlainCell: RoomCellThreadSummaryDisplayable {} diff --git a/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderPlainCell.swift b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderPlainCell.swift index 43047cfba..9cc4df73b 100644 --- a/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderPlainCell.swift +++ b/Riot/Modules/Room/TimelineCells/Styles/Plain/Cells/VoiceBroadcast/Recorder/VoiceBroadcastRecorderPlainCell.swift @@ -54,6 +54,14 @@ class VoiceBroadcastRecorderPlainCell: SizableBaseRoomCell, RoomCellReactionsDis delegate.cell(self, didRecognizeAction: kMXKRoomBubbleCellTapOnContentView, userInfo: [kMXKRoomBubbleCellEventKey: event]) } + // The normal flow for long press on cell content views doesn't work for bubbles without attributed strings + override func onLongPressGesture(_ longPressGestureRecognizer: UILongPressGestureRecognizer!) { + guard let event = self.event else { + return + } + delegate.cell(self, didRecognizeAction: kMXKRoomBubbleCellLongPressOnEvent, userInfo: [kMXKRoomBubbleCellEventKey: event]) + } + func addVoiceBroadcastView(_ voiceBroadcastView: UIView, on contentView: UIView) { self.voiceBroadcastView?.removeFromSuperview() diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h index 4e351806c..af84b462d 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h @@ -20,6 +20,7 @@ @class RoomActionsBar; @class RoomInputToolbarView; +@class LinkActionWrapper; /** Destination of the message in the composer @@ -77,6 +78,8 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode) - (void)didChangeMaximisedState: (BOOL) isMaximised; +- (void)didSendLinkAction: (LinkActionWrapper *)linkAction; + @end /** diff --git a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift index 5b9c7d4a7..7956ad107 100644 --- a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift +++ b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift @@ -42,7 +42,11 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp private var heightConstraint: NSLayoutConstraint! private var voiceMessageBottomConstraint: NSLayoutConstraint? private var hostingViewController: VectorHostingController! - private var wysiwygViewModel = WysiwygComposerViewModel(textColor: ThemeService.shared().theme.colors.primaryContent) + private var wysiwygViewModel = WysiwygComposerViewModel( + textColor: ThemeService.shared().theme.colors.primaryContent, + linkColor: ThemeService.shared().theme.colors.accent, + codeBackgroundColor: ThemeService.shared().theme.selectedBackgroundColor + ) private var viewModel: ComposerViewModelProtocol! private var isLandscapePhone: Bool { @@ -212,6 +216,13 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp wysiwygViewModel.maximised = false } + func performLinkOperation(_ linkOperation: WysiwygLinkOperation) { + if let selectionToRestore = viewModel.selectionToRestore { + wysiwygViewModel.select(range: selectionToRestore) + } + wysiwygViewModel.applyLinkOperation(linkOperation) + } + // MARK: - Private @objc private func keyboardWillShow(_ notification: Notification) { @@ -258,9 +269,11 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp private func handleViewModelResult(_ result: ComposerViewModelResult) { switch result { case .cancel: - self.toolbarViewDelegate?.roomInputToolbarViewDidTapCancel(self) + toolbarViewDelegate?.roomInputToolbarViewDidTapCancel(self) case let .contentDidChange(isEmpty): setVoiceMessageToolbarIsHidden(!isEmpty) + case let .linkTapped(linkAction): + toolbarViewDelegate?.didSendLinkAction(LinkActionWrapper(linkAction)) } } @@ -286,6 +299,8 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp private func update(theme: Theme) { hostingViewController.view.backgroundColor = theme.colors.background wysiwygViewModel.textColor = theme.colors.primaryContent + wysiwygViewModel.linkColor = theme.colors.accent + wysiwygViewModel.codeBackgroundColor = theme.selectedBackgroundColor } private func updateTextViewHeight() { diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageAttachmentCacheManager.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessageAttachmentCacheManager.swift index dc46839e3..8e31a229c 100644 --- a/Riot/Modules/Room/VoiceMessages/VoiceMessageAttachmentCacheManager.swift +++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageAttachmentCacheManager.swift @@ -137,10 +137,12 @@ class VoiceMessageAttachmentCacheManager { durations.removeAll() finalURLs.removeAll() - do { - try FileManager.default.removeItem(at: temporaryFilesFolderURL) - } catch { - MXLog.error("[VoiceMessageAttachmentCacheManager] Failed clearing cached disk files", context: error) + if FileManager.default.fileExists(atPath: temporaryFilesFolderURL.path) { + do { + try FileManager.default.removeItem(at: temporaryFilesFolderURL) + } catch { + MXLog.error("[VoiceMessageAttachmentCacheManager] Failed clearing cached disk files", context: error) + } } } diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageController.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessageController.swift index 85cae9941..fa5058a3f 100644 --- a/Riot/Modules/Room/VoiceMessages/VoiceMessageController.swift +++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageController.swift @@ -20,6 +20,7 @@ import DSWaveformImage @objc public protocol VoiceMessageControllerDelegate: AnyObject { func voiceMessageControllerDidRequestMicrophonePermission(_ voiceMessageController: VoiceMessageController) + func voiceMessageControllerDidRequestRecording(_ voiceMessageController: VoiceMessageController) -> Bool func voiceMessageController(_ voiceMessageController: VoiceMessageController, didRequestSendForFileAtURL url: URL, duration: UInt, samples: [Float]?, completion: @escaping (Bool) -> Void) } @@ -106,6 +107,13 @@ public class VoiceMessageController: NSObject, VoiceMessageToolbarViewDelegate, guard let temporaryFileURL = temporaryFileURL else { return } + + // Ask our delegate if we can start recording + let canStartRecording = delegate?.voiceMessageControllerDidRequestRecording(self) ?? true + guard canStartRecording else { + return + } + guard AVAudioSession.sharedInstance().recordPermission == .granted else { delegate?.voiceMessageControllerDidRequestMicrophonePermission(self) return @@ -364,7 +372,8 @@ public class VoiceMessageController: NSObject, VoiceMessageToolbarViewDelegate, } private func deleteRecordingAtURL(_ url: URL?) { - guard let url = url else { + // Fix: use url.path instead of url.absoluteString when using FileManager otherwise the url seems to be percent encoded and the file is not found. + guard let url = url, FileManager.default.fileExists(atPath: url.path) else { return } diff --git a/Riot/Modules/Threads/ThreadList/ThreadListViewController.swift b/Riot/Modules/Threads/ThreadList/ThreadListViewController.swift index f4075a0cf..7c532be5a 100644 --- a/Riot/Modules/Threads/ThreadList/ThreadListViewController.swift +++ b/Riot/Modules/Threads/ThreadList/ThreadListViewController.swift @@ -162,7 +162,7 @@ final class ThreadListViewController: UIViewController { private func renderLoading() { emptyView.isHidden = true - threadsTableView.isHidden = true + threadsTableView.isHidden = viewModel.numberOfThreads == 0 self.activityPresenter.presentActivityIndicator(on: self.view, animated: true) } @@ -352,6 +352,10 @@ extension ThreadListViewController: UITableViewDelegate { cell.backgroundColor = theme.backgroundColor cell.selectedBackgroundView = UIView() cell.selectedBackgroundView?.backgroundColor = theme.selectedBackgroundColor + + if indexPath.row == viewModel.numberOfThreads - 1 { + viewModel.process(viewAction: .loadData) + } } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { diff --git a/Riot/Modules/Threads/ThreadList/ThreadListViewModel.swift b/Riot/Modules/Threads/ThreadList/ThreadListViewModel.swift index 29ae93b02..16620a621 100644 --- a/Riot/Modules/Threads/ThreadList/ThreadListViewModel.swift +++ b/Riot/Modules/Threads/ThreadList/ThreadListViewModel.swift @@ -29,7 +29,7 @@ final class ThreadListViewModel: ThreadListViewModelProtocol { private var threads: [MXThreadProtocol] = [] private var eventFormatter: MXKEventFormatter? private var roomState: MXRoomState? - + private var nextBatch: String? private var currentOperation: MXHTTPOperation? private var longPressedThread: MXThreadProtocol? @@ -71,6 +71,7 @@ final class ThreadListViewModel: ThreadListViewModelProtocol { viewState = .showingFilterTypes case .selectFilterType(let type): selectedFilterType = type + resetData() loadData() case .selectThread(let index): selectThread(index) @@ -230,7 +231,15 @@ final class ThreadListViewModel: ThreadListViewModelProtocol { ) } + private func resetData() { + nextBatch = nil + threads = [] + } + private func loadData(showLoading: Bool = true) { + guard threads.isEmpty || nextBatch != nil else { + return + } if showLoading { viewState = .loading @@ -245,12 +254,12 @@ final class ThreadListViewModel: ThreadListViewModelProtocol { onlyParticipated = true } - session.threadingService.allThreads(inRoom: roomId, - onlyParticipated: onlyParticipated) { [weak self] response in + session.threadingService.allThreads(inRoom: roomId, from: nextBatch, onlyParticipated: onlyParticipated) { [weak self] response in guard let self = self else { return } switch response { - case .success(let threads): - self.threads = threads + case .success(let value): + self.threads = self.threads + value.threads + self.nextBatch = value.nextBatch self.threadsLoaded() case .failure(let error): MXLog.error("[ThreadListViewModel] loadData", context: error) diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfo.h b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/MatrixSDK/VoiceBroadcastInfo.h similarity index 100% rename from Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfo.h rename to Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/MatrixSDK/VoiceBroadcastInfo.h diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfo.m b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/MatrixSDK/VoiceBroadcastInfo.m similarity index 100% rename from Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfo.m rename to Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/MatrixSDK/VoiceBroadcastInfo.m diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfo.swift b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/MatrixSDK/VoiceBroadcastInfo.swift similarity index 100% rename from Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfo.swift rename to Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/MatrixSDK/VoiceBroadcastInfo.swift diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfoState.swift b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/MatrixSDK/VoiceBroadcastInfoState.swift similarity index 100% rename from Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfoState.swift rename to Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/MatrixSDK/VoiceBroadcastInfoState.swift diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastSettings.swift b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/MatrixSDK/VoiceBroadcastSettings.swift similarity index 100% rename from Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastSettings.swift rename to Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/MatrixSDK/VoiceBroadcastSettings.swift diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastAggregator.swift b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastAggregator.swift index 31dd0045b..f9afbadc1 100644 --- a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastAggregator.swift +++ b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastAggregator.swift @@ -68,9 +68,7 @@ public class VoiceBroadcastAggregator { public var delegate: VoiceBroadcastAggregatorDelegate? deinit { - if let referenceEventsListener = referenceEventsListener { - room.removeListener(referenceEventsListener) - } + self.stop() } public init(session: MXSession, room: MXRoom, voiceBroadcastStartEventId: String, voiceBroadcastState: VoiceBroadcastInfoState) throws { @@ -103,6 +101,10 @@ public class VoiceBroadcastAggregator { currentUserIdentifier: session.myUserId) } + private func registerEventDidDecryptNotification() { + NotificationCenter.default.addObserver(self, selector: #selector(eventDidDecrypt), name: NSNotification.Name.mxEventDidDecrypt, object: nil) + } + @objc private func handleRoomDataFlush(sender: Notification) { guard let room = sender.object as? MXRoom, room == self.room else { return @@ -112,20 +114,65 @@ public class VoiceBroadcastAggregator { MXLog.warning("[VoiceBroadcastAggregator] handleRoomDataFlush is not supported yet") } + @objc private func eventDidDecrypt(sender: Notification) { + guard let event = sender.object as? MXEvent else { return } + + self.handleEvent(event: event) + } + + private func handleEvent(event: MXEvent, direction: MXTimelineDirection? = nil, roomState: MXRoomState? = nil) { + switch event.eventType { + case .roomMessage: + self.updateVoiceBroadcast(event: event) + case .custom: + if event.type == VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType { + self.updateState() + } + default: + break + } + } + + private func updateVoiceBroadcast(event: MXEvent) { + guard event.sender == self.voiceBroadcastSenderId, + let relatedEventId = event.relatesTo?.eventId, + relatedEventId == self.voiceBroadcastStartEventId, + event.content[VoiceBroadcastSettings.voiceBroadcastContentKeyChunkType] != nil else { + return + } + + if !self.events.contains(where: { $0.eventId == event.eventId }) { + self.events.append(event) + MXLog.debug("[VoiceBroadcastAggregator] Got a new chunk for broadcast \(relatedEventId). Total: \(self.events.count)") + + if let chunk = self.voiceBroadcastBuilder.buildChunk(event: event, mediaManager: self.session.mediaManager, voiceBroadcastStartEventId: self.voiceBroadcastStartEventId) { + self.delegate?.voiceBroadcastAggregator(self, didReceiveChunk: chunk) + } + + self.voiceBroadcast = self.voiceBroadcastBuilder.build(mediaManager: self.session.mediaManager, + voiceBroadcastStartEventId: self.voiceBroadcastStartEventId, + voiceBroadcastInvoiceBroadcastStartEventContent: self.voiceBroadcastInfoStartEventContent, + events: self.events, + currentUserIdentifier: self.session.myUserId) + } + } + private func updateState() { - self.room.state { roomState in - guard let event = roomState?.stateEvents(with: .custom(VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType))?.last, + // This update is useful only in case of a live broadcast (The aggregator considers the broadcast stopped by default) + // We will consider here only the most recent voice broadcast state event + self.room.lastVoiceBroadcastStateEvent { event in + guard let event = event, event.stateKey == self.voiceBroadcastSenderId, let voiceBroadcastInfo = VoiceBroadcastInfo(fromJSON: event.content), (event.eventId == self.voiceBroadcastStartEventId || voiceBroadcastInfo.voiceBroadcastId == self.voiceBroadcastStartEventId), let state = VoiceBroadcastInfoState(rawValue: voiceBroadcastInfo.state) else { return } - + self.delegate?.voiceBroadcastAggregator(self, didReceiveState: state) } } - + func start() { guard launchState == .idle else { return @@ -149,38 +196,10 @@ public class VoiceBroadcastAggregator { self.events.append(contentsOf: filteredChunk) let eventTypes = [VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType, kMXEventTypeStringRoomMessage] - self.referenceEventsListener = self.room.listen(toEventsOfTypes: eventTypes) { [weak self] event, direction, state in - - guard let self = self else { - return - } - - if event.eventType == .roomMessage { - guard event.sender == self.voiceBroadcastSenderId, - let relatedEventId = event.relatesTo?.eventId, - relatedEventId == self.voiceBroadcastStartEventId, - event.content[VoiceBroadcastSettings.voiceBroadcastContentKeyChunkType] != nil else { - return - } - - if !self.events.contains(where: { $0.eventId == event.eventId }) { - self.events.append(event) - MXLog.debug("[VoiceBroadcastAggregator] Got a new chunk for broadcast \(relatedEventId). Total: \(self.events.count)") - - if let chunk = self.voiceBroadcastBuilder.buildChunk(event: event, mediaManager: self.session.mediaManager, voiceBroadcastStartEventId: self.voiceBroadcastStartEventId) { - self.delegate?.voiceBroadcastAggregator(self, didReceiveChunk: chunk) - } - - self.voiceBroadcast = self.voiceBroadcastBuilder.build(mediaManager: self.session.mediaManager, - voiceBroadcastStartEventId: self.voiceBroadcastStartEventId, - voiceBroadcastInvoiceBroadcastStartEventContent: self.voiceBroadcastInfoStartEventContent, - events: self.events, - currentUserIdentifier: self.session.myUserId) - } - } else { - self.updateState() - } - } as Any + self.referenceEventsListener = self.room.listen(toEventsOfTypes: eventTypes, onEvent: { [weak self] event, direction, roomState in + self?.handleEvent(event: event, direction: direction, roomState: roomState) + }) as Any + self.registerEventDidDecryptNotification() self.events.forEach { event in guard let chunk = self.voiceBroadcastBuilder.buildChunk(event: event, mediaManager: self.session.mediaManager, voiceBroadcastStartEventId: self.voiceBroadcastStartEventId) else { @@ -212,4 +231,13 @@ public class VoiceBroadcastAggregator { self.delegate?.voiceBroadcastAggregator(self, didFailWithError: error) } } + + func stop() { + if let referenceEventsListener = referenceEventsListener { + room.removeListener(referenceEventsListener) + } + + NotificationCenter.default.removeObserver(self, name: NSNotification.Name.mxEventDidDecrypt, object: nil) + NotificationCenter.default.removeObserver(self, name: NSNotification.Name.mxRoomDidFlushData, object: nil) + } } diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastService.swift b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastService.swift index b386f0d4d..8c707eab2 100644 --- a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastService.swift +++ b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastService.swift @@ -78,8 +78,14 @@ public class VoiceBroadcastService: NSObject { /// stop a voice broadcast info. /// - Parameters: /// - lastChunkSequence: The last sent chunk number. + /// - voiceBroadcastId: The VoiceBroadcast identifier to stop. Use it only to force stop a specific VoiceBroadcast. /// - completion: A closure called when the operation completes. Provides the event id of the event generated on the home server on success. - func stopVoiceBroadcast(lastChunkSequence: Int, completion: @escaping (MXResponse) -> Void) { + func stopVoiceBroadcast(lastChunkSequence: Int, + voiceBroadcastId: String? = nil, + completion: @escaping (MXResponse) -> Void) { + if let voiceBroadcastId = voiceBroadcastId { + self.voiceBroadcastId = voiceBroadcastId + } sendVoiceBroadcastInfo(lastChunkSequence: lastChunkSequence, state: VoiceBroadcastInfoState.stopped, completion: completion) } @@ -132,7 +138,7 @@ public class VoiceBroadcastService: NSObject { case .resumed: return [.paused, .stopped] case .stopped: - return [.started] + return [.started, .stopped] } } diff --git a/Riot/Utils/EventFormatter.m b/Riot/Utils/EventFormatter.m index 68e1f238e..a6e02ec59 100644 --- a/Riot/Utils/EventFormatter.m +++ b/Riot/Utils/EventFormatter.m @@ -556,6 +556,40 @@ static NSString *const kEventFormatterTimeFormat = @"HH:mm"; } #pragma mark - MXRoomSummaryUpdating +- (BOOL)session:(MXSession *)session updateRoomSummary:(MXRoomSummary *)summary withLastEvent:(MXEvent *)event eventState:(MXRoomState *)eventState roomState:(MXRoomState *)roomState { + + // Do not display voice broadcast chunk in last message. + if (event.eventType == MXEventTypeRoomMessage && event.content[VoiceBroadcastSettings.voiceBroadcastContentKeyChunkType]) + { + return NO; + } + + // Update last message if we have a voice broadcast in the room. + if ([event.type isEqualToString:VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType]) + { + return [self session:session updateRoomSummary:summary withVoiceBroadcastInfoStateEvent:event roomState:roomState]; + } + else + { + MXEvent *stateEvent = [roomState stateEventsWithType:VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType].lastObject; + if (stateEvent && ![VoiceBroadcastInfo isStoppedFor:[VoiceBroadcastInfo modelFromJSON: stateEvent.content].state]) + { + return [self session:session updateRoomSummary:summary withVoiceBroadcastInfoStateEvent:stateEvent roomState:roomState]; + } + } + + BOOL updated = [super session:session updateRoomSummary:summary withLastEvent:event eventState:eventState roomState:roomState]; + + if (updated) { + // Force the default text color for the last message (cancel highlighted message color) + NSMutableAttributedString *lastEventDescription = [[NSMutableAttributedString alloc] initWithAttributedString:summary.lastMessage.attributedText]; + [lastEventDescription addAttribute:NSForegroundColorAttributeName value:ThemeService.shared.theme.textSecondaryColor range:NSMakeRange(0, lastEventDescription.length)]; + summary.lastMessage.attributedText = lastEventDescription; + } + + return updated; +} + - (BOOL)session:(MXSession *)session updateRoomSummary:(MXRoomSummary *)summary withStateEvents:(NSArray *)stateEvents roomState:(MXRoomState *)roomState { BOOL updated = [super session:session updateRoomSummary:summary withStateEvents:stateEvents roomState:roomState]; @@ -579,6 +613,67 @@ static NSString *const kEventFormatterTimeFormat = @"HH:mm"; return updated; } +- (BOOL)session:(MXSession *)session updateRoomSummary:(MXRoomSummary *)summary withVoiceBroadcastInfoStateEvent:(MXEvent *)stateEvent roomState:(MXRoomState *)roomState +{ + [summary updateLastMessage:[[MXRoomLastMessage alloc] initWithEvent:stateEvent]]; + if (summary.lastMessage.others == nil) + { + summary.lastMessage.others = [NSMutableDictionary dictionary]; + } + summary.lastMessage.others[@"lastEventDate"] = [self dateStringFromEvent:stateEvent withTime:YES]; + + NSAttributedString *attachmentString = nil; + UIColor *textColor; + if ([VoiceBroadcastInfo isStoppedFor:[VoiceBroadcastInfo modelFromJSON: stateEvent.content].state]) + { + textColor = ThemeService.shared.theme.textSecondaryColor; + NSString *senderDisplayName; + if ([stateEvent.stateKey isEqualToString:session.myUser.userId]) + { + summary.lastMessage.text = VectorL10n.noticeVoiceBroadcastEndedByYou; + } + else + { + senderDisplayName = [self senderDisplayNameForEvent:stateEvent withRoomState:roomState]; + summary.lastMessage.text = [VectorL10n noticeVoiceBroadcastEnded:senderDisplayName]; + } + } + else + { + textColor = ThemeService.shared.theme.colors.alert; + UIImage *liveImage = AssetImages.voiceBroadcastLive.image; + + NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; + attachment.image = [liveImage imageWithTintColor:textColor renderingMode:UIImageRenderingModeAlwaysTemplate]; + attachmentString = [NSAttributedString attributedStringWithAttachment:attachment]; + + summary.lastMessage.text = VectorL10n.noticeVoiceBroadcastLive; + } + + // Compute the attribute text message + NSMutableAttributedString *lastMessage; + if (attachmentString) + { + lastMessage = [[NSMutableAttributedString alloc] initWithAttributedString:attachmentString]; + // Change base line + [lastMessage addAttribute:NSBaselineOffsetAttributeName value:@(-3.0f) range:NSMakeRange(0, attachmentString.length)]; + + NSAttributedString *attributedText = [[NSAttributedString alloc] initWithString:[NSString stringWithFormat:@" %@", summary.lastMessage.text]]; + [lastMessage appendAttributedString:attributedText]; + [lastMessage addAttribute:NSFontAttributeName value:self.defaultTextFont range:NSMakeRange(0, lastMessage.length)]; + } + else + { + NSAttributedString *attributedText = [self renderString:summary.lastMessage.text forEvent:stateEvent]; + lastMessage = [[NSMutableAttributedString alloc] initWithAttributedString:attributedText]; + } + + [lastMessage addAttribute:NSForegroundColorAttributeName value:textColor range:NSMakeRange(0, lastMessage.length)]; + summary.lastMessage.attributedText = lastMessage; + + return YES; +} + - (NSAttributedString *)redactedMessageReplacementAttributedString { UIFont *font = self.defaultTextFont; diff --git a/Riot/Utils/HTMLFormatter.swift b/Riot/Utils/HTMLFormatter.swift index b05fab336..0338cda07 100644 --- a/Riot/Utils/HTMLFormatter.swift +++ b/Riot/Utils/HTMLFormatter.swift @@ -62,7 +62,11 @@ class HTMLFormatter: NSObject { let mutableString = NSMutableAttributedString(attributedString: string) MXKTools.removeDTCoreTextArtifacts(mutableString) postFormatOperations?(mutableString) - + + // Remove CTForegroundColorFromContext attribute to fix the iOS 16 black link color issue + // REF: https://github.com/Cocoanetics/DTCoreText/issues/792 + mutableString.removeAttribute(NSAttributedString.Key("CTForegroundColorFromContext"), range: NSRange(location: 0, length: mutableString.length)) + return mutableString } diff --git a/RiotNSE/NotificationService.swift b/RiotNSE/NotificationService.swift index f48172e05..e71651d03 100644 --- a/RiotNSE/NotificationService.swift +++ b/RiotNSE/NotificationService.swift @@ -553,7 +553,14 @@ class NotificationService: UNNotificationServiceExtension { notificationBody = NotificationService.localizedString(forKey: "VIDEO_FROM_USER", eventSenderName) case kMXMessageTypeAudio: if event.isVoiceMessage() { - notificationBody = NotificationService.localizedString(forKey: "VOICE_MESSAGE_FROM_USER", eventSenderName) + // Ignore voice broadcast chunk event except the first one. + if let chunkInfo = event.content[VoiceBroadcastSettings.voiceBroadcastContentKeyChunkType] as? [String: UInt] { + if chunkInfo[VoiceBroadcastSettings.voiceBroadcastContentKeyChunkSequence] == 1 { + notificationBody = NotificationService.localizedString(forKey: "VOICE_BROADCAST_FROM_USER", eventSenderName) + } + } else { + notificationBody = NotificationService.localizedString(forKey: "VOICE_MESSAGE_FROM_USER", eventSenderName) + } } else { notificationBody = NotificationService.localizedString(forKey: "AUDIO_FROM_USER", eventSenderName, messageContent) } @@ -668,6 +675,7 @@ class NotificationService: UNNotificationServiceExtension { notificationTitle = nil notificationBody = NotificationService.localizedString(forKey: "MESSAGE_PROTECTED") } + case .pollStart: notificationTitle = self.messageTitle(for: eventSenderName, in: roomDisplayName) notificationBody = MXEventContentPollStart(fromJSON: event.content)?.question @@ -678,11 +686,15 @@ class NotificationService: UNNotificationServiceExtension { notificationTitle = nil notificationBody = NotificationService.localizedString(forKey: "MESSAGE_PROTECTED") } + case .pollEnd: + notificationTitle = self.messageTitle(for: eventSenderName, in: roomDisplayName) + notificationBody = VectorL10n.pollTimelineEndedText + // bwi 1.9.15: hide content in pollend notifications + default: break } - self.validateNotificationContentAndComplete( notificationTitle: notificationTitle, notificationBody: notificationBody, diff --git a/RiotNSE/target.yml b/RiotNSE/target.yml index 656d262fa..a0c042646 100644 --- a/RiotNSE/target.yml +++ b/RiotNSE/target.yml @@ -63,6 +63,7 @@ targets: - path: ../Riot/Managers/Locale/LocaleProviderType.swift - path: ../Riot/Managers/EncryptionKeyManager/EncryptionKeyManager.swift - path: ../Riot/Categories/Bundle.swift + - path: ../Riot/Categories/MXEvent.swift - path: ../Riot/Generated/Strings.swift - path: ../Riot/Generated/BWIStrings.swift - path: ../Riot/Generated/Images.swift @@ -86,4 +87,4 @@ targets: excludes: - "**/*.md" # excludes all files with the .md extension - path: ../Riot/Modules/Room/TimelineCells/Styles/RoomTimelineStyleIdentifier.swift - - path: ../Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastSettings.swift + - path: ../Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/MatrixSDK diff --git a/RiotShareExtension/target.yml b/RiotShareExtension/target.yml index 789cbb24d..614df9398 100644 --- a/RiotShareExtension/target.yml +++ b/RiotShareExtension/target.yml @@ -45,6 +45,7 @@ targets: - path: ../bwi/AppConfig - path: ../Riot/Modules/Common/SegmentedViewController/SegmentedViewController.m - path: ../Riot/Categories/Bundle.swift + - path: ../Riot/Categories/MXEvent.swift - path: ../Riot/Managers/Theme/ - path: ../Riot/Utils/AvatarGenerator.m - path: ../Config/BuildSettings.swift @@ -89,4 +90,4 @@ targets: excludes: - "**/*.md" # excludes all files with the .md extension - path: ../Riot/Modules/Room/TimelineCells/Styles/RoomTimelineStyleIdentifier.swift - - path: ../Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastSettings.swift + - path: ../Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/MatrixSDK diff --git a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift index e2b3ce30e..eaee22e73 100644 --- a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift +++ b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift @@ -71,6 +71,7 @@ enum MockAppScreens { MockSpaceSelectorScreenState.self, MockComposerScreenState.self, MockComposerCreateActionListScreenState.self, + MockComposerLinkActionScreenState.self, MockVoiceBroadcastPlaybackScreenState.self ] } diff --git a/RiotSwiftUI/Modules/Common/Test/UI/XCUIApplication+Riot.swift b/RiotSwiftUI/Modules/Common/Test/UI/XCUIApplication+Riot.swift index f0912c5bc..ecca19b66 100644 --- a/RiotSwiftUI/Modules/Common/Test/UI/XCUIApplication+Riot.swift +++ b/RiotSwiftUI/Modules/Common/Test/UI/XCUIApplication+Riot.swift @@ -18,16 +18,21 @@ import Foundation import XCTest extension XCUIApplication { - func goToScreenWithIdentifier(_ identifier: String) { + func goToScreenWithIdentifier(_ identifier: String, shouldUseSlowTyping: Bool = false) { // Search for the screen identifier let textField = textFields["searchQueryTextField"] let button = buttons[identifier] - // Sometimes the search gets stuck without showing any results. Try to nudge it along - for _ in 0...10 { - textField.clearAndTypeText(identifier) - if button.exists { - break + // This always fixes the stuck search issue, but makes the typing slower + if shouldUseSlowTyping { + textField.typeSlowly(identifier) + } else { + // Sometimes the search gets stuck without showing any results. Try to nudge it along + for _ in 0...10 { + textField.clearAndTypeText(identifier) + if button.exists { + break + } } } @@ -35,7 +40,7 @@ extension XCUIApplication { } } -private extension XCUIElement { +extension XCUIElement { func clearAndTypeText(_ text: String) { guard let stringValue = value as? String else { XCTFail("Tried to clear and type text into a non string value") @@ -49,4 +54,9 @@ private extension XCUIElement { typeText(deleteString) typeText(text) } + + func typeSlowly(_ text: String) { + tap() + text.forEach{ typeText(String($0)) } + } } diff --git a/RiotSwiftUI/Modules/Room/Composer/CreateActionList/MockComposerCreateActionListScreenState.swift b/RiotSwiftUI/Modules/Room/Composer/CreateActionList/MockComposerCreateActionListScreenState.swift index 8089ab7ae..027d23f2e 100644 --- a/RiotSwiftUI/Modules/Room/Composer/CreateActionList/MockComposerCreateActionListScreenState.swift +++ b/RiotSwiftUI/Modules/Room/Composer/CreateActionList/MockComposerCreateActionListScreenState.swift @@ -33,11 +33,16 @@ enum MockComposerCreateActionListScreenState: MockScreenState, CaseIterable { case .fullList: actions = ComposerCreateAction.allCases } - let viewModel = ComposerCreateActionListViewModel(initialViewState: ComposerCreateActionListViewState( - actions: actions, - wysiwygEnabled: true, - isScrollingEnabled: false, - bindings: ComposerCreateActionListBindings(textFormattingEnabled: true))) + let viewModel = ComposerCreateActionListViewModel( + initialViewState: ComposerCreateActionListViewState( + actions: actions, + wysiwygEnabled: true, + isScrollingEnabled: false, + bindings: ComposerCreateActionListBindings( + textFormattingEnabled: true + ) + ) + ) return ( [viewModel], diff --git a/RiotSwiftUI/Modules/Room/Composer/LinkAction/Coordinator/ComposerLinkActionBridgePresenter.swift b/RiotSwiftUI/Modules/Room/Composer/LinkAction/Coordinator/ComposerLinkActionBridgePresenter.swift new file mode 100644 index 000000000..b0c69c221 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/Composer/LinkAction/Coordinator/ComposerLinkActionBridgePresenter.swift @@ -0,0 +1,65 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import WysiwygComposer + +protocol ComposerLinkActionBridgePresenterDelegate: AnyObject { + func didCancel() + func didDismissInteractively() + func didRequestLinkOperation(_ linkOperation: WysiwygLinkOperation) +} + +final class ComposerLinkActionBridgePresenter: NSObject { + private var coordinator: ComposerLinkActionCoordinator? + private var linkAction: LinkAction + + weak var delegate: ComposerLinkActionBridgePresenterDelegate? + + init(linkAction: LinkActionWrapper) { + self.linkAction = linkAction.linkAction + super.init() + } + + func present(from viewController: UIViewController, animated: Bool) { + let composerLinkActionCoordinator = ComposerLinkActionCoordinator(linkAction: linkAction) + composerLinkActionCoordinator.callback = { [weak self] action in + switch action { + case .didTapCancel: + self?.delegate?.didCancel() + case .didDismissInteractively: + self?.delegate?.didDismissInteractively() + case let .didRequestLinkOperation(linkOperation): + self?.delegate?.didRequestLinkOperation(linkOperation) + } + } + let presentable = composerLinkActionCoordinator.toPresentable() + viewController.present(presentable, animated: animated, completion: nil) + composerLinkActionCoordinator.start() + coordinator = composerLinkActionCoordinator + } + + func dismiss(animated: Bool, completion: (() -> Void)?) { + guard let coordinator = coordinator else { + return + } + // Dismiss modal + coordinator.toPresentable().dismiss(animated: animated) { + self.coordinator = nil + completion?() + } + } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/LinkAction/Coordinator/ComposerLinkActionCoordinator.swift b/RiotSwiftUI/Modules/Room/Composer/LinkAction/Coordinator/ComposerLinkActionCoordinator.swift new file mode 100644 index 000000000..c3bd7406e --- /dev/null +++ b/RiotSwiftUI/Modules/Room/Composer/LinkAction/Coordinator/ComposerLinkActionCoordinator.swift @@ -0,0 +1,61 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import WysiwygComposer + +enum ComposerLinkActionCoordinatorAction { + case didTapCancel + case didDismissInteractively + case didRequestLinkOperation(_ linkOperation: WysiwygLinkOperation) +} + +final class ComposerLinkActionCoordinator: NSObject, Coordinator, Presentable { + var childCoordinators: [Coordinator] = [] + + private let hostingController: UIViewController + private let viewModel: ComposerLinkActionViewModel + + var callback: ((ComposerLinkActionCoordinatorAction) -> Void)? + + init(linkAction: LinkAction) { + viewModel = ComposerLinkActionViewModel(from: linkAction) + hostingController = VectorHostingController(rootView: ComposerLinkAction(viewModel: viewModel.context)) + super.init() + hostingController.presentationController?.delegate = self + } + + func start() { + viewModel.callback = { [weak self] result in + switch result { + case .cancel: + self?.callback?(.didTapCancel) + case let .performOperation(linkOperation): + self?.callback?(.didRequestLinkOperation(linkOperation)) + } + } + } + + func toPresentable() -> UIViewController { + hostingController + } +} + +extension ComposerLinkActionCoordinator: UIAdaptivePresentationControllerDelegate { + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + self.callback?(.didDismissInteractively) + } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/LinkAction/MockComposerLinkActionScreenState.swift b/RiotSwiftUI/Modules/Room/Composer/LinkAction/MockComposerLinkActionScreenState.swift new file mode 100644 index 000000000..6bdc5ebc5 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/Composer/LinkAction/MockComposerLinkActionScreenState.swift @@ -0,0 +1,43 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +enum MockComposerLinkActionScreenState: MockScreenState, CaseIterable { + case edit + case createWithText + case create + + var screenType: Any.Type { + ComposerLinkAction.self + } + + var screenView: ([Any], AnyView) { + let viewModel: ComposerLinkActionViewModel + switch self { + case .createWithText: + viewModel = .init(from: .createWithText) + case .create: + viewModel = .init(from: .create) + case .edit: + viewModel = .init(from: .edit(link: "https://element.io")) + } + return ( + [viewModel], + AnyView(ComposerLinkAction(viewModel: viewModel.context)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/LinkAction/Model/ComposerLinkActionModel.swift b/RiotSwiftUI/Modules/Room/Composer/LinkAction/Model/ComposerLinkActionModel.swift new file mode 100644 index 000000000..b47c5bcd5 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/Composer/LinkAction/Model/ComposerLinkActionModel.swift @@ -0,0 +1,78 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import WysiwygComposer + +enum ComposerLinkActionViewAction: Equatable { + case cancel + case save + case remove +} + +enum ComposerLinkActionViewModelResult: Equatable { + case cancel + case performOperation(_ linkOperation: WysiwygLinkOperation) +} + +// MARK: View + +struct ComposerLinkActionViewState: BindableState { + let linkAction: LinkAction + + var bindings: ComposerLinkActionBindings +} + +extension ComposerLinkActionViewState { + var title: String { + switch linkAction { + case .createWithText, .create: return VectorL10n.wysiwygComposerLinkActionCreateTitle + case .edit: return VectorL10n.wysiwygComposerLinkActionEditTitle + } + } + + var shouldDisplayTextField: Bool { + switch linkAction { + case .createWithText: return true + default: return false + } + } + + var shouldDisplayRemoveButton: Bool { + switch linkAction { + case .edit: return true + default: return false + } + } + + var isSaveButtonDisabled: Bool { + guard isValidLink else { return true } + switch linkAction { + case .createWithText: return bindings.text.isEmpty + default: return false + } + } + + private var isValidLink: Bool { + guard let url = URL(string: bindings.linkUrl) else { return false } + return UIApplication.shared.canOpenURL(url) + } +} + +struct ComposerLinkActionBindings { + var text: String + var linkUrl: String +} diff --git a/RiotSwiftUI/Modules/Room/Composer/LinkAction/Test/UI/ComposerLinkActionUITests.swift b/RiotSwiftUI/Modules/Room/Composer/LinkAction/Test/UI/ComposerLinkActionUITests.swift new file mode 100644 index 000000000..f30dacf30 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/Composer/LinkAction/Test/UI/ComposerLinkActionUITests.swift @@ -0,0 +1,72 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import RiotSwiftUI +import XCTest + +final class ComposerLinkActionUITests: MockScreenTestCase { + func testCreate() { + app.goToScreenWithIdentifier(MockComposerLinkActionScreenState.create.title, shouldUseSlowTyping: true) + XCTAssertFalse(app.buttons[VectorL10n.remove].exists) + XCTAssertTrue(app.buttons[VectorL10n.cancel].exists) + let saveButton = app.buttons[VectorL10n.save] + XCTAssertTrue(saveButton.exists) + XCTAssertFalse(saveButton.isEnabled) + XCTAssertFalse(app.textFields["textTextField"].exists) + let linkTextField = app.textFields["linkTextField"] + XCTAssertTrue(linkTextField.exists) + linkTextField.tap() + linkTextField.typeText("invalid url") + XCTAssertFalse(saveButton.isEnabled) + linkTextField.clearAndTypeText("https://element.io") + XCTAssertTrue(saveButton.isEnabled) + } + + func testCreateWithText() { + app.goToScreenWithIdentifier(MockComposerLinkActionScreenState.createWithText.title, shouldUseSlowTyping: true) + XCTAssertFalse(app.buttons[VectorL10n.remove].exists) + XCTAssertTrue(app.buttons[VectorL10n.cancel].exists) + let saveButton = app.buttons[VectorL10n.save] + XCTAssertTrue(saveButton.exists) + XCTAssertFalse(saveButton.isEnabled) + let textTextField = app.textFields["textTextField"] + XCTAssertTrue(textTextField.exists) + let linkTextField = app.textFields["linkTextField"] + XCTAssertTrue(linkTextField.exists) + linkTextField.tap() + linkTextField.typeText("https://element.io") + XCTAssertFalse(saveButton.isEnabled) + textTextField.tap() + textTextField.typeText("test") + XCTAssertTrue(saveButton.isEnabled) + } + + func testEdit() { + app.goToScreenWithIdentifier(MockComposerLinkActionScreenState.edit.title, shouldUseSlowTyping: true) + XCTAssertTrue(app.buttons[VectorL10n.remove].exists) + XCTAssertTrue(app.buttons[VectorL10n.cancel].exists) + let saveButton = app.buttons[VectorL10n.save] + XCTAssertTrue(saveButton.exists) + XCTAssertTrue(saveButton.isEnabled) + XCTAssertFalse(app.textFields["textTextField"].exists) + let linkTextField = app.textFields["linkTextField"] + XCTAssertTrue(linkTextField.exists) + let value = linkTextField.value as? String + XCTAssertEqual(value, "https://element.io") + linkTextField.clearAndTypeText("invalid url") + XCTAssertFalse(saveButton.isEnabled) + } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/LinkAction/Test/Unit/ComposerLinkActionViewModelTests.swift b/RiotSwiftUI/Modules/Room/Composer/LinkAction/Test/Unit/ComposerLinkActionViewModelTests.swift new file mode 100644 index 000000000..40ad27358 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/Composer/LinkAction/Test/Unit/ComposerLinkActionViewModelTests.swift @@ -0,0 +1,141 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +@testable import RiotSwiftUI +import WysiwygComposer +import XCTest + +final class ComposerLinkActionViewModelTests: XCTestCase { + var viewModel: ComposerLinkActionViewModel! + var context: ComposerLinkActionViewModel.Context! + + override func setUpWithError() throws { + viewModel = nil + context = nil + } + + private func setUp(with linkAction: LinkAction) { + viewModel = ComposerLinkActionViewModel(from: linkAction) + context = viewModel.context + } + + func testCreateWithTextDefaultState() { + setUp(with: .createWithText) + XCTAssertEqual(context.viewState.bindings.text, "") + XCTAssertEqual(context.viewState.bindings.linkUrl, "") + XCTAssertTrue(context.viewState.isSaveButtonDisabled) + XCTAssertFalse(context.viewState.shouldDisplayRemoveButton) + XCTAssertTrue(context.viewState.shouldDisplayTextField) + XCTAssertEqual(context.viewState.title, VectorL10n.wysiwygComposerLinkActionCreateTitle) + } + + func testCreateDefaultState() { + setUp(with: .create) + XCTAssertEqual(context.viewState.bindings.text, "") + XCTAssertEqual(context.viewState.bindings.linkUrl, "") + XCTAssertTrue(context.viewState.isSaveButtonDisabled) + XCTAssertFalse(context.viewState.shouldDisplayRemoveButton) + XCTAssertFalse(context.viewState.shouldDisplayTextField) + XCTAssertEqual(context.viewState.title, VectorL10n.wysiwygComposerLinkActionCreateTitle) + } + + func testEditDefaultState() { + let link = "https://element.io" + setUp(with: .edit(link: link)) + XCTAssertEqual(context.viewState.bindings.text, "") + XCTAssertEqual(context.viewState.bindings.linkUrl, link) + XCTAssertFalse(context.viewState.isSaveButtonDisabled) + XCTAssertTrue(context.viewState.shouldDisplayRemoveButton) + XCTAssertFalse(context.viewState.shouldDisplayTextField) + XCTAssertEqual(context.viewState.title, VectorL10n.wysiwygComposerLinkActionEditTitle) + } + + func testUrlValidityCheck() { + setUp(with: .create) + XCTAssertTrue(context.viewState.isSaveButtonDisabled) + context.linkUrl = "invalid url" + XCTAssertTrue(context.viewState.isSaveButtonDisabled) + context.linkUrl = "https://element.io" + XCTAssertFalse(context.viewState.isSaveButtonDisabled) + } + + func testTextNotEmptyCheck() { + setUp(with: .createWithText) + XCTAssertTrue(context.viewState.isSaveButtonDisabled) + context.linkUrl = "https://element.io" + XCTAssertTrue(context.viewState.isSaveButtonDisabled) + context.text = "text" + XCTAssertFalse(context.viewState.isSaveButtonDisabled) + } + + func testCancelAction() { + setUp(with: .create) + var result: ComposerLinkActionViewModelResult! + viewModel.callback = { value in + result = value + } + context.send(viewAction: .cancel) + XCTAssertEqual(result, .cancel) + } + + func testRemoveAction() { + setUp(with: .edit(link: "https://element.io")) + var result: ComposerLinkActionViewModelResult! + viewModel.callback = { value in + result = value + } + context.send(viewAction: .remove) + XCTAssertEqual(result, .performOperation(.removeLinks)) + } + + func testSaveActionForCreate() { + setUp(with: .create) + var result: ComposerLinkActionViewModelResult! + viewModel.callback = { value in + result = value + } + let link = "https://element.io" + context.linkUrl = link + context.send(viewAction: .save) + XCTAssertEqual(result, .performOperation(.setLink(urlString: link))) + } + + func testSaveActionForCreateWithText() { + setUp(with: .createWithText) + var result: ComposerLinkActionViewModelResult! + viewModel.callback = { value in + result = value + } + let link = "https://element.io" + context.linkUrl = link + let text = "test" + context.text = text + context.send(viewAction: .save) + XCTAssertEqual(result, .performOperation(.createLink(urlString: link, text: text))) + } + + func testSaveActionForEdit() { + setUp(with: .edit(link: "https://element.io")) + var result: ComposerLinkActionViewModelResult! + viewModel.callback = { value in + result = value + } + let link = "https://matrix.org" + context.linkUrl = link + context.send(viewAction: .save) + XCTAssertEqual(result, .performOperation(.setLink(urlString: link))) + } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/LinkAction/View/ComposerLinkAction.swift b/RiotSwiftUI/Modules/Room/Composer/LinkAction/View/ComposerLinkAction.swift new file mode 100644 index 000000000..1dbdee3ae --- /dev/null +++ b/RiotSwiftUI/Modules/Room/Composer/LinkAction/View/ComposerLinkAction.swift @@ -0,0 +1,132 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct ComposerLinkAction: View { + enum Field { + case text + case link + } + + @Environment(\.theme) private var theme: ThemeSwiftUI + @ObservedObject private var viewModel: ComposerLinkActionViewModel.Context + + @State private var selectedField: Field? + + private var isTextFocused: Bool { + selectedField == .text + } + + private var isLinkFocused: Bool { + selectedField == .link + } + + var body: some View { + NavigationView { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 24) { + if viewModel.viewState.shouldDisplayTextField { + VStack(alignment: .leading, spacing: 8.0) { + Text(VectorL10n.wysiwygComposerLinkActionText) + .font(theme.fonts.subheadline) + .foregroundColor(theme.colors.secondaryContent) + TextField( + "", + text: $viewModel.text, + onEditingChanged: { edit in + selectedField = edit ? .text : nil + } + ) + .textFieldStyle(BorderedInputFieldStyle(isEditing: isTextFocused)) + .autocapitalization(.none) + .accessibilityIdentifier("textTextField") + .accessibilityLabel(VectorL10n.wysiwygComposerLinkActionText) + } + } + VStack(alignment: .leading, spacing: 8.0) { + Text(VectorL10n.wysiwygComposerLinkActionLink) + .font(theme.fonts.subheadline) + .foregroundColor(theme.colors.secondaryContent) + TextField( + "", + text: $viewModel.linkUrl, + onEditingChanged: { edit in + selectedField = edit ? .link : nil + } + ) + .keyboardType(.URL) + .autocapitalization(.none) + .textFieldStyle(BorderedInputFieldStyle(isEditing: isLinkFocused)) + .accessibilityIdentifier("linkTextField") + .accessibilityLabel(VectorL10n.wysiwygComposerLinkActionLink) + } + } + Spacer() + VStack(spacing: 16) { + Button(VectorL10n.save) { + viewModel.send(viewAction: .save) + } + .buttonStyle(PrimaryActionButtonStyle()) + .disabled(viewModel.viewState.isSaveButtonDisabled) + .animation(.easeInOut(duration: 0.15), value: viewModel.viewState.isSaveButtonDisabled) + if viewModel.viewState.shouldDisplayRemoveButton { + Button(VectorL10n.remove) { + viewModel.send(viewAction: .remove) + } + .buttonStyle(PrimaryActionButtonStyle(customColor: theme.colors.alert)) + } + Button(VectorL10n.cancel) { + viewModel.send(viewAction: .cancel) + } + .buttonStyle(SecondaryActionButtonStyle()) + } + } + .padding(.top, 40.0) + .padding(.bottom, 12.0) + .padding(.horizontal, 16.0) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(VectorL10n.cancel, action: { + viewModel.send(viewAction: .cancel) + }) + } + ToolbarItem(placement: .principal) { + Text(viewModel.viewState.title) + .font(.headline) + .foregroundColor(theme.colors.primaryContent) + } + } + .navigationBarTitleDisplayMode(.inline) + .introspectNavigationController { navigationController in + ThemeService.shared().theme.applyStyle(onNavigationBar: navigationController.navigationBar) + } + .accentColor(theme.colors.accent) + .navigationViewStyle(StackNavigationViewStyle()) + } + } + + init(viewModel: ComposerLinkActionViewModel.Context) { + self.viewModel = viewModel + } +} + +struct ComposerLinkActionView_Previews: PreviewProvider { + static let stateRenderer = MockComposerLinkActionScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup() + } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/LinkAction/ViewModel/ComposerLinkActionViewModel.swift b/RiotSwiftUI/Modules/Room/Composer/LinkAction/ViewModel/ComposerLinkActionViewModel.swift new file mode 100644 index 000000000..9683ac621 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/Composer/LinkAction/ViewModel/ComposerLinkActionViewModel.swift @@ -0,0 +1,80 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import WysiwygComposer + +typealias ComposerLinkActionViewModelType = StateStoreViewModel + +final class ComposerLinkActionViewModel: ComposerLinkActionViewModelType, ComposerLinkActionViewModelProtocol { + // MARK: - Properties + + // MARK: Private + + // MARK: Public + + var callback: ((ComposerLinkActionViewModelResult) -> Void)? + + // MARK: - Public + + init(from linkAction: LinkAction) { + let initialViewState: ComposerLinkActionViewState + let simpleBindings = ComposerLinkActionBindings(text: "", linkUrl: "") + switch linkAction { + case let .edit(link): + initialViewState = .init( + linkAction: .edit(link: link), + bindings: .init( + text: "", + linkUrl: link + ) + ) + case .createWithText: + initialViewState = .init(linkAction: .createWithText, bindings: simpleBindings) + case .create: + initialViewState = .init(linkAction: .create, bindings: simpleBindings) + } + + super.init(initialViewState: initialViewState) + } + + override func process(viewAction: ComposerLinkActionViewAction) { + switch viewAction { + case .cancel: + callback?(.cancel) + case .remove: + callback?(.performOperation(.removeLinks)) + case .save: + switch state.linkAction { + case .createWithText: + callback?( + .performOperation( + .createLink( + urlString: state.bindings.linkUrl, + text: state.bindings.text + ) + ) + ) + case .create, .edit: + callback?( + .performOperation( + .setLink(urlString: state.bindings.linkUrl) + ) + ) + } + } + } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/LinkAction/ViewModel/ComposerLinkActionViewModelProtocol.swift b/RiotSwiftUI/Modules/Room/Composer/LinkAction/ViewModel/ComposerLinkActionViewModelProtocol.swift new file mode 100644 index 000000000..a33ddde00 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/Composer/LinkAction/ViewModel/ComposerLinkActionViewModelProtocol.swift @@ -0,0 +1,22 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +protocol ComposerLinkActionViewModelProtocol { + var context: ComposerLinkActionViewModelType.Context { get } + var callback: ((ComposerLinkActionViewModelResult) -> Void)? { get set } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift b/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift index 84b5f8f95..bc2e8771d 100644 --- a/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift +++ b/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift @@ -24,10 +24,8 @@ import WysiwygComposer struct FormatItem { /// The type of the item let type: FormatType - /// Whether it is active(highlighted) - let active: Bool - /// Whether it is disabled or enabled - let disabled: Bool + /// The state of the item + let state: ActionState } /// The types of formatting actions @@ -36,6 +34,8 @@ enum FormatType { case italic case underline case strikethrough + case inlineCode + case link } extension FormatType: CaseIterable, Identifiable { @@ -58,6 +58,10 @@ extension FormatItem { return Asset.Images.strikethrough.name case .underline: return Asset.Images.underlined.name + case .link: + return Asset.Images.link.name + case .inlineCode: + return Asset.Images.code.name } } @@ -71,6 +75,10 @@ extension FormatItem { return "strikethroughButton" case .underline: return "underlineButton" + case .link: + return "linkButton" + case .inlineCode: + return "inlineCodeButton" } } @@ -84,6 +92,10 @@ extension FormatItem { return VectorL10n.wysiwygComposerFormatActionStrikethrough case .underline: return VectorL10n.wysiwygComposerFormatActionUnderline + case .link: + return VectorL10n.wysiwygComposerFormatActionLink + case .inlineCode: + return VectorL10n.wysiwygComposerFormatActionInlineCode } } } @@ -100,11 +112,14 @@ extension FormatType { return .strikeThrough case .underline: return .underline + case .link: + return .link + case .inlineCode: + return .inlineCode } } // TODO: We probably don't need to expose this, clean up. - /// Convenience method to map it to the external rust binging action var composerAction: ComposerAction { switch self { @@ -116,6 +131,10 @@ extension FormatType { return .strikeThrough case .underline: return .underline + case .link: + return .link + case .inlineCode: + return .inlineCode } } } @@ -130,11 +149,23 @@ enum ComposerSendMode: Equatable { enum ComposerViewAction: Equatable { case cancel case contentDidChange(isEmpty: Bool) + case linkTapped(linkAction: LinkAction) + case storeSelection(selection: NSRange) } enum ComposerViewModelResult: Equatable { case cancel case contentDidChange(isEmpty: Bool) + case linkTapped(LinkAction: LinkAction) +} + +final class LinkActionWrapper: NSObject { + let linkAction: LinkAction + + init(_ linkAction: LinkAction) { + self.linkAction = linkAction + super.init() + } } diff --git a/RiotSwiftUI/Modules/Room/Composer/Test/Unit/ComposerViewModelTests.swift b/RiotSwiftUI/Modules/Room/Composer/Test/Unit/ComposerViewModelTests.swift index a49314062..e4d5b595d 100644 --- a/RiotSwiftUI/Modules/Room/Composer/Test/Unit/ComposerViewModelTests.swift +++ b/RiotSwiftUI/Modules/Room/Composer/Test/Unit/ComposerViewModelTests.swift @@ -81,4 +81,24 @@ final class ComposerViewModelTests: XCTestCase { viewModel.dismissKeyboard() XCTAssert(context.viewState.bindings.focused == false) } + + func testSelectionToRestore() { + XCTAssertEqual(viewModel.selectionToRestore, nil) + let testRange = NSRange(location: 0, length: 10) + context.send(viewAction: .storeSelection(selection: testRange)) + XCTAssertEqual(viewModel.selectionToRestore, testRange) + } + + func testLinkAction() { + var result: ComposerViewModelResult! + viewModel.callback = { value in + result = value + } + context.send(viewAction: .linkTapped(linkAction: .createWithText)) + XCTAssertEqual(result, .linkTapped(LinkAction: .createWithText)) + context.send(viewAction: .linkTapped(linkAction: .create)) + XCTAssertEqual(result, .linkTapped(LinkAction: .create)) + context.send(viewAction: .linkTapped(linkAction: .edit(link: "https://element.io"))) + XCTAssertEqual(result, .linkTapped(LinkAction: .edit(link: "https://element.io"))) + } } diff --git a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift index 5d8ec5320..6163f384a 100644 --- a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift +++ b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift @@ -74,8 +74,7 @@ struct Composer: View { FormatType.allCases.map { type in FormatItem( type: type, - active: wysiwygViewModel.actionStates[type.composerAction] == .reversed, - disabled: wysiwygViewModel.actionStates[type.composerAction] == .disabled + state: wysiwygViewModel.actionStates[type.composerAction] ?? .disabled ) } } @@ -226,7 +225,12 @@ struct Composer: View { HStack(alignment: .center, spacing: 0) { sendMediaButton FormattingToolbar(formatItems: formatItems) { type in - wysiwygViewModel.apply(type.action) + if type.action == .link { + storeCurrentSelection() + sendLinkAction() + } else { + wysiwygViewModel.apply(type.action) + } } .frame(height: 44) Spacer() @@ -242,6 +246,15 @@ struct Composer: View { } } } + + private func storeCurrentSelection() { + viewModel.send(viewAction: .storeSelection(selection: wysiwygViewModel.attributedContent.selection)) + } + + private func sendLinkAction() { + let linkAction = wysiwygViewModel.getLinkAction() + viewModel.send(viewAction: .linkTapped(linkAction: linkAction)) + } } // MARK: Previews diff --git a/RiotSwiftUI/Modules/Room/Composer/View/FormattingToolbar.swift b/RiotSwiftUI/Modules/Room/Composer/View/FormattingToolbar.swift index c721832bb..d8670ee0c 100644 --- a/RiotSwiftUI/Modules/Room/Composer/View/FormattingToolbar.swift +++ b/RiotSwiftUI/Modules/Room/Composer/View/FormattingToolbar.swift @@ -39,28 +39,40 @@ struct FormattingToolbar: View { } label: { Image(item.icon) .renderingMode(.template) - .foregroundColor(item.active ? theme.colors.accent : theme.colors.tertiaryContent) + .foregroundColor(getForegroundColor(for: item)) } - .disabled(item.disabled) + .disabled(item.state == .disabled) .frame(width: 44, height: 44) - .background(item.active ? theme.colors.accent.opacity(0.1) : theme.colors.background) + .background(getBackgroundColor(for: item)) .cornerRadius(8) .accessibilityIdentifier(item.accessibilityIdentifier) .accessibilityLabel(item.accessibilityLabel) } } } + + private func getForegroundColor(for item: FormatItem) -> Color { + switch item.state { + case .reversed: return theme.colors.accent + case .enabled: return theme.colors.tertiaryContent + case .disabled: return theme.colors.tertiaryContent.opacity(0.3) + } + } + + private func getBackgroundColor(for item: FormatItem) -> Color { + switch item.state { + case .reversed: return theme.colors.accent.opacity(0.1) + default: return theme.colors.background + } + } } // MARK: - Previews struct FormattingToolbar_Previews: PreviewProvider { static var previews: some View { - FormattingToolbar(formatItems: [ - FormatItem(type: .bold, active: true, disabled: false), - FormatItem(type: .italic, active: false, disabled: false), - FormatItem(type: .strikethrough, active: true, disabled: false), - FormatItem(type: .underline, active: false, disabled: true) - ], formatAction: { _ in }) + FormattingToolbar( + formatItems: FormatType.allCases.map { FormatItem(type: $0, state: .enabled) } + , formatAction: { _ in }) } } diff --git a/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift b/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift index 4e6442303..a78018f60 100644 --- a/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift +++ b/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift @@ -22,7 +22,7 @@ final class ComposerViewModel: ComposerViewModelType, ComposerViewModelProtocol // MARK: - Properties // MARK: Private - + // MARK: Public var callback: ((ComposerViewModelResult) -> Void)? @@ -76,6 +76,8 @@ final class ComposerViewModel: ComposerViewModelType, ComposerViewModelProtocol state.bindings.focused } + var selectionToRestore: NSRange? + // MARK: - Public override func process(viewAction: ComposerViewAction) { @@ -84,6 +86,10 @@ final class ComposerViewModel: ComposerViewModelType, ComposerViewModelProtocol callback?(.cancel) case let .contentDidChange(isEmpty): callback?(.contentDidChange(isEmpty: isEmpty)) + case let .linkTapped(linkAction): + callback?(.linkTapped(LinkAction: linkAction)) + case let .storeSelection(selection): + selectionToRestore = selection } } diff --git a/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModelProtocol.swift b/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModelProtocol.swift index 0d23be3cc..cb600c104 100644 --- a/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModelProtocol.swift @@ -25,6 +25,7 @@ protocol ComposerViewModelProtocol { var placeholder: String? { get set } var isFocused: Bool { get } var isLandscapePhone: Bool { get set } + var selectionToRestore: NSRange? { get } func dismissKeyboard() func showKeyboard() diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift index 74cfaf65b..c3c1cf327 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift @@ -21,7 +21,7 @@ import SwiftUI struct TimelinePollCoordinatorParameters { let session: MXSession let room: MXRoom - let pollStartEvent: MXEvent + let pollEvent: MXEvent } final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDelegate { @@ -46,7 +46,7 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel init(parameters: TimelinePollCoordinatorParameters) throws { self.parameters = parameters - try pollAggregator = PollAggregator(session: parameters.session, room: parameters.room, pollStartEventId: parameters.pollStartEvent.eventId) + try pollAggregator = PollAggregator(session: parameters.session, room: parameters.room, pollEvent: parameters.pollEvent) pollAggregator.delegate = self viewModel = TimelinePollViewModel(timelinePollDetails: buildTimelinePollFrom(pollAggregator.poll)) @@ -65,7 +65,7 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel .sink { [weak self] identifiers in guard let self = self else { return } - self.parameters.room.sendPollResponse(for: parameters.pollStartEvent, + self.parameters.room.sendPollResponse(for: parameters.pollEvent, withAnswerIdentifiers: identifiers, threadId: nil, localEcho: nil, success: nil) { [weak self] error in @@ -96,7 +96,7 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel } func endPoll() { - parameters.room.sendPollEnd(for: parameters.pollStartEvent, threadId: nil, localEcho: nil, success: nil) { [weak self] _ in + parameters.room.sendPollEnd(for: parameters.pollEvent, threadId: nil, localEcho: nil, success: nil) { [weak self] _ in self?.viewModel.showClosingFailure() } } @@ -131,8 +131,10 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel closed: poll.isClosed, totalAnswerCount: poll.totalAnswerCount, type: pollKindToTimelinePollType(poll.kind), + eventType: parameters.pollEvent.eventType == .pollStart ? .started : .ended, maxAllowedSelections: poll.maxAllowedSelections, - hasBeenEdited: poll.hasBeenEdited) + hasBeenEdited: poll.hasBeenEdited, + hasDecryptionError: poll.hasDecryptionError) } private func pollKindToTimelinePollType(_ kind: PollKind) -> TimelinePollType { diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollProvider.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollProvider.swift index a11cb3a2e..8e5a04cd9 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollProvider.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollProvider.swift @@ -43,7 +43,7 @@ class TimelinePollProvider: NSObject { return coordinator.toPresentable() } - let parameters = TimelinePollCoordinatorParameters(session: session, room: room, pollStartEvent: event) + let parameters = TimelinePollCoordinatorParameters(session: session, room: room, pollEvent: event) guard let coordinator = try? TimelinePollCoordinator(parameters: parameters) else { return nil } diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/Test/Unit/TimelinePollViewModelTests.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/Test/Unit/TimelinePollViewModelTests.swift index 0e102dc39..cd806da54 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/Test/Unit/TimelinePollViewModelTests.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/Test/Unit/TimelinePollViewModelTests.swift @@ -34,8 +34,10 @@ class TimelinePollViewModelTests: XCTestCase { closed: false, totalAnswerCount: 3, type: .disclosed, + eventType: .started, maxAllowedSelections: 1, - hasBeenEdited: false) + hasBeenEdited: false, + hasDecryptionError: false) viewModel = TimelinePollViewModel(timelinePollDetails: timelinePoll) context = viewModel.context diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollModels.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollModels.swift index 528ad7c17..3629aae3e 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollModels.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollModels.swift @@ -32,6 +32,11 @@ enum TimelinePollType { case undisclosed } +enum TimelinePollEventType { + case started + case ended +} + struct TimelinePollAnswerOption: Identifiable { var id: String var text: String @@ -62,22 +67,28 @@ struct TimelinePollDetails { var closed: Bool var totalAnswerCount: UInt var type: TimelinePollType + var eventType: TimelinePollEventType var maxAllowedSelections: UInt var hasBeenEdited = true + var hasDecryptionError: Bool init(question: String, answerOptions: [TimelinePollAnswerOption], closed: Bool, totalAnswerCount: UInt, type: TimelinePollType, + eventType: TimelinePollEventType, maxAllowedSelections: UInt, - hasBeenEdited: Bool) { + hasBeenEdited: Bool, + hasDecryptionError: Bool) { self.question = question self.answerOptions = answerOptions self.closed = closed self.totalAnswerCount = totalAnswerCount self.type = type + self.eventType = eventType self.maxAllowedSelections = maxAllowedSelections self.hasBeenEdited = hasBeenEdited + self.hasDecryptionError = hasDecryptionError } var hasCurrentUserVoted: Bool { @@ -91,6 +102,10 @@ struct TimelinePollDetails { return type == .disclosed && totalAnswerCount > 0 && hasCurrentUserVoted } } + + var representsPollEndedEvent: Bool { + eventType == .ended + } } struct TimelinePollViewState: BindableState { diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollScreenState.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollScreenState.swift index 01fb82c4a..a53a745b8 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollScreenState.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/TimelinePollScreenState.swift @@ -22,6 +22,7 @@ enum MockTimelinePollScreenState: MockScreenState, CaseIterable { case closedDisclosed case openUndisclosed case closedUndisclosed + case closedPollEnded var screenType: Any.Type { TimelinePollDetails.self @@ -37,8 +38,10 @@ enum MockTimelinePollScreenState: MockScreenState, CaseIterable { closed: self == .closedDisclosed || self == .closedUndisclosed ? true : false, totalAnswerCount: 20, type: self == .closedDisclosed || self == .openDisclosed ? .disclosed : .undisclosed, + eventType: self == .closedPollEnded ? .ended : .started, maxAllowedSelections: 1, - hasBeenEdited: false) + hasBeenEdited: false, + hasDecryptionError: false) let viewModel = TimelinePollViewModel(timelinePollDetails: poll) diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollAnswerOptionButton.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollAnswerOptionButton.swift index 2ffa68be9..85309c31c 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollAnswerOptionButton.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollAnswerOptionButton.swift @@ -151,8 +151,10 @@ struct TimelinePollAnswerOptionButton_Previews: PreviewProvider { closed: closed, totalAnswerCount: 100, type: type, + eventType: .started, maxAllowedSelections: 1, - hasBeenEdited: false) + hasBeenEdited: false, + hasDecryptionError: false) } static func buildAnswerOption(text: String = "Test", selected: Bool, winner: Bool = false) -> TimelinePollAnswerOption { diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollView.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollView.swift index ff2ce2541..2109a0e8a 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollView.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/View/TimelinePollView.swift @@ -31,6 +31,12 @@ struct TimelinePollView: View { let poll = viewModel.viewState.poll VStack(alignment: .leading, spacing: 16.0) { + if poll.representsPollEndedEvent { + Text(VectorL10n.pollTimelineEndedText) + .font(theme.fonts.footnote) + .foregroundColor(theme.colors.tertiaryContent) + } + Text(poll.question) .font(theme.fonts.bodySB) .foregroundColor(theme.colors.primaryContent) + @@ -49,6 +55,7 @@ struct TimelinePollView: View { .fixedSize(horizontal: false, vertical: true) Text(totalVotesString) + .lineLimit(2) .font(theme.fonts.footnote) .foregroundColor(theme.colors.tertiaryContent) } @@ -62,6 +69,10 @@ struct TimelinePollView: View { private var totalVotesString: String { let poll = viewModel.viewState.poll + if poll.hasDecryptionError, poll.totalAnswerCount > 0 { + return VectorL10n.pollTimelineDecryptionError + } + if poll.closed { if poll.totalAnswerCount == 1 { return VectorL10n.pollTimelineTotalFinalResultsOneVote diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackCoordinator.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackCoordinator.swift index 652d6f7b2..3f5e55c6e 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackCoordinator.swift @@ -56,6 +56,10 @@ final class VoiceBroadcastPlaybackCoordinator: Coordinator, Presentable { } + deinit { + viewModel.context.send(viewAction: .redact) + } + // MARK: - Public func start() { } diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackProvider.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackProvider.swift index eaae1b11f..3f28ab081 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackProvider.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackProvider.swift @@ -29,7 +29,19 @@ import Foundation } } } - var coordinatorsForEventIdentifiers = [String: VoiceBroadcastPlaybackCoordinator]() + private var coordinatorsForEventIdentifiers = [String: VoiceBroadcastPlaybackCoordinator]() { + didSet { + if !self.coordinatorsForEventIdentifiers.isEmpty && self.redactionsListener == nil { + redactionsListener = session?.listenToEvents([MXEventType(identifier: kMXEventTypeStringRoomRedaction)], self.handleEvent) + } + + if self.coordinatorsForEventIdentifiers.isEmpty && self.redactionsListener != nil { + session?.removeListener(self.redactionsListener) + self.redactionsListener = nil + } + } + } + private var redactionsListener: Any? private override init() { } @@ -58,16 +70,24 @@ import Foundation return coordinator.toPresentable() } - - /// Retrieve the voiceBroadcast timeline coordinator for the given event or nil if it hasn't been created yet - func voiceBroadcastPlaybackCoordinatorForEventIdentifier(_ eventIdentifier: String) -> VoiceBroadcastPlaybackCoordinator? { - coordinatorsForEventIdentifiers[eventIdentifier] - } - + /// Pause current voice broadcast playback. @objc public func pausePlaying() { coordinatorsForEventIdentifiers.forEach { _, coordinator in coordinator.pausePlaying() } } + + private func handleEvent(event: MXEvent, direction: MXTimelineDirection, customObject: Any?) { + if direction == .backwards { + // ignore backwards events + return + } + + var coordinator = coordinatorsForEventIdentifiers.removeValue(forKey: event.redacts) + + coordinator?.toPresentable().dismiss(animated: false) { + coordinator = nil + } + } } diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/MatrixSDK/VoiceBroadcastPlaybackViewModel.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/MatrixSDK/VoiceBroadcastPlaybackViewModel.swift index a07ff8fed..1f5ac9872 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/MatrixSDK/VoiceBroadcastPlaybackViewModel.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/MatrixSDK/VoiceBroadcastPlaybackViewModel.swift @@ -56,6 +56,23 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic return (!isPlaybackInitialized || isPlayingLastChunk) && (state.broadcastState == .started || state.broadcastState == .resumed) } + private static let defaultBackwardForwardValue: Float = 30000.0 // 30sec in ms + + private var fullDateFormatter: DateComponentsFormatter { + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .positional + formatter.allowedUnits = [.hour, .minute, .second] + return formatter + } + + private var shortDateFormatter: DateComponentsFormatter { + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .positional + formatter.zeroFormattingBehavior = .pad + formatter.allowedUnits = [.minute, .second] + return formatter + } + // MARK: Public // MARK: - Setup @@ -71,7 +88,7 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic let viewState = VoiceBroadcastPlaybackViewState(details: details, broadcastState: voiceBroadcastAggregator.voiceBroadcastState, playbackState: .stopped, - playingState: VoiceBroadcastPlayingState(duration: Float(voiceBroadcastAggregator.voiceBroadcast.duration), isLive: false), + playingState: VoiceBroadcastPlayingState(duration: Float(voiceBroadcastAggregator.voiceBroadcast.duration), isLive: false, canMoveForward: false, canMoveBackward: false), bindings: VoiceBroadcastPlaybackViewStateBindings(progress: 0)) super.init(initialViewState: viewState) @@ -85,10 +102,9 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic private func release() { MXLog.debug("[VoiceBroadcastPlaybackViewModel] release") - if let audioPlayer = audioPlayer { - audioPlayer.deregisterDelegate(self) - self.audioPlayer = nil - } + self.stop() + self.voiceBroadcastAggregator.delegate = nil + self.voiceBroadcastAggregator.stop() } // MARK: - Public @@ -99,8 +115,14 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic play() case .pause: pause() + case .redact: + release() case .sliderChange(let didChange): didSliderChanged(didChange) + case .backward: + backward() + case .forward: + forward() } } @@ -164,6 +186,49 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic audioPlayer?.stop() } + /// Backward (30sec) a voice broadcast + private func backward() { + let newProgressValue = context.progress - VoiceBroadcastPlaybackViewModel.defaultBackwardForwardValue + seek(to: max(newProgressValue, 0.0)) + } + + /// Forward (30sec) a voice broadcast + private func forward() { + let newProgressValue = context.progress + VoiceBroadcastPlaybackViewModel.defaultBackwardForwardValue + seek(to: min(newProgressValue, state.playingState.duration)) + } + + private func seek(to seekTime: Float) { + // Flush the chunks queue and the current audio player playlist + voiceBroadcastChunkQueue = [] + reloadVoiceBroadcastChunkQueue = isProcessingVoiceBroadcastChunk + audioPlayer?.removeAllPlayerItems() + + let chunks = reorderVoiceBroadcastChunks(chunks: Array(voiceBroadcastAggregator.voiceBroadcast.chunks)) + + // Reinject the chunks we need and play them + let remainingTime = state.playingState.duration - seekTime + var chunksDuration: UInt = 0 + for chunk in chunks.reversed() { + chunksDuration += chunk.duration + voiceBroadcastChunkQueue.append(chunk) + if Float(chunksDuration) >= remainingTime { + break + } + } + + MXLog.debug("[VoiceBroadcastPlaybackViewModel] seekTo: restart to time: \(seekTime) milliseconds") + let time = seekTime - state.playingState.duration + Float(chunksDuration) + seekToChunkTime = TimeInterval(time / 1000) + // Check the condition to resume the playback when data will be ready (after the chunk process). + if state.playbackState != .stopped, isActuallyPaused == false { + state.playbackState = .buffering + } + processPendingVoiceBroadcastChunks() + + state.bindings.progress = seekTime + updateUI() + } // MARK: - Voice broadcast chunks playback @@ -281,12 +346,16 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic private func updateDuration() { let duration = voiceBroadcastAggregator.voiceBroadcast.duration - let time = TimeInterval(duration / 1000) - let formatter = DateComponentsFormatter() - formatter.unitsStyle = .abbreviated - state.playingState.duration = Float(duration) - state.playingState.durationLabel = formatter.string(from: time) + updateUI() + } + + private func dateFormatter(for time: TimeInterval) -> DateComponentsFormatter { + if time >= 3600 { + return self.fullDateFormatter + } else { + return self.shortDateFormatter + } } private func didSliderChanged(_ didChange: Bool) { @@ -295,40 +364,11 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic audioPlayer?.pause() displayLink.isPaused = true } else { - // Flush the chunks queue and the current audio player playlist - voiceBroadcastChunkQueue = [] - reloadVoiceBroadcastChunkQueue = isProcessingVoiceBroadcastChunk - audioPlayer?.removeAllPlayerItems() - - let chunks = reorderVoiceBroadcastChunks(chunks: Array(voiceBroadcastAggregator.voiceBroadcast.chunks)) - - // Reinject the chunks we need and play them - let remainingTime = state.playingState.duration - state.bindings.progress - var chunksDuration: UInt = 0 - for chunk in chunks.reversed() { - chunksDuration += chunk.duration - voiceBroadcastChunkQueue.append(chunk) - if Float(chunksDuration) >= remainingTime { - break - } - } - - MXLog.debug("[VoiceBroadcastPlaybackViewModel] didSliderChanged: restart to time: \(state.bindings.progress) milliseconds") - let time = state.bindings.progress - state.playingState.duration + Float(chunksDuration) - seekToChunkTime = TimeInterval(time / 1000) - // Check the condition to resume the playback when data will be ready (after the chunk process). - if state.playbackState != .stopped, isActuallyPaused == false { - state.playbackState = .buffering - } - processPendingVoiceBroadcastChunks() + seek(to: state.bindings.progress) } } @objc private func handleDisplayLinkTick() { - updateUI() - } - - private func updateUI() { guard let playingEventId = voiceBroadcastAttachmentCacheManagerLoadResults.first(where: { result in result.url == audioPlayer?.currentUrl })?.eventIdentifier, @@ -343,6 +383,25 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic }.reduce(0) { $0 + $1.duration}) + (audioPlayer?.currentTime.rounded() ?? 0) * 1000 state.bindings.progress = Float(progress) + + updateUI() + } + + private func updateUI() { + let time = TimeInterval(state.playingState.duration / 1000) + let formatter = dateFormatter(for: time) + + let currentProgress = TimeInterval(state.bindings.progress / 1000) + let remainingTime = time-currentProgress + var label = "" + if let remainingTimeString = formatter.string(from: remainingTime) { + label = Int(remainingTime) == 0 ? remainingTimeString : "-" + remainingTimeString + } + state.playingState.elapsedTimeLabel = formatter.string(from: currentProgress) + state.playingState.remainingTimeLabel = label + + state.playingState.canMoveBackward = state.bindings.progress > 0 + state.playingState.canMoveForward = state.bindings.progress < state.playingState.duration } private func handleVoiceBroadcastChunksProcessing() { @@ -410,7 +469,8 @@ extension VoiceBroadcastPlaybackViewModel: VoiceMessageAudioPlayerDelegate { MXLog.debug("[VoiceBroadcastPlaybackViewModel] audioPlayerDidStopPlaying") state.playbackState = .stopped state.playingState.isLive = false - release() + audioPlayer.deregisterDelegate(self) + self.audioPlayer = nil } func audioPlayer(_ audioPlayer: VoiceMessageAudioPlayer, didFailWithError error: Error) { diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/View/VoiceBroadcastPlaybackView.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/View/VoiceBroadcastPlaybackView.swift index c518f7e59..09ed1ff44 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/View/VoiceBroadcastPlaybackView.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/View/VoiceBroadcastPlaybackView.swift @@ -33,7 +33,7 @@ struct VoiceBroadcastPlaybackView: View { @State private var bufferingSpinnerRotationValue = 0.0 private var backgroundColor: Color { - if viewModel.viewState.playingState.isLive { + if viewModel.viewState.broadcastState != .paused { return theme.colors.alert } return theme.colors.quarterlyContent @@ -49,9 +49,9 @@ struct VoiceBroadcastPlaybackView: View { VStack(alignment: .center) { HStack (alignment: .top) { - AvatarImage(avatarData: viewModel.viewState.details.avatarData, size: .xSmall) + AvatarImage(avatarData: viewModel.viewState.details.avatarData, size: .small) - VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 3) { Text(details.avatarData.displayName ?? details.avatarData.matrixItemId) .font(theme.fonts.bodySB) .foregroundColor(theme.colors.primaryContent) @@ -97,20 +97,34 @@ struct VoiceBroadcastPlaybackView: View { Text(VectorL10n.voiceBroadcastLive) .font(theme.fonts.caption1SB) .foregroundColor(Color.white) + .padding(.leading, -4) } icon: { Image(uiImage: Asset.Images.voiceBroadcastLive.image) } - .padding(.horizontal, 5) - .background(RoundedRectangle(cornerRadius: 4, style: .continuous).fill(backgroundColor)) + .padding(EdgeInsets(top: 2.0, leading: 4.0, bottom: 2.0, trailing: 4.0)) + .background(RoundedRectangle(cornerRadius: 2, style: .continuous).fill(backgroundColor)) .accessibilityIdentifier("liveLabel") } } .frame(maxWidth: .infinity, alignment: .leading) + .padding(EdgeInsets(top: 0.0, leading: 0.0, bottom: 4.0, trailing: 0.0)) if viewModel.viewState.playbackState == .error { VoiceBroadcastPlaybackErrorView() } else { - ZStack { + HStack (spacing: 34.0) { + if viewModel.viewState.playingState.canMoveBackward { + Button { + viewModel.send(viewAction: .backward) + } label: { + Image(uiImage: Asset.Images.voiceBroadcastBackward30s.image) + .renderingMode(.original) + } + .accessibilityIdentifier("backwardButton") + } else { + Spacer().frame(width: 25.0) + } + if viewModel.viewState.playbackState == .playing || viewModel.viewState.playbackState == .buffering { Button { viewModel.send(viewAction: .pause) } label: { Image(uiImage: Asset.Images.voiceBroadcastPause.image) @@ -125,21 +139,41 @@ struct VoiceBroadcastPlaybackView: View { .disabled(viewModel.viewState.playbackState == .buffering) .accessibilityIdentifier("playButton") } + + if viewModel.viewState.playingState.canMoveForward { + Button { + viewModel.send(viewAction: .forward) + } label: { + Image(uiImage: Asset.Images.voiceBroadcastForward30s.image) + .renderingMode(.original) + } + .accessibilityIdentifier("forwardButton") + } else { + Spacer().frame(width: 25.0) + } } + .padding(EdgeInsets(top: 10.0, leading: 0.0, bottom: 10.0, trailing: 0.0)) } - Slider(value: $viewModel.progress, in: 0...viewModel.viewState.playingState.duration) { - Text("Slider") - } minimumValueLabel: { - Text("") - } maximumValueLabel: { - Text(viewModel.viewState.playingState.durationLabel ?? "").font(.body) - } onEditingChanged: { didChange in + VoiceBroadcastSlider(value: $viewModel.progress, + minValue: 0.0, + maxValue: viewModel.viewState.playingState.duration) { didChange in viewModel.send(viewAction: .sliderChange(didChange: didChange)) } + + HStack { + Text(viewModel.viewState.playingState.elapsedTimeLabel ?? "") + .foregroundColor(theme.colors.secondaryContent) + .font(theme.fonts.caption1) + .padding(EdgeInsets(top: -8.0, leading: 4.0, bottom: 0.0, trailing: 0.0)) + Spacer() + Text(viewModel.viewState.playingState.remainingTimeLabel ?? "") + .foregroundColor(theme.colors.secondaryContent) + .font(theme.fonts.caption1) + .padding(EdgeInsets(top: -8.0, leading: 0.0, bottom: 0.0, trailing: 4.0)) + } } - .padding([.horizontal, .top], 2.0) - .padding([.bottom]) + .padding(EdgeInsets(top: 12.0, leading: 4.0, bottom: 12.0, trailing: 4.0)) } } diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/View/VoiceBroadcastSlider.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/View/VoiceBroadcastSlider.swift new file mode 100644 index 000000000..50845d5c8 --- /dev/null +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/View/VoiceBroadcastSlider.swift @@ -0,0 +1,69 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +/// Customized UISlider for SwiftUI. + +struct VoiceBroadcastSlider: UIViewRepresentable { + @Binding var value: Float + + var minValue: Float = 0.0 + var maxValue: Float = 1.0 + var onEditingChanged : ((Bool) -> Void)? + + func makeUIView(context: Context) -> UISlider { + let slider = UISlider(frame: .zero) + slider.setThumbImage(Asset.Images.voiceBroadcastSliderThumb.image, for: .normal) + slider.setMinimumTrackImage(Asset.Images.voiceBroadcastSliderMinTrack.image, for: .normal) + slider.setMaximumTrackImage(Asset.Images.voiceBroadcastSliderMaxTrack.image, for: .normal) + slider.minimumValue = Float(minValue) + slider.maximumValue = Float(maxValue) + slider.value = Float(value) + slider.addTarget(context.coordinator, action: #selector(Coordinator.valueChanged(_:)), for: .valueChanged) + slider.addTarget(context.coordinator, action: #selector(Coordinator.sliderEditingChanged(_:)), for: .touchUpInside) + slider.addTarget(context.coordinator, action: #selector(Coordinator.sliderEditingChanged(_:)), for: .touchUpOutside) + slider.addTarget(context.coordinator, action: #selector(Coordinator.sliderEditingChanged(_:)), for: .touchDown) + + return slider + } + + func updateUIView(_ uiView: UISlider, context: Context) { + uiView.value = Float(value) + } + + func makeCoordinator() -> VoiceBroadcastSlider.Coordinator { + Coordinator(parent: self, value: $value) + } + + class Coordinator: NSObject { + var parent: VoiceBroadcastSlider + var value: Binding + + init(parent: VoiceBroadcastSlider, value: Binding) { + self.value = value + self.parent = parent + } + + @objc func valueChanged(_ sender: UISlider) { + self.value.wrappedValue = sender.value + } + + @objc func sliderEditingChanged(_ sender: UISlider) { + parent.onEditingChanged?(sender.isTracking) + } + } +} diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackModels.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackModels.swift index 18d80d3af..488b65c1d 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackModels.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackModels.swift @@ -21,6 +21,9 @@ enum VoiceBroadcastPlaybackViewAction { case play case pause case sliderChange(didChange: Bool) + case backward + case forward + case redact } enum VoiceBroadcastPlaybackState { @@ -38,8 +41,11 @@ struct VoiceBroadcastPlaybackDetails { struct VoiceBroadcastPlayingState { var duration: Float - var durationLabel: String? + var elapsedTimeLabel: String? + var remainingTimeLabel: String? var isLive: Bool + var canMoveForward: Bool + var canMoveBackward: Bool } struct VoiceBroadcastPlaybackViewState: BindableState { diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackScreenState.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackScreenState.swift index d88f7dfa8..306a5be8c 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackScreenState.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackScreenState.swift @@ -18,7 +18,7 @@ import Foundation import SwiftUI typealias MockVoiceBroadcastPlaybackViewModelType = StateStoreViewModel -class MockVoiceBroadcastPlaybackViewModel: MockVoiceBroadcastPlaybackViewModelType, VoiceBroadcastPlaybackViewModelProtocol { +class MockVoiceBroadcastPlaybackViewModel: MockVoiceBroadcastPlaybackViewModelType, VoiceBroadcastPlaybackViewModelProtocol { } /// Using an enum for the screen allows you define the different state cases with @@ -43,7 +43,7 @@ enum MockVoiceBroadcastPlaybackScreenState: MockScreenState, CaseIterable { var screenView: ([Any], AnyView) { let details = VoiceBroadcastPlaybackDetails(senderDisplayName: "Alice", avatarData: AvatarInput(mxContentUri: "", matrixItemId: "!fakeroomid:matrix.org", displayName: "The name of the room")) - let viewModel = MockVoiceBroadcastPlaybackViewModel(initialViewState: VoiceBroadcastPlaybackViewState(details: details, broadcastState: .started, playbackState: .stopped, playingState: VoiceBroadcastPlayingState(duration: 10.0, isLive: true), bindings: VoiceBroadcastPlaybackViewStateBindings(progress: 0))) + let viewModel = MockVoiceBroadcastPlaybackViewModel(initialViewState: VoiceBroadcastPlaybackViewState(details: details, broadcastState: .started, playbackState: .stopped, playingState: VoiceBroadcastPlayingState(duration: 10.0, isLive: true, canMoveForward: false, canMoveBackward: false), bindings: VoiceBroadcastPlaybackViewStateBindings(progress: 0))) return ( [false, viewModel], diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackViewModelProtocol.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackViewModelProtocol.swift index 1ad8d64c5..e89df8828 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackViewModelProtocol.swift @@ -19,5 +19,5 @@ import Foundation typealias VoiceBroadcastPlaybackViewModelType = StateStoreViewModel protocol VoiceBroadcastPlaybackViewModelProtocol { - var context: VoiceBroadcastPlaybackViewModelType.Context { get } + var context: VoiceBroadcastPlaybackViewModelType.Context { get } } diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Coordinator/VoiceBroadcastRecorderCoordinator.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Coordinator/VoiceBroadcastRecorderCoordinator.swift index d0205a9fb..2a9fe90b8 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Coordinator/VoiceBroadcastRecorderCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Coordinator/VoiceBroadcastRecorderCoordinator.swift @@ -50,6 +50,10 @@ final class VoiceBroadcastRecorderCoordinator: Coordinator, Presentable { recorderService: voiceBroadcastRecorderService) voiceBroadcastRecorderViewModel = viewModel } + + deinit { + voiceBroadcastRecorderService.cancelRecordingVoiceBroadcast() + } // MARK: - Public @@ -57,6 +61,7 @@ final class VoiceBroadcastRecorderCoordinator: Coordinator, Presentable { func toPresentable() -> UIViewController { let view = VoiceBroadcastRecorderView(viewModel: voiceBroadcastRecorderViewModel.context) + .addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager)) return VectorHostingController(rootView: view) } diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Coordinator/VoiceBroadcastRecorderProvider.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Coordinator/VoiceBroadcastRecorderProvider.swift index 3db5cad54..e7f998716 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Coordinator/VoiceBroadcastRecorderProvider.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Coordinator/VoiceBroadcastRecorderProvider.swift @@ -33,7 +33,19 @@ import Foundation } } } - var coordinatorsForEventIdentifiers = [String: VoiceBroadcastRecorderCoordinator]() + private var coordinatorsForEventIdentifiers = [String: VoiceBroadcastRecorderCoordinator]() { + didSet { + if !self.coordinatorsForEventIdentifiers.isEmpty && self.redactionsListener == nil { + redactionsListener = session?.listenToEvents([MXEventType(identifier: kMXEventTypeStringRoomRedaction)], self.handleRedactedEvent) + } + + if self.coordinatorsForEventIdentifiers.isEmpty && self.redactionsListener != nil { + session?.removeListener(self.redactionsListener) + self.redactionsListener = nil + } + } + } + private var redactionsListener: Any? // MARK: Private private var currentEventIdentifier: String? @@ -83,4 +95,17 @@ import Foundation return coordinatorsForEventIdentifiers[currentEventIdentifier] } + + private func handleRedactedEvent(event: MXEvent, direction: MXTimelineDirection, customObject: Any?) { + if direction == .backwards { + // ignore backwards events + return + } + + var coordinator = coordinatorsForEventIdentifiers.removeValue(forKey: event.redacts) + + coordinator?.toPresentable().dismiss(animated: false) { + coordinator = nil + } + } } diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/MatrixSDK/VoiceBroadcastRecorderService.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/MatrixSDK/VoiceBroadcastRecorderService.swift index 5c4eae74a..437abbe3c 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/MatrixSDK/VoiceBroadcastRecorderService.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/MatrixSDK/VoiceBroadcastRecorderService.swift @@ -151,6 +151,21 @@ class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol { }) } + func cancelRecordingVoiceBroadcast() { + MXLog.debug("[VoiceBroadcastRecorderService] Cancel recording voice broadcast") + audioEngine.stop() + audioEngine.inputNode.removeTap(onBus: audioNodeBus) + UIApplication.shared.isIdleTimerDisabled = false + + // Remove current chunk + if self.chunkFile != nil { + self.deleteRecording(at: self.chunkFile.url) + self.chunkFile = nil + } + + self.tearDownVoiceBroadcastService() + } + // MARK: - Private /// Reset chunk values. private func resetValues() { diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/VoiceBroadcastRecorderServiceProtocol.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/VoiceBroadcastRecorderServiceProtocol.swift index e457eb843..9e48e2e9a 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/VoiceBroadcastRecorderServiceProtocol.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/VoiceBroadcastRecorderServiceProtocol.swift @@ -36,4 +36,7 @@ protocol VoiceBroadcastRecorderServiceProtocol { /// Resume voice broadcast recording after paused it. func resumeRecordingVoiceBroadcast() + + /// Cancel voice broadcast recording after redacted it. + func cancelRecordingVoiceBroadcast() } diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/View/VoiceBroadcastRecorderView.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/View/VoiceBroadcastRecorderView.swift index 13df1f5ac..c0cafed9b 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/View/VoiceBroadcastRecorderView.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/View/VoiceBroadcastRecorderView.swift @@ -42,9 +42,9 @@ struct VoiceBroadcastRecorderView: View { VStack(alignment: .center) { HStack(alignment: .top) { - AvatarImage(avatarData: viewModel.viewState.details.avatarData, size: .xSmall) + AvatarImage(avatarData: viewModel.viewState.details.avatarData, size: .small) - VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 3) { Text(details.avatarData.displayName ?? details.avatarData.matrixItemId) .font(theme.fonts.bodySB) .foregroundColor(theme.colors.primaryContent) @@ -69,15 +69,16 @@ struct VoiceBroadcastRecorderView: View { Text(VectorL10n.voiceBroadcastLive) .font(theme.fonts.caption1SB) .foregroundColor(Color.white) + .padding(.leading, -4) } icon: { Image(uiImage: Asset.Images.voiceBroadcastLive.image) } - .padding(.horizontal, 5) - .background(RoundedRectangle(cornerRadius: 4, style: .continuous).fill(backgroundColor)) + .padding(EdgeInsets(top: 2.0, leading: 4.0, bottom: 2.0, trailing: 4.0)) + .background(RoundedRectangle(cornerRadius: 2, style: .continuous).fill(backgroundColor)) .accessibilityIdentifier("liveButton") } - HStack(alignment: .top, spacing: 16.0) { + HStack(alignment: .top, spacing: 34.0) { Button { switch viewModel.viewState.recordingState { case .started, .resumed: @@ -117,9 +118,9 @@ struct VoiceBroadcastRecorderView: View { .disabled(viewModel.viewState.recordingState == .stopped) .mask(Color.black.opacity(viewModel.viewState.recordingState == .stopped ? 0.3 : 1.0)) } + .padding(EdgeInsets(top: 10.0, leading: 0.0, bottom: 10.0, trailing: 0.0)) } - .padding([.horizontal, .top], 2.0) - .padding([.bottom]) + .padding(EdgeInsets(top: 12.0, leading: 4.0, bottom: 12.0, trailing: 4.0)) } } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/View/SpaceSelectorListRow.swift b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/View/SpaceSelectorListRow.swift index d9e67c9b9..03b86ab45 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/View/SpaceSelectorListRow.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceSelectorBottomSheet/SpaceSelector/View/SpaceSelectorListRow.swift @@ -57,6 +57,7 @@ struct SpaceSelectorListRow: View { .clipShape(RoundedRectangle(cornerRadius: 8)) } Text(displayName ?? "") + .lineLimit(1) .foregroundColor(theme.colors.primaryContent) .font(theme.fonts.bodySB) .accessibility(identifier: "itemName") diff --git a/RiotSwiftUI/Modules/UserSessions/Common/Test/Unit/UserSessionNameFormatterTests.swift b/RiotSwiftUI/Modules/UserSessions/Common/Test/Unit/UserSessionNameFormatterTests.swift index c40bb2fa3..a3703ef54 100644 --- a/RiotSwiftUI/Modules/UserSessions/Common/Test/Unit/UserSessionNameFormatterTests.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/Test/Unit/UserSessionNameFormatterTests.swift @@ -20,14 +20,14 @@ import XCTest class UserSessionNameFormatterTests: XCTestCase { func testSessionDisplayNameTrumpsDeviceTypeName() { - XCTAssertEqual("Johnny's iPhone", UserSessionNameFormatter.sessionName(deviceType: .mobile, sessionDisplayName: "Johnny's iPhone")) + XCTAssertEqual("Johnny's iPhone", UserSessionNameFormatter.sessionName(sessionId: "sessionId", sessionDisplayName: "Johnny's iPhone")) } func testEmptySessionDisplayNameFallsBackToDeviceTypeName() { - XCTAssertEqual(DeviceType.mobile.name, UserSessionNameFormatter.sessionName(deviceType: .mobile, sessionDisplayName: "")) + XCTAssertEqual("sessionId", UserSessionNameFormatter.sessionName(sessionId: "sessionId", sessionDisplayName: "")) } func testNilSessionDisplayNameFallsBackToDeviceTypeName() { - XCTAssertEqual(DeviceType.mobile.name, UserSessionNameFormatter.sessionName(deviceType: .mobile, sessionDisplayName: nil)) + XCTAssertEqual("sessionId", UserSessionNameFormatter.sessionName(sessionId: "sessionId", sessionDisplayName: nil)) } } diff --git a/RiotSwiftUI/Modules/UserSessions/Common/UserSessionNameFormatter.swift b/RiotSwiftUI/Modules/UserSessions/Common/UserSessionNameFormatter.swift index db7f2e1e6..c39c64aba 100644 --- a/RiotSwiftUI/Modules/UserSessions/Common/UserSessionNameFormatter.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/UserSessionNameFormatter.swift @@ -19,7 +19,7 @@ import Foundation /// Enables to build user session name enum UserSessionNameFormatter { /// Session name with client name and session display name - static func sessionName(deviceType: DeviceType, sessionDisplayName: String?) -> String { - sessionDisplayName?.vc_nilIfEmpty() ?? deviceType.name + static func sessionName(sessionId: String, sessionDisplayName: String?) -> String { + sessionDisplayName?.vc_nilIfEmpty() ?? sessionId } } diff --git a/RiotSwiftUI/Modules/UserSessions/Common/View/DeviceAvatarView.swift b/RiotSwiftUI/Modules/UserSessions/Common/View/DeviceAvatarView.swift index 0e894961d..0352f35a4 100644 --- a/RiotSwiftUI/Modules/UserSessions/Common/View/DeviceAvatarView.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/View/DeviceAvatarView.swift @@ -23,6 +23,7 @@ struct DeviceAvatarView: View { var viewData: DeviceAvatarViewData var isSelected: Bool + var showVerificationBadge: Bool = true var avatarSize: CGFloat = 40 var badgeSize: CGFloat = 24 @@ -40,13 +41,14 @@ struct DeviceAvatarView: View { .background(isSelected ? theme.colors.primaryContent : theme.colors.system) .clipShape(Circle()) - // Verification badge - Image(viewData.verificationImageName) - .frame(maxWidth: CGFloat(badgeSize), maxHeight: CGFloat(badgeSize)) - .shapedBorder(color: theme.colors.system, borderWidth: 1, shape: Circle()) - .background(theme.colors.background) - .clipShape(Circle()) - .offset(x: 10, y: 8) + if showVerificationBadge { + Image(viewData.verificationImageName) + .frame(maxWidth: CGFloat(badgeSize), maxHeight: CGFloat(badgeSize)) + .shapedBorder(color: theme.colors.quinaryContent, borderWidth: 1, shape: Circle()) + .background(theme.colors.background) + .clipShape(Circle()) + .offset(x: 10, y: 8) + } } .frame(maxWidth: CGFloat(avatarSize), maxHeight: CGFloat(avatarSize)) } diff --git a/RiotSwiftUI/Modules/UserSessions/Common/View/SeparatorLine.swift b/RiotSwiftUI/Modules/UserSessions/Common/View/SeparatorLine.swift index 2f56761ef..22c24aa94 100644 --- a/RiotSwiftUI/Modules/UserSessions/Common/View/SeparatorLine.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/View/SeparatorLine.swift @@ -19,10 +19,12 @@ import SwiftUI struct SeparatorLine: View { @Environment(\.theme) private var theme: ThemeSwiftUI + var height: CGFloat = 1.0 + var body: some View { Rectangle() .fill(theme.colors.quinaryContent) .frame(maxWidth: .infinity) - .frame(height: 1.0) + .frame(height: height) } } diff --git a/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift b/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift index 9f6f6460f..a8a4aa351 100644 --- a/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift @@ -30,14 +30,21 @@ struct UserSessionCardView: View { RoundedRectangle(cornerRadius: 8) } + enum DisplayMode { + case compact + case extended + } + let showLocationInformations: Bool + let displayMode: DisplayMode + private var showExtraInformations: Bool { - viewData.isCurrentSessionDisplayMode == false && (viewData.lastActivityDateString.isEmptyOrNil == false || ipText.isEmptyOrNil == false) + displayMode == .extended && (viewData.lastActivityDateString.isEmptyOrNil == false || ipText.isEmptyOrNil == false) } var body: some View { VStack(alignment: .center, spacing: 12) { - DeviceAvatarView(viewData: viewData.deviceAvatarViewData, isSelected: false) + DeviceAvatarView(viewData: viewData.deviceAvatarViewData, isSelected: false, showVerificationBadge: false) .accessibilityHidden(true) Text(viewData.sessionName) @@ -45,10 +52,16 @@ struct UserSessionCardView: View { .foregroundColor(theme.colors.primaryContent) .multilineTextAlignment(.center) - Label(viewData.verificationStatusText, image: viewData.verificationStatusImageName) - .font(theme.fonts.subheadline) - .foregroundColor(theme.colors[keyPath: viewData.verificationStatusColor]) - .multilineTextAlignment(.center) + Label { + Text(viewData.verificationStatusText) + .font(theme.fonts.subheadline) + .foregroundColor(theme.colors[keyPath: viewData.verificationStatusColor]) + .multilineTextAlignment(.center) + } icon: { + Image(viewData.verificationStatusImageName) + .resizable() + .frame(width: 16, height: 16) + } InlineTextButton(viewData.verificationStatusAdditionalInfoText, tappableText: VectorL10n.userSessionLearnMore, alwaysCallAction: false) { onLearnMoreAction?() @@ -93,18 +106,19 @@ struct UserSessionCardView: View { .accessibilityIdentifier("userSessionCardVerifyButton") } - if viewData.isCurrentSessionDisplayMode { + if viewData.isCurrentSessionDisplayMode, displayMode == .compact { Text(VectorL10n.userSessionViewDetails) .font(theme.fonts.body) .foregroundColor(theme.colors.accent) .accessibilityIdentifier("userSessionCardViewDetails") + .padding(.top, 8) } } .padding(24) .frame(maxWidth: .infinity) .background(theme.colors.background) .clipShape(backgroundShape) - .shapedBorder(color: theme.colors.quinaryContent, borderWidth: 1.0, shape: backgroundShape) + .shapedBorder(color: theme.colors.quinaryContent, borderWidth: 0.5, shape: backgroundShape) .onTapGesture { if viewData.isCurrentSessionDisplayMode { onViewDetailsAction?(viewData.sessionId) @@ -124,8 +138,9 @@ struct UserSessionCardViewPreview: View { @Environment(\.theme) var theme: ThemeSwiftUI let viewData: UserSessionCardViewData + let displayMode: UserSessionCardView.DisplayMode - init(isCurrent: Bool = false, verificationState: UserSessionInfo.VerificationState = .unverified) { + init(isCurrent: Bool = false, verificationState: UserSessionInfo.VerificationState = .unverified, displayMode: UserSessionCardView.DisplayMode = .extended) { let sessionInfo = UserSessionInfo(id: "alice", name: "iOS", deviceType: .mobile, @@ -143,11 +158,12 @@ struct UserSessionCardViewPreview: View { isActive: true, isCurrent: isCurrent) viewData = UserSessionCardViewData(sessionInfo: sessionInfo) + self.displayMode = displayMode } var body: some View { VStack { - UserSessionCardView(viewData: viewData, showLocationInformations: true) + UserSessionCardView(viewData: viewData, showLocationInformations: true, displayMode: displayMode) } .frame(maxWidth: .infinity) .background(theme.colors.system) @@ -158,7 +174,8 @@ struct UserSessionCardViewPreview: View { struct UserSessionCardView_Previews: PreviewProvider { static var previews: some View { Group { - UserSessionCardViewPreview(isCurrent: true).theme(.light).preferredColorScheme(.light) + UserSessionCardViewPreview(isCurrent: true, displayMode: .compact).theme(.light).preferredColorScheme(.light) + UserSessionCardViewPreview(isCurrent: true, displayMode: .extended).theme(.light).preferredColorScheme(.light) UserSessionCardViewPreview(isCurrent: true).theme(.dark).preferredColorScheme(.dark) UserSessionCardViewPreview().theme(.light).preferredColorScheme(.light) UserSessionCardViewPreview().theme(.dark).preferredColorScheme(.dark) diff --git a/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardViewData.swift b/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardViewData.swift index 79cd2d812..7ccf63bff 100644 --- a/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardViewData.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardViewData.swift @@ -102,7 +102,7 @@ struct UserSessionCardViewData { isCurrentSessionDisplayMode: Bool = false, isActive: Bool) { self.sessionId = sessionId - sessionName = UserSessionNameFormatter.sessionName(deviceType: deviceType, sessionDisplayName: sessionDisplayName) + sessionName = UserSessionNameFormatter.sessionName(sessionId: sessionId, sessionDisplayName: sessionDisplayName) self.verificationState = verificationState var lastActivityDateString: String? diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift index e0f71675c..41d79fe54 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift @@ -22,8 +22,8 @@ struct UserOtherSessions: View { @ObservedObject var viewModel: UserOtherSessionsViewModel.Context var body: some View { - VStack(spacing: 0) { - ScrollView { + ScrollView { + VStack(spacing: 0) { SwiftUI.Section { if viewModel.viewState.sessionItems.isEmpty { noItemsView() @@ -37,7 +37,7 @@ struct UserOtherSessions: View { viewModel.send(viewAction: .viewSessionInfo) } ) - .padding(.top) + .padding(.vertical, 24) } } if viewModel.isEditModeEnabled { @@ -100,7 +100,7 @@ struct UserOtherSessions: View { isSeparatorHidden: viewData == viewModel.viewState.sessionItems.last, isEditModeEnabled: viewModel.isEditModeEnabled, onBackgroundTap: { sessionId in viewModel.send(viewAction: .userOtherSessionSelected(sessionId: sessionId)) }, - onBackgroundLongPress: { _ in viewModel.isEditModeEnabled = true }) + onBackgroundLongPress: { _ in }) } } .background(theme.colors.background) diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsHeaderView.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsHeaderView.swift index a815d7875..5555cc827 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsHeaderView.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsHeaderView.swift @@ -54,7 +54,6 @@ struct UserOtherSessionsHeaderView: View { } .font(theme.fonts.footnote) .foregroundColor(theme.colors.secondaryContent) - .padding(.bottom, 20.0) }) } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsToolbar.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsToolbar.swift index b74606910..331ede0c3 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsToolbar.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsToolbar.swift @@ -107,5 +107,6 @@ struct UserOtherSessionsToolbar: ToolbarContent { .padding(.vertical, 12) } } + .accessibilityIdentifier("More") } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/View/UserSessionDetails.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/View/UserSessionDetails.swift index 8801c9ef0..215441892 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/View/UserSessionDetails.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/View/UserSessionDetails.swift @@ -54,6 +54,7 @@ struct UserSessionDetails: View { } } .listStyle(.grouped) + .listBackgroundColor(theme.colors.system) .navigationBarTitle(VectorL10n.userSessionDetailsTitle) } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverview.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverview.swift index 023952845..8f155814f 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverview.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverview.swift @@ -34,21 +34,24 @@ struct UserSessionOverview: View { onLearnMoreAction: { viewModel.send(viewAction: .viewSessionInfo) }, - showLocationInformations: viewModel.viewState.showLocationInfo + showLocationInformations: viewModel.viewState.showLocationInfo, + displayMode: .extended ) .padding(16) SwiftUI.Section { - UserSessionOverviewItem(title: VectorL10n.userSessionOverviewSessionDetailsButtonTitle, - showsChevron: true) { - viewModel.send(viewAction: .viewSessionDetails) - } - - if let enabled = viewModel.viewState.isPusherEnabled { - UserSessionOverviewToggleCell(title: VectorL10n.userSessionPushNotifications, - message: VectorL10n.userSessionPushNotificationsMessage, - isOn: enabled, isEnabled: viewModel.viewState.remotelyTogglingPushersAvailable) { - viewModel.send(viewAction: .togglePushNotifications) + VStack(spacing: 24) { + UserSessionOverviewItem(title: VectorL10n.userSessionOverviewSessionDetailsButtonTitle, + showsChevron: true) { + viewModel.send(viewAction: .viewSessionDetails) + } + + if let enabled = viewModel.viewState.isPusherEnabled { + UserSessionOverviewToggleCell(title: VectorL10n.userSessionPushNotifications, + message: VectorL10n.userSessionPushNotificationsMessage, + isOn: enabled, isEnabled: viewModel.viewState.remotelyTogglingPushersAvailable) { + viewModel.send(viewAction: .togglePushNotifications) + } } } } @@ -77,12 +80,10 @@ struct UserSessionOverview: View { } .accessibilityIdentifier(VectorL10n.manageSessionRename) - if viewModel.viewState.isCurrentSession == false { - Button { - viewModel.send(viewAction: .showLocationInfo) - } label: { - Label(showLocationInfo: viewModel.viewState.showLocationInfo) - } + Button { + viewModel.send(viewAction: .showLocationInfo) + } label: { + Label(showLocationInfo: viewModel.viewState.showLocationInfo) } } DestructiveButton { @@ -93,7 +94,7 @@ struct UserSessionOverview: View { .accessibilityIdentifier(VectorL10n.signOut) } label: { Image(systemName: "ellipsis") - .foregroundColor(theme.colors.secondaryContent) + .foregroundColor(theme.colors.accent) .padding(.horizontal, 4) .padding(.vertical, 12) } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverviewItem.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverviewItem.swift index b54d23a99..580c9181f 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverviewItem.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverviewItem.swift @@ -36,10 +36,11 @@ struct UserSessionOverviewItem: View { .frame(maxWidth: .infinity, alignment: alignment) if showsChevron { - Image(Asset.Images.chevron.name) + Image(Asset.Images.disclosureIcon.name) + .foregroundColor(theme.colors.tertiaryContent) } } - .padding(.vertical, 15) + .padding(.vertical, 11) .padding(.horizontal, 16) SeparatorLine() } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverviewToggleCell.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverviewToggleCell.swift index b6f79b58f..c162e930e 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverviewToggleCell.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverviewToggleCell.swift @@ -41,7 +41,7 @@ struct UserSessionOverviewToggleCell: View { } .disabled(!isEnabled) .allowsHitTesting(false) - .padding(.vertical, 10) + .padding(.vertical, 5.5) .padding(.horizontal, 16) .accessibilityIdentifier("UserSessionOverviewToggleCell") SeparatorLine() diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProvider.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProvider.swift index a5c90d65d..d54d865ac 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProvider.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProvider.swift @@ -37,7 +37,16 @@ class UserSessionsDataProvider: UserSessionsDataProviderProtocol { } func devices(completion: @escaping (MXResponse<[MXDevice]>) -> Void) { - session.matrixRestClient.devices(completion: completion) + session.matrixRestClient.devices { [weak self] response in + switch response { + case .success(let devices): + self?.deleteAccountDataIfNeeded(deviceList: devices) + case .failure: + break + } + + completion(response) + } } func device(withDeviceId deviceId: String, ofUser userId: String) -> MXDeviceInfo? { @@ -66,3 +75,29 @@ class UserSessionsDataProvider: UserSessionsDataProviderProtocol { return try await service.isServiceAvailable() } } + +extension UserSessionsDataProvider { + private func deleteAccountDataIfNeeded(deviceList: [MXDevice]) { + let obsoletedDeviceAccountDataKeys = obsoletedDeviceAccountData(deviceList: deviceList, + accountDataEvents: session.accountData.allAccountDataEvents()) + + for accountDataKey in obsoletedDeviceAccountDataKeys { + session.deleteAccountData(withType: accountDataKey, success: {}, failure: { _ in }) + } + } + + // internal just to facilitate tests + func obsoletedDeviceAccountData(deviceList: [MXDevice], accountDataEvents: [String: Any]) -> Set { + let deviceAccountDataKeys = Set( + accountDataEvents + .map(\.key) + .filter { $0.hasPrefix(kMXAccountDataTypeClientInformation) } + ) + + let expectedDeviceAccountDataKeys = Set(deviceList.map { + "\(kMXAccountDataTypeClientInformation).\($0.deviceId)" + }) + + return deviceAccountDataKeys.subtracting(expectedDeviceAccountDataKeys) + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/SecurityRecommendationCard.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/SecurityRecommendationCard.swift index 090051154..e272c0563 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/SecurityRecommendationCard.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/SecurityRecommendationCard.swift @@ -30,12 +30,12 @@ struct SecurityRecommendationCard: View { let action: () -> Void var body: some View { - HStack(alignment: .top) { + HStack(alignment: .top, spacing: 16.0) { Image(iconName) VStack(alignment: .leading, spacing: 16.0) { VStack(alignment: .leading, spacing: 8.0) { Text(title) - .font(theme.fonts.calloutSB) + .font(theme.fonts.headline) .foregroundColor(theme.colors.primaryContent) Text(subtitle) @@ -43,20 +43,19 @@ struct SecurityRecommendationCard: View { .foregroundColor(theme.colors.secondaryContent) } - Button { - action() - } label: { - Text(buttonTitle) - .font(theme.fonts.body) - } - .foregroundColor(theme.colors.accent) + Text(buttonTitle) + .font(theme.fonts.body) + .foregroundColor(theme.colors.accent) } .frame(maxWidth: .infinity, alignment: .leading) } .padding(16) .background(theme.colors.background) .clipShape(backgroundShape) - .shapedBorder(color: theme.colors.quinaryContent, borderWidth: 1.0, shape: backgroundShape) + .shapedBorder(color: theme.colors.quinaryContent, borderWidth: 0.5, shape: backgroundShape) + .onTapGesture { + action() + } } private var backgroundShape: RoundedRectangle { diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItem.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItem.swift index f5d24c555..287b64003 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItem.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItem.swift @@ -43,13 +43,14 @@ struct UserSessionListItem: View { DeviceAvatarView(viewData: viewData.deviceAvatarViewData, isSelected: viewData.isSelected) VStack(alignment: .leading, spacing: 0) { Text(viewData.sessionName) - .font(theme.fonts.bodySB) + .lineLimit(1) + .font(theme.fonts.headline) .foregroundColor(theme.colors.primaryContent) .multilineTextAlignment(.leading) .padding(.top, 16) .padding(.bottom, 2) .padding(.trailing, 16) - HStack { + HStack(alignment: .top) { if let sessionDetailsIcon = viewData.sessionDetailsIcon { Image(sessionDetailsIcon) .padding(.leading, 2) @@ -67,7 +68,7 @@ struct UserSessionListItem: View { } .padding(.bottom, 16) .padding(.trailing, 16) - SeparatorLine() + SeparatorLine(height: 0.5) .isHidden(isSeparatorHidden) } .padding(.leading, 7) diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift index 227ed5d01..79317be71 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift @@ -19,7 +19,7 @@ import Foundation struct UserSessionListItemViewDataFactory { func create(from sessionInfo: UserSessionInfo, isSelected: Bool = false) -> UserSessionListItemViewData { - let sessionName = UserSessionNameFormatter.sessionName(deviceType: sessionInfo.deviceType, + let sessionName = UserSessionNameFormatter.sessionName(sessionId: sessionInfo.id, sessionDisplayName: sessionInfo.name) let sessionDetails = buildSessionDetails(sessionInfo: sessionInfo) let deviceAvatarViewData = DeviceAvatarViewData(deviceType: sessionInfo.deviceType, diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsListViewAllView.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsListViewAllView.swift index e3816314c..d472703b5 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsListViewAllView.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsListViewAllView.swift @@ -34,11 +34,11 @@ struct UserSessionsListViewAllView: View { .font(theme.fonts.body) .foregroundColor(theme.colors.accent) .frame(maxWidth: .infinity, alignment: .leading) - Image(Asset.Images.chevron.name) + Image(Asset.Images.disclosureIcon.name) + .foregroundColor(theme.colors.tertiaryContent) } .padding(.vertical, 15) .padding(.trailing, 20) - SeparatorLine() } .background(theme.colors.background) .padding(.leading, 72) diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift index c5ddce11d..9cfc89ca4 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift @@ -24,22 +24,19 @@ struct UserSessionsOverview: View { private let maxOtherSessionsToDisplay = 5 var body: some View { - VStack(alignment: .leading, spacing: 0) { - ScrollView { - if hasSecurityRecommendations { - securityRecommendationsSection - } - - currentSessionsSection - - if !viewModel.viewState.otherSessionsViewData.isEmpty { - otherSessionsSection - } + ScrollView { + if hasSecurityRecommendations { + securityRecommendationsSection + } + + currentSessionsSection + + if !viewModel.viewState.otherSessionsViewData.isEmpty { + otherSessionsSection } - .readableFrame() } .background(theme.colors.system.ignoresSafeArea()) - .frame(maxHeight: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity) .navigationTitle(VectorL10n.userSessionsOverviewTitle) .navigationBarTitleDisplayMode(.inline) .activityIndicator(show: viewModel.viewState.showLoadingIndicator) @@ -51,17 +48,19 @@ struct UserSessionsOverview: View { private var securityRecommendationsSection: some View { SwiftUI.Section { - if !viewModel.viewState.unverifiedSessionsViewData.isEmpty { - SecurityRecommendationCard(style: .unverified, - sessionCount: viewModel.viewState.unverifiedSessionsViewData.count) { - viewModel.send(viewAction: .viewAllUnverifiedSessions) + VStack(spacing: 16) { + if !viewModel.viewState.unverifiedSessionsViewData.isEmpty { + SecurityRecommendationCard(style: .unverified, + sessionCount: viewModel.viewState.unverifiedSessionsViewData.count) { + viewModel.send(viewAction: .viewAllUnverifiedSessions) + } } - } - - if !viewModel.viewState.inactiveSessionsViewData.isEmpty { - SecurityRecommendationCard(style: .inactive, - sessionCount: viewModel.viewState.inactiveSessionsViewData.count) { - viewModel.send(viewAction: .viewAllInactiveSessions) + + if !viewModel.viewState.inactiveSessionsViewData.isEmpty { + SecurityRecommendationCard(style: .inactive, + sessionCount: viewModel.viewState.inactiveSessionsViewData.count) { + viewModel.send(viewAction: .viewAllInactiveSessions) + } } } } header: { @@ -75,10 +74,10 @@ struct UserSessionsOverview: View { Text(VectorL10n.userSessionsOverviewSecurityRecommendationsSectionInfo) .font(theme.fonts.footnote) .foregroundColor(theme.colors.secondaryContent) - .padding(.bottom, 12.0) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, 24) + .padding(.bottom, 8.0) } .padding(.horizontal, 16) .accessibilityIdentifier("userSessionsOverviewSecurityRecommendationsSection") @@ -96,7 +95,7 @@ struct UserSessionsOverview: View { viewModel.send(viewAction: .verifyCurrentSession) }, onViewDetailsAction: { _ in viewModel.send(viewAction: .viewCurrentSessionDetails) - }, showLocationInformations: viewModel.viewState.showLocationInfo) + }, showLocationInformations: viewModel.viewState.showLocationInfo, displayMode: .compact) } header: { HStack(alignment: .firstTextBaseline) { Text(VectorL10n.userSessionsOverviewCurrentSessionSectionTitle) @@ -104,11 +103,11 @@ struct UserSessionsOverview: View { .font(theme.fonts.footnote) .foregroundColor(theme.colors.secondaryContent) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.bottom, 12.0) .padding(.top, 24.0) currentSessionMenu } + .padding(.bottom, 8.0) } .padding(.horizontal, 16) } @@ -170,6 +169,7 @@ struct UserSessionsOverview: View { private var otherSessionsSection: some View { SwiftUI.Section { LazyVStack(spacing: 0) { + SeparatorLine(height: 0.5) ForEach(viewModel.viewState.otherSessionsViewData.prefix(maxOtherSessionsToDisplay)) { viewData in UserSessionListItem(viewData: viewData, showsLocationInfo: viewModel.viewState.showLocationInfo, @@ -181,6 +181,7 @@ struct UserSessionsOverview: View { viewModel.send(viewAction: .viewAllOtherSessions) } } + SeparatorLine(height: 0.5) } .background(theme.colors.background) } header: { @@ -198,11 +199,11 @@ struct UserSessionsOverview: View { Text(VectorL10n.userSessionsOverviewOtherSessionsSectionInfo) .font(theme.fonts.footnote) .foregroundColor(theme.colors.secondaryContent) - .padding(.bottom, 12.0) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16.0) .padding(.top, 24.0) + .padding(.bottom, 8.0) } .accessibilityIdentifier("userSessionsOverviewOtherSection") } diff --git a/RiotSwiftUI/target.yml b/RiotSwiftUI/target.yml index be92f62ff..71c1433df 100644 --- a/RiotSwiftUI/target.yml +++ b/RiotSwiftUI/target.yml @@ -66,7 +66,7 @@ targets: - path: ../Riot/Modules/Analytics/AnalyticsScreen.swift - path: ../Riot/Modules/LocationSharing/LocationAuthorizationStatus.swift - path: ../Riot/Modules/QRCode/QRCodeGenerator.swift - - path: ../Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfoState.swift + - path: ../Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/MatrixSDK/VoiceBroadcastInfoState.swift - path: ../Riot/Assets/en.lproj/Untranslated.strings buildPhase: resources - path: ../Riot/Assets/Images.xcassets diff --git a/RiotSwiftUI/targetUITests.yml b/RiotSwiftUI/targetUITests.yml index 7b4da4574..3cd441aa1 100644 --- a/RiotSwiftUI/targetUITests.yml +++ b/RiotSwiftUI/targetUITests.yml @@ -75,7 +75,7 @@ targets: - path: ../Riot/Modules/Analytics/AnalyticsScreen.swift - path: ../Riot/Modules/LocationSharing/LocationAuthorizationStatus.swift - path: ../Riot/Modules/QRCode/QRCodeGenerator.swift - - path: ../Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastInfoState.swift + - path: ../Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/MatrixSDK/VoiceBroadcastInfoState.swift - path: ../Riot/Assets/en.lproj/Untranslated.strings buildPhase: resources - path: ../Riot/Assets/Images.xcassets diff --git a/RiotTests/UserSessionsDataProviderTests.swift b/RiotTests/UserSessionsDataProviderTests.swift index 4695d9fc3..69b1b0229 100644 --- a/RiotTests/UserSessionsDataProviderTests.swift +++ b/RiotTests/UserSessionsDataProviderTests.swift @@ -100,10 +100,69 @@ class UserSessionCardViewDataTests: XCTestCase { XCTAssertEqual(verificationState, .permanentlyUnverified) } + + func testObsoletedDeviceInformation_someMatch() { + let mxSession = MockSession(canCrossSign: true) + let dataProvider = UserSessionsDataProvider(session: mxSession) + + let accountDataEvents: [String: Any] = [ + "io.element.matrix_client_information.D": "", + "foo": "" + ] + + let expectedObsoletedEvents: Set = [ + "io.element.matrix_client_information.D" + ] + + let obsoletedEvents = dataProvider.obsoletedDeviceAccountData(deviceList: .mockDevices, accountDataEvents: accountDataEvents) + + XCTAssertEqual(obsoletedEvents, expectedObsoletedEvents) + } + + func testObsoletedDeviceInformation_noMatch() { + let mxSession = MockSession(canCrossSign: true) + let dataProvider = UserSessionsDataProvider(session: mxSession) + + let accountDataEvents: [String: Any] = [ + "io.element.matrix_client_information.C": "", + "foo": "" + ] + + let expectedObsoletedEvents: Set = [] + let obsoletedEvents = dataProvider.obsoletedDeviceAccountData(deviceList: .mockDevices, accountDataEvents: accountDataEvents) + + XCTAssertEqual(obsoletedEvents, expectedObsoletedEvents) + } + + func testObsoletedDeviceInformation_allMatch() { + let mxSession = MockSession(canCrossSign: true) + let dataProvider = UserSessionsDataProvider(session: mxSession) + + let expectedObsoletedEvents = Set(["D", "E", "F"].map { "io.element.matrix_client_information.\($0)"}) + + let accountDataEvents: [String: Any] = expectedObsoletedEvents.reduce(into: ["foo": ""]) { partialResult, value in + partialResult[value] = "" + } + + let obsoletedEvents = dataProvider.obsoletedDeviceAccountData(deviceList: .mockDevices, accountDataEvents: accountDataEvents) + + XCTAssertEqual(obsoletedEvents, expectedObsoletedEvents) + } } // MARK: Mocks +private extension Array where Element == MXDevice { + static let mockDevices: [MXDevice] = { + ["A", "B", "C"] + .map { + let device = MXDevice() + device.deviceId = $0 + return device + } + }() +} + // Device ID constants. private extension String { static var otherDeviceA: String { "abcdef" } diff --git a/RiotTests/target.yml b/RiotTests/target.yml index 611a0314b..849585240 100644 --- a/RiotTests/target.yml +++ b/RiotTests/target.yml @@ -75,4 +75,4 @@ targets: - path: ../Riot/Modules/Room/TimelineCells/Styles/RoomTimelineStyleIdentifier.swift - path: ../Riot/Modules/Room/EventMenu/EventMenuBuilder.swift - path: ../Riot/Modules/Room/EventMenu/EventMenuItemType.swift - - path: ../Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastSettings.swift + - path: ../Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/MatrixSDK/VoiceBroadcastSettings.swift diff --git a/SiriIntents/target.yml b/SiriIntents/target.yml index b1c369a9d..9b8d31482 100644 --- a/SiriIntents/target.yml +++ b/SiriIntents/target.yml @@ -42,6 +42,7 @@ targets: sources: - path: . - path: ../Riot/Categories/Bundle.swift + - path: ../Riot/Categories/MXEvent.swift - path: ../Config/CommonConfiguration.swift - path: ../Config/BuildSettings.swift - path: ../Config/BWIBuildSettings.swift @@ -66,4 +67,4 @@ targets: excludes: - "**/*.md" # excludes all files with the .md extension - path: ../Riot/Modules/Room/TimelineCells/Styles/RoomTimelineStyleIdentifier.swift - - path: ../Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastSettings.swift + - path: ../Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/MatrixSDK diff --git a/project.yml b/project.yml index 5e138d91a..eb23828f9 100644 --- a/project.yml +++ b/project.yml @@ -59,7 +59,7 @@ packages: maxVersion: 3.5.0 WysiwygComposer: url: https://github.com/matrix-org/matrix-wysiwyg-composer-swift - revision: 38ad28bedbe63b3587126158245659b6c989ec2c + revision: 534ee5bae5e8de69ed398937b5edb7b5f21551d2 DeviceKit: url: https://github.com/devicekit/DeviceKit majorVersion: 4.7.0