Merge branch 'release/1.10.11/master'

This commit is contained in:
Doug 2023-04-18 20:11:34 +01:00
commit 7d18f34a75
51 changed files with 1020 additions and 304 deletions

View file

@ -1,3 +1,22 @@
## 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) ## Changes in 1.10.10 (2023-04-12)
🙌 Improvements 🙌 Improvements

View file

@ -15,5 +15,5 @@
// //
// Version // Version
MARKETING_VERSION = 1.10.10 MARKETING_VERSION = 1.10.11
CURRENT_PROJECT_VERSION = 1.10.10 CURRENT_PROJECT_VERSION = 1.10.11

View file

@ -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 # - `{ :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 # Warning: our internal tooling depends on the name of this variable name, so be sure not to change it
$matrixSDKVersion = '= 0.26.7' $matrixSDKVersion = '= 0.26.9'
# $matrixSDKVersion = :local # $matrixSDKVersion = :local
# $matrixSDKVersion = { :branch => 'develop'} # $matrixSDKVersion = { :branch => 'develop'}
# $matrixSDKVersion = { :specHash => { git: 'https://git.io/fork123', branch: 'fix' } } # $matrixSDKVersion = { :specHash => { git: 'https://git.io/fork123', branch: 'fix' } }

View file

@ -39,20 +39,20 @@ PODS:
- LoggerAPI (1.9.200): - LoggerAPI (1.9.200):
- Logging (~> 1.1) - Logging (~> 1.1)
- Logging (1.4.0) - Logging (1.4.0)
- MatrixSDK (0.26.7): - MatrixSDK (0.26.9):
- MatrixSDK/Core (= 0.26.7) - MatrixSDK/Core (= 0.26.9)
- MatrixSDK/Core (0.26.7): - MatrixSDK/Core (0.26.9):
- AFNetworking (~> 4.0.0) - AFNetworking (~> 4.0.0)
- GZIP (~> 1.3.0) - GZIP (~> 1.3.0)
- libbase58 (~> 0.1.4) - libbase58 (~> 0.1.4)
- MatrixSDKCrypto (= 0.3.3) - MatrixSDKCrypto (= 0.3.4)
- OLMKit (~> 3.2.5) - OLMKit (~> 3.2.5)
- Realm (= 10.27.0) - Realm (= 10.27.0)
- SwiftyBeaver (= 1.9.5) - SwiftyBeaver (= 1.9.5)
- MatrixSDK/JingleCallStack (0.26.7): - MatrixSDK/JingleCallStack (0.26.9):
- JitsiMeetSDKLite (= 7.0.1-lite) - JitsiMeetSDKLite (= 7.0.1-lite)
- MatrixSDK/Core - MatrixSDK/Core
- MatrixSDKCrypto (0.3.3) - MatrixSDKCrypto (0.3.4)
- OLMKit (3.2.12): - OLMKit (3.2.12):
- OLMKit/olmc (= 3.2.12) - OLMKit/olmc (= 3.2.12)
- OLMKit/olmcpp (= 3.2.12) - OLMKit/olmcpp (= 3.2.12)
@ -102,8 +102,8 @@ DEPENDENCIES:
- KeychainAccess (~> 4.2.2) - KeychainAccess (~> 4.2.2)
- KTCenterFlowLayout (~> 1.3.1) - KTCenterFlowLayout (~> 1.3.1)
- libPhoneNumber-iOS (~> 0.9.13) - libPhoneNumber-iOS (~> 0.9.13)
- MatrixSDK (= 0.26.7) - MatrixSDK (= 0.26.9)
- MatrixSDK/JingleCallStack (= 0.26.7) - MatrixSDK/JingleCallStack (= 0.26.9)
- OLMKit - OLMKit
- PostHog (~> 2.0.0) - PostHog (~> 2.0.0)
- ReadMoreTextView (~> 3.0.1) - ReadMoreTextView (~> 3.0.1)
@ -187,8 +187,8 @@ SPEC CHECKSUMS:
libPhoneNumber-iOS: 0a32a9525cf8744fe02c5206eb30d571e38f7d75 libPhoneNumber-iOS: 0a32a9525cf8744fe02c5206eb30d571e38f7d75
LoggerAPI: ad9c4a6f1e32f518fdb43a1347ac14d765ab5e3d LoggerAPI: ad9c4a6f1e32f518fdb43a1347ac14d765ab5e3d
Logging: beeb016c9c80cf77042d62e83495816847ef108b Logging: beeb016c9c80cf77042d62e83495816847ef108b
MatrixSDK: 1de7cd06bef00fabf5693eabcdcdbf2aa1978063 MatrixSDK: 2f6222978156818cf4c6ba590762ade601ba72f9
MatrixSDKCrypto: 427dbb126a3e3f97cadf9fc407abf17d365b4b39 MatrixSDKCrypto: ac805c22c24f79f349cdbfa065855c73a4c81b51
OLMKit: da115f16582e47626616874e20f7bb92222c7a51 OLMKit: da115f16582e47626616874e20f7bb92222c7a51
PostHog: 660ec6c9d80cec17b685e148f17f6785a88b597d PostHog: 660ec6c9d80cec17b685e148f17f6785a88b597d
ReadMoreTextView: 19147adf93abce6d7271e14031a00303fe28720d ReadMoreTextView: 19147adf93abce6d7271e14031a00303fe28720d
@ -208,6 +208,6 @@ SPEC CHECKSUMS:
zxcvbn-ios: fef98b7c80f1512ff0eec47ac1fa399fc00f7e3c zxcvbn-ios: fef98b7c80f1512ff0eec47ac1fa399fc00f7e3c
ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb
PODFILE CHECKSUM: c063d05ddb39617ab9a259c4c9c6b57da2e6d8b6 PODFILE CHECKSUM: a55fb48d3bef5f5e24fcaf8c39d1eae1ed8c1603
COCOAPODS: 1.11.3 COCOAPODS: 1.11.3

View file

