diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BlapBridgeBadge.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BlapBridgeBadge.kt new file mode 100644 index 0000000000..8a55efd9cb --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BlapBridgeBadge.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026 Blap + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.components + +import androidx.compose.foundation.background + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.home.impl.model.RoomListRoomSummary +import io.element.android.libraries.designsystem.theme.components.Text + +/** + * Blap: which bridged network a DM belongs to, detected from the bridge bot's + * mxid localpart conventions (mautrix/mx-puppet/heisenbridge naming). Purely a + * visual cue — Discord/Telegram/etc. DMs are indistinguishable otherwise. + */ +enum class BlapBridgeNetwork( + val prefixes: List, + val letter: String, + val color: Color, +) { + Discord(listOf("discord_"), "D", Color(0xFF5865F2)), + Telegram(listOf("telegram_"), "T", Color(0xFF26A5E4)), + WhatsApp(listOf("whatsapp_"), "W", Color(0xFF25D366)), + Signal(listOf("signal_"), "S", Color(0xFF3A76F0)), + Slack(listOf("slack_"), "S", Color(0xFFECB22E)), + Irc(listOf("irc_"), "I", Color(0xFF9E9E9E)), + Xmpp(listOf("xmpp_"), "X", Color(0xFFE96D1F)), + ; + + companion object { + fun fromUserId(userId: String): BlapBridgeNetwork? { + // "@_discord_puppet_123:server" / "@discord_123:server" -> "discord_puppet_123" + val localpart = userId.removePrefix("@").substringBefore(":").trimStart('_') + return entries.firstOrNull { network -> + network.prefixes.any { localpart.startsWith(it) } + } + } + } +} + +/** Bridge network of a DM row, or null for plain Matrix DMs and group rooms. */ +fun RoomListRoomSummary.blapBridgeNetwork(): BlapBridgeNetwork? { + if (!isDm) return null + return heroes.firstNotNullOfOrNull { BlapBridgeNetwork.fromUserId(it.id) } + // Fallback: no heroes loaded — the DM's own avatar id is the room id (useless), + // but the room name of a bridged ghost user is not, so nothing to detect. Skip. + ?: BlapBridgeNetwork.fromUserId(avatarData.id) +} + +/** Small colored network badge overlaid on a DM avatar's bottom-end corner. */ +@Composable +fun BlapBridgeBadge( + network: BlapBridgeNetwork, + modifier: Modifier = Modifier, +) { + // Outer circle in the canvas color fakes a cutout ring against the avatar. + Box( + modifier = modifier + .size(17.dp) + .background(ElementTheme.colors.bgCanvasDefault, CircleShape), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(14.dp) + .background(network.color, CircleShape), + contentAlignment = Alignment.Center, + ) { + Text( + text = network.letter, + color = Color.White, + style = ElementTheme.typography.fontBodyXsMedium.copy( + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + ), + ) + } + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BlapSpaceRail.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BlapSpaceRail.kt index 1b42cebb51..d73da94307 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BlapSpaceRail.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BlapSpaceRail.kt @@ -7,11 +7,15 @@ package io.element.android.features.home.impl.components +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateIntAsState import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -20,6 +24,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -93,15 +98,44 @@ private fun RailItem( ) { Box( modifier = Modifier - .padding(vertical = 4.dp) - .size(48.dp) - .clip(RoundedCornerShape(percent = if (isSelected) 30 else 50)) - .background( - if (isSelected) ElementTheme.colors.bgAccentSelected else ElementTheme.colors.bgSubtleSecondary - ) - .clickable(onClick = onClick), + .fillMaxWidth() + .padding(vertical = 4.dp), contentAlignment = Alignment.Center, ) { - content() + // Discord-style selection pill hugging the left edge of the rail. + val pillHeight by animateDpAsState(if (isSelected) 36.dp else 0.dp, label = "railPillHeight") + if (pillHeight > 0.dp) { + Box( + modifier = Modifier + .align(Alignment.CenterStart) + .size(width = 4.dp, height = pillHeight) + .clip(RoundedCornerShape(topEnd = 4.dp, bottomEnd = 4.dp)) + .background(ElementTheme.colors.iconAccentPrimary), + ) + } + val cornerPercent by animateIntAsState(if (isSelected) 30 else 50, label = "railCorner") + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(percent = cornerPercent)) + .background( + if (isSelected) ElementTheme.colors.bgAccentSelected else ElementTheme.colors.bgSubtleSecondary + ) + .then( + if (isSelected) { + Modifier.border( + width = 2.dp, + color = ElementTheme.colors.iconAccentPrimary, + shape = RoundedCornerShape(percent = cornerPercent), + ) + } else { + Modifier + } + ) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + content() + } } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt index ae636f5e92..d1d75bef96 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt @@ -199,8 +199,6 @@ private fun RoomsView( RoomsViewList( state = state, hideInvitesAvatars = hideInvitesAvatars, - // Blap: at Home DMs lead (desktop parity); inside a space, channels lead. - blapDmFirst = !isSpaceFilterSelected, eventSink = eventSink, onSetUpRecoveryClick = onSetUpRecoveryClick, onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, @@ -216,7 +214,6 @@ private fun RoomsView( private fun RoomsViewList( state: RoomListContentState.Rooms, hideInvitesAvatars: Boolean, - blapDmFirst: Boolean, eventSink: (RoomListEvent) -> Unit, onSetUpRecoveryClick: () -> Unit, onConfirmRecoveryKeyClick: () -> Unit, @@ -229,10 +226,10 @@ private fun RoomsViewList( eventSink(RoomListEvent.UpdateVisibleRange(visibleRange)) } // Blap: Discord-style sections. Invites/knocks render on top, ungrouped; then - // DM / Text / Voice sections (DMs first at Home) with collapsible headers and - // inline voice members. - val (blapSpecial, blapSections) = remember(state.summaries, state.blapVoiceRoomIds, state.blapCollapsedSections, blapDmFirst) { - buildBlapSections(state.summaries, state.blapVoiceRoomIds, state.blapCollapsedSections, dmFirst = blapDmFirst) + // Text / Voice / DM sections (desktop order, Home included) with collapsible + // headers and inline voice members. + val (blapSpecial, blapSections) = remember(state.summaries, state.blapVoiceRoomIds, state.blapCollapsedSections) { + buildBlapSections(state.summaries, state.blapVoiceRoomIds, state.blapCollapsedSections) } LazyColumn( state = lazyListState, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt index ba9a204a64..77d1ae3254 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt @@ -193,18 +193,27 @@ private fun RoomSummaryScaffoldRow( .padding(horizontal = 16.dp, vertical = 11.dp) .height(IntrinsicSize.Min), ) { - Avatar( - avatarData = room.avatarData, - avatarType = if (room.isSpace) { - AvatarType.Space(isTombstoned = room.isTombstoned) - } else { - AvatarType.Room( - heroes = room.heroes, - isTombstoned = room.isTombstoned, + Box { + Avatar( + avatarData = room.avatarData, + avatarType = if (room.isSpace) { + AvatarType.Space(isTombstoned = room.isTombstoned) + } else { + AvatarType.Room( + heroes = room.heroes, + isTombstoned = room.isTombstoned, + ) + }, + hideImage = hideAvatarImage, + ) + // Blap: bridged DMs (Discord/Telegram/…) get a network badge + room.blapBridgeNetwork()?.let { network -> + BlapBridgeBadge( + network = network, + modifier = Modifier.align(Alignment.BottomEnd), ) - }, - hideImage = hideAvatarImage, - ) + } + } Spacer(modifier = Modifier.width(16.dp)) Column( modifier = Modifier.fillMaxWidth(), diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/BlapSections.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/BlapSections.kt index 181e3596c4..bf172778fd 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/BlapSections.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/BlapSections.kt @@ -38,14 +38,13 @@ data class BlapSectionEntry( * and render on top, ungrouped. A collapsed section keeps rooms that would notify the user * visible (same rule as desktop). * - * [dmFirst] mirrors desktop: at Home (no space selected) DMs come first, like Discord's DM - * home; inside a space the channels lead and DMs trail. + * Section order matches desktop's YapRoomList everywhere, Home included: + * text channels, voice channels, DMs last. */ fun buildBlapSections( summaries: List, voiceRoomIds: Set, collapsedSections: Set, - dmFirst: Boolean, ): Pair, List> { val (rooms, special) = summaries.partition { it.displayType == io.element.android.features.home.impl.model.RoomSummaryDisplayType.ROOM @@ -57,11 +56,7 @@ fun buildBlapSections( else -> BlapSection.Text } } - val sectionOrder = if (dmFirst) { - listOf(BlapSection.Dm, BlapSection.Text, BlapSection.Voice) - } else { - listOf(BlapSection.Text, BlapSection.Voice, BlapSection.Dm) - } + val sectionOrder = listOf(BlapSection.Text, BlapSection.Voice, BlapSection.Dm) val entries = sectionOrder.mapNotNull { section -> val sectionRooms = bySection[section] ?: return@mapNotNull null val isCollapsed = section.key in collapsedSections diff --git a/libraries/designsystem/src/main/res/drawable-night-xxhdpi/element_logo.png b/libraries/designsystem/src/main/res/drawable-night-xxhdpi/element_logo.png index d65c54da17..edcd356bd5 100644 Binary files a/libraries/designsystem/src/main/res/drawable-night-xxhdpi/element_logo.png and b/libraries/designsystem/src/main/res/drawable-night-xxhdpi/element_logo.png differ diff --git a/libraries/designsystem/src/main/res/drawable-night/bg_migration.png b/libraries/designsystem/src/main/res/drawable-night/bg_migration.png index 442628a6bf..e2bbd0e906 100644 Binary files a/libraries/designsystem/src/main/res/drawable-night/bg_migration.png and b/libraries/designsystem/src/main/res/drawable-night/bg_migration.png differ diff --git a/libraries/designsystem/src/main/res/drawable-night/onboarding_bg.png b/libraries/designsystem/src/main/res/drawable-night/onboarding_bg.png index 2f51442fd3..87d7a9d63b 100644 Binary files a/libraries/designsystem/src/main/res/drawable-night/onboarding_bg.png and b/libraries/designsystem/src/main/res/drawable-night/onboarding_bg.png differ diff --git a/libraries/designsystem/src/main/res/drawable-xxhdpi/element_logo.png b/libraries/designsystem/src/main/res/drawable-xxhdpi/element_logo.png index a5e24f328b..edcd356bd5 100644 Binary files a/libraries/designsystem/src/main/res/drawable-xxhdpi/element_logo.png and b/libraries/designsystem/src/main/res/drawable-xxhdpi/element_logo.png differ diff --git a/libraries/designsystem/src/main/res/drawable/bg_migration.png b/libraries/designsystem/src/main/res/drawable/bg_migration.png index 4d889596ab..7caf5df802 100644 Binary files a/libraries/designsystem/src/main/res/drawable/bg_migration.png and b/libraries/designsystem/src/main/res/drawable/bg_migration.png differ diff --git a/libraries/designsystem/src/main/res/drawable/onboarding_bg.png b/libraries/designsystem/src/main/res/drawable/onboarding_bg.png index 3b9468e357..36779529f8 100644 Binary files a/libraries/designsystem/src/main/res/drawable/onboarding_bg.png and b/libraries/designsystem/src/main/res/drawable/onboarding_bg.png differ