Skip to content
This repository was archived by the owner on Jun 19, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all 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 @@ -123,7 +123,8 @@ internal fun RoomSummaryRow(
NameAndTimestampRow(
name = room.name,
timestamp = room.timestamp,
isHighlighted = room.isHighlighted
isHighlighted = room.isHighlighted,
isPinned = room.isPinned,
)
MessagePreviewAndIndicatorRow(room = room)
}
Expand Down Expand Up @@ -215,7 +216,8 @@ private fun NameAndTimestampRow(
name: String?,
timestamp: String?,
isHighlighted: Boolean,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
isPinned: Boolean = false,
) {
Row(
modifier = modifier.fillMaxWidth(),
Expand All @@ -235,16 +237,29 @@ private fun NameAndTimestampRow(
overflow = TextOverflow.Ellipsis
)
}
// Timestamp
Text(
text = timestamp ?: "",
style = ElementTheme.typography.fontBodySmMedium,
color = if (isHighlighted) {
ElementTheme.colors.unreadIndicator
} else {
ElementTheme.colors.roomListRoomMessageDate
},
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = spacedBy(4.dp),
) {
if (isPinned) {
Icon(
modifier = Modifier.size(14.dp),
imageVector = CompoundIcons.PinSolid(),
contentDescription = stringResource(id = CommonStrings.common_pinned),
tint = ElementTheme.colors.iconSecondary,
)
}
// Timestamp
Text(
text = timestamp ?: "",
style = ElementTheme.typography.fontBodySmMedium,
color = if (isHighlighted) {
ElementTheme.colors.unreadIndicator
} else {
ElementTheme.colors.roomListRoomMessageDate
},
)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.api.roomlist.updateVisibleRange
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
Expand All @@ -35,6 +36,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
Expand All @@ -60,10 +62,12 @@ class RoomListDataSource(
private val sessionCoroutineScope: CoroutineScope,
private val dateTimeObserver: DateTimeObserver,
private val analyticsService: AnalyticsService,
private val sessionPreferencesStore: SessionPreferencesStore,
) {
init {
observeNotificationSettings()
observeDateTimeChanges()
observePinnedRoomsChanges()
}

private val roomList = roomListService.createRoomList(
Expand Down Expand Up @@ -145,6 +149,15 @@ class RoomListDataSource(
.launchIn(sessionCoroutineScope)
}

private fun observePinnedRoomsChanges() {
sessionPreferencesStore.getPinnedRoomsFlow()
.drop(1) // Skip initial value, only react to changes
.onEach {
rebuildAllRoomSummaries()
}
.launchIn(sessionCoroutineScope)
}

private suspend fun replaceWith(roomSummaries: List<RoomSummary>) = withContext(coroutineDispatchers.computation) {
lock.withLock {
diffCacheUpdater.updateWith(roomSummaries)
Expand Down Expand Up @@ -186,6 +199,22 @@ class RoomListDataSource(
}
}

// Apply pinned state and sort pinned rooms first, ordered by pin recency
val pinnedRoomIds = sessionPreferencesStore.getPinnedRoomsFlow().first()
val pinnedSet = pinnedRoomIds.toSet()
val withPinnedState = roomListRoomSummaries.map { summary ->
if (pinnedSet.contains(summary.roomId.value)) {
summary.copy(isPinned = true)
} else {
summary
}
}
val pinnedIndexMap = pinnedRoomIds.withIndex().associate { (index, id) -> id to index }
val sortedSummaries = withPinnedState.sortedWith(
compareBy<RoomListRoomSummary> { !it.isPinned }
.thenBy { pinnedIndexMap[it.roomId.value] ?: Int.MAX_VALUE }
)

// TODO remove once https://github.com/element-hq/element-x-android/issues/5031 has been confirmed as fixed
val duplicates = cachingResults.filter { (_, operations) -> operations.size > 1 }
if (duplicates.isNotEmpty()) {
Expand All @@ -197,9 +226,9 @@ class RoomListDataSource(
)

// Remove duplicates before emitting the new values
_roomSummariesFlow.emit(roomListRoomSummaries.distinctBy { it.roomId }.toImmutableList())
_roomSummariesFlow.emit(sortedSummaries.distinctBy { it.roomId }.toImmutableList())
} else {
_roomSummariesFlow.emit(roomListRoomSummaries.toImmutableList())
_roomSummariesFlow.emit(sortedSummaries.toImmutableList())
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ data class RoomListRoomSummary(
val isDirect: Boolean,
val isDm: Boolean,
val isFavorite: Boolean,
val isPinned: Boolean = false,
val inviteSender: InviteSender?,
val isTombstoned: Boolean,
val heroes: ImmutableList<AvatarData>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,11 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
name = "A tombstoned room",
displayType = RoomSummaryDisplayType.ROOM,
isTombstoned = true,
)
),
aRoomListRoomSummary(
name = "A pinned room",
isPinned = true,
),
),
listOf(
aRoomListRoomSummary(latestEvent = LatestEvent.Sending("A sending message")),
Expand Down Expand Up @@ -162,6 +166,7 @@ internal fun aRoomListRoomSummary(
isDirect: Boolean = false,
isDm: Boolean = false,
isFavorite: Boolean = false,
isPinned: Boolean = false,
inviteSender: InviteSender? = null,
displayType: RoomSummaryDisplayType = RoomSummaryDisplayType.ROOM,
canonicalAlias: RoomAlias? = null,
Expand All @@ -184,6 +189,7 @@ internal fun aRoomListRoomSummary(
isDirect = isDirect,
isDm = isDm,
isFavorite = isFavorite,
isPinned = isPinned,
inviteSender = inviteSender,
displayType = displayType,
canonicalAlias = canonicalAlias,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ fun RoomListContextMenu(
onFavoriteChange = { isFavorite ->
eventSink(RoomListEvent.SetRoomIsFavorite(contextMenu.roomId, isFavorite))
},
onPinChange = { isPinned ->
eventSink(RoomListEvent.SetRoomIsPinned(contextMenu.roomId, isPinned))
},
onClearCacheRoomClick = {
eventSink(RoomListEvent.HideContextMenu)
eventSink(RoomListEvent.ClearCacheOfRoom(contextMenu.roomId))
Expand All @@ -85,6 +88,7 @@ private fun RoomListModalBottomSheetContent(
onRoomSettingsClick: () -> Unit,
onLeaveRoomClick: () -> Unit,
onFavoriteChange: (isFavorite: Boolean) -> Unit,
onPinChange: (isPinned: Boolean) -> Unit,
onRoomMarkReadClick: () -> Unit,
onRoomMarkUnreadClick: () -> Unit,
onClearCacheRoomClick: () -> Unit,
Expand Down Expand Up @@ -156,6 +160,31 @@ private fun RoomListModalBottomSheetContent(
},
style = ListItemStyle.Primary,
)
val (pinTextResId, pinIcon) = if (contextMenu.isPinned) {
CommonStrings.common_pinned to CompoundIcons.PinSolid()
} else {
CommonStrings.common_pin_to_top to CompoundIcons.Pin()
}
ListItem(
headlineContent = {
Text(
text = stringResource(id = pinTextResId),
style = MaterialTheme.typography.bodyLarge,
)
},
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(
pinIcon,
)
),
trailingContent = ListItemContent.Switch(
checked = contextMenu.isPinned,
),
onClick = {
onPinChange(!contextMenu.isPinned)
},
style = ListItemStyle.Primary,
)
ListItem(
headlineContent = {
Text(
Expand Down Expand Up @@ -228,6 +257,7 @@ internal fun RoomListModalBottomSheetContentPreview(
onRoomSettingsClick = {},
onLeaveRoomClick = {},
onFavoriteChange = {},
onPinChange = {},
onClearCacheRoomClick = {},
onReportRoomClick = {},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,6 @@ sealed interface RoomListEvent {
data class MarkAsRead(val roomId: RoomId) : ContextMenuEvent
data class MarkAsUnread(val roomId: RoomId) : ContextMenuEvent
data class SetRoomIsFavorite(val roomId: RoomId, val isFavorite: Boolean) : ContextMenuEvent
data class SetRoomIsPinned(val roomId: RoomId, val isPinned: Boolean) : ContextMenuEvent
data class ClearCacheOfRoom(val roomId: RoomId) : ContextMenuEvent
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
Expand Down Expand Up @@ -140,6 +141,7 @@ class RoomListPresenter(
leaveRoomState.eventSink(LeaveRoomEvent.LeaveRoom(event.roomId, needsConfirmation = event.needsConfirmation))
}
is RoomListEvent.SetRoomIsFavorite -> coroutineScope.setRoomIsFavorite(event.roomId, event.isFavorite)
is RoomListEvent.SetRoomIsPinned -> coroutineScope.setRoomIsPinned(event.roomId, event.isPinned)
is RoomListEvent.MarkAsRead -> coroutineScope.markAsRead(event.roomId)
is RoomListEvent.MarkAsUnread -> coroutineScope.markAsUnread(event.roomId)
is RoomListEvent.AcceptInvite -> {
Expand Down Expand Up @@ -270,6 +272,7 @@ class RoomListPresenter(
roomName = event.roomSummary.name,
isDm = event.roomSummary.isDm,
isFavorite = event.roomSummary.isFavorite,
isPinned = event.roomSummary.isPinned,
hasNewContent = event.roomSummary.hasNewContent,
displayClearRoomCacheAction = appPreferencesStore.isDeveloperModeEnabledFlow().first(),
)
Expand All @@ -284,9 +287,15 @@ class RoomListPresenter(
.map { it.isFavorite }
.distinctUntilChanged()

isFavoriteFlow
.onEach { isFavorite ->
contextMenuState.value = initialState.copy(isFavorite = isFavorite)
val isPinnedFlow = sessionPreferencesStore.getPinnedRoomsFlow()
.map { it.contains(event.roomSummary.roomId.value) }
.distinctUntilChanged()

combine(isFavoriteFlow, isPinnedFlow) { isFavorite, isPinned ->
isFavorite to isPinned
}
.onEach { (isFavorite, isPinned) ->
contextMenuState.value = initialState.copy(isFavorite = isFavorite, isPinned = isPinned)
}
.flatMapLatest { isShowingContextMenuFlow }
.takeWhile { isShowingContextMenu -> isShowingContextMenu }
Expand All @@ -303,6 +312,16 @@ class RoomListPresenter(
}
}

private fun CoroutineScope.setRoomIsPinned(roomId: RoomId, isPinned: Boolean) = launch {
val current = sessionPreferencesStore.getPinnedRoomsFlow().first()
val updated = if (isPinned) {
listOf(roomId.value) + current.filter { it != roomId.value }
} else {
current.filter { it != roomId.value }
}
sessionPreferencesStore.setPinnedRooms(updated)
}

private fun CoroutineScope.markAsRead(roomId: RoomId) = launch {
notificationCleaner.clearMessagesForRoom(client.sessionId, roomId)
client.getRoom(roomId)?.use { room ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ data class RoomListState(
val roomName: String?,
val isDm: Boolean,
val isFavorite: Boolean,
val isPinned: Boolean,
val hasNewContent: Boolean,
val displayClearRoomCacheAction: Boolean,
) : ContextMenu
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ open class RoomListStateContextMenuShownProvider : PreviewParameterProvider<Room
get() = sequenceOf(
aContextMenuShown(hasNewContent = true),
aContextMenuShown(isDm = true, isFavorite = true),
aContextMenuShown(roomName = null)
aContextMenuShown(roomName = null),
aContextMenuShown(isPinned = true),
)
}

Expand All @@ -25,11 +26,13 @@ internal fun aContextMenuShown(
isDm: Boolean = false,
hasNewContent: Boolean = false,
isFavorite: Boolean = false,
isPinned: Boolean = false,
) = RoomListState.ContextMenu.Shown(
roomId = RoomId("!aRoom:aDomain"),
roomName = roomName,
isDm = isDm,
hasNewContent = hasNewContent,
isFavorite = isFavorite,
isPinned = isPinned,
displayClearRoomCacheAction = false,
)
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.test.notificationsettings.FakeNotific
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.flow.MutableStateFlow
Expand Down Expand Up @@ -113,5 +114,6 @@ class RoomListDataSourceTest {
sessionCoroutineScope = backgroundScope,
dateTimeObserver = dateTimeObserver,
analyticsService = FakeAnalyticsService(),
sessionPreferencesStore = InMemorySessionPreferencesStore(),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,38 @@ class RoomListContextMenuTest {
)
}

@Test
fun `clicking on Pin to top generates expected Event`() {
val eventsRecorder = EventsRecorder<RoomListEvent>()
val contextMenu = aContextMenuShown(isPinned = false)
rule.setRoomListContextMenu(
contextMenu = contextMenu,
eventSink = eventsRecorder,
)
rule.clickOn(CommonStrings.common_pin_to_top)
eventsRecorder.assertList(
listOf(
RoomListEvent.SetRoomIsPinned(contextMenu.roomId, true),
)
)
}

@Test
fun `clicking on Pinned generates expected Event`() {
val eventsRecorder = EventsRecorder<RoomListEvent>()
val contextMenu = aContextMenuShown(isPinned = true)
rule.setRoomListContextMenu(
contextMenu = contextMenu,
eventSink = eventsRecorder,
)
rule.clickOn(CommonStrings.common_pinned)
eventsRecorder.assertList(
listOf(
RoomListEvent.SetRoomIsPinned(contextMenu.roomId, false),
)
)
}

private fun AndroidComposeTestRule<*, *>.setRoomListContextMenu(
contextMenu: RoomListState.ContextMenu.Shown,
canReportRoom: Boolean = false,
Expand Down
Loading
Loading