@ -50,8 +50,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/matrix-org/matrix-wysiwyg-composer-swift", "location" : "https://github.com/matrix-org/matrix-wysiwyg-composer-swift",
"state" : { "state" : {
"revision" : "addf90f3e2a6ab46bd2b2febe117d9cddb646e7d", "revision" : "758f226a92d6726ab626c1e78ecd183bdba77016",
"version" : "1.1.1" "version" : "2.0.0"
} }
}, },
{ {

View file

@ -2742,3 +2742,15 @@
// MARK: - Launch loading // MARK: - Launch loading
"launch_loading_generic" = "Synchronisiere deine Unterhaltungen"; "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";

View file

@ -2680,3 +2680,15 @@
// MARK: - Launch loading // MARK: - Launch loading
"launch_loading_generic" = "Sinu vestlused on sünkroniseerimisel"; "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";

View file

@ -2654,7 +2654,6 @@
"voice_broadcast_stop_alert_title" = "Megszakítod az élő közvetítést?"; "voice_broadcast_stop_alert_title" = "Megszakítod az élő közvetítést?";
"voice_broadcast_buffering" = "Pufferelés…"; "voice_broadcast_buffering" = "Pufferelés…";
"voice_broadcast_time_left" = "%@ van vissza"; "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_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."; "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_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_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"; "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_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_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"; "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_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"; "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ő"; "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";

View file

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

View file

@ -2708,3 +2708,15 @@
// MARK: - Launch loading // MARK: - Launch loading
"launch_loading_generic" = "Sincronizzazione delle tue conversazioni"; "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";

View file

@ -239,7 +239,7 @@
"settings_ignored_users" = "ИГНОРИРУЕМЫЕ ПОЛЬЗОВАТЕЛИ"; "settings_ignored_users" = "ИГНОРИРУЕМЫЕ ПОЛЬЗОВАТЕЛИ";
"settings_contacts" = "КОНТАКТЫ УСТРОЙСТВА"; "settings_contacts" = "КОНТАКТЫ УСТРОЙСТВА";
"settings_advanced" = "ДОПОЛНИТЕЛЬНО"; "settings_advanced" = "ДОПОЛНИТЕЛЬНО";
"settings_other" = "ДРУГИЕ"; "settings_other" = "Другие";
"settings_labs" = "ЛАБОРАТОРИЯ"; "settings_labs" = "ЛАБОРАТОРИЯ";
"settings_devices" = "СЕАНСЫ"; "settings_devices" = "СЕАНСЫ";
"settings_cryptography" = "КРИПТОГРАФИЯ"; "settings_cryptography" = "КРИПТОГРАФИЯ";
@ -272,11 +272,11 @@
"settings_send_crash_report" = "Отправка данных о сбоях и использовании"; "settings_send_crash_report" = "Отправка данных о сбоях и использовании";
"settings_clear_cache" = "Очистить кэш"; "settings_clear_cache" = "Очистить кэш";
"settings_change_password" = "Изменить пароль"; "settings_change_password" = "Изменить пароль";
"settings_old_password" = "старый пароль"; "settings_old_password" = "Старый пароль";
"settings_new_password" = "новый пароль"; "settings_new_password" = "Новый пароль";
"settings_confirm_password" = "подтвердить пароль"; "settings_confirm_password" = "Подтвердить пароль";
"settings_fail_to_update_password" = "Не удалось обновить пароль"; "settings_fail_to_update_password" = "Не удалось обновить пароль аккаунта Matrix";
"settings_password_updated" = "Ваш пароль был обновлен"; "settings_password_updated" = "Ваш пароль аккаунта Matrix был обновлен";
"settings_crypto_device_name" = "Имя сеанса: "; "settings_crypto_device_name" = "Имя сеанса: ";
"settings_crypto_device_id" = "\nID сеанса: "; "settings_crypto_device_id" = "\nID сеанса: ";
"settings_crypto_device_key" = "\nКлюч сеанса:\n"; "settings_crypto_device_key" = "\nКлюч сеанса:\n";
@ -512,7 +512,7 @@
"room_action_send_photo_or_video" = "Отправить фото или видео"; "room_action_send_photo_or_video" = "Отправить фото или видео";
"room_action_send_sticker" = "Отправить стикер"; "room_action_send_sticker" = "Отправить стикер";
"settings_deactivate_account" = "ДЕАКТИВАЦИЯ АККАУНТА"; "settings_deactivate_account" = "ДЕАКТИВАЦИЯ АККАУНТА";
"settings_deactivate_my_account" = "Деактивировать мой аккаунт"; "settings_deactivate_my_account" = "Деактивировать аккаунт навсегда";
"widget_sticker_picker_no_stickerpacks_alert_add_now" = "Добавить сейчас?"; "widget_sticker_picker_no_stickerpacks_alert_add_now" = "Добавить сейчас?";
"deactivate_account_title" = "Деактивировать аккаунт"; "deactivate_account_title" = "Деактивировать аккаунт";
"deactivate_account_informations_part1" = "Это действие сделает вашу учетную запись непригодной для дальнейшего использования. Вы не сможете войти в систему и никто другой не сможет заново зарегистрировать учетную запись с вашим идентификатором. Также, это приведет к тому, что вы покинете все комнаты, в которых участвовали и данные о вашей учетной записи будут удалены с сервера идентификации. "; "deactivate_account_informations_part1" = "Это действие сделает вашу учетную запись непригодной для дальнейшего использования. Вы не сможете войти в систему и никто другой не сможет заново зарегистрировать учетную запись с вашим идентификатором. Также, это приведет к тому, что вы покинете все комнаты, в которых участвовали и данные о вашей учетной записи будут удалены с сервера идентификации. ";
@ -525,7 +525,7 @@
"deactivate_account_forget_messages_information_part3" = ": будущие участники увидят неполное представление разговоров)"; "deactivate_account_forget_messages_information_part3" = ": будущие участники увидят неполное представление разговоров)";
"deactivate_account_validate_action" = "Деактивировать аккаунт"; "deactivate_account_validate_action" = "Деактивировать аккаунт";
"deactivate_account_password_alert_title" = "Деактивировать аккаунт"; "deactivate_account_password_alert_title" = "Деактивировать аккаунт";
"deactivate_account_password_alert_message" = "Чтобы продолжить, введите пароль"; "deactivate_account_password_alert_message" = "Чтобы продолжить, введите пароль аккаунта Matrix";
"widget_sticker_picker_no_stickerpacks_alert" = "У вас пока нет включенных пакетов стикеров."; "widget_sticker_picker_no_stickerpacks_alert" = "У вас пока нет включенных пакетов стикеров.";
"event_formatter_rerequest_keys_part1_link" = "Повторно запросить ключи шифрования"; "event_formatter_rerequest_keys_part1_link" = "Повторно запросить ключи шифрования";
"event_formatter_rerequest_keys_part2" = " из других ваших сеансов."; "event_formatter_rerequest_keys_part2" = " из других ваших сеансов.";
@ -583,7 +583,7 @@
"key_backup_setup_intro_title" = "Никогда не теряйте зашифрованных сообщений"; "key_backup_setup_intro_title" = "Никогда не теряйте зашифрованных сообщений";
"key_backup_setup_intro_info" = "Сообщения в зашифрованных комнатах защищены сквозным шифрованием. Только вы и получатель(и) имеют ключи для чтения этих сообщений.\n\nСохраните ключи надежно, чтобы не потерять их."; "key_backup_setup_intro_info" = "Сообщения в зашифрованных комнатах защищены сквозным шифрованием. Только вы и получатель(и) имеют ключи для чтения этих сообщений.\n\nСохраните ключи надежно, чтобы не потерять их.";
"key_backup_setup_intro_setup_action" = "Настроить"; "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_title" = "Ввод";
"key_backup_setup_passphrase_passphrase_placeholder" = "Введите секретную фразу"; "key_backup_setup_passphrase_passphrase_placeholder" = "Введите секретную фразу";
"key_backup_setup_passphrase_passphrase_valid" = "Отлично!"; "key_backup_setup_passphrase_passphrase_valid" = "Отлично!";
@ -864,7 +864,7 @@
"identity_server_settings_alert_error_invalid_identity_server" = "%@ не является действительным сервером идентификации."; "identity_server_settings_alert_error_invalid_identity_server" = "%@ не является действительным сервером идентификации.";
"settings_add_3pid_password_title_email" = "Добавить адрес электронной почты"; "settings_add_3pid_password_title_email" = "Добавить адрес электронной почты";
"settings_add_3pid_password_title_msidsn" = "Добавить номер телефона"; "settings_add_3pid_password_title_msidsn" = "Добавить номер телефона";
"settings_add_3pid_password_message" = "Для продолжения, задайте пароль"; "settings_add_3pid_password_message" = "Для продолжения, введите пароль аккаунта Matrix";
"settings_add_3pid_invalid_password_message" = "Недействительные данные"; "settings_add_3pid_invalid_password_message" = "Недействительные данные";
"settings_discovery_three_pid_details_title_phone_number" = "Управление номера телефона"; "settings_discovery_three_pid_details_title_phone_number" = "Управление номера телефона";
"settings_identity_server_no_is" = "Сервер идентификации не настроен"; "settings_identity_server_no_is" = "Сервер идентификации не настроен";
@ -915,7 +915,7 @@
"security_settings_title" = "Безопасность"; "security_settings_title" = "Безопасность";
"security_settings_crypto_sessions" = "МОИ СЕАНСЫ"; "security_settings_crypto_sessions" = "МОИ СЕАНСЫ";
"security_settings_crypto_sessions_loading" = "Загрузка сеансов…"; "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" = "БЕЗОПАСНОЕ РЕЗЕРВНОЕ КОПИРОВАНИЕ";
"security_settings_secure_backup_description" = "Сделайте резервную копию ключей шифрования с данными вашей учетной записи на случай, если вы потеряете доступ к своим сеансам. Ваши ключи будут защищены уникальным электронным ключом."; "security_settings_secure_backup_description" = "Сделайте резервную копию ключей шифрования с данными вашей учетной записи на случай, если вы потеряете доступ к своим сеансам. Ваши ключи будут защищены уникальным электронным ключом.";
"security_settings_secure_backup_setup" = "Настроить"; "security_settings_secure_backup_setup" = "Настроить";
@ -938,7 +938,7 @@
"security_settings_complete_security_alert_title" = "Завершите настройку безопасности"; "security_settings_complete_security_alert_title" = "Завершите настройку безопасности";
"security_settings_complete_security_alert_message" = "Сначала вы должны завершить настройку безопасности текущего сеанса."; "security_settings_complete_security_alert_message" = "Сначала вы должны завершить настройку безопасности текущего сеанса.";
"security_settings_coming_soon" = "Извините. Это действие пока недоступно в %@ iOS. Пожалуйста, используйте другой клиент Matrix для его настройки. %@ iOS будет его использовать."; "security_settings_coming_soon" = "Извините. Это действие пока недоступно в %@ iOS. Пожалуйста, используйте другой клиент Matrix для его настройки. %@ iOS будет его использовать.";
"security_settings_user_password_description" = "Подтвердите свою личность, введя пароль учетной записи"; "security_settings_user_password_description" = "Подтвердите свою личность, введя пароль учётной записи Matrix";
// Manage session // Manage session
"manage_session_title" = "Управление сеансами"; "manage_session_title" = "Управление сеансами";
"manage_session_info" = "ИНФОРМАЦИЯ О СЕАНСЕ"; "manage_session_info" = "ИНФОРМАЦИЯ О СЕАНСЕ";
@ -1130,7 +1130,7 @@
"secrets_setup_recovery_key_storage_alert_message" = "✓ Распечатайте и храните в безопасном месте\n✓ Сохраните его на USB-носителе или резервном носителе\n✓ Скопируйте его в свое личное облачное хранилище"; "secrets_setup_recovery_key_storage_alert_message" = "✓ Распечатайте и храните в безопасном месте\n✓ Сохраните его на USB-носителе или резервном носителе\n✓ Скопируйте его в свое личное облачное хранилище";
"secrets_setup_recovery_passphrase_title" = "Задайте мнемоническую фразу"; "secrets_setup_recovery_passphrase_title" = "Задайте мнемоническую фразу";
"secrets_setup_recovery_passphrase_information" = "Введите секретную фразу, известную только вам, для защиты данных на вашем сервере."; "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_validate_action" = "Готово";
"secrets_setup_recovery_passphrase_confirm_information" = "Введите мнемоническую фразу ещё раз, чтобы подтвердить её."; "secrets_setup_recovery_passphrase_confirm_information" = "Введите мнемоническую фразу ещё раз, чтобы подтвердить её.";
"secrets_setup_recovery_passphrase_confirm_passphrase_title" = "Подтвердить"; "secrets_setup_recovery_passphrase_confirm_passphrase_title" = "Подтвердить";
@ -1183,7 +1183,7 @@
"searchable_directory_x_network" = "%@ Сеть"; "searchable_directory_x_network" = "%@ Сеть";
"searchable_directory_search_placeholder" = "Имя или ID"; "searchable_directory_search_placeholder" = "Имя или ID";
"create_room_title" = "Новая комната"; "create_room_title" = "Новая комната";
"create_room_section_header_name" = "Имя комнаты"; "create_room_section_header_name" = "НАЗВАНИЕ";
"create_room_placeholder_name" = "Имя"; "create_room_placeholder_name" = "Имя";
"create_room_section_header_topic" = "Тема комнаты (опционально)"; "create_room_section_header_topic" = "Тема комнаты (опционально)";
"create_room_placeholder_topic" = "Тема"; "create_room_placeholder_topic" = "Тема";
@ -1218,7 +1218,7 @@
"room_details_advanced_e2e_encryption_enabled_for_dm" = "Шифрование включено"; "room_details_advanced_e2e_encryption_enabled_for_dm" = "Шифрование включено";
"room_details_advanced_e2e_encryption_disabled_for_dm" = "Шифрование не включено."; "room_details_advanced_e2e_encryption_disabled_for_dm" = "Шифрование не включено.";
"pin_protection_kick_user_alert_message" = "Слишком много ошибок, вы вышли из системы"; "pin_protection_kick_user_alert_message" = "Слишком много ошибок, вы вышли из системы";
"secrets_reset_authentication_message" = "Введите пароль своей учётной записи для подтверждения"; "secrets_reset_authentication_message" = "Введите пароль своей учётной записи Matrix для подтверждения";
"secrets_reset_reset_action" = "Сброс"; "secrets_reset_reset_action" = "Сброс";
"secrets_reset_warning_message" = "Вы перезапустите приложение без истории, сообщений, доверенных устройств или доверенных пользователей."; "secrets_reset_warning_message" = "Вы перезапустите приложение без истории, сообщений, доверенных устройств или доверенных пользователей.";
"secrets_reset_warning_title" = "Если сбросить все"; "secrets_reset_warning_title" = "Если сбросить все";
@ -2224,3 +2224,40 @@
"threads_notice_information" = "Все потоки созданные во время экспериментального периода теперь <b>отображаются как обычные ответы</b>.<br/><br/>Это разовый переход, так как потоки теперь часть спецификации Matrix."; "threads_notice_information" = "Все потоки созданные во время экспериментального периода теперь <b>отображаются как обычные ответы</b>.<br/><br/>Это разовый переход, так как потоки теперь часть спецификации Matrix.";
"authentication_qr_login_failure_device_not_supported" = "Связь с этим устройством не поддерживается."; "authentication_qr_login_failure_device_not_supported" = "Связь с этим устройством не поддерживается.";
"accessibility_selected" = "выбранный"; "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" = "Мы <b>не</b> передаем информацию третьим лицам";
/* Note: The word "don't" is formatted in bold */
"analytics_prompt_point_1" = "Мы <b>не</b> записываем и не профилируем никакие данные учётной записи";
"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" = "Имейте ввиду, что эта функция все ещё на экспериментальной стадии, поэтому она может работать не так, как ожидается, и потенциально может иметь непредвиденные последствия. Для отмены функции выйдите из системы и войдите снова. Используйте её по своему усмотрению и с осторожностью.";

View file

@ -2931,3 +2931,15 @@
// MARK: - Launch loading // MARK: - Launch loading
"launch_loading_generic" = "Synchronizácia vašich konverzácií"; "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";

View file

@ -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_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"; "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ë"; "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";

View file

@ -2933,3 +2933,15 @@
// MARK: - Launch loading // MARK: - Launch loading
"launch_loading_generic" = "Синхронізація ваших розмов"; "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" = "Політика прийнятного користування";

View file

@ -1,8 +1,8 @@
// Permissions usage explanations // Permissions usage explanations
"NSCameraUsageDescription" = "給予相機權限會用來進行視訊通話或是拍攝並上傳照片與影片。"; "NSCameraUsageDescription" = "給予相機權限會用來進行視訊通話或是拍攝並上傳照片與影片。";
"NSPhotoLibraryUsageDescription" = "同意使用圖片的權限會用來上傳您圖庫的照片與影片。"; "NSPhotoLibraryUsageDescription" = "請允許存取「照片」,來上傳圖庫當中的照片或影片。";
"NSMicrophoneUsageDescription" = "Element 需要麥克風的權限來接受通話、拍攝影片以及錄製語音訊息。"; "NSMicrophoneUsageDescription" = "Element 需要麥克風的權限來通話、拍攝影片以及錄製語音訊息。";
"NSContactsUsageDescription" = "他們會與您的身分伺服器共享以找到您在Matrix上的聯絡人。"; "NSContactsUsageDescription" = "會將此資訊分享給您的身分伺服器,以幫助您尋找 Matrix 聯絡人。";
"NSLocationAlwaysAndWhenInUseUsageDescription" = "當您與其他人分享您的位置Element 需要權限將位置顯示在地圖上。"; "NSLocationAlwaysAndWhenInUseUsageDescription" = "當您與其他人分享您的位置Element 需要權限將位置顯示在地圖上。";
"NSLocationWhenInUseUsageDescription" = "當您與其他人分享您的位置Element 需要權限將位置顯示在地圖上。"; "NSLocationWhenInUseUsageDescription" = "當您與其他人分享您的位置Element 需要權限將位置顯示在地圖上。";
"NSFaceIDUsageDescription" = "您可以使用 Face ID 來登入您的應用程式。"; "NSFaceIDUsageDescription" = "您可以使用 Face ID 來登入您的應用程式。";

View file

@ -75,15 +75,15 @@
"USER_MEMBERSHIP_UPDATED" = "%@ 更新了個人資料"; "USER_MEMBERSHIP_UPDATED" = "%@ 更新了個人資料";
/* A user has change their name to a new name which we don't know */ /* 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 **/ /** Membership Updates **/
/* A user has change their name to a new name */ /* A user has change their name to a new name */
"USER_UPDATED_DISPLAYNAME" = "%@ 名稱變更為 %@"; "USER_UPDATED_DISPLAYNAME" = "%@ 名稱變更為 %@";
/* A user has change their avatar */ /* 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 */ /* A user has reacted to a message, but the reaction content is unknown */
"GENERIC_REACTION_FROM_USER" = "%@ 送出了一個反應"; "GENERIC_REACTION_FROM_USER" = "%@ 送出了一個反應";

View file

@ -98,7 +98,7 @@
"directory_title" = "目錄"; "directory_title" = "目錄";
"auth_recaptcha_message" = "這個家伺服器想要確認您不是機器人"; "auth_recaptcha_message" = "這個家伺服器想要確認您不是機器人";
"auth_reset_password_missing_email" = "必須輸入和您帳號綁定的電子郵件地址。"; "auth_reset_password_missing_email" = "必須輸入和您帳號綁定的電子郵件地址。";
"auth_reset_password_missing_password" = "必須輸入一個新密碼。"; "auth_reset_password_missing_password" = "必須輸入新密碼。";
"auth_reset_password_next_step_button" = "我已經驗證了電子郵件地址"; "auth_reset_password_next_step_button" = "我已經驗證了電子郵件地址";
"auth_reset_password_error_unauthorized" = "電子郵件地址驗證失敗:請確認您已點擊郵件中的連結"; "auth_reset_password_error_unauthorized" = "電子郵件地址驗證失敗:請確認您已點擊郵件中的連結";
"auth_reset_password_error_not_found" = "您的電子郵件地址似乎並未與這台家伺服器上的任何 Matrix ID 相關聯。"; "auth_reset_password_error_not_found" = "您的電子郵件地址似乎並未與這台家伺服器上的任何 Matrix ID 相關聯。";
@ -384,9 +384,9 @@
"room_details_photo" = "聊天室圖片"; "room_details_photo" = "聊天室圖片";
"room_details_room_name" = "聊天室名稱"; "room_details_room_name" = "聊天室名稱";
"room_details_mute_notifs" = "將通知靜音"; "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_access_section_directory_toggle" = "將此聊天室列入聊天室目錄";
"room_details_history_section_prompt_msg" = "對可閱讀歷史訊息的使用者的變更,僅適用於此聊天室的新訊息。現有訊息的顯示狀態將保持不變。"; "room_details_history_section_prompt_msg" = "對可閱讀訊息紀錄的使用者的變更,僅適用於此聊天室的新訊息。現有訊息的顯示狀態將保持不變。";
"room_details_new_address" = "新增位址"; "room_details_new_address" = "新增位址";
"room_details_new_address_placeholder" = "新增位址(例如 #foo%@"; "room_details_new_address_placeholder" = "新增位址(例如 #foo%@";
"room_details_addresses_invalid_address_prompt_msg" = "%@ 不是有效的別名格式"; "room_details_addresses_invalid_address_prompt_msg" = "%@ 不是有效的別名格式";
@ -454,7 +454,7 @@
"directory_server_type_homeserver" = "輸入一個家伺服器來列出所有公開聊天室"; "directory_server_type_homeserver" = "輸入一個家伺服器來列出所有公開聊天室";
"directory_server_placeholder" = "matrix.org"; "directory_server_placeholder" = "matrix.org";
// Events formatter // Events formatter
"event_formatter_member_updates" = "變更 %tu 成員身分"; "event_formatter_member_updates" = "%tu 筆成員狀態變更";
"event_formatter_widget_added" = "%@ 小工具已由 %@ 新增"; "event_formatter_widget_added" = "%@ 小工具已由 %@ 新增";
"event_formatter_widget_removed" = "%@ 小工具已由 %@ 移除"; "event_formatter_widget_removed" = "%@ 小工具已由 %@ 移除";
"event_formatter_jitsi_widget_added" = "VoIP 群組通話已由 %@ 新增"; "event_formatter_jitsi_widget_added" = "VoIP 群組通話已由 %@ 新增";
@ -526,7 +526,7 @@
"room_message_reply_to_placeholder" = "傳送回覆(未加密)…"; "room_message_reply_to_placeholder" = "傳送回覆(未加密)…";
"encrypted_room_message_reply_to_placeholder" = "傳送加密的回覆…"; "encrypted_room_message_reply_to_placeholder" = "傳送加密的回覆…";
"room_message_reply_to_short_placeholder" = "傳送回覆…"; "room_message_reply_to_short_placeholder" = "傳送回覆…";
"room_event_action_view_decrypted_source" = "檢視已解密的來源"; "room_event_action_view_decrypted_source" = "檢視解密的原始碼";
"room_predecessor_link" = "點擊此處以檢視更早以前的訊息。"; "room_predecessor_link" = "點擊此處以檢視更早以前的訊息。";
"room_replacement_information" = "這個聊天室已被取代,且不再使用。"; "room_replacement_information" = "這個聊天室已被取代,且不再使用。";
"room_replacement_link" = "對話在此繼續。"; "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 在所有裝置與網路取得完全同步的訊息記錄來保持聯繫。"; "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 // String for App Store
"store_short_description" = "去中心化的安全通訊VoIP 軟體"; "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_message" = "此連結 %@ 將帶您到另一網頁:%@\n\n確定要前往嗎";
"external_link_confirmation_title" = "請確認此連結"; "external_link_confirmation_title" = "請確認此連結";
"media_type_accessibility_sticker" = "貼圖"; "media_type_accessibility_sticker" = "貼圖";
@ -826,7 +826,7 @@
"event_formatter_call_answer" = "接聽"; "event_formatter_call_answer" = "接聽";
"event_formatter_call_back" = "回撥"; "event_formatter_call_back" = "回撥";
"event_formatter_call_has_ended" = "通話結束"; "event_formatter_call_has_ended" = "通話結束";
"event_formatter_call_connecting" = "正在連接…"; "event_formatter_call_connecting" = "連線中…";
"event_formatter_message_edited_mention" = "(已編輯)"; "event_formatter_message_edited_mention" = "(已編輯)";
"image_picker_action_library" = "從媒體庫挑選"; "image_picker_action_library" = "從媒體庫挑選";
@ -881,14 +881,14 @@
"settings_messages_containing_keywords" = "關鍵字"; "settings_messages_containing_keywords" = "關鍵字";
"settings_messages_containing_user_name" = "我的使用者名稱"; "settings_messages_containing_user_name" = "我的使用者名稱";
"settings_messages_containing_display_name" = "我的顯示名稱"; "settings_messages_containing_display_name" = "我的顯示名稱";
"settings_encrypted_group_messages" = "加密的群組訊息"; "settings_encrypted_group_messages" = "加密的群組訊息";
"settings_group_messages" = "群組訊息"; "settings_group_messages" = "群組訊息";
"settings_encrypted_direct_messages" = "加密的私人訊息"; "settings_encrypted_direct_messages" = "加密的私人訊息";
"settings_direct_messages" = "私人訊息"; "settings_direct_messages" = "私人訊息";
"settings_default" = "預設通知"; "settings_default" = "預設通知";
"settings_notifications_disabled_alert_title" = "已停用通知"; "settings_notifications_disabled_alert_title" = "已停用通知";
"settings_device_notifications" = "裝置通知"; "settings_device_notifications" = "裝置通知";
"settings_three_pids_management_information_part3" = "。"; "settings_three_pids_management_information_part3" = "管理您用作登入或恢復帳號的電子郵件地址或電話號碼。您也可控制誰可以用這些資料找到您。";
"room_join_group_call" = "加入"; "room_join_group_call" = "加入";
"room_place_voice_call" = "語音通話"; "room_place_voice_call" = "語音通話";
"room_accessibility_video_call" = "視訊通話"; "room_accessibility_video_call" = "視訊通話";
@ -924,7 +924,7 @@
"room_event_encryption_info_event" = "事件資訊\n"; "room_event_encryption_info_event" = "事件資訊\n";
"room_event_encryption_info_event_user_id" = "使用者 ID\n"; "room_event_encryption_info_event_user_id" = "使用者 ID\n";
"room_event_encryption_info_event_identity_key" = "Curve25519 身分認證金鑰\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_algorithm" = "演算法\n";
"room_event_encryption_info_event_session_id" = "工作階段 ID\n"; "room_event_encryption_info_event_session_id" = "工作階段 ID\n";
"room_event_encryption_info_event_decryption_error" = "解密錯誤\n"; "room_event_encryption_info_event_decryption_error" = "解密錯誤\n";
@ -993,14 +993,14 @@
"notice_room_ban" = "%@ 已封鎖 %@"; "notice_room_ban" = "%@ 已封鎖 %@";
"notice_room_withdraw" = "%@ 已撤回 %@ 的邀請"; "notice_room_withdraw" = "%@ 已撤回 %@ 的邀請";
"notice_room_reason" = ",原因:%@"; "notice_room_reason" = ",原因:%@";
"notice_avatar_url_changed" = "%@ 變更大頭照"; "notice_avatar_url_changed" = "%@ 變更大頭照";
"notice_display_name_set" = "%@ 他們的顯示名稱設定為 %@"; "notice_display_name_set" = "%@ 將顯示名稱設定為 %@";
"notice_display_name_changed_from" = "%@ 將自己的顯示名稱從 %@ 改為 %@"; "notice_display_name_changed_from" = "%@ 將顯示名稱從 %@ 改為 %@";
"notice_display_name_removed" = "%@ 已移除他們的顯示名稱"; "notice_display_name_removed" = "%@ 移除了顯示名稱";
"notice_topic_changed" = "%@ 已經將主題變更為:%@。"; "notice_topic_changed" = "%@ 將主題變更為「%@」。";
"notice_room_name_changed" = "%@ 將聊天室名稱變更為 %@。"; "notice_room_name_changed" = "%@ 將聊天室名稱變更為 %@。";
"notice_placed_voice_call" = "%@ 已出語音通話"; "notice_placed_voice_call" = "%@ 已出語音通話";
"notice_placed_video_call" = "%@ 已出視訊通話"; "notice_placed_video_call" = "%@ 已出視訊通話";
"notice_answered_video_call" = "%@ 已接聽通話"; "notice_answered_video_call" = "%@ 已接聽通話";
"notice_ended_video_call" = "%@ 已結束通話"; "notice_ended_video_call" = "%@ 已結束通話";
"notice_conference_call_request" = "%@ 已請求 VoIP 會議"; "notice_conference_call_request" = "%@ 已請求 VoIP 會議";
@ -1098,7 +1098,7 @@
"notice_room_topic_removed" = "%@ 移除了該主題"; "notice_room_topic_removed" = "%@ 移除了該主題";
"notice_event_redacted_by" = " 由 %@"; "notice_event_redacted_by" = " 由 %@";
"notice_event_redacted_reason" = " [理由:%@]"; "notice_event_redacted_reason" = " [理由:%@]";
"notice_profile_change_redacted" = "%@ 已更新他的個人檔案 %@"; "notice_profile_change_redacted" = "%@ 更新了個人檔案 %@";
"notice_room_created" = "%@ 已建立並設定該聊天室。"; "notice_room_created" = "%@ 已建立並設定該聊天室。";
"notice_room_join_rule" = "加入規則: %@"; "notice_room_join_rule" = "加入規則: %@";
"notice_room_power_level_intro" = "聊天室成員們的權限级别是:"; "notice_room_power_level_intro" = "聊天室成員們的權限级别是:";
@ -1138,7 +1138,7 @@
"notice_error_unexpected_event" = "意外事件"; "notice_error_unexpected_event" = "意外事件";
"notice_error_unknown_event_type" = "未知的事件類型"; "notice_error_unknown_event_type" = "未知的事件類型";
"notice_room_history_visible_to_anyone" = "%@ 讓任何人都能看到未來的聊天室歷史記錄。"; "notice_room_history_visible_to_anyone" = "%@ 讓任何人都能看到未來的聊天室歷史記錄。";
"notice_room_history_visible_to_members" = "%@ 讓所有聊天室成員都能看到聊天室之後的歷史記錄。"; "notice_room_history_visible_to_members" = "%@ 讓所有成員都能看到聊天室之後的歷史記錄。";
"stop" = "停止"; "stop" = "停止";
"joining" = "正在加入"; "joining" = "正在加入";
"enable" = "啟用"; "enable" = "啟用";
@ -1291,18 +1291,18 @@
// Settings keys // Settings keys
// call string // call string
"call_connecting" = "正在連接…"; "call_connecting" = "連線中…";
"notification_settings_notify_all_other" = "其他訊息/聊天室的通知"; "notification_settings_notify_all_other" = "其他訊息/聊天室的通知";
"notification_settings_by_default" = "按預設…"; "notification_settings_by_default" = "按預設…";
"notification_settings_suppress_from_bots" = "限制來自機器人的通知"; "notification_settings_suppress_from_bots" = "限制來自機器人的通知";
"notification_settings_receive_a_call" = "當我收到通話時,請通知我"; "notification_settings_receive_a_call" = "當我收到通話時,請通知我";
"notification_settings_people_join_leave_rooms" = "有人加入或離開聊天室時,請通知我"; "notification_settings_people_join_leave_rooms" = "有人加入或離開聊天室時,請通知我";
"notification_settings_invite_to_a_new_room" = "當我被邀請到一個全新的聊天室時,請通知我"; "notification_settings_invite_to_a_new_room" = "當我被邀請到全新的聊天室時,請通知我";
"notification_settings_just_sent_to_me" = "當收到只寄給我的訊息時,請用音通知我"; "notification_settings_just_sent_to_me" = "當收到只寄給我的訊息時,請用音通知我";
"notification_settings_contain_my_display_name" = "訊息出現我的顯示名稱時,請用音通知我"; "notification_settings_contain_my_display_name" = "訊息出現我的顯示名稱時,請用音通知我";
"notification_settings_contain_my_user_name" = "訊息出現我的使用者名稱時,請用音通知我"; "notification_settings_contain_my_user_name" = "訊息出現我的使用者名稱時,請用音通知我";
"notification_settings_other_alerts" = "其他警告"; "notification_settings_other_alerts" = "其他警告";
"notification_settings_select_room" = "選擇一個聊天室"; "notification_settings_select_room" = "選擇聊天室";
"notification_settings_sender_hint" = "@user:domain.com"; "notification_settings_sender_hint" = "@user:domain.com";
"notification_settings_per_sender_notifications" = "寄件人通知"; "notification_settings_per_sender_notifications" = "寄件人通知";
"notification_settings_per_room_notifications" = "聊天室的通知"; "notification_settings_per_room_notifications" = "聊天室的通知";
@ -1346,11 +1346,11 @@
"login_error_already_logged_in" = "已經登入"; "login_error_already_logged_in" = "已經登入";
"message_unsaved_changes" = "還有變更未儲存。現在離開的話,您將會放棄這些變動。"; "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_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_for_dm" = "您讓所有人收到邀請後,都能看到未來的聊天室歷史紀錄。";
"notice_room_history_visible_to_members_from_invited_point_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_for_dm" = "您讓所有成員都能看到聊天室未來的歷史記錄。";
"notice_room_history_visible_to_members_by_you" = "您讓所有聊天室成員都能看到聊天室未來的歷史記錄。"; "notice_room_history_visible_to_members_by_you" = "您讓所有成員都能看到聊天室未來的歷史記錄。";
"notice_room_history_visible_to_anyone_by_you" = "您讓任何人都能看到未來的聊天室歷史記錄。"; "notice_room_history_visible_to_anyone_by_you" = "您讓任何人都能看到未來的聊天室歷史記錄。";
"notice_redaction_by_you" = "您已取消一个事件id: %@)"; "notice_redaction_by_you" = "您已取消一个事件id: %@)";
"notice_encryption_enabled_unknown_algorithm_by_you" = "您已開啟端到端加密(無法識別的演算法 %@)。"; "notice_encryption_enabled_unknown_algorithm_by_you" = "您已開啟端到端加密(無法識別的演算法 %@)。";
@ -1366,14 +1366,14 @@
"notice_declined_video_call_by_you" = "您已拒絕此通話"; "notice_declined_video_call_by_you" = "您已拒絕此通話";
"notice_ended_video_call_by_you" = "您已結束通話"; "notice_ended_video_call_by_you" = "您已結束通話";
"notice_answered_video_call_by_you" = "您已接聽此通話"; "notice_answered_video_call_by_you" = "您已接聽此通話";
"notice_placed_video_call_by_you" = "您已出視訊通話"; "notice_placed_video_call_by_you" = "您已出視訊通話";
"notice_placed_voice_call_by_you" = "您已出語音通話"; "notice_placed_voice_call_by_you" = "您已出語音通話";
"notice_room_name_changed_by_you_for_dm" = "您將名稱變更為 %@。"; "notice_room_name_changed_by_you_for_dm" = "您將名稱變更為 %@。";
"notice_room_name_changed_by_you" = "您將聊天室名稱變更為 %@。"; "notice_room_name_changed_by_you" = "您將聊天室名稱變更為 %@。";
"notice_topic_changed_by_you" = "您已經將主題更為:%@。"; "notice_topic_changed_by_you" = "您將主題更為:%@。";
"notice_display_name_removed_by_you" = "您已移除自己的顯示名稱"; "notice_display_name_removed_by_you" = "您已移除自己的顯示名稱";
"notice_display_name_changed_from_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_avatar_url_changed_by_you" = "您已變更您的大頭照";
"notice_room_withdraw_by_you" = "您已撤回 %@ 的邀請"; "notice_room_withdraw_by_you" = "您已撤回 %@ 的邀請";
"notice_room_ban_by_you" = "您已封鎖 %@"; "notice_room_ban_by_you" = "您已封鎖 %@";
@ -1392,12 +1392,12 @@
// Notice Events with "You" // Notice Events with "You"
"notice_room_invite_by_you" = "您已邀請 %@"; "notice_room_invite_by_you" = "您已邀請 %@";
"notice_declined_video_call" = "%@ 已拒絕此通話"; "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_for_dm" = "%@ 已撤銷對 %@ 的邀請";
"notice_room_third_party_revoked_invite" = "%@ 已撤銷對 %@ 加入聊天室的邀請"; "notice_room_third_party_revoked_invite" = "%@ 已撤銷對 %@ 加入聊天室的邀請";
"notice_room_third_party_invite_for_dm" = "%@ 已邀請 %@"; "notice_room_third_party_invite_for_dm" = "%@ 已邀請 %@";
"microphone_access_not_granted_for_voice_message" = "語音簡訊需要使用麥克風的權限,但是 %@ 沒有存取權限"; "microphone_access_not_granted_for_voice_message" = "語音簡訊需要使用麥克風的權限,但是 %@ 沒有存取權限";
"error_common_message" = "發生了一個錯誤。請重新再試。"; "error_common_message" = "發生錯誤。請稍後再試。";
"e2e_passphrase_create" = "建立安全密語"; "e2e_passphrase_create" = "建立安全密語";
"e2e_passphrase_too_short" = "安全密語太短(至少要 %d 字母的長度)"; "e2e_passphrase_too_short" = "安全密語太短(至少要 %d 字母的長度)";
"e2e_passphrase_confirm" = "確認安全密語"; "e2e_passphrase_confirm" = "確認安全密語";
@ -1445,7 +1445,7 @@
"room_member_ignore_prompt" = "您確定要隱藏所有來自此使用者的訊息嗎?"; "room_member_ignore_prompt" = "您確定要隱藏所有來自此使用者的訊息嗎?";
"message_reply_to_message_to_reply_to_prefix" = "回覆給"; "message_reply_to_message_to_reply_to_prefix" = "回覆給";
"message_reply_to_sender_sent_their_live_location" = "即時位置。"; "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_file" = "已傳送檔案。";
"message_reply_to_sender_sent_a_voice_message" = "已傳送語音訊息。"; "message_reply_to_sender_sent_a_voice_message" = "已傳送語音訊息。";
"message_reply_to_sender_sent_an_audio_file" = "已傳送音訊檔。"; "message_reply_to_sender_sent_an_audio_file" = "已傳送音訊檔。";
@ -1531,7 +1531,7 @@
"user_session_rename_session_title" = "正在重新命名工作階段"; "user_session_rename_session_title" = "正在重新命名工作階段";
"user_session_inactive_session_description" = "不活躍的工作階段是您有一段時間未使用的工作階段,但它們會繼續接收加密金鑰。\n\n移除不活躍的工作階段可以改善安全性與效能並讓您可以更容易地識別新的工作階段是否可疑。"; "user_session_inactive_session_description" = "不活躍的工作階段是您有一段時間未使用的工作階段,但它們會繼續接收加密金鑰。\n\n移除不活躍的工作階段可以改善安全性與效能並讓您可以更容易地識別新的工作階段是否可疑。";
"user_session_inactive_session_title" = "不活躍的工作階段"; "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_description" = "未驗證的工作階段是使用您的憑證登入但交叉叉驗證的工作階段。\n\n您應特別確定您可以識別這些工作階段因為它們可能代表未經授權使用您的帳號。";
"user_session_unverified_session_title" = "未經驗證的工作階段"; "user_session_unverified_session_title" = "未經驗證的工作階段";
"user_session_verified_session_description" = "已驗證的工作階段,是您輸入安全密語或透過另一個已驗證工作階段確認您的身分後,使用此 Element 帳號的任何地方。\n\n這代表了您擁有解鎖加密訊息並向其他使用者確認您信任此工作階段所需的所有金鑰。"; "user_session_verified_session_description" = "已驗證的工作階段,是您輸入安全密語或透過另一個已驗證工作階段確認您的身分後,使用此 Element 帳號的任何地方。\n\n這代表了您擁有解鎖加密訊息並向其他使用者確認您信任此工作階段所需的所有金鑰。";
@ -1638,8 +1638,8 @@
"poll_timeline_total_one_vote_not_voted" = "已投 1 票。投票後即可檢視結果"; "poll_timeline_total_one_vote_not_voted" = "已投 1 票。投票後即可檢視結果";
"poll_timeline_total_votes" = "共計 %lu 票"; "poll_timeline_total_votes" = "共計 %lu 票";
"poll_timeline_total_one_vote" = "共計 1 票"; "poll_timeline_total_one_vote" = "共計 1 票";
"poll_timeline_total_no_votes" = "尚投票"; "poll_timeline_total_no_votes" = "尚投票";
"poll_timeline_votes_count" = "%lu 票"; "poll_timeline_votes_count" = "%lu 票";
"poll_timeline_one_vote" = "1 票"; "poll_timeline_one_vote" = "1 票";
"poll_edit_form_poll_type_closed_description" = "結果僅在您結束投票後顯示"; "poll_edit_form_poll_type_closed_description" = "結果僅在您結束投票後顯示";
"poll_edit_form_poll_type_closed" = "秘密投票"; "poll_edit_form_poll_type_closed" = "秘密投票";
@ -1683,12 +1683,12 @@
"all_chats_nothing_found_placeholder_title" = "找不到任何結果。"; "all_chats_nothing_found_placeholder_title" = "找不到任何結果。";
"all_chats_empty_unreads_placeholder_message" = "當您有一些未讀的訊息時,這裡會顯示您的未讀訊息。"; "all_chats_empty_unreads_placeholder_message" = "當您有一些未讀的訊息時,這裡會顯示您的未讀訊息。";
"all_chats_empty_list_placeholder_title" = "您都看完了。"; "all_chats_empty_list_placeholder_title" = "您都看完了。";
"all_chats_empty_view_information" = "適用於團隊、朋友與組織的多合一安全聊天應用程式。建立聊天室,或加入一個既有的聊天室。"; "all_chats_empty_view_information" = "適用於團隊、朋友與組織的多合一安全聊天應用程式。建立聊天室,或加入有的聊天室。";
"all_chats_empty_space_information" = "聊天空間是一種為聊天室與人們分組的新方式。使用右下角的按鈕新增既有的聊天室或建立新的。"; "all_chats_empty_space_information" = "聊天空間是一種為聊天室與人們分組的新方式。使用右下角的按鈕新增既有的聊天室或建立新的。";
"all_chats_empty_view_title" = "%@\n看起來有點空。"; "all_chats_empty_view_title" = "%@\n看起來有點空。";
"all_chats_all_filter" = "全部"; "all_chats_all_filter" = "全部";
"all_chats_edit_layout_alphabetical_order" = "按 A-Z 排列"; "all_chats_edit_layout_alphabetical_order" = "按名稱 A-Z 排序";
"all_chats_edit_layout_activity_order" = "按活動排列"; "all_chats_edit_layout_activity_order" = "按頻道最新活動排列";
"all_chats_edit_layout_show_filters" = "顯示過濾條件"; "all_chats_edit_layout_show_filters" = "顯示過濾條件";
"all_chats_edit_layout_show_recents" = "顯示最近的"; "all_chats_edit_layout_show_recents" = "顯示最近的";
"all_chats_edit_layout_sorting_options_title" = "分類您的訊息"; "all_chats_edit_layout_sorting_options_title" = "分類您的訊息";
@ -1870,7 +1870,7 @@
"home_context_menu_normal_priority" = "一般優先度"; "home_context_menu_normal_priority" = "一般優先度";
"home_context_menu_low_priority" = "低優先度"; "home_context_menu_low_priority" = "低優先度";
"home_context_menu_unfavourite" = "從我的最愛移除"; "home_context_menu_unfavourite" = "從我的最愛移除";
"home_context_menu_favourite" = "我的最愛"; "home_context_menu_favourite" = "加入我的最愛";
"home_context_menu_unmute" = "解除靜音"; "home_context_menu_unmute" = "解除靜音";
"home_context_menu_mute" = "靜音"; "home_context_menu_mute" = "靜音";
"home_context_menu_notifications" = "通知"; "home_context_menu_notifications" = "通知";
@ -1996,10 +1996,10 @@
"notice_crypto_error_unknown_inbound_session_id" = "傳送者的工作階段,尚未傳送傳給我們這則訊息的金鑰。"; "notice_crypto_error_unknown_inbound_session_id" = "傳送者的工作階段,尚未傳送傳給我們這則訊息的金鑰。";
"notice_crypto_unable_to_decrypt" = "** 無法解密:%@ **"; "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_for_dm" = "%@ 您讓所有人被邀請後,都能看到未來的聊天室歷史紀錄。";
"notice_room_history_visible_to_members_from_joined_point" = "%@ 讓所有聊天室成員被邀請後開始,都能看到未來的聊天室歷史紀錄。"; "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_for_dm" = "%@ 從他們被邀請開始,讓未來的聊天紀錄顯示給所有成員。";
"notice_room_history_visible_to_members_from_invited_point" = "%@ 從他們被邀請開始,讓未來的聊天室歷史紀錄顯示給所有聊天室成員。"; "notice_room_history_visible_to_members_from_invited_point" = "%@ 從他們被邀請開始,讓未來的聊天紀錄顯示給所有成員。";
"notice_room_history_visible_to_members_for_dm" = "%@ 讓所有聊天室成員都能看到聊天室之後的歷史記錄。"; "notice_room_history_visible_to_members_for_dm" = "%@ 讓所有成員都能看到聊天室之後的歷史記錄。";
"notice_error_unformattable_event" = "** 無法顯示這則訊息。請回報此錯誤"; "notice_error_unformattable_event" = "** 無法顯示這則訊息。請回報此錯誤";
"notice_encryption_enabled_unknown_algorithm" = "%1$@ 已開啟端到端加密(無法識別的演算法 %2$@)。"; "notice_encryption_enabled_unknown_algorithm" = "%1$@ 已開啟端到端加密(無法識別的演算法 %2$@)。";
"notice_encryption_enabled_ok" = "%@ 已開啟端到端加密。"; "notice_encryption_enabled_ok" = "%@ 已開啟端到端加密。";
@ -2009,11 +2009,11 @@
"notice_room_join_rule_public_by_you" = "您已公開此聊天室。"; "notice_room_join_rule_public_by_you" = "您已公開此聊天室。";
"notice_room_join_rule_public_for_dm" = "%@ 公開這個。"; "notice_room_join_rule_public_for_dm" = "%@ 公開這個。";
"notice_room_join_rule_public" = "%@ 公開此聊天室。"; "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_by_you" = "您讓聊天室變為邀請才可加入。";
"notice_room_join_rule_invite_for_dm" = "%@讓此變為邀請制。"; "notice_room_join_rule_invite_for_dm" = "%@ 將此處變為邀請制。";
// New // New
"notice_room_join_rule_invite" = "%@讓聊天室變為邀請才可加入。"; "notice_room_join_rule_invite" = "%@ 將聊天室變為邀請制。";
"notice_room_created_for_dm" = "%@ 已加入。"; "notice_room_created_for_dm" = "%@ 已加入。";
"notice_room_name_removed_for_dm" = "%@ 移除了該聊天室的名稱"; "notice_room_name_removed_for_dm" = "%@ 移除了該聊天室的名稱";
"ignore_user" = "忽略使用者"; "ignore_user" = "忽略使用者";
@ -2114,7 +2114,7 @@
// Generic errors // Generic errors
"error_invite_3pid_with_no_identity_server" = "在設定加入一個身分伺服器,才能用電子郵件寄送邀請。"; "error_invite_3pid_with_no_identity_server" = "在設定加入身分伺服器,才能用電子郵件寄送邀請。";
"emoji_picker_flags_category" = "旗幟"; "emoji_picker_flags_category" = "旗幟";
"emoji_picker_symbols_category" = "符號"; "emoji_picker_symbols_category" = "符號";
"emoji_picker_places_category" = "旅遊與景點"; "emoji_picker_places_category" = "旅遊與景點";
@ -2128,7 +2128,7 @@
// User // User
"key_verification_verified_user_information" = "與此使用者的訊息是端到端加密的,無法被第三方讀取。"; "key_verification_verified_user_information" = "與此使用者的訊息有端對端加密,無法被第三方讀取。";
"key_verification_verified_this_session_information" = "您現在可以在此裝置上閱讀您的加密訊息,其他使用者也會知道他們能夠信任此裝置。"; "key_verification_verified_this_session_information" = "您現在可以在此裝置上閱讀您的加密訊息,其他使用者也會知道他們能夠信任此裝置。";
"key_verification_verified_new_session_information" = "您現在也可以在新的裝置上閱讀您的加密訊息,其他使用者也會知道他們能夠信任此裝置。"; "key_verification_verified_new_session_information" = "您現在也可以在新的裝置上閱讀您的加密訊息,其他使用者也會知道他們能夠信任此裝置。";
"key_verification_verified_other_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_info" = "輸入只有您知道的安全密語,並產生備份的金鑰。";
"secure_key_backup_setup_intro_use_security_passphrase_title" = "使用安全密語"; "secure_key_backup_setup_intro_use_security_passphrase_title" = "使用安全密語";
"secure_key_backup_setup_intro_use_security_key_info" = "產生安全金鑰後,請儲存在密碼管理員或保險箱等安全的地方。"; "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_integration_manager" = "整合管理員能夠讓您加入第三方服務的功能。";
"service_terms_modal_information_description_identity_server" = "身分伺服器讓您能夠用電話或電子郵件,查詢您的聯絡人是否已經申請帳號。"; "service_terms_modal_information_description_identity_server" = "身分伺服器讓您能夠用電話或電子郵件,查詢您的聯絡人是否已經申請帳號。";
"service_terms_modal_information_title_integration_manager" = "整合管理員"; "service_terms_modal_information_title_integration_manager" = "整合管理員";
@ -2309,7 +2309,7 @@
"service_terms_modal_information_title_identity_server" = "身分伺服器"; "service_terms_modal_information_title_identity_server" = "身分伺服器";
"service_terms_modal_description_integration_manager" = "這會讓您可以使用聊天機器人、橋接、小工具和貼圖包。"; "service_terms_modal_description_integration_manager" = "這會讓您可以使用聊天機器人、橋接、小工具和貼圖包。";
"service_terms_modal_description_identity_server" = "這會讓手機上儲存您電話或電子郵件的人能找到您。"; "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_table_header_identity_server" = "身分伺服器條款";
"service_terms_modal_footer" = "您可以隨時在設定中取消。"; "service_terms_modal_footer" = "您可以隨時在設定中取消。";
@ -2362,10 +2362,10 @@
"leave_space_action" = "離開聊天空間"; "leave_space_action" = "離開聊天空間";
"spaces_add_room_missing_permission_message" = "您沒有權限在此聊天空間中新增聊天室。"; "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_many_spaces" = "在 %@ 個聊天空間";
"spaces_creation_in_spacename_plus_many" = "在 %@ 加入 %@ 個聊天空間"; "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_in_spacename" = "在 %@";
"spaces_creation_post_process_inviting_users" = "邀請 %@ 位使用者"; "spaces_creation_post_process_inviting_users" = "邀請 %@ 位使用者";
"spaces_creation_post_process_adding_rooms" = "加入 %@ 個聊天室"; "spaces_creation_post_process_adding_rooms" = "加入 %@ 個聊天室";
@ -2424,7 +2424,7 @@
"room_notifs_settings_mentions_and_keywords" = "僅提及和關鍵字"; "room_notifs_settings_mentions_and_keywords" = "僅提及和關鍵字";
// Room Notification Settings // Room Notification Settings
"room_notifs_settings_notify_me_for" = "通知我"; "room_notifs_settings_notify_me_for" = "收到下列訊息時通知我";
"room_suggestion_settings_screen_message" = "將向聊天空間中的成員推薦建議的聊天室。"; "room_suggestion_settings_screen_message" = "將向聊天空間中的成員推薦建議的聊天室。";
"room_suggestion_settings_screen_title" = "將聊天室設為聊天空間中的建議聊天室"; "room_suggestion_settings_screen_title" = "將聊天室設為聊天空間中的建議聊天室";
@ -2437,7 +2437,7 @@
"room_access_settings_screen_upgrade_alert_upgrading" = "升級聊天室"; "room_access_settings_screen_upgrade_alert_upgrading" = "升級聊天室";
"room_access_settings_screen_upgrade_alert_upgrade_button" = "升級"; "room_access_settings_screen_upgrade_alert_upgrade_button" = "升級";
"room_access_settings_screen_upgrade_alert_auto_invite_switch" = "自動邀請成員到新的聊天室"; "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_no_param" = "母聊天空間中的任何人都可以找到並加入此聊天室,不需要手動邀請所有人。您隨時都可以在聊天室設定中變更此設定。";
"room_access_settings_screen_upgrade_alert_message" = "任何在 %@ 的人都能找到並加入此聊天室,不需手動邀請所有人。您可以在聊天室的設定中隨時變更此設定。"; "room_access_settings_screen_upgrade_alert_message" = "任何在 %@ 的人都能找到並加入此聊天室,不需手動邀請所有人。您可以在聊天室的設定中隨時變更此設定。";
"room_access_settings_screen_upgrade_alert_title" = "升級聊天室"; "room_access_settings_screen_upgrade_alert_title" = "升級聊天室";
@ -2474,7 +2474,7 @@
"identity_server_settings_alert_change_title" = "變更身分伺服器"; "identity_server_settings_alert_change_title" = "變更身分伺服器";
"identity_server_settings_alert_no_terms" = "您選擇的身分伺服器沒有任何服務條款。僅在您信任服務擁有者時才繼續。"; "identity_server_settings_alert_no_terms" = "您選擇的身分伺服器沒有任何服務條款。僅在您信任服務擁有者時才繼續。";
"identity_server_settings_alert_no_terms_title" = "身分伺服器無使用條款"; "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_place_holder" = "輸入一個身分伺服器";
"identity_server_settings_no_is_description" = "您目前未使用身分伺服器。若想要尋找或被您認識的聯絡人找到,請在上方加入伺服器。"; "identity_server_settings_no_is_description" = "您目前未使用身分伺服器。若想要尋找或被您認識的聯絡人找到,請在上方加入伺服器。";
"identity_server_settings_description" = "您正在使用 %@ 來讓其他現有的聯絡人和您能夠找到彼此。"; "identity_server_settings_description" = "您正在使用 %@ 來讓其他現有的聯絡人和您能夠找到彼此。";
@ -2531,9 +2531,9 @@
"settings_discovery_three_pid_details_revoke_action" = "撤回"; "settings_discovery_three_pid_details_revoke_action" = "撤回";
"settings_discovery_three_pid_details_information_phone_number" = "在此管理讓其他使用者尋找您以及邀請您進入聊天室的電話號碼偏好設定。您可以在「帳號」中加入或刪除電話號碼。"; "settings_discovery_three_pid_details_information_phone_number" = "在此管理讓其他使用者尋找您以及邀請您進入聊天室的電話號碼偏好設定。您可以在「帳號」中加入或刪除電話號碼。";
"settings_discovery_three_pid_details_information_email" = "在此管理讓其他使用者尋找您以及邀請您進入聊天室的電子郵件地址偏好設定。您可以在「帳號」中加入或刪除電子郵件地址。"; "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_accept_terms" = "同意身分伺服器的使用條款";
"settings_discovery_terms_not_signed" = "同意身分伺服器(%@)的使用條款,讓其他人可以用您的電子郵件或電話號碼找到您。"; "settings_discovery_terms_not_signed" = "同意身分伺服器(%@)的使用條款,讓其他人可以用電子郵件地址或電話號碼找到您。";
"settings_discovery_no_identity_server" = "您目前未使用身分伺服器。若想要被您認識的聯絡人找到,請加入伺服器。"; "settings_discovery_no_identity_server" = "您目前未使用身分伺服器。若想要被您認識的聯絡人找到,請加入伺服器。";
"settings_devices_description" = "所有與您通訊的聯絡人都能看到此工作階段的公開名稱"; "settings_devices_description" = "所有與您通訊的聯絡人都能看到此工作階段的公開名稱";
"settings_key_backup_delete_confirmation_prompt_msg" = "您確定嗎?如果您的金鑰沒有正確備份的話,將會遺失所有加密訊息。"; "settings_key_backup_delete_confirmation_prompt_msg" = "您確定嗎?如果您的金鑰沒有正確備份的話,將會遺失所有加密訊息。";
@ -2576,7 +2576,7 @@
// Sessions list // Sessions list
"user_verification_sessions_list_user_trust_level_trusted_title" = "受信任"; "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_waiting_partner" = "正在等待 %@…";
"user_verification_start_information_part2" = " 雙方裝置上顯示的單次驗證碼。"; "user_verification_start_information_part2" = " 雙方裝置上顯示的單次驗證碼。";
"user_verification_start_information_part1" = "為了加強安全性,請確認 "; "user_verification_start_information_part1" = "為了加強安全性,請確認 ";
@ -2648,8 +2648,8 @@
"settings_call_invitations" = "通話邀請"; "settings_call_invitations" = "通話邀請";
"settings_room_invitations" = "聊天室邀請"; "settings_room_invitations" = "聊天室邀請";
"settings_messages_containing_at_room" = "@room"; "settings_messages_containing_at_room" = "@room";
"settings_notify_me_for" = "通知我"; "settings_notify_me_for" = "收到下列訊息時通知我";
"settings_mentions_and_keywords" = "僅有被提及與出現關鍵字"; "settings_mentions_and_keywords" = "提及與關鍵字";
"settings_notifications_disabled_alert_message" = "如需啟用通知,請前往裝置設定。"; "settings_notifications_disabled_alert_message" = "如需啟用通知,請前往裝置設定。";
"settings_security" = "安全性"; "settings_security" = "安全性";
"settings_confirm_media_size_description" = "開啟此選項後,傳送檔案前,會先向您確認準備傳送的圖片與影片大小。"; "settings_confirm_media_size_description" = "開啟此選項後,傳送檔案前,會先向您確認準備傳送的圖片與影片大小。";
@ -2733,7 +2733,7 @@
// Social login // Social login
"social_login_list_title_continue" = "繼續"; "social_login_list_title_continue" = "使用下列方式繼續";
"network_offline_message" = "您已離線,請確認您的網路連線。"; "network_offline_message" = "您已離線,請確認您的網路連線。";
"network_offline_title" = "您已離線"; "network_offline_title" = "您已離線";
"event_formatter_jitsi_widget_removed_by_you" = "您已刪除 VoIP 會議"; "event_formatter_jitsi_widget_removed_by_you" = "您已刪除 VoIP 會議";
@ -2743,7 +2743,7 @@
// Events formatter with you // Events formatter with you
"event_formatter_widget_added_by_you" = "您新增了小工具:%@"; "event_formatter_widget_added_by_you" = "您新增了小工具:%@";
"event_formatter_message_deleted" = "訊息已刪除"; "event_formatter_message_deleted" = "訊息已刪除";
"event_formatter_group_call_incoming" = "%@ 在 %@"; "event_formatter_group_call_incoming" = "%@ (來自 %@";
"event_formatter_call_decline" = "拒絕"; "event_formatter_call_decline" = "拒絕";
"event_formatter_call_connection_failed" = "連線失敗"; "event_formatter_call_connection_failed" = "連線失敗";
"event_formatter_call_missed_video" = "未接聽的視訊通話"; "event_formatter_call_missed_video" = "未接聽的視訊通話";
@ -2824,8 +2824,8 @@
"wysiwyg_composer_format_action_unordered_list" = "切換項目符號清單"; "wysiwyg_composer_format_action_unordered_list" = "切換項目符號清單";
"wysiwyg_composer_format_action_inline_code" = "套用內嵌程式碼格式"; "wysiwyg_composer_format_action_inline_code" = "套用內嵌程式碼格式";
"user_other_session_security_recommendation_title" = "其他工作階段"; "user_other_session_security_recommendation_title" = "其他工作階段";
"poll_timeline_reply_ended_poll" = "結束投票"; "poll_timeline_reply_ended_poll" = "結束投票";
"poll_timeline_ended_text" = "結束投票"; "poll_timeline_ended_text" = "投票已結束";
"poll_timeline_decryption_error" = "因為解密錯誤,不會計算部份投票"; "poll_timeline_decryption_error" = "因為解密錯誤,不會計算部份投票";
"poll_history_load_more" = "載入更多投票"; "poll_history_load_more" = "載入更多投票";
"poll_history_detail_view_in_timeline" = "在時間軸中檢視投票"; "poll_history_detail_view_in_timeline" = "在時間軸中檢視投票";
@ -2856,3 +2856,15 @@
// MARK: - Launch loading // MARK: - Launch loading
"launch_loading_generic" = "正在同步對話"; "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" = "可接受使用政策";

View file

@ -344,7 +344,7 @@ static NSString *const kRepliedTextPattern = @"<mx-reply>.*<blockquote>.*<br>(.*
if ((RiotSettings.shared.enableThreads && [mxSession.threadingService isEventThreadRoot:event]) if ((RiotSettings.shared.enableThreads && [mxSession.threadingService isEventThreadRoot:event])
|| _settings.showRedactionsInRoomHistory) || _settings.showRedactionsInRoomHistory)
{ {
MXLogDebug(@"[MXKEventFormatter] Redacted event %@ (%@)", event.description, event.redactedBecause); MXLogDebug(@"[MXKEventFormatter] Redacted event %@ (%@)", event.eventId, event.redactedBecause);
NSString *redactorId = event.redactedBecause[@"sender"]; NSString *redactorId = event.redactedBecause[@"sender"];
NSString *redactedBy = @""; NSString *redactedBy = @"";
@ -1316,7 +1316,7 @@ static NSString *const kRepliedTextPattern = @"<mx-reply>.*<blockquote>.*<br>(.*
// Check attachment validity // Check attachment validity
if (![self isSupportedAttachment:event]) if (![self isSupportedAttachment:event])
{ {
MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment %@", event.description); MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment in event %@", event.eventId);
body = [VectorL10n noticeInvalidAttachment]; body = [VectorL10n noticeInvalidAttachment];
*error = MXKEventFormatterErrorUnsupported; *error = MXKEventFormatterErrorUnsupported;
} }
@ -1326,7 +1326,7 @@ static NSString *const kRepliedTextPattern = @"<mx-reply>.*<blockquote>.*<br>(.*
body = body? body : [VectorL10n noticeAudioAttachment]; body = body? body : [VectorL10n noticeAudioAttachment];
if (![self isSupportedAttachment:event]) if (![self isSupportedAttachment:event])
{ {
MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment %@", event.description); MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment in event %@", event.eventId);
if (_isForSubtitle || !_settings.showUnsupportedEventsInRoomHistory) if (_isForSubtitle || !_settings.showUnsupportedEventsInRoomHistory)
{ {
body = [VectorL10n noticeInvalidAttachment]; body = [VectorL10n noticeInvalidAttachment];
@ -1343,7 +1343,7 @@ static NSString *const kRepliedTextPattern = @"<mx-reply>.*<blockquote>.*<br>(.*
body = body? body : [VectorL10n noticeVideoAttachment]; body = body? body : [VectorL10n noticeVideoAttachment];
if (![self isSupportedAttachment:event]) if (![self isSupportedAttachment:event])
{ {
MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment %@", event.description); MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment in event %@", event.eventId);
if (_isForSubtitle || !_settings.showUnsupportedEventsInRoomHistory) if (_isForSubtitle || !_settings.showUnsupportedEventsInRoomHistory)
{ {
body = [VectorL10n noticeInvalidAttachment]; body = [VectorL10n noticeInvalidAttachment];
@ -1374,14 +1374,14 @@ static NSString *const kRepliedTextPattern = @"<mx-reply>.*<blockquote>.*<br>(.*
} }
else else
{ {
MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported m.file format: %@", event.description); MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported m.file format in event: %@", event.eventId);
*error = MXKEventFormatterErrorUnsupported; *error = MXKEventFormatterErrorUnsupported;
} }
} }
} }
else else
{ {
MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment %@", event.description); MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported attachment in event %@", event.eventId);
body = [VectorL10n noticeInvalidAttachment]; body = [VectorL10n noticeInvalidAttachment];
*error = MXKEventFormatterErrorUnsupported; *error = MXKEventFormatterErrorUnsupported;
} }
@ -1620,7 +1620,7 @@ static NSString *const kRepliedTextPattern = @"<mx-reply>.*<blockquote>.*<br>(.*
// Check sticker validity // Check sticker validity
if (![self isSupportedAttachment:event]) if (![self isSupportedAttachment:event])
{ {
MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported sticker %@", event.description); MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported sticker in event %@", event.eventId);
body = [VectorL10n noticeInvalidAttachment]; body = [VectorL10n noticeInvalidAttachment];
*error = MXKEventFormatterErrorUnsupported; *error = MXKEventFormatterErrorUnsupported;
} }
@ -1674,7 +1674,7 @@ static NSString *const kRepliedTextPattern = @"<mx-reply>.*<blockquote>.*<br>(.*
if (!attributedDisplayText) if (!attributedDisplayText)
{ {
MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported event %@)", event.description); MXLogDebug(@"[MXKEventFormatter] Warning: Unsupported event %@)", event.eventId);
if (_settings.showUnsupportedEventsInRoomHistory) if (_settings.showUnsupportedEventsInRoomHistory)
{ {
if (MXKEventFormatterErrorNone == *error) if (MXKEventFormatterErrorNone == *error)
@ -1914,7 +1914,7 @@ static NSString *const kRepliedTextPattern = @"<mx-reply>.*<blockquote>.*<br>(.*
// No message content in a non-redacted event. Formatter should use fallback. // No message content in a non-redacted event. Formatter should use fallback.
if (!repliedEventContent) 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; return nil;
} }
} }
@ -1949,7 +1949,7 @@ static NSString *const kRepliedTextPattern = @"<mx-reply>.*<blockquote>.*<br>(.*
} }
else else
{ {
MXLogWarning(@"[MXKEventFormatter] Unable to build reply event %@", event.description) MXLogWarning(@"[MXKEventFormatter] Unable to build reply event %@", event.eventId)
} }
return html; return html;

