Author SHA1 Message Date
reya 7eb4dd8e81 update 2026-08-06 16:42:40 +07:00
reya e3fec77359 add basic ios implementation 2026-08-04 08:24:40 +07:00
67 changed files with 2935 additions and 5450 deletions
-1326
View File
File diff suppressed because it is too large Load Diff
@@ -1,18 +0,0 @@
<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>
@@ -1,15 +0,0 @@
<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,7 +39,6 @@ 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
@@ -55,15 +54,9 @@ 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
@@ -97,7 +90,6 @@ fun App(
profileCache: ProfileCache,
accountRepository: AccountRepository,
chatRepository: ChatRepository,
concordRepository: ConcordRepository,
settingsRepository: SettingsRepository,
connectivityMonitor: ConnectivityMonitor,
) {
@@ -117,10 +109,6 @@ fun App(
settingsRepository
)
modelClass.isAssignableFrom(ConcordViewModel::class.java) -> ConcordViewModel(
concordRepository
)
else -> throw IllegalArgumentException("Unknown ViewModel class")
}
@Suppress("UNCHECKED_CAST")
@@ -132,7 +120,6 @@ 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
@@ -183,11 +170,6 @@ fun App(
snackbarHostState.showSnackbar(message)
}
}
launch {
concordViewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
launch {
profileCache.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
@@ -255,7 +237,7 @@ fun App(
),
entryProvider = entryProvider {
entry<Screen.Home> {
HomeScreen(accountViewModel, chatViewModel, concordViewModel)
HomeScreen(accountViewModel, chatViewModel)
}
entry<Screen.RequestList> {
RequestListScreen(chatViewModel)
@@ -315,36 +297,6 @@ 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,7 +13,6 @@ 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
@@ -53,10 +52,6 @@ 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()
@@ -102,7 +97,6 @@ class MainActivity : ComponentActivity() {
profileCache = profileCache,
accountRepository = accountRepository,
chatRepository = chatRepository,
concordRepository = concordRepository,
settingsRepository = settingsRepository,
connectivityMonitor = connectivityMonitor,
)
@@ -61,16 +61,4 @@ 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
}
@@ -63,7 +63,7 @@ class NostrForegroundService : Service() {
// Initialize Nostr client
try {
nostr.init(dbDir.absolutePath, AppStore(this@NostrForegroundService))
nostr.init(dbDir.absolutePath)
} catch (e: Exception) {
throw IllegalStateException("Failed to initialize Nostr Client", e)
}
@@ -102,20 +102,17 @@ 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,
concordViewModel: ConcordViewModel,
chatViewModel: ChatViewModel
) {
val context = LocalContext.current
val navigator = LocalNavigator.current
@@ -169,11 +166,6 @@ fun HomeScreen(
LaunchedEffect(qrScanResult.content) {
qrScanResult.content?.let { result ->
// 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 {
@@ -184,7 +176,6 @@ fun HomeScreen(
}
}
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
}
// Clear the nav state
qrScanResult.clear()
}
@@ -482,8 +473,7 @@ fun HomeScreen(
BottomMenuList(
onDismiss = dismissAndRun,
accountViewModel = accountViewModel,
chatViewModel = chatViewModel,
concordViewModel = concordViewModel
chatViewModel = chatViewModel
)
}
}
@@ -798,14 +788,12 @@ 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) }
)
@@ -836,8 +824,6 @@ 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)? = null,
onMicClick: (() -> Unit)? = null
onUpload: () -> Unit,
onMicClick: () -> Unit
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
@@ -60,17 +60,13 @@ fun ChatInput(
capitalization = KeyboardCapitalization.Sentences,
imeAction = ImeAction.Default
),
leadingIcon = if (onUpload == null) {
null
} else {
{
leadingIcon = {
IconButton(onClick = onUpload) {
Icon(
painter = painterResource(Res.drawable.ic_add_circle),
contentDescription = "Upload",
)
}
}
},
)
Spacer(modifier = Modifier.size(8.dp))
@@ -79,21 +75,9 @@ fun ChatInput(
transitionSpec = { (scaleIn() + fadeIn()) togetherWith (scaleOut() + fadeOut()) },
label = "send_mic_transition"
) { 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 {
if (isNotEmpty) {
IconButton(
onClick = onSend,
enabled = isNotEmpty,
modifier = Modifier.size(56.dp),
colors = IconButtonDefaults.iconButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
@@ -105,6 +89,16 @@ fun ChatInput(
contentDescription = "Send"
)
}
} else {
FilledTonalIconButton(
onClick = onMicClick,
modifier = Modifier.size(56.dp),
) {
Icon(
painter = painterResource(Res.drawable.ic_audio),
contentDescription = "Speech to Text"
)
}
}
}
}
@@ -5,13 +5,9 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
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.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -38,7 +34,6 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import rust.nostr.sdk.EventId
import rust.nostr.sdk.PublicKey
@@ -51,13 +46,6 @@ import su.reya.coop.formatAsTime
import su.reya.coop.isImageUrl
import su.reya.coop.removeImageUrls
@Immutable
data class ReactionGroup(
val emoji: String,
val authors: List<PublicKey>,
val containsMe: Boolean
)
@Immutable
data class MessageModel(
val id: EventId,
@@ -66,20 +54,15 @@ data class MessageModel(
val images: List<String>,
val timestamp: String,
val isMine: Boolean,
val replyEventIds: List<EventId>,
val reactions: List<ReactionGroup> = emptyList()
val replyEventIds: List<EventId>
)
@Composable
fun rememberMessageModel(
event: UnsignedEvent,
reactions: List<UnsignedEvent> = emptyList(),
currentUser: PublicKey? = null
): MessageModel {
fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null): MessageModel {
val settings = LocalSettings.current
val isMobileData = LocalConnectivity.current
return remember(event, reactions, currentUser, settings, isMobileData) {
return remember(event, currentUser, settings, isMobileData) {
val id = event.ensureId().id()!!
val isMine = currentUser == event.author()
val content = event.content()
@@ -121,16 +104,6 @@ fun rememberMessageModel(
append(cleanedContent.substring(lastIndex))
}
val groupedReactions = reactions.groupBy { it.content() }
.map { (emoji, events) ->
val authors = events.map { it.author() }
ReactionGroup(
emoji = emoji,
authors = authors,
containsMe = authors.any { it == currentUser }
)
}
MessageModel(
id = id,
author = event.author(),
@@ -138,8 +111,7 @@ fun rememberMessageModel(
images = images,
timestamp = event.createdAt().formatAsTime(),
isMine = isMine,
replyEventIds = replyEventIds,
reactions = groupedReactions
replyEventIds = replyEventIds
)
}
}
@@ -185,15 +157,6 @@ fun ChatMessage(
),
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Box(contentAlignment = Alignment.BottomEnd) {
Column(
modifier = Modifier.padding(
bottom = if (model.reactions.isNotEmpty()) 4.dp else 0.dp,
end = if (model.reactions.isNotEmpty() && !model.isMine) 8.dp else 0.dp
),
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
if (model.annotatedContent.isNotBlank()) {
Surface(
@@ -225,71 +188,16 @@ fun ChatMessage(
)
}
}
}
if (model.reactions.isNotEmpty()) {
MessageReactions(
emojis = model.reactions.map { it.emoji },
total = model.reactions.sumOf { it.authors.size },
modifier = Modifier.offset(y = 12.dp)
)
}
}
if (isMessageClicked) {
Text(
text = model.timestamp,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.padding(top = if (model.reactions.isNotEmpty()) 8.dp else 0.dp)
modifier = Modifier.align(
if (model.isMine) Alignment.End else Alignment.Start
)
)
}
}
}
}
/** The reaction chips under a bubble, shared by DMs and Channels. */
@Composable
fun MessageReactions(
emojis: List<String>,
total: Int,
modifier: Modifier = Modifier
) {
val displayEmojis = emojis.take(3)
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
displayEmojis.forEach { emoji ->
Surface(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.surface,
shape = CircleShape,
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = emoji,
fontSize = 12.sp,
)
}
}
}
if (total > 2) {
Surface(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.surface,
shape = CircleShape,
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = total.toString(),
style = MaterialTheme.typography.labelSmall,
fontSize = 10.sp,
)
}
}
}
}
}
@@ -80,7 +80,6 @@ import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
@@ -92,7 +91,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.EventId
import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalProfileCache
@@ -146,21 +144,10 @@ fun ChatScreen(
val loading = viewModel.loading
val newOtherMessages = viewModel.newOtherMessages
val requireScreening = viewModel.requireScreening
val allEvents = viewModel.messages
val displayMessages by remember {
derivedStateOf { allEvents.filter { it.kind().asStd() != KindStandard.REACTION } }
}
val reactionsByMessage by remember {
derivedStateOf {
allEvents.filter { it.kind().asStd() == KindStandard.REACTION }
.groupBy { it.tags().eventIds().firstOrNull() }
}
}
val messages = viewModel.messages
val groupedMessages =
remember { derivedStateOf { displayMessages.groupBy { it.createdAt().formatAsGroup() } } }
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroup() } } }
val roomState by remember(id, currentUser?.publicKey) {
(room as Room).uiStateFlow(profileCache, currentUser?.publicKey)
@@ -183,7 +170,7 @@ fun ChatScreen(
for (group in groupedMessages.value) {
val msgInGroup = group.value
val idx = msgInGroup.indexOfFirst { it.ensureId().id() == eventId }
val idx = msgInGroup.indexOfFirst { it.id() == eventId }
if (idx != -1) {
targetIndex = currentIndex + idx
break
@@ -227,8 +214,8 @@ fun ChatScreen(
}
}
LaunchedEffect(allEvents.size) {
if (displayMessages.isNotEmpty()) {
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
listState.animateScrollToItem(0)
}
}
@@ -306,7 +293,7 @@ fun ChatScreen(
room?.let { ScreenerCard(accountViewModel, it) }
}
when (displayMessages.isNotEmpty()) {
when (messages.isNotEmpty()) {
true -> {
LazyColumn(
modifier = Modifier
@@ -321,15 +308,14 @@ fun ChatScreen(
items = messagesInGroup,
key = { it.ensureId().id()?.toHex()!! }
) { event ->
val msgReactions = reactionsByMessage[event.id()] ?: emptyList()
val model =
rememberMessageModel(event, msgReactions, currentUser?.publicKey)
rememberMessageModel(event, currentUser?.publicKey)
val replyPreview =
remember(model.replyEventIds, displayMessages.size) {
remember(model.replyEventIds, messages.size) {
model.replyEventIds.firstOrNull()
?.let { replyId ->
displayMessages.find { it.ensureId().id() == replyId }
messages.find { it.id() == replyId }
}
}
@@ -478,11 +464,14 @@ fun ChatScreen(
val (model, bounds) = selectedMessage ?: return@AnimatedVisibility
val density = LocalDensity.current
val windowInfo = LocalWindowInfo.current
val windowHeight = windowInfo.containerSize.height
val scrollState = rememberScrollState()
var menuHeight by remember { mutableFloatStateOf(0f) }
var toolbarHeight by remember { mutableFloatStateOf(0f) }
val spacing = with(density) { 6.dp.toPx() }
val spacing = with(density) { 12.dp.toPx() }
val showAbove =
(windowHeight - bounds.bottom) < (menuHeight + spacing) && bounds.top > (menuHeight + spacing)
Box(
modifier = Modifier
@@ -491,30 +480,11 @@ fun ChatScreen(
.clickable { selectedMessage = null }
.verticalScroll(scrollState),
) {
val totalExtraHeight = (if (menuHeight > 0) menuHeight + spacing else 300f) +
(if (toolbarHeight > 0) toolbarHeight + spacing else 100f)
val totalExtraHeight = if (menuHeight > 0) menuHeight + spacing else 300f
val contentBottom = with(density) { (bounds.bottom + totalExtraHeight).toDp() }
Spacer(modifier = Modifier.height(contentBottom + 200.dp))
// Reaction Toolbar (Above)
Box(
modifier = Modifier
.offset { IntOffset(0, (bounds.top - toolbarHeight - spacing).toInt().coerceAtLeast(0)) }
.onGloballyPositioned { toolbarHeight = it.size.height.toFloat() }
.fillMaxWidth()
.padding(horizontal = 16.dp),
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
) {
ReactionToolbar(
onReaction = { reaction ->
viewModel.sendReaction(model.id, reaction)
selectedMessage = null
}
)
}
// Message Preview
ChatMessage(
model = model,
modifier = Modifier
@@ -522,17 +492,21 @@ fun ChatScreen(
.padding(horizontal = 16.dp)
)
// Action Menu (Below)
val menuOffset = if (showAbove) {
bounds.top - menuHeight - spacing
} else {
bounds.bottom + spacing
}
Box(
modifier = Modifier
.offset { IntOffset(0, (bounds.bottom + spacing).toInt()) }
.offset { IntOffset(0, menuOffset.toInt().coerceAtLeast(0)) }
.onGloballyPositioned { menuHeight = it.size.height.toFloat() }
.fillMaxWidth()
.padding(horizontal = 16.dp),
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
) {
ContextMenu(
onAction = { action ->
ContextMenu { action ->
when (action) {
"Copy" -> {
scope.launch {
@@ -550,7 +524,6 @@ fun ChatScreen(
}
selectedMessage = null
}
)
}
}
}
@@ -649,41 +622,9 @@ private fun ReplyPreview(
}
}
@Composable
fun ReactionToolbar(
onReaction: (String) -> Unit
) {
val reactionEmojis = listOf("👍", "❤️", "😂", "😮", "😢", "😡", "🎉")
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(24.dp),
shadowElevation = 1.dp
) {
Row(
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
reactionEmojis.forEach { emoji ->
Text(
text = emoji,
modifier = Modifier
.clickable { onReaction(emoji) }
.padding(4.dp),
fontSize = 24.sp
)
}
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun ContextMenu(
onAction: (String) -> Unit
) {
private fun ContextMenu(onAction: (String) -> Unit) {
val menuItems = listOf(
"Copy" to Res.drawable.ic_copy,
"Reply" to Res.drawable.ic_reply
@@ -692,8 +633,6 @@ private fun ContextMenu(
DropdownMenuGroup(
shapes = MenuDefaults.groupShape(1, 1),
containerColor = MenuDefaults.groupVibrantContainerColor,
tonalElevation = 1.dp,
shadowElevation = 1.dp,
modifier = Modifier.width(220.dp)
) {
val itemCount = menuItems.size
@@ -1,365 +0,0 @@
package su.reya.coop.screens.communities
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.TextButton
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.screens.chat.MessageReactions
import su.reya.coop.screens.chat.ReactionToolbar
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("") }
/** The message whose action row is open, if any. */
var activeMessageId by remember { mutableStateOf<String?>(null) }
/** The message an Edit is being written for, if any. Its text lives in [text]. */
var editing by remember { mutableStateOf<ConcordMessage?>(null) }
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(),
active = activeMessageId == message.idHex,
onDismissActions = { activeMessageId = null },
onLongPress = { activeMessageId = message.idHex },
onReaction = { emoji ->
viewModel.sendReaction(message, emoji)
activeMessageId = null
},
onEdit = {
editing = message
text = message.content
activeMessageId = null
},
)
}
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 {
if (editing != null) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Editing a message",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
TextButton(onClick = { editing = null; text = "" }) { Text("Cancel") }
}
}
// 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 = {
val target = editing
if (target == null) viewModel.sendMessage(text)
else viewModel.editMessage(target.idHex, text)
editing = null
text = ""
},
)
}
}
}
},
)
}
@Composable
private fun ChannelMessageRow(
message: ConcordMessage,
isMine: Boolean,
active: Boolean,
onDismissActions: () -> Unit,
onLongPress: () -> Unit,
onReaction: (String) -> Unit,
onEdit: () -> Unit,
) {
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)
.combinedClickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onDismissActions,
onLongClick = onLongPress,
)
) {
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,
)
if (message.edited) {
Spacer(modifier = Modifier.size(4.dp))
Text(
text = "(edited)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
)
}
}
Text(
text = message.content,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
if (message.reactions.isNotEmpty()) {
MessageReactions(
emojis = message.reactions.map { it.emoji },
total = message.reactions.sumOf { it.authors.size },
modifier = Modifier.padding(top = 4.dp),
)
}
if (active) {
Column(modifier = Modifier.padding(top = 8.dp)) {
ReactionToolbar(onReaction = onReaction)
// Only the author may edit, so only they are offered it.
if (isMine) {
TextButton(onClick = onEdit) { Text("Edit") }
}
}
}
}
}
}
/** 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
}
@@ -1,185 +0,0 @@
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)
)
},
)
}
}
}
}
},
)
}
@@ -1,254 +0,0 @@
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) + "..."
@@ -1,171 +0,0 @@
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,
)
}
}
}
},
)
}
@@ -1,237 +0,0 @@
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"
+3
View File
@@ -5,3 +5,6 @@ PRODUCT_BUNDLE_IDENTIFIER=su.reya.coop.Coop$(TEAM_ID)
CURRENT_PROJECT_VERSION=1
MARKETING_VERSION=1.0
FRAMEWORK_SEARCH_PATHS=$(inherited) "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"
OTHER_LDFLAGS=$(inherited) -framework Shared
+14
View File
@@ -0,0 +1,14 @@
import Foundation
enum AppRoute: Hashable {
case home
case requestList
case contactList
case updateProfile
case newChat
case myQr
case relay
case settings
case chat(id: Int64, screening: Bool)
case profile(pubkey: String)
}
+103
View File
@@ -0,0 +1,103 @@
import Foundation
import Shared
import UIKit
@MainActor
@Observable
final class AppState {
let bootstrap: Bootstrap
private var subscriptions: [FlowSubscription] = []
var path: [AppRoute] = []
var signerRequired: Bool?
var isSyncing = false
var partialProcessed = false
var chatRooms: [Room] = []
var accountState: AccountState?
var settings: Settings?
var currentUserProfile: Profile?
var isUpdatingProfile = false
var errorMessage: String?
let networkMonitor = NetworkMonitor()
init() {
bootstrap = Bootstrap.companion.create(storage: KeychainStorage())
}
func start() {
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let dbDir = docs.appendingPathComponent("nostr", isDirectory: true)
try? FileManager.default.createDirectory(at: dbDir, withIntermediateDirectories: true)
bootstrap.start(dbPath: dbDir.path) { [weak self] event in
self?.handleNewMessage(event)
}
subscriptions.append(bootstrap.watchAccountState { [weak self] state in
self?.accountState = state
self?.signerRequired = state.signerRequired?.boolValue
})
subscriptions.append(bootstrap.watchChatRooms { [weak self] rooms in
self?.chatRooms = rooms
})
subscriptions.append(bootstrap.watchIsSyncing { [weak self] value in
self?.isSyncing = value.boolValue
})
subscriptions.append(bootstrap.watchPartialProcessed { [weak self] value in
self?.partialProcessed = value.boolValue
})
subscriptions.append(bootstrap.watchSettings { [weak self] settings in
self?.settings = settings
})
subscriptions.append(bootstrap.watchCurrentUserProfile { [weak self] profile in
self?.currentUserProfile = profile
})
subscriptions.append(bootstrap.watchIsUpdatingProfile { [weak self] value in
self?.isUpdatingProfile = value.boolValue
})
subscriptions.append(bootstrap.watchErrors { [weak self] message in
self?.errorMessage = message
})
}
func handle(_ url: URL) {
guard url.scheme == "coop" else { return }
switch url.host {
case "chat":
if let id = Int64(url.pathComponents.dropFirst().first ?? "") {
path.append(.chat(id: id, screening: false))
}
case "profile":
let pubkey = url.pathComponents.dropFirst().first ?? ""
if !pubkey.isEmpty {
path.append(.profile(pubkey: pubkey))
}
default:
break
}
}
func logout() {
bootstrap.logout()
bootstrap.resetState()
path = []
}
func resume() {
Task { try? await bootstrap.resume() }
}
func pause() {
let taskId = UIApplication.shared.beginBackgroundTask()
Task {
try? await bootstrap.pause()
UIApplication.shared.endBackgroundTask(taskId)
}
}
private func handleNewMessage(_ event: Nostr_sdk_kmpUnsignedEvent) {
NotificationService.shared.notifyNewMessage(roomId: event.roomId(), content: event.content())
}
}
+67 -19
View File
@@ -2,32 +2,80 @@ import SwiftUI
import Shared
struct ContentView: View {
@State private var showContent = false
@Environment(AppState.self) private var appState
var body: some View {
VStack {
Button("Click me!") {
withAnimation {
showContent = !showContent
Group {
switch appState.signerRequired {
case .none:
SplashView()
case .some(true):
OnboardingView()
case .some(false):
NavigationStack(path: Bindable(appState).path) {
HomeView()
.navigationDestination(for: AppRoute.self) { route in
destination(for: route)
}
}
}
}
.alert("Error", isPresented: Binding(
get: { appState.errorMessage != nil },
set: { if !$0 { appState.errorMessage = nil } }
)) {
Button("OK") { appState.errorMessage = nil }
} message: {
Text(appState.errorMessage ?? "")
}
.preferredColorScheme(colorScheme)
}
private var colorScheme: ColorScheme? {
switch appState.settings?.theme {
case Theme.light:
return .light
case Theme.dark:
return .dark
default:
return nil
}
}
if showContent {
VStack(spacing: 16) {
Image(systemName: "swift")
.font(.system(size: 200))
.foregroundColor(.accentColor)
Text("SwiftUI: \(Greeting().greet())")
@ViewBuilder
private func destination(for route: AppRoute) -> some View {
switch route {
case .home:
HomeView()
case .requestList:
RequestListView()
case .contactList:
ContactListView()
case .updateProfile:
UpdateProfileView()
case .newChat:
NewChatView()
case .myQr:
MyQrView()
case .relay:
RelayView()
case .settings:
SettingsView()
case .chat(let id, let screening):
ChatView(roomId: id, screening: screening)
case .profile(let pubkey):
ProfileView(pubkey: pubkey)
}
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.padding()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
struct SplashView: View {
var body: some View {
ZStack {
Color(.systemBackground).ignoresSafeArea()
Image(systemName: "bubble.left.and.bubble.right.fill")
.font(.system(size: 64))
.foregroundStyle(.tint)
}
}
}
+13
View File
@@ -4,5 +4,18 @@
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Coop uses the camera to scan QR codes for adding contacts and importing identities.</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>su.reya.coop</string>
<key>CFBundleURLSchemes</key>
<array>
<string>coop</string>
</array>
</dict>
</array>
</dict>
</plist>
@@ -0,0 +1,73 @@
import Foundation
import Security
import Shared
final class KeychainStorage: AppStorage {
private let defaults = UserDefaults.standard
private let service = "su.reya.coop"
func get(key: String, completionHandler: @escaping (String?, Error?) -> Void) {
completionHandler(defaults.string(forKey: key), nil)
}
func set(key: String, value: String, completionHandler: @escaping (Error?) -> Void) {
defaults.set(value, forKey: key)
completionHandler(nil)
}
func getSecret(key: String, completionHandler: @escaping (String?, Error?) -> Void) {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne,
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess, let data = item as? Data else {
completionHandler(nil, nil)
return
}
completionHandler(String(data: data, encoding: .utf8), nil)
}
func setSecret(key: String, value: String, completionHandler: @escaping (Error?) -> Void) {
let data = Data(value.utf8)
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key,
]
if SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess {
SecItemUpdate(query as CFDictionary, [kSecValueData: data] as CFDictionary)
} else {
var add = query
add[kSecValueData] = data
add[kSecAttrAccessible] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
SecItemAdd(add as CFDictionary, nil)
}
completionHandler(nil)
}
func clear(key: String, completionHandler: @escaping (Error?) -> Void) {
defaults.removeObject(forKey: key)
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key,
]
SecItemDelete(query as CFDictionary)
completionHandler(nil)
}
func has(key: String, completionHandler: @escaping (KotlinBoolean?, Error?) -> Void) {
if defaults.object(forKey: key) != nil {
completionHandler(KotlinBoolean(booleanLiteral: true), nil)
return
}
getSecret(key: key) { secret, _ in
completionHandler(KotlinBoolean(booleanLiteral: secret != nil), nil)
}
}
}
@@ -0,0 +1,26 @@
import Foundation
import Shared
extension Data {
func toKotlinByteArray() -> KotlinByteArray {
let array = KotlinByteArray(size: Int32(count))
for (index, byte) in enumerated() {
array.set(index: Int32(index), value: Int8(bitPattern: byte))
}
return array
}
}
extension KotlinBoolean {
var value: Bool { boolValue }
}
extension String {
func isImageUrl() -> Bool {
ExtensionsKt.isImageUrl(self)
}
func removeImageUrls() -> String {
ExtensionsKt.removeImageUrls(self)
}
}
@@ -0,0 +1,24 @@
import Foundation
import Network
@Observable
final class NetworkMonitor {
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "su.reya.coop.network")
private(set) var isMobileData = false
init() {
monitor.pathUpdateHandler = { [weak self] path in
let mobile = path.usesInterfaceType(.cellular)
Task { @MainActor in
self?.isMobileData = mobile
}
}
monitor.start(queue: queue)
}
deinit {
monitor.cancel()
}
}
@@ -0,0 +1,56 @@
import Foundation
import UserNotifications
final class NotificationService: NSObject, UNUserNotificationCenterDelegate {
static let shared = NotificationService()
private let center = UNUserNotificationCenter.current()
var onOpenChat: ((Int64) -> Void)?
override init() {
super.init()
center.delegate = self
}
func requestPermissionIfNeeded() {
center.getNotificationSettings { settings in
if settings.authorizationStatus == .notDetermined {
self.center.requestAuthorization(options: [.alert, .badge, .sound]) { _, _ in }
}
}
}
func notifyNewMessage(roomId: Int64, content: String) {
let notificationContent = UNMutableNotificationContent()
notificationContent.title = "You received a new message"
notificationContent.body = content
notificationContent.sound = .default
notificationContent.userInfo = ["roomId": roomId]
let request = UNNotificationRequest(
identifier: UUID().uuidString,
content: notificationContent,
trigger: nil
)
center.add(request)
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
[]
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
if let roomId = response.notification.request.content.userInfo["roomId"] as? Int64 {
await MainActor.run {
onOpenChat?(roomId)
}
}
}
}
+288
View File
@@ -0,0 +1,288 @@
import PhotosUI
import SwiftUI
import Shared
struct ChatView: View {
@Environment(AppState.self) private var appState
@Environment(\.dismiss) private var dismiss
let roomId: Int64
let screening: Bool
@State private var viewModel: ChatViewModel?
@State private var input = ""
@State private var photoItem: PhotosPickerItem?
@State private var authorProfiles: [String: Profile] = [:]
private var showScreener: Bool {
(viewModel?.requireScreening ?? false) && appState.settings?.screening == true
}
private var isGroup: Bool {
viewModel?.room?.isGroup() == true
}
var body: some View {
VStack(spacing: 0) {
messageList
if showScreener, let room = viewModel?.room {
ScreenerCard(
room: room,
onAccept: { viewModel?.requireScreening = false },
onReject: { dismiss() }
)
} else {
inputBar
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
if let room = viewModel?.room, let firstMember = otherMember(of: room) {
Button {
appState.path.append(.profile(pubkey: firstMember.toHex()))
} label: {
VStack(spacing: 2) {
AvatarView(
name: viewModel?.roomUi?.name ?? "?",
picture: viewModel?.roomUi?.picture,
size: 30
)
Text(viewModel?.roomUi?.name ?? "Chat")
.font(.caption)
.foregroundStyle(.primary)
}
}
} else {
Text(viewModel?.roomUi?.name ?? "Chat").font(.headline)
}
}
}
.task {
let vm = ChatViewModel(roomId: roomId, screening: screening)
viewModel = vm
vm.start(appState: appState)
}
.onDisappear {
viewModel?.stop()
}
.task(id: photoItem) {
guard let photoItem else { return }
guard let data = try? await photoItem.loadTransferable(type: Data.self) else { return }
let contentType = photoItem.supportedContentTypes.first?.preferredMIMEType ?? "image/jpeg"
viewModel?.sendImage(data, contentType: contentType, appState: appState)
self.photoItem = nil
}
}
private var messageList: some View {
ScrollView {
LazyVStack(spacing: 2) {
ForEach(groupedMessages, id: \.0) { group, messages in
Text(headerTitle(group: group, first: messages.first))
.font(.caption2)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 14)
ForEach(Array(messages.enumerated()), id: \.element.stableId) { index, event in
messageCell(event: event, at: index, in: messages)
}
}
if let last = viewModel?.messages.last, isMine(last) {
Text("Delivered")
.font(.caption2)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.top, 2)
}
}
.padding(.horizontal, 12)
}
.defaultScrollAnchor(.bottom)
.scrollDismissesKeyboard(.interactively)
.overlay {
if viewModel?.loading == true {
ProgressView()
}
}
}
@ViewBuilder
private func messageCell(
event: Nostr_sdk_kmpUnsignedEvent,
at index: Int,
in messages: [Nostr_sdk_kmpUnsignedEvent]
) -> some View {
let mine = isMine(event)
let authorHex = event.author().toHex()
let nextIsSameAuthor = index + 1 < messages.count &&
messages[index + 1].author().toHex() == authorHex
let prevIsSameAuthor = index > 0 &&
messages[index - 1].author().toHex() == authorHex
let replyId = event.tags().eventIds().first
let replied = replyId.flatMap { id in
viewModel?.messages.first { $0.id()?.toHex() == id.toHex() }
}
MessageBubble(
event: event,
isMine: mine,
showImages: showImages,
isFirstOfRun: !prevIsSameAuthor,
isLastOfRun: !nextIsSameAuthor,
showAuthorName: isGroup && !mine && !prevIsSameAuthor,
showAuthorAvatar: isGroup && !mine,
authorName: authorName(for: event),
authorPicture: authorProfiles[authorHex]?.picture,
repliedMessage: replied,
repliedAuthorName: replied.flatMap { authorName(for: $0) },
onReply: { viewModel?.replyingTo = event }
)
.padding(.bottom, nextIsSameAuthor ? 0 : 8)
}
private var inputBar: some View {
VStack(spacing: 0) {
if let replyingTo = viewModel?.replyingTo {
HStack(spacing: 8) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.accentColor)
.frame(width: 3)
VStack(alignment: .leading, spacing: 1) {
Text("Replying to \(authorName(for: replyingTo))")
.font(.caption.bold())
Text(replyingTo.content())
.font(.caption)
.lineLimit(1)
.foregroundStyle(.secondary)
}
Spacer()
Button {
viewModel?.replyingTo = nil
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.bar)
}
HStack(alignment: .bottom, spacing: 10) {
PhotosPicker(selection: $photoItem, matching: .images) {
Image(systemName: "plus")
.font(.system(size: 18, weight: .medium))
.foregroundStyle(.secondary)
.frame(width: 34, height: 34)
.background(Color(.secondarySystemFill), in: Circle())
}
TextField("Message", text: $input, axis: .vertical)
.lineLimit(1...6)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background {
Capsule()
.strokeBorder(Color(.systemGray4), lineWidth: 1)
}
if !input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Button {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
viewModel?.send(input, appState: appState)
input = ""
} label: {
Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 32))
.foregroundStyle(Color(.systemBlue))
}
.transition(.scale.combined(with: .opacity))
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.bar)
.animation(.snappy(duration: 0.2), value: input.isEmpty)
}
}
private var groupedMessages: [(String, [Nostr_sdk_kmpUnsignedEvent])] {
let messages = viewModel?.messages ?? []
var groups: [(String, [Nostr_sdk_kmpUnsignedEvent])] = []
var currentKey = ""
for message in messages {
let key = message.createdAt().formatAsGroup()
if key != currentKey {
groups.append((key, [message]))
currentKey = key
} else {
groups[groups.count - 1].1.append(message)
}
}
return groups
}
private func headerTitle(group: String, first: Nostr_sdk_kmpUnsignedEvent?) -> String {
guard let first else { return group }
return "\(group) \(first.createdAt().formatAsTime())"
}
private var showImages: Bool {
guard let media = appState.settings?.media else { return true }
switch media {
case MediaConfig.disabled:
return false
case MediaConfig.disabledformobiledata:
return !appState.networkMonitor.isMobileData
default:
return true
}
}
private func isMine(_ event: Nostr_sdk_kmpUnsignedEvent) -> Bool {
event.author().toHex() == appState.bootstrap.currentPublicKey()?.toHex()
}
private func otherMember(of room: Room) -> Nostr_sdk_kmpPublicKey? {
let selfHex = appState.bootstrap.currentPublicKey()?.toHex()
return room.members.first { $0.toHex() != selfHex } ?? room.members.first
}
private func authorName(for event: Nostr_sdk_kmpUnsignedEvent) -> String {
let hex = event.author().toHex()
if hex == appState.bootstrap.currentPublicKey()?.toHex() {
return appState.currentUserProfile?.name ?? "You"
}
if let profile = authorProfiles[hex] {
return profile.name
}
loadAuthorProfile(pubkey: event.author(), hex: hex)
return event.author().short()
}
private func loadAuthorProfile(pubkey: Nostr_sdk_kmpPublicKey, hex: String) {
guard authorProfiles[hex] == nil else { return }
let sub = appState.bootstrap.watchProfile(pubkey: pubkey) { profile in
Task { @MainActor in
if let profile {
authorProfiles[hex] = profile
}
}
}
Task { @MainActor in
try? await Task.sleep(for: .seconds(30))
sub.cancel()
}
}
}
private extension Nostr_sdk_kmpUnsignedEvent {
var stableId: String {
id()?.toHex() ?? "\(createdAt().asSecs())-\(content().hashValue)"
}
}
@@ -0,0 +1,82 @@
import SwiftUI
import Shared
@MainActor
@Observable
final class ChatViewModel {
let roomId: Int64
var messages: [Nostr_sdk_kmpUnsignedEvent] = []
var room: Room?
var roomUi: RoomUiState?
var loading = true
var replyingTo: Nostr_sdk_kmpUnsignedEvent?
var requireScreening: Bool
private var subscriptions: [FlowSubscription] = []
init(roomId: Int64, screening: Bool) {
self.roomId = roomId
self.requireScreening = screening
}
func start(appState: AppState) {
let bootstrap = appState.bootstrap
room = bootstrap.getChatRoom(id: roomId)
if let room {
subscriptions.append(bootstrap.watchRoomUi(
room: room,
currentUser: bootstrap.currentPublicKey()
) { [weak self] state in
Task { @MainActor in self?.roomUi = state }
})
}
subscriptions.append(bootstrap.watchNewEvents { [weak self] event in
Task { @MainActor in
guard let self, event.roomId() == self.roomId else { return }
if !self.messages.contains(where: { $0.id()?.toHex() == event.id()?.toHex() }) {
self.messages.append(event)
self.messages.sort { $0.createdAt().asSecs() < $1.createdAt().asSecs() }
}
appState.bootstrap.markRoomRead(id: self.roomId)
}
})
Task {
let loaded = (try? await bootstrap.loadRoomMessages(roomId: roomId)) ?? []
messages = loaded.sorted { $0.createdAt().asSecs() < $1.createdAt().asSecs() }
loading = false
bootstrap.markRoomRead(id: roomId)
bootstrap.connectRoom(id: roomId)
}
}
func stop() {
subscriptions.forEach { $0.cancel() }
subscriptions = []
}
func send(_ text: String, appState: AppState) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
if let replyTo = replyingTo, let replyId = replyTo.id() {
appState.bootstrap.sendReplyMessage(roomId: roomId, text: trimmed, replyTo: replyId)
} else {
appState.bootstrap.sendTextMessage(roomId: roomId, text: trimmed)
}
replyingTo = nil
requireScreening = false
}
func sendImage(_ data: Data, contentType: String, appState: AppState) {
appState.bootstrap.sendImageMessage(
roomId: roomId,
file: data.toKotlinByteArray(),
contentType: contentType
)
requireScreening = false
}
}
@@ -0,0 +1,177 @@
import SwiftUI
import Shared
struct MessageBubble: View {
let event: Nostr_sdk_kmpUnsignedEvent
let isMine: Bool
let showImages: Bool
let isFirstOfRun: Bool
let isLastOfRun: Bool
let showAuthorName: Bool
let showAuthorAvatar: Bool
let authorName: String?
let authorPicture: String?
let repliedMessage: Nostr_sdk_kmpUnsignedEvent?
let repliedAuthorName: String?
let onReply: () -> Void
@State private var showTimestamp = false
private var imageUrls: [URL] {
guard showImages else { return [] }
return event.content()
.components(separatedBy: .whitespacesAndNewlines)
.filter { $0.isImageUrl() }
.compactMap { URL(string: $0) }
}
private var text: String {
showImages ? event.content().removeImageUrls() : event.content()
}
var body: some View {
HStack(alignment: .bottom, spacing: 6) {
if isMine {
Spacer(minLength: 48)
} else {
avatarGutter
}
VStack(alignment: isMine ? .trailing : .leading, spacing: 2) {
if showAuthorName, let authorName {
Text(authorName)
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, 16)
}
if let repliedMessage {
replyPreview(repliedMessage)
}
ForEach(imageUrls, id: \.absoluteString) { url in
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFit()
case .failure:
ContentUnavailableView("Image unavailable", systemImage: "photo.badge.exclamationmark")
.frame(height: 120)
default:
ProgressView()
.frame(height: 120)
}
}
.frame(maxWidth: 260)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
if !text.isEmpty {
Text(text)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.foregroundStyle(isMine ? .white : .primary)
.background(
isMine ? Color(.systemBlue) : Color(.systemGray5),
in: BubbleShape(isMine: isMine, tail: isLastOfRun)
)
}
if showTimestamp {
Text(event.createdAt().formatAsTime())
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.horizontal, 16)
}
}
if !isMine { Spacer(minLength: 48) }
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.easeInOut(duration: 0.15)) {
showTimestamp.toggle()
}
}
.contextMenu {
Button {
UIPasteboard.general.string = event.content()
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
Button {
onReply()
} label: {
Label("Reply", systemImage: "arrowshape.turn.up.left")
}
}
}
@ViewBuilder
private var avatarGutter: some View {
if isLastOfRun, showAuthorAvatar {
AvatarView(name: authorName ?? "?", picture: authorPicture, size: 26)
} else {
Color.clear.frame(width: 26, height: 26)
}
}
private func replyPreview(_ replied: Nostr_sdk_kmpUnsignedEvent) -> some View {
VStack(alignment: .leading, spacing: 1) {
Text(repliedAuthorName ?? "Unknown")
.font(.caption2.bold())
Text(replied.content())
.font(.caption)
.lineLimit(2)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
isMine ? Color(.systemBlue).opacity(0.7) : Color(.systemGray4),
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
.foregroundStyle(isMine ? .white : .primary)
.padding(.horizontal, 4)
}
}
struct BubbleShape: Shape {
let isMine: Bool
let tail: Bool
func path(in rect: CGRect) -> Path {
let radius: CGFloat = 18
let small: CGFloat = tail ? 4 : radius
let topLeft: CGFloat = radius
let topRight: CGFloat = radius
let bottomLeft: CGFloat = isMine ? radius : small
let bottomRight: CGFloat = isMine ? small : radius
var path = Path()
path.move(to: CGPoint(x: rect.minX + topLeft, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX - topRight, y: rect.minY))
path.addArc(
center: CGPoint(x: rect.maxX - topRight, y: rect.minY + topRight),
radius: topRight, startAngle: .degrees(-90), endAngle: .degrees(0), clockwise: false
)
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - bottomRight))
path.addArc(
center: CGPoint(x: rect.maxX - bottomRight, y: rect.maxY - bottomRight),
radius: bottomRight, startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false
)
path.addLine(to: CGPoint(x: rect.minX + bottomLeft, y: rect.maxY))
path.addArc(
center: CGPoint(x: rect.minX + bottomLeft, y: rect.maxY - bottomLeft),
radius: bottomLeft, startAngle: .degrees(90), endAngle: .degrees(180), clockwise: false
)
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + topLeft))
path.addArc(
center: CGPoint(x: rect.minX + topLeft, y: rect.minY + topLeft),
radius: topLeft, startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false
)
path.closeSubpath()
return path
}
}
@@ -0,0 +1,96 @@
import SwiftUI
import Shared
struct ScreenerCard: View {
@Environment(AppState.self) private var appState
let room: Room
let onAccept: () -> Void
let onReject: () -> Void
@State private var profile: Profile?
@State private var isContact: Bool?
@State private var mutualCount: Int?
@State private var lastActivity: Nostr_sdk_kmpTimestamp?
@State private var subscription: FlowSubscription?
private var member: Nostr_sdk_kmpPublicKey? {
let selfHex = appState.bootstrap.currentPublicKey()?.toHex()
return room.members.first { $0.toHex() != selfHex } ?? room.members.first
}
var body: some View {
VStack(spacing: 16) {
AvatarView(
name: profile?.name ?? member?.short() ?? "?",
picture: profile?.picture,
size: 80
)
Text(profile?.name ?? member?.short() ?? "Unknown")
.font(.headline)
VStack(spacing: 8) {
indicator(
title: "In your contacts",
value: isContact.map { $0 ? "Yes" : "No" },
positive: isContact == true
)
indicator(
title: "Mutual contacts",
value: mutualCount.map { "\($0)" },
positive: (mutualCount ?? 0) > 0
)
indicator(
title: "Last public activity",
value: lastActivity?.humanReadable(),
positive: lastActivity != nil
)
}
.font(.subheadline)
HStack(spacing: 12) {
Button(role: .destructive, action: onReject) {
Text("Reject")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
Button(action: onAccept) {
Text("Accept")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
}
}
.padding()
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 16))
.padding()
.task {
guard let member else { return }
subscription = appState.bootstrap.watchProfile(pubkey: member) { value in
Task { @MainActor in profile = value }
}
isContact = (try? await appState.bootstrap.verifyContact(pubkey: member))?.boolValue
mutualCount = (try? await appState.bootstrap.mutualContacts(pubkey: member))?.count
lastActivity = try? await appState.bootstrap.verifyActivity(pubkey: member)
}
.onDisappear {
subscription?.cancel()
}
}
private func indicator(title: String, value: String?, positive: Bool) -> some View {
HStack {
Image(systemName: positive ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundStyle(positive ? .green : .secondary)
Text(title)
Spacer()
if let value {
Text(value).foregroundStyle(.secondary)
} else {
ProgressView().controlSize(.mini)
}
}
}
}
@@ -0,0 +1,35 @@
import SwiftUI
struct AvatarView: View {
let name: String
let picture: String?
var size: CGFloat = 44
var body: some View {
Group {
if let picture, let url = URL(string: picture), !picture.isEmpty {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
default:
placeholder
}
}
} else {
placeholder
}
}
.frame(width: size, height: size)
.clipShape(Circle())
}
private var placeholder: some View {
ZStack {
Circle().fill(Color(.secondarySystemFill))
Text(name.prefix(1).uppercased())
.font(.system(size: size * 0.45, weight: .semibold))
.foregroundStyle(.secondary)
}
}
}
@@ -0,0 +1,129 @@
import SwiftUI
import Shared
struct ContactListView: View {
@Environment(AppState.self) private var appState
@State private var showAddContact = false
@State private var showScanner = false
@State private var newContact = ""
@State private var validating = false
@State private var validationError: String?
@State private var removing: Nostr_sdk_kmpPublicKey?
private var contacts: [Nostr_sdk_kmpPublicKey] {
Array(appState.accountState?.contactList ?? [])
.sorted { $0.toHex() < $1.toHex() }
}
var body: some View {
List {
ForEach(contacts, id: \.self) { pubkey in
Button {
openChat(with: pubkey)
} label: {
ContactRow(pubkey: pubkey)
}
.tint(.primary)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
removing = pubkey
} label: {
Label("Remove", systemImage: "trash")
}
}
}
}
.overlay {
if contacts.isEmpty {
ContentUnavailableView(
"No contacts",
systemImage: "person.2",
description: Text("Add contacts to start chatting")
)
}
}
.navigationTitle("Contacts")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
HStack {
Button { showScanner = true } label: {
Image(systemName: "qrcode.viewfinder")
}
Button { showAddContact = true } label: {
Image(systemName: "plus")
}
}
}
}
.sheet(isPresented: $showScanner) {
ScanView { result in
showScanner = false
if let pubkey = appState.bootstrap.parsePublicKey(input: result) {
openChat(with: pubkey)
} else {
appState.errorMessage = "Invalid public key"
}
}
}
.alert("Add Contact", isPresented: $showAddContact) {
TextField("npub or user@domain", text: $newContact)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button("Cancel", role: .cancel) {
newContact = ""
validationError = nil
}
Button("Add") {
addContact()
}
.disabled(newContact.isEmpty || validating)
} message: {
if let validationError {
Text(validationError)
} else {
Text("Enter a nostr public key (npub) or NIP-05 address")
}
}
.alert("Remove Contact?", isPresented: Binding(
get: { removing != nil },
set: { if !$0 { removing = nil } }
)) {
Button("Cancel", role: .cancel) { removing = nil }
Button("Remove", role: .destructive) {
if let removing {
appState.bootstrap.removeContact(publicKey: removing)
}
removing = nil
}
} message: {
Text("This contact will be removed from your list.")
}
}
private func addContact() {
let value = newContact.trimmingCharacters(in: .whitespacesAndNewlines)
validating = true
Task {
defer { validating = false }
if appState.bootstrap.parsePublicKey(input: value) != nil {
appState.bootstrap.addContact(address: value)
newContact = ""
} else if value.contains("@"),
(try? await appState.bootstrap.searchByAddress(query: value)) != nil {
appState.bootstrap.addContact(address: value)
newContact = ""
} else {
validationError = "Could not find this user. Check the address and try again."
}
}
}
private func openChat(with pubkey: Nostr_sdk_kmpPublicKey) {
do {
let roomId = try appState.bootstrap.createChatRoom(recipients: [pubkey])
appState.path.append(.chat(id: roomId, screening: false))
} catch {
appState.errorMessage = error.localizedDescription
}
}
}
+118
View File
@@ -0,0 +1,118 @@
import SwiftUI
import Shared
struct HomeView: View {
@Environment(AppState.self) private var appState
@State private var showProfileSheet = false
@State private var showRelayWarning = false
private var ongoingRooms: [Room] {
appState.chatRooms.filter { $0.kind == RoomKind.ongoing }
}
private var requestRooms: [Room] {
appState.chatRooms.filter { $0.kind == RoomKind.request }
}
private var requestUnread: Int {
requestRooms.reduce(0) { $0 + Int($1.unreadCount) }
}
var body: some View {
List {
if !requestRooms.isEmpty {
Button {
appState.path.append(.requestList)
} label: {
HStack(spacing: 10) {
Circle()
.fill(requestUnread > 0 ? Color.accentColor : .clear)
.frame(width: 10, height: 10)
Image(systemName: "tray.full")
.font(.title2)
.foregroundStyle(.secondary)
.frame(width: 48, height: 48)
.background(Color(.secondarySystemFill), in: Circle())
VStack(alignment: .leading, spacing: 3) {
Text("Message Requests")
.font(.body.weight(requestUnread > 0 ? .semibold : .regular))
Text("\(requestRooms.count)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(Color(.systemGray3))
}
.padding(.vertical, 4)
}
.tint(.primary)
}
ForEach(ongoingRooms, id: \.id) { room in
Button {
appState.path.append(.chat(id: room.id, screening: false))
} label: {
RoomRow(room: room)
}
.tint(.primary)
}
}
.listStyle(.plain)
.refreshable {
appState.bootstrap.refreshChatRooms()
}
.overlay {
if !appState.partialProcessed {
ProgressView()
} else if appState.chatRooms.isEmpty {
ContentUnavailableView(
"No Messages",
systemImage: "bubble.left.and.bubble.right",
description: Text("Start a new conversation")
)
}
}
.navigationTitle("Coop")
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
showProfileSheet = true
} label: {
AvatarView(
name: appState.currentUserProfile?.name ?? "?",
picture: appState.currentUserProfile?.picture,
size: 30
)
}
}
ToolbarItem(placement: .topBarTrailing) {
HStack(spacing: 16) {
if appState.isSyncing {
ProgressView().controlSize(.small)
}
Button {
appState.path.append(.newChat)
} label: {
Image(systemName: "square.and.pencil")
}
}
}
}
.sheet(isPresented: $showProfileSheet) {
ProfileSheetView()
.presentationDetents([.medium, .large])
}
.sheet(isPresented: $showRelayWarning) {
RelayWarningSheet()
}
.onChange(of: appState.accountState?.isRelayListEmpty) { _, isEmpty in
showRelayWarning = isEmpty == true
}
}
}
@@ -0,0 +1,90 @@
import SwiftUI
struct ProfileSheetView: View {
@Environment(AppState.self) private var appState
@Environment(\.dismiss) private var dismiss
@State private var showLogoutConfirm = false
var body: some View {
NavigationStack {
List {
Section {
HStack(spacing: 16) {
AvatarView(
name: appState.currentUserProfile?.name ?? "?",
picture: appState.currentUserProfile?.picture,
size: 64
)
VStack(alignment: .leading, spacing: 4) {
Text(appState.currentUserProfile?.name ?? "Loading...")
.font(.headline)
if let npub = try? appState.bootstrap.currentPublicKey()?.toBech32() {
Text(npub)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
}
.padding(.vertical, 4)
Button {
dismiss()
appState.path.append(.myQr)
} label: {
Label("Show QR Code", systemImage: "qrcode")
}
}
Section {
Button {
dismiss()
appState.path.append(.updateProfile)
} label: {
Label("Update Profile", systemImage: "person.crop.circle")
}
Button {
dismiss()
appState.path.append(.contactList)
} label: {
Label("Contact List", systemImage: "person.2")
}
Button {
dismiss()
appState.path.append(.relay)
} label: {
Label("Relay Management", systemImage: "globe")
}
Button {
dismiss()
appState.path.append(.settings)
} label: {
Label("Settings", systemImage: "gear")
}
}
Section {
Button(role: .destructive) {
showLogoutConfirm = true
} label: {
Label("Logout", systemImage: "rectangle.portrait.and.arrow.right")
.foregroundStyle(.red)
}
}
}
.navigationTitle("Profile")
.navigationBarTitleDisplayMode(.inline)
.alert("Logout?", isPresented: $showLogoutConfirm) {
Button("Cancel", role: .cancel) {}
Button("Logout", role: .destructive) {
dismiss()
appState.logout()
}
} message: {
Text("This will delete all local data. Make sure you have backed up your secret key.")
}
}
.tint(.primary)
}
}
@@ -0,0 +1,45 @@
import SwiftUI
struct RelayWarningSheet: View {
@Environment(AppState.self) private var appState
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 24) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 48))
.foregroundStyle(.orange)
Text("No Messaging Relays")
.font(.title2.bold())
Text("You don't have any messaging relays configured. You won't be able to receive messages until this is resolved.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
VStack(spacing: 12) {
Button {
appState.bootstrap.refetchMsgRelays()
dismiss()
} label: {
Text("Retry")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
Button {
appState.bootstrap.useDefaultMsgRelayList()
dismiss()
} label: {
Text("Use Default")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.controlSize(.large)
}
}
.padding(32)
.presentationDetents([.medium])
}
}
@@ -0,0 +1,36 @@
import SwiftUI
import Shared
struct RequestListView: View {
@Environment(AppState.self) private var appState
private var requestRooms: [Room] {
appState.chatRooms.filter { $0.kind == RoomKind.request }
}
var body: some View {
List {
ForEach(requestRooms, id: \.id) { room in
Button {
appState.path.append(.chat(id: room.id, screening: true))
} label: {
RoomRow(room: room)
}
.tint(.primary)
}
}
.refreshable {
appState.bootstrap.refreshChatRooms()
}
.overlay {
if requestRooms.isEmpty {
ContentUnavailableView(
"No requests",
systemImage: "tray",
description: Text("New message requests will appear here")
)
}
}
.navigationTitle("Requests")
}
}
+57
View File
@@ -0,0 +1,57 @@
import SwiftUI
import Shared
struct RoomRow: View {
@Environment(AppState.self) private var appState
let room: Room
@State private var ui: RoomUiState?
@State private var subscription: FlowSubscription?
private var unread: Bool { room.unreadCount > 0 }
var body: some View {
HStack(spacing: 10) {
Circle()
.fill(unread ? Color.accentColor : .clear)
.frame(width: 10, height: 10)
AvatarView(name: ui?.name ?? "?", picture: ui?.picture, size: 48)
VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline) {
Text(ui?.name ?? "Loading...")
.font(.body.weight(unread ? .semibold : .regular))
.lineLimit(1)
Spacer()
Text(room.createdAt.ago())
.font(.subheadline)
.foregroundStyle(.secondary)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(Color(.systemGray3))
}
Text(room.lastMessage ?? "")
.font(.subheadline.weight(unread ? .semibold : .regular))
.foregroundStyle(unread ? .primary : .secondary)
.lineLimit(2)
}
}
.padding(.vertical, 4)
.task {
subscription = appState.bootstrap.watchRoomUi(
room: room,
currentUser: appState.bootstrap.currentPublicKey()
) { state in
Task { @MainActor in ui = state }
}
}
.onDisappear {
subscription?.cancel()
subscription = nil
}
}
}
@@ -0,0 +1,190 @@
import SwiftUI
import Shared
struct ContactRow: View {
@Environment(AppState.self) private var appState
let pubkey: Nostr_sdk_kmpPublicKey
@State private var profile: Profile?
@State private var subscription: FlowSubscription?
var body: some View {
HStack(spacing: 12) {
AvatarView(name: profile?.name ?? "?", picture: profile?.picture)
VStack(alignment: .leading, spacing: 2) {
Text(profile?.name ?? "Loading...")
.font(.headline)
.lineLimit(1)
Text(pubkey.short())
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.vertical, 2)
.task {
subscription = appState.bootstrap.watchProfile(pubkey: pubkey) { value in
Task { @MainActor in profile = value }
}
}
.onDisappear {
subscription?.cancel()
subscription = nil
}
}
}
struct NewChatView: View {
@Environment(AppState.self) private var appState
@State private var query = ""
@State private var searchResults: [Nostr_sdk_kmpPublicKey] = []
@State private var searching = false
@State private var selected: [Nostr_sdk_kmpPublicKey] = []
@State private var showScanner = false
@State private var searchTask: Task<Void, Never>?
private var contacts: [Nostr_sdk_kmpPublicKey] {
Array(appState.accountState?.contactList ?? [])
}
var body: some View {
List {
if !selected.isEmpty {
Section("To:") {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(selected, id: \.self) { pubkey in
Button {
selected.removeAll { $0.toHex() == pubkey.toHex() }
} label: {
Label(pubkey.short(), systemImage: "xmark.circle.fill")
.font(.subheadline)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color(.secondarySystemBackground), in: Capsule())
}
.tint(.primary)
}
}
}
}
}
if searching {
HStack {
Spacer()
ProgressView()
Spacer()
}
}
let results = query.isEmpty ? contacts : searchResults
Section(query.isEmpty ? "Contacts" : "Results") {
ForEach(results, id: \.self) { pubkey in
Button {
openChat(with: pubkey)
} label: {
ContactRow(pubkey: pubkey)
}
.tint(.primary)
.swipeActions(edge: .leading) {
Button {
toggleSelection(pubkey)
} label: {
Label("Select", systemImage: "checkmark.circle")
}
.tint(.accentColor)
}
}
}
}
.navigationTitle("New Chat")
.searchable(text: $query, prompt: "npub, user@domain, or name")
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.onChange(of: query) { _, newValue in
searchTask?.cancel()
searchTask = Task {
try? await Task.sleep(for: .milliseconds(500))
guard !Task.isCancelled else { return }
await performSearch(newValue)
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
HStack {
Button {
showScanner = true
} label: {
Image(systemName: "qrcode.viewfinder")
}
if !selected.isEmpty {
Button("Next") {
createGroupChat()
}
}
}
}
}
.sheet(isPresented: $showScanner) {
ScanView { result in
showScanner = false
if let pubkey = appState.bootstrap.parsePublicKey(input: result) {
openChat(with: pubkey)
} else {
appState.errorMessage = "Invalid public key"
}
}
}
}
private func performSearch(_ value: String) async {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.count >= 3 else {
searchResults = []
return
}
searching = true
defer { searching = false }
if trimmed.hasPrefix("npub1") {
if let pubkey = appState.bootstrap.parsePublicKey(input: trimmed) {
searchResults = [pubkey]
}
} else if trimmed.contains("@") {
if let pubkey = try? await appState.bootstrap.searchByAddress(query: trimmed) {
searchResults = [pubkey]
} else {
searchResults = []
}
} else {
searchResults = (try? await appState.bootstrap.searchByNostr(query: trimmed)) ?? []
}
}
private func toggleSelection(_ pubkey: Nostr_sdk_kmpPublicKey) {
if let index = selected.firstIndex(where: { $0.toHex() == pubkey.toHex() }) {
selected.remove(at: index)
} else {
selected.append(pubkey)
}
}
private func openChat(with pubkey: Nostr_sdk_kmpPublicKey) {
do {
let roomId = try appState.bootstrap.createChatRoom(recipients: [pubkey])
appState.path.append(.chat(id: roomId, screening: false))
} catch {
appState.errorMessage = error.localizedDescription
}
}
private func createGroupChat() {
do {
let roomId = try appState.bootstrap.createChatRoom(recipients: selected)
appState.path.append(.chat(id: roomId, screening: false))
} catch {
appState.errorMessage = error.localizedDescription
}
}
}
@@ -0,0 +1,67 @@
import SwiftUI
struct ImportView: View {
@Environment(AppState.self) private var appState
@State private var secret = ""
@State private var password = ""
@State private var showScanner = false
private var needsPassword: Bool {
secret.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("ncryptsec1")
}
private var canImport: Bool {
!secret.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
(!needsPassword || !password.isEmpty) &&
appState.accountState?.isImporting != true
}
var body: some View {
Form {
Section {
HStack {
SecureField("nsec1... or bunker://", text: $secret)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button {
showScanner = true
} label: {
Image(systemName: "qrcode.viewfinder")
}
}
if needsPassword {
SecureField("Decrypt Password", text: $password)
}
} header: {
Text("Secret Key")
} footer: {
Text("Enter your nsec, ncryptsec (with password), or bunker:// connection string.")
}
}
.navigationTitle("Import Identity")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Import") {
appState.bootstrap.importIdentity(
secret: secret.trimmingCharacters(in: .whitespacesAndNewlines),
password: needsPassword ? password : nil
)
}
.disabled(!canImport)
}
}
.overlay {
if appState.accountState?.isImporting == true {
ProgressView()
.controlSize(.large)
}
}
.sheet(isPresented: $showScanner) {
ScanView { result in
secret = result
showScanner = false
}
}
}
}
@@ -0,0 +1,20 @@
import SwiftUI
struct NewIdentityView: View {
@Environment(AppState.self) private var appState
var body: some View {
ProfileEditorForm(
title: "Create a new identity",
confirmLabel: "Continue",
isSubmitting: appState.accountState?.isImporting == true
) { name, bio, picture, contentType in
appState.bootstrap.createIdentity(
name: name,
bio: bio,
picture: picture,
contentType: contentType
)
}
}
}
@@ -0,0 +1,69 @@
import SwiftUI
private enum OnboardingRoute: Hashable {
case newIdentity
case importIdentity
}
struct OnboardingView: View {
@State private var path: [OnboardingRoute] = []
var body: some View {
NavigationStack(path: $path) {
VStack(spacing: 32) {
Spacer()
Image(systemName: "bubble.left.and.bubble.right.fill")
.font(.system(size: 80))
.foregroundStyle(.tint)
VStack(spacing: 8) {
Text("Coop")
.font(.largeTitle.bold())
Text("Simple, fast, and reliable nostr messaging")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
Spacer()
VStack(spacing: 12) {
Button {
path.append(.newIdentity)
} label: {
Text("Start Messaging")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
Button {
path.append(.importIdentity)
} label: {
Text("Add an Existing Identity")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.controlSize(.large)
}
.padding(.horizontal)
Text("By continuing, you agree to our Terms of Service and Privacy Policy")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
.padding()
.navigationDestination(for: OnboardingRoute.self) { route in
switch route {
case .newIdentity:
NewIdentityView()
case .importIdentity:
ImportView()
}
}
}
}
}
@@ -0,0 +1,103 @@
import PhotosUI
import SwiftUI
import Shared
struct ProfileEditorForm: View {
let title: String
let confirmLabel: String
let initialName: String
let initialBio: String
let initialPicture: String?
let isSubmitting: Bool
let onConfirm: (String, String?, KotlinByteArray?, String?) -> Void
@State private var name: String
@State private var bio: String
@State private var photoItem: PhotosPickerItem?
@State private var photoData: Data?
@State private var photoContentType: String?
init(
title: String,
confirmLabel: String,
initialName: String = "",
initialBio: String = "",
initialPicture: String? = nil,
isSubmitting: Bool = false,
onConfirm: @escaping (String, String?, KotlinByteArray?, String?) -> Void
) {
self.title = title
self.confirmLabel = confirmLabel
self.initialName = initialName
self.initialBio = initialBio
self.initialPicture = initialPicture
self.isSubmitting = isSubmitting
self.onConfirm = onConfirm
_name = State(initialValue: initialName)
_bio = State(initialValue: initialBio)
}
var body: some View {
Form {
Section {
HStack {
Spacer()
PhotosPicker(selection: $photoItem, matching: .images) {
if let photoData, let uiImage = UIImage(data: photoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.frame(width: 120, height: 120)
.clipShape(Circle())
} else {
AvatarView(name: name.isEmpty ? "?" : name, picture: initialPicture, size: 120)
.overlay(alignment: .bottomTrailing) {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundStyle(.tint)
}
}
}
Spacer()
}
}
.listRowBackground(Color.clear)
Section {
TextField("What others should call you?", text: $name)
TextField("Tell others about yourself (optional)", text: $bio, axis: .vertical)
.lineLimit(3...6)
}
Section {
Button {
onConfirm(
name.trimmingCharacters(in: .whitespacesAndNewlines),
bio.isEmpty ? nil : bio,
photoData?.toKotlinByteArray(),
photoContentType
)
} label: {
HStack {
Spacer()
if isSubmitting {
ProgressView()
} else {
Text(confirmLabel)
}
Spacer()
}
}
.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSubmitting)
}
}
.navigationTitle(title)
.task(id: photoItem) {
guard let photoItem else { return }
if let data = try? await photoItem.loadTransferable(type: Data.self) {
photoData = data
photoContentType = photoItem.supportedContentTypes.first?.preferredMIMEType ?? "image/jpeg"
}
}
}
}
@@ -0,0 +1,55 @@
import CoreImage.CIFilterBuiltins
import SwiftUI
struct MyQrView: View {
@Environment(AppState.self) private var appState
private var npub: String? {
try? appState.bootstrap.currentPublicKey()?.toBech32()
}
var body: some View {
VStack(spacing: 24) {
Spacer()
if let npub, let qrImage = generateQrCode(from: npub) {
Image(uiImage: qrImage)
.interpolation(.none)
.resizable()
.scaledToFit()
.frame(maxWidth: 280)
.padding()
.background(Color.white, in: RoundedRectangle(cornerRadius: 16))
Text(npub)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
ShareLink(item: npub) {
Label("Share", systemImage: "square.and.arrow.up")
}
.buttonStyle(.bordered)
} else {
ContentUnavailableView("No identity", systemImage: "qrcode")
}
Spacer()
}
.padding()
.navigationTitle("My QR Code")
.navigationBarTitleDisplayMode(.inline)
}
private func generateQrCode(from string: String) -> UIImage? {
let context = CIContext()
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(string.utf8)
filter.correctionLevel = "M"
guard let outputImage = filter.outputImage else { return nil }
let scaled = outputImage.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else { return nil }
return UIImage(cgImage: cgImage)
}
}
@@ -0,0 +1,85 @@
import SwiftUI
import Shared
struct ProfileView: View {
@Environment(AppState.self) private var appState
let pubkey: String
@State private var profile: Profile?
@State private var subscription: FlowSubscription?
@State private var parsedKey: Nostr_sdk_kmpPublicKey?
private var record: Nostr_sdk_kmpMetadataRecord? {
profile?.metadata.asRecord()
}
var body: some View {
List {
Section {
VStack(spacing: 12) {
AvatarView(
name: profile?.name ?? "?",
picture: profile?.picture,
size: 120
)
Text(profile?.name ?? "No name")
.font(.title2.bold())
Text(record?.nip05 ?? parsedKey?.short() ?? "")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical)
}
.listRowBackground(Color.clear)
Section("Details") {
LabeledContent("Username", value: record?.name ?? "None")
LabeledContent("Website", value: record?.website ?? "None")
LabeledContent("Lightning Address", value: record?.lud16 ?? "None")
}
Section {
Button {
openChat()
} label: {
Label("Message", systemImage: "message")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
if let npub = try? parsedKey?.toBech32() {
ShareLink(item: npub) {
Label("Share", systemImage: "square.and.arrow.up")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
}
.listRowBackground(Color.clear)
}
.navigationTitle("Profile")
.navigationBarTitleDisplayMode(.inline)
.task {
parsedKey = appState.bootstrap.parsePublicKey(input: pubkey)
guard let parsedKey else { return }
subscription = appState.bootstrap.watchProfile(pubkey: parsedKey) { value in
Task { @MainActor in profile = value }
}
}
.onDisappear {
subscription?.cancel()
}
}
private func openChat() {
guard let parsedKey else { return }
do {
let roomId = try appState.bootstrap.createChatRoom(recipients: [parsedKey])
appState.path.append(.chat(id: roomId, screening: false))
} catch {
appState.errorMessage = error.localizedDescription
}
}
}
@@ -0,0 +1,30 @@
import SwiftUI
import Shared
struct UpdateProfileView: View {
@Environment(AppState.self) private var appState
@Environment(\.dismiss) private var dismiss
private var record: Nostr_sdk_kmpMetadataRecord? {
appState.currentUserProfile?.metadata.asRecord()
}
var body: some View {
ProfileEditorForm(
title: "Update Profile",
confirmLabel: "Save changes",
initialName: record?.displayName ?? record?.name ?? "",
initialBio: record?.about ?? "",
initialPicture: appState.currentUserProfile?.picture,
isSubmitting: appState.isUpdatingProfile
) { name, bio, picture, contentType in
appState.bootstrap.updateProfile(
name: name,
bio: bio,
picture: picture,
contentType: contentType
)
dismiss()
}
}
}
+87
View File
@@ -0,0 +1,87 @@
import SwiftUI
import VisionKit
struct ScanView: View {
@Environment(\.dismiss) private var dismiss
let onResult: (String) -> Void
var body: some View {
ZStack(alignment: .top) {
if DataScannerViewController.isSupported && DataScannerViewController.isAvailable {
ScannerRepresentable(onResult: onResult)
.ignoresSafeArea()
} else {
ContentUnavailableView(
"Scanner unavailable",
systemImage: "camera.fill",
description: Text("This device does not support the data scanner")
)
}
VStack {
Spacer()
RoundedRectangle(cornerRadius: 16)
.strokeBorder(Color.white, lineWidth: 3)
.frame(width: 250, height: 250)
.background(.clear)
Spacer()
}
.allowsHitTesting(false)
}
.overlay(alignment: .topLeading) {
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.title)
.foregroundStyle(.white)
.padding()
}
}
}
}
private struct ScannerRepresentable: UIViewControllerRepresentable {
let onResult: (String) -> Void
func makeUIViewController(context: Context) -> DataScannerViewController {
let scanner = DataScannerViewController(
recognizedDataTypes: [.barcode(symbologies: [.qr])],
qualityLevel: .balanced,
recognizesMultipleItems: false,
isHighFrameRateTrackingEnabled: false,
isHighlightingEnabled: true
)
scanner.delegate = context.coordinator
try? scanner.startScanning()
return scanner
}
func updateUIViewController(_ uiViewController: DataScannerViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(onResult: onResult)
}
final class Coordinator: NSObject, DataScannerViewControllerDelegate {
let onResult: (String) -> Void
private var handled = false
init(onResult: @escaping (String) -> Void) {
self.onResult = onResult
}
func dataScanner(
_ dataScanner: DataScannerViewController,
didAdd addedItems: [RecognizedItem],
allItems: [RecognizedItem]
) {
guard !handled,
case .barcode(let barcode) = addedItems.first,
let payload = barcode.payloadStringValue
else { return }
handled = true
onResult(payload)
}
}
}
@@ -0,0 +1,169 @@
import SwiftUI
import Shared
private enum RelayRole: String, CaseIterable, Identifiable {
case messaging = "Messaging"
case inbox = "Inbox"
case outbox = "Outbox"
var id: String { rawValue }
}
struct RelayView: View {
@Environment(AppState.self) private var appState
@State private var lists: RelayLists?
@State private var subscription: FlowSubscription?
@State private var showAddRelay = false
@State private var newRelay = ""
@State private var newRelayRole = RelayRole.messaging
@State private var addError: String?
var body: some View {
List {
if let lists {
if !lists.messaging.isEmpty {
Section("Messaging Relays") {
ForEach(lists.messaging, id: \.self) { relay in
relayRow(relay)
.swipeActions(edge: .trailing) {
if lists.messaging.count > 1 {
Button(role: .destructive) {
appState.bootstrap.removeMsgRelay(relay: relay.description())
} label: {
Label("Remove", systemImage: "trash")
}
}
}
}
}
}
if !lists.inbox.isEmpty {
Section("Inbox Relays") {
ForEach(lists.inbox, id: \.self) { relay in
relayRow(relay)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
appState.bootstrap.removeRelay(relay: relay.description())
} label: {
Label("Remove", systemImage: "trash")
}
}
}
}
}
if !lists.outbox.isEmpty {
Section("Outbox Relays") {
ForEach(lists.outbox, id: \.self) { relay in
relayRow(relay)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
appState.bootstrap.removeRelay(relay: relay.description())
} label: {
Label("Remove", systemImage: "trash")
}
}
}
}
}
} else {
HStack {
Spacer()
ProgressView()
Spacer()
}
}
}
.navigationTitle("Relays")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button { showAddRelay = true } label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showAddRelay) {
NavigationStack {
Form {
Section("Relay URL") {
TextField("wss://relay.example.com", text: $newRelay)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
}
Section("Role") {
Picker("Role", selection: $newRelayRole) {
ForEach(RelayRole.allCases) { role in
Text(role.rawValue).tag(role)
}
}
.pickerStyle(.inline)
.labelsHidden()
}
if let addError {
Section {
Text(addError).foregroundStyle(.red)
}
}
}
.navigationTitle("Add Relay")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
showAddRelay = false
newRelay = ""
addError = nil
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Add") {
addRelay()
}
.disabled(!newRelay.hasPrefix("wss://"))
}
}
}
.presentationDetents([.medium])
}
.task {
appState.bootstrap.loadRelayLists()
subscription = appState.bootstrap.watchRelayLists { value in
Task { @MainActor in lists = value }
}
}
.onDisappear {
subscription?.cancel()
}
}
private func relayRow(_ relay: Nostr_sdk_kmpRelayUrl) -> some View {
HStack {
Image(systemName: "globe")
.foregroundStyle(.secondary)
Text(relay.description())
.font(.subheadline)
.lineLimit(1)
}
}
private func addRelay() {
let url = newRelay.trimmingCharacters(in: .whitespacesAndNewlines)
guard url.hasPrefix("wss://") else {
addError = "Relay URL must start with wss://"
return
}
switch newRelayRole {
case .messaging:
appState.bootstrap.addMsgRelay(relay: url)
case .inbox:
appState.bootstrap.addInboxRelay(relay: url)
case .outbox:
appState.bootstrap.addOutboxRelay(relay: url)
}
showAddRelay = false
newRelay = ""
addError = nil
}
}
@@ -0,0 +1,71 @@
import SwiftUI
import Shared
struct SettingsView: View {
@Environment(AppState.self) private var appState
@State private var blossomServer = ""
private var settings: Settings? {
appState.settings
}
var body: some View {
Form {
Section("General") {
Toggle("Filter unknown contacts", isOn: Binding(
get: { settings?.screening == true },
set: { appState.bootstrap.setScreening(enabled: $0) }
))
Picker("Media Preview", selection: Binding(
get: { settings?.media ?? MediaConfig.alwaysenabled },
set: { appState.bootstrap.setMediaConfig(media: $0) }
)) {
Text("Disabled").tag(MediaConfig.disabled)
Text("Disabled for Mobile Data").tag(MediaConfig.disabledformobiledata)
Text("Always Enabled").tag(MediaConfig.alwaysenabled)
}
HStack {
Text("Blossom Server")
Spacer()
TextField("https://blossom.band", text: $blossomServer)
.multilineTextAlignment(.trailing)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
.foregroundStyle(.secondary)
.onSubmit {
appState.bootstrap.setBlossomServer(
url: blossomServer.isEmpty ? nil : blossomServer
)
}
}
Button {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
} label: {
Label("Notifications", systemImage: "bell")
}
.tint(.primary)
}
Section("Appearance") {
Picker("Theme", selection: Binding(
get: { settings?.theme ?? Theme.system },
set: { appState.bootstrap.setTheme(theme: $0) }
)) {
Text("Light").tag(Theme.light)
Text("Dark").tag(Theme.dark)
Text("System").tag(Theme.system)
}
}
}
.navigationTitle("Settings")
.onAppear {
blossomServer = settings?.blossomServer ?? ""
}
}
}
+24
View File
@@ -2,9 +2,33 @@ import SwiftUI
@main
struct iOSApp: App {
@State private var appState = AppState()
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
.environment(appState)
.onAppear {
appState.start()
NotificationService.shared.requestPermissionIfNeeded()
NotificationService.shared.onOpenChat = { roomId in
appState.path.append(.chat(id: roomId, screening: false))
}
}
.onOpenURL { url in
appState.handle(url)
}
.onChange(of: scenePhase) { _, phase in
switch phase {
case .active:
appState.resume()
case .background:
appState.pause()
default:
break
}
}
}
}
}
+1
View File
@@ -22,6 +22,7 @@ kotlin {
iosTarget.binaries.framework {
baseName = "Shared"
isStatic = true
binaryOptions["objcExportSuspendFunctionLaunchThreadRestriction"] = "none"
}
}
@@ -1,46 +0,0 @@
package su.reya.coop
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus
import kotlinx.datetime.number
import kotlinx.datetime.toLocalDateTime
import su.reya.coop.concord.CommunityState
import su.reya.coop.concord.ConcordMessage
import kotlin.time.Clock
/** Control metadata first, then the snapshot the invite carried, then a last-resort label. */
fun CommunityState.displayName(): String {
val name = meta?.name?.sanitizeName()?.takeIf { it.isNotBlank() }
?: membership.name?.sanitizeName()?.takeIf { it.isNotBlank() }
return name ?: "Untitled community"
}
/** Every Channel's badge added up, for the Community row. */
fun CommunityState.unreadTotal(): Int = channels.sumOf { it.unreadCount }
/** `HH:mm`, local time. */
fun ConcordMessage.timeLabel(): String {
val time = createdAt.toLocalDateTime(TimeZone.currentSystemDefault())
val hour = time.hour.toString().padStart(2, '0')
val minute = time.minute.toString().padStart(2, '0')
return "$hour:$minute"
}
/** `Today` / `Yesterday` / `DD/MM/YY`, matching [formatAsGroup] for DMs. */
fun ConcordMessage.dayLabel(): String {
val zone = TimeZone.currentSystemDefault()
val date = createdAt.toLocalDateTime(zone).date
val today = Clock.System.now().toLocalDateTime(zone).date
return when (date) {
today -> "Today"
today.minus(1, DateTimeUnit.DAY) -> "Yesterday"
else -> {
val day = date.day.toString().padStart(2, '0')
val month = date.month.number.toString().padStart(2, '0')
val year = date.year.toString().takeLast(2)
"$day/$month/$year"
}
}
}
@@ -1,129 +0,0 @@
package su.reya.coop.concord
import kotlin.io.encoding.Base64
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* §8's 32-byte values are unpadded base64url rather than hex the Community List is the one
* encrypted document CORD-01 lets choose its own encoding. Padding is tolerated on read.
*/
private val listBase64 = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT_OPTIONAL)
/** One fragment's plaintext. `frags` and `seed` are read by writers, not here. */
@Serializable
internal data class CommunityListFragment(
val entries: List<CommunityListEntry> = emptyList(),
val tombstones: List<CommunityListTombstone> = emptyList(),
)
@Serializable
internal data class CommunityListEntry(
/** base64url, and case-significant: decoded, never case-folded. */
@SerialName("community_id") val communityId: String = "",
/** The freshest snapshot. `seed`, the backfill anchor, is not read here. */
val current: CommunityListSnapshot? = null,
/** Unix ms. Tiebreaks against the tombstone and between two entries. */
@SerialName("added_at") val addedAt: Long = 0L,
)
@Serializable
internal data class CommunityListTombstone(
@SerialName("community_id") val communityId: String = "",
@SerialName("removed_at") val removedAt: Long = 0L,
)
/**
* The invite bundle's *membership* subset (CORD-05), in §8's base64url: never the icon, never the
* link fields.
*/
@Serializable
internal data class CommunityListSnapshot(
val owner: String = "",
@SerialName("owner_salt") val ownerSalt: String = "",
@SerialName("community_root") val communityRoot: String = "",
@SerialName("root_epoch") val rootEpoch: ULong = 0u,
@SerialName("control_pk") val controlPk: String? = null,
val channels: List<CommunityListChannel> = emptyList(),
val relays: List<String> = emptyList(),
val name: String? = null,
)
@Serializable
internal data class CommunityListChannel(
val id: String = "",
val key: String = "",
val epoch: ULong = 0u,
val name: String? = null,
)
/**
* Unions fragments into one membership per Community (CORD-02 §8).
*
* Only the merges a *reader* needs: the newest entry per Community wins, and a Community whose
* tombstone is at least as new as its entry reads as left. `seed`, the canonical-bytes tiebreak and
* repacks exist to keep two writers byte-identical a client that never republishes cannot flap.
*/
internal fun List<CommunityListFragment>.listedMemberships(): List<Membership> {
val newest = mutableMapOf<String, Pair<Long, Membership>>()
for (entry in flatMap { it.entries }) {
val membership = entry.toMembership() ?: continue
val held = newest[membership.communityId]
if (held == null || entry.addedAt > held.first) {
newest[membership.communityId] = entry.addedAt to membership
}
}
val removed = mutableMapOf<String, Long>()
for (tombstone in flatMap { it.tombstones }) {
val id = tombstone.communityId.listId() ?: continue
removed[id] = maxOf(removed[id] ?: 0L, tombstone.removedAt)
}
return newest
.filter { (id, entry) -> (removed[id] ?: 0L) < entry.first }
.values.map { it.second }
}
/** One entry as a [Membership], reusing the invite bundle's own self-certification. */
private fun CommunityListEntry.toMembership(): Membership? {
val id = communityId.listId() ?: return null
val invite = (current ?: return null).toInvite(id) ?: return null
if (invite.problems().isNotEmpty()) return null
return invite.toMembership(invite.relaySet())
}
/** A snapshot normalised to the hex [CommunityInvite] the invite path already validates. */
private fun CommunityListSnapshot.toInvite(communityIdHex: String): CommunityInvite? {
val ownerHex = owner.listId() ?: return null
val saltHex = ownerSalt.listId() ?: return null
val rootHex = communityRoot.listId() ?: return null
// Strict: a malformed control key is a malformed entry, not a key to quietly drop null. Dropping
// it to null would leave the Control Plane unaddressable with nothing to show for it.
val controlHex = controlPk?.let { it.listId() ?: return null }
// Lenient by contrast: one unreadable Channel key costs that Channel, not the membership.
val granted = channels.mapNotNull { channel ->
val id = channel.id.listId() ?: return@mapNotNull null
val key = channel.key.listId() ?: return@mapNotNull null
InviteChannel(id = id, key = key, epoch = channel.epoch, name = channel.name)
}
return CommunityInvite(
communityId = communityIdHex,
owner = ownerHex,
ownerSalt = saltHex,
communityRoot = rootHex,
rootEpoch = rootEpoch,
controlPk = controlHex,
channels = granted,
relays = relays,
name = name,
)
}
/** §8's unpadded base64url for a 32-byte value, or null when it is not one. */
private fun String.listId(): String? =
runCatching { listBase64.decode(this) }.getOrNull()?.takeIf { it.size == 32 }?.toHex()
@@ -1,92 +0,0 @@
package su.reya.coop.concord
import kotlinx.serialization.decodeFromString
import rust.nostr.sdk.UnsignedEvent
import kotlin.time.Instant
object ConcordControl {
fun editionOf(rumor: UnsignedEvent): ControlEdition? {
if (rumor.kind().asU16() != ConcordKind.CONTROL_EDITION.toUShort()) return null
val tags = rumor.tags().toVec()
val vsk = tags.firstOrNull { it.kind() == ConcordTag.VSK }?.content()?.toIntOrNull() ?: return null
if (vsk == ConcordVsk.DISSOLVED) return null
val eidHex = tags.firstOrNull { it.kind() == ConcordTag.EID }?.content() ?: return null
if (eidHex.hex32() == null) return null
val version = tags.firstOrNull { it.kind() == ConcordTag.EV }?.content()?.toULongOrNull() ?: return null
if (version == 0uL) return null
val prev = tags.firstOrNull { it.kind() == ConcordTag.EP }?.content()
if (prev != null && prev.hex32() == null) return null
return ControlEdition(
vsk = vsk,
eidHex = eidHex.lowercase(),
version = version,
prevHashHex = prev?.lowercase(),
content = rumor.content(),
actorHex = rumor.author().toHex(),
rumorIdHex = rumor.id()?.toHex() ?: return null,
createdAt = Instant.fromEpochSeconds(rumor.createdAt().asSecs().toLong()),
)
}
fun fold(editions: List<ControlEdition>, communityIdHex: String): ControlFold {
var meta: CommunityMeta? = null
val channels = mutableMapOf<String, ChannelMeta>()
editions.groupBy { it.vsk to it.eidHex }.forEach { (entity, group) ->
val (vsk, eidHex) = entity
if (vsk != ConcordVsk.METADATA && vsk != ConcordVsk.CHANNEL) return@forEach
// The metadata entity *is* the Community, so its coordinate must be the community_id;
// an edition naming anything else is not this Community's metadata.
if (vsk == ConcordVsk.METADATA && eidHex != communityIdHex) return@forEach
val winner = winner(group) ?: return@forEach
when (vsk) {
ConcordVsk.METADATA -> meta = decode(winner.content)
else -> decode<ChannelMeta>(winner.content)?.let { channels[eidHex] = it }
}
}
return ControlFold(meta = meta, channels = channels)
}
private fun winner(group: List<ControlEdition>): ControlEdition? {
val byVersion = group
.groupBy { it.version }
.mapValues { (_, candidates) -> candidates.minByOrNull { it.rumorIdHex } ?: return null }
val eid = byVersion.values.firstOrNull()?.eidHex?.hex32() ?: return null
for (top in byVersion.keys.sortedDescending()) {
var prev: ByteArray? = null
var candidate: ControlEdition? = null
var intact = true
for (version in 1uL..top) {
val edition = byVersion[version]
if (edition == null) {
intact = false
break
}
// The first edition has no predecessor; every later one must chain to the last.
val expected = if (version == 1uL) null else prev?.toHex()
if (edition.prevHashHex != expected) {
intact = false
break
}
prev = editionHash(eid, version, prev, edition.content.encodeToByteArray())
candidate = edition
}
if (intact) return candidate
}
return null
}
private inline fun <reified T> decode(content: String): T? =
runCatching { concordJson.decodeFromString<T>(content) }.getOrNull()
}
@@ -1,157 +0,0 @@
package su.reya.coop.concord
import okio.Buffer
import okio.ByteString
import okio.ByteString.Companion.decodeHex
import okio.ByteString.Companion.toByteString
import rust.nostr.sdk.Keys
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.SecretKey
fun hkdfSha256(
ikm: ByteArray,
info: ByteArray,
salt: ByteArray = ByteArray(0),
length: Int = 32,
): ByteArray {
require(length in 1..255 * 32) { "HKDF output length must be 1..8160, was $length" }
val saltKey = if (salt.isEmpty()) ByteArray(32).toByteString() else salt.toByteString()
val prk = ikm.toByteString().hmacSha256(saltKey)
val out = Buffer()
var t = ByteString.EMPTY
var counter = 1
while (out.size < length) {
t = Buffer()
.write(t)
.write(info)
.writeByte(counter)
.readByteString()
.hmacSha256(prk)
out.write(t)
counter++
}
return out.readByteArray(length.toLong())
}
fun hkdfInfo(label: String, id: ByteArray, epoch: ULong? = null): ByteArray {
require(id.size == 32) { "Concord HKDF id must be 32 bytes, was ${id.size}" }
return Buffer().apply {
writeUtf8(label)
writeByte(0)
write(id)
if (epoch != null) writeLong(epoch.toLong())
}.readByteArray()
}
/** A plane's derived keypair: `(sk, xonly(sk))` from CORD-02 A.2 `group_key`. */
data class GroupKey(val secretKey: SecretKey, val publicKey: PublicKey)
fun groupSeed(
label: String,
secret: ByteArray,
id: ByteArray,
epoch: ULong? = null,
isValid: (ByteArray) -> Boolean = ::isValidScalar,
): ByteArray {
val base = hkdfInfo(label, id, epoch)
var counter = -1
while (counter <= 255) {
val info = if (counter < 0) base else base + byteArrayOf(counter.toByte())
val seed = hkdfSha256(secret, info)
if (isValid(seed)) return seed
counter++
}
error("Concord group_key: scalar_normalize exhausted for label $label")
}
fun isValidScalar(seed: ByteArray): Boolean {
if (seed.size != 32) return false
var anyNonZero = false
for (byte in seed) {
if (byte != 0.toByte()) {
anyNonZero = true
break
}
}
if (!anyNonZero) return false
for (i in 0 until 32) {
val candidate = seed[i].toInt() and 0xff
val order = SECP256K1_ORDER[i].toInt() and 0xff
if (candidate != order) return candidate < order
}
return false
}
private val SECP256K1_ORDER =
"fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141".hexToBytes()
fun groupKey(label: String, secret: ByteArray, id: ByteArray, epoch: ULong? = null): GroupKey {
val secretKey = SecretKey.fromBytes(groupSeed(label, secret, id, epoch))
return GroupKey(secretKey, Keys(secretKey).publicKey())
}
fun communityId(ownerXonly: ByteArray, ownerSalt: ByteArray): ByteArray {
require(ownerXonly.size == 32) { "owner_xonly must be 32 bytes, was ${ownerXonly.size}" }
require(ownerSalt.size == 32) { "owner_salt must be 32 bytes, was ${ownerSalt.size}" }
return Buffer().apply {
writeUtf8(ConcordLabel.COMMUNITY)
write(ownerXonly)
write(ownerSalt)
}.readByteString().sha256().toByteArray()
}
fun prevCommit(prevEpoch: ULong, prevKey: ByteArray): ByteArray {
require(prevKey.size == 32) { "prev_key must be 32 bytes, was ${prevKey.size}" }
return Buffer().apply {
writeUtf8(ConcordLabel.EPOCH_KEY_COMMITMENT)
writeLong(prevEpoch.toLong())
write(prevKey)
}.readByteString().sha256().toByteArray()
}
fun editionHash(entityId: ByteArray, version: ULong, prev: ByteArray?, content: ByteArray): ByteArray {
require(entityId.size == 32) { "entity_id must be 32 bytes, was ${entityId.size}" }
require(prev == null || prev.size == 32) { "prev must be 32 bytes" }
val label = ConcordLabel.EDITION_HASH.encodeToByteArray()
return Buffer().apply {
writeLong(label.size.toLong())
write(label)
write(entityId)
writeLong(version.toLong())
if (prev != null) {
writeByte(1)
write(prev)
} else {
writeByte(0)
write(ByteArray(32))
}
writeLong(content.size.toLong())
write(content)
}.readByteString().sha256().toByteArray()
}
/** 64 lowercase hex chars, the encoding CORD-01 mandates for every 32-byte value on the wire. */
internal fun ByteArray.toHex(): String = toByteString().hex()
/** Inverse of [toHex]. Throws on odd length or a non-hex character. */
internal fun String.hexToBytes(): ByteArray = decodeHex().toByteArray()
/** Inverse of [toHex], returning null instead of throwing — for reading untrusted input. */
internal fun String.hexToBytesOrNull(): ByteArray? = runCatching { hexToBytes() }.getOrNull()
/**
* A 32-byte value read from hex, or null when it is not exactly 32 bytes.
*
* The length check is the point: most of Concord's ids and keys are 32 bytes, and a short or long
* value must be rejected before it reaches a derivation that assumes a fixed width.
*/
internal fun String.hex32(): ByteArray? = if (length == 64) hexToBytesOrNull() else null
@@ -1,193 +0,0 @@
package su.reya.coop.concord
import kotlin.io.encoding.Base64
import rust.nostr.sdk.Nip19Coordinate
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.nip44Decrypt
/** The stock relay dictionary (CORD-05 §3). Referenced by one byte so links stay short. */
internal object ConcordRelayDictionary {
val STOCK = listOf(
"wss://jskitty.com/nostr",
"wss://asia.vectorapp.io/nostr",
"wss://relay.ditto.pub",
"wss://relay.dreamith.to",
)
}
/** The link's secret half: `[version][flags][relays?][token:16]`, base64url without padding. */
data class InviteFragment(
val version: Int,
val relays: List<String>,
/** The 16-byte unlock token, hex. Derives exactly one thing: the bundle key. */
val tokenHex: String,
)
/** A parsed invite link: the public locator plus the secret fragment. */
data class ParsedInvite(
val signerHex: String,
val identifier: String,
/** Relays the naddr itself names. Usually empty; the compact form travels in the fragment. */
val naddrRelays: List<RelayUrl>,
val fragment: InviteFragment,
)
private const val FRAGMENT_VERSION = 4
/** The one flag v1 knows: the stock set is in use, so zero relay bytes follow. */
private const val FLAG_STOCK_RELAYS = 1
private const val TOKEN_BYTES = 16
/** CORD-05 §3: the fragment only needs to *find* the bundle; the bundle carries the real set. */
private const val MAX_BOOTSTRAP_RELAYS = 3
/** CORD-05 §1: the spec's reference ceiling on a bundle's Channel list. */
private const val MAX_INVITE_CHANNELS = 256
/** CORD-02 §6: five stable relays is the recommendation, not a rule; a client may truncate. */
private const val MAX_COMMUNITY_RELAYS = 5
/** The fragment is unpadded base64url; ABSENT_OPTIONAL also tolerates a padded paste. */
private val fragmentBase64 = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT_OPTIONAL)
fun parseInviteLink(text: String): ParsedInvite? {
val trimmed = text.trim()
val hash = trimmed.indexOf('#')
if (hash <= 0 || hash == trimmed.lastIndex) return null
val naddr = trimmed.substring(0, hash).substringAfterLast('/')
val fragment = decodeInviteFragment(trimmed.substring(hash + 1)) ?: return null
val coordinate = runCatching { Nip19Coordinate.fromBech32(naddr) }.getOrNull() ?: return null
if (coordinate.coordinate().kind().asU16() != ConcordKind.INVITE_BUNDLE.toUShort()) return null
return ParsedInvite(
signerHex = coordinate.coordinate().publicKey().toHex(),
identifier = coordinate.coordinate().identifier(),
naddrRelays = coordinate.relays(),
fragment = fragment,
)
}
fun decodeInviteFragment(fragment: String): InviteFragment? {
val bytes = runCatching { fragmentBase64.decode(fragment) }.getOrNull() ?: return null
// Two header bytes plus the token at minimum.
if (bytes.size < 2 + TOKEN_BYTES) return null
val reader = FragmentReader(bytes)
if (reader.byte() != FRAGMENT_VERSION) return null
val flags = reader.byte() ?: return null
val relays = if (flags and FLAG_STOCK_RELAYS != 0) {
ConcordRelayDictionary.STOCK
} else {
val count = reader.byte() ?: return null
buildList {
repeat(count) {
val lead = reader.byte() ?: return null
val relay = when (lead) {
0 -> reader.slice(reader.byte() ?: return null)?.let { "wss://$it" }
255 -> reader.slice(reader.byte() ?: return null)
else -> ConcordRelayDictionary.STOCK.getOrNull(lead - 1)
} ?: return null
add(relay)
}
}.take(MAX_BOOTSTRAP_RELAYS)
}
// Whatever is left must be exactly the token: a short one silently weakens the link.
val token = bytes.copyOfRange(reader.index, bytes.size)
if (token.size != TOKEN_BYTES) return null
return InviteFragment(
version = FRAGMENT_VERSION,
relays = relays,
tokenHex = token.toHex(),
)
}
fun inviteBundleKey(tokenHex: String): GroupKey? =
tokenHex.hexToBytesOrNull()?.let { groupKey(ConcordLabel.INVITE_KEY, it, ByteArray(32)) }
fun decryptInviteBundle(content: String, key: GroupKey): CommunityInvite? {
val plaintext = runCatching { nip44Decrypt(key.secretKey, key.publicKey, content) }.getOrNull()
?: return null
return runCatching { concordJson.decodeFromString<CommunityInvite>(plaintext) }.getOrNull()
}
fun isInviteTombstone(vsk: String?): Boolean = vsk == ConcordVsk.INVITE_TOMBSTONE.toString()
fun CommunityInvite.problems(): List<String> {
val problems = mutableListOf<String>()
val ownerBytes = owner.hex32()
val saltBytes = ownerSalt.hex32()
val rootBytes = communityRoot.hex32()
if (ownerBytes == null) problems += "The invite's owner key is malformed"
if (saltBytes == null) problems += "The invite's owner salt is malformed"
if (rootBytes == null) problems += "The invite's community key is malformed"
if (ownerBytes != null && saltBytes != null) {
if (communityId(ownerBytes, saltBytes).toHex() != communityId.lowercase()) {
problems += "The invite does not prove its community id"
}
}
if (communityId.hex32() == null) problems += "The invite's community id is malformed"
if (controlPk != null && controlPk.hex32() == null) problems += "The invite's control key is malformed"
if (channels.size > MAX_INVITE_CHANNELS) {
problems += "The invite carries ${channels.size} channels (max $MAX_INVITE_CHANNELS)"
}
channels.forEachIndexed { index, channel ->
if (channel.id.hex32() == null) problems += "Channel $index has a malformed id"
if (channel.key.hex32() == null) problems += "Channel $index has a malformed key"
}
return problems
}
fun CommunityInvite.isExpired(nowMs: Long): Boolean = expiresAt != null && expiresAt <= nowMs
fun CommunityInvite.relaySet(fallback: List<String> = emptyList()): List<String> {
val source = relays.ifEmpty { fallback }
return source.map { it.trim() }.filter { it.isNotEmpty() }.distinct().take(MAX_COMMUNITY_RELAYS)
}
fun CommunityInvite.toMembership(relays: List<String>): Membership = Membership(
communityId = communityId.lowercase(),
owner = owner.lowercase(),
ownerSalt = ownerSalt.lowercase(),
communityRoot = communityRoot.lowercase(),
rootEpoch = rootEpoch,
controlPk = controlPk?.lowercase(),
controlRoot = null,
relays = relays,
name = name,
channels = channels.map { channel ->
StoredChannelKey(
id = channel.id.lowercase(),
key = channel.key.lowercase(),
epoch = channel.epoch,
name = channel.name,
private = !channel.key.equals(communityRoot, ignoreCase = true),
)
},
)
private class FragmentReader(private val bytes: ByteArray) {
var index = 0
private set
fun byte(): Int? = if (index < bytes.size) bytes[index++].toInt() and 0xff else null
fun slice(length: Int): String? {
if (length < 0 || index + length > bytes.size) return null
val value = bytes.decodeToString(index, index + length)
index += length
return value
}
}
@@ -1,263 +0,0 @@
package su.reya.coop.concord
object ConcordKind {
/** NIP-59 gift wrap. Concord reuses it but reverses the roles: fixed author, ephemeral `p`. */
const val WRAP = 1059
/** Ephemeral wrap for kinds that must never be stored (typing, voice presence). */
const val EPHEMERAL_WRAP = 21059
/**
* Sealed content for everything that must stay secret: Chat, Guestbook, rekey blobs
* and Direct Invites. CORD-02 §5 makes this choice normative, never stylistic.
*/
const val SEAL = 20013
/**
* Plaintext seal. **Control plane only** (CORD-02 §5) the roster is public by design
* so that membership, and therefore authority, is verifiable by anyone.
*/
const val PLAINTEXT_SEAL = 20014
/** Channel message (NIP-C7 shape). Chat plane. */
const val MESSAGE = 9
/** Threaded reply (NIP-22 shape). Chat plane. */
const val REPLY = 1111
/** Reaction (NIP-25 shape). Chat plane. */
const val REACTION = 7
/** Delete (NIP-09 shape). Chat plane. */
const val DELETE = 5
/** Disappearing-message timer notice (CORD-08 §4). Chat plane. */
const val TIMER_NOTICE = 1740
/** Edit. Chat plane. */
const val EDIT = 3302
/** Rekey blobs (CORD-06). Rekey addresses. */
const val REKEY = 3303
/** Join / Leave. Guestbook plane. */
const val JOIN_LEAVE = 3306
/** Control edition — sub-kinded by [ConcordVsk]. Control plane. */
const val CONTROL_EDITION = 3308
/** Kick. Guestbook plane. */
const val KICK = 3309
/** WebXDC peer signal. Chat plane. */
const val WEBXDC_SIGNAL = 3310
/** Guestbook snapshot, chunked, refounder-signed. Guestbook plane. */
const val GUESTBOOK_SNAPSHOT = 3312
/** Typing indicator. Chat plane, ephemeral. */
const val TYPING = 23311
/** Voice presence (CORD-07). Chat plane, ephemeral. */
const val VOICE_PRESENCE = 23313
/** Direct Invite — giftwrapped straight to an npub. Rides a standard wrap, not a stream. */
const val DIRECT_INVITE = 3313
/** Public invite bundle. Addressable, signed by its per-link keypair at an empty `d`. */
const val INVITE_BUNDLE = 33301
/** Community List — one addressable event per fragment, NIP-44 to self. */
const val COMMUNITY_LIST = 33302
/**
* The single-event Community List [COMMUNITY_LIST] superseded. Retired and never written;
* queried only so "no List exists" can be told apart from "a client still speaks the old shape".
*/
const val RETIRED_COMMUNITY_LIST = 13302
/** Invite List — replaceable, NIP-44 to self. */
const val INVITE_LIST = 13303
}
/** Control edition sub-kinds, the `vsk` tag on [ConcordKind.CONTROL_EDITION] (CORD-02 Appendix A). */
object ConcordVsk {
/** Community metadata. */
const val METADATA = 0
/** Role. */
const val ROLE = 1
/** Channel metadata — the only other edition v1 folds. */
const val CHANNEL = 2
/** Grant. */
const val GRANT = 3
/** Banlist. */
const val BANLIST = 4
/** Reserved for role ordering. */
const val RESERVED_ROLE_ORDER = 5
/** Claimed by the invite bundle's live marker. */
const val INVITE_LIVE = 6
/** Retired (was the v1 owner attestation). */
const val RETIRED_OWNER_ATTESTATION = 7
/** Invite-link registry. */
const val INVITE_REGISTRY = 8
/** Claimed by the invite bundle's revocation tombstone. */
const val INVITE_TOMBSTONE = 9
/** Dissolved tombstone — chainless, exempt from version discipline. */
const val DISSOLVED = 10
/** Pin List. */
const val PIN_LIST = 11
}
object ConcordPermission {
const val MANAGE_ROLES = 1 shl 0
const val MANAGE_CHANNELS = 1 shl 1
const val MANAGE_METADATA = 1 shl 2
const val KICK = 1 shl 3
const val BAN = 1 shl 4
const val MANAGE_MESSAGES = 1 shl 5
const val CREATE_INVITE = 1 shl 6
/** Retired (was `MANAGE_INVITES`). Never grant it. */
const val RETIRED_MANAGE_INVITES = 1 shl 7
const val VIEW_AUDIT_LOG = 1 shl 8
const val MENTION_EVERYONE = 1 shl 9
const val PIN_MESSAGES = 1 shl 11
/**
* Staff = anyone holding a staff bit, plus the owner. Staff are the ones who hold
* `control_root` and can therefore write to the Control plane.
*/
const val STAFF = MANAGE_ROLES or MANAGE_CHANNELS or MANAGE_METADATA or
BAN or CREATE_INVITE or PIN_MESSAGES
}
object ConcordLabel {
/** A Channel's group key. `secret` = channel key, or `community_root` for a public channel. */
const val CHANNEL = "concord/channel"
/** Control Plane **read** key. `secret` = `community_root`. */
const val CONTROL = "concord/control"
/** Control Plane signer, held by staff only. `secret` = `control_root`. */
const val CONTROL_SIGNER = "concord/control-signer"
/** Guestbook Plane group key. `secret` = `community_root`. */
const val GUESTBOOK = "concord/guestbook"
/** Dissolution tombstone address. `secret` = `community_id`, id = 32 zero bytes, no epoch. */
const val DISSOLVED = "concord/dissolved"
/** A channel rekey address. `secret` = the prior `community_root`. */
const val REKEY_PSEUDONYM = "concord/rekey-pseudonym"
/** A base (community-wide) rekey address. `secret` = the prior `community_root`. */
const val BASE_REKEY_PSEUDONYM = "concord/base-rekey-pseudonym"
/** A rekey blob locator. `secret` = `rotator_xonly ‖ recipient_xonly`. */
const val RECIPIENT_PSEUDONYM = "concord/recipient-pseudonym"
/** SFU room keypair (CORD-07). */
const val VOICE_SIGNER = "concord/voice-signer"
/** 32-byte call media key (CORD-07). */
const val VOICE_MEDIA = "concord/voice-media"
/** Per-sender frame key (CORD-07). id = `sha256(identity)`, no epoch. */
const val VOICE_SENDER = "concord/voice-sender"
/** A member's Grant coordinate. no epoch. */
const val GRANT = "concord/grant"
/** Banlist coordinate. no epoch. */
const val BANLIST = "concord/banlist"
/** A Channel's Pin List coordinate. no epoch. */
const val PINS = "concord/pins"
/** A creator's invite-link registry coordinate. no epoch. */
const val INVITE_LINKS = "concord/invite-links"
/** Public-invite decrypt key. `secret` = the link's unlock token, no epoch. */
const val INVITE_KEY = "concord/invite-key"
/**
* Prefix for the `community_id` commitment (CORD-02 A.4). Not an HKDF label it is
* hashed directly, with no `0x00` separator and no length prefix.
*/
const val COMMUNITY = "concord/community"
/** Prefix for `prevcommit` (CORD-02 A.8). Also hashed directly, not through HKDF. */
const val EPOCH_KEY_COMMITMENT = "concord/epoch-key-commitment"
/**
* Label for `edition_hash` (CORD-02 A.8).
*
* Deliberately **not** under the `concord/` prefix: the spec pins the reference
* implementation's own `vector` label here. Reproduce it exactly.
*/
const val EDITION_HASH = "vector-community/v1/edition"
}
/** Tag names (CORD-01, CORD-02 §5 and the per-kind sections). */
object ConcordTag {
/** `["ms", "<0..999>"]` — sub-second ordering. True time = `created_at * 1000 + ms`. */
const val MS = "ms"
/** `["p", "<ephemeral pubkey>"]` — the one tag a stream wrap carries (NIP-59 reversed). */
const val P = "p"
/** `["channel", "<channel_id>"]` — MUST also be committed inside the author-signed rumor. */
const val CHANNEL = "channel"
/** `["epoch", "<n>"]` — MUST also be committed inside the author-signed rumor. */
const val EPOCH = "epoch"
/** `["q", "<rumor id>", "", "<author>"]` — NIP-C7 inline quote. */
const val QUOTE = "q"
/** `["e", "<rumor id>"]` — the message a reaction, edit or delete targets (CORD-03 §2.32.5). */
const val E = "e"
/** `["vsk", "<n>"]` — Control edition entity type. */
const val VSK = "vsk"
/** `["eid", "<hex32>"]` — stable coordinate of a Control edition's entity. */
const val EID = "eid"
/** `["ev", "<n>"]` — this edition's version, climbing from 1. */
const val EV = "ev"
/** `["ep", "<hash hex>"]` — previous edition hash. Absent on the first edition. */
const val EP = "ep"
/** `["vac", "<grant eid>", "<version>", "<hash>"]` — authority citation. Absent when the owner acts. */
const val VAC = "vac"
/** `["expiration", "<secs>"]` — NIP-40. MUST be on both wrap and rumor, same value. */
const val EXPIRATION = "expiration"
/** `["d", ""]` / `["d", "<index>"]` — addressable coordinate. */
const val D = "d"
/** `["snap", "<id>", "<i>", "<n>"]` — Guestbook snapshot chunk. */
const val SNAP = "snap"
/** `["invite", "<creator hex>", "<label>"]` — optional invite attribution on a join. */
const val INVITE = "invite"
/** `["k", "<kind>"]` — the target's kind on a reaction, the invite hint on a `3313` wrap. */
const val K = "k"
}
@@ -1,742 +0,0 @@
package su.reya.coop.concord
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import rust.nostr.sdk.AckPolicy
import rust.nostr.sdk.Event
import rust.nostr.sdk.EventId
import rust.nostr.sdk.Filter
import rust.nostr.sdk.Kind
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.ReqTarget
import rust.nostr.sdk.SendEventTarget
import rust.nostr.sdk.Tag
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.AppStorage
import su.reya.coop.nostr.Nostr
import kotlin.concurrent.Volatile
import kotlin.time.Clock
import kotlin.time.Duration.Companion.seconds
class ConcordManager(private val nostr: Nostr) {
@Volatile
private var planes: Map<String, Plane> = emptyMap()
/** Latest Control fold per community id. Replaced wholesale; see [planes]. */
@Volatile
private var folds: Map<String, ControlFold> = emptyMap()
/**
* Unread badges by Channel id. Replaced wholesale; see [planes]. In memory only, mirroring
* `Room.unreadCount` in the chat layer, so a restart begins with every badge cleared.
*/
@Volatile
private var unread: Map<String, Int> = emptyMap()
/**
* Bumped whenever a plane rumor is cached or a message is sent, so a screen showing a Channel
* has something to re-query on. A counter rather than a set of changed scopes, because two
* messages to the same Channel must both be observable.
*/
private val _revision = MutableStateFlow(0L)
val revision: StateFlow<Long> = _revision.asStateFlow()
private val _memberships = MutableStateFlow<List<Membership>>(emptyList())
val memberships: StateFlow<List<Membership>> = _memberships.asStateFlow()
private val _communities = MutableStateFlow<List<CommunityState>>(emptyList())
val communities: StateFlow<List<CommunityState>> = _communities.asStateFlow()
/** Direct Invites that arrived giftwrapped to us and have not been accepted or dismissed. */
private val _directInvites = MutableStateFlow<List<CommunityInvite>>(emptyList())
val directInvites: StateFlow<List<CommunityInvite>> = _directInvites.asStateFlow()
/**
* True once [restore] has run. An empty [memberships] means the same thing before and after it,
* so a screen needs this to tell "joined nothing" from "not loaded yet".
*/
private val _restored = MutableStateFlow(false)
val restored: StateFlow<Boolean> = _restored.asStateFlow()
private var store: ConcordStore? = null
/**
* Hands over the encrypted storage [Nostr] cannot reach on its own.
*
* [Nostr] is a Context-free singleton, so the Android layer passes [AppStorage] in through
* [Nostr.init]. Until this is called nothing can be persisted or restored, and the manager
* simply routes nothing.
*/
internal fun attach(storage: AppStorage) {
store = ConcordStore(storage, nostr)
}
/** True when a wrap's author is one of our planes' stream keys. */
fun isPlaneAddress(authorHex: String): Boolean = planes.containsKey(authorHex)
/**
* Handles one stream wrap from a plane we hold a key for.
*
* [PlaneKey.unwrap] runs every check a reader must make and returns null on any failure, so a
* forged, unverifiable, mis-bound or undecryptable wrap is dropped here without ever being
* rendered. What survives is stored under its logical scope and, for Control, re-folded.
*/
suspend fun handlePlaneEvent(event: Event) {
val plane = planes[event.author().toHex()] ?: return
val rumor = plane.key.unwrap(event) ?: return
val store = store ?: return
store.cacheRumor(plane.scopeIdHex, event.id().toHex(), rumor)
_revision.update { it + 1 }
// A message from someone else is the only thing a badge counts. Our own wraps come back
// through the same subscription and so does a re-fetch, so the check is on the rumor's
// author rather than on the arrival.
if (plane.role == PlaneRole.Channel) countUnread(plane.scopeIdHex, rumor)
// Control carries consensus state, so every edition changes what we can read: a new
// Channel means a new plane address, and therefore a new subscription.
if (plane.role == PlaneRole.Control) refreshControl(plane.communityIdHex)
}
/**
* Intercepts rumors that arrive through the ordinary NIP-17 giftwrap pipeline and belong to
* Concord instead.
*
* Returns true when Concord consumed the rumor. A Direct Invite (kind `3313`) rides a *standard*
* NIP-59 wrap, so it unwraps fine but it is a key handoff, not a message, and letting it
* through would have the chat layer build a room out of it. Returning true here keeps all
* Concord kind knowledge inside this package and leaves the chat code untouched.
*/
fun onInboxRumor(rumor: UnsignedEvent): Boolean {
if (rumor.kind().asU16() != ConcordKind.DIRECT_INVITE.toUShort()) return false
val invite =
runCatching { concordJson.decodeFromString<CommunityInvite>(rumor.content()) }.getOrNull()
if (invite == null) {
println("Concord: a direct invite arrived but its bundle could not be read")
return true
}
if (invite.problems().isNotEmpty()) {
println("Concord: refusing a direct invite that does not prove its community id")
return true
}
_directInvites.update { current ->
if (current.any {
it.communityId.equals(
invite.communityId,
ignoreCase = true
)
}) current
else current + invite
}
return true
}
/** Local half of startup: load memberships, fold the cached Control plane, index the planes. */
suspend fun restore() {
val store = store ?: return
val memberships = store.loadMemberships()
_memberships.value = memberships
// Fold what is already on disk so Channels and metadata render immediately. Live editions
// arrive later through handlePlaneEvent and re-fold.
folds = memberships.associate { membership ->
membership.communityId to ConcordControl.fold(
editions = store.cachedRumors(membership.communityId)
.mapNotNull { ConcordControl.editionOf(it) },
communityIdHex = membership.communityId,
)
}
refreshPlanes()
_restored.value = true
}
/**
* Drops every membership and everything derived from it.
*
* Membership keys are identity-scoped holding them *is* being in the Community (CORD-02 §2)
* so signing out has to take them with it, or the next identity on this device holds a seat it
* never took. The cached rumors go with `Nostr.prune()`; the subscriptions are dropped here,
* since nothing else would.
*/
suspend fun reset() {
_memberships.value.forEach { membership ->
nostr.client?.unsubscribe(subscriptionId(membership.communityId))
}
store?.clearMemberships()
_memberships.value = emptyList()
_directInvites.value = emptyList()
folds = emptyMap()
unread = emptyMap()
refreshPlanes()
}
/** Network half of startup: adopt what other clients hold, then connect and subscribe. */
suspend fun sync() {
adoptCommunityList()
for (membership in _memberships.value) {
try {
subscribeCommunity(membership)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
println("Concord: could not subscribe to ${membership.communityId}: ${e.message}")
}
}
}
/**
* Adopts the memberships this identity holds on other clients (CORD-02 §8).
*
* The Community List is the only way a Community joined elsewhere can appear here: an invite
* hands keys to one device, while the List is the member's own self-encrypted vault on their
* own relays. Add-only a tombstone for a Community we do not hold is honoured, but nothing
* this device joined is dropped, because Coop never writes the List and so cannot tell which
* of the two states is newer.
*/
private suspend fun adoptCommunityList() {
val client = nostr.client
if (client == null) {
println("Concord: Community List skipped — no client yet")
return
}
val store = store
if (store == null) {
println("Concord: Community List skipped — storage was never attached")
return
}
val me = try {
nostr.signer.getPublicKeyAsync()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
println("Concord: Community List skipped — could not read the signer: ${e.message}")
return
}
if (me == null) {
println("Concord: Community List skipped — the signer has no public key")
return
}
val filter = Filter()
.kind(Kind(ConcordKind.COMMUNITY_LIST.toUShort()))
.author(me)
val events = try {
client.fetchEvents(ReqTarget.auto(listOf(filter)), timeout = 8.seconds).toVec()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
println("Concord: Community List fetch failed: ${e.message}")
return
}
println(
"Concord: Community List for ${me.toHex().take(8)}… came back with " +
"${events.size} event(s) from ${client.relays().size} relay(s)"
)
val fragments = events.mapNotNull { event ->
try {
val plaintext = nostr.signer.nip44DecryptAsync(me, event.content())
concordJson.decodeFromString<CommunityListFragment>(plaintext)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
println(
"Concord: Community List fragment ${event.id().toHex().take(8)}" +
"(d=${event.tagValue(ConcordTag.D)}) is unusable: ${e.message}"
)
null
}
}
val known = _memberships.value.mapTo(mutableSetOf()) { it.communityId }
val listed = fragments.listedMemberships()
val adopted = listed.filterNot { it.communityId in known }
println(
"Concord: Community List gave ${fragments.size} fragment(s), " +
"${listed.size} membership(s), ${adopted.size} new"
)
if (adopted.isEmpty()) return
val updated = _memberships.value + adopted
try {
store.saveMemberships(updated)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
println("Concord: could not store the adopted memberships: ${e.message}")
return
}
_memberships.value = updated
refreshPlanes()
}
/**
* Fetches and decrypts the bundle behind a public invite link, so the UI can show what joining
* would mean. Nothing is joined, nothing is subscribed and no presence is announced here
* (CORD-05 §1: a bundle is passive until the user accepts).
*/
suspend fun previewInvite(link: String): InvitePreview {
val client = nostr.client ?: throw IllegalStateException("Nostr client is not ready")
val parsed = parseInviteLink(link)
?: throw IllegalArgumentException("That is not a Concord invite link")
val bundleKey = inviteBundleKey(parsed.fragment.tokenHex)
?: throw IllegalArgumentException("The invite link's token is malformed")
val relays = (parsed.naddrRelays.map { it.toString() } + parsed.fragment.relays)
.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }
.distinct()
if (relays.isEmpty()) throw IllegalStateException("The invite link names no relay to fetch from")
relays.forEach { relay ->
client.addRelay(relay)
client.connectRelay(relay)
}
val filter = Filter()
.kind(Kind(ConcordKind.INVITE_BUNDLE.toUShort()))
.author(PublicKey.fromBytes(parsed.signerHex.hexToBytes()))
.identifier(parsed.identifier)
val bundle = client
.fetchEvents(
ReqTarget.manual(relays.associateWith { listOf(filter) }),
timeout = 8.seconds
)
.toVec()
.firstOrNull()
?: throw IllegalStateException("No invite bundle was found at that link")
if (isInviteTombstone(bundle.tagValue(ConcordTag.VSK))) {
throw IllegalStateException("This invite link has been revoked")
}
val invite = decryptInviteBundle(bundle.content(), bundleKey)
?: throw IllegalStateException("The invite bundle could not be opened")
return preview(invite, link, parsed.fragment.relays)
}
/** The same preview for a Direct Invite, which arrived with no link to fetch. */
fun previewDirectInvite(invite: CommunityInvite): InvitePreview =
preview(invite, link = null, fallbackRelays = emptyList())
/**
* Forgets a Direct Invite the user declined. It was never persisted, so this only clears the
* in-memory list there is nothing on a relay to take back.
*/
fun dismissDirectInvite(communityIdHex: String) {
_directInvites.update { invites ->
invites.filterNot { it.communityId.equals(communityIdHex, ignoreCase = true) }
}
}
/** Accepts an invite: persist the keys, connect, subscribe, and announce the join. */
suspend fun join(preview: InvitePreview): CommunityState {
preview.problems.firstOrNull()?.let { throw IllegalArgumentException(it) }
if (preview.expired) throw IllegalArgumentException("That invite has expired")
val store = store ?: throw IllegalStateException("Concord storage is not ready")
val membership = preview.invite.toMembership(preview.relays)
val updated =
_memberships.value.filterNot { it.communityId == membership.communityId } + membership
store.saveMemberships(updated)
_memberships.value = updated
refreshPlanes()
subscribeCommunity(membership)
if (!preview.alreadyJoined) publishJoin(membership, preview.invite)
_directInvites.update { invites ->
invites.filterNot { it.communityId.equals(membership.communityId, ignoreCase = true) }
}
return _communities.value.firstOrNull { it.membership.communityId == membership.communityId }
?: CommunityState(membership, null, emptyList())
}
/** A Channel's messages, oldest first, with reactions and edits folded on top. */
suspend fun channelMessages(channelIdHex: String): List<ConcordMessage> =
store?.cachedRumors(channelIdHex).orEmpty().toChannelMessages()
/** A Channel's unread badge. See [ConcordChannel.unreadCount] for what it does and does not survive. */
fun unreadCount(channelIdHex: String): Int = unread[channelIdHex] ?: 0
/** Clears a Channel's badge — the Channel screen calls this once its messages are on display. */
fun markChannelRead(channelIdHex: String) {
if ((unread[channelIdHex] ?: 0) == 0) return
unread = unread - channelIdHex
publishUnread(channelIdHex, 0)
}
/**
* Sends a message to a Channel (CORD-03 §3), and returns it as a reader will see it.
*
* Throws when the Channel is hidden behind a key we were never granted a Private Channel is
* listable without being writable.
*/
suspend fun sendChannelMessage(channelIdHex: String, content: String): ConcordMessage =
publish(channelPlane(channelIdHex), ConcordKind.MESSAGE, content)
.toConcordMessage()
?: throw IllegalStateException("Concord: could not read back the message just sent")
/**
* Reacts to a message (CORD-03 §2.3). The `e` tag names the target's *rumor* id, and `p`/`k`
* carry its author and kind, which is what lets a reader group it without a lookup.
*/
suspend fun sendChannelReaction(
channelIdHex: String,
targetIdHex: String,
targetAuthorHex: String,
reaction: String,
) {
publish(
plane = channelPlane(channelIdHex),
kind = ConcordKind.REACTION,
content = reaction,
extraTags = listOf(
Tag.event(EventId.parse(targetIdHex)),
Tag.publicKey(PublicKey.parse(targetAuthorHex)),
Tag.custom(ConcordTag.K, listOf(ConcordKind.MESSAGE.toString())),
),
)
}
/**
* Replaces one of our own messages (CORD-03 §2.5). The `e` tag names the message being edited;
* the edit rides the same Channel plane, so it reaches exactly the readers the original did.
*/
suspend fun sendChannelEdit(channelIdHex: String, targetIdHex: String, content: String) {
publish(
plane = channelPlane(channelIdHex),
kind = ConcordKind.EDIT,
content = content,
extraTags = listOf(Tag.event(EventId.parse(targetIdHex))),
)
}
private fun channelPlane(channelIdHex: String): Plane = planes.values.firstOrNull {
it.role == PlaneRole.Channel && it.scopeIdHex.equals(channelIdHex, ignoreCase = true)
} ?: throw IllegalArgumentException("That Channel is not one we hold a key for")
/**
* The whole write path: build the rumor from the Channel's own key, wrap it under that key's
* stream half, cache it locally, then publish to the Community's own relays.
*
* The rumor is cached *before* it is published, so it is visible even if every relay is
* unreachable and so the subscription's echo of the wrap lands on the same `d` slot instead of
* duplicating it. The relays are always the Community's, never the app's defaults.
*/
private suspend fun publish(
plane: Plane,
kind: Int,
content: String,
extraTags: List<Tag> = emptyList(),
): UnsignedEvent {
val client = nostr.client ?: throw IllegalStateException("Nostr client is not ready")
val author =
nostr.signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
val rumor = plane.key.rumor(
author = author,
kind = kind.toUShort(),
content = content,
createdAt = Clock.System.now(),
extraTags = extraTags,
)
val wrap = plane.key.wrap(rumor, nostr.signer)
store?.cacheRumor(plane.scopeIdHex, wrap.id().toHex(), rumor)
_revision.update { it + 1 }
// Always the Community's own relays, never the app's defaults (see [subscribeCommunity]).
val relays = _memberships.value
.firstOrNull { it.communityId == plane.communityIdHex }
?.relays
.orEmpty()
.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }
if (relays.isEmpty()) throw IllegalStateException("That Community names no relay to publish to")
client.sendEvent(
event = wrap,
target = SendEventTarget.to(relays),
ackPolicy = AckPolicy.none()
).failed.forEach { (relay, reason) -> println("Concord: $relay refused a write: $reason") }
return rumor
}
/** Records a badge change and re-publishes [communities] so a badge drawn from it moves. */
private fun publishUnread(channelIdHex: String, count: Int) {
_communities.update { states ->
if (states.none { state -> state.channels.any { it.idHex == channelIdHex } }) return@update states
states.map { state ->
state.copy(
channels = state.channels.map {
if (it.idHex == channelIdHex) it.copy(unreadCount = count) else it
}
)
}
}
}
/** Counts one incoming message against its Channel, ignoring our own and anything but a message. */
private fun countUnread(channelIdHex: String, rumor: UnsignedEvent) {
if (rumor.kind().asU16() != ConcordKind.MESSAGE.toUShort()) return
if (rumor.author() == nostr.signer.publicKeyFlow.value) return
val count = (unread[channelIdHex] ?: 0) + 1
unread = unread + (channelIdHex to count)
publishUnread(channelIdHex, count)
}
/**
* Rebuilds the plane index and the Community read model from the memberships and folds we hold.
*
* Returns the communities whose set of plane addresses changed, because those are exactly the
* ones whose subscription is now stale a Control edition that adds a Channel adds an address.
*/
private fun refreshPlanes(): Set<String> {
val previous = planes
val next = mutableMapOf<String, Plane>()
val states = mutableListOf<CommunityState>()
for (membership in _memberships.value) {
val communityId = membership.communityId.hex32()
val root = membership.communityRoot.hex32()
val fold = folds[membership.communityId]
val granted = membership.channels.associateBy { it.id }
if (communityId != null && root != null) {
membership.controlPk?.let { controlPkHex ->
if (controlPkHex.hex32() != null) {
val key =
controlPlaneKey(root, communityId, membership.rootEpoch, controlPkHex)
next[key.streamPublicKeyHex] = Plane(
communityIdHex = membership.communityId,
role = PlaneRole.Control,
key = key,
scopeIdHex = membership.communityId,
)
}
}
val guestbook = guestbookPlaneKey(root, communityId, membership.rootEpoch)
next[guestbook.streamPublicKeyHex] = Plane(
communityIdHex = membership.communityId,
role = PlaneRole.Guestbook,
key = guestbook,
scopeIdHex = membership.communityId,
)
}
val channelIds = (granted.keys + fold?.channels?.keys.orEmpty()).distinct()
val channels = channelIds.mapNotNull { channelIdHex ->
val meta = fold?.channels?.get(channelIdHex)
if (meta?.deleted == true) return@mapNotNull null
val stored = granted[channelIdHex]
val channelId = channelIdHex.hex32()
val secret = stored?.key?.hex32()
?: if (meta?.isPrivate != true) root else null
val epoch = stored?.epoch ?: membership.rootEpoch
var plane: PlaneKey? = null
if (channelId != null && secret != null) {
plane = channelPlaneKey(secret, channelId, epoch)
next[plane.streamPublicKeyHex] = Plane(
communityIdHex = membership.communityId,
role = PlaneRole.Channel,
key = plane,
scopeIdHex = channelIdHex,
)
}
ConcordChannel(
idHex = channelIdHex,
name = meta?.name ?: stored?.name ?: channelIdHex.take(8),
private = meta?.isPrivate ?: stored?.private ?: false,
epoch = epoch,
hasKey = plane != null,
// Carried across the re-index rather than recomputed: a Control edition must not
// silently clear every badge, the same way ChatRepository preserves its counters.
unreadCount = unread[channelIdHex] ?: 0,
)
}
states += CommunityState(
membership = membership,
meta = fold?.meta,
channels = channels.sortedBy { it.name.lowercase() },
)
}
planes = next
_communities.value = states
return _memberships.value
.filter { membership ->
previous.planeKeys(membership.communityId) != next.planeKeys(membership.communityId)
}
.map { it.communityId }
.toSet()
}
/** Re-folds one Community's Control plane from the cache, then re-indexes if planes shifted. */
private suspend fun refreshControl(communityIdHex: String) {
val store = store ?: return
val editions =
store.cachedRumors(communityIdHex).mapNotNull { ConcordControl.editionOf(it) }
folds = folds + (communityIdHex to ConcordControl.fold(editions, communityIdHex))
if (communityIdHex in refreshPlanes()) {
_memberships.value.firstOrNull { it.communityId == communityIdHex }
?.let { subscribeCommunity(it) }
}
}
/**
* Subscribes to one Community: connect its relays, then ask for every plane's address.
*
* `channel` and `epoch` are multi-letter tags, and Nostr filters only accept single-letter ones,
* so plane addresses are the finest granularity available. That is the right level anyway one
* key per plane and the `channel`/`epoch` binding is a strict check after decryption.
*/
private suspend fun subscribeCommunity(membership: Membership) {
val client = nostr.client ?: return
val id = subscriptionId(membership.communityId)
client.unsubscribe(id)
val relays = membership.relays
.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }
.distinct()
if (relays.isEmpty()) return
relays.forEach { relay ->
client.addRelay(relay)
client.connectRelay(relay)
}
val filters = planes.values
.filter { it.communityIdHex == membership.communityId }
.mapNotNull { plane -> plane.key.streamPublicKeyHex.hex32() }
.distinct()
.map {
Filter().kind(Kind(ConcordKind.WRAP.toUShort())).author(PublicKey.fromBytes(it))
}
if (filters.isEmpty()) return
client.subscribe(target = ReqTarget.manual(relays.associateWith { filters }), id = id)
}
/** Publishes a still-fetchable Guestbook Join (CORD-02 §5) — each member's own word. */
private suspend fun publishJoin(membership: Membership, invite: CommunityInvite?) {
val client = nostr.client ?: return
val communityId = membership.communityId.hex32() ?: return
val root = membership.communityRoot.hex32() ?: return
val author =
nostr.signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
val plane = guestbookPlaneKey(root, communityId, membership.rootEpoch)
val extraTags = invite?.creatorNpub
?.let { creator ->
listOf(Tag.custom(ConcordTag.INVITE, listOf(creator, invite.label.orEmpty())))
}.orEmpty()
val rumor = plane.rumor(
author = author,
kind = ConcordKind.JOIN_LEAVE.toUShort(),
content = "join",
createdAt = Clock.System.now(),
extraTags = extraTags,
)
val wrap = plane.wrap(rumor, nostr.signer)
val targets =
membership.relays.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }
client.sendEvent(
event = wrap,
target = SendEventTarget.to(targets),
ackPolicy = AckPolicy.none()
)
}
private fun subscriptionId(communityIdHex: String): String =
"$SUBSCRIPTION_PREFIX$communityIdHex"
private fun preview(
invite: CommunityInvite,
link: String?,
fallbackRelays: List<String>,
): InvitePreview {
val relays = invite.relaySet(fallbackRelays)
val problems =
invite.problems() + if (relays.isEmpty()) listOf("The invite names no relays") else emptyList()
return InvitePreview(
link = link,
invite = invite,
relays = relays,
problems = problems,
expired = invite.isExpired(Clock.System.now().toEpochMilliseconds()),
alreadyJoined = _memberships.value.any {
it.communityId.equals(
invite.communityId,
ignoreCase = true
)
},
)
}
private companion object {
const val SUBSCRIPTION_PREFIX = "concord:"
}
}
/** What a plane is for — the Community-wide Control and Guestbook planes, or one Channel. */
private enum class PlaneRole { Control, Guestbook, Channel }
/**
* One plane address we hold: the key to decrypt it, the role that says how to fold it, and the
* [scopeIdHex] it caches under.
*/
private class Plane(
val communityIdHex: String,
val role: PlaneRole,
val key: PlaneKey,
val scopeIdHex: String,
)
private fun Map<String, Plane>.planeKeys(communityIdHex: String): Set<String> =
filterValues { it.communityIdHex == communityIdHex }.keys
/** The first content value of a tag, or null. Used for tags read without their companion fields. */
private fun Event.tagValue(kind: String): String? =
tags().toVec().firstOrNull { it.kind() == kind }?.content()
@@ -1,246 +0,0 @@
package su.reya.coop.concord
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import rust.nostr.sdk.UnsignedEvent
import kotlin.time.Instant
internal val concordJson = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
@Serializable
data class Membership(
/** Hex `community_id`. Never appears on the wire; every coordinate derives from it one-way. */
val communityId: String,
/** Owner x-only pubkey hex, the other half of the `community_id` commitment. */
val owner: String,
/** 32-byte hex salt; not secret, travels in invites so anyone can recompute the commitment. */
val ownerSalt: String,
/** 32-byte hex `community_root`. The base access key — never leaves the encrypted store. */
val communityRoot: String,
/** Epoch the root was issued at (CORD-02 §3). */
val rootEpoch: ULong,
/** Control Plane signer pubkey (CORD-02 §5), used to *address* the plane. Trusted on trust. */
val controlPk: String? = null,
/** Staff-only write key. v1 never writes to the Control Plane, so this stays null. */
val controlRoot: String? = null,
/** The Community's relays — always from the invite, never the app's defaults (see PLAN.md 13.2). */
val relays: List<String> = emptyList(),
/** Last known name, cached so a community renders before its Control fold lands. */
val name: String? = null,
/** Channels this invite actually granted a key for. */
val channels: List<StoredChannelKey> = emptyList(),
)
@Serializable
data class StoredChannelKey(
val id: String,
val key: String,
val epoch: ULong,
val name: String? = null,
val private: Boolean = false,
)
@Serializable
data class CommunityInvite(
@SerialName("community_id") val communityId: String = "",
val owner: String = "",
@SerialName("owner_salt") val ownerSalt: String = "",
@SerialName("community_root") val communityRoot: String = "",
@SerialName("root_epoch") val rootEpoch: ULong = 0u,
@SerialName("control_pk") val controlPk: String? = null,
val channels: List<InviteChannel> = emptyList(),
val relays: List<String> = emptyList(),
val name: String? = null,
val icon: ConcordIcon? = null,
/** Unix **milliseconds** — see [expiryToEpochSeconds]. Optional. */
@SerialName("expires_at") val expiresAt: Long? = null,
@SerialName("creator_npub") val creatorNpub: String? = null,
val label: String? = null,
)
/** One granted Channel inside a bundle. `key` is `community_root` for a Public one (CORD-03 §1). */
@Serializable
data class InviteChannel(
val id: String = "",
val key: String = "",
val epoch: ULong = 0u,
val name: String? = null,
)
@Serializable
data class ConcordIcon(
val url: String? = null,
val key: String? = null,
val nonce: String? = null,
val hash: String? = null,
)
/** Community metadata — the `vsk 0` entity's content (CORD-02 §6). */
@Serializable
data class CommunityMeta(
val name: String? = null,
val description: String? = null,
/** The Community's own relay set. This is the authority; the invite's copy is a snapshot. */
val relays: List<String> = emptyList(),
val icon: ConcordIcon? = null,
val banner: ConcordIcon? = null,
/** Disappearing-messages timer in seconds (CORD-08). Read only; v1 does not enforce it. */
@SerialName("message_expiration") val messageExpiration: Long? = null,
)
/** Channel metadata — the `vsk 2` entity's content (CORD-03 §2). */
@Serializable
data class ChannelMeta(
val name: String? = null,
@SerialName("private") val isPrivate: Boolean = false,
/** Terminal: the id is never reused and clients drop the Channel (CORD-03 §2). */
val deleted: Boolean = false,
)
data class ControlEdition(
val vsk: Int,
val eidHex: String,
val version: ULong,
val prevHashHex: String?,
val content: String,
val actorHex: String,
val rumorIdHex: String,
val createdAt: Instant,
)
/** The projected Control state v1 uses: the Community's metadata and its Channel list. */
data class ControlFold(
val meta: CommunityMeta?,
val channels: Map<String, ChannelMeta>,
)
/** A Channel as the UI sees it: its identity, and whether we actually hold a key to read it. */
data class ConcordChannel(
val idHex: String,
val name: String,
val private: Boolean,
val epoch: ULong,
/** False for a Private Channel we were never granted — listable, but not readable. */
val hasKey: Boolean,
/**
* Messages from others since this Channel was last opened. In memory only, exactly like
* `Room.unreadCount`: it survives a Control re-fold, but not a restart.
*/
val unreadCount: Int = 0,
)
/** A Community with its Control fold applied. */
data class CommunityState(
val membership: Membership,
/** Null until a `vsk 0` edition has been folded; the invite's name fills in before that. */
val meta: CommunityMeta?,
val channels: List<ConcordChannel>,
)
/** One emoji and everyone who used it on a message (CORD-03 §2.3), grouped for display. */
data class ConcordReaction(
val emoji: String,
val authors: List<String>,
)
/** A Chat-plane message, already unwrapped and signature-checked. */
data class ConcordMessage(
val idHex: String,
val author: String,
val content: String,
val createdAt: Instant,
/** CORD-02 §4: `created_at * 1000 + ms`. The only ordering basis the protocol uses. */
val timestampMs: Long,
val reactions: List<ConcordReaction> = emptyList(),
/** True once the author's own Edit (kind `3302`) has replaced [content]. */
val edited: Boolean = false,
)
/** A redeemed-in-part invite, ready for the UI to preview before anything joins (CORD-05 §1). */
data class InvitePreview(
/** Null for a Direct Invite, which arrives with no link. */
val link: String?,
val invite: CommunityInvite,
/** Resolved relay set: the bundle's, falling back to the link's bootstrap relays. */
val relays: List<String>,
/** Non-empty means the bundle is refused — these are the reasons, in plain English. */
val problems: List<String>,
val expired: Boolean,
val alreadyJoined: Boolean,
) {
val joinable: Boolean get() = problems.isEmpty() && !expired
}
fun CommunityInvite.expiryToEpochSeconds(): Long? = expiresAt?.let { it / 1000 }
internal fun UnsignedEvent.concordTimestampMs(): Long {
val ms = tags().toVec()
.firstOrNull { it.kind() == ConcordTag.MS }
?.content()
?.toIntOrNull()
?.takeIf { it in 0..999 }
return createdAt().asSecs().toLong() * 1000 + (ms ?: 0)
}
internal fun UnsignedEvent.toConcordMessage(): ConcordMessage? {
if (kind().asU16() != ConcordKind.MESSAGE.toUShort()) return null
return ConcordMessage(
idHex = id()?.toHex().orEmpty(),
author = author().toHex(),
content = content(),
createdAt = Instant.fromEpochSeconds(createdAt().asSecs().toLong()),
timestampMs = concordTimestampMs(),
)
}
/**
* Projects one Channel's cached rumors into the timeline: its messages, with reactions and edits
* folded on top.
*
* Every one of these kinds names its target by the target's *rumor* id, never the outer wrap's
* that id differs on every re-wrap (CORD-03 §2). A reaction for a message we no longer hold is
* dropped along with it. An Edit counts only from the message's own author: the `e` tag is a claim,
* and authorship is what the seal actually proves.
*/
internal fun List<UnsignedEvent>.toChannelMessages(): List<ConcordMessage> {
val messages = filter { it.isKind(ConcordKind.MESSAGE) }
.mapNotNull { it.toConcordMessage() }
.sortedBy { it.timestampMs }
val reactions = filter { it.isKind(ConcordKind.REACTION) }
.mapNotNull { rumor -> rumor.targetHex()?.let { it to rumor } }
.groupBy({ it.first }, { it.second })
.mapValues { (_, rumors) ->
rumors.groupBy { it.content() }
.map { (emoji, reactors) ->
ConcordReaction(
emoji = emoji.ifEmpty { "+" },
authors = reactors.map { it.author().toHex() }.distinct(),
)
}
}
val edits = filter { it.isKind(ConcordKind.EDIT) }
.mapNotNull { rumor -> rumor.targetHex()?.let { it to rumor } }
.groupBy({ it.first }, { it.second })
.mapValues { (_, candidates) -> candidates.maxBy { it.concordTimestampMs() } }
return messages.map { message ->
val edit = edits[message.idHex]?.takeIf { it.author().toHex() == message.author }
message.copy(
content = edit?.content() ?: message.content,
edited = edit != null,
reactions = reactions[message.idHex].orEmpty(),
)
}
}
private fun UnsignedEvent.isKind(expected: Int): Boolean = kind().asU16() == expected.toUShort()
/** The target of a reaction, edit or delete: the first `e` tag (CORD-03 §2.32.5). */
private fun UnsignedEvent.targetHex(): String? =
tags().toVec().firstOrNull { it.kind() == ConcordTag.E }?.content()
@@ -1,179 +0,0 @@
package su.reya.coop.concord
import kotlinx.coroutines.CancellationException
import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.Event
import rust.nostr.sdk.EventBuilder
import rust.nostr.sdk.Keys
import rust.nostr.sdk.Kind
import rust.nostr.sdk.Nip44Version
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Tag
import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent
import rust.nostr.sdk.nip44Decrypt
import rust.nostr.sdk.nip44Encrypt
import kotlin.time.Instant
enum class SealForm(private val kindValue: Int) {
Encrypted(ConcordKind.SEAL),
Plaintext(ConcordKind.PLAINTEXT_SEAL);
val kind: UShort get() = kindValue.toUShort()
}
data class ChatBinding(val channelIdHex: String, val epoch: ULong)
class PlaneKey(
val read: GroupKey,
val stream: GroupKey?,
val streamPublicKeyHex: String,
val form: SealForm,
val chat: ChatBinding?,
)
fun channelPlaneKey(channelSecret: ByteArray, channelId: ByteArray, epoch: ULong): PlaneKey {
val key = groupKey(ConcordLabel.CHANNEL, channelSecret, channelId, epoch)
return PlaneKey(
read = key,
stream = key,
streamPublicKeyHex = key.publicKey.toHex(),
form = SealForm.Encrypted,
chat = ChatBinding(channelId.toHex(), epoch),
)
}
fun guestbookPlaneKey(communityRoot: ByteArray, communityId: ByteArray, epoch: ULong): PlaneKey {
val key = groupKey(ConcordLabel.GUESTBOOK, communityRoot, communityId, epoch)
return PlaneKey(
read = key,
stream = key,
streamPublicKeyHex = key.publicKey.toHex(),
form = SealForm.Encrypted,
chat = null,
)
}
fun controlPlaneKey(
communityRoot: ByteArray,
communityId: ByteArray,
epoch: ULong,
streamPublicKeyHex: String,
): PlaneKey {
val key = groupKey(ConcordLabel.CONTROL, communityRoot, communityId, epoch)
return PlaneKey(
read = key,
stream = null,
streamPublicKeyHex = streamPublicKeyHex,
form = SealForm.Plaintext,
chat = null,
)
}
fun PlaneKey.rumor(
author: PublicKey,
kind: UShort,
content: String,
createdAt: Instant,
extraTags: List<Tag> = emptyList(),
): UnsignedEvent {
val tags = buildList {
chat?.let {
add(Tag.custom(ConcordTag.CHANNEL, listOf(it.channelIdHex)))
add(Tag.custom(ConcordTag.EPOCH, listOf(it.epoch.toString())))
}
add(Tag.custom(ConcordTag.MS, listOf(msOf(createdAt).toString())))
addAll(extraTags)
}
return EventBuilder(Kind(kind), content)
.tags(tags)
.customCreatedAt(Timestamp.fromSecs(createdAt.epochSeconds.toULong()))
.finalizeUnsigned(author)
.ensureId()
}
suspend fun PlaneKey.wrap(rumor: UnsignedEvent, signer: AsyncNostrSigner): Event {
val stream = stream ?: error("Concord: this plane is read-only and cannot wrap")
val createdAt = rumor.createdAt()
val rumorJson = rumor.asJson()
val content = if (form == SealForm.Encrypted) nip44Seal(read, rumorJson) else rumorJson
val seal = try {
EventBuilder(Kind(form.kind), content).customCreatedAt(createdAt).finalizeAsync(signer)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
throw IllegalStateException("Concord: failed to seal rumor: ${e.message}", e)
}
check(seal.author() == rumor.author()) {
"Concord: seal author ${seal.author().toHex()} does not match rumor author ${
rumor.author().toHex()
}"
}
return try {
val ephemeral = Keys.generate().use { Tag.publicKey(it.publicKey()) }
Keys(stream.secretKey).use { keys ->
EventBuilder(Kind(concordKind(ConcordKind.WRAP)), nip44Seal(read, seal.asJson()))
.tags(listOf(ephemeral))
.customCreatedAt(createdAt)
.finalize(keys)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
throw IllegalStateException("Concord: failed to wrap seal: ${e.message}", e)
}
}
fun PlaneKey.unwrap(event: Event): UnsignedEvent? {
if (event.kind().asU16() != ConcordKind.WRAP.toUShort()) return null
if (event.author().toHex() != streamPublicKeyHex) return null
if (!event.verify()) return null
val seal = runCatching {
Event.fromJson(nip44Decrypt(read.secretKey, read.publicKey, event.content()))
}.getOrNull() ?: return null
if (!seal.verify()) return null
val sealKind = seal.kind().asU16()
val rumorJson = when {
sealKind == SealForm.Encrypted.kind && form == SealForm.Encrypted -> runCatching {
nip44Decrypt(read.secretKey, read.publicKey, seal.content())
}.getOrNull() ?: return null
// Plaintext seal: the rumor's bytes are already the content, never re-parsed as an event.
sealKind == SealForm.Plaintext.kind && form == SealForm.Plaintext -> seal.content()
else -> return null
}
val rumor =
runCatching { UnsignedEvent.fromJson(rumorJson).ensureId() }.getOrNull() ?: return null
// NIP-59's impersonation check: the seal proves who wrote the rumor inside it.
if (rumor.author() != seal.author()) return null
if (!bindsToThisPlane(rumor)) return null
return rumor
}
private fun PlaneKey.bindsToThisPlane(rumor: UnsignedEvent): Boolean {
val expected = chat ?: return true
val tags = rumor.tags().toVec()
val channel = tags.firstOrNull { it.kind() == ConcordTag.CHANNEL }?.content()
val epoch = tags.firstOrNull { it.kind() == ConcordTag.EPOCH }?.content()
return channel == expected.channelIdHex && epoch == expected.epoch.toString()
}
private fun msOf(createdAt: Instant): Int = createdAt.toEpochMilliseconds().mod(1000L).toInt()
private fun concordKind(kind: Int): UShort = kind.toUShort()
private fun nip44Seal(key: GroupKey, plaintext: String): String {
val size = plaintext.encodeToByteArray().size
require(size <= MAX_PLAINTEXT_BYTES) {
"Concord: NIP-44 plaintext is $size bytes, over the $MAX_PLAINTEXT_BYTES cap"
}
return nip44Encrypt(key.secretKey, key.publicKey, plaintext, Nip44Version.V2)
}
private const val MAX_PLAINTEXT_BYTES = 65_535
@@ -1,102 +0,0 @@
package su.reya.coop.concord
import kotlinx.coroutines.CancellationException
import rust.nostr.sdk.EventBuilder
import rust.nostr.sdk.Filter
import rust.nostr.sdk.Keys
import rust.nostr.sdk.Kind
import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.Tag
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.AppStorage
import su.reya.coop.nostr.Nostr
class ConcordStore(private val storage: AppStorage, private val nostr: Nostr) {
suspend fun loadMemberships(): List<Membership> {
val raw = try {
storage.getSecret(MEMBERSHIPS_KEY)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
println("Concord: could not read memberships: ${e.message}")
return emptyList()
} ?: return emptyList()
return try {
concordJson.decodeFromString<List<Membership>>(raw)
} catch (e: Exception) {
println("Concord: stored memberships are unreadable: ${e.message}")
emptyList()
}
}
suspend fun saveMemberships(memberships: List<Membership>) {
try {
storage.setSecret(MEMBERSHIPS_KEY, concordJson.encodeToString(memberships))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
throw IllegalStateException("Concord: could not store memberships: ${e.message}", e)
}
}
suspend fun clearMemberships() {
try {
storage.clear(MEMBERSHIPS_KEY)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
println("Concord: could not clear memberships: ${e.message}")
}
}
suspend fun cacheRumor(scopeIdHex: String, wrapIdHex: String, rumor: UnsignedEvent) {
try {
val event =
EventBuilder(Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA), rumor.asJson())
.tags(
listOf(
Tag.identifier(wrapIdHex),
Tag.custom(INDEX_SCOPE_TAG, listOf(scopeIdHex)),
Tag.custom(INDEX_KIND_TAG, listOf(rumor.kind().asU16().toString())),
)
)
.finalizeAsync(Keys.generate())
nostr.client?.database()?.saveEvent(event)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
println("Concord: failed to cache rumor: ${e.message}")
}
}
/** Every cached rumor for one scope, oldest first. */
suspend fun cachedRumors(scopeIdHex: String): List<UnsignedEvent> {
val events = try {
val filter = Filter()
.kind(Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA))
.reference(scopeIdHex)
nostr.client?.database()?.query(filter)?.toVec() ?: return emptyList()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
println("Concord: failed to read cached rumors: ${e.message}")
return emptyList()
}
return events
.mapNotNull {
runCatching {
UnsignedEvent.fromJson(it.content()).ensureId()
}.getOrNull()
}
.sortedBy { it.createdAt().asSecs() }
}
private companion object {
const val MEMBERSHIPS_KEY = "concord_memberships"
const val INDEX_SCOPE_TAG = "r"
const val INDEX_KIND_TAG = "k"
}
}
@@ -144,15 +144,12 @@ class MessageManager(private val nostr: Nostr) {
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
try {
val isReaction = rumor.kind().asStd() == KindStandard.REACTION
val kValue = if (isReaction) "reaction" else "dm"
// Construct reference tags
val tags = listOf(
Tag.identifier(giftId.toHex()),
Tag.publicKey(rumor.author()),
Tag.custom("r", listOf(rumor.roomId().toString())),
Tag.custom("k", listOf("14", kValue))
Tag.custom("k", listOf("14"))
)
// Set event kind
@@ -179,13 +176,12 @@ class MessageManager(private val nostr: Nostr) {
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
val kTag = SingleLetterTag.lowercase(Alphabet.K)
// Get all rumors (DMs and Reactions)
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm", "reaction"))
// Get all DM events
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm"))
val events = client?.database()?.query(filter)?.toVec() ?: return null
// Collect rooms
val roomsMap: MutableMap<Long, Room> = mutableMapOf()
val lastDmTimestampMap: MutableMap<Long, ULong> = mutableMapOf()
events
.map { UnsignedEvent.fromJson(it.content()) }
@@ -193,41 +189,18 @@ class MessageManager(private val nostr: Nostr) {
.forEach { rumor ->
val id = rumor.roomId()
val isFromMe = rumor.author() == userPubkey
val isReaction = rumor.kind().asStd() == KindStandard.REACTION
val existing = roomsMap[id]
val createdAt = rumor.createdAt()
if (existing == null) {
// If the room is new or the current rumor is newer than the existing one
if (existing == null || createdAt.asSecs() > existing.createdAt.asSecs()) {
// A room is "Ongoing" if it was already marked as such or if the current rumor is from the user
val isOngoing = (existing?.kind == RoomKind.Ongoing) || isFromMe
val room = Room.new(rumor = rumor, userPubkey = userPubkey, id = id)
// If the first event we see is a reaction, don't use it as lastMessage
roomsMap[id] = if (isReaction) {
room.copy(lastMessage = null)
} else {
lastDmTimestampMap[id] = createdAt.asSecs()
room
}
if (isFromMe) {
roomsMap[id] = roomsMap[id]!!.copy(kind = RoomKind.Ongoing)
}
} else {
// Update the overall room timestamp (for sorting) if this event is newer
if (createdAt.asSecs() > existing.createdAt.asSecs()) {
roomsMap[id] = roomsMap[id]!!.copy(createdAt = createdAt)
}
// Update the last message content if this is a DM and it's newer than the last DM we've seen
if (!isReaction) {
val lastDmTs = lastDmTimestampMap[id] ?: 0uL
if (createdAt.asSecs() >= lastDmTs) {
lastDmTimestampMap[id] = createdAt.asSecs()
roomsMap[id] = roomsMap[id]!!.copy(lastMessage = rumor.content())
}
}
// If any event is from the user, mark the room as Ongoing
if (isFromMe && roomsMap[id]?.kind != RoomKind.Ongoing) {
roomsMap[id] = roomsMap[id]!!.copy(kind = RoomKind.Ongoing)
}
roomsMap[id] = if (isOngoing) room.copy(kind = RoomKind.Ongoing) else room
} else if (isFromMe && existing.kind != RoomKind.Ongoing) {
// If it's an older rumor but sent by the user, mark the room as Ongoing
roomsMap[id] = existing.copy(kind = RoomKind.Ongoing)
}
}
@@ -375,64 +348,4 @@ class MessageManager(private val nostr: Nostr) {
throw IllegalStateException("Failed to send message: ${e.message}", e)
}
}
suspend fun sendReaction(
to: Set<PublicKey>,
targetEventId: EventId,
reaction: String,
onRumorCreated: ((UnsignedEvent) -> Unit)? = null,
) {
try {
val currentUser =
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
val tags = mutableListOf<Tag>()
tags.add(Tag.event(targetEventId))
// Add public key tags for each recipient (including me) to ensure roomId consistency
to.forEach { pubkey ->
tags.add(Tag.publicKey(pubkey))
}
for (receiver in setOf(currentUser) + to) {
// Construct the rumor event
val rumor = EventBuilder(Kind.fromStd(KindStandard.REACTION), reaction)
.tags(tags)
.finalizeUnsigned(currentUser)
.ensureId()
// Emit the rumor to the chat screen
if (receiver == currentUser) {
onRumorCreated?.invoke(rumor)
}
// Construct the gift wrap event
val gift = nip59MakeGiftWrapAsync(
signer = signer,
receiverPubkey = receiver,
rumor = rumor,
extraTags = listOf(
Tag.custom("k", listOf("14"))
)
)
// Send the event to receiver's NIP-17 relays
val output = client?.sendEvent(
event = gift,
target = SendEventTarget.toNip17(),
ackPolicy = AckPolicy.none(),
authenticationTimeout = Duration.parse("2s")
)
if (output != null) {
// Keep track of rumor IDs
val id = rumor.id() ?: throw IllegalStateException("Rumor ID is null")
rumorMap[id] = output.id
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
throw IllegalStateException("Failed to send reaction: ${e.message}", e)
}
}
}
@@ -32,8 +32,6 @@ import rust.nostr.sdk.SleepWhenIdle
import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent
import rust.nostr.sdk.initLogger
import su.reya.coop.AppStorage
import su.reya.coop.concord.ConcordManager
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@@ -52,7 +50,6 @@ class Nostr(
val messages = MessageManager(this)
val profiles = ProfileManager(this)
val relays = RelayManager(this)
val concord = ConcordManager(this)
private val isInitialized = MutableStateFlow(false)
@@ -67,18 +64,10 @@ class Nostr(
_newEvents.tryEmit(event)
}
suspend fun init(
dbPath: String,
storage: AppStorage,
logLevel: LogLevel = LogLevel.WARN,
) {
suspend fun init(dbPath: String, logLevel: LogLevel = LogLevel.WARN) {
try {
if (isInitialized.value) return
// Concord's keys live in the encrypted store, which only the Android layer can build.
// Handing it over here keeps it out of the Context-free singleton's constructor.
concord.attach(storage)
// Initialize the logger for nostr client
initLogger(logLevel)
@@ -155,35 +144,16 @@ class Nostr(
val notifications = client?.notifications() ?: return@supervisorScope
val giftWrapQueue = Channel<Event>(1024)
val concordQueue = Channel<Event>(1024)
var processedCount = 0
var eoseReceived = false
try {
concord.restore()
} catch (e: Exception) {
println("Failed to restore Concord state: ${e.message}")
}
launch(defaultDispatcher) { concord.sync() }
launch(defaultDispatcher) {
for (event in concordQueue) {
try {
concord.handlePlaneEvent(event)
} catch (e: Exception) {
println("Failed to handle Concord plane event: ${e.message}")
}
}
}
launch(defaultDispatcher) {
for (event in giftWrapQueue) {
val rumor = messages.extractRumor(event)
processedCount++
if (rumor != null && !concord.onInboxRumor(rumor)) {
// Trigger new message notification
if (rumor != null) {
val isSelfMessage = rumor.author() == signer.publicKeyFlow.value
val isNew = rumor.createdAt().asSecs() >= now.asSecs()
@@ -244,12 +214,8 @@ class Nostr(
}
KindStandard.GIFT_WRAP -> {
if (concord.isPlaneAddress(event.author().toHex())) {
concordQueue.send(event)
} else {
giftWrapQueue.send(event)
}
}
else -> {}
}
@@ -241,29 +241,8 @@ class ChatRepository(
}
}
fun sendReaction(roomId: Long, targetEventId: EventId, reaction: String) {
scope.launch(defaultDispatcher) {
try {
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
nostr.messages.sendReaction(
to = room.members,
targetEventId = targetEventId,
reaction = reaction,
onRumorCreated = {
scope.launch(defaultDispatcher) {
updateRoomState(it, roomId)
}
},
)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
private suspend fun updateRoomState(event: UnsignedEvent, roomId: Long = event.roomId()) {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return
val isReaction = event.kind().asStd() == KindStandard.REACTION
_state.update { currentState ->
val rooms = currentState.rooms.toMutableMap()
@@ -277,24 +256,22 @@ class ChatRepository(
// New room discovery
val newRoom = Room.new(event, currentUser, roomId).copy(
kind = newKind,
unreadCount = if (isFromMe || isReaction) 0 else 1,
lastMessage = if (isReaction) null else event.content()
unreadCount = if (isFromMe) 0 else 1
)
rooms[newRoom.id] = newRoom
} else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) {
// Update timestamp for any newer event (DM or Reaction)
// But only update preview if it's a DM
// Only update preview if message is newer (handles sync/late arrivals)
rooms[roomId] = existingRoom.copy(
lastMessage = if (isReaction) existingRoom.lastMessage else event.content(),
lastMessage = event.content(),
createdAt = event.createdAt(),
kind = newKind,
unreadCount = if (isFromMe || isReaction) existingRoom.unreadCount else existingRoom.unreadCount + 1
unreadCount = if (isFromMe) existingRoom.unreadCount else existingRoom.unreadCount + 1
)
} else if (isFromMe && existingRoom.kind != RoomKind.Ongoing) {
// Even if it's an older message or reaction, if it's from me, the room is ongoing
// Even if it's an older message, if it's from me, the room is ongoing
rooms[roomId] = existingRoom.copy(kind = RoomKind.Ongoing)
} else {
// Don't update the room list state for older messages or reactions that don't change preview
// Don't update the room list state for older messages
return@update currentState
}
currentState.copy(rooms = rooms)
@@ -1,88 +0,0 @@
package su.reya.coop.repository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import su.reya.coop.concord.CommunityInvite
import su.reya.coop.concord.CommunityState
import su.reya.coop.concord.ConcordMessage
import su.reya.coop.concord.InvitePreview
import su.reya.coop.nostr.Nostr
import su.reya.coop.viewmodel.ErrorHost
import su.reya.coop.viewmodel.createErrorHost
class ConcordRepository(
private val nostr: Nostr,
private val scope: CoroutineScope,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : ErrorHost by createErrorHost() {
private val concord = nostr.concord
val communities: StateFlow<List<CommunityState>> = concord.communities
/** Direct Invites that arrived giftwrapped to us and have not been accepted or dismissed. */
val directInvites: StateFlow<List<CommunityInvite>> = concord.directInvites
/** True once memberships have been loaded, so an empty list means "joined nothing". */
val isReady: StateFlow<Boolean> = concord.restored
/** Bumped whenever a plane rumor lands, so an open Channel knows to re-read its history. */
val revision: StateFlow<Long> = concord.revision
/** Fetches and decrypts the bundle behind a public invite link. */
suspend fun previewInvite(link: String): InvitePreview? = attempt { concord.previewInvite(link) }
/** The same preview for a Direct Invite, which already arrived with its bundle in hand. */
fun previewDirectInvite(invite: CommunityInvite): InvitePreview =
concord.previewDirectInvite(invite)
/** Accepts an invite: persist the keys, connect, subscribe, announce the join. */
suspend fun join(preview: InvitePreview): CommunityState? = attempt { concord.join(preview) }
/** A Channel's history, oldest first, from the local cache the live subscription keeps current. */
suspend fun channelMessages(channelIdHex: String): List<ConcordMessage> =
attempt { concord.channelMessages(channelIdHex) }.orEmpty()
suspend fun sendMessage(channelIdHex: String, content: String): ConcordMessage? =
attempt { concord.sendChannelMessage(channelIdHex, content) }
/** Reacts to one message; `targetAuthorHex` is the message's author, which rides the `p` tag. */
suspend fun sendReaction(
channelIdHex: String,
targetIdHex: String,
targetAuthorHex: String,
reaction: String,
) {
attempt { concord.sendChannelReaction(channelIdHex, targetIdHex, targetAuthorHex, reaction) }
}
/** Replaces one of our own messages with [content]. */
suspend fun editMessage(channelIdHex: String, targetIdHex: String, content: String) {
attempt { concord.sendChannelEdit(channelIdHex, targetIdHex, content) }
}
/** Clears a Channel's badge — the Channel screen calls this once its messages are on display. */
fun markChannelRead(channelIdHex: String) = concord.markChannelRead(channelIdHex)
fun dismissDirectInvite(communityIdHex: String) = concord.dismissDirectInvite(communityIdHex)
fun resetInternalState() {
scope.launch(defaultDispatcher) { attempt { concord.reset() } }
}
/** Runs one Concord call off the main thread, reporting any failure instead of throwing. */
private suspend fun <T> attempt(block: suspend () -> T): T? = withContext(defaultDispatcher) {
try {
block()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
showError("Error: ${e.message}")
null
}
}
}
@@ -1,83 +0,0 @@
package su.reya.coop.viewmodel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import su.reya.coop.Profile
import su.reya.coop.concord.ConcordChannel
import su.reya.coop.concord.ConcordMessage
import su.reya.coop.repository.AccountRepository
import su.reya.coop.repository.ConcordRepository
class ChannelScreenViewModel(
val communityId: String,
val channelId: String,
accountRepository: AccountRepository,
private val concordRepository: ConcordRepository,
) : ViewModel(), ErrorHost by concordRepository {
val currentUser: StateFlow<Profile?> = accountRepository.currentUserProfile
val channel: StateFlow<ConcordChannel?> = concordRepository.communities
.map { communities ->
communities
.firstOrNull { it.membership.communityId == communityId }
?.channels
?.firstOrNull { it.idHex == channelId }
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
var loading by mutableStateOf(true)
val messages = mutableStateListOf<ConcordMessage>()
private var reloadJob: Job? = null
init {
reload()
viewModelScope.launch {
concordRepository.revision.drop(1).collect { reload() }
}
}
private fun reload() {
reloadJob?.cancel()
reloadJob = viewModelScope.launch {
val loaded = concordRepository.channelMessages(channelId)
// The whole list, not just its ids: a reaction or an edit changes a message without
// changing which messages there are.
if (messages != loaded) {
messages.clear()
messages.addAll(loaded)
}
loading = false
concordRepository.markChannelRead(channelId)
}
}
fun sendMessage(text: String) {
if (text.isBlank()) return
viewModelScope.launch { concordRepository.sendMessage(channelId, text) }
}
fun sendReaction(message: ConcordMessage, reaction: String) {
viewModelScope.launch {
concordRepository.sendReaction(channelId, message.idHex, message.author, reaction)
}
}
/** Replaces one of our own messages; [targetIdHex] is the message being edited. */
fun editMessage(targetIdHex: String, text: String) {
if (text.isBlank()) return
viewModelScope.launch { concordRepository.editMessage(channelId, targetIdHex, text) }
}
}
@@ -74,8 +74,4 @@ class ChatScreenViewModel(
fun sendFileMessage(file: ByteArray?, type: String?) {
chatRepository.sendFileMessage(id, file, type)
}
fun sendReaction(targetEventId: EventId, reaction: String) {
chatRepository.sendReaction(id, targetEventId, reaction)
}
}
@@ -1,27 +0,0 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.StateFlow
import su.reya.coop.concord.CommunityInvite
import su.reya.coop.concord.CommunityState
import su.reya.coop.concord.InvitePreview
import su.reya.coop.repository.ConcordRepository
class ConcordViewModel(
private val repository: ConcordRepository,
) : ViewModel(), ErrorHost by repository {
val communities: StateFlow<List<CommunityState>> = repository.communities
val directInvites: StateFlow<List<CommunityInvite>> = repository.directInvites
val isReady: StateFlow<Boolean> = repository.isReady
fun previewDirectInvite(invite: CommunityInvite): InvitePreview =
repository.previewDirectInvite(invite)
suspend fun previewInvite(link: String): InvitePreview? = repository.previewInvite(link)
suspend fun join(preview: InvitePreview): CommunityState? = repository.join(preview)
fun dismissDirectInvite(communityIdHex: String) = repository.dismissDirectInvite(communityIdHex)
fun resetInternalState() = repository.resetInternalState()
}
@@ -0,0 +1,306 @@
package su.reya.coop
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import rust.nostr.sdk.EventId
import rust.nostr.sdk.Filter
import rust.nostr.sdk.Kind
import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.ReqTarget
import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.nostr.Nostr
import su.reya.coop.nostr.NostrManager
import su.reya.coop.repository.AccountRepository
import su.reya.coop.repository.AccountState
import su.reya.coop.repository.ChatRepository
import su.reya.coop.repository.MediaRepository
import su.reya.coop.repository.SettingsRepository
import su.reya.coop.viewmodel.ProfileCache
import kotlin.coroutines.resume
class FlowSubscription(private val job: Job) {
fun cancel() {
job.cancel()
}
}
data class RelayLists(
val messaging: List<RelayUrl>,
val inbox: List<RelayUrl>,
val outbox: List<RelayUrl>,
)
class Bootstrap private constructor(
val scope: CoroutineScope,
val nostr: Nostr,
val settingsRepository: SettingsRepository,
val accountRepository: AccountRepository,
val chatRepository: ChatRepository,
val profileCache: ProfileCache,
) {
companion object {
fun create(storage: AppStorage): Bootstrap {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val nostr = NostrManager.instance
val settingsRepository = SettingsRepository(storage, scope)
val mediaRepository = MediaRepository(settingsRepository)
val accountRepository = AccountRepository(
nostr = nostr,
storage = storage,
mediaRepository = mediaRepository,
settingsRepository = settingsRepository,
scope = scope,
externalSignerHandler = null,
)
val chatRepository = ChatRepository(nostr, mediaRepository, settingsRepository, scope)
val profileCache = ProfileCache(nostr)
return Bootstrap(
scope = scope,
nostr = nostr,
settingsRepository = settingsRepository,
accountRepository = accountRepository,
chatRepository = chatRepository,
profileCache = profileCache,
)
}
}
private var notificationsJob: Job? = null
private var dbPath: String? = null
private var onNewMessage: ((UnsignedEvent) -> Unit)? = null
fun start(dbPath: String, onNewMessage: (UnsignedEvent) -> Unit) {
this.dbPath = dbPath
this.onNewMessage = onNewMessage
startNotificationLoop()
}
private fun startNotificationLoop() {
if (notificationsJob?.isActive == true) return
val path = dbPath ?: return
val messageCallback = onNewMessage ?: return
notificationsJob = scope.launch {
runCatching {
nostr.init(path)
nostr.connectBootstrapRelays()
nostr.handleNotifications(
onMetadataUpdate = { pubkey, metadata ->
scope.launch { nostr.profiles.emitMetadataUpdate(pubkey, metadata) }
},
onContactListUpdate = { contacts ->
scope.launch { nostr.profiles.emitContactListUpdate(contacts) }
},
onNewMessage = { event ->
nostr.emitNewEvent(event)
messageCallback(event)
},
)
}.onFailure {
accountRepository.showError("Failed to start Nostr: ${it.message}")
}
}
}
suspend fun resume() {
startNotificationLoop()
nostr.waitUntilInitialized()
nostr.client?.connect()
val pubkey = nostr.signer.publicKeyFlow.value ?: return
runCatching { nostr.profiles.getUserMetadata() }
val relays = nostr.relays.getMsgRelays(pubkey)
if (relays.isEmpty()) return
relays.forEach { relay ->
nostr.client?.addRelay(relay)
nostr.client?.connectRelay(relay)
}
nostr.messages.updateSyncState { it.copy(isSyncing = true) }
val filter = Filter().kind(Kind.fromStd(KindStandard.GIFT_WRAP)).pubkey(pubkey)
val target = relays.associateWith { listOf(filter) }
nostr.client?.subscribe(target = ReqTarget.manual(target), id = "gift-wraps")
}
suspend fun pause() {
nostr.client?.disconnect()
}
private fun <T> Flow<T>.watch(onEach: (T) -> Unit): FlowSubscription {
val job = scope.launch(Dispatchers.Main) { collect { onEach(it) } }
return FlowSubscription(job)
}
fun watchAccountState(onEach: (AccountState) -> Unit): FlowSubscription =
accountRepository.state.watch(onEach)
fun watchIsUpdatingProfile(onEach: (Boolean) -> Unit): FlowSubscription =
accountRepository.isUpdatingProfile.watch(onEach)
fun watchCurrentUserProfile(onEach: (Profile?) -> Unit): FlowSubscription =
accountRepository.currentUserProfile.watch(onEach)
fun watchChatRooms(onEach: (List<Room>) -> Unit): FlowSubscription =
chatRepository.chatRooms.watch(onEach)
fun watchIsSyncing(onEach: (Boolean) -> Unit): FlowSubscription =
chatRepository.isSyncing.watch(onEach)
fun watchPartialProcessed(onEach: (Boolean) -> Unit): FlowSubscription =
chatRepository.isPartialProcessedGiftWrap.watch(onEach)
fun watchSettings(onEach: (Settings) -> Unit): FlowSubscription =
settingsRepository.settings.watch(onEach)
fun watchNewEvents(onEach: (UnsignedEvent) -> Unit): FlowSubscription =
chatRepository.newEvents.watch(onEach)
fun watchErrors(onEach: (String) -> Unit): FlowSubscription =
merge(
accountRepository.errorEvents,
chatRepository.errorEvents,
profileCache.errorEvents,
).watch(onEach)
fun watchProfile(pubkey: PublicKey, onEach: (Profile?) -> Unit): FlowSubscription =
profileCache.getMetadata(pubkey).watch(onEach)
fun watchRoomUi(
room: Room,
currentUser: PublicKey?,
onEach: (RoomUiState) -> Unit
): FlowSubscription =
room.uiStateFlow(profileCache, currentUser).watch(onEach)
@Throws(IllegalArgumentException::class)
fun createChatRoom(recipients: List<PublicKey>): Long =
chatRepository.createChatRoom(recipients)
fun getChatRoom(id: Long): Room? = chatRepository.getChatRoom(id)
fun markRoomRead(id: Long) = chatRepository.markAsRead(id)
fun refreshChatRooms() = chatRepository.refreshChatRooms()
fun connectRoom(id: Long) = chatRepository.chatRoomConnect(id)
fun sendTextMessage(roomId: Long, text: String) =
chatRepository.sendMessage(roomId, text, emptyList())
fun sendReplyMessage(roomId: Long, text: String, replyTo: EventId) =
chatRepository.sendMessage(roomId, text, listOf(replyTo))
fun sendImageMessage(roomId: Long, file: ByteArray, contentType: String) =
chatRepository.sendFileMessage(roomId, file, contentType, emptyList())
suspend fun loadRoomMessages(roomId: Long): List<UnsignedEvent> =
suspendCancellableCoroutine { cont ->
chatRepository.loadChatRoomMessages(roomId) { cont.resume(it) }
}
fun importIdentity(secret: String, password: String?) =
accountRepository.importIdentity(secret, password)
fun createIdentity(name: String, bio: String?, picture: ByteArray?, contentType: String?) =
accountRepository.createIdentity(name, bio, picture, contentType)
fun updateProfile(name: String?, bio: String?, picture: ByteArray?, contentType: String?) =
accountRepository.updateProfile(name, bio, picture, contentType)
fun logout() = accountRepository.logout {}
fun dismissNotificationBanner() = accountRepository.dismissNotificationBanner()
fun addContact(address: String) = accountRepository.addContact(address)
fun removeContact(publicKey: PublicKey) = accountRepository.removeContact(publicKey)
suspend fun searchByAddress(query: String): PublicKey? =
suspendCancellableCoroutine { cont ->
accountRepository.searchByAddress(query) { cont.resume(it) }
}
suspend fun searchByNostr(query: String): List<PublicKey> =
suspendCancellableCoroutine { cont ->
accountRepository.searchByNostr(query) { cont.resume(it) }
}
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? =
suspendCancellableCoroutine { cont ->
accountRepository.verifyActivity(pubkey) { cont.resume(it) }
}
suspend fun verifyContact(pubkey: PublicKey): Boolean =
suspendCancellableCoroutine { cont ->
accountRepository.verifyContact(pubkey) { cont.resume(it) }
}
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> =
suspendCancellableCoroutine { cont ->
accountRepository.mutualContacts(pubkey) { cont.resume(it) }
}
fun refetchMsgRelays() = accountRepository.refetchMsgRelays()
fun useDefaultMsgRelayList() = accountRepository.useDefaultMsgRelayList()
fun loadRelayLists() {
accountRepository.loadCurrentUserRelayList()
accountRepository.loadCurrentUserMsgRelayList()
}
fun watchRelayLists(onEach: (RelayLists) -> Unit): FlowSubscription =
accountRepository.state.map { state ->
RelayLists(
messaging = state.userMsgRelayList,
inbox = state.userRelayList
.filter { it.value == RelayMetadata.READ || it.value == null }
.keys.toList(),
outbox = state.userRelayList
.filter { it.value == RelayMetadata.WRITE || it.value == null }
.keys.toList(),
)
}.watch(onEach)
fun addMsgRelay(relay: String) = accountRepository.addMsgRelay(relay)
fun removeMsgRelay(relay: String) = accountRepository.removeMsgRelay(relay)
fun addInboxRelay(relay: String) = accountRepository.addInboxRelay(relay)
fun addOutboxRelay(relay: String) = accountRepository.addOutboxRelay(relay)
fun removeRelay(relay: String) = accountRepository.removeRelay(relay)
fun setTheme(theme: Theme) = settingsRepository.update { it.copy(theme = theme) }
fun setMediaConfig(media: MediaConfig) = settingsRepository.update { it.copy(media = media) }
fun setScreening(enabled: Boolean) = settingsRepository.update { it.copy(screening = enabled) }
fun setBlossomServer(url: String?) = settingsRepository.update { it.copy(blossomServer = url) }
fun parsePublicKey(input: String): PublicKey? =
runCatching { PublicKey.parse(input.trim()) }.getOrNull()
fun currentPublicKey(): PublicKey? = nostr.signer.publicKeyFlow.value
fun resetState() {
accountRepository.resetInternalState()
chatRepository.resetInternalState()
}
}