Compare commits
7 Commits
dev
...
d05c3ce367
| Author | SHA1 | Date | |
|---|---|---|---|
| d05c3ce367 | |||
| 1ab649d6a1 | |||
| b3b2796d25 | |||
| c83b3e455f | |||
| bd5f4dd263 | |||
| d28ae0a48c | |||
| f0e6ba189e |
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Created by sweetbread
|
||||
* Copyright (c) 2026. All rights reserved.
|
||||
*/
|
||||
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
/*
|
||||
@@ -58,6 +63,7 @@ dependencies {
|
||||
implementation(libs.androidx.ui.graphics)
|
||||
implementation(libs.androidx.ui.tooling.preview)
|
||||
implementation(libs.androidx.material3)
|
||||
implementation(libs.androidx.compose.ui.unit)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
|
||||
@@ -5,41 +5,70 @@
|
||||
|
||||
package ru.risdeveau.pixeldragon.ui.activity
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import de.connect2x.trixnity.client.CryptoDriverModule
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import de.connect2x.trixnity.client.MatrixClient
|
||||
import de.connect2x.trixnity.client.create
|
||||
import de.connect2x.trixnity.client.cryptodriver.vodozemac.vodozemac
|
||||
import de.connect2x.trixnity.client.flattenValues
|
||||
import de.connect2x.trixnity.client.room
|
||||
import de.connect2x.trixnity.client.store.type
|
||||
import de.connect2x.trixnity.clientserverapi.client.SyncState
|
||||
import de.connect2x.trixnity.clientserverapi.model.user.avatarUrl
|
||||
import de.connect2x.trixnity.clientserverapi.model.user.displayName
|
||||
import de.connect2x.trixnity.core.model.events.m.room.CreateEventContent
|
||||
import io.github.rabehx.iconsax.Iconsax
|
||||
import io.github.rabehx.iconsax.automirrored.outline.ArrowLeft2
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.risdeveau.pixeldragon.R
|
||||
import ru.risdeveau.pixeldragon.client
|
||||
import ru.risdeveau.pixeldragon.ui.item.Avatar
|
||||
import ru.risdeveau.pixeldragon.ui.layout.Room
|
||||
import ru.risdeveau.pixeldragon.ui.layout.RoomList
|
||||
import ru.risdeveau.pixeldragon.ui.theme.PixelDragonTheme
|
||||
@@ -47,11 +76,10 @@ import ru.risdeveau.pixeldragon.util.getMediaStore
|
||||
import ru.risdeveau.pixeldragon.util.getRoomStore
|
||||
import splitties.activities.start
|
||||
import splitties.init.appCtx
|
||||
import splitties.resources.str
|
||||
import de.connect2x.trixnity.client.store.Room as TrixnityRoom
|
||||
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
@@ -59,34 +87,28 @@ class MainActivity : ComponentActivity() {
|
||||
setContent {
|
||||
PixelDragonTheme {
|
||||
var isClientReady by remember { mutableStateOf(false) }
|
||||
val syncState by remember { mutableStateOf(SyncState.STOPPED) }
|
||||
|
||||
if (!isClientReady) {
|
||||
if (!isClientReady || client == null) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
val navController = rememberNavController()
|
||||
val syncState by client!!.api.sync.currentSyncState
|
||||
.collectAsState(initial = SyncState.STOPPED)
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
colors = topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
titleContentColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
title = {
|
||||
when (syncState) {
|
||||
SyncState.STARTED -> Text("Syncing...")
|
||||
SyncState.INITIAL_SYNC -> Text("Initial sync...")
|
||||
SyncState.STOPPED,
|
||||
SyncState.RUNNING -> Text(str(R.string.app_name))
|
||||
SyncState.TIMEOUT -> Text("No network connection")
|
||||
SyncState.ERROR -> Text("Error syncing")
|
||||
}
|
||||
}
|
||||
PixelDragonTopBar(
|
||||
navController = navController,
|
||||
syncState = syncState,
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
val navController = rememberNavController()
|
||||
|
||||
NavHost(navController = navController, startDestination = "rooms") {
|
||||
composable("rooms") {
|
||||
RoomList(Modifier.padding(innerPadding), navController)
|
||||
@@ -102,7 +124,7 @@ class MainActivity : ComponentActivity() {
|
||||
composable(
|
||||
"space/{rid}",
|
||||
arguments = listOf(navArgument("rid") { type = NavType.StringType })
|
||||
) { navBackStackEntry ->
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
text = "Not implemented"
|
||||
@@ -142,3 +164,156 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("FlowOperatorInvokedInComposition")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun PixelDragonTopBar(
|
||||
navController: NavHostController,
|
||||
syncState: SyncState,
|
||||
) {
|
||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||
val route = backStackEntry?.destination?.route
|
||||
val rid = backStackEntry?.arguments?.getString("rid")
|
||||
val isRoomLikeScreen = route == "room/{rid}" || route == "space/{rid}"
|
||||
|
||||
val roomsFlow = remember(client) {
|
||||
client!!.room.getAll().flattenValues().map { it.toList() }
|
||||
}
|
||||
val rooms by roomsFlow.collectAsState(initial = emptyList())
|
||||
|
||||
val currentRoom = remember(isRoomLikeScreen, rid, rooms) {
|
||||
if (isRoomLikeScreen && rid != null) {
|
||||
rooms.firstOrNull { it.roomId.toString() == rid }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (isRoomLikeScreen) {
|
||||
RoomTopBar(
|
||||
room = currentRoom,
|
||||
fallbackTitle = rid ?: stringResource(R.string.app_name),
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
} else {
|
||||
HomeTopBar(syncState = syncState)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun HomeTopBar(syncState: SyncState) {
|
||||
CenterAlignedTopAppBar(
|
||||
colors = topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
titleContentColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
title = {
|
||||
Text(syncState.toStatusTitle() ?: stringResource(R.string.app_name))
|
||||
},
|
||||
actions = {
|
||||
val client = client!!
|
||||
|
||||
var userName by remember { mutableStateOf("?") }
|
||||
var userAvatar: String? by remember { mutableStateOf(null) }
|
||||
|
||||
LaunchedEffect(client, syncState) {
|
||||
val profile = client.api.user
|
||||
.getProfile(client.userId)
|
||||
.getOrNull()
|
||||
|
||||
userName = profile?.displayName ?: "?"
|
||||
userAvatar = profile?.avatarUrl
|
||||
}
|
||||
|
||||
Avatar(
|
||||
Modifier.size(32.dp).clip(CircleShape),
|
||||
userAvatar,
|
||||
userName,
|
||||
MaterialTheme.colorScheme.background
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun RoomTopBar(
|
||||
room: TrixnityRoom?,
|
||||
fallbackTitle: String,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
CenterAlignedTopAppBar(
|
||||
colors = topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface.copy(.5f),
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Iconsax.AutoMirrored.Outline.ArrowLeft2,
|
||||
"To home",
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
if (room != null) {
|
||||
RoomTopBarTitle(room)
|
||||
} else {
|
||||
Text(
|
||||
text = fallbackTitle,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomTopBarTitle(room: TrixnityRoom) {
|
||||
val title = remember(room) { room.displayName() }
|
||||
val isSpace = room.type == CreateEventContent.RoomType.Space
|
||||
val avatarShape = if (isSpace) RoundedCornerShape(8.dp) else CircleShape
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Avatar(
|
||||
Modifier
|
||||
.size(36.dp)
|
||||
.clip(avatarShape),
|
||||
room.avatarUrl,
|
||||
title
|
||||
)
|
||||
|
||||
Spacer(Modifier.width(10.dp))
|
||||
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun TrixnityRoom.displayName(): String {
|
||||
return name?.explicitName
|
||||
?: name?.heroes?.firstNotNullOfOrNull { it.localpart }
|
||||
?: roomId.toString()
|
||||
}
|
||||
|
||||
private fun SyncState.toStatusTitle(): String? {
|
||||
return when (this) {
|
||||
SyncState.INITIAL_SYNC -> "Initial sync..."
|
||||
SyncState.STARTED -> "Syncing..."
|
||||
SyncState.TIMEOUT -> "No network connection"
|
||||
SyncState.ERROR -> "Error syncing"
|
||||
SyncState.RUNNING,
|
||||
SyncState.STOPPED -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Created by sweetbread
|
||||
* Copyright (c) 2026. All rights reserved.
|
||||
*/
|
||||
|
||||
package ru.risdeveau.pixeldragon.ui.item
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
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.layout.ContentScale
|
||||
|
||||
@Composable
|
||||
fun Avatar(
|
||||
modifier: Modifier,
|
||||
url: String?,
|
||||
fallbackName: String,
|
||||
fallbackColor: Color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
) {
|
||||
if (url != null) {
|
||||
MXCImage(
|
||||
mxcUrl = url,
|
||||
modifier = modifier,
|
||||
contentScale = ContentScale.Crop,
|
||||
contentDescription = fallbackName,
|
||||
showProgress = false,
|
||||
)
|
||||
} else {
|
||||
AvatarPlaceholder(
|
||||
modifier,
|
||||
fallbackName,
|
||||
fallbackColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AvatarPlaceholder(
|
||||
modifier: Modifier = Modifier,
|
||||
fallbackName: String,
|
||||
fallbackColor: Color,
|
||||
) {
|
||||
val initial = fallbackName
|
||||
.trim()
|
||||
.firstOrNull()
|
||||
?.uppercaseChar()
|
||||
?.toString()
|
||||
?: "?"
|
||||
|
||||
Box(
|
||||
modifier = modifier.background(fallbackColor),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = initial,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -24,21 +24,27 @@ import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.TextView
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.isImeVisible
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -51,6 +57,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -58,16 +65,22 @@ import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.focus.onFocusEvent
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.core.text.HtmlCompat
|
||||
import androidx.core.widget.TextViewCompat
|
||||
import de.connect2x.trixnity.client.room
|
||||
@@ -77,6 +90,7 @@ import de.connect2x.trixnity.client.store.TimelineEvent
|
||||
import de.connect2x.trixnity.client.store.eventId
|
||||
import de.connect2x.trixnity.core.model.RoomId
|
||||
import de.connect2x.trixnity.core.model.events.ClientEvent
|
||||
import de.connect2x.trixnity.core.model.events.m.FullyReadEventContent
|
||||
import de.connect2x.trixnity.core.model.events.m.room.AvatarEventContent
|
||||
import de.connect2x.trixnity.core.model.events.m.room.CanonicalAliasEventContent
|
||||
import de.connect2x.trixnity.core.model.events.m.room.HistoryVisibilityEventContent
|
||||
@@ -87,7 +101,6 @@ import de.connect2x.trixnity.core.model.events.m.room.RoomMessageEventContent
|
||||
import de.connect2x.trixnity.core.model.events.m.room.TopicEventContent
|
||||
import io.github.rabehx.iconsax.Iconsax
|
||||
import io.github.rabehx.iconsax.filled.Send2
|
||||
import io.github.rabehx.iconsax.outline.Send2
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@@ -95,6 +108,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Element
|
||||
@@ -111,6 +125,13 @@ import kotlin.math.roundToInt
|
||||
|
||||
private const val WEBVIEW_HEIGHT_TITLE_PREFIX = "pd-height:"
|
||||
private const val MESSAGE_GROUP_WINDOW_MS = 5 * 60 * 1000L
|
||||
private const val TIMELINE_PAGE_SIZE = 50
|
||||
private const val TIMELINE_TOP_PREFETCH_THRESHOLD = 4
|
||||
private const val TIMELINE_BOTTOM_AUTOSCROLL_THRESHOLD = 3
|
||||
private const val MAX_INITIAL_UNREAD_SEARCH_LIMIT = 500
|
||||
private val TIMELINE_AVATAR_SIZE = 32.dp
|
||||
private val MESSAGE_FIELD_TOP_PADDING = 8.dp
|
||||
private val MESSAGE_FIELD_SIZE = 48.dp
|
||||
|
||||
private val simpleHtmlTags = setOf(
|
||||
"a", "b", "blockquote", "br", "code", "del", "div", "em", "h1", "h2",
|
||||
@@ -170,13 +191,18 @@ private val headingMetrics = mapOf(
|
||||
"h6" to TextBlockMetrics(22f, 8f, 10f),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun Room(modifier: Modifier = Modifier, rid: String) {
|
||||
val roomId = remember(rid) { RoomId(rid) }
|
||||
val limit = remember { MutableStateFlow(50) }
|
||||
var requestedLimit by remember(roomId) { mutableStateOf(TIMELINE_PAGE_SIZE) }
|
||||
val limit = remember(roomId) { MutableStateFlow(TIMELINE_PAGE_SIZE) }
|
||||
|
||||
val eventsFlow = remember(roomId) {
|
||||
LaunchedEffect(requestedLimit) {
|
||||
limit.value = requestedLimit
|
||||
}
|
||||
|
||||
val eventsFlow = remember(roomId, client!!) {
|
||||
client!!.room
|
||||
.getLastTimelineEvents(roomId)
|
||||
.toFlowList(limit)
|
||||
@@ -186,19 +212,139 @@ fun Room(modifier: Modifier = Modifier, rid: String) {
|
||||
}
|
||||
}
|
||||
val events by eventsFlow.collectAsState(initial = emptyList())
|
||||
val currentUserId = remember { client!!.userId.toString() }
|
||||
val timelineItems = remember(events, currentUserId) {
|
||||
buildTimelineItems(events = events, currentUserId = currentUserId)
|
||||
|
||||
val readMarkerFlow = remember(roomId, client!!) {
|
||||
client!!.room
|
||||
.getAccountData(roomId, FullyReadEventContent::class)
|
||||
.map { content -> ReadMarkerState.Ready(content?.eventId?.full) as ReadMarkerState }
|
||||
}
|
||||
val readMarkerState by readMarkerFlow.collectAsState(initial = ReadMarkerState.Loading)
|
||||
val fullyReadEventId = (readMarkerState as? ReadMarkerState.Ready)?.eventId
|
||||
|
||||
val currentUserId = remember(client!!) { client!!.userId.toString() }
|
||||
val timelineItems = remember(events, currentUserId, fullyReadEventId) {
|
||||
buildTimelineItems(
|
||||
events = events,
|
||||
currentUserId = currentUserId,
|
||||
fullyReadEventId = fullyReadEventId,
|
||||
)
|
||||
}
|
||||
val latestTimelineKey = remember(timelineItems) {
|
||||
timelineItems.lastOrNull()?.latestKey()
|
||||
}
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
var didInitialScroll by remember(roomId) { mutableStateOf(false) }
|
||||
var lastAutoScrollTimelineKey by remember(roomId) { mutableStateOf<String?>(null) }
|
||||
|
||||
val isNearBottom by remember(timelineItems, listState) {
|
||||
derivedStateOf {
|
||||
val totalItems = listState.layoutInfo.totalItemsCount
|
||||
if (totalItems == 0) return@derivedStateOf true
|
||||
|
||||
val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
|
||||
?: return@derivedStateOf false
|
||||
lastVisibleIndex >= totalItems - 1 - TIMELINE_BOTTOM_AUTOSCROLL_THRESHOLD
|
||||
}
|
||||
}
|
||||
|
||||
val shouldLoadOlder by remember(listState, didInitialScroll) {
|
||||
derivedStateOf {
|
||||
didInitialScroll &&
|
||||
listState.layoutInfo.totalItemsCount > 0 &&
|
||||
listState.firstVisibleItemIndex <= TIMELINE_TOP_PREFETCH_THRESHOLD
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier) {
|
||||
var message by remember { mutableStateOf("") }
|
||||
|
||||
LazyColumn(Modifier.weight(1f), state = listState, reverseLayout = true) {
|
||||
items(timelineItems, key = { it.key }) { item ->
|
||||
TimelineItem(item)
|
||||
LaunchedEffect(timelineItems, readMarkerState, events.size, requestedLimit) {
|
||||
if (didInitialScroll || timelineItems.isEmpty()) return@LaunchedEffect
|
||||
if (readMarkerState is ReadMarkerState.Loading) return@LaunchedEffect
|
||||
|
||||
val readMarkerEventId = (readMarkerState as ReadMarkerState.Ready).eventId
|
||||
val unreadDividerIndex = timelineItems.indexOfFirst { it is UnreadDividerItem }
|
||||
if (unreadDividerIndex >= 0) {
|
||||
listState.scrollToItem(unreadDividerIndex)
|
||||
didInitialScroll = true
|
||||
lastAutoScrollTimelineKey = latestTimelineKey
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val readMarkerIsLoaded = readMarkerEventId != null &&
|
||||
events.any { it.eventId.full == readMarkerEventId }
|
||||
val shouldSearchOlderForUnreadMarker = readMarkerEventId != null &&
|
||||
!readMarkerIsLoaded &&
|
||||
events.size >= requestedLimit &&
|
||||
requestedLimit < MAX_INITIAL_UNREAD_SEARCH_LIMIT
|
||||
|
||||
if (shouldSearchOlderForUnreadMarker) {
|
||||
requestedLimit = (requestedLimit + TIMELINE_PAGE_SIZE)
|
||||
.coerceAtMost(MAX_INITIAL_UNREAD_SEARCH_LIMIT)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
listState.scrollToItem(timelineItems.lastIndex)
|
||||
didInitialScroll = true
|
||||
lastAutoScrollTimelineKey = latestTimelineKey
|
||||
}
|
||||
|
||||
LaunchedEffect(shouldLoadOlder, events.size, requestedLimit) {
|
||||
if (!shouldLoadOlder) return@LaunchedEffect
|
||||
if (events.size < requestedLimit) return@LaunchedEffect
|
||||
|
||||
requestedLimit += TIMELINE_PAGE_SIZE
|
||||
}
|
||||
|
||||
LaunchedEffect(latestTimelineKey) {
|
||||
val currentLatestKey = latestTimelineKey ?: return@LaunchedEffect
|
||||
val previousLatestKey = lastAutoScrollTimelineKey
|
||||
|
||||
if (!didInitialScroll) {
|
||||
lastAutoScrollTimelineKey = currentLatestKey
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (previousLatestKey != null && previousLatestKey != currentLatestKey && isNearBottom) {
|
||||
listState.animateScrollToItem(timelineItems.lastIndex)
|
||||
}
|
||||
|
||||
lastAutoScrollTimelineKey = currentLatestKey
|
||||
}
|
||||
|
||||
val density = LocalDensity.current
|
||||
BoxWithConstraints(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.clipToBounds()
|
||||
) {
|
||||
val listContainerHeightPx = with(density) { maxHeight.roundToPx() }
|
||||
val stickyAvatar by remember(timelineItems, listState, density, listContainerHeightPx) {
|
||||
derivedStateOf {
|
||||
calculateStickyAvatarState(
|
||||
timelineItems = timelineItems,
|
||||
layoutInfo = listState.layoutInfo,
|
||||
avatarSizePx = with(density) { TIMELINE_AVATAR_SIZE.roundToPx() },
|
||||
viewportBottomLimitPx = listContainerHeightPx,
|
||||
)
|
||||
}
|
||||
}
|
||||
LazyColumn(Modifier.fillMaxSize(), state = listState) {
|
||||
for (timelineItem in timelineItems) {
|
||||
when (timelineItem) {
|
||||
is DateDividerItem -> stickyHeader(key = timelineItem.key) {
|
||||
DateDivider(timelineItem)
|
||||
}
|
||||
|
||||
else -> item(key = timelineItem.key) {
|
||||
TimelineItem(
|
||||
item = timelineItem,
|
||||
pinnedAvatarGroupKey = stickyAvatar?.groupKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (timelineItems.isEmpty()) {
|
||||
@@ -208,17 +354,46 @@ fun Room(modifier: Modifier = Modifier, rid: String) {
|
||||
}
|
||||
}
|
||||
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
stickyAvatar?.let { avatarState ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset {
|
||||
IntOffset(
|
||||
x = with(density) { MESSAGE_FIELD_TOP_PADDING.roundToPx() },
|
||||
y = avatarState.yPx,
|
||||
)
|
||||
}
|
||||
.zIndex(1f),
|
||||
) {
|
||||
AvatarPlaceholder(avatarState.senderLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.clearFocusOnKeyboardDismiss()
|
||||
.padding(MESSAGE_FIELD_TOP_PADDING)
|
||||
.height(MESSAGE_FIELD_SIZE)
|
||||
.weight(1f),
|
||||
shape = CircleShape,
|
||||
value = message,
|
||||
onValueChange = { message = it },
|
||||
)
|
||||
|
||||
AnimatedVisibility(message.isNotBlank()) {
|
||||
IconButton(
|
||||
modifier = Modifier
|
||||
.size(MESSAGE_FIELD_SIZE)
|
||||
.background(MaterialTheme.colorScheme.primary, CircleShape),
|
||||
enabled = message.isNotBlank(),
|
||||
content = { Icon(if (message.isBlank()) Iconsax.Outline.Send2 else Iconsax.Filled.Send2, contentDescription = "Send") },
|
||||
content = {
|
||||
Icon(Iconsax.Filled.Send2,
|
||||
contentDescription = "Send",
|
||||
tint = MaterialTheme.colorScheme.inversePrimary
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
val payload = message.trim()
|
||||
if (payload.isBlank()) return@IconButton
|
||||
@@ -233,6 +408,12 @@ fun Room(modifier: Modifier = Modifier, rid: String) {
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface ReadMarkerState {
|
||||
data object Loading : ReadMarkerState
|
||||
data class Ready(val eventId: String?) : ReadMarkerState
|
||||
}
|
||||
|
||||
private sealed interface TimelineUiItem {
|
||||
@@ -244,18 +425,33 @@ private data class DateDividerItem(
|
||||
val label: String,
|
||||
) : TimelineUiItem
|
||||
|
||||
private data class MessageTimelineItem(
|
||||
private data class UnreadDividerItem(
|
||||
override val key: String,
|
||||
) : TimelineUiItem
|
||||
|
||||
private data class MessageGroupItem(
|
||||
override val key: String,
|
||||
val senderId: String?,
|
||||
val senderLabel: String,
|
||||
val isOwn: Boolean,
|
||||
val messages: List<MessageTimelineItem>,
|
||||
) : TimelineUiItem
|
||||
|
||||
private data class MessageTimelineItem(
|
||||
val key: String,
|
||||
val content: RoomMessageEventContent,
|
||||
val senderId: String?,
|
||||
val senderLabel: String,
|
||||
val timeLabel: String?,
|
||||
val timestampMs: Long?,
|
||||
val isOwn: Boolean,
|
||||
val showSender: Boolean,
|
||||
val showAvatar: Boolean,
|
||||
val showTimestamp: Boolean,
|
||||
) : TimelineUiItem
|
||||
)
|
||||
|
||||
private data class StickyAvatarState(
|
||||
val groupKey: String,
|
||||
val senderLabel: String,
|
||||
val yPx: Int,
|
||||
)
|
||||
|
||||
private data class SystemTimelineItem(
|
||||
override val key: String,
|
||||
@@ -265,6 +461,7 @@ private data class SystemTimelineItem(
|
||||
private fun buildTimelineItems(
|
||||
events: List<TimelineEvent>,
|
||||
currentUserId: String,
|
||||
fullyReadEventId: String?,
|
||||
): List<TimelineUiItem> {
|
||||
if (events.isEmpty()) return emptyList()
|
||||
|
||||
@@ -272,7 +469,9 @@ private fun buildTimelineItems(
|
||||
val items = mutableListOf<TimelineUiItem>()
|
||||
|
||||
var previousDayStartMs: Long? = null
|
||||
var lastMessageIndex: Int? = null
|
||||
var lastMessageGroupIndex: Int? = null
|
||||
var insertUnreadDividerBeforeNextMessage = false
|
||||
var unreadDividerInserted = false
|
||||
|
||||
for (event in chronologicalEvents) {
|
||||
val content = event.content?.getOrNull()
|
||||
@@ -285,7 +484,18 @@ private fun buildTimelineItems(
|
||||
label = formatDateDividerLabel(dayStartMs),
|
||||
)
|
||||
previousDayStartMs = dayStartMs
|
||||
lastMessageIndex = null
|
||||
lastMessageGroupIndex = null
|
||||
}
|
||||
|
||||
if (
|
||||
content is RoomMessageEventContent &&
|
||||
insertUnreadDividerBeforeNextMessage &&
|
||||
!unreadDividerInserted
|
||||
) {
|
||||
items += UnreadDividerItem(key = "unread:$fullyReadEventId")
|
||||
unreadDividerInserted = true
|
||||
insertUnreadDividerBeforeNextMessage = false
|
||||
lastMessageGroupIndex = null
|
||||
}
|
||||
|
||||
when {
|
||||
@@ -294,7 +504,7 @@ private fun buildTimelineItems(
|
||||
key = "event:${event.eventId.full}",
|
||||
text = "Not decrypted",
|
||||
)
|
||||
lastMessageIndex = null
|
||||
lastMessageGroupIndex = null
|
||||
}
|
||||
|
||||
content is RoomMessageEventContent -> {
|
||||
@@ -303,22 +513,7 @@ private fun buildTimelineItems(
|
||||
val senderLabel = event.extractSenderDisplayName().takeUnless { it.isNullOrBlank() }
|
||||
?: senderId.toSenderLabel()
|
||||
val timeLabel = timestampMs?.let(::formatTimeLabel)
|
||||
|
||||
val previousMessage = lastMessageIndex
|
||||
?.let { items.getOrNull(it) as? MessageTimelineItem }
|
||||
val groupedWithPrevious = previousMessage != null &&
|
||||
previousMessage.senderId != null &&
|
||||
previousMessage.senderId == senderId &&
|
||||
previousMessage.isOwn == isOwn &&
|
||||
previousMessage.timestampMs != null &&
|
||||
timestampMs != null &&
|
||||
timestampMs - previousMessage.timestampMs <= MESSAGE_GROUP_WINDOW_MS
|
||||
|
||||
if (groupedWithPrevious && previousMessage != null && lastMessageIndex != null) {
|
||||
items[lastMessageIndex] = previousMessage.copy(showTimestamp = false)
|
||||
}
|
||||
|
||||
items += MessageTimelineItem(
|
||||
val message = MessageTimelineItem(
|
||||
key = "event:${event.eventId.full}",
|
||||
content = content,
|
||||
senderId = senderId,
|
||||
@@ -326,11 +521,33 @@ private fun buildTimelineItems(
|
||||
timeLabel = timeLabel,
|
||||
timestampMs = timestampMs,
|
||||
isOwn = isOwn,
|
||||
showSender = !isOwn && !groupedWithPrevious,
|
||||
showAvatar = !isOwn && !groupedWithPrevious,
|
||||
showTimestamp = true,
|
||||
)
|
||||
lastMessageIndex = items.lastIndex
|
||||
|
||||
val previousGroup = lastMessageGroupIndex
|
||||
?.let { items.getOrNull(it) as? MessageGroupItem }
|
||||
val previousMessage = previousGroup?.messages?.lastOrNull()
|
||||
val groupedWithPrevious = previousGroup != null &&
|
||||
previousGroup.senderId != null &&
|
||||
previousGroup.senderId == senderId &&
|
||||
previousGroup.isOwn == isOwn &&
|
||||
previousMessage?.timestampMs != null &&
|
||||
timestampMs != null &&
|
||||
timestampMs - previousMessage.timestampMs <= MESSAGE_GROUP_WINDOW_MS
|
||||
|
||||
if (groupedWithPrevious) {
|
||||
items[lastMessageGroupIndex] = previousGroup.copy(
|
||||
messages = previousGroup.messages + message,
|
||||
)
|
||||
} else {
|
||||
items += MessageGroupItem(
|
||||
key = "group:${event.eventId.full}",
|
||||
senderId = senderId,
|
||||
senderLabel = senderLabel,
|
||||
isOwn = isOwn,
|
||||
messages = listOf(message),
|
||||
)
|
||||
lastMessageGroupIndex = items.lastIndex
|
||||
}
|
||||
}
|
||||
|
||||
event.event is ClientEvent.RoomEvent.StateEvent -> {
|
||||
@@ -338,7 +555,7 @@ private fun buildTimelineItems(
|
||||
key = "event:${event.eventId.full}",
|
||||
text = stateEventLabel(content),
|
||||
)
|
||||
lastMessageIndex = null
|
||||
lastMessageGroupIndex = null
|
||||
}
|
||||
|
||||
else -> {
|
||||
@@ -346,34 +563,86 @@ private fun buildTimelineItems(
|
||||
key = "event:${event.eventId.full}",
|
||||
text = content.toString(),
|
||||
)
|
||||
lastMessageIndex = null
|
||||
}
|
||||
lastMessageGroupIndex = null
|
||||
}
|
||||
}
|
||||
|
||||
return items.asReversed()
|
||||
if (!unreadDividerInserted && fullyReadEventId != null && event.eventId.full == fullyReadEventId) {
|
||||
insertUnreadDividerBeforeNextMessage = true
|
||||
lastMessageGroupIndex = null
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimelineItem(item: TimelineUiItem) {
|
||||
private fun TimelineItem(
|
||||
item: TimelineUiItem,
|
||||
pinnedAvatarGroupKey: String?,
|
||||
) {
|
||||
when (item) {
|
||||
is DateDividerItem -> DateDivider(item)
|
||||
is MessageTimelineItem -> MessageRow(item)
|
||||
is UnreadDividerItem -> UnreadDivider()
|
||||
is MessageGroupItem -> MessageGroupRow(
|
||||
group = item,
|
||||
hideInlineAvatar = pinnedAvatarGroupKey == item.key,
|
||||
)
|
||||
is SystemTimelineItem -> SystemEventRow(item)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DateDivider(item: DateDividerItem) {
|
||||
Text(
|
||||
text = item.label,
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = item.label,
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.8f),
|
||||
shape = RoundedCornerShape(50),
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UnreadDivider() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(1.dp)
|
||||
.weight(1f)
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.45f)),
|
||||
)
|
||||
Text(
|
||||
text = "Unread messages",
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(1.dp)
|
||||
.weight(1f)
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.45f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -390,36 +659,64 @@ private fun SystemEventRow(item: SystemTimelineItem) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageRow(item: MessageTimelineItem) {
|
||||
val bubbleColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
val bubbleTextColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
private fun MessageGroupRow(
|
||||
group: MessageGroupItem,
|
||||
hideInlineAvatar: Boolean,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
contentAlignment = if (item.isOwn) Alignment.CenterEnd else Alignment.CenterStart,
|
||||
contentAlignment = if (group.isOwn) Alignment.CenterEnd else Alignment.CenterStart,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = if (item.isOwn) Arrangement.End else Arrangement.Start,
|
||||
horizontalArrangement = if (group.isOwn) Arrangement.End else Arrangement.Start,
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
if (!item.isOwn) {
|
||||
if (item.showAvatar) {
|
||||
AvatarPlaceholder(item.senderLabel)
|
||||
if (!group.isOwn) {
|
||||
if (hideInlineAvatar) {
|
||||
Spacer(Modifier.size(TIMELINE_AVATAR_SIZE))
|
||||
} else {
|
||||
Spacer(Modifier.width(32.dp))
|
||||
AvatarPlaceholder(group.senderLabel)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.widthIn(max = 320.dp),
|
||||
horizontalAlignment = if (group.isOwn) Alignment.End else Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
for ((index, message) in group.messages.withIndex()) {
|
||||
MessageBubble(
|
||||
item = message,
|
||||
showSender = !group.isOwn && index == 0,
|
||||
showTimestamp = index == group.messages.lastIndex,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageBubble(
|
||||
item: MessageTimelineItem,
|
||||
showSender: Boolean,
|
||||
showTimestamp: Boolean,
|
||||
) {
|
||||
val bubbleColor = if (item.senderId != client!!.userId.full)
|
||||
MaterialTheme.colorScheme.surfaceContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
val bubbleTextColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 320.dp)
|
||||
.background(bubbleColor, RoundedCornerShape(18.dp))
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
) {
|
||||
if (item.showSender) {
|
||||
if (showSender) {
|
||||
Text(
|
||||
text = item.senderLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
@@ -434,7 +731,7 @@ private fun MessageRow(item: MessageTimelineItem) {
|
||||
textColor = bubbleTextColor,
|
||||
)
|
||||
|
||||
if (item.showTimestamp && item.timeLabel != null) {
|
||||
if (showTimestamp && item.timeLabel != null) {
|
||||
Text(
|
||||
text = item.timeLabel,
|
||||
modifier = Modifier
|
||||
@@ -445,8 +742,6 @@ private fun MessageRow(item: MessageTimelineItem) {
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -490,6 +785,49 @@ private fun MessageBody(
|
||||
}
|
||||
}
|
||||
|
||||
private fun TimelineUiItem.latestKey(): String {
|
||||
return when (this) {
|
||||
is MessageGroupItem -> messages.lastOrNull()?.key ?: key
|
||||
else -> key
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateStickyAvatarState(
|
||||
timelineItems: List<TimelineUiItem>,
|
||||
layoutInfo: androidx.compose.foundation.lazy.LazyListLayoutInfo,
|
||||
avatarSizePx: Int,
|
||||
viewportBottomLimitPx: Int,
|
||||
): StickyAvatarState? {
|
||||
val viewportBottom = if (viewportBottomLimitPx > 0) {
|
||||
layoutInfo.viewportEndOffset.coerceAtMost(viewportBottomLimitPx)
|
||||
} else {
|
||||
layoutInfo.viewportEndOffset
|
||||
}
|
||||
val viewportTop = layoutInfo.viewportStartOffset
|
||||
val pinnedAvatarTop = viewportBottom - avatarSizePx
|
||||
|
||||
return layoutInfo.visibleItemsInfo
|
||||
.mapNotNull { visibleItem ->
|
||||
val group = timelineItems.getOrNull(visibleItem.index) as? MessageGroupItem
|
||||
?: return@mapNotNull null
|
||||
if (group.isOwn) return@mapNotNull null
|
||||
|
||||
val groupTop = visibleItem.offset
|
||||
val groupBottom = visibleItem.offset + visibleItem.size
|
||||
val normalAvatarTop = groupBottom - avatarSizePx
|
||||
val isVisible = groupBottom > viewportTop && groupTop < viewportBottom
|
||||
val shouldPin = isVisible && normalAvatarTop > pinnedAvatarTop
|
||||
if (!shouldPin) return@mapNotNull null
|
||||
|
||||
StickyAvatarState(
|
||||
groupKey = group.key,
|
||||
senderLabel = group.senderLabel,
|
||||
yPx = pinnedAvatarTop.coerceAtLeast(groupTop),
|
||||
)
|
||||
}
|
||||
.lastOrNull()
|
||||
}
|
||||
|
||||
private fun stateEventLabel(content: Any): String {
|
||||
return when (content) {
|
||||
is AvatarEventContent -> "Avatar changed"
|
||||
@@ -1410,3 +1748,28 @@ private fun wrapHtml(
|
||||
private fun colorToCss(color: Color): String {
|
||||
return String.format("#%06X", 0xFFFFFF and color.toArgb())
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
fun Modifier.clearFocusOnKeyboardDismiss(): Modifier = composed {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
var keyboardAppearedSinceLastFocused by remember { mutableStateOf(false) }
|
||||
if (isFocused) {
|
||||
val imeIsVisible = WindowInsets.isImeVisible
|
||||
val focusManager = LocalFocusManager.current
|
||||
LaunchedEffect(imeIsVisible) {
|
||||
if (imeIsVisible) {
|
||||
keyboardAppearedSinceLastFocused = true
|
||||
} else if (keyboardAppearedSinceLastFocused) {
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
onFocusEvent {
|
||||
if (isFocused != it.isFocused) {
|
||||
isFocused = it.isFocused
|
||||
if (isFocused) {
|
||||
keyboardAppearedSinceLastFocused = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ navigationCompose = "2.9.7"
|
||||
room = "2.6.1"
|
||||
splittiesFunPackAndroidBase = "3.0.0"
|
||||
trixnityClient = "5.2.0"
|
||||
uiUnit = "1.10.6"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -53,6 +54,7 @@ trixnity-client = { module = "de.connect2x.trixnity:trixnity-client", version.re
|
||||
trixnity-client-media-okio = { module = "de.connect2x.trixnity:trixnity-client-media-okio", version.ref = "trixnityClient" }
|
||||
trixnity-client-repository-room = { module = "de.connect2x.trixnity:trixnity-client-repository-room", version.ref = "trixnityClient" }
|
||||
trixnity-client-cryptodriver-vodozemac = { module = "de.connect2x.trixnity:trixnity-client-cryptodriver-vodozemac", version.ref = "trixnityClient" }
|
||||
androidx-compose-ui-unit = { group = "androidx.compose.ui", name = "ui-unit", version.ref = "uiUnit" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
Reference in New Issue
Block a user