View file

@ -69,8 +69,9 @@ static NSRegularExpression* permalinkRegex;
httpLinksRegex = [NSRegularExpression regularExpressionWithPattern:@"(?i)\\b(https?://\\S*)\\b" options:NSRegularExpressionCaseInsensitive error:nil]; httpLinksRegex = [NSRegularExpression regularExpressionWithPattern:@"(?i)\\b(https?://\\S*)\\b" options:NSRegularExpressionCaseInsensitive error:nil];
htmlTagsRegex = [NSRegularExpression regularExpressionWithPattern:@"<(\\w+)[^>]*>" options:NSRegularExpressionCaseInsensitive error:nil]; htmlTagsRegex = [NSRegularExpression regularExpressionWithPattern:@"<(\\w+)[^>]*>" options:NSRegularExpressionCaseInsensitive error:nil];
linkDetector = [NSDataDetector dataDetectorWithTypes:NSTextCheckingTypeLink 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]; permalinkRegex = [NSRegularExpression regularExpressionWithPattern:permalinkPattern options:NSRegularExpressionCaseInsensitive error:nil];
}); });
} }

View file

@ -382,6 +382,11 @@ typedef enum : NSUInteger
*/ */
@property (nonatomic) NSAttributedString *attributedTextMessage; @property (nonatomic) NSAttributedString *attributedTextMessage;
/**
Default font for the message composer.
*/
@property (nonatomic, readonly, nonnull) UIFont *defaultFont;
- (void)dismissValidationView:(MXKImageView*)validationView; - (void)dismissValidationView:(MXKImageView*)validationView;
@end @end

View file

@ -358,6 +358,10 @@
self.textMessage = [NSString stringWithFormat:@"%@%@", self.textMessage, text]; self.textMessage = [NSString stringWithFormat:@"%@%@", self.textMessage, text];
} }
- (UIFont *)defaultFont
{
return [UIFont systemFontOfSize:15.f];
}
#pragma mark - MXKFileSizes #pragma mark - MXKFileSizes

View file

@ -25,25 +25,29 @@ import UIKit
avatarLeading: 2.0, avatarLeading: 2.0,
avatarSideLength: 16.0, avatarSideLength: 16.0,
itemSpacing: 4) itemSpacing: 4)
private weak var messageTextView: MXKMessageTextView? private weak var messageTextView: UITextView?
private var pillViewFlusher: PillViewFlusher? {
messageTextView as? PillViewFlusher
}
// MARK: - Override // MARK: - Override
override init(textAttachment: NSTextAttachment, parentView: UIView?, textLayoutManager: NSTextLayoutManager?, location: NSTextLocation) { override init(textAttachment: NSTextAttachment, parentView: UIView?, textLayoutManager: NSTextLayoutManager?, location: NSTextLocation) {
super.init(textAttachment: textAttachment, parentView: parentView, textLayoutManager: textLayoutManager, location: location) 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() { override func loadView() {
super.loadView() super.loadView()
guard let textAttachment = self.textAttachment as? PillTextAttachment else { 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 return
} }
guard var pillData = textAttachment.data else { guard var pillData = textAttachment.data else {
MXLog.debug("[PillAttachmentViewProvider]: attachment misses pill data") MXLog.failure("[PillAttachmentViewProvider]: attachment misses pill data")
return return
} }
@ -59,6 +63,11 @@ import UIKit
mediaManager: mainSession?.mediaManager, mediaManager: mainSession?.mediaManager,
andPillData: pillData) andPillData: pillData)
view = pillView view = pillView
messageTextView?.registerPillView(pillView)
if let pillViewFlusher {
pillViewFlusher.registerPillView(pillView)
} else {
MXLog.failure("[PillAttachmentViewProvider]: no handler found, pill will not be flushed properly")
}
} }
} }

