Skip to content

Add shortcut suggestions for rooms, remove then when leaving #5180

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Aug 19, 2025
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
Expand Down Expand Up @@ -121,6 +122,7 @@ class LoggedInFlowNode @AssistedInject constructor(
private val mediaPreviewConfigMigration: MediaPreviewConfigMigration,
private val sessionEnterpriseService: SessionEnterpriseService,
private val networkMonitor: NetworkMonitor,
private val notificationConversationService: NotificationConversationService,
snackbarDispatcher: SnackbarDispatcher,
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack(
Expand Down Expand Up @@ -206,6 +208,12 @@ class LoggedInFlowNode @AssistedInject constructor(
}
.launchIn(lifecycleScope)
},
onResume = {
lifecycleScope.launch {
val availableRoomIds = matrixClient.getJoinedRoomIds().getOrNull() ?: return@launch
notificationConversationService.onAvailableRoomsChanged(sessionId = matrixClient.sessionId, roomIds = availableRoomIds)
}
},
Comment on lines +211 to +216
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not really convinced of adding it here, but the alternative would be to trigger it when the room list updates which is way too often IMO... maybe there's a better middle ground.

onDestroy = {
appNavigationStateService.onLeavingSpace(id)
appNavigationStateService.onLeavingSession(id)
Expand Down
2 changes: 2 additions & 0 deletions features/leaveroom/impl/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ dependencies {
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.push.api)

testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
Expand All @@ -32,5 +33,6 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.tests.testutils)
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.usersWithRole
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
Expand All @@ -33,6 +34,7 @@ import javax.inject.Inject
class LeaveRoomPresenter @Inject constructor(
private val client: MatrixClient,
private val dispatchers: CoroutineDispatchers,
private val notificationConversationService: NotificationConversationService,
) : Presenter<LeaveRoomState> {
@Composable
override fun present(): LeaveRoomState {
Expand Down Expand Up @@ -78,6 +80,7 @@ class LeaveRoomPresenter @Inject constructor(
client.getRoom(roomId)!!.use { room ->
room
.leave()
.onSuccess { notificationConversationService.onLeftRoom(client.sessionId, roomId) }
.onFailure { Timber.e(it, "Error while leaving room ${room.roomId}") }
.getOrThrow()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
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.aRoomInfo
import io.element.android.libraries.push.test.notifications.conversations.FakeNotificationConversationService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
Expand Down Expand Up @@ -209,4 +210,5 @@ private fun TestScope.createLeaveRoomPresenter(
): LeaveRoomPresenter = LeaveRoomPresenter(
client = client,
dispatchers = testCoroutineDispatchers(false),
notificationConversationService = FakeNotificationConversationService(),
)
2 changes: 2 additions & 0 deletions features/messages/impl/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ dependencies {
implementation(projects.libraries.voiceplayer.api)
implementation(projects.libraries.voicerecorder.api)
implementation(projects.libraries.mediaplayer.api)
implementation(projects.libraries.push.api)
implementation(projects.libraries.uiUtils)
implementation(projects.libraries.testtags)
implementation(projects.features.networkmonitor.api)
Expand All @@ -76,6 +77,7 @@ dependencies {
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.features.location.test)
testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.features.messages.test)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,18 +52,22 @@ import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
import io.element.android.libraries.matrix.api.room.getDirectRoomMember
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
Expand Down Expand Up @@ -119,6 +123,7 @@ class MessageComposerPresenter @AssistedInject constructor(
private val pillificationHelper: TextPillificationHelper,
private val suggestionsProcessor: SuggestionsProcessor,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
private val notificationConversationService: NotificationConversationService,
) : Presenter<MessageComposerState> {
@AssistedFactory
interface Factory {
Expand Down Expand Up @@ -463,6 +468,18 @@ class MessageComposerPresenter @AssistedInject constructor(
}
}
}

val roomInfo = room.info()
val roomMembers = room.membersStateFlow.value

notificationConversationService.onSendMessage(
sessionId = room.sessionId,
roomId = roomInfo.id,
roomName = roomInfo.name ?: roomInfo.id.value,
roomIsDirect = roomInfo.isDm,
roomAvatarUrl = roomInfo.avatarUrl ?: roomMembers.getDirectRoomMember(roomInfo = roomInfo, sessionId = room.sessionId)?.avatarUrl,
)

analyticsService.capture(
Composer(
inThread = capturedMode.inThread,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import io.element.android.libraries.permissions.test.FakePermissionsPresenterFac
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.push.test.notifications.conversations.FakeNotificationConversationService
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
Expand Down Expand Up @@ -127,6 +128,7 @@ class MessageComposerPresenterTest {
private val mockMediaUrl: Uri = mockk("localMediaUri")
private val localMediaFactory = FakeLocalMediaFactory(mockMediaUrl)
private val analyticsService = FakeAnalyticsService()
private val notificationConversationService = FakeNotificationConversationService()

@Test
fun `present - initial state`() = runTest {
Expand Down Expand Up @@ -1566,6 +1568,7 @@ class MessageComposerPresenterTest {
pillificationHelper = textPillificationHelper,
suggestionsProcessor = SuggestionsProcessor(),
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
notificationConversationService = notificationConversationService,
).apply {
isTesting = true
showTextFormatting = isRichTextEditorEnabled
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ interface MatrixClient {
suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom?
suspend fun getRoom(roomId: RoomId): BaseRoom?
suspend fun findDM(userId: UserId): Result<RoomId?>
suspend fun getJoinedRoomIds(): Result<Set<RoomId>>
suspend fun ignoreUser(userId: UserId): Result<Unit>
suspend fun unignoreUser(userId: UserId): Result<Unit>
suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.api.room

import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.collections.immutable.ImmutableList

@Immutable
Expand All @@ -34,3 +35,9 @@ fun RoomMembersState.joinedRoomMembers(): List<RoomMember> {
fun RoomMembersState.activeRoomMembers(): List<RoomMember> {
return roomMembers().orEmpty().filter { it.membership.isActive() }
}

fun RoomMembersState.getDirectRoomMember(roomInfo: RoomInfo, sessionId: SessionId): RoomMember? {
return roomMembers()
?.takeIf { roomInfo.isDm }
?.find { it.userId != sessionId && it.membership.isActive() }
}
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ import org.matrix.rustcomponents.sdk.AuthDataPasswordDetails
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientException
import org.matrix.rustcomponents.sdk.IgnoredUsersListener
import org.matrix.rustcomponents.sdk.Membership
import org.matrix.rustcomponents.sdk.NotificationProcessSetup
import org.matrix.rustcomponents.sdk.PowerLevels
import org.matrix.rustcomponents.sdk.RoomInfoListener
Expand Down Expand Up @@ -274,6 +275,7 @@ class RustMatrixClient(
}

override suspend fun getRoom(roomId: RoomId): BaseRoom? = withContext(sessionDispatcher) {
innerClient.rooms()
roomFactory.getBaseRoom(roomId)
}

Expand Down Expand Up @@ -308,6 +310,15 @@ class RustMatrixClient(
}
}

override suspend fun getJoinedRoomIds(): Result<Set<RoomId>> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.rooms()
.filter { it.membership() == Membership.JOINED }
.map { RoomId(it.id()) }
.toSet()
}
}

override suspend fun ignoreUser(userId: UserId): Result<Unit> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.ignoreUser(userId.value)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ class FakeMatrixClient(
private val isLivekitRtcSupportedLambda: () -> Boolean = { false },
override val ignoredUsersFlow: StateFlow<ImmutableList<UserId>> = MutableStateFlow(persistentListOf()),
private val getMaxUploadSizeResult: () -> Result<Long> = { lambdaError() },
private val getJoinedRoomIdsResult: () -> Result<Set<RoomId>> = { Result.success(emptySet()) },
) : MatrixClient {
var setDisplayNameCalled: Boolean = false
private set
Expand Down Expand Up @@ -141,6 +142,10 @@ class FakeMatrixClient(
return findDmResult
}

override suspend fun getJoinedRoomIds(): Result<Set<RoomId>> {
return getJoinedRoomIdsResult()
}

override suspend fun ignoreUser(userId: UserId): Result<Unit> = simulateLongTask {
return ignoreUserResult(userId)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/*
* Copyright 2025 New Vector 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.ui.media

import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.Typeface
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.dp
import androidx.core.graphics.createBitmap
import coil3.Bitmap
import io.element.android.compound.theme.AvatarColors
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.SemanticColors
import io.element.android.compound.tokens.generated.compoundColorsDark
import io.element.android.compound.tokens.generated.compoundColorsLight
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.theme.components.Text

/**
* Generates a bitmap for an initials avatar based on the provided [AvatarData].
*/
class InitialsAvatarBitmapGenerator(
useDarkTheme: Boolean = false,
private val fontSizePercentage: Float = 0.5f,
) {
private val compoundColors: SemanticColors = if (useDarkTheme) {
compoundColorsDark
} else {
compoundColorsLight
}

// List of predefined avatar colors to use for initials avatars, in light mode
private val allAvatarColors: List<AvatarColors> = listOf(
AvatarColors(
background = compoundColors.bgDecorative1,
foreground = compoundColors.textDecorative1,
),
AvatarColors(
background = compoundColors.bgDecorative2,
foreground = compoundColors.textDecorative2,
),
AvatarColors(
background = compoundColors.bgDecorative3,
foreground = compoundColors.textDecorative3,
),
AvatarColors(
background = compoundColors.bgDecorative4,
foreground = compoundColors.textDecorative4,
),
AvatarColors(
background = compoundColors.bgDecorative5,
foreground = compoundColors.textDecorative5,
),
AvatarColors(
background = compoundColors.bgDecorative6,
foreground = compoundColors.textDecorative6,
),
)

/**
* Generates a bitmap for an avatar with no URL, using the initials from the [AvatarData].
* @param size The size of the bitmap to generate, in pixels.
* @param avatarData The [AvatarData] containing the initials and other information.
*/
fun generateBitmap(size: Int, avatarData: AvatarData): Bitmap? {
if (avatarData.url != null) {
// This generator is only for initials avatars, not for avatars with URLs
return null
}

// Get the color pair to use for the initials avatar
val avatarColors = allAvatarColors[avatarData.id.sumOf { it.code } % allAvatarColors.size]

val bitmap = createBitmap(size, size)
Canvas(bitmap).run {
drawColor(avatarColors.background.toArgb())
val letter = avatarData.initialLetter

val textPaint = Paint().apply {
color = avatarColors.foreground.toArgb()
textSize = size * fontSizePercentage // Adjust text size relative to the avatar size
isAntiAlias = true
textAlign = Paint.Align.CENTER
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
}
val bounds = Rect()
textPaint.getTextBounds(letter, 0, letter.length, bounds)
drawText(
letter,
size / 2f,
size.toFloat() / 2 - (textPaint.descent() + textPaint.ascent()) / 2,
textPaint
)
}

return bitmap
}
}

@Composable
@PreviewsDayNight
internal fun InitialsAvatarBitmapGeneratorPreview() = ElementPreview {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
repeat(6) { index ->
val avatarData = remember { AvatarData(id = index.toString(), name = Char('0'.code + index).toString(), size = AvatarSize.IncomingCall) }
val isLightTheme = ElementTheme.isLightTheme
val bitmap = remember(isLightTheme) {
val generator = InitialsAvatarBitmapGenerator(useDarkTheme = !isLightTheme)
generator.generateBitmap(512, avatarData)?.asImageBitmap()
}

bitmap?.let {
Image(bitmap = it, contentDescription = null, modifier = Modifier.size(48.dp))
} ?: Text("No avatar generated")
}
}
}
Loading