Compare commits
13 Commits
d05c3ce367
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 2092de3e9a | |||
| 098c9fe2aa | |||
| 6bf33a91c9 | |||
| 7a5889a351 | |||
| 11ad22c818 | |||
| 16fbd40907 | |||
| f2ab63887a | |||
| 52ca540bca | |||
| a89b2c36a7 | |||
| 56cb0ea39c | |||
| 6a8e1780d7 | |||
| fc331f726e | |||
| 06b5ce326c |
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
+128
@@ -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
|
||||
}
|
||||
+64
@@ -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()
|
||||
}
|
||||
}
|
||||
+137
@@ -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) {
|
||||
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,19 +217,139 @@ fun Room(modifier: Modifier = Modifier, rid: String) {
|
||||
}
|
||||
}
|
||||
val events by eventsFlow.collectAsState(initial = emptyList())
|
||||
val currentUserId = remember { client!!.userId.toString() }
|
||||
val timelineItems = remember(events, currentUserId) {
|
||||
buildTimelineItems(events = events, currentUserId = currentUserId)
|
||||
|
||||
val readMarkerFlow = remember(roomId, client!!) {
|
||||
client!!.room
|
||||
.getAccountData(roomId, FullyReadEventContent::class)
|
||||
.map { content -> ReadMarkerState.Ready(content?.eventId?.full) as ReadMarkerState }
|
||||
}
|
||||
val readMarkerState by readMarkerFlow.collectAsState(initial = ReadMarkerState.Loading)
|
||||
val fullyReadEventId = (readMarkerState as? ReadMarkerState.Ready)?.eventId
|
||||
|
||||
val currentUserId = remember(client!!) { client!!.userId.toString() }
|
||||
val timelineItems = remember(events, currentUserId, fullyReadEventId) {
|
||||
buildTimelineItems(
|
||||
events = events,
|
||||
currentUserId = currentUserId,
|
||||
fullyReadEventId = fullyReadEventId,
|
||||
)
|
||||
}
|
||||
val latestTimelineKey = remember(timelineItems) {
|
||||
timelineItems.lastOrNull()?.latestKey()
|
||||
}
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
var didInitialScroll by remember(roomId) { mutableStateOf(false) }
|
||||
var lastAutoScrollTimelineKey by remember(roomId) { mutableStateOf<String?>(null) }
|
||||
|
||||
val isNearBottom by remember(timelineItems, listState) {
|
||||
derivedStateOf {
|
||||
val totalItems = listState.layoutInfo.totalItemsCount
|
||||
if (totalItems == 0) return@derivedStateOf true
|
||||
|
||||
val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
|
||||
?: return@derivedStateOf false
|
||||
lastVisibleIndex >= totalItems - 1 - TIMELINE_BOTTOM_AUTOSCROLL_THRESHOLD
|
||||
}
|
||||
}
|
||||
|
||||
val shouldLoadOlder by remember(listState, didInitialScroll) {
|
||||
derivedStateOf {
|
||||
didInitialScroll &&
|
||||
listState.layoutInfo.totalItemsCount > 0 &&
|
||||
listState.firstVisibleItemIndex <= TIMELINE_TOP_PREFETCH_THRESHOLD
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier) {
|
||||
var message by remember { mutableStateOf("") }
|
||||
|
||||
LazyColumn(Modifier.weight(1f), state = listState, reverseLayout = true) {
|
||||
items(timelineItems, key = { it.key }) { item ->
|
||||
TimelineItem(item)
|
||||
LaunchedEffect(timelineItems, readMarkerState, events.size, requestedLimit) {
|
||||
if (didInitialScroll || timelineItems.isEmpty()) return@LaunchedEffect
|
||||
if (readMarkerState is ReadMarkerState.Loading) return@LaunchedEffect
|
||||
|
||||
val readMarkerEventId = (readMarkerState as ReadMarkerState.Ready).eventId
|
||||
val unreadDividerIndex = timelineItems.indexOfFirst { it is UnreadDividerItem }
|
||||
if (unreadDividerIndex >= 0) {
|
||||
listState.scrollToItem(unreadDividerIndex)
|
||||
didInitialScroll = true
|
||||
lastAutoScrollTimelineKey = latestTimelineKey
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val readMarkerIsLoaded = readMarkerEventId != null &&
|
||||
events.any { it.eventId.full == readMarkerEventId }
|
||||
val shouldSearchOlderForUnreadMarker = readMarkerEventId != null &&
|
||||
!readMarkerIsLoaded &&
|
||||
events.size >= requestedLimit &&
|
||||
requestedLimit < MAX_INITIAL_UNREAD_SEARCH_LIMIT
|
||||
|
||||
if (shouldSearchOlderForUnreadMarker) {
|
||||
requestedLimit = (requestedLimit + TIMELINE_PAGE_SIZE)
|
||||
.coerceAtMost(MAX_INITIAL_UNREAD_SEARCH_LIMIT)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
listState.scrollToItem(timelineItems.lastIndex)
|
||||
didInitialScroll = true
|
||||
lastAutoScrollTimelineKey = latestTimelineKey
|
||||
}
|
||||
|
||||
LaunchedEffect(shouldLoadOlder, events.size, requestedLimit) {
|
||||
if (!shouldLoadOlder) return@LaunchedEffect
|
||||
if (events.size < requestedLimit) return@LaunchedEffect
|
||||
|
||||
requestedLimit += TIMELINE_PAGE_SIZE
|
||||
}
|
||||
|
||||
LaunchedEffect(latestTimelineKey) {
|
||||
val currentLatestKey = latestTimelineKey ?: return@LaunchedEffect
|
||||
val previousLatestKey = lastAutoScrollTimelineKey
|
||||
|
||||
if (!didInitialScroll) {
|
||||
lastAutoScrollTimelineKey = currentLatestKey
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (previousLatestKey != null && previousLatestKey != currentLatestKey && isNearBottom) {
|
||||
listState.animateScrollToItem(timelineItems.lastIndex)
|
||||
}
|
||||
|
||||
lastAutoScrollTimelineKey = currentLatestKey
|
||||
}
|
||||
|
||||
val density = LocalDensity.current
|
||||
BoxWithConstraints(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.clipToBounds()
|
||||
) {
|
||||
val listContainerHeightPx = with(density) { maxHeight.roundToPx() }
|
||||
val stickyAvatar by remember(timelineItems, listState, density, listContainerHeightPx) {
|
||||
derivedStateOf {
|
||||
calculateStickyAvatarState(
|
||||
timelineItems = timelineItems,
|
||||
layoutInfo = listState.layoutInfo,
|
||||
avatarSizePx = with(density) { TIMELINE_AVATAR_SIZE.roundToPx() },
|
||||
viewportBottomLimitPx = listContainerHeightPx,
|
||||
)
|
||||
}
|
||||
}
|
||||
LazyColumn(Modifier.fillMaxSize(), state = listState) {
|
||||
for (timelineItem in timelineItems) {
|
||||
when (timelineItem) {
|
||||
is DateDividerItem -> stickyHeader(key = timelineItem.key) {
|
||||
DateDivider(timelineItem)
|
||||
}
|
||||
|
||||
else -> item(key = timelineItem.key) {
|
||||
TimelineItem(
|
||||
item = timelineItem,
|
||||
pinnedAvatarGroupKey = stickyAvatar?.groupKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (timelineItems.isEmpty()) {
|
||||
@@ -208,17 +359,50 @@ fun Room(modifier: Modifier = Modifier, rid: String) {
|
||||
}
|
||||
}
|
||||
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
stickyAvatar?.let { avatarState ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset {
|
||||
IntOffset(
|
||||
x = with(density) { MESSAGE_FIELD_TOP_PADDING.roundToPx() },
|
||||
y = avatarState.yPx,
|
||||
)
|
||||
}
|
||||
.zIndex(1f),
|
||||
) {
|
||||
TimelineAvatar(
|
||||
modifier = Modifier.size(TIMELINE_AVATAR_SIZE),
|
||||
senderId = avatarState.senderId,
|
||||
senderLabel = avatarState.senderLabel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.clearFocusOnKeyboardDismiss()
|
||||
.padding(MESSAGE_FIELD_TOP_PADDING)
|
||||
.height(MESSAGE_FIELD_SIZE)
|
||||
.weight(1f),
|
||||
shape = CircleShape,
|
||||
value = message,
|
||||
onValueChange = { message = it },
|
||||
)
|
||||
|
||||
AnimatedVisibility(message.isNotBlank()) {
|
||||
IconButton(
|
||||
modifier = Modifier
|
||||
.size(MESSAGE_FIELD_SIZE)
|
||||
.background(MaterialTheme.colorScheme.primary, CircleShape),
|
||||
enabled = message.isNotBlank(),
|
||||
content = { Icon(if (message.isBlank()) Iconsax.Outline.Send2 else Iconsax.Filled.Send2, contentDescription = "Send") },
|
||||
content = {
|
||||
Icon(Iconsax.Filled.Send2,
|
||||
contentDescription = "Send",
|
||||
tint = MaterialTheme.colorScheme.inversePrimary
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
val payload = message.trim()
|
||||
if (payload.isBlank()) return@IconButton
|
||||
@@ -234,6 +418,12 @@ fun Room(modifier: Modifier = Modifier, rid: String) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface ReadMarkerState {
|
||||
data object Loading : ReadMarkerState
|
||||
data class Ready(val eventId: String?) : ReadMarkerState
|
||||
}
|
||||
|
||||
private sealed interface TimelineUiItem {
|
||||
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,35 +573,87 @@ private fun buildTimelineItems(
|
||||
key = "event:${event.eventId.full}",
|
||||
text = content.toString(),
|
||||
)
|
||||
lastMessageIndex = null
|
||||
}
|
||||
lastMessageGroupIndex = null
|
||||
}
|
||||
}
|
||||
|
||||
return items.asReversed()
|
||||
if (!unreadDividerInserted && fullyReadEventId != null && event.eventId.full == fullyReadEventId) {
|
||||
insertUnreadDividerBeforeNextMessage = true
|
||||
lastMessageGroupIndex = null
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimelineItem(item: TimelineUiItem) {
|
||||
private fun TimelineItem(
|
||||
item: TimelineUiItem,
|
||||
pinnedAvatarGroupKey: String?,
|
||||
) {
|
||||
when (item) {
|
||||
is DateDividerItem -> DateDivider(item)
|
||||
is MessageTimelineItem -> MessageRow(item)
|
||||
is UnreadDividerItem -> UnreadDivider()
|
||||
is MessageGroupItem -> MessageGroupRow(
|
||||
group = item,
|
||||
hideInlineAvatar = pinnedAvatarGroupKey == item.key,
|
||||
)
|
||||
is SystemTimelineItem -> SystemEventRow(item)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DateDivider(item: DateDividerItem) {
|
||||
Text(
|
||||
text = item.label,
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = item.label,
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.8f),
|
||||
shape = RoundedCornerShape(50),
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UnreadDivider() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(1.dp)
|
||||
.weight(1f)
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.45f)),
|
||||
)
|
||||
Text(
|
||||
text = "Unread messages",
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(1.dp)
|
||||
.weight(1f)
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.45f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SystemEventRow(item: SystemTimelineItem) {
|
||||
@@ -390,36 +669,123 @@ 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),
|
||||
horizontalAlignment = if (group.isOwn) Alignment.End else Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
for ((index, message) in group.messages.withIndex()) {
|
||||
MessageBubble(
|
||||
item = message,
|
||||
showSender = !group.isOwn && index == 0,
|
||||
showTimestamp = index == group.messages.lastIndex,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val timelineAvatarProfileCache = ConcurrentHashMap<String, TimelineAvatarProfile>()
|
||||
|
||||
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
|
||||
.widthIn(max = 320.dp)
|
||||
.background(bubbleColor, RoundedCornerShape(18.dp))
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
) {
|
||||
if (item.showSender) {
|
||||
if (showSender) {
|
||||
Text(
|
||||
text = item.senderLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
@@ -434,7 +800,7 @@ private fun MessageRow(item: MessageTimelineItem) {
|
||||
textColor = bubbleTextColor,
|
||||
)
|
||||
|
||||
if (item.showTimestamp && item.timeLabel != null) {
|
||||
if (showTimestamp && item.timeLabel != null) {
|
||||
Text(
|
||||
text = item.timeLabel,
|
||||
modifier = Modifier
|
||||
@@ -446,29 +812,6 @@ private fun MessageRow(item: MessageTimelineItem) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AvatarPlaceholder(senderLabel: String) {
|
||||
val initial = senderLabel.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageBody(
|
||||
@@ -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"
|
||||
@@ -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
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user