mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 15:44:54 +00:00
Merge branch 'develop' into fix/6371-save-file-name-conflicts
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_analytics_settings_help_us_improve">"مشاركة بيانات استخدام مجهولة لمساعدتنا على تتبع المشاكل."</string>
|
||||
<string name="screen_analytics_settings_read_terms">"يمكنك قراءة كافة شروطنا %1$s."</string>
|
||||
<string name="screen_analytics_settings_read_terms_content_link">"هنا"</string>
|
||||
<string name="screen_analytics_settings_share_data">"مشاركة البيانات الإحصائية"</string>
|
||||
</resources>
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_analytics_settings_help_us_improve">"Udostępniaj anonimowe dane użytkowania, aby pomóc nam identyfikować problemy."</string>
|
||||
<string name="screen_analytics_settings_help_us_improve">"Udostępniaj anonimowe dane użytkowania, aby pomóc nam ulepszać aplikację."</string>
|
||||
<string name="screen_analytics_settings_read_terms">"Przeczytaj nasze warunki użytkowania %1$s."</string>
|
||||
<string name="screen_analytics_settings_read_terms_content_link">"tutaj"</string>
|
||||
<string name="screen_analytics_settings_share_data">"Udostępniaj dane analityczne"</string>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_analytics_prompt_data_usage">"لن نسجّل أو ننشئ ملفات شخصية لأي معلومات شخصية"</string>
|
||||
<string name="screen_analytics_prompt_help_us_improve">"مشاركة بيانات استخدام مجهولة لمساعدتنا على تتبع المشاكل."</string>
|
||||
<string name="screen_analytics_prompt_read_terms">"يمكنك قراءة كافة شروطنا %1$s."</string>
|
||||
<string name="screen_analytics_prompt_read_terms_content_link">"هنا"</string>
|
||||
<string name="screen_analytics_prompt_settings">"يمكنك إطفاء هذا الخيار في أي وقت"</string>
|
||||
<string name="screen_analytics_prompt_third_party_sharing">"لن نشارك بياناتك مع أطراف ثالثة"</string>
|
||||
<string name="screen_analytics_prompt_title">"ساعدنا على التحسن %1$s"</string>
|
||||
</resources>
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_analytics_prompt_data_usage">"Nie będziemy rejestrować ani profilować żadnych danych osobistych"</string>
|
||||
<string name="screen_analytics_prompt_help_us_improve">"Udostępniaj anonimowe dane użytkowania, aby pomóc nam identyfikować problemy."</string>
|
||||
<string name="screen_analytics_prompt_help_us_improve">"Udostępniaj anonimowe dane użytkowania, aby pomóc nam ulepszać aplikację."</string>
|
||||
<string name="screen_analytics_prompt_read_terms">"Przeczytaj nasze warunki użytkowania %1$s."</string>
|
||||
<string name="screen_analytics_prompt_read_terms_content_link">"tutaj"</string>
|
||||
<string name="screen_analytics_prompt_settings">"Możesz to wyłączyć w dowolnym momencie"</string>
|
||||
|
||||
+19
@@ -12,15 +12,34 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Queues the full-screen announcements shown to the user once, such as the notice about a new feature.
|
||||
*
|
||||
* A dismissed announcement is remembered so it is not shown again.
|
||||
*/
|
||||
interface AnnouncementService {
|
||||
/**
|
||||
* Queues an announcement, unless the user has already dismissed it.
|
||||
*
|
||||
* @param announcement the announcement to show.
|
||||
*/
|
||||
suspend fun showAnnouncement(announcement: Announcement)
|
||||
|
||||
/**
|
||||
* Records that the user dismissed an announcement, so it is not queued again.
|
||||
*
|
||||
* @param announcement the announcement that was dismissed.
|
||||
*/
|
||||
suspend fun onAnnouncementDismissed(announcement: Announcement)
|
||||
|
||||
/** The announcements still waiting to be shown, in the order they should appear. */
|
||||
fun announcementsToShowFlow(): Flow<List<Announcement>>
|
||||
|
||||
/**
|
||||
* Use this composable to render the announcement UI in Fullscreen.
|
||||
* Draws nothing while there is no announcement to show.
|
||||
*
|
||||
* @param modifier layout modifier for the container.
|
||||
*/
|
||||
@Composable
|
||||
fun Render(
|
||||
|
||||
+3
@@ -8,6 +8,9 @@
|
||||
|
||||
package io.element.android.features.cachecleaner.api
|
||||
|
||||
/**
|
||||
* Removes the temporary files holding decrypted content, so plaintext media does not linger on disk.
|
||||
*/
|
||||
interface CacheCleaner {
|
||||
/**
|
||||
* Clear the cache subdirs holding temporarily decrypted content (such as media and voice messages).
|
||||
|
||||
+3
@@ -10,6 +10,9 @@ package io.element.android.features.call.api
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Tracks the call the user is currently in on this device, so unrelated parts of the app can react to it.
|
||||
*/
|
||||
interface CurrentCallService {
|
||||
/**
|
||||
* The current call state flow, which will be updated when the active call changes.
|
||||
|
||||
+2
@@ -11,7 +11,9 @@ package io.element.android.features.call.impl.pip
|
||||
import io.element.android.features.call.impl.utils.PipController
|
||||
|
||||
sealed interface PictureInPictureEvent {
|
||||
data object OnCallStarted : PictureInPictureEvent
|
||||
data class SetPipController(val pipController: PipController) : PictureInPictureEvent
|
||||
data object EnterPictureInPicture : PictureInPictureEvent
|
||||
data class OnPictureInPictureModeChanged(val isInPip: Boolean) : PictureInPictureEvent
|
||||
data class SetPipOrientation(val orientation: Int?) : PictureInPictureEvent
|
||||
}
|
||||
|
||||
+7
@@ -38,6 +38,9 @@ class PictureInPicturePresenter(
|
||||
|
||||
fun handleEvent(event: PictureInPictureEvent) {
|
||||
when (event) {
|
||||
is PictureInPictureEvent.OnCallStarted -> {
|
||||
pipController?.setupPipCallbacks()
|
||||
}
|
||||
is PictureInPictureEvent.SetPipController -> {
|
||||
pipController = event.pipController
|
||||
}
|
||||
@@ -55,6 +58,10 @@ class PictureInPicturePresenter(
|
||||
pipController?.exitPip()
|
||||
}
|
||||
}
|
||||
is PictureInPictureEvent.SetPipOrientation -> {
|
||||
Timber.tag(loggerTag.value).d("onOrientationChange: ${event.orientation}")
|
||||
pipView?.setPipOrientation(event.orientation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -9,6 +9,7 @@
|
||||
package io.element.android.features.call.impl.pip
|
||||
|
||||
interface PipView {
|
||||
fun setPipOrientation(orientation: Int?)
|
||||
fun setPipParams()
|
||||
fun enterPipMode(): Boolean
|
||||
fun hangUp()
|
||||
|
||||
+7
-1
@@ -118,6 +118,7 @@ internal fun CallScreenView(
|
||||
if (webViewAudioManager?.isInCallMode?.get() == false) {
|
||||
Timber.d("URL $url is loaded, starting in-call audio mode")
|
||||
webViewAudioManager?.onCallStarted()
|
||||
pipState.eventSink(PictureInPictureEvent.OnCallStarted)
|
||||
} else {
|
||||
Timber.d("Can't start in-call audio mode since the app is already in it.")
|
||||
}
|
||||
@@ -130,7 +131,12 @@ internal fun CallScreenView(
|
||||
onInvalidAudioDeviceAdded = { invalidAudioDeviceReason = it },
|
||||
)
|
||||
state.eventSink(CallScreenEvent.SetupMessageChannels(interceptor))
|
||||
val pipController = WebViewPipController(webView)
|
||||
val pipController = WebViewPipController(
|
||||
webView = webView,
|
||||
updatePipOrientation = { orientation ->
|
||||
pipState.eventSink(PictureInPictureEvent.SetPipOrientation(orientation))
|
||||
}
|
||||
)
|
||||
pipState.eventSink(PictureInPictureEvent.SetPipController(pipController))
|
||||
},
|
||||
onDestroyWebView = {
|
||||
|
||||
+13
-3
@@ -11,9 +11,9 @@ package io.element.android.features.call.impl.ui
|
||||
import android.Manifest
|
||||
import android.app.PictureInPictureParams
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Rational
|
||||
import android.view.WindowManager
|
||||
import android.webkit.PermissionRequest
|
||||
import androidx.activity.compose.setContent
|
||||
@@ -49,6 +49,7 @@ import io.element.android.features.call.impl.pip.PipView
|
||||
import io.element.android.features.call.impl.services.CallForegroundService
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.libraries.androidutils.browser.ConsoleMessageLogger
|
||||
import io.element.android.libraries.androidutils.media.setAspectRatioFromOrientation
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.bindings
|
||||
import io.element.android.libraries.audio.api.AudioFocus
|
||||
@@ -86,6 +87,8 @@ class ElementCallActivity :
|
||||
|
||||
private var eventSink: ((CallScreenEvent) -> Unit)? = null
|
||||
|
||||
private var currentPipOrientation: Int? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
@@ -199,6 +202,7 @@ class ElementCallActivity :
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
val onPictureInPictureModeChangedListener = Consumer { _: PictureInPictureModeChangedInfo ->
|
||||
setPipParams()
|
||||
pipEventSink(PictureInPictureEvent.OnPictureInPictureModeChanged(isInPictureInPictureMode))
|
||||
if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
|
||||
Timber.tag(loggerTag.value).d("Exiting PiP mode: Hangup the call")
|
||||
@@ -296,11 +300,17 @@ class ElementCallActivity :
|
||||
}
|
||||
}
|
||||
|
||||
override fun setPipOrientation(orientation: Int?) {
|
||||
currentPipOrientation = orientation
|
||||
setPictureInPictureParams(getPictureInPictureParams())
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun getPictureInPictureParams(): PictureInPictureParams {
|
||||
// Portrait for calls seems more appropriate as a fallback value
|
||||
val orientation = currentPipOrientation ?: Configuration.ORIENTATION_PORTRAIT
|
||||
return PictureInPictureParams.Builder()
|
||||
// Portrait for calls seems more appropriate
|
||||
.setAspectRatio(Rational(3, 5))
|
||||
.setAspectRatioFromOrientation(orientation)
|
||||
.apply {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
setAutoEnterEnabled(true)
|
||||
|
||||
+1
@@ -9,6 +9,7 @@
|
||||
package io.element.android.features.call.impl.utils
|
||||
|
||||
interface PipController {
|
||||
fun setupPipCallbacks()
|
||||
suspend fun canEnterPip(): Boolean
|
||||
fun enterPip()
|
||||
fun exitPip()
|
||||
|
||||
+1
-1
@@ -250,7 +250,7 @@ class WebViewAudioManager(
|
||||
|
||||
hasRegisteredCallbacks = true
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
Timber.d("Setting androidNativeBridge javascript interface in webview")
|
||||
webView.addJavascriptInterface(webViewAudioDeviceSelectedCallback, "androidNativeBridge")
|
||||
|
||||
+30
@@ -8,7 +8,10 @@
|
||||
|
||||
package io.element.android.features.call.impl.utils
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebView
|
||||
import timber.log.Timber
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
@@ -18,7 +21,18 @@ import kotlin.coroutines.suspendCoroutine
|
||||
*/
|
||||
class WebViewPipController(
|
||||
private val webView: WebView,
|
||||
updatePipOrientation: (orientation: Int?) -> Unit,
|
||||
) : PipController {
|
||||
init {
|
||||
// Register the JavaScript interface ahead of time to ensure it's available when the WebView content is loaded and tries to call it
|
||||
webView.addJavascriptInterface(PipBridge(updatePipOrientation), "androidPipBridge")
|
||||
}
|
||||
|
||||
override fun setupPipCallbacks() {
|
||||
Timber.d("Adding callback in controls.onPipMediaOrientationUpdate")
|
||||
webView.evaluateJavascript("controls.onPipMediaOrientationUpdate = (orientation) => { androidPipBridge.setPipOrientation(orientation); };", null)
|
||||
}
|
||||
|
||||
override suspend fun canEnterPip(): Boolean {
|
||||
return suspendCoroutine { continuation ->
|
||||
webView.evaluateJavascript("controls.canEnterPip()") { result ->
|
||||
@@ -36,3 +50,19 @@ class WebViewPipController(
|
||||
webView.evaluateJavascript("controls.disablePip()", null)
|
||||
}
|
||||
}
|
||||
|
||||
private class PipBridge(
|
||||
private val onOrientationChange: (orientation: Int?) -> Unit,
|
||||
) {
|
||||
@JavascriptInterface
|
||||
fun setPipOrientation(orientation: String?) {
|
||||
Timber.d("Picture-in-picture orientation changed in webview, orientation: $orientation")
|
||||
// This callback is used to update the picture-in-picture orientation in the WebView when it changes
|
||||
val orientation = when (orientation) {
|
||||
"portrait" -> Configuration.ORIENTATION_PORTRAIT
|
||||
"landscape" -> Configuration.ORIENTATION_LANDSCAPE
|
||||
else -> return
|
||||
}
|
||||
onOrientationChange(orientation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="call_foreground_service_channel_title_android">"مكالمة جارية"</string>
|
||||
<string name="call_foreground_service_message_android">"اضغط للعودة إلى المكالمة"</string>
|
||||
<string name="call_foreground_service_title_android">"☎️ الاتصال يتمّ"</string>
|
||||
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"لا يدعم Element Call استخدام أجهزة الصوت عبر Bluetooth في إصدار Android هذا. يرجى اختيار جهاز صوت آخر."</string>
|
||||
<string name="screen_incoming_call_subtitle_android">"مكالمة Element Call واردة"</string>
|
||||
</resources>
|
||||
@@ -3,5 +3,6 @@
|
||||
<string name="call_foreground_service_channel_title_android">"تماس خروجی"</string>
|
||||
<string name="call_foreground_service_message_android">"زدن برای بازگشت به تماس"</string>
|
||||
<string name="call_foreground_service_title_android">"تماس در جریان ☎️"</string>
|
||||
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"برنامه Element Call در این نسخه اندروید از دستگاههای صوتی بلوتوث پشتیبانی نمیکند. لطفاً دستگاه صوتی دیگری را انتخاب کنید."</string>
|
||||
<string name="screen_incoming_call_subtitle_android">"تماس المنتی ورودی"</string>
|
||||
</resources>
|
||||
|
||||
+3
@@ -12,10 +12,13 @@ import io.element.android.features.call.impl.utils.PipController
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
|
||||
class FakePipController(
|
||||
private val setupPipCallbacksResult: () -> Unit = { lambdaError() },
|
||||
private val canEnterPipResult: () -> Boolean = { lambdaError() },
|
||||
private val enterPipResult: () -> Unit = { lambdaError() },
|
||||
private val exitPipResult: () -> Unit = { lambdaError() },
|
||||
) : PipController {
|
||||
override fun setupPipCallbacks() = setupPipCallbacksResult()
|
||||
|
||||
override suspend fun canEnterPip(): Boolean = canEnterPipResult()
|
||||
|
||||
override fun enterPip() = enterPipResult()
|
||||
|
||||
+3
-1
@@ -13,9 +13,11 @@ import io.element.android.tests.testutils.lambda.lambdaError
|
||||
class FakePipView(
|
||||
private val setPipParamsResult: () -> Unit = { lambdaError() },
|
||||
private val enterPipModeResult: () -> Boolean = { lambdaError() },
|
||||
private val handUpResult: () -> Unit = { lambdaError() }
|
||||
private val handUpResult: () -> Unit = { lambdaError() },
|
||||
private val onOrientationChangeResult: (orientation: Int?) -> Unit = { lambdaError() },
|
||||
) : PipView {
|
||||
override fun setPipParams() = setPipParamsResult()
|
||||
override fun enterPipMode(): Boolean = enterPipModeResult()
|
||||
override fun hangUp() = handUpResult()
|
||||
override fun setPipOrientation(orientation: Int?) = onOrientationChangeResult(orientation)
|
||||
}
|
||||
|
||||
+6
@@ -18,5 +18,11 @@ import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentVal
|
||||
* This will only process media when a [ContentScanner] is provided when building the client in Pro.
|
||||
*/
|
||||
fun interface ContentScannerService {
|
||||
/**
|
||||
* Starts scanning the given media, reporting the verdict of each one through [contentValidationState] rather than by returning.
|
||||
*
|
||||
* @param mediaSources the media to scan.
|
||||
* @param contentValidationState where the outcome of each scan is published for the UI to observe.
|
||||
*/
|
||||
fun scan(mediaSources: List<MediaSource>, contentValidationState: ContentValidationState)
|
||||
}
|
||||
|
||||
+12
@@ -15,9 +15,21 @@ import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
||||
interface CreateRoomEntryPoint : FeatureEntryPoint {
|
||||
/**
|
||||
* Configures the create-room flow before it is started, since it serves rooms and spaces from the same screens.
|
||||
*/
|
||||
interface Builder {
|
||||
/**
|
||||
* @param isSpace true to create a space rather than a room.
|
||||
*/
|
||||
fun setIsSpace(isSpace: Boolean): Builder
|
||||
|
||||
/**
|
||||
* @param parentSpaceId the space the new room should be added to once created.
|
||||
*/
|
||||
fun setParentSpace(parentSpaceId: RoomId): Builder
|
||||
|
||||
/** Builds the node with the options configured so far. */
|
||||
fun build(): Node
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_create_room_action_create_room">"إنشاء غرفة جديدة"</string>
|
||||
<string name="screen_create_room_add_people_title">"دعوة أشخاص"</string>
|
||||
<string name="screen_create_room_error_creating_room">"حصل خطأ أثناء إنشاء الغرفة"</string>
|
||||
<string name="screen_create_room_error_creating_space">"تعذّر إنشاء المساحة بسبب خطأ غير معروف. حاول مرة أخرى لاحقًا."</string>
|
||||
<string name="screen_create_room_name_placeholder">"إضافة اسم…"</string>
|
||||
<string name="screen_create_room_new_room_title">"غرفة جديدة"</string>
|
||||
<string name="screen_create_room_new_space_title">"مساحة جديدة"</string>
|
||||
<string name="screen_create_room_private_option_description">"لا يدخل إلا المدعوّون."</string>
|
||||
<string name="screen_create_room_private_option_title">"خاصة"</string>
|
||||
<string name="screen_create_room_public_option_description">"بإمكان الجميع العثور على هذه الغرفة.
|
||||
يمكنك تغيير هذا من إعدادات الغرفة متى شئت."</string>
|
||||
<string name="screen_create_room_public_option_short_description">"يمكن لأي شخص الانضمام."</string>
|
||||
<string name="screen_create_room_public_option_title">"عامة"</string>
|
||||
<string name="screen_create_room_room_access_section_knocking_option_description">"يمكن لأي شخص طلب الانضمام، لكن يجب أن يقبل الطلب مسؤول أو مشرف."</string>
|
||||
<string name="screen_create_room_room_access_section_knocking_option_title">"السماح بطلبات الانضمام"</string>
|
||||
<string name="screen_create_room_room_access_section_knocking_restricted_option_description">"يمكن لأي شخص في %1$s الانضمام، لكن يجب على الآخرين طلب الوصول."</string>
|
||||
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"طلب الانضمام"</string>
|
||||
<string name="screen_create_room_room_access_section_private_option_description">"يمكن للمدعوين فقط الانضمام."</string>
|
||||
<string name="screen_create_room_room_access_section_private_option_title">"خاصة"</string>
|
||||
<string name="screen_create_room_room_access_section_public_option_description">"يمكن لأي شخص الانضمام."</string>
|
||||
<string name="screen_create_room_room_access_section_public_option_title">"عامة"</string>
|
||||
<string name="screen_create_room_room_access_section_restricted_option_description">"يمكن لأي شخص في %1$s الانضمام."</string>
|
||||
<string name="screen_create_room_room_access_section_restricted_option_title">"قياسي"</string>
|
||||
<string name="screen_create_room_room_access_section_title">"من يمكنه الوصول"</string>
|
||||
<string name="screen_create_room_room_address_section_footer">"تحتاج إلى عنوان حتى تظهر الغرفة في الدليل العام."</string>
|
||||
<string name="screen_create_room_room_address_section_title">"العنوان"</string>
|
||||
<string name="screen_create_room_room_visibility_section_title">"ظهور الغرفة"</string>
|
||||
<string name="screen_create_room_space_selection_no_space_description">"(لا توجد مساحة)"</string>
|
||||
<string name="screen_create_room_space_selection_no_space_option">"عدم الإضافة إلى مساحة"</string>
|
||||
<string name="screen_create_room_space_selection_no_space_title">"لم يتم اختيار أي مساحة"</string>
|
||||
<string name="screen_create_room_space_selection_sheet_title">"إضافة إلى مساحة"</string>
|
||||
<string name="screen_create_room_topic_label">"الموضوع (اختياري)"</string>
|
||||
<string name="screen_create_room_topic_placeholder">"إضافة وصف…"</string>
|
||||
</resources>
|
||||
@@ -3,14 +3,34 @@
|
||||
<string name="screen_create_room_action_create_room">"Nueva sala"</string>
|
||||
<string name="screen_create_room_add_people_title">"Invitar personas"</string>
|
||||
<string name="screen_create_room_error_creating_room">"Se ha producido un error al crear la sala"</string>
|
||||
<string name="screen_create_room_error_creating_space">"No se pudo crear el espacio debido a un error desconocido. Inténtalo de nuevo más tarde."</string>
|
||||
<string name="screen_create_room_name_placeholder">"Añadir nombre…"</string>
|
||||
<string name="screen_create_room_new_room_title">"Nueva sala"</string>
|
||||
<string name="screen_create_room_new_space_title">"Nuevo espacio"</string>
|
||||
<string name="screen_create_room_private_option_description">"Solo las personas invitadas pueden acceder."</string>
|
||||
<string name="screen_create_room_private_option_title">"Privado"</string>
|
||||
<string name="screen_create_room_public_option_description">"Cualquiera puede encontrar esta sala.
|
||||
Puedes cambiar esto en cualquier momento en los ajustes de la sala."</string>
|
||||
<string name="screen_create_room_public_option_short_description">"Cualquiera puede unirse."</string>
|
||||
<string name="screen_create_room_public_option_title">"Público"</string>
|
||||
<string name="screen_create_room_room_access_section_knocking_option_description">"Cualquiera puede solicitar unirse, pero un administrador o un moderador tendrá que aceptar la solicitud"</string>
|
||||
<string name="screen_create_room_room_access_section_knocking_option_title">"Permitir solicitar unirse"</string>
|
||||
<string name="screen_create_room_room_access_section_knocking_restricted_option_description">"Cualquiera en %1$s puede unirse, pero el resto debe solicitar acceso."</string>
|
||||
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Solicitar unirse"</string>
|
||||
<string name="screen_create_room_room_access_section_private_option_description">"Solo las personas invitadas pueden unirse."</string>
|
||||
<string name="screen_create_room_room_access_section_private_option_title">"Privado"</string>
|
||||
<string name="screen_create_room_room_access_section_public_option_description">"Cualquiera puede unirse."</string>
|
||||
<string name="screen_create_room_room_access_section_public_option_title">"Público"</string>
|
||||
<string name="screen_create_room_room_access_section_restricted_option_description">"Cualquiera de %1$s puede unirse."</string>
|
||||
<string name="screen_create_room_room_access_section_restricted_option_title">"Estándar"</string>
|
||||
<string name="screen_create_room_room_access_section_title">"Quién tiene acceso"</string>
|
||||
<string name="screen_create_room_room_address_section_footer">"Necesitarás una dirección de sala para que sea visible en el directorio de salas públicas."</string>
|
||||
<string name="screen_create_room_room_address_section_title">"Dirección"</string>
|
||||
<string name="screen_create_room_room_visibility_section_title">"Visibilidad de la sala"</string>
|
||||
<string name="screen_create_room_space_selection_no_space_description">"(ningún espacio)"</string>
|
||||
<string name="screen_create_room_space_selection_no_space_option">"No añadir a un espacio"</string>
|
||||
<string name="screen_create_room_space_selection_no_space_title">"No se ha seleccionado ningún espacio"</string>
|
||||
<string name="screen_create_room_space_selection_sheet_title">"Añadir a un espacio"</string>
|
||||
<string name="screen_create_room_topic_label">"Tema (opcional)"</string>
|
||||
<string name="screen_create_room_topic_placeholder">"Añadir descripción…"</string>
|
||||
</resources>
|
||||
|
||||
@@ -3,12 +3,34 @@
|
||||
<string name="screen_create_room_action_create_room">"اتاق جدید"</string>
|
||||
<string name="screen_create_room_add_people_title">"دعوت افراد"</string>
|
||||
<string name="screen_create_room_error_creating_room">"هنگام ایجاد اتاق خطایی رخ داد"</string>
|
||||
<string name="screen_create_room_error_creating_space">"به دلیل خطای ناشناختهای، فضا ایجاد نشد. بعداً دوباره امتحان کنید."</string>
|
||||
<string name="screen_create_room_name_placeholder">"افزودن نام…"</string>
|
||||
<string name="screen_create_room_new_room_title">"اتاق جدید"</string>
|
||||
<string name="screen_create_room_new_space_title">"فضای جدید"</string>
|
||||
<string name="screen_create_room_private_option_description">"تنها افراد دعوت شده میتوانند بپیوندند."</string>
|
||||
<string name="screen_create_room_private_option_title">"خصوصی"</string>
|
||||
<string name="screen_create_room_public_option_description">"هرکسی میتواند اتاق را بیابد.
|
||||
میتوانید بعداً در تظیمات اتاق عوضش کنید."</string>
|
||||
<string name="screen_create_room_room_access_section_knocking_option_title">"درخواست دعوت"</string>
|
||||
<string name="screen_create_room_room_access_section_public_option_description">"هرکسی میتواند به این اتاق بپیوندد"</string>
|
||||
<string name="screen_create_room_room_address_section_title">"نشانی اتاق"</string>
|
||||
<string name="screen_create_room_public_option_short_description">"هر کسی میتواند عضو شود."</string>
|
||||
<string name="screen_create_room_public_option_title">"عمومی"</string>
|
||||
<string name="screen_create_room_room_access_section_knocking_option_description">"هر کسی میتواند درخواست عضویت بدهد، اما یک مدیر یا ناظر باید درخواست را بپذیرد."</string>
|
||||
<string name="screen_create_room_room_access_section_knocking_option_title">"اجازه درخواست عضویت"</string>
|
||||
<string name="screen_create_room_room_access_section_knocking_restricted_option_description">"هر کسی در %1$s میتواند عضو شود، اما بقیه باید درخواست دسترسی بدهند."</string>
|
||||
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"درخواست عضویت"</string>
|
||||
<string name="screen_create_room_room_access_section_private_option_description">"تنها افراد دعوت شده میتوانند بپیوندند."</string>
|
||||
<string name="screen_create_room_room_access_section_private_option_title">"خصوصی"</string>
|
||||
<string name="screen_create_room_room_access_section_public_option_description">"هر کسی میتواند عضو شود."</string>
|
||||
<string name="screen_create_room_room_access_section_public_option_title">"عمومی"</string>
|
||||
<string name="screen_create_room_room_access_section_restricted_option_description">"هر کسی که در %1$s باشد میتواند عضو شود."</string>
|
||||
<string name="screen_create_room_room_access_section_restricted_option_title">"استاندارد"</string>
|
||||
<string name="screen_create_room_room_access_section_title">"چه کسی دسترسی دارد"</string>
|
||||
<string name="screen_create_room_room_address_section_footer">"برای اینکه بتوانید آن را در فهرست عمومی مشاهده کنید، به یک آدرس نیاز دارید."</string>
|
||||
<string name="screen_create_room_room_address_section_title">"آدرس"</string>
|
||||
<string name="screen_create_room_room_visibility_section_title">"نمایانی اتاق"</string>
|
||||
<string name="screen_create_room_space_selection_no_space_description">"(بدون فضا)"</string>
|
||||
<string name="screen_create_room_space_selection_no_space_option">"به یک فضا اضافه نکنید"</string>
|
||||
<string name="screen_create_room_space_selection_no_space_title">"فضایی انتخاب نشده است"</string>
|
||||
<string name="screen_create_room_space_selection_sheet_title">"به فضا اضافه کنید"</string>
|
||||
<string name="screen_create_room_topic_label">"موضوع (اختیاری)"</string>
|
||||
<string name="screen_create_room_topic_placeholder">"اضافه کردن توضیحات…"</string>
|
||||
</resources>
|
||||
|
||||
+2
-2
@@ -299,10 +299,10 @@ private fun Content(
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that the string does not contain any new line characters, which can happen when pasting values.
|
||||
* Ensure that the string does not contain any line separator, which can happen when pasting values.
|
||||
*/
|
||||
private fun String.sanitize(): String {
|
||||
return replace("\n", "")
|
||||
return filterNot { it == '\n' || it == '\r' }
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_deactivate_account_confirmation_dialog_content">"يرجى تأكيد رغبتك في حذف حسابك. لا يمكن التراجع عن هذا الإجراء."</string>
|
||||
<string name="screen_deactivate_account_delete_all_messages">"حذف جميع رسائلي"</string>
|
||||
<string name="screen_deactivate_account_delete_all_messages_notice">"تحذير: قد يرى المستخدمون في المستقبل محادثات غير مكتملة."</string>
|
||||
<string name="screen_deactivate_account_description">"حذف حسابك %1$s، وسيؤدي إلى:"</string>
|
||||
<string name="screen_deactivate_account_description_bold_part">"إجراء لا رجعة فيه"</string>
|
||||
<string name="screen_deactivate_account_list_item_1">"%1$s (لن تتمكن من تسجيل الدخول مرة أخرى، ولا يمكن إعادة استخدام معرّفك)."</string>
|
||||
<string name="screen_deactivate_account_list_item_1_bold_part">"تعطيل حسابك نهائيًا"</string>
|
||||
<string name="screen_deactivate_account_list_item_2">"إزالتك من جميع غرف الدردشة."</string>
|
||||
<string name="screen_deactivate_account_list_item_3">"حذف معلومات حسابك من خادم الهوية لدينا."</string>
|
||||
<string name="screen_deactivate_account_list_item_4">"ستظل رسائلك مرئية للمستخدمين المسجلين، لكنها لن تكون متاحة للمستخدمين الجدد أو غير المسجلين إذا اخترت حذفها."</string>
|
||||
<string name="screen_deactivate_account_title">"حذف الحساب"</string>
|
||||
</resources>
|
||||
@@ -1,13 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_deactivate_account_confirmation_dialog_content">"Confirma que quieres desactivar tu cuenta. Esta acción no se puede deshacer."</string>
|
||||
<string name="screen_deactivate_account_confirmation_dialog_content">"Confirma que quieres eliminar tu cuenta. Esta acción no se puede deshacer."</string>
|
||||
<string name="screen_deactivate_account_delete_all_messages">"Borrar todos mis mensajes"</string>
|
||||
<string name="screen_deactivate_account_delete_all_messages_notice">"Advertencia: Futuros usuarios pueden ver conversaciones incompletas."</string>
|
||||
<string name="screen_deactivate_account_description">"Desactivar tu cuenta es %1$s:"</string>
|
||||
<string name="screen_deactivate_account_description">"Eliminar tu cuenta es %1$s:"</string>
|
||||
<string name="screen_deactivate_account_description_bold_part">"irreversible"</string>
|
||||
<string name="screen_deactivate_account_list_item_1">"%1$s tu cuenta (no podrás volver a iniciar sesión y tu ID no se podrá volver a utilizar)."</string>
|
||||
<string name="screen_deactivate_account_list_item_1_bold_part">"Inhabilitará permanentemente"</string>
|
||||
<string name="screen_deactivate_account_list_item_2">"Te eliminará de todas las salas de chat."</string>
|
||||
<string name="screen_deactivate_account_list_item_3">"Eliminará la información de tu cuenta de nuestro servidor de identidad."</string>
|
||||
<string name="screen_deactivate_account_list_item_4">"Tus mensajes seguirán siendo visibles para los usuarios registrados, pero no estarán disponibles para los usuarios nuevos o no registrados si decides eliminarlos."</string>
|
||||
<string name="screen_deactivate_account_title">"Eliminar cuenta"</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_deactivate_account_confirmation_dialog_content">"لطفاً تأیید کنید که میخواهید حساب کاربری خود را حذف کنید. این اقدام قابل بازگشت نیست."</string>
|
||||
<string name="screen_deactivate_account_delete_all_messages">"حذف همهٔ پیامهایم"</string>
|
||||
<string name="screen_deactivate_account_delete_all_messages_notice">"هشدار: کاربران آینده ممکن است مکالمات ناقص را ببینند."</string>
|
||||
<string name="screen_deactivate_account_description">"حذف حساب کاربری شما %1$s است، این کار باعث میشود:"</string>
|
||||
<string name="screen_deactivate_account_description_bold_part">"بازگشتناپذیر"</string>
|
||||
<string name="screen_deactivate_account_list_item_1">"حساب کاربری خود را %1$s کنید (شما نمیتوانید دوباره وارد سیستم شوید و شناسه شما قابل استفاده مجدد نیست)."</string>
|
||||
<string name="screen_deactivate_account_list_item_1_bold_part">"از کار انداختن دایمی"</string>
|
||||
<string name="screen_deactivate_account_list_item_2">"برداشتنتان از همهٔ اتاقهای گپ."</string>
|
||||
<string name="screen_deactivate_account_list_item_3">"حذف اطّلاعات حسابتان از کارساز هویت."</string>
|
||||
<string name="screen_deactivate_account_title">"غیرفعّالسازی حساب"</string>
|
||||
<string name="screen_deactivate_account_list_item_4">"پیامهای شما همچنان برای کاربران ثبتنامشده قابل مشاهده خواهند بود، اما اگر تصمیم به حذف آنها بگیرید، برای کاربران جدید یا ثبتنامنشده در دسترس نخواهند بود."</string>
|
||||
<string name="screen_deactivate_account_title">"حذف حساب کاربری"</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_deactivate_account_confirmation_dialog_content">"請確認您想要停用您的帳號。此動作無法還原。"</string>
|
||||
<string name="screen_deactivate_account_confirmation_dialog_content">"請確認您想要刪除您的帳號。此動作無法還原。"</string>
|
||||
<string name="screen_deactivate_account_delete_all_messages">"刪除我所有的訊息"</string>
|
||||
<string name="screen_deactivate_account_delete_all_messages_notice">"警告:未來的使用者可能會看到不完整的對話。"</string>
|
||||
<string name="screen_deactivate_account_description">"停用您的帳號為 %1$s,它將:"</string>
|
||||
<string name="screen_deactivate_account_description">"刪除您的帳號為 %1$s,它將:"</string>
|
||||
<string name="screen_deactivate_account_description_bold_part">"不可逆"</string>
|
||||
<string name="screen_deactivate_account_list_item_1">"%1$s 您的帳號(您將無法重新登入,也無法重用您的 ID)。"</string>
|
||||
<string name="screen_deactivate_account_list_item_1_bold_part">"永久停用"</string>
|
||||
<string name="screen_deactivate_account_list_item_2">"將您從所有聊天室移除。"</string>
|
||||
<string name="screen_deactivate_account_list_item_3">"從我們的身份伺服器將您的帳號資訊刪除。"</string>
|
||||
<string name="screen_deactivate_account_list_item_4">"註冊使用者仍可看到您的訊息,但如果您選擇刪除,新使用者與未註冊的使用者將看不到它們。"</string>
|
||||
<string name="screen_deactivate_account_title">"停用帳號"</string>
|
||||
<string name="screen_deactivate_account_title">"刪除帳號"</string>
|
||||
</resources>
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.enterprise.api
|
||||
|
||||
import io.element.android.libraries.matrix.api.MatrixClientBuilder
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
/**
|
||||
* A hook that can be used to customize the [MatrixClientBuilder] for enterprise features.
|
||||
*/
|
||||
fun interface ClientBuilderEnterpriseHook {
|
||||
suspend operator fun invoke(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.enterprise.api
|
||||
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
|
||||
/**
|
||||
* A hook that can be used to customize the [MatrixClient] for enterprise features.
|
||||
*/
|
||||
fun interface ClientEnterpriseHook {
|
||||
suspend operator fun invoke(client: MatrixClient)
|
||||
}
|
||||
+61
-10
@@ -10,17 +10,52 @@ package io.element.android.features.enterprise.api
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import io.element.android.compound.colors.SemanticColorsLightDark
|
||||
import io.element.android.libraries.matrix.api.ClientUrlContentFetcher
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Exposes the restrictions and customisations an enterprise deployment can impose: allowed homeservers, branding, push gateways and more.
|
||||
*
|
||||
* On a standard build this reports permissive defaults, so callers do not need to branch on the build type themselves.
|
||||
*/
|
||||
interface EnterpriseService {
|
||||
val isEnterpriseBuild: Boolean
|
||||
/**
|
||||
* Whether the given session belongs to an enterprise deployment, which can be true even on a standard build.
|
||||
*
|
||||
* @param sessionId the session to check.
|
||||
*/
|
||||
suspend fun isEnterpriseUser(sessionId: SessionId): Boolean
|
||||
suspend fun tweakMasUrl(url: String, homeserver: String): String
|
||||
fun defaultHomeserverList(): List<String>
|
||||
|
||||
/**
|
||||
* Rewrites the authentication server URL when the deployment requires a different one from the one advertised.
|
||||
*
|
||||
* @param url the URL to rewrite.
|
||||
* @param urlContentFetcher used to read the deployment configuration; a client that is not authenticated yet also works.
|
||||
*/
|
||||
suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher): String
|
||||
|
||||
/**
|
||||
* Returns the list of homeservers the user is allowed to sign in to.
|
||||
*
|
||||
* If the list is empty or contains the special value [ANY_ACCOUNT_PROVIDER], the user is allowed to sign in to any homeserver.
|
||||
*/
|
||||
fun homeserverAllowList(): List<String>
|
||||
|
||||
/**
|
||||
* Whether the user is allowed to sign in to a given homeserver, according to [homeserverAllowList].
|
||||
*
|
||||
* @param homeserverUrl the server the user is trying to use.
|
||||
*/
|
||||
suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean
|
||||
|
||||
/**
|
||||
* Whether the given homeserver enforces the use of Element Pro or a derived app.
|
||||
*
|
||||
* @param serverName the homeserver to check.
|
||||
*/
|
||||
suspend fun isElementProEnforced(serverName: String): Boolean
|
||||
|
||||
/**
|
||||
* Override the brand color.
|
||||
* @param sessionId the session to override the brand color for, or null to set the brand color to use when there is no session.
|
||||
@@ -28,32 +63,48 @@ interface EnterpriseService {
|
||||
*/
|
||||
suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?)
|
||||
|
||||
/**
|
||||
* The brand color to apply, or `null` when the default theme color should be used.
|
||||
*
|
||||
* @param sessionId the session to read the color of, or `null` for the color used when no session is active.
|
||||
*/
|
||||
fun brandColorsFlow(sessionId: SessionId?): Flow<Color?>
|
||||
|
||||
/**
|
||||
* The full light and dark color palettes derived from the brand color.
|
||||
*
|
||||
* @param sessionId the session to read the colors of, or `null` for the colors used when no session is active.
|
||||
*/
|
||||
fun semanticColorsFlow(sessionId: SessionId?): Flow<SemanticColorsLightDark>
|
||||
|
||||
/** The push gateway to register with when using Firebase, or `null` to use the app default. */
|
||||
fun firebasePushGateway(): String?
|
||||
|
||||
/** The push gateway to register with when using UnifiedPush, or `null` to use the app default. */
|
||||
fun unifiedPushDefaultPushGateway(): String?
|
||||
|
||||
/**
|
||||
* Where bug reports should be submitted, which an enterprise deployment can redirect or disable entirely.
|
||||
*
|
||||
* @param sessionId the session to read the setting of, or `null` for the setting used when no session is active.
|
||||
*/
|
||||
fun bugReportUrlFlow(sessionId: SessionId?): Flow<BugReportUrl>
|
||||
|
||||
/**
|
||||
* Gets Notification Channel to use for the noisy notifications of the provided session.
|
||||
* Returns `null` when the session has no dedicated channel and the app-wide one should be used.
|
||||
*
|
||||
* @param sessionId the session whose channel is requested.
|
||||
*/
|
||||
fun getNoisyNotificationChannelId(sessionId: SessionId): String?
|
||||
|
||||
/**
|
||||
* Gets the overridden Element Well-Known data if it has been set, or null if not set.
|
||||
*/
|
||||
fun overriddenElementWellKnown(): ElementWellKnown?
|
||||
|
||||
companion object {
|
||||
const val ANY_ACCOUNT_PROVIDER = "*"
|
||||
}
|
||||
}
|
||||
|
||||
fun EnterpriseService.canConnectToAnyHomeserver(): Boolean {
|
||||
return defaultHomeserverList().let {
|
||||
return homeserverAllowList().let {
|
||||
it.isEmpty() || it.contains(EnterpriseService.ANY_ACCOUNT_PROVIDER)
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.enterprise.api
|
||||
|
||||
/**
|
||||
* A use case to check if this build of the app is an enterprise build.
|
||||
*/
|
||||
fun interface IsEnterpriseBuild {
|
||||
/**
|
||||
* Returns `true` if this build of the app is an enterprise build, `false` otherwise.
|
||||
*/
|
||||
operator fun invoke(): Boolean
|
||||
}
|
||||
+16
-2
@@ -8,11 +8,25 @@
|
||||
|
||||
package io.element.android.features.enterprise.api
|
||||
|
||||
/**
|
||||
* The session-scoped counterpart of [EnterpriseService], answering the same kind of questions for the session it belongs to.
|
||||
*
|
||||
* [init] must have run before the other members return meaningful answers.
|
||||
*/
|
||||
interface SessionEnterpriseService {
|
||||
/** Loads the deployment configuration of this session; to be called once when the session starts. */
|
||||
suspend fun init()
|
||||
|
||||
/** Whether Element Call may be used on this session, which a deployment can turn off. */
|
||||
suspend fun isElementCallAvailable(): Boolean
|
||||
|
||||
/**
|
||||
* Rewrites the authentication server URL for this session; see [EnterpriseService.tweakMasUrl].
|
||||
*
|
||||
* @param url the URL to rewrite.
|
||||
*/
|
||||
suspend fun tweakMasUrl(url: String): String
|
||||
|
||||
/** Whether the homeserver forbids creating encrypted rooms, in which case the app must not offer the option. */
|
||||
suspend fun isEncryptionDisabledByHomeserver(): Boolean
|
||||
|
||||
suspend fun init()
|
||||
}
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.enterprise.api.remoteconfig
|
||||
|
||||
/**
|
||||
* A provider of a runtime-fetched MapTiler configuration, which can be used to override the default MapTiler configuration.
|
||||
*/
|
||||
fun interface CustomMapTilerConfigProvider {
|
||||
suspend fun get(): Result<MapTilerConfig?>
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.enterprise.api.remoteconfig
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
/**
|
||||
* A `MapTiler` configuration received from the server. This configuration is used to display maps in the app.
|
||||
*/
|
||||
@Immutable
|
||||
data class MapTilerConfig(
|
||||
/** The API key to use with MapTiler. This is always not null, but can be empty/blank. */
|
||||
val apiKey: String,
|
||||
/** The style ID to use for light mode. This is optional and can be null. */
|
||||
val lightStyleId: String?,
|
||||
/** The style ID to use for light mode. This is optional and can be null. */
|
||||
val darkStyleId: String?,
|
||||
/** The base URL to use for MapTiler. This is optional and can be null. */
|
||||
val baseUrl: String?,
|
||||
)
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.enterprise.api.remoteconfig
|
||||
|
||||
sealed interface RemoteEnterpriseConfigResult<out T> {
|
||||
/**
|
||||
* Well-known data has been successfully retrieved.
|
||||
*/
|
||||
data class Success<out T>(val data: T) : RemoteEnterpriseConfigResult<T>
|
||||
|
||||
/**
|
||||
* Well-known data has been retrieved from the local cache but is outdated and a manual refresh has been launched.
|
||||
*/
|
||||
data class Outdated<out T>(val data: T) : RemoteEnterpriseConfigResult<T>
|
||||
|
||||
/**
|
||||
* Well-known data is not found (file does not exist server side, we got a 404).
|
||||
*/
|
||||
data object NotFound : RemoteEnterpriseConfigResult<Nothing>
|
||||
|
||||
/**
|
||||
* Any other error.
|
||||
*/
|
||||
data class Error(val exception: Exception) : RemoteEnterpriseConfigResult<Nothing>
|
||||
|
||||
fun dataOrNull(): T? = when (this) {
|
||||
is Success<T> -> data
|
||||
is Outdated<T> -> data
|
||||
is Error -> null
|
||||
is NotFound -> null
|
||||
}
|
||||
|
||||
fun upToDateDataOrNull(): T? = when (this) {
|
||||
is Success<T> -> data
|
||||
is Outdated<T> -> null
|
||||
is Error -> null
|
||||
is NotFound -> null
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.enterprise.impl
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.features.enterprise.api.ClientBuilderEnterpriseHook
|
||||
import io.element.android.libraries.matrix.api.MatrixClientBuilder
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
/**
|
||||
* Default implementation of [ClientBuilderEnterpriseHook] that does nothing for FOSS.
|
||||
*/
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultClientBuilderEnterpriseHook : ClientBuilderEnterpriseHook {
|
||||
override suspend fun invoke(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder {
|
||||
// No modification
|
||||
return clientBuilder
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.enterprise.impl
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.features.enterprise.api.ClientEnterpriseHook
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
|
||||
/**
|
||||
* Default implementation of [ClientEnterpriseHook] that does nothing for FOSS.
|
||||
*/
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultClientEnterpriseHook : ClientEnterpriseHook {
|
||||
override suspend fun invoke(client: MatrixClient) {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
+4
-7
@@ -14,19 +14,18 @@ import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.compound.colors.SemanticColorsLightDark
|
||||
import io.element.android.features.enterprise.api.BugReportUrl
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.libraries.matrix.api.ClientUrlContentFetcher
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultEnterpriseService : EnterpriseService {
|
||||
override val isEnterpriseBuild = false
|
||||
|
||||
override suspend fun isEnterpriseUser(sessionId: SessionId) = false
|
||||
override suspend fun tweakMasUrl(url: String, homeserver: String) = url
|
||||
override fun defaultHomeserverList(): List<String> = emptyList()
|
||||
override suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher) = url
|
||||
override fun homeserverAllowList(): List<String> = emptyList()
|
||||
override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true
|
||||
override suspend fun isElementProEnforced(serverName: String): Boolean = false
|
||||
|
||||
override suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?) = Unit
|
||||
|
||||
@@ -46,6 +45,4 @@ class DefaultEnterpriseService : EnterpriseService {
|
||||
}
|
||||
|
||||
override fun getNoisyNotificationChannelId(sessionId: SessionId): String? = null
|
||||
|
||||
override fun overriddenElementWellKnown(): ElementWellKnown? = null
|
||||
}
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.enterprise.impl
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.features.enterprise.api.IsEnterpriseBuild
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultIsEnterpriseBuild : IsEnterpriseBuild {
|
||||
override fun invoke(): Boolean = false
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.enterprise.impl.remoteconfig
|
||||
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider
|
||||
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultCustomMapTilerConfigProvider : CustomMapTilerConfigProvider {
|
||||
override suspend fun get(): Result<MapTilerConfig?> = Result.success(null)
|
||||
}
|
||||
+2
-8
@@ -19,15 +19,9 @@ import org.junit.Test
|
||||
|
||||
class DefaultEnterpriseServiceTest {
|
||||
@Test
|
||||
fun `isEnterpriseBuild is false`() {
|
||||
fun homeserverWhitelist() {
|
||||
val defaultEnterpriseService = DefaultEnterpriseService()
|
||||
assertThat(defaultEnterpriseService.isEnterpriseBuild).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `defaultHomeserverList should return empty list`() {
|
||||
val defaultEnterpriseService = DefaultEnterpriseService()
|
||||
assertThat(defaultEnterpriseService.defaultHomeserverList()).isEmpty()
|
||||
assertThat(defaultEnterpriseService.homeserverAllowList()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+10
-11
@@ -12,8 +12,8 @@ import androidx.compose.ui.graphics.Color
|
||||
import io.element.android.compound.colors.SemanticColorsLightDark
|
||||
import io.element.android.features.enterprise.api.BugReportUrl
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.libraries.matrix.api.ClientUrlContentFetcher
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.simulateLongTask
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class FakeEnterpriseService(
|
||||
override val isEnterpriseBuild: Boolean = false,
|
||||
private val isEnterpriseUserResult: (SessionId) -> Boolean = { lambdaError() },
|
||||
private val defaultHomeserverListResult: () -> List<String> = { emptyList() },
|
||||
private val isAllowedToConnectToHomeserverResult: (String) -> Boolean = { lambdaError() },
|
||||
@@ -31,8 +30,8 @@ class FakeEnterpriseService(
|
||||
private val firebasePushGatewayResult: () -> String? = { lambdaError() },
|
||||
private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() },
|
||||
private val getNoisyNotificationChannelIdResult: (SessionId?) -> String? = { lambdaError() },
|
||||
private val tweakMasUrlResult: (String, String) -> String = { _, _ -> lambdaError() },
|
||||
private val overrideWellKnownResult: () -> ElementWellKnown? = { lambdaError() },
|
||||
private val tweakMasUrlResult: (String, ClientUrlContentFetcher) -> String = { _, _ -> lambdaError() },
|
||||
private val isElementProEnforcedResult: (String) -> Boolean = { lambdaError() },
|
||||
) : EnterpriseService {
|
||||
private val brandColorState = MutableStateFlow(initialBrandColor)
|
||||
private val semanticColorsState = MutableStateFlow(initialSemanticColors)
|
||||
@@ -41,11 +40,11 @@ class FakeEnterpriseService(
|
||||
isEnterpriseUserResult(sessionId)
|
||||
}
|
||||
|
||||
override suspend fun tweakMasUrl(url: String, homeserver: String): String = simulateLongTask {
|
||||
tweakMasUrlResult(url, homeserver)
|
||||
override suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher): String = simulateLongTask {
|
||||
tweakMasUrlResult(url, urlContentFetcher)
|
||||
}
|
||||
|
||||
override fun defaultHomeserverList(): List<String> {
|
||||
override fun homeserverAllowList(): List<String> {
|
||||
return defaultHomeserverListResult()
|
||||
}
|
||||
|
||||
@@ -53,6 +52,10 @@ class FakeEnterpriseService(
|
||||
isAllowedToConnectToHomeserverResult(homeserverUrl)
|
||||
}
|
||||
|
||||
override suspend fun isElementProEnforced(serverName: String): Boolean = simulateLongTask {
|
||||
isElementProEnforcedResult(serverName)
|
||||
}
|
||||
|
||||
override suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?) = simulateLongTask {
|
||||
overrideBrandColorResult(sessionId, brandColor)
|
||||
}
|
||||
@@ -81,8 +84,4 @@ class FakeEnterpriseService(
|
||||
override fun getNoisyNotificationChannelId(sessionId: SessionId): String? {
|
||||
return getNoisyNotificationChannelIdResult(sessionId)
|
||||
}
|
||||
|
||||
override fun overriddenElementWellKnown(): ElementWellKnown? {
|
||||
return overrideWellKnownResult()
|
||||
}
|
||||
}
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.enterprise.test.remoteconfig
|
||||
|
||||
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
|
||||
|
||||
fun aMapTilerConfig(
|
||||
apiKey: String = "test_api_key",
|
||||
baseUrl: String? = null,
|
||||
lightStyleId: String? = null,
|
||||
darkStyleId: String? = null,
|
||||
) = MapTilerConfig(
|
||||
apiKey = apiKey,
|
||||
baseUrl = baseUrl,
|
||||
lightStyleId = lightStyleId,
|
||||
darkStyleId = darkStyleId,
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_identity_confirmation_cannot_confirm">"لا يمكنك التأكيد؟"</string>
|
||||
<string name="screen_identity_confirmation_create_new_recovery_key">"إنشاء مفتاح استرداد جديد"</string>
|
||||
<string name="screen_identity_confirmation_subtitle">"اختر طريقة التحقق لإعداد المراسلة الآمنة."</string>
|
||||
<string name="screen_identity_confirmation_title">"تأكيد هويتك الرقمية"</string>
|
||||
<string name="screen_identity_confirmation_use_another_device">"استخدام جهاز آخر"</string>
|
||||
<string name="screen_identity_confirmation_use_recovery_key">"استخدام مفتاح الاسترداد"</string>
|
||||
<string name="screen_identity_confirmed_subtitle">"يمكنك الآن قراءة الرسائل أو إرسالها بأمان، ويمكن لأي شخص تدردش معه أن يثق بهذا الجهاز أيضًا."</string>
|
||||
<string name="screen_identity_confirmed_title">"تم التحقق من الجهاز"</string>
|
||||
<string name="screen_identity_use_another_device">"استخدام جهاز آخر"</string>
|
||||
<string name="screen_identity_waiting_on_other_device">"في انتظار الجهاز الآخر…"</string>
|
||||
<string name="screen_notification_optin_subtitle">"يمكنك تغيير إعداداتك لاحقاً."</string>
|
||||
<string name="screen_notification_optin_title">"السماح بالإشعارات وعدم تفويت أي رسالة"</string>
|
||||
</resources>
|
||||
@@ -2,8 +2,8 @@
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_identity_confirmation_cannot_confirm">"¿No puedes confirmar?"</string>
|
||||
<string name="screen_identity_confirmation_create_new_recovery_key">"Crear una nueva clave de recuperación"</string>
|
||||
<string name="screen_identity_confirmation_subtitle">"Verifica este dispositivo para configurar la mensajería segura."</string>
|
||||
<string name="screen_identity_confirmation_title">"Confirma que eres tú"</string>
|
||||
<string name="screen_identity_confirmation_subtitle">"Elige cómo verificar para configurar la mensajería segura."</string>
|
||||
<string name="screen_identity_confirmation_title">"Confirma tu identidad digital"</string>
|
||||
<string name="screen_identity_confirmation_use_another_device">"Usar otro dispositivo"</string>
|
||||
<string name="screen_identity_confirmation_use_recovery_key">"Usar clave de recuperación"</string>
|
||||
<string name="screen_identity_confirmed_subtitle">"Ahora puedes leer o enviar mensajes de forma segura y cualquier persona con la que chatees también puede confiar en este dispositivo."</string>
|
||||
|
||||
+2
-1
@@ -12,6 +12,7 @@ import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
|
||||
@@ -23,7 +24,7 @@ interface HomeEntryPoint : FeatureEntryPoint {
|
||||
): Node
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?)
|
||||
fun navigateToRoom(roomId: RoomId, eventId: EventId?, joinedRoom: JoinedRoom?)
|
||||
fun navigateToCreateRoom()
|
||||
fun navigateToCreateSpace()
|
||||
fun navigateToSettings()
|
||||
|
||||
+10
-3
@@ -56,6 +56,7 @@ import io.element.android.libraries.designsystem.utils.DelayedVisibility
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -183,6 +184,7 @@ class HomeFlowNode(
|
||||
|
||||
fun navigateToRoom(
|
||||
roomId: RoomId,
|
||||
eventId: EventId?,
|
||||
) {
|
||||
if (!loadingJoinedRoomJob.value.isUninitialized()) {
|
||||
Timber.w("Already loading a room, ignoring navigateToRoom for $roomId")
|
||||
@@ -195,7 +197,7 @@ class HomeFlowNode(
|
||||
}.fold(
|
||||
onSuccess = { joinedRoom ->
|
||||
if (isActive) {
|
||||
callback.navigateToRoom(roomId, joinedRoom)
|
||||
callback.navigateToRoom(roomId = roomId, eventId = eventId, joinedRoom = joinedRoom)
|
||||
loadingJoinedRoomJob.value = AsyncData.Success(coroutineContext.job)
|
||||
// Wait a bit before resetting the state to avoid allowing to open several rooms
|
||||
delay(200.milliseconds)
|
||||
@@ -205,7 +207,7 @@ class HomeFlowNode(
|
||||
onFailure = {
|
||||
// If the operation wasn't cancelled, navigate without the room, using the room id
|
||||
if (it !is CancellationException) {
|
||||
callback.navigateToRoom(roomId, null)
|
||||
callback.navigateToRoom(roomId = roomId, eventId = null, joinedRoom = null)
|
||||
}
|
||||
loadingJoinedRoomJob.value = AsyncData.Failure(error = it, prevData = coroutineContext.job)
|
||||
// Wait a bit before resetting the state to avoid allowing to open several rooms
|
||||
@@ -233,7 +235,7 @@ class HomeFlowNode(
|
||||
acceptDeclineInviteView = {
|
||||
acceptDeclineInviteView.Render(
|
||||
state = state.roomListState.acceptDeclineInviteState,
|
||||
onAcceptInviteSuccess = ::navigateToRoom,
|
||||
onAcceptInviteSuccess = { navigateToRoom(roomId = it, eventId = null) },
|
||||
onDeclineInviteSuccess = { },
|
||||
modifier = Modifier
|
||||
)
|
||||
@@ -269,6 +271,11 @@ class HomeFlowNode(
|
||||
parentNode = this,
|
||||
buildContext = buildContext,
|
||||
inviteData = navTarget.inviteData,
|
||||
callback = object : DeclineInviteAndBlockEntryPoint.Callback {
|
||||
override fun onDeclineSuccess() {
|
||||
backstack.pop()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
is NavTarget.SelectNewOwnersWhenLeavingRoom -> {
|
||||
|
||||
+41
-16
@@ -55,6 +55,8 @@ import io.element.android.features.home.impl.roomlist.RoomListContextMenu
|
||||
import io.element.android.features.home.impl.roomlist.RoomListDeclineInviteMenu
|
||||
import io.element.android.features.home.impl.roomlist.RoomListEvent
|
||||
import io.element.android.features.home.impl.roomlist.RoomListState
|
||||
import io.element.android.features.home.impl.search.GlobalSearchEvent
|
||||
import io.element.android.features.home.impl.search.GlobalSearchView
|
||||
import io.element.android.features.home.impl.search.RoomListSearchView
|
||||
import io.element.android.features.home.impl.spacefilters.SpaceFiltersEvent
|
||||
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
|
||||
@@ -73,6 +75,7 @@ import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
|
||||
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -80,7 +83,7 @@ import kotlinx.coroutines.launch
|
||||
@Composable
|
||||
fun HomeView(
|
||||
homeState: HomeState,
|
||||
onRoomClick: (RoomId) -> Unit,
|
||||
onRoomClick: (RoomId, EventId?) -> Unit,
|
||||
onSettingsClick: () -> Unit,
|
||||
onSetUpRecoveryClick: () -> Unit,
|
||||
onConfirmRecoveryKeyClick: () -> Unit,
|
||||
@@ -122,22 +125,34 @@ fun HomeView(
|
||||
state = homeState,
|
||||
onSetUpRecoveryClick = onSetUpRecoveryClick,
|
||||
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
|
||||
onRoomClick = { if (firstThrottler.canHandle()) onRoomClick(it) },
|
||||
onRoomClick = { roomId -> if (firstThrottler.canHandle()) onRoomClick(roomId, null) },
|
||||
onOpenSettings = { if (firstThrottler.canHandle()) onSettingsClick() },
|
||||
onStartChatClick = { if (firstThrottler.canHandle()) onStartChatClick() },
|
||||
onCreateSpaceClick = { if (firstThrottler.canHandle()) onCreateSpaceClick() },
|
||||
onMenuActionClick = onMenuActionClick,
|
||||
)
|
||||
// This overlaid view will only be visible when state.displaySearchResults is true
|
||||
RoomListSearchView(
|
||||
state = state.searchState,
|
||||
eventSink = state.eventSink,
|
||||
hideInvitesAvatars = state.hideInvitesAvatars,
|
||||
onRoomClick = { if (firstThrottler.canHandle()) onRoomClick(it) },
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(ElementTheme.colors.bgCanvasDefault)
|
||||
)
|
||||
|
||||
if (state.globalSearchState.isEnabled) {
|
||||
GlobalSearchView(
|
||||
state = state.globalSearchState,
|
||||
onSelectSearchResult = { roomId, eventId -> if (firstThrottler.canHandle()) onRoomClick(roomId, eventId) },
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(ElementTheme.colors.bgCanvasDefault),
|
||||
)
|
||||
} else {
|
||||
// This overlaid view will only be visible when state.displaySearchResults is true
|
||||
RoomListSearchView(
|
||||
state = state.searchState,
|
||||
eventSink = state.eventSink,
|
||||
hideInvitesAvatars = state.hideInvitesAvatars,
|
||||
onRoomClick = { roomId -> if (firstThrottler.canHandle()) onRoomClick(roomId, null) },
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(ElementTheme.colors.bgCanvasDefault)
|
||||
)
|
||||
}
|
||||
|
||||
acceptDeclineInviteView()
|
||||
}
|
||||
}
|
||||
@@ -186,8 +201,18 @@ private fun HomeScaffold(
|
||||
selectedNavigationItem = state.currentHomeNavigationBarItem,
|
||||
currentUserAndNeighbors = state.currentUserAndNeighbors,
|
||||
showAvatarIndicator = state.showAvatarIndicator,
|
||||
areSearchResultsDisplayed = roomListState.searchState.isSearchActive,
|
||||
onToggleSearch = { roomListState.eventSink(RoomListEvent.ToggleSearchResults) },
|
||||
areSearchResultsDisplayed = if (roomListState.globalSearchState.isEnabled) {
|
||||
roomListState.globalSearchState.isSearchActive
|
||||
} else {
|
||||
roomListState.searchState.isSearchActive
|
||||
},
|
||||
onToggleSearch = {
|
||||
if (roomListState.globalSearchState.isEnabled) {
|
||||
roomListState.globalSearchState.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
|
||||
} else {
|
||||
roomListState.eventSink(RoomListEvent.ToggleSearchResults)
|
||||
}
|
||||
},
|
||||
onMenuActionClick = onMenuActionClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onAccountSwitch = {
|
||||
@@ -349,7 +374,7 @@ internal fun RoomListRoomSummary.contentType() = displayType.ordinal
|
||||
internal fun HomeViewPreview(@PreviewParameter(HomeStateProvider::class) state: HomeState) = ElementPreview {
|
||||
HomeView(
|
||||
homeState = state,
|
||||
onRoomClick = {},
|
||||
onRoomClick = { _, _ -> },
|
||||
onSettingsClick = {},
|
||||
onSetUpRecoveryClick = {},
|
||||
onConfirmRecoveryKeyClick = {},
|
||||
@@ -369,7 +394,7 @@ internal fun HomeViewPreview(@PreviewParameter(HomeStateProvider::class) state:
|
||||
internal fun HomeViewA11yPreview() = ElementPreview {
|
||||
HomeView(
|
||||
homeState = aHomeState(),
|
||||
onRoomClick = {},
|
||||
onRoomClick = { _, _ -> },
|
||||
onSettingsClick = {},
|
||||
onSetUpRecoveryClick = {},
|
||||
onConfirmRecoveryKeyClick = {},
|
||||
|
||||
+127
-5
@@ -8,6 +8,8 @@
|
||||
|
||||
package io.element.android.features.home.impl.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
@@ -15,9 +17,11 @@ import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.pager.VerticalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
@@ -25,8 +29,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -37,10 +44,20 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.appconfig.RoomListConfig
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
@@ -71,10 +88,13 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.api.user.UserStatus
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||
import io.element.android.libraries.matrix.ui.model.toEmojiText
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.testtags.testTag
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@@ -151,7 +171,7 @@ fun HomeTopBar(
|
||||
},
|
||||
// We want a 16dp left padding for the navigationIcon :
|
||||
// 4dp from default TopAppBarHorizontalPadding
|
||||
// 8dp from AccountIcon default padding (because of IconButton)
|
||||
// 8dp from AccountIcon default padding (48dp touch target around 32dp avatar)
|
||||
// 4dp extra padding using left insets
|
||||
windowInsets = WindowInsets(left = 4.dp),
|
||||
)
|
||||
@@ -316,19 +336,36 @@ private fun AccountIcon(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val testTag = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier
|
||||
IconButton(
|
||||
modifier = modifier.then(testTag),
|
||||
onClick = onClick,
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Box(
|
||||
modifier = modifier
|
||||
.then(testTag)
|
||||
.minimumInteractiveComponentSize()
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
indication = ripple(bounded = false),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box {
|
||||
val avatarData by remember(matrixUser) {
|
||||
derivedStateOf {
|
||||
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
|
||||
}
|
||||
}
|
||||
val statusEmoji = matrixUser.displayedStatus?.toEmojiText()
|
||||
val avatarModifier = if (statusEmoji != null) {
|
||||
Modifier.eraseStatusEmojiBackground(
|
||||
parentSize = AvatarSize.CurrentUserTopBar.dp,
|
||||
layoutDirection = LocalLayoutDirection.current,
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
Avatar(
|
||||
avatarData = avatarData,
|
||||
avatarType = AvatarType.User,
|
||||
modifier = avatarModifier,
|
||||
contentDescription = if (isCurrentAccount) {
|
||||
if (showAvatarIndicator) {
|
||||
stringResource(CommonStrings.a11y_settings_with_required_action)
|
||||
@@ -339,11 +376,69 @@ private fun AccountIcon(
|
||||
null
|
||||
},
|
||||
)
|
||||
if (statusEmoji != null) {
|
||||
StatusEmojiBadge(
|
||||
emoji = statusEmoji,
|
||||
modifier = Modifier.align(Alignment.BottomEnd),
|
||||
)
|
||||
}
|
||||
if (showAvatarIndicator) {
|
||||
RedIndicatorAtom(
|
||||
modifier = Modifier.align(Alignment.TopEnd)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val statusEmojiBadgeSize = 20.dp
|
||||
private val statusEmojiBadgeRadius = statusEmojiBadgeSize / 2
|
||||
private val statusEmojiBadgeOffset = 8.dp
|
||||
|
||||
private fun Modifier.eraseStatusEmojiBackground(
|
||||
parentSize: Dp,
|
||||
layoutDirection: LayoutDirection,
|
||||
): Modifier = this
|
||||
.graphicsLayer {
|
||||
compositingStrategy = CompositingStrategy.Offscreen
|
||||
}
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
drawCircle(
|
||||
color = Color.Black,
|
||||
center = Offset(
|
||||
x = if (layoutDirection == LayoutDirection.Ltr) {
|
||||
(parentSize - statusEmojiBadgeRadius + statusEmojiBadgeOffset).toPx()
|
||||
} else {
|
||||
(statusEmojiBadgeRadius - statusEmojiBadgeOffset).toPx()
|
||||
},
|
||||
y = size.height - statusEmojiBadgeRadius.toPx(),
|
||||
),
|
||||
radius = statusEmojiBadgeRadius.toPx(),
|
||||
blendMode = BlendMode.Clear,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusEmojiBadge(
|
||||
emoji: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
// Ensure the emoji size is not affected by the user's font scale settings
|
||||
val fixedFontScaleDensity = remember(density.density) {
|
||||
Density(density = density.density, fontScale = 1f)
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
.offset(x = statusEmojiBadgeOffset)
|
||||
.size(statusEmojiBadgeSize),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CompositionLocalProvider(LocalDensity provides fixedFontScaleDensity) {
|
||||
Text(
|
||||
text = emoji,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -432,6 +527,33 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun HomeTopBarWithStatusPreview() = ElementPreview {
|
||||
HomeTopBar(
|
||||
selectedNavigationItem = HomeNavigationBarItem.Chats,
|
||||
currentUserAndNeighbors = persistentListOf(
|
||||
aMatrixUser(
|
||||
id = "@id:domain",
|
||||
displayName = USER_NAME_ALICE,
|
||||
displayedStatus = DisplayedStatus.UserSet(UserStatus(emoji = "🌴", text = "Away")),
|
||||
)
|
||||
),
|
||||
showAvatarIndicator = false,
|
||||
areSearchResultsDisplayed = false,
|
||||
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
|
||||
onOpenSettings = {},
|
||||
onAccountSwitch = {},
|
||||
onToggleSearch = {},
|
||||
canReportBug = true,
|
||||
displayFilters = true,
|
||||
filtersState = aRoomListFiltersState(),
|
||||
spaceFiltersState = anUnselectedSpaceFiltersState(),
|
||||
onMenuActionClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
|
||||
+5
@@ -15,6 +15,8 @@ import io.element.android.features.home.impl.filters.RoomListFiltersPresenter
|
||||
import io.element.android.features.home.impl.filters.RoomListFiltersState
|
||||
import io.element.android.features.home.impl.roomlist.RoomListPresenter
|
||||
import io.element.android.features.home.impl.roomlist.RoomListState
|
||||
import io.element.android.features.home.impl.search.GlobalSearchPresenter
|
||||
import io.element.android.features.home.impl.search.GlobalSearchState
|
||||
import io.element.android.features.home.impl.search.RoomListSearchPresenter
|
||||
import io.element.android.features.home.impl.search.RoomListSearchState
|
||||
import io.element.android.features.home.impl.spacefilters.SpaceFiltersPresenter
|
||||
@@ -36,4 +38,7 @@ interface RoomListModule {
|
||||
|
||||
@Binds
|
||||
fun bindSpaceFiltersPresenter(presenter: SpaceFiltersPresenter): Presenter<SpaceFiltersState>
|
||||
|
||||
@Binds
|
||||
fun bindGlobalSearchPresenter(presenter: GlobalSearchPresenter): Presenter<GlobalSearchState>
|
||||
}
|
||||
|
||||
+1
@@ -22,6 +22,7 @@ class DefaultFilterSelectionStrategy : FilterSelectionStrategy {
|
||||
override val filterSelectionStates = MutableStateFlow(buildFilters())
|
||||
|
||||
override fun select(filter: RoomListFilter) {
|
||||
if (selectedFilters.any { it in filter.incompatibleFilters }) return
|
||||
selectedFilters.add(filter)
|
||||
filterSelectionStates.value = buildFilters()
|
||||
}
|
||||
|
||||
+4
@@ -30,6 +30,7 @@ import io.element.android.features.announcement.api.AnnouncementService
|
||||
import io.element.android.features.home.impl.datasource.RoomListDataSource
|
||||
import io.element.android.features.home.impl.filters.RoomListFiltersState
|
||||
import io.element.android.features.home.impl.filters.into
|
||||
import io.element.android.features.home.impl.search.GlobalSearchState
|
||||
import io.element.android.features.home.impl.search.RoomListSearchEvent
|
||||
import io.element.android.features.home.impl.search.RoomListSearchState
|
||||
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
|
||||
@@ -85,6 +86,7 @@ class RoomListPresenter(
|
||||
private val announcementService: AnnouncementService,
|
||||
private val coldStartWatcher: AnalyticsColdStartWatcher,
|
||||
private val spaceFiltersPresenter: Presenter<SpaceFiltersState>,
|
||||
private val globalSearchPresenter: Presenter<GlobalSearchState>,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
) : Presenter<RoomListState> {
|
||||
private val encryptionService = client.encryptionService
|
||||
@@ -97,6 +99,7 @@ class RoomListPresenter(
|
||||
val searchState = searchPresenter.present()
|
||||
val spaceFiltersState = spaceFiltersPresenter.present()
|
||||
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
|
||||
val globalSearchState = globalSearchPresenter.present()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
roomListDataSource.launchIn(this)
|
||||
@@ -177,6 +180,7 @@ class RoomListPresenter(
|
||||
leaveRoomState = leaveRoomState,
|
||||
filtersState = filtersState,
|
||||
searchState = searchState,
|
||||
globalSearchState = globalSearchState,
|
||||
spaceFiltersState = spaceFiltersState,
|
||||
contentState = contentState,
|
||||
acceptDeclineInviteState = acceptDeclineInviteState,
|
||||
|
||||
+2
@@ -11,6 +11,7 @@ package io.element.android.features.home.impl.roomlist
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.home.impl.filters.RoomListFiltersState
|
||||
import io.element.android.features.home.impl.model.RoomListRoomSummary
|
||||
import io.element.android.features.home.impl.search.GlobalSearchState
|
||||
import io.element.android.features.home.impl.search.RoomListSearchState
|
||||
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
|
||||
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
|
||||
@@ -27,6 +28,7 @@ data class RoomListState(
|
||||
val leaveRoomState: LeaveRoomState,
|
||||
val filtersState: RoomListFiltersState,
|
||||
val searchState: RoomListSearchState,
|
||||
val globalSearchState: GlobalSearchState,
|
||||
val spaceFiltersState: SpaceFiltersState,
|
||||
val contentState: RoomListContentState,
|
||||
val acceptDeclineInviteState: AcceptDeclineInviteState,
|
||||
|
||||
+8
-1
@@ -16,7 +16,9 @@ import io.element.android.features.home.impl.model.RoomListRoomSummary
|
||||
import io.element.android.features.home.impl.model.RoomSummaryDisplayType
|
||||
import io.element.android.features.home.impl.model.aRoomListRoomSummary
|
||||
import io.element.android.features.home.impl.model.anInviteSender
|
||||
import io.element.android.features.home.impl.search.GlobalSearchState
|
||||
import io.element.android.features.home.impl.search.RoomListSearchState
|
||||
import io.element.android.features.home.impl.search.aGlobalSearchState
|
||||
import io.element.android.features.home.impl.search.aRoomListSearchState
|
||||
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
|
||||
import io.element.android.features.home.impl.spacefilters.anUnselectedSpaceFiltersState
|
||||
@@ -41,7 +43,10 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
|
||||
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)),
|
||||
aRoomListState(contentState = anEmptyContentState()),
|
||||
aRoomListState(contentState = aSkeletonContentState()),
|
||||
aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")),
|
||||
aRoomListState(
|
||||
searchState = aRoomListSearchState(isSearchActive = true, query = "Test"),
|
||||
globalSearchState = aGlobalSearchState(isEnabled = false),
|
||||
),
|
||||
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery)),
|
||||
aRoomListState(contentState = aRoomsContentState(batteryOptimizationState = aBatteryOptimizationState(shouldDisplayBanner = true))),
|
||||
aRoomListState(contentState = anEmptyContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)),
|
||||
@@ -53,6 +58,7 @@ internal fun aRoomListState(
|
||||
declineInviteMenu: RoomListState.DeclineInviteMenu = RoomListState.DeclineInviteMenu.Hidden,
|
||||
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
|
||||
searchState: RoomListSearchState = aRoomListSearchState(),
|
||||
globalSearchState: GlobalSearchState = aGlobalSearchState(),
|
||||
filtersState: RoomListFiltersState = aRoomListFiltersState(),
|
||||
spaceFiltersState: SpaceFiltersState = anUnselectedSpaceFiltersState(),
|
||||
contentState: RoomListContentState = aRoomsContentState(),
|
||||
@@ -66,6 +72,7 @@ internal fun aRoomListState(
|
||||
leaveRoomState = leaveRoomState,
|
||||
filtersState = filtersState,
|
||||
searchState = searchState,
|
||||
globalSearchState = globalSearchState,
|
||||
spaceFiltersState = spaceFiltersState,
|
||||
contentState = contentState,
|
||||
acceptDeclineInviteState = acceptDeclineInviteState,
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.home.impl.search
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
sealed interface GlobalSearchEvent {
|
||||
object ClearQuery : GlobalSearchEvent
|
||||
object ToggleSearchVisibility : GlobalSearchEvent
|
||||
data class UpdateVisibleRange(val range: IntRange) : GlobalSearchEvent
|
||||
data class UpdateTarget(val target: GlobalSearchTarget) : GlobalSearchEvent
|
||||
}
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.home.impl.search
|
||||
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
||||
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import io.element.android.libraries.matrix.api.room.RoomInfo
|
||||
import io.element.android.libraries.matrix.api.roomlist.LatestEventValue
|
||||
import io.element.android.libraries.matrix.api.search.MessageSearch
|
||||
import io.element.android.libraries.matrix.api.search.MessageSearchPaginationState
|
||||
import io.element.android.libraries.matrix.api.search.MessageSearchResult
|
||||
import io.element.android.libraries.matrix.api.search.MessageSearchService
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageTypeWithAttachment
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.isMediaContent
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
||||
import io.element.android.libraries.matrix.ui.messages.toPlainText
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import kotlin.jvm.optionals.getOrElse
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Inject
|
||||
class GlobalSearchPresenter(
|
||||
private val roomListSearchDataSourceFactory: RoomListSearchDataSource.Factory,
|
||||
private val messageSearchService: MessageSearchService,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val latestEventFormatter: RoomLatestEventFormatter,
|
||||
private val dateFormatter: DateFormatter,
|
||||
private val fileSizeFormatter: FileSizeFormatter,
|
||||
private val permalinkParser: PermalinkParser,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
private val matrixClient: MatrixClient,
|
||||
) : Presenter<GlobalSearchState> {
|
||||
@Composable
|
||||
override fun present(): GlobalSearchState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val isEnabled by produceState(false) {
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageSearch).collectLatest { value = it }
|
||||
}
|
||||
val roomListSearchDataSource = remember { roomListSearchDataSourceFactory.create(coroutineScope = coroutineScope) }
|
||||
val queryState = rememberTextFieldState()
|
||||
var isSearchActive by remember { mutableStateOf(false) }
|
||||
var searchResults: AsyncData<GlobalSearchResults> by remember { mutableStateOf(AsyncData.Uninitialized) }
|
||||
var currentTarget: GlobalSearchTarget by remember { mutableStateOf(GlobalSearchTarget.ROOMS) }
|
||||
val currentMessageSearch: MessageSearch = remember { messageSearchService.createMessageSearch(scope = coroutineScope) }
|
||||
|
||||
LaunchedEffect(queryState.text) {
|
||||
// Add a delay to avoid performing too many searches in a short period of time, which can be expensive
|
||||
delay(200.milliseconds)
|
||||
|
||||
searchResults = if (queryState.text.isNotEmpty()) {
|
||||
AsyncData.Loading(prevData = searchResults.dataOrNull())
|
||||
} else {
|
||||
AsyncData.Uninitialized
|
||||
}
|
||||
|
||||
launch { roomListSearchDataSource.setSearchQuery(queryState.text.toString()) }
|
||||
launch {
|
||||
currentMessageSearch.setQuery(queryState.text.toString())
|
||||
.onFailure { Timber.e(it, "Could not set query for message search") }
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(currentTarget) {
|
||||
if (queryState.text.isNotEmpty()) {
|
||||
searchResults = AsyncData.Loading()
|
||||
}
|
||||
|
||||
when (currentTarget) {
|
||||
GlobalSearchTarget.ROOMS -> {
|
||||
roomListSearchDataSource.roomSummaries.collectLatest { results ->
|
||||
Timber.d("Room list search found ${results.size} items")
|
||||
// The room list data source always returns some values, even if the query is empty,
|
||||
// so we only want to update the search results if the query is not empty
|
||||
searchResults = if (queryState.text.isNotEmpty()) {
|
||||
AsyncData.Success(GlobalSearchResults.RoomListResults(results = results))
|
||||
} else {
|
||||
AsyncData.Uninitialized
|
||||
}
|
||||
}
|
||||
}
|
||||
GlobalSearchTarget.MESSAGES -> {
|
||||
Timber.d("Message search found ${currentMessageSearch.results.value.size} items")
|
||||
combine(
|
||||
currentMessageSearch.results,
|
||||
currentMessageSearch.paginationState,
|
||||
) { results, paginationState ->
|
||||
Pair(results, paginationState)
|
||||
}.collectLatest { (results, paginationState) ->
|
||||
Timber.d("Message search found ${results.size} items, pagination state: $paginationState")
|
||||
|
||||
// Try processing the results in parallel to speed up the processing time, since we have to wait until we get the room info for each
|
||||
// result, which can be slow if we have a lot of results. The original order is kept by using `awaitAll()`.
|
||||
val mappedResults = results.map { result ->
|
||||
async(coroutineDispatchers.computation) {
|
||||
val roomInfo = matrixClient.getRoomInfoFlow(result.roomId).first().getOrElse { return@async null }
|
||||
val formattedTimestamp = dateFormatter.format(
|
||||
timestamp = result.timestamp,
|
||||
mode = DateFormatterMode.TimeOrDate,
|
||||
useRelative = true
|
||||
)
|
||||
|
||||
if (!result.content.isMediaContent()) {
|
||||
mapMessageContent(result, roomInfo, formattedTimestamp)
|
||||
} else {
|
||||
mapMediaContent(result, roomInfo, formattedTimestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
.awaitAll()
|
||||
.mapNotNull { it }
|
||||
|
||||
searchResults = when {
|
||||
queryState.text.isEmpty() -> AsyncData.Uninitialized
|
||||
mappedResults.isNotEmpty() -> {
|
||||
AsyncData.Success(GlobalSearchResults.MessageSearchResults(results = mappedResults.toImmutableList()))
|
||||
}
|
||||
paginationState is MessageSearchPaginationState.Idle && paginationState.endReached -> {
|
||||
AsyncData.Success(GlobalSearchResults.MessageSearchResults(results = persistentListOf()))
|
||||
}
|
||||
paginationState is MessageSearchPaginationState.Loading -> {
|
||||
AsyncData.Loading(prevData = searchResults.dataOrNull())
|
||||
}
|
||||
else -> {
|
||||
AsyncData.Uninitialized
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleEvent(event: GlobalSearchEvent) {
|
||||
when (event) {
|
||||
GlobalSearchEvent.ClearQuery -> {
|
||||
queryState.clearText()
|
||||
}
|
||||
GlobalSearchEvent.ToggleSearchVisibility -> {
|
||||
isSearchActive = !isSearchActive
|
||||
queryState.clearText()
|
||||
}
|
||||
is GlobalSearchEvent.UpdateVisibleRange -> {
|
||||
when (currentTarget) {
|
||||
GlobalSearchTarget.ROOMS -> coroutineScope.launch {
|
||||
// TODO Maybe this is not needed? We're not displaying the latest event or timestamp info so maybe we dont need to subscribe
|
||||
// to the room info for each room in the search results.
|
||||
roomListSearchDataSource.updateVisibleRange(event.range)
|
||||
}
|
||||
GlobalSearchTarget.MESSAGES -> coroutineScope.launch {
|
||||
val currentCount = (searchResults.dataOrNull() as? GlobalSearchResults.MessageSearchResults)?.results?.size ?: 0
|
||||
val currentPaginationState = currentMessageSearch.paginationState.value
|
||||
val canPaginate = currentPaginationState is MessageSearchPaginationState.Idle && !currentPaginationState.endReached
|
||||
if (event.range.last >= currentCount - 10 && canPaginate) {
|
||||
currentMessageSearch.paginate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is GlobalSearchEvent.UpdateTarget -> currentTarget = event.target
|
||||
}
|
||||
}
|
||||
|
||||
return GlobalSearchState(
|
||||
isEnabled = isEnabled,
|
||||
isSearchActive = isSearchActive,
|
||||
queryState = queryState,
|
||||
currentTarget = currentTarget,
|
||||
results = searchResults,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
private fun mapMessageContent(
|
||||
result: MessageSearchResult,
|
||||
roomInfo: RoomInfo,
|
||||
formattedTimestamp: String,
|
||||
): MessageSearchResultItem.Message? {
|
||||
val body = latestEventFormatter.format(
|
||||
latestEvent = LatestEventValue.Remote(
|
||||
timestamp = result.timestamp,
|
||||
content = result.content,
|
||||
senderId = result.senderId,
|
||||
senderProfile = result.senderProfile,
|
||||
isOwn = matrixClient.isMe(result.senderId),
|
||||
),
|
||||
isDmRoom = false,
|
||||
)
|
||||
return MessageSearchResultItem.Message(
|
||||
messageSearchResult = result,
|
||||
body = body?.toString() ?: "",
|
||||
formattedTimestamp = formattedTimestamp,
|
||||
roomInfo = roomInfo,
|
||||
)
|
||||
}
|
||||
|
||||
private fun mapMediaContent(
|
||||
result: MessageSearchResult,
|
||||
roomInfo: RoomInfo,
|
||||
formattedTimestamp: String,
|
||||
): MessageSearchResultItem.Media? {
|
||||
val messageType = when (val content = result.content) {
|
||||
is MessageContent if content.type is MessageTypeWithAttachment -> content.type as MessageTypeWithAttachment
|
||||
else -> return null
|
||||
}
|
||||
|
||||
val caption = messageType.toPlainText(permalinkParser, default = messageType.caption ?: messageType.filename)
|
||||
|
||||
val thumbnailType = when (messageType) {
|
||||
is ImageMessageType -> AttachmentThumbnailType.Image
|
||||
is VideoMessageType -> AttachmentThumbnailType.Video
|
||||
is AudioMessageType -> AttachmentThumbnailType.Audio
|
||||
is VoiceMessageType -> AttachmentThumbnailType.Voice
|
||||
is FileMessageType -> AttachmentThumbnailType.File
|
||||
is StickerMessageType -> AttachmentThumbnailType.Image
|
||||
}
|
||||
val thumbnailSource = when (messageType) {
|
||||
is ImageMessageType -> messageType.info?.thumbnailSource ?: messageType.source
|
||||
is VideoMessageType -> messageType.info?.thumbnailSource
|
||||
is AudioMessageType -> null
|
||||
is VoiceMessageType -> null
|
||||
is FileMessageType -> null
|
||||
is StickerMessageType -> messageType.info?.thumbnailSource ?: messageType.source
|
||||
}
|
||||
val blurhash = when (messageType) {
|
||||
is ImageMessageType -> messageType.info?.blurhash
|
||||
is VideoMessageType -> messageType.info?.blurhash
|
||||
is AudioMessageType -> null
|
||||
is VoiceMessageType -> null
|
||||
is FileMessageType -> null
|
||||
is StickerMessageType -> messageType.info?.blurhash
|
||||
}
|
||||
|
||||
val extension = messageType.filename.split(".").takeIf { it.size > 1 }?.lastOrNull()?.uppercase()
|
||||
val formattedSize = when (messageType) {
|
||||
is ImageMessageType -> messageType.info?.size
|
||||
is VideoMessageType -> messageType.info?.size
|
||||
is AudioMessageType -> messageType.info?.size
|
||||
is VoiceMessageType -> messageType.info?.size
|
||||
is FileMessageType -> messageType.info?.size
|
||||
is StickerMessageType -> messageType.info?.size
|
||||
}
|
||||
?.let(fileSizeFormatter::format)
|
||||
|
||||
val mediaContent = MediaSearchResultContent(
|
||||
filename = messageType.filename,
|
||||
extension = extension,
|
||||
caption = caption,
|
||||
formattedSize = formattedSize,
|
||||
thumbnailSource = thumbnailSource,
|
||||
thumbnailType = thumbnailType,
|
||||
blurhash = blurhash
|
||||
)
|
||||
|
||||
return MessageSearchResultItem.Media(
|
||||
messageSearchResult = result,
|
||||
mediaContent = mediaContent,
|
||||
formattedTimestamp = formattedTimestamp,
|
||||
roomInfo = roomInfo,
|
||||
)
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.home.impl.search
|
||||
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.home.impl.model.RoomListRoomSummary
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.room.RoomInfo
|
||||
import io.element.android.libraries.matrix.api.search.MessageSearchResult
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
data class GlobalSearchState(
|
||||
val isEnabled: Boolean,
|
||||
val isSearchActive: Boolean,
|
||||
val queryState: TextFieldState,
|
||||
val currentTarget: GlobalSearchTarget,
|
||||
val results: AsyncData<GlobalSearchResults>,
|
||||
val eventSink: (GlobalSearchEvent) -> Unit
|
||||
)
|
||||
|
||||
@Immutable
|
||||
enum class GlobalSearchTarget {
|
||||
ROOMS,
|
||||
MESSAGES,
|
||||
}
|
||||
|
||||
@Immutable
|
||||
sealed interface GlobalSearchResults {
|
||||
data class RoomListResults(val results: ImmutableList<RoomListRoomSummary>) : GlobalSearchResults
|
||||
data class MessageSearchResults(val results: ImmutableList<MessageSearchResultItem>) : GlobalSearchResults
|
||||
|
||||
fun isEmpty(): Boolean = when (this) {
|
||||
is RoomListResults -> results.isEmpty()
|
||||
is MessageSearchResults -> results.isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface MessageSearchResultItem {
|
||||
val roomId: RoomId
|
||||
val eventId: EventId
|
||||
val senderId: UserId
|
||||
val senderName: String
|
||||
val roomInfo: RoomInfo
|
||||
val formattedTimestamp: String
|
||||
|
||||
data class Message(
|
||||
val messageSearchResult: MessageSearchResult,
|
||||
val body: String,
|
||||
override val roomInfo: RoomInfo,
|
||||
override val formattedTimestamp: String,
|
||||
) : MessageSearchResultItem {
|
||||
override val roomId: RoomId = messageSearchResult.roomId
|
||||
override val eventId: EventId = messageSearchResult.eventId
|
||||
override val senderId: UserId = messageSearchResult.senderId
|
||||
override val senderName: String = messageSearchResult.senderProfile.getDisambiguatedDisplayName(senderId)
|
||||
}
|
||||
|
||||
data class Media(
|
||||
val messageSearchResult: MessageSearchResult,
|
||||
val mediaContent: MediaSearchResultContent,
|
||||
override val roomInfo: RoomInfo,
|
||||
override val formattedTimestamp: String,
|
||||
) : MessageSearchResultItem {
|
||||
override val roomId: RoomId = messageSearchResult.roomId
|
||||
override val eventId: EventId = messageSearchResult.eventId
|
||||
override val senderId: UserId = messageSearchResult.senderId
|
||||
override val senderName: String = messageSearchResult.senderProfile.getDisambiguatedDisplayName(senderId)
|
||||
}
|
||||
}
|
||||
|
||||
data class MediaSearchResultContent(
|
||||
val filename: String,
|
||||
val extension: String?,
|
||||
val caption: String?,
|
||||
val formattedSize: String?,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val thumbnailType: AttachmentThumbnailType,
|
||||
val blurhash: String?,
|
||||
)
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.home.impl.search
|
||||
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.home.impl.roomlist.aRoomListRoomSummaryList
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.room.CallIntentConsensus
|
||||
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
||||
import io.element.android.libraries.matrix.api.room.RoomInfo
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
|
||||
import io.element.android.libraries.matrix.api.room.join.JoinRule
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
|
||||
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
|
||||
import io.element.android.libraries.matrix.api.search.MessageSearchResult
|
||||
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
class GlobalSearchStateProvider : PreviewParameterProvider<GlobalSearchState> {
|
||||
override val values: Sequence<GlobalSearchState>
|
||||
get() = sequenceOf(
|
||||
// Not enabled, no preview
|
||||
aGlobalSearchState(isEnabled = false),
|
||||
aGlobalSearchState(isSearchActive = true, queryState = TextFieldState("Query")),
|
||||
aGlobalSearchState(
|
||||
isSearchActive = true,
|
||||
currentTarget = GlobalSearchTarget.ROOMS,
|
||||
queryState = TextFieldState("Query"),
|
||||
results = AsyncData.Loading(),
|
||||
),
|
||||
aGlobalSearchState(
|
||||
isSearchActive = true,
|
||||
currentTarget = GlobalSearchTarget.ROOMS,
|
||||
queryState = TextFieldState("Query"),
|
||||
results = AsyncData.Success(
|
||||
GlobalSearchResults.RoomListResults(
|
||||
results = aRoomListRoomSummaryList().mapIndexed { index, summary ->
|
||||
summary.copy(name = "Room with Query #${index + 1}")
|
||||
}.toPersistentList()
|
||||
)
|
||||
),
|
||||
),
|
||||
aGlobalSearchState(
|
||||
isSearchActive = true,
|
||||
currentTarget = GlobalSearchTarget.MESSAGES,
|
||||
queryState = TextFieldState("Query"),
|
||||
results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf(
|
||||
MessageSearchResultItem.Message(
|
||||
messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId1:server.org")),
|
||||
body = "A message with Query",
|
||||
roomInfo = aRoomInfo(),
|
||||
formattedTimestamp = "12:00",
|
||||
),
|
||||
MessageSearchResultItem.Media(
|
||||
messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId2:server.org")),
|
||||
mediaContent = MediaSearchResultContent(
|
||||
filename = "file.png",
|
||||
extension = "PNG",
|
||||
caption = "A caption containing Query",
|
||||
formattedSize = "1 MB",
|
||||
thumbnailSource = MediaSource("https://example.com/thumbnail.png"),
|
||||
thumbnailType = AttachmentThumbnailType.Image,
|
||||
blurhash = null,
|
||||
),
|
||||
roomInfo = aRoomInfo(),
|
||||
formattedTimestamp = "12:00",
|
||||
),
|
||||
))),
|
||||
),
|
||||
aGlobalSearchState(
|
||||
isSearchActive = true,
|
||||
currentTarget = GlobalSearchTarget.MESSAGES,
|
||||
queryState = TextFieldState("Query"),
|
||||
results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf())),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun aRoomInfo(
|
||||
id: RoomId = RoomId("!roomId:server.org"),
|
||||
name: String? = "A room",
|
||||
rawName: String? = "A room raw name",
|
||||
topic: String? = "A room topic",
|
||||
avatarUrl: String? = "https://example.com/avatar.png",
|
||||
isPublic: Boolean = true,
|
||||
isDirect: Boolean = false,
|
||||
isEncrypted: Boolean = false,
|
||||
joinRule: JoinRule? = JoinRule.Public,
|
||||
isSpace: Boolean = false,
|
||||
successorRoom: SuccessorRoom? = null,
|
||||
isFavorite: Boolean = false,
|
||||
canonicalAlias: RoomAlias? = null,
|
||||
alternativeAliases: List<RoomAlias> = emptyList(),
|
||||
currentUserMembership: CurrentUserMembership = CurrentUserMembership.JOINED,
|
||||
inviter: RoomMember? = null,
|
||||
activeMembersCount: Long = 2,
|
||||
invitedMembersCount: Long = 1,
|
||||
joinedMembersCount: Long = 1,
|
||||
highlightCount: Long = 0,
|
||||
notificationCount: Long = 0,
|
||||
userDefinedNotificationMode: RoomNotificationMode? = null,
|
||||
hasRoomCall: Boolean = false,
|
||||
roomPowerLevels: RoomPowerLevels? = RoomPowerLevels(
|
||||
values = RoomPowerLevelsValues(
|
||||
ban = 0,
|
||||
invite = 0,
|
||||
kick = 0,
|
||||
eventsDefault = 0,
|
||||
stateDefault = 0,
|
||||
redactEvents = 0,
|
||||
roomName = 0,
|
||||
roomAvatar = 0,
|
||||
roomTopic = 0,
|
||||
spaceChild = 0,
|
||||
beacon = 0,
|
||||
beaconInfo = 0
|
||||
),
|
||||
users = persistentMapOf(),
|
||||
),
|
||||
activeRoomCallParticipants: List<UserId> = emptyList(),
|
||||
heroes: List<MatrixUser> = emptyList(),
|
||||
pinnedEventIds: List<EventId> = emptyList(),
|
||||
roomCreators: List<UserId> = emptyList(),
|
||||
isMarkedUnread: Boolean = false,
|
||||
numUnreadMessages: Long = 0,
|
||||
numUnreadNotifications: Long = 0,
|
||||
numUnreadMentions: Long = 0,
|
||||
historyVisibility: RoomHistoryVisibility = RoomHistoryVisibility.Joined,
|
||||
roomVersion: String? = "11",
|
||||
privilegedCreatorRole: Boolean = false,
|
||||
isLowPriority: Boolean = false,
|
||||
activeCallIntentConsensus: CallIntentConsensus = CallIntentConsensus.None,
|
||||
isDm: Boolean = false,
|
||||
fullyReadEventId: EventId? = null,
|
||||
) = RoomInfo(
|
||||
id = id,
|
||||
name = name,
|
||||
rawName = rawName,
|
||||
topic = topic,
|
||||
avatarUrl = avatarUrl,
|
||||
isPublic = isPublic,
|
||||
isDirect = isDirect,
|
||||
isEncrypted = isEncrypted,
|
||||
joinRule = joinRule,
|
||||
isSpace = isSpace,
|
||||
successorRoom = successorRoom,
|
||||
isFavorite = isFavorite,
|
||||
canonicalAlias = canonicalAlias,
|
||||
alternativeAliases = alternativeAliases.toImmutableList(),
|
||||
currentUserMembership = currentUserMembership,
|
||||
inviter = inviter,
|
||||
activeMembersCount = activeMembersCount,
|
||||
invitedMembersCount = invitedMembersCount,
|
||||
joinedMembersCount = joinedMembersCount,
|
||||
highlightCount = highlightCount,
|
||||
notificationCount = notificationCount,
|
||||
userDefinedNotificationMode = userDefinedNotificationMode,
|
||||
hasRoomCall = hasRoomCall,
|
||||
roomPowerLevels = roomPowerLevels,
|
||||
activeRoomCallParticipants = activeRoomCallParticipants.toImmutableList(),
|
||||
heroes = heroes.toImmutableList(),
|
||||
pinnedEventIds = pinnedEventIds.toImmutableList(),
|
||||
creators = roomCreators.toImmutableList(),
|
||||
isMarkedUnread = isMarkedUnread,
|
||||
numUnreadMessages = numUnreadMessages,
|
||||
numUnreadNotifications = numUnreadNotifications,
|
||||
numUnreadMentions = numUnreadMentions,
|
||||
historyVisibility = historyVisibility,
|
||||
roomVersion = roomVersion,
|
||||
privilegedCreatorRole = privilegedCreatorRole,
|
||||
isLowPriority = isLowPriority,
|
||||
activeCallIntentConsensus = activeCallIntentConsensus,
|
||||
isDm = isDm,
|
||||
fullyReadEventId = fullyReadEventId,
|
||||
)
|
||||
|
||||
fun aMessageContent(
|
||||
body: String = "body",
|
||||
inReplyTo: InReplyTo? = null,
|
||||
isEdited: Boolean = false,
|
||||
threadInfo: EventThreadInfo? = null,
|
||||
messageType: MessageType = TextMessageType(
|
||||
body = body,
|
||||
formatted = null
|
||||
)
|
||||
) = MessageContent(
|
||||
body = body,
|
||||
inReplyTo = inReplyTo,
|
||||
isEdited = isEdited,
|
||||
threadInfo = threadInfo,
|
||||
type = messageType
|
||||
)
|
||||
|
||||
internal fun aMessageSearchResult(
|
||||
roomId: RoomId = RoomId("!roomId:server.org"),
|
||||
eventId: EventId = EventId("\$eventId:server.org"),
|
||||
senderId: UserId = UserId("@user:server.org"),
|
||||
senderProfile: ProfileDetails = aProfileDetailsReady(),
|
||||
content: MessageContent = aMessageContent(),
|
||||
timestamp: Long = 0L,
|
||||
) = MessageSearchResult(
|
||||
roomId = roomId,
|
||||
eventId = eventId,
|
||||
senderId = senderId,
|
||||
senderProfile = senderProfile,
|
||||
content = content,
|
||||
timestamp = timestamp,
|
||||
)
|
||||
|
||||
internal fun aGlobalSearchState(
|
||||
isEnabled: Boolean = true,
|
||||
isSearchActive: Boolean = false,
|
||||
queryState: TextFieldState = TextFieldState(),
|
||||
currentTarget: GlobalSearchTarget = GlobalSearchTarget.ROOMS,
|
||||
results: AsyncData<GlobalSearchResults> = AsyncData.Uninitialized,
|
||||
eventSink: (GlobalSearchEvent) -> Unit = {},
|
||||
) = GlobalSearchState(
|
||||
isEnabled = isEnabled,
|
||||
isSearchActive = isSearchActive,
|
||||
queryState = queryState,
|
||||
currentTarget = currentTarget,
|
||||
results = results,
|
||||
eventSink = eventSink
|
||||
)
|
||||
+564
@@ -0,0 +1,564 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.home.impl.search
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.home.impl.R
|
||||
import io.element.android.features.home.impl.model.RoomListRoomSummary
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarType
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.modifiers.niceClickable
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.roundToPx
|
||||
import io.element.android.libraries.designsystem.theme.components.FilledTextField
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.SegmentedButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect
|
||||
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
|
||||
import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
|
||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@Composable
|
||||
fun GlobalSearchView(
|
||||
state: GlobalSearchState,
|
||||
onSelectSearchResult: (RoomId, EventId?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BackHandler(enabled = state.isSearchActive) {
|
||||
state.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = state.isSearchActive,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
GlobalSearchContent(
|
||||
state = state,
|
||||
onBackButtonClick = {
|
||||
state.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
|
||||
},
|
||||
onSelectSearchResult = onSelectSearchResult,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GlobalSearchContent(
|
||||
state: GlobalSearchState,
|
||||
onBackButtonClick: () -> Unit,
|
||||
onSelectSearchResult: (RoomId, EventId?) -> Unit,
|
||||
) {
|
||||
val borderColor = MaterialTheme.colorScheme.tertiary
|
||||
val strokeWidth = 1.dp
|
||||
|
||||
val drawBottomLineModifier = Modifier.drawBehind {
|
||||
drawLine(
|
||||
color = borderColor,
|
||||
start = Offset(0f, size.height),
|
||||
end = Offset(size.width, size.height),
|
||||
strokeWidth = strokeWidth.value
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = { BackButton(onClick = onBackButtonClick) },
|
||||
title = {
|
||||
// The stateSaver will keep the selection state when returning to this UI
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val searchLabel = stringResource(CommonStrings.action_search)
|
||||
FilledTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.semantics { contentDescription = searchLabel },
|
||||
state = state.queryState,
|
||||
lineLimits = TextFieldLineLimits.SingleLine,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
disabledContainerColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent,
|
||||
errorIndicatorColor = Color.Transparent,
|
||||
),
|
||||
trailingIcon = if (state.queryState.text.isNotEmpty()) {
|
||||
@Composable {
|
||||
IconButton(onClick = { state.eventSink(GlobalSearchEvent.ClearQuery) }) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Close(),
|
||||
contentDescription = stringResource(CommonStrings.a11y_clear_search_field)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (!focusRequester.restoreFocusedChild()) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
focusRequester.saveFocusedChild()
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
contentWindowInsets = scaffoldScrollableContentInsets,
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(padding),
|
||||
) {
|
||||
SingleChoiceSegmentedButtonRow(
|
||||
modifier = drawBottomLineModifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp)
|
||||
) {
|
||||
SegmentedButton(
|
||||
index = 0,
|
||||
count = 2,
|
||||
selected = state.currentTarget == GlobalSearchTarget.ROOMS,
|
||||
onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.ROOMS)) },
|
||||
text = stringResource(R.string.search_section_chats),
|
||||
)
|
||||
|
||||
SegmentedButton(
|
||||
index = 1,
|
||||
count = 2,
|
||||
selected = state.currentTarget == GlobalSearchTarget.MESSAGES,
|
||||
onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) },
|
||||
text = stringResource(R.string.search_section_messages),
|
||||
)
|
||||
}
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
|
||||
state.eventSink(GlobalSearchEvent.UpdateVisibleRange(visibleRange))
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = lazyColumnContentPadding,
|
||||
state = lazyListState,
|
||||
) {
|
||||
val results = state.results.dataOrNull()
|
||||
when {
|
||||
state.results.isUninitialized() -> startSearching()
|
||||
state.results.isLoading() -> loading()
|
||||
results?.isEmpty() == true -> emptySearchResults(query = state.queryState.text.toString())
|
||||
results is GlobalSearchResults.RoomListResults -> roomListResults(
|
||||
results = results.results,
|
||||
onRoomClick = { roomId -> onSelectSearchResult(roomId, null) },
|
||||
)
|
||||
results is GlobalSearchResults.MessageSearchResults -> messageSearchResults(
|
||||
results = results.results,
|
||||
onSearchResultSelected = onSelectSearchResult,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.startSearching() {
|
||||
item {
|
||||
IconTitleSubtitleMolecule(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(40.dp),
|
||||
title = stringResource(R.string.start_searching_title),
|
||||
subTitle = stringResource(R.string.start_searching_subtitle),
|
||||
iconStyle = BigIcon.Style.Default(CompoundIcons.Search()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.loading() {
|
||||
item {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.emptySearchResults(query: String) {
|
||||
item {
|
||||
IconTitleSubtitleMolecule(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(40.dp),
|
||||
title = stringResource(R.string.search_no_results_title),
|
||||
subTitle = stringResource(R.string.search_no_results_subtitle, query),
|
||||
iconStyle = BigIcon.Style.Default(CompoundIcons.Search()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.roomListResults(
|
||||
results: ImmutableList<RoomListRoomSummary>,
|
||||
onRoomClick: (RoomId) -> Unit,
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = results,
|
||||
key = { _, roomSummary -> roomSummary.roomId },
|
||||
) { index, result ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.niceClickable(onClick = { onRoomClick(result.roomId) })
|
||||
.fillMaxWidth()
|
||||
.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Avatar(
|
||||
avatarData = result.avatarData.copy(size = AvatarSize.SearchRoomListItem),
|
||||
avatarType = AvatarType.Room(
|
||||
heroes = result.heroes.map { it.copy(size = AvatarSize.SearchRoomListItem) }.toImmutableList(),
|
||||
isTombstoned = result.isTombstoned,
|
||||
),
|
||||
)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = result.name ?: result.roomId.value,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
)
|
||||
|
||||
if (result.isDm) {
|
||||
val userId = result.heroes.firstOrNull()?.id
|
||||
userId?.let {
|
||||
Text(
|
||||
text = it,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
result.canonicalAlias?.let {
|
||||
Text(
|
||||
text = it.value,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.messageSearchResults(
|
||||
results: ImmutableList<MessageSearchResultItem>,
|
||||
onSearchResultSelected: (RoomId, EventId?) -> Unit,
|
||||
) {
|
||||
if (results.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
text = stringResource(R.string.search_from_your_messages_header),
|
||||
style = ElementTheme.typography.fontBodyMdMedium,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
itemsIndexed(
|
||||
items = results,
|
||||
contentType = { _, messageSearchResult -> messageSearchResult::class.java },
|
||||
key = { _, messageSearchResult -> messageSearchResult.eventId },
|
||||
) { index, result ->
|
||||
val clickableModifier = Modifier.niceClickable(onClick = { onSearchResultSelected(result.roomId, result.eventId) })
|
||||
when (result) {
|
||||
is MessageSearchResultItem.Message -> TextMessageSearchResultItemView(
|
||||
modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex),
|
||||
avatarData = result.roomInfo.getAvatarData(AvatarSize.RoomListItem),
|
||||
avatarType = AvatarType.Room(
|
||||
heroes = result.roomInfo.heroes.map { it.getAvatarData(AvatarSize.RoomListItem) }.toImmutableList(),
|
||||
isTombstoned = result.roomInfo.successorRoom != null
|
||||
),
|
||||
roomName = result.roomInfo.name ?: result.roomInfo.id.value,
|
||||
body = result.body,
|
||||
formattedTimestamp = result.formattedTimestamp,
|
||||
)
|
||||
is MessageSearchResultItem.Media -> {
|
||||
MediaMessageSearchResultItemView(
|
||||
modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex),
|
||||
avatarData = result.roomInfo.getAvatarData(AvatarSize.RoomListItem),
|
||||
avatarType = AvatarType.Room(
|
||||
heroes = result.roomInfo.heroes.map { it.getAvatarData(AvatarSize.RoomListItem) }.toImmutableList(),
|
||||
isTombstoned = result.roomInfo.successorRoom != null
|
||||
),
|
||||
roomName = result.roomInfo.name ?: result.roomInfo.id.value,
|
||||
content = result.mediaContent,
|
||||
formattedTimestamp = result.formattedTimestamp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextMessageSearchResultItemView(
|
||||
avatarData: AvatarData,
|
||||
avatarType: AvatarType,
|
||||
roomName: String,
|
||||
body: String,
|
||||
formattedTimestamp: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Avatar(
|
||||
avatarData = avatarData,
|
||||
avatarType = avatarType,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp, bottom = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = roomName,
|
||||
maxLines = 1,
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
)
|
||||
Text(
|
||||
text = formattedTimestamp,
|
||||
maxLines = 1,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = body,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaMessageSearchResultItemView(
|
||||
avatarData: AvatarData,
|
||||
avatarType: AvatarType,
|
||||
roomName: String,
|
||||
content: MediaSearchResultContent,
|
||||
formattedTimestamp: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Avatar(
|
||||
avatarData = avatarData,
|
||||
avatarType = avatarType,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = roomName,
|
||||
maxLines = 1,
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
)
|
||||
Text(
|
||||
text = formattedTimestamp,
|
||||
maxLines = 1,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
|
||||
content.caption?.let {
|
||||
Text(
|
||||
text = it,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(1f)
|
||||
.background(ElementTheme.colors.bgSubtleSecondary, shape = RoundedCornerShape(12.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AttachmentThumbnail(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(4.dp)),
|
||||
info = AttachmentThumbnailInfo(
|
||||
type = content.thumbnailType,
|
||||
thumbnailSource = content.thumbnailSource,
|
||||
textContent = null,
|
||||
blurHash = content.blurhash,
|
||||
),
|
||||
thumbnailSize = 36.dp.roundToPx().toLong(),
|
||||
backgroundColor = ElementTheme.colors.bgCanvasDefault,
|
||||
)
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = content.filename,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
content.extension?.let {
|
||||
Text(
|
||||
text = it,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
content.formattedSize?.let {
|
||||
Text(
|
||||
text = "($it)",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Modifier.addSeparatorLine(color: Color, isLastItem: Boolean): Modifier {
|
||||
if (isLastItem) return this
|
||||
|
||||
return this.drawBehind {
|
||||
val strokeWidthPx = 1.dp.toPx()
|
||||
drawLine(
|
||||
color = color,
|
||||
start = Offset(0f, size.height - strokeWidthPx),
|
||||
end = Offset(size.width, size.height - strokeWidthPx),
|
||||
strokeWidth = strokeWidthPx,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun GlobalSearchViewPreview(
|
||||
@PreviewParameter(GlobalSearchStateProvider::class) state: GlobalSearchState,
|
||||
) {
|
||||
ElementPreview {
|
||||
GlobalSearchView(
|
||||
state = state,
|
||||
onSelectSearchResult = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
+2
@@ -46,6 +46,8 @@ class RoomListSearchDataSource(
|
||||
coroutineScope = coroutineScope
|
||||
)
|
||||
|
||||
val loadingState = roomList.loadingState
|
||||
|
||||
val roomSummaries: Flow<ImmutableList<RoomListRoomSummary>> = roomList.summaries
|
||||
.map { roomSummaries ->
|
||||
roomSummaries
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="banner_battery_optimization_content_android">"عطّل تحسين البطارية لهذا التطبيق للتأكد من استلام جميع الإشعارات."</string>
|
||||
<string name="banner_battery_optimization_submit_android">"تعطيل التحسين"</string>
|
||||
<string name="banner_battery_optimization_title_android">"هل لا تصلك الإشعارات؟"</string>
|
||||
<string name="banner_new_sound_message">"تم تحديث صوت إشعاراتك، ليصبح أوضح وأسرع وأقل إزعاجًا."</string>
|
||||
<string name="banner_new_sound_title">"حدّثنا الأصوات لديك"</string>
|
||||
<string name="banner_set_up_recovery_content">"يتم نسخ دردشاتك احتياطيًا تلقائيًا بتشفير من طرف إلى طرف. لاستعادة هذه النسخة الاحتياطية والاحتفاظ بهويتك الرقمية إذا فقدت الوصول إلى جميع أجهزتك، ستحتاج إلى مفتاح الاسترداد."</string>
|
||||
<string name="banner_set_up_recovery_submit">"الحصول على مفتاح الاسترداد"</string>
|
||||
<string name="banner_set_up_recovery_title">"احتفِظ بنسخة احتياطية من دردشاتك"</string>
|
||||
<string name="confirm_recovery_key_banner_message">"أكّد مفتاح الاسترداد الخاص بك للحفاظ على الوصول إلى تخزين المفاتيح وسجل الرسائل."</string>
|
||||
<string name="confirm_recovery_key_banner_primary_button_title">"أدخل مفتاح الاسترداد"</string>
|
||||
<string name="confirm_recovery_key_banner_secondary_button_title">"هل نسيت مفتاح الاسترداد؟"</string>
|
||||
<string name="confirm_recovery_key_banner_title">"تخزين المفاتيح لديك غير متزامن"</string>
|
||||
<string name="full_screen_intent_banner_message">"للتأكد من عدم تفويت أي مكالمة مهمة، يُرجى تغيير إعداداتك للسماح بإشعارات ملء الشاشة عندما يكون هاتفك مقفلاً."</string>
|
||||
<string name="full_screen_intent_banner_title">"حسّن تجربة مكالماتك"</string>
|
||||
<string name="screen_home_tab_chats">"المحادثات"</string>
|
||||
<string name="screen_home_tab_spaces">"المساحات"</string>
|
||||
<string name="screen_invites_decline_chat_message">"أمتأكد من رغبتك برفض دعوة للانضمام إلى %1$s؟"</string>
|
||||
<string name="screen_invites_decline_chat_title">"رفض الدعوة"</string>
|
||||
<string name="screen_invites_decline_direct_chat_message">"أمتأكد من رغبتك برفض المحادثة الخاصة مع %1$s؟"</string>
|
||||
<string name="screen_invites_decline_direct_chat_title">"رفض المحادثة"</string>
|
||||
<string name="screen_invites_empty_list">"لا دعوات"</string>
|
||||
<string name="screen_invites_invited_you">"دعاك %1$s (%2$s)"</string>
|
||||
<string name="screen_migration_message">"هذه العملية تُجرى لمرة واحدة، شكراً على انتظارك."</string>
|
||||
<string name="screen_migration_title">"إعداد حسابك"</string>
|
||||
<string name="screen_roomlist_a11y_create_message">"إنشاء محادثة جديد أو غرفة"</string>
|
||||
<string name="screen_roomlist_clear_filters">"مسح عوامل التصفية"</string>
|
||||
<string name="screen_roomlist_empty_message">"ابدأ الآن بمراسلة من تشاء."</string>
|
||||
<string name="screen_roomlist_empty_title">"لا محادثات بعد."</string>
|
||||
<string name="screen_roomlist_filter_favourites">"المفضلة"</string>
|
||||
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"يمكنك إضافة محادثة إلى المفضلة من إعدادات المحادثة.
|
||||
يمكنك الآن إلغاء تحديد عوامل التصفية لرؤية محادثاتك الأخرى"</string>
|
||||
<string name="screen_roomlist_filter_favourites_empty_state_title">"ليست لديك أي محادثات مفضلة بعد"</string>
|
||||
<string name="screen_roomlist_filter_invites">"الدعوات"</string>
|
||||
<string name="screen_roomlist_filter_invites_empty_state_title">"ليس لديك أي دعوات قيد الانتظار."</string>
|
||||
<string name="screen_roomlist_filter_low_priority">"منخفضة الأولوية"</string>
|
||||
<string name="screen_roomlist_filter_low_priority_empty_state_title">"ليست لديك أي دردشات منخفضة الأولوية بعد"</string>
|
||||
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"يمكنك إلغاء تحديد عوامل التصفية لرؤية محادثاتك الأخرى"</string>
|
||||
<string name="screen_roomlist_filter_mixed_empty_state_title">"ليست لديك محادثات لهذا الاختيار"</string>
|
||||
<string name="screen_roomlist_filter_people">"الأشخاص"</string>
|
||||
<string name="screen_roomlist_filter_people_empty_state_title">"ليست لديك أي رسائل مباشرة بعد"</string>
|
||||
<string name="screen_roomlist_filter_rooms">"الغرف"</string>
|
||||
<string name="screen_roomlist_filter_rooms_empty_state_title">"لست في أي غرفة بعد"</string>
|
||||
<string name="screen_roomlist_filter_unreads">"غير المقروءة"</string>
|
||||
<string name="screen_roomlist_filter_unreads_empty_state_title">"تهانينا!
|
||||
ليست لديك أي رسائل غير مقروءة!"</string>
|
||||
<string name="screen_roomlist_knock_event_sent_description">"تم إرسال طلب الانضمام"</string>
|
||||
<string name="screen_roomlist_main_space_title">"المحادثات"</string>
|
||||
<string name="screen_roomlist_mark_as_read">"وضع علامة كمقروء"</string>
|
||||
<string name="screen_roomlist_mark_as_unread">"وضع علامة كغير مقروء"</string>
|
||||
<string name="screen_roomlist_tombstoned_room_description">"تمت ترقية هذه الغرفة"</string>
|
||||
<string name="screen_roomlist_your_spaces">"مساحاتك"</string>
|
||||
<string name="session_verification_banner_message">"يبدو أنك تستخدم جهازاً جديداً. تحقق باستخدام جهاز آخر ليصبح لك وصول إلى رسائلك المشفرة."</string>
|
||||
<string name="session_verification_banner_title">"أثبت هويتك"</string>
|
||||
</resources>
|
||||
@@ -3,9 +3,11 @@
|
||||
<string name="banner_battery_optimization_content_android">"Deshabilita la optimización de la batería en esta aplicación para asegurarte de que recibes notificaciones."</string>
|
||||
<string name="banner_battery_optimization_submit_android">"Deshabilitar la optimización"</string>
|
||||
<string name="banner_battery_optimization_title_android">"¿No llegan las notificaciones?"</string>
|
||||
<string name="banner_set_up_recovery_content">"Recupera tu identidad criptográfica y tu historial de mensajes con una clave de recuperación si has perdido todos tus dispositivos actuales."</string>
|
||||
<string name="banner_set_up_recovery_submit">"Configurar la recuperación"</string>
|
||||
<string name="banner_set_up_recovery_title">"Configura la recuperación para proteger tu cuenta"</string>
|
||||
<string name="banner_new_sound_message">"Se ha actualizado tu sonido de notificación: más claro, rápido y menos disruptivo."</string>
|
||||
<string name="banner_new_sound_title">"Hemos actualizado tus sonidos"</string>
|
||||
<string name="banner_set_up_recovery_content">"Tus chats se respaldan automáticamente con cifrado de extremo a extremo. Para restaurar esta copia de seguridad y conservar tu identidad digital si pierdes el acceso a todos tus dispositivos, necesitarás tu clave de recuperación."</string>
|
||||
<string name="banner_set_up_recovery_submit">"Obtener clave de recuperación"</string>
|
||||
<string name="banner_set_up_recovery_title">"Copia de seguridad de tus chats"</string>
|
||||
<string name="confirm_recovery_key_banner_message">"Confirma tu clave de recuperación para mantener el acceso a tu almacén de claves y al historial de mensajes."</string>
|
||||
<string name="confirm_recovery_key_banner_primary_button_title">"Introduce tu clave de recuperación"</string>
|
||||
<string name="confirm_recovery_key_banner_secondary_button_title">"¿Olvidaste tu clave de recuperación?"</string>
|
||||
@@ -13,6 +15,7 @@
|
||||
<string name="full_screen_intent_banner_message">"Para asegurarte de que nunca te pierdas una llamada importante, modifica tus ajustes para permitir notificaciones a pantalla completa cuando el teléfono esté bloqueado."</string>
|
||||
<string name="full_screen_intent_banner_title">"Mejora tu experiencia de llamada"</string>
|
||||
<string name="screen_home_tab_chats">"Chats"</string>
|
||||
<string name="screen_home_tab_spaces">"Espacios"</string>
|
||||
<string name="screen_invites_decline_chat_message">"¿Estás seguro de que quieres rechazar la invitación a unirte a %1$s?"</string>
|
||||
<string name="screen_invites_decline_chat_title">"Rechazar la invitación"</string>
|
||||
<string name="screen_invites_decline_direct_chat_message">"¿Estás seguro de que quieres rechazar este chat privado con %1$s?"</string>
|
||||
@@ -32,6 +35,7 @@ Por ahora, puedes deseleccionar los filtros para ver tus otros chats"</string>
|
||||
<string name="screen_roomlist_filter_invites">"Invitaciones"</string>
|
||||
<string name="screen_roomlist_filter_invites_empty_state_title">"No tienes ninguna invitación pendiente."</string>
|
||||
<string name="screen_roomlist_filter_low_priority">"Prioridad baja"</string>
|
||||
<string name="screen_roomlist_filter_low_priority_empty_state_title">"Aún no tienes ningún chat de prioridad baja"</string>
|
||||
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Puedes deseleccionar filtros para ver tus otros chats."</string>
|
||||
<string name="screen_roomlist_filter_mixed_empty_state_title">"No tienes chats para esta selección"</string>
|
||||
<string name="screen_roomlist_filter_people">"Personas"</string>
|
||||
@@ -46,6 +50,7 @@ Por ahora, puedes deseleccionar los filtros para ver tus otros chats"</string>
|
||||
<string name="screen_roomlist_mark_as_read">"Marcar como leído"</string>
|
||||
<string name="screen_roomlist_mark_as_unread">"Marcar como no leído"</string>
|
||||
<string name="screen_roomlist_tombstoned_room_description">"Esta sala ha sido renovada"</string>
|
||||
<string name="screen_roomlist_your_spaces">"Tus espacios"</string>
|
||||
<string name="session_verification_banner_message">"Parece que estás usando un nuevo dispositivo. Verifica que eres tú para acceder a tus mensajes cifrados."</string>
|
||||
<string name="session_verification_banner_title">"Verifica que eres tú"</string>
|
||||
</resources>
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
<string name="banner_battery_optimization_content_android">"از کار انداختن بهینهسازی باتری برای این کاره برای اطمینان از گرفتن همهٔ آگاهیها."</string>
|
||||
<string name="banner_battery_optimization_submit_android">"از کار انداختن بهینه سازی"</string>
|
||||
<string name="banner_battery_optimization_title_android">"آگاهیها نمیرسند؟"</string>
|
||||
<string name="banner_set_up_recovery_content">"بازگردانی تاریخچهٔ پیامها و هویت رمزنگاشتهتان با کلید بازیابی در صورت از دست دادن همهٔ افزارههای موجودتان."</string>
|
||||
<string name="banner_new_sound_message">"پینگ اعلان شما بهروزرسانی شده است—واضحتر، سریعتر و کمدردسرتر."</string>
|
||||
<string name="banner_new_sound_title">"ما صداهای شما را تازه کردهایم"</string>
|
||||
<string name="banner_set_up_recovery_content">"چتهای شما به طور خودکار با رمزگذاری سراسری پشتیبانگیری میشوند. برای بازیابی این پشتیبانگیری و حفظ هویت دیجیتال خود در زمانی که دسترسی به تمام دستگاههایتان را از دست میدهید، به کلید بازیابی خود نیاز خواهید داشت."</string>
|
||||
<string name="banner_set_up_recovery_submit">"دریافت کلید بازیابی"</string>
|
||||
<string name="banner_set_up_recovery_title">"برپایی بازیابی"</string>
|
||||
<string name="banner_set_up_recovery_title">"از چتهایتان پشتیبان بگیرید"</string>
|
||||
<string name="confirm_recovery_key_banner_message">"کلید بازیابی خود را تأیید کنید تا دسترسی به حافظه کلیدها و تاریخچه پیامهایتان حفظ شود ."</string>
|
||||
<string name="confirm_recovery_key_banner_primary_button_title">"ورود کلید بازیابیتان"</string>
|
||||
<string name="confirm_recovery_key_banner_secondary_button_title">"کلید بازیابی خود را فراموش کردهاید؟"</string>
|
||||
<string name="confirm_recovery_key_banner_title">"ذخیرهساز کلیدتان از همگام بودن در آمده"</string>
|
||||
<string name="full_screen_intent_banner_message">"برای اطمینان از اینکه هرگز تماس مهمی را از دست نمیدهید، لطفاً تنظیمات خود را تغییر دهید تا وقتی تلفنتان قفل است، اعلانها به صورت تمام صفحه نمایش داده شوند."</string>
|
||||
<string name="full_screen_intent_banner_title">"بهبود تجریهٔ تماستان"</string>
|
||||
<string name="screen_home_tab_chats">"گپها"</string>
|
||||
<string name="screen_home_tab_spaces">"فضاها"</string>
|
||||
@@ -30,6 +34,7 @@
|
||||
<string name="screen_roomlist_filter_invites">"دعوتها"</string>
|
||||
<string name="screen_roomlist_filter_invites_empty_state_title">"هیچ دعوت منتظری ندارید."</string>
|
||||
<string name="screen_roomlist_filter_low_priority">"اولویت کم"</string>
|
||||
<string name="screen_roomlist_filter_low_priority_empty_state_title">"شما هنوز هیچ چت با اولویت پایین ندارید"</string>
|
||||
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"می توانید پالایهها را برای دیدن دیگر گپهایتان بردارید"</string>
|
||||
<string name="screen_roomlist_filter_mixed_empty_state_title">"هیچ گپی برای این گزینش ندارید"</string>
|
||||
<string name="screen_roomlist_filter_people">"افراد"</string>
|
||||
@@ -44,6 +49,7 @@
|
||||
<string name="screen_roomlist_mark_as_read">"علامتگذاری به عنوان خوانده شده"</string>
|
||||
<string name="screen_roomlist_mark_as_unread">"نشان به ناخوانده"</string>
|
||||
<string name="screen_roomlist_tombstoned_room_description">"این اتاق ارتقا یافته"</string>
|
||||
<string name="screen_roomlist_your_spaces">"فضاهای شما"</string>
|
||||
<string name="session_verification_banner_message">"گویا از افزارهای جدید استفاده میکنید. تأیید با افزارهای دیگر برای دسترسی به پیامهای رمزنگاری شدهتان."</string>
|
||||
<string name="session_verification_banner_title">"تأیید کنید که خودتانید"</string>
|
||||
</resources>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<string name="banner_battery_optimization_title_android">"Powiadomienia nie dochodzą?"</string>
|
||||
<string name="banner_new_sound_message">"Sygnał powiadomień został zaktualizowany — jest wyraźniejszy, szybszy i mniej uciążliwy."</string>
|
||||
<string name="banner_new_sound_title">"Odświeżyliśmy Twoje dźwięki"</string>
|
||||
<string name="banner_set_up_recovery_content">"Twoje czaty są automatycznie archiwizowane za pomocą szyfrowania end-to-end. Aby przywrócić tę kopię zapasową i swoją tożsamość cyfrową, wymagany będzie klucz przywracania."</string>
|
||||
<string name="banner_set_up_recovery_content">"Twoje czaty są automatycznie archiwizowane za pomocą szyfrowania end-to-end. Aby przywrócić kopię zapasową i swoją tożsamość cyfrową, wymagany będzie klucz przywracania."</string>
|
||||
<string name="banner_set_up_recovery_submit">"Uzyskaj klucz przywracania"</string>
|
||||
<string name="banner_set_up_recovery_title">"Utwórz kopię zapasową swoich czatów"</string>
|
||||
<string name="confirm_recovery_key_banner_message">"Potwierdź klucz przywracania, aby zachować dostęp do magazynu kluczy i historii wiadomości."</string>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<string name="banner_battery_optimization_title_android">"Nu primiți notificări?"</string>
|
||||
<string name="banner_new_sound_message">"Sunetul pentru notificări a fost actualizat — mai clar, mai rapid și mai puțin perturbatoar."</string>
|
||||
<string name="banner_new_sound_title">"Am reîmprospătat sunetele"</string>
|
||||
<string name="banner_set_up_recovery_content">"Chaturile dumneavoastră sunt salvate automat cu criptare end-to-end. Pentru a restaura această copie de rezervă și a vă păstra identitatea digitală atunci când pierzdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare."</string>
|
||||
<string name="banner_set_up_recovery_content">"Chaturile dumneavoastră sunt salvate automat cu criptare end-to-end. Pentru a restaura aceast backup și a vă păstra identitatea digitală atunci când pierzdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare."</string>
|
||||
<string name="banner_set_up_recovery_submit">"Obțineți cheia de recuperare"</string>
|
||||
<string name="banner_set_up_recovery_title">"Faceți un backup al mesajelor"</string>
|
||||
<string name="confirm_recovery_key_banner_message">"Backup-ul pentru chat nu este sincronizat. Trebuie să confirmați cheia de recuperare pentru a menține accesul la backup."</string>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2026 Element Creations Ltd.
|
||||
~
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
~ Please see LICENSE files in the repository root for full details.
|
||||
-->
|
||||
|
||||
<resources>
|
||||
<string name="start_searching_title">Start searching…</string>
|
||||
<string name="start_searching_subtitle">Search for chats and messages</string>
|
||||
<string name="search_section_chats">Chats</string>
|
||||
<string name="search_from_your_messages_header">From your messages</string>
|
||||
<string name="search_section_messages">Messages</string>
|
||||
<string name="search_no_results_title">No results</string>
|
||||
<string name="search_no_results_subtitle">There are no results for “%1$s”. Try a new search term.</string>
|
||||
</resources>
|
||||
+4
-2
@@ -11,6 +11,8 @@ package io.element.android.features.home.impl
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.home.api.HomeEntryPoint
|
||||
import io.element.android.features.invite.test.declineandblock.FakeDeclineInviteAndBlockEntryPoint
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
@@ -36,14 +38,14 @@ class DefaultHomeEntryPointTest : RobolectricTest() {
|
||||
acceptDeclineInviteView = { _, _, _, _ -> lambdaError() },
|
||||
directLogoutView = { _ -> lambdaError() },
|
||||
reportRoomEntryPoint = { _, _, _ -> lambdaError() },
|
||||
declineInviteAndBlockUserEntryPoint = { _, _, _ -> lambdaError() },
|
||||
declineInviteAndBlockUserEntryPoint = FakeDeclineInviteAndBlockEntryPoint(),
|
||||
changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() },
|
||||
leaveRoomRenderer = { _, _, _ -> lambdaError() },
|
||||
sessionCoroutineScope = backgroundScope,
|
||||
)
|
||||
}
|
||||
val callback = object : HomeEntryPoint.Callback {
|
||||
override fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?) = lambdaError()
|
||||
override fun navigateToRoom(roomId: RoomId, eventId: EventId?, joinedRoom: JoinedRoom?) = lambdaError()
|
||||
override fun navigateToCreateRoom() = lambdaError()
|
||||
override fun navigateToCreateSpace() = lambdaError()
|
||||
override fun navigateToSettings() = lambdaError()
|
||||
|
||||
+43
@@ -72,6 +72,49 @@ class RoomListFiltersPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun `present - toggling two incompatible filters in the same frame keeps the first one`() = runTest {
|
||||
val presenter = createRoomListFiltersPresenter()
|
||||
presenter.test {
|
||||
awaitItem().let { state ->
|
||||
state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Favourites))
|
||||
state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Invites))
|
||||
}
|
||||
advanceUntilIdle()
|
||||
awaitLastSequentialItem().let { state ->
|
||||
assertThat(state.selectedFilters()).containsExactly(RoomListFilter.Favourites)
|
||||
assertThat(state.filterSelectionStates).containsExactly(
|
||||
filterSelectionState(RoomListFilter.Favourites, true),
|
||||
filterSelectionState(RoomListFilter.Unread, false),
|
||||
filterSelectionState(RoomListFilter.People, false),
|
||||
filterSelectionState(RoomListFilter.Rooms, false),
|
||||
).inOrder()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun `present - toggling two incompatible categories in the same frame keeps the first one`() = runTest {
|
||||
val presenter = createRoomListFiltersPresenter()
|
||||
presenter.test {
|
||||
awaitItem().let { state ->
|
||||
state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.People))
|
||||
state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms))
|
||||
}
|
||||
advanceUntilIdle()
|
||||
awaitLastSequentialItem().let { state ->
|
||||
assertThat(state.selectedFilters()).containsExactly(RoomListFilter.People)
|
||||
assertThat(state.filterSelectionStates).containsExactly(
|
||||
filterSelectionState(RoomListFilter.People, true),
|
||||
filterSelectionState(RoomListFilter.Unread, false),
|
||||
filterSelectionState(RoomListFilter.Favourites, false),
|
||||
).inOrder()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun `present - clear filters event`() = runTest {
|
||||
|
||||
+2
@@ -20,6 +20,7 @@ import io.element.android.features.home.impl.filters.aRoomListFiltersState
|
||||
import io.element.android.features.home.impl.model.createRoomListRoomSummary
|
||||
import io.element.android.features.home.impl.search.RoomListSearchEvent
|
||||
import io.element.android.features.home.impl.search.RoomListSearchState
|
||||
import io.element.android.features.home.impl.search.aGlobalSearchState
|
||||
import io.element.android.features.home.impl.search.aRoomListSearchState
|
||||
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
|
||||
import io.element.android.features.home.impl.spacefilters.aDisabledSpaceFiltersState
|
||||
@@ -680,6 +681,7 @@ class RoomListPresenterTest {
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
),
|
||||
searchPresenter = searchPresenter,
|
||||
globalSearchPresenter = { aGlobalSearchState() },
|
||||
filtersPresenter = filtersPresenter,
|
||||
spaceFiltersPresenter = spaceFiltersPresenter,
|
||||
analyticsService = analyticsService,
|
||||
|
||||
+1
-1
@@ -277,7 +277,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setRoomListView(
|
||||
setSafeContent {
|
||||
HomeView(
|
||||
homeState = aHomeState(roomListState = state),
|
||||
onRoomClick = onRoomClick,
|
||||
onRoomClick = { roomId, _ -> onRoomClick(roomId) },
|
||||
onSettingsClick = onSettingsClick,
|
||||
onSetUpRecoveryClick = onSetUpRecoveryClick,
|
||||
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
|
||||
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.home.impl.search
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory
|
||||
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
|
||||
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
||||
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
|
||||
import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||
import io.element.android.libraries.matrix.api.search.MessageSearchService
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||
import io.element.android.libraries.matrix.test.room.aRoomSummary
|
||||
import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
|
||||
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
|
||||
import io.element.android.libraries.matrix.test.search.FakeMessageSearch
|
||||
import io.element.android.libraries.matrix.test.search.FakeMessageSearchService
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
import io.element.android.tests.testutils.test
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import java.util.Optional
|
||||
|
||||
class GlobalSearchPresenterTest {
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = createGlobalSearchPresenter()
|
||||
presenter.test {
|
||||
awaitItem().let { state ->
|
||||
assertThat(state.isEnabled).isFalse()
|
||||
assertThat(state.isSearchActive).isFalse()
|
||||
assertThat(state.queryState.text.toString()).isEmpty()
|
||||
assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS)
|
||||
assertThat(state.results).isEqualTo(AsyncData.Uninitialized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - isEnabled reflects the feature flag`() = runTest {
|
||||
val presenter = createGlobalSearchPresenter(
|
||||
featureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(FeatureFlags.MessageSearch.key to true),
|
||||
),
|
||||
)
|
||||
presenter.test {
|
||||
// isEnabled is emitted asynchronously via produceState, so it starts as false and flips to true
|
||||
var state = awaitItem()
|
||||
while (!state.isEnabled) {
|
||||
state = awaitItem()
|
||||
}
|
||||
assertThat(state.isEnabled).isTrue()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - toggle search visibility`() = runTest {
|
||||
val presenter = createGlobalSearchPresenter()
|
||||
presenter.test {
|
||||
awaitItem().let { state ->
|
||||
assertThat(state.isSearchActive).isFalse()
|
||||
state.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
|
||||
}
|
||||
awaitItem().let { state ->
|
||||
assertThat(state.isSearchActive).isTrue()
|
||||
state.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
|
||||
}
|
||||
awaitItem().let { state ->
|
||||
assertThat(state.isSearchActive).isFalse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - update target`() = runTest {
|
||||
val presenter = createGlobalSearchPresenter()
|
||||
presenter.test {
|
||||
awaitItem().let { state ->
|
||||
assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS)
|
||||
state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES))
|
||||
}
|
||||
awaitItem().let { state ->
|
||||
assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.MESSAGES)
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - clear query`() = runTest {
|
||||
val presenter = createGlobalSearchPresenter()
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.queryState.edit { append("A query") }
|
||||
awaitItem().let { state ->
|
||||
assertThat(state.queryState.text.toString()).isEqualTo("A query")
|
||||
state.eventSink(GlobalSearchEvent.ClearQuery)
|
||||
}
|
||||
awaitItem().let { state ->
|
||||
assertThat(state.queryState.text.toString()).isEmpty()
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - room search results are emitted when the room list changes`() = runTest {
|
||||
val roomList = FakeDynamicRoomList()
|
||||
val roomListService = FakeRoomListService(createRoomListLambda = { roomList })
|
||||
val presenter = createGlobalSearchPresenter(roomListService = roomListService)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.results).isEqualTo(AsyncData.Uninitialized)
|
||||
|
||||
initialState.queryState.edit { append("A query") }
|
||||
// Let the query be propagated to the data source before emitting results
|
||||
testScheduler.advanceUntilIdle()
|
||||
roomList.summaries.emit(listOf(aRoomSummary()))
|
||||
|
||||
// Wait for the presenter to emit a loading state before the success state
|
||||
consumeItemsUntilPredicate { it.results is AsyncData.Loading }
|
||||
|
||||
val successState = awaitItem()
|
||||
val results = (successState.results.dataOrNull() as GlobalSearchResults.RoomListResults).results
|
||||
assertThat(results).hasSize(1)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - room search displays the uninitialized state when removing the query`() = runTest {
|
||||
val roomList = FakeDynamicRoomList()
|
||||
val roomListService = FakeRoomListService(createRoomListLambda = { roomList })
|
||||
val presenter = createGlobalSearchPresenter(roomListService = roomListService)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.results).isEqualTo(AsyncData.Uninitialized)
|
||||
|
||||
initialState.queryState.edit { append("A query") }
|
||||
// Let the query be propagated to the data source before emitting results
|
||||
testScheduler.advanceUntilIdle()
|
||||
roomList.summaries.emit(listOf(aRoomSummary()))
|
||||
|
||||
// Wait for the presenter to emit a loading state before the success state
|
||||
consumeItemsUntilPredicate { it.results is AsyncData.Loading }
|
||||
|
||||
val successState = awaitItem()
|
||||
val results = (successState.results.dataOrNull() as GlobalSearchResults.RoomListResults).results
|
||||
assertThat(results).hasSize(1)
|
||||
|
||||
// Remove the query
|
||||
successState.queryState.edit { replace(0, length, "") }
|
||||
// Skip the intermediate state where the query is empty but results are still present
|
||||
skipItems(1)
|
||||
// It's back to the uninitialized state
|
||||
assertThat(awaitItem().results).isInstanceOf(AsyncData.Uninitialized::class.java)
|
||||
// This happens even if for some reason the room results still contained any items
|
||||
assertThat(roomList.summaries.value).isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - message search results are mapped when the message search emits`() = runTest {
|
||||
val messageSearch = FakeMessageSearch()
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) }
|
||||
}
|
||||
val presenter = createGlobalSearchPresenter(
|
||||
messageSearchService = FakeMessageSearchService(messageSearch),
|
||||
matrixClient = matrixClient,
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES))
|
||||
initialState.queryState.edit { append("A query") }
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
messageSearch.emitResults(persistentListOf(aMessageSearchResult()))
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last()
|
||||
val results = (successState.results.dataOrNull() as GlobalSearchResults.MessageSearchResults).results
|
||||
assertThat(results).hasSize(1)
|
||||
assertThat(results.first()).isInstanceOf(MessageSearchResultItem.Message::class.java)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - message search displays the uninitialized state after removing the query`() = runTest {
|
||||
val messageSearch = FakeMessageSearch()
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) }
|
||||
}
|
||||
val presenter = createGlobalSearchPresenter(
|
||||
messageSearchService = FakeMessageSearchService(messageSearch),
|
||||
matrixClient = matrixClient,
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES))
|
||||
initialState.queryState.edit { append("A query") }
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
messageSearch.emitResults(persistentListOf(aMessageSearchResult()))
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last()
|
||||
val results = (successState.results.dataOrNull() as GlobalSearchResults.MessageSearchResults).results
|
||||
assertThat(results).hasSize(1)
|
||||
assertThat(results.first()).isInstanceOf(MessageSearchResultItem.Message::class.java)
|
||||
|
||||
// Remove the query
|
||||
successState.queryState.edit { replace(0, length, "") }
|
||||
// Skip the intermediate state where the query is empty but results are still present
|
||||
skipItems(1)
|
||||
// It's back to the uninitialized state
|
||||
assertThat(awaitItem().results).isInstanceOf(AsyncData.Uninitialized::class.java)
|
||||
// This happens even if for some reason the message results still contained any items
|
||||
assertThat(messageSearch.results.value).isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - UpdateVisibleRange triggers pagination for messages when near the end`() = runTest {
|
||||
val messageSearch = FakeMessageSearch()
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) }
|
||||
}
|
||||
val presenter = createGlobalSearchPresenter(
|
||||
messageSearchService = FakeMessageSearchService(messageSearch),
|
||||
matrixClient = matrixClient,
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES))
|
||||
initialState.queryState.edit { append("A query") }
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
messageSearch.emitResults(persistentListOf(aMessageSearchResult()))
|
||||
val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last()
|
||||
|
||||
successState.eventSink(GlobalSearchEvent.UpdateVisibleRange(IntRange(0, 0)))
|
||||
runCurrent()
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
assertThat(messageSearch.paginateCallCount).isEqualTo(1)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.createGlobalSearchPresenter(
|
||||
roomListService: RoomListService = FakeRoomListService(),
|
||||
messageSearchService: MessageSearchService = FakeMessageSearchService(),
|
||||
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
|
||||
latestEventFormatter: RoomLatestEventFormatter = FakeRoomLatestEventFormatter(),
|
||||
dateFormatter: DateFormatter = FakeDateFormatter(),
|
||||
fileSizeFormatter: FileSizeFormatter = FakeFileSizeFormatter(),
|
||||
permalinkParser: PermalinkParser = FakePermalinkParser(),
|
||||
matrixClient: MatrixClient = FakeMatrixClient(),
|
||||
): GlobalSearchPresenter {
|
||||
return GlobalSearchPresenter(
|
||||
roomListSearchDataSourceFactory = object : RoomListSearchDataSource.Factory {
|
||||
override fun create(coroutineScope: CoroutineScope): RoomListSearchDataSource {
|
||||
return RoomListSearchDataSource(
|
||||
coroutineScope = coroutineScope,
|
||||
roomListService = roomListService,
|
||||
coroutineDispatchers = testCoroutineDispatchers(),
|
||||
roomSummaryFactory = aRoomListRoomSummaryFactory(),
|
||||
)
|
||||
}
|
||||
},
|
||||
messageSearchService = messageSearchService,
|
||||
featureFlagService = featureFlagService,
|
||||
latestEventFormatter = latestEventFormatter,
|
||||
dateFormatter = dateFormatter,
|
||||
fileSizeFormatter = fileSizeFormatter,
|
||||
permalinkParser = permalinkParser,
|
||||
coroutineDispatchers = testCoroutineDispatchers(),
|
||||
matrixClient = matrixClient,
|
||||
)
|
||||
}
|
||||
+4
-1
@@ -11,6 +11,9 @@ package io.element.android.features.invite.api
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Remembers which invitations the user has already looked at, so that only genuinely new ones are highlighted in the room list.
|
||||
*/
|
||||
interface SeenInvitesStore {
|
||||
/**
|
||||
* Returns a flow of seen room IDs of invitation.
|
||||
@@ -32,7 +35,7 @@ interface SeenInvitesStore {
|
||||
suspend fun markAsUnSeen(roomId: RoomId)
|
||||
|
||||
/**
|
||||
* Delete the store.
|
||||
* Delete the store, so every invitation counts as unseen again.
|
||||
*/
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
+3
@@ -10,6 +10,9 @@ package io.element.android.features.invite.api.acceptdecline
|
||||
|
||||
import io.element.android.features.invite.api.InviteData
|
||||
|
||||
/**
|
||||
* Events sent to the accept/decline invite presenter, from whichever screen is showing the invitation.
|
||||
*/
|
||||
interface AcceptDeclineInviteEvents {
|
||||
data class AcceptInvite(val invite: InviteData) : AcceptDeclineInviteEvents
|
||||
data class DeclineInvite(val invite: InviteData, val blockUser: Boolean, val shouldConfirm: Boolean) : AcceptDeclineInviteEvents
|
||||
|
||||
+13
@@ -12,7 +12,20 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
||||
/**
|
||||
* Renders the confirmation dialogs and error states of accepting or declining an invitation; it draws nothing until such an event is sent.
|
||||
*
|
||||
* Embedded by every screen that can act on an invitation, so the behaviour stays identical between the invite list and a room preview.
|
||||
*/
|
||||
fun interface AcceptDeclineInviteView {
|
||||
/**
|
||||
* Draws whichever dialog the current state calls for.
|
||||
*
|
||||
* @param state the state produced by the accept/decline presenter.
|
||||
* @param onAcceptInviteSuccess called once the room has been joined, so the host screen can navigate into it.
|
||||
* @param onDeclineInviteSuccess called once the invitation has been declined.
|
||||
* @param modifier layout modifier for the container.
|
||||
*/
|
||||
@Composable
|
||||
fun Render(
|
||||
state: AcceptDeclineInviteState,
|
||||
|
||||
+7
-1
@@ -10,13 +10,19 @@ package io.element.android.features.invite.api.declineandblock
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import io.element.android.features.invite.api.InviteData
|
||||
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||
|
||||
fun interface DeclineInviteAndBlockEntryPoint : FeatureEntryPoint {
|
||||
interface DeclineInviteAndBlockEntryPoint : FeatureEntryPoint {
|
||||
fun createNode(
|
||||
parentNode: Node,
|
||||
buildContext: BuildContext,
|
||||
inviteData: InviteData,
|
||||
callback: Callback,
|
||||
): Node
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onDeclineSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -17,7 +17,9 @@ import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.annotations.ContributesNode
|
||||
import io.element.android.features.invite.api.InviteData
|
||||
import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.callback
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
|
||||
@@ -31,6 +33,7 @@ class DeclineAndBlockNode(
|
||||
data class Inputs(val inviteData: InviteData) : NodeInputs
|
||||
|
||||
private val inviteData = inputs<Inputs>().inviteData
|
||||
private val callback: DeclineInviteAndBlockEntryPoint.Callback = callback()
|
||||
private val presenter = presenterFactory.create(inviteData)
|
||||
|
||||
@Composable
|
||||
@@ -39,6 +42,7 @@ class DeclineAndBlockNode(
|
||||
DeclineAndBlockView(
|
||||
state = state,
|
||||
onBackClick = ::navigateUp,
|
||||
onDeclineSuccess = callback::onDeclineSuccess,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
+3
-1
@@ -46,6 +46,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||
fun DeclineAndBlockView(
|
||||
state: DeclineAndBlockState,
|
||||
onBackClick: () -> Unit,
|
||||
onDeclineSuccess: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
@@ -53,7 +54,7 @@ fun DeclineAndBlockView(
|
||||
val isDeclining = state.declineAction is AsyncAction.Loading
|
||||
AsyncActionView(
|
||||
async = state.declineAction,
|
||||
onSuccess = { onBackClick() },
|
||||
onSuccess = { onDeclineSuccess() },
|
||||
errorMessage = { stringResource(CommonStrings.error_unknown) },
|
||||
onRetry = { state.eventSink(DeclineAndBlockEvents.Decline) },
|
||||
onErrorDismiss = { state.eventSink(DeclineAndBlockEvents.ClearDeclineAction) }
|
||||
@@ -149,5 +150,6 @@ internal fun DeclineAndBlockViewPreview(
|
||||
DeclineAndBlockView(
|
||||
state = state,
|
||||
onBackClick = {},
|
||||
onDeclineSuccess = {},
|
||||
)
|
||||
}
|
||||
|
||||
+2
-1
@@ -22,8 +22,9 @@ class DefaultDeclineAndBlockEntryPoint : DeclineInviteAndBlockEntryPoint {
|
||||
parentNode: Node,
|
||||
buildContext: BuildContext,
|
||||
inviteData: InviteData,
|
||||
callback: DeclineInviteAndBlockEntryPoint.Callback,
|
||||
): Node {
|
||||
val inputs = DeclineAndBlockNode.Inputs(inviteData)
|
||||
return parentNode.createNode<DeclineAndBlockNode>(buildContext, plugins = listOf(inputs))
|
||||
return parentNode.createNode<DeclineAndBlockNode>(buildContext, plugins = listOf(inputs, callback))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_decline_and_block_block_user_option_description">"لن ترى أي رسائل أو دعوات إلى غرف من هذا المستخدم"</string>
|
||||
<string name="screen_decline_and_block_block_user_option_title">"حظر المستخدم"</string>
|
||||
<string name="screen_decline_and_block_report_user_option_description">"أبلغ موفّر حسابك عن هذه الغرفة."</string>
|
||||
<string name="screen_decline_and_block_report_user_reason_placeholder">"صِف سبب الإبلاغ…"</string>
|
||||
<string name="screen_decline_and_block_title">"رفض وحظر"</string>
|
||||
<string name="screen_invites_decline_chat_message">"أمتأكد من رغبتك برفض دعوة للانضمام إلى %1$s؟"</string>
|
||||
<string name="screen_invites_decline_chat_title">"رفض الدعوة"</string>
|
||||
<string name="screen_invites_decline_direct_chat_message">"أمتأكد من رغبتك برفض المحادثة الخاصة مع %1$s؟"</string>
|
||||
<string name="screen_invites_decline_direct_chat_title">"رفض المحادثة"</string>
|
||||
<string name="screen_invites_empty_list">"لا دعوات"</string>
|
||||
<string name="screen_invites_invited_you">"دعاك %1$s (%2$s)"</string>
|
||||
<string name="screen_join_room_decline_and_block_alert_confirmation">"نعم، ارفض واحظر"</string>
|
||||
<string name="screen_join_room_decline_and_block_alert_message">"هل أنت متأكد من رغبتك في رفض الدعوة للانضمام إلى هذه الغرفة؟ سيمنع هذا أيضًا %1$s من التواصل معك أو دعوتك إلى غرف."</string>
|
||||
<string name="screen_join_room_decline_and_block_alert_title">"رفض الدعوة والحظر"</string>
|
||||
<string name="screen_join_room_decline_and_block_button_title">"رفض وحظر"</string>
|
||||
</resources>
|
||||
@@ -1,6 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_decline_and_block_block_user_option_description">"شما هیچ پیام یا دعوتنامهای از این کاربر نخواهید دید."</string>
|
||||
<string name="screen_decline_and_block_block_user_option_title">"انسداد کاربر"</string>
|
||||
<string name="screen_decline_and_block_report_user_option_description">"این اتاق را به ارائه دهنده حساب خود گزارش دهید."</string>
|
||||
<string name="screen_decline_and_block_report_user_reason_placeholder">"شرح دلیل گزارش…"</string>
|
||||
<string name="screen_decline_and_block_title">"رد و انسداد"</string>
|
||||
<string name="screen_invites_decline_chat_message">"مطمئنید که میخواهید دعوت پیوستن به %1$s را رد کنید؟"</string>
|
||||
@@ -10,6 +12,7 @@
|
||||
<string name="screen_invites_empty_list">"بدون دعوت"</string>
|
||||
<string name="screen_invites_invited_you">"%1$s (%2$s) دعوتتان کرد"</string>
|
||||
<string name="screen_join_room_decline_and_block_alert_confirmation">"بله. رد و انسداد"</string>
|
||||
<string name="screen_join_room_decline_and_block_alert_message">"آیا مطمئنید که میخواهید دعوت به عضویت در این اتاق را رد کنید؟ این کار همچنین مانع از تماس %1$s با شما یا دعوت شما به اتاقها میشود."</string>
|
||||
<string name="screen_join_room_decline_and_block_alert_title">"رد دعوت و انسداد"</string>
|
||||
<string name="screen_join_room_decline_and_block_button_title">"رد و انسداد"</string>
|
||||
</resources>
|
||||
|
||||
+17
@@ -17,6 +17,7 @@ import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import io.element.android.features.invite.impl.R
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
@@ -41,6 +42,20 @@ class DeclineAndBlockViewTest : RobolectricTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a successful decline invokes the expected callback`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<DeclineAndBlockEvents>(expectEvents = false)
|
||||
ensureCalledOnce {
|
||||
setDeclineAndBlockView(
|
||||
aDeclineAndBlockState(
|
||||
declineAction = AsyncAction.Success(Unit),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onDeclineSuccess = it,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on decline when enabled emits the expected event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<DeclineAndBlockEvents>()
|
||||
@@ -112,11 +127,13 @@ class DeclineAndBlockViewTest : RobolectricTest() {
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.setDeclineAndBlockView(
|
||||
state: DeclineAndBlockState,
|
||||
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||
onDeclineSuccess: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
DeclineAndBlockView(
|
||||
state = state,
|
||||
onBackClick = onBackClick,
|
||||
onDeclineSuccess = onDeclineSuccess,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -11,7 +11,9 @@ package io.element.android.features.invite.impl.declineandblock
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint
|
||||
import io.element.android.features.invite.test.anInviteData
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.node.TestParentNode
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -31,12 +33,17 @@ class DefaultDeclineAndBlockEntryPointTest {
|
||||
)
|
||||
}
|
||||
val inviteData = anInviteData()
|
||||
val callback = object : DeclineInviteAndBlockEntryPoint.Callback {
|
||||
override fun onDeclineSuccess() = lambdaError()
|
||||
}
|
||||
val result = entryPoint.createNode(
|
||||
parentNode = parentNode,
|
||||
buildContext = BuildContext.root(null),
|
||||
inviteData = inviteData
|
||||
inviteData = inviteData,
|
||||
callback = callback,
|
||||
)
|
||||
assertThat(result).isInstanceOf(DeclineAndBlockNode::class.java)
|
||||
assertThat(result.plugins).contains(DeclineAndBlockNode.Inputs(inviteData))
|
||||
assertThat(result.plugins).contains(callback)
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -19,6 +19,7 @@ class FakeDeclineInviteAndBlockEntryPoint : DeclineInviteAndBlockEntryPoint {
|
||||
parentNode: Node,
|
||||
buildContext: BuildContext,
|
||||
inviteData: InviteData,
|
||||
callback: DeclineInviteAndBlockEntryPoint.Callback,
|
||||
): Node {
|
||||
lambdaError()
|
||||
}
|
||||
|
||||
+3
@@ -8,6 +8,9 @@
|
||||
|
||||
package io.element.android.features.invitepeople.api
|
||||
|
||||
/**
|
||||
* Events the invite people UI sends to its presenter through [InvitePeopleState.eventSink].
|
||||
*/
|
||||
interface InvitePeopleEvents {
|
||||
data object SendInvites : InvitePeopleEvents
|
||||
data object CloseSearch : InvitePeopleEvents
|
||||
|
||||
+7
@@ -13,7 +13,14 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
|
||||
interface InvitePeoplePresenter : Presenter<InvitePeopleState> {
|
||||
/**
|
||||
* Creates a presenter for one invite flow.
|
||||
*/
|
||||
interface Factory {
|
||||
/**
|
||||
* @param joinedRoom the room to invite into, or `null` when the room does not exist yet, as when inviting from a direct message.
|
||||
* @param roomId the id of the room to invite into.
|
||||
*/
|
||||
fun create(
|
||||
joinedRoom: JoinedRoom?,
|
||||
roomId: RoomId,
|
||||
|
||||
+9
@@ -11,7 +11,16 @@ package io.element.android.features.invitepeople.api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
/**
|
||||
* Renders the invite people UI, so that other screens can embed it without depending on its implementation.
|
||||
*/
|
||||
interface InvitePeopleRenderer {
|
||||
/**
|
||||
* Draws the invite list and its search field.
|
||||
*
|
||||
* @param state the state produced by [InvitePeoplePresenter].
|
||||
* @param modifier layout modifier for the container.
|
||||
*/
|
||||
@Composable
|
||||
fun Render(
|
||||
state: InvitePeopleState,
|
||||
|
||||
+12
@@ -11,10 +11,22 @@ package io.element.android.features.invitepeople.api
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
||||
/**
|
||||
* State of the invite people UI, produced by an [InvitePeoplePresenter] and consumed by an [InvitePeopleRenderer].
|
||||
*/
|
||||
interface InvitePeopleState {
|
||||
/** Whether the send button should be enabled, i.e. at least one user is selected and the user has the permission. */
|
||||
val canInvite: Boolean
|
||||
|
||||
/** Whether the search field currently has focus, which the host screen uses to adapt its own layout. */
|
||||
val isSearchActive: Boolean
|
||||
|
||||
/** Progress of the invite request, so the UI can show a loader and surface failures. */
|
||||
val sendInvitesAction: AsyncAction<Unit>
|
||||
|
||||
/** Progress of turning a direct message into a room, which happens when inviting a third person into a DM. */
|
||||
val createRoomFromDmAction: AsyncAction<RoomId>
|
||||
|
||||
/** Where the UI sends its events. */
|
||||
val eventSink: (InvitePeopleEvents) -> Unit
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_invite_users_already_a_member">"أنت عضو بالفعل"</string>
|
||||
<string name="screen_invite_users_already_invited">"مَدعُوّ بالفعل"</string>
|
||||
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"ليست لديك حاليًا أي دردشات مع جهات الاتصال هذه. أكّد دعوتهم إلى هذه الغرفة قبل المتابعة."</string>
|
||||
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"ليست لديك حاليًا أي دردشات مع جهة الاتصال هذه. أكّد دعوتها إلى هذه الغرفة قبل المتابعة."</string>
|
||||
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"هل تريد دعوة جهات اتصال جديدة إلى هذه الغرفة؟"</string>
|
||||
<string name="screen_invite_users_confirm_dialog_title_one_user">"هل تريد دعوة جهة اتصال جديدة إلى هذه الغرفة؟"</string>
|
||||
</resources>
|
||||
@@ -2,4 +2,8 @@
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_invite_users_already_a_member">"Bereits Mitglied"</string>
|
||||
<string name="screen_invite_users_already_invited">"Bereits eingeladen"</string>
|
||||
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"Du hast derzeit keine Chats mit diesen Kontakten. Bestätige, dass du sie in diesen Chat einladen möchtest, bevor du fortfährst."</string>
|
||||
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"Du hast derzeit keine Chats mit diesem Kontakt. Bestätige, dass du ihn in diesen Chat einladen möchtest, bevor du fortfährst."</string>
|
||||
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"Möchtest du neue Kontakte in diesen Chat einladen?"</string>
|
||||
<string name="screen_invite_users_confirm_dialog_title_one_user">"Willst du einen neuen Kontakt in diesen Chat einladen?"</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_invite_users_already_a_member">"Ya eres miembro"</string>
|
||||
<string name="screen_invite_users_already_a_member">"Ya es miembro"</string>
|
||||
<string name="screen_invite_users_already_invited">"Ya estás invitado"</string>
|
||||
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"De momento no tienes ningún chat con estos contactos. Confirma que quieres invitarlos a esta sala antes de continuar."</string>
|
||||
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"De momento no tienes ningún chat con este contacto. Confirma que quieres invitarlo a esta sala antes de continuar."</string>
|
||||
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"¿Invitar contactos nuevos a esta sala?"</string>
|
||||
<string name="screen_invite_users_confirm_dialog_title_one_user">"¿Invitar contacto nuevo a esta sala?"</string>
|
||||
</resources>
|
||||
|
||||
@@ -2,4 +2,8 @@
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_invite_users_already_a_member">"از پیش عضو است"</string>
|
||||
<string name="screen_invite_users_already_invited">"از پیش دعوت شده"</string>
|
||||
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"شما در حال حاضر هیچ گفتوگویی با این مخاطبین ندارید. قبل از ادامه، دعوت آنها را به این اتاق تأیید کنید."</string>
|
||||
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"شما در حال حاضر هیچ گفتوگویی با این مخاطب ندارید. قبل از ادامه، دعوت او را به این اتاق تأیید کنید."</string>
|
||||
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"مخاطبین جدید را به این اتاق دعوت کنید؟"</string>
|
||||
<string name="screen_invite_users_confirm_dialog_title_one_user">"مخاطب جدید را به این اتاق دعوت میکنید؟"</string>
|
||||
</resources>
|
||||
|
||||
@@ -2,4 +2,8 @@
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_invite_users_already_a_member">"已是成員"</string>
|
||||
<string name="screen_invite_users_already_invited">"已邀請"</string>
|
||||
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"您目前與這些聯絡人沒有任何聊天紀錄。請確認是否要邀請他們加入此聊天室,然後再繼續。"</string>
|
||||
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"您目前與此聯絡人沒有任何聊天紀錄。請確認是否要邀請對方加入此聊天室,然後再繼續。"</string>
|
||||
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"要邀請新聯絡人加入此聊天室嗎?"</string>
|
||||
<string name="screen_invite_users_confirm_dialog_title_one_user">"要邀請新聯絡人加入此聊天室嗎?"</string>
|
||||
</resources>
|
||||
|
||||
+4
-1
@@ -69,6 +69,9 @@ class JoinRoomFlowNode(
|
||||
parentNode = this,
|
||||
buildContext = buildContext,
|
||||
inviteData = navTarget.inviteData,
|
||||
callback = object : DeclineInviteAndBlockEntryPoint.Callback {
|
||||
override fun onDeclineSuccess() = navigateUp()
|
||||
},
|
||||
)
|
||||
NavTarget.Root -> rootNode(buildContext)
|
||||
}
|
||||
@@ -99,7 +102,7 @@ class JoinRoomFlowNode(
|
||||
acceptDeclineInviteView.Render(
|
||||
state = state.acceptDeclineInviteState,
|
||||
onAcceptInviteSuccess = {},
|
||||
onDeclineInviteSuccess = {},
|
||||
onDeclineInviteSuccess = { navigateUp() },
|
||||
modifier = Modifier
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_join_room_ban_by_message">"لقد حظرك %1$s."</string>
|
||||
<string name="screen_join_room_ban_message">"تم حظرك"</string>
|
||||
<string name="screen_join_room_ban_reason">"السبب: %1$s."</string>
|
||||
<string name="screen_join_room_cancel_knock_action">"إلغاء الطلب"</string>
|
||||
<string name="screen_join_room_cancel_knock_alert_confirmation">"نعم، ألغِ"</string>
|
||||
<string name="screen_join_room_cancel_knock_alert_description">"هل أنت متأكد من رغبتك في إلغاء طلبك للانضمام إلى هذه الغرفة؟"</string>
|
||||
<string name="screen_join_room_cancel_knock_alert_title">"إلغاء طلب الانضمام"</string>
|
||||
<string name="screen_join_room_decline_and_block_alert_confirmation">"نعم، ارفض واحظر"</string>
|
||||
<string name="screen_join_room_decline_and_block_alert_message">"هل أنت متأكد من رغبتك في رفض الدعوة للانضمام إلى هذه الغرفة؟ سيمنع هذا أيضًا %1$s من التواصل معك أو دعوتك إلى غرف."</string>
|
||||
<string name="screen_join_room_decline_and_block_alert_title">"رفض الدعوة والحظر"</string>
|
||||
<string name="screen_join_room_decline_and_block_button_title">"رفض وحظر"</string>
|
||||
<string name="screen_join_room_fail_message">"فشل الانضمام"</string>
|
||||
<string name="screen_join_room_fail_reason">"إما أنك تحتاج إلى دعوة للانضمام، أو قد توجد قيود على الوصول."</string>
|
||||
<string name="screen_join_room_forget_action">"نسيان"</string>
|
||||
<string name="screen_join_room_invite_required_message">"تحتاج إلى دعوة للانضمام"</string>
|
||||
<string name="screen_join_room_invited_by">"بدعوة من"</string>
|
||||
<string name="screen_join_room_join_action">"إنضمام"</string>
|
||||
<string name="screen_join_room_join_restricted_message">"قد تحتاج إلى دعوة أو إلى أن تكون عضوًا في مساحة لتتمكن من الانضمام."</string>
|
||||
<string name="screen_join_room_knock_action">"إرسال طلب الانضمام"</string>
|
||||
<string name="screen_join_room_knock_message_characters_count">"الأحرف المسموح بها: %1$d من %2$d"</string>
|
||||
<string name="screen_join_room_knock_message_description">"رسالة (اختيارية)"</string>
|
||||
<string name="screen_join_room_knock_sent_description">"ستتلقى دعوة للانضمام إلى الغرفة إذا تم قبول طلبك."</string>
|
||||
<string name="screen_join_room_knock_sent_title">"تم إرسال طلب الانضمام"</string>
|
||||
<string name="screen_join_room_loading_alert_message">"تعذّر عرض معاينة الغرفة. قد يكون ذلك بسبب مشكلات في الشبكة أو الخادم."</string>
|
||||
<string name="screen_join_room_loading_alert_title">"تعذّر عرض معاينة هذه الغرفة"</string>
|
||||
<string name="screen_join_room_space_not_supported_description">"لا يدعم %1$s المساحات بعد. يمكنك الوصول إلى المساحات عبر الويب."</string>
|
||||
<string name="screen_join_room_space_not_supported_title">"المساحات غير مدعومة بعد"</string>
|
||||
<string name="screen_join_room_subtitle_knock">"انقر على الزر أدناه وسيتم إشعار مسؤول الغرفة. ستتمكن من الانضمام إلى المحادثة بعد الموافقة."</string>
|
||||
<string name="screen_join_room_subtitle_no_preview">"يجب أن تكون عضوًا في هذه الغرفة لعرض سجل الرسائل."</string>
|
||||
<string name="screen_join_room_title_knock">"هل تريد الانضمام إلى هذه الغرفة؟"</string>
|
||||
<string name="screen_join_room_title_no_preview">"المعاينة غير متاحة"</string>
|
||||
</resources>
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_join_room_ban_by_message">"Has sido baneado por %1$s."</string>
|
||||
<string name="screen_join_room_ban_message">"Has sido baneado."</string>
|
||||
<string name="screen_join_room_ban_by_message">"Has sido vetado por %1$s."</string>
|
||||
<string name="screen_join_room_ban_message">"Has sido vetado"</string>
|
||||
<string name="screen_join_room_ban_reason">"Motivo: %1$s."</string>
|
||||
<string name="screen_join_room_cancel_knock_action">"Cancelar solicitud"</string>
|
||||
<string name="screen_join_room_cancel_knock_alert_confirmation">"Sí, cancelar"</string>
|
||||
@@ -15,9 +15,11 @@
|
||||
<string name="screen_join_room_fail_reason">"O es necesario estar invitado para unirse o puede haber restricciones de acceso."</string>
|
||||
<string name="screen_join_room_forget_action">"Olvidar"</string>
|
||||
<string name="screen_join_room_invite_required_message">"Necesitas una invitación para unirte"</string>
|
||||
<string name="screen_join_room_invited_by">"Te invitó"</string>
|
||||
<string name="screen_join_room_join_action">"Unirse"</string>
|
||||
<string name="screen_join_room_join_restricted_message">"Es posible que necesites ser invitado o ser miembro de un espacio para poder unirte."</string>
|
||||
<string name="screen_join_room_knock_action">"Enviar solicitud de unión"</string>
|
||||
<string name="screen_join_room_knock_message_characters_count">"%1$d de %2$d caracteres permitidos"</string>
|
||||
<string name="screen_join_room_knock_message_description">"Mensaje (opcional)"</string>
|
||||
<string name="screen_join_room_knock_sent_description">"Recibirás una invitación para unirte a la sala si se acepta tu solicitud."</string>
|
||||
<string name="screen_join_room_knock_sent_title">"Solicitud de unión enviada"</string>
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_join_room_ban_by_message">"به دست %1$s از این اتاق محروم شدید."</string>
|
||||
<string name="screen_join_room_ban_message">"از این اتاق محروم شدید"</string>
|
||||
<string name="screen_join_room_ban_by_message">"شما توسط %1$s مسدود شدهاید."</string>
|
||||
<string name="screen_join_room_ban_message">"شما محروم شدید"</string>
|
||||
<string name="screen_join_room_ban_reason">"دلیل: %1$s."</string>
|
||||
<string name="screen_join_room_cancel_knock_action">"لغو درخواست"</string>
|
||||
<string name="screen_join_room_cancel_knock_alert_confirmation">"بله. لغو"</string>
|
||||
<string name="screen_join_room_cancel_knock_alert_description">"آیا مطمئن هستید که میخواهید درخواست عضویت خود در این اتاق را لغو کنید؟"</string>
|
||||
<string name="screen_join_room_cancel_knock_alert_title">"لغو درخواست پیوستن"</string>
|
||||
<string name="screen_join_room_decline_and_block_alert_confirmation">"بله. رد و انسداد"</string>
|
||||
<string name="screen_join_room_decline_and_block_alert_message">"آیا مطمئنید که میخواهید دعوت به عضویت در این اتاق را رد کنید؟ این کار همچنین مانع از تماس %1$s با شما یا دعوت شما به اتاقها میشود."</string>
|
||||
<string name="screen_join_room_decline_and_block_alert_title">"رد دعوت و انسداد"</string>
|
||||
<string name="screen_join_room_decline_and_block_button_title">"رد و انسداد"</string>
|
||||
<string name="screen_join_room_fail_message">"پیوستن شکست خورد"</string>
|
||||
<string name="screen_join_room_forget_action">"فراموشی این اتاق"</string>
|
||||
<string name="screen_join_room_invite_required_message">"برای پیوستن به این اتاق نیاز به دعوت دارید"</string>
|
||||
<string name="screen_join_room_fail_reason">"یا باید دعوتنامه داشته باشید یا ممکن است محدودیتهایی برای دسترسی وجود داشته باشد."</string>
|
||||
<string name="screen_join_room_forget_action">"فراموش کردن"</string>
|
||||
<string name="screen_join_room_invite_required_message">"برای عضویت نیاز به دعوتنامه دارید"</string>
|
||||
<string name="screen_join_room_invited_by">"دعوت شده از سوی"</string>
|
||||
<string name="screen_join_room_join_action">"پیوستن"</string>
|
||||
<string name="screen_join_room_join_restricted_message">"برای پیوستن به فضا باید دعوت شده باشید."</string>
|
||||
<string name="screen_join_room_knock_action">"در زدن برای پیوستن"</string>
|
||||
<string name="screen_join_room_knock_message_characters_count">"کاراکترهای مجاز %1$d از %2$d"</string>
|
||||
<string name="screen_join_room_knock_message_description">"پیام (اختیاری)"</string>
|
||||
<string name="screen_join_room_knock_sent_description">"در صورت پذیرش درخواست، دعوتنامهای برای ورود به اتاق دریافت خواهید کرد."</string>
|
||||
<string name="screen_join_room_knock_sent_title">"درخواست پیوستن فرستاده شد"</string>
|
||||
<string name="screen_join_room_loading_alert_message">"ما نتوانستیم پیشنمایش اتاق را نمایش دهیم. این ممکن است به دلیل مشکلات شبکه یا سرور باشد."</string>
|
||||
<string name="screen_join_room_loading_alert_title">"ما نتوانستیم پیشنمایش این اتاق را نمایش دهیم"</string>
|
||||
<string name="screen_join_room_space_not_supported_description">"%1$s هنوز از فضاها پشتیبانی نمیکند. میتوانید روی وب به فضاها دسترسی داشته باشید."</string>
|
||||
<string name="screen_join_room_space_not_supported_title">"فضاها هنوز پشتیبانی نمیشوند"</string>
|
||||
<string name="screen_join_room_subtitle_knock">"زدن روی این دکمه برای آگاه شدن مدیر اتاق. پس از تأیید میتوانید به گفتوگو بپیوندید."</string>
|
||||
|
||||
+9
@@ -11,7 +11,16 @@ package io.element.android.features.knockrequests.api.banner
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
/**
|
||||
* Renders the banner shown at the top of a room when people are waiting to join it.
|
||||
*/
|
||||
interface KnockRequestsBannerRenderer {
|
||||
/**
|
||||
* Draws the banner, or nothing when there is no pending request or the user cannot moderate the room.
|
||||
*
|
||||
* @param modifier layout modifier for the container.
|
||||
* @param onViewRequestsClick called when the user asks to see the full list of requests.
|
||||
*/
|
||||
@Composable
|
||||
fun View(modifier: Modifier, onViewRequestsClick: () -> Unit)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_knock_requests_list_accept_all_alert_confirm_button_title">"نعم، قبول الكل"</string>
|
||||
<string name="screen_knock_requests_list_accept_all_alert_description">"هل أنت متأكد من رغبتك في قبول جميع طلبات الانضمام؟"</string>
|
||||
<string name="screen_knock_requests_list_accept_all_alert_title">"قبول جميع الطلبات"</string>
|
||||
<string name="screen_knock_requests_list_accept_all_button_title">"قبول الكل"</string>
|
||||
<string name="screen_knock_requests_list_accept_all_failed_alert_description">"تعذّر قبول جميع الطلبات. هل ترغب في المحاولة مرة أخرى؟"</string>
|
||||
<string name="screen_knock_requests_list_accept_all_failed_alert_title">"فشل قبول جميع الطلبات"</string>
|
||||
<string name="screen_knock_requests_list_accept_all_loading_title">"جارٍ قبول جميع طلبات الانضمام"</string>
|
||||
<string name="screen_knock_requests_list_accept_failed_alert_description">"تعذّر قبول هذا الطلب. هل ترغب في المحاولة مرة أخرى؟"</string>
|
||||
<string name="screen_knock_requests_list_accept_failed_alert_title">"فشل قبول الطلب"</string>
|
||||
<string name="screen_knock_requests_list_accept_loading_title">"جارٍ قبول طلب الانضمام"</string>
|
||||
<string name="screen_knock_requests_list_ban_alert_confirm_button_title">"نعم، ارفض واحظر"</string>
|
||||
<string name="screen_knock_requests_list_ban_alert_description">"هل أنت متأكد من رغبتك في رفض %1$s وحظره؟ لن يتمكن هذا المستخدم من طلب الوصول للانضمام إلى هذه الغرفة مرة أخرى."</string>
|
||||
<string name="screen_knock_requests_list_ban_alert_title">"رفض الوصول والحظر"</string>
|
||||
<string name="screen_knock_requests_list_ban_loading_title">"جارٍ رفض الوصول وحظره"</string>
|
||||
<string name="screen_knock_requests_list_decline_alert_confirm_button_title">"نعم، ارفض"</string>
|
||||
<string name="screen_knock_requests_list_decline_alert_description">"هل أنت متأكد من رغبتك في رفض طلب %1$s للانضمام إلى هذه الغرفة؟"</string>
|
||||
<string name="screen_knock_requests_list_decline_alert_title">"رفض الوصول"</string>
|
||||
<string name="screen_knock_requests_list_decline_and_ban_action_title">"رفض وحظر"</string>
|
||||
<string name="screen_knock_requests_list_decline_failed_alert_description">"تعذّر رفض هذا الطلب. هل ترغب في المحاولة مرة أخرى؟"</string>
|
||||
<string name="screen_knock_requests_list_decline_failed_alert_title">"فشل رفض الطلب"</string>
|
||||
<string name="screen_knock_requests_list_decline_loading_title">"جارٍ رفض طلب الانضمام"</string>
|
||||
<string name="screen_knock_requests_list_empty_state_description">"عندما يطلب أحدهم الانضمام إلى الغرفة، ستتمكن من رؤية طلبه هنا."</string>
|
||||
<string name="screen_knock_requests_list_empty_state_title">"لا توجد طلبات انضمام معلّقة"</string>
|
||||
<string name="screen_knock_requests_list_initial_loading_title">"جارٍ تحميل طلبات الانضمام…"</string>
|
||||
<string name="screen_knock_requests_list_title">"طلبات الانضمام"</string>
|
||||
<plurals name="screen_room_multiple_knock_requests_title">
|
||||
<item quantity="zero">"%1$s يريد الانضمام إلى هذه الغرفة"</item>
|
||||
<item quantity="one">"%1$s +%2$d آخر يريدان الانضمام إلى هذه الغرفة"</item>
|
||||
<item quantity="two">"%1$s +%2$d آخران يريدون الانضمام إلى هذه الغرفة"</item>
|
||||
<item quantity="few">"%1$s +%2$d آخرون يريدون الانضمام إلى هذه الغرفة"</item>
|
||||
<item quantity="many">"%1$s +%2$d آخرين يريدون الانضمام إلى هذه الغرفة"</item>
|
||||
<item quantity="other">"%1$s +%2$d آخرين يريدون الانضمام إلى هذه الغرفة"</item>
|
||||
</plurals>
|
||||
<string name="screen_room_multiple_knock_requests_view_all_button_title">"عرض الكل"</string>
|
||||
<string name="screen_room_single_knock_request_accept_button_title">"قبول"</string>
|
||||
<string name="screen_room_single_knock_request_title">"%1$s يريد الانضمام إلى هذه الغرفة"</string>
|
||||
<string name="screen_room_single_knock_request_view_button_title">"عرض"</string>
|
||||
</resources>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user