Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7eb4dd8e81 | ||
|
|
e3fec77359 |
@@ -63,7 +63,7 @@ class NostrForegroundService : Service() {
|
|||||||
|
|
||||||
// Initialize Nostr client
|
// Initialize Nostr client
|
||||||
try {
|
try {
|
||||||
nostr.init(dbDir.absolutePath, AppStore(this@NostrForegroundService))
|
nostr.init(dbDir.absolutePath)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to initialize Nostr Client", e)
|
throw IllegalStateException("Failed to initialize Nostr Client", e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,9 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import rust.nostr.sdk.EventId
|
import rust.nostr.sdk.EventId
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
@@ -51,13 +46,6 @@ import su.reya.coop.formatAsTime
|
|||||||
import su.reya.coop.isImageUrl
|
import su.reya.coop.isImageUrl
|
||||||
import su.reya.coop.removeImageUrls
|
import su.reya.coop.removeImageUrls
|
||||||
|
|
||||||
@Immutable
|
|
||||||
data class ReactionGroup(
|
|
||||||
val emoji: String,
|
|
||||||
val authors: List<PublicKey>,
|
|
||||||
val containsMe: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class MessageModel(
|
data class MessageModel(
|
||||||
val id: EventId,
|
val id: EventId,
|
||||||
@@ -66,20 +54,15 @@ data class MessageModel(
|
|||||||
val images: List<String>,
|
val images: List<String>,
|
||||||
val timestamp: String,
|
val timestamp: String,
|
||||||
val isMine: Boolean,
|
val isMine: Boolean,
|
||||||
val replyEventIds: List<EventId>,
|
val replyEventIds: List<EventId>
|
||||||
val reactions: List<ReactionGroup> = emptyList()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberMessageModel(
|
fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null): MessageModel {
|
||||||
event: UnsignedEvent,
|
|
||||||
reactions: List<UnsignedEvent> = emptyList(),
|
|
||||||
currentUser: PublicKey? = null
|
|
||||||
): MessageModel {
|
|
||||||
val settings = LocalSettings.current
|
val settings = LocalSettings.current
|
||||||
val isMobileData = LocalConnectivity.current
|
val isMobileData = LocalConnectivity.current
|
||||||
|
|
||||||
return remember(event, reactions, currentUser, settings, isMobileData) {
|
return remember(event, currentUser, settings, isMobileData) {
|
||||||
val id = event.ensureId().id()!!
|
val id = event.ensureId().id()!!
|
||||||
val isMine = currentUser == event.author()
|
val isMine = currentUser == event.author()
|
||||||
val content = event.content()
|
val content = event.content()
|
||||||
@@ -121,16 +104,6 @@ fun rememberMessageModel(
|
|||||||
append(cleanedContent.substring(lastIndex))
|
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(
|
MessageModel(
|
||||||
id = id,
|
id = id,
|
||||||
author = event.author(),
|
author = event.author(),
|
||||||
@@ -138,8 +111,7 @@ fun rememberMessageModel(
|
|||||||
images = images,
|
images = images,
|
||||||
timestamp = event.createdAt().formatAsTime(),
|
timestamp = event.createdAt().formatAsTime(),
|
||||||
isMine = isMine,
|
isMine = isMine,
|
||||||
replyEventIds = replyEventIds,
|
replyEventIds = replyEventIds
|
||||||
reactions = groupedReactions
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,108 +158,46 @@ fun ChatMessage(
|
|||||||
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
|
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
Box(contentAlignment = Alignment.BottomEnd) {
|
if (model.annotatedContent.isNotBlank()) {
|
||||||
Column(
|
Surface(
|
||||||
modifier = Modifier.padding(
|
modifier = Modifier.widthIn(max = 280.dp),
|
||||||
bottom = if (model.reactions.isNotEmpty()) 4.dp else 0.dp,
|
color = containerColor,
|
||||||
end = if (model.reactions.isNotEmpty() && !model.isMine) 8.dp else 0.dp
|
contentColor = contentColor,
|
||||||
),
|
shape = bubbleShape,
|
||||||
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
|
||||||
) {
|
) {
|
||||||
if (model.annotatedContent.isNotBlank()) {
|
Text(
|
||||||
Surface(
|
text = model.annotatedContent,
|
||||||
modifier = Modifier.widthIn(max = 280.dp),
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
color = containerColor,
|
style = MaterialTheme.typography.bodyLarge
|
||||||
contentColor = contentColor,
|
)
|
||||||
shape = bubbleShape,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = model.annotatedContent,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
style = MaterialTheme.typography.bodyLarge
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
model.images.forEach { imageUrl ->
|
|
||||||
Surface(
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
modifier = Modifier.widthIn(max = 280.dp)
|
|
||||||
) {
|
|
||||||
AsyncImage(
|
|
||||||
model = imageUrl,
|
|
||||||
contentDescription = "Image from chat",
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(16.dp)),
|
|
||||||
contentScale = ContentScale.FillWidth
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (model.reactions.isNotEmpty()) {
|
model.images.forEach { imageUrl ->
|
||||||
MessageReactions(
|
Surface(
|
||||||
reactions = model.reactions,
|
shape = RoundedCornerShape(16.dp),
|
||||||
modifier = Modifier.offset(y = 12.dp)
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
modifier = Modifier.widthIn(max = 280.dp)
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = imageUrl,
|
||||||
|
contentDescription = "Image from chat",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(16.dp)),
|
||||||
|
contentScale = ContentScale.FillWidth
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMessageClicked) {
|
if (isMessageClicked) {
|
||||||
Text(
|
Text(
|
||||||
text = model.timestamp,
|
text = model.timestamp,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.outline,
|
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
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun MessageReactions(
|
|
||||||
reactions: List<ReactionGroup>,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val totalCount = reactions.sumOf { it.authors.size }
|
|
||||||
val displayEmojis = reactions.take(3).map { it.emoji }
|
|
||||||
|
|
||||||
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 (totalCount > 2) {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
color = MaterialTheme.colorScheme.surface,
|
|
||||||
shape = CircleShape,
|
|
||||||
) {
|
|
||||||
Box(contentAlignment = Alignment.Center) {
|
|
||||||
Text(
|
|
||||||
text = totalCount.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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
@@ -92,7 +91,6 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.EventId
|
import rust.nostr.sdk.EventId
|
||||||
import rust.nostr.sdk.KindStandard
|
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileCache
|
import su.reya.coop.LocalProfileCache
|
||||||
@@ -146,21 +144,10 @@ fun ChatScreen(
|
|||||||
val loading = viewModel.loading
|
val loading = viewModel.loading
|
||||||
val newOtherMessages = viewModel.newOtherMessages
|
val newOtherMessages = viewModel.newOtherMessages
|
||||||
val requireScreening = viewModel.requireScreening
|
val requireScreening = viewModel.requireScreening
|
||||||
val allEvents = viewModel.messages
|
val messages = 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 groupedMessages =
|
val groupedMessages =
|
||||||
remember { derivedStateOf { displayMessages.groupBy { it.createdAt().formatAsGroup() } } }
|
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroup() } } }
|
||||||
|
|
||||||
val roomState by remember(id, currentUser?.publicKey) {
|
val roomState by remember(id, currentUser?.publicKey) {
|
||||||
(room as Room).uiStateFlow(profileCache, currentUser?.publicKey)
|
(room as Room).uiStateFlow(profileCache, currentUser?.publicKey)
|
||||||
@@ -183,7 +170,7 @@ fun ChatScreen(
|
|||||||
|
|
||||||
for (group in groupedMessages.value) {
|
for (group in groupedMessages.value) {
|
||||||
val msgInGroup = group.value
|
val msgInGroup = group.value
|
||||||
val idx = msgInGroup.indexOfFirst { it.ensureId().id() == eventId }
|
val idx = msgInGroup.indexOfFirst { it.id() == eventId }
|
||||||
if (idx != -1) {
|
if (idx != -1) {
|
||||||
targetIndex = currentIndex + idx
|
targetIndex = currentIndex + idx
|
||||||
break
|
break
|
||||||
@@ -227,8 +214,8 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(allEvents.size) {
|
LaunchedEffect(messages.size) {
|
||||||
if (displayMessages.isNotEmpty()) {
|
if (messages.isNotEmpty()) {
|
||||||
listState.animateScrollToItem(0)
|
listState.animateScrollToItem(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,7 +293,7 @@ fun ChatScreen(
|
|||||||
room?.let { ScreenerCard(accountViewModel, it) }
|
room?.let { ScreenerCard(accountViewModel, it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
when (displayMessages.isNotEmpty()) {
|
when (messages.isNotEmpty()) {
|
||||||
true -> {
|
true -> {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -321,15 +308,14 @@ fun ChatScreen(
|
|||||||
items = messagesInGroup,
|
items = messagesInGroup,
|
||||||
key = { it.ensureId().id()?.toHex()!! }
|
key = { it.ensureId().id()?.toHex()!! }
|
||||||
) { event ->
|
) { event ->
|
||||||
val msgReactions = reactionsByMessage[event.id()] ?: emptyList()
|
|
||||||
val model =
|
val model =
|
||||||
rememberMessageModel(event, msgReactions, currentUser?.publicKey)
|
rememberMessageModel(event, currentUser?.publicKey)
|
||||||
|
|
||||||
val replyPreview =
|
val replyPreview =
|
||||||
remember(model.replyEventIds, displayMessages.size) {
|
remember(model.replyEventIds, messages.size) {
|
||||||
model.replyEventIds.firstOrNull()
|
model.replyEventIds.firstOrNull()
|
||||||
?.let { replyId ->
|
?.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 (model, bounds) = selectedMessage ?: return@AnimatedVisibility
|
||||||
|
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
val windowInfo = LocalWindowInfo.current
|
||||||
|
val windowHeight = windowInfo.containerSize.height
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
var menuHeight by remember { mutableFloatStateOf(0f) }
|
var menuHeight by remember { mutableFloatStateOf(0f) }
|
||||||
var toolbarHeight by remember { mutableFloatStateOf(0f) }
|
val spacing = with(density) { 12.dp.toPx() }
|
||||||
val spacing = with(density) { 6.dp.toPx() }
|
val showAbove =
|
||||||
|
(windowHeight - bounds.bottom) < (menuHeight + spacing) && bounds.top > (menuHeight + spacing)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -491,30 +480,11 @@ fun ChatScreen(
|
|||||||
.clickable { selectedMessage = null }
|
.clickable { selectedMessage = null }
|
||||||
.verticalScroll(scrollState),
|
.verticalScroll(scrollState),
|
||||||
) {
|
) {
|
||||||
val totalExtraHeight = (if (menuHeight > 0) menuHeight + spacing else 300f) +
|
val totalExtraHeight = if (menuHeight > 0) menuHeight + spacing else 300f
|
||||||
(if (toolbarHeight > 0) toolbarHeight + spacing else 100f)
|
|
||||||
val contentBottom = with(density) { (bounds.bottom + totalExtraHeight).toDp() }
|
val contentBottom = with(density) { (bounds.bottom + totalExtraHeight).toDp() }
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(contentBottom + 200.dp))
|
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(
|
ChatMessage(
|
||||||
model = model,
|
model = model,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -522,35 +492,38 @@ fun ChatScreen(
|
|||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Action Menu (Below)
|
val menuOffset = if (showAbove) {
|
||||||
|
bounds.top - menuHeight - spacing
|
||||||
|
} else {
|
||||||
|
bounds.bottom + spacing
|
||||||
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset { IntOffset(0, (bounds.bottom + spacing).toInt()) }
|
.offset { IntOffset(0, menuOffset.toInt().coerceAtLeast(0)) }
|
||||||
.onGloballyPositioned { menuHeight = it.size.height.toFloat() }
|
.onGloballyPositioned { menuHeight = it.size.height.toFloat() }
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
|
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
|
||||||
) {
|
) {
|
||||||
ContextMenu(
|
ContextMenu { action ->
|
||||||
onAction = { action ->
|
when (action) {
|
||||||
when (action) {
|
"Copy" -> {
|
||||||
"Copy" -> {
|
scope.launch {
|
||||||
scope.launch {
|
val content = model.annotatedContent
|
||||||
val content = model.annotatedContent
|
val data = ClipData.newPlainText(content, content)
|
||||||
val data = ClipData.newPlainText(content, content)
|
clipboardManager.setClipEntry(ClipEntry(data))
|
||||||
clipboardManager.setClipEntry(ClipEntry(data))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
"Reply" -> {
|
|
||||||
replyingTo = model
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {}
|
|
||||||
}
|
}
|
||||||
selectedMessage = null
|
|
||||||
|
"Reply" -> {
|
||||||
|
replyingTo = model
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
}
|
}
|
||||||
)
|
selectedMessage = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -649,41 +622,9 @@ private fun ReplyPreview(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private 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)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContextMenu(
|
private fun ContextMenu(onAction: (String) -> Unit) {
|
||||||
onAction: (String) -> Unit
|
|
||||||
) {
|
|
||||||
val menuItems = listOf(
|
val menuItems = listOf(
|
||||||
"Copy" to Res.drawable.ic_copy,
|
"Copy" to Res.drawable.ic_copy,
|
||||||
"Reply" to Res.drawable.ic_reply
|
"Reply" to Res.drawable.ic_reply
|
||||||
@@ -692,8 +633,6 @@ private fun ContextMenu(
|
|||||||
DropdownMenuGroup(
|
DropdownMenuGroup(
|
||||||
shapes = MenuDefaults.groupShape(1, 1),
|
shapes = MenuDefaults.groupShape(1, 1),
|
||||||
containerColor = MenuDefaults.groupVibrantContainerColor,
|
containerColor = MenuDefaults.groupVibrantContainerColor,
|
||||||
tonalElevation = 1.dp,
|
|
||||||
shadowElevation = 1.dp,
|
|
||||||
modifier = Modifier.width(220.dp)
|
modifier = Modifier.width(220.dp)
|
||||||
) {
|
) {
|
||||||
val itemCount = menuItems.size
|
val itemCount = menuItems.size
|
||||||
|
|||||||
@@ -4,4 +4,7 @@ PRODUCT_NAME=Coop
|
|||||||
PRODUCT_BUNDLE_IDENTIFIER=su.reya.coop.Coop$(TEAM_ID)
|
PRODUCT_BUNDLE_IDENTIFIER=su.reya.coop.Coop$(TEAM_ID)
|
||||||
|
|
||||||
CURRENT_PROJECT_VERSION=1
|
CURRENT_PROJECT_VERSION=1
|
||||||
MARKETING_VERSION=1.0
|
MARKETING_VERSION=1.0
|
||||||
|
|
||||||
|
FRAMEWORK_SEARCH_PATHS=$(inherited) "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"
|
||||||
|
OTHER_LDFLAGS=$(inherited) -framework Shared
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,32 +2,80 @@ import SwiftUI
|
|||||||
import Shared
|
import Shared
|
||||||
|
|
||||||
struct ContentView: View {
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if showContent {
|
var body: some View {
|
||||||
VStack(spacing: 16) {
|
Group {
|
||||||
Image(systemName: "swift")
|
switch appState.signerRequired {
|
||||||
.font(.system(size: 200))
|
case .none:
|
||||||
.foregroundColor(.accentColor)
|
SplashView()
|
||||||
Text("SwiftUI: \(Greeting().greet())")
|
case .some(true):
|
||||||
|
OnboardingView()
|
||||||
|
case .some(false):
|
||||||
|
NavigationStack(path: Bindable(appState).path) {
|
||||||
|
HomeView()
|
||||||
|
.navigationDestination(for: AppRoute.self) { route in
|
||||||
|
destination(for: route)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.transition(.move(edge: .top).combined(with: .opacity))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
.alert("Error", isPresented: Binding(
|
||||||
.padding()
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ContentView_Previews: PreviewProvider {
|
struct SplashView: View {
|
||||||
static var previews: some View {
|
var body: some View {
|
||||||
ContentView()
|
ZStack {
|
||||||
|
Color(.systemBackground).ignoresSafeArea()
|
||||||
|
Image(systemName: "bubble.left.and.bubble.right.fill")
|
||||||
|
.font(.system(size: 64))
|
||||||
|
.foregroundStyle(.tint)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,18 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<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>
|
</dict>
|
||||||
</plist>
|
</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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,33 @@ import SwiftUI
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct iOSApp: App {
|
struct iOSApp: App {
|
||||||
|
@State private var appState = AppState()
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ kotlin {
|
|||||||
iosTarget.binaries.framework {
|
iosTarget.binaries.framework {
|
||||||
baseName = "Shared"
|
baseName = "Shared"
|
||||||
isStatic = true
|
isStatic = true
|
||||||
|
binaryOptions["objcExportSuspendFunctionLaunchThreadRestriction"] = "none"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
package su.reya.coop.concord
|
|
||||||
|
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Folding the Control Plane (CORD-04 §1).
|
|
||||||
*
|
|
||||||
* The Control Plane carries the Community's authoritative state as **editions**: each is a
|
|
||||||
* `kind 3308` rumor naming its entity (`vsk`), that entity's stable coordinate (`eid`), this
|
|
||||||
* edition's version (`ev`) and the hash of the previous one (`ep`). Every member folds the whole
|
|
||||||
* chain and reaches the same verdict, so authority is arithmetic rather than a server's say-so.
|
|
||||||
*
|
|
||||||
* v1 folds only two entity types: Community metadata (`vsk 0`) and Channel metadata (`vsk 2`).
|
|
||||||
*
|
|
||||||
* ## What v1 does not do
|
|
||||||
*
|
|
||||||
* **The fold is not gated on authority.** CORD-04 judges every edition by its actor's rank in the
|
|
||||||
* owner-rooted Roster, via the `vac` citation. v1 has no Roster, so a `control_root` holder could
|
|
||||||
* publish a forged metadata or Channel edition and v1 would display it.
|
|
||||||
*
|
|
||||||
* That gap is bounded rather than open — only the owner and staff hold `control_root` (CORD-02 §2),
|
|
||||||
* and the spec itself calls that secret "a spam gate, never authority" — but it is real. It is the
|
|
||||||
* reason the feature ships labelled beta. Closing it is CORD-04's job: fold `vsk 1` (Roles) and
|
|
||||||
* `vsk 3` (Grants), then require every edition's `vac` to cite a Grant whose actor strictly
|
|
||||||
* outranks the entity it edits.
|
|
||||||
*/
|
|
||||||
object ConcordControl {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses one Control rumor into an edition, or null when it is not a foldable edition.
|
|
||||||
*
|
|
||||||
* A `vsk 10` Dissolution tombstone is refused here: it is chainless (no `ev`, no `ep`) and v1
|
|
||||||
* does not implement dissolution, so treating it as an ordinary edition would misread it.
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
// CORD-04: versions climb from 1. A zero version has no place in the chain.
|
|
||||||
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()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Projects the editions into the metadata and Channel list v1 renders.
|
|
||||||
*
|
|
||||||
* Entities are folded independently: each `(vsk, eid)` group resolves to its own winner, so a
|
|
||||||
* broken chain on one Channel never takes the Community's metadata down with it.
|
|
||||||
*/
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Picks the highest version whose `ep` chain reaches all the way back to version 1.
|
|
||||||
*
|
|
||||||
* "Highest" is not simply `max(ev)`: CORD-04 lets a client refuse to downgrade, so we walk down
|
|
||||||
* from the top and take the first version we can actually verify. A missing predecessor or a
|
|
||||||
* broken link disqualifies that version, not the entity.
|
|
||||||
*
|
|
||||||
* Ties on the same version break on the **lower rumor id**, never on `created_at`, so two
|
|
||||||
* clients folding the same two candidates always agree.
|
|
||||||
*/
|
|
||||||
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,250 +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
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Byte-exact cryptographic primitives from Concord (CORD-02 Appendix A).
|
|
||||||
*
|
|
||||||
* Everything here is frozen by the spec, and a single wrong byte breaks interop silently
|
|
||||||
* rather than loudly — so each function quotes the CORD section that governs it, and each was
|
|
||||||
* checked against RFC 5869 vectors and independently computed digests before it was written.
|
|
||||||
* Concord ships no test vectors of its own ("Examples are illustrative, not verifiable test
|
|
||||||
* vectors"), and no test file is kept — see PLAN.md §14.
|
|
||||||
*
|
|
||||||
* Only HMAC-SHA256 and SHA-256 come from outside: both are Okio `ByteString` members that
|
|
||||||
* are available on every target this module builds for, so no new crypto dependency is
|
|
||||||
* needed. Everything else is composition of the existing nostr SDK.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HKDF-SHA256 (RFC 5869), Extract then Expand.
|
|
||||||
*
|
|
||||||
* Concord always calls this with no salt (CORD-02 A.1 specifies a zero-length salt, not 32
|
|
||||||
* zero bytes), so [salt] defaults to empty. It is exposed only so the RFC's known-answer
|
|
||||||
* vectors — which do use a salt — can be re-checked by hand; Concord publishes none.
|
|
||||||
*
|
|
||||||
* @param length output length in octets, `1..255 * 32` per RFC 5869.
|
|
||||||
*/
|
|
||||||
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" }
|
|
||||||
|
|
||||||
// Extract: PRK = HMAC-SHA256(salt, IKM). Note IKM is the HMAC *message*, not the key.
|
|
||||||
// Okio refuses a zero-length HMAC key, while RFC 5869 treats an absent salt as HashLen
|
|
||||||
// (32) zero octets — and HMAC zero-pads any key shorter than its 64-octet block, so the
|
|
||||||
// two are literally the same key. Substituting is exact, not a workaround; the RFC's own
|
|
||||||
// zero-length-salt vector was checked against it.
|
|
||||||
val saltKey = if (salt.isEmpty()) ByteArray(32).toByteString() else salt.toByteString()
|
|
||||||
val prk = ikm.toByteString().hmacSha256(saltKey)
|
|
||||||
|
|
||||||
// Expand: T(n) = HMAC-SHA256(PRK, T(n-1) | info | n), counter being one octet.
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the HKDF `info` for a Concord label (CORD-02 A.1):
|
|
||||||
*
|
|
||||||
* ```
|
|
||||||
* info = utf8(label) | 0x00 | id[32] | epoch_be[8] // epoch omitted for labels marked "—"
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* [id] is always present and always 32 bytes, all-zeroes where a label has no meaningful
|
|
||||||
* id. The epoch is the only omittable field.
|
|
||||||
*/
|
|
||||||
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)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The secret-key material of a plane's group key: CORD-02 A.2's `group_key` up to and
|
|
||||||
* including A.3's `scalar_normalize`.
|
|
||||||
*
|
|
||||||
* ```
|
|
||||||
* info = hkdfInfo(label, id, epoch)
|
|
||||||
* seed = hkdf(secret, info)
|
|
||||||
* while (!isValidScalar(seed)) { info = info | counter++; seed = hkdf(secret, info) }
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* A.3 only bites when the HKDF output is not a secp256k1 scalar, which is ~2⁻¹²⁸ rare, so
|
|
||||||
* [isValid] exists as a seam for tests; production callers pass the default.
|
|
||||||
*
|
|
||||||
* This is deliberately split from [groupKey]: it is pure byte manipulation and so is
|
|
||||||
* unit-testable, whereas the secp256k1 half needs the nostr SDK, whose native library
|
|
||||||
* cannot be loaded by a host JVM unit test (see PLAN.md §13.2).
|
|
||||||
*/
|
|
||||||
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 // -1 means "no counter byte", i.e. the first attempt
|
|
||||||
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++ // A.3: the counter starts at 0 on the first retry
|
|
||||||
}
|
|
||||||
error("Concord group_key: scalar_normalize exhausted for label $label")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A secp256k1 secret key is any integer in `[1, n-1]`, so CORD-02 A.3's validity test rejects
|
|
||||||
* exactly the all-zeroes seed and any seed not below the group order.
|
|
||||||
*/
|
|
||||||
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 // exactly n, also out of range
|
|
||||||
}
|
|
||||||
|
|
||||||
private val SECP256K1_ORDER =
|
|
||||||
"fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141".hexToBytes()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `group_key` (CORD-02 A.2): a plane's keypair, `(scalar_normalize(seed), xonly_pubkey(sk))`.
|
|
||||||
*
|
|
||||||
* The `conv_key` of A.2 needs no implementation of its own — it *is* the NIP-44 conversation
|
|
||||||
* key, which the SDK derives from the pair returned here, so nothing here hand-rolls ECDH.
|
|
||||||
*/
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `community_id` (CORD-02 A.4), which is also the community's self-certification:
|
|
||||||
*
|
|
||||||
* ```
|
|
||||||
* community_id = sha256( utf8("concord/community") | owner_xonly[32] | owner_salt[32] )
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* Note this is a plain SHA-256 commitment with **no** `0x00` separator and no length
|
|
||||||
* prefix — it is deliberately *not* the HKDF construction of A.1, despite looking like it.
|
|
||||||
*/
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `prevcommit` (CORD-02 A.8) — the commitment to the previous epoch's key, published when
|
|
||||||
* an epoch rolls so that members can verify the rotation chained from what they held:
|
|
||||||
*
|
|
||||||
* ```
|
|
||||||
* prevcommit = sha256( utf8("concord/epoch-key-commitment") | prev_epoch_be[8] | prev_key[32] )
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `edition_hash` (CORD-02 A.8) — links a Control edition to its predecessor, so a client
|
|
||||||
* folding the Control plane can detect a rewritten chain:
|
|
||||||
*
|
|
||||||
* ```
|
|
||||||
* edition_hash = sha256(
|
|
||||||
* len64(label) | label // label = ConcordLabel.EDITION_HASH
|
|
||||||
* | entity_id[32]
|
|
||||||
* | version_be[8]
|
|
||||||
* | (prev ? 0x01 | prev[32] : 0x00 | zero[32])
|
|
||||||
* | len64(content) | content ) // content bytes verbatim, never re-serialized
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* [content] must be the exact bytes that were signed — re-serializing the JSON would change
|
|
||||||
* the hash.
|
|
||||||
*/
|
|
||||||
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,253 +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
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CORD-05: redeeming an invite.
|
|
||||||
*
|
|
||||||
* Two ways to be handed the keys, one bundle:
|
|
||||||
*
|
|
||||||
* - **Public link** — `$BASE/invite/<naddr>#<fragment>`. The naddr names where the encrypted
|
|
||||||
* bundle sits on relays; the fragment carries an off-network unlock token and never reaches a
|
|
||||||
* server. The bundle is fetched, then decrypted with a key derived from the token.
|
|
||||||
* - **Direct Invite** — the same bundle, giftwrapped straight to an npub. Nothing to fetch. That
|
|
||||||
* path needs only [decryptInviteBundle]; the arrival is handled in `ConcordManager.onInboxRumor`.
|
|
||||||
*
|
|
||||||
* Minting is out of v1's scope, so this file only decodes.
|
|
||||||
*
|
|
||||||
* A bundle is attacker-crafted input reached by following a link, so nothing here allocates on the
|
|
||||||
* strength of what the bundle claims (CORD-05 §1).
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** 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)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decodes a link into its locator and fragment, or null when it is not a Concord invite.
|
|
||||||
*
|
|
||||||
* The base domain is deliberately ignored — CORD-05 §2 makes the base interchangeable and says any
|
|
||||||
* client recognizing an invite must respect the naddr and fragment verbatim — so only the last path
|
|
||||||
* segment and the `#` fragment are read.
|
|
||||||
*/
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CORD-05 §3's fragment layout:
|
|
||||||
*
|
|
||||||
* ```
|
|
||||||
* [version=4][flags][relays?][token:16]
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* With the stock flag set no relay bytes follow. Otherwise a count byte precedes that many entries,
|
|
||||||
* each a leading byte selecting a dictionary id, a host with `wss://` implied, or a verbatim URL.
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
// The stock flag selects the whole dictionary, so nothing extra is carried and the cap on
|
|
||||||
// explicit entries does not apply to it.
|
|
||||||
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(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `bundle_key = hkdf(token, "concord/invite-key")` (CORD-05 §2). The token derives exactly this one
|
|
||||||
* thing — decrypting the bundle — and nothing else.
|
|
||||||
*
|
|
||||||
* Modelled as a `group_key` with an all-zero `id` and no epoch, per CORD-02 A.6, because the spec's
|
|
||||||
* `nip44_encrypt(bundle_key, …)` is the same self-ECDH conversation key every other Concord
|
|
||||||
* `nip44_encrypt` call uses (CORD-01). Encoding it as a keypair rather than a raw conversation key
|
|
||||||
* is what lets the existing SDK do the encryption instead of hand-rolling NIP-44.
|
|
||||||
*/
|
|
||||||
fun inviteBundleKey(tokenHex: String): GroupKey? =
|
|
||||||
tokenHex.hexToBytesOrNull()?.let { groupKey(ConcordLabel.INVITE_KEY, it, ByteArray(32)) }
|
|
||||||
|
|
||||||
/** Decrypts a `kind 33301` bundle's content into [CommunityInvite], or null on any failure. */
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** CORD-05 §2: a link is retired by re-posting its coordinate as a `vsk 9` tombstone. */
|
|
||||||
fun isInviteTombstone(vsk: String?): Boolean = vsk == ConcordVsk.INVITE_TOMBSTONE.toString()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CORD-05 §1's required checks, in the order the spec gives them. Returns every problem found so
|
|
||||||
* the preview can explain itself rather than silently refusing.
|
|
||||||
*
|
|
||||||
* The first one is the load-bearing one: `community_id == sha256("concord/community" ‖ owner ‖
|
|
||||||
* owner_salt)` is what stops a bundle smuggling a false owner or a fake key for a real Community.
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/** CORD-05 §1: an expired bundle still previews, but joining refuses. */
|
|
||||||
fun CommunityInvite.isExpired(nowMs: Long): Boolean = expiresAt != null && expiresAt <= nowMs
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Community's relay set as the invite names it. [fallback] is the link's bootstrap relays,
|
|
||||||
* used only when the bundle lists none: the bootstrap set exists to *find* the bundle, and the
|
|
||||||
* bundle's copy is the join-time snapshot of the real set (CORD-02 §6).
|
|
||||||
*/
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Turns a validated bundle into the membership we persist. */
|
|
||||||
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,
|
|
||||||
// A Public Channel is one whose key *is* the community_root (CORD-03 §1), which is the
|
|
||||||
// only reading the bundle supports. The Control fold overrides this for display.
|
|
||||||
private = !channel.key.equals(communityRoot, ignoreCase = true),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Reads the fragment's length-prefixed fields, refusing to run past the end. */
|
|
||||||
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,282 +0,0 @@
|
|||||||
package su.reya.coop.concord
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Frozen constants from the Concord specification.
|
|
||||||
*
|
|
||||||
* Concord is defined by the CORD documents (github.com/concord-protocol/concord); the
|
|
||||||
* numbers, labels and permission bits below are normative and must match byte for byte
|
|
||||||
* or nothing interoperates. They are collected in this one file so that a spec revision
|
|
||||||
* is a one-file change and so that no literal kind number ever appears at a call site.
|
|
||||||
*
|
|
||||||
* References are to the CORD section that defines each value. See `PLAN.md` Appendix A
|
|
||||||
* for the same tables with prose.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Event kinds, either the outer wrap or the inner rumor it carries (CORD-01, CORD-02 §5,
|
|
||||||
* CORD-02 Appendix B).
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
|
|
||||||
/** 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
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Permission bits (CORD-04 §3). Rank ordering is separate: `position` orders authority and
|
|
||||||
* **lower is higher**, with the owner at position 0.
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
|
|
||||||
// 1 shl 10 is reserved.
|
|
||||||
|
|
||||||
const val PIN_MESSAGES = 1 shl 11
|
|
||||||
|
|
||||||
// 1 shl 12 is reserved.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HKDF label registry (CORD-02 Appendix A.6). The label is the *first* field of the HKDF
|
|
||||||
* `info` (see `hkdfInfo`), so these strings are on the wire and must not be edited.
|
|
||||||
*/
|
|
||||||
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"
|
|
||||||
|
|
||||||
/** `["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", "3313"]` — the one deliberate outer-tag exception on a Direct Invite wrap. */
|
|
||||||
const val K = "k"
|
|
||||||
}
|
|
||||||
@@ -1,584 +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 kotlinx.serialization.decodeFromString
|
|
||||||
import rust.nostr.sdk.AckPolicy
|
|
||||||
import rust.nostr.sdk.Event
|
|
||||||
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
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Concord's read path: which planes we hold keys for, what we fold out of them, and how an invite
|
|
||||||
* turns into membership.
|
|
||||||
*
|
|
||||||
* ## Why this lives on [Nostr]
|
|
||||||
*
|
|
||||||
* The client has exactly **one** notification pump. `client.notifications()` is called once, inside
|
|
||||||
* [Nostr.handleNotifications], and a second consumer would silently split the stream — so Concord
|
|
||||||
* routes through that pump rather than subscribing to its own (see [isPlaneAddress] and [onInboxRumor]).
|
|
||||||
*
|
|
||||||
* ## The two phases
|
|
||||||
*
|
|
||||||
* [restore] is local and fast: it reads memberships from storage and folds whatever the Control
|
|
||||||
* plane has already cached, so routing is meaningful before anything hits the network. [sync] is
|
|
||||||
* the network half: connect the Community's relays and subscribe to its plane addresses. The pump
|
|
||||||
* calls them in that order, so the first event that arrives already knows where it belongs.
|
|
||||||
*/
|
|
||||||
class ConcordManager(private val nostr: Nostr) {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Concord's plane pubkeys, keyed by hex. Every incoming `kind 1059` is routed by author: a
|
|
||||||
* Concord wrap is signed by a plane's *derived* stream key and can never be read by the NIP-17
|
|
||||||
* path, which assumes an ephemeral author and a `p`-tagged recipient.
|
|
||||||
*
|
|
||||||
* Replaced wholesale rather than mutated, and read from the notification pump's thread while a
|
|
||||||
* worker coroutine rewrites it, so the reference is volatile.
|
|
||||||
*/
|
|
||||||
@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()
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------------------
|
|
||||||
// Routing
|
|
||||||
// -----------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------------------
|
|
||||||
// Lifecycle
|
|
||||||
// -----------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** 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()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Network half of startup: connect every Community's relays and subscribe to its planes. */
|
|
||||||
suspend fun sync() {
|
|
||||||
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}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------------------
|
|
||||||
// Invites
|
|
||||||
// -----------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 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 client = nostr.client ?: throw IllegalStateException("Nostr client is not ready")
|
|
||||||
|
|
||||||
// The fragment's bootstrap relays only have to *find* the bundle; the bundle then carries
|
|
||||||
// the Community's real relay set (CORD-05 §3).
|
|
||||||
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())
|
|
||||||
|
|
||||||
/** 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)
|
|
||||||
|
|
||||||
// CORD-02 §5: a Join is each member's own word, published to the Guestbook. There is no
|
|
||||||
// Guestbook fold in v1 — the Control plane is what drives the UI.
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------------------
|
|
||||||
// Reading
|
|
||||||
// -----------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A Channel's messages, oldest first. Reads the local cache; the live subscription is what
|
|
||||||
* keeps it current.
|
|
||||||
*/
|
|
||||||
suspend fun channelMessages(channelIdHex: String): List<ConcordMessage> =
|
|
||||||
store?.cachedRumors(channelIdHex).orEmpty()
|
|
||||||
.mapNotNull { it.toConcordMessage() }
|
|
||||||
.sortedBy { it.timestampMs }
|
|
||||||
|
|
||||||
/** 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------------------
|
|
||||||
// Writing
|
|
||||||
// -----------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends a message to a Channel (CORD-03 §3).
|
|
||||||
*
|
|
||||||
* The send *is* [PlaneKey.rumor] plus [PlaneKey.wrap], with nothing in between: the rumor carries
|
|
||||||
* the `channel`/`epoch` binding stamped from the Channel's own key, and the wrap is signed by that
|
|
||||||
* key's stream half, so a message cannot be built for a coordinate it will not verify at.
|
|
||||||
*
|
|
||||||
* The message is cached locally *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.
|
|
||||||
*
|
|
||||||
* Returns the message 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 {
|
|
||||||
val client = nostr.client ?: throw IllegalStateException("Nostr client is not ready")
|
|
||||||
val 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")
|
|
||||||
val author = nostr.signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
|
||||||
|
|
||||||
val rumor = plane.key.rumor(
|
|
||||||
author = author,
|
|
||||||
kind = ConcordKind.MESSAGE.toUShort(),
|
|
||||||
content = content,
|
|
||||||
createdAt = Clock.System.now(),
|
|
||||||
)
|
|
||||||
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 message: $reason") }
|
|
||||||
|
|
||||||
return rumor.toConcordMessage()
|
|
||||||
?: throw IllegalStateException("Concord: could not read back the message just sent")
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------------------
|
|
||||||
// Planes
|
|
||||||
// -----------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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) {
|
|
||||||
// Control is write-restricted: we hold the read key plus the writers' pubkey, which
|
|
||||||
// is all reading takes (CORD-01, CORD-02 §5). A bundle with no `control_pk` is a
|
|
||||||
// legacy, pre-split Community, whose plane was addressed by the `concord/control`
|
|
||||||
// derivation itself; v1 does not read those, so such a Community shows no metadata.
|
|
||||||
// CORD-02 §5 requires that legacy reading and the first base rotation upgrades it.
|
|
||||||
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()
|
|
||||||
// A Public Channel's key *is* the community_root, so one we were never granted is
|
|
||||||
// still derivable; a Private one is not (CORD-03 §1).
|
|
||||||
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
|
|
||||||
|
|
||||||
// Always the Community's own relays, never the app's defaults: Concord reverses NIP-59
|
|
||||||
// (fixed author, ephemeral `p`), so a relay enforcing the optional `p`-tag guard drops
|
|
||||||
// these wraps, and the bootstrap set is tuned for NIP-17.
|
|
||||||
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)
|
|
||||||
// CORD-05 §1: an accepting joiner echoes the invite's creator and label, which is what
|
|
||||||
// makes per-link usage counters possible at all.
|
|
||||||
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,259 +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
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Concord's data model: what we persist about a Community, what an invite carries, and what the
|
|
||||||
* Control fold projects.
|
|
||||||
*
|
|
||||||
* Types that travel on the wire ([CommunityInvite], [CommunityMeta], [ChannelMeta], [ConcordIcon])
|
|
||||||
* keep the spec's snake_case field names because those keys are normative. Types that are ours
|
|
||||||
* alone ([Membership], [StoredChannelKey]) keep Kotlin naming.
|
|
||||||
*
|
|
||||||
* Concord reserves every top-level field it does not define, and CORD-02 §6 requires editors to
|
|
||||||
* round-trip fields they do not understand. v1 only ever *reads* these documents, so the models
|
|
||||||
* below carry just the fields v1 uses and rely on `ignoreUnknownKeys`; anything aimed at writing
|
|
||||||
* them back must preserve what it did not parse.
|
|
||||||
*/
|
|
||||||
internal val concordJson = Json {
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
encodeDefaults = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
|
||||||
// Persisted
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A Community this device has joined, as stored locally. Holding these keys *is* membership
|
|
||||||
* (CORD-02 §2), so the whole blob lives in [su.reya.coop.AppStorage]'s encrypted store.
|
|
||||||
*
|
|
||||||
* There is no owner recovery by design: [communityId] commits to [owner], so losing the owner key
|
|
||||||
* cannot be repaired by anyone, including us. Nothing in the UI should suggest otherwise.
|
|
||||||
*/
|
|
||||||
@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(),
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A Channel key handed out by an invite. Public Channels derive from `community_root` and so carry
|
|
||||||
* it here, which is why [private] can only be a hint — the Control fold is the authority.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class StoredChannelKey(
|
|
||||||
val id: String,
|
|
||||||
val key: String,
|
|
||||||
val epoch: ULong,
|
|
||||||
val name: String? = null,
|
|
||||||
val private: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
|
||||||
// Invite bundle (CORD-05 §1)
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The `CommunityInvite` bundle: the same document whether it arrives inside a public link's
|
|
||||||
* encrypted relay-side event or giftwrapped straight to an npub as a Direct Invite (CORD-05 §6).
|
|
||||||
*
|
|
||||||
* The `community_id` self-certifies the owner, so a bundle cannot smuggle a false owner onto a real
|
|
||||||
* Community. [controlPk] is the one field taken on trust: it derives from a secret the joiner will
|
|
||||||
* never hold, so nothing in the bundle can prove it. Build nothing security-relevant on it.
|
|
||||||
*/
|
|
||||||
@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,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A pointer to an encrypted blob — icon, banner (CORD-02 §6). The media server holds ciphertext
|
|
||||||
* only; a member fetches, decrypts and verifies [hash]. v1 never fetches these.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class ConcordIcon(
|
|
||||||
val url: String? = null,
|
|
||||||
val key: String? = null,
|
|
||||||
val nonce: String? = null,
|
|
||||||
val hash: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
|
||||||
// Control fold
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** 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,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One `kind 3308` Control edition, parsed from its rumor (CORD-04 §1, CORD-02 Appendix B).
|
|
||||||
*
|
|
||||||
* The tags are the edition machinery — `vsk` names the entity type, `eid` its stable coordinate,
|
|
||||||
* `ev` this version, `ep` the hash of the previous edition. [content] is the entity's new state as
|
|
||||||
* a JSON string, held verbatim because [editionHash] hashes those bytes and never a re-serialization.
|
|
||||||
*/
|
|
||||||
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>,
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
|
||||||
// Read surface
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** 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>,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 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,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CORD-05 §1: the bundle's `expires_at` is unix **milliseconds**, the Invite List's is unix
|
|
||||||
* **seconds**, and a NIP-40 `expiration` tag is **seconds**. Three spellings of one instant, so
|
|
||||||
* every conversion lives here. Returns null when the bundle carries no expiry at all.
|
|
||||||
*/
|
|
||||||
fun CommunityInvite.expiryToEpochSeconds(): Long? = expiresAt?.let { it / 1000 }
|
|
||||||
|
|
||||||
/** CORD-02 §4: true time is `created_at * 1000 + ms`; an absent or out-of-range `ms` reads as 0. */
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Projects a Chat-plane rumor into a [ConcordMessage], or null when it is not a message. */
|
|
||||||
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(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,308 +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
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The CORD-01 wire stack: `wrap → seal → rumor`.
|
|
||||||
*
|
|
||||||
* A stream is a shared key and a stream of gift wraps signed with it. Everything a plane sends
|
|
||||||
* is three nested layers:
|
|
||||||
*
|
|
||||||
* ```
|
|
||||||
* wrap kind 1059, signed by the stream key, one ephemeral `p` tag
|
|
||||||
* └ nip44(conv_key) of
|
|
||||||
* seal kind 20013 (encrypted) or 20014 (plaintext), signed by the real author
|
|
||||||
* └ nip44(conv_key) of | byte-verbatim
|
|
||||||
* rumor unsigned, its authority is the seal's signature around it
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* Both encrypted layers use the *same* conversation key — that is double encryption under one
|
|
||||||
* key, not two, and it is what makes the wrap readable by anyone holding the plane key.
|
|
||||||
*
|
|
||||||
* Concord reverses NIP-59: the author is fixed (the stream key) and the `p` tag is ephemeral,
|
|
||||||
* which is why the app's normal NIP-59 path in `MessageManager.extractRumor` cannot read these
|
|
||||||
* and why routing happens by author before that code is reached.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which of CORD-01's two seal forms a plane uses; CORD-02 §5 makes this a fixed property of the
|
|
||||||
* plane, "never a per-message choice". Because the plane owns it, [wrap] cannot pick the wrong
|
|
||||||
* one and [unwrap] can reject a seal of the other form instead of quietly accommodating it.
|
|
||||||
*/
|
|
||||||
enum class SealForm(private val kindValue: Int) {
|
|
||||||
/**
|
|
||||||
* Chat, Guestbook, rekey. The rumor is NIP-44-encrypted inside the already-encrypted wrap,
|
|
||||||
* so no relay — honest or malicious — can retain and display the rumor as a public event.
|
|
||||||
*/
|
|
||||||
Encrypted(ConcordKind.SEAL),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Control plane only. The seal's content is the rumor's serialized JSON **byte-verbatim**,
|
|
||||||
* because a signature over ciphertext is bound to the key that encrypted it and would break
|
|
||||||
* if re-wrapped under another key across an epoch change.
|
|
||||||
*/
|
|
||||||
Plaintext(ConcordKind.PLAINTEXT_SEAL);
|
|
||||||
|
|
||||||
val kind: UShort get() = kindValue.toUShort()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CORD-03 §3: what a Chat-plane rumor must commit so a member cannot re-wrap another's message
|
|
||||||
* into a different Channel or replay it across an epoch.
|
|
||||||
*
|
|
||||||
* The tags live *inside* the author-signed rumor, so the author's signature covers them; the
|
|
||||||
* reader checks them strict-equal against the coordinate whose key opened the wrap.
|
|
||||||
*/
|
|
||||||
data class ChatBinding(val channelIdHex: String, val epoch: ULong)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One plane's keys and, for a Chat plane, the coordinate its rumors are bound to.
|
|
||||||
*
|
|
||||||
* [read] decrypts: it is the conversation key for the wrap and for an encrypted seal. [stream]
|
|
||||||
* is the address that signs wraps. They are the same key on a normal stream, and differ only on
|
|
||||||
* a write-restricted one (CORD-01, used by the Control Plane in CORD-02 §5) — where a reader
|
|
||||||
* holds the read key plus the writers' *pubkey*, enough to verify a wrap but not to mint one.
|
|
||||||
*
|
|
||||||
* Deliberately not a `data class`: there is no value equality worth having, and the default
|
|
||||||
* `toString` keeps key material out of logs.
|
|
||||||
*/
|
|
||||||
class PlaneKey(
|
|
||||||
val read: GroupKey,
|
|
||||||
val stream: GroupKey?,
|
|
||||||
/** The x-only hex of the key every wrap on this plane must be signed by. */
|
|
||||||
val streamPublicKeyHex: String,
|
|
||||||
/** CORD-02 §5: the one seal form this plane may use, on both the write and the read side. */
|
|
||||||
val form: SealForm,
|
|
||||||
val chat: ChatBinding?,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A Channel's Chat plane (CORD-03 §1). Public channels pass `community_root` as the secret,
|
|
||||||
* Private ones their own independent `channel_key`; the `channel_id` in the derivation is what
|
|
||||||
* gives each Channel a distinct address either way.
|
|
||||||
*/
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The community-wide Guestbook plane (CORD-02 §5), where joins, leaves and kicks are recorded. */
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Control plane's **read** key (CORD-02 §5). Its wraps are signed by the staff-held
|
|
||||||
* `control-signer` key instead, so this plane is read-only and [streamPublicKeyHex] is
|
|
||||||
* whatever the invite claimed — nothing in an invite can prove it, so build nothing
|
|
||||||
* security-relevant on it beyond the subscription address.
|
|
||||||
*/
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds an unsigned rumor for this plane (CORD-01).
|
|
||||||
*
|
|
||||||
* The `channel`/`epoch` binding tags are stamped from the plane itself, and therefore from the
|
|
||||||
* very key that will encrypt the wrap, so a rumor can never be built whose coordinate does not
|
|
||||||
* match the key it travels under. `ms` rides every rumor (CORD-02 A.5) because `created_at` is
|
|
||||||
* never tweaked — true time is `created_at * 1000 + ms`.
|
|
||||||
*
|
|
||||||
* A rumor is never signed and never a standalone artifact: its authority is the seal's
|
|
||||||
* signature around it.
|
|
||||||
*/
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wraps [rumor] into a publishable stream event.
|
|
||||||
*
|
|
||||||
* [signer] signs the *seal*, so it must be the real author's signer — the user's own key, which
|
|
||||||
* also means a NIP-46 bunker works here. The wrap is signed by the plane's stream key instead,
|
|
||||||
* and the seal form comes from the plane itself (CORD-02 §5).
|
|
||||||
*
|
|
||||||
* Both the seal and the wrap take the rumor's `created_at`, never a fresh one, so the three
|
|
||||||
* layers agree and pagination by wrap timestamp lines up with message ordering.
|
|
||||||
*/
|
|
||||||
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 seal = try {
|
|
||||||
EventBuilder(
|
|
||||||
Kind(form.kind),
|
|
||||||
// CORD-01: byte-verbatim for a plaintext seal, so a re-wrap can carry the exact
|
|
||||||
// signed bytes forward instead of re-serializing them.
|
|
||||||
if (form == SealForm.Encrypted) nip44Seal(read, rumorJson) else rumorJson,
|
|
||||||
)
|
|
||||||
.customCreatedAt(createdAt)
|
|
||||||
.finalizeAsync(signer)
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Concord: failed to seal rumor: ${e.message}", e)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The receiver drops a rumor whose author differs from its seal's, so publishing a mismatch
|
|
||||||
// would produce a message nobody can read. Fail here instead, where the cause is visible.
|
|
||||||
check(seal.author() == rumor.author()) {
|
|
||||||
"Concord: seal author ${seal.author().toHex()} does not match rumor author ${rumor.author().toHex()}"
|
|
||||||
}
|
|
||||||
|
|
||||||
return try {
|
|
||||||
// The ephemeral `p` is discarded: it only breaks linkage between a plane's wraps. Only
|
|
||||||
// its pubkey is kept, and `Tag.publicKey` has already serialized it, so destroying the
|
|
||||||
// keypair right away is safe. v1 has no giftwrap deletion, which is the one thing
|
|
||||||
// CORD-01 §Deletions would want the secret for.
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unwraps a stream event and enforces every check a reader must make, returning null when any
|
|
||||||
* of them fails.
|
|
||||||
*
|
|
||||||
* Null means **drop**, never retry: a wrong key, a forged or unverifiable signature, an
|
|
||||||
* impersonation attempt, a decompression/serialization failure, or a `channel`/`epoch`
|
|
||||||
* mismatch (CORD-03 §3). The checks are all here so a caller cannot skip one, and nothing is
|
|
||||||
* rendered before they pass.
|
|
||||||
*/
|
|
||||||
fun PlaneKey.unwrap(event: Event): UnsignedEvent? {
|
|
||||||
if (event.kind().asU16() != ConcordKind.WRAP.toUShort()) return null
|
|
||||||
// The wrap is signed by the stream key. Verifying this is what makes CORD-01's
|
|
||||||
// write-restricted split real: a read-key holder can verify a wrap but cannot mint one.
|
|
||||||
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
|
|
||||||
|
|
||||||
// CORD-02 §5 makes the seal form a fixed property of the plane, so a seal of the other form
|
|
||||||
// is a discipline violation, not a variant to accommodate: only the matching pair is accepted.
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CORD-03 §3, strict-equal and fail-closed: on a Chat plane both tags must be present and match
|
|
||||||
* this plane's coordinate, so neither a re-wrap into another Channel nor a cross-epoch replay
|
|
||||||
* survives. The community-wide Guestbook and Control planes split no sub-context, so the spec
|
|
||||||
* binds nothing there and this is a no-op.
|
|
||||||
*/
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CORD-02 A.5: true time is `created_at * 1000 + ms`, and a reader drops a rumor whose `ms`
|
|
||||||
* falls outside `0..999`. `mod` rather than `%` so a pre-epoch timestamp cannot produce a
|
|
||||||
* negative value.
|
|
||||||
*/
|
|
||||||
private fun msOf(createdAt: Instant): Int = createdAt.toEpochMilliseconds().mod(1000L).toInt()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* NIP-44's plaintext cap, enforced by the publisher (CORD-01 §Encoding).
|
|
||||||
*
|
|
||||||
* Libraries are lenient and a lenient publisher mints events a strict reader cannot decrypt, so
|
|
||||||
* this fails loudly at build time instead of producing an undecryptable message.
|
|
||||||
*/
|
|
||||||
private const val MAX_PLAINTEXT_BYTES = 65_535
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `Kind` for a frozen [ConcordKind] number; the SDK's constructor wants a `UShort`. */
|
|
||||||
private fun concordKind(kind: Int): UShort = kind.toUShort()
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
package su.reya.coop.concord
|
|
||||||
|
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
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
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Concord's local persistence, in the two places the rest of the app already keeps things.
|
|
||||||
*
|
|
||||||
* **Secrets → [AppStorage]'s encrypted store.** A Community's keys *are* membership (CORD-02 §2),
|
|
||||||
* so they go through `setSecret`, which is backed by Android Keystore AES-GCM. They must never go
|
|
||||||
* through plaintext storage, and they never touch LMDB.
|
|
||||||
*
|
|
||||||
* **Community state → LMDB, as index events.** Decrypted plane rumors are cached the same way
|
|
||||||
* [su.reya.coop.nostr.MessageManager] caches DM rumors, so history survives a restart and the
|
|
||||||
* Control fold has something to fold before the network answers.
|
|
||||||
*/
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Caches one decrypted plane rumor under its logical scope — a Channel id for Chat, the
|
|
||||||
* community id for Control and Guestbook.
|
|
||||||
*
|
|
||||||
* The `d` tag is not optional. [KindStandard.APPLICATION_SPECIFIC_DATA] is an *addressable*
|
|
||||||
* kind, so LMDB keeps one event per `(kind, pubkey, d)` coordinate: without a unique `d` every
|
|
||||||
* message would replace the one before it and history would vanish silently. The wrap id is
|
|
||||||
* that unique value, exactly as [su.reya.coop.nostr.MessageManager.setCachedRumor] uses it —
|
|
||||||
* and it doubles as the dedupe key when a wrap arrives from several relays.
|
|
||||||
*/
|
|
||||||
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"
|
|
||||||
|
|
||||||
/** Single-letter index tags; `r` is what `Filter.reference` queries. */
|
|
||||||
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) {
|
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
|
||||||
try {
|
try {
|
||||||
val isReaction = rumor.kind().asStd() == KindStandard.REACTION
|
|
||||||
val kValue = if (isReaction) "reaction" else "dm"
|
|
||||||
|
|
||||||
// Construct reference tags
|
// Construct reference tags
|
||||||
val tags = listOf(
|
val tags = listOf(
|
||||||
Tag.identifier(giftId.toHex()),
|
Tag.identifier(giftId.toHex()),
|
||||||
Tag.publicKey(rumor.author()),
|
Tag.publicKey(rumor.author()),
|
||||||
Tag.custom("r", listOf(rumor.roomId().toString())),
|
Tag.custom("r", listOf(rumor.roomId().toString())),
|
||||||
Tag.custom("k", listOf("14", kValue))
|
Tag.custom("k", listOf("14"))
|
||||||
)
|
)
|
||||||
|
|
||||||
// Set event kind
|
// Set event kind
|
||||||
@@ -179,13 +176,12 @@ class MessageManager(private val nostr: Nostr) {
|
|||||||
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
||||||
val kTag = SingleLetterTag.lowercase(Alphabet.K)
|
val kTag = SingleLetterTag.lowercase(Alphabet.K)
|
||||||
|
|
||||||
// Get all rumors (DMs and Reactions)
|
// Get all DM events
|
||||||
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm", "reaction"))
|
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm"))
|
||||||
val events = client?.database()?.query(filter)?.toVec() ?: return null
|
val events = client?.database()?.query(filter)?.toVec() ?: return null
|
||||||
|
|
||||||
// Collect rooms
|
// Collect rooms
|
||||||
val roomsMap: MutableMap<Long, Room> = mutableMapOf()
|
val roomsMap: MutableMap<Long, Room> = mutableMapOf()
|
||||||
val lastDmTimestampMap: MutableMap<Long, ULong> = mutableMapOf()
|
|
||||||
|
|
||||||
events
|
events
|
||||||
.map { UnsignedEvent.fromJson(it.content()) }
|
.map { UnsignedEvent.fromJson(it.content()) }
|
||||||
@@ -193,41 +189,18 @@ class MessageManager(private val nostr: Nostr) {
|
|||||||
.forEach { rumor ->
|
.forEach { rumor ->
|
||||||
val id = rumor.roomId()
|
val id = rumor.roomId()
|
||||||
val isFromMe = rumor.author() == userPubkey
|
val isFromMe = rumor.author() == userPubkey
|
||||||
val isReaction = rumor.kind().asStd() == KindStandard.REACTION
|
|
||||||
val existing = roomsMap[id]
|
val existing = roomsMap[id]
|
||||||
val createdAt = rumor.createdAt()
|
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)
|
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 (isOngoing) room.copy(kind = RoomKind.Ongoing) else room
|
||||||
roomsMap[id] = if (isReaction) {
|
} else if (isFromMe && existing.kind != RoomKind.Ongoing) {
|
||||||
room.copy(lastMessage = null)
|
// If it's an older rumor but sent by the user, mark the room as Ongoing
|
||||||
} else {
|
roomsMap[id] = existing.copy(kind = RoomKind.Ongoing)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,64 +348,4 @@ class MessageManager(private val nostr: Nostr) {
|
|||||||
throw IllegalStateException("Failed to send message: ${e.message}", e)
|
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.Timestamp
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import rust.nostr.sdk.initLogger
|
import rust.nostr.sdk.initLogger
|
||||||
import su.reya.coop.AppStorage
|
|
||||||
import su.reya.coop.concord.ConcordManager
|
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@@ -52,7 +50,6 @@ class Nostr(
|
|||||||
val messages = MessageManager(this)
|
val messages = MessageManager(this)
|
||||||
val profiles = ProfileManager(this)
|
val profiles = ProfileManager(this)
|
||||||
val relays = RelayManager(this)
|
val relays = RelayManager(this)
|
||||||
val concord = ConcordManager(this)
|
|
||||||
|
|
||||||
private val isInitialized = MutableStateFlow(false)
|
private val isInitialized = MutableStateFlow(false)
|
||||||
|
|
||||||
@@ -67,18 +64,10 @@ class Nostr(
|
|||||||
_newEvents.tryEmit(event)
|
_newEvents.tryEmit(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun init(
|
suspend fun init(dbPath: String, logLevel: LogLevel = LogLevel.WARN) {
|
||||||
dbPath: String,
|
|
||||||
storage: AppStorage,
|
|
||||||
logLevel: LogLevel = LogLevel.WARN,
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
if (isInitialized.value) return
|
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
|
// Initialize the logger for nostr client
|
||||||
initLogger(logLevel)
|
initLogger(logLevel)
|
||||||
|
|
||||||
@@ -155,35 +144,16 @@ class Nostr(
|
|||||||
val notifications = client?.notifications() ?: return@supervisorScope
|
val notifications = client?.notifications() ?: return@supervisorScope
|
||||||
|
|
||||||
val giftWrapQueue = Channel<Event>(1024)
|
val giftWrapQueue = Channel<Event>(1024)
|
||||||
val concordQueue = Channel<Event>(1024)
|
|
||||||
|
|
||||||
var processedCount = 0
|
var processedCount = 0
|
||||||
var eoseReceived = false
|
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) {
|
launch(defaultDispatcher) {
|
||||||
for (event in giftWrapQueue) {
|
for (event in giftWrapQueue) {
|
||||||
val rumor = messages.extractRumor(event)
|
val rumor = messages.extractRumor(event)
|
||||||
processedCount++
|
processedCount++
|
||||||
|
|
||||||
if (rumor != null && !concord.onInboxRumor(rumor)) {
|
// Trigger new message notification
|
||||||
|
if (rumor != null) {
|
||||||
val isSelfMessage = rumor.author() == signer.publicKeyFlow.value
|
val isSelfMessage = rumor.author() == signer.publicKeyFlow.value
|
||||||
val isNew = rumor.createdAt().asSecs() >= now.asSecs()
|
val isNew = rumor.createdAt().asSecs() >= now.asSecs()
|
||||||
|
|
||||||
@@ -244,11 +214,7 @@ class Nostr(
|
|||||||
}
|
}
|
||||||
|
|
||||||
KindStandard.GIFT_WRAP -> {
|
KindStandard.GIFT_WRAP -> {
|
||||||
if (concord.isPlaneAddress(event.author().toHex())) {
|
giftWrapQueue.send(event)
|
||||||
concordQueue.send(event)
|
|
||||||
} else {
|
|
||||||
giftWrapQueue.send(event)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {}
|
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()) {
|
private suspend fun updateRoomState(event: UnsignedEvent, roomId: Long = event.roomId()) {
|
||||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: return
|
val currentUser = nostr.signer.getPublicKeyAsync() ?: return
|
||||||
val isReaction = event.kind().asStd() == KindStandard.REACTION
|
|
||||||
|
|
||||||
_state.update { currentState ->
|
_state.update { currentState ->
|
||||||
val rooms = currentState.rooms.toMutableMap()
|
val rooms = currentState.rooms.toMutableMap()
|
||||||
@@ -277,24 +256,22 @@ class ChatRepository(
|
|||||||
// New room discovery
|
// New room discovery
|
||||||
val newRoom = Room.new(event, currentUser, roomId).copy(
|
val newRoom = Room.new(event, currentUser, roomId).copy(
|
||||||
kind = newKind,
|
kind = newKind,
|
||||||
unreadCount = if (isFromMe || isReaction) 0 else 1,
|
unreadCount = if (isFromMe) 0 else 1
|
||||||
lastMessage = if (isReaction) null else event.content()
|
|
||||||
)
|
)
|
||||||
rooms[newRoom.id] = newRoom
|
rooms[newRoom.id] = newRoom
|
||||||
} else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) {
|
} else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) {
|
||||||
// Update timestamp for any newer event (DM or Reaction)
|
// Only update preview if message is newer (handles sync/late arrivals)
|
||||||
// But only update preview if it's a DM
|
|
||||||
rooms[roomId] = existingRoom.copy(
|
rooms[roomId] = existingRoom.copy(
|
||||||
lastMessage = if (isReaction) existingRoom.lastMessage else event.content(),
|
lastMessage = event.content(),
|
||||||
createdAt = event.createdAt(),
|
createdAt = event.createdAt(),
|
||||||
kind = newKind,
|
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) {
|
} 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)
|
rooms[roomId] = existingRoom.copy(kind = RoomKind.Ongoing)
|
||||||
} else {
|
} 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
|
return@update currentState
|
||||||
}
|
}
|
||||||
currentState.copy(rooms = rooms)
|
currentState.copy(rooms = rooms)
|
||||||
|
|||||||
@@ -74,8 +74,4 @@ class ChatScreenViewModel(
|
|||||||
fun sendFileMessage(file: ByteArray?, type: String?) {
|
fun sendFileMessage(file: ByteArray?, type: String?) {
|
||||||
chatRepository.sendFileMessage(id, file, type)
|
chatRepository.sendFileMessage(id, file, type)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendReaction(targetEventId: EventId, reaction: String) {
|
|
||||||
chatRepository.sendReaction(id, targetEventId, reaction)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user