add simple ui
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#000000"
|
||||
android:strokeWidth="56"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="M120,220 Q120,120 220,120 L740,120 Q840,120 840,220 L840,740 Q840,840 740,840 L220,840 Q120,840 120,740 Z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#000000"
|
||||
android:strokeWidth="72"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="M400,320 L400,640 M560,320 L560,640 M320,400 L640,400 M320,560 L640,560" />
|
||||
</vector>
|
||||
@@ -0,0 +1,15 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M240,500 Q240,440 300,440 L660,440 Q720,440 720,500 L720,780 Q720,840 660,840 L300,840 Q240,840 240,780 Z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#000000"
|
||||
android:strokeWidth="64"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="M340,500 L340,440 A140,140 0 0 1 620,440 L620,500" />
|
||||
</vector>
|
||||
@@ -39,6 +39,7 @@ import androidx.navigation3.ui.NavDisplay
|
||||
import kotlinx.coroutines.launch
|
||||
import su.reya.coop.repository.AccountRepository
|
||||
import su.reya.coop.repository.ChatRepository
|
||||
import su.reya.coop.repository.ConcordRepository
|
||||
import su.reya.coop.repository.SettingsRepository
|
||||
import su.reya.coop.screens.ContactListScreen
|
||||
import su.reya.coop.screens.HomeScreen
|
||||
@@ -54,9 +55,15 @@ import su.reya.coop.screens.ScanScreen
|
||||
import su.reya.coop.screens.SettingsScreen
|
||||
import su.reya.coop.screens.UpdateProfileScreen
|
||||
import su.reya.coop.screens.chat.ChatScreen
|
||||
import su.reya.coop.screens.communities.ChannelScreen
|
||||
import su.reya.coop.screens.communities.CommunitiesScreen
|
||||
import su.reya.coop.screens.communities.CommunityScreen
|
||||
import su.reya.coop.screens.communities.JoinCommunityScreen
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
import su.reya.coop.viewmodel.ChannelScreenViewModel
|
||||
import su.reya.coop.viewmodel.ChatScreenViewModel
|
||||
import su.reya.coop.viewmodel.ChatViewModel
|
||||
import su.reya.coop.viewmodel.ConcordViewModel
|
||||
import su.reya.coop.viewmodel.ProfileCache
|
||||
import su.reya.coop.viewmodel.SettingsViewModel
|
||||
|
||||
@@ -90,6 +97,7 @@ fun App(
|
||||
profileCache: ProfileCache,
|
||||
accountRepository: AccountRepository,
|
||||
chatRepository: ChatRepository,
|
||||
concordRepository: ConcordRepository,
|
||||
settingsRepository: SettingsRepository,
|
||||
connectivityMonitor: ConnectivityMonitor,
|
||||
) {
|
||||
@@ -109,6 +117,10 @@ fun App(
|
||||
settingsRepository
|
||||
)
|
||||
|
||||
modelClass.isAssignableFrom(ConcordViewModel::class.java) -> ConcordViewModel(
|
||||
concordRepository
|
||||
)
|
||||
|
||||
else -> throw IllegalArgumentException("Unknown ViewModel class")
|
||||
}
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@@ -120,6 +132,7 @@ fun App(
|
||||
val accountViewModel: AccountViewModel = viewModel(factory = viewModelFactory)
|
||||
val chatViewModel: ChatViewModel = viewModel(factory = viewModelFactory)
|
||||
val settingsViewModel: SettingsViewModel = viewModel(factory = viewModelFactory)
|
||||
val concordViewModel: ConcordViewModel = viewModel(factory = viewModelFactory)
|
||||
|
||||
val context = LocalContext.current
|
||||
val activity = context as? ComponentActivity
|
||||
@@ -170,6 +183,11 @@ fun App(
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
concordViewModel.errorEvents.collect { message ->
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
profileCache.errorEvents.collect { message ->
|
||||
snackbarHostState.showSnackbar(message)
|
||||
@@ -237,7 +255,7 @@ fun App(
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Home> {
|
||||
HomeScreen(accountViewModel, chatViewModel)
|
||||
HomeScreen(accountViewModel, chatViewModel, concordViewModel)
|
||||
}
|
||||
entry<Screen.RequestList> {
|
||||
RequestListScreen(chatViewModel)
|
||||
@@ -297,6 +315,36 @@ fun App(
|
||||
entry<Screen.Settings> {
|
||||
SettingsScreen(settingsViewModel)
|
||||
}
|
||||
entry<Screen.Communities> {
|
||||
CommunitiesScreen(concordViewModel)
|
||||
}
|
||||
entry<Screen.JoinCommunity> { key ->
|
||||
JoinCommunityScreen(concordViewModel, key.link)
|
||||
}
|
||||
entry<Screen.Community> { key ->
|
||||
CommunityScreen(key.communityId, concordViewModel)
|
||||
}
|
||||
entry<Screen.Channel> { key ->
|
||||
val factory = remember(key) {
|
||||
object : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return ChannelScreenViewModel(
|
||||
key.communityId,
|
||||
key.channelId,
|
||||
accountRepository,
|
||||
concordRepository
|
||||
) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
ChannelScreen(
|
||||
viewModel<ChannelScreenViewModel>(
|
||||
key = "${key.communityId}:${key.channelId}",
|
||||
factory = factory
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import kotlinx.coroutines.MainScope
|
||||
import su.reya.coop.nostr.NostrManager
|
||||
import su.reya.coop.repository.AccountRepository
|
||||
import su.reya.coop.repository.ChatRepository
|
||||
import su.reya.coop.repository.ConcordRepository
|
||||
import su.reya.coop.repository.MediaRepository
|
||||
import su.reya.coop.repository.SettingsRepository
|
||||
import su.reya.coop.viewmodel.ProfileCache
|
||||
@@ -52,6 +53,10 @@ class MainActivity : ComponentActivity() {
|
||||
ChatRepository(NostrManager.instance, mediaRepository, settingsRepository, scope)
|
||||
}
|
||||
|
||||
private val concordRepository by lazy {
|
||||
ConcordRepository(NostrManager.instance, scope)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
throwable.printStackTrace()
|
||||
@@ -97,6 +102,7 @@ class MainActivity : ComponentActivity() {
|
||||
profileCache = profileCache,
|
||||
accountRepository = accountRepository,
|
||||
chatRepository = chatRepository,
|
||||
concordRepository = concordRepository,
|
||||
settingsRepository = settingsRepository,
|
||||
connectivityMonitor = connectivityMonitor,
|
||||
)
|
||||
|
||||
@@ -61,4 +61,16 @@ sealed interface Screen : NavKey {
|
||||
|
||||
@Serializable
|
||||
data object Settings : Screen
|
||||
|
||||
@Serializable
|
||||
data object Communities : Screen
|
||||
|
||||
@Serializable
|
||||
data class Community(val communityId: String) : Screen
|
||||
|
||||
@Serializable
|
||||
data class Channel(val communityId: String, val channelId: String) : Screen
|
||||
|
||||
@Serializable
|
||||
data class JoinCommunity(val link: String? = null) : Screen
|
||||
}
|
||||
|
||||
@@ -102,17 +102,20 @@ import su.reya.coop.RoomKind
|
||||
import su.reya.coop.RoomUiState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.ago
|
||||
import su.reya.coop.concord.parseInviteLink
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.shared.getExpressiveFontFamily
|
||||
import su.reya.coop.uiStateFlow
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
import su.reya.coop.viewmodel.ChatViewModel
|
||||
import su.reya.coop.viewmodel.ConcordViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
chatViewModel: ChatViewModel
|
||||
chatViewModel: ChatViewModel,
|
||||
concordViewModel: ConcordViewModel,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val navigator = LocalNavigator.current
|
||||
@@ -166,16 +169,22 @@ fun HomeScreen(
|
||||
|
||||
LaunchedEffect(qrScanResult.content) {
|
||||
qrScanResult.content?.let { result ->
|
||||
runCatching { PublicKey.parse(result) }
|
||||
.onSuccess { pubkey ->
|
||||
try {
|
||||
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
||||
navigator.navigate(Screen.Chat(roomId))
|
||||
} catch (e: Exception) {
|
||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||
// A Concord invite is a link, not a npub, so it is routed to the Join screen instead of
|
||||
// being force-fed to PublicKey.parse.
|
||||
if (parseInviteLink(result) != null) {
|
||||
navigator.navigate(Screen.JoinCommunity(result))
|
||||
} else {
|
||||
runCatching { PublicKey.parse(result) }
|
||||
.onSuccess { pubkey ->
|
||||
try {
|
||||
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
||||
navigator.navigate(Screen.Chat(roomId))
|
||||
} catch (e: Exception) {
|
||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
|
||||
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
|
||||
}
|
||||
// Clear the nav state
|
||||
qrScanResult.clear()
|
||||
}
|
||||
@@ -473,7 +482,8 @@ fun HomeScreen(
|
||||
BottomMenuList(
|
||||
onDismiss = dismissAndRun,
|
||||
accountViewModel = accountViewModel,
|
||||
chatViewModel = chatViewModel
|
||||
chatViewModel = chatViewModel,
|
||||
concordViewModel = concordViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -788,12 +798,14 @@ fun BottomMenuList(
|
||||
onDismiss: (suspend () -> Unit) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
chatViewModel: ChatViewModel,
|
||||
concordViewModel: ConcordViewModel,
|
||||
) {
|
||||
val navigator = LocalNavigator.current
|
||||
|
||||
val defaultMenuList = listOf(
|
||||
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
||||
"Contact List" to { navigator.navigate(Screen.ContactList) },
|
||||
"Communities (beta)" to { navigator.navigate(Screen.Communities) },
|
||||
"Relay Management" to { navigator.navigate(Screen.Relay) },
|
||||
"Settings" to { navigator.navigate(Screen.Settings) }
|
||||
)
|
||||
@@ -824,6 +836,8 @@ fun BottomMenuList(
|
||||
accountViewModel.logout(onLogout = {
|
||||
accountViewModel.resetInternalState()
|
||||
chatViewModel.resetInternalState()
|
||||
// Concord's keys are identity-scoped, so they leave with the identity.
|
||||
concordViewModel.resetInternalState()
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -38,8 +38,8 @@ fun ChatInput(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
onUpload: () -> Unit,
|
||||
onMicClick: () -> Unit
|
||||
onUpload: (() -> Unit)? = null,
|
||||
onMicClick: (() -> Unit)? = null
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
@@ -60,12 +60,16 @@ fun ChatInput(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
imeAction = ImeAction.Default
|
||||
),
|
||||
leadingIcon = {
|
||||
IconButton(onClick = onUpload) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_add_circle),
|
||||
contentDescription = "Upload",
|
||||
)
|
||||
leadingIcon = if (onUpload == null) {
|
||||
null
|
||||
} else {
|
||||
{
|
||||
IconButton(onClick = onUpload) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_add_circle),
|
||||
contentDescription = "Upload",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -75,9 +79,21 @@ fun ChatInput(
|
||||
transitionSpec = { (scaleIn() + fadeIn()) togetherWith (scaleOut() + fadeOut()) },
|
||||
label = "send_mic_transition"
|
||||
) { isNotEmpty ->
|
||||
if (isNotEmpty) {
|
||||
// Nothing to send and nowhere to dictate to: the send button stays, greyed out.
|
||||
if (!isNotEmpty && onMicClick != null) {
|
||||
FilledTonalIconButton(
|
||||
onClick = onMicClick,
|
||||
modifier = Modifier.size(56.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_audio),
|
||||
contentDescription = "Speech to Text"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(
|
||||
onClick = onSend,
|
||||
enabled = isNotEmpty,
|
||||
modifier = Modifier.size(56.dp),
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
@@ -89,16 +105,6 @@ fun ChatInput(
|
||||
contentDescription = "Send"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
FilledTonalIconButton(
|
||||
onClick = onMicClick,
|
||||
modifier = Modifier.size(56.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_audio),
|
||||
contentDescription = "Speech to Text"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
package su.reya.coop.screens.communities
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.ime
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalProfileCache
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Profile
|
||||
import su.reya.coop.concord.ConcordMessage
|
||||
import su.reya.coop.dayLabel
|
||||
import su.reya.coop.sanitizeName
|
||||
import su.reya.coop.screens.chat.ChatInput
|
||||
import su.reya.coop.screens.chat.DateSeparator
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.short
|
||||
import su.reya.coop.timeLabel
|
||||
import su.reya.coop.viewmodel.ChannelScreenViewModel
|
||||
|
||||
/**
|
||||
* One Channel's history, laid out Discord-style: every message carries its author, because a Channel
|
||||
* has many speakers rather than the two a DM has.
|
||||
*
|
||||
* The list is reversed, exactly as `ChatScreen` is, so the newest message sits at the bottom without
|
||||
* any scroll bookkeeping — the item order below is bottom-to-top, and the day separator is placed
|
||||
* after its group for that reason.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ChannelScreen(viewModel: ChannelScreenViewModel) {
|
||||
val navigator = LocalNavigator.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val channel by viewModel.channel.collectAsStateWithLifecycle()
|
||||
val currentUser by viewModel.currentUser.collectAsStateWithLifecycle()
|
||||
|
||||
var text by remember { mutableStateOf("") }
|
||||
|
||||
val grouped by remember {
|
||||
derivedStateOf {
|
||||
viewModel.messages
|
||||
.groupBy { it.dayLabel() }
|
||||
.toList()
|
||||
.reversed()
|
||||
.map { (day, dayMessages) -> day to dayMessages.asReversed() }
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "#${channel?.name ?: "channel"}",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (channel?.hasKey == false) {
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = "No key",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navigator.goBack() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
content = { innerPadding ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = innerPadding.calculateTopPadding()),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||
) {
|
||||
if (viewModel.loading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
LoadingIndicator()
|
||||
}
|
||||
} else if (viewModel.messages.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "No messages yet",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
// Nothing to add for a Channel we hold no key for: the bar at the
|
||||
// bottom of this screen is already saying why it is empty.
|
||||
if (channel?.hasKey != false) {
|
||||
Text(
|
||||
text = "Say something to start it off.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
state = listState,
|
||||
reverseLayout = true,
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
) {
|
||||
grouped.forEach { (day, dayMessages) ->
|
||||
items(dayMessages, key = { it.idHex }) { message ->
|
||||
ChannelMessageRow(
|
||||
message = message,
|
||||
isMine = message.author == currentUser?.publicKey?.toHex(),
|
||||
)
|
||||
}
|
||||
item(key = "day:$day") { DateSeparator(day) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A Private Channel we hold no key for is listable but not writable, so the input
|
||||
// is replaced by the reason rather than left to fail on send.
|
||||
if (channel?.hasKey == false) {
|
||||
Text(
|
||||
text = "This channel was not part of the invite, so this device has no key " +
|
||||
"for it.",
|
||||
modifier = Modifier.padding(16.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
} else {
|
||||
// No attachments and no dictation in v1, so the input shows neither: a button
|
||||
// that does nothing would be worse than its absence.
|
||||
ChatInput(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
onSend = {
|
||||
viewModel.sendMessage(text)
|
||||
text = ""
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelMessageRow(message: ConcordMessage, isMine: Boolean) {
|
||||
val (pubkey, profile) = rememberAuthor(message.author)
|
||||
val name = profile?.name?.sanitizeName()?.takeIf { it.isNotBlank() }
|
||||
?: pubkey?.short()
|
||||
?: message.author.take(8)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
) {
|
||||
Avatar(picture = profile?.picture, description = name, size = 36.dp)
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = if (isMine) "$name (you)" else name,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Spacer(modifier = Modifier.size(6.dp))
|
||||
Text(
|
||||
text = message.timeLabel(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = message.content,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The profile behind a message's author, fetched the ordinary Nostr way — see `Avatar`. */
|
||||
@Composable
|
||||
private fun rememberAuthor(authorHex: String): Pair<PublicKey?, Profile?> {
|
||||
val profileCache = LocalProfileCache.current
|
||||
val pubkey = remember(authorHex) { runCatching { PublicKey.parse(authorHex) }.getOrNull() }
|
||||
val profile by remember(pubkey) {
|
||||
pubkey?.let { profileCache.getMetadata(it) } ?: flowOf<Profile?>(null)
|
||||
}.collectAsStateWithLifecycle(null)
|
||||
|
||||
return pubkey to profile
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
package su.reya.coop.screens.communities
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.PlainTooltip
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TooltipAnchorPosition
|
||||
import androidx.compose.material3.TooltipBox
|
||||
import androidx.compose.material3.TooltipDefaults
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_plus
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.displayName
|
||||
import su.reya.coop.viewmodel.ConcordViewModel
|
||||
|
||||
/**
|
||||
* Every Community this device has joined, plus any Direct Invite still waiting.
|
||||
*
|
||||
* There is no file upload, no message search and no per-Community settings page — v1 is the read
|
||||
* path and sending, and the screen says so rather than pretending otherwise.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun CommunitiesScreen(concordViewModel: ConcordViewModel) {
|
||||
val navigator = LocalNavigator.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val communities by concordViewModel.communities.collectAsStateWithLifecycle()
|
||||
val directInvites by concordViewModel.directInvites.collectAsStateWithLifecycle()
|
||||
val isReady by concordViewModel.isReady.collectAsStateWithLifecycle()
|
||||
|
||||
val sorted = remember(communities) { communities.sortedBy { it.displayName().lowercase() } }
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Communities",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navigator.goBack() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
|
||||
TooltipAnchorPosition.Above,
|
||||
spacingBetweenTooltipAndAnchor = 8.dp,
|
||||
),
|
||||
tooltip = {
|
||||
PlainTooltip { Text("Join a community") }
|
||||
},
|
||||
state = rememberTooltipState(),
|
||||
) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { navigator.navigate(Screen.JoinCommunity()) },
|
||||
expanded = false,
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_plus),
|
||||
contentDescription = "Join a community"
|
||||
)
|
||||
},
|
||||
text = { Text("Join") },
|
||||
)
|
||||
}
|
||||
},
|
||||
content = { innerPadding ->
|
||||
when {
|
||||
!isReady && sorted.isEmpty() -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
LoadingIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
sorted.isEmpty() && directInvites.isEmpty() -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
CommunityEmptyState(
|
||||
title = "No communities yet",
|
||||
subtitle = "Join one with an invite link.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = innerPadding.calculateTopPadding()),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
|
||||
) {
|
||||
items(directInvites, key = { it.communityId }) { invite ->
|
||||
DirectInviteCard(
|
||||
invite = invite,
|
||||
onJoin = {
|
||||
scope.launch {
|
||||
// A Direct Invite already carries its bundle, so there is
|
||||
// nothing to fetch — only to validate and accept.
|
||||
val preview = concordViewModel.previewDirectInvite(invite)
|
||||
val joined = concordViewModel.join(preview)
|
||||
if (joined != null) {
|
||||
navigator.navigate(
|
||||
Screen.Community(joined.membership.communityId)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
concordViewModel.dismissDirectInvite(invite.communityId)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
items(sorted, key = { it.membership.communityId }) { state ->
|
||||
CommunityRow(
|
||||
state = state,
|
||||
onClick = {
|
||||
navigator.navigate(
|
||||
Screen.Community(state.membership.communityId)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
package su.reya.coop.screens.communities
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SegmentedListItem
|
||||
import androidx.compose.material3.Surface
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_communities
|
||||
import coop.composeapp.generated.resources.ic_lock
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.concord.CommunityInvite
|
||||
import su.reya.coop.concord.CommunityState
|
||||
import su.reya.coop.concord.ConcordChannel
|
||||
import su.reya.coop.displayName
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.unreadTotal
|
||||
|
||||
/**
|
||||
* Rows and small blocks shared by the Communities screens, mirroring `HomeScreen`'s `ChatRoom` and
|
||||
* `ContactListItem`: `ListItem` for the top level, `SegmentedListItem` inside a group.
|
||||
*/
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun CommunityRow(state: CommunityState, onClick: () -> Unit) {
|
||||
val unread = state.unreadTotal()
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
leadingContent = {
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (unread > 0) {
|
||||
Badge { Text(unread.toString()) }
|
||||
}
|
||||
}
|
||||
) {
|
||||
// Concord icons are encrypted blobs v1 never fetches, so this is always the placeholder.
|
||||
Avatar(picture = null, description = state.displayName())
|
||||
}
|
||||
},
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = state.displayName(),
|
||||
style = MaterialTheme.typography.titleMediumEmphasized.copy(
|
||||
fontWeight = if (unread > 0) FontWeight.SemiBold else FontWeight.Normal
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = channelCountLabel(state.channels.size),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surface)
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ChannelRow(channel: ConcordChannel, index: Int, total: Int, onClick: () -> Unit) {
|
||||
val unread = channel.unreadCount
|
||||
|
||||
SegmentedListItem(
|
||||
onClick = onClick,
|
||||
shapes = ListItemDefaults.segmentedShapes(index = index, count = total),
|
||||
leadingContent = {
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (unread > 0) {
|
||||
Badge { Text(unread.toString()) }
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center) {
|
||||
if (channel.private) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_lock),
|
||||
contentDescription = "Private",
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "#",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
content = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = channel.name,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontWeight = if (unread > 0) FontWeight.SemiBold else FontWeight.Normal
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
// A Private Channel with no key is listable but unreadable: the row says so rather
|
||||
// than opening an empty room and looking broken.
|
||||
if (!channel.hasKey) {
|
||||
Text(
|
||||
text = "No key",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A Direct Invite (CORD-05 §6) waiting to be accepted. It arrived giftwrapped to this npub, so it is
|
||||
* shown on the Communities list rather than in any chat.
|
||||
*/
|
||||
@Composable
|
||||
fun DirectInviteCard(invite: CommunityInvite, onJoin: () -> Unit, onDismiss: () -> Unit) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Invited to a community",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(4.dp))
|
||||
Text(
|
||||
text = invite.name ?: "Untitled community",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(2.dp))
|
||||
Text(
|
||||
text = shortCommunityId(invite.communityId),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(onClick = onDismiss) { Text("Dismiss") }
|
||||
Button(onClick = onJoin) { Text("Join") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The app's empty-state convention: two centred lines, with the feature's mark above them. */
|
||||
@Composable
|
||||
fun CommunityEmptyState(title: String, subtitle: String) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_communities),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(56.dp),
|
||||
tint = MaterialTheme.colorScheme.outlineVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Says out loud what v1 does not do yet. Concord's authority is a signed roster every client folds
|
||||
* (CORD-04), and this build folds none of it — a stale claim of moderation would be worse than none.
|
||||
*/
|
||||
@Composable
|
||||
fun BetaNotice() {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_lock),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.size(12.dp))
|
||||
Text(
|
||||
text = "Beta. Messages, channels and invites work. Roles, kicks and bans are not " +
|
||||
"enforced by this build yet — only by the other clients in the community.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun channelCountLabel(count: Int): String =
|
||||
if (count == 1) "1 channel" else "$count channels"
|
||||
|
||||
/** A Community's identity is a hash, so show enough of it to tell two apart. */
|
||||
internal fun shortCommunityId(communityId: String): String =
|
||||
if (communityId.length <= 16) communityId else communityId.take(16) + "..."
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
package su.reya.coop.screens.communities
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.displayName
|
||||
import su.reya.coop.viewmodel.ConcordViewModel
|
||||
|
||||
/**
|
||||
* One Community's Channels, split public and private the way the protocol splits them: a Public
|
||||
* Channel's key derives from `community_root`, so anyone in the Community can read it, while a
|
||||
* Private one is only listable to those an invite or a rekey handed a key to (CORD-03 §1).
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun CommunityScreen(communityId: String, concordViewModel: ConcordViewModel) {
|
||||
val navigator = LocalNavigator.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
|
||||
val communities by concordViewModel.communities.collectAsStateWithLifecycle()
|
||||
val state = remember(communities, communityId) {
|
||||
communities.firstOrNull { it.membership.communityId == communityId }
|
||||
}
|
||||
|
||||
if (state == null) {
|
||||
// Reachable if the Community was dropped underneath this screen (a logout while it was open),
|
||||
// so it keeps a way back rather than dead-ending here.
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Community",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navigator.goBack() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "This community is not on this device.",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val (privateChannels, publicChannels) = remember(state.channels) {
|
||||
state.channels.partition { it.private }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = state.displayName(),
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navigator.goBack() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
content = { innerPadding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = innerPadding.calculateTopPadding()),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
|
||||
) {
|
||||
item { BetaNotice() }
|
||||
|
||||
itemsIndexed(publicChannels) { index, channel ->
|
||||
ChannelRow(
|
||||
channel = channel,
|
||||
index = index,
|
||||
total = publicChannels.size,
|
||||
onClick = {
|
||||
navigator.navigate(Screen.Channel(communityId, channel.idHex))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
itemsIndexed(privateChannels) { index, channel ->
|
||||
ChannelRow(
|
||||
channel = channel,
|
||||
index = index,
|
||||
total = privateChannels.size,
|
||||
onClick = {
|
||||
navigator.navigate(Screen.Channel(communityId, channel.idHex))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (state.channels.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "No channels yet. They arrive with the community's metadata.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
package su.reya.coop.screens.communities
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
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.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_scanner
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalScanResult
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.concord.InvitePreview
|
||||
import su.reya.coop.viewmodel.ConcordViewModel
|
||||
|
||||
/**
|
||||
* Paste or scan an invite link, see what joining would mean, then join.
|
||||
*
|
||||
* The preview is deliberately a separate step: a link is fetched and decrypted, but nothing is
|
||||
* subscribed and no presence is announced until the user accepts (CORD-05 §1), so backing out here
|
||||
* leaves no trace on any relay.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun JoinCommunityScreen(concordViewModel: ConcordViewModel, initialLink: String? = null) {
|
||||
val navigator = LocalNavigator.current
|
||||
val qrScanResult = LocalScanResult.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var link by remember(initialLink) { mutableStateOf(initialLink.orEmpty()) }
|
||||
var preview by remember { mutableStateOf<InvitePreview?>(null) }
|
||||
var isBusy by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(qrScanResult.content) {
|
||||
qrScanResult.content?.let { scanned ->
|
||||
link = scanned
|
||||
preview = null
|
||||
qrScanResult.clear()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Join a community",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navigator.goBack() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_scanner),
|
||||
contentDescription = "Scanner"
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
content = { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = link,
|
||||
onValueChange = {
|
||||
link = it
|
||||
// Any edit invalidates what was fetched for the old text.
|
||||
preview = null
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Invite link") },
|
||||
placeholder = { Text("https://.../invite/...") },
|
||||
maxLines = 4,
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
isBusy = true
|
||||
preview = concordViewModel.previewInvite(link)
|
||||
isBusy = false
|
||||
}
|
||||
},
|
||||
enabled = link.isNotBlank() && !isBusy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Look it up")
|
||||
}
|
||||
|
||||
preview?.let { shown ->
|
||||
InvitePreviewCard(
|
||||
preview = shown,
|
||||
isBusy = isBusy,
|
||||
onJoin = {
|
||||
scope.launch {
|
||||
isBusy = true
|
||||
val joined = concordViewModel.join(shown)
|
||||
isBusy = false
|
||||
if (joined != null) {
|
||||
// Replace this screen with the Community it just joined.
|
||||
navigator.goBack()
|
||||
navigator.navigate(
|
||||
Screen.Community(joined.membership.communityId)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun InvitePreviewCard(preview: InvitePreview, isBusy: Boolean, onJoin: () -> Unit) {
|
||||
val invite = preview.invite
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = invite.name ?: "Untitled community",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
Text(
|
||||
text = shortCommunityId(invite.communityId),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(4.dp))
|
||||
Text(
|
||||
text = plural(invite.channels.size, "channel"),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = plural(preview.relays.size, "relay"),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
|
||||
// Everything that would stop this invite from being accepted, in plain English.
|
||||
preview.problems.forEach { problem -> Refusal(problem) }
|
||||
if (preview.expired) Refusal("This invite has expired.")
|
||||
if (preview.alreadyJoined) {
|
||||
Text(
|
||||
text = "You are already in this community.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Button(
|
||||
onClick = onJoin,
|
||||
enabled = preview.joinable && !isBusy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (isBusy) {
|
||||
LoadingIndicator(modifier = Modifier.size(20.dp))
|
||||
} else {
|
||||
Text(if (preview.alreadyJoined) "Rejoin" else "Join")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Refusal(message: String) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
private fun plural(count: Int, noun: String): String =
|
||||
if (count == 1) "1 $noun" else "$count ${noun}s"
|
||||
Reference in New Issue
Block a user