Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d830b778f | ||
|
|
fcc30157bb | ||
|
|
43618533a5 | ||
|
|
9c9d9ae882 |
@@ -17,4 +17,3 @@ captures
|
|||||||
!*.xcworkspace/contents.xcworkspacedata
|
!*.xcworkspace/contents.xcworkspacedata
|
||||||
**/xcshareddata/WorkspaceSettings.xcsettings
|
**/xcshareddata/WorkspaceSettings.xcsettings
|
||||||
node_modules/
|
node_modules/
|
||||||
.artifacts/
|
|
||||||
@@ -69,7 +69,7 @@ android {
|
|||||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.2.6"
|
versionName = "0.2.4"
|
||||||
}
|
}
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
android:required="false" />
|
android:required="false" />
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="960"
|
|
||||||
android:viewportHeight="960">
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:strokeColor="#000000"
|
|
||||||
android:strokeWidth="56"
|
|
||||||
android:strokeLineCap="round"
|
|
||||||
android:pathData="M120,220 Q120,120 220,120 L740,120 Q840,120 840,220 L840,740 Q840,840 740,840 L220,840 Q120,840 120,740 Z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:strokeColor="#000000"
|
|
||||||
android:strokeWidth="72"
|
|
||||||
android:strokeLineCap="round"
|
|
||||||
android:pathData="M400,320 L400,640 M560,320 L560,640 M320,400 L640,400 M320,560 L640,560" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="960"
|
|
||||||
android:viewportHeight="960">
|
|
||||||
<path
|
|
||||||
android:fillColor="#000000"
|
|
||||||
android:pathData="M240,500 Q240,440 300,440 L660,440 Q720,440 720,500 L720,780 Q720,840 660,840 L300,840 Q240,840 240,780 Z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:strokeColor="#000000"
|
|
||||||
android:strokeWidth="64"
|
|
||||||
android:strokeLineCap="round"
|
|
||||||
android:pathData="M340,500 L340,440 A140,140 0 0 1 620,440 L620,500" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package su.reya.coop
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.ConnectivityManager
|
|
||||||
import android.net.Network
|
|
||||||
import android.net.NetworkCapabilities
|
|
||||||
import android.net.NetworkRequest
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
|
|
||||||
class AndroidConnectivityMonitor(context: Context) : ConnectivityMonitor {
|
|
||||||
private val manager =
|
|
||||||
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
||||||
|
|
||||||
private val _isMobileData = MutableStateFlow(checkIsMobileData())
|
|
||||||
override val isMobileData: StateFlow<Boolean> = _isMobileData.asStateFlow()
|
|
||||||
|
|
||||||
init {
|
|
||||||
val networkRequest = NetworkRequest.Builder()
|
|
||||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
manager.registerNetworkCallback(
|
|
||||||
networkRequest,
|
|
||||||
object : ConnectivityManager.NetworkCallback() {
|
|
||||||
override fun onAvailable(network: Network) {
|
|
||||||
_isMobileData.value = checkIsMobileData()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLost(network: Network) {
|
|
||||||
_isMobileData.value = checkIsMobileData()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCapabilitiesChanged(
|
|
||||||
network: Network,
|
|
||||||
networkCapabilities: NetworkCapabilities
|
|
||||||
) {
|
|
||||||
_isMobileData.value = checkIsMobileData()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkIsMobileData(): Boolean {
|
|
||||||
val activeNetwork = manager.activeNetwork ?: return false
|
|
||||||
val capabilities = manager.getNetworkCapabilities(activeNetwork) ?: return false
|
|
||||||
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ import android.app.Activity
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
@@ -39,8 +40,6 @@ import androidx.navigation3.ui.NavDisplay
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import su.reya.coop.repository.AccountRepository
|
import su.reya.coop.repository.AccountRepository
|
||||||
import su.reya.coop.repository.ChatRepository
|
import su.reya.coop.repository.ChatRepository
|
||||||
import su.reya.coop.repository.ConcordRepository
|
|
||||||
import su.reya.coop.repository.SettingsRepository
|
|
||||||
import su.reya.coop.screens.ContactListScreen
|
import su.reya.coop.screens.ContactListScreen
|
||||||
import su.reya.coop.screens.HomeScreen
|
import su.reya.coop.screens.HomeScreen
|
||||||
import su.reya.coop.screens.ImportScreen
|
import su.reya.coop.screens.ImportScreen
|
||||||
@@ -52,33 +51,17 @@ import su.reya.coop.screens.ProfileScreen
|
|||||||
import su.reya.coop.screens.RelayScreen
|
import su.reya.coop.screens.RelayScreen
|
||||||
import su.reya.coop.screens.RequestListScreen
|
import su.reya.coop.screens.RequestListScreen
|
||||||
import su.reya.coop.screens.ScanScreen
|
import su.reya.coop.screens.ScanScreen
|
||||||
import su.reya.coop.screens.SettingsScreen
|
|
||||||
import su.reya.coop.screens.UpdateProfileScreen
|
import su.reya.coop.screens.UpdateProfileScreen
|
||||||
import su.reya.coop.screens.chat.ChatScreen
|
import su.reya.coop.screens.chat.ChatScreen
|
||||||
import su.reya.coop.screens.communities.ChannelScreen
|
|
||||||
import su.reya.coop.screens.communities.CommunitiesScreen
|
|
||||||
import su.reya.coop.screens.communities.CommunityScreen
|
|
||||||
import su.reya.coop.screens.communities.JoinCommunityScreen
|
|
||||||
import su.reya.coop.viewmodel.AccountViewModel
|
import su.reya.coop.viewmodel.AccountViewModel
|
||||||
import su.reya.coop.viewmodel.ChannelScreenViewModel
|
|
||||||
import su.reya.coop.viewmodel.ChatScreenViewModel
|
import su.reya.coop.viewmodel.ChatScreenViewModel
|
||||||
import su.reya.coop.viewmodel.ChatViewModel
|
import su.reya.coop.viewmodel.ChatViewModel
|
||||||
import su.reya.coop.viewmodel.ConcordViewModel
|
|
||||||
import su.reya.coop.viewmodel.ProfileCache
|
import su.reya.coop.viewmodel.ProfileCache
|
||||||
import su.reya.coop.viewmodel.SettingsViewModel
|
|
||||||
|
|
||||||
val LocalProfileCache = staticCompositionLocalOf<ProfileCache> {
|
val LocalProfileCache = staticCompositionLocalOf<ProfileCache> {
|
||||||
error("No ProfileCache provided")
|
error("No ProfileCache provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
val LocalSettings = staticCompositionLocalOf<Settings> {
|
|
||||||
error("No Settings provided")
|
|
||||||
}
|
|
||||||
|
|
||||||
val LocalConnectivity = staticCompositionLocalOf<Boolean> {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
||||||
error("No SnackbarHostState provided")
|
error("No SnackbarHostState provided")
|
||||||
}
|
}
|
||||||
@@ -97,9 +80,6 @@ fun App(
|
|||||||
profileCache: ProfileCache,
|
profileCache: ProfileCache,
|
||||||
accountRepository: AccountRepository,
|
accountRepository: AccountRepository,
|
||||||
chatRepository: ChatRepository,
|
chatRepository: ChatRepository,
|
||||||
concordRepository: ConcordRepository,
|
|
||||||
settingsRepository: SettingsRepository,
|
|
||||||
connectivityMonitor: ConnectivityMonitor,
|
|
||||||
) {
|
) {
|
||||||
val viewModelFactory = remember {
|
val viewModelFactory = remember {
|
||||||
object : ViewModelProvider.Factory {
|
object : ViewModelProvider.Factory {
|
||||||
@@ -113,14 +93,6 @@ fun App(
|
|||||||
accountRepository
|
accountRepository
|
||||||
)
|
)
|
||||||
|
|
||||||
modelClass.isAssignableFrom(SettingsViewModel::class.java) -> SettingsViewModel(
|
|
||||||
settingsRepository
|
|
||||||
)
|
|
||||||
|
|
||||||
modelClass.isAssignableFrom(ConcordViewModel::class.java) -> ConcordViewModel(
|
|
||||||
concordRepository
|
|
||||||
)
|
|
||||||
|
|
||||||
else -> throw IllegalArgumentException("Unknown ViewModel class")
|
else -> throw IllegalArgumentException("Unknown ViewModel class")
|
||||||
}
|
}
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
@@ -131,8 +103,6 @@ fun App(
|
|||||||
|
|
||||||
val accountViewModel: AccountViewModel = viewModel(factory = viewModelFactory)
|
val accountViewModel: AccountViewModel = viewModel(factory = viewModelFactory)
|
||||||
val chatViewModel: ChatViewModel = viewModel(factory = viewModelFactory)
|
val chatViewModel: ChatViewModel = viewModel(factory = viewModelFactory)
|
||||||
val settingsViewModel: SettingsViewModel = viewModel(factory = viewModelFactory)
|
|
||||||
val concordViewModel: ConcordViewModel = viewModel(factory = viewModelFactory)
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val activity = context as? ComponentActivity
|
val activity = context as? ComponentActivity
|
||||||
@@ -144,27 +114,19 @@ fun App(
|
|||||||
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
||||||
val signerRequired = accountState.signerRequired
|
val signerRequired = accountState.signerRequired
|
||||||
|
|
||||||
// Get the settings
|
|
||||||
val settings by settingsViewModel.settings.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
// Get connectivity status
|
|
||||||
val isMobileData by connectivityMonitor.isMobileData.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
// Snackbar
|
// Snackbar
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
// Check if dark theme enabled
|
// Check if dark theme enabled
|
||||||
val darkMode = when (settings.theme) {
|
val darkMode = isSystemInDarkTheme()
|
||||||
Theme.Light -> false
|
|
||||||
Theme.Dark -> true
|
|
||||||
Theme.System -> isSystemInDarkTheme()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enabled the dynamic color scheme
|
// Enabled the dynamic color scheme
|
||||||
val colorScheme = when {
|
val colorScheme = when {
|
||||||
// Enable the dynamic color scheme for Android 12+
|
// Enable the dynamic color scheme for Android 12+
|
||||||
settings.dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(
|
||||||
|
context
|
||||||
|
)
|
||||||
}
|
}
|
||||||
// When dark mode is enabled, use the dark color scheme
|
// When dark mode is enabled, use the dark color scheme
|
||||||
darkMode -> darkColorScheme()
|
darkMode -> darkColorScheme()
|
||||||
@@ -172,6 +134,10 @@ fun App(
|
|||||||
else -> expressiveLightColorScheme()
|
else -> expressiveLightColorScheme()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BackHandler(enabled = backStack.size > 1) {
|
||||||
|
navigator.goBack()
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
launch {
|
launch {
|
||||||
accountViewModel.errorEvents.collect { message ->
|
accountViewModel.errorEvents.collect { message ->
|
||||||
@@ -183,11 +149,6 @@ fun App(
|
|||||||
snackbarHostState.showSnackbar(message)
|
snackbarHostState.showSnackbar(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
launch {
|
|
||||||
concordViewModel.errorEvents.collect { message ->
|
|
||||||
snackbarHostState.showSnackbar(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
launch {
|
launch {
|
||||||
profileCache.errorEvents.collect { message ->
|
profileCache.errorEvents.collect { message ->
|
||||||
snackbarHostState.showSnackbar(message)
|
snackbarHostState.showSnackbar(message)
|
||||||
@@ -234,12 +195,11 @@ fun App(
|
|||||||
) {
|
) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalProfileCache provides profileCache,
|
LocalProfileCache provides profileCache,
|
||||||
LocalSettings provides settings,
|
|
||||||
LocalConnectivity provides isMobileData,
|
|
||||||
LocalSnackbarHostState provides snackbarHostState,
|
LocalSnackbarHostState provides snackbarHostState,
|
||||||
LocalNavigator provides navigator,
|
LocalNavigator provides navigator,
|
||||||
LocalScanResult provides qrScanResult,
|
LocalScanResult provides qrScanResult,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
NavDisplay(
|
NavDisplay(
|
||||||
backStack = backStack,
|
backStack = backStack,
|
||||||
onBack = {
|
onBack = {
|
||||||
@@ -255,7 +215,7 @@ fun App(
|
|||||||
),
|
),
|
||||||
entryProvider = entryProvider {
|
entryProvider = entryProvider {
|
||||||
entry<Screen.Home> {
|
entry<Screen.Home> {
|
||||||
HomeScreen(accountViewModel, chatViewModel, concordViewModel)
|
HomeScreen(accountViewModel, chatViewModel)
|
||||||
}
|
}
|
||||||
entry<Screen.RequestList> {
|
entry<Screen.RequestList> {
|
||||||
RequestListScreen(chatViewModel)
|
RequestListScreen(chatViewModel)
|
||||||
@@ -312,39 +272,6 @@ fun App(
|
|||||||
entry<Screen.Relay> {
|
entry<Screen.Relay> {
|
||||||
RelayScreen(accountViewModel)
|
RelayScreen(accountViewModel)
|
||||||
}
|
}
|
||||||
entry<Screen.Settings> {
|
|
||||||
SettingsScreen(settingsViewModel)
|
|
||||||
}
|
|
||||||
entry<Screen.Communities> {
|
|
||||||
CommunitiesScreen(concordViewModel)
|
|
||||||
}
|
|
||||||
entry<Screen.JoinCommunity> { key ->
|
|
||||||
JoinCommunityScreen(concordViewModel, key.link)
|
|
||||||
}
|
|
||||||
entry<Screen.Community> { key ->
|
|
||||||
CommunityScreen(key.communityId, concordViewModel)
|
|
||||||
}
|
|
||||||
entry<Screen.Channel> { key ->
|
|
||||||
val factory = remember(key) {
|
|
||||||
object : ViewModelProvider.Factory {
|
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
return ChannelScreenViewModel(
|
|
||||||
key.communityId,
|
|
||||||
key.channelId,
|
|
||||||
accountRepository,
|
|
||||||
concordRepository
|
|
||||||
) as T
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ChannelScreen(
|
|
||||||
viewModel<ChannelScreenViewModel>(
|
|
||||||
key = "${key.communityId}:${key.channelId}",
|
|
||||||
factory = factory
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ import kotlinx.coroutines.MainScope
|
|||||||
import su.reya.coop.nostr.NostrManager
|
import su.reya.coop.nostr.NostrManager
|
||||||
import su.reya.coop.repository.AccountRepository
|
import su.reya.coop.repository.AccountRepository
|
||||||
import su.reya.coop.repository.ChatRepository
|
import su.reya.coop.repository.ChatRepository
|
||||||
import su.reya.coop.repository.ConcordRepository
|
|
||||||
import su.reya.coop.repository.MediaRepository
|
import su.reya.coop.repository.MediaRepository
|
||||||
import su.reya.coop.repository.SettingsRepository
|
|
||||||
import su.reya.coop.viewmodel.ProfileCache
|
import su.reya.coop.viewmodel.ProfileCache
|
||||||
import kotlin.system.exitProcess
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
@@ -27,34 +25,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
private val profileCache by lazy { ProfileCache(NostrManager.instance) }
|
private val profileCache by lazy { ProfileCache(NostrManager.instance) }
|
||||||
private val scope = MainScope()
|
private val scope = MainScope()
|
||||||
|
|
||||||
private val connectivityMonitor by lazy { AndroidConnectivityMonitor(this@MainActivity) }
|
|
||||||
|
|
||||||
private val settingsRepository by lazy {
|
|
||||||
val storage = AppStore(this@MainActivity)
|
|
||||||
SettingsRepository(storage, scope)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val accountRepository by lazy {
|
private val accountRepository by lazy {
|
||||||
val storage = AppStore(this@MainActivity)
|
val storage = AppStore(this@MainActivity)
|
||||||
val mediaRepository = MediaRepository(settingsRepository)
|
val mediaRepository = MediaRepository()
|
||||||
val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
||||||
AccountRepository(
|
AccountRepository(NostrManager.instance, storage, mediaRepository, scope, androidSigner)
|
||||||
NostrManager.instance,
|
|
||||||
storage,
|
|
||||||
mediaRepository,
|
|
||||||
settingsRepository,
|
|
||||||
scope,
|
|
||||||
androidSigner
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val chatRepository by lazy {
|
private val chatRepository by lazy {
|
||||||
val mediaRepository = MediaRepository(settingsRepository)
|
val mediaRepository = MediaRepository()
|
||||||
ChatRepository(NostrManager.instance, mediaRepository, settingsRepository, scope)
|
ChatRepository(NostrManager.instance, mediaRepository, scope)
|
||||||
}
|
|
||||||
|
|
||||||
private val concordRepository by lazy {
|
|
||||||
ConcordRepository(NostrManager.instance, scope)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -102,9 +82,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
profileCache = profileCache,
|
profileCache = profileCache,
|
||||||
accountRepository = accountRepository,
|
accountRepository = accountRepository,
|
||||||
chatRepository = chatRepository,
|
chatRepository = chatRepository,
|
||||||
concordRepository = concordRepository,
|
|
||||||
settingsRepository = settingsRepository,
|
|
||||||
connectivityMonitor = connectivityMonitor,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,19 +58,4 @@ sealed interface Screen : NavKey {
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Relay : Screen
|
data object Relay : Screen
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data object Settings : Screen
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data object Communities : Screen
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Community(val communityId: String) : Screen
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Channel(val communityId: String, val channelId: String) : Screen
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class JoinCommunity(val link: String? = null) : Screen
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import android.os.Build
|
|||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.ProcessLifecycleOwner
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -28,6 +28,8 @@ private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES"
|
|||||||
|
|
||||||
class NostrForegroundService : Service() {
|
class NostrForegroundService : Service() {
|
||||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
|
var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||||
private val nostr by lazy { NostrManager.instance }
|
private val nostr by lazy { NostrManager.instance }
|
||||||
private var notificationJob: Job? = null
|
private var notificationJob: Job? = null
|
||||||
|
|
||||||
@@ -63,7 +65,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)
|
||||||
}
|
}
|
||||||
@@ -189,13 +191,4 @@ class NostrForegroundService : Service() {
|
|||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
serviceScope.cancel()
|
serviceScope.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
|
||||||
super.onTaskRemoved(rootIntent)
|
|
||||||
if (!NotificationManagerCompat.from(this).areNotificationsEnabled()) {
|
|
||||||
Log.d("Coop", "Stopping service on task removed because notifications are disabled")
|
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
|
||||||
stopSelf()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,8 +73,7 @@ fun ContactListScreen(
|
|||||||
) {
|
) {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
val contactList by accountViewModel.contactList.collectAsStateWithLifecycle()
|
||||||
val contactList = accountState.contactList
|
|
||||||
var openAddContactDialog by remember { mutableStateOf(false) }
|
var openAddContactDialog by remember { mutableStateOf(false) }
|
||||||
var contactToDelete by remember { mutableStateOf<PublicKey?>(null) }
|
var contactToDelete by remember { mutableStateOf<PublicKey?>(null) }
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import android.os.Build
|
|||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -25,8 +24,6 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Badge
|
|
||||||
import androidx.compose.material3.BadgedBox
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -89,7 +86,6 @@ import coop.composeapp.generated.resources.ic_new_chat
|
|||||||
import coop.composeapp.generated.resources.ic_qr
|
import coop.composeapp.generated.resources.ic_qr
|
||||||
import coop.composeapp.generated.resources.ic_request
|
import coop.composeapp.generated.resources.ic_request
|
||||||
import coop.composeapp.generated.resources.ic_scanner
|
import coop.composeapp.generated.resources.ic_scanner
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
@@ -102,20 +98,17 @@ import su.reya.coop.RoomKind
|
|||||||
import su.reya.coop.RoomUiState
|
import su.reya.coop.RoomUiState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.ago
|
import su.reya.coop.ago
|
||||||
import su.reya.coop.concord.parseInviteLink
|
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
import su.reya.coop.shared.getExpressiveFontFamily
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
import su.reya.coop.uiStateFlow
|
import su.reya.coop.uiStateFlow
|
||||||
import su.reya.coop.viewmodel.AccountViewModel
|
import su.reya.coop.viewmodel.AccountViewModel
|
||||||
import su.reya.coop.viewmodel.ChatViewModel
|
import su.reya.coop.viewmodel.ChatViewModel
|
||||||
import su.reya.coop.viewmodel.ConcordViewModel
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
chatViewModel: ChatViewModel,
|
chatViewModel: ChatViewModel
|
||||||
concordViewModel: ConcordViewModel,
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
@@ -131,13 +124,13 @@ fun HomeScreen(
|
|||||||
val userProfile by accountViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
val userProfile by accountViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||||
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
val isRelayListEmpty by accountViewModel.isRelayListEmpty.collectAsStateWithLifecycle()
|
||||||
val isRelayListEmpty = accountState.isRelayListEmpty
|
|
||||||
val isBannerDismissed = accountState.isNotificationBannerDismissed
|
|
||||||
|
|
||||||
val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle()
|
val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle()
|
||||||
val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
|
val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val isBannerDismissed = accountState.isNotificationBannerDismissed
|
||||||
|
|
||||||
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
|
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
|
||||||
var showBottomSheet by remember { mutableStateOf(false) }
|
var showBottomSheet by remember { mutableStateOf(false) }
|
||||||
var isRefreshing by remember { mutableStateOf(false) }
|
var isRefreshing by remember { mutableStateOf(false) }
|
||||||
@@ -169,22 +162,16 @@ fun HomeScreen(
|
|||||||
|
|
||||||
LaunchedEffect(qrScanResult.content) {
|
LaunchedEffect(qrScanResult.content) {
|
||||||
qrScanResult.content?.let { result ->
|
qrScanResult.content?.let { result ->
|
||||||
// A Concord invite is a link, not a npub, so it is routed to the Join screen instead of
|
runCatching { PublicKey.parse(result) }
|
||||||
// being force-fed to PublicKey.parse.
|
.onSuccess { pubkey ->
|
||||||
if (parseInviteLink(result) != null) {
|
try {
|
||||||
navigator.navigate(Screen.JoinCommunity(result))
|
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
||||||
} else {
|
navigator.navigate(Screen.Chat(roomId))
|
||||||
runCatching { PublicKey.parse(result) }
|
} catch (e: Exception) {
|
||||||
.onSuccess { pubkey ->
|
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||||
try {
|
|
||||||
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
|
||||||
navigator.navigate(Screen.Chat(roomId))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
|
}
|
||||||
}
|
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
|
||||||
// Clear the nav state
|
// Clear the nav state
|
||||||
qrScanResult.clear()
|
qrScanResult.clear()
|
||||||
}
|
}
|
||||||
@@ -262,9 +249,7 @@ fun HomeScreen(
|
|||||||
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
|
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
if (!isNotificationEnabled && !isBannerDismissed) {
|
||||||
visible = !isNotificationEnabled && !isBannerDismissed,
|
|
||||||
) {
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -387,14 +372,10 @@ fun HomeScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
items(ongoing, key = { it.id }) { room ->
|
items(ongoing, key = { it.id }) { room ->
|
||||||
Row(
|
ChatRoom(
|
||||||
modifier = Modifier.animateItem()
|
room = room,
|
||||||
) {
|
onClick = { navigator.navigate(Screen.Chat(room.id)) }
|
||||||
ChatRoom(
|
)
|
||||||
room = room,
|
|
||||||
onClick = { navigator.navigate(Screen.Chat(room.id)) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -482,8 +463,7 @@ fun HomeScreen(
|
|||||||
BottomMenuList(
|
BottomMenuList(
|
||||||
onDismiss = dismissAndRun,
|
onDismiss = dismissAndRun,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
chatViewModel = chatViewModel,
|
chatViewModel = chatViewModel
|
||||||
concordViewModel = concordViewModel
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -640,16 +620,13 @@ fun NewRequests(requests: List<Room>) {
|
|||||||
val firstRoom = requests.getOrNull(0)
|
val firstRoom = requests.getOrNull(0)
|
||||||
val secondRoom = requests.getOrNull(1)
|
val secondRoom = requests.getOrNull(1)
|
||||||
|
|
||||||
val firstRoomState by remember(firstRoom?.id) {
|
val firstRoomState by (firstRoom as Room).uiStateFlow(profileCache)
|
||||||
firstRoom?.uiStateFlow(profileCache) ?: flowOf(RoomUiState())
|
.collectAsStateWithLifecycle(RoomUiState())
|
||||||
}.collectAsStateWithLifecycle(RoomUiState())
|
val secondRoomState by (secondRoom ?: firstRoom).uiStateFlow(profileCache)
|
||||||
|
.collectAsStateWithLifecycle(RoomUiState())
|
||||||
val secondRoomState by remember(secondRoom?.id) {
|
|
||||||
(secondRoom ?: firstRoom)?.uiStateFlow(profileCache) ?: flowOf(RoomUiState())
|
|
||||||
}.collectAsStateWithLifecycle(RoomUiState())
|
|
||||||
|
|
||||||
val supportingText = when {
|
val supportingText = when {
|
||||||
total == 1 && firstRoom != null -> {
|
total == 1 -> {
|
||||||
val message = firstRoom.lastMessage ?: ""
|
val message = firstRoom.lastMessage ?: ""
|
||||||
"${firstRoomState.name}: $message"
|
"${firstRoomState.name}: $message"
|
||||||
}
|
}
|
||||||
@@ -667,39 +644,27 @@ fun NewRequests(requests: List<Room>) {
|
|||||||
else -> ""
|
else -> ""
|
||||||
}
|
}
|
||||||
|
|
||||||
val totalUnread = requests.sumOf { it.unreadCount }
|
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
modifier = Modifier.clickable {
|
modifier = Modifier.clickable {
|
||||||
navigator.navigate(Screen.RequestList)
|
navigator.navigate(Screen.RequestList)
|
||||||
},
|
},
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
BadgedBox(
|
Box(
|
||||||
badge = {
|
modifier = Modifier
|
||||||
if (totalUnread > 0) {
|
.size(48.dp)
|
||||||
Badge {
|
.clip(MaterialShapes.Clover4Leaf.toShape()),
|
||||||
Text(totalUnread.toString())
|
contentAlignment = Alignment.Center
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
Box(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier.size(48.dp),
|
||||||
.size(48.dp)
|
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
.clip(MaterialShapes.Clover4Leaf.toShape()),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
Surface(
|
Box(contentAlignment = Alignment.Center) {
|
||||||
modifier = Modifier.size(48.dp),
|
Icon(
|
||||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
painter = painterResource(Res.drawable.ic_request),
|
||||||
) {
|
contentDescription = "Requests",
|
||||||
Box(contentAlignment = Alignment.Center) {
|
tint = MaterialTheme.colorScheme.onTertiaryFixed
|
||||||
Icon(
|
)
|
||||||
painter = painterResource(Res.drawable.ic_request),
|
|
||||||
contentDescription = "Requests",
|
|
||||||
tint = MaterialTheme.colorScheme.onTertiaryFixed
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -707,19 +672,14 @@ fun NewRequests(requests: List<Room>) {
|
|||||||
headlineContent = {
|
headlineContent = {
|
||||||
Text(
|
Text(
|
||||||
text = "Requests",
|
text = "Requests",
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized.copy(
|
style = MaterialTheme.typography.titleMediumEmphasized
|
||||||
fontWeight = if (totalUnread > 0) FontWeight.SemiBold else FontWeight.Normal
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
if (supportingText.isNotEmpty()) {
|
if (supportingText.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = supportingText,
|
text = supportingText,
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = if (totalUnread > 0) FontWeight.SemiBold else FontWeight.Normal,
|
|
||||||
color = if (totalUnread > 0) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outline
|
|
||||||
),
|
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
@@ -735,41 +695,27 @@ fun NewRequests(requests: List<Room>) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
||||||
val profileCache = LocalProfileCache.current
|
val profileCache = LocalProfileCache.current
|
||||||
val roomState by remember(room.id) { room.uiStateFlow(profileCache) }
|
val roomState by room.uiStateFlow(profileCache).collectAsStateWithLifecycle(RoomUiState())
|
||||||
.collectAsStateWithLifecycle(RoomUiState())
|
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
modifier = Modifier.clickable(onClick = onClick),
|
modifier = Modifier.clickable(onClick = onClick),
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
BadgedBox(
|
Avatar(picture = roomState.picture, description = roomState.picture)
|
||||||
badge = {
|
|
||||||
if (room.unreadCount > 0) {
|
|
||||||
Badge {
|
|
||||||
Text(room.unreadCount.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Avatar(picture = roomState.picture, description = roomState.picture)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = roomState.name,
|
text = roomState.name,
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized.copy(
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
fontWeight = if (room.unreadCount > 0) FontWeight.SemiBold else FontWeight.Normal
|
|
||||||
),
|
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = room.createdAt.ago(),
|
text = room.createdAt.ago(),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.outline,
|
color = MaterialTheme.colorScheme.outline
|
||||||
textAlign = TextAlign.End,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -777,10 +723,7 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
|
|||||||
if (!room.lastMessage.isNullOrBlank()) {
|
if (!room.lastMessage.isNullOrBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = room.lastMessage ?: "",
|
text = room.lastMessage ?: "",
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = if (room.unreadCount > 0) FontWeight.SemiBold else FontWeight.Normal,
|
|
||||||
color = if (room.unreadCount > 0) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outline
|
|
||||||
),
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
@@ -798,16 +741,14 @@ fun BottomMenuList(
|
|||||||
onDismiss: (suspend () -> Unit) -> Unit,
|
onDismiss: (suspend () -> Unit) -> Unit,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
chatViewModel: ChatViewModel,
|
chatViewModel: ChatViewModel,
|
||||||
concordViewModel: ConcordViewModel,
|
|
||||||
) {
|
) {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
|
|
||||||
val defaultMenuList = listOf(
|
val defaultMenuList = listOf(
|
||||||
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
||||||
"Contact List" to { navigator.navigate(Screen.ContactList) },
|
"Contact List" to { navigator.navigate(Screen.ContactList) },
|
||||||
"Communities (beta)" to { navigator.navigate(Screen.Communities) },
|
|
||||||
"Relay Management" to { navigator.navigate(Screen.Relay) },
|
"Relay Management" to { navigator.navigate(Screen.Relay) },
|
||||||
"Settings" to { navigator.navigate(Screen.Settings) }
|
"Settings" to { }
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
@@ -836,8 +777,6 @@ fun BottomMenuList(
|
|||||||
accountViewModel.logout(onLogout = {
|
accountViewModel.logout(onLogout = {
|
||||||
accountViewModel.resetInternalState()
|
accountViewModel.resetInternalState()
|
||||||
chatViewModel.resetInternalState()
|
chatViewModel.resetInternalState()
|
||||||
// Concord's keys are identity-scoped, so they leave with the identity.
|
|
||||||
concordViewModel.resetInternalState()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -101,6 +101,13 @@ fun ImportScreen(viewModel: AccountViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show import errors via snackbar
|
||||||
|
LaunchedEffect(accountState.importError) {
|
||||||
|
accountState.importError?.let {
|
||||||
|
snackbarHostState.showSnackbar(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
|||||||
@@ -75,8 +75,7 @@ fun NewChatScreen(
|
|||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val qrScanResult = LocalScanResult.current
|
val qrScanResult = LocalScanResult.current
|
||||||
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
val contactList by accountViewModel.contactList.collectAsStateWithLifecycle()
|
||||||
val contactList = accountState.contactList
|
|
||||||
var query by remember { mutableStateOf("") }
|
var query by remember { mutableStateOf("") }
|
||||||
|
|
||||||
val createGroup = remember { mutableStateOf(false) }
|
val createGroup = remember { mutableStateOf(false) }
|
||||||
@@ -186,8 +185,8 @@ fun NewChatScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
text = { Text("Next") },
|
text = { Text("Next") },
|
||||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
containerColor = MaterialTheme.colorScheme.tertiary,
|
||||||
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
|
contentColor = MaterialTheme.colorScheme.onTertiary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,13 @@ fun OnboardingScreen(viewModel: AccountViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show connection errors
|
||||||
|
LaunchedEffect(accountState.importError) {
|
||||||
|
accountState.importError?.let {
|
||||||
|
snackbarHostState.showSnackbar(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val logoPainter = painterResource(Res.drawable.coop)
|
val logoPainter = painterResource(Res.drawable.coop)
|
||||||
val expressiveFont = getExpressiveFontFamily()
|
val expressiveFont = getExpressiveFontFamily()
|
||||||
|
|
||||||
|
|||||||
@@ -95,15 +95,14 @@ fun RelayScreen(viewModel: AccountViewModel) {
|
|||||||
var openAddRelayDialog by remember { mutableStateOf(false) }
|
var openAddRelayDialog by remember { mutableStateOf(false) }
|
||||||
var relayToDelete by remember { mutableStateOf<String?>(null) }
|
var relayToDelete by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
val accountState by viewModel.state.collectAsStateWithLifecycle()
|
|
||||||
val loadedRelayList = accountState.userRelayList
|
|
||||||
val loadedMsgRelayList = accountState.userMsgRelayList
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.loadCurrentUserRelayList()
|
viewModel.loadCurrentUserRelayList()
|
||||||
viewModel.loadCurrentUserMsgRelayList()
|
viewModel.loadCurrentUserMsgRelayList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val loadedRelayList by viewModel.userRelayList.collectAsStateWithLifecycle()
|
||||||
|
val loadedMsgRelayList by viewModel.userMsgRelayList.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
LaunchedEffect(loadedRelayList) {
|
LaunchedEffect(loadedRelayList) {
|
||||||
if (loadedRelayList.isNotEmpty()) {
|
if (loadedRelayList.isNotEmpty()) {
|
||||||
relayList.clear()
|
relayList.clear()
|
||||||
|
|||||||
@@ -1,291 +0,0 @@
|
|||||||
package su.reya.coop.screens
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.provider.Settings as AndroidSettings
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.selection.selectable
|
|
||||||
import androidx.compose.foundation.selection.selectableGroup
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.ListItemDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.RadioButton
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.SegmentedListItem
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.semantics.Role
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import coop.composeapp.generated.resources.Res
|
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
|
||||||
import org.jetbrains.compose.resources.painterResource
|
|
||||||
import su.reya.coop.LocalNavigator
|
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
|
||||||
import su.reya.coop.MediaConfig
|
|
||||||
import su.reya.coop.Theme
|
|
||||||
import su.reya.coop.viewmodel.SettingsViewModel
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun SettingsScreen(viewModel: SettingsViewModel) {
|
|
||||||
val navigator = LocalNavigator.current
|
|
||||||
val context = LocalContext.current
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
|
||||||
val settings by viewModel.settings.collectAsState()
|
|
||||||
|
|
||||||
var showThemeDialog by remember { mutableStateOf(false) }
|
|
||||||
var showMediaDialog by remember { mutableStateOf(false) }
|
|
||||||
var showBlossomDialog by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
text = "Settings",
|
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized
|
|
||||||
)
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
),
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = { navigator.goBack() }) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
|
||||||
contentDescription = "Back"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { innerPadding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding)
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "General",
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.padding(horizontal = 8.dp)
|
|
||||||
)
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)
|
|
||||||
) {
|
|
||||||
SegmentedListItem(
|
|
||||||
onClick = { viewModel.update { it.copy(screening = !it.screening) } },
|
|
||||||
shapes = ListItemDefaults.segmentedShapes(index = 0, count = 4),
|
|
||||||
content = { Text("Screening") },
|
|
||||||
supportingContent = { Text("Filter unknown contacts") },
|
|
||||||
trailingContent = {
|
|
||||||
Switch(
|
|
||||||
checked = settings.screening,
|
|
||||||
onCheckedChange = { viewModel.update { s -> s.copy(screening = it) } }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
SegmentedListItem(
|
|
||||||
onClick = { showMediaDialog = true },
|
|
||||||
shapes = ListItemDefaults.segmentedShapes(index = 1, count = 4),
|
|
||||||
content = { Text("Media Preview") },
|
|
||||||
supportingContent = {
|
|
||||||
Text(
|
|
||||||
when (settings.media) {
|
|
||||||
MediaConfig.Disabled -> "Disabled"
|
|
||||||
MediaConfig.DisabledForMobileData -> "Disabled for Mobile Data"
|
|
||||||
MediaConfig.AlwaysEnabled -> "Always Enabled"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
SegmentedListItem(
|
|
||||||
onClick = { showBlossomDialog = true },
|
|
||||||
shapes = ListItemDefaults.segmentedShapes(index = 2, count = 4),
|
|
||||||
content = { Text("Blossom Server") },
|
|
||||||
supportingContent = { Text(settings.blossomServer ?: "Default") }
|
|
||||||
)
|
|
||||||
SegmentedListItem(
|
|
||||||
onClick = {
|
|
||||||
val intent = Intent(AndroidSettings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
|
||||||
putExtra(AndroidSettings.EXTRA_APP_PACKAGE, context.packageName)
|
|
||||||
}
|
|
||||||
context.startActivity(intent)
|
|
||||||
},
|
|
||||||
shapes = ListItemDefaults.segmentedShapes(index = 3, count = 4),
|
|
||||||
content = { Text("Notifications") },
|
|
||||||
supportingContent = { Text("System notification settings") }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Appearance",
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.padding(horizontal = 8.dp)
|
|
||||||
)
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)
|
|
||||||
) {
|
|
||||||
SegmentedListItem(
|
|
||||||
onClick = { showThemeDialog = true },
|
|
||||||
shapes = ListItemDefaults.segmentedShapes(index = 0, count = 2),
|
|
||||||
content = { Text("Theme") },
|
|
||||||
supportingContent = { Text(settings.theme.name) }
|
|
||||||
)
|
|
||||||
SegmentedListItem(
|
|
||||||
onClick = { viewModel.update { it.copy(dynamicColor = !it.dynamicColor) } },
|
|
||||||
shapes = ListItemDefaults.segmentedShapes(index = 1, count = 2),
|
|
||||||
content = { Text("Dynamic Color") },
|
|
||||||
trailingContent = {
|
|
||||||
Switch(
|
|
||||||
checked = settings.dynamicColor,
|
|
||||||
onCheckedChange = {
|
|
||||||
viewModel.update { s -> s.copy(dynamicColor = it) }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showThemeDialog) {
|
|
||||||
OptionDialog(
|
|
||||||
title = "Select Theme",
|
|
||||||
options = Theme.entries.map { it.name },
|
|
||||||
selected = settings.theme.name,
|
|
||||||
onSelected = { name ->
|
|
||||||
viewModel.update { it.copy(theme = Theme.valueOf(name)) }
|
|
||||||
showThemeDialog = false
|
|
||||||
},
|
|
||||||
onDismiss = { showThemeDialog = false }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showMediaDialog) {
|
|
||||||
OptionDialog(
|
|
||||||
title = "Media Preview",
|
|
||||||
options = listOf("Disabled", "Disabled for Mobile Data", "Always Enabled"),
|
|
||||||
selected = when (settings.media) {
|
|
||||||
MediaConfig.Disabled -> "Disabled"
|
|
||||||
MediaConfig.DisabledForMobileData -> "Disabled for Mobile Data"
|
|
||||||
MediaConfig.AlwaysEnabled -> "Always Enabled"
|
|
||||||
},
|
|
||||||
onSelected = { choice ->
|
|
||||||
val newConfig = when (choice) {
|
|
||||||
"Disabled" -> MediaConfig.Disabled
|
|
||||||
"Disabled for Mobile Data" -> MediaConfig.DisabledForMobileData
|
|
||||||
else -> MediaConfig.AlwaysEnabled
|
|
||||||
}
|
|
||||||
viewModel.update { it.copy(media = newConfig) }
|
|
||||||
showMediaDialog = false
|
|
||||||
},
|
|
||||||
onDismiss = { showMediaDialog = false }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showBlossomDialog) {
|
|
||||||
var text by remember { mutableStateOf(settings.blossomServer ?: "") }
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showBlossomDialog = false },
|
|
||||||
title = { Text("Blossom Server URL") },
|
|
||||||
text = {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = text,
|
|
||||||
onValueChange = { text = it },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
placeholder = { Text("https://...") },
|
|
||||||
singleLine = true
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = {
|
|
||||||
viewModel.update { it.copy(blossomServer = text.ifBlank { null }) }
|
|
||||||
showBlossomDialog = false
|
|
||||||
}) {
|
|
||||||
Text("Save")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { showBlossomDialog = false }) {
|
|
||||||
Text("Cancel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun OptionDialog(
|
|
||||||
title: String,
|
|
||||||
options: List<String>,
|
|
||||||
selected: String,
|
|
||||||
onSelected: (String) -> Unit,
|
|
||||||
onDismiss: () -> Unit
|
|
||||||
) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text(title) },
|
|
||||||
text = {
|
|
||||||
Column(modifier = Modifier.selectableGroup()) {
|
|
||||||
options.forEach { text ->
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.selectable(
|
|
||||||
selected = (text == selected),
|
|
||||||
onClick = { onSelected(text) },
|
|
||||||
role = Role.RadioButton
|
|
||||||
)
|
|
||||||
.padding(vertical = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
RadioButton(selected = (text == selected), onClick = null)
|
|
||||||
Spacer(Modifier.size(16.dp))
|
|
||||||
Text(text = text, style = MaterialTheme.typography.bodyLarge)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -38,8 +38,8 @@ fun ChatInput(
|
|||||||
value: String,
|
value: String,
|
||||||
onValueChange: (String) -> Unit,
|
onValueChange: (String) -> Unit,
|
||||||
onSend: () -> Unit,
|
onSend: () -> Unit,
|
||||||
onUpload: (() -> Unit)? = null,
|
onUpload: () -> Unit,
|
||||||
onMicClick: (() -> Unit)? = null
|
onMicClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
@@ -60,16 +60,12 @@ fun ChatInput(
|
|||||||
capitalization = KeyboardCapitalization.Sentences,
|
capitalization = KeyboardCapitalization.Sentences,
|
||||||
imeAction = ImeAction.Default
|
imeAction = ImeAction.Default
|
||||||
),
|
),
|
||||||
leadingIcon = if (onUpload == null) {
|
leadingIcon = {
|
||||||
null
|
IconButton(onClick = onUpload) {
|
||||||
} else {
|
Icon(
|
||||||
{
|
painter = painterResource(Res.drawable.ic_add_circle),
|
||||||
IconButton(onClick = onUpload) {
|
contentDescription = "Upload",
|
||||||
Icon(
|
)
|
||||||
painter = painterResource(Res.drawable.ic_add_circle),
|
|
||||||
contentDescription = "Upload",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -79,21 +75,9 @@ fun ChatInput(
|
|||||||
transitionSpec = { (scaleIn() + fadeIn()) togetherWith (scaleOut() + fadeOut()) },
|
transitionSpec = { (scaleIn() + fadeIn()) togetherWith (scaleOut() + fadeOut()) },
|
||||||
label = "send_mic_transition"
|
label = "send_mic_transition"
|
||||||
) { isNotEmpty ->
|
) { isNotEmpty ->
|
||||||
// Nothing to send and nowhere to dictate to: the send button stays, greyed out.
|
if (isNotEmpty) {
|
||||||
if (!isNotEmpty && onMicClick != null) {
|
|
||||||
FilledTonalIconButton(
|
|
||||||
onClick = onMicClick,
|
|
||||||
modifier = Modifier.size(56.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(Res.drawable.ic_audio),
|
|
||||||
contentDescription = "Speech to Text"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onSend,
|
onClick = onSend,
|
||||||
enabled = isNotEmpty,
|
|
||||||
modifier = Modifier.size(56.dp),
|
modifier = Modifier.size(56.dp),
|
||||||
colors = IconButtonDefaults.iconButtonColors(
|
colors = IconButtonDefaults.iconButtonColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
@@ -105,6 +89,16 @@ fun ChatInput(
|
|||||||
contentDescription = "Send"
|
contentDescription = "Send"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
FilledTonalIconButton(
|
||||||
|
onClick = onMicClick,
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_audio),
|
||||||
|
contentDescription = "Speech to Text"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
package su.reya.coop.screens.chat
|
package su.reya.coop.screens.chat
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
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,26 +39,15 @@ 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
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import su.reya.coop.LocalConnectivity
|
|
||||||
import su.reya.coop.LocalSettings
|
|
||||||
import su.reya.coop.MediaConfig
|
|
||||||
import su.reya.coop.URL_REGEX
|
import su.reya.coop.URL_REGEX
|
||||||
import su.reya.coop.formatAsTime
|
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,37 +56,19 @@ 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,
|
return remember(event, currentUser) {
|
||||||
reactions: List<UnsignedEvent> = emptyList(),
|
|
||||||
currentUser: PublicKey? = null
|
|
||||||
): MessageModel {
|
|
||||||
val settings = LocalSettings.current
|
|
||||||
val isMobileData = LocalConnectivity.current
|
|
||||||
|
|
||||||
return remember(event, reactions, 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()
|
||||||
val replyEventIds = event.tags().eventIds()
|
val replyEventIds = event.tags().eventIds()
|
||||||
|
|
||||||
val showMedia = when (settings.media) {
|
val images = URL_REGEX.findAll(content).map { it.value }.filter { it.isImageUrl() }.toList()
|
||||||
MediaConfig.AlwaysEnabled -> true
|
val cleanedContent = content.removeImageUrls()
|
||||||
MediaConfig.Disabled -> false
|
|
||||||
MediaConfig.DisabledForMobileData -> !isMobileData
|
|
||||||
}
|
|
||||||
|
|
||||||
val images = if (showMedia) {
|
|
||||||
URL_REGEX.findAll(content).map { it.value }.filter { it.isImageUrl() }.toList()
|
|
||||||
} else {
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
val cleanedContent = if (showMedia) content.removeImageUrls() else content
|
|
||||||
|
|
||||||
val annotatedString = buildAnnotatedString {
|
val annotatedString = buildAnnotatedString {
|
||||||
var lastIndex = 0
|
var lastIndex = 0
|
||||||
@@ -121,16 +93,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 +100,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,110 +147,50 @@ 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()) {
|
|
||||||
MessageReactions(
|
|
||||||
emojis = model.reactions.map { it.emoji },
|
|
||||||
total = model.reactions.sumOf { it.authors.size },
|
|
||||||
modifier = Modifier.offset(y = 12.dp)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
model.images.forEach { imageUrl ->
|
||||||
if (isMessageClicked) {
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isMessageClicked,
|
||||||
|
enter = fadeIn() + expandVertically(),
|
||||||
|
exit = fadeOut() + shrinkVertically()
|
||||||
|
) {
|
||||||
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
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The reaction chips under a bubble, shared by DMs and Channels. */
|
|
||||||
@Composable
|
|
||||||
fun MessageReactions(
|
|
||||||
emojis: List<String>,
|
|
||||||
total: Int,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val displayEmojis = emojis.take(3)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = modifier,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
|
||||||
) {
|
|
||||||
displayEmojis.forEach { emoji ->
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
color = MaterialTheme.colorScheme.surface,
|
|
||||||
shape = CircleShape,
|
|
||||||
) {
|
|
||||||
Box(contentAlignment = Alignment.Center) {
|
|
||||||
Text(
|
|
||||||
text = emoji,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (total > 2) {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
color = MaterialTheme.colorScheme.surface,
|
|
||||||
shape = CircleShape,
|
|
||||||
) {
|
|
||||||
Box(contentAlignment = Alignment.Center) {
|
|
||||||
Text(
|
|
||||||
text = total.toString(),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
fontSize = 10.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ import androidx.compose.ui.platform.LocalWindowInfo
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.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
|
||||||
@@ -91,12 +90,9 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.launch
|
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.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
|
||||||
import su.reya.coop.LocalSettings
|
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Room
|
import su.reya.coop.Room
|
||||||
import su.reya.coop.RoomUiState
|
import su.reya.coop.RoomUiState
|
||||||
@@ -119,7 +115,6 @@ fun ChatScreen(
|
|||||||
val clipboardManager = LocalClipboard.current
|
val clipboardManager = LocalClipboard.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val profileCache = LocalProfileCache.current
|
val profileCache = LocalProfileCache.current
|
||||||
val settings = LocalSettings.current
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
@@ -146,25 +141,13 @@ 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 (room as Room).uiStateFlow(profileCache, currentUser?.publicKey)
|
||||||
(room as Room).uiStateFlow(profileCache, currentUser?.publicKey)
|
.collectAsStateWithLifecycle(RoomUiState())
|
||||||
}.collectAsStateWithLifecycle(RoomUiState())
|
|
||||||
|
|
||||||
var text by remember { mutableStateOf("") }
|
var text by remember { mutableStateOf("") }
|
||||||
var selectedMessage by remember { mutableStateOf<Pair<MessageModel, Rect>?>(null) }
|
var selectedMessage by remember { mutableStateOf<Pair<MessageModel, Rect>?>(null) }
|
||||||
@@ -175,29 +158,6 @@ fun ChatScreen(
|
|||||||
label = "blurAnimation"
|
label = "blurAnimation"
|
||||||
)
|
)
|
||||||
|
|
||||||
val goToMessage = { eventId: EventId? ->
|
|
||||||
if (eventId != null) {
|
|
||||||
scope.launch {
|
|
||||||
var targetIndex = -1
|
|
||||||
var currentIndex = 0
|
|
||||||
|
|
||||||
for (group in groupedMessages.value) {
|
|
||||||
val msgInGroup = group.value
|
|
||||||
val idx = msgInGroup.indexOfFirst { it.ensureId().id() == eventId }
|
|
||||||
if (idx != -1) {
|
|
||||||
targetIndex = currentIndex + idx
|
|
||||||
break
|
|
||||||
}
|
|
||||||
currentIndex += msgInGroup.size + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetIndex != -1) {
|
|
||||||
listState.animateScrollToItem(targetIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val sendFile = { uri: Uri ->
|
val sendFile = { uri: Uri ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
// Read file on IO dispatcher
|
// Read file on IO dispatcher
|
||||||
@@ -227,13 +187,15 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(allEvents.size) {
|
LaunchedEffect(messages.size) {
|
||||||
if (displayMessages.isNotEmpty()) {
|
if (messages.isNotEmpty()) {
|
||||||
listState.animateScrollToItem(0)
|
listState.animateScrollToItem(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.blur(blurAmount),
|
modifier = Modifier.blur(blurAmount),
|
||||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
|
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
|
||||||
@@ -302,11 +264,11 @@ fun ChatScreen(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||||
) {
|
) {
|
||||||
if (requireScreening && settings.screening) {
|
if (requireScreening) {
|
||||||
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,31 +283,22 @@ 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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
|
||||||
.animateItem(),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
) {
|
) {
|
||||||
replyPreview?.let { previewEvent ->
|
replyPreview?.let { ReplyPreview(it, model.isMine) }
|
||||||
ReplyPreview(
|
|
||||||
event = previewEvent,
|
|
||||||
isMine = model.isMine,
|
|
||||||
onClick = { goToMessage(previewEvent.id()) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ChatMessage(
|
ChatMessage(
|
||||||
model = model,
|
model = model,
|
||||||
modifier = Modifier.graphicsLayer {
|
modifier = Modifier.graphicsLayer {
|
||||||
@@ -393,7 +346,7 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
when (requireScreening && settings.screening) {
|
when (requireScreening) {
|
||||||
true -> {
|
true -> {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -427,18 +380,15 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
AnimatedVisibility(visible = replyingTo != null) {
|
replyingTo?.let {
|
||||||
replyingTo?.let {
|
ReplyBox(it) { replyingTo = null }
|
||||||
ReplyBox(it) { replyingTo = null }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ChatInput(
|
ChatInput(
|
||||||
value = text,
|
value = text,
|
||||||
onValueChange = { text = it },
|
onValueChange = { text = it },
|
||||||
onSend = {
|
onSend = {
|
||||||
viewModel.sendMessage(text, replyingTo?.id)
|
viewModel.sendMessage(text)
|
||||||
text = ""
|
text = ""
|
||||||
replyingTo = null
|
|
||||||
},
|
},
|
||||||
onUpload = {
|
onUpload = {
|
||||||
fileLauncher.launch("image/*")
|
fileLauncher.launch("image/*")
|
||||||
@@ -470,6 +420,7 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = selectedMessage != null,
|
visible = selectedMessage != null,
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
@@ -478,11 +429,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 +445,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 +457,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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -600,11 +538,7 @@ private fun ReplyBox(model: MessageModel, onDismiss: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReplyPreview(
|
private fun ReplyPreview(event: UnsignedEvent, isMine: Boolean = false) {
|
||||||
event: UnsignedEvent,
|
|
||||||
isMine: Boolean = false,
|
|
||||||
onClick: () -> Unit
|
|
||||||
) {
|
|
||||||
val profileCache = LocalProfileCache.current
|
val profileCache = LocalProfileCache.current
|
||||||
val profileFlow = remember(event) { profileCache.getMetadata(event.author()) }
|
val profileFlow = remember(event) { profileCache.getMetadata(event.author()) }
|
||||||
val profile by profileFlow.collectAsStateWithLifecycle()
|
val profile by profileFlow.collectAsStateWithLifecycle()
|
||||||
@@ -620,9 +554,7 @@ private fun ReplyPreview(
|
|||||||
contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart
|
contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier.widthIn(max = 280.dp),
|
||||||
.widthIn(max = 280.dp)
|
|
||||||
.clickable(onClick = onClick),
|
|
||||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
shape = bubbleShape,
|
shape = bubbleShape,
|
||||||
) {
|
) {
|
||||||
@@ -630,13 +562,13 @@ private fun ReplyPreview(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = profile?.name ?: "Unknown",
|
text = profile?.name ?: "Unknown",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
fontWeight = FontWeight.SemiBold,
|
color = MaterialTheme.colorScheme.onTertiaryContainer.copy(
|
||||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
alpha = 0.6f
|
||||||
|
),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = event.content(),
|
text = event.content(),
|
||||||
@@ -649,41 +581,9 @@ private fun ReplyPreview(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ReactionToolbar(
|
|
||||||
onReaction: (String) -> Unit
|
|
||||||
) {
|
|
||||||
val reactionEmojis = listOf("👍", "❤️", "😂", "😮", "😢", "😡", "🎉")
|
|
||||||
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
||||||
shape = RoundedCornerShape(24.dp),
|
|
||||||
shadowElevation = 1.dp
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
reactionEmojis.forEach { emoji ->
|
|
||||||
Text(
|
|
||||||
text = emoji,
|
|
||||||
modifier = Modifier
|
|
||||||
.clickable { onReaction(emoji) }
|
|
||||||
.padding(4.dp),
|
|
||||||
fontSize = 24.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@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 +592,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
|
||||||
|
|||||||
@@ -1,365 +0,0 @@
|
|||||||
package su.reya.coop.screens.communities
|
|
||||||
|
|
||||||
import androidx.compose.foundation.combinedClickable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.ime
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.union
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.LoadingIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.ScaffoldDefaults
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import coop.composeapp.generated.resources.Res
|
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import org.jetbrains.compose.resources.painterResource
|
|
||||||
import rust.nostr.sdk.PublicKey
|
|
||||||
import su.reya.coop.LocalNavigator
|
|
||||||
import su.reya.coop.LocalProfileCache
|
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
|
||||||
import su.reya.coop.Profile
|
|
||||||
import su.reya.coop.concord.ConcordMessage
|
|
||||||
import su.reya.coop.dayLabel
|
|
||||||
import su.reya.coop.sanitizeName
|
|
||||||
import su.reya.coop.screens.chat.ChatInput
|
|
||||||
import su.reya.coop.screens.chat.DateSeparator
|
|
||||||
import su.reya.coop.screens.chat.MessageReactions
|
|
||||||
import su.reya.coop.screens.chat.ReactionToolbar
|
|
||||||
import su.reya.coop.shared.Avatar
|
|
||||||
import su.reya.coop.short
|
|
||||||
import su.reya.coop.timeLabel
|
|
||||||
import su.reya.coop.viewmodel.ChannelScreenViewModel
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One Channel's history, laid out Discord-style: every message carries its author, because a Channel
|
|
||||||
* has many speakers rather than the two a DM has.
|
|
||||||
*
|
|
||||||
* The list is reversed, exactly as `ChatScreen` is, so the newest message sits at the bottom without
|
|
||||||
* any scroll bookkeeping — the item order below is bottom-to-top, and the day separator is placed
|
|
||||||
* after its group for that reason.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun ChannelScreen(viewModel: ChannelScreenViewModel) {
|
|
||||||
val navigator = LocalNavigator.current
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
|
||||||
val listState = rememberLazyListState()
|
|
||||||
|
|
||||||
val channel by viewModel.channel.collectAsStateWithLifecycle()
|
|
||||||
val currentUser by viewModel.currentUser.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
var text by remember { mutableStateOf("") }
|
|
||||||
|
|
||||||
/** The message whose action row is open, if any. */
|
|
||||||
var activeMessageId by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
/** The message an Edit is being written for, if any. Its text lives in [text]. */
|
|
||||||
var editing by remember { mutableStateOf<ConcordMessage?>(null) }
|
|
||||||
|
|
||||||
val grouped by remember {
|
|
||||||
derivedStateOf {
|
|
||||||
viewModel.messages
|
|
||||||
.groupBy { it.dayLabel() }
|
|
||||||
.toList()
|
|
||||||
.reversed()
|
|
||||||
.map { (day, dayMessages) -> day to dayMessages.asReversed() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
text = "#${channel?.name ?: "channel"}",
|
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
if (channel?.hasKey == false) {
|
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
|
||||||
Text(
|
|
||||||
text = "No key",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.outline,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
),
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = { navigator.goBack() }) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
|
||||||
contentDescription = "Back"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
content = { innerPadding ->
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(top = innerPadding.calculateTopPadding()),
|
|
||||||
color = MaterialTheme.colorScheme.surface,
|
|
||||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
|
||||||
) {
|
|
||||||
if (viewModel.loading) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
LoadingIndicator()
|
|
||||||
}
|
|
||||||
} else if (viewModel.messages.isEmpty()) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "No messages yet",
|
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
),
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
// Nothing to add for a Channel we hold no key for: the bar at the
|
|
||||||
// bottom of this screen is already saying why it is empty.
|
|
||||||
if (channel?.hasKey != false) {
|
|
||||||
Text(
|
|
||||||
text = "Say something to start it off.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.outline,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
state = listState,
|
|
||||||
reverseLayout = true,
|
|
||||||
contentPadding = PaddingValues(vertical = 8.dp),
|
|
||||||
) {
|
|
||||||
grouped.forEach { (day, dayMessages) ->
|
|
||||||
items(dayMessages, key = { it.idHex }) { message ->
|
|
||||||
ChannelMessageRow(
|
|
||||||
message = message,
|
|
||||||
isMine = message.author == currentUser?.publicKey?.toHex(),
|
|
||||||
active = activeMessageId == message.idHex,
|
|
||||||
onDismissActions = { activeMessageId = null },
|
|
||||||
onLongPress = { activeMessageId = message.idHex },
|
|
||||||
onReaction = { emoji ->
|
|
||||||
viewModel.sendReaction(message, emoji)
|
|
||||||
activeMessageId = null
|
|
||||||
},
|
|
||||||
onEdit = {
|
|
||||||
editing = message
|
|
||||||
text = message.content
|
|
||||||
activeMessageId = null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
item(key = "day:$day") { DateSeparator(day) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A Private Channel we hold no key for is listable but not writable, so the input
|
|
||||||
// is replaced by the reason rather than left to fail on send.
|
|
||||||
if (channel?.hasKey == false) {
|
|
||||||
Text(
|
|
||||||
text = "This channel was not part of the invite, so this device has no key " +
|
|
||||||
"for it.",
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.outline,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
if (editing != null) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Editing a message",
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
TextButton(onClick = { editing = null; text = "" }) { Text("Cancel") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No attachments and no dictation in v1, so the input shows neither: a button
|
|
||||||
// that does nothing would be worse than its absence.
|
|
||||||
ChatInput(
|
|
||||||
value = text,
|
|
||||||
onValueChange = { text = it },
|
|
||||||
onSend = {
|
|
||||||
val target = editing
|
|
||||||
if (target == null) viewModel.sendMessage(text)
|
|
||||||
else viewModel.editMessage(target.idHex, text)
|
|
||||||
editing = null
|
|
||||||
text = ""
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ChannelMessageRow(
|
|
||||||
message: ConcordMessage,
|
|
||||||
isMine: Boolean,
|
|
||||||
active: Boolean,
|
|
||||||
onDismissActions: () -> Unit,
|
|
||||||
onLongPress: () -> Unit,
|
|
||||||
onReaction: (String) -> Unit,
|
|
||||||
onEdit: () -> Unit,
|
|
||||||
) {
|
|
||||||
val (pubkey, profile) = rememberAuthor(message.author)
|
|
||||||
val name = profile?.name?.sanitizeName()?.takeIf { it.isNotBlank() }
|
|
||||||
?: pubkey?.short()
|
|
||||||
?: message.author.take(8)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
|
||||||
) {
|
|
||||||
Avatar(picture = profile?.picture, description = name, size = 36.dp)
|
|
||||||
Spacer(modifier = Modifier.size(10.dp))
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.combinedClickable(
|
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
|
||||||
indication = null,
|
|
||||||
onClick = onDismissActions,
|
|
||||||
onLongClick = onLongPress,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
text = if (isMine) "$name (you)" else name,
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.weight(1f, fill = false),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.size(6.dp))
|
|
||||||
Text(
|
|
||||||
text = message.timeLabel(),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.outline,
|
|
||||||
)
|
|
||||||
if (message.edited) {
|
|
||||||
Spacer(modifier = Modifier.size(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "(edited)",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.outline,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = message.content,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
if (message.reactions.isNotEmpty()) {
|
|
||||||
MessageReactions(
|
|
||||||
emojis = message.reactions.map { it.emoji },
|
|
||||||
total = message.reactions.sumOf { it.authors.size },
|
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (active) {
|
|
||||||
Column(modifier = Modifier.padding(top = 8.dp)) {
|
|
||||||
ReactionToolbar(onReaction = onReaction)
|
|
||||||
// Only the author may edit, so only they are offered it.
|
|
||||||
if (isMine) {
|
|
||||||
TextButton(onClick = onEdit) { Text("Edit") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The profile behind a message's author, fetched the ordinary Nostr way — see `Avatar`. */
|
|
||||||
@Composable
|
|
||||||
private fun rememberAuthor(authorHex: String): Pair<PublicKey?, Profile?> {
|
|
||||||
val profileCache = LocalProfileCache.current
|
|
||||||
val pubkey = remember(authorHex) { runCatching { PublicKey.parse(authorHex) }.getOrNull() }
|
|
||||||
val profile by remember(pubkey) {
|
|
||||||
pubkey?.let { profileCache.getMetadata(it) } ?: flowOf<Profile?>(null)
|
|
||||||
}.collectAsStateWithLifecycle(null)
|
|
||||||
|
|
||||||
return pubkey to profile
|
|
||||||
}
|
|
||||||
-185
@@ -1,185 +0,0 @@
|
|||||||
package su.reya.coop.screens.communities
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.ListItemDefaults
|
|
||||||
import androidx.compose.material3.LoadingIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.PlainTooltip
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TooltipAnchorPosition
|
|
||||||
import androidx.compose.material3.TooltipBox
|
|
||||||
import androidx.compose.material3.TooltipDefaults
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.material3.rememberTooltipState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import coop.composeapp.generated.resources.Res
|
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
|
||||||
import coop.composeapp.generated.resources.ic_plus
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.jetbrains.compose.resources.painterResource
|
|
||||||
import su.reya.coop.LocalNavigator
|
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
|
||||||
import su.reya.coop.Screen
|
|
||||||
import su.reya.coop.displayName
|
|
||||||
import su.reya.coop.viewmodel.ConcordViewModel
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every Community this device has joined, plus any Direct Invite still waiting.
|
|
||||||
*
|
|
||||||
* There is no file upload, no message search and no per-Community settings page — v1 is the read
|
|
||||||
* path and sending, and the screen says so rather than pretending otherwise.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun CommunitiesScreen(concordViewModel: ConcordViewModel) {
|
|
||||||
val navigator = LocalNavigator.current
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
val communities by concordViewModel.communities.collectAsStateWithLifecycle()
|
|
||||||
val directInvites by concordViewModel.directInvites.collectAsStateWithLifecycle()
|
|
||||||
val isReady by concordViewModel.isReady.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
val sorted = remember(communities) { communities.sortedBy { it.displayName().lowercase() } }
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
text = "Communities",
|
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized
|
|
||||||
)
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
),
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = { navigator.goBack() }) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
|
||||||
contentDescription = "Back"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
floatingActionButton = {
|
|
||||||
TooltipBox(
|
|
||||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
|
|
||||||
TooltipAnchorPosition.Above,
|
|
||||||
spacingBetweenTooltipAndAnchor = 8.dp,
|
|
||||||
),
|
|
||||||
tooltip = {
|
|
||||||
PlainTooltip { Text("Join a community") }
|
|
||||||
},
|
|
||||||
state = rememberTooltipState(),
|
|
||||||
) {
|
|
||||||
ExtendedFloatingActionButton(
|
|
||||||
onClick = { navigator.navigate(Screen.JoinCommunity()) },
|
|
||||||
expanded = false,
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(Res.drawable.ic_plus),
|
|
||||||
contentDescription = "Join a community"
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = { Text("Join") },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
content = { innerPadding ->
|
|
||||||
when {
|
|
||||||
!isReady && sorted.isEmpty() -> {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
LoadingIndicator()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sorted.isEmpty() && directInvites.isEmpty() -> {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding)
|
|
||||||
) {
|
|
||||||
CommunityEmptyState(
|
|
||||||
title = "No communities yet",
|
|
||||||
subtitle = "Join one with an invite link.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(top = innerPadding.calculateTopPadding()),
|
|
||||||
contentPadding = PaddingValues(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
|
|
||||||
) {
|
|
||||||
items(directInvites, key = { it.communityId }) { invite ->
|
|
||||||
DirectInviteCard(
|
|
||||||
invite = invite,
|
|
||||||
onJoin = {
|
|
||||||
scope.launch {
|
|
||||||
// A Direct Invite already carries its bundle, so there is
|
|
||||||
// nothing to fetch — only to validate and accept.
|
|
||||||
val preview = concordViewModel.previewDirectInvite(invite)
|
|
||||||
val joined = concordViewModel.join(preview)
|
|
||||||
if (joined != null) {
|
|
||||||
navigator.navigate(
|
|
||||||
Screen.Community(joined.membership.communityId)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDismiss = {
|
|
||||||
concordViewModel.dismissDirectInvite(invite.communityId)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
items(sorted, key = { it.membership.communityId }) { state ->
|
|
||||||
CommunityRow(
|
|
||||||
state = state,
|
|
||||||
onClick = {
|
|
||||||
navigator.navigate(
|
|
||||||
Screen.Community(state.membership.communityId)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-254
@@ -1,254 +0,0 @@
|
|||||||
package su.reya.coop.screens.communities
|
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Badge
|
|
||||||
import androidx.compose.material3.BadgedBox
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.ListItemDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.SegmentedListItem
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import coop.composeapp.generated.resources.Res
|
|
||||||
import coop.composeapp.generated.resources.ic_communities
|
|
||||||
import coop.composeapp.generated.resources.ic_lock
|
|
||||||
import org.jetbrains.compose.resources.painterResource
|
|
||||||
import su.reya.coop.concord.CommunityInvite
|
|
||||||
import su.reya.coop.concord.CommunityState
|
|
||||||
import su.reya.coop.concord.ConcordChannel
|
|
||||||
import su.reya.coop.displayName
|
|
||||||
import su.reya.coop.shared.Avatar
|
|
||||||
import su.reya.coop.unreadTotal
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rows and small blocks shared by the Communities screens, mirroring `HomeScreen`'s `ChatRoom` and
|
|
||||||
* `ContactListItem`: `ListItem` for the top level, `SegmentedListItem` inside a group.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun CommunityRow(state: CommunityState, onClick: () -> Unit) {
|
|
||||||
val unread = state.unreadTotal()
|
|
||||||
|
|
||||||
ListItem(
|
|
||||||
modifier = Modifier.clickable(onClick = onClick),
|
|
||||||
leadingContent = {
|
|
||||||
BadgedBox(
|
|
||||||
badge = {
|
|
||||||
if (unread > 0) {
|
|
||||||
Badge { Text(unread.toString()) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
// Concord icons are encrypted blobs v1 never fetches, so this is always the placeholder.
|
|
||||||
Avatar(picture = null, description = state.displayName())
|
|
||||||
}
|
|
||||||
},
|
|
||||||
headlineContent = {
|
|
||||||
Text(
|
|
||||||
text = state.displayName(),
|
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized.copy(
|
|
||||||
fontWeight = if (unread > 0) FontWeight.SemiBold else FontWeight.Normal
|
|
||||||
),
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
supportingContent = {
|
|
||||||
Text(
|
|
||||||
text = channelCountLabel(state.channels.size),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.outline,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surface)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun ChannelRow(channel: ConcordChannel, index: Int, total: Int, onClick: () -> Unit) {
|
|
||||||
val unread = channel.unreadCount
|
|
||||||
|
|
||||||
SegmentedListItem(
|
|
||||||
onClick = onClick,
|
|
||||||
shapes = ListItemDefaults.segmentedShapes(index = index, count = total),
|
|
||||||
leadingContent = {
|
|
||||||
BadgedBox(
|
|
||||||
badge = {
|
|
||||||
if (unread > 0) {
|
|
||||||
Badge { Text(unread.toString()) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center) {
|
|
||||||
if (channel.private) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(Res.drawable.ic_lock),
|
|
||||||
contentDescription = "Private",
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.outline,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = "#",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.outline,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
content = {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
text = channel.name,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
style = MaterialTheme.typography.bodyLarge.copy(
|
|
||||||
fontWeight = if (unread > 0) FontWeight.SemiBold else FontWeight.Normal
|
|
||||||
),
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
// A Private Channel with no key is listable but unreadable: the row says so rather
|
|
||||||
// than opening an empty room and looking broken.
|
|
||||||
if (!channel.hasKey) {
|
|
||||||
Text(
|
|
||||||
text = "No key",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.outline,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A Direct Invite (CORD-05 §6) waiting to be accepted. It arrived giftwrapped to this npub, so it is
|
|
||||||
* shown on the Communities list rather than in any chat.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun DirectInviteCard(invite: CommunityInvite, onJoin: () -> Unit, onDismiss: () -> Unit) {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
|
||||||
Text(
|
|
||||||
text = "Invited to a community",
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.size(4.dp))
|
|
||||||
Text(
|
|
||||||
text = invite.name ?: "Untitled community",
|
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.size(2.dp))
|
|
||||||
Text(
|
|
||||||
text = shortCommunityId(invite.communityId),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
TextButton(onClick = onDismiss) { Text("Dismiss") }
|
|
||||||
Button(onClick = onJoin) { Text("Join") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The app's empty-state convention: two centred lines, with the feature's mark above them. */
|
|
||||||
@Composable
|
|
||||||
fun CommunityEmptyState(title: String, subtitle: String) {
|
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(Res.drawable.ic_communities),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(56.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.outlineVariant,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
),
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = subtitle,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.outline,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Says out loud what v1 does not do yet. Concord's authority is a signed roster every client folds
|
|
||||||
* (CORD-04), and this build folds none of it — a stale claim of moderation would be worse than none.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun BetaNotice() {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(Res.drawable.ic_lock),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.size(12.dp))
|
|
||||||
Text(
|
|
||||||
text = "Beta. Messages, channels and invites work. Roles, kicks and bans are not " +
|
|
||||||
"enforced by this build yet — only by the other clients in the community.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun channelCountLabel(count: Int): String =
|
|
||||||
if (count == 1) "1 channel" else "$count channels"
|
|
||||||
|
|
||||||
/** A Community's identity is a hash, so show enough of it to tell two apart. */
|
|
||||||
internal fun shortCommunityId(communityId: String): String =
|
|
||||||
if (communityId.length <= 16) communityId else communityId.take(16) + "..."
|
|
||||||
-171
@@ -1,171 +0,0 @@
|
|||||||
package su.reya.coop.screens.communities
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.ListItemDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import coop.composeapp.generated.resources.Res
|
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
|
||||||
import org.jetbrains.compose.resources.painterResource
|
|
||||||
import su.reya.coop.LocalNavigator
|
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
|
||||||
import su.reya.coop.Screen
|
|
||||||
import su.reya.coop.displayName
|
|
||||||
import su.reya.coop.viewmodel.ConcordViewModel
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One Community's Channels, split public and private the way the protocol splits them: a Public
|
|
||||||
* Channel's key derives from `community_root`, so anyone in the Community can read it, while a
|
|
||||||
* Private one is only listable to those an invite or a rekey handed a key to (CORD-03 §1).
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun CommunityScreen(communityId: String, concordViewModel: ConcordViewModel) {
|
|
||||||
val navigator = LocalNavigator.current
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
|
||||||
|
|
||||||
val communities by concordViewModel.communities.collectAsStateWithLifecycle()
|
|
||||||
val state = remember(communities, communityId) {
|
|
||||||
communities.firstOrNull { it.membership.communityId == communityId }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state == null) {
|
|
||||||
// Reachable if the Community was dropped underneath this screen (a logout while it was open),
|
|
||||||
// so it keeps a way back rather than dead-ending here.
|
|
||||||
Scaffold(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
text = "Community",
|
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized
|
|
||||||
)
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
),
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = { navigator.goBack() }) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
|
||||||
contentDescription = "Back"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { innerPadding ->
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "This community is not on this device.",
|
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val (privateChannels, publicChannels) = remember(state.channels) {
|
|
||||||
state.channels.partition { it.private }
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
text = state.displayName(),
|
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
),
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = { navigator.goBack() }) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
|
||||||
contentDescription = "Back"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
content = { innerPadding ->
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(top = innerPadding.calculateTopPadding()),
|
|
||||||
contentPadding = PaddingValues(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
|
|
||||||
) {
|
|
||||||
item { BetaNotice() }
|
|
||||||
|
|
||||||
itemsIndexed(publicChannels) { index, channel ->
|
|
||||||
ChannelRow(
|
|
||||||
channel = channel,
|
|
||||||
index = index,
|
|
||||||
total = publicChannels.size,
|
|
||||||
onClick = {
|
|
||||||
navigator.navigate(Screen.Channel(communityId, channel.idHex))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
itemsIndexed(privateChannels) { index, channel ->
|
|
||||||
ChannelRow(
|
|
||||||
channel = channel,
|
|
||||||
index = index,
|
|
||||||
total = privateChannels.size,
|
|
||||||
onClick = {
|
|
||||||
navigator.navigate(Screen.Channel(communityId, channel.idHex))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.channels.isEmpty()) {
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
text = "No channels yet. They arrive with the community's metadata.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.outline,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-237
@@ -1,237 +0,0 @@
|
|||||||
package su.reya.coop.screens.communities
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.LoadingIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import coop.composeapp.generated.resources.Res
|
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
|
||||||
import coop.composeapp.generated.resources.ic_scanner
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.jetbrains.compose.resources.painterResource
|
|
||||||
import su.reya.coop.LocalNavigator
|
|
||||||
import su.reya.coop.LocalScanResult
|
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
|
||||||
import su.reya.coop.Screen
|
|
||||||
import su.reya.coop.concord.InvitePreview
|
|
||||||
import su.reya.coop.viewmodel.ConcordViewModel
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Paste or scan an invite link, see what joining would mean, then join.
|
|
||||||
*
|
|
||||||
* The preview is deliberately a separate step: a link is fetched and decrypted, but nothing is
|
|
||||||
* subscribed and no presence is announced until the user accepts (CORD-05 §1), so backing out here
|
|
||||||
* leaves no trace on any relay.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun JoinCommunityScreen(concordViewModel: ConcordViewModel, initialLink: String? = null) {
|
|
||||||
val navigator = LocalNavigator.current
|
|
||||||
val qrScanResult = LocalScanResult.current
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
var link by remember(initialLink) { mutableStateOf(initialLink.orEmpty()) }
|
|
||||||
var preview by remember { mutableStateOf<InvitePreview?>(null) }
|
|
||||||
var isBusy by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
LaunchedEffect(qrScanResult.content) {
|
|
||||||
qrScanResult.content?.let { scanned ->
|
|
||||||
link = scanned
|
|
||||||
preview = null
|
|
||||||
qrScanResult.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
text = "Join a community",
|
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized
|
|
||||||
)
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
),
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = { navigator.goBack() }) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
|
||||||
contentDescription = "Back"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(Res.drawable.ic_scanner),
|
|
||||||
contentDescription = "Scanner"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
content = { innerPadding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding)
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = link,
|
|
||||||
onValueChange = {
|
|
||||||
link = it
|
|
||||||
// Any edit invalidates what was fetched for the old text.
|
|
||||||
preview = null
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
label = { Text("Invite link") },
|
|
||||||
placeholder = { Text("https://.../invite/...") },
|
|
||||||
maxLines = 4,
|
|
||||||
)
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
|
||||||
isBusy = true
|
|
||||||
preview = concordViewModel.previewInvite(link)
|
|
||||||
isBusy = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = link.isNotBlank() && !isBusy,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text("Look it up")
|
|
||||||
}
|
|
||||||
|
|
||||||
preview?.let { shown ->
|
|
||||||
InvitePreviewCard(
|
|
||||||
preview = shown,
|
|
||||||
isBusy = isBusy,
|
|
||||||
onJoin = {
|
|
||||||
scope.launch {
|
|
||||||
isBusy = true
|
|
||||||
val joined = concordViewModel.join(shown)
|
|
||||||
isBusy = false
|
|
||||||
if (joined != null) {
|
|
||||||
// Replace this screen with the Community it just joined.
|
|
||||||
navigator.goBack()
|
|
||||||
navigator.navigate(
|
|
||||||
Screen.Community(joined.membership.communityId)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
private fun InvitePreviewCard(preview: InvitePreview, isBusy: Boolean, onJoin: () -> Unit) {
|
|
||||||
val invite = preview.invite
|
|
||||||
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = invite.name ?: "Untitled community",
|
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = shortCommunityId(invite.communityId),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.size(4.dp))
|
|
||||||
Text(
|
|
||||||
text = plural(invite.channels.size, "channel"),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = plural(preview.relays.size, "relay"),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Everything that would stop this invite from being accepted, in plain English.
|
|
||||||
preview.problems.forEach { problem -> Refusal(problem) }
|
|
||||||
if (preview.expired) Refusal("This invite has expired.")
|
|
||||||
if (preview.alreadyJoined) {
|
|
||||||
Text(
|
|
||||||
text = "You are already in this community.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
|
||||||
Button(
|
|
||||||
onClick = onJoin,
|
|
||||||
enabled = preview.joinable && !isBusy,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
if (isBusy) {
|
|
||||||
LoadingIndicator(modifier = Modifier.size(20.dp))
|
|
||||||
} else {
|
|
||||||
Text(if (preview.alreadyJoined) "Rejoin" else "Join")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun Refusal(message: String) {
|
|
||||||
Text(
|
|
||||||
text = message,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun plural(count: Int, noun: String): String =
|
|
||||||
if (count == 1) "1 $noun" else "$count ${noun}s"
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package su.reya.coop.shared
|
package su.reya.coop.shared
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -14,9 +13,6 @@ import coil3.compose.AsyncImage
|
|||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.avatar
|
import coop.composeapp.generated.resources.avatar
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import su.reya.coop.LocalConnectivity
|
|
||||||
import su.reya.coop.LocalSettings
|
|
||||||
import su.reya.coop.MediaConfig
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Avatar(
|
fun Avatar(
|
||||||
@@ -26,36 +22,17 @@ fun Avatar(
|
|||||||
size: Dp = 48.dp,
|
size: Dp = 48.dp,
|
||||||
shape: Shape = CircleShape
|
shape: Shape = CircleShape
|
||||||
) {
|
) {
|
||||||
val settings = LocalSettings.current
|
|
||||||
val isMobileData = LocalConnectivity.current
|
|
||||||
val placeholder = painterResource(Res.drawable.avatar)
|
val placeholder = painterResource(Res.drawable.avatar)
|
||||||
|
|
||||||
val showMedia = when (settings.media) {
|
AsyncImage(
|
||||||
MediaConfig.AlwaysEnabled -> true
|
model = picture,
|
||||||
MediaConfig.Disabled -> false
|
contentDescription = description,
|
||||||
MediaConfig.DisabledForMobileData -> !isMobileData
|
modifier = modifier
|
||||||
}
|
.size(size)
|
||||||
|
.clip(shape),
|
||||||
if (showMedia) {
|
contentScale = ContentScale.Crop,
|
||||||
AsyncImage(
|
fallback = placeholder,
|
||||||
model = picture,
|
error = placeholder,
|
||||||
contentDescription = description,
|
placeholder = placeholder
|
||||||
modifier = modifier
|
)
|
||||||
.size(size)
|
|
||||||
.clip(shape),
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
fallback = placeholder,
|
|
||||||
error = placeholder,
|
|
||||||
placeholder = placeholder
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Image(
|
|
||||||
painter = placeholder,
|
|
||||||
contentDescription = description,
|
|
||||||
modifier = modifier
|
|
||||||
.size(size)
|
|
||||||
.clip(shape),
|
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
package su.reya.coop
|
|
||||||
|
|
||||||
import kotlinx.datetime.DateTimeUnit
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.minus
|
|
||||||
import kotlinx.datetime.number
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import su.reya.coop.concord.CommunityState
|
|
||||||
import su.reya.coop.concord.ConcordMessage
|
|
||||||
import kotlin.time.Clock
|
|
||||||
|
|
||||||
/** Control metadata first, then the snapshot the invite carried, then a last-resort label. */
|
|
||||||
fun CommunityState.displayName(): String {
|
|
||||||
val name = meta?.name?.sanitizeName()?.takeIf { it.isNotBlank() }
|
|
||||||
?: membership.name?.sanitizeName()?.takeIf { it.isNotBlank() }
|
|
||||||
return name ?: "Untitled community"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every Channel's badge added up, for the Community row. */
|
|
||||||
fun CommunityState.unreadTotal(): Int = channels.sumOf { it.unreadCount }
|
|
||||||
|
|
||||||
/** `HH:mm`, local time. */
|
|
||||||
fun ConcordMessage.timeLabel(): String {
|
|
||||||
val time = createdAt.toLocalDateTime(TimeZone.currentSystemDefault())
|
|
||||||
val hour = time.hour.toString().padStart(2, '0')
|
|
||||||
val minute = time.minute.toString().padStart(2, '0')
|
|
||||||
return "$hour:$minute"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `Today` / `Yesterday` / `DD/MM/YY`, matching [formatAsGroup] for DMs. */
|
|
||||||
fun ConcordMessage.dayLabel(): String {
|
|
||||||
val zone = TimeZone.currentSystemDefault()
|
|
||||||
val date = createdAt.toLocalDateTime(zone).date
|
|
||||||
val today = Clock.System.now().toLocalDateTime(zone).date
|
|
||||||
|
|
||||||
return when (date) {
|
|
||||||
today -> "Today"
|
|
||||||
today.minus(1, DateTimeUnit.DAY) -> "Yesterday"
|
|
||||||
else -> {
|
|
||||||
val day = date.day.toString().padStart(2, '0')
|
|
||||||
val month = date.month.number.toString().padStart(2, '0')
|
|
||||||
val year = date.year.toString().takeLast(2)
|
|
||||||
"$day/$month/$year"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package su.reya.coop
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
|
|
||||||
interface ConnectivityMonitor {
|
|
||||||
val isMobileData: StateFlow<Boolean>
|
|
||||||
}
|
|
||||||
@@ -30,8 +30,7 @@ data class Room(
|
|||||||
val subject: String?,
|
val subject: String?,
|
||||||
val members: Set<PublicKey>,
|
val members: Set<PublicKey>,
|
||||||
val kind: RoomKind = RoomKind.default(),
|
val kind: RoomKind = RoomKind.default(),
|
||||||
val lastMessage: String? = null,
|
val lastMessage: String? = null
|
||||||
val unreadCount: Int = 0
|
|
||||||
) : Comparable<Room> {
|
) : Comparable<Room> {
|
||||||
override fun compareTo(other: Room): Int {
|
override fun compareTo(other: Room): Int {
|
||||||
return this.createdAt.asSecs().compareTo(other.createdAt.asSecs())
|
return this.createdAt.asSecs().compareTo(other.createdAt.asSecs())
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
package su.reya.coop
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Settings(
|
|
||||||
val theme: Theme = Theme.System,
|
|
||||||
val dynamicColor: Boolean = true,
|
|
||||||
val media: MediaConfig = MediaConfig.AlwaysEnabled,
|
|
||||||
val screening: Boolean = true,
|
|
||||||
val blossomServer: String? = "https://blossom.band",
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
enum class Theme {
|
|
||||||
Light, Dark, System
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
enum class MediaConfig {
|
|
||||||
Disabled, DisabledForMobileData, AlwaysEnabled
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
package su.reya.coop.concord
|
|
||||||
|
|
||||||
import kotlin.io.encoding.Base64
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* §8's 32-byte values are unpadded base64url rather than hex — the Community List is the one
|
|
||||||
* encrypted document CORD-01 lets choose its own encoding. Padding is tolerated on read.
|
|
||||||
*/
|
|
||||||
private val listBase64 = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT_OPTIONAL)
|
|
||||||
|
|
||||||
/** One fragment's plaintext. `frags` and `seed` are read by writers, not here. */
|
|
||||||
@Serializable
|
|
||||||
internal data class CommunityListFragment(
|
|
||||||
val entries: List<CommunityListEntry> = emptyList(),
|
|
||||||
val tombstones: List<CommunityListTombstone> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
internal data class CommunityListEntry(
|
|
||||||
/** base64url, and case-significant: decoded, never case-folded. */
|
|
||||||
@SerialName("community_id") val communityId: String = "",
|
|
||||||
/** The freshest snapshot. `seed`, the backfill anchor, is not read here. */
|
|
||||||
val current: CommunityListSnapshot? = null,
|
|
||||||
/** Unix ms. Tiebreaks against the tombstone and between two entries. */
|
|
||||||
@SerialName("added_at") val addedAt: Long = 0L,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
internal data class CommunityListTombstone(
|
|
||||||
@SerialName("community_id") val communityId: String = "",
|
|
||||||
@SerialName("removed_at") val removedAt: Long = 0L,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The invite bundle's *membership* subset (CORD-05), in §8's base64url: never the icon, never the
|
|
||||||
* link fields.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
internal data class CommunityListSnapshot(
|
|
||||||
val owner: String = "",
|
|
||||||
@SerialName("owner_salt") val ownerSalt: String = "",
|
|
||||||
@SerialName("community_root") val communityRoot: String = "",
|
|
||||||
@SerialName("root_epoch") val rootEpoch: ULong = 0u,
|
|
||||||
@SerialName("control_pk") val controlPk: String? = null,
|
|
||||||
val channels: List<CommunityListChannel> = emptyList(),
|
|
||||||
val relays: List<String> = emptyList(),
|
|
||||||
val name: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
internal data class CommunityListChannel(
|
|
||||||
val id: String = "",
|
|
||||||
val key: String = "",
|
|
||||||
val epoch: ULong = 0u,
|
|
||||||
val name: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unions fragments into one membership per Community (CORD-02 §8).
|
|
||||||
*
|
|
||||||
* Only the merges a *reader* needs: the newest entry per Community wins, and a Community whose
|
|
||||||
* tombstone is at least as new as its entry reads as left. `seed`, the canonical-bytes tiebreak and
|
|
||||||
* repacks exist to keep two writers byte-identical — a client that never republishes cannot flap.
|
|
||||||
*/
|
|
||||||
internal fun List<CommunityListFragment>.listedMemberships(): List<Membership> {
|
|
||||||
val newest = mutableMapOf<String, Pair<Long, Membership>>()
|
|
||||||
|
|
||||||
for (entry in flatMap { it.entries }) {
|
|
||||||
val membership = entry.toMembership() ?: continue
|
|
||||||
val held = newest[membership.communityId]
|
|
||||||
if (held == null || entry.addedAt > held.first) {
|
|
||||||
newest[membership.communityId] = entry.addedAt to membership
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val removed = mutableMapOf<String, Long>()
|
|
||||||
for (tombstone in flatMap { it.tombstones }) {
|
|
||||||
val id = tombstone.communityId.listId() ?: continue
|
|
||||||
removed[id] = maxOf(removed[id] ?: 0L, tombstone.removedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
return newest
|
|
||||||
.filter { (id, entry) -> (removed[id] ?: 0L) < entry.first }
|
|
||||||
.values.map { it.second }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One entry as a [Membership], reusing the invite bundle's own self-certification. */
|
|
||||||
private fun CommunityListEntry.toMembership(): Membership? {
|
|
||||||
val id = communityId.listId() ?: return null
|
|
||||||
val invite = (current ?: return null).toInvite(id) ?: return null
|
|
||||||
if (invite.problems().isNotEmpty()) return null
|
|
||||||
return invite.toMembership(invite.relaySet())
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A snapshot normalised to the hex [CommunityInvite] the invite path already validates. */
|
|
||||||
private fun CommunityListSnapshot.toInvite(communityIdHex: String): CommunityInvite? {
|
|
||||||
val ownerHex = owner.listId() ?: return null
|
|
||||||
val saltHex = ownerSalt.listId() ?: return null
|
|
||||||
val rootHex = communityRoot.listId() ?: return null
|
|
||||||
|
|
||||||
// Strict: a malformed control key is a malformed entry, not a key to quietly drop null. Dropping
|
|
||||||
// it to null would leave the Control Plane unaddressable with nothing to show for it.
|
|
||||||
val controlHex = controlPk?.let { it.listId() ?: return null }
|
|
||||||
|
|
||||||
// Lenient by contrast: one unreadable Channel key costs that Channel, not the membership.
|
|
||||||
val granted = channels.mapNotNull { channel ->
|
|
||||||
val id = channel.id.listId() ?: return@mapNotNull null
|
|
||||||
val key = channel.key.listId() ?: return@mapNotNull null
|
|
||||||
InviteChannel(id = id, key = key, epoch = channel.epoch, name = channel.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
return CommunityInvite(
|
|
||||||
communityId = communityIdHex,
|
|
||||||
owner = ownerHex,
|
|
||||||
ownerSalt = saltHex,
|
|
||||||
communityRoot = rootHex,
|
|
||||||
rootEpoch = rootEpoch,
|
|
||||||
controlPk = controlHex,
|
|
||||||
channels = granted,
|
|
||||||
relays = relays,
|
|
||||||
name = name,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** §8's unpadded base64url for a 32-byte value, or null when it is not one. */
|
|
||||||
private fun String.listId(): String? =
|
|
||||||
runCatching { listBase64.decode(this) }.getOrNull()?.takeIf { it.size == 32 }?.toHex()
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
package su.reya.coop.concord
|
|
||||||
|
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
object ConcordControl {
|
|
||||||
fun editionOf(rumor: UnsignedEvent): ControlEdition? {
|
|
||||||
if (rumor.kind().asU16() != ConcordKind.CONTROL_EDITION.toUShort()) return null
|
|
||||||
|
|
||||||
val tags = rumor.tags().toVec()
|
|
||||||
val vsk = tags.firstOrNull { it.kind() == ConcordTag.VSK }?.content()?.toIntOrNull() ?: return null
|
|
||||||
if (vsk == ConcordVsk.DISSOLVED) return null
|
|
||||||
|
|
||||||
val eidHex = tags.firstOrNull { it.kind() == ConcordTag.EID }?.content() ?: return null
|
|
||||||
if (eidHex.hex32() == null) return null
|
|
||||||
|
|
||||||
val version = tags.firstOrNull { it.kind() == ConcordTag.EV }?.content()?.toULongOrNull() ?: return null
|
|
||||||
if (version == 0uL) return null
|
|
||||||
|
|
||||||
val prev = tags.firstOrNull { it.kind() == ConcordTag.EP }?.content()
|
|
||||||
if (prev != null && prev.hex32() == null) return null
|
|
||||||
|
|
||||||
return ControlEdition(
|
|
||||||
vsk = vsk,
|
|
||||||
eidHex = eidHex.lowercase(),
|
|
||||||
version = version,
|
|
||||||
prevHashHex = prev?.lowercase(),
|
|
||||||
content = rumor.content(),
|
|
||||||
actorHex = rumor.author().toHex(),
|
|
||||||
rumorIdHex = rumor.id()?.toHex() ?: return null,
|
|
||||||
createdAt = Instant.fromEpochSeconds(rumor.createdAt().asSecs().toLong()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun fold(editions: List<ControlEdition>, communityIdHex: String): ControlFold {
|
|
||||||
var meta: CommunityMeta? = null
|
|
||||||
val channels = mutableMapOf<String, ChannelMeta>()
|
|
||||||
|
|
||||||
editions.groupBy { it.vsk to it.eidHex }.forEach { (entity, group) ->
|
|
||||||
val (vsk, eidHex) = entity
|
|
||||||
if (vsk != ConcordVsk.METADATA && vsk != ConcordVsk.CHANNEL) return@forEach
|
|
||||||
// The metadata entity *is* the Community, so its coordinate must be the community_id;
|
|
||||||
// an edition naming anything else is not this Community's metadata.
|
|
||||||
if (vsk == ConcordVsk.METADATA && eidHex != communityIdHex) return@forEach
|
|
||||||
|
|
||||||
val winner = winner(group) ?: return@forEach
|
|
||||||
when (vsk) {
|
|
||||||
ConcordVsk.METADATA -> meta = decode(winner.content)
|
|
||||||
else -> decode<ChannelMeta>(winner.content)?.let { channels[eidHex] = it }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ControlFold(meta = meta, channels = channels)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun winner(group: List<ControlEdition>): ControlEdition? {
|
|
||||||
val byVersion = group
|
|
||||||
.groupBy { it.version }
|
|
||||||
.mapValues { (_, candidates) -> candidates.minByOrNull { it.rumorIdHex } ?: return null }
|
|
||||||
|
|
||||||
val eid = byVersion.values.firstOrNull()?.eidHex?.hex32() ?: return null
|
|
||||||
|
|
||||||
for (top in byVersion.keys.sortedDescending()) {
|
|
||||||
var prev: ByteArray? = null
|
|
||||||
var candidate: ControlEdition? = null
|
|
||||||
var intact = true
|
|
||||||
|
|
||||||
for (version in 1uL..top) {
|
|
||||||
val edition = byVersion[version]
|
|
||||||
if (edition == null) {
|
|
||||||
intact = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
// The first edition has no predecessor; every later one must chain to the last.
|
|
||||||
val expected = if (version == 1uL) null else prev?.toHex()
|
|
||||||
if (edition.prevHashHex != expected) {
|
|
||||||
intact = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
prev = editionHash(eid, version, prev, edition.content.encodeToByteArray())
|
|
||||||
candidate = edition
|
|
||||||
}
|
|
||||||
|
|
||||||
if (intact) return candidate
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun <reified T> decode(content: String): T? =
|
|
||||||
runCatching { concordJson.decodeFromString<T>(content) }.getOrNull()
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
package su.reya.coop.concord
|
|
||||||
|
|
||||||
import okio.Buffer
|
|
||||||
import okio.ByteString
|
|
||||||
import okio.ByteString.Companion.decodeHex
|
|
||||||
import okio.ByteString.Companion.toByteString
|
|
||||||
import rust.nostr.sdk.Keys
|
|
||||||
import rust.nostr.sdk.PublicKey
|
|
||||||
import rust.nostr.sdk.SecretKey
|
|
||||||
|
|
||||||
fun hkdfSha256(
|
|
||||||
ikm: ByteArray,
|
|
||||||
info: ByteArray,
|
|
||||||
salt: ByteArray = ByteArray(0),
|
|
||||||
length: Int = 32,
|
|
||||||
): ByteArray {
|
|
||||||
require(length in 1..255 * 32) { "HKDF output length must be 1..8160, was $length" }
|
|
||||||
|
|
||||||
val saltKey = if (salt.isEmpty()) ByteArray(32).toByteString() else salt.toByteString()
|
|
||||||
val prk = ikm.toByteString().hmacSha256(saltKey)
|
|
||||||
|
|
||||||
val out = Buffer()
|
|
||||||
var t = ByteString.EMPTY
|
|
||||||
var counter = 1
|
|
||||||
|
|
||||||
while (out.size < length) {
|
|
||||||
t = Buffer()
|
|
||||||
.write(t)
|
|
||||||
.write(info)
|
|
||||||
.writeByte(counter)
|
|
||||||
.readByteString()
|
|
||||||
.hmacSha256(prk)
|
|
||||||
out.write(t)
|
|
||||||
counter++
|
|
||||||
}
|
|
||||||
|
|
||||||
return out.readByteArray(length.toLong())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun hkdfInfo(label: String, id: ByteArray, epoch: ULong? = null): ByteArray {
|
|
||||||
require(id.size == 32) { "Concord HKDF id must be 32 bytes, was ${id.size}" }
|
|
||||||
return Buffer().apply {
|
|
||||||
writeUtf8(label)
|
|
||||||
writeByte(0)
|
|
||||||
write(id)
|
|
||||||
if (epoch != null) writeLong(epoch.toLong())
|
|
||||||
}.readByteArray()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A plane's derived keypair: `(sk, xonly(sk))` from CORD-02 A.2 `group_key`. */
|
|
||||||
data class GroupKey(val secretKey: SecretKey, val publicKey: PublicKey)
|
|
||||||
|
|
||||||
fun groupSeed(
|
|
||||||
label: String,
|
|
||||||
secret: ByteArray,
|
|
||||||
id: ByteArray,
|
|
||||||
epoch: ULong? = null,
|
|
||||||
isValid: (ByteArray) -> Boolean = ::isValidScalar,
|
|
||||||
): ByteArray {
|
|
||||||
val base = hkdfInfo(label, id, epoch)
|
|
||||||
var counter = -1
|
|
||||||
|
|
||||||
while (counter <= 255) {
|
|
||||||
val info = if (counter < 0) base else base + byteArrayOf(counter.toByte())
|
|
||||||
val seed = hkdfSha256(secret, info)
|
|
||||||
if (isValid(seed)) return seed
|
|
||||||
counter++
|
|
||||||
}
|
|
||||||
|
|
||||||
error("Concord group_key: scalar_normalize exhausted for label $label")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isValidScalar(seed: ByteArray): Boolean {
|
|
||||||
if (seed.size != 32) return false
|
|
||||||
var anyNonZero = false
|
|
||||||
|
|
||||||
for (byte in seed) {
|
|
||||||
if (byte != 0.toByte()) {
|
|
||||||
anyNonZero = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!anyNonZero) return false
|
|
||||||
for (i in 0 until 32) {
|
|
||||||
val candidate = seed[i].toInt() and 0xff
|
|
||||||
val order = SECP256K1_ORDER[i].toInt() and 0xff
|
|
||||||
if (candidate != order) return candidate < order
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private val SECP256K1_ORDER =
|
|
||||||
"fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141".hexToBytes()
|
|
||||||
|
|
||||||
fun groupKey(label: String, secret: ByteArray, id: ByteArray, epoch: ULong? = null): GroupKey {
|
|
||||||
val secretKey = SecretKey.fromBytes(groupSeed(label, secret, id, epoch))
|
|
||||||
return GroupKey(secretKey, Keys(secretKey).publicKey())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun communityId(ownerXonly: ByteArray, ownerSalt: ByteArray): ByteArray {
|
|
||||||
require(ownerXonly.size == 32) { "owner_xonly must be 32 bytes, was ${ownerXonly.size}" }
|
|
||||||
require(ownerSalt.size == 32) { "owner_salt must be 32 bytes, was ${ownerSalt.size}" }
|
|
||||||
return Buffer().apply {
|
|
||||||
writeUtf8(ConcordLabel.COMMUNITY)
|
|
||||||
write(ownerXonly)
|
|
||||||
write(ownerSalt)
|
|
||||||
}.readByteString().sha256().toByteArray()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun prevCommit(prevEpoch: ULong, prevKey: ByteArray): ByteArray {
|
|
||||||
require(prevKey.size == 32) { "prev_key must be 32 bytes, was ${prevKey.size}" }
|
|
||||||
return Buffer().apply {
|
|
||||||
writeUtf8(ConcordLabel.EPOCH_KEY_COMMITMENT)
|
|
||||||
writeLong(prevEpoch.toLong())
|
|
||||||
write(prevKey)
|
|
||||||
}.readByteString().sha256().toByteArray()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun editionHash(entityId: ByteArray, version: ULong, prev: ByteArray?, content: ByteArray): ByteArray {
|
|
||||||
require(entityId.size == 32) { "entity_id must be 32 bytes, was ${entityId.size}" }
|
|
||||||
require(prev == null || prev.size == 32) { "prev must be 32 bytes" }
|
|
||||||
val label = ConcordLabel.EDITION_HASH.encodeToByteArray()
|
|
||||||
return Buffer().apply {
|
|
||||||
writeLong(label.size.toLong())
|
|
||||||
write(label)
|
|
||||||
write(entityId)
|
|
||||||
writeLong(version.toLong())
|
|
||||||
if (prev != null) {
|
|
||||||
writeByte(1)
|
|
||||||
write(prev)
|
|
||||||
} else {
|
|
||||||
writeByte(0)
|
|
||||||
write(ByteArray(32))
|
|
||||||
}
|
|
||||||
writeLong(content.size.toLong())
|
|
||||||
write(content)
|
|
||||||
}.readByteString().sha256().toByteArray()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 64 lowercase hex chars, the encoding CORD-01 mandates for every 32-byte value on the wire. */
|
|
||||||
internal fun ByteArray.toHex(): String = toByteString().hex()
|
|
||||||
|
|
||||||
/** Inverse of [toHex]. Throws on odd length or a non-hex character. */
|
|
||||||
internal fun String.hexToBytes(): ByteArray = decodeHex().toByteArray()
|
|
||||||
|
|
||||||
/** Inverse of [toHex], returning null instead of throwing — for reading untrusted input. */
|
|
||||||
internal fun String.hexToBytesOrNull(): ByteArray? = runCatching { hexToBytes() }.getOrNull()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A 32-byte value read from hex, or null when it is not exactly 32 bytes.
|
|
||||||
*
|
|
||||||
* The length check is the point: most of Concord's ids and keys are 32 bytes, and a short or long
|
|
||||||
* value must be rejected before it reaches a derivation that assumes a fixed width.
|
|
||||||
*/
|
|
||||||
internal fun String.hex32(): ByteArray? = if (length == 64) hexToBytesOrNull() else null
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
package su.reya.coop.concord
|
|
||||||
|
|
||||||
import kotlin.io.encoding.Base64
|
|
||||||
import rust.nostr.sdk.Nip19Coordinate
|
|
||||||
import rust.nostr.sdk.RelayUrl
|
|
||||||
import rust.nostr.sdk.nip44Decrypt
|
|
||||||
|
|
||||||
/** The stock relay dictionary (CORD-05 §3). Referenced by one byte so links stay short. */
|
|
||||||
internal object ConcordRelayDictionary {
|
|
||||||
val STOCK = listOf(
|
|
||||||
"wss://jskitty.com/nostr",
|
|
||||||
"wss://asia.vectorapp.io/nostr",
|
|
||||||
"wss://relay.ditto.pub",
|
|
||||||
"wss://relay.dreamith.to",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The link's secret half: `[version][flags][relays?][token:16]`, base64url without padding. */
|
|
||||||
data class InviteFragment(
|
|
||||||
val version: Int,
|
|
||||||
val relays: List<String>,
|
|
||||||
/** The 16-byte unlock token, hex. Derives exactly one thing: the bundle key. */
|
|
||||||
val tokenHex: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** A parsed invite link: the public locator plus the secret fragment. */
|
|
||||||
data class ParsedInvite(
|
|
||||||
val signerHex: String,
|
|
||||||
val identifier: String,
|
|
||||||
/** Relays the naddr itself names. Usually empty; the compact form travels in the fragment. */
|
|
||||||
val naddrRelays: List<RelayUrl>,
|
|
||||||
val fragment: InviteFragment,
|
|
||||||
)
|
|
||||||
|
|
||||||
private const val FRAGMENT_VERSION = 4
|
|
||||||
|
|
||||||
/** The one flag v1 knows: the stock set is in use, so zero relay bytes follow. */
|
|
||||||
private const val FLAG_STOCK_RELAYS = 1
|
|
||||||
private const val TOKEN_BYTES = 16
|
|
||||||
|
|
||||||
/** CORD-05 §3: the fragment only needs to *find* the bundle; the bundle carries the real set. */
|
|
||||||
private const val MAX_BOOTSTRAP_RELAYS = 3
|
|
||||||
|
|
||||||
/** CORD-05 §1: the spec's reference ceiling on a bundle's Channel list. */
|
|
||||||
private const val MAX_INVITE_CHANNELS = 256
|
|
||||||
|
|
||||||
/** CORD-02 §6: five stable relays is the recommendation, not a rule; a client may truncate. */
|
|
||||||
private const val MAX_COMMUNITY_RELAYS = 5
|
|
||||||
|
|
||||||
/** The fragment is unpadded base64url; ABSENT_OPTIONAL also tolerates a padded paste. */
|
|
||||||
private val fragmentBase64 = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT_OPTIONAL)
|
|
||||||
|
|
||||||
fun parseInviteLink(text: String): ParsedInvite? {
|
|
||||||
val trimmed = text.trim()
|
|
||||||
val hash = trimmed.indexOf('#')
|
|
||||||
if (hash <= 0 || hash == trimmed.lastIndex) return null
|
|
||||||
|
|
||||||
val naddr = trimmed.substring(0, hash).substringAfterLast('/')
|
|
||||||
val fragment = decodeInviteFragment(trimmed.substring(hash + 1)) ?: return null
|
|
||||||
|
|
||||||
val coordinate = runCatching { Nip19Coordinate.fromBech32(naddr) }.getOrNull() ?: return null
|
|
||||||
if (coordinate.coordinate().kind().asU16() != ConcordKind.INVITE_BUNDLE.toUShort()) return null
|
|
||||||
|
|
||||||
return ParsedInvite(
|
|
||||||
signerHex = coordinate.coordinate().publicKey().toHex(),
|
|
||||||
identifier = coordinate.coordinate().identifier(),
|
|
||||||
naddrRelays = coordinate.relays(),
|
|
||||||
fragment = fragment,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun decodeInviteFragment(fragment: String): InviteFragment? {
|
|
||||||
val bytes = runCatching { fragmentBase64.decode(fragment) }.getOrNull() ?: return null
|
|
||||||
// Two header bytes plus the token at minimum.
|
|
||||||
if (bytes.size < 2 + TOKEN_BYTES) return null
|
|
||||||
|
|
||||||
val reader = FragmentReader(bytes)
|
|
||||||
if (reader.byte() != FRAGMENT_VERSION) return null
|
|
||||||
|
|
||||||
val flags = reader.byte() ?: return null
|
|
||||||
|
|
||||||
val relays = if (flags and FLAG_STOCK_RELAYS != 0) {
|
|
||||||
ConcordRelayDictionary.STOCK
|
|
||||||
} else {
|
|
||||||
val count = reader.byte() ?: return null
|
|
||||||
buildList {
|
|
||||||
repeat(count) {
|
|
||||||
val lead = reader.byte() ?: return null
|
|
||||||
val relay = when (lead) {
|
|
||||||
0 -> reader.slice(reader.byte() ?: return null)?.let { "wss://$it" }
|
|
||||||
255 -> reader.slice(reader.byte() ?: return null)
|
|
||||||
else -> ConcordRelayDictionary.STOCK.getOrNull(lead - 1)
|
|
||||||
} ?: return null
|
|
||||||
add(relay)
|
|
||||||
}
|
|
||||||
}.take(MAX_BOOTSTRAP_RELAYS)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Whatever is left must be exactly the token: a short one silently weakens the link.
|
|
||||||
val token = bytes.copyOfRange(reader.index, bytes.size)
|
|
||||||
if (token.size != TOKEN_BYTES) return null
|
|
||||||
|
|
||||||
return InviteFragment(
|
|
||||||
version = FRAGMENT_VERSION,
|
|
||||||
relays = relays,
|
|
||||||
tokenHex = token.toHex(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun inviteBundleKey(tokenHex: String): GroupKey? =
|
|
||||||
tokenHex.hexToBytesOrNull()?.let { groupKey(ConcordLabel.INVITE_KEY, it, ByteArray(32)) }
|
|
||||||
|
|
||||||
fun decryptInviteBundle(content: String, key: GroupKey): CommunityInvite? {
|
|
||||||
val plaintext = runCatching { nip44Decrypt(key.secretKey, key.publicKey, content) }.getOrNull()
|
|
||||||
?: return null
|
|
||||||
return runCatching { concordJson.decodeFromString<CommunityInvite>(plaintext) }.getOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isInviteTombstone(vsk: String?): Boolean = vsk == ConcordVsk.INVITE_TOMBSTONE.toString()
|
|
||||||
|
|
||||||
fun CommunityInvite.problems(): List<String> {
|
|
||||||
val problems = mutableListOf<String>()
|
|
||||||
|
|
||||||
val ownerBytes = owner.hex32()
|
|
||||||
val saltBytes = ownerSalt.hex32()
|
|
||||||
val rootBytes = communityRoot.hex32()
|
|
||||||
|
|
||||||
if (ownerBytes == null) problems += "The invite's owner key is malformed"
|
|
||||||
if (saltBytes == null) problems += "The invite's owner salt is malformed"
|
|
||||||
if (rootBytes == null) problems += "The invite's community key is malformed"
|
|
||||||
|
|
||||||
if (ownerBytes != null && saltBytes != null) {
|
|
||||||
if (communityId(ownerBytes, saltBytes).toHex() != communityId.lowercase()) {
|
|
||||||
problems += "The invite does not prove its community id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (communityId.hex32() == null) problems += "The invite's community id is malformed"
|
|
||||||
if (controlPk != null && controlPk.hex32() == null) problems += "The invite's control key is malformed"
|
|
||||||
|
|
||||||
if (channels.size > MAX_INVITE_CHANNELS) {
|
|
||||||
problems += "The invite carries ${channels.size} channels (max $MAX_INVITE_CHANNELS)"
|
|
||||||
}
|
|
||||||
|
|
||||||
channels.forEachIndexed { index, channel ->
|
|
||||||
if (channel.id.hex32() == null) problems += "Channel $index has a malformed id"
|
|
||||||
if (channel.key.hex32() == null) problems += "Channel $index has a malformed key"
|
|
||||||
}
|
|
||||||
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
fun CommunityInvite.isExpired(nowMs: Long): Boolean = expiresAt != null && expiresAt <= nowMs
|
|
||||||
|
|
||||||
fun CommunityInvite.relaySet(fallback: List<String> = emptyList()): List<String> {
|
|
||||||
val source = relays.ifEmpty { fallback }
|
|
||||||
return source.map { it.trim() }.filter { it.isNotEmpty() }.distinct().take(MAX_COMMUNITY_RELAYS)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun CommunityInvite.toMembership(relays: List<String>): Membership = Membership(
|
|
||||||
communityId = communityId.lowercase(),
|
|
||||||
owner = owner.lowercase(),
|
|
||||||
ownerSalt = ownerSalt.lowercase(),
|
|
||||||
communityRoot = communityRoot.lowercase(),
|
|
||||||
rootEpoch = rootEpoch,
|
|
||||||
controlPk = controlPk?.lowercase(),
|
|
||||||
controlRoot = null,
|
|
||||||
relays = relays,
|
|
||||||
name = name,
|
|
||||||
channels = channels.map { channel ->
|
|
||||||
StoredChannelKey(
|
|
||||||
id = channel.id.lowercase(),
|
|
||||||
key = channel.key.lowercase(),
|
|
||||||
epoch = channel.epoch,
|
|
||||||
name = channel.name,
|
|
||||||
private = !channel.key.equals(communityRoot, ignoreCase = true),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
private class FragmentReader(private val bytes: ByteArray) {
|
|
||||||
var index = 0
|
|
||||||
private set
|
|
||||||
|
|
||||||
fun byte(): Int? = if (index < bytes.size) bytes[index++].toInt() and 0xff else null
|
|
||||||
|
|
||||||
fun slice(length: Int): String? {
|
|
||||||
if (length < 0 || index + length > bytes.size) return null
|
|
||||||
val value = bytes.decodeToString(index, index + length)
|
|
||||||
index += length
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
package su.reya.coop.concord
|
|
||||||
|
|
||||||
object ConcordKind {
|
|
||||||
/** NIP-59 gift wrap. Concord reuses it but reverses the roles: fixed author, ephemeral `p`. */
|
|
||||||
const val WRAP = 1059
|
|
||||||
|
|
||||||
/** Ephemeral wrap for kinds that must never be stored (typing, voice presence). */
|
|
||||||
const val EPHEMERAL_WRAP = 21059
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sealed content for everything that must stay secret: Chat, Guestbook, rekey blobs
|
|
||||||
* and Direct Invites. CORD-02 §5 makes this choice normative, never stylistic.
|
|
||||||
*/
|
|
||||||
const val SEAL = 20013
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Plaintext seal. **Control plane only** (CORD-02 §5) — the roster is public by design
|
|
||||||
* so that membership, and therefore authority, is verifiable by anyone.
|
|
||||||
*/
|
|
||||||
const val PLAINTEXT_SEAL = 20014
|
|
||||||
|
|
||||||
/** Channel message (NIP-C7 shape). Chat plane. */
|
|
||||||
const val MESSAGE = 9
|
|
||||||
|
|
||||||
/** Threaded reply (NIP-22 shape). Chat plane. */
|
|
||||||
const val REPLY = 1111
|
|
||||||
|
|
||||||
/** Reaction (NIP-25 shape). Chat plane. */
|
|
||||||
const val REACTION = 7
|
|
||||||
|
|
||||||
/** Delete (NIP-09 shape). Chat plane. */
|
|
||||||
const val DELETE = 5
|
|
||||||
|
|
||||||
/** Disappearing-message timer notice (CORD-08 §4). Chat plane. */
|
|
||||||
const val TIMER_NOTICE = 1740
|
|
||||||
|
|
||||||
/** Edit. Chat plane. */
|
|
||||||
const val EDIT = 3302
|
|
||||||
|
|
||||||
/** Rekey blobs (CORD-06). Rekey addresses. */
|
|
||||||
const val REKEY = 3303
|
|
||||||
|
|
||||||
/** Join / Leave. Guestbook plane. */
|
|
||||||
const val JOIN_LEAVE = 3306
|
|
||||||
|
|
||||||
/** Control edition — sub-kinded by [ConcordVsk]. Control plane. */
|
|
||||||
const val CONTROL_EDITION = 3308
|
|
||||||
|
|
||||||
/** Kick. Guestbook plane. */
|
|
||||||
const val KICK = 3309
|
|
||||||
|
|
||||||
/** WebXDC peer signal. Chat plane. */
|
|
||||||
const val WEBXDC_SIGNAL = 3310
|
|
||||||
|
|
||||||
/** Guestbook snapshot, chunked, refounder-signed. Guestbook plane. */
|
|
||||||
const val GUESTBOOK_SNAPSHOT = 3312
|
|
||||||
|
|
||||||
/** Typing indicator. Chat plane, ephemeral. */
|
|
||||||
const val TYPING = 23311
|
|
||||||
|
|
||||||
/** Voice presence (CORD-07). Chat plane, ephemeral. */
|
|
||||||
const val VOICE_PRESENCE = 23313
|
|
||||||
|
|
||||||
/** Direct Invite — giftwrapped straight to an npub. Rides a standard wrap, not a stream. */
|
|
||||||
const val DIRECT_INVITE = 3313
|
|
||||||
|
|
||||||
/** Public invite bundle. Addressable, signed by its per-link keypair at an empty `d`. */
|
|
||||||
const val INVITE_BUNDLE = 33301
|
|
||||||
|
|
||||||
/** Community List — one addressable event per fragment, NIP-44 to self. */
|
|
||||||
const val COMMUNITY_LIST = 33302
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The single-event Community List [COMMUNITY_LIST] superseded. Retired and never written;
|
|
||||||
* queried only so "no List exists" can be told apart from "a client still speaks the old shape".
|
|
||||||
*/
|
|
||||||
const val RETIRED_COMMUNITY_LIST = 13302
|
|
||||||
|
|
||||||
/** Invite List — replaceable, NIP-44 to self. */
|
|
||||||
const val INVITE_LIST = 13303
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Control edition sub-kinds, the `vsk` tag on [ConcordKind.CONTROL_EDITION] (CORD-02 Appendix A). */
|
|
||||||
object ConcordVsk {
|
|
||||||
/** Community metadata. */
|
|
||||||
const val METADATA = 0
|
|
||||||
|
|
||||||
/** Role. */
|
|
||||||
const val ROLE = 1
|
|
||||||
|
|
||||||
/** Channel metadata — the only other edition v1 folds. */
|
|
||||||
const val CHANNEL = 2
|
|
||||||
|
|
||||||
/** Grant. */
|
|
||||||
const val GRANT = 3
|
|
||||||
|
|
||||||
/** Banlist. */
|
|
||||||
const val BANLIST = 4
|
|
||||||
|
|
||||||
/** Reserved for role ordering. */
|
|
||||||
const val RESERVED_ROLE_ORDER = 5
|
|
||||||
|
|
||||||
/** Claimed by the invite bundle's live marker. */
|
|
||||||
const val INVITE_LIVE = 6
|
|
||||||
|
|
||||||
/** Retired (was the v1 owner attestation). */
|
|
||||||
const val RETIRED_OWNER_ATTESTATION = 7
|
|
||||||
|
|
||||||
/** Invite-link registry. */
|
|
||||||
const val INVITE_REGISTRY = 8
|
|
||||||
|
|
||||||
/** Claimed by the invite bundle's revocation tombstone. */
|
|
||||||
const val INVITE_TOMBSTONE = 9
|
|
||||||
|
|
||||||
/** Dissolved tombstone — chainless, exempt from version discipline. */
|
|
||||||
const val DISSOLVED = 10
|
|
||||||
|
|
||||||
/** Pin List. */
|
|
||||||
const val PIN_LIST = 11
|
|
||||||
}
|
|
||||||
|
|
||||||
object ConcordPermission {
|
|
||||||
const val MANAGE_ROLES = 1 shl 0
|
|
||||||
const val MANAGE_CHANNELS = 1 shl 1
|
|
||||||
const val MANAGE_METADATA = 1 shl 2
|
|
||||||
const val KICK = 1 shl 3
|
|
||||||
const val BAN = 1 shl 4
|
|
||||||
const val MANAGE_MESSAGES = 1 shl 5
|
|
||||||
const val CREATE_INVITE = 1 shl 6
|
|
||||||
|
|
||||||
/** Retired (was `MANAGE_INVITES`). Never grant it. */
|
|
||||||
const val RETIRED_MANAGE_INVITES = 1 shl 7
|
|
||||||
|
|
||||||
const val VIEW_AUDIT_LOG = 1 shl 8
|
|
||||||
const val MENTION_EVERYONE = 1 shl 9
|
|
||||||
|
|
||||||
const val PIN_MESSAGES = 1 shl 11
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Staff = anyone holding a staff bit, plus the owner. Staff are the ones who hold
|
|
||||||
* `control_root` and can therefore write to the Control plane.
|
|
||||||
*/
|
|
||||||
const val STAFF = MANAGE_ROLES or MANAGE_CHANNELS or MANAGE_METADATA or
|
|
||||||
BAN or CREATE_INVITE or PIN_MESSAGES
|
|
||||||
}
|
|
||||||
|
|
||||||
object ConcordLabel {
|
|
||||||
/** A Channel's group key. `secret` = channel key, or `community_root` for a public channel. */
|
|
||||||
const val CHANNEL = "concord/channel"
|
|
||||||
|
|
||||||
/** Control Plane **read** key. `secret` = `community_root`. */
|
|
||||||
const val CONTROL = "concord/control"
|
|
||||||
|
|
||||||
/** Control Plane signer, held by staff only. `secret` = `control_root`. */
|
|
||||||
const val CONTROL_SIGNER = "concord/control-signer"
|
|
||||||
|
|
||||||
/** Guestbook Plane group key. `secret` = `community_root`. */
|
|
||||||
const val GUESTBOOK = "concord/guestbook"
|
|
||||||
|
|
||||||
/** Dissolution tombstone address. `secret` = `community_id`, id = 32 zero bytes, no epoch. */
|
|
||||||
const val DISSOLVED = "concord/dissolved"
|
|
||||||
|
|
||||||
/** A channel rekey address. `secret` = the prior `community_root`. */
|
|
||||||
const val REKEY_PSEUDONYM = "concord/rekey-pseudonym"
|
|
||||||
|
|
||||||
/** A base (community-wide) rekey address. `secret` = the prior `community_root`. */
|
|
||||||
const val BASE_REKEY_PSEUDONYM = "concord/base-rekey-pseudonym"
|
|
||||||
|
|
||||||
/** A rekey blob locator. `secret` = `rotator_xonly ‖ recipient_xonly`. */
|
|
||||||
const val RECIPIENT_PSEUDONYM = "concord/recipient-pseudonym"
|
|
||||||
|
|
||||||
/** SFU room keypair (CORD-07). */
|
|
||||||
const val VOICE_SIGNER = "concord/voice-signer"
|
|
||||||
|
|
||||||
/** 32-byte call media key (CORD-07). */
|
|
||||||
const val VOICE_MEDIA = "concord/voice-media"
|
|
||||||
|
|
||||||
/** Per-sender frame key (CORD-07). id = `sha256(identity)`, no epoch. */
|
|
||||||
const val VOICE_SENDER = "concord/voice-sender"
|
|
||||||
|
|
||||||
/** A member's Grant coordinate. no epoch. */
|
|
||||||
const val GRANT = "concord/grant"
|
|
||||||
|
|
||||||
/** Banlist coordinate. no epoch. */
|
|
||||||
const val BANLIST = "concord/banlist"
|
|
||||||
|
|
||||||
/** A Channel's Pin List coordinate. no epoch. */
|
|
||||||
const val PINS = "concord/pins"
|
|
||||||
|
|
||||||
/** A creator's invite-link registry coordinate. no epoch. */
|
|
||||||
const val INVITE_LINKS = "concord/invite-links"
|
|
||||||
|
|
||||||
/** Public-invite decrypt key. `secret` = the link's unlock token, no epoch. */
|
|
||||||
const val INVITE_KEY = "concord/invite-key"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prefix for the `community_id` commitment (CORD-02 A.4). Not an HKDF label — it is
|
|
||||||
* hashed directly, with no `0x00` separator and no length prefix.
|
|
||||||
*/
|
|
||||||
const val COMMUNITY = "concord/community"
|
|
||||||
|
|
||||||
/** Prefix for `prevcommit` (CORD-02 A.8). Also hashed directly, not through HKDF. */
|
|
||||||
const val EPOCH_KEY_COMMITMENT = "concord/epoch-key-commitment"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Label for `edition_hash` (CORD-02 A.8).
|
|
||||||
*
|
|
||||||
* Deliberately **not** under the `concord/` prefix: the spec pins the reference
|
|
||||||
* implementation's own `vector` label here. Reproduce it exactly.
|
|
||||||
*/
|
|
||||||
const val EDITION_HASH = "vector-community/v1/edition"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tag names (CORD-01, CORD-02 §5 and the per-kind sections). */
|
|
||||||
object ConcordTag {
|
|
||||||
/** `["ms", "<0..999>"]` — sub-second ordering. True time = `created_at * 1000 + ms`. */
|
|
||||||
const val MS = "ms"
|
|
||||||
|
|
||||||
/** `["p", "<ephemeral pubkey>"]` — the one tag a stream wrap carries (NIP-59 reversed). */
|
|
||||||
const val P = "p"
|
|
||||||
|
|
||||||
/** `["channel", "<channel_id>"]` — MUST also be committed inside the author-signed rumor. */
|
|
||||||
const val CHANNEL = "channel"
|
|
||||||
|
|
||||||
/** `["epoch", "<n>"]` — MUST also be committed inside the author-signed rumor. */
|
|
||||||
const val EPOCH = "epoch"
|
|
||||||
|
|
||||||
/** `["q", "<rumor id>", "", "<author>"]` — NIP-C7 inline quote. */
|
|
||||||
const val QUOTE = "q"
|
|
||||||
|
|
||||||
/** `["e", "<rumor id>"]` — the message a reaction, edit or delete targets (CORD-03 §2.3–2.5). */
|
|
||||||
const val E = "e"
|
|
||||||
|
|
||||||
/** `["vsk", "<n>"]` — Control edition entity type. */
|
|
||||||
const val VSK = "vsk"
|
|
||||||
|
|
||||||
/** `["eid", "<hex32>"]` — stable coordinate of a Control edition's entity. */
|
|
||||||
const val EID = "eid"
|
|
||||||
|
|
||||||
/** `["ev", "<n>"]` — this edition's version, climbing from 1. */
|
|
||||||
const val EV = "ev"
|
|
||||||
|
|
||||||
/** `["ep", "<hash hex>"]` — previous edition hash. Absent on the first edition. */
|
|
||||||
const val EP = "ep"
|
|
||||||
|
|
||||||
/** `["vac", "<grant eid>", "<version>", "<hash>"]` — authority citation. Absent when the owner acts. */
|
|
||||||
const val VAC = "vac"
|
|
||||||
|
|
||||||
/** `["expiration", "<secs>"]` — NIP-40. MUST be on both wrap and rumor, same value. */
|
|
||||||
const val EXPIRATION = "expiration"
|
|
||||||
|
|
||||||
/** `["d", ""]` / `["d", "<index>"]` — addressable coordinate. */
|
|
||||||
const val D = "d"
|
|
||||||
|
|
||||||
/** `["snap", "<id>", "<i>", "<n>"]` — Guestbook snapshot chunk. */
|
|
||||||
const val SNAP = "snap"
|
|
||||||
|
|
||||||
/** `["invite", "<creator hex>", "<label>"]` — optional invite attribution on a join. */
|
|
||||||
const val INVITE = "invite"
|
|
||||||
|
|
||||||
/** `["k", "<kind>"]` — the target's kind on a reaction, the invite hint on a `3313` wrap. */
|
|
||||||
const val K = "k"
|
|
||||||
}
|
|
||||||
@@ -1,742 +0,0 @@
|
|||||||
package su.reya.coop.concord
|
|
||||||
|
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import rust.nostr.sdk.AckPolicy
|
|
||||||
import rust.nostr.sdk.Event
|
|
||||||
import rust.nostr.sdk.EventId
|
|
||||||
import rust.nostr.sdk.Filter
|
|
||||||
import rust.nostr.sdk.Kind
|
|
||||||
import rust.nostr.sdk.PublicKey
|
|
||||||
import rust.nostr.sdk.RelayUrl
|
|
||||||
import rust.nostr.sdk.ReqTarget
|
|
||||||
import rust.nostr.sdk.SendEventTarget
|
|
||||||
import rust.nostr.sdk.Tag
|
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
|
||||||
import su.reya.coop.AppStorage
|
|
||||||
import su.reya.coop.nostr.Nostr
|
|
||||||
import kotlin.concurrent.Volatile
|
|
||||||
import kotlin.time.Clock
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
|
|
||||||
class ConcordManager(private val nostr: Nostr) {
|
|
||||||
@Volatile
|
|
||||||
private var planes: Map<String, Plane> = emptyMap()
|
|
||||||
|
|
||||||
/** Latest Control fold per community id. Replaced wholesale; see [planes]. */
|
|
||||||
@Volatile
|
|
||||||
private var folds: Map<String, ControlFold> = emptyMap()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unread badges by Channel id. Replaced wholesale; see [planes]. In memory only, mirroring
|
|
||||||
* `Room.unreadCount` in the chat layer, so a restart begins with every badge cleared.
|
|
||||||
*/
|
|
||||||
@Volatile
|
|
||||||
private var unread: Map<String, Int> = emptyMap()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bumped whenever a plane rumor is cached or a message is sent, so a screen showing a Channel
|
|
||||||
* has something to re-query on. A counter rather than a set of changed scopes, because two
|
|
||||||
* messages to the same Channel must both be observable.
|
|
||||||
*/
|
|
||||||
private val _revision = MutableStateFlow(0L)
|
|
||||||
val revision: StateFlow<Long> = _revision.asStateFlow()
|
|
||||||
|
|
||||||
private val _memberships = MutableStateFlow<List<Membership>>(emptyList())
|
|
||||||
val memberships: StateFlow<List<Membership>> = _memberships.asStateFlow()
|
|
||||||
|
|
||||||
private val _communities = MutableStateFlow<List<CommunityState>>(emptyList())
|
|
||||||
val communities: StateFlow<List<CommunityState>> = _communities.asStateFlow()
|
|
||||||
|
|
||||||
/** Direct Invites that arrived giftwrapped to us and have not been accepted or dismissed. */
|
|
||||||
private val _directInvites = MutableStateFlow<List<CommunityInvite>>(emptyList())
|
|
||||||
val directInvites: StateFlow<List<CommunityInvite>> = _directInvites.asStateFlow()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True once [restore] has run. An empty [memberships] means the same thing before and after it,
|
|
||||||
* so a screen needs this to tell "joined nothing" from "not loaded yet".
|
|
||||||
*/
|
|
||||||
private val _restored = MutableStateFlow(false)
|
|
||||||
val restored: StateFlow<Boolean> = _restored.asStateFlow()
|
|
||||||
|
|
||||||
private var store: ConcordStore? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hands over the encrypted storage [Nostr] cannot reach on its own.
|
|
||||||
*
|
|
||||||
* [Nostr] is a Context-free singleton, so the Android layer passes [AppStorage] in through
|
|
||||||
* [Nostr.init]. Until this is called nothing can be persisted or restored, and the manager
|
|
||||||
* simply routes nothing.
|
|
||||||
*/
|
|
||||||
internal fun attach(storage: AppStorage) {
|
|
||||||
store = ConcordStore(storage, nostr)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when a wrap's author is one of our planes' stream keys. */
|
|
||||||
fun isPlaneAddress(authorHex: String): Boolean = planes.containsKey(authorHex)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles one stream wrap from a plane we hold a key for.
|
|
||||||
*
|
|
||||||
* [PlaneKey.unwrap] runs every check a reader must make and returns null on any failure, so a
|
|
||||||
* forged, unverifiable, mis-bound or undecryptable wrap is dropped here without ever being
|
|
||||||
* rendered. What survives is stored under its logical scope and, for Control, re-folded.
|
|
||||||
*/
|
|
||||||
suspend fun handlePlaneEvent(event: Event) {
|
|
||||||
val plane = planes[event.author().toHex()] ?: return
|
|
||||||
val rumor = plane.key.unwrap(event) ?: return
|
|
||||||
val store = store ?: return
|
|
||||||
|
|
||||||
store.cacheRumor(plane.scopeIdHex, event.id().toHex(), rumor)
|
|
||||||
_revision.update { it + 1 }
|
|
||||||
|
|
||||||
// A message from someone else is the only thing a badge counts. Our own wraps come back
|
|
||||||
// through the same subscription and so does a re-fetch, so the check is on the rumor's
|
|
||||||
// author rather than on the arrival.
|
|
||||||
if (plane.role == PlaneRole.Channel) countUnread(plane.scopeIdHex, rumor)
|
|
||||||
|
|
||||||
// Control carries consensus state, so every edition changes what we can read: a new
|
|
||||||
// Channel means a new plane address, and therefore a new subscription.
|
|
||||||
if (plane.role == PlaneRole.Control) refreshControl(plane.communityIdHex)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Intercepts rumors that arrive through the ordinary NIP-17 giftwrap pipeline and belong to
|
|
||||||
* Concord instead.
|
|
||||||
*
|
|
||||||
* Returns true when Concord consumed the rumor. A Direct Invite (kind `3313`) rides a *standard*
|
|
||||||
* NIP-59 wrap, so it unwraps fine — but it is a key handoff, not a message, and letting it
|
|
||||||
* through would have the chat layer build a room out of it. Returning true here keeps all
|
|
||||||
* Concord kind knowledge inside this package and leaves the chat code untouched.
|
|
||||||
*/
|
|
||||||
fun onInboxRumor(rumor: UnsignedEvent): Boolean {
|
|
||||||
if (rumor.kind().asU16() != ConcordKind.DIRECT_INVITE.toUShort()) return false
|
|
||||||
|
|
||||||
val invite =
|
|
||||||
runCatching { concordJson.decodeFromString<CommunityInvite>(rumor.content()) }.getOrNull()
|
|
||||||
if (invite == null) {
|
|
||||||
println("Concord: a direct invite arrived but its bundle could not be read")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (invite.problems().isNotEmpty()) {
|
|
||||||
println("Concord: refusing a direct invite that does not prove its community id")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
_directInvites.update { current ->
|
|
||||||
if (current.any {
|
|
||||||
it.communityId.equals(
|
|
||||||
invite.communityId,
|
|
||||||
ignoreCase = true
|
|
||||||
)
|
|
||||||
}) current
|
|
||||||
else current + invite
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Local half of startup: load memberships, fold the cached Control plane, index the planes. */
|
|
||||||
suspend fun restore() {
|
|
||||||
val store = store ?: return
|
|
||||||
val memberships = store.loadMemberships()
|
|
||||||
_memberships.value = memberships
|
|
||||||
|
|
||||||
// Fold what is already on disk so Channels and metadata render immediately. Live editions
|
|
||||||
// arrive later through handlePlaneEvent and re-fold.
|
|
||||||
folds = memberships.associate { membership ->
|
|
||||||
membership.communityId to ConcordControl.fold(
|
|
||||||
editions = store.cachedRumors(membership.communityId)
|
|
||||||
.mapNotNull { ConcordControl.editionOf(it) },
|
|
||||||
communityIdHex = membership.communityId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
refreshPlanes()
|
|
||||||
_restored.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drops every membership and everything derived from it.
|
|
||||||
*
|
|
||||||
* Membership keys are identity-scoped — holding them *is* being in the Community (CORD-02 §2) —
|
|
||||||
* so signing out has to take them with it, or the next identity on this device holds a seat it
|
|
||||||
* never took. The cached rumors go with `Nostr.prune()`; the subscriptions are dropped here,
|
|
||||||
* since nothing else would.
|
|
||||||
*/
|
|
||||||
suspend fun reset() {
|
|
||||||
_memberships.value.forEach { membership ->
|
|
||||||
nostr.client?.unsubscribe(subscriptionId(membership.communityId))
|
|
||||||
}
|
|
||||||
|
|
||||||
store?.clearMemberships()
|
|
||||||
|
|
||||||
_memberships.value = emptyList()
|
|
||||||
_directInvites.value = emptyList()
|
|
||||||
|
|
||||||
folds = emptyMap()
|
|
||||||
unread = emptyMap()
|
|
||||||
|
|
||||||
refreshPlanes()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Network half of startup: adopt what other clients hold, then connect and subscribe. */
|
|
||||||
suspend fun sync() {
|
|
||||||
adoptCommunityList()
|
|
||||||
|
|
||||||
for (membership in _memberships.value) {
|
|
||||||
try {
|
|
||||||
subscribeCommunity(membership)
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Concord: could not subscribe to ${membership.communityId}: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adopts the memberships this identity holds on other clients (CORD-02 §8).
|
|
||||||
*
|
|
||||||
* The Community List is the only way a Community joined elsewhere can appear here: an invite
|
|
||||||
* hands keys to one device, while the List is the member's own self-encrypted vault on their
|
|
||||||
* own relays. Add-only — a tombstone for a Community we do not hold is honoured, but nothing
|
|
||||||
* this device joined is dropped, because Coop never writes the List and so cannot tell which
|
|
||||||
* of the two states is newer.
|
|
||||||
*/
|
|
||||||
private suspend fun adoptCommunityList() {
|
|
||||||
val client = nostr.client
|
|
||||||
if (client == null) {
|
|
||||||
println("Concord: Community List skipped — no client yet")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val store = store
|
|
||||||
if (store == null) {
|
|
||||||
println("Concord: Community List skipped — storage was never attached")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val me = try {
|
|
||||||
nostr.signer.getPublicKeyAsync()
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Concord: Community List skipped — could not read the signer: ${e.message}")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (me == null) {
|
|
||||||
println("Concord: Community List skipped — the signer has no public key")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val filter = Filter()
|
|
||||||
.kind(Kind(ConcordKind.COMMUNITY_LIST.toUShort()))
|
|
||||||
.author(me)
|
|
||||||
|
|
||||||
val events = try {
|
|
||||||
client.fetchEvents(ReqTarget.auto(listOf(filter)), timeout = 8.seconds).toVec()
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Concord: Community List fetch failed: ${e.message}")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
println(
|
|
||||||
"Concord: Community List for ${me.toHex().take(8)}… came back with " +
|
|
||||||
"${events.size} event(s) from ${client.relays().size} relay(s)"
|
|
||||||
)
|
|
||||||
|
|
||||||
val fragments = events.mapNotNull { event ->
|
|
||||||
try {
|
|
||||||
val plaintext = nostr.signer.nip44DecryptAsync(me, event.content())
|
|
||||||
concordJson.decodeFromString<CommunityListFragment>(plaintext)
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println(
|
|
||||||
"Concord: Community List fragment ${event.id().toHex().take(8)}… " +
|
|
||||||
"(d=${event.tagValue(ConcordTag.D)}) is unusable: ${e.message}"
|
|
||||||
)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val known = _memberships.value.mapTo(mutableSetOf()) { it.communityId }
|
|
||||||
val listed = fragments.listedMemberships()
|
|
||||||
val adopted = listed.filterNot { it.communityId in known }
|
|
||||||
|
|
||||||
println(
|
|
||||||
"Concord: Community List gave ${fragments.size} fragment(s), " +
|
|
||||||
"${listed.size} membership(s), ${adopted.size} new"
|
|
||||||
)
|
|
||||||
|
|
||||||
if (adopted.isEmpty()) return
|
|
||||||
|
|
||||||
val updated = _memberships.value + adopted
|
|
||||||
try {
|
|
||||||
store.saveMemberships(updated)
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Concord: could not store the adopted memberships: ${e.message}")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_memberships.value = updated
|
|
||||||
refreshPlanes()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches and decrypts the bundle behind a public invite link, so the UI can show what joining
|
|
||||||
* would mean. Nothing is joined, nothing is subscribed and no presence is announced here
|
|
||||||
* (CORD-05 §1: a bundle is passive until the user accepts).
|
|
||||||
*/
|
|
||||||
suspend fun previewInvite(link: String): InvitePreview {
|
|
||||||
val client = nostr.client ?: throw IllegalStateException("Nostr client is not ready")
|
|
||||||
|
|
||||||
val parsed = parseInviteLink(link)
|
|
||||||
?: throw IllegalArgumentException("That is not a Concord invite link")
|
|
||||||
|
|
||||||
val bundleKey = inviteBundleKey(parsed.fragment.tokenHex)
|
|
||||||
?: throw IllegalArgumentException("The invite link's token is malformed")
|
|
||||||
|
|
||||||
|
|
||||||
val relays = (parsed.naddrRelays.map { it.toString() } + parsed.fragment.relays)
|
|
||||||
.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }
|
|
||||||
.distinct()
|
|
||||||
|
|
||||||
if (relays.isEmpty()) throw IllegalStateException("The invite link names no relay to fetch from")
|
|
||||||
|
|
||||||
relays.forEach { relay ->
|
|
||||||
client.addRelay(relay)
|
|
||||||
client.connectRelay(relay)
|
|
||||||
}
|
|
||||||
|
|
||||||
val filter = Filter()
|
|
||||||
.kind(Kind(ConcordKind.INVITE_BUNDLE.toUShort()))
|
|
||||||
.author(PublicKey.fromBytes(parsed.signerHex.hexToBytes()))
|
|
||||||
.identifier(parsed.identifier)
|
|
||||||
|
|
||||||
val bundle = client
|
|
||||||
.fetchEvents(
|
|
||||||
ReqTarget.manual(relays.associateWith { listOf(filter) }),
|
|
||||||
timeout = 8.seconds
|
|
||||||
)
|
|
||||||
.toVec()
|
|
||||||
.firstOrNull()
|
|
||||||
?: throw IllegalStateException("No invite bundle was found at that link")
|
|
||||||
|
|
||||||
if (isInviteTombstone(bundle.tagValue(ConcordTag.VSK))) {
|
|
||||||
throw IllegalStateException("This invite link has been revoked")
|
|
||||||
}
|
|
||||||
|
|
||||||
val invite = decryptInviteBundle(bundle.content(), bundleKey)
|
|
||||||
?: throw IllegalStateException("The invite bundle could not be opened")
|
|
||||||
|
|
||||||
return preview(invite, link, parsed.fragment.relays)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The same preview for a Direct Invite, which arrived with no link to fetch. */
|
|
||||||
fun previewDirectInvite(invite: CommunityInvite): InvitePreview =
|
|
||||||
preview(invite, link = null, fallbackRelays = emptyList())
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Forgets a Direct Invite the user declined. It was never persisted, so this only clears the
|
|
||||||
* in-memory list — there is nothing on a relay to take back.
|
|
||||||
*/
|
|
||||||
fun dismissDirectInvite(communityIdHex: String) {
|
|
||||||
_directInvites.update { invites ->
|
|
||||||
invites.filterNot { it.communityId.equals(communityIdHex, ignoreCase = true) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Accepts an invite: persist the keys, connect, subscribe, and announce the join. */
|
|
||||||
suspend fun join(preview: InvitePreview): CommunityState {
|
|
||||||
preview.problems.firstOrNull()?.let { throw IllegalArgumentException(it) }
|
|
||||||
if (preview.expired) throw IllegalArgumentException("That invite has expired")
|
|
||||||
|
|
||||||
val store = store ?: throw IllegalStateException("Concord storage is not ready")
|
|
||||||
val membership = preview.invite.toMembership(preview.relays)
|
|
||||||
|
|
||||||
val updated =
|
|
||||||
_memberships.value.filterNot { it.communityId == membership.communityId } + membership
|
|
||||||
store.saveMemberships(updated)
|
|
||||||
_memberships.value = updated
|
|
||||||
|
|
||||||
refreshPlanes()
|
|
||||||
subscribeCommunity(membership)
|
|
||||||
|
|
||||||
if (!preview.alreadyJoined) publishJoin(membership, preview.invite)
|
|
||||||
|
|
||||||
_directInvites.update { invites ->
|
|
||||||
invites.filterNot { it.communityId.equals(membership.communityId, ignoreCase = true) }
|
|
||||||
}
|
|
||||||
|
|
||||||
return _communities.value.firstOrNull { it.membership.communityId == membership.communityId }
|
|
||||||
?: CommunityState(membership, null, emptyList())
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A Channel's messages, oldest first, with reactions and edits folded on top. */
|
|
||||||
suspend fun channelMessages(channelIdHex: String): List<ConcordMessage> =
|
|
||||||
store?.cachedRumors(channelIdHex).orEmpty().toChannelMessages()
|
|
||||||
|
|
||||||
/** A Channel's unread badge. See [ConcordChannel.unreadCount] for what it does and does not survive. */
|
|
||||||
fun unreadCount(channelIdHex: String): Int = unread[channelIdHex] ?: 0
|
|
||||||
|
|
||||||
/** Clears a Channel's badge — the Channel screen calls this once its messages are on display. */
|
|
||||||
fun markChannelRead(channelIdHex: String) {
|
|
||||||
if ((unread[channelIdHex] ?: 0) == 0) return
|
|
||||||
unread = unread - channelIdHex
|
|
||||||
publishUnread(channelIdHex, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends a message to a Channel (CORD-03 §3), and returns it as a reader will see it.
|
|
||||||
*
|
|
||||||
* Throws when the Channel is hidden behind a key we were never granted — a Private Channel is
|
|
||||||
* listable without being writable.
|
|
||||||
*/
|
|
||||||
suspend fun sendChannelMessage(channelIdHex: String, content: String): ConcordMessage =
|
|
||||||
publish(channelPlane(channelIdHex), ConcordKind.MESSAGE, content)
|
|
||||||
.toConcordMessage()
|
|
||||||
?: throw IllegalStateException("Concord: could not read back the message just sent")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reacts to a message (CORD-03 §2.3). The `e` tag names the target's *rumor* id, and `p`/`k`
|
|
||||||
* carry its author and kind, which is what lets a reader group it without a lookup.
|
|
||||||
*/
|
|
||||||
suspend fun sendChannelReaction(
|
|
||||||
channelIdHex: String,
|
|
||||||
targetIdHex: String,
|
|
||||||
targetAuthorHex: String,
|
|
||||||
reaction: String,
|
|
||||||
) {
|
|
||||||
publish(
|
|
||||||
plane = channelPlane(channelIdHex),
|
|
||||||
kind = ConcordKind.REACTION,
|
|
||||||
content = reaction,
|
|
||||||
extraTags = listOf(
|
|
||||||
Tag.event(EventId.parse(targetIdHex)),
|
|
||||||
Tag.publicKey(PublicKey.parse(targetAuthorHex)),
|
|
||||||
Tag.custom(ConcordTag.K, listOf(ConcordKind.MESSAGE.toString())),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replaces one of our own messages (CORD-03 §2.5). The `e` tag names the message being edited;
|
|
||||||
* the edit rides the same Channel plane, so it reaches exactly the readers the original did.
|
|
||||||
*/
|
|
||||||
suspend fun sendChannelEdit(channelIdHex: String, targetIdHex: String, content: String) {
|
|
||||||
publish(
|
|
||||||
plane = channelPlane(channelIdHex),
|
|
||||||
kind = ConcordKind.EDIT,
|
|
||||||
content = content,
|
|
||||||
extraTags = listOf(Tag.event(EventId.parse(targetIdHex))),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun channelPlane(channelIdHex: String): Plane = planes.values.firstOrNull {
|
|
||||||
it.role == PlaneRole.Channel && it.scopeIdHex.equals(channelIdHex, ignoreCase = true)
|
|
||||||
} ?: throw IllegalArgumentException("That Channel is not one we hold a key for")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The whole write path: build the rumor from the Channel's own key, wrap it under that key's
|
|
||||||
* stream half, cache it locally, then publish to the Community's own relays.
|
|
||||||
*
|
|
||||||
* The rumor is cached *before* it is published, so it is visible even if every relay is
|
|
||||||
* unreachable and so the subscription's echo of the wrap lands on the same `d` slot instead of
|
|
||||||
* duplicating it. The relays are always the Community's, never the app's defaults.
|
|
||||||
*/
|
|
||||||
private suspend fun publish(
|
|
||||||
plane: Plane,
|
|
||||||
kind: Int,
|
|
||||||
content: String,
|
|
||||||
extraTags: List<Tag> = emptyList(),
|
|
||||||
): UnsignedEvent {
|
|
||||||
val client = nostr.client ?: throw IllegalStateException("Nostr client is not ready")
|
|
||||||
val author =
|
|
||||||
nostr.signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
|
||||||
|
|
||||||
val rumor = plane.key.rumor(
|
|
||||||
author = author,
|
|
||||||
kind = kind.toUShort(),
|
|
||||||
content = content,
|
|
||||||
createdAt = Clock.System.now(),
|
|
||||||
extraTags = extraTags,
|
|
||||||
)
|
|
||||||
val wrap = plane.key.wrap(rumor, nostr.signer)
|
|
||||||
|
|
||||||
store?.cacheRumor(plane.scopeIdHex, wrap.id().toHex(), rumor)
|
|
||||||
_revision.update { it + 1 }
|
|
||||||
|
|
||||||
// Always the Community's own relays, never the app's defaults (see [subscribeCommunity]).
|
|
||||||
val relays = _memberships.value
|
|
||||||
.firstOrNull { it.communityId == plane.communityIdHex }
|
|
||||||
?.relays
|
|
||||||
.orEmpty()
|
|
||||||
.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }
|
|
||||||
if (relays.isEmpty()) throw IllegalStateException("That Community names no relay to publish to")
|
|
||||||
|
|
||||||
client.sendEvent(
|
|
||||||
event = wrap,
|
|
||||||
target = SendEventTarget.to(relays),
|
|
||||||
ackPolicy = AckPolicy.none()
|
|
||||||
).failed.forEach { (relay, reason) -> println("Concord: $relay refused a write: $reason") }
|
|
||||||
|
|
||||||
return rumor
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Records a badge change and re-publishes [communities] so a badge drawn from it moves. */
|
|
||||||
private fun publishUnread(channelIdHex: String, count: Int) {
|
|
||||||
_communities.update { states ->
|
|
||||||
if (states.none { state -> state.channels.any { it.idHex == channelIdHex } }) return@update states
|
|
||||||
states.map { state ->
|
|
||||||
state.copy(
|
|
||||||
channels = state.channels.map {
|
|
||||||
if (it.idHex == channelIdHex) it.copy(unreadCount = count) else it
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Counts one incoming message against its Channel, ignoring our own and anything but a message. */
|
|
||||||
private fun countUnread(channelIdHex: String, rumor: UnsignedEvent) {
|
|
||||||
if (rumor.kind().asU16() != ConcordKind.MESSAGE.toUShort()) return
|
|
||||||
if (rumor.author() == nostr.signer.publicKeyFlow.value) return
|
|
||||||
|
|
||||||
val count = (unread[channelIdHex] ?: 0) + 1
|
|
||||||
unread = unread + (channelIdHex to count)
|
|
||||||
publishUnread(channelIdHex, count)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rebuilds the plane index and the Community read model from the memberships and folds we hold.
|
|
||||||
*
|
|
||||||
* Returns the communities whose set of plane addresses changed, because those are exactly the
|
|
||||||
* ones whose subscription is now stale — a Control edition that adds a Channel adds an address.
|
|
||||||
*/
|
|
||||||
private fun refreshPlanes(): Set<String> {
|
|
||||||
val previous = planes
|
|
||||||
val next = mutableMapOf<String, Plane>()
|
|
||||||
val states = mutableListOf<CommunityState>()
|
|
||||||
|
|
||||||
for (membership in _memberships.value) {
|
|
||||||
val communityId = membership.communityId.hex32()
|
|
||||||
val root = membership.communityRoot.hex32()
|
|
||||||
val fold = folds[membership.communityId]
|
|
||||||
val granted = membership.channels.associateBy { it.id }
|
|
||||||
|
|
||||||
if (communityId != null && root != null) {
|
|
||||||
membership.controlPk?.let { controlPkHex ->
|
|
||||||
if (controlPkHex.hex32() != null) {
|
|
||||||
val key =
|
|
||||||
controlPlaneKey(root, communityId, membership.rootEpoch, controlPkHex)
|
|
||||||
next[key.streamPublicKeyHex] = Plane(
|
|
||||||
communityIdHex = membership.communityId,
|
|
||||||
role = PlaneRole.Control,
|
|
||||||
key = key,
|
|
||||||
scopeIdHex = membership.communityId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val guestbook = guestbookPlaneKey(root, communityId, membership.rootEpoch)
|
|
||||||
next[guestbook.streamPublicKeyHex] = Plane(
|
|
||||||
communityIdHex = membership.communityId,
|
|
||||||
role = PlaneRole.Guestbook,
|
|
||||||
key = guestbook,
|
|
||||||
scopeIdHex = membership.communityId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val channelIds = (granted.keys + fold?.channels?.keys.orEmpty()).distinct()
|
|
||||||
val channels = channelIds.mapNotNull { channelIdHex ->
|
|
||||||
val meta = fold?.channels?.get(channelIdHex)
|
|
||||||
if (meta?.deleted == true) return@mapNotNull null
|
|
||||||
|
|
||||||
val stored = granted[channelIdHex]
|
|
||||||
val channelId = channelIdHex.hex32()
|
|
||||||
val secret = stored?.key?.hex32()
|
|
||||||
?: if (meta?.isPrivate != true) root else null
|
|
||||||
val epoch = stored?.epoch ?: membership.rootEpoch
|
|
||||||
|
|
||||||
var plane: PlaneKey? = null
|
|
||||||
if (channelId != null && secret != null) {
|
|
||||||
plane = channelPlaneKey(secret, channelId, epoch)
|
|
||||||
next[plane.streamPublicKeyHex] = Plane(
|
|
||||||
communityIdHex = membership.communityId,
|
|
||||||
role = PlaneRole.Channel,
|
|
||||||
key = plane,
|
|
||||||
scopeIdHex = channelIdHex,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
ConcordChannel(
|
|
||||||
idHex = channelIdHex,
|
|
||||||
name = meta?.name ?: stored?.name ?: channelIdHex.take(8),
|
|
||||||
private = meta?.isPrivate ?: stored?.private ?: false,
|
|
||||||
epoch = epoch,
|
|
||||||
hasKey = plane != null,
|
|
||||||
// Carried across the re-index rather than recomputed: a Control edition must not
|
|
||||||
// silently clear every badge, the same way ChatRepository preserves its counters.
|
|
||||||
unreadCount = unread[channelIdHex] ?: 0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
states += CommunityState(
|
|
||||||
membership = membership,
|
|
||||||
meta = fold?.meta,
|
|
||||||
channels = channels.sortedBy { it.name.lowercase() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
planes = next
|
|
||||||
_communities.value = states
|
|
||||||
|
|
||||||
return _memberships.value
|
|
||||||
.filter { membership ->
|
|
||||||
previous.planeKeys(membership.communityId) != next.planeKeys(membership.communityId)
|
|
||||||
}
|
|
||||||
.map { it.communityId }
|
|
||||||
.toSet()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Re-folds one Community's Control plane from the cache, then re-indexes if planes shifted. */
|
|
||||||
private suspend fun refreshControl(communityIdHex: String) {
|
|
||||||
val store = store ?: return
|
|
||||||
val editions =
|
|
||||||
store.cachedRumors(communityIdHex).mapNotNull { ConcordControl.editionOf(it) }
|
|
||||||
folds = folds + (communityIdHex to ConcordControl.fold(editions, communityIdHex))
|
|
||||||
|
|
||||||
if (communityIdHex in refreshPlanes()) {
|
|
||||||
_memberships.value.firstOrNull { it.communityId == communityIdHex }
|
|
||||||
?.let { subscribeCommunity(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribes to one Community: connect its relays, then ask for every plane's address.
|
|
||||||
*
|
|
||||||
* `channel` and `epoch` are multi-letter tags, and Nostr filters only accept single-letter ones,
|
|
||||||
* so plane addresses are the finest granularity available. That is the right level anyway — one
|
|
||||||
* key per plane — and the `channel`/`epoch` binding is a strict check after decryption.
|
|
||||||
*/
|
|
||||||
private suspend fun subscribeCommunity(membership: Membership) {
|
|
||||||
val client = nostr.client ?: return
|
|
||||||
val id = subscriptionId(membership.communityId)
|
|
||||||
client.unsubscribe(id)
|
|
||||||
|
|
||||||
val relays = membership.relays
|
|
||||||
.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }
|
|
||||||
.distinct()
|
|
||||||
|
|
||||||
if (relays.isEmpty()) return
|
|
||||||
|
|
||||||
relays.forEach { relay ->
|
|
||||||
client.addRelay(relay)
|
|
||||||
client.connectRelay(relay)
|
|
||||||
}
|
|
||||||
|
|
||||||
val filters = planes.values
|
|
||||||
.filter { it.communityIdHex == membership.communityId }
|
|
||||||
.mapNotNull { plane -> plane.key.streamPublicKeyHex.hex32() }
|
|
||||||
.distinct()
|
|
||||||
.map {
|
|
||||||
Filter().kind(Kind(ConcordKind.WRAP.toUShort())).author(PublicKey.fromBytes(it))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.isEmpty()) return
|
|
||||||
|
|
||||||
client.subscribe(target = ReqTarget.manual(relays.associateWith { filters }), id = id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Publishes a still-fetchable Guestbook Join (CORD-02 §5) — each member's own word. */
|
|
||||||
private suspend fun publishJoin(membership: Membership, invite: CommunityInvite?) {
|
|
||||||
val client = nostr.client ?: return
|
|
||||||
val communityId = membership.communityId.hex32() ?: return
|
|
||||||
val root = membership.communityRoot.hex32() ?: return
|
|
||||||
val author =
|
|
||||||
nostr.signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
|
||||||
|
|
||||||
val plane = guestbookPlaneKey(root, communityId, membership.rootEpoch)
|
|
||||||
|
|
||||||
val extraTags = invite?.creatorNpub
|
|
||||||
?.let { creator ->
|
|
||||||
listOf(Tag.custom(ConcordTag.INVITE, listOf(creator, invite.label.orEmpty())))
|
|
||||||
}.orEmpty()
|
|
||||||
|
|
||||||
val rumor = plane.rumor(
|
|
||||||
author = author,
|
|
||||||
kind = ConcordKind.JOIN_LEAVE.toUShort(),
|
|
||||||
content = "join",
|
|
||||||
createdAt = Clock.System.now(),
|
|
||||||
extraTags = extraTags,
|
|
||||||
)
|
|
||||||
val wrap = plane.wrap(rumor, nostr.signer)
|
|
||||||
|
|
||||||
val targets =
|
|
||||||
membership.relays.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }
|
|
||||||
|
|
||||||
client.sendEvent(
|
|
||||||
event = wrap,
|
|
||||||
target = SendEventTarget.to(targets),
|
|
||||||
ackPolicy = AckPolicy.none()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun subscriptionId(communityIdHex: String): String =
|
|
||||||
"$SUBSCRIPTION_PREFIX$communityIdHex"
|
|
||||||
|
|
||||||
private fun preview(
|
|
||||||
invite: CommunityInvite,
|
|
||||||
link: String?,
|
|
||||||
fallbackRelays: List<String>,
|
|
||||||
): InvitePreview {
|
|
||||||
val relays = invite.relaySet(fallbackRelays)
|
|
||||||
val problems =
|
|
||||||
invite.problems() + if (relays.isEmpty()) listOf("The invite names no relays") else emptyList()
|
|
||||||
|
|
||||||
return InvitePreview(
|
|
||||||
link = link,
|
|
||||||
invite = invite,
|
|
||||||
relays = relays,
|
|
||||||
problems = problems,
|
|
||||||
expired = invite.isExpired(Clock.System.now().toEpochMilliseconds()),
|
|
||||||
alreadyJoined = _memberships.value.any {
|
|
||||||
it.communityId.equals(
|
|
||||||
invite.communityId,
|
|
||||||
ignoreCase = true
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val SUBSCRIPTION_PREFIX = "concord:"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What a plane is for — the Community-wide Control and Guestbook planes, or one Channel. */
|
|
||||||
private enum class PlaneRole { Control, Guestbook, Channel }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One plane address we hold: the key to decrypt it, the role that says how to fold it, and the
|
|
||||||
* [scopeIdHex] it caches under.
|
|
||||||
*/
|
|
||||||
private class Plane(
|
|
||||||
val communityIdHex: String,
|
|
||||||
val role: PlaneRole,
|
|
||||||
val key: PlaneKey,
|
|
||||||
val scopeIdHex: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun Map<String, Plane>.planeKeys(communityIdHex: String): Set<String> =
|
|
||||||
filterValues { it.communityIdHex == communityIdHex }.keys
|
|
||||||
|
|
||||||
/** The first content value of a tag, or null. Used for tags read without their companion fields. */
|
|
||||||
private fun Event.tagValue(kind: String): String? =
|
|
||||||
tags().toVec().firstOrNull { it.kind() == kind }?.content()
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
package su.reya.coop.concord
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
internal val concordJson = Json {
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
encodeDefaults = true
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Membership(
|
|
||||||
/** Hex `community_id`. Never appears on the wire; every coordinate derives from it one-way. */
|
|
||||||
val communityId: String,
|
|
||||||
/** Owner x-only pubkey hex, the other half of the `community_id` commitment. */
|
|
||||||
val owner: String,
|
|
||||||
/** 32-byte hex salt; not secret, travels in invites so anyone can recompute the commitment. */
|
|
||||||
val ownerSalt: String,
|
|
||||||
/** 32-byte hex `community_root`. The base access key — never leaves the encrypted store. */
|
|
||||||
val communityRoot: String,
|
|
||||||
/** Epoch the root was issued at (CORD-02 §3). */
|
|
||||||
val rootEpoch: ULong,
|
|
||||||
/** Control Plane signer pubkey (CORD-02 §5), used to *address* the plane. Trusted on trust. */
|
|
||||||
val controlPk: String? = null,
|
|
||||||
/** Staff-only write key. v1 never writes to the Control Plane, so this stays null. */
|
|
||||||
val controlRoot: String? = null,
|
|
||||||
/** The Community's relays — always from the invite, never the app's defaults (see PLAN.md 13.2). */
|
|
||||||
val relays: List<String> = emptyList(),
|
|
||||||
/** Last known name, cached so a community renders before its Control fold lands. */
|
|
||||||
val name: String? = null,
|
|
||||||
/** Channels this invite actually granted a key for. */
|
|
||||||
val channels: List<StoredChannelKey> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class StoredChannelKey(
|
|
||||||
val id: String,
|
|
||||||
val key: String,
|
|
||||||
val epoch: ULong,
|
|
||||||
val name: String? = null,
|
|
||||||
val private: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class CommunityInvite(
|
|
||||||
@SerialName("community_id") val communityId: String = "",
|
|
||||||
val owner: String = "",
|
|
||||||
@SerialName("owner_salt") val ownerSalt: String = "",
|
|
||||||
@SerialName("community_root") val communityRoot: String = "",
|
|
||||||
@SerialName("root_epoch") val rootEpoch: ULong = 0u,
|
|
||||||
@SerialName("control_pk") val controlPk: String? = null,
|
|
||||||
val channels: List<InviteChannel> = emptyList(),
|
|
||||||
val relays: List<String> = emptyList(),
|
|
||||||
val name: String? = null,
|
|
||||||
val icon: ConcordIcon? = null,
|
|
||||||
/** Unix **milliseconds** — see [expiryToEpochSeconds]. Optional. */
|
|
||||||
@SerialName("expires_at") val expiresAt: Long? = null,
|
|
||||||
@SerialName("creator_npub") val creatorNpub: String? = null,
|
|
||||||
val label: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** One granted Channel inside a bundle. `key` is `community_root` for a Public one (CORD-03 §1). */
|
|
||||||
@Serializable
|
|
||||||
data class InviteChannel(
|
|
||||||
val id: String = "",
|
|
||||||
val key: String = "",
|
|
||||||
val epoch: ULong = 0u,
|
|
||||||
val name: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class ConcordIcon(
|
|
||||||
val url: String? = null,
|
|
||||||
val key: String? = null,
|
|
||||||
val nonce: String? = null,
|
|
||||||
val hash: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Community metadata — the `vsk 0` entity's content (CORD-02 §6). */
|
|
||||||
@Serializable
|
|
||||||
data class CommunityMeta(
|
|
||||||
val name: String? = null,
|
|
||||||
val description: String? = null,
|
|
||||||
/** The Community's own relay set. This is the authority; the invite's copy is a snapshot. */
|
|
||||||
val relays: List<String> = emptyList(),
|
|
||||||
val icon: ConcordIcon? = null,
|
|
||||||
val banner: ConcordIcon? = null,
|
|
||||||
/** Disappearing-messages timer in seconds (CORD-08). Read only; v1 does not enforce it. */
|
|
||||||
@SerialName("message_expiration") val messageExpiration: Long? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Channel metadata — the `vsk 2` entity's content (CORD-03 §2). */
|
|
||||||
@Serializable
|
|
||||||
data class ChannelMeta(
|
|
||||||
val name: String? = null,
|
|
||||||
@SerialName("private") val isPrivate: Boolean = false,
|
|
||||||
/** Terminal: the id is never reused and clients drop the Channel (CORD-03 §2). */
|
|
||||||
val deleted: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ControlEdition(
|
|
||||||
val vsk: Int,
|
|
||||||
val eidHex: String,
|
|
||||||
val version: ULong,
|
|
||||||
val prevHashHex: String?,
|
|
||||||
val content: String,
|
|
||||||
val actorHex: String,
|
|
||||||
val rumorIdHex: String,
|
|
||||||
val createdAt: Instant,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** The projected Control state v1 uses: the Community's metadata and its Channel list. */
|
|
||||||
data class ControlFold(
|
|
||||||
val meta: CommunityMeta?,
|
|
||||||
val channels: Map<String, ChannelMeta>,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** A Channel as the UI sees it: its identity, and whether we actually hold a key to read it. */
|
|
||||||
data class ConcordChannel(
|
|
||||||
val idHex: String,
|
|
||||||
val name: String,
|
|
||||||
val private: Boolean,
|
|
||||||
val epoch: ULong,
|
|
||||||
/** False for a Private Channel we were never granted — listable, but not readable. */
|
|
||||||
val hasKey: Boolean,
|
|
||||||
/**
|
|
||||||
* Messages from others since this Channel was last opened. In memory only, exactly like
|
|
||||||
* `Room.unreadCount`: it survives a Control re-fold, but not a restart.
|
|
||||||
*/
|
|
||||||
val unreadCount: Int = 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** A Community with its Control fold applied. */
|
|
||||||
data class CommunityState(
|
|
||||||
val membership: Membership,
|
|
||||||
/** Null until a `vsk 0` edition has been folded; the invite's name fills in before that. */
|
|
||||||
val meta: CommunityMeta?,
|
|
||||||
val channels: List<ConcordChannel>,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** One emoji and everyone who used it on a message (CORD-03 §2.3), grouped for display. */
|
|
||||||
data class ConcordReaction(
|
|
||||||
val emoji: String,
|
|
||||||
val authors: List<String>,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** A Chat-plane message, already unwrapped and signature-checked. */
|
|
||||||
data class ConcordMessage(
|
|
||||||
val idHex: String,
|
|
||||||
val author: String,
|
|
||||||
val content: String,
|
|
||||||
val createdAt: Instant,
|
|
||||||
/** CORD-02 §4: `created_at * 1000 + ms`. The only ordering basis the protocol uses. */
|
|
||||||
val timestampMs: Long,
|
|
||||||
val reactions: List<ConcordReaction> = emptyList(),
|
|
||||||
/** True once the author's own Edit (kind `3302`) has replaced [content]. */
|
|
||||||
val edited: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** A redeemed-in-part invite, ready for the UI to preview before anything joins (CORD-05 §1). */
|
|
||||||
data class InvitePreview(
|
|
||||||
/** Null for a Direct Invite, which arrives with no link. */
|
|
||||||
val link: String?,
|
|
||||||
val invite: CommunityInvite,
|
|
||||||
/** Resolved relay set: the bundle's, falling back to the link's bootstrap relays. */
|
|
||||||
val relays: List<String>,
|
|
||||||
/** Non-empty means the bundle is refused — these are the reasons, in plain English. */
|
|
||||||
val problems: List<String>,
|
|
||||||
val expired: Boolean,
|
|
||||||
val alreadyJoined: Boolean,
|
|
||||||
) {
|
|
||||||
val joinable: Boolean get() = problems.isEmpty() && !expired
|
|
||||||
}
|
|
||||||
|
|
||||||
fun CommunityInvite.expiryToEpochSeconds(): Long? = expiresAt?.let { it / 1000 }
|
|
||||||
|
|
||||||
internal fun UnsignedEvent.concordTimestampMs(): Long {
|
|
||||||
val ms = tags().toVec()
|
|
||||||
.firstOrNull { it.kind() == ConcordTag.MS }
|
|
||||||
?.content()
|
|
||||||
?.toIntOrNull()
|
|
||||||
?.takeIf { it in 0..999 }
|
|
||||||
return createdAt().asSecs().toLong() * 1000 + (ms ?: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun UnsignedEvent.toConcordMessage(): ConcordMessage? {
|
|
||||||
if (kind().asU16() != ConcordKind.MESSAGE.toUShort()) return null
|
|
||||||
return ConcordMessage(
|
|
||||||
idHex = id()?.toHex().orEmpty(),
|
|
||||||
author = author().toHex(),
|
|
||||||
content = content(),
|
|
||||||
createdAt = Instant.fromEpochSeconds(createdAt().asSecs().toLong()),
|
|
||||||
timestampMs = concordTimestampMs(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Projects one Channel's cached rumors into the timeline: its messages, with reactions and edits
|
|
||||||
* folded on top.
|
|
||||||
*
|
|
||||||
* Every one of these kinds names its target by the target's *rumor* id, never the outer wrap's —
|
|
||||||
* that id differs on every re-wrap (CORD-03 §2). A reaction for a message we no longer hold is
|
|
||||||
* dropped along with it. An Edit counts only from the message's own author: the `e` tag is a claim,
|
|
||||||
* and authorship is what the seal actually proves.
|
|
||||||
*/
|
|
||||||
internal fun List<UnsignedEvent>.toChannelMessages(): List<ConcordMessage> {
|
|
||||||
val messages = filter { it.isKind(ConcordKind.MESSAGE) }
|
|
||||||
.mapNotNull { it.toConcordMessage() }
|
|
||||||
.sortedBy { it.timestampMs }
|
|
||||||
|
|
||||||
val reactions = filter { it.isKind(ConcordKind.REACTION) }
|
|
||||||
.mapNotNull { rumor -> rumor.targetHex()?.let { it to rumor } }
|
|
||||||
.groupBy({ it.first }, { it.second })
|
|
||||||
.mapValues { (_, rumors) ->
|
|
||||||
rumors.groupBy { it.content() }
|
|
||||||
.map { (emoji, reactors) ->
|
|
||||||
ConcordReaction(
|
|
||||||
emoji = emoji.ifEmpty { "+" },
|
|
||||||
authors = reactors.map { it.author().toHex() }.distinct(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val edits = filter { it.isKind(ConcordKind.EDIT) }
|
|
||||||
.mapNotNull { rumor -> rumor.targetHex()?.let { it to rumor } }
|
|
||||||
.groupBy({ it.first }, { it.second })
|
|
||||||
.mapValues { (_, candidates) -> candidates.maxBy { it.concordTimestampMs() } }
|
|
||||||
|
|
||||||
return messages.map { message ->
|
|
||||||
val edit = edits[message.idHex]?.takeIf { it.author().toHex() == message.author }
|
|
||||||
message.copy(
|
|
||||||
content = edit?.content() ?: message.content,
|
|
||||||
edited = edit != null,
|
|
||||||
reactions = reactions[message.idHex].orEmpty(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun UnsignedEvent.isKind(expected: Int): Boolean = kind().asU16() == expected.toUShort()
|
|
||||||
|
|
||||||
/** The target of a reaction, edit or delete: the first `e` tag (CORD-03 §2.3–2.5). */
|
|
||||||
private fun UnsignedEvent.targetHex(): String? =
|
|
||||||
tags().toVec().firstOrNull { it.kind() == ConcordTag.E }?.content()
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
package su.reya.coop.concord
|
|
||||||
|
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import rust.nostr.sdk.AsyncNostrSigner
|
|
||||||
import rust.nostr.sdk.Event
|
|
||||||
import rust.nostr.sdk.EventBuilder
|
|
||||||
import rust.nostr.sdk.Keys
|
|
||||||
import rust.nostr.sdk.Kind
|
|
||||||
import rust.nostr.sdk.Nip44Version
|
|
||||||
import rust.nostr.sdk.PublicKey
|
|
||||||
import rust.nostr.sdk.Tag
|
|
||||||
import rust.nostr.sdk.Timestamp
|
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
|
||||||
import rust.nostr.sdk.nip44Decrypt
|
|
||||||
import rust.nostr.sdk.nip44Encrypt
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
enum class SealForm(private val kindValue: Int) {
|
|
||||||
Encrypted(ConcordKind.SEAL),
|
|
||||||
Plaintext(ConcordKind.PLAINTEXT_SEAL);
|
|
||||||
|
|
||||||
val kind: UShort get() = kindValue.toUShort()
|
|
||||||
}
|
|
||||||
|
|
||||||
data class ChatBinding(val channelIdHex: String, val epoch: ULong)
|
|
||||||
|
|
||||||
class PlaneKey(
|
|
||||||
val read: GroupKey,
|
|
||||||
val stream: GroupKey?,
|
|
||||||
val streamPublicKeyHex: String,
|
|
||||||
val form: SealForm,
|
|
||||||
val chat: ChatBinding?,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun channelPlaneKey(channelSecret: ByteArray, channelId: ByteArray, epoch: ULong): PlaneKey {
|
|
||||||
val key = groupKey(ConcordLabel.CHANNEL, channelSecret, channelId, epoch)
|
|
||||||
return PlaneKey(
|
|
||||||
read = key,
|
|
||||||
stream = key,
|
|
||||||
streamPublicKeyHex = key.publicKey.toHex(),
|
|
||||||
form = SealForm.Encrypted,
|
|
||||||
chat = ChatBinding(channelId.toHex(), epoch),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun guestbookPlaneKey(communityRoot: ByteArray, communityId: ByteArray, epoch: ULong): PlaneKey {
|
|
||||||
val key = groupKey(ConcordLabel.GUESTBOOK, communityRoot, communityId, epoch)
|
|
||||||
return PlaneKey(
|
|
||||||
read = key,
|
|
||||||
stream = key,
|
|
||||||
streamPublicKeyHex = key.publicKey.toHex(),
|
|
||||||
form = SealForm.Encrypted,
|
|
||||||
chat = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun controlPlaneKey(
|
|
||||||
communityRoot: ByteArray,
|
|
||||||
communityId: ByteArray,
|
|
||||||
epoch: ULong,
|
|
||||||
streamPublicKeyHex: String,
|
|
||||||
): PlaneKey {
|
|
||||||
val key = groupKey(ConcordLabel.CONTROL, communityRoot, communityId, epoch)
|
|
||||||
return PlaneKey(
|
|
||||||
read = key,
|
|
||||||
stream = null,
|
|
||||||
streamPublicKeyHex = streamPublicKeyHex,
|
|
||||||
form = SealForm.Plaintext,
|
|
||||||
chat = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun PlaneKey.rumor(
|
|
||||||
author: PublicKey,
|
|
||||||
kind: UShort,
|
|
||||||
content: String,
|
|
||||||
createdAt: Instant,
|
|
||||||
extraTags: List<Tag> = emptyList(),
|
|
||||||
): UnsignedEvent {
|
|
||||||
val tags = buildList {
|
|
||||||
chat?.let {
|
|
||||||
add(Tag.custom(ConcordTag.CHANNEL, listOf(it.channelIdHex)))
|
|
||||||
add(Tag.custom(ConcordTag.EPOCH, listOf(it.epoch.toString())))
|
|
||||||
}
|
|
||||||
add(Tag.custom(ConcordTag.MS, listOf(msOf(createdAt).toString())))
|
|
||||||
addAll(extraTags)
|
|
||||||
}
|
|
||||||
return EventBuilder(Kind(kind), content)
|
|
||||||
.tags(tags)
|
|
||||||
.customCreatedAt(Timestamp.fromSecs(createdAt.epochSeconds.toULong()))
|
|
||||||
.finalizeUnsigned(author)
|
|
||||||
.ensureId()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun PlaneKey.wrap(rumor: UnsignedEvent, signer: AsyncNostrSigner): Event {
|
|
||||||
val stream = stream ?: error("Concord: this plane is read-only and cannot wrap")
|
|
||||||
val createdAt = rumor.createdAt()
|
|
||||||
|
|
||||||
val rumorJson = rumor.asJson()
|
|
||||||
val content = if (form == SealForm.Encrypted) nip44Seal(read, rumorJson) else rumorJson
|
|
||||||
|
|
||||||
val seal = try {
|
|
||||||
EventBuilder(Kind(form.kind), content).customCreatedAt(createdAt).finalizeAsync(signer)
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Concord: failed to seal rumor: ${e.message}", e)
|
|
||||||
}
|
|
||||||
|
|
||||||
check(seal.author() == rumor.author()) {
|
|
||||||
"Concord: seal author ${seal.author().toHex()} does not match rumor author ${
|
|
||||||
rumor.author().toHex()
|
|
||||||
}"
|
|
||||||
}
|
|
||||||
|
|
||||||
return try {
|
|
||||||
val ephemeral = Keys.generate().use { Tag.publicKey(it.publicKey()) }
|
|
||||||
Keys(stream.secretKey).use { keys ->
|
|
||||||
EventBuilder(Kind(concordKind(ConcordKind.WRAP)), nip44Seal(read, seal.asJson()))
|
|
||||||
.tags(listOf(ephemeral))
|
|
||||||
.customCreatedAt(createdAt)
|
|
||||||
.finalize(keys)
|
|
||||||
}
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Concord: failed to wrap seal: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun PlaneKey.unwrap(event: Event): UnsignedEvent? {
|
|
||||||
if (event.kind().asU16() != ConcordKind.WRAP.toUShort()) return null
|
|
||||||
if (event.author().toHex() != streamPublicKeyHex) return null
|
|
||||||
if (!event.verify()) return null
|
|
||||||
|
|
||||||
val seal = runCatching {
|
|
||||||
Event.fromJson(nip44Decrypt(read.secretKey, read.publicKey, event.content()))
|
|
||||||
}.getOrNull() ?: return null
|
|
||||||
if (!seal.verify()) return null
|
|
||||||
|
|
||||||
val sealKind = seal.kind().asU16()
|
|
||||||
val rumorJson = when {
|
|
||||||
sealKind == SealForm.Encrypted.kind && form == SealForm.Encrypted -> runCatching {
|
|
||||||
nip44Decrypt(read.secretKey, read.publicKey, seal.content())
|
|
||||||
}.getOrNull() ?: return null
|
|
||||||
// Plaintext seal: the rumor's bytes are already the content, never re-parsed as an event.
|
|
||||||
sealKind == SealForm.Plaintext.kind && form == SealForm.Plaintext -> seal.content()
|
|
||||||
else -> return null
|
|
||||||
}
|
|
||||||
|
|
||||||
val rumor =
|
|
||||||
runCatching { UnsignedEvent.fromJson(rumorJson).ensureId() }.getOrNull() ?: return null
|
|
||||||
// NIP-59's impersonation check: the seal proves who wrote the rumor inside it.
|
|
||||||
if (rumor.author() != seal.author()) return null
|
|
||||||
if (!bindsToThisPlane(rumor)) return null
|
|
||||||
return rumor
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun PlaneKey.bindsToThisPlane(rumor: UnsignedEvent): Boolean {
|
|
||||||
val expected = chat ?: return true
|
|
||||||
val tags = rumor.tags().toVec()
|
|
||||||
val channel = tags.firstOrNull { it.kind() == ConcordTag.CHANNEL }?.content()
|
|
||||||
val epoch = tags.firstOrNull { it.kind() == ConcordTag.EPOCH }?.content()
|
|
||||||
return channel == expected.channelIdHex && epoch == expected.epoch.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun msOf(createdAt: Instant): Int = createdAt.toEpochMilliseconds().mod(1000L).toInt()
|
|
||||||
|
|
||||||
private fun concordKind(kind: Int): UShort = kind.toUShort()
|
|
||||||
|
|
||||||
private fun nip44Seal(key: GroupKey, plaintext: String): String {
|
|
||||||
val size = plaintext.encodeToByteArray().size
|
|
||||||
require(size <= MAX_PLAINTEXT_BYTES) {
|
|
||||||
"Concord: NIP-44 plaintext is $size bytes, over the $MAX_PLAINTEXT_BYTES cap"
|
|
||||||
}
|
|
||||||
return nip44Encrypt(key.secretKey, key.publicKey, plaintext, Nip44Version.V2)
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val MAX_PLAINTEXT_BYTES = 65_535
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
package su.reya.coop.concord
|
|
||||||
|
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import rust.nostr.sdk.EventBuilder
|
|
||||||
import rust.nostr.sdk.Filter
|
|
||||||
import rust.nostr.sdk.Keys
|
|
||||||
import rust.nostr.sdk.Kind
|
|
||||||
import rust.nostr.sdk.KindStandard
|
|
||||||
import rust.nostr.sdk.Tag
|
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
|
||||||
import su.reya.coop.AppStorage
|
|
||||||
import su.reya.coop.nostr.Nostr
|
|
||||||
|
|
||||||
class ConcordStore(private val storage: AppStorage, private val nostr: Nostr) {
|
|
||||||
suspend fun loadMemberships(): List<Membership> {
|
|
||||||
val raw = try {
|
|
||||||
storage.getSecret(MEMBERSHIPS_KEY)
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Concord: could not read memberships: ${e.message}")
|
|
||||||
return emptyList()
|
|
||||||
} ?: return emptyList()
|
|
||||||
|
|
||||||
return try {
|
|
||||||
concordJson.decodeFromString<List<Membership>>(raw)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Concord: stored memberships are unreadable: ${e.message}")
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun saveMemberships(memberships: List<Membership>) {
|
|
||||||
try {
|
|
||||||
storage.setSecret(MEMBERSHIPS_KEY, concordJson.encodeToString(memberships))
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Concord: could not store memberships: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun clearMemberships() {
|
|
||||||
try {
|
|
||||||
storage.clear(MEMBERSHIPS_KEY)
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Concord: could not clear memberships: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun cacheRumor(scopeIdHex: String, wrapIdHex: String, rumor: UnsignedEvent) {
|
|
||||||
try {
|
|
||||||
val event =
|
|
||||||
EventBuilder(Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA), rumor.asJson())
|
|
||||||
.tags(
|
|
||||||
listOf(
|
|
||||||
Tag.identifier(wrapIdHex),
|
|
||||||
Tag.custom(INDEX_SCOPE_TAG, listOf(scopeIdHex)),
|
|
||||||
Tag.custom(INDEX_KIND_TAG, listOf(rumor.kind().asU16().toString())),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.finalizeAsync(Keys.generate())
|
|
||||||
|
|
||||||
nostr.client?.database()?.saveEvent(event)
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Concord: failed to cache rumor: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every cached rumor for one scope, oldest first. */
|
|
||||||
suspend fun cachedRumors(scopeIdHex: String): List<UnsignedEvent> {
|
|
||||||
val events = try {
|
|
||||||
val filter = Filter()
|
|
||||||
.kind(Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA))
|
|
||||||
.reference(scopeIdHex)
|
|
||||||
nostr.client?.database()?.query(filter)?.toVec() ?: return emptyList()
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Concord: failed to read cached rumors: ${e.message}")
|
|
||||||
return emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
return events
|
|
||||||
.mapNotNull {
|
|
||||||
runCatching {
|
|
||||||
UnsignedEvent.fromJson(it.content()).ensureId()
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
.sortedBy { it.createdAt().asSecs() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val MEMBERSHIPS_KEY = "concord_memberships"
|
|
||||||
const val INDEX_SCOPE_TAG = "r"
|
|
||||||
const val INDEX_KIND_TAG = "k"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -39,11 +39,12 @@ class MessageManager(private val nostr: Nostr) {
|
|||||||
private val client: Client? get() = nostr.client
|
private val client: Client? get() = nostr.client
|
||||||
private val signer: UniversalSigner get() = nostr.signer
|
private val signer: UniversalSigner get() = nostr.signer
|
||||||
|
|
||||||
|
val sentEvents: MutableMap<EventId, List<RelayUrl>> = mutableMapOf()
|
||||||
|
val rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
|
||||||
|
|
||||||
private val _messageSyncState = MutableStateFlow(MessageSyncState())
|
private val _messageSyncState = MutableStateFlow(MessageSyncState())
|
||||||
val messageSyncState = _messageSyncState.asStateFlow()
|
val messageSyncState = _messageSyncState.asStateFlow()
|
||||||
|
|
||||||
val rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
|
|
||||||
|
|
||||||
fun updateSyncState(update: (MessageSyncState) -> MessageSyncState) {
|
fun updateSyncState(update: (MessageSyncState) -> MessageSyncState) {
|
||||||
_messageSyncState.update(update)
|
_messageSyncState.update(update)
|
||||||
}
|
}
|
||||||
@@ -144,15 +145,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 +177,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 +190,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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,6 +333,9 @@ class MessageManager(private val nostr: Nostr) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (output != null) {
|
if (output != null) {
|
||||||
|
// Keep track of sent events
|
||||||
|
sentEvents[output.id] = emptyList()
|
||||||
|
|
||||||
// Keep track of rumor IDs
|
// Keep track of rumor IDs
|
||||||
val id = rumor.id() ?: throw IllegalStateException("Rumor ID is null")
|
val id = rumor.id() ?: throw IllegalStateException("Rumor ID is null")
|
||||||
rumorMap[id] = output.id
|
rumorMap[id] = output.id
|
||||||
@@ -375,64 +352,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,39 +144,17 @@ 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
|
||||||
val isSelfMessage = rumor.author() == signer.publicKeyFlow.value
|
if (rumor != null) {
|
||||||
val isNew = rumor.createdAt().asSecs() >= now.asSecs()
|
if (rumor.createdAt().asSecs() >= now.asSecs()) {
|
||||||
|
|
||||||
if (isNew && !isSelfMessage) {
|
|
||||||
onNewMessage(rumor)
|
onNewMessage(rumor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,6 +174,8 @@ class Nostr(
|
|||||||
|
|
||||||
when (notification) {
|
when (notification) {
|
||||||
is ClientNotification.Message -> {
|
is ClientNotification.Message -> {
|
||||||
|
val relayUrl = notification.relayUrl
|
||||||
|
|
||||||
when (val message = notification.message.asEnum()) {
|
when (val message = notification.message.asEnum()) {
|
||||||
is RelayMessageEnum.EventMsg -> {
|
is RelayMessageEnum.EventMsg -> {
|
||||||
val event = message.event
|
val event = message.event
|
||||||
@@ -244,11 +213,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 -> {}
|
||||||
@@ -264,6 +229,14 @@ class Nostr(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is RelayMessageEnum.Ok -> {
|
||||||
|
if (messages.sentEvents.containsKey(message.eventId)) {
|
||||||
|
val currentRelays =
|
||||||
|
messages.sentEvents[message.eventId] ?: emptyList()
|
||||||
|
messages.sentEvents[message.eventId] = currentRelays + relayUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
/* Ignore other message types */
|
/* Ignore other message types */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,15 +22,13 @@ import kotlin.time.Duration
|
|||||||
class RelayManager(private val nostr: Nostr) {
|
class RelayManager(private val nostr: Nostr) {
|
||||||
companion object {
|
companion object {
|
||||||
val BOOTSTRAP_RELAYS = listOf(
|
val BOOTSTRAP_RELAYS = listOf(
|
||||||
"wss://relay.ditto.pub",
|
|
||||||
"wss://relay.primal.net",
|
"wss://relay.primal.net",
|
||||||
"wss://relay.nostr.net",
|
"wss://relay.ditto.pub",
|
||||||
"wss://profiles.nostr1.com",
|
"wss://user.kindpag.es",
|
||||||
)
|
)
|
||||||
|
|
||||||
val INDEXER_RELAY = listOf(
|
val INDEXER_RELAY = listOf(
|
||||||
"wss://indexer.coracle.social",
|
"wss://indexer.coracle.social",
|
||||||
"wss://user.kindpag.es",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
|
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
|
||||||
@@ -132,7 +130,7 @@ class RelayManager(private val nostr: Nostr) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setRelayList(relays: Map<RelayUrl, RelayMetadata?>) {
|
suspend fun setRelaylist(relays: Map<RelayUrl, RelayMetadata?>) {
|
||||||
try {
|
try {
|
||||||
val event = EventBuilder.relayList(relays).finalizeAsync(signer)
|
val event = EventBuilder.relayList(relays).finalizeAsync(signer)
|
||||||
|
|
||||||
@@ -145,7 +143,7 @@ class RelayManager(private val nostr: Nostr) {
|
|||||||
throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
|
throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setMsgRelays(urls: List<RelayUrl>) {
|
suspend fun setMsgRelays(urls: List<RelayUrl>) {
|
||||||
try {
|
try {
|
||||||
val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer)
|
val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer)
|
||||||
|
|||||||
@@ -47,17 +47,13 @@ data class AccountState(
|
|||||||
val signerRequired: Boolean? = null,
|
val signerRequired: Boolean? = null,
|
||||||
val isNotificationBannerDismissed: Boolean = false,
|
val isNotificationBannerDismissed: Boolean = false,
|
||||||
val isImporting: Boolean = false,
|
val isImporting: Boolean = false,
|
||||||
val isRelayListEmpty: Boolean = false,
|
val importError: String? = null,
|
||||||
val contactList: Set<PublicKey> = emptySet(),
|
|
||||||
val userRelayList: Map<RelayUrl, RelayMetadata?> = emptyMap(),
|
|
||||||
val userMsgRelayList: List<RelayUrl> = emptyList(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
class AccountRepository(
|
class AccountRepository(
|
||||||
private val nostr: Nostr,
|
private val nostr: Nostr,
|
||||||
private val storage: AppStorage,
|
private val storage: AppStorage,
|
||||||
private val mediaRepository: MediaRepository,
|
private val mediaRepository: MediaRepository,
|
||||||
private val settingsRepository: SettingsRepository,
|
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val externalSignerHandler: ExternalSignerHandler? = null,
|
private val externalSignerHandler: ExternalSignerHandler? = null,
|
||||||
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||||
@@ -76,9 +72,23 @@ class AccountRepository(
|
|||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
val currentUserProfile: StateFlow<Profile?> = nostr.signer.publicKeyFlow
|
val currentUserProfile: StateFlow<Profile?> = nostr.signer.publicKeyFlow
|
||||||
.flatMapLatest { if (it != null) currentUserProfileFlow(it) else flowOf(null) }
|
.flatMapLatest { pubkey ->
|
||||||
|
if (pubkey != null) currentUserProfileFlow(pubkey) else flowOf(null)
|
||||||
|
}
|
||||||
.stateIn(scope, SharingStarted.WhileSubscribed(5000), null)
|
.stateIn(scope, SharingStarted.WhileSubscribed(5000), null)
|
||||||
|
|
||||||
|
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
|
||||||
|
val contactList: StateFlow<Set<PublicKey>> = _contactList.asStateFlow()
|
||||||
|
|
||||||
|
private val _isRelayListEmpty = MutableStateFlow(false)
|
||||||
|
val isRelayListEmpty: StateFlow<Boolean> = _isRelayListEmpty.asStateFlow()
|
||||||
|
|
||||||
|
private val _userRelayList = MutableStateFlow<Map<RelayUrl, RelayMetadata?>>(emptyMap())
|
||||||
|
val userRelayList: StateFlow<Map<RelayUrl, RelayMetadata?>> = _userRelayList.asStateFlow()
|
||||||
|
|
||||||
|
private val _userMsgRelayList = MutableStateFlow<List<RelayUrl>>(emptyList())
|
||||||
|
val userMsgRelayList: StateFlow<List<RelayUrl>> = _userMsgRelayList.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
checkNotificationBannerDismissedStatus()
|
checkNotificationBannerDismissedStatus()
|
||||||
login()
|
login()
|
||||||
@@ -106,7 +116,7 @@ class AccountRepository(
|
|||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
|
|
||||||
// Automatically update the warning state
|
// Automatically update the warning state
|
||||||
_state.update { it.copy(isRelayListEmpty = relays.isEmpty()) }
|
_isRelayListEmpty.value = relays.isEmpty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,7 +229,7 @@ class AccountRepository(
|
|||||||
|
|
||||||
fun importIdentity(secret: String, password: String? = null) {
|
fun importIdentity(secret: String, password: String? = null) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
_state.update { it.copy(isImporting = true) }
|
_state.update { it.copy(isImporting = true, importError = null) }
|
||||||
try {
|
try {
|
||||||
val (signer, decryptedSecret) = createSigner(secret, password)
|
val (signer, decryptedSecret) = createSigner(secret, password)
|
||||||
|
|
||||||
@@ -229,14 +239,14 @@ class AccountRepository(
|
|||||||
_state.update { it.copy(signerRequired = false, isImporting = false) }
|
_state.update { it.copy(signerRequired = false, isImporting = false) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Import failed: ${e.message}")
|
showError("Import failed: ${e.message}")
|
||||||
_state.update { it.copy(isImporting = false) }
|
_state.update { it.copy(isImporting = false, importError = e.message) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun connectExternalSigner() {
|
fun connectExternalSigner() {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
_state.update { it.copy(isImporting = true) }
|
_state.update { it.copy(isImporting = true, importError = null) }
|
||||||
try {
|
try {
|
||||||
val handler =
|
val handler =
|
||||||
externalSignerHandler ?: throw IllegalStateException("Signer not available")
|
externalSignerHandler ?: throw IllegalStateException("Signer not available")
|
||||||
@@ -266,7 +276,7 @@ class AccountRepository(
|
|||||||
_state.update { it.copy(signerRequired = false, isImporting = false) }
|
_state.update { it.copy(signerRequired = false, isImporting = false) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("External signer connection failed: ${e.message}")
|
showError("External signer connection failed: ${e.message}")
|
||||||
_state.update { it.copy(isImporting = false) }
|
_state.update { it.copy(isImporting = false, importError = e.message) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -278,7 +288,7 @@ class AccountRepository(
|
|||||||
contentType: String? = null
|
contentType: String? = null
|
||||||
) {
|
) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
_state.update { it.copy(isImporting = true) }
|
_state.update { it.copy(isImporting = true, importError = null) }
|
||||||
try {
|
try {
|
||||||
val keys = Keys.generate()
|
val keys = Keys.generate()
|
||||||
val secret = keys.secretKey().toBech32()
|
val secret = keys.secretKey().toBech32()
|
||||||
@@ -297,7 +307,7 @@ class AccountRepository(
|
|||||||
_state.update { it.copy(signerRequired = false, isImporting = false) }
|
_state.update { it.copy(signerRequired = false, isImporting = false) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Identity creation failed: ${e.message}")
|
showError("Identity creation failed: ${e.message}")
|
||||||
_state.update { it.copy(isImporting = false) }
|
_state.update { it.copy(isImporting = false, importError = e.message) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -345,13 +355,14 @@ class AccountRepository(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
nostr.waitUntilInitialized()
|
nostr.waitUntilInitialized()
|
||||||
nostr.profiles.contactListUpdates.collect { contacts ->
|
nostr.profiles.contactListUpdates.collect { contacts ->
|
||||||
_state.update { it.copy(contactList = contacts.toSet()) }
|
_contactList.value = contacts.toSet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resetInternalState() {
|
fun resetInternalState() {
|
||||||
_state.update { it.copy(contactList = emptySet(), isRelayListEmpty = false) }
|
_contactList.value = emptySet()
|
||||||
|
_isRelayListEmpty.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addContact(address: String) {
|
fun addContact(address: String) {
|
||||||
@@ -367,12 +378,12 @@ class AccountRepository(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pubkey in _state.value.contactList) return@launch
|
if (pubkey in _contactList.value) return@launch
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val updated = _state.value.contactList + pubkey
|
val updated = _contactList.value + pubkey
|
||||||
nostr.profiles.setContactList(updated.toList())
|
nostr.profiles.setContactList(updated.toList())
|
||||||
_state.update { it.copy(contactList = it.contactList + pubkey) }
|
_contactList.update { it + pubkey }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -381,12 +392,12 @@ class AccountRepository(
|
|||||||
|
|
||||||
fun removeContact(publicKey: PublicKey) {
|
fun removeContact(publicKey: PublicKey) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (publicKey !in _state.value.contactList) return@launch
|
if (publicKey !in _contactList.value) return@launch
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val updated = _state.value.contactList - publicKey
|
val updated = _contactList.value - publicKey
|
||||||
nostr.profiles.setContactList(updated.toList())
|
nostr.profiles.setContactList(updated.toList())
|
||||||
_state.update { it.copy(contactList = it.contactList - publicKey) }
|
_contactList.update { it - publicKey }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -449,7 +460,7 @@ class AccountRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun dismissRelayWarning() {
|
fun dismissRelayWarning() {
|
||||||
_state.update { it.copy(isRelayListEmpty = false) }
|
_isRelayListEmpty.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refetchMsgRelays() {
|
fun refetchMsgRelays() {
|
||||||
@@ -476,8 +487,7 @@ class AccountRepository(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
val user = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
val user = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||||
val relayList = nostr.relays.getRelayList(user)
|
_userRelayList.value = nostr.relays.getRelayList(user)
|
||||||
_state.update { it.copy(userRelayList = relayList) }
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -501,7 +511,7 @@ class AccountRepository(
|
|||||||
val relays = currentUserRelayListInternal().toMutableMap()
|
val relays = currentUserRelayListInternal().toMutableMap()
|
||||||
relays[relayUrl] = RelayMetadata.WRITE
|
relays[relayUrl] = RelayMetadata.WRITE
|
||||||
|
|
||||||
nostr.relays.setRelayList(relays)
|
nostr.relays.setRelaylist(relays)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -515,7 +525,7 @@ class AccountRepository(
|
|||||||
val relays = currentUserRelayListInternal().toMutableMap()
|
val relays = currentUserRelayListInternal().toMutableMap()
|
||||||
relays[relayUrl] = RelayMetadata.READ
|
relays[relayUrl] = RelayMetadata.READ
|
||||||
|
|
||||||
nostr.relays.setRelayList(relays)
|
nostr.relays.setRelaylist(relays)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -529,7 +539,7 @@ class AccountRepository(
|
|||||||
val relays = currentUserRelayListInternal().toMutableMap()
|
val relays = currentUserRelayListInternal().toMutableMap()
|
||||||
relays.remove(relayUrl)
|
relays.remove(relayUrl)
|
||||||
|
|
||||||
nostr.relays.setRelayList(relays)
|
nostr.relays.setRelaylist(relays)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -540,8 +550,7 @@ class AccountRepository(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
val user = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
val user = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||||
val msgRelays = nostr.relays.getMsgRelays(user)
|
_userMsgRelayList.value = nostr.relays.getMsgRelays(user)
|
||||||
_state.update { it.copy(userMsgRelayList = msgRelays) }
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
@@ -22,7 +21,6 @@ import rust.nostr.sdk.PublicKey
|
|||||||
import rust.nostr.sdk.Tag
|
import rust.nostr.sdk.Tag
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import su.reya.coop.Room
|
import su.reya.coop.Room
|
||||||
import su.reya.coop.RoomKind
|
|
||||||
import su.reya.coop.nostr.Nostr
|
import su.reya.coop.nostr.Nostr
|
||||||
import su.reya.coop.roomId
|
import su.reya.coop.roomId
|
||||||
import su.reya.coop.viewmodel.ErrorHost
|
import su.reya.coop.viewmodel.ErrorHost
|
||||||
@@ -36,12 +34,15 @@ data class ChatState(
|
|||||||
class ChatRepository(
|
class ChatRepository(
|
||||||
private val nostr: Nostr,
|
private val nostr: Nostr,
|
||||||
private val mediaRepository: MediaRepository,
|
private val mediaRepository: MediaRepository,
|
||||||
private val settingsRepository: SettingsRepository,
|
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||||
) : ErrorHost by createErrorHost() {
|
) : ErrorHost by createErrorHost() {
|
||||||
private val _state = MutableStateFlow(ChatState())
|
private val _state = MutableStateFlow(ChatState())
|
||||||
val state = _state.asStateFlow()
|
val state = _state.stateIn(
|
||||||
|
scope,
|
||||||
|
SharingStarted.WhileSubscribed(5000),
|
||||||
|
ChatState()
|
||||||
|
)
|
||||||
|
|
||||||
private val _newEvents = MutableSharedFlow<UnsignedEvent>(
|
private val _newEvents = MutableSharedFlow<UnsignedEvent>(
|
||||||
replay = 0,
|
replay = 0,
|
||||||
@@ -84,7 +85,18 @@ class ChatRepository(
|
|||||||
// Observe new messages
|
// Observe new messages
|
||||||
launch {
|
launch {
|
||||||
nostr.newEvents.collect { event ->
|
nostr.newEvents.collect { event ->
|
||||||
updateRoomState(event)
|
val roomId = event.roomId()
|
||||||
|
val existingRoom = _state.value.rooms[roomId]
|
||||||
|
|
||||||
|
if (existingRoom == null) {
|
||||||
|
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect
|
||||||
|
val newRoom = Room.new(event, currentUser)
|
||||||
|
_state.update { it.copy(rooms = it.rooms + (newRoom.id to newRoom)) }
|
||||||
|
} else {
|
||||||
|
updateRoomList(roomId, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
_newEvents.tryEmit(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,39 +142,13 @@ class ChatRepository(
|
|||||||
return _state.value.rooms[id]
|
return _state.value.rooms[id]
|
||||||
}
|
}
|
||||||
|
|
||||||
fun markAsRead(roomId: Long) {
|
|
||||||
_state.update { currentState ->
|
|
||||||
val rooms = currentState.rooms.toMutableMap()
|
|
||||||
val room = rooms[roomId]
|
|
||||||
if (room != null && room.unreadCount > 0) {
|
|
||||||
rooms[roomId] = room.copy(unreadCount = 0)
|
|
||||||
currentState.copy(rooms = rooms)
|
|
||||||
} else {
|
|
||||||
currentState
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refreshChatRooms() {
|
fun refreshChatRooms() {
|
||||||
scope.launch(defaultDispatcher) {
|
scope.launch(defaultDispatcher) {
|
||||||
try {
|
try {
|
||||||
val dbRooms = nostr.messages.getChatRooms() ?: emptySet()
|
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||||
_state.update { currentState ->
|
_state.update { currentState ->
|
||||||
val newMap = currentState.rooms.toMutableMap()
|
val newMap = currentState.rooms.toMutableMap()
|
||||||
dbRooms.forEach { dbRoom ->
|
rooms.forEach { room -> newMap[room.id] = room }
|
||||||
val existing = newMap[dbRoom.id]
|
|
||||||
// Only update if the database version is newer or equal
|
|
||||||
if (existing == null || dbRoom.createdAt.asSecs() >= existing.createdAt.asSecs()) {
|
|
||||||
// Preserve Ongoing kind and unreadCount status if already marked as such in memory
|
|
||||||
val mergedKind =
|
|
||||||
if (existing?.kind == RoomKind.Ongoing) RoomKind.Ongoing else dbRoom.kind
|
|
||||||
val mergedUnreadCount = existing?.unreadCount ?: 0
|
|
||||||
newMap[dbRoom.id] = dbRoom.copy(
|
|
||||||
kind = mergedKind,
|
|
||||||
unreadCount = mergedUnreadCount
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
currentState.copy(rooms = newMap)
|
currentState.copy(rooms = newMap)
|
||||||
}
|
}
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
@@ -211,10 +197,9 @@ class ChatRepository(
|
|||||||
content = message,
|
content = message,
|
||||||
subject = room.subject,
|
subject = room.subject,
|
||||||
replies = replies,
|
replies = replies,
|
||||||
onRumorCreated = {
|
onRumorCreated = { event ->
|
||||||
scope.launch(defaultDispatcher) {
|
updateRoomList(roomId, event)
|
||||||
updateRoomState(it, roomId)
|
scope.launch(defaultDispatcher) { _newEvents.tryEmit(event) }
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -241,67 +226,26 @@ class ChatRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendReaction(roomId: Long, targetEventId: EventId, reaction: String) {
|
fun isMessageSent(id: EventId): Boolean {
|
||||||
scope.launch(defaultDispatcher) {
|
val giftWrapId = nostr.messages.rumorMap[id]
|
||||||
try {
|
|
||||||
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
if (giftWrapId != null) {
|
||||||
nostr.messages.sendReaction(
|
val isSent = nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false
|
||||||
to = room.members,
|
return isSent
|
||||||
targetEventId = targetEventId,
|
} else {
|
||||||
reaction = reaction,
|
return false
|
||||||
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 fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
|
||||||
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 room = currentState.rooms[roomId] ?: return@update currentState
|
||||||
val existingRoom = rooms[roomId]
|
val updatedRoom = room.copy(
|
||||||
|
lastMessage = newMessage.content(),
|
||||||
val isFromMe = event.author() == currentUser
|
createdAt = newMessage.createdAt()
|
||||||
val newKind =
|
)
|
||||||
if (isFromMe) RoomKind.Ongoing else (existingRoom?.kind ?: RoomKind.Request)
|
currentState.copy(rooms = currentState.rooms + (roomId to updatedRoom))
|
||||||
|
|
||||||
if (existingRoom == null) {
|
|
||||||
// New room discovery
|
|
||||||
val newRoom = Room.new(event, currentUser, roomId).copy(
|
|
||||||
kind = newKind,
|
|
||||||
unreadCount = if (isFromMe || isReaction) 0 else 1,
|
|
||||||
lastMessage = if (isReaction) null else event.content()
|
|
||||||
)
|
|
||||||
rooms[newRoom.id] = newRoom
|
|
||||||
} else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) {
|
|
||||||
// Update timestamp for any newer event (DM or Reaction)
|
|
||||||
// But only update preview if it's a DM
|
|
||||||
rooms[roomId] = existingRoom.copy(
|
|
||||||
lastMessage = if (isReaction) existingRoom.lastMessage else event.content(),
|
|
||||||
createdAt = event.createdAt(),
|
|
||||||
kind = newKind,
|
|
||||||
unreadCount = if (isFromMe || isReaction) existingRoom.unreadCount else existingRoom.unreadCount + 1
|
|
||||||
)
|
|
||||||
} else if (isFromMe && existingRoom.kind != RoomKind.Ongoing) {
|
|
||||||
// Even if it's an older message or reaction, if it's from me, the room is ongoing
|
|
||||||
rooms[roomId] = existingRoom.copy(kind = RoomKind.Ongoing)
|
|
||||||
} else {
|
|
||||||
// Don't update the room list state for older messages or reactions that don't change preview
|
|
||||||
return@update currentState
|
|
||||||
}
|
|
||||||
currentState.copy(rooms = rooms)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify subscribers about the new event (for the active chat screen)
|
|
||||||
_newEvents.tryEmit(event)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resetInternalState() {
|
fun resetInternalState() {
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
package su.reya.coop.repository
|
|
||||||
|
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import su.reya.coop.concord.CommunityInvite
|
|
||||||
import su.reya.coop.concord.CommunityState
|
|
||||||
import su.reya.coop.concord.ConcordMessage
|
|
||||||
import su.reya.coop.concord.InvitePreview
|
|
||||||
import su.reya.coop.nostr.Nostr
|
|
||||||
import su.reya.coop.viewmodel.ErrorHost
|
|
||||||
import su.reya.coop.viewmodel.createErrorHost
|
|
||||||
|
|
||||||
class ConcordRepository(
|
|
||||||
private val nostr: Nostr,
|
|
||||||
private val scope: CoroutineScope,
|
|
||||||
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
|
||||||
) : ErrorHost by createErrorHost() {
|
|
||||||
private val concord = nostr.concord
|
|
||||||
|
|
||||||
val communities: StateFlow<List<CommunityState>> = concord.communities
|
|
||||||
|
|
||||||
/** Direct Invites that arrived giftwrapped to us and have not been accepted or dismissed. */
|
|
||||||
val directInvites: StateFlow<List<CommunityInvite>> = concord.directInvites
|
|
||||||
|
|
||||||
/** True once memberships have been loaded, so an empty list means "joined nothing". */
|
|
||||||
val isReady: StateFlow<Boolean> = concord.restored
|
|
||||||
|
|
||||||
/** Bumped whenever a plane rumor lands, so an open Channel knows to re-read its history. */
|
|
||||||
val revision: StateFlow<Long> = concord.revision
|
|
||||||
|
|
||||||
/** Fetches and decrypts the bundle behind a public invite link. */
|
|
||||||
suspend fun previewInvite(link: String): InvitePreview? = attempt { concord.previewInvite(link) }
|
|
||||||
|
|
||||||
/** The same preview for a Direct Invite, which already arrived with its bundle in hand. */
|
|
||||||
fun previewDirectInvite(invite: CommunityInvite): InvitePreview =
|
|
||||||
concord.previewDirectInvite(invite)
|
|
||||||
|
|
||||||
/** Accepts an invite: persist the keys, connect, subscribe, announce the join. */
|
|
||||||
suspend fun join(preview: InvitePreview): CommunityState? = attempt { concord.join(preview) }
|
|
||||||
|
|
||||||
/** A Channel's history, oldest first, from the local cache the live subscription keeps current. */
|
|
||||||
suspend fun channelMessages(channelIdHex: String): List<ConcordMessage> =
|
|
||||||
attempt { concord.channelMessages(channelIdHex) }.orEmpty()
|
|
||||||
|
|
||||||
suspend fun sendMessage(channelIdHex: String, content: String): ConcordMessage? =
|
|
||||||
attempt { concord.sendChannelMessage(channelIdHex, content) }
|
|
||||||
|
|
||||||
/** Reacts to one message; `targetAuthorHex` is the message's author, which rides the `p` tag. */
|
|
||||||
suspend fun sendReaction(
|
|
||||||
channelIdHex: String,
|
|
||||||
targetIdHex: String,
|
|
||||||
targetAuthorHex: String,
|
|
||||||
reaction: String,
|
|
||||||
) {
|
|
||||||
attempt { concord.sendChannelReaction(channelIdHex, targetIdHex, targetAuthorHex, reaction) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Replaces one of our own messages with [content]. */
|
|
||||||
suspend fun editMessage(channelIdHex: String, targetIdHex: String, content: String) {
|
|
||||||
attempt { concord.sendChannelEdit(channelIdHex, targetIdHex, content) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clears a Channel's badge — the Channel screen calls this once its messages are on display. */
|
|
||||||
fun markChannelRead(channelIdHex: String) = concord.markChannelRead(channelIdHex)
|
|
||||||
|
|
||||||
fun dismissDirectInvite(communityIdHex: String) = concord.dismissDirectInvite(communityIdHex)
|
|
||||||
|
|
||||||
fun resetInternalState() {
|
|
||||||
scope.launch(defaultDispatcher) { attempt { concord.reset() } }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Runs one Concord call off the main thread, reporting any failure instead of throwing. */
|
|
||||||
private suspend fun <T> attempt(block: suspend () -> T): T? = withContext(defaultDispatcher) {
|
|
||||||
try {
|
|
||||||
block()
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,9 +8,7 @@ import kotlinx.serialization.json.Json
|
|||||||
import rust.nostr.sdk.AsyncNostrSigner
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
import su.reya.coop.blossom.BlossomClient
|
import su.reya.coop.blossom.BlossomClient
|
||||||
|
|
||||||
class MediaRepository(
|
class MediaRepository {
|
||||||
private val settingsRepository: SettingsRepository
|
|
||||||
) {
|
|
||||||
private val httpClient = HttpClient {
|
private val httpClient = HttpClient {
|
||||||
install(ContentNegotiation) {
|
install(ContentNegotiation) {
|
||||||
json(Json {
|
json(Json {
|
||||||
@@ -27,9 +25,12 @@ class MediaRepository(
|
|||||||
contentType: String? = "image/jpeg"
|
contentType: String? = "image/jpeg"
|
||||||
): String? {
|
): String? {
|
||||||
return try {
|
return try {
|
||||||
val url = settingsRepository.settings.value.blossomServer ?: "https://blossom.band"
|
val blossom = BlossomClient(url = "https://blossom.band", client = httpClient)
|
||||||
val blossom = BlossomClient(url, httpClient)
|
val descriptor = blossom.upload(
|
||||||
val descriptor = blossom.upload(file = file, contentType = contentType, signer = signer)
|
file = file,
|
||||||
|
contentType = contentType,
|
||||||
|
signer = signer,
|
||||||
|
)
|
||||||
descriptor?.url
|
descriptor?.url
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
package su.reya.coop.repository
|
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import su.reya.coop.AppStorage
|
|
||||||
import su.reya.coop.Settings
|
|
||||||
|
|
||||||
class SettingsRepository(
|
|
||||||
private val storage: AppStorage,
|
|
||||||
private val scope: CoroutineScope
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
private const val KEY_SETTINGS = "app_settings"
|
|
||||||
}
|
|
||||||
|
|
||||||
private val json = Json {
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
encodeDefaults = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private val _settings = MutableStateFlow(Settings())
|
|
||||||
val settings: StateFlow<Settings> = _settings.asStateFlow()
|
|
||||||
|
|
||||||
init {
|
|
||||||
scope.launch {
|
|
||||||
_settings.value = load()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun update(transform: (Settings) -> Settings) {
|
|
||||||
scope.launch {
|
|
||||||
val newSettings = transform(_settings.value)
|
|
||||||
_settings.value = newSettings
|
|
||||||
save(newSettings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun save(settings: Settings) {
|
|
||||||
val jsonString = json.encodeToString(settings)
|
|
||||||
storage.set(KEY_SETTINGS, jsonString)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun load(): Settings {
|
|
||||||
val jsonString = storage.get(KEY_SETTINGS)
|
|
||||||
return if (jsonString != null) {
|
|
||||||
try {
|
|
||||||
json.decodeFromString<Settings>(jsonString)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
Settings()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Settings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,8 @@ package su.reya.coop.viewmodel
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.RelayMetadata
|
||||||
|
import rust.nostr.sdk.RelayUrl
|
||||||
import rust.nostr.sdk.Timestamp
|
import rust.nostr.sdk.Timestamp
|
||||||
import su.reya.coop.Profile
|
import su.reya.coop.Profile
|
||||||
import su.reya.coop.repository.AccountRepository
|
import su.reya.coop.repository.AccountRepository
|
||||||
@@ -14,6 +16,10 @@ class AccountViewModel(
|
|||||||
val state: StateFlow<AccountState> = repository.state
|
val state: StateFlow<AccountState> = repository.state
|
||||||
val isUpdatingProfile: StateFlow<Boolean> = repository.isUpdatingProfile
|
val isUpdatingProfile: StateFlow<Boolean> = repository.isUpdatingProfile
|
||||||
val currentUserProfile: StateFlow<Profile?> = repository.currentUserProfile
|
val currentUserProfile: StateFlow<Profile?> = repository.currentUserProfile
|
||||||
|
val contactList: StateFlow<Set<PublicKey>> = repository.contactList
|
||||||
|
val isRelayListEmpty: StateFlow<Boolean> = repository.isRelayListEmpty
|
||||||
|
val userRelayList: StateFlow<Map<RelayUrl, RelayMetadata?>> = repository.userRelayList
|
||||||
|
val userMsgRelayList: StateFlow<List<RelayUrl>> = repository.userMsgRelayList
|
||||||
|
|
||||||
fun logout(onLogout: () -> Unit = {}) = repository.logout(onLogout)
|
fun logout(onLogout: () -> Unit = {}) = repository.logout(onLogout)
|
||||||
fun dismissNotificationBanner() = repository.dismissNotificationBanner()
|
fun dismissNotificationBanner() = repository.dismissNotificationBanner()
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
package su.reya.coop.viewmodel
|
|
||||||
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.drop
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import su.reya.coop.Profile
|
|
||||||
import su.reya.coop.concord.ConcordChannel
|
|
||||||
import su.reya.coop.concord.ConcordMessage
|
|
||||||
import su.reya.coop.repository.AccountRepository
|
|
||||||
import su.reya.coop.repository.ConcordRepository
|
|
||||||
|
|
||||||
class ChannelScreenViewModel(
|
|
||||||
val communityId: String,
|
|
||||||
val channelId: String,
|
|
||||||
accountRepository: AccountRepository,
|
|
||||||
private val concordRepository: ConcordRepository,
|
|
||||||
) : ViewModel(), ErrorHost by concordRepository {
|
|
||||||
val currentUser: StateFlow<Profile?> = accountRepository.currentUserProfile
|
|
||||||
|
|
||||||
val channel: StateFlow<ConcordChannel?> = concordRepository.communities
|
|
||||||
.map { communities ->
|
|
||||||
communities
|
|
||||||
.firstOrNull { it.membership.communityId == communityId }
|
|
||||||
?.channels
|
|
||||||
?.firstOrNull { it.idHex == channelId }
|
|
||||||
}
|
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
|
|
||||||
|
|
||||||
var loading by mutableStateOf(true)
|
|
||||||
val messages = mutableStateListOf<ConcordMessage>()
|
|
||||||
|
|
||||||
private var reloadJob: Job? = null
|
|
||||||
|
|
||||||
init {
|
|
||||||
reload()
|
|
||||||
|
|
||||||
viewModelScope.launch {
|
|
||||||
concordRepository.revision.drop(1).collect { reload() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun reload() {
|
|
||||||
reloadJob?.cancel()
|
|
||||||
reloadJob = viewModelScope.launch {
|
|
||||||
val loaded = concordRepository.channelMessages(channelId)
|
|
||||||
// The whole list, not just its ids: a reaction or an edit changes a message without
|
|
||||||
// changing which messages there are.
|
|
||||||
if (messages != loaded) {
|
|
||||||
messages.clear()
|
|
||||||
messages.addAll(loaded)
|
|
||||||
}
|
|
||||||
loading = false
|
|
||||||
concordRepository.markChannelRead(channelId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendMessage(text: String) {
|
|
||||||
if (text.isBlank()) return
|
|
||||||
viewModelScope.launch { concordRepository.sendMessage(channelId, text) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendReaction(message: ConcordMessage, reaction: String) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
concordRepository.sendReaction(channelId, message.idHex, message.author, reaction)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Replaces one of our own messages; [targetIdHex] is the message being edited. */
|
|
||||||
fun editMessage(targetIdHex: String, text: String) {
|
|
||||||
if (text.isBlank()) return
|
|
||||||
viewModelScope.launch { concordRepository.editMessage(channelId, targetIdHex, text) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -42,8 +42,6 @@ class ChatScreenViewModel(
|
|||||||
messages.clear()
|
messages.clear()
|
||||||
messages.addAll(initialMessages.distinctBy { it.id() })
|
messages.addAll(initialMessages.distinctBy { it.id() })
|
||||||
loading = false
|
loading = false
|
||||||
// Mark the room as read once messages are loaded
|
|
||||||
chatRepository.markAsRead(id)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +55,6 @@ class ChatScreenViewModel(
|
|||||||
if (event.roomId() == id) {
|
if (event.roomId() == id) {
|
||||||
if (messages.none { it.id() == event.id() }) {
|
if (messages.none { it.id() == event.id() }) {
|
||||||
messages.add(0, event)
|
messages.add(0, event)
|
||||||
chatRepository.markAsRead(id)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
newOtherMessages++
|
newOtherMessages++
|
||||||
@@ -74,8 +71,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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
package su.reya.coop.viewmodel
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import su.reya.coop.concord.CommunityInvite
|
|
||||||
import su.reya.coop.concord.CommunityState
|
|
||||||
import su.reya.coop.concord.InvitePreview
|
|
||||||
import su.reya.coop.repository.ConcordRepository
|
|
||||||
|
|
||||||
class ConcordViewModel(
|
|
||||||
private val repository: ConcordRepository,
|
|
||||||
) : ViewModel(), ErrorHost by repository {
|
|
||||||
val communities: StateFlow<List<CommunityState>> = repository.communities
|
|
||||||
val directInvites: StateFlow<List<CommunityInvite>> = repository.directInvites
|
|
||||||
val isReady: StateFlow<Boolean> = repository.isReady
|
|
||||||
|
|
||||||
fun previewDirectInvite(invite: CommunityInvite): InvitePreview =
|
|
||||||
repository.previewDirectInvite(invite)
|
|
||||||
|
|
||||||
suspend fun previewInvite(link: String): InvitePreview? = repository.previewInvite(link)
|
|
||||||
|
|
||||||
suspend fun join(preview: InvitePreview): CommunityState? = repository.join(preview)
|
|
||||||
|
|
||||||
fun dismissDirectInvite(communityIdHex: String) = repository.dismissDirectInvite(communityIdHex)
|
|
||||||
|
|
||||||
fun resetInternalState() = repository.resetInternalState()
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package su.reya.coop.viewmodel
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import su.reya.coop.Settings
|
|
||||||
import su.reya.coop.repository.SettingsRepository
|
|
||||||
|
|
||||||
class SettingsViewModel(
|
|
||||||
private val repository: SettingsRepository
|
|
||||||
) : ViewModel() {
|
|
||||||
val settings: StateFlow<Settings> = repository.settings
|
|
||||||
|
|
||||||
fun update(transform: (Settings) -> Settings) {
|
|
||||||
repository.update(transform)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user