feat: join and create rooms
This commit is contained in:
@@ -23,6 +23,7 @@ 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
|
||||
@@ -73,9 +74,13 @@ 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
|
||||
@@ -108,6 +113,18 @@ class MainActivity : ComponentActivity() {
|
||||
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(),
|
||||
@@ -117,9 +134,16 @@ class MainActivity : ComponentActivity() {
|
||||
syncState = syncState,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (route == RoomActionsRoutes.Rooms) {
|
||||
FloatingActionButton(onClick = { showRoomActionsSheet = true }) {
|
||||
Text("+")
|
||||
}
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
NavHost(navController = navController, startDestination = "rooms") {
|
||||
composable("rooms") {
|
||||
NavHost(navController = navController, startDestination = RoomActionsRoutes.Rooms) {
|
||||
composable(RoomActionsRoutes.Rooms) {
|
||||
RoomList(Modifier.padding(innerPadding), navController)
|
||||
}
|
||||
composable(
|
||||
@@ -140,6 +164,41 @@ class MainActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -216,6 +275,7 @@ private fun PixelDragonTopBar(
|
||||
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) {
|
||||
@@ -240,6 +300,14 @@ private fun PixelDragonTopBar(
|
||||
)
|
||||
}
|
||||
|
||||
roomActionTitle != null -> {
|
||||
RoomTopBar(
|
||||
room = null,
|
||||
fallbackTitle = roomActionTitle,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
|
||||
isSettingsScreen -> {
|
||||
RoomTopBar(
|
||||
room = null,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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("›") },
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user