Compare commits

7 Commits

Author SHA1 Message Date
Sweetbread d05c3ce367 fix: close message field after closing a keyboard,
change design
2026-04-23 21:19:19 +03:00
Sweetbread 1ab649d6a1 refactor: Avatar function 2026-04-23 21:19:19 +03:00
Sweetbread b3b2796d25 Fix message loading 2026-04-23 21:19:19 +03:00
Sweetbread c83b3e455f New TopBar 2026-04-23 21:19:19 +03:00
Sweetbread bd5f4dd263 fixup! Message bubbles 2026-04-23 21:19:19 +03:00
Sweetbread d28ae0a48c Sticky avatar 2026-04-23 21:19:19 +03:00
Sweetbread f0e6ba189e Sticky date 2026-04-23 21:19:19 +03:00
5 changed files with 744 additions and 133 deletions
+6
View File
@@ -1,3 +1,8 @@
/*
* Created by sweetbread
* Copyright (c) 2026. All rights reserved.
*/
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
/* /*
@@ -58,6 +63,7 @@ dependencies {
implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3) implementation(libs.androidx.material3)
implementation(libs.androidx.compose.ui.unit)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
@@ -5,41 +5,70 @@
package ru.risdeveau.pixeldragon.ui.activity package ru.risdeveau.pixeldragon.ui.activity
import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge 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.fillMaxSize
import androidx.compose.foundation.layout.padding 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.CenterAlignedTopAppBar
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults.topAppBarColors import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import de.connect2x.trixnity.client.CryptoDriverModule 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.MatrixClient
import de.connect2x.trixnity.client.create import de.connect2x.trixnity.client.create
import de.connect2x.trixnity.client.cryptodriver.vodozemac.vodozemac 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.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.R
import ru.risdeveau.pixeldragon.client 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.Room
import ru.risdeveau.pixeldragon.ui.layout.RoomList import ru.risdeveau.pixeldragon.ui.layout.RoomList
import ru.risdeveau.pixeldragon.ui.theme.PixelDragonTheme import ru.risdeveau.pixeldragon.ui.theme.PixelDragonTheme
@@ -47,11 +76,10 @@ import ru.risdeveau.pixeldragon.util.getMediaStore
import ru.risdeveau.pixeldragon.util.getRoomStore import ru.risdeveau.pixeldragon.util.getRoomStore
import splitties.activities.start import splitties.activities.start
import splitties.init.appCtx import splitties.init.appCtx
import splitties.resources.str import de.connect2x.trixnity.client.store.Room as TrixnityRoom
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -59,34 +87,28 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
PixelDragonTheme { PixelDragonTheme {
var isClientReady by remember { mutableStateOf(false) } var isClientReady by remember { mutableStateOf(false) }
val syncState by remember { mutableStateOf(SyncState.STOPPED) }
if (!isClientReady) { if (!isClientReady || client == null) {
CircularProgressIndicator() Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
} else { } else {
val navController = rememberNavController()
val syncState by client!!.api.sync.currentSyncState
.collectAsState(initial = SyncState.STOPPED)
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
CenterAlignedTopAppBar( PixelDragonTopBar(
colors = topAppBarColors( navController = navController,
containerColor = MaterialTheme.colorScheme.primaryContainer, syncState = syncState,
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")
}
}
) )
}, },
) { innerPadding -> ) { innerPadding ->
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "rooms") { NavHost(navController = navController, startDestination = "rooms") {
composable("rooms") { composable("rooms") {
RoomList(Modifier.padding(innerPadding), navController) RoomList(Modifier.padding(innerPadding), navController)
@@ -102,7 +124,7 @@ class MainActivity : ComponentActivity() {
composable( composable(
"space/{rid}", "space/{rid}",
arguments = listOf(navArgument("rid") { type = NavType.StringType }) arguments = listOf(navArgument("rid") { type = NavType.StringType })
) { navBackStackEntry -> ) {
Text( Text(
modifier = Modifier.padding(innerPadding), modifier = Modifier.padding(innerPadding),
text = "Not implemented" 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.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import android.widget.TextView import android.widget.TextView
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.fillMaxWidth
import androidx.compose.foundation.layout.height 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -51,6 +57,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -58,16 +65,22 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.alpha 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.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.zIndex
import androidx.core.text.HtmlCompat import androidx.core.text.HtmlCompat
import androidx.core.widget.TextViewCompat import androidx.core.widget.TextViewCompat
import de.connect2x.trixnity.client.room 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.client.store.eventId
import de.connect2x.trixnity.core.model.RoomId import de.connect2x.trixnity.core.model.RoomId
import de.connect2x.trixnity.core.model.events.ClientEvent 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.AvatarEventContent
import de.connect2x.trixnity.core.model.events.m.room.CanonicalAliasEventContent import de.connect2x.trixnity.core.model.events.m.room.CanonicalAliasEventContent
import de.connect2x.trixnity.core.model.events.m.room.HistoryVisibilityEventContent 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 de.connect2x.trixnity.core.model.events.m.room.TopicEventContent
import io.github.rabehx.iconsax.Iconsax import io.github.rabehx.iconsax.Iconsax
import io.github.rabehx.iconsax.filled.Send2 import io.github.rabehx.iconsax.filled.Send2
import io.github.rabehx.iconsax.outline.Send2
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -95,6 +108,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.jsoup.nodes.Element 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 WEBVIEW_HEIGHT_TITLE_PREFIX = "pd-height:"
private const val MESSAGE_GROUP_WINDOW_MS = 5 * 60 * 1000L 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( private val simpleHtmlTags = setOf(
"a", "b", "blockquote", "br", "code", "del", "div", "em", "h1", "h2", "a", "b", "blockquote", "br", "code", "del", "div", "em", "h1", "h2",
@@ -170,13 +191,18 @@ private val headingMetrics = mapOf(
"h6" to TextBlockMetrics(22f, 8f, 10f), "h6" to TextBlockMetrics(22f, 8f, 10f),
) )
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun Room(modifier: Modifier = Modifier, rid: String) { fun Room(modifier: Modifier = Modifier, rid: String) {
val roomId = remember(rid) { RoomId(rid) } 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 client!!.room
.getLastTimelineEvents(roomId) .getLastTimelineEvents(roomId)
.toFlowList(limit) .toFlowList(limit)
@@ -186,55 +212,210 @@ fun Room(modifier: Modifier = Modifier, rid: String) {
} }
} }
val events by eventsFlow.collectAsState(initial = emptyList()) val events by eventsFlow.collectAsState(initial = emptyList())
val currentUserId = remember { client!!.userId.toString() }
val timelineItems = remember(events, currentUserId) { val readMarkerFlow = remember(roomId, client!!) {
buildTimelineItems(events = events, currentUserId = currentUserId) 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() 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) { Column(modifier) {
var message by remember { mutableStateOf("") } var message by remember { mutableStateOf("") }
LazyColumn(Modifier.weight(1f), state = listState, reverseLayout = true) { LaunchedEffect(timelineItems, readMarkerState, events.size, requestedLimit) {
items(timelineItems, key = { it.key }) { item -> if (didInitialScroll || timelineItems.isEmpty()) return@LaunchedEffect
TimelineItem(item) 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
} }
if (timelineItems.isEmpty()) { val readMarkerIsLoaded = readMarkerEventId != null &&
item { events.any { it.eventId.full == readMarkerEventId }
Text("Empty room", modifier = Modifier.padding(16.dp)) 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()) {
item {
Text("Empty room", modifier = Modifier.padding(16.dp))
}
}
}
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()) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField( OutlinedTextField(
modifier = Modifier modifier = Modifier
.padding(4.dp) .clearFocusOnKeyboardDismiss()
.padding(MESSAGE_FIELD_TOP_PADDING)
.height(MESSAGE_FIELD_SIZE)
.weight(1f), .weight(1f),
shape = CircleShape,
value = message, value = message,
onValueChange = { message = it }, onValueChange = { message = it },
) )
IconButton(
enabled = message.isNotBlank(),
content = { Icon(if (message.isBlank()) Iconsax.Outline.Send2 else Iconsax.Filled.Send2, contentDescription = "Send") },
onClick = {
val payload = message.trim()
if (payload.isBlank()) return@IconButton
CoroutineScope(Dispatchers.IO).launch { AnimatedVisibility(message.isNotBlank()) {
client!!.room.sendMessage(RoomId(rid)) { IconButton(
text(payload) modifier = Modifier
.size(MESSAGE_FIELD_SIZE)
.background(MaterialTheme.colorScheme.primary, CircleShape),
enabled = message.isNotBlank(),
content = {
Icon(Iconsax.Filled.Send2,
contentDescription = "Send",
tint = MaterialTheme.colorScheme.inversePrimary
)
},
onClick = {
val payload = message.trim()
if (payload.isBlank()) return@IconButton
CoroutineScope(Dispatchers.IO).launch {
client!!.room.sendMessage(RoomId(rid)) {
text(payload)
}
} }
message = ""
} }
message = "" )
} }
)
} }
} }
} }
private sealed interface ReadMarkerState {
data object Loading : ReadMarkerState
data class Ready(val eventId: String?) : ReadMarkerState
}
private sealed interface TimelineUiItem { private sealed interface TimelineUiItem {
val key: String val key: String
} }
@@ -244,18 +425,33 @@ private data class DateDividerItem(
val label: String, val label: String,
) : TimelineUiItem ) : TimelineUiItem
private data class MessageTimelineItem( private data class UnreadDividerItem(
override val key: String, 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 content: RoomMessageEventContent,
val senderId: String?, val senderId: String?,
val senderLabel: String, val senderLabel: String,
val timeLabel: String?, val timeLabel: String?,
val timestampMs: Long?, val timestampMs: Long?,
val isOwn: Boolean, val isOwn: Boolean,
val showSender: Boolean, )
val showAvatar: Boolean,
val showTimestamp: Boolean, private data class StickyAvatarState(
) : TimelineUiItem val groupKey: String,
val senderLabel: String,
val yPx: Int,
)
private data class SystemTimelineItem( private data class SystemTimelineItem(
override val key: String, override val key: String,
@@ -265,6 +461,7 @@ private data class SystemTimelineItem(
private fun buildTimelineItems( private fun buildTimelineItems(
events: List<TimelineEvent>, events: List<TimelineEvent>,
currentUserId: String, currentUserId: String,
fullyReadEventId: String?,
): List<TimelineUiItem> { ): List<TimelineUiItem> {
if (events.isEmpty()) return emptyList() if (events.isEmpty()) return emptyList()
@@ -272,7 +469,9 @@ private fun buildTimelineItems(
val items = mutableListOf<TimelineUiItem>() val items = mutableListOf<TimelineUiItem>()
var previousDayStartMs: Long? = null var previousDayStartMs: Long? = null
var lastMessageIndex: Int? = null var lastMessageGroupIndex: Int? = null
var insertUnreadDividerBeforeNextMessage = false
var unreadDividerInserted = false
for (event in chronologicalEvents) { for (event in chronologicalEvents) {
val content = event.content?.getOrNull() val content = event.content?.getOrNull()
@@ -285,7 +484,18 @@ private fun buildTimelineItems(
label = formatDateDividerLabel(dayStartMs), label = formatDateDividerLabel(dayStartMs),
) )
previousDayStartMs = 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 { when {
@@ -294,7 +504,7 @@ private fun buildTimelineItems(
key = "event:${event.eventId.full}", key = "event:${event.eventId.full}",
text = "Not decrypted", text = "Not decrypted",
) )
lastMessageIndex = null lastMessageGroupIndex = null
} }
content is RoomMessageEventContent -> { content is RoomMessageEventContent -> {
@@ -303,22 +513,7 @@ private fun buildTimelineItems(
val senderLabel = event.extractSenderDisplayName().takeUnless { it.isNullOrBlank() } val senderLabel = event.extractSenderDisplayName().takeUnless { it.isNullOrBlank() }
?: senderId.toSenderLabel() ?: senderId.toSenderLabel()
val timeLabel = timestampMs?.let(::formatTimeLabel) val timeLabel = timestampMs?.let(::formatTimeLabel)
val message = MessageTimelineItem(
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(
key = "event:${event.eventId.full}", key = "event:${event.eventId.full}",
content = content, content = content,
senderId = senderId, senderId = senderId,
@@ -326,11 +521,33 @@ private fun buildTimelineItems(
timeLabel = timeLabel, timeLabel = timeLabel,
timestampMs = timestampMs, timestampMs = timestampMs,
isOwn = isOwn, 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 -> { event.event is ClientEvent.RoomEvent.StateEvent -> {
@@ -338,7 +555,7 @@ private fun buildTimelineItems(
key = "event:${event.eventId.full}", key = "event:${event.eventId.full}",
text = stateEventLabel(content), text = stateEventLabel(content),
) )
lastMessageIndex = null lastMessageGroupIndex = null
} }
else -> { else -> {
@@ -346,34 +563,86 @@ private fun buildTimelineItems(
key = "event:${event.eventId.full}", key = "event:${event.eventId.full}",
text = content.toString(), text = content.toString(),
) )
lastMessageIndex = null lastMessageGroupIndex = null
} }
} }
if (!unreadDividerInserted && fullyReadEventId != null && event.eventId.full == fullyReadEventId) {
insertUnreadDividerBeforeNextMessage = true
lastMessageGroupIndex = null
}
} }
return items.asReversed() return items
} }
@Composable @Composable
private fun TimelineItem(item: TimelineUiItem) { private fun TimelineItem(
item: TimelineUiItem,
pinnedAvatarGroupKey: String?,
) {
when (item) { when (item) {
is DateDividerItem -> DateDivider(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) is SystemTimelineItem -> SystemEventRow(item)
} }
} }
@Composable @Composable
private fun DateDivider(item: DateDividerItem) { private fun DateDivider(item: DateDividerItem) {
Text( Box(
text = item.label,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
textAlign = TextAlign.Center, contentAlignment = Alignment.Center,
style = MaterialTheme.typography.labelSmall, ) {
color = MaterialTheme.colorScheme.onSurfaceVariant, 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 @Composable
@@ -390,58 +659,39 @@ private fun SystemEventRow(item: SystemTimelineItem) {
} }
@Composable @Composable
private fun MessageRow(item: MessageTimelineItem) { private fun MessageGroupRow(
val bubbleColor = MaterialTheme.colorScheme.surfaceVariant group: MessageGroupItem,
val bubbleTextColor = MaterialTheme.colorScheme.onSurfaceVariant hideInlineAvatar: Boolean,
) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp), .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( Row(
horizontalArrangement = if (item.isOwn) Arrangement.End else Arrangement.Start, horizontalArrangement = if (group.isOwn) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.Bottom,
) { ) {
if (!item.isOwn) { if (!group.isOwn) {
if (item.showAvatar) { if (hideInlineAvatar) {
AvatarPlaceholder(item.senderLabel) Spacer(Modifier.size(TIMELINE_AVATAR_SIZE))
} else { } else {
Spacer(Modifier.width(32.dp)) AvatarPlaceholder(group.senderLabel)
} }
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
} }
Column( Column(
modifier = Modifier modifier = Modifier.widthIn(max = 320.dp),
.widthIn(max = 320.dp) horizontalAlignment = if (group.isOwn) Alignment.End else Alignment.Start,
.background(bubbleColor, RoundedCornerShape(18.dp)) verticalArrangement = Arrangement.spacedBy(2.dp),
.padding(horizontal = 10.dp, vertical = 8.dp),
) { ) {
if (item.showSender) { for ((index, message) in group.messages.withIndex()) {
Text( MessageBubble(
text = item.senderLabel, item = message,
style = MaterialTheme.typography.labelSmall, showSender = !group.isOwn && index == 0,
fontWeight = FontWeight.SemiBold, showTimestamp = index == group.messages.lastIndex,
color = bubbleTextColor,
modifier = Modifier.padding(bottom = 4.dp),
)
}
MessageBody(
content = item.content,
textColor = bubbleTextColor,
)
if (item.showTimestamp && item.timeLabel != null) {
Text(
text = item.timeLabel,
modifier = Modifier
.align(Alignment.End)
.padding(top = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = bubbleTextColor.copy(alpha = 0.75f),
) )
} }
} }
@@ -449,6 +699,51 @@ private fun MessageRow(item: MessageTimelineItem) {
} }
} }
@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
.background(bubbleColor, RoundedCornerShape(18.dp))
.padding(horizontal = 10.dp, vertical = 8.dp),
) {
if (showSender) {
Text(
text = item.senderLabel,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = bubbleTextColor,
modifier = Modifier.padding(bottom = 4.dp),
)
}
MessageBody(
content = item.content,
textColor = bubbleTextColor,
)
if (showTimestamp && item.timeLabel != null) {
Text(
text = item.timeLabel,
modifier = Modifier
.align(Alignment.End)
.padding(top = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = bubbleTextColor.copy(alpha = 0.75f),
)
}
}
}
@Composable @Composable
private fun AvatarPlaceholder(senderLabel: String) { private fun AvatarPlaceholder(senderLabel: String) {
val initial = senderLabel.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?" val initial = senderLabel.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?"
@@ -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 { private fun stateEventLabel(content: Any): String {
return when (content) { return when (content) {
is AvatarEventContent -> "Avatar changed" is AvatarEventContent -> "Avatar changed"
@@ -943,8 +1281,8 @@ private fun ComplexHtmlRenderer(
val resolvedHeightPx = rawHeightPx.coerceAtLeast(1) val resolvedHeightPx = rawHeightPx.coerceAtLeast(1)
val currentHeightPx = measuredHeightPx ?: estimatedHeightPx val currentHeightPx = measuredHeightPx ?: estimatedHeightPx
val shouldUpdate = measuredHeightPx == null || val shouldUpdate = measuredHeightPx == null ||
resolvedHeightPx > currentHeightPx || resolvedHeightPx > currentHeightPx ||
abs(resolvedHeightPx - currentHeightPx) > updateThresholdPx abs(resolvedHeightPx - currentHeightPx) > updateThresholdPx
if (shouldUpdate) { if (shouldUpdate) {
complexHtmlHeightCache[cacheKey] = resolvedHeightPx complexHtmlHeightCache[cacheKey] = resolvedHeightPx
@@ -1000,7 +1338,7 @@ private fun ComplexHtmlRenderer(
val currentView = view ?: return val currentView = view ?: return
currentView.post { currentView.post {
val fallbackHeightPx = (currentView.contentHeight * val fallbackHeightPx = (currentView.contentHeight *
currentView.resources.displayMetrics.density).roundToInt() currentView.resources.displayMetrics.density).roundToInt()
if (fallbackHeightPx > 0) { if (fallbackHeightPx > 0) {
onHeightMeasured(fallbackHeightPx) onHeightMeasured(fallbackHeightPx)
} }
@@ -1410,3 +1748,28 @@ private fun wrapHtml(
private fun colorToCss(color: Color): String { private fun colorToCss(color: Color): String {
return String.format("#%06X", 0xFFFFFF and color.toArgb()) 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
}
}
}
}
+2
View File
@@ -17,6 +17,7 @@ navigationCompose = "2.9.7"
room = "2.6.1" room = "2.6.1"
splittiesFunPackAndroidBase = "3.0.0" splittiesFunPackAndroidBase = "3.0.0"
trixnityClient = "5.2.0" trixnityClient = "5.2.0"
uiUnit = "1.10.6"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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-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-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" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }