diff --git a/CHANGES.md b/CHANGES.md index 033842289..428bd30f7 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,30 @@ +## Changes in 1.10.11 (2023-04-18) + +🙌 Improvements + +- Upgrade MatrixSDK version ([v0.26.9](https://github.com/matrix-org/matrix-ios-sdk/releases/tag/v0.26.9)). +- Labs: Rich Text Editor: Integrate version 2.0.0 with mention Pills support. ([#7442](https://github.com/vector-im/element-ios/issues/7442)) + +🐛 Bugfixes + +- Continue to display pills for matrix.to permalinks if a custom permalinkBaseUrl is set. ([#7482](https://github.com/vector-im/element-ios/pull/7482)) +- Add a foreground color attribute for the unformattable event error message. ([#7501](https://github.com/vector-im/element-ios/pull/7501)) +- Fixed a bug that prevented audio messages that were not .mp4 to be played in the timeline ([#7451](https://github.com/vector-im/element-ios/issues/7451)) +- Fix user suggestion list item height on iOS 16+ ([#7492](https://github.com/vector-im/element-ios/issues/7492)) + +🧱 Build + +- Pinned used Xcode version to 14.2 as newer version fail ASC validation ([#7476](https://github.com/vector-im/element-ios/issues/7476)) + + +## Changes in 1.10.10 (2023-04-12) + +🙌 Improvements + +- Crypto: Enable Rust Crypto for all users ([#7485](https://github.com/vector-im/element-ios/pull/7485)) +- Upgrade MatrixSDK version ([v0.26.7](https://github.com/matrix-org/matrix-ios-sdk/releases/tag/v0.26.7)). + + ## Changes in 1.10.9 (2023-04-04) 🙌 Improvements diff --git a/Podfile b/Podfile index f3258d8a3..3da19f891 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.26.6' +$matrixSDKVersion = '= 0.26.9' # $matrixSDKVersion = :local # $matrixSDKVersion = { :branch => 'develop'} # $matrixSDKVersion = { :specHash => { git: 'https://git.io/fork123', branch: 'fix' } } diff --git a/Podfile.lock b/Podfile.lock index fcb75095b..f47ccb4f3 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -39,20 +39,20 @@ PODS: - LoggerAPI (1.9.200): - Logging (~> 1.1) - Logging (1.4.0) - - MatrixSDK (0.26.6): - - MatrixSDK/Core (= 0.26.6) - - MatrixSDK/Core (0.26.6): + - MatrixSDK (0.26.9): + - MatrixSDK/Core (= 0.26.9) + - MatrixSDK/Core (0.26.9): - AFNetworking (~> 4.0.0) - GZIP (~> 1.3.0) - libbase58 (~> 0.1.4) - - MatrixSDKCrypto (= 0.3.2) + - MatrixSDKCrypto (= 0.3.4) - OLMKit (~> 3.2.5) - Realm (= 10.27.0) - SwiftyBeaver (= 1.9.5) - - MatrixSDK/JingleCallStack (0.26.6): + - MatrixSDK/JingleCallStack (0.26.9): - JitsiMeetSDKLite (= 7.0.1-lite) - MatrixSDK/Core - - MatrixSDKCrypto (0.3.2) + - MatrixSDKCrypto (0.3.4) - OLMKit (3.2.12): - OLMKit/olmc (= 3.2.12) - OLMKit/olmcpp (= 3.2.12) @@ -102,8 +102,8 @@ DEPENDENCIES: - KeychainAccess (~> 4.2.2) - KTCenterFlowLayout (~> 1.3.1) - libPhoneNumber-iOS (~> 0.9.13) - - MatrixSDK (= 0.26.6) - - MatrixSDK/JingleCallStack (= 0.26.6) + - MatrixSDK (= 0.26.9) + - MatrixSDK/JingleCallStack (= 0.26.9) - OLMKit - PostHog (~> 2.0.0) - ReadMoreTextView (~> 3.0.1) @@ -187,8 +187,8 @@ SPEC CHECKSUMS: libPhoneNumber-iOS: 0a32a9525cf8744fe02c5206eb30d571e38f7d75 LoggerAPI: ad9c4a6f1e32f518fdb43a1347ac14d765ab5e3d Logging: beeb016c9c80cf77042d62e83495816847ef108b - MatrixSDK: 8179c184d819782282f47dab16ce6c2b68ef8a74 - MatrixSDKCrypto: 7073c382c484cb8ba7dba0a83e112ead96d3bbfd + MatrixSDK: 2f6222978156818cf4c6ba590762ade601ba72f9 + MatrixSDKCrypto: ac805c22c24f79f349cdbfa065855c73a4c81b51 OLMKit: da115f16582e47626616874e20f7bb92222c7a51 PostHog: 660ec6c9d80cec17b685e148f17f6785a88b597d ReadMoreTextView: 19147adf93abce6d7271e14031a00303fe28720d @@ -208,6 +208,6 @@ SPEC CHECKSUMS: zxcvbn-ios: fef98b7c80f1512ff0eec47ac1fa399fc00f7e3c ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: 54848168ab5303c9126626395886cd85f27a44b3 +PODFILE CHECKSUM: a55fb48d3bef5f5e24fcaf8c39d1eae1ed8c1603 COCOAPODS: 1.11.3 diff --git a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0968cfb53..489a44a34 100644 --- a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/matrix-org/matrix-wysiwyg-composer-swift", "state" : { - "revision" : "addf90f3e2a6ab46bd2b2febe117d9cddb646e7d", - "version" : "1.1.1" + "revision" : "758f226a92d6726ab626c1e78ecd183bdba77016", + "version" : "2.0.0" } }, { diff --git a/Riot/Assets/de.lproj/Vector.strings b/Riot/Assets/de.lproj/Vector.strings index 3afd654fc..d00ab8bbb 100644 --- a/Riot/Assets/de.lproj/Vector.strings +++ b/Riot/Assets/de.lproj/Vector.strings @@ -2742,3 +2742,15 @@ // MARK: - Launch loading "launch_loading_generic" = "Synchronisiere deine Unterhaltungen"; +"pill_message_in" = "Nachricht in %@"; +"pill_message_from" = "Nachricht von %@"; +"pill_message" = "Nachricht"; + +// Pills +"pill_room_fallback_display_name" = "Space/Raum"; +"key_verification_self_verify_security_upgrade_alert_message" = "Verschlüsselte Kommunikation wurde mit der neuesten Aktualisierung verbessert. Bitte verifiziere deine Geräte erneut."; + +// Legacy to Rust security upgrade + +"key_verification_self_verify_security_upgrade_alert_title" = "App aktualisiert"; +"settings_acceptable_use" = "Nutzungsbedingungen"; diff --git a/Riot/Assets/et.lproj/Vector.strings b/Riot/Assets/et.lproj/Vector.strings index a3750917e..12a8f9557 100644 --- a/Riot/Assets/et.lproj/Vector.strings +++ b/Riot/Assets/et.lproj/Vector.strings @@ -2680,3 +2680,15 @@ // MARK: - Launch loading "launch_loading_generic" = "Sinu vestlused on sünkroniseerimisel"; +"pill_message_in" = "Sõnum jututoas %@"; +"pill_message_from" = "Sõnum kasutajalt %@"; +"pill_message" = "Sõnum"; + +// Pills +"pill_room_fallback_display_name" = "Kogukond/jututuba"; +"settings_acceptable_use" = "Vastuvõetava kasutamise põhimõtted"; +"key_verification_self_verify_security_upgrade_alert_message" = "Turvalisele sõnumivahetusele on lisandunud palju täiendusi. Palun verifitseeri oma seade uuesti."; + +// Legacy to Rust security upgrade + +"key_verification_self_verify_security_upgrade_alert_title" = "Rakendus on uuendatud"; diff --git a/Riot/Assets/hu.lproj/Vector.strings b/Riot/Assets/hu.lproj/Vector.strings index c3f99464a..a77322f08 100644 --- a/Riot/Assets/hu.lproj/Vector.strings +++ b/Riot/Assets/hu.lproj/Vector.strings @@ -2654,7 +2654,6 @@ "voice_broadcast_stop_alert_title" = "Megszakítod az élő közvetítést?"; "voice_broadcast_buffering" = "Pufferelés…"; "voice_broadcast_time_left" = "%@ van vissza"; - "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."; @@ -2701,7 +2700,6 @@ "poll_history_no_past_poll_period_text" = "%@ napja nincs aktív szavazás. További szavazások betöltése az előző havi szavazások megjelenítéséhez"; "poll_history_no_active_poll_period_text" = "%@ napja nincs aktív szavazás. További szavazások betöltése az előző havi szavazások megjelenítéséhez"; "poll_history_loading_text" = "Szavazások megjelenítése"; - "settings_labs_disable_crypto_sdk" = "Rust végpontok közötti titkosítás (kikapcsoláshoz kijelentkezés szükséges)"; "settings_labs_confirm_crypto_sdk" = "Ez a funkció még kísérleti fázisban van. Lehet, hogy nem az elvártnak megfelelően fog működni és előre nem látható következménye lehet. A funkció kikapcsolásához egyszerű ki-, és bejelentkezés szükséges. Használata csak saját felelősségre."; "settings_labs_enable_crypto_sdk" = "Rust végpontok közötti titkosítás"; @@ -2720,3 +2718,25 @@ "device_verification_self_verify_wait_recover_secrets_additional_help" = "Nem férsz hozzá létező munkamenethez, %@?"; "device_verification_self_verify_open_on_other_device_title" = "Nyisd meg ezt: %@ a másik eszközön"; "room_creation_only_one_email_invite" = "E-mail meghívóból egyszerre csak egy küldhető"; +"pill_message_in" = "Üzenet itt: %@"; +"pill_message_from" = "Üzenet tőle: %@"; +"pill_message" = "Üzenet"; + +// Pills +"pill_room_fallback_display_name" = "Tér/Szoba"; +"launch_loading_delay_warning" = "Ez egy kicsit tovább tarthat.\nKöszönjük a türelmet."; + +// MARK: - Launch loading + +"launch_loading_generic" = "Beszélgetések szinkronizálása"; +"key_verification_scan_qr_code_information_new_session" = "Az új munkameneted ellenőrzéséhez irányítsd a kamerádat a másik eszközödön megjelenő QR kódra"; +"key_verification_scan_qr_code_information_other_session" = "A munkameneted ellenőrzéséhez irányítsd a kamerádat a másik eszközödön megjelenő QR kódra"; +"key_verification_scan_qr_code_information_other_device" = "A munkamenet ellenőrzéséhez irányítsd a kamerádat a másik eszközödön megjelenő QR kódra"; +"key_verification_scan_qr_code_information_other_user" = "A munkamenetük ellenőrzéséhez irányítsd a kamerádat az eszközükön megjelenő QR kódra"; +"device_verification_self_verify_open_on_other_device_information" = "Ennek a munkamenetnek az ellenőrzésére szükséged van a régi titkosított üzenetek olvasásához.\n\nNyisd meg az Elementet egy másik eszközödön és kövesd az utasításokat."; +"key_verification_self_verify_security_upgrade_alert_message" = "A biztonságos üzenetküldés a legutolsó fejlesztésekkel frissült. Kérjük ellenőrizzed újra az eszközt."; + +// Legacy to Rust security upgrade + +"key_verification_self_verify_security_upgrade_alert_title" = "Alkalmazás frissítve"; +"settings_acceptable_use" = "Elfogadható felhasználói feltételek"; diff --git a/Riot/Assets/id.lproj/Vector.strings b/Riot/Assets/id.lproj/Vector.strings index 5990734f3..1aea826f3 100644 --- a/Riot/Assets/id.lproj/Vector.strings +++ b/Riot/Assets/id.lproj/Vector.strings @@ -2935,3 +2935,15 @@ "device_verification_self_verify_open_on_other_device_information" = "Anda harus memverifikasi sesi ini supaya dapat membaca riwayat pesan aman Anda.\n\nBuka Element di salah satu perangkat Anda yang lain dan ikuti petunjuknya."; "device_verification_self_verify_open_on_other_device_title" = "Buka %@ di perangkat Anda yang lain"; "room_creation_only_one_email_invite" = "Amda hanya dapat mengundang satu surel satu-satu"; +"pill_message_in" = "Pesan di %@"; +"pill_message_from" = "Pesan dari %@"; +"pill_message" = "Pesan"; + +// Pills +"pill_room_fallback_display_name" = "Space/Ruangan"; +"key_verification_self_verify_security_upgrade_alert_message" = "Perpesanan aman telah ditingkatkan dengan pembaruan terkini. Silakan verifikasi ulang perangkat Anda."; + +// Legacy to Rust security upgrade + +"key_verification_self_verify_security_upgrade_alert_title" = "Aplikasi diperbarui"; +"settings_acceptable_use" = "Kebijakan Penggunaan yang Dapat Diterima"; diff --git a/Riot/Assets/it.lproj/Vector.strings b/Riot/Assets/it.lproj/Vector.strings index 930ace277..d07f51b64 100644 --- a/Riot/Assets/it.lproj/Vector.strings +++ b/Riot/Assets/it.lproj/Vector.strings @@ -2708,3 +2708,15 @@ // MARK: - Launch loading "launch_loading_generic" = "Sincronizzazione delle tue conversazioni"; +"pill_message_in" = "Messaggio in %@"; +"pill_message_from" = "Messaggio da %@"; +"pill_message" = "Messaggio"; + +// Pills +"pill_room_fallback_display_name" = "Spazio/Stanza"; +"key_verification_self_verify_security_upgrade_alert_message" = "La messaggistica sicura è stata migliorata con l'aggiornamento più recente. Ri-verifica il tuo dispositivo."; + +// Legacy to Rust security upgrade + +"key_verification_self_verify_security_upgrade_alert_title" = "App aggiornata"; +"settings_acceptable_use" = "Politica di utilizzo accettabile"; diff --git a/Riot/Assets/ru.lproj/Vector.strings b/Riot/Assets/ru.lproj/Vector.strings index 857845707..0f2f21fd4 100644 --- a/Riot/Assets/ru.lproj/Vector.strings +++ b/Riot/Assets/ru.lproj/Vector.strings @@ -239,7 +239,7 @@ "settings_ignored_users" = "ИГНОРИРУЕМЫЕ ПОЛЬЗОВАТЕЛИ"; "settings_contacts" = "КОНТАКТЫ УСТРОЙСТВА"; "settings_advanced" = "ДОПОЛНИТЕЛЬНО"; -"settings_other" = "ДРУГИЕ"; +"settings_other" = "Другие"; "settings_labs" = "ЛАБОРАТОРИЯ"; "settings_devices" = "СЕАНСЫ"; "settings_cryptography" = "КРИПТОГРАФИЯ"; @@ -272,11 +272,11 @@ "settings_send_crash_report" = "Отправка данных о сбоях и использовании"; "settings_clear_cache" = "Очистить кэш"; "settings_change_password" = "Изменить пароль"; -"settings_old_password" = "старый пароль"; -"settings_new_password" = "новый пароль"; -"settings_confirm_password" = "подтвердить пароль"; -"settings_fail_to_update_password" = "Не удалось обновить пароль"; -"settings_password_updated" = "Ваш пароль был обновлен"; +"settings_old_password" = "Старый пароль"; +"settings_new_password" = "Новый пароль"; +"settings_confirm_password" = "Подтвердить пароль"; +"settings_fail_to_update_password" = "Не удалось обновить пароль аккаунта Matrix"; +"settings_password_updated" = "Ваш пароль аккаунта Matrix был обновлен"; "settings_crypto_device_name" = "Имя сеанса: "; "settings_crypto_device_id" = "\nID сеанса: "; "settings_crypto_device_key" = "\nКлюч сеанса:\n"; @@ -512,7 +512,7 @@ "room_action_send_photo_or_video" = "Отправить фото или видео"; "room_action_send_sticker" = "Отправить стикер"; "settings_deactivate_account" = "ДЕАКТИВАЦИЯ АККАУНТА"; -"settings_deactivate_my_account" = "Деактивировать мой аккаунт"; +"settings_deactivate_my_account" = "Деактивировать аккаунт навсегда"; "widget_sticker_picker_no_stickerpacks_alert_add_now" = "Добавить сейчас?"; "deactivate_account_title" = "Деактивировать аккаунт"; "deactivate_account_informations_part1" = "Это действие сделает вашу учетную запись непригодной для дальнейшего использования. Вы не сможете войти в систему и никто другой не сможет заново зарегистрировать учетную запись с вашим идентификатором. Также, это приведет к тому, что вы покинете все комнаты, в которых участвовали и данные о вашей учетной записи будут удалены с сервера идентификации. "; @@ -525,7 +525,7 @@ "deactivate_account_forget_messages_information_part3" = ": будущие участники увидят неполное представление разговоров)"; "deactivate_account_validate_action" = "Деактивировать аккаунт"; "deactivate_account_password_alert_title" = "Деактивировать аккаунт"; -"deactivate_account_password_alert_message" = "Чтобы продолжить, введите пароль"; +"deactivate_account_password_alert_message" = "Чтобы продолжить, введите пароль аккаунта Matrix"; "widget_sticker_picker_no_stickerpacks_alert" = "У вас пока нет включенных пакетов стикеров."; "event_formatter_rerequest_keys_part1_link" = "Повторно запросить ключи шифрования"; "event_formatter_rerequest_keys_part2" = " из других ваших сеансов."; @@ -583,7 +583,7 @@ "key_backup_setup_intro_title" = "Никогда не теряйте зашифрованных сообщений"; "key_backup_setup_intro_info" = "Сообщения в зашифрованных комнатах защищены сквозным шифрованием. Только вы и получатель(и) имеют ключи для чтения этих сообщений.\n\nСохраните ключи надежно, чтобы не потерять их."; "key_backup_setup_intro_setup_action" = "Настроить"; -"key_backup_setup_passphrase_info" = "Мы будем хранить зашифрованную копию ваших ключей на нашем сервере. Для безопасности, защитите резервную копию секретной фразой.\n\nДля обеспечения максимальной безопасности она должна отличаться от пароля учетной записи."; +"key_backup_setup_passphrase_info" = "Мы будем хранить зашифрованную копию ваших ключей на нашем сервере. Для безопасности, защитите резервную копию секретной фразой.\n\nДля обеспечения максимальной безопасности она должна отличаться от пароля учётной записи Matrix."; "key_backup_setup_passphrase_passphrase_title" = "Ввод"; "key_backup_setup_passphrase_passphrase_placeholder" = "Введите секретную фразу"; "key_backup_setup_passphrase_passphrase_valid" = "Отлично!"; @@ -864,7 +864,7 @@ "identity_server_settings_alert_error_invalid_identity_server" = "%@ не является действительным сервером идентификации."; "settings_add_3pid_password_title_email" = "Добавить адрес электронной почты"; "settings_add_3pid_password_title_msidsn" = "Добавить номер телефона"; -"settings_add_3pid_password_message" = "Для продолжения, задайте пароль"; +"settings_add_3pid_password_message" = "Для продолжения, введите пароль аккаунта Matrix"; "settings_add_3pid_invalid_password_message" = "Недействительные данные"; "settings_discovery_three_pid_details_title_phone_number" = "Управление номера телефона"; "settings_identity_server_no_is" = "Сервер идентификации не настроен"; @@ -915,7 +915,7 @@ "security_settings_title" = "Безопасность"; "security_settings_crypto_sessions" = "МОИ СЕАНСЫ"; "security_settings_crypto_sessions_loading" = "Загрузка сеансов…"; -"security_settings_crypto_sessions_description_2" = "Если вы не узнали логин, измените пароль и сбросьте Безопасное резервное копирование."; +"security_settings_crypto_sessions_description_2" = "Если вы не узнали логин, измените пароль аккаунта Matrix и сбросьте Безопасное резервное копирование."; "security_settings_secure_backup" = "БЕЗОПАСНОЕ РЕЗЕРВНОЕ КОПИРОВАНИЕ"; "security_settings_secure_backup_description" = "Сделайте резервную копию ключей шифрования с данными вашей учетной записи на случай, если вы потеряете доступ к своим сеансам. Ваши ключи будут защищены уникальным электронным ключом."; "security_settings_secure_backup_setup" = "Настроить"; @@ -938,7 +938,7 @@ "security_settings_complete_security_alert_title" = "Завершите настройку безопасности"; "security_settings_complete_security_alert_message" = "Сначала вы должны завершить настройку безопасности текущего сеанса."; "security_settings_coming_soon" = "Извините. Это действие пока недоступно в %@ iOS. Пожалуйста, используйте другой клиент Matrix для его настройки. %@ iOS будет его использовать."; -"security_settings_user_password_description" = "Подтвердите свою личность, введя пароль учетной записи"; +"security_settings_user_password_description" = "Подтвердите свою личность, введя пароль учётной записи Matrix"; // Manage session "manage_session_title" = "Управление сеансами"; "manage_session_info" = "ИНФОРМАЦИЯ О СЕАНСЕ"; @@ -1130,7 +1130,7 @@ "secrets_setup_recovery_key_storage_alert_message" = "✓ Распечатайте и храните в безопасном месте\n✓ Сохраните его на USB-носителе или резервном носителе\n✓ Скопируйте его в свое личное облачное хранилище"; "secrets_setup_recovery_passphrase_title" = "Задайте мнемоническую фразу"; "secrets_setup_recovery_passphrase_information" = "Введите секретную фразу, известную только вам, для защиты данных на вашем сервере."; -"secrets_setup_recovery_passphrase_additional_information" = "Не используйте пароль своей учетной записи."; +"secrets_setup_recovery_passphrase_additional_information" = "Не используйте пароль своей учётной записи Matrix."; "secrets_setup_recovery_passphrase_validate_action" = "Готово"; "secrets_setup_recovery_passphrase_confirm_information" = "Введите мнемоническую фразу ещё раз, чтобы подтвердить её."; "secrets_setup_recovery_passphrase_confirm_passphrase_title" = "Подтвердить"; @@ -1183,7 +1183,7 @@ "searchable_directory_x_network" = "%@ Сеть"; "searchable_directory_search_placeholder" = "Имя или ID"; "create_room_title" = "Новая комната"; -"create_room_section_header_name" = "Имя комнаты"; +"create_room_section_header_name" = "НАЗВАНИЕ"; "create_room_placeholder_name" = "Имя"; "create_room_section_header_topic" = "Тема комнаты (опционально)"; "create_room_placeholder_topic" = "Тема"; @@ -1218,7 +1218,7 @@ "room_details_advanced_e2e_encryption_enabled_for_dm" = "Шифрование включено"; "room_details_advanced_e2e_encryption_disabled_for_dm" = "Шифрование не включено."; "pin_protection_kick_user_alert_message" = "Слишком много ошибок, вы вышли из системы"; -"secrets_reset_authentication_message" = "Введите пароль своей учётной записи для подтверждения"; +"secrets_reset_authentication_message" = "Введите пароль своей учётной записи Matrix для подтверждения"; "secrets_reset_reset_action" = "Сброс"; "secrets_reset_warning_message" = "Вы перезапустите приложение без истории, сообщений, доверенных устройств или доверенных пользователей."; "secrets_reset_warning_title" = "Если сбросить все"; @@ -2224,3 +2224,40 @@ "threads_notice_information" = "Все потоки созданные во время экспериментального периода теперь отображаются как обычные ответы.