View file

@ -26,14 +26,14 @@ private enum PillAttachmentKind {
struct PillProvider { struct PillProvider {
private let session: MXSession private let session: MXSession
private let eventFormatter: MXKEventFormatter private let eventFormatter: MXKEventFormatter
private let event: MXEvent private let event: MXEvent?
private let roomState: MXRoomState private let roomState: MXRoomState
private let latestRoomState: MXRoomState? private let latestRoomState: MXRoomState?
private let isEditMode: Bool private let isEditMode: Bool
init(withSession session: MXSession, init(withSession session: MXSession,
eventFormatter: MXKEventFormatter, eventFormatter: MXKEventFormatter,
event: MXEvent, event: MXEvent?,
roomState: MXRoomState, roomState: MXRoomState,
andLatestRoomState latestRoomState: MXRoomState?, andLatestRoomState latestRoomState: MXRoomState?,
isEditMode: Bool) { isEditMode: Bool) {
@ -46,7 +46,7 @@ struct PillProvider {
self.isEditMode = isEditMode 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 // Try to get a pill from this url
guard let pillType = PillType.from(url: url) else { guard let pillType = PillType.from(url: url) else {
@ -133,6 +133,10 @@ struct PillProvider {
let avatarUrl = roomMember?.avatarUrl ?? user?.avatarUrl let avatarUrl = roomMember?.avatarUrl ?? user?.avatarUrl
let displayName = roomMember?.displayname ?? user?.displayName ?? userId let displayName = roomMember?.displayname ?? user?.displayName ?? userId
let isHighlighted = userId == session.myUserId 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 let avatar: PillTextAttachmentItem
if roomMember == nil && user == nil { if roomMember == nil && user == nil {

View file

@ -27,7 +27,7 @@ enum PillType: Codable {
extension PillType { extension PillType {
private static var regexPermalinkTarget: NSRegularExpression? = { private static var regexPermalinkTarget: NSRegularExpression? = {
let clientBaseUrl = BuildSettings.clientPermalinkBaseUrl ?? kMXMatrixDotToUrl 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) return try? NSRegularExpression(pattern: pattern, options: .caseInsensitive)
}() }()

View file

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

View file

@ -65,7 +65,7 @@ class PillsFormatter: NSObject {
// try to get a mention pill from the url // try to get a mention pill from the url
let label = Range(range, in: newAttr.string).flatMap { String(newAttr.string[$0]) } 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 // replace the url with the pill
newAttr.replaceCharacters(in: range, with: attachmentString) newAttr.replaceCharacters(in: range, with: attachmentString)
} }
@ -74,6 +74,41 @@ class PillsFormatter: NSObject {
return newAttr 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. /// Creates a string with all pills of given attributed string replaced by display names.
/// ///
/// - Parameters: /// - Parameters:
@ -123,6 +158,20 @@ class PillsFormatter: NSObject {
} }
return attributedStringWithAttachment(attachment, link: url, font: font) 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. /// Update alpha of all `PillTextAttachment` contained in given attributed string.
/// ///
@ -160,12 +209,45 @@ class PillsFormatter: NSObject {
} }
} }
} }
} }
// MARK: - Private Methods // MARK: - Private Methods
@available (iOS 15.0, *) @available (iOS 15.0, *)
extension PillsFormatter { 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 { static func attributedStringWithAttachment(_ attachment: PillTextAttachment, link: URL?, font: UIFont) -> NSAttributedString {
let string = NSMutableAttributedString(attachment: attachment) let string = NSMutableAttributedString(attachment: attachment)

View file

@ -5150,6 +5150,21 @@ static CGSize kThreadListBarButtonItemImageSize;
[self.userSuggestionCoordinator processTextMessage:toolbarView.textMessage]; [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 - (void)roomInputToolbarViewDidOpenActionMenu:(RoomInputToolbarView*)toolbarView
{ {
// Consider opening the action menu as beginning to type and share encryption keys if requested. // Consider opening the action menu as beginning to type and share encryption keys if requested.

View file

@ -14,46 +14,52 @@
// limitations under the License. // limitations under the License.
// //
import HTMLParser
import UIKit import UIKit
import WysiwygComposer import WysiwygComposer
extension RoomViewController { extension RoomViewController {
// MARK: - Override // MARK: - Override
open override func mention(_ roomMember: MXRoomMember) { open override func mention(_ roomMember: MXRoomMember) {
guard let inputToolbar = inputToolbar else { if let wysiwygInputToolbar, wysiwygInputToolbar.textFormattingEnabled {
return wysiwygInputToolbar.mention(roomMember)
} wysiwygInputToolbar.becomeFirstResponder()
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 ")
} else { } else {
if #available(iOS 15.0, *) { guard let attributedText = inputToolbarView.attributedTextMessage else { return }
newAttributedString.append(PillsFormatter.mentionPill(withRoomMember: roomMember, let newAttributedString = NSMutableAttributedString(attributedString: attributedText)
isHighlighted: false,
font: inputToolbar.textDefaultFont))
} else {
newAttributedString.appendString(roomMember.displayname.count > 0 ? roomMember.displayname : roomMember.userId)
}
newAttributedString.appendString(": ")
}
inputToolbar.attributedTextMessage = newAttributedString if attributedText.length > 0 {
inputToolbar.becomeFirstResponder() 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 rawTextMsg: the raw text message
/// - Parameter htmlMsg: the html text message /// - Parameter htmlMsg: the html text message
@ -361,6 +367,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 // MARK: - VoiceBroadcast
extension RoomViewController { extension RoomViewController {
@objc func stopUncompletedVoiceBroadcastIfNeeded() { @objc func stopUncompletedVoiceBroadcastIfNeeded() {

View file

@ -39,6 +39,7 @@
<outletCollection property="toolbarContainerConstraints" destination="pRw-S0-6WL" id="q4S-0g-sqQ"/> <outletCollection property="toolbarContainerConstraints" destination="pRw-S0-6WL" id="q4S-0g-sqQ"/>
<outletCollection property="toolbarContainerConstraints" destination="QO8-nF-xys" id="aQe-20-4Pq"/> <outletCollection property="toolbarContainerConstraints" destination="QO8-nF-xys" id="aQe-20-4Pq"/>
<outletCollection property="toolbarContainerConstraints" destination="acJ-g8-R7x" id="uEo-Ez-seV"/> <outletCollection property="toolbarContainerConstraints" destination="acJ-g8-R7x" id="uEo-Ez-seV"/>
<outletCollection property="toolbarContainerConstraints" destination="ave-fu-X1D" id="xfF-6Q-MDo"/>
</connections> </connections>
</placeholder> </placeholder>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>

View file

@ -21,6 +21,8 @@
@class RoomActionsBar; @class RoomActionsBar;
@class RoomInputToolbarView; @class RoomInputToolbarView;
@class LinkActionWrapper; @class LinkActionWrapper;
@class SuggestionPatternWrapper;
@class UserSuggestionViewModelContextWrapper;
/** /**
Destination of the message in the composer Destination of the message in the composer
@ -59,7 +61,7 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode)
@param toolbarView the room input toolbar view @param toolbarView the room input toolbar view
*/ */
- (void)roomInputToolbarViewDidChangeTextMessage:(RoomInputToolbarView*)toolbarView; - (void)roomInputToolbarViewDidChangeTextMessage:(MXKRoomInputToolbarView*)toolbarView;
/** /**
Inform the delegate that the action menu was opened. Inform the delegate that the action menu was opened.
@ -80,6 +82,12 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode)
- (void)didSendLinkAction: (LinkActionWrapper *)linkAction; - (void)didSendLinkAction: (LinkActionWrapper *)linkAction;
- (void)didDetectTextPattern: (SuggestionPatternWrapper *)suggestionPattern;
- (UserSuggestionViewModelContextWrapper *)userSuggestionContext;
- (MXMediaManager *)mediaManager;
@end @end
/** /**
@ -128,8 +136,6 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode)
*/ */
@property (nonatomic, weak, readonly) UIButton *attachMediaButton; @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 Adds a voice message toolbar view to be displayed inside this input toolbar
*/ */

View file

@ -154,7 +154,7 @@ static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3;
{ {
NSMutableAttributedString *mutableTextMessage = [[NSMutableAttributedString alloc] initWithAttributedString:attributedTextMessage]; NSMutableAttributedString *mutableTextMessage = [[NSMutableAttributedString alloc] initWithAttributedString:attributedTextMessage];
[mutableTextMessage addAttributes:@{ NSForegroundColorAttributeName: ThemeService.shared.theme.textPrimaryColor, [mutableTextMessage addAttributes:@{ NSForegroundColorAttributeName: ThemeService.shared.theme.textPrimaryColor,
NSFontAttributeName: self.textDefaultFont } NSFontAttributeName: self.defaultFont }
range:NSMakeRange(0, mutableTextMessage.length)]; range:NSMakeRange(0, mutableTextMessage.length)];
attributedTextMessage = mutableTextMessage; attributedTextMessage = mutableTextMessage;
} }
@ -181,7 +181,7 @@ static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3;
return self.textView.text; return self.textView.text;
} }
- (UIFont *)textDefaultFont - (UIFont *)defaultFont
{ {
if (self.textView.font) if (self.textView.font)
{ {

View file

@ -72,6 +72,12 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
} }
// MARK: Public // MARK: Public
override var delegate: MXKRoomInputToolbarViewDelegate! {
didSet {
setupComposerIfNeeded()
}
}
override var placeholder: String! { override var placeholder: String! {
get { get {
@ -85,6 +91,23 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
override var isFocused: Bool { override var isFocused: Bool {
viewModel.isFocused 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 { var isMaximised: Bool {
wysiwygViewModel.maximised wysiwygViewModel.maximised
@ -120,93 +143,15 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
private weak var toolbarViewDelegate: RoomInputToolbarViewDelegate? { private weak var toolbarViewDelegate: RoomInputToolbarViewDelegate? {
return (delegate as? RoomInputToolbarViewDelegate) ?? nil return (delegate as? RoomInputToolbarViewDelegate) ?? nil
} }
private var permalinkReplacer: PermalinkReplacer? {
return (delegate as? PermalinkReplacer)
}
override func awakeFromNib() { override func awakeFromNib() {
super.awakeFromNib() super.awakeFromNib()
viewModel = ComposerViewModel(
initialViewState: ComposerViewState(textFormattingEnabled: RiotSettings.shared.enableWysiwygTextFormatting, setupComposerIfNeeded()
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)
} }
override func customizeRendering() { override func customizeRendering() {
@ -217,6 +162,11 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
override func dismissKeyboard() { override func dismissKeyboard() {
self.viewModel.dismissKeyboard() self.viewModel.dismissKeyboard()
} }
@discardableResult
override func becomeFirstResponder() -> Bool {
self.wysiwygViewModel.textView.becomeFirstResponder()
}
override func dismissValidationView(_ validationView: MXKImageView!) { override func dismissValidationView(_ validationView: MXKImageView!) {
super.dismissValidationView(validationView) super.dismissValidationView(validationView)
@ -239,8 +189,119 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
} }
wysiwygViewModel.applyLinkOperation(linkOperation) wysiwygViewModel.applyLinkOperation(linkOperation)
} }
func mention(_ member: MXRoomMember) {
self.wysiwygViewModel.setMention(link: MXTools.permalinkToUser(withUserId: member.userId),
name: member.displayname,
mentionType: .user)
}
// MARK: - Private // 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) { @objc private func keyboardWillShow(_ notification: Notification) {
if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue { if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
@ -291,6 +352,8 @@ class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInp
setVoiceMessageToolbarIsHidden(!isEmpty) setVoiceMessageToolbarIsHidden(!isEmpty)
case let .linkTapped(linkAction): case let .linkTapped(linkAction):
toolbarViewDelegate?.didSendLinkAction(LinkActionWrapper(linkAction)) toolbarViewDelegate?.didSendLinkAction(LinkActionWrapper(linkAction))
case let .suggestion(pattern):
toolbarViewDelegate?.didDetectTextPattern(SuggestionPatternWrapper(pattern))
} }
} }

View file

@ -206,11 +206,12 @@ class VoiceMessageAttachmentCacheManager {
} }
private func convertFileAtPath(_ path: String?, numberOfSamples: Int, identifier: String, semaphore: DispatchSemaphore) { private func convertFileAtPath(_ path: String?, numberOfSamples: Int, identifier: String, semaphore: DispatchSemaphore) {
guard let filePath = path else { guard let path else {
return 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 newURL = temporaryFilesFolderURL.appendingPathComponent(identifier).appendingPathExtension(fileExtension)
let conversionCompletion: (Result<Void, VoiceMessageAudioConverterError>) -> Void = { result in let conversionCompletion: (Result<Void, VoiceMessageAudioConverterError>) -> Void = { result in
@ -252,7 +253,7 @@ class VoiceMessageAttachmentCacheManager {
if FileManager.default.fileExists(atPath: newURL.path) { if FileManager.default.fileExists(atPath: newURL.path) {
conversionCompletion(Result.success(())) conversionCompletion(Result.success(()))
} else { } else {
VoiceMessageAudioConverter.convertToMPEG4AAC(sourceURL: URL(fileURLWithPath: filePath), destinationURL: newURL, completion: conversionCompletion) VoiceMessageAudioConverter.convertToMPEG4AACIfNeeded(sourceURL: filePath, destinationURL: newURL, completion: conversionCompletion)
} }
} }

View file

@ -39,10 +39,10 @@ struct VoiceMessageAudioConverter {
} }
} }
static func convertToMPEG4AAC(sourceURL: URL, destinationURL: URL, completion: @escaping (Result<Void, VoiceMessageAudioConverterError>) -> Void) { static func convertToMPEG4AACIfNeeded(sourceURL: URL, destinationURL: URL, completion: @escaping (Result<Void, VoiceMessageAudioConverterError>) -> Void) {
DispatchQueue.global(qos: .userInitiated).async { DispatchQueue.global(qos: .userInitiated).async {
do { do {
if sourceURL.pathExtension == "mp4" { if sourceURL.hasSupportedAudioExtension {
try FileManager.default.copyItem(atPath: sourceURL.path, toPath: destinationURL.path) try FileManager.default.copyItem(atPath: sourceURL.path, toPath: destinationURL.path)
} else { } else {
try OGGConverter.convertOpusOGGToM4aFile(src: sourceURL, dest: destinationURL) 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())
}
}

View file

@ -103,7 +103,8 @@ static NSString *const kEventFormatterTimeFormat = @"HH:mm";
@"event_id": event.eventId ?: @"unknown" @"event_id": event.eventId ?: @"unknown"
}); });
string = [[NSAttributedString alloc] initWithString:[VectorL10n noticeErrorUnformattableEvent] attributes:@{ string = [[NSAttributedString alloc] initWithString:[VectorL10n noticeErrorUnformattableEvent] attributes:@{
NSFontAttributeName: [self encryptedMessagesTextFont] NSFontAttributeName: [self encryptedMessagesTextFont],
NSForegroundColorAttributeName: [self encryptingTextColor]
}]; }];
} }
} }

View file

@ -41,6 +41,7 @@ extension ComposerLinkActionViewState {
switch linkAction { switch linkAction {
case .createWithText, .create: return VectorL10n.wysiwygComposerLinkActionCreateTitle case .createWithText, .create: return VectorL10n.wysiwygComposerLinkActionCreateTitle
case .edit: return VectorL10n.wysiwygComposerLinkActionEditTitle case .edit: return VectorL10n.wysiwygComposerLinkActionEditTitle
case .disabled: return ""
} }
} }
@ -64,6 +65,7 @@ extension ComposerLinkActionViewState {
case .createWithText: return bindings.text.isEmpty case .createWithText: return bindings.text.isEmpty
case .create: return false case .create: return false
case .edit: return !bindings.hasEditedUrl case .edit: return !bindings.hasEditedUrl
case .disabled: return false
} }
} }
} }

View file

@ -46,6 +46,9 @@ final class ComposerLinkActionViewModel: ComposerLinkActionViewModelType, Compos
initialViewState = .init(linkAction: .createWithText, bindings: simpleBindings) initialViewState = .init(linkAction: .createWithText, bindings: simpleBindings)
case .create: case .create:
initialViewState = .init(linkAction: .create, bindings: simpleBindings) initialViewState = .init(linkAction: .create, bindings: simpleBindings)
case .disabled:
// Note: Unreachable
initialViewState = .init(linkAction: .disabled, bindings: simpleBindings)
} }
super.init(initialViewState: initialViewState) super.init(initialViewState: initialViewState)
@ -74,6 +77,8 @@ final class ComposerLinkActionViewModel: ComposerLinkActionViewModelType, Compos
.setLink(urlString: state.bindings.linkUrl) .setLink(urlString: state.bindings.linkUrl)
) )
) )
case .disabled:
break
} }
} }
} }

View file

@ -29,12 +29,22 @@ enum MockComposerScreenState: MockScreenState, CaseIterable {
var screenView: ([Any], AnyView) { var screenView: ([Any], AnyView) {
let viewModel: ComposerViewModel let viewModel: ComposerViewModel
let userSuggestionViewModel = MockUserSuggestionViewModel(initialViewState: UserSuggestionViewState(items: []))
let bindings = ComposerBindings(focused: false) let bindings = ComposerBindings(focused: false)
switch self { switch self {
case .send: viewModel = ComposerViewModel(initialViewState: ComposerViewState(textFormattingEnabled: true, isLandscapePhone: false, bindings: bindings)) case .send: viewModel = ComposerViewModel(initialViewState: ComposerViewState(textFormattingEnabled: true,
case .edit: viewModel = ComposerViewModel(initialViewState: ComposerViewState(sendMode: .edit, textFormattingEnabled: true, isLandscapePhone: false, bindings: bindings)) isLandscapePhone: false,
case .reply: viewModel = ComposerViewModel(initialViewState: ComposerViewState(eventSenderDisplayName: "TestUser", sendMode: .reply, textFormattingEnabled: true, isLandscapePhone: false, bindings: bindings)) 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) let wysiwygviewModel = WysiwygComposerViewModel(minHeight: 20, maxCompressedHeight: 360)
@ -57,6 +67,7 @@ enum MockComposerScreenState: MockScreenState, CaseIterable {
Spacer() Spacer()
Composer(viewModel: viewModel.context, Composer(viewModel: viewModel.context,
wysiwygViewModel: wysiwygviewModel, wysiwygViewModel: wysiwygviewModel,
userSuggestionSharedContext: userSuggestionViewModel.context,
resizeAnimationDuration: 0.1, resizeAnimationDuration: 0.1,
sendMessageAction: { _ in }, sendMessageAction: { _ in },
showSendMediaActions: { }) showSendMediaActions: { })
@ -70,3 +81,7 @@ enum MockComposerScreenState: MockScreenState, CaseIterable {
) )
} }
} }
private final class MockUserSuggestionViewModel: UserSuggestionViewModelType {
}

View file

@ -229,12 +229,14 @@ enum ComposerViewAction: Equatable {
case contentDidChange(isEmpty: Bool) case contentDidChange(isEmpty: Bool)
case linkTapped(linkAction: LinkAction) case linkTapped(linkAction: LinkAction)
case storeSelection(selection: NSRange) case storeSelection(selection: NSRange)
case suggestion(pattern: SuggestionPattern?)
} }
enum ComposerViewModelResult: Equatable { enum ComposerViewModelResult: Equatable {
case cancel case cancel
case contentDidChange(isEmpty: Bool) case contentDidChange(isEmpty: Bool)
case linkTapped(LinkAction: LinkAction) case linkTapped(LinkAction: LinkAction)
case suggestion(pattern: SuggestionPattern?)
} }
final class LinkActionWrapper: NSObject { final class LinkActionWrapper: NSObject {
@ -245,3 +247,21 @@ final class LinkActionWrapper: NSObject {
super.init() 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()
}
}

View file

@ -23,6 +23,7 @@ struct Composer: View {
// MARK: Private // MARK: Private
@ObservedObject private var viewModel: ComposerViewModelType.Context @ObservedObject private var viewModel: ComposerViewModelType.Context
@ObservedObject private var wysiwygViewModel: WysiwygComposerViewModel @ObservedObject private var wysiwygViewModel: WysiwygComposerViewModel
private let userSuggestionSharedContext: UserSuggestionViewModelType.Context
private let resizeAnimationDuration: Double private let resizeAnimationDuration: Double
private let sendMessageAction: (WysiwygComposerContent) -> Void private let sendMessageAction: (WysiwygComposerContent) -> Void
@ -31,15 +32,42 @@ struct Composer: View {
@Environment(\.theme) private var theme: ThemeSwiftUI @Environment(\.theme) private var theme: ThemeSwiftUI
@State private var isActionButtonShowing = false @State private var isActionButtonShowing = false
private let horizontalPadding: CGFloat = 12 private let horizontalPadding: CGFloat = 12
private let borderHeight: CGFloat = 40 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 (borderHeight - wysiwygViewModel.minHeight) / 2
} }
private var topPadding: CGFloat { /// Computes the top padding to apply on the composer text view depending on context.
viewModel.viewState.shouldDisplayContext ? 0 : verticalPadding 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 { private var cornerRadius: CGFloat {
@ -84,7 +112,7 @@ struct Composer: View {
private var composerContainer: some View { private var composerContainer: some View {
let rect = RoundedRectangle(cornerRadius: cornerRadius) let rect = RoundedRectangle(cornerRadius: cornerRadius)
return VStack(spacing: 12) { return VStack(spacing: verticalComponentSpacing) {
if viewModel.viewState.shouldDisplayContext { if viewModel.viewState.shouldDisplayContext {
HStack { HStack {
if let imageName = viewModel.viewState.contextImageName { if let imageName = viewModel.viewState.contextImageName {
@ -106,7 +134,8 @@ struct Composer: View {
} }
.accessibilityIdentifier("cancelButton") .accessibilityIdentifier("cancelButton")
} }
.padding(.top, 8) .frame(height: contextBannerHeight)
.padding(.top, standardVerticalPadding)
.padding(.horizontal, horizontalPadding) .padding(.horizontal, horizontalPadding)
} }
HStack(alignment: shouldFixRoundCorner ? .top : .center, spacing: 0) { HStack(alignment: shouldFixRoundCorner ? .top : .center, spacing: 0) {
@ -116,7 +145,6 @@ struct Composer: View {
) )
.tintColor(theme.colors.accent) .tintColor(theme.colors.accent)
.placeholder(viewModel.viewState.placeholder, color: theme.colors.tertiaryContent) .placeholder(viewModel.viewState.placeholder, color: theme.colors.tertiaryContent)
.frame(height: wysiwygViewModel.idealHeight)
.onAppear { .onAppear {
if wysiwygViewModel.isContentEmpty { if wysiwygViewModel.isContentEmpty {
wysiwygViewModel.setup() wysiwygViewModel.setup()
@ -137,13 +165,13 @@ struct Composer: View {
} }
} }
.padding(.horizontal, horizontalPadding) .padding(.horizontal, horizontalPadding)
.padding(.top, topPadding) .padding(.top, composerTopPadding)
.padding(.bottom, verticalPadding) .padding(.bottom, composerVerticalPadding)
} }
.clipShape(rect) .clipShape(rect)
.overlay(rect.stroke(borderColor, lineWidth: 1)) .overlay(rect.stroke(borderColor, lineWidth: 1))
.animation(.easeInOut(duration: resizeAnimationDuration), value: wysiwygViewModel.idealHeight) .animation(.easeInOut(duration: resizeAnimationDuration), value: wysiwygViewModel.idealHeight)
.padding(.top, 8) .padding(.top, standardVerticalPadding)
.onTapGesture { .onTapGesture {
if viewModel.focused { if viewModel.focused {
viewModel.focused = true viewModel.focused = true
@ -195,11 +223,13 @@ struct Composer: View {
init( init(
viewModel: ComposerViewModelType.Context, viewModel: ComposerViewModelType.Context,
wysiwygViewModel: WysiwygComposerViewModel, wysiwygViewModel: WysiwygComposerViewModel,
userSuggestionSharedContext: UserSuggestionViewModelType.Context,
resizeAnimationDuration: Double, resizeAnimationDuration: Double,
sendMessageAction: @escaping (WysiwygComposerContent) -> Void, sendMessageAction: @escaping (WysiwygComposerContent) -> Void,
showSendMediaActions: @escaping () -> Void) { showSendMediaActions: @escaping () -> Void) {
self.viewModel = viewModel self.viewModel = viewModel
self.wysiwygViewModel = wysiwygViewModel self.wysiwygViewModel = wysiwygViewModel
self.userSuggestionSharedContext = userSuggestionSharedContext
self.resizeAnimationDuration = resizeAnimationDuration self.resizeAnimationDuration = resizeAnimationDuration
self.sendMessageAction = sendMessageAction self.sendMessageAction = sendMessageAction
self.showSendMediaActions = showSendMediaActions self.showSendMediaActions = showSendMediaActions
@ -213,17 +243,23 @@ struct Composer: View {
.frame(width: 36, height: 5) .frame(width: 36, height: 5)
.padding(.top, 10) .padding(.top, 10)
} }
HStack(alignment: .bottom, spacing: 0) { VStack {
if !viewModel.viewState.textFormattingEnabled { HStack(alignment: .bottom, spacing: 0) {
sendMediaButton if !viewModel.viewState.textFormattingEnabled {
.padding(.bottom, 1) sendMediaButton
.padding(.bottom, 1)
}
composerContainer
if !viewModel.viewState.textFormattingEnabled {
sendButton
.padding(.bottom, 1)
}
} }
composerContainer if wysiwygViewModel.maximised {
if !viewModel.viewState.textFormattingEnabled { UserSuggestionList(viewModel: userSuggestionSharedContext, showBackgroundShadow: false)
sendButton
.padding(.bottom, 1)
} }
} }
.frame(height: composerHeight)
if viewModel.viewState.textFormattingEnabled { if viewModel.viewState.textFormattingEnabled {
HStack(alignment: .center, spacing: 0) { HStack(alignment: .center, spacing: 0) {
sendMediaButton sendMediaButton
@ -248,6 +284,9 @@ struct Composer: View {
wysiwygViewModel.maximised = false wysiwygViewModel.maximised = false
} }
} }
.onChange(of: wysiwygViewModel.suggestionPattern) { newValue in
sendMentionPattern(pattern: newValue)
}
} }
private func storeCurrentSelection() { private func storeCurrentSelection() {
@ -258,6 +297,10 @@ struct Composer: View {
let linkAction = wysiwygViewModel.getLinkAction() let linkAction = wysiwygViewModel.getLinkAction()
viewModel.send(viewAction: .linkTapped(linkAction: linkAction)) viewModel.send(viewAction: .linkTapped(linkAction: linkAction))
} }
private func sendMentionPattern(pattern: SuggestionPattern?) {
viewModel.send(viewAction: .suggestion(pattern: pattern))
}
} }
private extension WysiwygComposerViewModel { private extension WysiwygComposerViewModel {

View file

@ -90,6 +90,8 @@ final class ComposerViewModel: ComposerViewModelType, ComposerViewModelProtocol
callback?(.linkTapped(LinkAction: linkAction)) callback?(.linkTapped(LinkAction: linkAction))
case let .storeSelection(selection): case let .storeSelection(selection):
selectionToRestore = selection selectionToRestore = selection
case let .suggestion(pattern: pattern):
callback?(.suggestion(pattern: pattern))
} }
} }

View file

@ -18,6 +18,7 @@ import Combine
import Foundation import Foundation
import SwiftUI import SwiftUI
import UIKit import UIKit
import WysiwygComposer
protocol UserSuggestionCoordinatorDelegate: AnyObject { protocol UserSuggestionCoordinatorDelegate: AnyObject {
func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didRequestMentionForMember member: MXRoomMember, textTrigger: String?) func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator, didRequestMentionForMember member: MXRoomMember, textTrigger: String?)
@ -31,6 +32,15 @@ struct UserSuggestionCoordinatorParameters {
let userID: String 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 { final class UserSuggestionCoordinator: Coordinator, Presentable {
// MARK: - Properties // MARK: - Properties
@ -99,6 +109,10 @@ final class UserSuggestionCoordinator: Coordinator, Presentable {
userSuggestionService.processTextMessage(textMessage) userSuggestionService.processTextMessage(textMessage)
} }
func processSuggestionPattern(_ suggestionPattern: SuggestionPattern?) {
userSuggestionService.processSuggestionPattern(suggestionPattern)
}
// MARK: - Public // MARK: - Public
func start() { } func start() { }
@ -107,6 +121,10 @@ final class UserSuggestionCoordinator: Coordinator, Presentable {
userSuggestionHostingController userSuggestionHostingController
} }
func sharedContext() -> UserSuggestionViewModelContextWrapper {
UserSuggestionViewModelContextWrapper(context: userSuggestionViewModel.sharedContext)
}
// MARK: - Private // MARK: - Private
private func calculateViewHeight() -> CGFloat { private func calculateViewHeight() -> CGFloat {

View file

@ -45,10 +45,18 @@ final class UserSuggestionCoordinatorBridge: NSObject {
func processTextMessage(_ textMessage: String) { func processTextMessage(_ textMessage: String) {
userSuggestionCoordinator.processTextMessage(textMessage) userSuggestionCoordinator.processTextMessage(textMessage)
} }
func processSuggestionPattern(_ suggestionPatternWrapper: SuggestionPatternWrapper) {
userSuggestionCoordinator.processSuggestionPattern(suggestionPatternWrapper.suggestionPattern)
}
func toPresentable() -> UIViewController? { func toPresentable() -> UIViewController? {
userSuggestionCoordinator.toPresentable() userSuggestionCoordinator.toPresentable()
} }
func sharedContext() -> UserSuggestionViewModelContextWrapper {
userSuggestionCoordinator.sharedContext()
}
} }
extension UserSuggestionCoordinatorBridge: UserSuggestionCoordinatorDelegate { extension UserSuggestionCoordinatorBridge: UserSuggestionCoordinatorDelegate {

View file

@ -16,6 +16,7 @@
import Combine import Combine
import Foundation import Foundation
import WysiwygComposer
struct RoomMembersProviderMember { struct RoomMembersProviderMember {
var userId: String var userId: String
@ -91,6 +92,16 @@ class UserSuggestionService: UserSuggestionServiceProtocol {
currentTextTriggerSubject.send(lastComponent) 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 // MARK: - Private

View file

@ -16,6 +16,7 @@
import Combine import Combine
import Foundation import Foundation
import WysiwygComposer
protocol UserSuggestionItemProtocol: Avatarable { protocol UserSuggestionItemProtocol: Avatarable {
var userId: String { get } var userId: String { get }
@ -29,6 +30,7 @@ protocol UserSuggestionServiceProtocol {
var currentTextTrigger: String? { get } var currentTextTrigger: String? { get }
func processTextMessage(_ textMessage: String?) func processTextMessage(_ textMessage: String?)
func processSuggestionPattern(_ suggestionPattern: SuggestionPattern?)
} }
// MARK: Avatarable // MARK: Avatarable

View file

@ -27,7 +27,11 @@ class UserSuggestionViewModel: UserSuggestionViewModelType, UserSuggestionViewMo
private let userSuggestionService: UserSuggestionServiceProtocol private let userSuggestionService: UserSuggestionServiceProtocol
// MARK: Public // MARK: Public
var sharedContext: UserSuggestionViewModelType.Context {
return self.context
}
var completion: ((UserSuggestionViewModelResult) -> Void)? var completion: ((UserSuggestionViewModelResult) -> Void)?
// MARK: - Setup // MARK: - Setup

View file

@ -17,5 +17,9 @@
import Foundation import Foundation
protocol UserSuggestionViewModelProtocol { 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 } var completion: ((UserSuggestionViewModelResult) -> Void)? { get set }
} }

View file

@ -23,6 +23,15 @@ struct UserSuggestionList: View {
static let lineSpacing: CGFloat = 10.0 static let lineSpacing: CGFloat = 10.0
static let maxHeight: CGFloat = 300.0 static let maxHeight: CGFloat = 300.0
static let maxVisibleRows = 4 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 // MARK: - Properties
@ -35,6 +44,7 @@ struct UserSuggestionList: View {
// MARK: Public // MARK: Public
@ObservedObject var viewModel: UserSuggestionViewModel.Context @ObservedObject var viewModel: UserSuggestionViewModel.Context
var showBackgroundShadow: Bool = true
var body: some View { var body: some View {
if viewModel.viewState.items.isEmpty { if viewModel.viewState.items.isEmpty {
@ -46,25 +56,12 @@ struct UserSuggestionList: View {
userId: "Prototype") userId: "Prototype")
.background(ViewFrameReader(frame: $prototypeListItemFrame)) .background(ViewFrameReader(frame: $prototypeListItemFrame))
.hidden() .hidden()
BackgroundView { if showBackgroundShadow {
List(viewModel.viewState.items) { item in BackgroundView {
Button { list()
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)
}
} }
.listStyle(PlainListStyle()) } else {
.frame(height: min(Constants.maxHeight, list()
min(contentHeightForRowCount(Constants.maxVisibleRows),
contentHeightForRowCount(viewModel.viewState.items.count))))
.id(UUID()) // Rebuild the whole list on item changes. Fixes performance issues.
} }
} }
} }
@ -73,6 +70,47 @@ struct UserSuggestionList: View {
private func contentHeightForRowCount(_ count: Int) -> CGFloat { private func contentHeightForRowCount(_ count: Int) -> CGFloat {
(prototypeListItemFrame.height + (Constants.listItemPadding * 2) + Constants.lineSpacing) * CGFloat(count) + Constants.topPadding (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<Content: View>: View { private struct BackgroundView<Content: View>: View {

View file

@ -29,12 +29,14 @@ private enum Inputs {
static let aliceMemberAway = FakeMXRoomMember(displayname: aliceAwayDisplayname, avatarUrl: aliceNewAvatarUrl, userId: "@alice:matrix.org") static let aliceMemberAway = FakeMXRoomMember(displayname: aliceAwayDisplayname, avatarUrl: aliceNewAvatarUrl, userId: "@alice:matrix.org")
static let alicePermalink = "https://matrix.to/#/@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 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 bobUserId = "@bob:matrix.org"
static let bobDisplayname = "Bob" static let bobDisplayname = "Bob"
static let bobAvatarUrl = "mxc://matrix.org/VyNYBgahazAzUuOeZETtQ" static let bobAvatarUrl = "mxc://matrix.org/VyNYBgahazAzUuOeZETtQ"
static let bobMember = FakeMXRoomMember(displayname: bobDisplayname, avatarUrl: bobAvatarUrl, userId: bobUserId) 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 anotherUserId = "@another.user:matrix.org"
static let anotherUserPermalink = "https://matrix.to/#/@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): case .room(let userId):
XCTAssertEqual(userId, Inputs.roomId) XCTAssertEqual(userId, Inputs.roomId)
switch pillTextAttachmentData.items.first { switch pillTextAttachmentData.items.first {
case .asset(let assetName, let parameters): case .asset(let assetName, _):
XCTAssertEqual(assetName, "link_icon") XCTAssertEqual(assetName, "link_icon")
default: default:
XCTFail("First pill item should be the asset") XCTFail("First pill item should be the asset")
@ -436,7 +438,7 @@ class PillsFormatterTests: XCTestCase {
XCTAssertEqual(roomId, Inputs.anotherRoomId) XCTAssertEqual(roomId, Inputs.anotherRoomId)
XCTAssertEqual(messageId, Inputs.messageEventId) XCTAssertEqual(messageId, Inputs.messageEventId)
switch pillTextAttachmentData.items.first { switch pillTextAttachmentData.items.first {
case .asset(let name, let parameters): case .asset(let name, _):
XCTAssertEqual(name, "link_icon") XCTAssertEqual(name, "link_icon")
default: default:
XCTFail("First pill item should be the asset") XCTFail("First pill item should be the asset")
@ -445,6 +447,79 @@ class PillsFormatterTests: XCTestCase {
XCTFail("Pill should be of type .message") 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, *) @available(iOS 15.0, *)
@ -604,6 +679,15 @@ private extension PillsFormatterTests {
return messageWithPills 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 // MARK: - Mock objects

View file

@ -21,7 +21,7 @@ platform :ios do
before_all do before_all do
# Ensure used Xcode version # Ensure used Xcode version
xcversion(version: "~> 14.2") xcversion(version: "14.2")
end end
#### Public #### #### Public ####

View file

@ -56,7 +56,7 @@ packages:
branch: 0.0.1 branch: 0.0.1
WysiwygComposer: WysiwygComposer:
url: https://github.com/matrix-org/matrix-wysiwyg-composer-swift url: https://github.com/matrix-org/matrix-wysiwyg-composer-swift
version: 1.1.1 version: 2.0.0
DeviceKit: DeviceKit:
url: https://github.com/devicekit/DeviceKit url: https://github.com/devicekit/DeviceKit
majorVersion: 4.7.0 majorVersion: 4.7.0