feat(discover): Implement server joining functionality

- Added `ServerInviteHandler.kt` and `ServerInviteDialog.kt` to manage and display server invite information and allow users to join servers.
- Updated `DiscoverServersListViewModel.kt` to include logic for fetching invite details and joining servers.
- Modified `DiscoverServersList.kt` to trigger the server invite dialog when a server item is clicked and to handle the invite code.
- Added `inviteCode` field to `ServerData` in `ServerDataRepository.kt`.
- Removed unused `DiscoverViewModel.kt`.
- Added new string resources for invite dialog messages and member count.
This commit is contained in:
AbronStudio 2025-08-05 12:09:06 +03:30
parent c3600690d5
commit 54a8e7e1ce
7 changed files with 330 additions and 89 deletions

View File

@ -24,6 +24,7 @@ class ServerDataRepository {
id = rowData["id"] ?: "",
name = rowData["name"] ?: "",
description = rowData["description"] ?: "",
inviteCode = rowData["inviteCode"] ?: "",
)
}
emit(servers)
@ -58,6 +59,7 @@ data class ServerData(
val id: String,
val name: String,
val description: String,
val inviteCode: String,
)
/**

View File

@ -21,6 +21,9 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.graphics.ColorFilter
@ -39,6 +42,22 @@ fun DiscoverServersList() {
val servers by viewModel.servers.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val error by viewModel.error.collectAsState()
val selectedInviteCode by viewModel.selectedServerInviteCodeFlow.collectAsState()
// Handle server invite dialog
selectedInviteCode?.let { inviteCode ->
ServerInviteHandler(
inviteCode = inviteCode,
viewModel = viewModel,
onDismiss = {
viewModel.selectedServerInviteCode = null
},
onJoinSuccess = {
// Show a success message or navigate to the joined server
viewModel.selectedServerInviteCode = null
}
)
}
Column(
modifier = Modifier
@ -90,7 +109,11 @@ fun DiscoverServersList() {
items(servers) { server ->
ServerItem(
server = server,
onClick = { /* Handle server selection */ }
onClick = {
if (server.inviteCode.isNotEmpty()) {
viewModel.selectedServerInviteCode = server.inviteCode
}
}
)
}
item {

View File

@ -2,12 +2,22 @@ package chat.revolt.composables.screens.chat.discover
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import chat.revolt.api.RevoltError
import chat.revolt.api.routes.googlesheets.ServerData
import chat.revolt.api.routes.googlesheets.ServerDataRepository
import chat.revolt.api.routes.invites.fetchInviteByCode
import chat.revolt.api.routes.invites.joinInviteByCode
import chat.revolt.api.schemas.Invite
import chat.revolt.api.schemas.InviteJoined
import chat.revolt.api.schemas.RsResult
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -28,6 +38,17 @@ class DiscoverServersListViewModel @Inject constructor(
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
// Selected server invite code
private val _selectedServerInviteCode = MutableStateFlow<String?>(null)
val selectedServerInviteCodeFlow: StateFlow<String?> = _selectedServerInviteCode.asStateFlow()
// Property for easy access to selectedServerInviteCode
var selectedServerInviteCode: String?
get() = _selectedServerInviteCode.value
set(value) {
_selectedServerInviteCode.value = value
}
// Initialize by loading servers
init {
loadServers()
@ -53,4 +74,34 @@ class DiscoverServersListViewModel @Inject constructor(
}
}
}
fun loadServerInviteInfo(inviteCode: String): Flow<RsResult<Invite, RevoltError>> = flow {
_isLoading.value = true
_error.value = null
try {
val result = fetchInviteByCode(inviteCode)
emit(result)
} catch (e: Exception) {
_error.value = e.message ?: "Unknown error occurred"
emit(RsResult.err(RevoltError("Unknown")))
} finally {
_isLoading.value = false
}
}.flowOn(Dispatchers.IO)
fun joinServer(inviteCode: String): Flow<RsResult<InviteJoined, RevoltError>> = flow {
_isLoading.value = true
_error.value = null
try {
val result = joinInviteByCode(inviteCode)
emit(result)
} catch (e: Exception) {
_error.value = e.message ?: "Unknown error occurred"
emit(RsResult.err(RevoltError("Unknown")))
} finally {
_isLoading.value = false
}
}.flowOn(Dispatchers.IO)
}

View File

@ -0,0 +1,183 @@
package chat.revolt.composables.screens.chat.discover
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.revolt.R
import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.RevoltError
import chat.revolt.api.schemas.Invite
import chat.revolt.composables.generic.IconPlaceholder
import chat.revolt.composables.generic.RemoteImage
@Composable
fun ServerInviteDialog(
isLoading: Boolean,
invite: Invite?,
error: RevoltError?,
onJoinClick: () -> Unit,
onDismiss: () -> Unit
) {
if (isLoading) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = stringResource(id = R.string.loading),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
},
text = {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth()
) {
CircularProgressIndicator(
modifier = Modifier.size(48.dp)
)
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(id = R.string.invite_cancel))
}
}
)
return
}
if (error != null) {
AlertDialog(
onDismissRequest = onDismiss,
icon = {
Icon(
painter = painterResource(R.drawable.icn_error_24dp),
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
},
title = {
Text(
text = stringResource(id = R.string.invite_error_header),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
},
text = {
Text(
text = when (error.type) {
"NotFound" -> stringResource(id = R.string.invite_error_invalid_invite)
"Banned" -> stringResource(id = R.string.invite_error_banned)
else -> stringResource(id = R.string.invite_error_unknown)
},
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(id = R.string.invite_cancel))
}
}
)
return
}
if (invite == null) {
// This shouldn't happen, but handle it just in case
return
}
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = invite.serverName ?: stringResource(id = R.string.unknown),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
modifier = Modifier.fillMaxWidth()
)
},
text = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
if (invite.serverIcon != null) {
RemoteImage(
url = "$REVOLT_FILES/icons/${invite.serverIcon.id}/${invite.serverIcon.filename}",
allowAnimation = false,
description = invite.serverName ?: stringResource(id = R.string.unknown),
modifier = Modifier
.size(64.dp)
.clip(CircleShape)
)
} else {
IconPlaceholder(
name = invite.serverName ?: stringResource(id = R.string.unknown),
modifier = Modifier
.size(64.dp)
.clip(CircleShape)
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(id = R.string.invite_message),
textAlign = TextAlign.Center
)
if (invite.memberCount != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(
id = R.string.members_count,
invite.memberCount
),
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
},
confirmButton = {
Button(onClick = onJoinClick) {
Text(text = stringResource(id = R.string.invite_join))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(id = R.string.invite_cancel))
}
}
)
}

View File

@ -0,0 +1,69 @@
package chat.revolt.composables.screens.chat.discover
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.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel
import chat.revolt.api.RevoltError
import chat.revolt.api.schemas.Invite
import chat.revolt.api.schemas.RsResult
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@Composable
fun ServerInviteHandler(
inviteCode: String,
viewModel: DiscoverServersListViewModel = viewModel(),
onDismiss: () -> Unit = {},
onJoinSuccess: () -> Unit = {}
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var isLoading by remember { mutableStateOf(true) }
var showDialog by remember { mutableStateOf(true) }
var inviteResult by remember { mutableStateOf<RsResult<Invite, RevoltError>?>(null) }
var error by remember { mutableStateOf<RevoltError?>(null) }
// Fetch server invite info
LaunchedEffect(inviteCode) {
viewModel.loadServerInviteInfo(inviteCode).collectLatest { result ->
inviteResult = result
isLoading = false
if (result.err) {
error = result.error
}
}
}
if (showDialog) {
ServerInviteDialog(
isLoading = isLoading,
invite = inviteResult?.value,
error = error,
onJoinClick = {
isLoading = true
scope.launch {
viewModel.joinServer(inviteCode).collectLatest { result ->
isLoading = false
if (result.ok) {
showDialog = false
onJoinSuccess()
} else {
error = result.error
}
}
}
},
onDismiss = {
showDialog = false
onDismiss()
}
)
}
}

View File

@ -1,88 +0,0 @@
package chat.revolt.screens.discover
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import chat.revolt.api.routes.googlesheets.ServerCategory
import chat.revolt.api.routes.googlesheets.ServerData
import chat.revolt.api.routes.googlesheets.ServerDataRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* ViewModel for the Discover screen
* Handles fetching and managing server data from Google Sheets
*/
@HiltViewModel
class DiscoverViewModel @Inject constructor(
private val serverDataRepository: ServerDataRepository
) : ViewModel() {
// UI state
var uiState by mutableStateOf<DiscoverUiState>(DiscoverUiState.Loading)
private set
// Initialize the ViewModel
init {
loadServerData()
}
/**
* Loads server data from the repository
*/
fun loadServerData() {
uiState = DiscoverUiState.Loading
viewModelScope.launch {
serverDataRepository.getServers(
sheetUrl = "https://docs.google.com/spreadsheets/d/e/2PACX-1vRY41D-NgTE6bC3kTN3dRpisI-DoeHG8Eg7n31xb1CdydWjOLaphqYckkTiaG9oIQSWP92h3NE-7cpF/pub?gid=0&single=true&output=csv"
)
.catch { exception ->
uiState = DiscoverUiState.Error(exception.message ?: "Unknown error")
}
.collect { servers ->
serverDataRepository.getServerCategories(
sheetUrl = "https://docs.google.com/spreadsheets/d/e/2PACX-1vRY41D-NgTE6bC3kTN3dRpisI-DoeHG8Eg7n31xb1CdydWjOLaphqYckkTiaG9oIQSWP92h3NE-7cpF/pub?gid=0&single=true&output=csv"
)
.catch { exception ->
uiState = DiscoverUiState.Error(exception.message ?: "Unknown error")
}
.collect { categories ->
uiState = DiscoverUiState.Success(
servers = servers,
categories = categories
)
}
}
}
}
}
/**
* Sealed class representing the UI state of the Discover screen
*/
sealed class DiscoverUiState {
/**
* Loading state
*/
object Loading : DiscoverUiState()
/**
* Success state with data
* @param servers The list of servers
* @param categories The list of server categories
*/
data class Success(
val servers: List<ServerData>,
val categories: List<ServerCategory>
) : DiscoverUiState()
/**
* Error state
* @param message The error message
*/
data class Error(val message: String) : DiscoverUiState()
}

View File

@ -547,6 +547,7 @@
<string name="invite_join">Join</string>
<string name="invite_cancel">Cancel</string>
<string name="invite_already_member">You are already a member of this server.</string>
<string name="members_count">%1$d members</string>
<string name="invite_error_header">There was an error</string>
<string name="invite_error_no_invite">No invite code was specified.</string>
<string name="invite_error_invalid_invite">Could not find an invite with the specified code.</string>