Blap login branding, rail selection cue, bridge badges, desktop section order
Sonar / Sonar Quality Checks (push) Has been cancelled
Code Quality Checks / Search for forbidden patterns (push) Has been cancelled
Code Quality Checks / Search for invalid screenshot files (push) Has been cancelled
Code Quality Checks / Search for invalid dependencies (push) Has been cancelled
Code Quality Checks / Compose tests (push) Has been cancelled
Code Quality Checks / Android lint check (push) Has been cancelled
Code Quality Checks / Detekt checks (push) Has been cancelled
Code Quality Checks / Ktlint checks (push) Has been cancelled
Code Quality Checks / Doc checks (push) Has been cancelled
Code Quality Checks / Check shell scripts (push) Has been cancelled
Create release App Bundle and APKs / Create App Bundle (Gplay) (push) Has been cancelled
Post-release / post-release (push) Has been cancelled
Code Quality Checks / Konsist tests (push) Has been cancelled
Code Quality Checks / Run zizmor (push) Has been cancelled
Code Quality Checks / Project Check Suite (push) Has been cancelled
Create release App Bundle and APKs / Create App Bundle Enterprise (push) Has been cancelled
Create release App Bundle and APKs / Create APKs (FDroid) (push) Has been cancelled
Test / Runs unit tests (push) Has been cancelled

- Login/onboarding: Element swirl logo replaced with the purple five-dot mark,
  onboarding + migration backgrounds regenerated as purple gradients
- Space rail: Discord-style animated selection pill on the left edge, corner
  morph + accent ring on the selected item (there was no visible cue before)
- DM rows: bridged DMs (Discord/Telegram/WhatsApp/Signal/Slack/IRC/XMPP,
  detected from the ghost user's mxid prefix) get a colored network badge
  on the avatar
- Room list: section order now matches desktop everywhere — Text, Voice,
  DMs last (the DMs-first-at-Home special case is gone)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 23:37:26 -07:00
parent 40369299f7
commit a302dfb1eb
11 changed files with 163 additions and 34 deletions
@@ -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<String>,
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,
),
)
}
}
}
@@ -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()
}
}
}
@@ -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,
@@ -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(),
@@ -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<RoomListRoomSummary>,
voiceRoomIds: Set<RoomId>,
collapsedSections: Set<String>,
dmFirst: Boolean,
): Pair<List<RoomListRoomSummary>, List<BlapSectionEntry>> {
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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 26 KiB