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>
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 328 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 404 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 26 KiB |