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.CenterAlignedTopAppBar
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.item.Avatar
|
||||||
import ru.risdeveau.pixeldragon.ui.layout.AccountSettingsScreen
|
import ru.risdeveau.pixeldragon.ui.layout.AccountSettingsScreen
|
||||||
import ru.risdeveau.pixeldragon.ui.layout.AppearanceSettingsScreen
|
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.DevicesSettingsScreen
|
||||||
|
import ru.risdeveau.pixeldragon.ui.layout.JoinRoomScreen
|
||||||
import ru.risdeveau.pixeldragon.ui.layout.NotificationsSettingsScreen
|
import ru.risdeveau.pixeldragon.ui.layout.NotificationsSettingsScreen
|
||||||
import ru.risdeveau.pixeldragon.ui.layout.Room
|
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.RoomList
|
||||||
import ru.risdeveau.pixeldragon.ui.layout.SettingsMainScreen
|
import ru.risdeveau.pixeldragon.ui.layout.SettingsMainScreen
|
||||||
import ru.risdeveau.pixeldragon.ui.layout.SettingsRoutes
|
import ru.risdeveau.pixeldragon.ui.layout.SettingsRoutes
|
||||||
@@ -108,6 +113,18 @@ class MainActivity : ComponentActivity() {
|
|||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val syncState by client!!.api.sync.currentSyncState
|
val syncState by client!!.api.sync.currentSyncState
|
||||||
.collectAsState(initial = SyncState.STOPPED)
|
.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(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -117,9 +134,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
syncState = syncState,
|
syncState = syncState,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
if (route == RoomActionsRoutes.Rooms) {
|
||||||
|
FloatingActionButton(onClick = { showRoomActionsSheet = true }) {
|
||||||
|
Text("+")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
NavHost(navController = navController, startDestination = "rooms") {
|
NavHost(navController = navController, startDestination = RoomActionsRoutes.Rooms) {
|
||||||
composable("rooms") {
|
composable(RoomActionsRoutes.Rooms) {
|
||||||
RoomList(Modifier.padding(innerPadding), navController)
|
RoomList(Modifier.padding(innerPadding), navController)
|
||||||
}
|
}
|
||||||
composable(
|
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(
|
navigation(
|
||||||
route = SettingsRoutes.Graph,
|
route = SettingsRoutes.Graph,
|
||||||
startDestination = SettingsRoutes.Main,
|
startDestination = SettingsRoutes.Main,
|
||||||
@@ -216,6 +275,7 @@ private fun PixelDragonTopBar(
|
|||||||
val route = backStackEntry?.destination?.route
|
val route = backStackEntry?.destination?.route
|
||||||
val rid = backStackEntry?.arguments?.getString("rid")
|
val rid = backStackEntry?.arguments?.getString("rid")
|
||||||
val isRoomLikeScreen = route == "room/{rid}" || route == "space/{rid}"
|
val isRoomLikeScreen = route == "room/{rid}" || route == "space/{rid}"
|
||||||
|
val roomActionTitle = RoomActionsRoutes.titleFor(route)
|
||||||
val isSettingsScreen = route == SettingsRoutes.Graph || route?.startsWith("settings/") == true
|
val isSettingsScreen = route == SettingsRoutes.Graph || route?.startsWith("settings/") == true
|
||||||
|
|
||||||
val roomsFlow = remember(client) {
|
val roomsFlow = remember(client) {
|
||||||
@@ -240,6 +300,14 @@ private fun PixelDragonTopBar(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
roomActionTitle != null -> {
|
||||||
|
RoomTopBar(
|
||||||
|
room = null,
|
||||||
|
fallbackTitle = roomActionTitle,
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
isSettingsScreen -> {
|
isSettingsScreen -> {
|
||||||
RoomTopBar(
|
RoomTopBar(
|
||||||
room = null,
|
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