Merge branch 'develop' into feature/fga/live_location_rendering

This commit is contained in:
ganfra 2026-04-10 09:50:44 +02:00
commit f7bb5b203e
705 changed files with 6439 additions and 2804 deletions

View file

@ -16,9 +16,11 @@ import android.view.ViewTreeObserver
import android.view.WindowInsets
import android.view.inputmethod.InputMethodManager
import androidx.core.content.getSystemService
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.resume
import kotlin.time.Duration.Companion.seconds
fun View.hideKeyboard() {
val imm = context?.getSystemService<InputMethodManager>()
@ -26,29 +28,39 @@ fun View.hideKeyboard() {
}
suspend fun View.hideKeyboardAndAwaitAnimation() {
val imm = context?.getSystemService<InputMethodManager>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !rootWindowInsets.isVisible(WindowInsets.Type.ime())) {
// Keyboard is already hidden, no need to do anything
return
}
val mutex = Mutex()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val imm = context?.getSystemService<InputMethodManager>() ?: return
val future = CompletableDeferred<Unit>()
val requested = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
setOnApplyWindowInsetsListener { view, insets ->
if (!insets.isVisible(WindowInsets.Type.ime())) {
mutex.unlock()
future.complete(Unit)
// Remove the listener now, it's a single use operation
setOnApplyWindowInsetsListener(null)
}
insets
}
imm?.hideSoftInputFromWindow(windowToken, 0)
imm.hideSoftInputFromWindow(windowToken, 0)
} else {
@Suppress("DEPRECATION")
imm?.hideSoftInputFromWindow(windowToken, 0, object : ResultReceiver(null) {
imm.hideSoftInputFromWindow(windowToken, 0, object : ResultReceiver(null) {
override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
if (resultCode == InputMethodManager.RESULT_UNCHANGED_HIDDEN ||
resultCode == InputMethodManager.RESULT_HIDDEN) {
mutex.unlock()
if (resultCode == InputMethodManager.RESULT_UNCHANGED_HIDDEN || resultCode == InputMethodManager.RESULT_HIDDEN) {
future.complete(Unit)
}
}
})
}
mutex.lock()
if (requested) {
// Await the future to ensure the keyboard hide animation has completed before proceeding
withTimeoutOrNull(1.seconds) { future.await() }
}
}
fun View.showKeyboard(andRequestFocus: Boolean = false) {

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:460ddd253f4029b29edde9d858237204acb55aca7e13e92bc691ea71ca34c53e
size 237462
oid sha256:7901fea2f578c8ed796160c9c08f417c61f4fb21580f958844fdf0cb794adf8a
size 239731

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a0b76eee73be6a2ba58eb12883477ebce7daf039b6c60637e263a478a0bc68fe
size 250668
oid sha256:245f012d419817f6557d92a71729b3b70092f24f0eba37f2f1fc431ad27592be
size 252969

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d12abdfa2a2d7d1943e0e377279134b44de0ad5d8fb12b09e23c2083b728989f
size 251951
oid sha256:2d15c52b21cc279d306fa187cd0c318820109b5ec66270e6447e1b02e800eeba
size 254206

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:afb0295b04f302c25f40774562e7d5b2bb668c4cf1158b521ae9b50a35a58d2b
size 322068
oid sha256:a9334d37f010d4e520b11dbd16d664fbb4413497d371dc8b0af0157faf870451
size 323086

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3b94a6d004999869b8650559a70a1427882408b242c9b47788e56320aaeef34c
size 320114
oid sha256:9e016ef5e07de6f6e86e5e6104d78502f5ee15ecb39d1533f020cf94ac087603
size 320821

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:06cebaaf9e0e4f2b69231ab2b866652419e70df50b0abb68288e08f748ed9b76
size 301985
oid sha256:93df69ddd7a1571abcb868495edb9914b5d832c1e55f1520a1c04a71de59577f
size 302213

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:baf841165dfd7c6315dc7bd82d1be8935976d0a9a70e83f4d70e23a2389dab95
size 301760
oid sha256:b1eb3a0283e42d2e2d1083c95fd2bbd2e338fcc5f318c07386f04cfb97e6fed7
size 301963

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:40f0940bd8a5ddee96ea2aac01d9672478fc15044621bfb10f5f0b20d61f035d
size 93402
oid sha256:85ef188fa3a27e42f4beafc899c1f3e7e8bcfad980ed76af6a03f76d70d6a511
size 93807

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:48d8c1bef4a59554649fab33aa716ca2e9fe24f29a6b7e0dae9c404afedd6695
size 99735
oid sha256:f6e38386e95dc0c50384f06fca122ce14851ceff8ffc7865e394c1b4fccc5db6
size 100555

File diff suppressed because one or more lines are too long

View file

@ -122,6 +122,7 @@ private fun getSemanticColors(): ImmutableMap<String, Color> {
"bgBadgeAccent" to bgBadgeAccent,
"bgBadgeDefault" to bgBadgeDefault,
"bgBadgeInfo" to bgBadgeInfo,
"bgBadgePrimary" to bgBadgePrimary,
"bgCanvasDefault" to bgCanvasDefault,
"bgCanvasDefaultLevel1" to bgCanvasDefaultLevel1,
"bgCanvasDisabled" to bgCanvasDisabled,

View file

@ -148,6 +148,9 @@ object CompoundIcons {
@Composable fun Delete(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_delete)
}
@Composable fun DevicePasskey(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_device_passkey)
}
@Composable fun Devices(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_devices)
}
@ -738,6 +741,7 @@ object CompoundIcons {
Copy(),
DarkMode(),
Delete(),
DevicePasskey(),
Devices(),
DialPad(),
Document(),
@ -965,6 +969,7 @@ object CompoundIcons {
R.drawable.ic_compound_copy,
R.drawable.ic_compound_dark_mode,
R.drawable.ic_compound_delete,
R.drawable.ic_compound_device_passkey,
R.drawable.ic_compound_devices,
R.drawable.ic_compound_dial_pad,
R.drawable.ic_compound_document,

View file

@ -49,12 +49,12 @@ data class SemanticColors(
val bgActionTertiaryRest: Color,
/** Background colour for tertiary actions. State: Selected */
val bgActionTertiarySelected: Color,
/** Badge accent background colour */
val bgBadgeAccent: Color,
/** Badge default background colour */
val bgBadgeCritical: Color,
val bgBadgeDefault: Color,
/** Badge info background colour */
val bgBadgeInfo: Color,
val bgBadgePrimary: Color,
val bgBadgeSecondary: Color,
/** Default global background for the user interface. Elevation: Default (Level 0) */
val bgCanvasDefault: Color,
/** Default global background for the user interface. Elevation: Level 1. */
@ -91,6 +91,8 @@ data class SemanticColors(
val bgSubtleSecondaryLevel0: Color,
/** Subtle background colour for success state elements. State: Rest. */
val bgSuccessSubtle: Color,
/** Accent borders for containers */
val borderAccentPrimary: Color,
/** accent border intended for keylines on message highlights */
val borderAccentSubtle: Color,
/** High-contrast border for critical state. State: Hover. */
@ -171,6 +173,8 @@ data class SemanticColors(
val iconTertiary: Color,
/** Translucent version of tertiary icon. Refer to it for intended use. */
val iconTertiaryAlpha: Color,
/** Used to separate core sections of the UI as well as containers */
val separatorPrimary: Color,
/** Accent text colour for plain actions. */
val textActionAccent: Color,
/** Default text colour for plain actions. */

View file

@ -37,9 +37,12 @@ val compoundColorsDark = SemanticColors(
bgActionTertiaryHovered = DarkColorTokens.colorGray300,
bgActionTertiaryRest = DarkColorTokens.colorThemeBg,
bgActionTertiarySelected = DarkColorTokens.colorGray400,
bgBadgeAccent = DarkColorTokens.colorAlphaGreen500,
bgBadgeDefault = DarkColorTokens.colorAlphaGray500,
bgBadgeInfo = DarkColorTokens.colorAlphaBlue500,
bgBadgeAccent = DarkColorTokens.colorGreen400,
bgBadgeCritical = DarkColorTokens.colorRed300,
bgBadgeDefault = DarkColorTokens.colorThemeBg,
bgBadgeInfo = DarkColorTokens.colorBlue400,
bgBadgePrimary = DarkColorTokens.colorGray1400,
bgBadgeSecondary = DarkColorTokens.colorGray400,
bgCanvasDefault = DarkColorTokens.colorThemeBg,
bgCanvasDefaultLevel1 = DarkColorTokens.colorGray300,
bgCanvasDisabled = DarkColorTokens.colorGray200,
@ -58,6 +61,7 @@ val compoundColorsDark = SemanticColors(
bgSubtleSecondary = DarkColorTokens.colorGray300,
bgSubtleSecondaryLevel0 = DarkColorTokens.colorThemeBg,
bgSuccessSubtle = DarkColorTokens.colorGreen200,
borderAccentPrimary = DarkColorTokens.colorGreen900,
borderAccentSubtle = DarkColorTokens.colorGreen700,
borderCriticalHovered = DarkColorTokens.colorRed1000,
borderCriticalPrimary = DarkColorTokens.colorRed900,
@ -98,6 +102,7 @@ val compoundColorsDark = SemanticColors(
iconSuccessPrimary = DarkColorTokens.colorGreen900,
iconTertiary = DarkColorTokens.colorGray800,
iconTertiaryAlpha = DarkColorTokens.colorAlphaGray800,
separatorPrimary = DarkColorTokens.colorGray400,
textActionAccent = DarkColorTokens.colorGreen900,
textActionPrimary = DarkColorTokens.colorGray1400,
textBadgeAccent = DarkColorTokens.colorGreen1100,

View file

@ -37,9 +37,12 @@ val compoundColorsHcDark = SemanticColors(
bgActionTertiaryHovered = DarkHcColorTokens.colorGray300,
bgActionTertiaryRest = DarkHcColorTokens.colorThemeBg,
bgActionTertiarySelected = DarkHcColorTokens.colorGray400,
bgBadgeAccent = DarkHcColorTokens.colorAlphaGreen500,
bgBadgeDefault = DarkHcColorTokens.colorAlphaGray500,
bgBadgeInfo = DarkHcColorTokens.colorAlphaBlue500,
bgBadgeAccent = DarkHcColorTokens.colorGreen400,
bgBadgeCritical = DarkHcColorTokens.colorRed300,
bgBadgeDefault = DarkHcColorTokens.colorThemeBg,
bgBadgeInfo = DarkHcColorTokens.colorBlue400,
bgBadgePrimary = DarkHcColorTokens.colorGray1400,
bgBadgeSecondary = DarkHcColorTokens.colorGray400,
bgCanvasDefault = DarkHcColorTokens.colorThemeBg,
bgCanvasDefaultLevel1 = DarkHcColorTokens.colorGray300,
bgCanvasDisabled = DarkHcColorTokens.colorGray200,
@ -58,6 +61,7 @@ val compoundColorsHcDark = SemanticColors(
bgSubtleSecondary = DarkHcColorTokens.colorGray300,
bgSubtleSecondaryLevel0 = DarkHcColorTokens.colorThemeBg,
bgSuccessSubtle = DarkHcColorTokens.colorGreen200,
borderAccentPrimary = DarkHcColorTokens.colorGreen900,
borderAccentSubtle = DarkHcColorTokens.colorGreen700,
borderCriticalHovered = DarkHcColorTokens.colorRed1000,
borderCriticalPrimary = DarkHcColorTokens.colorRed900,
@ -98,6 +102,7 @@ val compoundColorsHcDark = SemanticColors(
iconSuccessPrimary = DarkHcColorTokens.colorGreen900,
iconTertiary = DarkHcColorTokens.colorGray800,
iconTertiaryAlpha = DarkHcColorTokens.colorAlphaGray800,
separatorPrimary = DarkHcColorTokens.colorGray400,
textActionAccent = DarkHcColorTokens.colorGreen900,
textActionPrimary = DarkHcColorTokens.colorGray1400,
textBadgeAccent = DarkHcColorTokens.colorGreen1100,

View file

@ -37,9 +37,12 @@ val compoundColorsLight = SemanticColors(
bgActionTertiaryHovered = LightColorTokens.colorGray300,
bgActionTertiaryRest = LightColorTokens.colorThemeBg,
bgActionTertiarySelected = LightColorTokens.colorGray400,
bgBadgeAccent = LightColorTokens.colorAlphaGreen400,
bgBadgeDefault = LightColorTokens.colorAlphaGray400,
bgBadgeInfo = LightColorTokens.colorAlphaBlue400,
bgBadgeAccent = LightColorTokens.colorGreen400,
bgBadgeCritical = LightColorTokens.colorRed300,
bgBadgeDefault = LightColorTokens.colorThemeBg,
bgBadgeInfo = LightColorTokens.colorBlue400,
bgBadgePrimary = LightColorTokens.colorGray1400,
bgBadgeSecondary = LightColorTokens.colorGray400,
bgCanvasDefault = LightColorTokens.colorThemeBg,
bgCanvasDefaultLevel1 = LightColorTokens.colorThemeBg,
bgCanvasDisabled = LightColorTokens.colorGray200,
@ -58,6 +61,7 @@ val compoundColorsLight = SemanticColors(
bgSubtleSecondary = LightColorTokens.colorGray300,
bgSubtleSecondaryLevel0 = LightColorTokens.colorGray300,
bgSuccessSubtle = LightColorTokens.colorGreen200,
borderAccentPrimary = LightColorTokens.colorGreen900,
borderAccentSubtle = LightColorTokens.colorGreen700,
borderCriticalHovered = LightColorTokens.colorRed1000,
borderCriticalPrimary = LightColorTokens.colorRed900,
@ -98,6 +102,7 @@ val compoundColorsLight = SemanticColors(
iconSuccessPrimary = LightColorTokens.colorGreen900,
iconTertiary = LightColorTokens.colorGray800,
iconTertiaryAlpha = LightColorTokens.colorAlphaGray800,
separatorPrimary = LightColorTokens.colorGray400,
textActionAccent = LightColorTokens.colorGreen900,
textActionPrimary = LightColorTokens.colorGray1400,
textBadgeAccent = LightColorTokens.colorGreen1100,

View file

@ -37,9 +37,12 @@ val compoundColorsHcLight = SemanticColors(
bgActionTertiaryHovered = LightHcColorTokens.colorGray300,
bgActionTertiaryRest = LightHcColorTokens.colorThemeBg,
bgActionTertiarySelected = LightHcColorTokens.colorGray400,
bgBadgeAccent = LightHcColorTokens.colorAlphaGreen400,
bgBadgeDefault = LightHcColorTokens.colorAlphaGray400,
bgBadgeInfo = LightHcColorTokens.colorAlphaBlue400,
bgBadgeAccent = LightHcColorTokens.colorGreen400,
bgBadgeCritical = LightHcColorTokens.colorRed300,
bgBadgeDefault = LightHcColorTokens.colorThemeBg,
bgBadgeInfo = LightHcColorTokens.colorBlue400,
bgBadgePrimary = LightHcColorTokens.colorGray1400,
bgBadgeSecondary = LightHcColorTokens.colorGray400,
bgCanvasDefault = LightHcColorTokens.colorThemeBg,
bgCanvasDefaultLevel1 = LightHcColorTokens.colorThemeBg,
bgCanvasDisabled = LightHcColorTokens.colorGray200,
@ -58,6 +61,7 @@ val compoundColorsHcLight = SemanticColors(
bgSubtleSecondary = LightHcColorTokens.colorGray300,
bgSubtleSecondaryLevel0 = LightHcColorTokens.colorGray300,
bgSuccessSubtle = LightHcColorTokens.colorGreen200,
borderAccentPrimary = LightHcColorTokens.colorGreen900,
borderAccentSubtle = LightHcColorTokens.colorGreen700,
borderCriticalHovered = LightHcColorTokens.colorRed1000,
borderCriticalPrimary = LightHcColorTokens.colorRed900,
@ -98,6 +102,7 @@ val compoundColorsHcLight = SemanticColors(
iconSuccessPrimary = LightHcColorTokens.colorGreen900,
iconTertiary = LightHcColorTokens.colorGray800,
iconTertiaryAlpha = LightHcColorTokens.colorAlphaGray800,
separatorPrimary = LightHcColorTokens.colorGray400,
textActionAccent = LightHcColorTokens.colorGreen900,
textActionPrimary = LightHcColorTokens.colorGray1400,
textBadgeAccent = LightHcColorTokens.colorGreen1100,

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M9.5,2 L11,6.5 15.5,8 11,9.5 9.5,14 8,9.5 3.5,8 8,6.5zM19,11 L20,14 23,15 20,16 19,19 18,16 15,15 18,14zM12,17 L12.75,19.25L15,20l-2.25,0.75L12,23l-0.75,-2.25L9,20l2.25,-0.75z"
android:fillColor="#FF000000"/>
</vector>

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M19.5,9.385q1.458,0 2.48,1.021Q23,11.426 23,12.885q0,1.122 -0.642,2.027a3.4,3.4 0,0 1,-1.691 1.269v0.204l0.758,0.759q0.088,0.087 0.124,0.189a0.7,0.7 0,0 1,0.036 0.219q0,0.116 -0.036,0.218a0.5,0.5 0,0 1,-0.124 0.19l-0.758,0.759 0.802,1.02a0.6,0.6 0,0 1,0.11 0.197,0.5 0.5,0 0,1 0.02,0.226 0.57,0.57 0,0 1,-0.204 0.38l-1.501,1.313a0.62,0.62 0,0 1,-0.409 0.145,0.5 0.5,0 0,1 -0.212,-0.044 0.6,0.6 0,0 1,-0.167 -0.116l-0.598,-0.599a0.58,0.58 0,0 1,-0.175 -0.423v-4.637a3.54,3.54 0,0 1,-1.875 -1.553A3.36,3.36 0,0 1,16 12.885q0,-1.458 1.02,-2.479 1.022,-1.02 2.48,-1.021m0,2.334q-0.481,0 -0.824,0.343a1.12,1.12 0,0 0,-0.343 0.823q0,0.48 0.343,0.824 0.342,0.343 0.824,0.343t0.824,-0.343q0.343,-0.342 0.343,-0.824 0,-0.48 -0.343,-0.823a1.12,1.12 0,0 0,-0.824 -0.343"
android:fillColor="#FF000000"
android:fillType="evenOdd"/>
<path
android:pathData="M11,13.125q1.532,0 3.025,0.298a5.25,5.25 0,0 0,1.054 2.737q0.542,0.714 1.254,1.217v3.441q0,0.092 0.007,0.182H3.125q-0.478,0 -0.802,-0.323A1.1,1.1 0,0 1,2 19.875V17.85q0,-0.957 0.492,-1.758A3.3,3.3 0,0 1,3.8 14.869a16.7,16.7 0,0 1,3.544 -1.309A15.5,15.5 0,0 1,11 13.125M11,3q1.857,0 3.178,1.322Q15.5,5.644 15.5,7.5t-1.322,3.178T11,12t-3.178,-1.322T6.5,7.5t1.322,-3.178T11,3"
android:fillColor="#FF000000"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M4,20q-0.824,0 -1.412,-0.587A1.93,1.93 0,0 1,2 18L2,6q0,-0.824 0.587,-1.412A1.93,1.93 0,0 1,4 4h6l2,2h8q0.824,0 1.413,0.588Q22,7.175 22,8v10q0,0.824 -0.587,1.413A1.93,1.93 0,0 1,20 20zM4,18h16L20,8h-8.825l-2,-2L4,6z"
android:fillColor="#FF000000"/>
</vector>

View file

@ -8,14 +8,19 @@
package io.element.android.libraries.designsystem.atomic.atoms
import androidx.compose.foundation.BorderStroke
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
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.libraries.designsystem.components.Badge
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
/**
* https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=1960-491
*/
object MatrixBadgeAtom {
data class MatrixBadgeData(
val text: String,
@ -40,6 +45,12 @@ object MatrixBadgeAtom {
Type.Negative -> ElementTheme.colors.bgCriticalSubtle
Type.Info -> ElementTheme.colors.bgBadgeInfo
}
val borderStroke = when (data.type) {
Type.Positive -> null
Type.Neutral -> BorderStroke(1.dp, ElementTheme.colors.borderInteractiveSecondary)
Type.Negative -> null
Type.Info -> null
}
val textColor = when (data.type) {
Type.Positive -> ElementTheme.colors.textBadgeAccent
Type.Neutral -> ElementTheme.colors.textPrimary
@ -58,6 +69,7 @@ object MatrixBadgeAtom {
backgroundColor = backgroundColor,
iconColor = iconColor,
textColor = textColor,
borderStroke = borderStroke,
)
}
}

View file

@ -16,7 +16,10 @@ import android.widget.EditText
import androidx.appcompat.app.ActionBar.LayoutParams
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.awaitVerticalPointerSlopOrCancellation
import androidx.compose.foundation.gestures.verticalDrag
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
@ -41,10 +44,14 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
@ -94,7 +101,7 @@ fun ExpandableBottomSheetLayout(
.run {
if (isSwipeGestureEnabled) {
pointerInput(maxBottomSheetContentHeight) {
detectVerticalDragGestures(
customDetectVerticalDragGestures(
onVerticalDrag = { _, dragAmount ->
val calculatedHeight = max(minBottomContentHeightPx, currentBottomContentHeightPx - dragAmount.roundToInt())
val newHeight = min(calculatedMaxBottomContentHeightPx, calculatedHeight)
@ -120,7 +127,11 @@ fun ExpandableBottomSheetLayout(
animatable.animateTo(destination)
}
}
},
canScroll = {
// We only consider we can scroll in the contents if the min size matches the max size so it's maximized
minBottomContentHeightPx == calculatedMaxBottomContentHeightPx
},
)
}
} else {
@ -189,6 +200,45 @@ fun ExpandableBottomSheetLayout(
)
}
// The original detectVerticalDragGestures doesn't allow us to conditionally consume the initial slop event that triggers the drag,
// which is necessary in our case to allow inner scrollables to work when the sheet is not fully expanded, so we need to re-implement it here
private suspend fun PointerInputScope.customDetectVerticalDragGestures(
onDragStart: (Offset) -> Unit = {},
onDragEnd: () -> Unit = {},
onDragCancel: () -> Unit = {},
canScroll: () -> Boolean = { false },
onVerticalDrag: (change: PointerInputChange, dragAmount: Float) -> Unit,
) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
var overSlop = 0f
val drag =
awaitVerticalPointerSlopOrCancellation(down.id, down.type) { change, over ->
// Consuming this event is what triggers the dragging instead of the inner content scrolling
// We should only consume it if we can't scroll in the inner content so we drag the bottom sheet instead, otherwise we let it pass through
// This is the only change compared to the original detectVerticalDragGestures implementation
if (!canScroll()) {
change.consume()
}
overSlop = over
}
if (drag != null) {
onDragStart.invoke(drag.position)
onVerticalDrag.invoke(drag, overSlop)
if (
verticalDrag(drag.id) {
onVerticalDrag(it, it.positionChange().y)
it.consume()
}
) {
onDragEnd()
} else {
onDragCancel()
}
}
}
}
@Preview(showBackground = true)
@Composable
@Suppress("UnusedPrivateMember")

View file

@ -32,9 +32,13 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.toBitmap
import androidx.core.graphics.withSave
import coil3.Image
import coil3.ImageLoader
@ -50,6 +54,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.utils.CommonDrawables
private val PIN_WIDTH = 42.dp
private val PIN_HEIGHT = PIN_WIDTH * 1.2f
@ -99,21 +104,33 @@ fun LocationPin(
fun rememberLocationPinBitmap(variant: PinVariant): ImageBitmap? {
val context = LocalContext.current
val density = LocalDensity.current
val imageLoader = SingletonImageLoader.get(context)
val colors = pinColors(variant)
val cacheKey = rememberCacheKey(variant)
return produceState<ImageBitmap?>(initialValue = null, cacheKey) {
val memoryCacheKey = MemoryCache.Key(cacheKey)
val cached = imageLoader.memoryCache?.get(memoryCacheKey)
if (cached != null) {
value = cached.image.toBitmap().asImageBitmap()
} else {
val dimensions = PinDimensions(density)
val bitmap = LocationPinRenderer.renderPin(variant, colors, dimensions, context, imageLoader)
imageLoader.memoryCache?.set(memoryCacheKey, MemoryCache.Value(bitmap.asImage()))
value = bitmap.asImageBitmap()
}
}.value
val resources = LocalResources.current
return if (LocalInspectionMode.current) {
// In preview mode, skip async loading and return a simple placeholder image instead to avoid using ImageLoader
val dimensions = PinDimensions(density)
val avatarImage = ResourcesCompat.getDrawable(resources, CommonDrawables.sample_avatar, context.theme)?.toBitmap()?.asImage()
LocationPinRenderer.renderPin(variant, colors, dimensions, avatarImage).asImageBitmap()
} else {
produceState<ImageBitmap?>(initialValue = null, cacheKey) {
val imageLoader = SingletonImageLoader.get(context)
val memoryCacheKey = MemoryCache.Key(cacheKey)
val cached = imageLoader.memoryCache?.get(memoryCacheKey)
if (cached != null) {
value = cached.image.toBitmap().asImageBitmap()
} else {
val dimensions = PinDimensions(density)
val bitmap = with(LocationPinRenderer) {
val avatarImage = loadAvatarImage(variant, context, imageLoader)
renderPin(variant, colors, dimensions, avatarImage)
}
imageLoader.memoryCache?.set(memoryCacheKey, MemoryCache.Value(bitmap.asImage()))
value = bitmap.asImageBitmap()
}
}.value
}
}
@Composable
@ -208,19 +225,17 @@ private object LocationPinRenderer {
/**
* Renders a pin variant to bitmap. Suspending for async avatar loading.
*/
suspend fun renderPin(
fun renderPin(
variant: PinVariant,
colors: PinColors,
dimensions: PinDimensions,
context: Context,
imageLoader: ImageLoader,
avatarImage: Image?,
): Bitmap {
val bitmap = createBitmap(dimensions.pinWidth.toInt(), dimensions.pinHeight.toInt())
val canvas = Canvas(bitmap)
canvas.drawPinShape(colors.fill, colors.stroke, dimensions)
when (variant) {
is PinVariant.UserLocation -> {
val avatarImage = loadAvatarImage(variant.avatarData, context, imageLoader)
canvas.drawAvatar(
avatarImage = avatarImage,
avatarData = variant.avatarData,
@ -284,11 +299,15 @@ private object LocationPinRenderer {
return path
}
private suspend fun loadAvatarImage(
avatarData: AvatarData,
suspend fun loadAvatarImage(
variant: PinVariant,
context: Context,
imageLoader: ImageLoader,
): Image? {
val avatarData = when (variant) {
is PinVariant.UserLocation -> variant.avatarData
else -> return null
}
val request = ImageRequest.Builder(context)
.data(avatarData)
// Disable hardware rendering for Canvas

View file

@ -46,7 +46,8 @@ enum class AvatarSize(val dp: Dp) {
RoomInviteItem(52.dp),
InviteSender(16.dp),
EditRoomDetails(68.dp),
EditRoomDetails(64.dp),
EditSpaceDetails(96.dp),
RoomListManageUser(96.dp),
NotificationsOptIn(32.dp),

View file

@ -75,6 +75,9 @@ val SemanticColors.pinnedMessageBannerIndicator
val SemanticColors.pinnedMessageBannerBorder
get() = if (isLight) LightColorTokens.colorAlphaGray400 else DarkColorTokens.colorAlphaGray400
val SemanticColors.floatingDateBadgeBackground
get() = if (isLight) bgCanvasDefault else bgSubtlePrimary
@PreviewsDayNight
@Composable
internal fun ColorAliasesPreview() = ElementPreview {

View file

@ -118,15 +118,6 @@ class StateContentFormatter(
"PolicyRuleUser"
}
}
OtherState.RoomAliases -> when (renderingMode) {
RenderingMode.RoomList -> {
Timber.v("Filtering timeline item for room state change: $content")
null
}
RenderingMode.Timeline -> {
"RoomAliases"
}
}
OtherState.RoomCanonicalAlias -> when (renderingMode) {
RenderingMode.RoomList -> {
Timber.v("Filtering timeline item for room state change: $content")

View file

@ -1,13 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="state_event_avatar_changed_too">"(taip pat buvo pakeistas ir avataras)"</string>
<string name="state_event_avatar_url_changed">"%1$s pakeitė savo avatarą"</string>
<string name="state_event_avatar_url_changed_by_you">"Jūs pakeitėte savo avatarą"</string>
<string name="state_event_avatar_url_changed">"%1$s pakeitė savo pseudoportretą"</string>
<string name="state_event_avatar_url_changed_by_you">"Jūs pakeitėte savo pseudoportretą"</string>
<string name="state_event_display_name_changed_from">"%1$s pakeitė savo slapyvardį iš %2$s į %3$s"</string>
<string name="state_event_display_name_changed_from_by_you">"Jūs pakeitėte savo slapyvardį iš %1$s į %2$s"</string>
<string name="state_event_display_name_removed">"%1$s pašalino savo slapyvardį (jis buvo %2$s)"</string>
<string name="state_event_display_name_removed_by_you">"Jūs pašalinote savo slapyvardį (jis buvo %1$s)"</string>
<string name="state_event_display_name_set">"%1$s pakeitė savo slapyvardį į %2$s"</string>
<string name="state_event_display_name_set">"%1$s nustatė savo rodomą vardą į %2$s"</string>
<string name="state_event_display_name_set_by_you">"Jūs nustatėte savo slapyvardį į %1$s"</string>
<string name="state_event_room_avatar_changed">"%1$s pakeitė kambario avatarą"</string>
<string name="state_event_room_avatar_changed_by_you">"Jūs pakeitėte kambario avatarą"</string>
@ -21,7 +21,7 @@
<string name="state_event_room_invite_accepted">"%1$s priėmė kvietimą"</string>
<string name="state_event_room_invite_accepted_by_you">"Priėmėte kvietimą"</string>
<string name="state_event_room_invite_by_you">"Jūs pakvietėte %1$s"</string>
<string name="state_event_room_invite_you">"%1$s pakvietė Jus"</string>
<string name="state_event_room_invite_you">"%1$s pakvietė jus"</string>
<string name="state_event_room_join">"%1$s prisijungė prie kambario"</string>
<string name="state_event_room_join_by_you">"Jūs prisijungėte prie kambario"</string>
<string name="state_event_room_knock">"%1$s prašo prisijungti"</string>

View file

@ -601,7 +601,6 @@ class DefaultPinnedMessagesBannerFormatterTest {
OtherState.PolicyRuleRoom,
OtherState.PolicyRuleServer,
OtherState.PolicyRuleUser,
OtherState.RoomAliases,
OtherState.RoomCanonicalAlias,
OtherState.RoomGuestAccess,
OtherState.RoomHistoryVisibility,

View file

@ -746,7 +746,6 @@ class DefaultRoomLatestEventFormatterTest {
OtherState.PolicyRuleRoom,
OtherState.PolicyRuleServer,
OtherState.PolicyRuleUser,
OtherState.RoomAliases,
OtherState.RoomCanonicalAlias,
OtherState.RoomGuestAccess,
OtherState.RoomHistoryVisibility,

View file

@ -70,27 +70,6 @@ enum class FeatureFlags(
defaultValue = { false },
isFinished = false,
),
CreateSpaces(
key = "feature.createSpaces",
title = "Create spaces",
description = "Allow creating spaces.",
defaultValue = { true },
isFinished = false,
),
SpaceSettings(
key = "feature.spaceSettings",
title = "Space settings",
description = "Allow managing space settings such as details, permissions and privacy.",
defaultValue = { true },
isFinished = false,
),
RoomListSpaceFilters(
key = "feature.roomListSpaceFilters",
title = "Room list space filters",
description = "Allow filtering the room list by space.",
defaultValue = { true },
isFinished = false,
),
PrintLogsToLogcat(
key = "feature.print_logs_to_logcat",
title = "Print logs to logcat",
@ -156,10 +135,24 @@ enum class FeatureFlags(
),
ValidateNetworkWhenSchedulingNotificationFetching(
key = "feature.validate_network_when_scheduling_notification_fetching",
title = "validate internet connectivity when scheduling notification fetching",
title = "Validate internet connectivity when scheduling notification fetching",
description = "Only fetch events for push notifications when the device has internet connectivity. " +
"Enabling this can be problematic in air-gapped environments.",
defaultValue = { true },
isFinished = false,
),
FloatingDateBadge(
key = "feature.floating_date_badge",
title = "Display sticky date headers in the timeline",
description = "When scrolling, a sticky date badge will be displayed so you can easily know on which date the messages you're seeing were sent.",
defaultValue = { false },
isFinished = false,
),
SlashCommand(
key = "feature.slash_command",
title = "Parse slash commands in the message composer",
description = "Allow parsing slash commands in the message composer and perform action.",
defaultValue = { false },
isFinished = false,
),
}

View file

@ -27,6 +27,16 @@ sealed interface RoomIdOrAlias : Parcelable {
is Id -> roomId.value
is Alias -> roomAlias.value
}
companion object {
fun from(id: String): RoomIdOrAlias? {
return when {
MatrixPatterns.isRoomId(id) -> Id(RoomId(id))
MatrixPatterns.isRoomAlias(id) -> Alias(RoomAlias(id))
else -> null
}
}
}
}
fun RoomId.toRoomIdOrAlias() = RoomIdOrAlias.Id(this)

View file

@ -17,3 +17,18 @@ interface MxcTools {
*/
fun mxcUri2FilePath(mxcUri: String): String?
}
/**
* "mxc" scheme, including "://". So "mxc://".
*/
const val MATRIX_CONTENT_URI_SCHEME = "mxc://"
/**
* Return true if the String starts with "mxc://".
*/
fun String.isMxcUrl() = startsWith(MATRIX_CONTENT_URI_SCHEME)
/**
* Remove the "mxc://" prefix. No op if the String is not a Mxc URL.
*/
fun String.removeMxcPrefix() = removePrefix(MATRIX_CONTENT_URI_SCHEME)

View file

@ -95,7 +95,6 @@ sealed interface NotificationContent {
data object PolicyRuleRoom : StateEvent
data object PolicyRuleServer : StateEvent
data object PolicyRuleUser : StateEvent
data object RoomAliases : StateEvent
data object RoomAvatar : StateEvent
data object RoomCanonicalAlias : StateEvent
data object RoomCreate : StateEvent

View file

@ -13,7 +13,6 @@ sealed interface StateEventType {
data object PolicyRuleServer : StateEventType
data object PolicyRuleUser : StateEventType
data object CallMember : StateEventType
data object RoomAliases : StateEventType
data object RoomAvatar : StateEventType
data object RoomCanonicalAlias : StateEventType
data object RoomCreate : StateEventType

View file

@ -0,0 +1,19 @@
/*
* 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.libraries.matrix.api.timeline
enum class MsgType {
MSG_TYPE_TEXT,
MSG_TYPE_EMOTE,
// For future support
MSG_TYPE_SNOW,
// For future support
MSG_TYPE_CONFETTI,
}

View file

@ -69,6 +69,8 @@ interface Timeline : AutoCloseable {
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
msgType: MsgType = MsgType.MSG_TYPE_TEXT,
asPlainText: Boolean = false,
): Result<Unit>
suspend fun editMessage(
@ -90,6 +92,7 @@ interface Timeline : AutoCloseable {
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean = false,
msgType: MsgType = MsgType.MSG_TYPE_TEXT,
): Result<Unit>
suspend fun sendImage(

View file

@ -16,7 +16,6 @@ sealed interface OtherState {
data object PolicyRuleRoom : OtherState
data object PolicyRuleServer : OtherState
data object PolicyRuleUser : OtherState
data object RoomAliases : OtherState
data class RoomAvatar(val url: String?) : OtherState
data object RoomCanonicalAlias : OtherState
data object RoomCreate : OtherState

View file

@ -49,7 +49,6 @@ private fun StateEventContent.toContent(): NotificationContent.StateEvent {
StateEventContent.PolicyRuleRoom -> NotificationContent.StateEvent.PolicyRuleRoom
StateEventContent.PolicyRuleServer -> NotificationContent.StateEvent.PolicyRuleServer
StateEventContent.PolicyRuleUser -> NotificationContent.StateEvent.PolicyRuleUser
StateEventContent.RoomAliases -> NotificationContent.StateEvent.RoomAliases
StateEventContent.RoomAvatar -> NotificationContent.StateEvent.RoomAvatar
StateEventContent.RoomCanonicalAlias -> NotificationContent.StateEvent.RoomCanonicalAlias
StateEventContent.RoomCreate -> NotificationContent.StateEvent.RoomCreate

View file

@ -16,7 +16,6 @@ fun StateEventType.map(): RustStateEventType = when (this) {
StateEventType.PolicyRuleServer -> RustStateEventType.PolicyRuleServer
StateEventType.PolicyRuleUser -> RustStateEventType.PolicyRuleUser
StateEventType.CallMember -> RustStateEventType.CallMember
StateEventType.RoomAliases -> RustStateEventType.RoomAliases
StateEventType.RoomAvatar -> RustStateEventType.RoomAvatar
StateEventType.RoomCanonicalAlias -> RustStateEventType.RoomCanonicalAlias
StateEventType.RoomCreate -> RustStateEventType.RoomCreate
@ -46,7 +45,6 @@ fun RustStateEventType.map(): StateEventType = when (this) {
RustStateEventType.PolicyRuleServer -> StateEventType.PolicyRuleServer
RustStateEventType.PolicyRuleUser -> StateEventType.PolicyRuleUser
RustStateEventType.CallMember -> StateEventType.CallMember
RustStateEventType.RoomAliases -> StateEventType.RoomAliases
RustStateEventType.RoomAvatar -> StateEventType.RoomAvatar
RustStateEventType.RoomCanonicalAlias -> StateEventType.RoomCanonicalAlias
RustStateEventType.RoomCreate -> StateEventType.RoomCreate

View file

@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.MsgType
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.TimelineException
@ -271,8 +272,16 @@ class RustTimeline(
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
msgType: MsgType,
asPlainText: Boolean,
): Result<Unit> = withContext(dispatcher) {
MessageEventContent.from(body, htmlBody, intentionalMentions).use { content ->
MessageEventContent.from(
body = body,
htmlBody = htmlBody,
intentionalMentions = intentionalMentions,
msgType = msgType,
asPlainText = asPlainText,
).use { content ->
runCatchingExceptions<Unit> {
inner.send(content)
}
@ -337,9 +346,15 @@ class RustTimeline(
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean,
msgType: MsgType,
): Result<Unit> = withContext(dispatcher) {
runCatchingExceptions {
val msg = MessageEventContent.from(body, htmlBody, intentionalMentions)
val msg = MessageEventContent.from(
body = body,
htmlBody = htmlBody,
intentionalMentions = intentionalMentions,
msgType = msgType,
)
inner.sendReply(
msg = msg,
eventId = repliedToEventId.value,

View file

@ -233,7 +233,6 @@ private fun RustOtherState.map(): OtherState {
RustOtherState.PolicyRuleRoom -> OtherState.PolicyRuleRoom
RustOtherState.PolicyRuleServer -> OtherState.PolicyRuleServer
RustOtherState.PolicyRuleUser -> OtherState.PolicyRuleUser
RustOtherState.RoomAliases -> OtherState.RoomAliases
is RustOtherState.RoomAvatar -> OtherState.RoomAvatar(url)
RustOtherState.RoomCanonicalAlias -> OtherState.RoomCanonicalAlias
RustOtherState.RoomCreate -> OtherState.RoomCreate

View file

@ -9,20 +9,54 @@
package io.element.android.libraries.matrix.impl.util
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.timeline.MsgType
import io.element.android.libraries.matrix.impl.room.map
import org.matrix.rustcomponents.sdk.MessageContent
import org.matrix.rustcomponents.sdk.MessageType
import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
import org.matrix.rustcomponents.sdk.TextMessageContent
import org.matrix.rustcomponents.sdk.contentWithoutRelationFromMessage
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
import org.matrix.rustcomponents.sdk.messageEventContentFromHtmlAsEmote
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdownAsEmote
/**
* Creates a [RoomMessageEventContentWithoutRelation] from a body, an html body and a list of mentions.
*/
object MessageEventContent {
fun from(body: String, htmlBody: String?, intentionalMentions: List<IntentionalMention>): RoomMessageEventContentWithoutRelation {
return if (htmlBody != null) {
messageEventContentFromHtml(body, htmlBody)
} else {
messageEventContentFromMarkdown(body)
}.withMentions(intentionalMentions.map())
fun from(
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
msgType: MsgType = MsgType.MSG_TYPE_TEXT,
asPlainText: Boolean = false,
): RoomMessageEventContentWithoutRelation {
return when {
asPlainText -> contentWithoutRelationFromMessage(
MessageContent(
msgType = MessageType.Text(
TextMessageContent(
body = body,
formatted = null,
)
),
body = body,
isEdited = false,
mentions = null,
)
)
htmlBody != null -> if (msgType == MsgType.MSG_TYPE_EMOTE) {
messageEventContentFromHtmlAsEmote(body, htmlBody)
} else {
messageEventContentFromHtml(body, htmlBody)
}
else -> if (msgType == MsgType.MSG_TYPE_EMOTE) {
messageEventContentFromMarkdownAsEmote(body)
} else {
messageEventContentFromMarkdown(body)
}
}
.withMentions(intentionalMentions.map())
}
}

View file

@ -14,10 +14,9 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import org.matrix.rustcomponents.sdk.EventOrTransactionId
import org.matrix.rustcomponents.sdk.EventSendState
import org.matrix.rustcomponents.sdk.EventTimelineItem
import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo
import org.matrix.rustcomponents.sdk.LazyTimelineItemProvider
import org.matrix.rustcomponents.sdk.ProfileDetails
import org.matrix.rustcomponents.sdk.Receipt
import org.matrix.rustcomponents.sdk.ShieldState
import org.matrix.rustcomponents.sdk.TimelineItemContent
import uniffi.matrix_sdk_ui.EventItemOrigin
@ -26,37 +25,35 @@ internal fun aRustEventTimelineItem(
eventOrTransactionId: EventOrTransactionId = EventOrTransactionId.EventId(AN_EVENT_ID.value),
sender: String = A_USER_ID.value,
senderProfile: ProfileDetails = ProfileDetails.Unavailable,
forwarder: String? = null,
forwarderProfile: ProfileDetails? = null,
isOwn: Boolean = true,
isEditable: Boolean = true,
content: TimelineItemContent = aRustTimelineItemContentMsgLike(),
eventTypeRaw: String? = null,
timestamp: ULong = 0uL,
debugInfo: EventTimelineItemDebugInfo = anEventTimelineItemDebugInfo(),
localSendState: EventSendState? = null,
localCreatedAt: ULong? = null,
readReceipts: Map<String, Receipt> = emptyMap(),
origin: EventItemOrigin? = EventItemOrigin.SYNC,
canBeRepliedTo: Boolean = true,
shieldsState: ShieldState = ShieldState.None,
localCreatedAt: ULong? = null,
forwarder: String? = null,
forwarderProfile: ProfileDetails? = null,
lazyProvider: LazyTimelineItemProvider = FakeFfiLazyTimelineItemProvider(),
) = EventTimelineItem(
isRemote = isRemote,
eventOrTransactionId = eventOrTransactionId,
sender = sender,
senderProfile = senderProfile,
timestamp = timestamp,
isOwn = isOwn,
isEditable = isEditable,
canBeRepliedTo = canBeRepliedTo,
content = content,
localSendState = localSendState,
readReceipts = readReceipts,
origin = origin,
localCreatedAt = localCreatedAt,
lazyProvider = FakeFfiLazyTimelineItemProvider(
debugInfo = debugInfo,
shieldsState = shieldsState,
),
forwarder = forwarder,
forwarderProfile = forwarderProfile,
isOwn = isOwn,
isEditable = isEditable,
content = content,
eventTypeRaw = eventTypeRaw,
timestamp = timestamp,
localSendState = localSendState,
localCreatedAt = localCreatedAt,
readReceipts = readReceipts,
origin = origin,
canBeRepliedTo = canBeRepliedTo,
lazyProvider = lazyProvider,
)

View file

@ -20,7 +20,6 @@ class StateEventTypeTest {
assertThat(RustStateEventType.PolicyRuleRoom.map()).isEqualTo(StateEventType.PolicyRuleRoom)
assertThat(RustStateEventType.PolicyRuleServer.map()).isEqualTo(StateEventType.PolicyRuleServer)
assertThat(RustStateEventType.PolicyRuleUser.map()).isEqualTo(StateEventType.PolicyRuleUser)
assertThat(RustStateEventType.RoomAliases.map()).isEqualTo(StateEventType.RoomAliases)
assertThat(RustStateEventType.RoomAvatar.map()).isEqualTo(StateEventType.RoomAvatar)
assertThat(RustStateEventType.RoomCanonicalAlias.map()).isEqualTo(StateEventType.RoomCanonicalAlias)
assertThat(RustStateEventType.RoomCreate.map()).isEqualTo(StateEventType.RoomCreate)
@ -47,7 +46,6 @@ class StateEventTypeTest {
assertThat(StateEventType.PolicyRuleRoom.map()).isEqualTo(RustStateEventType.PolicyRuleRoom)
assertThat(StateEventType.PolicyRuleServer.map()).isEqualTo(RustStateEventType.PolicyRuleServer)
assertThat(StateEventType.PolicyRuleUser.map()).isEqualTo(RustStateEventType.PolicyRuleUser)
assertThat(StateEventType.RoomAliases.map()).isEqualTo(RustStateEventType.RoomAliases)
assertThat(StateEventType.RoomAvatar.map()).isEqualTo(RustStateEventType.RoomAvatar)
assertThat(StateEventType.RoomCanonicalAlias.map()).isEqualTo(RustStateEventType.RoomCanonicalAlias)
assertThat(StateEventType.RoomCreate.map()).isEqualTo(RustStateEventType.RoomCreate)

View file

@ -19,7 +19,6 @@ dependencies {
api(projects.libraries.matrix.api)
api(libs.coroutines.core)
implementation(libs.coroutines.test)
implementation(projects.libraries.matrix.impl)
implementation(projects.services.analytics.api)
implementation(projects.tests.testutils)
implementation(libs.kotlinx.collections.immutable)

View file

@ -8,8 +8,12 @@
package io.element.android.libraries.matrix.test.mxc
import io.element.android.libraries.matrix.api.mxc.MxcTools
import io.element.android.libraries.matrix.impl.mxc.DefaultMxcTools
import io.element.android.tests.testutils.lambda.lambdaError
class FakeMxcTools(
private val delegate: MxcTools = DefaultMxcTools()
) : MxcTools by delegate
private val mxcUri2FilePathResult: (String) -> String? = { lambdaError() }
) : MxcTools {
override fun mxcUri2FilePath(mxcUri: String): String? {
return mxcUri2FilePathResult(mxcUri)
}
}

View file

@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.MsgType
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
@ -64,7 +65,9 @@ class FakeTimeline(
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
) -> Result<Unit> = { _, _, _ ->
msgType: MsgType,
asPlainText: Boolean,
) -> Result<Unit> = { _, _, _, _, _ ->
lambdaError()
}
@ -76,8 +79,10 @@ class FakeTimeline(
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
msgType: MsgType,
asPlainText: Boolean,
): Result<Unit> = simulateLongTask {
sendMessageLambda(body, htmlBody, intentionalMentions)
sendMessageLambda(body, htmlBody, intentionalMentions, msgType, asPlainText)
}
var redactEventLambda: (eventOrTransactionId: EventOrTransactionId, reason: String?) -> Result<Unit> = { _, _ ->
@ -134,7 +139,8 @@ class FakeTimeline(
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean,
) -> Result<Unit> = { _, _, _, _, _ ->
msgType: MsgType,
) -> Result<Unit> = { _, _, _, _, _, _ ->
lambdaError()
}
@ -144,12 +150,14 @@ class FakeTimeline(
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
fromNotification: Boolean,
msgType: MsgType,
): Result<Unit> = replyMessageLambda(
repliedToEventId,
body,
htmlBody,
intentionalMentions,
fromNotification,
msgType,
)
var sendImageLambda: (

View file

@ -61,8 +61,16 @@ import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
private val editIconContainerSize = 30.dp
private val editIconContainerRadius = editIconContainerSize / 2
private val editIconContainerPadding = 4.dp
private val editIconSize = 20.dp
private val editIconOffset = 8.dp
/**
* Avatar picker view, based on https://www.figma.com/design/kcnHxunG1LDWXsJhaNuiHz/ER-145--Spaces-on-Element-X?node-id=5918-97417&t=JYDQysgjS33AZb74-4
* Avatar picker view.
*
* https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=1949-1384
*
* It takes a [state], which can be [AvatarPickerState.Pick] for displaying the 'pick avatar' button, or [AvatarPickerState.Selected] when an avatar has
* already been selected.
@ -96,7 +104,6 @@ fun AvatarPickerView(
fun eraseBackgroundModifier(
parentWidth: Dp,
editIconRadius: Dp,
) = Modifier
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
@ -107,13 +114,13 @@ fun AvatarPickerView(
color = Color.Black,
center = Offset(
x = if (layoutDirection == LayoutDirection.Ltr) {
parentWidth.toPx() - editIconRadius.toPx() * 0.48f
(parentWidth - editIconContainerRadius + editIconOffset).toPx()
} else {
editIconRadius.toPx() * 0.48f
(editIconContainerRadius - editIconOffset).toPx()
},
y = size.height - editIconRadius.toPx(),
y = size.height - editIconContainerRadius.toPx(),
),
radius = editIconRadius.toPx() * 1.2f,
radius = (editIconContainerRadius + editIconContainerPadding).toPx(),
blendMode = BlendMode.Clear,
)
}
@ -132,7 +139,7 @@ fun AvatarPickerView(
is AvatarPickerState.Selected -> {
Box(modifier = modifier) {
val backgroundModifier = if (enabled) {
eraseBackgroundModifier(state.avatarData.size.dp, state.avatarData.size.dp * 0.225f)
eraseBackgroundModifier(state.avatarData.size.dp)
} else {
Modifier
}
@ -143,7 +150,6 @@ fun AvatarPickerView(
)
if (enabled) {
OverlayEditButton(
editButtonSize = state.avatarData.size.dp * 0.44f,
onClick = onClick,
interactionSource = interactionSource
)
@ -179,15 +185,14 @@ private fun PickButton(
@Composable
private fun BoxScope.OverlayEditButton(
editButtonSize: Dp,
onClick: () -> Unit,
interactionSource: MutableInteractionSource
) {
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.size(editButtonSize)
.offset(x = editButtonSize * 0.266f)
.size(editIconContainerSize)
.offset(x = editIconOffset)
.clip(CircleShape)
.clickable(interactionSource = interactionSource, onClick = onClick, indication = null)
.background(ElementTheme.colors.bgCanvasDefault)
@ -195,7 +200,7 @@ private fun BoxScope.OverlayEditButton(
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(editButtonSize * 0.66f),
modifier = Modifier.size(editIconSize),
imageVector = CompoundIcons.Edit(),
contentDescription = null,
)
@ -234,97 +239,45 @@ internal fun AvatarPickerViewRtlPreview() = CompositionLocalProvider(
@PreviewsDayNight
@Composable
internal fun AvatarPickerSizesPreview() = ElementPreview {
Column {
Row {
AvatarPickerView(AvatarPickerState.Pick(buttonSize = 24.dp, externalPadding = PaddingValues(6.dp)), onClick = {})
AvatarPickerView(AvatarPickerState.Pick(buttonSize = 32.dp, externalPadding = PaddingValues(6.dp)), onClick = {})
AvatarPickerView(AvatarPickerState.Pick(buttonSize = 48.dp, externalPadding = PaddingValues(6.dp)), onClick = {})
AvatarPickerView(AvatarPickerState.Pick(buttonSize = 64.dp, externalPadding = PaddingValues(6.dp)), onClick = {})
AvatarPickerView(AvatarPickerState.Pick(buttonSize = 96.dp, externalPadding = PaddingValues(6.dp)), onClick = {})
// Size used across the codebase
val sizes = listOf(
AvatarSize.EditRoomDetails,
AvatarSize.EditProfileDetails,
)
Column(
modifier = Modifier.padding(12.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
sizes.forEach {
AvatarPickerView(
state = AvatarPickerState.Pick(buttonSize = it.dp, externalPadding = PaddingValues(6.dp)),
onClick = {},
)
}
}
Row {
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.TimelineThreadLatestEventSender),
type = AvatarType.User
),
onClick = {},
modifier = Modifier.padding(6.dp)
)
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.ReadReceiptList),
type = AvatarType.User
),
onClick = {},
modifier = Modifier.padding(6.dp)
)
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.SelectedUser),
type = AvatarType.User
),
onClick = {},
modifier = Modifier.padding(6.dp)
)
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.EditRoomDetails),
type = AvatarType.User
),
onClick = {},
modifier = Modifier.padding(6.dp)
)
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.RoomListManageUser),
type = AvatarType.User
),
onClick = {},
modifier = Modifier.padding(6.dp)
)
Row(verticalAlignment = Alignment.CenterVertically) {
sizes.forEach {
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("@user:example.com", "User", "content://test", size = it),
type = AvatarType.User,
),
onClick = {},
modifier = Modifier.padding(6.dp)
)
}
}
Row {
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.TimelineThreadLatestEventSender),
type = AvatarType.Space()
),
onClick = {},
modifier = Modifier.padding(6.dp)
)
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.ReadReceiptList),
type = AvatarType.Space()
),
onClick = {},
modifier = Modifier.padding(6.dp)
)
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.SelectedUser),
type = AvatarType.Space()
),
onClick = {},
modifier = Modifier.padding(6.dp)
)
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.EditRoomDetails),
type = AvatarType.Space()
),
onClick = {},
modifier = Modifier.padding(6.dp)
)
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.RoomListManageUser),
type = AvatarType.Space()
),
onClick = {},
modifier = Modifier.padding(6.dp)
)
Row(verticalAlignment = Alignment.CenterVertically) {
sizes.forEach {
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("@user:example.com", "User", "content://test", size = it),
type = AvatarType.Space(),
),
onClick = {},
modifier = Modifier.padding(6.dp)
)
}
}
}
}
@ -335,8 +288,9 @@ private fun PreviewContent() {
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
val size = AvatarSize.EditRoomDetails
Text("Pick image")
AvatarPickerView(AvatarPickerState.Pick(buttonSize = 48.dp, externalPadding = PaddingValues(6.dp)), onClick = {})
AvatarPickerView(AvatarPickerState.Pick(buttonSize = size.dp, externalPadding = PaddingValues(6.dp)), onClick = {})
HorizontalDivider()
Text("User avatar")
@ -345,7 +299,7 @@ private fun PreviewContent() {
Text("No url")
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("@user:example.com", "User", null, size = AvatarSize.EditRoomDetails),
avatarData = AvatarData("@user:example.com", "User", null, size = size),
type = AvatarType.User
),
onClick = {},
@ -356,7 +310,7 @@ private fun PreviewContent() {
Text("Local")
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.EditRoomDetails),
avatarData = AvatarData("@user:example.com", "User", "content://test", size = size),
type = AvatarType.User
),
onClick = {},
@ -367,7 +321,7 @@ private fun PreviewContent() {
Text("MXC")
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("@user:example.com", "User", "mxc://test", size = AvatarSize.EditRoomDetails),
avatarData = AvatarData("@user:example.com", "User", "mxc://test", size = size),
type = AvatarType.User
),
onClick = {},
@ -383,7 +337,7 @@ private fun PreviewContent() {
Text("No url")
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("!room:example.com", "Room", null, size = AvatarSize.EditRoomDetails),
avatarData = AvatarData("!room:example.com", "Room", null, size = size),
type = AvatarType.Room()
),
onClick = {},
@ -394,7 +348,7 @@ private fun PreviewContent() {
Text("Local")
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("!room:example.com", "Room", "content://test", size = AvatarSize.EditRoomDetails),
avatarData = AvatarData("!room:example.com", "Room", "content://test", size = size),
type = AvatarType.Room()
),
onClick = {},
@ -405,7 +359,7 @@ private fun PreviewContent() {
Text("MXC")
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("!room:example.com", "Room", "mxc://test", size = AvatarSize.EditRoomDetails),
avatarData = AvatarData("!room:example.com", "Room", "mxc://test", size = size),
type = AvatarType.Room()
),
onClick = {},
@ -421,7 +375,7 @@ private fun PreviewContent() {
Text("No url")
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("!room:example.com", "Space", null, size = AvatarSize.EditRoomDetails),
avatarData = AvatarData("!room:example.com", "Space", null, size = size),
type = AvatarType.Space()
),
onClick = {},
@ -432,7 +386,7 @@ private fun PreviewContent() {
Text("Local")
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("!room:example.com", "Space", "content://test", size = AvatarSize.EditRoomDetails),
avatarData = AvatarData("!room:example.com", "Space", "content://test", size = size),
type = AvatarType.Space()
),
onClick = {},
@ -443,7 +397,7 @@ private fun PreviewContent() {
Text("MXC")
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("!room:example.com", "Space", "mxc://test", size = AvatarSize.EditRoomDetails),
avatarData = AvatarData("!room:example.com", "Space", "mxc://test", size = size),
type = AvatarType.Space()
),
onClick = {},

View file

@ -48,7 +48,7 @@ fun SpaceInfoRow(
) {
if (iconVector != null) {
Icon(
modifier = Modifier.size(20.dp),
modifier = Modifier.size(16.dp),
imageVector = iconVector,
contentDescription = null,
tint = ElementTheme.colors.iconTertiary,
@ -61,7 +61,7 @@ fun SpaceInfoRow(
}
Text(
text = text,
style = ElementTheme.typography.fontBodyLgRegular,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)

View file

@ -18,7 +18,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -82,19 +81,19 @@ private fun ReplyToReadyContent(
modifier: Modifier = Modifier,
) {
val paddings = if (metadata is InReplyToMetadata.Thumbnail) {
PaddingValues(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp)
PaddingValues(end = 8.dp)
} else {
PaddingValues(horizontal = 12.dp, vertical = 4.dp)
PaddingValues(start = 8.dp, end = 8.dp)
}
Row(
modifier
.background(MaterialTheme.colorScheme.surface)
.background(ElementTheme.colors.bgCanvasDefault)
.padding(paddings)
) {
if (metadata is InReplyToMetadata.Thumbnail) {
AttachmentThumbnail(
info = metadata.attachmentThumbnailInfo,
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
backgroundColor = ElementTheme.colors.bgSubtlePrimary,
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(4.dp))
@ -128,7 +127,7 @@ private fun ReplyToLoadingContent(
val paddings = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
Row(
modifier
.background(MaterialTheme.colorScheme.surface)
.background(ElementTheme.colors.bgCanvasDefault)
.padding(paddings)
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
@ -146,7 +145,7 @@ private fun ReplyToErrorContent(
val paddings = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
Row(
modifier
.background(MaterialTheme.colorScheme.surface)
.background(ElementTheme.colors.bgCanvasDefault)
.padding(paddings)
) {
Text(

View file

@ -195,18 +195,16 @@ class AndroidMediaPreProcessor(
file = file,
mimeType = mimeType,
)
val imageInfo = contentResolver.openInputStream(uri).use { input ->
val bitmap = BitmapFactory.decodeStream(input, null, null)!!
ImageInfo(
width = bitmap.width.toLong(),
height = bitmap.height.toLong(),
mimetype = mimeType,
size = file.length(),
thumbnailInfo = thumbnailResult?.info,
thumbnailSource = null,
blurhash = thumbnailResult?.blurhash,
)
}
val (width, height) = extractOrientedImageDimensions(file)
val imageInfo = ImageInfo(
width = width,
height = height,
mimetype = mimeType,
size = file.length(),
thumbnailInfo = thumbnailResult?.info,
thumbnailSource = null,
blurhash = thumbnailResult?.blurhash,
)
removeSensitiveImageMetadata(file)
return MediaUploadInfo.Image(
file = file,
@ -354,6 +352,23 @@ class AndroidMediaPreProcessor(
return contentResolver.openInputStream(uri)?.use { createTmpFileWithInput(it) }
?: error("Could not copy the contents of $uri to a temporary file")
}
private fun extractOrientedImageDimensions(file: File): Pair<Long, Long> {
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.path, options)
val rawWidth = options.outWidth.toLong()
val rawHeight = options.outHeight.toLong()
val orientation = tryOrNull {
ExifInterface(file).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED)
} ?: ExifInterface.ORIENTATION_UNDEFINED
return orientedImageDimensions(
rawWidth = rawWidth,
rawHeight = rawHeight,
orientation = orientation,
)
}
}
private fun ImageCompressionResult.toImageInfo(mimeType: String, thumbnailResult: ThumbnailResult?) = ImageInfo(
@ -371,3 +386,18 @@ private fun MediaMetadataRetriever.extractDuration(): Duration {
val durationInMs = extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L
return durationInMs.milliseconds
}
internal fun orientedImageDimensions(rawWidth: Long, rawHeight: Long, orientation: Int): Pair<Long, Long> {
return if (orientation.rotatesRightAngle()) {
rawHeight to rawWidth
} else {
rawWidth to rawHeight
}
}
private fun Int.rotatesRightAngle(): Boolean {
return this == ExifInterface.ORIENTATION_ROTATE_90 ||
this == ExifInterface.ORIENTATION_ROTATE_270 ||
this == ExifInterface.ORIENTATION_TRANSPOSE ||
this == ExifInterface.ORIENTATION_TRANSVERSE
}

View file

@ -12,6 +12,7 @@ import android.content.Context
import android.net.Uri
import android.os.Build
import androidx.core.net.toUri
import androidx.exifinterface.media.ExifInterface
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
@ -42,6 +43,30 @@ import kotlin.time.Duration
@RunWith(RobolectricTestRunner::class)
class AndroidMediaPreProcessorTest {
@Test
fun `orientedImageDimensions swaps width and height for 90 degree exif orientation`() {
val (width, height) = orientedImageDimensions(
rawWidth = 4032,
rawHeight = 2268,
orientation = ExifInterface.ORIENTATION_ROTATE_90,
)
assertThat(width).isEqualTo(2268)
assertThat(height).isEqualTo(4032)
}
@Test
fun `orientedImageDimensions keeps width and height for upright exif orientation`() {
val (width, height) = orientedImageDimensions(
rawWidth = 4032,
rawHeight = 2268,
orientation = ExifInterface.ORIENTATION_NORMAL,
)
assertThat(width).isEqualTo(4032)
assertThat(height).isEqualTo(2268)
}
private suspend fun TestScope.process(
asset: Asset,
mediaOptimizationConfig: MediaOptimizationConfig,

View file

@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@ -51,6 +52,7 @@ import androidx.media3.common.Timeline
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import com.bumble.appyx.core.node.LocalNodeTargetVisibility
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.audio.api.AudioFocus
@ -130,6 +132,8 @@ private fun ExoPlayerMediaAudioView(
mutableStateOf(null)
}
val isTargetVisible = LocalNodeTargetVisibility.current
val playableState: PlayableState.Playable by remember {
derivedStateOf {
PlayableState.Playable(
@ -196,13 +200,21 @@ private fun ExoPlayerMediaAudioView(
exoPlayer.pause()
}
}
LaunchedEffect(isTargetVisible) {
if (!isTargetVisible) {
exoPlayer.pause()
}
}
if (localMedia?.uri != null) {
LaunchedEffect(localMedia.uri) {
val mediaItem = MediaItem.fromUri(localMedia.uri)
exoPlayer.setMediaItem(mediaItem)
exoPlayer.prepare()
}
} else {
exoPlayer.setMediaItems(emptyList())
LaunchedEffect(Unit) {
exoPlayer.setMediaItems(emptyList())
}
}
val context = LocalContext.current
val waveform = info?.waveform
@ -247,7 +259,7 @@ private fun ExoPlayerMediaAudioView(
}
},
update = { playerView ->
playerView.isVisible = metadata.hasArtwork()
playerView.isVisible = metadata.hasArtwork() && isTargetVisible
},
onRelease = { playerView ->
playerView.player = null
@ -317,16 +329,19 @@ private fun ExoPlayerMediaAudioView(
)
}
OnLifecycleEvent { _, event ->
when (event) {
Lifecycle.Event.ON_CREATE -> exoPlayer.addListener(playerListener)
Lifecycle.Event.ON_RESUME -> exoPlayer.prepare()
Lifecycle.Event.ON_PAUSE -> exoPlayer.pause()
Lifecycle.Event.ON_DESTROY -> {
exoPlayer.release()
DisposableEffect(exoPlayer) {
exoPlayer.addListener(playerListener)
onDispose {
if (!exoPlayer.isReleased) {
exoPlayer.removeListener(playerListener)
exoPlayer.release()
}
else -> Unit
}
}
OnLifecycleEvent { _, event ->
if (event == Lifecycle.Event.ON_PAUSE) {
exoPlayer.pause()
}
}
}

View file

@ -22,5 +22,5 @@ interface PushHandlingWakeLock {
/**
* Release the wakelock. If no wakelock is associated with the key, this method does nothing.
*/
fun unlock()
suspend fun unlock()
}

View file

@ -302,7 +302,6 @@ class DefaultNotifiableEventResolver(
NotificationContent.StateEvent.PolicyRuleRoom,
NotificationContent.StateEvent.PolicyRuleServer,
NotificationContent.StateEvent.PolicyRuleUser,
NotificationContent.StateEvent.RoomAliases,
NotificationContent.StateEvent.RoomAvatar,
NotificationContent.StateEvent.RoomCanonicalAlias,
NotificationContent.StateEvent.RoomCreate,

View file

@ -14,7 +14,6 @@ import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.push.api.push.PushHandlingWakeLock
import timber.log.Timber
import java.util.concurrent.atomic.AtomicInteger
import kotlin.time.Duration
@ContributesBinding(AppScope::class)
@ -22,24 +21,13 @@ import kotlin.time.Duration
class DefaultPushHandlingWakeLock(
@ApplicationContext private val context: Context,
) : PushHandlingWakeLock {
private val count = AtomicInteger(0)
override fun lock(time: Duration) {
Timber.d("Acquiring wakelock for push handling, starting service.")
FetchPushForegroundService.startIfNeeded(context)
count.incrementAndGet()
}
override fun unlock() {
override suspend fun unlock() {
Timber.d("Releasing wakelock used for push handling.")
FetchPushForegroundService.stop(context)
if (count.decrementAndGet() <= 0) {
Timber.d("No more wakelock needed for push handling, stopping service.")
count.set(0)
} else {
Timber.d("Wakelock still needed for push handling, restarting service | count: ${count.get()}.")
FetchPushForegroundService.startIfNeeded(context)
}
}
}

View file

@ -7,6 +7,7 @@
package io.element.android.libraries.push.impl.push
import android.app.ActivityManager
import android.app.Service
import android.content.Context
import android.content.Intent
@ -16,6 +17,7 @@ import android.os.PowerManager
import androidx.core.app.NotificationCompat
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.push.api.push.PushHandlingWakeLock
@ -25,7 +27,12 @@ import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
private const val NOTIFICATION_ID = 1001
@ -51,11 +58,15 @@ class FetchPushForegroundService : Service() {
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
private var isOnForeground = false
override fun onCreate() {
Timber.d("Creating FetchPushForegroundService")
bindings<PushBindings>().inject(this)
wakelock.acquire(wakelockTimeout)
Timber.d("Starting FetchPushForegroundService with wakelock timeout of $wakelockTimeout ms")
// Start the foreground service as soon as possible
val notificationCompat = NotificationCompat.Builder(this, notificationChannels.getSilentChannelId())
.setSmallIcon(CommonDrawables.ic_notification)
.setContentTitle(getString(CommonStrings.common_android_fetching_notifications_title))
@ -63,13 +74,39 @@ class FetchPushForegroundService : Service() {
.setVibrate(longArrayOf(0))
.setSound(null)
.build()
startForeground(NOTIFICATION_ID, notificationCompat)
// Try to start the service in foreground. This can fail, even in cases where it's supposed to work according to the docs.
// In those cases we catch the exception and handle the failure so we don't try to start the wakelock or stop the service
// from running in foreground later.
runCatchingExceptions {
startForeground(NOTIFICATION_ID, notificationCompat)
}
.onSuccess {
isOnForeground = true
Timber.d("FetchPushForegroundService started in foreground successfully")
}
.onFailure {
isOnForeground = false
Timber.e(it, "Failed to start FetchPushForegroundService in foreground")
}
super.onCreate()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (!isOnForeground) {
Timber.w("FetchPushForegroundService is not running in foreground, stopping it to avoid crash")
stopSelf()
return START_NOT_STICKY
}
wakelock.acquire(wakelockTimeout)
// The timeout is not automatic before Android 15, so we need to schedule it ourselves
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) {
coroutineScope.launch {
delay(wakelockTimeout)
onTimeout(startId)
onTimeoutAction(calledByTheSystem = false)
}
}
@ -77,19 +114,30 @@ class FetchPushForegroundService : Service() {
}
override fun stopService(intent: Intent?): Boolean {
wakelock.release()
if (isOnForeground) {
wakelock.release()
stopForeground(STOP_FOREGROUND_REMOVE)
}
stopForeground(STOP_FOREGROUND_REMOVE)
return super.stopService(intent)
}
override fun onTimeout(startId: Int) {
super.onTimeout(startId)
onTimeoutAction(calledByTheSystem = true)
}
pushHandlingWakeLock.unlock()
private fun onTimeoutAction(calledByTheSystem: Boolean) {
Timber.d("onTimeoutAction, calledByTheSystem: $calledByTheSystem, isOnForeground: $isOnForeground")
if (isOnForeground) {
Timber.d("Wakelock timeout reached, stopping FetchPushForegroundService")
coroutineScope.launch { pushHandlingWakeLock.unlock() }
}
}
companion object {
private val stopMutex = Mutex()
fun startIfNeeded(context: Context) {
// Don't start the foreground service if the device is already awake
val powerManager = context.getSystemService(POWER_SERVICE) as PowerManager
@ -101,15 +149,46 @@ class FetchPushForegroundService : Service() {
fun start(context: Context) {
val intent = Intent(context, FetchPushForegroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
runCatchingExceptions { context.startForegroundService(intent) }
.onFailure { throwable ->
Timber.e(
throwable,
"Failed to start FetchPushForegroundService, notifications may take longer than usual to sync"
)
}
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
val intent = Intent(context, FetchPushForegroundService::class.java)
context.stopService(intent)
suspend fun stop(context: Context) = stopMutex.withLock {
val runningServiceInfo = getRunningServiceInfo(context)
if (runningServiceInfo != null) {
val intent = Intent(context, FetchPushForegroundService::class.java)
// If it's still not running in foreground, it means the service is still starting,
// so we delay the stop to give it time to start and be set as foreground, otherwise we can crash
// with `ForegroundServiceDidNotStartInTimeException`.
var isInForeground = runningServiceInfo.foreground
withTimeoutOrNull(5.seconds) {
while (!isInForeground) {
delay(50)
val updatedServiceInfo = getRunningServiceInfo(context)
if (updatedServiceInfo == null) {
Timber.d("FetchPushForegroundService is no longer running, no need to stop it.")
return@withTimeoutOrNull
}
isInForeground = updatedServiceInfo.foreground == true
}
} ?: Timber.w("FetchPushForegroundService did not start in foreground after 5s, stopping it anyway.")
context.stopService(intent)
}
}
@Suppress("DEPRECATION")
private fun getRunningServiceInfo(context: Context): ActivityManager.RunningServiceInfo? {
val activityManager = context.getSystemService(ACTIVITY_SERVICE) as ActivityManager
return activityManager.getRunningServices(Int.MAX_VALUE)
.firstOrNull { it.service.className == FetchPushForegroundService::class.java.name }
}
}
}

View file

@ -16,6 +16,7 @@
<plurals name="notification_fallback_n_content">
<item quantity="other">"%d개의 새 메시지가 있습니다."</item>
</plurals>
<string name="notification_incoming_audio_call">"📞 수신 전화"</string>
<string name="notification_incoming_call">"📹 수신 전화"</string>
<string name="notification_inline_reply_failed">"** 전송 실패 - 방을 열여주세요"</string>
<string name="notification_invitation_action_join">"참가하기"</string>

View file

@ -56,7 +56,6 @@ import io.element.android.libraries.push.impl.notifications.model.FallbackNotifi
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
import io.element.android.libraries.push.test.notifications.FakeCallNotificationEventResolver
import io.element.android.services.toolbox.impl.strings.AndroidStringProvider
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
@ -835,7 +834,6 @@ class DefaultNotifiableEventResolverTest {
testNoResults(NotificationContent.StateEvent.PolicyRuleRoom)
testNoResults(NotificationContent.StateEvent.PolicyRuleServer)
testNoResults(NotificationContent.StateEvent.PolicyRuleUser)
testNoResults(NotificationContent.StateEvent.RoomAliases)
testNoResults(NotificationContent.StateEvent.RoomAvatar)
testNoResults(NotificationContent.StateEvent.RoomCanonicalAlias)
testNoResults(NotificationContent.StateEvent.RoomCreate)

View file

@ -18,7 +18,6 @@ import io.element.android.libraries.matrix.test.notification.FakeNotificationSer
import io.element.android.libraries.matrix.test.notification.aNotificationData
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDataFactory
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.test.notifications.FakeCallNotificationEventResolver
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runCurrent

View file

@ -6,11 +6,10 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.test.notifications
package io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.push.impl.notifications.CallNotificationEventResolver
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.tests.testutils.lambda.lambdaError

View file

@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.timeline.MsgType
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_MESSAGE
@ -341,9 +342,9 @@ class NotificationBroadcastReceiverHandlerTest {
@Test
fun `Test send reply`() = runTest {
val sendMessage = lambdaRecorder<String, String?, List<IntentionalMention>, Result<Unit>> { _, _, _ -> Result.success(Unit) }
val sendMessage = lambdaRecorder<String, String?, List<IntentionalMention>, MsgType, Boolean, Result<Unit>> { _, _, _, _, _ -> Result.success(Unit) }
val replyMessage =
lambdaRecorder<EventId?, String, String?, List<IntentionalMention>, Boolean, Result<Unit>> { _, _, _, _, _ -> Result.success(Unit) }
lambdaRecorder<EventId?, String, String?, List<IntentionalMention>, Boolean, MsgType, Result<Unit>> { _, _, _, _, _, _ -> Result.success(Unit) }
val liveTimeline = FakeTimeline().apply {
sendMessageLambda = sendMessage
replyMessageLambda = replyMessage
@ -375,7 +376,13 @@ class NotificationBroadcastReceiverHandlerTest {
advanceUntilIdle()
sendMessage.assertions()
.isCalledOnce()
.with(value(A_MESSAGE), value(null), value(emptyList<IntentionalMention>()))
.with(
value(A_MESSAGE),
value(null),
value(emptyList<IntentionalMention>()),
value(MsgType.MSG_TYPE_TEXT),
value(false),
)
onNotifiableEventsReceivedResult.assertions()
.isCalledOnce()
replyMessage.assertions()
@ -384,7 +391,7 @@ class NotificationBroadcastReceiverHandlerTest {
@Test
fun `Test send reply blank message`() = runTest {
val sendMessage = lambdaRecorder<String, String?, List<IntentionalMention>, Result<Unit>> { _, _, _ -> Result.success(Unit) }
val sendMessage = lambdaRecorder<String, String?, List<IntentionalMention>, MsgType, Boolean, Result<Unit>> { _, _, _, _, _ -> Result.success(Unit) }
val liveTimeline = FakeTimeline().apply {
sendMessageLambda = sendMessage
}
@ -408,9 +415,9 @@ class NotificationBroadcastReceiverHandlerTest {
@Test
fun `Test send reply to thread`() = runTest {
val sendMessage = lambdaRecorder<String, String?, List<IntentionalMention>, Result<Unit>> { _, _, _ -> Result.success(Unit) }
val sendMessage = lambdaRecorder<String, String?, List<IntentionalMention>, MsgType, Boolean, Result<Unit>> { _, _, _, _, _ -> Result.success(Unit) }
val replyMessage =
lambdaRecorder<EventId?, String, String?, List<IntentionalMention>, Boolean, Result<Unit>> { _, _, _, _, _ -> Result.success(Unit) }
lambdaRecorder<EventId?, String, String?, List<IntentionalMention>, Boolean, MsgType, Result<Unit>> { _, _, _, _, _, _ -> Result.success(Unit) }
val liveTimeline = FakeTimeline().apply {
sendMessageLambda = sendMessage
replyMessageLambda = replyMessage
@ -453,7 +460,8 @@ class NotificationBroadcastReceiverHandlerTest {
value(A_MESSAGE),
value(null),
value(emptyList<IntentionalMention>()),
value(true)
value(true),
value(MsgType.MSG_TYPE_TEXT),
)
}

View file

@ -26,8 +26,8 @@ import io.element.android.libraries.push.impl.history.PushHistoryService
import io.element.android.libraries.push.impl.notifications.FakeNotificationResultProcessor
import io.element.android.libraries.push.impl.test.DefaultTestPush
import io.element.android.libraries.push.impl.troubleshoot.DiagnosticPushHandler
import io.element.android.libraries.push.impl.workmanager.FakeSyncPendingNotificationsRequestBuilder
import io.element.android.libraries.push.impl.workmanager.SyncPendingNotificationsRequestBuilder
import io.element.android.libraries.push.test.workmanager.FakeSyncPendingNotificationsRequestBuilder
import io.element.android.libraries.pushproviders.api.PushData
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore

View file

@ -5,9 +5,8 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.test.workmanager
package io.element.android.libraries.push.impl.workmanager
import io.element.android.libraries.push.impl.workmanager.SyncPendingNotificationsRequestBuilder
import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper
class FakeSyncPendingNotificationsRequestBuilder(

View file

@ -18,7 +18,6 @@ dependencies {
api(projects.libraries.push.api)
api(projects.libraries.pushproviders.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.push.impl)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.workmanager.api)

View file

@ -18,7 +18,7 @@ class FakePushHandlingWakeLock(
lock.invoke(time)
}
override fun unlock() {
override suspend fun unlock() {
unlock.invoke()
}
}

View file

@ -10,6 +10,7 @@ package io.element.android.libraries.pushproviders.firebase
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.google.firebase.messaging.RemoteMessage.PRIORITY_HIGH
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag
@ -45,8 +46,11 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
Timber.tag(loggerTag.value).w("New Firebase message. Priority: ${message.priority}/${message.originalPriority}")
// Acquire wakelock to ensure the device stays awake while we handle the push and schedule and run the work
pushHandlingWakeLock.lock()
val isHighPriority = message.priority == PRIORITY_HIGH
if (isHighPriority) {
// Acquire wakelock to ensure the device stays awake while we handle the push and schedule and run the work
pushHandlingWakeLock.lock()
}
coroutineScope.launch {
val pushData = pushParser.parse(message.data)
@ -58,7 +62,9 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() {
"$it: ${message.data[it]}"
},
)
pushHandlingWakeLock.unlock()
if (isHighPriority) {
pushHandlingWakeLock.unlock()
}
} else {
val handled = pushHandler.handle(
pushData = pushData,
@ -66,7 +72,7 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() {
)
// If we failed to handle the push, we should release the wakelock early to avoid keeping the device awake for too long.
if (!handled) {
if (!handled && isHighPriority) {
pushHandlingWakeLock.unlock()
}
}

View file

@ -96,6 +96,7 @@ class VectorFirebaseMessagingServiceTest {
putString("event_id", AN_EVENT_ID.value)
putString("room_id", A_ROOM_ID.value)
putString("cs", A_SECRET)
putString("google.priority", "high")
},
)
)
@ -127,6 +128,7 @@ class VectorFirebaseMessagingServiceTest {
putString("event_id", AN_EVENT_ID.value)
putString("room_id", A_ROOM_ID.value)
putString("cs", A_SECRET)
putString("google.priority", "high")
},
)
)
@ -141,6 +143,33 @@ class VectorFirebaseMessagingServiceTest {
unlockLambda.assertions().isCalledOnce()
}
@Test
fun `test pushHandler with a remote message with normal priority won't lock the wakelock`() = runTest {
val lockLambda = lambdaRecorder<Duration, Unit> { _ -> }
val unlockLambda = lambdaRecorder<Unit> { }
val vectorFirebaseMessagingService = createVectorFirebaseMessagingService(
pushHandler = FakePushHandler(handleResult = { _, _ -> false }),
pushHandlingWakeLock = FakePushHandlingWakeLock(
lock = lockLambda,
unlock = unlockLambda
)
)
vectorFirebaseMessagingService.onMessageReceived(
message = RemoteMessage(
Bundle().apply {
putString("event_id", AN_EVENT_ID.value)
putString("room_id", A_ROOM_ID.value)
putString("cs", A_SECRET)
putString("google.priority", "normal")
},
)
)
// The wakelock should not be locked
lockLambda.assertions().isNeverCalled()
unlockLambda.assertions().isNeverCalled()
}
@Test
fun `test new token is forwarded to the handler`() = runTest {
val lambda = lambdaRecorder<String, Unit> { }

View file

@ -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.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.slashcommands.api"
}
dependencies {
implementation(projects.libraries.matrix.api)
}

View file

@ -0,0 +1,13 @@
/*
* 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.libraries.slashcommands.api
enum class ChatEffect {
CONFETTI,
SNOWFALL
}

View file

@ -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.libraries.slashcommands.api
enum class MessagePrefix {
Shrug,
TableFlip,
Unflip,
Lenny,
}

View file

@ -0,0 +1,71 @@
/*
* 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.libraries.slashcommands.api
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
/**
* Represent a slash command.
*/
sealed interface SlashCommand {
// This is not a Slash command
data object NotACommand : SlashCommand
// Slash command types:
sealed interface Error : SlashCommand
sealed interface SlashCommandSendMessage : SlashCommand
sealed interface SlashCommandAdmin : SlashCommand
sealed interface SlashCommandNavigation : SlashCommand
// Errors
data class ErrorEmptySlashCommand(val message: String) : Error
data class ErrorCommandNotSupportedInThreads(val message: String) : Error
// Unknown/Unsupported slash command
data class ErrorUnknownSlashCommand(val message: String) : Error
// A slash command is detected, but there is an error
data class ErrorSyntax(val message: String) : Error
// Valid commands:
data class SendPlainText(val message: CharSequence) : SlashCommandSendMessage
data class SendEmote(val message: CharSequence) : SlashCommandSendMessage
data class SendRainbow(val message: CharSequence) : SlashCommandSendMessage
data class SendRainbowEmote(val message: CharSequence) : SlashCommandSendMessage
data class BanUser(val userId: UserId, val reason: String?) : SlashCommandAdmin
data class UnbanUser(val userId: UserId, val reason: String?) : SlashCommandAdmin
data class IgnoreUser(val userId: UserId) : SlashCommandAdmin
data class UnignoreUser(val userId: UserId) : SlashCommandAdmin
data class SetUserPowerLevel(val userId: UserId, val powerLevel: Int?) : SlashCommandAdmin
data class ChangeRoomName(val name: String) : SlashCommandAdmin
data class Invite(val userId: UserId, val reason: String?) : SlashCommandAdmin
data class JoinRoom(val roomIdOrAlias: RoomIdOrAlias, val reason: String?) : SlashCommandAdmin
data class ChangeTopic(val topic: String) : SlashCommandAdmin
data class RemoveUser(val userId: UserId, val reason: String?) : SlashCommandAdmin
data class ChangeDisplayName(val displayName: String) : SlashCommandAdmin
data class ChangeDisplayNameForRoom(val displayName: String) : SlashCommandAdmin
data class ChangeRoomAvatar(val url: String) : SlashCommandAdmin
data class ChangeAvatarForRoom(val url: String) : SlashCommandAdmin
data class SendSpoiler(val message: String) : SlashCommandSendMessage
data class SendWithPrefix(val prefix: MessagePrefix, val message: CharSequence) : SlashCommandSendMessage
data object DiscardSession : SlashCommandAdmin
data class SendChatEffect(val chatEffect: ChatEffect, val message: String) : SlashCommandSendMessage
data object LeaveRoom : SlashCommandAdmin
data class UpgradeRoom(val newVersion: String) : SlashCommandAdmin
data object DevTools : SlashCommandNavigation
data class ShowUser(val userId: UserId) : SlashCommandNavigation
}
fun SlashCommand.Error.message() = when (this) {
is SlashCommand.ErrorEmptySlashCommand -> message
is SlashCommand.ErrorCommandNotSupportedInThreads -> message
is SlashCommand.ErrorUnknownSlashCommand -> message
is SlashCommand.ErrorSyntax -> message
}

View file

@ -0,0 +1,41 @@
/*
* 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.libraries.slashcommands.api
import io.element.android.libraries.matrix.api.timeline.Timeline
interface SlashCommandService {
suspend fun getSuggestions(
text: String,
isInThread: Boolean,
): List<SlashCommandSuggestion>
/**
* Parse the message and return a SlashCommand.
*/
suspend fun parse(
textMessage: CharSequence,
formattedMessage: String?,
isInThreadTimeline: Boolean,
): SlashCommand
/**
* Proceed a SlashCommandSendMessage.
*/
suspend fun proceedSendMessage(
slashCommand: SlashCommand.SlashCommandSendMessage,
timeline: Timeline,
): Result<Unit>
/**
* Proceed a SlashCommandAdmin.
*/
suspend fun proceedAdmin(
slashCommand: SlashCommand.SlashCommandAdmin,
): Result<Unit>
}

View file

@ -0,0 +1,14 @@
/*
* 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.libraries.slashcommands.api
data class SlashCommandSuggestion(
val command: String,
val parameters: String?,
val description: String,
)

View file

@ -0,0 +1,35 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* 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.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.slashcommands.impl"
}
setupDependencyInjection()
dependencies {
implementation(projects.libraries.androidutils)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api)
api(projects.libraries.slashcommands.api)
implementation(projects.libraries.di)
implementation(projects.libraries.featureflag.api)
implementation(projects.services.toolbox.api)
testCommonDependencies(libs)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.toolbox.test)
}

View file

@ -0,0 +1,233 @@
/*
* 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.libraries.slashcommands.impl
import androidx.annotation.StringRes
/**
* Defines the command line operations.
* The user can write these messages to perform some actions.
* The list will be displayed in this order.
*/
enum class Command(
val command: String,
val aliases: List<String>? = null,
val parameters: String? = null,
@StringRes val description: Int,
val isAllowedInThread: Boolean = true,
val isSupported: Boolean = true,
val isDevCommand: Boolean = false,
) {
CRASH_APP(
command = "/crash",
description = R.string.slash_command_description_crash_application,
isDevCommand = true,
),
EMOTE(
command = "/me",
parameters = "<message>",
description = R.string.slash_command_description_emote,
),
BAN_USER(
command = "/ban",
parameters = "<user-id> [reason]",
description = R.string.slash_command_description_ban_user,
),
UNBAN_USER(
command = "/unban",
parameters = "<user-id> [reason]",
description = R.string.slash_command_description_unban_user,
),
IGNORE_USER(
command = "/ignore",
parameters = "<user-id> [reason]",
description = R.string.slash_command_description_ignore_user,
),
UNIGNORE_USER(
command = "/unignore",
parameters = "<user-id>",
description = R.string.slash_command_description_unignore_user,
),
SET_USER_POWER_LEVEL(
command = "/op",
parameters = "<user-id> [<power-level>]",
description = R.string.slash_command_description_op_user,
isAllowedInThread = false,
isSupported = false,
),
RESET_USER_POWER_LEVEL(
command = "/deop",
parameters = "<user-id>",
description = R.string.slash_command_description_deop_user,
isAllowedInThread = false,
isSupported = false,
),
ROOM_NAME(
command = "/roomname",
parameters = "<name>",
description = R.string.slash_command_description_room_name,
isAllowedInThread = false,
),
INVITE(
command = "/invite",
parameters = "<user-id> [reason]",
description = R.string.slash_command_description_invite_user,
),
JOIN_ROOM(
command = "/join",
aliases = listOf("/j", "/goto"),
parameters = "<room-address> [reason]",
description = R.string.slash_command_description_join_room,
isAllowedInThread = false,
isSupported = false,
),
TOPIC(
command = "/topic",
parameters = "<topic>",
description = R.string.slash_command_description_topic,
isAllowedInThread = false,
),
REMOVE_USER(
command = "/remove",
aliases = listOf("/kick"),
parameters = "<user-id> [reason]",
description = R.string.slash_command_description_remove_user,
),
CHANGE_DISPLAY_NAME(
command = "/nick",
parameters = "<display-name>",
description = R.string.slash_command_description_nick,
),
CHANGE_DISPLAY_NAME_FOR_ROOM(
command = "/myroomnick",
aliases = listOf("/roomnick"),
parameters = "<display-name>",
description = R.string.slash_command_description_nick_for_room,
isAllowedInThread = false,
isSupported = false,
),
ROOM_AVATAR(
command = "/roomavatar",
parameters = "<mxc_url>",
description = R.string.slash_command_description_room_avatar,
isAllowedInThread = false,
// Dev command since user has to know the mxc url
isDevCommand = true,
isSupported = false,
),
CHANGE_AVATAR_FOR_ROOM(
command = "/myroomavatar",
parameters = "<mxc_url>",
description = R.string.slash_command_description_avatar_for_room,
isAllowedInThread = false,
// Dev command since user has to know the mxc url
isDevCommand = true,
isSupported = false,
),
RAINBOW(
command = "/rainbow",
parameters = "<message>",
description = R.string.slash_command_description_rainbow,
),
RAINBOW_EMOTE(
command = "/rainbowme",
parameters = "<message>",
description = R.string.slash_command_description_rainbow_emote,
),
DEVTOOLS(
command = "/devtools",
description = R.string.slash_command_description_devtools,
isDevCommand = true,
),
SPOILER(
command = "/spoiler",
parameters = "<message>",
description = R.string.slash_command_description_spoiler,
),
SHRUG(
command = "/shrug",
parameters = "<message>",
description = R.string.slash_command_description_shrug,
),
LENNY(
command = "/lenny",
parameters = "<message>",
description = R.string.slash_command_description_lenny,
),
PLAIN(
command = "/plain",
parameters = "<message>",
description = R.string.slash_command_description_plain,
),
WHOIS(
command = "/whois",
parameters = "<user-id>",
description = R.string.slash_command_description_whois,
),
DISCARD_SESSION(
command = "/discardsession",
description = R.string.slash_command_description_discard_session,
isAllowedInThread = false,
isSupported = false,
),
CONFETTI(
command = "/confetti",
parameters = "<message>",
description = R.string.slash_command_confetti,
isAllowedInThread = false,
isSupported = false,
),
SNOWFALL(
command = "/snowfall",
parameters = "<message>",
description = R.string.slash_command_snow,
isAllowedInThread = false,
isSupported = false,
),
LEAVE_ROOM(
command = "/leave",
aliases = listOf("/part"),
description = R.string.slash_command_description_leave_room,
isAllowedInThread = false,
isDevCommand = true,
),
UPGRADE_ROOM(
command = "/upgraderoom",
parameters = "newVersion",
description = R.string.slash_command_description_upgrade_room,
isAllowedInThread = false,
isDevCommand = true,
isSupported = false,
),
TABLE_FLIP(
command = "/tableflip",
parameters = "<message>",
description = R.string.slash_command_description_table_flip,
),
UNFLIP(
command = "/unflip",
parameters = "<message>",
description = R.string.slash_command_description_unflip,
);
val allAliases = listOf(command) + aliases.orEmpty()
/**
* Checks if the input command matches any of the command aliases, ignoring case.
* Do not exclude not supported commands so that user can discover that the command is not supported.
* Used for whole command parsing.
*/
fun matches(inputCommand: CharSequence) = allAliases.any { it.contentEquals(inputCommand, true) }
/**
* Checks if the input is a prefix of any of the command aliases, ignoring the first character (the slash), and excluding not supported command.
* Used for suggestions.
*/
fun startsWith(input: CharSequence) = isSupported &&
allAliases.any { it.startsWith(input, 1, true) }
}

View file

@ -0,0 +1,214 @@
/*
* 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.libraries.slashcommands.impl
import dev.zacsweers.metro.Inject
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.timeline.MsgType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.slashcommands.api.MessagePrefix
import io.element.android.libraries.slashcommands.api.SlashCommand
import io.element.android.libraries.slashcommands.impl.rainbow.RainbowGenerator
import io.element.android.services.toolbox.api.strings.StringProvider
@Inject
class CommandExecutor(
private val matrixClient: MatrixClient,
private val joinedRoom: JoinedRoom,
private val rainbowGenerator: RainbowGenerator,
private val stringProvider: StringProvider,
) {
suspend fun proceedSendMessage(
slashCommand: SlashCommand.SlashCommandSendMessage,
timeline: Timeline,
): Result<Unit> {
return when (slashCommand) {
is SlashCommand.SendChatEffect -> sendChatEffect()
is SlashCommand.SendEmote -> sendEmote(slashCommand, timeline)
is SlashCommand.SendWithPrefix -> sendPrefixedMessage(slashCommand.prefix, slashCommand.message, timeline)
is SlashCommand.SendPlainText -> sendPlainText(slashCommand, timeline)
is SlashCommand.SendRainbow -> sendRainbow(slashCommand, timeline)
is SlashCommand.SendRainbowEmote -> sendRainbowEmote(slashCommand, timeline)
is SlashCommand.SendSpoiler -> sendSpoiler(slashCommand, timeline)
}
}
suspend fun proceedAdmin(
slashCommand: SlashCommand.SlashCommandAdmin,
): Result<Unit> {
return when (slashCommand) {
is SlashCommand.BanUser -> banUser(slashCommand)
is SlashCommand.ChangeAvatarForRoom -> changeAvatarForRoom()
is SlashCommand.ChangeDisplayName -> changeDisplayName(slashCommand)
is SlashCommand.ChangeDisplayNameForRoom -> changeDisplayNameForRoom()
is SlashCommand.ChangeRoomAvatar -> changeRoomAvatar()
is SlashCommand.ChangeRoomName -> changeRoomName(slashCommand)
is SlashCommand.ChangeTopic -> changeTopic(slashCommand)
is SlashCommand.DiscardSession -> discardSession()
is SlashCommand.IgnoreUser -> ignoreUser(slashCommand)
is SlashCommand.Invite -> invite(slashCommand)
is SlashCommand.JoinRoom -> joinRoom(slashCommand)
is SlashCommand.LeaveRoom -> leaveRoom(joinedRoom)
is SlashCommand.RemoveUser -> removeUser(slashCommand)
is SlashCommand.SetUserPowerLevel -> setUserPowerLevel()
is SlashCommand.UnbanUser -> unbanUser(slashCommand)
is SlashCommand.UnignoreUser -> unignoreUser(slashCommand)
is SlashCommand.UpgradeRoom -> upgradeRoom()
}
}
private fun upgradeRoom(): Result<Unit> {
return Result.failure(Exception("Not yet implemented"))
}
private suspend fun unignoreUser(slashCommand: SlashCommand.UnignoreUser): Result<Unit> {
return matrixClient.unignoreUser(slashCommand.userId)
}
private suspend fun unbanUser(slashCommand: SlashCommand.UnbanUser): Result<Unit> {
return joinedRoom.unbanUser(slashCommand.userId, slashCommand.reason)
}
private fun setUserPowerLevel(): Result<Unit> {
return Result.failure(Exception("Not yet implemented"))
}
private suspend fun sendSpoiler(slashCommand: SlashCommand.SendSpoiler, timeline: Timeline): Result<Unit> {
val text = "[${stringProvider.getString(R.string.common_spoiler)}](${slashCommand.message})"
val formattedText = "<span data-mx-spoiler>${slashCommand.message}</span>"
return timeline.sendMessage(
body = text,
htmlBody = formattedText,
intentionalMentions = emptyList(),
)
}
private suspend fun sendRainbowEmote(slashCommand: SlashCommand.SendRainbowEmote, timeline: Timeline): Result<Unit> {
val message = slashCommand.message.toString()
return timeline.sendMessage(
body = message,
htmlBody = rainbowGenerator.generate(message),
msgType = MsgType.MSG_TYPE_EMOTE,
intentionalMentions = emptyList(),
)
}
private suspend fun sendRainbow(slashCommand: SlashCommand.SendRainbow, timeline: Timeline): Result<Unit> {
val message = slashCommand.message.toString()
return timeline.sendMessage(
body = message,
htmlBody = rainbowGenerator.generate(message),
intentionalMentions = emptyList(),
)
}
private suspend fun sendPlainText(slashCommand: SlashCommand.SendPlainText, timeline: Timeline): Result<Unit> {
return timeline.sendMessage(
body = slashCommand.message.toString(),
htmlBody = null,
intentionalMentions = emptyList(),
asPlainText = true,
)
}
private suspend fun sendEmote(slashCommand: SlashCommand.SendEmote, timeline: Timeline): Result<Unit> {
val message = slashCommand.message.toString()
return timeline.sendMessage(
body = message,
htmlBody = null,
msgType = MsgType.MSG_TYPE_EMOTE,
intentionalMentions = emptyList(),
)
}
private fun sendChatEffect(): Result<Unit> {
return Result.failure(Exception("Not yet implemented"))
}
private suspend fun removeUser(slashCommand: SlashCommand.RemoveUser): Result<Unit> {
return joinedRoom.kickUser(slashCommand.userId, slashCommand.reason)
}
private suspend fun leaveRoom(
room: JoinedRoom,
): Result<Unit> {
return room.leave()
}
private suspend fun joinRoom(slashCommand: SlashCommand.JoinRoom): Result<Unit> {
return matrixClient.joinRoomByIdOrAlias(slashCommand.roomIdOrAlias, emptyList())
.map {}
}
private suspend fun invite(slashCommand: SlashCommand.Invite): Result<Unit> {
return joinedRoom.inviteUserById(slashCommand.userId)
}
private suspend fun ignoreUser(slashCommand: SlashCommand.IgnoreUser): Result<Unit> {
return matrixClient.ignoreUser(slashCommand.userId)
}
private fun discardSession(): Result<Unit> {
return Result.failure(Exception("Not yet implemented"))
}
private suspend fun changeTopic(slashCommand: SlashCommand.ChangeTopic): Result<Unit> {
return joinedRoom.setTopic(slashCommand.topic)
}
private suspend fun changeRoomName(slashCommand: SlashCommand.ChangeRoomName): Result<Unit> {
return joinedRoom.setName(slashCommand.name)
}
private fun changeRoomAvatar(): Result<Unit> {
return Result.failure(Exception("Not yet implemented"))
}
private fun changeDisplayNameForRoom(): Result<Unit> {
return Result.failure(Exception("Not yet implemented"))
}
private suspend fun changeDisplayName(slashCommand: SlashCommand.ChangeDisplayName): Result<Unit> {
return matrixClient.setDisplayName(slashCommand.displayName)
}
private fun changeAvatarForRoom(): Result<Unit> {
return Result.failure(Exception("Not yet implemented"))
}
private suspend fun banUser(slashCommand: SlashCommand.BanUser): Result<Unit> {
return joinedRoom.banUser(slashCommand.userId, slashCommand.reason)
}
private suspend fun sendPrefixedMessage(
prefix: MessagePrefix,
message: CharSequence,
timeline: Timeline,
): Result<Unit> {
val sequence = buildString {
append(prefix.toMarkdown())
if (message.isNotEmpty()) {
append(" ")
append(message)
}
}
return timeline.sendMessage(
body = sequence,
htmlBody = null,
intentionalMentions = emptyList(),
)
}
}
private fun MessagePrefix.toMarkdown() = when (this) {
MessagePrefix.Shrug -> "¯\\\\_(ツ)\\_/¯"
MessagePrefix.TableFlip -> "(╯°□°)╯︵ ┻━┻"
MessagePrefix.Unflip -> "┬──┬ ( ゜-゜ノ)"
MessagePrefix.Lenny -> "( ͡° ͜ʖ ͡°)"
}

View file

@ -0,0 +1,430 @@
/*
* 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.libraries.slashcommands.impl
import dev.zacsweers.metro.Inject
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.mxc.isMxcUrl
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.slashcommands.api.ChatEffect
import io.element.android.libraries.slashcommands.api.MessagePrefix
import io.element.android.libraries.slashcommands.api.SlashCommand
import io.element.android.services.toolbox.api.strings.StringProvider
import kotlinx.coroutines.flow.first
import timber.log.Timber
@Inject
class CommandParser(
private val appPreferencesStore: AppPreferencesStore,
private val featureFlagService: FeatureFlagService,
private val stringProvider: StringProvider,
) {
/**
* Convert the text message into a Slash command.
*
* @param textMessage the text message in plain text
* @param formattedMessage the text messaged in HTML format
* @param isInThreadTimeline true if the user is currently typing in a thread
* @return a parsed slash command (ok or error)
*/
suspend fun parseSlashCommand(
textMessage: CharSequence,
formattedMessage: String?,
isInThreadTimeline: Boolean,
): SlashCommand {
if (!featureFlagService.isFeatureEnabled(FeatureFlags.SlashCommand)) {
return SlashCommand.NotACommand
}
// check if it has the Slash marker
val message = formattedMessage ?: textMessage
return if (!message.startsWith("/")) {
SlashCommand.NotACommand
} else {
// "/" only
if (message.length == 1) {
return SlashCommand.ErrorEmptySlashCommand(
stringProvider.getString(R.string.slash_command_unrecognized, "/")
)
}
// Exclude "//"
if ("/" == message.substring(1, 2)) {
return SlashCommand.NotACommand
}
val (messageParts, message) = extractMessage(message.toString())
?: return SlashCommand.ErrorEmptySlashCommand(
stringProvider.getString(R.string.slash_command_unrecognized, "/")
)
val slashCommand = messageParts.first()
getNotSupportedByThreads(isInThreadTimeline, slashCommand)?.let {
return SlashCommand.ErrorCommandNotSupportedInThreads(
stringProvider.getString(
R.string.slash_command_not_supported_in_threads,
it.command,
)
)
}
when {
Command.PLAIN.matches(slashCommand) -> {
if (message.isNotEmpty()) {
SlashCommand.SendPlainText(message = message)
} else {
syntaxError(Command.PLAIN)
}
}
Command.CHANGE_DISPLAY_NAME.matches(slashCommand) -> {
if (message.isNotEmpty()) {
SlashCommand.ChangeDisplayName(displayName = message)
} else {
syntaxError(Command.CHANGE_DISPLAY_NAME)
}
}
Command.CHANGE_DISPLAY_NAME_FOR_ROOM.matches(slashCommand) -> {
if (message.isNotEmpty()) {
SlashCommand.ChangeDisplayNameForRoom(displayName = message)
} else {
syntaxError(Command.CHANGE_DISPLAY_NAME_FOR_ROOM)
}
}
Command.ROOM_AVATAR.matches(slashCommand) -> {
if (messageParts.size == 2) {
val url = messageParts[1]
if (url.isMxcUrl()) {
SlashCommand.ChangeRoomAvatar(url)
} else {
syntaxError(Command.ROOM_AVATAR)
}
} else {
syntaxError(Command.ROOM_AVATAR)
}
}
Command.CHANGE_AVATAR_FOR_ROOM.matches(slashCommand) -> {
if (messageParts.size == 2) {
val url = messageParts[1]
if (url.isMxcUrl()) {
SlashCommand.ChangeAvatarForRoom(url)
} else {
syntaxError(Command.CHANGE_AVATAR_FOR_ROOM)
}
} else {
syntaxError(Command.CHANGE_AVATAR_FOR_ROOM)
}
}
Command.TOPIC.matches(slashCommand) -> {
if (message.isNotEmpty()) {
SlashCommand.ChangeTopic(topic = message)
} else {
syntaxError(Command.TOPIC)
}
}
Command.EMOTE.matches(slashCommand) -> {
if (message.isNotEmpty()) {
SlashCommand.SendEmote(message)
} else {
syntaxError(Command.EMOTE)
}
}
Command.RAINBOW.matches(slashCommand) -> {
if (message.isNotEmpty()) {
SlashCommand.SendRainbow(message)
} else {
syntaxError(Command.RAINBOW)
}
}
Command.RAINBOW_EMOTE.matches(slashCommand) -> {
if (message.isNotEmpty()) {
SlashCommand.SendRainbowEmote(message)
} else {
syntaxError(Command.RAINBOW_EMOTE)
}
}
Command.JOIN_ROOM.matches(slashCommand) -> {
if (messageParts.size >= 2) {
val id = messageParts[1]
val roomIdOrAlias = RoomIdOrAlias.from(id)
if (roomIdOrAlias != null) {
SlashCommand.JoinRoom(
RoomIdOrAlias.Id(RoomId(id)),
trimParts(textMessage, messageParts.take(2))
)
} else {
syntaxError(Command.JOIN_ROOM)
}
} else {
syntaxError(Command.JOIN_ROOM)
}
}
Command.ROOM_NAME.matches(slashCommand) -> {
if (message.isNotEmpty()) {
SlashCommand.ChangeRoomName(name = message)
} else {
syntaxError(Command.ROOM_NAME)
}
}
Command.INVITE.matches(slashCommand) -> {
if (messageParts.size >= 2) {
parseUserId(messageParts)
?.let { userId ->
SlashCommand.Invite(
userId = userId,
reason = trimParts(textMessage, messageParts.take(2))
)
}
?: syntaxError(Command.INVITE)
} else {
syntaxError(Command.INVITE)
}
}
Command.REMOVE_USER.matches(slashCommand) -> {
parseUserId(messageParts)
?.let { userId ->
SlashCommand.RemoveUser(
userId = userId,
reason = trimParts(textMessage, messageParts.take(2))
)
}
?: syntaxError(Command.REMOVE_USER)
}
Command.BAN_USER.matches(slashCommand) -> {
parseUserId(messageParts)
?.let { userId ->
SlashCommand.BanUser(
userId = userId,
reason = trimParts(textMessage, messageParts.take(2))
)
}
?: syntaxError(Command.BAN_USER)
}
Command.UNBAN_USER.matches(slashCommand) -> {
parseUserId(messageParts)
?.let { userId ->
SlashCommand.UnbanUser(
userId = userId,
reason = trimParts(textMessage, messageParts.take(2))
)
}
?: syntaxError(Command.UNBAN_USER)
}
Command.IGNORE_USER.matches(slashCommand) -> {
parseUserId(messageParts)
?.let { userId ->
SlashCommand.IgnoreUser(
userId = userId,
)
}
?: syntaxError(Command.IGNORE_USER)
}
Command.UNIGNORE_USER.matches(slashCommand) -> {
parseUserId(messageParts)
?.let { userId ->
SlashCommand.UnignoreUser(
userId = userId,
)
}
?: syntaxError(Command.UNIGNORE_USER)
}
Command.SET_USER_POWER_LEVEL.matches(slashCommand) -> {
if (messageParts.size == 3) {
val userId = parseUserId(messageParts)
if (userId != null) {
val powerLevelsAsString = messageParts[2]
try {
val powerLevelsAsInt = Integer.parseInt(powerLevelsAsString)
SlashCommand.SetUserPowerLevel(
userId = userId,
powerLevel = powerLevelsAsInt
)
} catch (_: Exception) {
syntaxError(Command.SET_USER_POWER_LEVEL)
}
} else {
syntaxError(Command.SET_USER_POWER_LEVEL)
}
} else {
syntaxError(Command.SET_USER_POWER_LEVEL)
}
}
Command.RESET_USER_POWER_LEVEL.matches(slashCommand) -> {
parseUserId(messageParts)
?.let { userId ->
SlashCommand.SetUserPowerLevel(
userId = userId,
powerLevel = null
)
}
?: syntaxError(Command.SET_USER_POWER_LEVEL)
}
Command.DEVTOOLS.matches(slashCommand) -> {
if (messageParts.size == 1) {
SlashCommand.DevTools
} else {
syntaxError(Command.DEVTOOLS)
}
}
Command.SPOILER.matches(slashCommand) -> {
if (message.isNotEmpty()) {
SlashCommand.SendSpoiler(message)
} else {
syntaxError(Command.SPOILER)
}
}
Command.SHRUG.matches(slashCommand) -> {
SlashCommand.SendWithPrefix(MessagePrefix.Shrug, message)
}
Command.LENNY.matches(slashCommand) -> {
SlashCommand.SendWithPrefix(MessagePrefix.Lenny, message)
}
Command.TABLE_FLIP.matches(slashCommand) -> {
SlashCommand.SendWithPrefix(MessagePrefix.TableFlip, message)
}
Command.UNFLIP.matches(slashCommand) -> {
SlashCommand.SendWithPrefix(MessagePrefix.Unflip, message)
}
Command.DISCARD_SESSION.matches(slashCommand) -> {
if (messageParts.size == 1) {
SlashCommand.DiscardSession
} else {
syntaxError(Command.DISCARD_SESSION)
}
}
Command.WHOIS.matches(slashCommand) -> {
parseUserId(messageParts)
?.let { userId ->
SlashCommand.ShowUser(
userId = userId,
)
}
?: syntaxError(Command.WHOIS)
}
Command.CONFETTI.matches(slashCommand) -> {
SlashCommand.SendChatEffect(ChatEffect.CONFETTI, message)
}
Command.SNOWFALL.matches(slashCommand) -> {
SlashCommand.SendChatEffect(ChatEffect.SNOWFALL, message)
}
Command.LEAVE_ROOM.matches(slashCommand) -> {
if (messageParts.size == 1) {
SlashCommand.LeaveRoom
} else {
syntaxError(Command.LEAVE_ROOM)
}
}
Command.UPGRADE_ROOM.matches(slashCommand) -> {
if (message.isNotEmpty()) {
SlashCommand.UpgradeRoom(newVersion = message)
} else {
syntaxError(Command.UPGRADE_ROOM)
}
}
Command.CRASH_APP.matches(slashCommand) && appPreferencesStore.isDeveloperModeEnabledFlow().first() -> {
error("Application crashed from user demand")
}
else -> {
// Unknown command
SlashCommand.ErrorUnknownSlashCommand(
stringProvider.getString(R.string.slash_command_unrecognized, slashCommand)
)
}
}
}
}
private fun parseUserId(messageParts: List<String>): UserId? {
val str = messageParts.getOrNull(1) ?: return null
return when {
MatrixPatterns.isUserId(str) -> str
str == "<a" -> {
// Rich text editor mode
messageParts.getOrNull(2)?.let { html ->
// html must match "href="https://matrix.to/#/@user:domain.org">@user:domain.org</a>"
val regex = "href=\"https://matrix.to/#/([^\"]+)\">([^<]+)</a>".toRegex()
val matchResult = regex.find(html)
val userId = matchResult?.groupValues?.getOrNull(1)
userId?.takeIf {
userId == matchResult.groupValues.getOrNull(2) && MatrixPatterns.isUserId(it)
}
}
}
else -> {
// Can be markdown format like "[@user:domain.org](https://matrix.to/#/@user:domain.org)"
val regex = "\\[([^\\]]+)]\\(https://matrix.to/#/([^\\]]+)\\)".toRegex()
val matchResult = regex.find(str)
val userId = matchResult?.groupValues?.getOrNull(1)
userId?.takeIf {
userId == matchResult.groupValues.getOrNull(2) && MatrixPatterns.isUserId(it)
}
}
}
?.let(::UserId)
}
private fun syntaxError(command: Command) = SlashCommand.ErrorSyntax(
stringProvider.getString(
R.string.slash_command_parameters_error,
command.command,
buildString {
append(command.command)
if (command.parameters != null) {
append(" ${command.parameters}")
}
},
)
)
private fun extractMessage(message: String): Pair<List<String>, String>? {
val messageParts = try {
message.split("\\s+".toRegex()).dropLastWhile { it.isEmpty() }
} catch (e: Exception) {
Timber.e(e, "## parseSlashCommand() : split failed")
null
}
// test if the string cut fails
if (messageParts.isNullOrEmpty()) {
return null
}
val slashCommand = messageParts.first()
val trimmedMessage = message.substring(slashCommand.length).trim()
return messageParts to trimmedMessage
}
private val notSupportedThreadsCommands: List<Command> by lazy {
Command.entries.filter {
!it.isAllowedInThread
}
}
/**
* Checks whether the current command is not supported by threads.
* @param isInThreadTimeline if its true we are in a thread timeline
* @param slashCommand the slash command that will be checked
* @return The command that is not supported
*/
private fun getNotSupportedByThreads(isInThreadTimeline: Boolean, slashCommand: String): Command? {
return if (isInThreadTimeline) {
notSupportedThreadsCommands.firstOrNull {
it.command == slashCommand
}
} else {
null
}
}
private fun trimParts(message: CharSequence, messageParts: List<String>): String? {
val partsSize = messageParts.sumOf { it.length }
val gapsNumber = messageParts.size - 1
return message.substring(partsSize + gapsNumber).trim().takeIf { it.isNotEmpty() }
}
}

View file

@ -0,0 +1,80 @@
/*
* 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.libraries.slashcommands.impl
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.slashcommands.api.SlashCommand
import io.element.android.libraries.slashcommands.api.SlashCommandService
import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion
import io.element.android.services.toolbox.api.strings.StringProvider
import kotlinx.coroutines.flow.first
@ContributesBinding(RoomScope::class)
class DefaultSlashCommandService(
private val commandParser: CommandParser,
private val commandExecutor: CommandExecutor,
private val stringProvider: StringProvider,
private val appPreferencesStore: AppPreferencesStore,
private val featureFlagService: FeatureFlagService,
) : SlashCommandService {
override suspend fun getSuggestions(
text: String,
isInThread: Boolean,
): List<SlashCommandSuggestion> {
if (!featureFlagService.isFeatureEnabled(FeatureFlags.SlashCommand)) return emptyList()
val isDeveloperModeEnabled = appPreferencesStore.isDeveloperModeEnabledFlow().first()
return Command.entries.filter {
it.startsWith(text)
}.filter {
!isInThread || it.isAllowedInThread
}.filter {
!it.isDevCommand || isDeveloperModeEnabled
}.map {
SlashCommandSuggestion(
command = it.command,
parameters = it.parameters,
description = stringProvider.getString(it.description),
)
}
}
override suspend fun parse(
textMessage: CharSequence,
formattedMessage: String?,
isInThreadTimeline: Boolean,
): SlashCommand {
return commandParser.parseSlashCommand(
textMessage = textMessage,
formattedMessage = formattedMessage,
isInThreadTimeline = isInThreadTimeline,
)
}
override suspend fun proceedSendMessage(
slashCommand: SlashCommand.SlashCommandSendMessage,
timeline: Timeline,
): Result<Unit> {
return commandExecutor.proceedSendMessage(
slashCommand = slashCommand,
timeline = timeline,
)
}
override suspend fun proceedAdmin(
slashCommand: SlashCommand.SlashCommandAdmin,
): Result<Unit> {
return commandExecutor.proceedAdmin(
slashCommand = slashCommand,
)
}
}

View file

@ -0,0 +1,113 @@
/*
* 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.libraries.slashcommands.impl.rainbow
import dev.zacsweers.metro.Inject
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.math.sin
/**
* Inspired from React-Sdk
* Ref: https://github.com/matrix-org/matrix-react-sdk/blob/develop/src/utils/colour.js
*/
@Inject
class RainbowGenerator {
fun generate(text: String): String {
val split = text.splitEmoji()
val frequency = 2 * Math.PI / split.size
return split
.mapIndexed { idx, letter ->
// Do better than React-Sdk: Avoid adding font color for spaces
if (letter == " ") {
"$letter"
} else {
val (a, b) = generateAB(idx * frequency, 1f)
val dashColor = labToRGB(75, a, b).toDashColor()
"<font color=\"$dashColor\">$letter</font>"
}
}
.joinToString(separator = "")
}
private fun generateAB(hue: Double, chroma: Float): Pair<Double, Double> {
val a = chroma * 127 * cos(hue)
val b = chroma * 127 * sin(hue)
return Pair(a, b)
}
private fun labToRGB(l: Int, a: Double, b: Double): RgbColor {
// Convert CIELAB to CIEXYZ (D65)
var y = (l + 16) / 116.0
val x = adjustXYZ(y + a / 500) * 0.9505
val z = adjustXYZ(y - b / 200) * 1.0890
y = adjustXYZ(y)
// Linear transformation from CIEXYZ to RGB
val red = 3.24096994 * x - 1.53738318 * y - 0.49861076 * z
val green = -0.96924364 * x + 1.8759675 * y + 0.04155506 * z
val blue = 0.05563008 * x - 0.20397696 * y + 1.05697151 * z
return RgbColor(adjustRGB(red), adjustRGB(green), adjustRGB(blue))
}
private fun adjustXYZ(value: Double): Double {
if (value > 0.2069) {
return value.pow(3)
}
return 0.1284 * value - 0.01771
}
private fun gammaCorrection(value: Double): Double {
// Non-linear transformation to sRGB
if (value <= 0.0031308) {
return 12.92 * value
}
return 1.055 * value.pow(1 / 2.4) - 0.055
}
private fun adjustRGB(value: Double): Int {
return (gammaCorrection(value)
.coerceIn(0.0, 1.0) * 255)
.roundToInt()
}
}
/**
* Same as split, but considering emojis.
*/
private fun CharSequence.splitEmoji(): List<CharSequence> {
val result = mutableListOf<CharSequence>()
var index = 0
while (index < length) {
val firstChar = get(index)
if (firstChar.code == 0x200e) {
// Left to right mark. What should I do with it?
} else if (firstChar.code in 0xD800..0xDBFF && index + 1 < length) {
// We have the start of a surrogate pair
val secondChar = get(index + 1)
if (secondChar.code in 0xDC00..0xDFFF) {
// We have an emoji
result.add("$firstChar$secondChar")
index++
} else {
// Not sure what we have here...
result.add("$firstChar")
}
} else {
// Regular char
result.add("$firstChar")
}
index++
}
return result
}

View file

@ -0,0 +1,21 @@
/*
* 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.libraries.slashcommands.impl.rainbow
data class RgbColor(
val r: Int,
val g: Int,
val b: Int
)
fun RgbColor.toDashColor(): String {
return listOf(r, g, b)
.joinToString(separator = "", prefix = "#") {
it.toString(16).padStart(2, '0')
}
}

View file

@ -0,0 +1,47 @@
<?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="slash_command_error">Command error</string>
<string name="slash_command_unrecognized">Unrecognized command: %1$s</string>
<string name="slash_command_parameters_error">The command \"%1$s\" needs more parameters, or some parameters are incorrect.The syntax is\n\n%2$s</string>
<string name="slash_command_not_supported_in_threads">The command \"%1$s\" is recognized but not supported in threads.</string>
<string name="slash_command_description_emote">Displays action</string>
<string name="slash_command_description_crash_application">Crash the application.</string>
<string name="slash_command_description_ban_user">Bans user with given id</string>
<string name="slash_command_description_unban_user">Unbans user with given id</string>
<string name="slash_command_description_ignore_user">Ignores a user, hiding their messages from you</string>
<string name="slash_command_description_unignore_user">Stops ignoring a user, showing their messages going forward</string>
<string name="slash_command_description_op_user">Define the power level of a user</string>
<string name="slash_command_description_deop_user">Deops user with given id</string>
<string name="slash_command_description_room_name">Sets the room name</string>
<string name="slash_command_description_rainbow">Sends the given message colored as a rainbow</string>
<string name="slash_command_description_rainbow_emote">Sends the given emote colored as a rainbow</string>
<string name="slash_command_description_invite_user">Invites user with given id to current room</string>
<string name="slash_command_description_join_room">Joins room with given address</string>
<string name="slash_command_description_spoiler">Sends the given message as a spoiler</string>
<string name="slash_command_description_topic">Set the room topic</string>
<string name="slash_command_description_remove_user">Removes user with given id from this room</string>
<string name="slash_command_description_nick">Changes your display nickname</string>
<string name="slash_command_confetti">Sends the given message with confetti</string>
<string name="slash_command_snow">Sends the given message with snowfall</string>
<string name="slash_command_description_plain">Sends a message as plain text, without interpreting it as markdown</string>
<string name="slash_command_description_nick_for_room">Changes your display nickname in the current room only</string>
<string name="slash_command_description_room_avatar">Changes the avatar of the current room</string>
<string name="slash_command_description_avatar_for_room">Changes your avatar in this current room only</string>
<string name="slash_command_description_devtools">Open the developer tools screen</string>
<string name="slash_command_description_whois">Displays information about a user</string>
<string name="slash_command_description_shrug">Prepends ¯\\_(ツ)_/¯ to a plain-text message</string>
<string name="slash_command_description_lenny">Prepends ( ͡° ͜ʖ ͡°) to a plain-text message</string>
<string name="slash_command_description_table_flip">Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message</string>
<string name="slash_command_description_unflip">Prepends ┬──┬ ( ゜-゜ノ) to a plain-text message</string>
<string name="slash_command_description_discard_session">Forces the current outbound group session in an encrypted room to be discarded</string>
<string name="slash_command_description_discard_session_not_handled">Only supported in encrypted rooms</string>
<string name="slash_command_description_leave_room">Leave the current room</string>
<string name="slash_command_description_upgrade_room">Upgrades a room to a new version</string>
<string name="common_spoiler">Spoiler</string>
</resources>

View file

@ -0,0 +1,359 @@
/*
* 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.libraries.slashcommands.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.timeline.MsgType
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.slashcommands.api.ChatEffect
import io.element.android.libraries.slashcommands.api.MessagePrefix
import io.element.android.libraries.slashcommands.api.SlashCommand
import io.element.android.libraries.slashcommands.impl.rainbow.RainbowGenerator
import io.element.android.services.toolbox.api.strings.StringProvider
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import kotlinx.coroutines.test.runTest
import org.junit.Test
class CommandExecutorTest {
@Test
fun `send plain text delegates to timeline with plain flag`() = runTest {
val timeline = FakeTimeline()
var capturedBody: String? = null
var capturedHtml: String? = "initial"
var capturedAsPlainText = false
timeline.sendMessageLambda = { body, htmlBody, _, _, asPlainText ->
capturedBody = body
capturedHtml = htmlBody
capturedAsPlainText = asPlainText
Result.success(Unit)
}
val sut = createCommandExecutor()
val res = sut.proceedSendMessage(SlashCommand.SendPlainText("hello"), timeline)
assertThat(res.isSuccess).isTrue()
assertThat(capturedBody).isEqualTo("hello")
assertThat(capturedHtml).isNull()
assertThat(capturedAsPlainText).isTrue()
}
@Test
fun `send emote delegates to timeline as emote`() = runTest {
val timeline = FakeTimeline()
var msgType: MsgType? = null
timeline.sendMessageLambda = { _, _, _, type, _ ->
msgType = type
Result.success(Unit)
}
val sut = createCommandExecutor()
val res = sut.proceedSendMessage(SlashCommand.SendEmote("yay"), timeline)
assertThat(res.isSuccess).isTrue()
assertThat(msgType).isEqualTo(MsgType.MSG_TYPE_EMOTE)
}
@Test
fun `send lenny prefixes message`() = runTest {
val timeline = FakeTimeline()
var capturedBody: String? = null
timeline.sendMessageLambda = { body, _, _, _, _ ->
capturedBody = body
Result.success(Unit)
}
val sut = createCommandExecutor()
val res = sut.proceedSendMessage(SlashCommand.SendWithPrefix(MessagePrefix.Lenny, "fun"), timeline)
assertThat(res.isSuccess).isTrue()
assertThat(capturedBody).isEqualTo("( ͡° ͜ʖ ͡°) fun")
}
@Test
fun `send table flip prefixes message`() = runTest {
val timeline = FakeTimeline()
var capturedBody: String? = null
timeline.sendMessageLambda = { body, _, _, _, _ ->
capturedBody = body
Result.success(Unit)
}
val sut = createCommandExecutor()
val res = sut.proceedSendMessage(SlashCommand.SendWithPrefix(MessagePrefix.TableFlip, "wow"), timeline)
assertThat(res.isSuccess).isTrue()
assertThat(capturedBody).isEqualTo("(╯°□°)╯︵ ┻━┻ wow")
}
@Test
fun `send unflip prefixes message`() = runTest {
val timeline = FakeTimeline()
var capturedBody: String? = null
timeline.sendMessageLambda = { body, _, _, _, _ ->
capturedBody = body
Result.success(Unit)
}
val sut = createCommandExecutor()
val res = sut.proceedSendMessage(SlashCommand.SendWithPrefix(MessagePrefix.Unflip, "keep cool"), timeline)
assertThat(res.isSuccess).isTrue()
assertThat(capturedBody).isEqualTo("┬──┬ ( ゜-゜ノ) keep cool")
}
@Test
fun `send shrug prefixes message`() = runTest {
val timeline = FakeTimeline()
var capturedBody: String? = null
timeline.sendMessageLambda = { body, _, _, _, _ ->
capturedBody = body
Result.success(Unit)
}
val sut = createCommandExecutor()
val res = sut.proceedSendMessage(SlashCommand.SendWithPrefix(MessagePrefix.Shrug, "wow"), timeline)
assertThat(res.isSuccess).isTrue()
assertThat(capturedBody).isEqualTo("¯\\\\_(ツ)\\_/¯ wow")
}
@Test
fun `send rainbow provides html body`() = runTest {
val timeline = FakeTimeline()
var capturedHtml: String? = null
var capturedBody: String? = null
var capturedMsgType: MsgType? = null
timeline.sendMessageLambda = { body, htmlBody, _, msgType, _ ->
capturedBody = body
capturedHtml = htmlBody
capturedMsgType = msgType
Result.success(Unit)
}
val sut = createCommandExecutor()
val res = sut.proceedSendMessage(SlashCommand.SendRainbow("a nice rainbow"), timeline)
assertThat(res.isSuccess).isTrue()
assertThat(capturedBody).isEqualTo("a nice rainbow")
assertThat(capturedHtml).isNotNull()
assertThat(capturedHtml!!.contains("<font") || capturedHtml!!.contains("<span")).isTrue()
assertThat(capturedMsgType).isEqualTo(MsgType.MSG_TYPE_TEXT)
}
@Test
fun `send rainbow emote provides html body`() = runTest {
val timeline = FakeTimeline()
var capturedHtml: String? = null
var capturedBody: String? = null
var capturedMsgType: MsgType? = null
timeline.sendMessageLambda = { body, htmlBody, _, msgType, _ ->
capturedBody = body
capturedHtml = htmlBody
capturedMsgType = msgType
Result.success(Unit)
}
val sut = createCommandExecutor()
val res = sut.proceedSendMessage(SlashCommand.SendRainbowEmote("a nice rainbow"), timeline)
assertThat(res.isSuccess).isTrue()
assertThat(capturedBody).isEqualTo("a nice rainbow")
assertThat(capturedHtml).isNotNull()
assertThat(capturedHtml!!.contains("<font") || capturedHtml!!.contains("<span")).isTrue()
assertThat(capturedMsgType).isEqualTo(MsgType.MSG_TYPE_EMOTE)
}
@Test
fun `change display name invokes the method of the matrix client`() = runTest {
val matrixClient = FakeMatrixClient()
val sut = createCommandExecutor(matrixClient = matrixClient)
val res = sut.proceedAdmin(SlashCommand.ChangeDisplayName("new name"))
assertThat(res.isSuccess).isTrue()
assertThat(matrixClient.setDisplayNameCalled).isTrue()
}
@Test
fun `change room avatar is not supported`() = runTest {
val sut = createCommandExecutor()
val res = sut.proceedAdmin(SlashCommand.ChangeRoomAvatar(AN_AVATAR_URL))
assertThat(res.isFailure).isTrue()
}
@Test
fun `change avatar for room is not supported`() = runTest {
val sut = createCommandExecutor()
val res = sut.proceedAdmin(SlashCommand.ChangeAvatarForRoom(AN_AVATAR_URL))
assertThat(res.isFailure).isTrue()
}
@Test
fun `change display name for room is not supported`() = runTest {
val sut = createCommandExecutor()
val res = sut.proceedAdmin(SlashCommand.ChangeDisplayNameForRoom(A_USER_NAME))
assertThat(res.isFailure).isTrue()
}
@Test
fun `upgrade room is not supported`() = runTest {
val sut = createCommandExecutor()
val res = sut.proceedAdmin(SlashCommand.UpgradeRoom("1"))
assertThat(res.isFailure).isTrue()
}
@Test
fun `set user power level is not supported`() = runTest {
val sut = createCommandExecutor()
val res = sut.proceedAdmin(SlashCommand.SetUserPowerLevel(A_USER_ID, 50))
assertThat(res.isFailure).isTrue()
}
@Test
fun `discard session is not supported`() = runTest {
val sut = createCommandExecutor()
val res = sut.proceedAdmin(SlashCommand.DiscardSession)
assertThat(res.isFailure).isTrue()
}
@Test
fun `send spoiler sets formatted and body includes spoiler label`() = runTest {
val timeline = FakeTimeline()
var capturedBody: String? = null
var capturedHtml: String? = null
timeline.sendMessageLambda = { body, htmlBody, _, _, _ ->
capturedBody = body
capturedHtml = htmlBody
Result.success(Unit)
}
val stringProvider = FakeStringProvider(defaultResult = "SPOILER")
val sut = createCommandExecutor(
stringProvider = stringProvider,
)
val res = sut.proceedSendMessage(SlashCommand.SendSpoiler("secret"), timeline)
assertThat(res.isSuccess).isTrue()
assertThat(capturedBody).isEqualTo("[SPOILER](secret)")
assertThat(capturedHtml).isEqualTo("<span data-mx-spoiler>secret</span>")
}
@Test
fun `send chat effect is not supported`() = runTest {
val sut = createCommandExecutor()
val res = sut.proceedSendMessage(
SlashCommand.SendChatEffect(ChatEffect.CONFETTI, A_MESSAGE),
FakeTimeline()
)
assertThat(res.isFailure).isTrue()
}
@Test
fun `admin commands call underlying client and room APIs`() = runTest {
var kicked = false
var banned = false
var unbanned = false
var invited = false
var ignored = false
var unignored = false
var left = false
var topicSet = false
var nameSet = false
var joined = false
val joinedRoom = FakeJoinedRoom(
kickUserResult = { _, _ ->
kicked = true
Result.success(Unit)
},
banUserResult = { _, _ ->
banned = true
Result.success(Unit)
},
unBanUserResult = { _, _ ->
unbanned = true
Result.success(Unit)
},
inviteUserResult = { _ ->
invited = true
Result.success(Unit)
},
setTopicResult = { _ ->
topicSet = true
Result.success(Unit)
},
setNameResult = { _ ->
nameSet = true
Result.success(Unit)
},
baseRoom = FakeBaseRoom(
leaveRoomLambda = {
left = true
Result.success(Unit)
},
)
)
val matrixClient = FakeMatrixClient(
ignoreUserResult = { _ ->
ignored = true
Result.success(Unit)
},
unIgnoreUserResult = { _ ->
unignored = true
Result.success(Unit)
},
).apply {
joinRoomByIdOrAliasLambda = { _, _ ->
joined = true
Result.success(null)
}
}
val sut = createCommandExecutor(
matrixClient = matrixClient,
joinedRoom = joinedRoom,
)
val kickRes = sut.proceedAdmin(SlashCommand.RemoveUser(A_USER_ID, null))
assertThat(kicked).isTrue()
assertThat(kickRes.isSuccess).isTrue()
val banRes = sut.proceedAdmin(SlashCommand.BanUser(A_USER_ID, "reason"))
assertThat(banned).isTrue()
assertThat(banRes.isSuccess).isTrue()
val unbanRes = sut.proceedAdmin(SlashCommand.UnbanUser(A_USER_ID, null))
assertThat(unbanned).isTrue()
assertThat(unbanRes.isSuccess).isTrue()
val inviteRes = sut.proceedAdmin(SlashCommand.Invite(A_USER_ID, null))
assertThat(invited).isTrue()
assertThat(inviteRes.isSuccess).isTrue()
val ignoreRes = sut.proceedAdmin(SlashCommand.IgnoreUser(A_USER_ID))
assertThat(ignoreRes.isSuccess).isTrue()
assertThat(ignored).isTrue()
val unignoreRes = sut.proceedAdmin(SlashCommand.UnignoreUser(A_USER_ID))
assertThat(unignoreRes.isSuccess).isTrue()
assertThat(unignored).isTrue()
val leaveRes = sut.proceedAdmin(SlashCommand.LeaveRoom)
assertThat(leaveRes.isSuccess).isTrue()
assertThat(left).isTrue()
val topicRes = sut.proceedAdmin(SlashCommand.ChangeTopic("t"))
assertThat(topicRes.isSuccess).isTrue()
assertThat(topicSet).isTrue()
val nameRes = sut.proceedAdmin(SlashCommand.ChangeRoomName("n"))
assertThat(nameRes.isSuccess).isTrue()
assertThat(nameSet).isTrue()
val joinRes = sut.proceedAdmin(
SlashCommand.JoinRoom(
roomIdOrAlias = RoomIdOrAlias.Id(
RoomId("!room:domain")
),
reason = null,
)
)
assertThat(joinRes.isSuccess).isTrue()
assertThat(joined).isTrue()
}
}
fun createCommandExecutor(
matrixClient: FakeMatrixClient = FakeMatrixClient(),
joinedRoom: FakeJoinedRoom = FakeJoinedRoom(),
rainbowGenerator: RainbowGenerator = RainbowGenerator(),
stringProvider: StringProvider = FakeStringProvider(),
) = CommandExecutor(
matrixClient = matrixClient,
joinedRoom = joinedRoom,
rainbowGenerator = rainbowGenerator,
stringProvider = stringProvider,
)

View file

@ -0,0 +1,224 @@
/*
* 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.libraries.slashcommands.impl
import com.google.common.truth.Truth.assertThat
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.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.slashcommands.api.ChatEffect
import io.element.android.libraries.slashcommands.api.MessagePrefix
import io.element.android.libraries.slashcommands.api.SlashCommand
import io.element.android.services.toolbox.api.strings.StringProvider
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import kotlinx.coroutines.test.runTest
import org.junit.Test
class CommandParserTest {
@Test
fun parseSlashCommandEmpty() = runTest {
test("/", SlashCommand.ErrorEmptySlashCommand("A string/"))
}
@Test
fun parseSlashCommandUnknown() = runTest {
test("/unknown", SlashCommand.ErrorUnknownSlashCommand("A string/unknown"))
test("/unknown with param", SlashCommand.ErrorUnknownSlashCommand("A string/unknown"))
}
@Test
fun parseSlashCommandNotACommand() = runTest {
test("", SlashCommand.NotACommand)
test("test", SlashCommand.NotACommand)
test("// test", SlashCommand.NotACommand)
}
@Test
fun parseSlashCommandEmote() = runTest {
test("/me test", SlashCommand.SendEmote("test"))
test("/me", SlashCommand.ErrorSyntax("A string/me, /me <message>"))
}
@Test
fun parseSlashCommandRemove() = runTest {
// Nominal
test("/remove $A_USER_ID", SlashCommand.RemoveUser(A_USER_ID, null))
// With a reason
test("/remove $A_USER_ID a reason", SlashCommand.RemoveUser(A_USER_ID, "a reason"))
// Trim the reason
test("/remove $A_USER_ID a reason ", SlashCommand.RemoveUser(A_USER_ID, "a reason"))
// Alias
test("/kick $A_USER_ID", SlashCommand.RemoveUser(A_USER_ID, null))
// Error
test("/remove", SlashCommand.ErrorSyntax("A string/remove, /remove <user-id> [reason]"))
}
@Test
fun parseSlashCommandRemoveMarkdown() = runTest {
// Nominal
test(
"/remove [@user:domain.org](https://matrix.to/#/@user:domain.org)",
SlashCommand.RemoveUser(UserId("@user:domain.org"), null)
)
test(
"/remove [@user:domain.org](https://matrix.to/#/@user:domain.org) reason",
SlashCommand.RemoveUser(UserId("@user:domain.org"), "reason")
)
}
@Test
fun parseSlashCommandPlainAndNick() = runTest {
test("/plain hello", SlashCommand.SendPlainText("hello"))
test("/plain", SlashCommand.ErrorSyntax("A string/plain, /plain <message>"))
test("/nick John", SlashCommand.ChangeDisplayName("John"))
test("/nick", SlashCommand.ErrorSyntax("A string/nick, /nick <display-name>"))
}
@Test
fun parseSlashCommandRoomNickAndAvatars() = runTest {
test("/myroomnick Roomy", SlashCommand.ChangeDisplayNameForRoom("Roomy"))
test("/roomavatar mxc://matrix.org/abc", SlashCommand.ChangeRoomAvatar("mxc://matrix.org/abc"))
test("/roomavatar http://notmxc", SlashCommand.ErrorSyntax("A string/roomavatar, /roomavatar <mxc_url>"))
test("/myroomavatar mxc://matrix.org/abc", SlashCommand.ChangeAvatarForRoom("mxc://matrix.org/abc"))
}
@Test
fun parseSlashCommandTopicAndRainbow() = runTest {
test("/topic New topic", SlashCommand.ChangeTopic("New topic"))
test("/topic", SlashCommand.ErrorSyntax("A string/topic, /topic <topic>"))
test("/rainbow yay", SlashCommand.SendRainbow("yay"))
test("/rainbow", SlashCommand.ErrorSyntax("A string/rainbow, /rainbow <message>"))
test("/rainbowme yay", SlashCommand.SendRainbowEmote("yay"))
test("/rainbowme", SlashCommand.ErrorSyntax("A string/rainbowme, /rainbowme <message>"))
}
@Test
fun parseSlashCommandJoinAndRoomName() = runTest {
// valid join
test(
"/join !roomId:domain reason",
SlashCommand.JoinRoom(
RoomIdOrAlias.Id(RoomId("!roomId:domain")),
"reason"
)
)
// invalid join
test("/join notavalid", SlashCommand.ErrorSyntax("A string/join, /join <room-address> [reason]"))
test("/roomname My Room", SlashCommand.ChangeRoomName("My Room"))
test("/roomname", SlashCommand.ErrorSyntax("A string/roomname, /roomname <name>"))
}
@Test
fun parseSlashCommandInviteBanEtc() = runTest {
test("/invite $A_USER_ID", SlashCommand.Invite(A_USER_ID, null))
test("/invite", SlashCommand.ErrorSyntax("A string/invite, /invite <user-id> [reason]"))
test("/ban $A_USER_ID bad", SlashCommand.BanUser(A_USER_ID, "bad"))
test("/unban $A_USER_ID", SlashCommand.UnbanUser(A_USER_ID, null))
test("/ignore $A_USER_ID", SlashCommand.IgnoreUser(A_USER_ID))
test("/unignore $A_USER_ID", SlashCommand.UnignoreUser(A_USER_ID))
}
@Test
fun parseSlashCommandPowerLevels() = runTest {
test("/op $A_USER_ID 50", SlashCommand.SetUserPowerLevel(A_USER_ID, 50))
test("/op $A_USER_ID notnumber", SlashCommand.ErrorSyntax("A string/op, /op <user-id> [<power-level>]"))
test("/deop $A_USER_ID", SlashCommand.SetUserPowerLevel(A_USER_ID, null))
}
@Test
fun parseSlashCommandDevtoolsAndSpoiler() = runTest {
test("/devtools", SlashCommand.DevTools)
test("/devtools extra", SlashCommand.ErrorSyntax("A string/devtools, /devtools"))
test("/spoiler secret", SlashCommand.SendSpoiler("secret"))
test("/spoiler", SlashCommand.ErrorSyntax("A string/spoiler, /spoiler <message>"))
}
@Test
fun parseSlashCommandEmojisAndSession() = runTest {
test("/shrug hello", SlashCommand.SendWithPrefix(MessagePrefix.Shrug, "hello"))
test("/shrug", SlashCommand.SendWithPrefix(MessagePrefix.Shrug, ""))
test("/lenny fun", SlashCommand.SendWithPrefix(MessagePrefix.Lenny, "fun"))
test("/tableflip wow", SlashCommand.SendWithPrefix(MessagePrefix.TableFlip, "wow"))
test("/unflip be safe", SlashCommand.SendWithPrefix(MessagePrefix.Unflip, "be safe"))
test("/discardsession", SlashCommand.DiscardSession)
test("/discardsession extra", SlashCommand.ErrorSyntax("A string/discardsession, /discardsession"))
}
@Test
fun parseSlashCommandWhoisAndEffectsAndLeave() = runTest {
test("/whois $A_USER_ID", SlashCommand.ShowUser(A_USER_ID))
test("/confetti party", SlashCommand.SendChatEffect(ChatEffect.CONFETTI, "party"))
test("/snowfall snow", SlashCommand.SendChatEffect(ChatEffect.SNOWFALL, "snow"))
test("/leave", SlashCommand.LeaveRoom)
test("/leave now", SlashCommand.ErrorSyntax("A string/leave, /leave"))
}
@Test
fun parseSlashCommandUpgradeAndCrashAndFeatureFlagAndThreads() = runTest {
test("/upgraderoom 9", SlashCommand.UpgradeRoom("9"))
test("/upgraderoom", SlashCommand.ErrorSyntax("A string/upgraderoom, /upgraderoom newVersion"))
// Crash only when developer mode enabled
val cpDev = createCommandParser(appPreferencesStore = InMemoryAppPreferencesStore(isDeveloperModeEnabled = true))
try {
cpDev.parseSlashCommand("/crash", null, false)
org.junit.Assert.fail("Expected crash to throw")
} catch (_: IllegalStateException) {
// expected
}
// Feature flag disabled
val cpFF = createCommandParser(featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.SlashCommand.key to false)))
val res = cpFF.parseSlashCommand("/me test", null, false)
assertThat(res).isEqualTo(SlashCommand.NotACommand)
// Not supported in threads (e.g. /join)
val cpThread = createCommandParser()
val threadRes = cpThread.parseSlashCommand("/join !roomId:domain", null, true)
assertThat(threadRes).isInstanceOf(SlashCommand.ErrorCommandNotSupportedInThreads::class.java)
assertThat((threadRes as SlashCommand.ErrorCommandNotSupportedInThreads).message).isEqualTo("A string/join")
}
private suspend fun test(message: String, expectedResult: SlashCommand) {
val commandParser = createCommandParser()
val result = commandParser.parseSlashCommand(message, null, false)
assertThat(result).isEqualTo(expectedResult)
}
}
internal fun createCommandParser(
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(
initialState = mapOf(
FeatureFlags.SlashCommand.key to true,
),
),
stringProvider: StringProvider = FakeStringProvider(),
) = CommandParser(
appPreferencesStore = appPreferencesStore,
featureFlagService = featureFlagService,
stringProvider = stringProvider,
)

View file

@ -0,0 +1,165 @@
/*
* 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.libraries.slashcommands.impl
import com.google.common.truth.Truth.assertThat
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.room.IntentionalMention
import io.element.android.libraries.matrix.api.timeline.MsgType
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.slashcommands.api.SlashCommand
import io.element.android.libraries.slashcommands.impl.rainbow.RainbowGenerator
import io.element.android.services.toolbox.api.strings.StringProvider
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultSlashCommandServiceTest {
@Test
fun `getSuggestions filters by text and maps to suggestions`() = runTest {
val stringProvider = FakeStringProvider(defaultResult = "desc")
val prefs = InMemoryAppPreferencesStore(isDeveloperModeEnabled = false)
val sut = createDefaultSlashCommandService(
commandParser = CommandParser(
appPreferencesStore = prefs,
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(
FeatureFlags.SlashCommand.key to true,
)
),
stringProvider = stringProvider,
),
stringProvider = stringProvider,
appPreferencesStore = prefs,
)
val res = sut.getSuggestions("ra", isInThread = true)
// Expect commands starting with "/ra" (case-insensitive) and that are allowed in threads
assertThat(res).isNotEmpty()
assertThat(res.first().description).isEqualTo("desc")
}
@Test
fun `getSuggestions hides dev commands when developer mode disabled`() = runTest {
val stringProvider = FakeStringProvider()
val prefs = InMemoryAppPreferencesStore(isDeveloperModeEnabled = false)
val sut = createDefaultSlashCommandService(appPreferencesStore = prefs, stringProvider = stringProvider)
val all = sut.getSuggestions("crash", isInThread = true)
assertThat(all).isEmpty()
}
@Test
fun `getSuggestions returns empty list when the feature is enabled`() = runTest {
val sut = createDefaultSlashCommandService(isFeatureEnabled = true)
val all = sut.getSuggestions("me", isInThread = false)
assertThat(all).isNotEmpty()
}
@Test
fun `getSuggestions returns empty list when the feature is disabled`() = runTest {
val sut = createDefaultSlashCommandService(isFeatureEnabled = false)
val all = sut.getSuggestions("me", isInThread = false)
assertThat(all).isEmpty()
}
@Test
fun `getSuggestions for aliases`() = runTest {
val stringProvider = FakeStringProvider()
val prefs = InMemoryAppPreferencesStore(isDeveloperModeEnabled = false)
val sut = createDefaultSlashCommandService(appPreferencesStore = prefs, stringProvider = stringProvider)
val all = sut.getSuggestions("part", isInThread = true)
assertThat(all).isEmpty()
}
@Test
fun `getSuggestions shows dev commands when developer mode enabled`() = runTest {
val stringProvider = FakeStringProvider()
val prefs = InMemoryAppPreferencesStore(isDeveloperModeEnabled = true)
val sut = createDefaultSlashCommandService(appPreferencesStore = prefs, stringProvider = stringProvider)
val all = sut.getSuggestions("crash", isInThread = true)
assertThat(all).isNotEmpty()
assertThat(all.first().command).isEqualTo("/crash")
}
@Test
fun `parse delegates to commandParser`() = runTest {
val sut = createDefaultSlashCommandService()
val res = sut.parse("test", null, false)
assertThat(res).isEqualTo(SlashCommand.NotACommand)
}
@Test
fun `proceedSendMessage delegate to commandExecutor`() = runTest {
val sendMessage = lambdaRecorder { _: String, _: String?, _: List<IntentionalMention>, _: MsgType, _: Boolean ->
Result.success(Unit)
}
val sut = createDefaultSlashCommandService()
val sendRes = sut.proceedSendMessage(
slashCommand = SlashCommand.SendPlainText("hi"),
timeline = FakeTimeline().apply {
sendMessageLambda = sendMessage
},
)
assertThat(sendRes.isSuccess).isTrue()
sendMessage.assertions().isCalledOnce()
}
@Test
fun `proceedAdmin delegates to commandExecutor`() = runTest {
val leaveRoomLambda = lambdaRecorder<Result<Unit>> {
Result.success(Unit)
}
val sut = createDefaultSlashCommandService(
commandExecutor = CommandExecutor(
matrixClient = FakeMatrixClient(),
joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
leaveRoomLambda = leaveRoomLambda
),
),
rainbowGenerator = RainbowGenerator(),
stringProvider = FakeStringProvider(),
),
)
val adminRes = sut.proceedAdmin(SlashCommand.LeaveRoom)
assertThat(adminRes.isSuccess).isTrue()
leaveRoomLambda.assertions().isCalledOnce()
}
private fun createDefaultSlashCommandService(
isFeatureEnabled: Boolean = true,
featureFlagService: FeatureFlagService = FakeFeatureFlagService(
initialState = mapOf(
FeatureFlags.SlashCommand.key to isFeatureEnabled,
),
),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
stringProvider: StringProvider = FakeStringProvider(),
commandParser: CommandParser = createCommandParser(
featureFlagService = featureFlagService,
appPreferencesStore = appPreferencesStore,
stringProvider = stringProvider,
),
commandExecutor: CommandExecutor = createCommandExecutor(
stringProvider = stringProvider,
),
) = DefaultSlashCommandService(
commandParser = commandParser,
commandExecutor = commandExecutor,
stringProvider = stringProvider,
appPreferencesStore = appPreferencesStore,
featureFlagService = featureFlagService,
)
}

View file

@ -0,0 +1,19 @@
/*
* 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.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.slashcommands.test"
}
dependencies {
implementation(projects.libraries.slashcommands.api)
implementation(projects.libraries.matrix.api)
implementation(projects.tests.testutils)
}

View file

@ -0,0 +1,45 @@
/*
* 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.libraries.slashcommands.test
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.slashcommands.api.SlashCommand
import io.element.android.libraries.slashcommands.api.SlashCommandService
import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
class FakeSlashCommandService(
private val getSuggestionsResult: (String, Boolean) -> List<SlashCommandSuggestion> = { _, _ -> lambdaError() },
private val parseResult: (CharSequence, String?, Boolean) -> SlashCommand = { _, _, _ -> lambdaError() },
private val proceedSendMessageResult: (SlashCommand.SlashCommandSendMessage, Timeline) -> Result<Unit> = { _, _ -> lambdaError() },
private val proceedAdminResult: (SlashCommand.SlashCommandAdmin) -> Result<Unit> = { lambdaError() },
) : SlashCommandService {
override suspend fun getSuggestions(text: String, isInThread: Boolean): List<SlashCommandSuggestion> = simulateLongTask {
getSuggestionsResult(text, isInThread)
}
override suspend fun parse(
textMessage: CharSequence,
formattedMessage: String?,
isInThreadTimeline: Boolean,
): SlashCommand = simulateLongTask {
parseResult(textMessage, formattedMessage, isInThreadTimeline)
}
override suspend fun proceedSendMessage(
slashCommand: SlashCommand.SlashCommandSendMessage,
timeline: Timeline,
): Result<Unit> = simulateLongTask {
proceedSendMessageResult(slashCommand, timeline)
}
override suspend fun proceedAdmin(slashCommand: SlashCommand.SlashCommandAdmin): Result<Unit> = simulateLongTask {
proceedAdminResult(slashCommand)
}
}

View file

@ -33,6 +33,7 @@ dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiUtils)
implementation(projects.libraries.slashcommands.api)
releaseApi(libs.matrix.richtexteditor)
releaseApi(libs.matrix.richtexteditor.compose)

View file

@ -9,6 +9,7 @@
package io.element.android.libraries.textcomposer
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
@ -17,7 +18,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@ -120,6 +120,9 @@ private fun EditingModeView(
}
}
/**
* https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=2019-6286
*/
@Composable
private fun ReplyToModeView(
replyToDetails: InReplyToDetails,
@ -129,8 +132,9 @@ private fun ReplyToModeView(
) {
Row(
modifier
.clip(RoundedCornerShape(13.dp))
.background(MaterialTheme.colorScheme.surface)
.clip(RoundedCornerShape(6.dp))
.background(ElementTheme.colors.bgCanvasDefault)
.border(1.dp, ElementTheme.colors.separatorPrimary, RoundedCornerShape(6.dp))
.padding(4.dp)
) {
InReplyToView(

View file

@ -50,6 +50,9 @@ object ElementRichTextEditorStyle {
val codeCornerRadius = 4.dp
val codeBorderWidth = 1.dp
return RichTextEditorDefaults.style(
bulletList = RichTextEditorDefaults.bulletListStyle(
bulletGapWidth = 8.dp,
),
text = RichTextEditorDefaults.textStyle(
color = LocalTextStyle.current.color.takeIf { it.isSpecified } ?: LocalContentColor.current,
fontStyle = LocalTextStyle.current.fontStyle,

View file

@ -55,14 +55,14 @@ class MentionSpan(
backgroundColor = when (type) {
is MentionType.User -> if (isCurrentUser) mentionSpanTheme.currentUserBackgroundColor else mentionSpanTheme.otherBackgroundColor
is MentionType.Everyone -> mentionSpanTheme.currentUserBackgroundColor
is MentionType.Everyone -> mentionSpanTheme.otherBackgroundColor
is MentionType.Room -> mentionSpanTheme.otherBackgroundColor
is MentionType.Message -> mentionSpanTheme.otherBackgroundColor
}
textColor = when (type) {
is MentionType.User -> if (isCurrentUser) mentionSpanTheme.currentUserTextColor else mentionSpanTheme.otherTextColor
is MentionType.Everyone -> mentionSpanTheme.currentUserTextColor
is MentionType.Everyone -> mentionSpanTheme.otherTextColor
is MentionType.Room -> mentionSpanTheme.otherTextColor
is MentionType.Message -> mentionSpanTheme.otherTextColor
}

View file

@ -54,6 +54,7 @@ import kotlinx.collections.immutable.persistentListOf
* To make this work, you need to:
* 1. Call [MentionSpanTheme.updateStyles] so the colors and sizes are computed.
* 2. Use either [MentionSpanTheme.updateMentionStyles] or [MentionSpan.updateTheme] to update the styles of the mention spans.
* https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=3236-11203
*/
@Stable
@SingleIn(SessionScope::class)
@ -61,10 +62,14 @@ class MentionSpanTheme(val currentUserId: UserId) {
@Inject
constructor(matrixClient: MatrixClient) : this(matrixClient.sessionId)
internal var currentUserTextColor: Int = 0
internal var currentUserTextColor: Int = Color.BLACK
private set
internal var currentUserBackgroundColor: Int = Color.WHITE
internal var otherTextColor: Int = 0
private set
internal var otherTextColor: Int = Color.BLACK
private set
internal var otherBackgroundColor: Int = Color.WHITE
private set
private val paddingValues = PaddingValues(start = 4.dp, end = 6.dp)
internal val paddingValuesPx = mutableStateOf(0 to 0)
@ -78,8 +83,8 @@ class MentionSpanTheme(val currentUserId: UserId) {
fun updateStyles() {
currentUserTextColor = ElementTheme.colors.textBadgeAccent.toArgb()
currentUserBackgroundColor = ElementTheme.colors.bgBadgeAccent.toArgb()
otherTextColor = ElementTheme.colors.textPrimary.toArgb()
otherBackgroundColor = ElementTheme.colors.bgBadgeDefault.toArgb()
otherTextColor = ElementTheme.colors.textOnSolidPrimary.toArgb()
otherBackgroundColor = ElementTheme.colors.bgBadgePrimary.toArgb()
typeface.value = ElementTheme.typography.fontBodyLgMedium.rememberTypeface().value
val density = LocalDensity.current

View file

@ -14,6 +14,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
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.room.RoomMember
import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion
@Immutable
sealed interface ResolvedSuggestion {
@ -32,4 +33,8 @@ sealed interface ResolvedSuggestion {
size = size,
)
}
data class Command(
val command: SlashCommandSuggestion,
) : ResolvedSuggestion
}

View file

@ -61,21 +61,29 @@ class MarkdownTextEditorState(
}
is ResolvedSuggestion.Member -> {
val currentText = SpannableStringBuilder(text.value())
val mentionSpan = mentionSpanProvider.createUserMentionSpan(resolvedSuggestion.roomMember.userId)
val userId = resolvedSuggestion.roomMember.userId
val mentionSpan = mentionSpanProvider.createUserMentionSpan(userId)
currentText.replace(suggestion.start, suggestion.end, "@ ")
val end = suggestion.start + 1
currentText.setSpan(mentionSpan, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
this.text.update(currentText, true)
this.selection = IntRange(end + 1, end + 1)
text.update(currentText, true)
selection = IntRange(end + 1, end + 1)
}
is ResolvedSuggestion.Alias -> {
val currentText = SpannableStringBuilder(text.value())
val mentionSpan = mentionSpanProvider.createRoomMentionSpan(resolvedSuggestion.roomAlias.toRoomIdOrAlias())
val roomAlias = resolvedSuggestion.roomAlias
val mentionSpan = mentionSpanProvider.createRoomMentionSpan(roomAlias.toRoomIdOrAlias())
currentText.replace(suggestion.start, suggestion.end, "# ")
val end = suggestion.start + 1
currentText.setSpan(mentionSpan, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
this.text.update(currentText, true)
this.selection = IntRange(end + 1, end + 1)
text.update(currentText, true)
selection = IntRange(end + 1, end + 1)
}
is ResolvedSuggestion.Command -> {
// Just insert the command text
text.update("${resolvedSuggestion.command.command} ", true)
val length = resolvedSuggestion.command.command.length + 1
selection = IntRange(length, length)
}
}
}

View file

@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion
import io.element.android.libraries.textcomposer.impl.mentions.aMentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.MentionType
@ -42,6 +43,7 @@ class MarkdownTextEditorStateTest {
val mentionSpanProvider = aMentionSpanProvider()
state.insertSuggestion(suggestion, mentionSpanProvider)
assertThat(state.getMentions()).isEmpty()
assertThat(state.text.value().toString()).isEqualTo("Hello @")
}
@Test
@ -53,6 +55,7 @@ class MarkdownTextEditorStateTest {
val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertSuggestion(suggestion, mentionSpanProvider)
assertThat(state.text.value().toString()).isEqualTo("Hello # ")
}
@Test
@ -64,6 +67,19 @@ class MarkdownTextEditorStateTest {
val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertSuggestion(suggestion, mentionSpanProvider)
assertThat(state.text.value().toString()).isEqualTo("Hello # ")
}
@Test
fun `insertSuggestion - command`() {
val state = aMarkdownTextEditorState(initialText = "/rai", initialFocus = true).apply {
currentSuggestion = Suggestion(start = 0, end = 3, type = SuggestionType.Command, text = "/rainbow")
}
val suggestion = aSlashCommandSuggestion()
val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertSuggestion(suggestion, mentionSpanProvider)
assertThat(state.text.value().toString()).isEqualTo("/rainbow ")
}
@Test
@ -74,6 +90,7 @@ class MarkdownTextEditorStateTest {
val mentionSpanProvider = aMentionSpanProvider()
state.insertSuggestion(mention, mentionSpanProvider)
assertThat(state.getMentions()).isEmpty()
assertThat(state.text.value().toString()).isEqualTo("Hello @")
}
@Test
@ -91,6 +108,7 @@ class MarkdownTextEditorStateTest {
val mentions = state.getMentions()
assertThat(mentions).isNotEmpty()
assertThat((mentions.firstOrNull() as? IntentionalMention.User)?.userId).isEqualTo(member.userId)
assertThat(state.text.value().toString()).isEqualTo("Hello @ ")
}
@Test
@ -107,15 +125,14 @@ class MarkdownTextEditorStateTest {
val mentions = state.getMentions()
assertThat(mentions).isNotEmpty()
assertThat(mentions.firstOrNull()).isInstanceOf(IntentionalMention.Room::class.java)
assertThat(state.text.value().toString()).isEqualTo("Hello @ ")
}
@Test
fun `getMessageMarkdown - when there are no MentionSpans returns the same text`() {
val text = "No mentions here"
val state = aMarkdownTextEditorState(initialText = text, initialFocus = true)
val markdown = state.getMessageMarkdown(FakePermalinkBuilder())
assertThat(markdown).isEqualTo(text)
}
@ -128,19 +145,17 @@ class MarkdownTextEditorStateTest {
)
val state = aMarkdownTextEditorState(initialText = text, initialFocus = true)
state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false)
val markdown = state.getMessageMarkdown(permalinkBuilder = permalinkBuilder)
assertThat(markdown).isEqualTo(
"Hello [@alice:matrix.org](https://matrix.to/#/@alice:matrix.org) and everyone in @room" +
" and a room [#room:domain.org](https://matrix.to/#/#room:domain.org)"
)
assertThat(state.text.value().toString()).isEqualTo("Hello @ and everyone in @ and a room #room:domain.org")
}
@Test
fun `getMentions - when there are no MentionSpans returns empty list of mentions`() {
val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
assertThat(state.getMentions()).isEmpty()
}
@ -148,9 +163,7 @@ class MarkdownTextEditorStateTest {
fun `getMentions - when there are MentionSpans returns a list of mentions`() {
val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false)
val mentions = state.getMentions()
assertThat(mentions).isNotEmpty()
assertThat((mentions.firstOrNull() as? IntentionalMention.User)?.userId?.value).isEqualTo("@alice:matrix.org")
assertThat(mentions.lastOrNull()).isInstanceOf(IntentionalMention.Room::class.java)
@ -184,4 +197,14 @@ class MarkdownTextEditorStateTest {
roomAvatarUrl = null
)
}
private fun aSlashCommandSuggestion(): ResolvedSuggestion.Command {
return ResolvedSuggestion.Command(
command = SlashCommandSuggestion(
command = "/rainbow",
parameters = "param",
description = "Make the text colorful 🌈",
),
)
}
}

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="troubleshoot_notifications_screen_action">"Vykdyti testus"</string>
<string name="troubleshoot_notifications_screen_action_again">"Vykdyti testus dar kartą"</string>
</resources>

View file

@ -122,6 +122,7 @@
<string name="action_leave_space">"Opustit prostor"</string>
<string name="action_load_more">"Načíst více"</string>
<string name="action_manage_account">"Spravovat účet"</string>
<string name="action_manage_account_and_devices">"Správa účtu a zařízení"</string>
<string name="action_manage_devices">"Spravovat zařízení"</string>
<string name="action_manage_rooms">"Spravovat místnosti"</string>
<string name="action_message">"Zpráva"</string>
@ -164,14 +165,15 @@
<string name="action_share_live_location">"Sdílet aktuální polohu"</string>
<string name="action_show">"Zobrazit"</string>
<string name="action_sign_in_again">"Přihlásit se znovu"</string>
<string name="action_signout">"Odhlásit se"</string>
<string name="action_signout_anyway">"Přesto se odhlásit"</string>
<string name="action_signout">"Odebrat toto zařízení"</string>
<string name="action_signout_anyway">"Přesto toto zařízení odebrat"</string>
<string name="action_skip">"Přeskočit"</string>
<string name="action_start">"Začít"</string>
<string name="action_start_chat">"Zahájit chat"</string>
<string name="action_start_over">"Začít znovu"</string>
<string name="action_start_verification">"Zahájit ověření"</string>
<string name="action_static_map_load">"Klepnutím načtete mapu"</string>
<string name="action_stop">"Zastavit"</string>
<string name="action_take_photo">"Vyfotit"</string>
<string name="action_tap_for_options">"Klepnutím zobrazíte možnosti"</string>
<string name="action_translate">"Přeložit"</string>
@ -192,6 +194,7 @@
<string name="common_advanced_settings">"Pokročilá nastavení"</string>
<string name="common_an_image">"obrázek"</string>
<string name="common_analytics">"Analytika"</string>
<string name="common_android_fetching_notifications_title">"Synchronizace oznámení…"</string>
<string name="common_android_shortcuts_remove_reason_left_room">"Opustili jste místnost"</string>
<string name="common_android_shortcuts_remove_reason_session_logged_out">"Byli jste odhlášeni z relace"</string>
<string name="common_appearance">"Vzhled"</string>
@ -225,6 +228,7 @@
<string name="common_empty_file">"Prázdný soubor"</string>
<string name="common_encryption">"Šifrování"</string>
<string name="common_encryption_enabled">"Šifrování povoleno"</string>
<string name="common_ends_at">"Končí v %1$s"</string>
<string name="common_enter_your_pin">"Zadejte svůj PIN"</string>
<string name="common_error">"Chyba"</string>
<string name="common_error_registering_pusher_android">"Došlo k chybě, nemusíte dostávat oznámení o nových zprávách. Vyřešte prosím problémy s oznámeními z nastavení.
@ -251,6 +255,8 @@ Důvod: %1$s."</string>
<string name="common_line_copied_to_clipboard">"Řádek zkopírován do schránky"</string>
<string name="common_link_copied_to_clipboard">"Odkaz zkopírován do schránky"</string>
<string name="common_link_new_device">"Připojit nové zařízení"</string>
<string name="common_live_location">"Aktuální poloha"</string>
<string name="common_live_location_ended">"Sdílení aktuální polohy skončilo"</string>
<string name="common_loading">"Načítání…"</string>
<string name="common_loading_more">"Načítání dalších…"</string>
<plurals name="common_many_members">
@ -351,9 +357,10 @@ Důvod: %1$s."</string>
<string name="common_settings">"Nastavení"</string>
<string name="common_share_space">"Sdílet prostor"</string>
<string name="common_shared_history">"Noví členové vidí historii"</string>
<string name="common_shared_live_location">"Sdílená aktuální poloha"</string>
<string name="common_shared_location">"Sdílená poloha"</string>
<string name="common_shared_space">"Sdílený prostor"</string>
<string name="common_signing_out">"Odhlašování"</string>
<string name="common_signing_out">"Odebrání zařízení"</string>
<string name="common_something_went_wrong">"Něco se nepovedlo"</string>
<string name="common_something_went_wrong_message">"Narazili jsme na problém. Zkuste to prosím znovu."</string>
<string name="common_space">"Prostor"</string>
@ -374,12 +381,13 @@ Důvod: %1$s."</string>
<string name="common_text">"Text"</string>
<string name="common_third_party_notices">"Oznámení třetích stran"</string>
<string name="common_thread">"Vlákno"</string>
<string name="common_threads">"Vlákna"</string>
<string name="common_topic">"Téma"</string>
<string name="common_topic_placeholder">"O čem je tato místnost?"</string>
<string name="common_unable_to_decrypt">"Nelze dešifrovat"</string>
<string name="common_unable_to_decrypt_insecure_device">"Šifrováno nezabezpečeným zařízením"</string>
<string name="common_unable_to_decrypt_no_access">"Nemáte přístup k této zprávě"</string>
<string name="common_unable_to_decrypt_verification_violation">"Ověřená identita odesílatele se změnila"</string>
<string name="common_unable_to_decrypt_verification_violation">"Ověřená digitální identita odesílatele byla resetována"</string>
<string name="common_unable_to_invite_message">"Pozvánky nebylo možné odeslat jednomu nebo více uživatelům."</string>
<string name="common_unable_to_invite_title">"Nelze odeslat pozvánky"</string>
<string name="common_unlock">"Odemknout"</string>
@ -404,16 +412,17 @@ Důvod: %1$s."</string>
<string name="common_voice_message">"Hlasová zpráva"</string>
<string name="common_waiting">"Čekání…"</string>
<string name="common_waiting_for_decryption_key">"Čekání na dešifrovací klíč"</string>
<string name="common_waiting_live_location">"Čekání na aktuální polohu…"</string>
<string name="common_world_readable_history">"Kdokoli může vidět historii"</string>
<string name="common_you">"Vy"</string>
<string name="crypto_event_key_forwarded_known_profile_dialog_content">"%1$s (%2$s) sdílel(a) tuto zprávu v době, kdy jste nebyli v místnosti."</string>
<string name="crypto_event_key_forwarded_unknown_profile_dialog_content">"%1$s sdílel(a) tuto zprávu v době, kdy jste nebyli v místnosti."</string>
<string name="crypto_history_visible">"Tato místnost byla nastavena tak, aby noví členové mohli číst historii. %1$s"</string>
<string name="crypto_identity_change_pin_violation">"Identita uživatele %1$s se změnila. %2$s"</string>
<string name="crypto_identity_change_pin_violation_new">"Identita uživatele %1$s %2$s se změnila. %3$s"</string>
<string name="crypto_identity_change_pin_violation">"Identita uživatele %1$s byla resetována. %2$s"</string>
<string name="crypto_identity_change_pin_violation_new">"Identita uživatele %1$s %2$s byla resetována. %3$s"</string>
<string name="crypto_identity_change_pin_violation_new_user_id">"(%1$s)"</string>
<string name="crypto_identity_change_profile_pin_violation">"Identita uživatele %1$s se změnila."</string>
<string name="crypto_identity_change_verification_violation_new">"Identita uživatele %1$s %2$s se změnila. %3$s"</string>
<string name="crypto_identity_change_profile_pin_violation">"Identita uživatele %1$s byla resetována."</string>
<string name="crypto_identity_change_verification_violation_new">"Identita uživatele %1$s %2$s byla resetována. %3$s"</string>
<string name="crypto_identity_change_withdraw_verification_action">"Zrušit ověření"</string>
<string name="dialog_allow_access">"Povolit přístup"</string>
<string name="dialog_confirm_link_message">"Odkaz %1$s vás přesměruje na jinou stránku %2$s
@ -445,6 +454,7 @@ Opravdu chcete pokračovat?"</string>
<string name="error_failed_locating_user">"%1$s nemá přístup k vaší poloze. Zkuste to prosím později."</string>
<string name="error_failed_uploading_voice_message">"Nepodařilo se nahrát hlasovou zprávu."</string>
<string name="error_invalid_invite">"Místnost již neexistuje nebo pozvánka již není platná."</string>
<string name="error_location_service_disabled_android">"Pro přístup k funkcím založeným na poloze prosím povolte GPS."</string>
<string name="error_message_not_found">"Zpráva nebyla nalezena"</string>
<string name="error_missing_location_auth_android">"%1$s nemá oprávnění k přístupu k vaší poloze. Přístup můžete povolit v Nastavení."</string>
<string name="error_missing_location_rationale_android">"%1$s nemá oprávnění k přístupu k vaší poloze. Povolit přístup níže."</string>
@ -473,11 +483,11 @@ Opravdu chcete pokračovat?"</string>
<item quantity="other">"%1$d Připnutých zpráv"</item>
</plurals>
<string name="screen_pinned_timeline_screen_title_empty">"Připnuté zprávy"</string>
<string name="screen_reset_identity_confirmation_subtitle">"Chystáte se přejít na svůj %1$s účet a obnovit svou identitu. Poté budete přesměrováni zpět do aplikace."</string>
<string name="screen_reset_identity_confirmation_title">"Nemůžete to potvrdit? Přejděte na svůj účet a resetujte svou identitu."</string>
<string name="screen_reset_identity_confirmation_subtitle">"Chystáte se přejít na svůj účet %1$s, abyste resetovali svou digitální identitu. Poté budete přesměrováni zpět do aplikace."</string>
<string name="screen_reset_identity_confirmation_title">"Nemůžete to potvrdit? Přejděte do svého účtu a resetujte svou digitální identitu."</string>
<string name="screen_resolve_send_failure_changed_identity_primary_button_title">"Zrušit ověření a odeslat"</string>
<string name="screen_resolve_send_failure_changed_identity_subtitle">"Ověření můžete zrušit a přesto odeslat tuto zprávu, nebo můžete prozatím zrušit a zkusit to znovu později po opětovném ověření %1$s."</string>
<string name="screen_resolve_send_failure_changed_identity_title">"Vaše zpráva nebyla odeslána, protože ověřená identita uživatele %1$s se změnila"</string>
<string name="screen_resolve_send_failure_changed_identity_title">"Vaše zpráva nebyla odeslána, protože byla resetována ověřená digitální identita %1$s"</string>
<string name="screen_resolve_send_failure_unsigned_device_primary_button_title">"Přesto odeslat zprávu"</string>
<string name="screen_resolve_send_failure_unsigned_device_subtitle">"%1$s používá jedno nebo více neověřených zařízení. Zprávu můžete přesto odeslat, nebo můžete prozatím zrušit a zkusit to znovu později poté, co %2$s ověří všechna svá zařízení."</string>
<string name="screen_resolve_send_failure_unsigned_device_title">"Vaše zpráva nebyla odeslána, protože%1$s neověřil(a) všechna zařízení"</string>
@ -509,7 +519,7 @@ Opravdu chcete pokračovat?"</string>
<string name="screen_space_list_title">"Prostory"</string>
<string name="screen_static_location_sheet_timestamp_description">"Sdíleno %1$s"</string>
<string name="screen_static_location_sheet_title">"Na mapě"</string>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Zpráva nebyla odeslána, protože ověřená identita uživatele %1$s se změnila."</string>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Zpráva nebyla odeslána, protože byla resetována ověřená digitální identita %1$s."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Zpráva nebyla odeslána, protože%1$s neověřil(a) všechna zařízení."</string>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"Zpráva nebyla odeslána, protože jste neověřili jedno nebo více zařízení."</string>
<string name="screen_view_location_title">"Poloha"</string>
@ -519,5 +529,5 @@ Opravdu chcete pokračovat?"</string>
<string name="timeline_decryption_failure_historical_event_unverified_device">"Pro přístup k historickým zprávám musíte toto zařízení ověřit"</string>
<string name="timeline_decryption_failure_historical_event_user_not_joined">"Nemáte přístup k této zprávě"</string>
<string name="timeline_decryption_failure_unable_to_decrypt">"Nelze dešifrovat zprávu"</string>
<string name="timeline_decryption_failure_withheld_unverified">"Tato zpráva byla zablokována buď proto, že jste neověřili své zařízení, nebo proto, že odesílatel potřebuje ověřit vaši identitu."</string>
<string name="timeline_decryption_failure_withheld_unverified">"Tato zpráva byla zablokována buď proto, že jste neověřili své zařízení, nebo proto, že odesílatel musí ověřit vaši digitální identitu."</string>
</resources>

View file

@ -120,6 +120,7 @@
<string name="action_leave_space">"Forlad klynge"</string>
<string name="action_load_more">"Indlæs mere"</string>
<string name="action_manage_account">"Administrer konto"</string>
<string name="action_manage_account_and_devices">"Administrer konto og enheder"</string>
<string name="action_manage_devices">"Administrer enheder"</string>
<string name="action_manage_rooms">"Administrer rum"</string>
<string name="action_message">"Besked"</string>
@ -162,14 +163,15 @@
<string name="action_share_live_location">"Del liveplacering"</string>
<string name="action_show">"Vis"</string>
<string name="action_sign_in_again">"Log ind igen"</string>
<string name="action_signout">"Log ud"</string>
<string name="action_signout_anyway">"Log ud alligevel"</string>
<string name="action_signout">"Fjern denne enhed"</string>
<string name="action_signout_anyway">"Fjern denne enhed alligevel"</string>
<string name="action_skip">"Spring over"</string>
<string name="action_start">"Start"</string>
<string name="action_start_chat">"Start samtale"</string>
<string name="action_start_over">"Begynd forfra"</string>
<string name="action_start_verification">"Begynd verifikation"</string>
<string name="action_static_map_load">"Tryk for at indlæse kort"</string>
<string name="action_stop">"Stop"</string>
<string name="action_take_photo">"Tag billede"</string>
<string name="action_tap_for_options">"Tryk for indstillinger"</string>
<string name="action_translate">"Oversæt"</string>
@ -190,6 +192,7 @@
<string name="common_advanced_settings">"Avancerede indstillinger"</string>
<string name="common_an_image">"et billede"</string>
<string name="common_analytics">"Analyse-værktøj"</string>
<string name="common_android_fetching_notifications_title">"Synkroniserer notifikationer…"</string>
<string name="common_android_shortcuts_remove_reason_left_room">"Du forlod rummet"</string>
<string name="common_android_shortcuts_remove_reason_session_logged_out">"Du blev logget ud af sessionen"</string>
<string name="common_appearance">"Udseende"</string>
@ -223,6 +226,7 @@
<string name="common_empty_file">"Tom fil"</string>
<string name="common_encryption">"Kryptering"</string>
<string name="common_encryption_enabled">"Kryptering aktiveret"</string>
<string name="common_ends_at">"Slutter kl.%1$s"</string>
<string name="common_enter_your_pin">"Indtast din PIN-kode"</string>
<string name="common_error">"Fejl"</string>
<string name="common_error_registering_pusher_android">"Der opstod en fejl, du modtager muligvis ikke meddelelser om nye meddelelser. Fejlfinding af meddelelser fra indstillingerne.
@ -249,6 +253,8 @@
<string name="common_line_copied_to_clipboard">"Linje kopieret til udklipsholder"</string>
<string name="common_link_copied_to_clipboard">"Linket er kopieret til udklipsholderen"</string>
<string name="common_link_new_device">"Forbind ny enhed"</string>
<string name="common_live_location">"Aktuel position"</string>
<string name="common_live_location_ended">"Aktuel position afsluttet"</string>
<string name="common_loading">"Indlæser…"</string>
<string name="common_loading_more">"Indlæser flere…"</string>
<plurals name="common_many_members">
@ -344,9 +350,10 @@
<string name="common_settings">"Indstillinger"</string>
<string name="common_share_space">"Del klynge"</string>
<string name="common_shared_history">"Nye medlemmer ser historik"</string>
<string name="common_shared_live_location">"Deling af aktuel position"</string>
<string name="common_shared_location">"Delt placering"</string>
<string name="common_shared_space">"Delt klynge"</string>
<string name="common_signing_out">"Logger ud"</string>
<string name="common_signing_out">"Fjerner enhed"</string>
<string name="common_something_went_wrong">"Noget gik galt"</string>
<string name="common_something_went_wrong_message">"Vi stødte på et problem. Prøv venligst igen."</string>
<string name="common_space">"Klynge"</string>
@ -366,12 +373,13 @@
<string name="common_text">"Tekst"</string>
<string name="common_third_party_notices">"Tredjepartsmeddelelser"</string>
<string name="common_thread">"Tråd"</string>
<string name="common_threads">"Tråde"</string>
<string name="common_topic">"Emne"</string>
<string name="common_topic_placeholder">"Hvad handler det her rum om?"</string>
<string name="common_unable_to_decrypt">"Ude af stand til at dekryptere"</string>
<string name="common_unable_to_decrypt_insecure_device">"Sendt fra en usikker enhed"</string>
<string name="common_unable_to_decrypt_no_access">"Du har ikke adgang til denne meddelelse"</string>
<string name="common_unable_to_decrypt_verification_violation">"Afsenderens verificerede identitet blev nulstillet"</string>
<string name="common_unable_to_decrypt_verification_violation">"Afsenderens verificerede digitale identitet blev nulstillet"</string>
<string name="common_unable_to_invite_message">"Invitationer kunne ikke sendes til en eller flere brugere."</string>
<string name="common_unable_to_invite_title">"Kan ikke sende invitation(er)"</string>
<string name="common_unlock">"Lås op"</string>
@ -396,16 +404,17 @@
<string name="common_voice_message">"Talebesked"</string>
<string name="common_waiting">"Venter…"</string>
<string name="common_waiting_for_decryption_key">"Venter på denne besked"</string>
<string name="common_waiting_live_location">"Venter på aktuel position…"</string>
<string name="common_world_readable_history">"Alle kan se historikken"</string>
<string name="common_you">"Dig"</string>
<string name="crypto_event_key_forwarded_known_profile_dialog_content">"%1$s(%2$s ) har delt denne besked siden du ikke var i rummet da den blev sendt."</string>
<string name="crypto_event_key_forwarded_unknown_profile_dialog_content">"%1$s delte denne besked, siden du ikke var i rummet da den blev sendt."</string>
<string name="crypto_history_visible">"Dette rum er konfigureret, så nye medlemmer kan læse historikken.%1$s"</string>
<string name="crypto_identity_change_pin_violation">"%1$ss identitet blev nulstillet. %2$s"</string>
<string name="crypto_identity_change_pin_violation_new">"%1$ss %2$s identitet blev nulstillet. %3$s"</string>
<string name="crypto_identity_change_pin_violation">"%1$ss digitale identitet blev nulstillet. %2$s"</string>
<string name="crypto_identity_change_pin_violation_new">"%1$ss %2$s digitale identitet blev nulstillet. %3$s"</string>
<string name="crypto_identity_change_pin_violation_new_user_id">"(%1$s)"</string>
<string name="crypto_identity_change_profile_pin_violation">"%1$ss identitet blev nulstillet."</string>
<string name="crypto_identity_change_verification_violation_new">"%1$ss %2$s identitet blev nulstillet. %3$s"</string>
<string name="crypto_identity_change_profile_pin_violation">"%1$ss digitale identitet blev nulstillet."</string>
<string name="crypto_identity_change_verification_violation_new">"%1$ss %2$s digitale identitet blev nulstillet. %3$s"</string>
<string name="crypto_identity_change_withdraw_verification_action">"Tilbagetræk verifikation"</string>
<string name="dialog_allow_access">"Tillad adgang"</string>
<string name="dialog_confirm_link_message">"Linket %1$s fører dig til et andet websted %2$s
@ -437,6 +446,7 @@ Er du sikker på, at du vil fortsætte?"</string>
<string name="error_failed_locating_user">"%1$s kunne ikke få adgang til din placering. Prøv igen senere."</string>
<string name="error_failed_uploading_voice_message">"Kunne ikke uploade din talebesked."</string>
<string name="error_invalid_invite">"Rummet findes ikke længere, eller invitationen er ikke længere gyldig."</string>
<string name="error_location_service_disabled_android">"Aktiver venligst din GPS for at få adgang til lokationsbaserede funktioner."</string>
<string name="error_message_not_found">"Meddelelsen blev ikke fundet"</string>
<string name="error_missing_location_auth_android">"%1$s har ikke tilladelse til at få adgang til din placering. Du kan aktivere adgang i Indstillinger."</string>
<string name="error_missing_location_rationale_android">"%1$s har ikke tilladelse til at se din placering. Aktivér adgang nedenfor."</string>
@ -464,11 +474,11 @@ Er du sikker på, at du vil fortsætte?"</string>
<item quantity="other">"%1$d Fastgjorte beskeder"</item>
</plurals>
<string name="screen_pinned_timeline_screen_title_empty">"Fastgjorte beskeder"</string>
<string name="screen_reset_identity_confirmation_subtitle">"Du er ved at gå til din %1$s konto for at nulstille din identitet. Derefter vil du blive ført tilbage til appen."</string>
<string name="screen_reset_identity_confirmation_title">"Kan du ikke bekræfte? Gå til din konto for at nulstille din identitet."</string>
<string name="screen_reset_identity_confirmation_subtitle">"Du er ved at gå til din %1$s konto for at nulstille din digitale identitet. Derefter vil du blive ført tilbage til appen."</string>
<string name="screen_reset_identity_confirmation_title">"Kan du ikke bekræfte? Gå til din konto for at nulstille din digitale identitet."</string>
<string name="screen_resolve_send_failure_changed_identity_primary_button_title">"Træk verifikationen tilbage og send"</string>
<string name="screen_resolve_send_failure_changed_identity_subtitle">"Du kan trække din verifikation tilbage og sende denne meddelelse alligevel, eller du kan annullere for nu og prøve igen senere efter at have gen-verificeret. %1$s"</string>
<string name="screen_resolve_send_failure_changed_identity_title">"Din besked blev ikke sendt, fordi %1$s\'s verificerede identitet er blevet nulstillet"</string>
<string name="screen_resolve_send_failure_changed_identity_title">"Din besked blev ikke sendt, fordi %1$s\'s verificerede digitale identitet er blevet nulstillet"</string>
<string name="screen_resolve_send_failure_unsigned_device_primary_button_title">"Send besked alligevel"</string>
<string name="screen_resolve_send_failure_unsigned_device_subtitle">"%1$s bruger en eller flere uverificerede enheder. Du kan sende beskeden alligevel, eller du kan annullere for nu og prøve igen senere, når %2$s har bekræftet alle deres enheder."</string>
<string name="screen_resolve_send_failure_unsigned_device_title">"Din besked blev ikke sendt, fordi %1$s ikke har bekræftet alle enheder"</string>
@ -500,7 +510,7 @@ Er du sikker på, at du vil fortsætte?"</string>
<string name="screen_space_list_title">"Klynger"</string>
<string name="screen_static_location_sheet_timestamp_description">"Delt %1$s"</string>
<string name="screen_static_location_sheet_title">"På kortet"</string>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Beskeden blev ikke sendt fordi %1$s s bekræftede identitet blev nulstillet."</string>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Beskeden blev ikke sendt fordi %1$s s bekræftede digitale identitet blev nulstillet."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Meddelelsen er ikke sendt, fordi %1$s ikke har bekræftet alle enheder."</string>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"Beskeden er ikke sendt, fordi du ikke har verificeret en eller flere af dine enheder."</string>
<string name="screen_view_location_title">"Lokation"</string>
@ -510,5 +520,5 @@ Er du sikker på, at du vil fortsætte?"</string>
<string name="timeline_decryption_failure_historical_event_unverified_device">"Du skal verificere denne enhed for at få adgang til historiske beskeder"</string>
<string name="timeline_decryption_failure_historical_event_user_not_joined">"Du har ikke adgang til denne meddelelse"</string>
<string name="timeline_decryption_failure_unable_to_decrypt">"Kan ikke dekryptere beskeden"</string>
<string name="timeline_decryption_failure_withheld_unverified">"Denne besked blev blokeret, enten fordi du ikke verificerede din enhed, eller fordi afsenderen skal have verificeret din identitet."</string>
<string name="timeline_decryption_failure_withheld_unverified">"Denne besked blev blokeret, enten fordi du ikke verificerede din enhed, eller fordi afsenderen endnu ikke har bekræftet din digitale identitet."</string>
</resources>

View file

@ -374,6 +374,7 @@ Syy: %1$s."</string>
<string name="common_text">"Teksti"</string>
<string name="common_third_party_notices">"Kolmannen osapuolen ilmoitukset"</string>
<string name="common_thread">"Viestiketju"</string>
<string name="common_threads">"Viestiketjut"</string>
<string name="common_topic">"Aihe"</string>
<string name="common_topic_placeholder">"Mistä tässä huoneessa on kyse?"</string>
<string name="common_unable_to_decrypt">"Salauksen purkaminen ei onnistunut"</string>
@ -466,6 +467,12 @@ Haluatko varmasti jatkaa?"</string>
<string name="screen_create_poll_remove_accessibility_label">"Poista %1$s"</string>
<string name="screen_create_poll_settings_section_title">"Asetukset"</string>
<string name="screen_media_picker_error_failed_selection">"Median valinta epäonnistui, yritä uudelleen."</string>
<string name="screen_missing_key_backup_open_element_classic">"Avaa Element Classic"</string>
<string name="screen_missing_key_backup_step_1">"Avaa Element Classic laitteellasi"</string>
<string name="screen_missing_key_backup_step_2_android">"Mene kohtaan \"Asetukset\" &gt; \"Tietoturva ja yksityisyys\""</string>
<string name="screen_missing_key_backup_step_3_android">"Osiossa \"Salausavainten hallinta\", paina \"Salattujen viestien palautus\"."</string>
<string name="screen_missing_key_backup_step_4">"Noudata ohjeita"</string>
<string name="screen_missing_key_backup_step_5">"Palaa takaisin %1$s -sovellukseen"</string>
<string name="screen_onboarding_welcome_back">"Tervetuloa takaisin"</string>
<string name="screen_pinned_timeline_empty_state_description">"Paina viestiä ja valitse “%1$s” lisätäksesi sen tänne."</string>
<string name="screen_pinned_timeline_empty_state_headline">"Kiinnitä tärkeät viestit, jotta ne löytyvät helposti."</string>

Some files were not shown because too many files have changed in this diff Show more