Это разовый переход, так как потоки теперь часть спецификации Matrix."; "authentication_qr_login_failure_device_not_supported" = "Связь с этим устройством не поддерживается."; "accessibility_selected" = "выбранный"; +"room_access_settings_screen_message" = "Решите, кто может найти и присоединиться к %@."; +"room_access_settings_screen_title" = "Кто может получить доступ к этой комнате?"; +"room_details_promote_room_suggest_title" = "Предложить участникам пространства"; +/* The placeholder will be replaces with manage_session_name_info_link */ +"manage_session_name_info" = "Учитывайте, что имена сессий также видны людям, с которыми вы общаетесь. %@"; +"settings_labs_enable_new_client_info_feature" = "Запишите имя клиента, версию и URL-адрес, чтобы упростить распознавание сеансов в диспетчере сеансов"; +"sign_out_confirmation_message" = "Вы уверены, что хотите выйти?"; +"share_extension_send_now" = "Отправить сейчас"; +"share_extension_low_quality_video_title" = "Видео будет отправлено в низком качестве"; +"analytics_prompt_stop" = "Прекратить делиться"; +"analytics_prompt_not_now" = "Не сейчас"; +"analytics_prompt_point_3" = "Вы можете отключить это в любое время в настройках"; +/* Note: The word "don't" is formatted in bold */ +"analytics_prompt_point_2" = "Мы не передаем информацию третьим лицам"; +/* Note: The word "don't" is formatted in bold */ +"analytics_prompt_point_1" = "Мы не записываем и не профилируем никакие данные учётной записи"; +"analytics_prompt_message_upgrade" = "Ранее вы дали согласие на передачу нам анонимных данных об использовании. Теперь, чтобы помочь понять, как люди используют несколько устройств, мы сгенерируем случайный идентификатор, общий для всех ваших устройств."; +"analytics_prompt_message_new_user" = "Помогите нам выявить проблемы и улучшить %@, поделившись анонимными данными об использовании. Чтобы понять, как люди используют несколько устройств, мы сгенерируем случайный идентификатор, общий для всех ваших устройств."; + +// Analytics +"analytics_prompt_title" = "Помогите улучшить %@"; +"call_jitsi_unable_to_start" = "Невозможно начать конференц-звонок"; +"network_offline_message" = "Вы не в сети, проверьте ваше соединение."; +"network_offline_title" = "Вы не в сети"; +"event_formatter_message_deleted" = "Сообщение удалено"; +"room_access_space_chooser_other_spaces_section" = "Другие пространства или комнаты"; +"room_access_settings_screen_setting_room_access" = "Настройка доступа к комнате"; +"room_access_settings_screen_upgrade_alert_auto_invite_switch" = "Автоматически приглашать участников в новую комнату"; +"room_access_settings_screen_upgrade_alert_note" = "Обратите внимание, что при обновлении будет создана новая версия комнаты. Все текущие сообщения останутся в этой архивной комнате."; +"room_access_settings_screen_upgrade_alert_message_no_param" = "Любой в родительском пространстве сможет найти и присоединиться к этой комнате - нет необходимости вручную приглашать всех вручную. Вы сможете изменить это в настройках комнаты в любое время."; +"room_access_settings_screen_upgrade_alert_message" = "Любой человек в %@ сможет найти и присоединиться к этой комнате - нет необходимости вручную приглашать всех. Вы сможете изменить это в настройках комнаты в любое время."; +"room_access_settings_screen_public_message" = "Любой желающий может найти и присоединиться."; +"room_access_settings_screen_edit_spaces" = "Редактировать пространства"; +"room_access_settings_screen_restricted_message" = "Позволяет всем, кто находится в пространстве, найти его и присоединиться.\nВам будет предложено подтвердить к каким пространствам."; +"room_access_settings_screen_private_message" = "Только приглашенные люди могут найти и присоединиться."; +"manage_session_name_hint" = "Индивидуальные имена сеансов помогут Вам легче распознавать свои устройства."; +"settings_labs_confirm_crypto_sdk" = "Имейте ввиду, что эта функция все ещё на экспериментальной стадии, поэтому она может работать не так, как ожидается, и потенциально может иметь непредвиденные последствия. Для отмены функции выйдите из системы и войдите снова. Используйте её по своему усмотрению и с осторожностью."; diff --git a/Riot/Assets/sk.lproj/Vector.strings b/Riot/Assets/sk.lproj/Vector.strings index 7f2255f4b..8399cd6ff 100644 --- a/Riot/Assets/sk.lproj/Vector.strings +++ b/Riot/Assets/sk.lproj/Vector.strings @@ -2931,3 +2931,15 @@ // MARK: - Launch loading "launch_loading_generic" = "Synchronizácia vašich konverzácií"; +"pill_message_in" = "Správa v %@"; +"pill_message_from" = "Správa od %@"; +"pill_message" = "Správa"; + +// Pills +"pill_room_fallback_display_name" = "Priestor/miestnosť"; +"key_verification_self_verify_security_upgrade_alert_message" = "Najnovšou aktualizáciou sa zlepšilo bezpečné zasielanie správ. Overte prosím znova svoje zariadenie."; + +// Legacy to Rust security upgrade + +"key_verification_self_verify_security_upgrade_alert_title" = "Aplikácia bola aktualizovaná"; +"settings_acceptable_use" = "Zásady prijateľného používania"; diff --git a/Riot/Assets/sq.lproj/Vector.strings b/Riot/Assets/sq.lproj/Vector.strings index 4274f5dc5..fa2860895 100644 --- a/Riot/Assets/sq.lproj/Vector.strings +++ b/Riot/Assets/sq.lproj/Vector.strings @@ -2718,3 +2718,16 @@ "device_verification_self_verify_open_on_other_device_information" = "Lypset të verifikoni këtë sesion, që të mund të lexoni historikun e mesazheve tuaj të siguruar.\n\nHapeni Element-in në një nga pajisjet tuaja të tjera dhe ndiqni udhëzimet."; "device_verification_self_verify_open_on_other_device_title" = "Hapeni %@ në pajisjen tuaj tjetër"; "room_creation_only_one_email_invite" = "Mund të ftoni vetëm një email në herë"; +"pill_message_in" = "Mesazh te %@"; +"pill_message_from" = "Mesazh nga %@"; +"pill_message" = "Mesazh"; + +// Pills +"pill_room_fallback_display_name" = "Hapësirë/Dhomë"; +"key_verification_self_verify_security_upgrade_alert_message" = "Me përditësimin e fundit shkëmbimi i siguruar i mesazheve është përmirësuar. Ju lutemi, riverifikoni pajisjen tuaj."; + +// Legacy to Rust security upgrade + +"key_verification_self_verify_security_upgrade_alert_title" = "Aplikacioni u përditësua"; +"settings_acceptable_use" = "Rregull Përdorimi të Pranueshëm"; +"accessibility_selected" = "përzgjedhur"; diff --git a/Riot/Assets/uk.lproj/Vector.strings b/Riot/Assets/uk.lproj/Vector.strings index 7b321d90b..65ff758cb 100644 --- a/Riot/Assets/uk.lproj/Vector.strings +++ b/Riot/Assets/uk.lproj/Vector.strings @@ -2933,3 +2933,15 @@ // MARK: - Launch loading "launch_loading_generic" = "Синхронізація ваших розмов"; +"pill_message_in" = "Повідомлення у %@"; +"pill_message_from" = "Повідомлення від %@"; +"pill_message" = "Повідомлення"; + +// Pills +"pill_room_fallback_display_name" = "Простір/кімната"; +"key_verification_self_verify_security_upgrade_alert_message" = "В останньому оновленні було вдосконалено захищений обмін повідомленнями. Перевірте свій пристрій ще раз."; + +// Legacy to Rust security upgrade + +"key_verification_self_verify_security_upgrade_alert_title" = "Застосунок оновлено"; +"settings_acceptable_use" = "Політика прийнятного користування"; diff --git a/Riot/Assets/zh_Hant.lproj/InfoPlist.strings b/Riot/Assets/zh_Hant.lproj/InfoPlist.strings index e236e4a3d..30c871e37 100644 --- a/Riot/Assets/zh_Hant.lproj/InfoPlist.strings +++ b/Riot/Assets/zh_Hant.lproj/InfoPlist.strings @@ -1,8 +1,8 @@ // Permissions usage explanations "NSCameraUsageDescription" = "給予相機權限會用來進行視訊通話或是拍攝並上傳照片與影片。"; -"NSPhotoLibraryUsageDescription" = "同意使用圖片的權限會用來上傳您圖庫的照片與影片。"; -"NSMicrophoneUsageDescription" = "Element 需要麥克風的權限來接受通話、拍攝影片以及錄製語音訊息。"; -"NSContactsUsageDescription" = "他們會與您的身分伺服器共享以找到您在Matrix上的聯絡人。"; +"NSPhotoLibraryUsageDescription" = "請允許存取「照片」,來上傳圖庫當中的照片或影片。"; +"NSMicrophoneUsageDescription" = "Element 需要麥克風的權限來通話、拍攝影片以及錄製語音訊息。"; +"NSContactsUsageDescription" = "會將此資訊分享給您的身分伺服器,以幫助您尋找 Matrix 聯絡人。"; "NSLocationAlwaysAndWhenInUseUsageDescription" = "當您與其他人分享您的位置,Element 需要權限將位置顯示在地圖上。"; "NSLocationWhenInUseUsageDescription" = "當您與其他人分享您的位置,Element 需要權限將位置顯示在地圖上。"; "NSFaceIDUsageDescription" = "您可以使用 Face ID 來登入您的應用程式。"; diff --git a/Riot/Assets/zh_Hant.lproj/Localizable.strings b/Riot/Assets/zh_Hant.lproj/Localizable.strings index 07a5f408b..9bc6bfb82 100644 --- a/Riot/Assets/zh_Hant.lproj/Localizable.strings +++ b/Riot/Assets/zh_Hant.lproj/Localizable.strings @@ -75,15 +75,15 @@ "USER_MEMBERSHIP_UPDATED" = "%@ 更新了個人資料"; /* A user has change their name to a new name which we don't know */ -"GENERIC_USER_UPDATED_DISPLAYNAME" = "%@ 變更了名字"; +"GENERIC_USER_UPDATED_DISPLAYNAME" = "%@ 變更了名稱"; /** Membership Updates **/ /* A user has change their name to a new name */ -"USER_UPDATED_DISPLAYNAME" = "%@ 把名稱變更為 %@"; +"USER_UPDATED_DISPLAYNAME" = "%@ 將名稱變更為 %@"; /* A user has change their avatar */ -"USER_UPDATED_AVATAR" = "%@ 變更了他們的頭像"; +"USER_UPDATED_AVATAR" = "%@ 變更了大頭照"; /* A user has reacted to a message, but the reaction content is unknown */ "GENERIC_REACTION_FROM_USER" = "%@ 送出了一個反應"; diff --git a/Riot/Assets/zh_Hant.lproj/Vector.strings b/Riot/Assets/zh_Hant.lproj/Vector.strings index 91fc0beee..167652a1d 100644 --- a/Riot/Assets/zh_Hant.lproj/Vector.strings +++ b/Riot/Assets/zh_Hant.lproj/Vector.strings @@ -98,7 +98,7 @@ "directory_title" = "目錄"; "auth_recaptcha_message" = "這個家伺服器想要確認您不是機器人"; "auth_reset_password_missing_email" = "必須輸入和您帳號綁定的電子郵件地址。"; -"auth_reset_password_missing_password" = "必須輸入一個新密碼。"; +"auth_reset_password_missing_password" = "必須輸入新密碼。"; "auth_reset_password_next_step_button" = "我已經驗證了電子郵件地址"; "auth_reset_password_error_unauthorized" = "電子郵件地址驗證失敗:請確認您已點擊郵件中的連結"; "auth_reset_password_error_not_found" = "您的電子郵件地址似乎並未與這台家伺服器上的任何 Matrix ID 相關聯。"; @@ -384,9 +384,9 @@ "room_details_photo" = "聊天室圖片"; "room_details_room_name" = "聊天室名稱"; "room_details_mute_notifs" = "將通知靜音"; -"room_details_access_section_no_address_warning" = "要連結一個聊天室,該聊天室必須要有位址"; +"room_details_access_section_no_address_warning" = "要連結聊天室,該聊天室必須要有位址"; "room_details_access_section_directory_toggle" = "將此聊天室列入聊天室目錄"; -"room_details_history_section_prompt_msg" = "對可閱讀歷史訊息的使用者的變更,將僅適用於此聊天室的新訊息。現有訊息的顯示狀態將保持不變。"; +"room_details_history_section_prompt_msg" = "對可閱讀訊息紀錄的使用者的變更,僅適用於此聊天室的新訊息。現有訊息的顯示狀態將保持不變。"; "room_details_new_address" = "新增位址"; "room_details_new_address_placeholder" = "新增位址(例如 #foo%@)"; "room_details_addresses_invalid_address_prompt_msg" = "%@ 不是有效的別名格式"; @@ -454,7 +454,7 @@ "directory_server_type_homeserver" = "輸入一個家伺服器來列出所有公開聊天室"; "directory_server_placeholder" = "matrix.org"; // Events formatter -"event_formatter_member_updates" = "變更 %tu 成員身分"; +"event_formatter_member_updates" = "%tu 筆成員狀態變更"; "event_formatter_widget_added" = "%@ 小工具已由 %@ 新增"; "event_formatter_widget_removed" = "%@ 小工具已由 %@ 移除"; "event_formatter_jitsi_widget_added" = "VoIP 群組通話已由 %@ 新增"; @@ -526,7 +526,7 @@ "room_message_reply_to_placeholder" = "傳送回覆(未加密)…"; "encrypted_room_message_reply_to_placeholder" = "傳送加密的回覆…"; "room_message_reply_to_short_placeholder" = "傳送回覆…"; -"room_event_action_view_decrypted_source" = "檢視已解密的來源"; +"room_event_action_view_decrypted_source" = "檢視解密的原始碼"; "room_predecessor_link" = "點擊此處以檢視更早以前的訊息。"; "room_replacement_information" = "這個聊天室已被取代,且不再使用。"; "room_replacement_link" = "對話在此繼續。"; @@ -622,7 +622,7 @@ "store_full_description" = "Element 是一套新型的通訊和協作應用程式,它提供下列功能:\n\n1. 您可以自行掌控隱私\n2. 可以與 Matrix 網路中的任何人進行通訊,甚至可以與 Slack 等應用程式整合\n3. 保護您免受廣告、資料探勘、後門和封閉平台的侵害\n4. 透過端到端加密和交叉簽署來驗證彼此,互相確保安全\n\nElement 是去中心化的開源軟體,因此與其他通訊和協作應用程式完全不同。\n\nElement 允許您自行架設(或選擇託管)伺服器,使您可針對隱私權,所有權以及對資料和對話內容的完整控制權。您可以連線到所有開放的網路,所以您不是只能與其他 Element 使用者聊天。而且還非常安全。\n\nElement 之所以能夠做到所有這些目標,是因為它使用 Matrix(一套開放、去中心化的通訊標準)運作。\n\nElement 讓您可以自行選擇要將對話放在哪一台伺服器來讓您可自行控制自己的訊息和資料。在 Element 應用程式中,您可以選擇以不同方式託管您的訊息:\n\n1. 在 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://element.io/app 在所有裝置與網路取得完全同步的訊息記錄來保持聯繫。"; // String for App Store "store_short_description" = "去中心化的安全通訊/VoIP 軟體"; -"settings_three_pids_management_information_part1" = "在此管理您用作登入或恢復帳號的電子郵件地址或電話號碼。您也可控制誰可以用這些資料找到您 "; +"settings_three_pids_management_information_part1" = "您可以在 "; "external_link_confirmation_message" = "此連結 %@ 將帶您到另一網頁:%@\n\n確定要前往嗎?"; "external_link_confirmation_title" = "請確認此連結"; "media_type_accessibility_sticker" = "貼圖"; @@ -826,7 +826,7 @@ "event_formatter_call_answer" = "接聽"; "event_formatter_call_back" = "回撥"; "event_formatter_call_has_ended" = "通話結束"; -"event_formatter_call_connecting" = "正在連接…"; +"event_formatter_call_connecting" = "連線中…"; "event_formatter_message_edited_mention" = "(已編輯)"; "image_picker_action_library" = "從媒體庫挑選"; @@ -881,14 +881,14 @@ "settings_messages_containing_keywords" = "關鍵字"; "settings_messages_containing_user_name" = "我的使用者名稱"; "settings_messages_containing_display_name" = "我的顯示名稱"; -"settings_encrypted_group_messages" = "已加密的群組訊息"; +"settings_encrypted_group_messages" = "加密的群組訊息"; "settings_group_messages" = "群組訊息"; -"settings_encrypted_direct_messages" = "已加密的私人訊息"; +"settings_encrypted_direct_messages" = "加密的私人訊息"; "settings_direct_messages" = "私人訊息"; "settings_default" = "預設通知"; "settings_notifications_disabled_alert_title" = "已停用通知"; "settings_device_notifications" = "裝置通知"; -"settings_three_pids_management_information_part3" = "。"; +"settings_three_pids_management_information_part3" = "管理您用作登入或恢復帳號的電子郵件地址或電話號碼。您也可控制誰可以用這些資料找到您。"; "room_join_group_call" = "加入"; "room_place_voice_call" = "語音通話"; "room_accessibility_video_call" = "視訊通話"; @@ -924,7 +924,7 @@ "room_event_encryption_info_event" = "事件資訊\n"; "room_event_encryption_info_event_user_id" = "使用者 ID\n"; "room_event_encryption_info_event_identity_key" = "Curve25519 身分認證金鑰\n"; -"room_event_encryption_info_event_fingerprint_key" = "已聲請之 Ed25519 指紋金鑰\n"; +"room_event_encryption_info_event_fingerprint_key" = "聲稱的 Ed25519 指紋金鑰\n"; "room_event_encryption_info_event_algorithm" = "演算法\n"; "room_event_encryption_info_event_session_id" = "工作階段 ID\n"; "room_event_encryption_info_event_decryption_error" = "解密錯誤\n"; @@ -993,14 +993,14 @@ "notice_room_ban" = "%@ 已封鎖 %@"; "notice_room_withdraw" = "%@ 已撤回 %@ 的邀請"; "notice_room_reason" = ",原因:%@"; -"notice_avatar_url_changed" = "%@ 已變更大頭照"; -"notice_display_name_set" = "%@ 已將他們的顯示名稱設定為 %@"; -"notice_display_name_changed_from" = "%@ 將自己的顯示名稱從 %@ 改為 %@"; -"notice_display_name_removed" = "%@ 已移除他們的顯示名稱"; -"notice_topic_changed" = "%@ 已經將主題變更為:%@。"; +"notice_avatar_url_changed" = "%@ 變更了大頭照"; +"notice_display_name_set" = "%@ 將顯示名稱設定為 %@"; +"notice_display_name_changed_from" = "%@ 將顯示名稱從 %@ 改為 %@"; +"notice_display_name_removed" = "%@ 移除了顯示名稱"; +"notice_topic_changed" = "%@ 將主題變更為「%@」。"; "notice_room_name_changed" = "%@ 將聊天室名稱變更為 %@。"; -"notice_placed_voice_call" = "%@ 已播出語音通話"; -"notice_placed_video_call" = "%@ 已播出視訊通話"; +"notice_placed_voice_call" = "%@ 已撥出語音通話"; +"notice_placed_video_call" = "%@ 已撥出視訊通話"; "notice_answered_video_call" = "%@ 已接聽通話"; "notice_ended_video_call" = "%@ 已結束通話"; "notice_conference_call_request" = "%@ 已請求 VoIP 會議"; @@ -1098,7 +1098,7 @@ "notice_room_topic_removed" = "%@ 移除了該主題"; "notice_event_redacted_by" = " 由 %@"; "notice_event_redacted_reason" = " [理由:%@]"; -"notice_profile_change_redacted" = "%@ 已更新他的個人檔案 %@"; +"notice_profile_change_redacted" = "%@ 更新了個人檔案 %@"; "notice_room_created" = "%@ 已建立並設定該聊天室。"; "notice_room_join_rule" = "加入規則: %@"; "notice_room_power_level_intro" = "聊天室成員們的權限级别是:"; @@ -1138,7 +1138,7 @@ "notice_error_unexpected_event" = "意外事件"; "notice_error_unknown_event_type" = "未知的事件類型"; "notice_room_history_visible_to_anyone" = "%@ 讓任何人都能看到未來的聊天室歷史記錄。"; -"notice_room_history_visible_to_members" = "%@ 讓所有聊天室成員都能看到聊天室之後的歷史記錄。"; +"notice_room_history_visible_to_members" = "%@ 讓所有成員都能看到聊天室之後的歷史記錄。"; "stop" = "停止"; "joining" = "正在加入"; "enable" = "啟用"; @@ -1291,18 +1291,18 @@ // Settings keys // call string -"call_connecting" = "正在連接…"; +"call_connecting" = "連線中…"; "notification_settings_notify_all_other" = "其他訊息/聊天室的通知"; "notification_settings_by_default" = "按預設…"; "notification_settings_suppress_from_bots" = "限制來自機器人的通知"; "notification_settings_receive_a_call" = "當我收到通話時,請通知我"; "notification_settings_people_join_leave_rooms" = "有人加入或離開聊天室時,請通知我"; -"notification_settings_invite_to_a_new_room" = "當我被邀請到一個全新的聊天室時,請通知我"; -"notification_settings_just_sent_to_me" = "當收到只寄給我的訊息時,請用聲音通知我"; -"notification_settings_contain_my_display_name" = "訊息出現我的顯示名稱時,請用聲音通知我"; -"notification_settings_contain_my_user_name" = "訊息出現我的使用者名稱時,請用聲音通知我"; +"notification_settings_invite_to_a_new_room" = "當我被邀請到全新的聊天室時,請通知我"; +"notification_settings_just_sent_to_me" = "當收到只寄給我的訊息時,請用音效通知我"; +"notification_settings_contain_my_display_name" = "訊息出現我的顯示名稱時,請用音效通知我"; +"notification_settings_contain_my_user_name" = "訊息出現我的使用者名稱時,請用音效通知我"; "notification_settings_other_alerts" = "其他警告"; -"notification_settings_select_room" = "選擇一個聊天室"; +"notification_settings_select_room" = "請選擇聊天室"; "notification_settings_sender_hint" = "@user:domain.com"; "notification_settings_per_sender_notifications" = "寄件人通知"; "notification_settings_per_room_notifications" = "聊天室的通知"; @@ -1346,11 +1346,11 @@ "login_error_already_logged_in" = "已經登入"; "message_unsaved_changes" = "還有變更未儲存。現在離開的話,您將會放棄這些變動。"; "notice_room_history_visible_to_members_from_joined_point_by_you_for_dm" = "您讓所有人在加入後,就能看到未來的聊天室歷史紀錄。"; -"notice_room_history_visible_to_members_from_joined_point_by_you" = "您讓所有聊天室成員在加入後,都能看到未來的聊天室歷史紀錄。"; +"notice_room_history_visible_to_members_from_joined_point_by_you" = "您讓所有成員在加入後,都能看到未來的聊天室歷史紀錄。"; "notice_room_history_visible_to_members_from_invited_point_by_you_for_dm" = "您讓所有人收到邀請後,都能看到未來的聊天室歷史紀錄。"; -"notice_room_history_visible_to_members_from_invited_point_by_you" = "您讓所有聊天室成員被邀請後,都能看到未來的聊天室歷史紀錄。"; -"notice_room_history_visible_to_members_by_you_for_dm" = "您讓所有聊天室成員都能看到聊天室未來的歷史記錄。"; -"notice_room_history_visible_to_members_by_you" = "您讓所有聊天室成員都能看到聊天室未來的歷史記錄。"; +"notice_room_history_visible_to_members_from_invited_point_by_you" = "您讓所有成員被邀請後,都能看到未來的聊天室歷史紀錄。"; +"notice_room_history_visible_to_members_by_you_for_dm" = "您讓所有成員都能看到聊天室未來的歷史記錄。"; +"notice_room_history_visible_to_members_by_you" = "您讓所有成員都能看到聊天室未來的歷史記錄。"; "notice_room_history_visible_to_anyone_by_you" = "您讓任何人都能看到未來的聊天室歷史記錄。"; "notice_redaction_by_you" = "您已取消一个事件(id: %@)"; "notice_encryption_enabled_unknown_algorithm_by_you" = "您已開啟端到端加密(無法識別的演算法 %@)。"; @@ -1366,14 +1366,14 @@ "notice_declined_video_call_by_you" = "您已拒絕此通話"; "notice_ended_video_call_by_you" = "您已結束通話"; "notice_answered_video_call_by_you" = "您已接聽此通話"; -"notice_placed_video_call_by_you" = "您已播出視訊通話"; -"notice_placed_voice_call_by_you" = "您已播出語音通話"; -"notice_room_name_changed_by_you_for_dm" = "您已將名稱變更為 %@。"; -"notice_room_name_changed_by_you" = "您已將聊天室名稱變更為 %@。"; -"notice_topic_changed_by_you" = "您已經將主題變更為:%@。"; +"notice_placed_video_call_by_you" = "您已撥出視訊通話"; +"notice_placed_voice_call_by_you" = "您已撥出語音通話"; +"notice_room_name_changed_by_you_for_dm" = "您將名稱變更為 %@。"; +"notice_room_name_changed_by_you" = "您將聊天室名稱變更為 %@。"; +"notice_topic_changed_by_you" = "您將主題更改為:「%@」。"; "notice_display_name_removed_by_you" = "您已移除自己的顯示名稱"; "notice_display_name_changed_from_by_you" = "您已將顯示名稱從 %@ 變更為 %@"; -"notice_display_name_set_by_you" = "您已將顯示名稱設定為 %@"; +"notice_display_name_set_by_you" = "您將您的顯示名稱設定為 %@"; "notice_avatar_url_changed_by_you" = "您已變更您的大頭照"; "notice_room_withdraw_by_you" = "您已撤回 %@ 的邀請"; "notice_room_ban_by_you" = "您已封鎖 %@"; @@ -1392,12 +1392,12 @@ // Notice Events with "You" "notice_room_invite_by_you" = "您已邀請 %@"; "notice_declined_video_call" = "%@ 已拒絕此通話"; -"notice_room_name_changed_for_dm" = "%@ 把名稱變更為 %@。"; +"notice_room_name_changed_for_dm" = "%@ 將名稱變更為 %@。"; "notice_room_third_party_revoked_invite_for_dm" = "%@ 已撤銷對 %@ 的邀請"; "notice_room_third_party_revoked_invite" = "%@ 已撤銷對 %@ 加入聊天室的邀請"; "notice_room_third_party_invite_for_dm" = "%@ 已邀請 %@"; "microphone_access_not_granted_for_voice_message" = "語音簡訊需要使用麥克風的權限,但是 %@ 沒有存取權限"; -"error_common_message" = "發生了一個錯誤。請重新再試。"; +"error_common_message" = "發生錯誤。請稍後再試。"; "e2e_passphrase_create" = "建立安全密語"; "e2e_passphrase_too_short" = "安全密語太短(至少要 %d 字母的長度)"; "e2e_passphrase_confirm" = "確認安全密語"; @@ -1445,7 +1445,7 @@ "room_member_ignore_prompt" = "您確定要隱藏所有來自此使用者的訊息嗎?"; "message_reply_to_message_to_reply_to_prefix" = "回覆給"; "message_reply_to_sender_sent_their_live_location" = "即時位置。"; -"message_reply_to_sender_sent_their_location" = "已經分享了他們的位置。"; +"message_reply_to_sender_sent_their_location" = "分享了他們的位置。"; "message_reply_to_sender_sent_a_file" = "已傳送檔案。"; "message_reply_to_sender_sent_a_voice_message" = "已傳送語音訊息。"; "message_reply_to_sender_sent_an_audio_file" = "已傳送音訊檔。"; @@ -1531,7 +1531,7 @@ "user_session_rename_session_title" = "正在重新命名工作階段"; "user_session_inactive_session_description" = "不活躍的工作階段是您有一段時間未使用的工作階段,但它們會繼續接收加密金鑰。\n\n移除不活躍的工作階段可以改善安全性與效能,並讓您可以更容易地識別新的工作階段是否可疑。"; "user_session_inactive_session_title" = "不活躍的工作階段"; -"user_session_permanently_unverified_session_description" = "此工作階段無法對此對話進行加密,因此無法驗證。\n\n您無法進入已加密的聊天室中。\n\n為了安全與隱私,建議使用支援加密的 Matrix 客戶端。"; +"user_session_permanently_unverified_session_description" = "此工作階段不支援加密功能,所以無法驗證。\n\n您無法使用此工作階段進入有開啟加密的聊天室中。\n\n為了安全與隱私,建議使用支援加密的 Matrix 客戶端。"; "user_session_unverified_session_description" = "未驗證的工作階段是使用您的憑證登入但交叉叉驗證的工作階段。\n\n您應特別確定您可以識別這些工作階段,因為它們可能代表未經授權使用您的帳號。"; "user_session_unverified_session_title" = "未經驗證的工作階段"; "user_session_verified_session_description" = "已驗證的工作階段,是您輸入安全密語或透過另一個已驗證工作階段確認您的身分後,使用此 Element 帳號的任何地方。\n\n這代表了您擁有解鎖加密訊息,並向其他使用者確認您信任此工作階段所需的所有金鑰。"; @@ -1638,8 +1638,8 @@ "poll_timeline_total_one_vote_not_voted" = "已投 1 票。投票後即可檢視結果"; "poll_timeline_total_votes" = "共計 %lu 票"; "poll_timeline_total_one_vote" = "共計 1 票"; -"poll_timeline_total_no_votes" = "尚未投票"; -"poll_timeline_votes_count" = "%lu 張票"; +"poll_timeline_total_no_votes" = "尚無投票"; +"poll_timeline_votes_count" = "%lu 票"; "poll_timeline_one_vote" = "1 票"; "poll_edit_form_poll_type_closed_description" = "結果僅在您結束投票後顯示"; "poll_edit_form_poll_type_closed" = "秘密投票"; @@ -1683,12 +1683,12 @@ "all_chats_nothing_found_placeholder_title" = "找不到任何結果。"; "all_chats_empty_unreads_placeholder_message" = "當您有一些未讀的訊息時,這裡會顯示您的未讀訊息。"; "all_chats_empty_list_placeholder_title" = "您都看完了。"; -"all_chats_empty_view_information" = "適用於團隊、朋友與組織的多合一安全聊天應用程式。建立聊天室,或加入一個既有的聊天室。"; +"all_chats_empty_view_information" = "適用於團隊、朋友與組織的多合一安全聊天應用程式。建立聊天室,或加入現有的聊天室。"; "all_chats_empty_space_information" = "聊天空間是一種為聊天室與人們分組的新方式。使用右下角的按鈕新增既有的聊天室或建立新的。"; "all_chats_empty_view_title" = "%@\n看起來有點空。"; "all_chats_all_filter" = "全部"; -"all_chats_edit_layout_alphabetical_order" = "按 A-Z 排列"; -"all_chats_edit_layout_activity_order" = "按活動排列"; +"all_chats_edit_layout_alphabetical_order" = "按名稱 A-Z 排序"; +"all_chats_edit_layout_activity_order" = "按頻道最新活動排列"; "all_chats_edit_layout_show_filters" = "顯示過濾條件"; "all_chats_edit_layout_show_recents" = "顯示最近的"; "all_chats_edit_layout_sorting_options_title" = "分類您的訊息"; @@ -1870,7 +1870,7 @@ "home_context_menu_normal_priority" = "一般優先度"; "home_context_menu_low_priority" = "低優先度"; "home_context_menu_unfavourite" = "從我的最愛移除"; -"home_context_menu_favourite" = "我的最愛"; +"home_context_menu_favourite" = "加入我的最愛"; "home_context_menu_unmute" = "解除靜音"; "home_context_menu_mute" = "靜音"; "home_context_menu_notifications" = "通知"; @@ -1996,10 +1996,10 @@ "notice_crypto_error_unknown_inbound_session_id" = "傳送者的工作階段,尚未傳送傳給我們這則訊息的金鑰。"; "notice_crypto_unable_to_decrypt" = "** 無法解密:%@ **"; "notice_room_history_visible_to_members_from_joined_point_for_dm" = "%@ 您讓所有人被邀請後,都能看到未來的聊天室歷史紀錄。"; -"notice_room_history_visible_to_members_from_joined_point" = "%@ 讓所有聊天室成員被邀請後開始,都能看到未來的聊天室歷史紀錄。"; -"notice_room_history_visible_to_members_from_invited_point_for_dm" = "%@ 從他們被邀請開始,讓未來的聊天室歷史紀錄顯示給所有聊天室成員。"; -"notice_room_history_visible_to_members_from_invited_point" = "%@ 從他們被邀請開始,讓未來的聊天室歷史紀錄顯示給所有聊天室成員。"; -"notice_room_history_visible_to_members_for_dm" = "%@ 讓所有聊天室成員都能看到聊天室之後的歷史記錄。"; +"notice_room_history_visible_to_members_from_joined_point" = "%@ 讓所有成員被邀請後開始,都能看到未來的聊天紀錄。"; +"notice_room_history_visible_to_members_from_invited_point_for_dm" = "%@ 從他們被邀請開始,讓未來的聊天紀錄顯示給所有成員。"; +"notice_room_history_visible_to_members_from_invited_point" = "%@ 從他們被邀請開始,讓未來的聊天紀錄顯示給所有成員。"; +"notice_room_history_visible_to_members_for_dm" = "%@ 讓所有成員都能看到聊天室之後的歷史記錄。"; "notice_error_unformattable_event" = "** 無法顯示這則訊息。請回報此錯誤"; "notice_encryption_enabled_unknown_algorithm" = "%1$@ 已開啟端到端加密(無法識別的演算法 %2$@)。"; "notice_encryption_enabled_ok" = "%@ 已開啟端到端加密。"; @@ -2009,11 +2009,11 @@ "notice_room_join_rule_public_by_you" = "您已公開此聊天室。"; "notice_room_join_rule_public_for_dm" = "%@ 公開這個。"; "notice_room_join_rule_public" = "%@ 公開此聊天室。"; -"notice_room_join_rule_invite_by_you_for_dm" = "您讓此變為邀請制。"; +"notice_room_join_rule_invite_by_you_for_dm" = "您將此處變為邀請制。"; "notice_room_join_rule_invite_by_you" = "您讓聊天室變為邀請才可加入。"; -"notice_room_join_rule_invite_for_dm" = "%@讓此變為邀請制。"; +"notice_room_join_rule_invite_for_dm" = "%@ 將此處變為邀請制。"; // New -"notice_room_join_rule_invite" = "%@讓聊天室變為邀請才可加入。"; +"notice_room_join_rule_invite" = "%@ 將聊天室變為邀請制。"; "notice_room_created_for_dm" = "%@ 已加入。"; "notice_room_name_removed_for_dm" = "%@ 移除了該聊天室的名稱"; "ignore_user" = "忽略使用者"; @@ -2114,7 +2114,7 @@ // Generic errors -"error_invite_3pid_with_no_identity_server" = "在設定加入一個身分伺服器,才能用電子郵件寄送邀請。"; +"error_invite_3pid_with_no_identity_server" = "在設定加入身分伺服器後,才能用電子郵件寄送邀請。"; "emoji_picker_flags_category" = "旗幟"; "emoji_picker_symbols_category" = "符號"; "emoji_picker_places_category" = "旅遊與景點"; @@ -2128,7 +2128,7 @@ // User -"key_verification_verified_user_information" = "與此使用者的訊息是端到端加密的,無法被第三方讀取。"; +"key_verification_verified_user_information" = "與此使用者的訊息有端對端加密,無法被第三方讀取。"; "key_verification_verified_this_session_information" = "您現在可以在此裝置上閱讀您的加密訊息,其他使用者也會知道他們能夠信任此裝置。"; "key_verification_verified_new_session_information" = "您現在也可以在新的裝置上閱讀您的加密訊息,其他使用者也會知道他們能夠信任此裝置。"; "key_verification_verified_other_session_information" = "您現在也可以在其他的工作階段閱讀您的加密訊息,其他使用者也會知道他們能夠信任此工作階段。"; @@ -2300,7 +2300,7 @@ "secure_key_backup_setup_intro_use_security_passphrase_info" = "輸入只有您知道的安全密語,並產生備份的金鑰。"; "secure_key_backup_setup_intro_use_security_passphrase_title" = "使用安全密語"; "secure_key_backup_setup_intro_use_security_key_info" = "產生安全金鑰後,請儲存在密碼管理員或保險箱等安全的地方。"; -"secure_key_backup_setup_intro_info" = "透過備份您伺服器上的加密金鑰,來防止失去對您已加密的訊息與資料的存取權。"; +"secure_key_backup_setup_intro_info" = "透過備份您伺服器上的加密金鑰,來防止失去對加密訊息與資料的存取權。"; "service_terms_modal_information_description_integration_manager" = "整合管理員能夠讓您加入第三方服務的功能。"; "service_terms_modal_information_description_identity_server" = "身分伺服器讓您能夠用電話或電子郵件,查詢您的聯絡人是否已經申請帳號。"; "service_terms_modal_information_title_integration_manager" = "整合管理員"; @@ -2309,7 +2309,7 @@ "service_terms_modal_information_title_identity_server" = "身分伺服器"; "service_terms_modal_description_integration_manager" = "這會讓您可以使用聊天機器人、橋接、小工具和貼圖包。"; "service_terms_modal_description_identity_server" = "這會讓手機上儲存您電話或電子郵件的人能找到您。"; -"service_terms_modal_table_header_integration_manager" = "管理整合服務使用條款"; +"service_terms_modal_table_header_integration_manager" = "整合管理員使用條款"; "service_terms_modal_table_header_identity_server" = "身分伺服器條款"; "service_terms_modal_footer" = "您可以隨時在設定中取消。"; @@ -2362,10 +2362,10 @@ "leave_space_action" = "離開聊天空間"; "spaces_add_room_missing_permission_message" = "您沒有權限在此聊天空間中新增聊天室。"; -"spaces_creation_in_one_space" = "在一個聊天空間"; +"spaces_creation_in_one_space" = "在 1 個聊天空間"; "spaces_creation_in_many_spaces" = "在 %@ 個聊天空間"; "spaces_creation_in_spacename_plus_many" = "在 %@ 加入 %@ 個聊天空間"; -"spaces_creation_in_spacename_plus_one" = "在 %@ 加入一個聊天空間"; +"spaces_creation_in_spacename_plus_one" = "在 %@ 加入 1 個聊天空間"; "spaces_creation_in_spacename" = "在 %@"; "spaces_creation_post_process_inviting_users" = "邀請 %@ 位使用者"; "spaces_creation_post_process_adding_rooms" = "加入 %@ 個聊天室"; @@ -2424,7 +2424,7 @@ "room_notifs_settings_mentions_and_keywords" = "僅提及和關鍵字"; // Room Notification Settings -"room_notifs_settings_notify_me_for" = "通知我"; +"room_notifs_settings_notify_me_for" = "收到下列訊息時通知我"; "room_suggestion_settings_screen_message" = "將向聊天空間中的成員推薦建議的聊天室。"; "room_suggestion_settings_screen_title" = "將聊天室設為聊天空間中的建議聊天室"; @@ -2437,7 +2437,7 @@ "room_access_settings_screen_upgrade_alert_upgrading" = "升級聊天室"; "room_access_settings_screen_upgrade_alert_upgrade_button" = "升級"; "room_access_settings_screen_upgrade_alert_auto_invite_switch" = "自動邀請成員到新的聊天室"; -"room_access_settings_screen_upgrade_alert_note" = "請注意,升級會創造一個新版本的聊天室。目前所有的訊息都會放在已封存的聊天室。"; +"room_access_settings_screen_upgrade_alert_note" = "請注意,升級會建立新版的聊天室。目前的所有訊息都將封存在此聊天室中。"; "room_access_settings_screen_upgrade_alert_message_no_param" = "母聊天空間中的任何人都可以找到並加入此聊天室,不需要手動邀請所有人。您隨時都可以在聊天室設定中變更此設定。"; "room_access_settings_screen_upgrade_alert_message" = "任何在 %@ 的人都能找到並加入此聊天室,不需手動邀請所有人。您可以在聊天室的設定中隨時變更此設定。"; "room_access_settings_screen_upgrade_alert_title" = "升級聊天室"; @@ -2474,7 +2474,7 @@ "identity_server_settings_alert_change_title" = "變更身分伺服器"; "identity_server_settings_alert_no_terms" = "您選擇的身分伺服器沒有任何服務條款。僅在您信任服務擁有者時才繼續。"; "identity_server_settings_alert_no_terms_title" = "身分伺服器無使用條款"; -"identity_server_settings_disconnect_info" = "如果您未連線到您的身分伺服器,其他的使用者將無法找到您,您也無法經由電子郵件和電話找到其他使用者。"; +"identity_server_settings_disconnect_info" = "與您的身分伺服器中斷連線後,其他使用者就無法再探索到您,您也不能透過電子郵件地址或電話號碼邀請其他人。"; "identity_server_settings_place_holder" = "輸入一個身分伺服器"; "identity_server_settings_no_is_description" = "您目前未使用身分伺服器。若想要尋找或被您認識的聯絡人找到,請在上方加入伺服器。"; "identity_server_settings_description" = "您正在使用 %@ 來讓其他現有的聯絡人和您能夠找到彼此。"; @@ -2531,9 +2531,9 @@ "settings_discovery_three_pid_details_revoke_action" = "撤回"; "settings_discovery_three_pid_details_information_phone_number" = "在此管理讓其他使用者尋找您以及邀請您進入聊天室的電話號碼偏好設定。您可以在「帳號」中加入或刪除電話號碼。"; "settings_discovery_three_pid_details_information_email" = "在此管理讓其他使用者尋找您以及邀請您進入聊天室的電子郵件地址偏好設定。您可以在「帳號」中加入或刪除電子郵件地址。"; -"settings_discovery_three_pids_management_information_part1" = "選擇您希望其他使用者用哪一個電子郵件(或電話)聯絡您,以及邀請您進入聊天室。您可以在此清單加入/移除電子郵件(或電話)。 "; +"settings_discovery_three_pids_management_information_part1" = "選擇您希望其他使用者用哪一個電子郵件地址(或電話號碼)聯絡您,以及邀請您進入聊天室。您可以在此清單加入/移除電子郵件地址(或電話號碼)。 "; "settings_discovery_accept_terms" = "同意身分伺服器的使用條款"; -"settings_discovery_terms_not_signed" = "同意身分伺服器(%@)的使用條款,讓其他人可以用您的電子郵件或電話號碼找到您。"; +"settings_discovery_terms_not_signed" = "需同意身分伺服器(%@)的使用條款,讓其他人可以用電子郵件地址或電話號碼找到您。"; "settings_discovery_no_identity_server" = "您目前未使用身分伺服器。若想要被您認識的聯絡人找到,請加入伺服器。"; "settings_devices_description" = "所有與您通訊的聯絡人都能看到此工作階段的公開名稱"; "settings_key_backup_delete_confirmation_prompt_msg" = "您確定嗎?如果您的金鑰沒有正確備份的話,將會遺失所有加密訊息。"; @@ -2576,7 +2576,7 @@ // Sessions list "user_verification_sessions_list_user_trust_level_trusted_title" = "受信任"; -"user_verification_start_additional_information" = "要確定安全,請面對面進行或使用其他方式來通訊。"; +"user_verification_start_additional_information" = "為了確保安全,請面對面進行驗證,或使用其他方式來通訊。"; "user_verification_start_waiting_partner" = "正在等待 %@…"; "user_verification_start_information_part2" = " 雙方裝置上顯示的單次驗證碼。"; "user_verification_start_information_part1" = "為了加強安全性,請確認 "; @@ -2648,8 +2648,8 @@ "settings_call_invitations" = "通話邀請"; "settings_room_invitations" = "聊天室邀請"; "settings_messages_containing_at_room" = "@room"; -"settings_notify_me_for" = "通知我"; -"settings_mentions_and_keywords" = "僅有被提及與出現關鍵字時"; +"settings_notify_me_for" = "收到下列訊息時通知我"; +"settings_mentions_and_keywords" = "提及與關鍵字"; "settings_notifications_disabled_alert_message" = "如需啟用通知,請前往裝置設定。"; "settings_security" = "安全性"; "settings_confirm_media_size_description" = "開啟此選項後,傳送檔案前,會先向您確認準備傳送的圖片與影片大小。"; @@ -2733,7 +2733,7 @@ // Social login -"social_login_list_title_continue" = "繼續"; +"social_login_list_title_continue" = "使用下列方式繼續"; "network_offline_message" = "您已離線,請確認您的網路連線。"; "network_offline_title" = "您已離線"; "event_formatter_jitsi_widget_removed_by_you" = "您已刪除 VoIP 會議"; @@ -2743,7 +2743,7 @@ // Events formatter with you "event_formatter_widget_added_by_you" = "您新增了小工具:%@"; "event_formatter_message_deleted" = "訊息已刪除"; -"event_formatter_group_call_incoming" = "%@ 在 %@"; +"event_formatter_group_call_incoming" = "%@ (來自 %@)"; "event_formatter_call_decline" = "拒絕"; "event_formatter_call_connection_failed" = "連線失敗"; "event_formatter_call_missed_video" = "未接聽的視訊通話"; @@ -2824,8 +2824,8 @@ "wysiwyg_composer_format_action_unordered_list" = "切換項目符號清單"; "wysiwyg_composer_format_action_inline_code" = "套用內嵌程式碼格式"; "user_other_session_security_recommendation_title" = "其他工作階段"; -"poll_timeline_reply_ended_poll" = "結束投票"; -"poll_timeline_ended_text" = "結束投票"; +"poll_timeline_reply_ended_poll" = "已結束投票"; +"poll_timeline_ended_text" = "投票已結束"; "poll_timeline_decryption_error" = "因為解密錯誤,不會計算部份投票"; "poll_history_load_more" = "載入更多投票"; "poll_history_detail_view_in_timeline" = "在時間軸中檢視投票"; @@ -2856,3 +2856,15 @@ // MARK: - Launch loading "launch_loading_generic" = "正在同步對話"; +"pill_message_in" = "在 %@ 的訊息"; +"pill_message_from" = "來自 %@ 的訊息"; +"pill_message" = "訊息"; + +// Pills +"pill_room_fallback_display_name" = "聊天空間/聊天室"; +"key_verification_self_verify_security_upgrade_alert_message" = "最新版本中已改進加密訊息傳輸功能,請重新驗證您的裝置。"; + +// Legacy to Rust security upgrade + +"key_verification_self_verify_security_upgrade_alert_title" = "已更新程式"; +"settings_acceptable_use" = "可接受使用政策"; diff --git a/Riot/Experiments/CryptoSDKFeature.swift b/Riot/Experiments/CryptoSDKFeature.swift index bd159ebfd..1e151fe79 100644 --- a/Riot/Experiments/CryptoSDKFeature.swift +++ b/Riot/Experiments/CryptoSDKFeature.swift @@ -52,7 +52,7 @@ import MatrixSDKCrypto init( remoteFeature: RemoteFeaturesClientProtocol = PostHogAnalyticsClient.shared, - localTargetPercentage: Double = 0.5 + localTargetPercentage: Double = 1 ) { var targetPercentage = 0.0 if BWIBuildSettings.shared.useRustEncryption { diff --git a/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m index a1239e184..9301854f0 100644 --- a/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m +++ b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m @@ -344,7 +344,7 @@ static NSString *const kRepliedTextPattern = @".*
.*
(.* if ((RiotSettings.shared.enableThreads && [mxSession.threadingService isEventThreadRoot:event]) || _settings.showRedactionsInRoomHistory) { - MXLogDebug(@"[MXKEventFormatter] Redacted event %@ (%@)", event.description, event.redactedBecause); + MXLogDebug(@"[MXKEventFormatter] Redacted event %@ (%@)", event.eventId, event.redactedBecause); NSString *redactorId = event.redactedBecause[@"sender"]; NSString *redactedBy = @""; @@ -1316,7 +1316,7 @@ static NSString *const kRepliedTextPattern = @".*
.*
(.* // Check attachment validity if (![self isSupportedAttachment:event]) { - MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment %@", event.description); + MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment in event %@", event.eventId); body = [VectorL10n noticeInvalidAttachment]; *error = MXKEventFormatterErrorUnsupported; } @@ -1326,7 +1326,7 @@ static NSString *const kRepliedTextPattern = @".*
.*
(.* body = body? body : [VectorL10n noticeAudioAttachment]; if (![self isSupportedAttachment:event]) { - MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment %@", event.description); + MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment in event %@", event.eventId); if (_isForSubtitle || !_settings.showUnsupportedEventsInRoomHistory) { body = [VectorL10n noticeInvalidAttachment]; @@ -1343,7 +1343,7 @@ static NSString *const kRepliedTextPattern = @".*
.*
(.* body = body? body : [VectorL10n noticeVideoAttachment]; if (![self isSupportedAttachment:event]) { - MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment %@", event.description); + MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment in event %@", event.eventId); if (_isForSubtitle || !_settings.showUnsupportedEventsInRoomHistory) { body = [VectorL10n noticeInvalidAttachment]; @@ -1374,14 +1374,14 @@ static NSString *const kRepliedTextPattern = @".*
.*
(.* } else { - MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported m.file format: %@", event.description); + MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported m.file format in event: %@", event.eventId); *error = MXKEventFormatterErrorUnsupported; } } } else { - MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment %@", event.description); + MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment in event %@", event.eventId); body = [VectorL10n noticeInvalidAttachment]; *error = MXKEventFormatterErrorUnsupported; } @@ -1620,7 +1620,7 @@ static NSString *const kRepliedTextPattern = @".*
.*
(.* // Check sticker validity if (![self isSupportedAttachment:event]) { - MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported sticker %@", event.description); + MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported sticker in event %@", event.eventId); body = [VectorL10n noticeInvalidAttachment]; *error = MXKEventFormatterErrorUnsupported; } @@ -1674,7 +1674,7 @@ static NSString *const kRepliedTextPattern = @".*
.*
(.* if (!attributedDisplayText) { - MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported event %@)", event.description); + MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported event %@)", event.eventId); if (_settings.showUnsupportedEventsInRoomHistory) { if (MXKEventFormatterErrorNone == *error) @@ -1914,7 +1914,7 @@ static NSString *const kRepliedTextPattern = @".*
.*
(.* // No message content in a non-redacted event. Formatter should use fallback. if (!repliedEventContent) { - MXLogWarning(@"[MXKEventFormatter] Unable to retrieve content from replied event %@", repliedEvent.description) + MXLogWarning(@"[MXKEventFormatter] Unable to retrieve content from replied event %@", repliedEvent.eventId) return nil; } } @@ -1949,7 +1949,7 @@ static NSString *const kRepliedTextPattern = @".*
.*
(.* } else { - MXLogWarning(@"[MXKEventFormatter] Unable to build reply event %@", event.description) + MXLogWarning(@"[MXKEventFormatter] Unable to build reply event %@", event.eventId) } return html; diff --git a/Riot/Modules/MatrixKit/Utils/MXKTools.m b/Riot/Modules/MatrixKit/Utils/MXKTools.m index 3af8ef1fd..c993d0832 100644 --- a/Riot/Modules/MatrixKit/Utils/MXKTools.m +++ b/Riot/Modules/MatrixKit/Utils/MXKTools.m @@ -69,8 +69,9 @@ static NSRegularExpression* permalinkRegex; httpLinksRegex = [NSRegularExpression regularExpressionWithPattern:@"(?i)\\b(https?://\\S*)\\b" options:NSRegularExpressionCaseInsensitive error:nil]; htmlTagsRegex = [NSRegularExpression regularExpressionWithPattern:@"<(\\w+)[^>]*>" options:NSRegularExpressionCaseInsensitive error:nil]; linkDetector = [NSDataDetector dataDetectorWithTypes:NSTextCheckingTypeLink error:nil]; - - NSString *permalinkPattern = [NSString stringWithFormat:@"%@%@", BuildSettings.clientPermalinkBaseUrl ?: kMXMatrixDotToUrl, kMXKToolsRegexStringForPermalink]; + + // if we have a custom clientPermalinkBaseUrl, we also need to support matrix.to permalinks + NSString *permalinkPattern = [NSString stringWithFormat:@"(?:%@|%@)%@", BuildSettings.clientPermalinkBaseUrl, kMXMatrixDotToUrl, kMXKToolsRegexStringForPermalink]; permalinkRegex = [NSRegularExpression regularExpressionWithPattern:permalinkPattern options:NSRegularExpressionCaseInsensitive error:nil]; }); } diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h index d7bf9d8fc..e366ae239 100644 --- a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.h @@ -382,6 +382,11 @@ typedef enum : NSUInteger */ @property (nonatomic) NSAttributedString *attributedTextMessage; +/** + Default font for the message composer. + */ +@property (nonatomic, readonly, nonnull) UIFont *defaultFont; + - (void)dismissValidationView:(MXKImageView*)validationView; @end diff --git a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m index 9581df2a7..d05cd9f53 100644 --- a/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m +++ b/Riot/Modules/MatrixKit/Views/RoomInputToolbar/MXKRoomInputToolbarView.m @@ -358,6 +358,10 @@ self.textMessage = [NSString stringWithFormat:@"%@%@", self.textMessage, text]; } +- (UIFont *)defaultFont +{ + return [UIFont systemFontOfSize:15.f]; +} #pragma mark - MXKFileSizes diff --git a/Riot/Modules/Pills/PillAttachmentViewProvider.swift b/Riot/Modules/Pills/PillAttachmentViewProvider.swift index ae02b019f..5d57bb3b2 100644 --- a/Riot/Modules/Pills/PillAttachmentViewProvider.swift +++ b/Riot/Modules/Pills/PillAttachmentViewProvider.swift @@ -25,25 +25,29 @@ import UIKit avatarLeading: 2.0, avatarSideLength: 16.0, itemSpacing: 4) - private weak var messageTextView: MXKMessageTextView? + private weak var messageTextView: UITextView? + private var pillViewFlusher: PillViewFlusher? { + messageTextView as? PillViewFlusher + } // MARK: - Override override init(textAttachment: NSTextAttachment, parentView: UIView?, textLayoutManager: NSTextLayoutManager?, location: NSTextLocation) { super.init(textAttachment: textAttachment, parentView: parentView, textLayoutManager: textLayoutManager, location: location) - self.messageTextView = parentView?.superview as? MXKMessageTextView + // Keep a reference to the parent text view for size adjustments and pills flushing. + messageTextView = parentView?.superview as? UITextView } override func loadView() { super.loadView() guard let textAttachment = self.textAttachment as? PillTextAttachment else { - MXLog.debug("[PillAttachmentViewProvider]: attachment is missing or not of expected class") + MXLog.failure("[PillAttachmentViewProvider]: attachment is missing or not of expected class") return } guard var pillData = textAttachment.data else { - MXLog.debug("[PillAttachmentViewProvider]: attachment misses pill data") + MXLog.failure("[PillAttachmentViewProvider]: attachment misses pill data") return } @@ -59,6 +63,11 @@ import UIKit mediaManager: mainSession?.mediaManager, andPillData: pillData) view = pillView - messageTextView?.registerPillView(pillView) + + if let pillViewFlusher { + pillViewFlusher.registerPillView(pillView) + } else { + MXLog.failure("[PillAttachmentViewProvider]: no handler found, pill will not be flushed properly") + } } } diff --git a/Riot/Modules/Pills/PillProvider.swift b/Riot/Modules/Pills/PillProvider.swift index 60363bc47..1941f8af1 100644 --- a/Riot/Modules/Pills/PillProvider.swift +++ b/Riot/Modules/Pills/PillProvider.swift @@ -26,14 +26,14 @@ private enum PillAttachmentKind { struct PillProvider { private let session: MXSession private let eventFormatter: MXKEventFormatter - private let event: MXEvent + private let event: MXEvent? private let roomState: MXRoomState private let latestRoomState: MXRoomState? private let isEditMode: Bool init(withSession session: MXSession, eventFormatter: MXKEventFormatter, - event: MXEvent, + event: MXEvent?, roomState: MXRoomState, andLatestRoomState latestRoomState: MXRoomState?, isEditMode: Bool) { @@ -46,7 +46,7 @@ struct PillProvider { self.isEditMode = isEditMode } - func pillTextAttachmentString(forUrl url: URL, withLabel label: String, event: MXEvent) -> NSAttributedString? { + func pillTextAttachmentString(forUrl url: URL, withLabel label: String) -> NSAttributedString? { // Try to get a pill from this url guard let pillType = PillType.from(url: url) else { @@ -133,6 +133,10 @@ struct PillProvider { let avatarUrl = roomMember?.avatarUrl ?? user?.avatarUrl let displayName = roomMember?.displayname ?? user?.displayName ?? userId let isHighlighted = userId == session.myUserId + // No actual event means it is a composer Pill. No highlight + && event != nil + // No highlight on self-mentions + && event?.sender != session.myUserId let avatar: PillTextAttachmentItem if roomMember == nil && user == nil { diff --git a/Riot/Modules/Pills/PillType.swift b/Riot/Modules/Pills/PillType.swift index 8b90de15b..53b42e0e2 100644 --- a/Riot/Modules/Pills/PillType.swift +++ b/Riot/Modules/Pills/PillType.swift @@ -27,7 +27,7 @@ enum PillType: Codable { extension PillType { private static var regexPermalinkTarget: NSRegularExpression? = { let clientBaseUrl = BuildSettings.clientPermalinkBaseUrl ?? kMXMatrixDotToUrl - let pattern = #"\#(clientBaseUrl)/#/(?:(?:room|user)/)?((?:@|!|#)[^@!#/?\s]*)/?((?:\$)[^\$/?\s]*)?"# + let pattern = #"(?:\#(clientBaseUrl)|\#(kMXMatrixDotToUrl))/#/(?:(?:room|user)/)?((?:@|!|#)[^@!#/?\s]*)/?((?:\$)[^\$/?\s]*)?"# return try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) }() diff --git a/Riot/Modules/Pills/PillViewFlusher.swift b/Riot/Modules/Pills/PillViewFlusher.swift new file mode 100644 index 000000000..44a4d7cbf --- /dev/null +++ b/Riot/Modules/Pills/PillViewFlusher.swift @@ -0,0 +1,39 @@ +// +// Copyright 2023 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 UIKit +import WysiwygComposer + +/// Defines behaviour for an object that is able to manage views created +/// by a `NSTextAttachmentViewProvider`. This can be implemented +/// by an `UITextView` that would keep track of views in order to +/// (internally) clear them when required (e.g. when setting a new attributed text). +/// +/// Note: It is necessary to clear views manually due to a bug in iOS. See `MXKMessageTextView`. +@available(iOS 15.0, *) +protocol PillViewFlusher: AnyObject { + /// Register a pill view that has been added through `NSTextAttachmentViewProvider`. + /// Should be called within the `loadView` function in order to clear the pills properly on text updates. + /// + /// - Parameter pillView: View to register. + func registerPillView(_ pillView: UIView) +} + +@available(iOS 15.0, *) +extension MXKMessageTextView: PillViewFlusher { } + +@available(iOS 15.0, *) +extension WysiwygTextView: PillViewFlusher { } diff --git a/Riot/Modules/Pills/PillsFormatter.swift b/Riot/Modules/Pills/PillsFormatter.swift index a9df99fd4..1b6256835 100644 --- a/Riot/Modules/Pills/PillsFormatter.swift +++ b/Riot/Modules/Pills/PillsFormatter.swift @@ -65,7 +65,7 @@ class PillsFormatter: NSObject { // try to get a mention pill from the url let label = Range(range, in: newAttr.string).flatMap { String(newAttr.string[$0]) } - if let attachmentString: NSAttributedString = provider.pillTextAttachmentString(forUrl: url, withLabel: label ?? "", event: event) { + if let attachmentString: NSAttributedString = provider.pillTextAttachmentString(forUrl: url, withLabel: label ?? "") { // replace the url with the pill newAttr.replaceCharacters(in: range, with: attachmentString) } @@ -74,6 +74,41 @@ class PillsFormatter: NSObject { return newAttr } + /// Insert text attachments for pills inside given attributed string containing markdown. + /// + /// - Parameters: + /// - markdownString: An attributed string with markdown formatting + /// - roomState: The current room state + /// - font: The font to use for the pill text + /// - Returns: A new attributed string with pills. + static func insertPills(in markdownString: NSAttributedString, + withSession session: MXSession, + eventFormatter: MXKEventFormatter, + roomState: MXRoomState, + font: UIFont) -> NSAttributedString { + let matches = markdownLinks(in: markdownString) + + // If we have some matches, replace permalinks by a pill version. + guard !matches.isEmpty else { return markdownString } + + let pillProvider = PillProvider(withSession: session, + eventFormatter: eventFormatter, + event: nil, + roomState: roomState, + andLatestRoomState: nil, + isEditMode: true) + + let mutable = NSMutableAttributedString(attributedString: markdownString) + + matches.reversed().forEach { + if let attachmentString = pillProvider.pillTextAttachmentString(forUrl: $0.url, withLabel: $0.label) { + mutable.replaceCharacters(in: $0.range, with: attachmentString) + } + } + + return mutable + } + /// Creates a string with all pills of given attributed string replaced by display names. /// /// - Parameters: @@ -123,6 +158,20 @@ class PillsFormatter: NSObject { } return attributedStringWithAttachment(attachment, link: url, font: font) } + + static func mentionPill(withUrl url: URL, + andLabel label: String, + session: MXSession, + eventFormatter: MXKEventFormatter, + roomState: MXRoomState) -> NSAttributedString? { + let pillProvider = PillProvider(withSession: session, + eventFormatter: eventFormatter, + event: nil, + roomState: roomState, + andLatestRoomState: nil, + isEditMode: true) + return pillProvider.pillTextAttachmentString(forUrl: url, withLabel: label) + } /// Update alpha of all `PillTextAttachment` contained in given attributed string. /// @@ -160,12 +209,45 @@ class PillsFormatter: NSObject { } } } - } // MARK: - Private Methods @available (iOS 15.0, *) extension PillsFormatter { + struct MarkdownLinkResult: Equatable { + let url: URL + let label: String + let range: NSRange + } + + static func markdownLinks(in attributedString: NSAttributedString) -> [MarkdownLinkResult] { + // Create a regexp that detects markdown links. + // Pattern source: https://gist.github.com/hugocf/66d6cd241eff921e0e02 + let pattern = "\\[([^\\]]+)\\]\\(([^\\)\"\\s]+)(?:\\s+\"(.*)\")?\\)" + guard let regExp = try? NSRegularExpression(pattern: pattern) else { return [] } + + let matches = regExp.matches(in: attributedString.string, + range: .init(location: 0, length: attributedString.length)) + + return matches.compactMap { match in + let labelRange = match.range(at: 1) + let urlRange = match.range(at: 2) + let label = attributedString.attributedSubstring(from: labelRange).string + var url = attributedString.attributedSubstring(from: urlRange).string + + // Note: a valid markdown link can be written with + // enclosing <..>, remove them for userId detection. + if url.first == "<" && url.last == ">" { + url = String(url[url.index(after: url.startIndex)...url.index(url.endIndex, offsetBy: -2)]) + } + + if let url = URL(string: url) { + return MarkdownLinkResult(url: url, label: label, range: match.range) + } else { + return nil + } + } + } static func attributedStringWithAttachment(_ attachment: PillTextAttachment, link: URL?, font: UIFont) -> NSAttributedString { let string = NSMutableAttributedString(attachment: attachment) diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index e3861e0db..66d0f3538 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -5205,6 +5205,21 @@ static CGSize kThreadListBarButtonItemImageSize; [self.userSuggestionCoordinator processTextMessage:toolbarView.textMessage]; } +- (void)didDetectTextPattern:(SuggestionPatternWrapper *)suggestionPattern +{ + [self.userSuggestionCoordinator processSuggestionPattern:suggestionPattern]; +} + +- (UserSuggestionViewModelContextWrapper *)userSuggestionContext +{ + return [self.userSuggestionCoordinator sharedContext]; +} + +- (MXMediaManager *)mediaManager +{ + return self.roomDataSource.mxSession.mediaManager; +} + - (void)roomInputToolbarViewDidOpenActionMenu:(RoomInputToolbarView*)toolbarView { // Consider opening the action menu as beginning to type and share encryption keys if requested. diff --git a/Riot/Modules/Room/RoomViewController.swift b/Riot/Modules/Room/RoomViewController.swift index ef1c3e828..1a3b45b17 100644 --- a/Riot/Modules/Room/RoomViewController.swift +++ b/Riot/Modules/Room/RoomViewController.swift @@ -14,46 +14,52 @@ // limitations under the License. // +import HTMLParser import UIKit import WysiwygComposer extension RoomViewController { // MARK: - Override open override func mention(_ roomMember: MXRoomMember) { - guard let inputToolbar = inputToolbar else { - return - } - - let newAttributedString = NSMutableAttributedString(attributedString: inputToolbar.attributedTextMessage) - - if inputToolbar.attributedTextMessage.length > 0 { - if #available(iOS 15.0, *) { - newAttributedString.append(PillsFormatter.mentionPill(withRoomMember: roomMember, - isHighlighted: false, - font: inputToolbar.textDefaultFont)) - } else { - newAttributedString.appendString(roomMember.displayname.count > 0 ? roomMember.displayname : roomMember.userId) - } - newAttributedString.appendString(" ") - } else if roomMember.userId == self.mainSession.myUser.userId { - newAttributedString.appendString("/me ") + if let wysiwygInputToolbar, wysiwygInputToolbar.textFormattingEnabled { + wysiwygInputToolbar.mention(roomMember) + wysiwygInputToolbar.becomeFirstResponder() } else { - if #available(iOS 15.0, *) { - newAttributedString.append(PillsFormatter.mentionPill(withRoomMember: roomMember, - isHighlighted: false, - font: inputToolbar.textDefaultFont)) - } else { - newAttributedString.appendString(roomMember.displayname.count > 0 ? roomMember.displayname : roomMember.userId) - } - newAttributedString.appendString(": ") - } + guard let attributedText = inputToolbarView.attributedTextMessage else { return } + let newAttributedString = NSMutableAttributedString(attributedString: attributedText) - inputToolbar.attributedTextMessage = newAttributedString - inputToolbar.becomeFirstResponder() + if attributedText.length > 0 { + if #available(iOS 15.0, *) { + newAttributedString.append(PillsFormatter.mentionPill(withRoomMember: roomMember, + isHighlighted: false, + font: inputToolbarView.defaultFont)) + } else { + newAttributedString.appendString(roomMember.displayname.count > 0 ? roomMember.displayname : roomMember.userId) + } + newAttributedString.appendString(" ") + } else if roomMember.userId == self.mainSession.myUser.userId { + newAttributedString.appendString("/me ") + newAttributedString.addAttribute(.font, + value: inputToolbarView.defaultFont, + range: .init(location: 0, length: newAttributedString.length)) + } else { + if #available(iOS 15.0, *) { + newAttributedString.append(PillsFormatter.mentionPill(withRoomMember: roomMember, + isHighlighted: false, + font: inputToolbarView.defaultFont)) + } else { + newAttributedString.appendString(roomMember.displayname.count > 0 ? roomMember.displayname : roomMember.userId) + } + newAttributedString.appendString(": ") + } + + inputToolbarView.attributedTextMessage = newAttributedString + inputToolbarView.becomeFirstResponder() + } } - /// Send the formatted text message and its raw counterpat to the room + /// Send the formatted text message and its raw counterpart to the room /// /// - Parameter rawTextMsg: the raw text message /// - Parameter htmlMsg: the html text message @@ -369,6 +375,48 @@ extension RoomViewController: ComposerLinkActionBridgePresenterDelegate { } } +// MARK: - PermalinkReplacer +extension RoomViewController: PermalinkReplacer { + public func replacementForLink(_ url: String, text: String) -> NSAttributedString? { + guard #available(iOS 15.0, *), + let url = URL(string: url), + let session = roomDataSource.mxSession, + let eventFormatter = roomDataSource.eventFormatter, + let roomState = roomDataSource.roomState else { + return nil + } + + return PillsFormatter.mentionPill(withUrl: url, + andLabel: text, + session: session, + eventFormatter: eventFormatter, + roomState: roomState) + } + + public func postProcessMarkdown(in attributedString: NSAttributedString) -> NSAttributedString { + guard #available(iOS 15.0, *), + let roomDataSource, + let session = roomDataSource.mxSession, + let eventFormatter = roomDataSource.eventFormatter, + let roomState = roomDataSource.roomState else { + return attributedString + } + return PillsFormatter.insertPills(in: attributedString, + withSession: session, + eventFormatter: eventFormatter, + roomState: roomState, + font: inputToolbarView.defaultFont) + } + + public func restoreMarkdown(in attributedString: NSAttributedString) -> String { + if #available(iOS 15.0, *) { + return PillsFormatter.stringByReplacingPills(in: attributedString, mode: .markdown) + } else { + return attributedString.string + } + } +} + // MARK: - VoiceBroadcast extension RoomViewController { @objc func stopUncompletedVoiceBroadcastIfNeeded() { diff --git a/Riot/Modules/Room/RoomViewController.xib b/Riot/Modules/Room/RoomViewController.xib index f33d661bd..b7a62a8bf 100644 --- a/Riot/Modules/Room/RoomViewController.xib +++ b/Riot/Modules/Room/RoomViewController.xib @@ -39,6 +39,7 @@ + diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h index af84b462d..df71790be 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.h @@ -21,6 +21,8 @@ @class RoomActionsBar; @class RoomInputToolbarView; @class LinkActionWrapper; +@class SuggestionPatternWrapper; +@class UserSuggestionViewModelContextWrapper; /** Destination of the message in the composer @@ -59,7 +61,7 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode) @param toolbarView the room input toolbar view */ -- (void)roomInputToolbarViewDidChangeTextMessage:(RoomInputToolbarView*)toolbarView; +- (void)roomInputToolbarViewDidChangeTextMessage:(MXKRoomInputToolbarView*)toolbarView; /** Inform the delegate that the action menu was opened. @@ -80,6 +82,12 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode) - (void)didSendLinkAction: (LinkActionWrapper *)linkAction; +- (void)didDetectTextPattern: (SuggestionPatternWrapper *)suggestionPattern; + +- (UserSuggestionViewModelContextWrapper *)userSuggestionContext; + +- (MXMediaManager *)mediaManager; + @end /** @@ -128,8 +136,6 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode) */ @property (nonatomic, weak, readonly) UIButton *attachMediaButton; -@property (nonatomic, readonly, nonnull) UIFont *textDefaultFont; - /** Adds a voice message toolbar view to be displayed inside this input toolbar */ diff --git a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m index c2826e3b9..0da9df3e6 100644 --- a/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m +++ b/Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m @@ -157,7 +157,7 @@ static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3; { NSMutableAttributedString *mutableTextMessage = [[NSMutableAttributedString alloc] initWithAttributedString:attributedTextMessage]; [mutableTextMessage addAttributes:@{ NSForegroundColorAttributeName: ThemeService.shared.theme.textPrimaryColor, - NSFontAttributeName: self.textDefaultFont } + NSFontAttributeName: self.defaultFont } range:NSMakeRange(0, mutableTextMessage.length)]; attributedTextMessage = mutableTextMessage; } @@ -184,7 +184,7 @@ static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3; return self.textView.text; } -- (UIFont *)textDefaultFont +- (UIFont *)defaultFont { if (self.textView.font) { diff --git a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift index db6cc8193..f3fc1111b 100644 --- a/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift +++ b/Riot/Modules/Room/Views/WYSIWYGInputToolbar/WysiwygInputToolbarView.swift @@ -72,6 +72,12 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp } // MARK: Public + + override var delegate: MXKRoomInputToolbarViewDelegate! { + didSet { + setupComposerIfNeeded() + } + } override var placeholder: String! { get { @@ -85,6 +91,23 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp override var isFocused: Bool { viewModel.isFocused } + + override var attributedTextMessage: NSAttributedString? { + // Note: this is only interactive in plain text mode. If RTE is enabled, + // APIs from the composer view model should be used. + get { + guard !self.textFormattingEnabled else { return nil } + return self.wysiwygViewModel.textView.attributedText + } + set { + guard !self.textFormattingEnabled else { return } + self.wysiwygViewModel.textView.attributedText = newValue + } + } + + override var defaultFont: UIFont { + return UIFont.preferredFont(forTextStyle: .body) + } var isMaximised: Bool { wysiwygViewModel.maximised @@ -120,93 +143,15 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp private weak var toolbarViewDelegate: RoomInputToolbarViewDelegate? { return (delegate as? RoomInputToolbarViewDelegate) ?? nil } + + private var permalinkReplacer: PermalinkReplacer? { + return (delegate as? PermalinkReplacer) + } override func awakeFromNib() { super.awakeFromNib() - viewModel = ComposerViewModel( - initialViewState: ComposerViewState(textFormattingEnabled: RiotSettings.shared.enableWysiwygTextFormatting, - isLandscapePhone: isLandscapePhone, bindings: ComposerBindings(focused: false))) - - viewModel.callback = { [weak self] result in - self?.handleViewModelResult(result) - } - wysiwygViewModel.plainTextMode = !RiotSettings.shared.enableWysiwygTextFormatting - - inputAccessoryViewForKeyboard = UIView(frame: .zero) - - let composer = Composer( - viewModel: viewModel.context, - wysiwygViewModel: wysiwygViewModel, - resizeAnimationDuration: Double(kResizeComposerAnimationDuration), - sendMessageAction: { [weak self] content in - guard let self = self else { return } - self.sendWysiwygMessage(content: content) - }, showSendMediaActions: { [weak self] in - guard let self = self else { return } - self.showSendMediaActions() - }).introspectTextView { [weak self] textView in - guard let self = self else { return } - textView.inputAccessoryView = self.inputAccessoryViewForKeyboard - } - - hostingViewController = VectorHostingController(rootView: composer) - hostingViewController.publishHeightChanges = true - let height = hostingViewController.sizeThatFits(in: CGSize(width: self.frame.width, height: UIView.layoutFittingExpandedSize.height)).height - let subView: UIView = hostingViewController.view - self.addSubview(subView) - - self.translatesAutoresizingMaskIntoConstraints = false - subView.translatesAutoresizingMaskIntoConstraints = false - heightConstraint = subView.heightAnchor.constraint(equalToConstant: height) - NSLayoutConstraint.activate([ - heightConstraint, - subView.leadingAnchor.constraint(equalTo: self.leadingAnchor), - subView.trailingAnchor.constraint(equalTo: self.trailingAnchor), - subView.bottomAnchor.constraint(equalTo: self.bottomAnchor) - ]) - - cancellables = [ - hostingViewController.heightPublisher - .removeDuplicates() - .sink(receiveValue: { [weak self] idealHeight in - guard let self = self else { return } - self.updateToolbarHeight(wysiwygHeight: idealHeight) - }), - // Required to update the view constraints after minimise/maximise is tapped - wysiwygViewModel.$idealHeight - .removeDuplicates() - .sink { [weak hostingViewController] _ in - hostingViewController?.view.setNeedsLayout() - }, - - wysiwygViewModel.$maximised - .dropFirst() - .removeDuplicates() - .sink { [weak self] value in - guard let self = self else { return } - self.toolbarViewDelegate?.didChangeMaximisedState(value) - self.hostingViewController.view.layer.cornerRadius = value ? 20 : 0 - if !value { - self.voiceMessageBottomConstraint?.constant = 2 - } - } - ] - - update(theme: ThemeService.shared().theme) - registerThemeServiceDidChangeThemeNotification() - NotificationCenter.default.addObserver( - self, - selector: #selector(keyboardWillShow), - name: UIResponder.keyboardWillShowNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(keyboardWillHide), - name: UIResponder.keyboardWillHideNotification, - object: nil - ) - NotificationCenter.default.addObserver(self, selector: #selector(deviceDidRotate), name: UIDevice.orientationDidChangeNotification, object: nil) + + setupComposerIfNeeded() } override func customizeRendering() { @@ -217,6 +162,11 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp override func dismissKeyboard() { self.viewModel.dismissKeyboard() } + + @discardableResult + override func becomeFirstResponder() -> Bool { + self.wysiwygViewModel.textView.becomeFirstResponder() + } override func dismissValidationView(_ validationView: MXKImageView!) { super.dismissValidationView(validationView) @@ -239,8 +189,119 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp } wysiwygViewModel.applyLinkOperation(linkOperation) } + + func mention(_ member: MXRoomMember) { + self.wysiwygViewModel.setMention(link: MXTools.permalinkToUser(withUserId: member.userId), + name: member.displayname, + mentionType: .user) + } // MARK: - Private + + private func setupComposerIfNeeded() { + guard hostingViewController == nil, + let toolbarViewDelegate, + let permalinkReplacer else { return } + + viewModel = ComposerViewModel( + initialViewState: ComposerViewState(textFormattingEnabled: RiotSettings.shared.enableWysiwygTextFormatting, + isLandscapePhone: isLandscapePhone, + bindings: ComposerBindings(focused: false))) + + viewModel.callback = { [weak self] result in + self?.handleViewModelResult(result) + } + wysiwygViewModel.plainTextMode = !RiotSettings.shared.enableWysiwygTextFormatting + wysiwygViewModel.permalinkReplacer = permalinkReplacer + + inputAccessoryViewForKeyboard = UIView(frame: .zero) + + let composer = Composer( + viewModel: viewModel.context, + wysiwygViewModel: wysiwygViewModel, + userSuggestionSharedContext: toolbarViewDelegate.userSuggestionContext().context, + resizeAnimationDuration: Double(kResizeComposerAnimationDuration), + sendMessageAction: { [weak self] content in + guard let self = self else { return } + self.sendWysiwygMessage(content: content) + }, showSendMediaActions: { [weak self] in + guard let self = self else { return } + self.showSendMediaActions() + }) + .introspectTextView { [weak self] textView in + guard let self = self else { return } + textView.inputAccessoryView = self.inputAccessoryViewForKeyboard + } + .environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: toolbarViewDelegate.mediaManager()))) + + hostingViewController = VectorHostingController(rootView: composer) + hostingViewController.publishHeightChanges = true + let height = hostingViewController.sizeThatFits(in: CGSize(width: self.frame.width, height: UIView.layoutFittingExpandedSize.height)).height + let subView: UIView = hostingViewController.view + self.addSubview(subView) + + self.translatesAutoresizingMaskIntoConstraints = false + subView.translatesAutoresizingMaskIntoConstraints = false + heightConstraint = subView.heightAnchor.constraint(equalToConstant: height) + NSLayoutConstraint.activate([ + heightConstraint, + subView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + subView.trailingAnchor.constraint(equalTo: self.trailingAnchor), + subView.bottomAnchor.constraint(equalTo: self.bottomAnchor) + ]) + + cancellables = [ + hostingViewController.heightPublisher + .removeDuplicates() + .sink(receiveValue: { [weak self] idealHeight in + guard let self = self else { return } + self.updateToolbarHeight(wysiwygHeight: idealHeight) + }), + // Required to update the view constraints after minimise/maximise is tapped + wysiwygViewModel.$idealHeight + .removeDuplicates() + .sink { [weak hostingViewController] _ in + hostingViewController?.view.setNeedsLayout() + }, + + wysiwygViewModel.$maximised + .dropFirst() + .removeDuplicates() + .sink { [weak self] value in + guard let self = self else { return } + self.toolbarViewDelegate?.didChangeMaximisedState(value) + self.hostingViewController.view.layer.cornerRadius = value ? 20 : 0 + if !value { + self.voiceMessageBottomConstraint?.constant = 2 + } + }, + + wysiwygViewModel.$plainTextContent + .dropFirst() + .removeDuplicates() + .sink { [weak self] value in + guard let self else { return } + self.textMessage = value.string + self.toolbarViewDelegate?.roomInputToolbarViewDidChangeTextMessage(self) + } + ] + + update(theme: ThemeService.shared().theme) + registerThemeServiceDidChangeThemeNotification() + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillShow), + name: UIResponder.keyboardWillShowNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillHide), + name: UIResponder.keyboardWillHideNotification, + object: nil + ) + NotificationCenter.default.addObserver(self, selector: #selector(deviceDidRotate), name: UIDevice.orientationDidChangeNotification, object: nil) + } @objc private func keyboardWillShow(_ notification: Notification) { if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue { @@ -291,6 +352,8 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp setVoiceMessageToolbarIsHidden(!isEmpty) case let .linkTapped(linkAction): toolbarViewDelegate?.didSendLinkAction(LinkActionWrapper(linkAction)) + case let .suggestion(pattern): + toolbarViewDelegate?.didDetectTextPattern(SuggestionPatternWrapper(pattern)) } } diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageAttachmentCacheManager.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessageAttachmentCacheManager.swift index 8e31a229c..2cc989d99 100644 --- a/Riot/Modules/Room/VoiceMessages/VoiceMessageAttachmentCacheManager.swift +++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageAttachmentCacheManager.swift @@ -206,11 +206,12 @@ class VoiceMessageAttachmentCacheManager { } private func convertFileAtPath(_ path: String?, numberOfSamples: Int, identifier: String, semaphore: DispatchSemaphore) { - guard let filePath = path else { + guard let path else { return } - - let fileExtension = filePath.hasSuffix(".mp4") ? "mp4" : "m4a" + + let filePath = URL(fileURLWithPath: path) + let fileExtension = filePath.hasSupportedAudioExtension ? filePath.pathExtension : "m4a" let newURL = temporaryFilesFolderURL.appendingPathComponent(identifier).appendingPathExtension(fileExtension) let conversionCompletion: (Result) -> Void = { result in @@ -252,7 +253,7 @@ class VoiceMessageAttachmentCacheManager { if FileManager.default.fileExists(atPath: newURL.path) { conversionCompletion(Result.success(())) } else { - VoiceMessageAudioConverter.convertToMPEG4AAC(sourceURL: URL(fileURLWithPath: filePath), destinationURL: newURL, completion: conversionCompletion) + VoiceMessageAudioConverter.convertToMPEG4AACIfNeeded(sourceURL: filePath, destinationURL: newURL, completion: conversionCompletion) } } diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioConverter.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioConverter.swift index 996e33b4a..608f14234 100644 --- a/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioConverter.swift +++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioConverter.swift @@ -39,10 +39,10 @@ struct VoiceMessageAudioConverter { } } - static func convertToMPEG4AAC(sourceURL: URL, destinationURL: URL, completion: @escaping (Result) -> Void) { + static func convertToMPEG4AACIfNeeded(sourceURL: URL, destinationURL: URL, completion: @escaping (Result) -> Void) { DispatchQueue.global(qos: .userInitiated).async { do { - if sourceURL.pathExtension == "mp4" { + if sourceURL.hasSupportedAudioExtension { try FileManager.default.copyItem(atPath: sourceURL.path, toPath: destinationURL.path) } else { try OGGConverter.convertOpusOGGToM4aFile(src: sourceURL, dest: destinationURL) @@ -86,3 +86,11 @@ struct VoiceMessageAudioConverter { } } } + +extension URL { + /// Returns true if the URL has a supported audio extension + var hasSupportedAudioExtension: Bool { + let supportedExtensions = ["mp3", "mp4", "m4a", "wav", "aac"] + return supportedExtensions.contains(pathExtension.lowercased()) + } +} diff --git a/Riot/Utils/EventFormatter.m b/Riot/Utils/EventFormatter.m index 321f8d23e..a84023879 100644 --- a/Riot/Utils/EventFormatter.m +++ b/Riot/Utils/EventFormatter.m @@ -107,8 +107,8 @@ static NSString *const kEventFormatterTimeFormat = @"HH:mm"; @"event_id": event.eventId ?: @"unknown" }); string = [[NSAttributedString alloc] initWithString:[VectorL10n noticeErrorUnformattableEvent] attributes:@{ - NSForegroundColorAttributeName: self.sendingTextColor, - NSFontAttributeName: [self encryptedMessagesTextFont] + NSFontAttributeName: [self encryptedMessagesTextFont], + NSForegroundColorAttributeName: [self encryptingTextColor] }]; } } diff --git a/RiotSwiftUI/Modules/Room/Composer/LinkAction/Model/ComposerLinkActionModel.swift b/RiotSwiftUI/Modules/Room/Composer/LinkAction/Model/ComposerLinkActionModel.swift index fdf92cab5..0c3ba03e2 100644 --- a/RiotSwiftUI/Modules/Room/Composer/LinkAction/Model/ComposerLinkActionModel.swift +++ b/RiotSwiftUI/Modules/Room/Composer/LinkAction/Model/ComposerLinkActionModel.swift @@ -41,6 +41,7 @@ extension ComposerLinkActionViewState { switch linkAction { case .createWithText, .create: return VectorL10n.wysiwygComposerLinkActionCreateTitle case .edit: return VectorL10n.wysiwygComposerLinkActionEditTitle + case .disabled: return "" } } @@ -64,6 +65,7 @@ extension ComposerLinkActionViewState { case .createWithText: return bindings.text.isEmpty case .create: return false case .edit: return !bindings.hasEditedUrl + case .disabled: return false } } } diff --git a/RiotSwiftUI/Modules/Room/Composer/LinkAction/ViewModel/ComposerLinkActionViewModel.swift b/RiotSwiftUI/Modules/Room/Composer/LinkAction/ViewModel/ComposerLinkActionViewModel.swift index 9683ac621..367417282 100644 --- a/RiotSwiftUI/Modules/Room/Composer/LinkAction/ViewModel/ComposerLinkActionViewModel.swift +++ b/RiotSwiftUI/Modules/Room/Composer/LinkAction/ViewModel/ComposerLinkActionViewModel.swift @@ -46,6 +46,9 @@ final class ComposerLinkActionViewModel: ComposerLinkActionViewModelType, Compos initialViewState = .init(linkAction: .createWithText, bindings: simpleBindings) case .create: initialViewState = .init(linkAction: .create, bindings: simpleBindings) + case .disabled: + // Note: Unreachable + initialViewState = .init(linkAction: .disabled, bindings: simpleBindings) } super.init(initialViewState: initialViewState) @@ -74,6 +77,8 @@ final class ComposerLinkActionViewModel: ComposerLinkActionViewModelType, Compos .setLink(urlString: state.bindings.linkUrl) ) ) + case .disabled: + break } } } diff --git a/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift b/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift index 35a628d02..8b5327b14 100644 --- a/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift +++ b/RiotSwiftUI/Modules/Room/Composer/MockComposerScreenState.swift @@ -29,12 +29,22 @@ enum MockComposerScreenState: MockScreenState, CaseIterable { var screenView: ([Any], AnyView) { let viewModel: ComposerViewModel + let userSuggestionViewModel = MockUserSuggestionViewModel(initialViewState: UserSuggestionViewState(items: [])) let bindings = ComposerBindings(focused: false) switch self { - case .send: viewModel = ComposerViewModel(initialViewState: ComposerViewState(textFormattingEnabled: true, isLandscapePhone: false, bindings: bindings)) - case .edit: viewModel = ComposerViewModel(initialViewState: ComposerViewState(sendMode: .edit, textFormattingEnabled: true, isLandscapePhone: false, bindings: bindings)) - case .reply: viewModel = ComposerViewModel(initialViewState: ComposerViewState(eventSenderDisplayName: "TestUser", sendMode: .reply, textFormattingEnabled: true, isLandscapePhone: false, bindings: bindings)) + case .send: viewModel = ComposerViewModel(initialViewState: ComposerViewState(textFormattingEnabled: true, + isLandscapePhone: false, + bindings: bindings)) + case .edit: viewModel = ComposerViewModel(initialViewState: ComposerViewState(sendMode: .edit, + textFormattingEnabled: true, + isLandscapePhone: false, + bindings: bindings)) + case .reply: viewModel = ComposerViewModel(initialViewState: ComposerViewState(eventSenderDisplayName: "TestUser", + sendMode: .reply, + textFormattingEnabled: true, + isLandscapePhone: false, + bindings: bindings)) } let wysiwygviewModel = WysiwygComposerViewModel(minHeight: 20, maxCompressedHeight: 360) @@ -57,6 +67,7 @@ enum MockComposerScreenState: MockScreenState, CaseIterable { Spacer() Composer(viewModel: viewModel.context, wysiwygViewModel: wysiwygviewModel, + userSuggestionSharedContext: userSuggestionViewModel.context, resizeAnimationDuration: 0.1, sendMessageAction: { _ in }, showSendMediaActions: { }) @@ -70,3 +81,7 @@ enum MockComposerScreenState: MockScreenState, CaseIterable { ) } } + +private final class MockUserSuggestionViewModel: UserSuggestionViewModelType { + +} diff --git a/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift b/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift index 98d7febf6..6f7bab165 100644 --- a/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift +++ b/RiotSwiftUI/Modules/Room/Composer/Model/ComposerModels.swift @@ -229,12 +229,14 @@ enum ComposerViewAction: Equatable { case contentDidChange(isEmpty: Bool) case linkTapped(linkAction: LinkAction) case storeSelection(selection: NSRange) + case suggestion(pattern: SuggestionPattern?) } enum ComposerViewModelResult: Equatable { case cancel case contentDidChange(isEmpty: Bool) case linkTapped(LinkAction: LinkAction) + case suggestion(pattern: SuggestionPattern?) } final class LinkActionWrapper: NSObject { @@ -245,3 +247,21 @@ final class LinkActionWrapper: NSObject { super.init() } } + +final class SuggestionPatternWrapper: NSObject { + let suggestionPattern: SuggestionPattern? + + init(_ suggestionPattern: SuggestionPattern?) { + self.suggestionPattern = suggestionPattern + super.init() + } +} + +final class UserSuggestionViewModelWrapper: NSObject { + let userSuggestionViewModel: UserSuggestionViewModel + + init(_ userSuggestionViewModel: UserSuggestionViewModel) { + self.userSuggestionViewModel = userSuggestionViewModel + super.init() + } +} diff --git a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift index e121d6075..278f5e447 100644 --- a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift +++ b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift @@ -23,6 +23,7 @@ struct Composer: View { // MARK: Private @ObservedObject private var viewModel: ComposerViewModelType.Context @ObservedObject private var wysiwygViewModel: WysiwygComposerViewModel + private let userSuggestionSharedContext: UserSuggestionViewModelType.Context private let resizeAnimationDuration: Double private let sendMessageAction: (WysiwygComposerContent) -> Void @@ -31,15 +32,42 @@ struct Composer: View { @Environment(\.theme) private var theme: ThemeSwiftUI @State private var isActionButtonShowing = false - + private let horizontalPadding: CGFloat = 12 private let borderHeight: CGFloat = 40 - private var verticalPadding: CGFloat { + private let standardVerticalPadding: CGFloat = 8.0 + private let contextBannerHeight: CGFloat = 14.5 + + /// Spacing applied within the VStack holding the context banner and the composer text view. + private let verticalComponentSpacing: CGFloat = 12.0 + /// Padding for the main composer text view. Always applied on bottom. + /// Applied on top only if no context banner is present. + private var composerVerticalPadding: CGFloat { (borderHeight - wysiwygViewModel.minHeight) / 2 } - - private var topPadding: CGFloat { - viewModel.viewState.shouldDisplayContext ? 0 : verticalPadding + + /// Computes the top padding to apply on the composer text view depending on context. + private var composerTopPadding: CGFloat { + viewModel.viewState.shouldDisplayContext ? 0 : composerVerticalPadding + } + + /// Computes the additional height required to display the context banner. + /// Returns 0.0 if the banner is not displayed. + /// Note: height of the actual banner + its added standard top padding + VStack spacing + private var additionalHeightForContextBanner: CGFloat { + viewModel.viewState.shouldDisplayContext ? contextBannerHeight + standardVerticalPadding + verticalComponentSpacing : 0 + } + + /// Computes the total height of the composer (excluding the RTE formatting bar). + /// This height includes the text view, as well as the context banner + /// and user suggestion list when displayed. + private var composerHeight: CGFloat { + wysiwygViewModel.idealHeight + + composerTopPadding + + composerVerticalPadding + // Extra padding added on top of the VStack containing the composer + + standardVerticalPadding + + additionalHeightForContextBanner } private var cornerRadius: CGFloat { @@ -84,7 +112,7 @@ struct Composer: View { private var composerContainer: some View { let rect = RoundedRectangle(cornerRadius: cornerRadius) - return VStack(spacing: 12) { + return VStack(spacing: verticalComponentSpacing) { if viewModel.viewState.shouldDisplayContext { HStack { if let imageName = viewModel.viewState.contextImageName { @@ -106,7 +134,8 @@ struct Composer: View { } .accessibilityIdentifier("cancelButton") } - .padding(.top, 8) + .frame(height: contextBannerHeight) + .padding(.top, standardVerticalPadding) .padding(.horizontal, horizontalPadding) } HStack(alignment: shouldFixRoundCorner ? .top : .center, spacing: 0) { @@ -116,7 +145,6 @@ struct Composer: View { ) .tintColor(theme.colors.accent) .placeholder(viewModel.viewState.placeholder, color: theme.colors.tertiaryContent) - .frame(height: wysiwygViewModel.idealHeight) .onAppear { if wysiwygViewModel.isContentEmpty { wysiwygViewModel.setup() @@ -137,13 +165,13 @@ struct Composer: View { } } .padding(.horizontal, horizontalPadding) - .padding(.top, topPadding) - .padding(.bottom, verticalPadding) + .padding(.top, composerTopPadding) + .padding(.bottom, composerVerticalPadding) } .clipShape(rect) .overlay(rect.stroke(borderColor, lineWidth: 1)) .animation(.easeInOut(duration: resizeAnimationDuration), value: wysiwygViewModel.idealHeight) - .padding(.top, 8) + .padding(.top, standardVerticalPadding) .onTapGesture { if viewModel.focused { viewModel.focused = true @@ -195,11 +223,13 @@ struct Composer: View { init( viewModel: ComposerViewModelType.Context, wysiwygViewModel: WysiwygComposerViewModel, + userSuggestionSharedContext: UserSuggestionViewModelType.Context, resizeAnimationDuration: Double, sendMessageAction: @escaping (WysiwygComposerContent) -> Void, showSendMediaActions: @escaping () -> Void) { self.viewModel = viewModel self.wysiwygViewModel = wysiwygViewModel + self.userSuggestionSharedContext = userSuggestionSharedContext self.resizeAnimationDuration = resizeAnimationDuration self.sendMessageAction = sendMessageAction self.showSendMediaActions = showSendMediaActions @@ -213,17 +243,23 @@ struct Composer: View { .frame(width: 36, height: 5) .padding(.top, 10) } - HStack(alignment: .bottom, spacing: 0) { - if !viewModel.viewState.textFormattingEnabled { - sendMediaButton - .padding(.bottom, 1) + VStack { + HStack(alignment: .bottom, spacing: 0) { + if !viewModel.viewState.textFormattingEnabled { + sendMediaButton + .padding(.bottom, 1) + } + composerContainer + if !viewModel.viewState.textFormattingEnabled { + sendButton + .padding(.bottom, 1) + } } - composerContainer - if !viewModel.viewState.textFormattingEnabled { - sendButton - .padding(.bottom, 1) + if wysiwygViewModel.maximised { + UserSuggestionList(viewModel: userSuggestionSharedContext, showBackgroundShadow: false) } } + .frame(height: composerHeight) if viewModel.viewState.textFormattingEnabled { HStack(alignment: .center, spacing: 0) { sendMediaButton @@ -248,6 +284,9 @@ struct Composer: View { wysiwygViewModel.maximised = false } } + .onChange(of: wysiwygViewModel.suggestionPattern) { newValue in + sendMentionPattern(pattern: newValue) + } } private func storeCurrentSelection() { @@ -258,6 +297,10 @@ struct Composer: View { let linkAction = wysiwygViewModel.getLinkAction() viewModel.send(viewAction: .linkTapped(linkAction: linkAction)) } + + private func sendMentionPattern(pattern: SuggestionPattern?) { + viewModel.send(viewAction: .suggestion(pattern: pattern)) + } } private extension WysiwygComposerViewModel { diff --git a/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift b/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift index a78018f60..6448b9de3 100644 --- a/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift +++ b/RiotSwiftUI/Modules/Room/Composer/ViewModel/ComposerViewModel.swift @@ -90,6 +90,8 @@ final class ComposerViewModel: ComposerViewModelType, ComposerViewModelProtocol callback?(.linkTapped(LinkAction: linkAction)) case let .storeSelection(selection): selectionToRestore = selection + case let .suggestion(pattern: pattern): + callback?(.suggestion(pattern: pattern)) } } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift index cc3f208c3..a2156cd89 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinator.swift @@ -18,6 +18,7 @@ import Combine import Foundation import SwiftUI import UIKit +import WysiwygComposer protocol UserSuggestionCoordinatorDelegate: AnyObject { func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) @@ -31,6 +32,15 @@ struct UserSuggestionCoordinatorParameters { let userID: String } +/// Wrapper around `UserSuggestionViewModelType.Context` to pass it through obj-c. +final class UserSuggestionViewModelContextWrapper: NSObject { + let context: UserSuggestionViewModelType.Context + + init(context: UserSuggestionViewModelType.Context) { + self.context = context + } +} + final class UserSuggestionCoordinator: Coordinator, Presentable { // MARK: - Properties @@ -99,6 +109,10 @@ final class UserSuggestionCoordinator: Coordinator, Presentable { userSuggestionService.processTextMessage(textMessage) } + func processSuggestionPattern(_ suggestionPattern: SuggestionPattern?) { + userSuggestionService.processSuggestionPattern(suggestionPattern) + } + // MARK: - Public func start() { } @@ -107,6 +121,10 @@ final class UserSuggestionCoordinator: Coordinator, Presentable { userSuggestionHostingController } + func sharedContext() -> UserSuggestionViewModelContextWrapper { + UserSuggestionViewModelContextWrapper(context: userSuggestionViewModel.sharedContext) + } + // MARK: - Private private func calculateViewHeight() -> CGFloat { diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift index ea8163106..0d1f6795e 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/Coordinator/UserSuggestionCoordinatorBridge.swift @@ -45,10 +45,18 @@ final class UserSuggestionCoordinatorBridge: NSObject { func processTextMessage(_ textMessage: String) { userSuggestionCoordinator.processTextMessage(textMessage) } + + func processSuggestionPattern(_ suggestionPatternWrapper: SuggestionPatternWrapper) { + userSuggestionCoordinator.processSuggestionPattern(suggestionPatternWrapper.suggestionPattern) + } func toPresentable() -> UIViewController? { userSuggestionCoordinator.toPresentable() } + + func sharedContext() -> UserSuggestionViewModelContextWrapper { + userSuggestionCoordinator.sharedContext() + } } extension UserSuggestionCoordinatorBridge: UserSuggestionCoordinatorDelegate { diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift index 2bd8a4569..a790e2845 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionService.swift @@ -16,6 +16,7 @@ import Combine import Foundation +import WysiwygComposer struct RoomMembersProviderMember { var userId: String @@ -91,6 +92,16 @@ class UserSuggestionService: UserSuggestionServiceProtocol { currentTextTriggerSubject.send(lastComponent) } + + func processSuggestionPattern(_ suggestionPattern: SuggestionPattern?) { + guard let suggestionPattern, suggestionPattern.key == .at else { + items.send([]) + currentTextTriggerSubject.send(nil) + return + } + + currentTextTriggerSubject.send("@" + suggestionPattern.text) + } // MARK: - Private diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift index 81edb0df9..43006dbed 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/Service/UserSuggestionServiceProtocol.swift @@ -16,6 +16,7 @@ import Combine import Foundation +import WysiwygComposer protocol UserSuggestionItemProtocol: Avatarable { var userId: String { get } @@ -29,6 +30,7 @@ protocol UserSuggestionServiceProtocol { var currentTextTrigger: String? { get } func processTextMessage(_ textMessage: String?) + func processSuggestionPattern(_ suggestionPattern: SuggestionPattern?) } // MARK: Avatarable diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift index 1e1f490fc..3999447b7 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift @@ -27,7 +27,11 @@ class UserSuggestionViewModel: UserSuggestionViewModelType, UserSuggestionViewMo private let userSuggestionService: UserSuggestionServiceProtocol // MARK: Public - + + var sharedContext: UserSuggestionViewModelType.Context { + return self.context + } + var completion: ((UserSuggestionViewModelResult) -> Void)? // MARK: - Setup diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift index 1d89ca9b4..33aa5bb79 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModelProtocol.swift @@ -17,5 +17,9 @@ import Foundation protocol UserSuggestionViewModelProtocol { + /// Defines a shared context providing the ability to use a single `UserSuggestionViewModel` for multiple + /// `UserSuggestionList` e.g. the list component can then be displayed seemlessly in both `RoomViewController` + /// UIKit hosted context, and in Rich-Text-Editor's SwiftUI fullscreen mode, without need to reload the data. + var sharedContext: UserSuggestionViewModelType.Context { get } var completion: ((UserSuggestionViewModelResult) -> Void)? { get set } } diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionList.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionList.swift index 859b0b414..e509a58b3 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionList.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/View/UserSuggestionList.swift @@ -23,6 +23,15 @@ struct UserSuggestionList: View { static let lineSpacing: CGFloat = 10.0 static let maxHeight: CGFloat = 300.0 static let maxVisibleRows = 4 + + /* + As of iOS 16.0, SwiftUI's List uses `UICollectionView` instead + of `UITableView` internally, this value is an adjustment to apply + to the list items in order to be as close as possible as the + `UITableView` display. + */ + @available (iOS 16.0, *) + static let collectionViewPaddingCorrection: CGFloat = -5.0 } // MARK: - Properties @@ -35,6 +44,7 @@ struct UserSuggestionList: View { // MARK: Public @ObservedObject var viewModel: UserSuggestionViewModel.Context + var showBackgroundShadow: Bool = true var body: some View { if viewModel.viewState.items.isEmpty { @@ -46,25 +56,12 @@ struct UserSuggestionList: View { userId: "Prototype") .background(ViewFrameReader(frame: $prototypeListItemFrame)) .hidden() - BackgroundView { - List(viewModel.viewState.items) { item in - Button { - viewModel.send(viewAction: .selectedItem(item)) - } label: { - UserSuggestionListItem( - avatar: item.avatar, - displayName: item.displayName, - userId: item.id - ) - .padding(.bottom, Constants.listItemPadding) - .padding(.top, viewModel.viewState.items.first?.id == item.id ? Constants.listItemPadding + Constants.topPadding : Constants.listItemPadding) - } + if showBackgroundShadow { + BackgroundView { + list() } - .listStyle(PlainListStyle()) - .frame(height: min(Constants.maxHeight, - min(contentHeightForRowCount(Constants.maxVisibleRows), - contentHeightForRowCount(viewModel.viewState.items.count)))) - .id(UUID()) // Rebuild the whole list on item changes. Fixes performance issues. + } else { + list() } } } @@ -73,6 +70,47 @@ struct UserSuggestionList: View { private func contentHeightForRowCount(_ count: Int) -> CGFloat { (prototypeListItemFrame.height + (Constants.listItemPadding * 2) + Constants.lineSpacing) * CGFloat(count) + Constants.topPadding } + + private func list() -> some View { + List(viewModel.viewState.items) { item in + Button { + viewModel.send(viewAction: .selectedItem(item)) + } label: { + UserSuggestionListItem( + avatar: item.avatar, + displayName: item.displayName, + userId: item.id + ) + .modifier(ListItemPaddingModifier(isFirst: viewModel.viewState.items.first?.id == item.id)) + } + } + .listStyle(PlainListStyle()) + .frame(height: min(Constants.maxHeight, + min(contentHeightForRowCount(Constants.maxVisibleRows), + contentHeightForRowCount(viewModel.viewState.items.count)))) + .id(UUID()) // Rebuild the whole list on item changes. Fixes performance issues. + } + + private struct ListItemPaddingModifier: ViewModifier { + private let isFirst: Bool + + init(isFirst: Bool) { + self.isFirst = isFirst + } + + func body(content: Content) -> some View { + var topPadding: CGFloat = isFirst ? Constants.listItemPadding + Constants.topPadding : Constants.listItemPadding + var bottomPadding: CGFloat = Constants.listItemPadding + if #available(iOS 16.0, *) { + topPadding += Constants.collectionViewPaddingCorrection + bottomPadding += Constants.collectionViewPaddingCorrection + } + + return content + .padding(.top, topPadding) + .padding(.bottom, bottomPadding) + } + } } private struct BackgroundView: View { diff --git a/RiotTests/PillsFormatterTests.swift b/RiotTests/PillsFormatterTests.swift index 573fd234c..a52011776 100644 --- a/RiotTests/PillsFormatterTests.swift +++ b/RiotTests/PillsFormatterTests.swift @@ -29,12 +29,14 @@ private enum Inputs { static let aliceMemberAway = FakeMXRoomMember(displayname: aliceAwayDisplayname, avatarUrl: aliceNewAvatarUrl, userId: "@alice:matrix.org") static let alicePermalink = "https://matrix.to/#/@alice:matrix.org" static let mentionToAlice = NSAttributedString(string: aliceDisplayname, attributes: [.link: URL(string: alicePermalink)!]) - static let markdownLinkToAlice = "[Alice](\(alicePermalink))" + static let markdownLinkToAlice = "[\(aliceDisplayname)](\(alicePermalink))" static let bobUserId = "@bob:matrix.org" static let bobDisplayname = "Bob" static let bobAvatarUrl = "mxc://matrix.org/VyNYBgahazAzUuOeZETtQ" static let bobMember = FakeMXRoomMember(displayname: bobDisplayname, avatarUrl: bobAvatarUrl, userId: bobUserId) + static let bobPermalink = "https://matrix.to/#/@bob:matrix.org" + static let markdownLinkToBob = "[\(bobDisplayname)](\(bobPermalink))" static let anotherUserId = "@another.user:matrix.org" static let anotherUserPermalink = "https://matrix.to/#/@another.user:matrix.org" @@ -310,7 +312,7 @@ class PillsFormatterTests: XCTestCase { case .room(let userId): XCTAssertEqual(userId, Inputs.roomId) switch pillTextAttachmentData.items.first { - case .asset(let assetName, let parameters): + case .asset(let assetName, _): XCTAssertEqual(assetName, "link_icon") default: XCTFail("First pill item should be the asset") @@ -436,7 +438,7 @@ class PillsFormatterTests: XCTestCase { XCTAssertEqual(roomId, Inputs.anotherRoomId) XCTAssertEqual(messageId, Inputs.messageEventId) switch pillTextAttachmentData.items.first { - case .asset(let name, let parameters): + case .asset(let name, _): XCTAssertEqual(name, "link_icon") default: XCTFail("First pill item should be the asset") @@ -445,6 +447,79 @@ class PillsFormatterTests: XCTestCase { XCTFail("Pill should be of type .message") } } + + func testInsertPillInMarkdownString() { + let message = "Hello \(Inputs.markdownLinkToBob)" + let messageWithPills = insertPillsInMarkdownString(message) + XCTAssertTrue(messageWithPills.attribute(.attachment, at: 6, effectiveRange: nil) is PillTextAttachment) + let pillTextAttachment = messageWithPills.attribute(.attachment, at: 6, effectiveRange: nil) as? PillTextAttachment + XCTAssertEqual(pillTextAttachment?.data?.displayText, Inputs.bobDisplayname) + } + + func testInsertMultiplePillsInMarkdownString() { + let message = "Hello \(Inputs.markdownLinkToBob) and \(Inputs.markdownLinkToAlice)" + let messageWithPills = insertPillsInMarkdownString(message) + let bobPillTextAttachment = messageWithPills.attribute(.attachment, at: 6, effectiveRange: nil) as? PillTextAttachment + XCTAssertEqual(bobPillTextAttachment?.data?.displayText, Inputs.bobDisplayname) + + let alicePillTextAttachment = messageWithPills.attribute(.attachment, at: 12, effectiveRange: nil) as? PillTextAttachment + XCTAssertEqual(alicePillTextAttachment?.data?.displayText, Inputs.aliceDisplayname) + // No self highlight + XCTAssert(alicePillTextAttachment?.data?.isHighlighted == false) + } + + func testMarkdownLinkToUnknownUserIsNotPillified() { + let message = "Hello [Unknown user](https://matrix.to/#/@unknown:matrix.org)" + let messageWithPills = insertPillsInMarkdownString(message) + XCTAssertFalse(messageWithPills.attribute(.attachment, at: 6, effectiveRange: nil) is PillTextAttachment) + } + + func testMarkdownSingleLinkDetection() { + let message = NSAttributedString(string: "Hello \(Inputs.markdownLinkToAlice)") + let expected = [ + PillsFormatter.MarkdownLinkResult(url: URL(string: Inputs.alicePermalink)!, + label: Inputs.aliceDisplayname, + range: NSRange(location: 6, length: Inputs.markdownLinkToAlice.count)) + ] + + XCTAssertEqual( + PillsFormatter.markdownLinks(in: message), + expected + ) + } + + func testMarkdownMultipleLinksDetection() { + let message = NSAttributedString(string: "Hello \(Inputs.markdownLinkToAlice) and \(Inputs.markdownLinkToBob)") + let expected = [ + PillsFormatter.MarkdownLinkResult(url: URL(string: Inputs.alicePermalink)!, + label: Inputs.aliceDisplayname, + range: NSRange(location: 6, length: Inputs.markdownLinkToAlice.count)), + PillsFormatter.MarkdownLinkResult(url: URL(string: Inputs.bobPermalink)!, + label: Inputs.bobDisplayname, + range: NSRange(location: 6 + Inputs.markdownLinkToAlice.count + 5, + length: Inputs.markdownLinkToBob.count)) + ] + + XCTAssertEqual( + PillsFormatter.markdownLinks(in: message), + expected + ) + } + + func testBrokenMarkdownLinkIsNotDetected() { + let brokenMarkdownMessages = [ + NSAttributedString(string: "Hello [Alice](https://matrix.to/#/@alice:matrix.org"), + NSAttributedString(string: "Hello [Alice]https://matrix.to/#/@alice:matrix.org)"), + NSAttributedString(string: "Hello [Alice(https://matrix.to/#/@alice:matrix.org)"), + NSAttributedString(string: "Hello Alice](https://matrix.to/#/@alice:matrix.org)"), + NSAttributedString(string: "Hello [Alice]](https://matrix.to/#/@alice:matrix.org)"), + NSAttributedString(string: "Hello (https://matrix.to/#/@alice:matrix.org)"), + ] + + for message in brokenMarkdownMessages { + XCTAssertTrue(PillsFormatter.markdownLinks(in: message).isEmpty) + } + } } @available(iOS 15.0, *) @@ -604,6 +679,15 @@ private extension PillsFormatterTests { return messageWithPills } + private func insertPillsInMarkdownString(_ markdownString: String) -> NSAttributedString { + let message = NSAttributedString(string: markdownString) + let session = FakeMXSession(myUserId: Inputs.aliceUserId) + return PillsFormatter.insertPills(in: message, + withSession: session, + eventFormatter: EventFormatter(matrixSession: session), + roomState: FakeMXRoomState(roomMembers: FakeMXRoomMembers()), + font: UIFont.systemFont(ofSize: 15.0)) + } } // MARK: - Mock objects diff --git a/RiotTests/SessionCreatorTests.swift b/RiotTests/SessionCreatorTests.swift index dbc28c728..688313756 100644 --- a/RiotTests/SessionCreatorTests.swift +++ b/RiotTests/SessionCreatorTests.swift @@ -25,8 +25,9 @@ class SessionCreatorTests: XCTestCase { let mockIS = "mock_identity_server" let credentials = MXCredentials(homeServer: "mock_home_server", - userId: "mock_user_id", + userId: "@mock_user_id:localhost", accessToken: "mock_access_token") + credentials.deviceId = "mock_device_id" let client = MXRestClient(credentials: credentials) client.identityServer = mockIS let session = await sessionCreator.createSession(credentials: credentials, client: client, removeOtherAccounts: false) diff --git a/project.yml b/project.yml index 9d63a33aa..3c93ca513 100644 --- a/project.yml +++ b/project.yml @@ -62,7 +62,7 @@ packages: maxVersion: 3.5.0 WysiwygComposer: url: https://github.com/matrix-org/matrix-wysiwyg-composer-swift - version: 1.1.1 + version: 2.0.0 DeviceKit: url: https://github.com/devicekit/DeviceKit majorVersion: 4.7.0