Compare commits

13 Commits

Author SHA1 Message Date
Sweetbread 2092de3e9a fix: round avatar 2026-05-08 20:37:45 +03:00
Sweetbread 098c9fe2aa feat: device verification 2026-05-08 07:03:27 +03:00
Sweetbread 6bf33a91c9 feat: join and create rooms 2026-05-07 06:54:43 +03:00
Sweetbread 7a5889a351 feat: Unified Push 2026-05-07 05:41:13 +03:00
Sweetbread 11ad22c818 wip: settings 2026-05-07 04:24:24 +03:00
Sweetbread 16fbd40907 fix: close message field after closing a keyboard,
change design
2026-04-23 22:26:14 +03:00
Sweetbread f2ab63887a refactor: Avatar function 2026-04-23 22:26:14 +03:00
Sweetbread 52ca540bca Fix message loading 2026-04-23 22:26:14 +03:00
Sweetbread a89b2c36a7 New TopBar 2026-04-23 22:26:14 +03:00
Sweetbread 56cb0ea39c fixup! Message bubbles 2026-04-23 22:26:14 +03:00
Sweetbread 6a8e1780d7 Sticky avatar 2026-04-23 22:26:13 +03:00
Sweetbread fc331f726e Sticky date 2026-04-23 22:13:28 +03:00
Sweetbread 06b5ce326c fixup! Message bubbles 2026-04-23 22:13:10 +03:00
15 changed files with 4429 additions and 158 deletions
+7
View File
@@ -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)
@@ -99,4 +105,5 @@ dependencies {
implementation(libs.splitties.base) // Syntax sugar
implementation(libs.jsoup) // HTML parser
implementation(libs.iconsax.compose) // Material icons
implementation(libs.unifiedpush.connector) // UnifiedPush / ntfy notifications
}
+10 -1
View File
@@ -1,13 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Created by sweetbread
~ Copyright (c) 2025. All rights reserved.
~ Copyright (c) 2026. All rights reserved.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
@@ -34,6 +35,14 @@
android:name=".ui.activity.Login"
android:exported="false"
android:theme="@style/Theme.PixelDragon" />
<service
android:name=".push.PixelDragonUnifiedPushService"
android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
</intent-filter>
</service>
</application>
</manifest>
@@ -0,0 +1,128 @@
/*
* Created by sweetbread
* Copyright (c) 2026. All rights reserved.
*/
package ru.risdeveau.pixeldragon.push
import android.content.Context
import android.os.Build
import de.connect2x.trixnity.client.MatrixClient
import de.connect2x.trixnity.clientserverapi.model.push.PusherData
import de.connect2x.trixnity.clientserverapi.model.push.SetPushers
import ru.risdeveau.pixeldragon.client
import java.util.Locale
const val PIXELDRAGON_UNIFIED_PUSH_APP_ID = "ru.risdeveau.pixeldragon.unifiedpush"
object MatrixUnifiedPushManager {
suspend fun reconcileRegisteredPusher(context: Context): Result<Boolean> {
val state = UnifiedPushSettingsStore.read(context)
if (!state.enabled || state.endpoint.isNullOrBlank()) {
return Result.success(false)
}
return registerMatrixPusher(context)
}
suspend fun registerMatrixPusher(context: Context): Result<Boolean> {
return runCatching {
val state = UnifiedPushSettingsStore.read(context)
val endpoint = state.endpoint?.takeIf { it.isNotBlank() }
?: error("UnifiedPush endpoint is not available yet")
val gatewayUrl = state.gatewayUrl?.takeIf { it.isNotBlank() }
?: error("ntfy Matrix gateway URL is not configured")
val matrixClient = client ?: error("Matrix client is not ready")
matrixClient.registerNtfyPusher(
endpoint = endpoint,
gatewayUrl = gatewayUrl,
)
UnifiedPushSettingsStore.setMatrixPusherStatus(context, registered = true)
true
}.onFailure { throwable ->
UnifiedPushSettingsStore.setMatrixPusherStatus(
context = context,
registered = false,
error = throwable.toPushError(),
)
}
}
suspend fun unregisterMatrixPusher(context: Context): Result<Boolean> {
return runCatching {
val endpoint = UnifiedPushSettingsStore.read(context).endpoint
?: return@runCatching false
val matrixClient = client ?: error("Matrix client is not ready")
matrixClient.api.push.setPushers(
SetPushers.Request.Remove(
appId = PIXELDRAGON_UNIFIED_PUSH_APP_ID,
pushkey = endpoint,
)
).getOrThrow()
UnifiedPushSettingsStore.setMatrixPusherStatus(context, registered = false)
true
}.onFailure { throwable ->
UnifiedPushSettingsStore.setMatrixPusherStatus(
context = context,
registered = false,
error = throwable.toPushError(),
)
}
}
suspend fun refreshMatrixPusherStatus(context: Context): Result<Boolean> {
return runCatching {
val endpoint = UnifiedPushSettingsStore.read(context).endpoint
?: return@runCatching false
val matrixClient = client ?: error("Matrix client is not ready")
val isRegistered = matrixClient.api.push.getPushers()
.getOrThrow()
.devices
.any { pusher ->
pusher.appId == PIXELDRAGON_UNIFIED_PUSH_APP_ID && pusher.pushkey == endpoint
}
UnifiedPushSettingsStore.setMatrixPusherStatus(context, registered = isRegistered)
isRegistered
}.onFailure { throwable ->
UnifiedPushSettingsStore.setMatrixPusherStatus(
context = context,
registered = false,
error = throwable.toPushError(),
)
}
}
}
private suspend fun MatrixClient.registerNtfyPusher(
endpoint: String,
gatewayUrl: String,
) {
api.push.setPushers(
SetPushers.Request.Set(
appId = PIXELDRAGON_UNIFIED_PUSH_APP_ID,
pushkey = endpoint,
kind = "http",
appDisplayName = "PixelDragon",
deviceDisplayName = deviceDisplayName(),
lang = Locale.getDefault().toLanguageTag(),
data = PusherData(
url = gatewayUrl,
format = "event_id_only",
),
append = true,
)
).getOrThrow()
}
private fun MatrixClient.deviceDisplayName(): String {
return listOfNotNull(
Build.MANUFACTURER.takeIf { it.isNotBlank() },
Build.MODEL.takeIf { it.isNotBlank() },
deviceId?.takeIf { it.isNotBlank() },
).joinToString(" ").ifBlank { "Android" }
}
fun Throwable.toPushError(): String {
return message?.takeIf { it.isNotBlank() } ?: this::class.simpleName ?: "Unknown error"
}
@@ -0,0 +1,74 @@
/*
* Created by sweetbread
* Copyright (c) 2026. All rights reserved.
*/
package ru.risdeveau.pixeldragon.push
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import ru.risdeveau.pixeldragon.R
import ru.risdeveau.pixeldragon.ui.activity.MainActivity
private const val MATRIX_CHANNEL_ID = "matrix_messages"
private const val MATRIX_NOTIFICATION_ID = 1001
object PixelDragonNotifier {
fun showWakeUpNotification(context: Context) {
val appContext = context.applicationContext
if (!appContext.canPostNotifications()) return
ensureChannel(appContext)
val intent = Intent(appContext, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
appContext,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(appContext, MATRIX_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle("New Matrix activity")
.setContentText("Open PixelDragon to sync and decrypt the latest messages.")
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.build()
NotificationManagerCompat.from(appContext).notify(MATRIX_NOTIFICATION_ID, notification)
}
private fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val notificationManager = context.getSystemService(NotificationManager::class.java)
val channel = NotificationChannel(
MATRIX_CHANNEL_ID,
"Matrix messages",
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Wake-up notifications from ntfy / UnifiedPush."
}
notificationManager.createNotificationChannel(channel)
}
}
fun Context.canPostNotifications(): Boolean {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
}
@@ -0,0 +1,64 @@
/*
* Created by sweetbread
* Copyright (c) 2026. All rights reserved.
*/
package ru.risdeveau.pixeldragon.push
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import org.unifiedpush.android.connector.FailedReason
import org.unifiedpush.android.connector.PushService
import org.unifiedpush.android.connector.data.PushEndpoint
import org.unifiedpush.android.connector.data.PushMessage
import ru.risdeveau.pixeldragon.client
class PixelDragonUnifiedPushService : PushService() {
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
UnifiedPushSettingsStore.setEndpoint(applicationContext, endpoint.url)
UnifiedPushSettingsStore.setLastError(applicationContext, null)
serviceScope.launch {
MatrixUnifiedPushManager.registerMatrixPusher(applicationContext)
}
}
override fun onMessage(message: PushMessage, instance: String) {
UnifiedPushSettingsStore.recordPush(applicationContext)
serviceScope.launch {
runCatching {
client?.startSync()
}
PixelDragonNotifier.showWakeUpNotification(applicationContext)
}
}
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
UnifiedPushSettingsStore.setLastError(applicationContext, reason.name)
}
override fun onTempUnavailable(instance: String) {
UnifiedPushSettingsStore.setLastError(
context = applicationContext,
error = "Distributor backend is temporarily unavailable",
)
}
override fun onUnregistered(instance: String) {
serviceScope.launch {
MatrixUnifiedPushManager.unregisterMatrixPusher(applicationContext)
UnifiedPushSettingsStore.clear(applicationContext)
}
}
override fun onDestroy() {
serviceScope.cancel()
super.onDestroy()
}
}
@@ -0,0 +1,137 @@
/*
* Created by sweetbread
* Copyright (c) 2026. All rights reserved.
*/
package ru.risdeveau.pixeldragon.push
import android.content.Context
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
private const val PREFS_NAME = "unified_push_settings"
private const val KEY_ENABLED = "enabled"
private const val KEY_ENDPOINT = "endpoint"
private const val KEY_GATEWAY_OVERRIDE = "gateway_override"
private const val KEY_LAST_ERROR = "last_error"
private const val KEY_LAST_PUSH_TS = "last_push_ts"
private const val KEY_MATRIX_PUSHER_REGISTERED = "matrix_pusher_registered"
private const val KEY_MATRIX_PUSHER_TS = "matrix_pusher_ts"
private const val KEY_MATRIX_ERROR = "matrix_error"
data class UnifiedPushSettingsState(
val enabled: Boolean = false,
val endpoint: String? = null,
val gatewayUrlOverride: String? = null,
val lastError: String? = null,
val lastPushAt: Long? = null,
val matrixPusherRegistered: Boolean = false,
val matrixPusherUpdatedAt: Long? = null,
val matrixError: String? = null,
) {
val gatewayUrl: String?
get() = gatewayUrlOverride?.takeIf { it.isNotBlank() }
?: endpoint?.toNtfyMatrixGatewayUrl()
}
object UnifiedPushSettingsStore {
private val state = MutableStateFlow(UnifiedPushSettingsState())
fun observe(context: Context): StateFlow<UnifiedPushSettingsState> {
refresh(context)
return state.asStateFlow()
}
fun refresh(context: Context) {
state.value = read(context)
}
fun setEnabled(context: Context, enabled: Boolean) {
edit(context) { putBoolean(KEY_ENABLED, enabled) }
}
fun setEndpoint(context: Context, endpoint: String?) {
edit(context) {
putNullableString(KEY_ENDPOINT, endpoint)
putBoolean(KEY_ENABLED, endpoint != null)
}
}
fun setGatewayOverride(context: Context, gatewayUrl: String?) {
edit(context) { putNullableString(KEY_GATEWAY_OVERRIDE, gatewayUrl?.trim()?.takeIf { it.isNotBlank() }) }
}
fun setLastError(context: Context, error: String?) {
edit(context) { putNullableString(KEY_LAST_ERROR, error) }
}
fun recordPush(context: Context) {
edit(context) {
putLong(KEY_LAST_PUSH_TS, System.currentTimeMillis())
remove(KEY_LAST_ERROR)
}
}
fun setMatrixPusherStatus(
context: Context,
registered: Boolean,
error: String? = null,
) {
edit(context) {
putBoolean(KEY_MATRIX_PUSHER_REGISTERED, registered)
if (registered) {
putLong(KEY_MATRIX_PUSHER_TS, System.currentTimeMillis())
remove(KEY_MATRIX_ERROR)
} else {
remove(KEY_MATRIX_PUSHER_TS)
putNullableString(KEY_MATRIX_ERROR, error)
}
}
}
fun clear(context: Context) {
prefs(context).edit().clear().apply()
refresh(context)
}
fun read(context: Context): UnifiedPushSettingsState {
val prefs = prefs(context)
return UnifiedPushSettingsState(
enabled = prefs.getBoolean(KEY_ENABLED, false),
endpoint = prefs.getString(KEY_ENDPOINT, null),
gatewayUrlOverride = prefs.getString(KEY_GATEWAY_OVERRIDE, null),
lastError = prefs.getString(KEY_LAST_ERROR, null),
lastPushAt = prefs.getLongOrNull(KEY_LAST_PUSH_TS),
matrixPusherRegistered = prefs.getBoolean(KEY_MATRIX_PUSHER_REGISTERED, false),
matrixPusherUpdatedAt = prefs.getLongOrNull(KEY_MATRIX_PUSHER_TS),
matrixError = prefs.getString(KEY_MATRIX_ERROR, null),
)
}
private fun edit(context: Context, block: android.content.SharedPreferences.Editor.() -> Unit) {
prefs(context).edit().apply(block).apply()
refresh(context)
}
private fun prefs(context: Context) =
context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
private fun android.content.SharedPreferences.getLongOrNull(key: String): Long? {
return if (contains(key)) getLong(key, 0L) else null
}
private fun android.content.SharedPreferences.Editor.putNullableString(
key: String,
value: String?,
) {
if (value == null) remove(key) else putString(key, value)
}
fun String.toNtfyMatrixGatewayUrl(): String? {
val uri = android.net.Uri.parse(this)
val scheme = uri.scheme ?: return null
val authority = uri.authority ?: return null
return "$scheme://$authority/_matrix/push/v1/notify"
}
@@ -5,53 +5,95 @@
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.FloatingActionButton
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.navigation
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.push.MatrixUnifiedPushManager
import ru.risdeveau.pixeldragon.ui.item.Avatar
import ru.risdeveau.pixeldragon.ui.layout.AccountSettingsScreen
import ru.risdeveau.pixeldragon.ui.layout.AppearanceSettingsScreen
import ru.risdeveau.pixeldragon.ui.layout.CreateRoomScreen
import ru.risdeveau.pixeldragon.ui.layout.DevicesSettingsScreen
import ru.risdeveau.pixeldragon.ui.layout.JoinRoomScreen
import ru.risdeveau.pixeldragon.ui.layout.NotificationsSettingsScreen
import ru.risdeveau.pixeldragon.ui.layout.Room
import ru.risdeveau.pixeldragon.ui.layout.RoomActionsRoutes
import ru.risdeveau.pixeldragon.ui.layout.RoomActionsSheet
import ru.risdeveau.pixeldragon.ui.layout.RoomList
import ru.risdeveau.pixeldragon.ui.layout.SettingsMainScreen
import ru.risdeveau.pixeldragon.ui.layout.SettingsRoutes
import ru.risdeveau.pixeldragon.ui.layout.settingsTitleForRoute
import ru.risdeveau.pixeldragon.ui.theme.PixelDragonTheme
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,36 +101,49 @@ class MainActivity : ComponentActivity() {
setContent {
PixelDragonTheme {
var isClientReady by remember { mutableStateOf(false) }
val syncState by remember { mutableStateOf(SyncState.STOPPED) }
if (!isClientReady) {
CircularProgressIndicator()
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)
val backStackEntry by navController.currentBackStackEntryAsState()
val route = backStackEntry?.destination?.route
var showRoomActionsSheet by remember { mutableStateOf(false) }
if (showRoomActionsSheet) {
RoomActionsSheet(
onDismiss = { showRoomActionsSheet = false },
onJoinRoom = { navController.navigate(RoomActionsRoutes.JoinRoom) },
onCreateRoom = { navController.navigate(RoomActionsRoutes.CreateRoom) },
onCreateSpace = { navController.navigate(RoomActionsRoutes.CreateSpace) },
)
}
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,
)
},
floatingActionButton = {
if (route == RoomActionsRoutes.Rooms) {
FloatingActionButton(onClick = { showRoomActionsSheet = true }) {
Text("+")
}
}
},
) { innerPadding ->
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "rooms") {
composable("rooms") {
NavHost(navController = navController, startDestination = RoomActionsRoutes.Rooms) {
composable(RoomActionsRoutes.Rooms) {
RoomList(Modifier.padding(innerPadding), navController)
}
composable(
@@ -102,12 +157,77 @@ class MainActivity : ComponentActivity() {
composable(
"space/{rid}",
arguments = listOf(navArgument("rid") { type = NavType.StringType })
) { navBackStackEntry ->
) {
Text(
modifier = Modifier.padding(innerPadding),
text = "Not implemented"
)
}
composable(RoomActionsRoutes.JoinRoom) {
JoinRoomScreen(
modifier = Modifier.padding(innerPadding),
onJoined = { roomId ->
navController.navigate("room/${roomId.full}") {
popUpTo(RoomActionsRoutes.Rooms)
}
},
)
}
composable(RoomActionsRoutes.CreateRoom) {
CreateRoomScreen(
modifier = Modifier.padding(innerPadding),
isSpace = false,
onCreated = { roomId ->
navController.navigate("room/${roomId.full}") {
popUpTo(RoomActionsRoutes.Rooms)
}
},
)
}
composable(RoomActionsRoutes.CreateSpace) {
CreateRoomScreen(
modifier = Modifier.padding(innerPadding),
isSpace = true,
onCreated = { roomId ->
navController.navigate("space/${roomId.full}") {
popUpTo(RoomActionsRoutes.Rooms)
}
},
)
}
navigation(
route = SettingsRoutes.Graph,
startDestination = SettingsRoutes.Main,
) {
composable(SettingsRoutes.Main) {
SettingsMainScreen(
modifier = Modifier.padding(innerPadding),
onCategoryClick = { category ->
navController.navigate(category.route)
},
)
}
composable(SettingsRoutes.Account) {
AccountSettingsScreen(Modifier.padding(innerPadding))
}
composable(SettingsRoutes.Devices) {
DevicesSettingsScreen(Modifier.padding(innerPadding))
}
composable(SettingsRoutes.Notifications) {
NotificationsSettingsScreen(Modifier.padding(innerPadding))
}
composable(SettingsRoutes.Appearance) {
AppearanceSettingsScreen(Modifier.padding(innerPadding))
}
}
}
}
}
@@ -129,6 +249,7 @@ class MainActivity : ComponentActivity() {
Log.i("MainActivity", "Log in as ${client!!.userId}")
client!!.startSync()
MatrixUnifiedPushManager.reconcileRegisteredPusher(appCtx)
isClientReady = true
}
}
@@ -142,3 +263,192 @@ 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 roomActionTitle = RoomActionsRoutes.titleFor(route)
val isSettingsScreen = route == SettingsRoutes.Graph || route?.startsWith("settings/") == true
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
}
}
when {
isRoomLikeScreen -> {
RoomTopBar(
room = currentRoom,
fallbackTitle = rid ?: stringResource(R.string.app_name),
onBack = { navController.popBackStack() },
)
}
roomActionTitle != null -> {
RoomTopBar(
room = null,
fallbackTitle = roomActionTitle,
onBack = { navController.popBackStack() },
)
}
isSettingsScreen -> {
RoomTopBar(
room = null,
fallbackTitle = settingsTitleForRoute(route),
onBack = { navController.popBackStack() },
)
}
else -> {
HomeTopBar(
syncState = syncState,
onAvatarClick = {
navController.navigate(SettingsRoutes.Graph) {
launchSingleTop = true
}
},
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun HomeTopBar(
syncState: SyncState,
onAvatarClick: () -> Unit,
) {
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
}
IconButton(onClick = onAvatarClick) {
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,69 @@
/*
* 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.foundation.shape.CircleShape
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.draw.clip
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,
) {
val avatarModifier = modifier.clip(CircleShape)
if (url != null) {
MXCImage(
mxcUrl = url,
modifier = avatarModifier,
contentScale = ContentScale.Crop,
contentDescription = fallbackName,
showProgress = false,
)
} else {
AvatarPlaceholder(
avatarModifier,
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,
)
}
}
@@ -8,7 +8,6 @@ package ru.risdeveau.pixeldragon.ui.item
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import io.github.rabehx.iconsax.Iconsax
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
@@ -24,16 +23,20 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import coil3.compose.rememberAsyncImagePainter
import coil3.request.ImageRequest
import kotlinx.coroutines.flow.MutableStateFlow
import de.connect2x.trixnity.client.media
import de.connect2x.trixnity.clientserverapi.model.media.FileTransferProgress
import io.github.rabehx.iconsax.Iconsax
import io.github.rabehx.iconsax.outline.Warning2
import kotlinx.coroutines.flow.MutableStateFlow
import ru.risdeveau.pixeldragon.client
import java.util.concurrent.ConcurrentHashMap
enum class ImageLoadState {
Loading, Success, Error
}
private val mxcImageByteCache = ConcurrentHashMap<String, ByteArray>()
@Composable
fun MXCImage(
mxcUrl: String,
@@ -43,16 +46,26 @@ fun MXCImage(
showProgress: Boolean = true
) {
val context = LocalContext.current
var imageLoadState by remember { mutableStateOf(ImageLoadState.Loading) }
var imageBytes by remember { mutableStateOf<ByteArray?>(null) }
val cachedBytes = remember(mxcUrl) { mxcImageByteCache[mxcUrl] }
var imageLoadState by remember(mxcUrl) {
mutableStateOf(if (cachedBytes != null) ImageLoadState.Success else ImageLoadState.Loading)
}
var imageBytes by remember(mxcUrl) { mutableStateOf(cachedBytes) }
val progressFlow = remember { MutableStateFlow<FileTransferProgress?>(null) }
val progressFlow = remember(mxcUrl) { MutableStateFlow<FileTransferProgress?>(null) }
LaunchedEffect(mxcUrl) {
if (mxcUrl.isBlank()) {
imageLoadState = ImageLoadState.Error
return@LaunchedEffect
}
mxcImageByteCache[mxcUrl]?.let { bytes ->
imageBytes = bytes
imageLoadState = ImageLoadState.Success
return@LaunchedEffect
}
imageLoadState = ImageLoadState.Loading
progressFlow.value = null
@@ -66,6 +79,7 @@ fun MXCImage(
onSuccess = { media ->
val bytes = media.toByteArray()
if (bytes != null) {
mxcImageByteCache[mxcUrl] = bytes
imageBytes = bytes
ImageLoadState.Success
} else {
@@ -87,6 +101,8 @@ fun MXCImage(
val painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(context)
.data(imageBytes)
.memoryCacheKey(mxcUrl)
.diskCacheKey(mxcUrl)
.build()
)
@@ -0,0 +1,195 @@
/*
* Created by sweetbread
* Copyright (c) 2026. All rights reserved.
*/
package ru.risdeveau.pixeldragon.ui.layout
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import de.connect2x.trixnity.clientserverapi.model.room.CreateRoom
import de.connect2x.trixnity.clientserverapi.model.room.DirectoryVisibility
import de.connect2x.trixnity.core.model.RoomAliasId
import de.connect2x.trixnity.core.model.RoomId
import de.connect2x.trixnity.core.model.events.m.room.CreateEventContent
import kotlinx.coroutines.launch
import ru.risdeveau.pixeldragon.client
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateRoomScreen(
modifier: Modifier = Modifier,
isSpace: Boolean,
onCreated: (RoomId) -> Unit,
) {
val matrixClient = client!!
val scope = rememberCoroutineScope()
var name by remember { mutableStateOf("") }
var topic by remember { mutableStateOf("") }
var alias by remember { mutableStateOf("") }
var isPublic by remember { mutableStateOf(false) }
var isCreating by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
text = if (isSpace) "New space" else "New room",
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = if (isSpace) {
"Spaces are useful for grouping related rooms."
} else {
"Create a room, then invite people or publish it in the directory."
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = name,
onValueChange = { name = it },
singleLine = true,
label = { Text(if (isSpace) "Space name" else "Room name") },
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = topic,
onValueChange = { topic = it },
minLines = 2,
label = { Text("Topic") },
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = alias,
onValueChange = { alias = it.trim() },
singleLine = true,
label = { Text("Local alias, optional") },
supportingText = {
Text("Example: pixeldragon creates #pixeldragon:${matrixClient.userId.domain}")
},
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text("Publish in room directory")
Text(
text = "People can find it in public search. Join rules are still controlled by the server.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
)
}
Switch(
checked = isPublic,
onCheckedChange = { isPublic = it },
)
}
error?.let {
Text(
text = it,
color = MaterialTheme.colorScheme.error,
)
}
Spacer(Modifier.height(4.dp))
Button(
modifier = Modifier.fillMaxWidth(),
enabled = !isCreating && name.isNotBlank(),
onClick = {
isCreating = true
error = null
scope.launch {
val aliasValue = alias.trim()
val roomAliasId = aliasValue.toRoomAliasIdOrNull(matrixClient.userId.domain)
if (aliasValue.isNotEmpty() && roomAliasId == null) {
error = "Invalid alias"
isCreating = false
return@launch
}
val result = matrixClient.api.room.createRoom(
visibility = if (isPublic) DirectoryVisibility.PUBLIC else DirectoryVisibility.PRIVATE,
roomAliasId = roomAliasId,
name = name.trim(),
topic = topic.trim().takeIf { it.isNotEmpty() },
preset = if (isPublic) CreateRoom.Request.Preset.PUBLIC else CreateRoom.Request.Preset.PRIVATE,
creationContent = if (isSpace) {
CreateEventContent(type = CreateEventContent.RoomType.Space)
} else {
null
},
)
result.fold(
onSuccess = { roomId -> onCreated(roomId) },
onFailure = { throwable ->
error = throwable.message ?: "Could not create ${if (isSpace) "space" else "room"}"
},
)
isCreating = false
}
},
) {
if (isCreating) {
CircularProgressIndicator()
} else {
Text(if (isSpace) "Create space" else "Create room")
}
}
}
}
private fun String.toRoomAliasIdOrNull(defaultDomain: String): RoomAliasId? {
val value = trim()
if (value.isEmpty()) return null
val fullAlias = when {
value.startsWith("#") && value.contains(":") -> value
value.startsWith("#") -> "$value:$defaultDomain"
value.contains(":") -> "#$value"
else -> "#$value:$defaultDomain"
}
return RoomAliasId(fullAlias).takeIf { it.isValid }
}
@@ -0,0 +1,450 @@
/*
* Created by sweetbread
* Copyright (c) 2026. All rights reserved.
*/
package ru.risdeveau.pixeldragon.ui.layout
import android.net.Uri
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import de.connect2x.trixnity.clientserverapi.model.room.GetPublicRoomsResponse
import de.connect2x.trixnity.clientserverapi.model.room.GetPublicRoomsWithFilter
import de.connect2x.trixnity.core.model.RoomAliasId
import de.connect2x.trixnity.core.model.RoomId
import de.connect2x.trixnity.core.model.events.m.room.CreateEventContent
import de.connect2x.trixnity.core.model.events.m.room.JoinRulesEventContent
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import ru.risdeveau.pixeldragon.client
import ru.risdeveau.pixeldragon.ui.item.Avatar
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun JoinRoomScreen(
modifier: Modifier = Modifier,
onJoined: (RoomId) -> Unit,
) {
val matrixClient = client!!
val scope = rememberCoroutineScope()
var query by remember { mutableStateOf("") }
var server by remember { mutableStateOf(matrixClient.userId.domain) }
var results by remember { mutableStateOf<List<GetPublicRoomsResponse.PublicRoomsChunk>>(emptyList()) }
var nextBatch by remember { mutableStateOf<String?>(null) }
var isSearching by remember { mutableStateOf(false) }
var isJoining by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val directTarget = remember(query) { query.parseJoinTarget() }
fun searchPublicRooms(since: String? = null) {
isSearching = true
error = null
scope.launch {
val searchTerm = query.trim().takeUnless { directTarget != null }
val result = matrixClient.api.room.getPublicRooms(
limit = 30,
server = server.trim().ifEmpty { null },
since = since,
filter = GetPublicRoomsWithFilter.Request.Filter(
genericSearchTerm = searchTerm?.takeIf { it.isNotBlank() },
),
)
result.fold(
onSuccess = { response ->
results = if (since == null) response.chunk else results + response.chunk
nextBatch = response.nextBatch
},
onFailure = { throwable ->
if (since == null) results = emptyList()
nextBatch = null
error = throwable.message ?: "Could not search rooms"
},
)
isSearching = false
}
}
fun joinTarget(target: JoinTarget) {
isJoining = true
error = null
scope.launch {
val result = when (target) {
is JoinTarget.Alias -> matrixClient.api.room.joinRoom(
roomAliasId = RoomAliasId(target.alias),
via = target.via.takeIf { it.isNotEmpty() },
)
is JoinTarget.Room -> matrixClient.api.room.joinRoom(
roomId = RoomId(target.roomId),
via = target.via.takeIf { it.isNotEmpty() },
)
}
result.fold(
onSuccess = { roomId -> onJoined(roomId) },
onFailure = { throwable ->
error = throwable.message ?: "Could not join room"
},
)
isJoining = false
}
}
LaunchedEffect(query, server) {
delay(450)
if (query.parseJoinTarget() == null) {
searchPublicRooms()
} else {
results = emptyList()
nextBatch = null
}
}
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = "Join room or space",
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = "Search a public room directory, paste #alias:server, !roomId:server or a matrix.to link.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = query,
onValueChange = { query = it },
singleLine = true,
label = { Text("Search, alias or Matrix link") },
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = server,
onValueChange = { server = it.trim() },
singleLine = true,
label = { Text("Directory server") },
supportingText = { Text("Leave current homeserver or enter another server, for example matrix.org") },
)
}
}
directTarget?.let { target ->
item {
ElevatedCard(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = "Direct join",
style = MaterialTheme.typography.titleMedium,
)
Text(
text = target.id,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (target.via.isNotEmpty()) {
Text(
text = "via ${target.via.joinToString()}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Button(
enabled = !isJoining,
onClick = { joinTarget(target) },
) {
if (isJoining) {
CircularProgressIndicator()
} else {
Text("Join")
}
}
}
}
}
}
error?.let {
item {
Text(
text = it,
color = MaterialTheme.colorScheme.error,
)
}
}
if (directTarget == null) {
item {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = if (query.isBlank()) "Public directory" else "Search results",
style = MaterialTheme.typography.titleMedium,
)
if (isSearching) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
}
if (!isSearching && results.isEmpty()) {
item {
Text(
text = "No rooms found",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
items(
items = results,
key = { it.roomId.full },
) { room ->
PublicRoomItem(
room = room,
isJoining = isJoining,
onJoin = {
joinTarget(
JoinTarget.Room(
roomId = room.roomId.full,
via = room.canonicalAlias?.domain?.let { setOf(it) }.orEmpty(),
),
)
},
onKnock = {
isJoining = true
error = null
scope.launch {
val result = matrixClient.api.room.knockRoom(room.roomId)
result.fold(
onSuccess = { roomId -> onJoined(roomId) },
onFailure = { throwable -> error = throwable.message ?: "Could not knock room" },
)
isJoining = false
}
},
)
}
nextBatch?.let { since ->
item {
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
enabled = !isSearching,
onClick = { searchPublicRooms(since) },
) {
Text("Load more")
}
}
}
}
}
}
@Composable
private fun PublicRoomItem(
room: GetPublicRoomsResponse.PublicRoomsChunk,
isJoining: Boolean,
onJoin: () -> Unit,
onKnock: () -> Unit,
) {
val isSpace = room.roomType == CreateEventContent.RoomType.Space
val title = room.name ?: room.canonicalAlias?.full ?: room.roomId.full
val avatarShape = if (isSpace) RoundedCornerShape(12.dp) else CircleShape
val canKnock = room.joinRule == JoinRulesEventContent.JoinRule.Knock ||
room.joinRule == JoinRulesEventContent.JoinRule.KnockRestricted
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
modifier = Modifier
.size(48.dp)
.clip(avatarShape),
url = room.avatarUrl,
fallbackName = title,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleMedium,
)
room.canonicalAlias?.let {
Text(
text = it.full,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
room.topic?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AssistChip(
onClick = {},
label = { Text(if (isSpace) "space" else "room") },
)
AssistChip(
onClick = {},
label = { Text(room.joinRule.name) },
)
AssistChip(
onClick = {},
label = { Text("${room.joinedMembersCount} members") },
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
enabled = !isJoining,
onClick = onJoin,
) {
Text("Join")
}
if (canKnock) {
OutlinedButton(
enabled = !isJoining,
onClick = onKnock,
) {
Text("Knock")
}
}
}
}
}
}
private sealed interface JoinTarget {
val id: String
val via: Set<String>
data class Alias(
val alias: String,
override val via: Set<String> = emptySet(),
) : JoinTarget {
override val id: String = alias
}
data class Room(
val roomId: String,
override val via: Set<String> = emptySet(),
) : JoinTarget {
override val id: String = roomId
}
}
private fun String.parseJoinTarget(): JoinTarget? {
val value = trim()
if (value.isEmpty()) return null
if (value.startsWith("https://matrix.to/#/") || value.startsWith("http://matrix.to/#/")) {
return value.parseMatrixToTarget()
}
if (value.startsWith("#") && RoomAliasId(value).isValid) {
return JoinTarget.Alias(value)
}
if (value.startsWith("!") && RoomId(value).isReasonable) {
return JoinTarget.Room(value)
}
return null
}
private fun String.parseMatrixToTarget(): JoinTarget? {
val fragment = Uri.parse(this).fragment.orEmpty().removePrefix("/")
if (fragment.isBlank()) return null
val idPart = fragment.substringBefore('?')
val queryPart = fragment.substringAfter('?', missingDelimiterValue = "")
val id = Uri.decode(idPart)
val via = Uri.parse("https://matrix.to/?$queryPart")
.getQueryParameters("via")
.filter { it.isNotBlank() }
.toSet()
return when {
id.startsWith("#") && RoomAliasId(id).isValid -> JoinTarget.Alias(id, via)
id.startsWith("!") && RoomId(id).isReasonable -> JoinTarget.Room(id, via)
else -> null
}
}
@@ -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
@@ -75,8 +88,12 @@ import de.connect2x.trixnity.client.room.message.text
import de.connect2x.trixnity.client.room.toFlowList
import de.connect2x.trixnity.client.store.TimelineEvent
import de.connect2x.trixnity.client.store.eventId
import de.connect2x.trixnity.clientserverapi.model.user.avatarUrl
import de.connect2x.trixnity.clientserverapi.model.user.displayName
import de.connect2x.trixnity.core.model.RoomId
import de.connect2x.trixnity.core.model.UserId
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 +104,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,15 +111,18 @@ 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
import org.jsoup.safety.Safelist
import ru.risdeveau.pixeldragon.client
import ru.risdeveau.pixeldragon.ui.item.Avatar
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.max
@@ -111,6 +130,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 +196,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,55 +217,214 @@ 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
}
if (timelineItems.isEmpty()) {
item {
Text("Empty room", modifier = Modifier.padding(16.dp))
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()) {
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),
) {
TimelineAvatar(
modifier = Modifier.size(TIMELINE_AVATAR_SIZE),
senderId = avatarState.senderId,
senderLabel = avatarState.senderLabel,
)
}
}
}
Row(Modifier.fillMaxWidth()) {
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 },
)
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 {
client!!.room.sendMessage(RoomId(rid)) {
text(payload)
AnimatedVisibility(message.isNotBlank()) {
IconButton(
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 {
val key: String
}
@@ -244,18 +434,34 @@ 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 senderId: String?,
val senderLabel: String,
val yPx: Int,
)
private data class SystemTimelineItem(
override val key: String,
@@ -265,6 +471,7 @@ private data class SystemTimelineItem(
private fun buildTimelineItems(
events: List<TimelineEvent>,
currentUserId: String,
fullyReadEventId: String?,
): List<TimelineUiItem> {
if (events.isEmpty()) return emptyList()
@@ -272,7 +479,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 +494,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 +514,7 @@ private fun buildTimelineItems(
key = "event:${event.eventId.full}",
text = "Not decrypted",
)
lastMessageIndex = null
lastMessageGroupIndex = null
}
content is RoomMessageEventContent -> {
@@ -303,22 +523,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 +531,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 +565,7 @@ private fun buildTimelineItems(
key = "event:${event.eventId.full}",
text = stateEventLabel(content),
)
lastMessageIndex = null
lastMessageGroupIndex = null
}
else -> {
@@ -346,34 +573,86 @@ private fun buildTimelineItems(
key = "event:${event.eventId.full}",
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
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),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
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,58 +669,43 @@ 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))
TimelineAvatar(
modifier = Modifier.size(TIMELINE_AVATAR_SIZE),
senderId = group.senderId,
senderLabel = group.senderLabel,
)
}
Spacer(Modifier.width(8.dp))
}
Column(
modifier = Modifier
.widthIn(max = 320.dp)
.background(bubbleColor, RoundedCornerShape(18.dp))
.padding(horizontal = 10.dp, vertical = 8.dp),
modifier = Modifier.widthIn(max = 320.dp),
horizontalAlignment = if (group.isOwn) Alignment.End else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
if (item.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 (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),
for ((index, message) in group.messages.withIndex()) {
MessageBubble(
item = message,
showSender = !group.isOwn && index == 0,
showTimestamp = index == group.messages.lastIndex,
)
}
}
@@ -449,24 +713,103 @@ private fun MessageRow(item: MessageTimelineItem) {
}
}
@Composable
private fun AvatarPlaceholder(senderLabel: String) {
val initial = senderLabel.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?"
private val timelineAvatarProfileCache = ConcurrentHashMap<String, TimelineAvatarProfile>()
Box(
modifier = Modifier
.size(32.dp)
.background(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = CircleShape,
),
contentAlignment = Alignment.Center,
) {
Text(
text = initial,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
private data class TimelineAvatarProfile(
val displayName: String,
val avatarUrl: String?,
)
@Composable
private fun TimelineAvatar(
modifier: Modifier = Modifier,
senderId: String?,
senderLabel: String,
) {
val fallbackProfile = remember(senderId, senderLabel) {
TimelineAvatarProfile(senderLabel.takeUnless { it.isBlank() } ?: "?", null)
}
var profile by remember(senderId, senderLabel) {
mutableStateOf(senderId?.let(timelineAvatarProfileCache::get) ?: fallbackProfile)
}
LaunchedEffect(senderId, senderLabel) {
val id = senderId ?: run {
profile = fallbackProfile
return@LaunchedEffect
}
timelineAvatarProfileCache[id]?.let { cached ->
profile = cached
return@LaunchedEffect
}
val fetchedProfile = client?.api?.user
?.getProfile(UserId(id))
?.getOrNull()
val resolvedProfile = TimelineAvatarProfile(
displayName = fetchedProfile
?.displayName
?.takeUnless { it.isBlank() }
?: senderLabel.takeUnless { it.isBlank() }
?: "?",
avatarUrl = fetchedProfile?.avatarUrl,
)
timelineAvatarProfileCache[id] = resolvedProfile
profile = resolvedProfile
}
Avatar(
modifier = modifier,
url = profile.avatarUrl,
fallbackName = profile.displayName,
)
}
@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),
)
}
}
}
@@ -490,6 +833,50 @@ 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,
senderId = group.senderId,
senderLabel = group.senderLabel,
yPx = pinnedAvatarTop.coerceAtLeast(groupTop),
)
}
.lastOrNull()
}
private fun stateEventLabel(content: Any): String {
return when (content) {
is AvatarEventContent -> "Avatar changed"
@@ -943,8 +1330,8 @@ private fun ComplexHtmlRenderer(
val resolvedHeightPx = rawHeightPx.coerceAtLeast(1)
val currentHeightPx = measuredHeightPx ?: estimatedHeightPx
val shouldUpdate = measuredHeightPx == null ||
resolvedHeightPx > currentHeightPx ||
abs(resolvedHeightPx - currentHeightPx) > updateThresholdPx
resolvedHeightPx > currentHeightPx ||
abs(resolvedHeightPx - currentHeightPx) > updateThresholdPx
if (shouldUpdate) {
complexHtmlHeightCache[cacheKey] = resolvedHeightPx
@@ -1000,7 +1387,7 @@ private fun ComplexHtmlRenderer(
val currentView = view ?: return
currentView.post {
val fallbackHeightPx = (currentView.contentHeight *
currentView.resources.displayMetrics.density).roundToInt()
currentView.resources.displayMetrics.density).roundToInt()
if (fallbackHeightPx > 0) {
onHeightMeasured(fallbackHeightPx)
}
@@ -1410,3 +1797,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
}
}
}
}
@@ -0,0 +1,110 @@
/*
* Created by sweetbread
* Copyright (c) 2026. All rights reserved.
*/
package ru.risdeveau.pixeldragon.ui.layout
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ListItem
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
object RoomActionsRoutes {
const val Rooms = "rooms"
const val JoinRoom = "join-room"
const val CreateRoom = "create-room"
const val CreateSpace = "create-space"
fun titleFor(route: String?): String? {
return when (route) {
JoinRoom -> "Join room or space"
CreateRoom -> "Create room"
CreateSpace -> "Create space"
else -> null
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoomActionsSheet(
onDismiss: () -> Unit,
onJoinRoom: () -> Unit,
onCreateRoom: () -> Unit,
onCreateSpace: () -> Unit,
) {
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
) {
Column {
RoomActionItem(
title = "Join room or space",
subtitle = "Search public directories or paste a Matrix link",
onClick = {
onDismiss()
onJoinRoom()
},
)
HorizontalDivider(modifier = Modifier.padding(start = 16.dp))
RoomActionItem(
title = "Create room",
subtitle = "Start a public or private room",
onClick = {
onDismiss()
onCreateRoom()
},
)
HorizontalDivider(modifier = Modifier.padding(start = 16.dp))
RoomActionItem(
title = "Create space",
subtitle = "Group related rooms together",
onClick = {
onDismiss()
onCreateSpace()
},
)
}
}
Spacer(Modifier.height(32.dp))
}
}
}
@Composable
private fun RoomActionItem(
title: String,
subtitle: String,
onClick: () -> Unit,
) {
ListItem(
modifier = Modifier.clickable(onClick = onClick),
headlineContent = { Text(title) },
supportingContent = { Text(subtitle) },
trailingContent = { Text("") },
)
}
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -16,7 +16,9 @@ composeBom = "2026.03.00"
navigationCompose = "2.9.7"
room = "2.6.1"
splittiesFunPackAndroidBase = "3.0.0"
trixnityClient = "5.2.0"
trixnityClient = "5.5.2"
unifiedPushConnector = "3.3.2"
uiUnit = "1.10.6"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -53,6 +55,8 @@ 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" }
unifiedpush-connector = { module = "org.unifiedpush.android:connector", version.ref = "unifiedPushConnector" }
androidx-compose-ui-unit = { group = "androidx.compose.ui", name = "ui-unit", version.ref = "uiUnit" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }