add simple ui

This commit is contained in:
2026-09-15 11:07:41 +07:00
parent 9230628441
commit 63ef074e8f
25 changed files with 1751 additions and 635 deletions
@@ -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
}
@@ -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)
)
},
)
}
}
}
}
},
)
}
@@ -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) + "..."
@@ -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,
)
}
}
}
},
)
}
@@ -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"