Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e4eb697a4 | ||
|
|
5b72b07ca8 | ||
|
|
63ef074e8f | ||
|
|
9230628441 | ||
|
|
871efa6b6f | ||
|
|
8b0cbd294e | ||
|
|
e221eda546 | ||
|
|
e49168851e | ||
|
|
b6f4a97778 | ||
|
|
7009dbf692 | ||
|
|
9a2bd3816f | ||
|
|
44c743d039 | ||
|
|
a124dc4cc4 | ||
|
|
4a8134dec0 | ||
|
|
07085449a4 | ||
|
|
9defad522c | ||
|
|
47196dbea4 | ||
|
|
30191170f1 | ||
|
|
1485a767ed | ||
|
|
0e789b5d91 | ||
|
|
410615bd89 | ||
|
|
a5951e03cf | ||
|
|
175c06a1a8 | ||
|
|
c7a646ae73 | ||
|
|
7a44e836a9 | ||
|
|
4dae4045ae | ||
|
|
9df4789b62 | ||
|
|
ea7e3fd3ed | ||
|
|
a76c8d117b | ||
|
|
9ed29c90ba | ||
|
|
80c6426d27 | ||
|
|
801347ccb1 | ||
|
|
19daea119d | ||
|
|
8b2f0faa59 | ||
|
|
a2d28ecc40 | ||
|
|
cd5a393a01 | ||
|
|
3bfe2308ba | ||
|
|
eed9d401da | ||
|
|
3943e2baab | ||
|
|
d5ea5570b6 | ||
|
|
26f9e577f4 | ||
|
|
dd416711e2 | ||
|
|
b90279e174 | ||
|
|
25ba03562a | ||
|
|
010a8b4060 | ||
|
|
750d46bc9e | ||
|
|
790d24c24b | ||
|
|
4fbc8446d3 | ||
|
|
b4518ce360 | ||
|
|
0f4bb71a39 |
@@ -17,3 +17,4 @@ captures
|
||||
!*.xcworkspace/contents.xcworkspacedata
|
||||
**/xcshareddata/WorkspaceSettings.xcsettings
|
||||
node_modules/
|
||||
.artifacts/
|
||||
@@ -0,0 +1,93 @@
|
||||
### [2026-06-20] v0.2.0 (Stable)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Fixed the issue that cause app crashing
|
||||
* Added screener for new message requests
|
||||
* Added request messages screen
|
||||
* Added contact list screen
|
||||
|
||||
---
|
||||
|
||||
### [2026-06-12] v0.1.9 (Pre-Release)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Fixed the issue that cause app crashing
|
||||
* Added relay management
|
||||
|
||||
---
|
||||
|
||||
### [2026-06-09] v0.1.8 (Pre-Release)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Added support for NIP-55 (use external signer like Amber)
|
||||
|
||||
---
|
||||
|
||||
### [2026-06-07] v0.1.7 (Pre-Release)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Added a screen for update profile
|
||||
* Fixed occasional splash screen hang
|
||||
* Improved the battery usage in background
|
||||
|
||||
---
|
||||
|
||||
### [2026-06-04] v0.1.6 (Pre-Release)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Added self chat
|
||||
* Fixed empty chat rooms on refreshes
|
||||
* Fixed crash on startup
|
||||
|
||||
---
|
||||
|
||||
### [2026-06-03] v0.1.5 (Pre-Release)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Added crash report screen
|
||||
* Added notification permission banner
|
||||
* Fixed navigation issues after create or import identity
|
||||
|
||||
---
|
||||
|
||||
### [2026-06-01] v0.1.4 (Pre-Release)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Added the deep link to allow user open chat screen directly via notification
|
||||
* Fixed the issue cause app crash if room has no member
|
||||
* Improve stability and performance
|
||||
|
||||
---
|
||||
|
||||
### [2026-05-29] v0.1.3 (Pre-Release)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Added basic notification when the app in background
|
||||
* Redesign the splash screen
|
||||
* Redesign the onboarding screen
|
||||
* Improved UX for the new identity creation and import screen
|
||||
* Fixed some issues cause app crashes
|
||||
|
||||
---
|
||||
|
||||
### [2026-05-24] v0.1.2 (Pre-Release)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Fixed the issue that cause app crash when open new chat room via QR
|
||||
|
||||
---
|
||||
|
||||
### [2026-05-23] v0.1.1 (Pre-Release)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Fixed the issue that cause app crash on startup
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 296 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 444 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 212 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
@@ -24,9 +24,9 @@ kotlin {
|
||||
implementation(libs.jetbrains.navigation3.ui)
|
||||
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
implementation("su.reya:nostr-sdk-kmp:0.3.1")
|
||||
implementation("io.coil-kt.coil3:coil-compose:3.4.0")
|
||||
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
|
||||
implementation("su.reya:nostr-sdk-kmp:0.3.2")
|
||||
implementation("io.coil-kt.coil3:coil-compose:3.5.0")
|
||||
implementation("io.coil-kt.coil3:coil-network-okhttp:3.5.0")
|
||||
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
|
||||
implementation("io.github.alexzhirkevich:qrose:1.1.2")
|
||||
}
|
||||
@@ -69,7 +69,7 @@ android {
|
||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||
versionCode = 1
|
||||
versionName = "0.2.0"
|
||||
versionName = "0.2.6"
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
android:required="false" />
|
||||
|
||||
<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_REMOTE_MESSAGING" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
@@ -36,7 +38,8 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.App.Starting">
|
||||
android:theme="@style/Theme.App.Starting"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<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="M440,680L520,680L520,520L680,520L680,440L520,440L520,280L440,280L440,440L280,440L280,520L440,520L440,680ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<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="M280,720L280,240L360,240L360,720L280,720ZM440,880L440,80L520,80L520,880L440,880ZM120,560L120,400L200,400L200,560L120,560ZM600,720L600,240L680,240L680,720L600,720ZM760,560L760,400L840,400L840,560L760,560Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,18 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#000000"
|
||||
android:strokeWidth="56"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="M120,220 Q120,120 220,120 L740,120 Q840,120 840,220 L840,740 Q840,840 740,840 L220,840 Q120,840 120,740 Z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#000000"
|
||||
android:strokeWidth="72"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="M400,320 L400,640 M560,320 L560,640 M320,400 L640,400 M320,560 L640,560" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<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="M360,720Q327,720 303.5,696.5Q280,673 280,640L280,160Q280,127 303.5,103.5Q327,80 360,80L720,80Q753,80 776.5,103.5Q800,127 800,160L800,640Q800,673 776.5,696.5Q753,720 720,720L360,720ZM360,640L720,640Q720,640 720,640Q720,640 720,640L720,160Q720,160 720,160Q720,160 720,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640ZM200,880Q167,880 143.5,856.5Q120,833 120,800L120,240L200,240L200,800Q200,800 200,800Q200,800 200,800L640,800L640,880L200,880ZM360,640Q360,640 360,640Q360,640 360,640L360,160Q360,160 360,160Q360,160 360,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<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="M440,680L520,680L520,440L440,440L440,680ZM508.5,348.5Q520,337 520,320Q520,303 508.5,291.5Q497,280 480,280Q463,280 451.5,291.5Q440,303 440,320Q440,337 451.5,348.5Q463,360 480,360Q497,360 508.5,348.5ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,15 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M240,500 Q240,440 300,440 L660,440 Q720,440 720,500 L720,780 Q720,840 660,840 L300,840 Q240,840 240,780 Z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#000000"
|
||||
android:strokeWidth="64"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="M340,500 L340,440 A140,140 0 0 1 620,440 L620,500" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:autoMirrored="true"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M480,840L480,760L760,760Q760,760 760,760Q760,760 760,760L760,200Q760,200 760,200Q760,200 760,200L480,200L480,120L760,120Q793,120 816.5,143.5Q840,167 840,200L840,760Q840,793 816.5,816.5Q793,840 760,840L480,840ZM400,680L345,622L447,520L120,520L120,440L447,440L345,338L400,280L600,480L400,680Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:autoMirrored="true"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M760,760L760,600Q760,550 725,515Q690,480 640,480L273,480L417,624L360,680L120,440L360,200L417,256L273,400L640,400Q723,400 781.5,458.5Q840,517 840,600L840,760L760,760Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,49 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import android.content.Intent
|
||||
import androidx.core.net.toUri
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import su.reya.coop.nostr.ExternalSignerHandler
|
||||
import su.reya.coop.nostr.ExternalSignerResult
|
||||
|
||||
class AndroidExternalSigner(
|
||||
private val context: Context,
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
@@ -26,7 +25,10 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.util.Consumer
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
@@ -34,7 +36,11 @@ import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import su.reya.coop.screens.ChatScreen
|
||||
import kotlinx.coroutines.launch
|
||||
import su.reya.coop.repository.AccountRepository
|
||||
import su.reya.coop.repository.ChatRepository
|
||||
import su.reya.coop.repository.ConcordRepository
|
||||
import su.reya.coop.repository.SettingsRepository
|
||||
import su.reya.coop.screens.ContactListScreen
|
||||
import su.reya.coop.screens.HomeScreen
|
||||
import su.reya.coop.screens.ImportScreen
|
||||
@@ -46,10 +52,31 @@ import su.reya.coop.screens.ProfileScreen
|
||||
import su.reya.coop.screens.RelayScreen
|
||||
import su.reya.coop.screens.RequestListScreen
|
||||
import su.reya.coop.screens.ScanScreen
|
||||
import su.reya.coop.screens.SettingsScreen
|
||||
import su.reya.coop.screens.UpdateProfileScreen
|
||||
import su.reya.coop.screens.chat.ChatScreen
|
||||
import su.reya.coop.screens.communities.ChannelScreen
|
||||
import su.reya.coop.screens.communities.CommunitiesScreen
|
||||
import su.reya.coop.screens.communities.CommunityScreen
|
||||
import su.reya.coop.screens.communities.JoinCommunityScreen
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
import su.reya.coop.viewmodel.ChannelScreenViewModel
|
||||
import su.reya.coop.viewmodel.ChatScreenViewModel
|
||||
import su.reya.coop.viewmodel.ChatViewModel
|
||||
import su.reya.coop.viewmodel.ConcordViewModel
|
||||
import su.reya.coop.viewmodel.ProfileCache
|
||||
import su.reya.coop.viewmodel.SettingsViewModel
|
||||
|
||||
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
|
||||
error("No NostrViewModel provided")
|
||||
val LocalProfileCache = staticCompositionLocalOf<ProfileCache> {
|
||||
error("No ProfileCache provided")
|
||||
}
|
||||
|
||||
val LocalSettings = staticCompositionLocalOf<Settings> {
|
||||
error("No Settings provided")
|
||||
}
|
||||
|
||||
val LocalConnectivity = staticCompositionLocalOf<Boolean> {
|
||||
false
|
||||
}
|
||||
|
||||
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
||||
@@ -66,28 +93,78 @@ val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun App(viewModel: NostrViewModel) {
|
||||
fun App(
|
||||
profileCache: ProfileCache,
|
||||
accountRepository: AccountRepository,
|
||||
chatRepository: ChatRepository,
|
||||
concordRepository: ConcordRepository,
|
||||
settingsRepository: SettingsRepository,
|
||||
connectivityMonitor: ConnectivityMonitor,
|
||||
) {
|
||||
val viewModelFactory = remember {
|
||||
object : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
val result = when {
|
||||
modelClass.isAssignableFrom(ChatViewModel::class.java) -> ChatViewModel(
|
||||
chatRepository
|
||||
)
|
||||
|
||||
modelClass.isAssignableFrom(AccountViewModel::class.java) -> AccountViewModel(
|
||||
accountRepository
|
||||
)
|
||||
|
||||
modelClass.isAssignableFrom(SettingsViewModel::class.java) -> SettingsViewModel(
|
||||
settingsRepository
|
||||
)
|
||||
|
||||
modelClass.isAssignableFrom(ConcordViewModel::class.java) -> ConcordViewModel(
|
||||
concordRepository
|
||||
)
|
||||
|
||||
else -> throw IllegalArgumentException("Unknown ViewModel class")
|
||||
}
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return result as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val accountViewModel: AccountViewModel = viewModel(factory = viewModelFactory)
|
||||
val chatViewModel: ChatViewModel = viewModel(factory = viewModelFactory)
|
||||
val settingsViewModel: SettingsViewModel = viewModel(factory = viewModelFactory)
|
||||
val concordViewModel: ConcordViewModel = viewModel(factory = viewModelFactory)
|
||||
|
||||
val context = LocalContext.current
|
||||
val activity = context as? ComponentActivity
|
||||
val backStack = rememberNavBackStack(Screen.Home)
|
||||
val navigator = remember(backStack) { Navigator(backStack) }
|
||||
val qrScanResult = remember { QrScanResult() }
|
||||
|
||||
val signerRequired by viewModel.signerRequired.collectAsStateWithLifecycle()
|
||||
// Get the signer required state
|
||||
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
||||
val signerRequired = accountState.signerRequired
|
||||
|
||||
// Get the settings
|
||||
val settings by settingsViewModel.settings.collectAsStateWithLifecycle()
|
||||
|
||||
// Get connectivity status
|
||||
val isMobileData by connectivityMonitor.isMobileData.collectAsStateWithLifecycle()
|
||||
|
||||
// Snackbar
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
// Check if dark theme enabled
|
||||
val darkMode = isSystemInDarkTheme()
|
||||
val darkMode = when (settings.theme) {
|
||||
Theme.Light -> false
|
||||
Theme.Dark -> true
|
||||
Theme.System -> isSystemInDarkTheme()
|
||||
}
|
||||
|
||||
// Enabled the dynamic color scheme
|
||||
val colorScheme = when {
|
||||
// Enable the dynamic color scheme for Android 12+
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(
|
||||
context
|
||||
)
|
||||
settings.dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
// When dark mode is enabled, use the dark color scheme
|
||||
darkMode -> darkColorScheme()
|
||||
@@ -95,13 +172,26 @@ fun App(viewModel: NostrViewModel) {
|
||||
else -> expressiveLightColorScheme()
|
||||
}
|
||||
|
||||
BackHandler(enabled = backStack.size > 1) {
|
||||
navigator.goBack()
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.errorEvents.collect { message ->
|
||||
snackbarHostState.showSnackbar(message)
|
||||
launch {
|
||||
accountViewModel.errorEvents.collect { message ->
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
chatViewModel.errorEvents.collect { message ->
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
concordViewModel.errorEvents.collect { message ->
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
profileCache.errorEvents.collect { message ->
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +200,7 @@ fun App(viewModel: NostrViewModel) {
|
||||
fun handleIntent(intent: Intent) {
|
||||
val screen = Screen.fromIntent(intent)
|
||||
// Prevent pushing the same screen
|
||||
if (screen != null && backStack.lastOrNull() != screen) {
|
||||
if ((screen != null) && (backStack.lastOrNull() != screen)) {
|
||||
navigator.navigate(screen)
|
||||
}
|
||||
}
|
||||
@@ -143,7 +233,9 @@ fun App(viewModel: NostrViewModel) {
|
||||
motionScheme = MotionScheme.expressive(),
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalNostrViewModel provides viewModel,
|
||||
LocalProfileCache provides profileCache,
|
||||
LocalSettings provides settings,
|
||||
LocalConnectivity provides isMobileData,
|
||||
LocalSnackbarHostState provides snackbarHostState,
|
||||
LocalNavigator provides navigator,
|
||||
LocalScanResult provides qrScanResult,
|
||||
@@ -163,43 +255,95 @@ fun App(viewModel: NostrViewModel) {
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Home> {
|
||||
HomeScreen()
|
||||
HomeScreen(accountViewModel, chatViewModel, concordViewModel)
|
||||
}
|
||||
entry<Screen.RequestList> {
|
||||
RequestListScreen()
|
||||
RequestListScreen(chatViewModel)
|
||||
}
|
||||
entry<Screen.Onboarding> {
|
||||
OnboardingScreen()
|
||||
OnboardingScreen(accountViewModel)
|
||||
}
|
||||
entry<Screen.Import> {
|
||||
ImportScreen()
|
||||
ImportScreen(accountViewModel)
|
||||
}
|
||||
entry<Screen.NewIdentity> {
|
||||
NewIdentityScreen()
|
||||
NewIdentityScreen(accountViewModel)
|
||||
}
|
||||
entry<Screen.Chat> { key ->
|
||||
ChatScreen(id = key.id, screening = key.screening)
|
||||
val factory = remember(key) {
|
||||
object : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return ChatScreenViewModel(
|
||||
key.id,
|
||||
key.screening,
|
||||
accountRepository,
|
||||
chatRepository
|
||||
) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
ChatScreen(
|
||||
viewModel<ChatScreenViewModel>(
|
||||
key = key.id.toString(),
|
||||
factory = factory
|
||||
),
|
||||
accountViewModel
|
||||
)
|
||||
}
|
||||
entry<Screen.NewChat> {
|
||||
NewChatScreen()
|
||||
NewChatScreen(accountViewModel, chatViewModel)
|
||||
}
|
||||
entry<Screen.Profile> { key ->
|
||||
ProfileScreen(pubkey = key.pubkey)
|
||||
ProfileScreen(pubkey = key.pubkey, chatViewModel = chatViewModel)
|
||||
}
|
||||
entry<Screen.UpdateProfile> {
|
||||
UpdateProfileScreen()
|
||||
UpdateProfileScreen(accountViewModel)
|
||||
}
|
||||
entry<Screen.Scan> {
|
||||
ScanScreen()
|
||||
}
|
||||
entry<Screen.MyQr> {
|
||||
MyQrScreen()
|
||||
MyQrScreen(accountViewModel)
|
||||
}
|
||||
entry<Screen.ContactList> {
|
||||
ContactListScreen()
|
||||
ContactListScreen(accountViewModel, chatViewModel)
|
||||
}
|
||||
entry<Screen.Relay> {
|
||||
RelayScreen()
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
+52
-16
@@ -1,8 +1,13 @@
|
||||
package su.reya.coop.coop.storage
|
||||
package su.reya.coop
|
||||
|
||||
import android.content.Context
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
@@ -10,10 +15,9 @@ import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
data class SecretEntry(
|
||||
val encrypted: String,
|
||||
val iv: String
|
||||
)
|
||||
private val Context.dataStore by preferencesDataStore("secret_store")
|
||||
|
||||
data class SecretEntry(val encrypted: String, val iv: String)
|
||||
|
||||
class SecretCrypto {
|
||||
private val keyAlias = "coop"
|
||||
@@ -21,11 +25,9 @@ class SecretCrypto {
|
||||
private val transformation = "AES/GCM/NoPadding"
|
||||
|
||||
fun encrypt(content: String): SecretEntry {
|
||||
// Initialize cipher
|
||||
val cipher = Cipher.getInstance(transformation)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
|
||||
|
||||
// Encrypt content
|
||||
val encrypted = cipher.doFinal(content.toByteArray())
|
||||
val iv = cipher.iv
|
||||
|
||||
@@ -39,12 +41,10 @@ class SecretCrypto {
|
||||
val encrypted = Base64.decode(entry.encrypted, Base64.NO_WRAP)
|
||||
val iv = Base64.decode(entry.iv, Base64.NO_WRAP)
|
||||
|
||||
// Initialize cipher
|
||||
val cipher = Cipher.getInstance(transformation)
|
||||
val spec = GCMParameterSpec(128, iv)
|
||||
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), spec)
|
||||
|
||||
// Decrypt content
|
||||
val plaintext = cipher.doFinal(encrypted)
|
||||
|
||||
return String(plaintext, StandardCharsets.UTF_8)
|
||||
@@ -54,13 +54,9 @@ class SecretCrypto {
|
||||
val keyStore = KeyStore.getInstance(keyStoreType).apply { load(null) }
|
||||
val existingKey = keyStore.getKey(keyAlias, null)
|
||||
|
||||
// Return existing key if available
|
||||
if (existingKey is SecretKey) return existingKey
|
||||
|
||||
// Construct a new key generator
|
||||
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, keyStoreType)
|
||||
|
||||
// Initialize key generation parameters
|
||||
val spec = KeyGenParameterSpec.Builder(
|
||||
keyAlias,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||
@@ -70,9 +66,49 @@ class SecretCrypto {
|
||||
.setKeySize(256)
|
||||
.build()
|
||||
|
||||
// Generate a new key
|
||||
keyGenerator.init(spec)
|
||||
|
||||
return keyGenerator.generateKey()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AppStore(private val context: Context) : AppStorage {
|
||||
private val crypto = SecretCrypto()
|
||||
|
||||
override suspend fun get(key: String): String? {
|
||||
return context.dataStore.data.first()[stringPreferencesKey(key)]
|
||||
}
|
||||
|
||||
override suspend fun set(key: String, value: String) {
|
||||
context.dataStore.edit { it[stringPreferencesKey(key)] = value }
|
||||
}
|
||||
|
||||
override suspend fun getSecret(key: String): String? {
|
||||
val prefs = context.dataStore.data.first()
|
||||
val encrypted = prefs[stringPreferencesKey("${key}_encrypted")] ?: return null
|
||||
val iv = prefs[stringPreferencesKey("${key}_iv")] ?: return null
|
||||
|
||||
return crypto.decrypt(SecretEntry(encrypted, iv))
|
||||
}
|
||||
|
||||
override suspend fun setSecret(key: String, value: String) {
|
||||
val entry = crypto.encrypt(value)
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[stringPreferencesKey("${key}_encrypted")] = entry.encrypted
|
||||
prefs[stringPreferencesKey("${key}_iv")] = entry.iv
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear(key: String) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs.remove(stringPreferencesKey(key))
|
||||
prefs.remove(stringPreferencesKey("${key}_encrypted"))
|
||||
prefs.remove(stringPreferencesKey("${key}_iv"))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun has(key: String): Boolean {
|
||||
val prefs = context.dataStore.data.first()
|
||||
return prefs.contains(stringPreferencesKey(key)) ||
|
||||
prefs.contains(stringPreferencesKey("${key}_encrypted"))
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,15 @@ import android.content.Intent
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ExternalSignerLauncher {
|
||||
class ExternalSignerLauncher(
|
||||
private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
|
||||
) {
|
||||
private var launcher: ActivityResultLauncher<Intent>? = null
|
||||
private var pendingResult: CompletableDeferred<ActivityResult>? = null
|
||||
private val mutex = Mutex()
|
||||
@@ -19,7 +22,7 @@ class ExternalSignerLauncher {
|
||||
}
|
||||
|
||||
suspend fun launch(intent: Intent): ActivityResult = mutex.withLock {
|
||||
withContext(Dispatchers.Main) {
|
||||
withContext(mainDispatcher) {
|
||||
val deferred = CompletableDeferred<ActivityResult>()
|
||||
pendingResult = deferred
|
||||
launcher?.launch(intent) ?: throw IllegalStateException("Signer not registered")
|
||||
|
||||
@@ -8,12 +8,15 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import su.reya.coop.coop.storage.SecretStore
|
||||
import kotlinx.coroutines.MainScope
|
||||
import su.reya.coop.nostr.NostrManager
|
||||
import su.reya.coop.repository.AccountRepository
|
||||
import su.reya.coop.repository.ChatRepository
|
||||
import su.reya.coop.repository.ConcordRepository
|
||||
import su.reya.coop.repository.MediaRepository
|
||||
import su.reya.coop.repository.SettingsRepository
|
||||
import su.reya.coop.viewmodel.ProfileCache
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
@@ -21,14 +24,37 @@ class MainActivity : ComponentActivity() {
|
||||
val externalSignerLauncher = ExternalSignerLauncher()
|
||||
}
|
||||
|
||||
private val viewModel: NostrViewModel by viewModels {
|
||||
object : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
val secretStore = SecretStore(this@MainActivity)
|
||||
val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
||||
return NostrViewModel(NostrManager.instance, secretStore, androidSigner) as T
|
||||
}
|
||||
}
|
||||
private val profileCache by lazy { ProfileCache(NostrManager.instance) }
|
||||
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 {
|
||||
val storage = AppStore(this@MainActivity)
|
||||
val mediaRepository = MediaRepository(settingsRepository)
|
||||
val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
||||
AccountRepository(
|
||||
NostrManager.instance,
|
||||
storage,
|
||||
mediaRepository,
|
||||
settingsRepository,
|
||||
scope,
|
||||
androidSigner
|
||||
)
|
||||
}
|
||||
|
||||
private val chatRepository by lazy {
|
||||
val mediaRepository = MediaRepository(settingsRepository)
|
||||
ChatRepository(NostrManager.instance, mediaRepository, settingsRepository, scope)
|
||||
}
|
||||
|
||||
private val concordRepository by lazy {
|
||||
ConcordRepository(NostrManager.instance, scope)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -68,17 +94,22 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
// Keep the splash screen visible until the signer check is complete
|
||||
splashScreen.setKeepOnScreenCondition {
|
||||
viewModel.signerRequired.value == null
|
||||
accountRepository.state.value.signerRequired == null
|
||||
}
|
||||
|
||||
// Bind the lifecycle of the ViewModel to the Activity's lifecycle'
|
||||
viewModel.bindLifecycle(ProcessLifecycleOwner.get().lifecycle)
|
||||
|
||||
setContent {
|
||||
App(viewModel = viewModel)
|
||||
App(
|
||||
profileCache = profileCache,
|
||||
accountRepository = accountRepository,
|
||||
chatRepository = chatRepository,
|
||||
concordRepository = concordRepository,
|
||||
settingsRepository = settingsRepository,
|
||||
connectivityMonitor = connectivityMonitor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
|
||||
@@ -58,4 +58,19 @@ sealed interface Screen : NavKey {
|
||||
|
||||
@Serializable
|
||||
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,6 +11,7 @@ import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
@@ -20,6 +21,7 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import su.reya.coop.nostr.NostrManager
|
||||
import java.io.File
|
||||
|
||||
private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES"
|
||||
@@ -61,22 +63,21 @@ class NostrForegroundService : Service() {
|
||||
|
||||
// Initialize Nostr client
|
||||
try {
|
||||
nostr.init(dbDir.absolutePath)
|
||||
nostr.init(dbDir.absolutePath, AppStore(this@NostrForegroundService))
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to initialize Nostr Client", e)
|
||||
}
|
||||
|
||||
// Connect to bootstrap relays
|
||||
nostr.connectBootstrapRelays()
|
||||
|
||||
// Handle notifications
|
||||
nostr.handleNotifications(
|
||||
onMetadataUpdate = { pubkey, metadata ->
|
||||
serviceScope.launch { nostr.emitMetadataUpdate(pubkey, metadata) }
|
||||
serviceScope.launch { nostr.profiles.emitMetadataUpdate(pubkey, metadata) }
|
||||
},
|
||||
onContactListUpdate = { contacts ->
|
||||
serviceScope.launch { nostr.emitContactListUpdate(contacts) }
|
||||
},
|
||||
onSubscriptionClose = {
|
||||
serviceScope.launch { nostr.emitSubscriptionClosed() }
|
||||
serviceScope.launch { nostr.profiles.emitContactListUpdate(contacts) }
|
||||
},
|
||||
onNewMessage = { event ->
|
||||
serviceScope.launch {
|
||||
@@ -188,4 +189,13 @@ class NostrForegroundService : Service() {
|
||||
super.onDestroy()
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,563 +0,0 @@
|
||||
package su.reya.coop.screens
|
||||
|
||||
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.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
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.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.toShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
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_cancel
|
||||
import coop.composeapp.generated.resources.ic_check_circle
|
||||
import coop.composeapp.generated.resources.ic_send
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.Timestamp
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Room
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.formatAsGroupHeader
|
||||
import su.reya.coop.humanReadable
|
||||
import su.reya.coop.roomId
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.shared.getExpressiveFontFamily
|
||||
import su.reya.coop.shared.nameFlow
|
||||
import su.reya.coop.shared.pictureFlow
|
||||
import su.reya.coop.short
|
||||
|
||||
@Composable
|
||||
fun ChatScreen(id: Long, screening: Boolean = false) {
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val navigator = LocalNavigator.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
|
||||
// Get chat room by ID
|
||||
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
||||
val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
|
||||
|
||||
// Show empty screen
|
||||
if (room == null) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Something went wrong.",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val displayName by remember(room) { room!!.nameFlow(viewModel) }.collectAsStateWithLifecycle("Loading...")
|
||||
val picture by remember(room) { room!!.pictureFlow(viewModel) }.collectAsStateWithLifecycle(null)
|
||||
|
||||
var text by remember { mutableStateOf("") }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var newOtherMessages by remember { mutableIntStateOf(0) }
|
||||
var requireScreening by remember { mutableStateOf(screening) }
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val messages = remember { mutableStateListOf<UnsignedEvent>() }
|
||||
|
||||
val groupedMessages = remember(messages.toList()) {
|
||||
messages.groupBy { it.createdAt().formatAsGroupHeader() }
|
||||
}
|
||||
|
||||
LaunchedEffect(id) {
|
||||
// Start loading spinner
|
||||
loading = true
|
||||
|
||||
// Get messages
|
||||
val initialMessages = viewModel.getChatRoomMessages(id)
|
||||
messages.clear()
|
||||
messages.addAll(initialMessages)
|
||||
|
||||
// Stop loading spinner
|
||||
loading = false
|
||||
|
||||
// Get msg relays for each member
|
||||
viewModel.chatRoomConnect(id)
|
||||
|
||||
// Handle new messages
|
||||
viewModel.newEvents.collect { event ->
|
||||
if (event.roomId() == id) {
|
||||
if (event.id() !in messages.map { it.id() }) {
|
||||
messages.add(0, event)
|
||||
}
|
||||
} else {
|
||||
// If the event is not in the current room, it's a new message from another user
|
||||
newOtherMessages++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable {
|
||||
room!!.members.firstOrNull()?.let { pubkey ->
|
||||
navigator.navigate(Screen.Profile(pubkey.toBech32()))
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (loading) {
|
||||
LoadingIndicator(modifier = Modifier.size(32.dp))
|
||||
} else {
|
||||
Avatar(
|
||||
picture = picture,
|
||||
description = displayName,
|
||||
size = 32.dp,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (newOtherMessages > 0) {
|
||||
Badge {
|
||||
Text(newOtherMessages.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
IconButton(onClick = { navigator.goBack() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
)
|
||||
)
|
||||
},
|
||||
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 (requireScreening) {
|
||||
room?.let { ScreenerCard(it) }
|
||||
}
|
||||
|
||||
when (messages.isNotEmpty()) {
|
||||
true -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
reverseLayout = true,
|
||||
state = listState,
|
||||
) {
|
||||
groupedMessages.forEach { (dateHeader, messagesInGroup) ->
|
||||
items(
|
||||
messagesInGroup,
|
||||
key = { it.id()?.toBech32()!! }) { event ->
|
||||
ChatMessage(event)
|
||||
}
|
||||
item {
|
||||
DateSeparator(dateHeader)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false -> {
|
||||
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
|
||||
)
|
||||
Text(
|
||||
text = "Your conversations will appear here.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (requireScreening) {
|
||||
true -> {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { navigator.goBack() },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = "Reject",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = { requireScreening = false },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = "Accept",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
ChatInput(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
onSend = {
|
||||
viewModel.sendMessage(id, text)
|
||||
text = ""
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ScreenerCard(room: Room) {
|
||||
val pubkey = room.members.firstOrNull() ?: return
|
||||
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var isContact by remember { mutableStateOf(false) }
|
||||
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
|
||||
var lastActivity by remember { mutableStateOf<Timestamp?>(null) }
|
||||
|
||||
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
||||
val metadata by metadataFlow.collectAsStateWithLifecycle()
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
val displayName = profile?.displayName ?: profile?.name ?: "No name"
|
||||
val picture = profile?.picture
|
||||
|
||||
LaunchedEffect(pubkey) {
|
||||
scope.launch {
|
||||
// Check contact
|
||||
viewModel.verifyContact(pubkey).let { isContact = it }
|
||||
// Get mutual contacts
|
||||
viewModel.mutualContacts(pubkey).let { mutualContacts = it }
|
||||
// Get the last activity
|
||||
viewModel.verifyActivity(pubkey)?.let { lastActivity = it }
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 48.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Avatar(
|
||||
picture = picture,
|
||||
description = "Profile picture",
|
||||
modifier = Modifier.size(120.dp),
|
||||
shape = MaterialShapes.Cookie12Sided.toShape(),
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = displayName,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontFamily = getExpressiveFontFamily()
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = pubkey.short(),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
if (isContact) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
|
||||
),
|
||||
contentDescription = "Warning",
|
||||
tint = if (isContact) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||
)
|
||||
Text(
|
||||
text = if (isContact) "Contact" else "Not a contact",
|
||||
style = MaterialTheme.typography.labelMediumEmphasized
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
if (mutualContacts.isNotEmpty()) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
|
||||
),
|
||||
contentDescription = "Warning",
|
||||
tint = if (mutualContacts.isNotEmpty()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||
)
|
||||
Text(
|
||||
text = if (mutualContacts.isEmpty()) "No contacts in common" else "${mutualContacts.size} contacts in common",
|
||||
style = MaterialTheme.typography.labelMediumEmphasized
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_check_circle),
|
||||
contentDescription = "Warning",
|
||||
tint = if (lastActivity != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Text(
|
||||
text = if (lastActivity == null) "Don't have any public activities" else "Last activity at ${lastActivity?.humanReadable()}",
|
||||
style = MaterialTheme.typography.labelMediumEmphasized
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DateSeparator(date: String) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = date,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatMessage(
|
||||
rumor: UnsignedEvent
|
||||
) {
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val currentUser = viewModel.currentUser()
|
||||
val isMine = rumor.author() == currentUser
|
||||
|
||||
val bubbleShape = if (isMine) {
|
||||
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp)
|
||||
} else {
|
||||
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 4.dp, bottomEnd = 20.dp)
|
||||
}
|
||||
|
||||
val containerColor =
|
||||
if (isMine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.tertiaryContainer
|
||||
|
||||
val contentColor =
|
||||
if (isMine) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onTertiaryContainer
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = if (isMine) Alignment.End else Alignment.Start
|
||||
) {
|
||||
Surface(
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
shape = bubbleShape,
|
||||
modifier = Modifier
|
||||
.widthIn(max = 280.dp)
|
||||
.clickable(
|
||||
onClick = {
|
||||
val id = rumor.id()
|
||||
if (id != null) {
|
||||
val sent = viewModel.isMessageSent(id)
|
||||
println("Sent: $sent")
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = rumor.content(),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatInput(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
onSend: () -> Unit
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.height(IntrinsicSize.Min),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
TextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
placeholder = { Text("Message") },
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
FilledTonalIconButton(
|
||||
onClick = onSend,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.aspectRatio(1f),
|
||||
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_send),
|
||||
contentDescription = "Send"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,15 @@ package su.reya.coop.screens
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -20,6 +24,7 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SegmentedListItem
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TooltipAnchorPosition
|
||||
import androidx.compose.material3.TooltipBox
|
||||
import androidx.compose.material3.TooltipDefaults
|
||||
@@ -28,11 +33,9 @@ import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -50,26 +53,30 @@ import coop.composeapp.generated.resources.ic_check
|
||||
import coop.composeapp.generated.resources.ic_close
|
||||
import coop.composeapp.generated.resources.ic_plus
|
||||
import coop.composeapp.generated.resources.ic_scanner
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.Nip05Address
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalProfileCache
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.short
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
import su.reya.coop.viewmodel.ChatViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ContactListScreen() {
|
||||
fun ContactListScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
chatViewModel: ChatViewModel
|
||||
) {
|
||||
val navigator = LocalNavigator.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
|
||||
val contactList by viewModel.contactList.collectAsStateWithLifecycle()
|
||||
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
||||
val contactList = accountState.contactList
|
||||
var openAddContactDialog by remember { mutableStateOf(false) }
|
||||
var contactToDelete by remember { mutableStateOf<PublicKey?>(null) }
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
@@ -128,43 +135,54 @@ fun ContactListScreen() {
|
||||
}
|
||||
},
|
||||
content = { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.padding(innerPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
|
||||
) {
|
||||
if (contactList.isNotEmpty()) {
|
||||
contactList.forEachIndexed { index, pubkey ->
|
||||
if (contactList.isNotEmpty()) {
|
||||
val contacts = remember(contactList) { contactList.toList() }
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = innerPadding.calculateTopPadding()),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
|
||||
) {
|
||||
itemsIndexed(contacts) { index, pubkey ->
|
||||
ContactListItem(
|
||||
pubkey = pubkey,
|
||||
index = index,
|
||||
total = contactList.size,
|
||||
onClick = {})
|
||||
total = contacts.size,
|
||||
onClick = {
|
||||
val room = chatViewModel.createChatRoom(listOf(pubkey))
|
||||
navigator.navigate(Screen.Chat(room))
|
||||
},
|
||||
onLongClick = {
|
||||
contactToDelete = pubkey
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "No contacts yet",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "Your contacts will appear here",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "No contacts yet",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "Your contacts will appear here",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,21 +190,47 @@ fun ContactListScreen() {
|
||||
)
|
||||
|
||||
if (openAddContactDialog) {
|
||||
AddContactDialog(onDismissRequest = { openAddContactDialog = false })
|
||||
AddContactDialog(
|
||||
accountViewModel = accountViewModel,
|
||||
onDismissRequest = { openAddContactDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
if (contactToDelete != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { contactToDelete = null },
|
||||
title = { Text("Delete Contact") },
|
||||
text = { Text("Are you sure you want to remove this contact?") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
contactToDelete?.let { accountViewModel.removeContact(it) }
|
||||
contactToDelete = null
|
||||
}
|
||||
) {
|
||||
Text("Delete")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { contactToDelete = null }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun AddContactDialog(onDismissRequest: () -> Unit) {
|
||||
fun AddContactDialog(
|
||||
accountViewModel: AccountViewModel,
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var contact by remember { mutableStateOf("") }
|
||||
var isError by remember { mutableStateOf(false) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
@@ -224,10 +268,8 @@ fun AddContactDialog(onDismissRequest: () -> Unit) {
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
val success = viewModel.addContact(contact)
|
||||
if (success) onDismissRequest()
|
||||
}
|
||||
accountViewModel.addContact(contact)
|
||||
onDismissRequest()
|
||||
}) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_check),
|
||||
@@ -282,34 +324,31 @@ fun ContactListItem(
|
||||
index: Int,
|
||||
total: Int = 0,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
||||
val metadata by metadataFlow.collectAsState(initial = null)
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
|
||||
val picture = profile?.picture
|
||||
val profileCache = LocalProfileCache.current
|
||||
val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
|
||||
val profile by profileFlow.collectAsStateWithLifecycle(initialValue = null)
|
||||
|
||||
SegmentedListItem(
|
||||
onClick = onClick,
|
||||
onLongClick = { viewModel.removeContact(pubkey) },
|
||||
onLongClick = onLongClick,
|
||||
shapes = ListItemDefaults.segmentedShapes(
|
||||
index = index,
|
||||
count = total
|
||||
),
|
||||
leadingContent = {
|
||||
Avatar(
|
||||
picture = picture,
|
||||
description = displayName,
|
||||
picture = profile?.picture,
|
||||
description = profile?.name,
|
||||
size = 36.dp
|
||||
)
|
||||
},
|
||||
supportingContent = { Text(text = pubkey.short()) },
|
||||
supportingContent = { Text(pubkey.short()) },
|
||||
content = {
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
text = profile?.name ?: "No name",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -24,6 +25,8 @@ 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.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -60,7 +63,6 @@ import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.material3.toShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -92,43 +94,49 @@ import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalProfileCache
|
||||
import su.reya.coop.LocalScanResult
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Room
|
||||
import su.reya.coop.RoomKind
|
||||
import su.reya.coop.RoomUiState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.ago
|
||||
import su.reya.coop.concord.parseInviteLink
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.shared.getExpressiveFontFamily
|
||||
import su.reya.coop.shared.nameFlow
|
||||
import su.reya.coop.shared.pictureFlow
|
||||
import su.reya.coop.short
|
||||
import su.reya.coop.uiStateFlow
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
import su.reya.coop.viewmodel.ChatViewModel
|
||||
import su.reya.coop.viewmodel.ConcordViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen() {
|
||||
fun HomeScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
chatViewModel: ChatViewModel,
|
||||
concordViewModel: ConcordViewModel,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val navigator = LocalNavigator.current
|
||||
val qrScanResult = LocalScanResult.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val sheetState = rememberModalBottomSheetState(true)
|
||||
val listState = rememberLazyListState()
|
||||
val pullToRefreshState = rememberPullToRefreshState()
|
||||
|
||||
val currentUser = viewModel.currentUser() ?: return
|
||||
val currentUserProfile = viewModel.getMetadata(currentUser)
|
||||
val userProfile by accountViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||
|
||||
val userProfile by currentUserProfile.collectAsStateWithLifecycle()
|
||||
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
||||
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsStateWithLifecycle()
|
||||
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false)
|
||||
val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState()
|
||||
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
||||
val isRelayListEmpty = accountState.isRelayListEmpty
|
||||
val isBannerDismissed = accountState.isNotificationBannerDismissed
|
||||
|
||||
val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle()
|
||||
val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
|
||||
|
||||
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
|
||||
var showBottomSheet by remember { mutableStateOf(false) }
|
||||
@@ -155,22 +163,28 @@ fun HomeScreen() {
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.getChatRooms()
|
||||
LaunchedEffect(accountState.signerRequired) {
|
||||
chatViewModel.refreshChatRooms()
|
||||
}
|
||||
|
||||
LaunchedEffect(qrScanResult.content) {
|
||||
qrScanResult.content?.let { result ->
|
||||
runCatching { PublicKey.parse(result) }
|
||||
.onSuccess { pubkey ->
|
||||
try {
|
||||
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
||||
navigator.navigate(Screen.Chat(roomId))
|
||||
} catch (e: Exception) {
|
||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||
// A Concord invite is a link, not a npub, so it is routed to the Join screen instead of
|
||||
// being force-fed to PublicKey.parse.
|
||||
if (parseInviteLink(result) != null) {
|
||||
navigator.navigate(Screen.JoinCommunity(result))
|
||||
} else {
|
||||
runCatching { PublicKey.parse(result) }
|
||||
.onSuccess { pubkey ->
|
||||
try {
|
||||
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
||||
navigator.navigate(Screen.Chat(roomId))
|
||||
} catch (e: Exception) {
|
||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
|
||||
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
|
||||
}
|
||||
// Clear the nav state
|
||||
qrScanResult.clear()
|
||||
}
|
||||
@@ -185,10 +199,18 @@ fun HomeScreen() {
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
title = {
|
||||
Text(
|
||||
text = "Coop",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Coop",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
if (isSyncing) {
|
||||
LoadingIndicator(modifier = Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// QR Scanner
|
||||
@@ -201,8 +223,8 @@ fun HomeScreen() {
|
||||
// User
|
||||
IconButton(onClick = { showBottomSheet = true }) {
|
||||
Avatar(
|
||||
picture = userProfile?.asRecord()?.picture,
|
||||
description = userProfile?.asRecord()?.displayName,
|
||||
picture = userProfile?.picture,
|
||||
description = userProfile?.name ?: "No name",
|
||||
size = 32.dp,
|
||||
)
|
||||
}
|
||||
@@ -240,7 +262,9 @@ fun HomeScreen() {
|
||||
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
if (!isNotificationEnabled && !isBannerDismissed) {
|
||||
AnimatedVisibility(
|
||||
visible = !isNotificationEnabled && !isBannerDismissed,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -272,7 +296,7 @@ fun HomeScreen() {
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TextButton(
|
||||
onClick = { viewModel.dismissNotificationBanner() },
|
||||
onClick = { accountViewModel.dismissNotificationBanner() },
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(text = "Maybe later")
|
||||
@@ -311,11 +335,9 @@ fun HomeScreen() {
|
||||
isRefreshing = isRefreshing,
|
||||
state = pullToRefreshState,
|
||||
onRefresh = {
|
||||
scope.launch {
|
||||
isRefreshing = true
|
||||
viewModel.refreshChatRooms()
|
||||
isRefreshing = false
|
||||
}
|
||||
isRefreshing = true
|
||||
chatViewModel.refreshChatRooms()
|
||||
isRefreshing = false
|
||||
},
|
||||
indicator = {
|
||||
PullToRefreshDefaults.LoadingIndicator(
|
||||
@@ -365,10 +387,14 @@ fun HomeScreen() {
|
||||
}
|
||||
|
||||
items(ongoing, key = { it.id }) { room ->
|
||||
ChatRoom(
|
||||
room = room,
|
||||
onClick = { navigator.navigate(Screen.Chat(room.id)) }
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.animateItem()
|
||||
) {
|
||||
ChatRoom(
|
||||
room = room,
|
||||
onClick = { navigator.navigate(Screen.Chat(room.id)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -383,14 +409,6 @@ fun HomeScreen() {
|
||||
onDismissRequest = { showBottomSheet = false },
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
val pubkey = viewModel.currentUser()
|
||||
val shortPubkey = pubkey?.short() ?: "Not available"
|
||||
|
||||
val userName =
|
||||
userProfile?.asRecord()?.displayName
|
||||
?: userProfile?.asRecord()?.name
|
||||
?: "No name"
|
||||
|
||||
val dismissAndRun: (suspend () -> Unit) -> Unit = { action ->
|
||||
scope.launch {
|
||||
sheetState.hide()
|
||||
@@ -415,8 +433,8 @@ fun HomeScreen() {
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Avatar(
|
||||
picture = userProfile?.asRecord()?.picture,
|
||||
description = userProfile?.asRecord()?.displayName,
|
||||
picture = userProfile?.picture,
|
||||
description = userProfile?.name ?: "No name",
|
||||
shape = MaterialShapes.Cookie9Sided.toShape(),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
@@ -426,7 +444,7 @@ fun HomeScreen() {
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = userName,
|
||||
text = userProfile?.name ?: "No name",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
||||
)
|
||||
}
|
||||
@@ -437,16 +455,15 @@ fun HomeScreen() {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
pubkey?.let {
|
||||
userProfile?.publicKey?.let {
|
||||
val bech32 = it.toBech32()
|
||||
val data =
|
||||
ClipData.newPlainText(bech32, bech32)
|
||||
val data = ClipData.newPlainText(bech32, bech32)
|
||||
clipboardManager.setClipEntry(ClipEntry(data))
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(text = shortPubkey)
|
||||
Text(text = userProfile?.shortPublicKey ?: "Unknown")
|
||||
}
|
||||
FilledIconButton(
|
||||
onClick = {
|
||||
@@ -462,7 +479,12 @@ fun HomeScreen() {
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
BottomMenuList(onDismiss = dismissAndRun)
|
||||
BottomMenuList(
|
||||
onDismiss = dismissAndRun,
|
||||
accountViewModel = accountViewModel,
|
||||
chatViewModel = chatViewModel,
|
||||
concordViewModel = concordViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -470,7 +492,7 @@ fun HomeScreen() {
|
||||
// Show the relay setup dialog if the msg relay list is empty
|
||||
if (isRelayListEmpty) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { viewModel.dismissRelayWarning() },
|
||||
onDismissRequest = { accountViewModel.dismissRelayWarning() },
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
) {
|
||||
@@ -574,15 +596,9 @@ fun HomeScreen() {
|
||||
TextButton(
|
||||
enabled = !isBusy,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
isBusy = true
|
||||
try {
|
||||
viewModel.refetchMsgRelays(currentUser)
|
||||
} catch (e: Exception) {
|
||||
snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}")
|
||||
}
|
||||
isBusy = false
|
||||
}
|
||||
isBusy = true
|
||||
accountViewModel.refetchMsgRelays()
|
||||
isBusy = false
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
@@ -596,10 +612,8 @@ fun HomeScreen() {
|
||||
Button(
|
||||
enabled = !isBusy,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
viewModel.useDefaultMsgRelayList()
|
||||
sheetState.hide()
|
||||
}
|
||||
accountViewModel.useDefaultMsgRelayList()
|
||||
scope.launch { sheetState.hide() }
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
@@ -620,60 +634,72 @@ fun HomeScreen() {
|
||||
@Composable
|
||||
fun NewRequests(requests: List<Room>) {
|
||||
val navigator = LocalNavigator.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val profileCache = LocalProfileCache.current
|
||||
|
||||
val total = requests.size
|
||||
val firstRoom = requests.getOrNull(0)
|
||||
val secondRoom = requests.getOrNull(1)
|
||||
|
||||
val firstName by remember(firstRoom) {
|
||||
firstRoom?.nameFlow(viewModel) ?: flowOf("")
|
||||
}.collectAsStateWithLifecycle("Loading...")
|
||||
val firstRoomState by remember(firstRoom?.id) {
|
||||
firstRoom?.uiStateFlow(profileCache) ?: flowOf(RoomUiState())
|
||||
}.collectAsStateWithLifecycle(RoomUiState())
|
||||
|
||||
val secondName by remember(secondRoom) {
|
||||
secondRoom?.nameFlow(viewModel) ?: flowOf("")
|
||||
}.collectAsStateWithLifecycle("")
|
||||
val secondRoomState by remember(secondRoom?.id) {
|
||||
(secondRoom ?: firstRoom)?.uiStateFlow(profileCache) ?: flowOf(RoomUiState())
|
||||
}.collectAsStateWithLifecycle(RoomUiState())
|
||||
|
||||
val supportingText = when {
|
||||
total == 1 && firstRoom != null -> {
|
||||
val message = firstRoom.lastMessage ?: ""
|
||||
"$firstName: $message"
|
||||
"${firstRoomState.name}: $message"
|
||||
}
|
||||
|
||||
total == 2 -> {
|
||||
"$firstName and $secondName"
|
||||
"${firstRoomState.name} and ${secondRoomState.name}"
|
||||
}
|
||||
|
||||
total > 2 -> {
|
||||
val othersCount = total - 2
|
||||
val othersText = if (othersCount == 1) "1 other" else "$othersCount others"
|
||||
"$firstName, $secondName and $othersText"
|
||||
"${firstRoomState.name}, ${secondRoomState.name} and $othersText"
|
||||
}
|
||||
|
||||
else -> ""
|
||||
}
|
||||
|
||||
val totalUnread = requests.sumOf { it.unreadCount }
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
navigator.navigate(Screen.RequestList)
|
||||
},
|
||||
leadingContent = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(MaterialShapes.Clover4Leaf.toShape()),
|
||||
contentAlignment = Alignment.Center
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (totalUnread > 0) {
|
||||
Badge {
|
||||
Text(totalUnread.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(48.dp),
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(MaterialShapes.Clover4Leaf.toShape()),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_request),
|
||||
contentDescription = "Requests",
|
||||
tint = MaterialTheme.colorScheme.onTertiaryFixed
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier.size(48.dp),
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_request),
|
||||
contentDescription = "Requests",
|
||||
tint = MaterialTheme.colorScheme.onTertiaryFixed
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -681,14 +707,19 @@ fun NewRequests(requests: List<Room>) {
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = "Requests",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
style = MaterialTheme.typography.titleMediumEmphasized.copy(
|
||||
fontWeight = if (totalUnread > 0) FontWeight.SemiBold else FontWeight.Normal
|
||||
)
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
if (supportingText.isNotEmpty()) {
|
||||
Text(
|
||||
text = supportingText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontWeight = if (totalUnread > 0) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = if (totalUnread > 0) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outline
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
@@ -703,37 +734,53 @@ fun NewRequests(requests: List<Room>) {
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val displayName by remember(room) { room.nameFlow(viewModel) }.collectAsStateWithLifecycle("Loading...")
|
||||
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsStateWithLifecycle(null)
|
||||
val profileCache = LocalProfileCache.current
|
||||
val roomState by remember(room.id) { room.uiStateFlow(profileCache) }
|
||||
.collectAsStateWithLifecycle(RoomUiState())
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
leadingContent = {
|
||||
Avatar(picture = picture, description = displayName)
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (room.unreadCount > 0) {
|
||||
Badge {
|
||||
Text(room.unreadCount.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Avatar(picture = roomState.picture, description = roomState.picture)
|
||||
}
|
||||
},
|
||||
headlineContent = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
text = roomState.name,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized.copy(
|
||||
fontWeight = if (room.unreadCount > 0) FontWeight.SemiBold else FontWeight.Normal
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = room.createdAt.ago(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
}
|
||||
},
|
||||
supportingContent = {
|
||||
if (!room.lastMessage.isNullOrBlank()) {
|
||||
Text(
|
||||
text = room.lastMessage!!,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
text = room.lastMessage ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
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,
|
||||
maxLines = 1,
|
||||
)
|
||||
@@ -748,16 +795,19 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun BottomMenuList(
|
||||
onDismiss: (suspend () -> Unit) -> Unit
|
||||
onDismiss: (suspend () -> Unit) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
chatViewModel: ChatViewModel,
|
||||
concordViewModel: ConcordViewModel,
|
||||
) {
|
||||
val navigator = LocalNavigator.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
|
||||
val defaultMenuList = listOf(
|
||||
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
||||
"Contact List" to { navigator.navigate(Screen.ContactList) },
|
||||
"Communities (beta)" to { navigator.navigate(Screen.Communities) },
|
||||
"Relay Management" to { navigator.navigate(Screen.Relay) },
|
||||
"Settings" to { }
|
||||
"Settings" to { navigator.navigate(Screen.Settings) }
|
||||
)
|
||||
|
||||
Column(
|
||||
@@ -781,7 +831,16 @@ fun BottomMenuList(
|
||||
}
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.logout() },
|
||||
onClick = {
|
||||
onDismiss {
|
||||
accountViewModel.logout(onLogout = {
|
||||
accountViewModel.resetInternalState()
|
||||
chatViewModel.resetInternalState()
|
||||
// Concord's keys are identity-scoped, so they leave with the identity.
|
||||
concordViewModel.resetInternalState()
|
||||
})
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError
|
||||
|
||||
@@ -22,7 +22,6 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
@@ -30,86 +29,78 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.toShape
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
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_scanner
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.Keys
|
||||
import rust.nostr.sdk.NostrConnectUri
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalScanResult
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.shared.getExpressiveFontFamily
|
||||
import su.reya.coop.short
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ImportScreen() {
|
||||
fun ImportScreen(viewModel: AccountViewModel) {
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val navigator = LocalNavigator.current
|
||||
val qrScanResult = LocalScanResult.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false)
|
||||
val accountState by viewModel.state.collectAsStateWithLifecycle()
|
||||
var secret by remember { mutableStateOf("") }
|
||||
var pubkey by remember { mutableStateOf<PublicKey?>(null) }
|
||||
|
||||
// Get metadata when pubkey changes
|
||||
val metadata by remember(pubkey) {
|
||||
pubkey?.let(viewModel::getMetadata) ?: flowOf(null)
|
||||
}.collectAsStateWithLifecycle(null)
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown"
|
||||
val picture = profile?.picture
|
||||
var password by remember { mutableStateOf("") }
|
||||
var requirePassword by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(qrScanResult.content) {
|
||||
qrScanResult.content?.let { result ->
|
||||
runCatching {
|
||||
if (result.startsWith("nsec")) {
|
||||
if (result.startsWith("nsec1") || result.startsWith("ncryptsec1")) {
|
||||
Keys.parse(result)
|
||||
} else if (result.startsWith("bunker://")) {
|
||||
NostrConnectUri.parse(result)
|
||||
} else {
|
||||
throw IllegalArgumentException("Invalid secret: $result")
|
||||
throw IllegalArgumentException("Unsupported secret format")
|
||||
}
|
||||
}.onSuccess {
|
||||
secret = result
|
||||
}.onFailure { e ->
|
||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||
}
|
||||
.onSuccess { it -> secret = result }
|
||||
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
|
||||
|
||||
// Clear the nav state
|
||||
qrScanResult.clear()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(secret) {
|
||||
if (secret.startsWith("ncryptsec1")) {
|
||||
requirePassword = true
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to Home on successful import (signerRequired becomes false)
|
||||
LaunchedEffect(accountState.signerRequired) {
|
||||
if (accountState.signerRequired == false) {
|
||||
navigator.navigate(Screen.Home)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
@@ -149,35 +140,6 @@ fun ImportScreen() {
|
||||
.padding(top = innerPadding.calculateTopPadding())
|
||||
.imePadding(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(120.dp)
|
||||
.clip(MaterialShapes.Cookie9Sided.toShape()),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Avatar(
|
||||
picture = picture,
|
||||
description = "Profile picture",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
shape = MaterialShapes.Cookie9Sided.toShape(),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = displayName,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontFamily = getExpressiveFontFamily()
|
||||
),
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -188,7 +150,7 @@ fun ImportScreen() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp)
|
||||
.padding(24.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -205,9 +167,9 @@ fun ImportScreen() {
|
||||
BasicTextField(
|
||||
value = secret,
|
||||
onValueChange = { secret = it },
|
||||
enabled = !isBusy,
|
||||
enabled = !accountState.isImporting,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 4,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
@@ -239,32 +201,57 @@ fun ImportScreen() {
|
||||
}
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
if (requirePassword) {
|
||||
Text(
|
||||
text = "Decrypt Password:",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
)
|
||||
BasicTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
enabled = !accountState.isImporting && requirePassword,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
),
|
||||
visualTransformation = PasswordVisualTransformation('*'),
|
||||
textStyle = MaterialTheme.typography.bodyMediumEmphasized.copy(
|
||||
color = MaterialTheme.colorScheme.tertiaryFixedDim,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.tertiaryContainer),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(contentAlignment = Alignment.CenterStart) {
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
if (pubkey == null) {
|
||||
viewModel.verifyIdentity(secret).let { pubkey = it }
|
||||
} else {
|
||||
// Import the identity
|
||||
viewModel.importIdentity(secret)
|
||||
// Navigate to the home screen
|
||||
navigator.navigate(Screen.Home)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.importIdentity(secret, password)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(ButtonDefaults.MediumContainerHeight),
|
||||
enabled = secret.isNotBlank() && !isBusy,
|
||||
enabled = secret.isNotBlank() && !accountState.isImporting,
|
||||
) {
|
||||
if (isBusy) {
|
||||
if (accountState.isImporting) {
|
||||
LoadingIndicator()
|
||||
} else {
|
||||
Text(
|
||||
text = if (pubkey == null) "Verify" else "Click again to Continue",
|
||||
text = "Continue",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,22 +13,23 @@ import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import io.github.alexzhirkevich.qrose.rememberQrCodePainter
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun MyQrScreen() {
|
||||
fun MyQrScreen(viewModel: AccountViewModel) {
|
||||
val navigator = LocalNavigator.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val currentUser = viewModel.currentUser() ?: return
|
||||
val currentUser by viewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
@@ -59,7 +60,7 @@ fun MyQrScreen() {
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Image(
|
||||
painter = rememberQrCodePainter(currentUser.toBech32()),
|
||||
painter = rememberQrCodePainter(currentUser?.publicKey?.toBech32() ?: ""),
|
||||
contentDescription = "My QR"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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_arrow_next
|
||||
@@ -55,27 +56,32 @@ import kotlinx.coroutines.delay
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalProfileCache
|
||||
import su.reya.coop.LocalScanResult
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.short
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
import su.reya.coop.viewmodel.ChatViewModel
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun NewChatScreen() {
|
||||
fun NewChatScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
chatViewModel: ChatViewModel
|
||||
) {
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val navigator = LocalNavigator.current
|
||||
val qrScanResult = LocalScanResult.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
||||
val contactList = accountState.contactList
|
||||
var query by remember { mutableStateOf("") }
|
||||
|
||||
val contactList by viewModel.contactList.collectAsState(initial = emptySet())
|
||||
val createGroup = remember { mutableStateOf(false) }
|
||||
val searchResults = remember { mutableStateListOf<PublicKey>() }
|
||||
val selectedReceivers = remember { mutableStateListOf<PublicKey>() }
|
||||
var query by remember { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(query) {
|
||||
if (query.length >= 3) {
|
||||
@@ -92,14 +98,16 @@ fun NewChatScreen() {
|
||||
selectedReceivers.add(pubkey)
|
||||
}
|
||||
} else if (query.contains("@")) {
|
||||
val pubkey = viewModel.searchByAddress(query)
|
||||
if (pubkey != null) {
|
||||
selectedReceivers.add(pubkey)
|
||||
accountViewModel.searchByAddress(query) { pubkey ->
|
||||
if (pubkey != null) {
|
||||
selectedReceivers.add(pubkey)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val results = viewModel.searchByNostr(query)
|
||||
searchResults.clear()
|
||||
searchResults.addAll(results)
|
||||
accountViewModel.searchByNostr(query) { results ->
|
||||
searchResults.clear()
|
||||
searchResults.addAll(results)
|
||||
}
|
||||
}
|
||||
|
||||
query = ""
|
||||
@@ -109,14 +117,13 @@ fun NewChatScreen() {
|
||||
LaunchedEffect(qrScanResult.content) {
|
||||
qrScanResult.content?.let { result ->
|
||||
// Verify the content
|
||||
runCatching { PublicKey.parse(result) }
|
||||
.onSuccess { pubkey ->
|
||||
selectedReceivers.add(pubkey)
|
||||
}
|
||||
.onFailure { e ->
|
||||
println("Failed to parse QR: ${e.message}")
|
||||
}
|
||||
|
||||
runCatching {
|
||||
PublicKey.parse(result)
|
||||
}.onSuccess { pubkey ->
|
||||
selectedReceivers.add(pubkey)
|
||||
}.onFailure { e ->
|
||||
snackbarHostState.showSnackbar("Invalid address: ${e.message}")
|
||||
}
|
||||
// Clear the nav state
|
||||
qrScanResult.clear()
|
||||
}
|
||||
@@ -168,7 +175,7 @@ fun NewChatScreen() {
|
||||
) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = {
|
||||
val roomId = viewModel.createChatRoom(selectedReceivers.toList())
|
||||
val roomId = chatViewModel.createChatRoom(selectedReceivers.toList())
|
||||
navigator.navigate(Screen.Chat(roomId))
|
||||
},
|
||||
expanded = false,
|
||||
@@ -179,8 +186,8 @@ fun NewChatScreen() {
|
||||
)
|
||||
},
|
||||
text = { Text("Next") },
|
||||
containerColor = MaterialTheme.colorScheme.tertiary,
|
||||
contentColor = MaterialTheme.colorScheme.onTertiary,
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -259,7 +266,7 @@ fun NewChatScreen() {
|
||||
items = searchResults,
|
||||
selectedReceivers = selectedReceivers,
|
||||
onContactClick = { pubkey ->
|
||||
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
||||
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
||||
navigator.navigate(Screen.Chat(roomId))
|
||||
},
|
||||
)
|
||||
@@ -270,7 +277,7 @@ fun NewChatScreen() {
|
||||
items = contactList.toList(),
|
||||
selectedReceivers = selectedReceivers,
|
||||
onContactClick = { pubkey ->
|
||||
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
||||
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
||||
navigator.navigate(Screen.Chat(roomId))
|
||||
}
|
||||
)
|
||||
@@ -287,13 +294,9 @@ fun ReceiverChip(
|
||||
pubkey: PublicKey,
|
||||
onRemove: () -> Unit
|
||||
) {
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
||||
val metadata by metadataFlow.collectAsState(initial = null)
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
|
||||
val picture = profile?.picture
|
||||
val profileCache = LocalProfileCache.current
|
||||
val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
|
||||
val profile by profileFlow.collectAsState(initial = null)
|
||||
|
||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
|
||||
InputChip(
|
||||
@@ -301,7 +304,7 @@ fun ReceiverChip(
|
||||
onClick = onRemove,
|
||||
label = {
|
||||
Text(
|
||||
text = displayName,
|
||||
text = profile?.name ?: "No name",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
@@ -309,8 +312,8 @@ fun ReceiverChip(
|
||||
},
|
||||
avatar = {
|
||||
Avatar(
|
||||
picture = picture,
|
||||
description = displayName,
|
||||
picture = profile?.picture,
|
||||
description = profile?.name ?: "No name",
|
||||
size = 24.dp
|
||||
)
|
||||
},
|
||||
@@ -373,13 +376,9 @@ fun ContactListItem(
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit
|
||||
) {
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
||||
val metadata by metadataFlow.collectAsState(initial = null)
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
|
||||
val picture = profile?.picture
|
||||
val profileCache = LocalProfileCache.current
|
||||
val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
|
||||
val profile by profileFlow.collectAsState(initial = null)
|
||||
|
||||
SegmentedListItem(
|
||||
selected = isSelected,
|
||||
@@ -391,15 +390,15 @@ fun ContactListItem(
|
||||
),
|
||||
leadingContent = {
|
||||
Avatar(
|
||||
picture = picture,
|
||||
description = displayName,
|
||||
picture = profile?.picture,
|
||||
description = profile?.name ?: "",
|
||||
size = 36.dp
|
||||
)
|
||||
},
|
||||
supportingContent = { Text(text = pubkey.short()) },
|
||||
content = {
|
||||
Text(
|
||||
text = displayName,
|
||||
text = profile?.name ?: "Unknown",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,31 +2,25 @@ package su.reya.coop.screens
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.shared.ProfileEditor
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun NewIdentityScreen() {
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
fun NewIdentityScreen(viewModel: AccountViewModel) {
|
||||
val navigator = LocalNavigator.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false)
|
||||
val accountState by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
ProfileEditor(
|
||||
title = "Create a new identity",
|
||||
buttonLabel = "Continue",
|
||||
isBusy = isBusy,
|
||||
isBusy = accountState.isImporting,
|
||||
onBack = { navigator.goBack() },
|
||||
onConfirm = { name, bio, bytes, type ->
|
||||
scope.launch {
|
||||
viewModel.createIdentity(name, bio, bytes, type)
|
||||
navigator.navigate(Screen.Home)
|
||||
}
|
||||
viewModel.createIdentity(name, bio, bytes, type)
|
||||
navigator.navigate(Screen.Home)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
@@ -41,28 +43,36 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.coop
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.shared.getExpressiveFontFamily
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun OnboardingScreen() {
|
||||
fun OnboardingScreen(viewModel: AccountViewModel) {
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val navigator = LocalNavigator.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val accountState by viewModel.state.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Navigate to Home on successful external signer connection
|
||||
LaunchedEffect(accountState.signerRequired) {
|
||||
if (accountState.signerRequired == false) {
|
||||
navigator.navigate(Screen.Home)
|
||||
}
|
||||
}
|
||||
|
||||
val logoPainter = painterResource(Res.drawable.coop)
|
||||
val expressiveFont = getExpressiveFontFamily()
|
||||
|
||||
|
||||
val annotatedText = buildAnnotatedString {
|
||||
append("By using Coop, you agree to accept\nour ")
|
||||
// Push "Terms of Use" link
|
||||
@@ -157,15 +167,12 @@ fun OnboardingScreen() {
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
if (viewModel.isExternalSignerAvailable()) {
|
||||
try {
|
||||
viewModel.connectExternalSigner()
|
||||
navigator.navigate(Screen.Home)
|
||||
} catch (e: Exception) {
|
||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||
}
|
||||
} else {
|
||||
if (viewModel.isExternalSignerAvailable()) {
|
||||
// Connect to the external signer
|
||||
// TODO: show all available signers?
|
||||
viewModel.connectExternalSigner()
|
||||
} else {
|
||||
scope.launch {
|
||||
val result = snackbarHostState.showSnackbar(
|
||||
message = "External signer not installed. Please install Amber or alternatives.",
|
||||
actionLabel = "Install",
|
||||
|
||||
@@ -45,37 +45,40 @@ import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalProfileCache
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.shared.getExpressiveFontFamily
|
||||
import su.reya.coop.short
|
||||
import su.reya.coop.viewmodel.ChatViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ProfileScreen(pubkey: String) {
|
||||
fun ProfileScreen(
|
||||
pubkey: String,
|
||||
chatViewModel: ChatViewModel
|
||||
) {
|
||||
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
|
||||
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val navigator = LocalNavigator.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val profileCache = LocalProfileCache.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
|
||||
val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
|
||||
val profile by profileFlow.collectAsStateWithLifecycle()
|
||||
|
||||
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
||||
val metadata by metadataFlow.collectAsStateWithLifecycle()
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
val displayName = profile?.displayName ?: profile?.name ?: "No name"
|
||||
val nip05 = profile?.nip05 ?: pubkey.short()
|
||||
val picture = profile?.picture
|
||||
val metadata = profile?.metadata?.asRecord()
|
||||
val nip05 = metadata?.nip05 ?: pubkey.short()
|
||||
val picture = metadata?.picture
|
||||
|
||||
val details = remember(profile) {
|
||||
listOf(
|
||||
"Username:" to (profile?.name ?: "None"),
|
||||
"Website:" to (profile?.website ?: "None"),
|
||||
"₿ Lightning Address:" to (profile?.lud16 ?: "None"),
|
||||
"Username:" to (metadata?.name ?: "None"),
|
||||
"Website:" to (metadata?.website ?: "None"),
|
||||
"₿ Lightning Address:" to (metadata?.lud16 ?: "None"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -133,7 +136,7 @@ fun ProfileScreen(pubkey: String) {
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = displayName,
|
||||
text = profile?.name ?: "No name",
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontFamily = getExpressiveFontFamily()
|
||||
@@ -159,7 +162,8 @@ fun ProfileScreen(pubkey: String) {
|
||||
onClick = {
|
||||
scope.launch {
|
||||
try {
|
||||
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
||||
val roomId =
|
||||
chatViewModel.createChatRoom(listOf(pubkey))
|
||||
navigator.navigate(Screen.Chat(roomId))
|
||||
} catch (e: Exception) {
|
||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||
@@ -238,4 +242,4 @@ fun ProfileScreen(pubkey: String) {
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
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_check
|
||||
@@ -66,15 +67,14 @@ import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.RelayMetadata
|
||||
import rust.nostr.sdk.RelayUrl
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun RelayScreen() {
|
||||
fun RelayScreen(viewModel: AccountViewModel) {
|
||||
val navigator = LocalNavigator.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
|
||||
@@ -95,9 +95,27 @@ fun RelayScreen() {
|
||||
var openAddRelayDialog by remember { mutableStateOf(false) }
|
||||
var relayToDelete by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val accountState by viewModel.state.collectAsStateWithLifecycle()
|
||||
val loadedRelayList = accountState.userRelayList
|
||||
val loadedMsgRelayList = accountState.userMsgRelayList
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
relayList.putAll(viewModel.currentUserRelayList())
|
||||
msgRelayList.addAll(viewModel.currentUserMsgRelayList())
|
||||
viewModel.loadCurrentUserRelayList()
|
||||
viewModel.loadCurrentUserMsgRelayList()
|
||||
}
|
||||
|
||||
LaunchedEffect(loadedRelayList) {
|
||||
if (loadedRelayList.isNotEmpty()) {
|
||||
relayList.clear()
|
||||
relayList.putAll(loadedRelayList)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(loadedMsgRelayList) {
|
||||
if (loadedMsgRelayList.isNotEmpty()) {
|
||||
msgRelayList.clear()
|
||||
msgRelayList.addAll(loadedMsgRelayList)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
@@ -296,6 +314,7 @@ fun RelayScreen() {
|
||||
|
||||
if (openAddRelayDialog) {
|
||||
AddRelayDialog(
|
||||
viewModel = viewModel,
|
||||
onDismissRequest = { openAddRelayDialog = false },
|
||||
onMsgRelayAdded = { newRelay ->
|
||||
msgRelayList.add(RelayUrl.parse(newRelay))
|
||||
@@ -314,20 +333,16 @@ fun RelayScreen() {
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
if (msgRelayList.size == 1) {
|
||||
if (msgRelayList.size == 1) {
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar("You must have at least one relay")
|
||||
relayToDelete = null
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
viewModel.removeMsgRelay(relayToDelete!!)
|
||||
msgRelayList.removeIf { it.toString() == relayToDelete }
|
||||
relayToDelete = null
|
||||
} catch (e: Exception) {
|
||||
snackbarHostState.showSnackbar("Failed to remove relay")
|
||||
}
|
||||
relayToDelete = null
|
||||
return@TextButton
|
||||
}
|
||||
viewModel.removeMsgRelay(relayToDelete!!)
|
||||
msgRelayList.removeIf { it.toString() == relayToDelete }
|
||||
relayToDelete = null
|
||||
}
|
||||
) {
|
||||
Text("Confirm")
|
||||
@@ -345,14 +360,12 @@ fun RelayScreen() {
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun AddRelayDialog(
|
||||
viewModel: AccountViewModel,
|
||||
onDismissRequest: () -> Unit,
|
||||
onMsgRelayAdded: (newRelay: String) -> Unit,
|
||||
onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit,
|
||||
) {
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
var relayAddress by remember { mutableStateOf("") }
|
||||
@@ -397,26 +410,24 @@ fun AddRelayDialog(
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
if (!isError) {
|
||||
when (selected) {
|
||||
"Messaging" -> {
|
||||
viewModel.addMsgRelay(relayAddress)
|
||||
onMsgRelayAdded(relayAddress)
|
||||
}
|
||||
if (!isError) {
|
||||
when (selected) {
|
||||
"Messaging" -> {
|
||||
viewModel.addMsgRelay(relayAddress)
|
||||
onMsgRelayAdded(relayAddress)
|
||||
}
|
||||
|
||||
"Inbox" -> {
|
||||
viewModel.addInboxRelay(relayAddress)
|
||||
onRelayAdded(relayAddress, RelayMetadata.WRITE)
|
||||
}
|
||||
"Inbox" -> {
|
||||
viewModel.addInboxRelay(relayAddress)
|
||||
onRelayAdded(relayAddress, RelayMetadata.WRITE)
|
||||
}
|
||||
|
||||
"Outbox" -> {
|
||||
viewModel.addOutboxRelay(relayAddress)
|
||||
onRelayAdded(relayAddress, RelayMetadata.READ)
|
||||
}
|
||||
"Outbox" -> {
|
||||
viewModel.addOutboxRelay(relayAddress)
|
||||
onRelayAdded(relayAddress, RelayMetadata.READ)
|
||||
}
|
||||
onDismissRequest()
|
||||
}
|
||||
onDismissRequest()
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
|
||||
@@ -27,7 +27,6 @@ import androidx.compose.runtime.Composable
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -36,21 +35,18 @@ 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.launch
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.RoomKind
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.viewmodel.ChatViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RequestListScreen() {
|
||||
fun RequestListScreen(viewModel: ChatViewModel) {
|
||||
val navigator = LocalNavigator.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
val pullToRefreshState = rememberPullToRefreshState()
|
||||
|
||||
@@ -101,11 +97,9 @@ fun RequestListScreen() {
|
||||
isRefreshing = isRefreshing,
|
||||
state = pullToRefreshState,
|
||||
onRefresh = {
|
||||
scope.launch {
|
||||
isRefreshing = true
|
||||
viewModel.refreshChatRooms()
|
||||
isRefreshing = false
|
||||
}
|
||||
isRefreshing = true
|
||||
viewModel.refreshChatRooms()
|
||||
isRefreshing = false
|
||||
},
|
||||
indicator = {
|
||||
PullToRefreshDefaults.LoadingIndicator(
|
||||
@@ -155,4 +149,4 @@ fun RequestListScreen() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,15 @@ import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
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.ui.Alignment
|
||||
@@ -44,10 +43,10 @@ fun ScanScreen() {
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
CenterAlignedTopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Scan QR",
|
||||
text = "Scan a Nostr Address",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
},
|
||||
@@ -97,14 +96,6 @@ fun ScanScreen() {
|
||||
.align(Alignment.Center)
|
||||
.border(2.dp, Color.White, RoundedCornerShape(12.dp))
|
||||
)
|
||||
Text(
|
||||
text = "Scan a Nostr address",
|
||||
style = MaterialTheme.typography.titleSmallEmphasized,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 64.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
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 = {}
|
||||
)
|
||||
}
|
||||
@@ -1,26 +1,18 @@
|
||||
package su.reya.coop.screens
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.shared.ProfileEditor
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun UpdateProfileScreen() {
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
fun UpdateProfileScreen(viewModel: AccountViewModel) {
|
||||
val navigator = LocalNavigator.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val currentUser = viewModel.currentUser() ?: return
|
||||
val metadata by viewModel.getMetadata(currentUser).collectAsState(initial = null)
|
||||
val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false)
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
val currentUser by viewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||
val profile = currentUser?.metadata?.asRecord()
|
||||
val isUpdatingProfile by viewModel.isUpdatingProfile.collectAsStateWithLifecycle()
|
||||
|
||||
ProfileEditor(
|
||||
title = "Update profile",
|
||||
@@ -28,13 +20,11 @@ fun UpdateProfileScreen() {
|
||||
initialName = profile?.displayName ?: profile?.name ?: "",
|
||||
initialBio = profile?.about ?: "",
|
||||
initialPicture = profile?.picture,
|
||||
isBusy = isBusy,
|
||||
isBusy = isUpdatingProfile,
|
||||
onBack = { navigator.goBack() },
|
||||
onConfirm = { name, bio, bytes, type ->
|
||||
scope.launch {
|
||||
viewModel.updateProfile(name, bio, bytes, type)
|
||||
navigator.goBack()
|
||||
}
|
||||
viewModel.updateProfile(name, bio, bytes, type)
|
||||
navigator.goBack()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package su.reya.coop.screens.chat
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.toShape
|
||||
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.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_cancel
|
||||
import coop.composeapp.generated.resources.ic_check_circle
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.Timestamp
|
||||
import su.reya.coop.LocalProfileCache
|
||||
import su.reya.coop.Room
|
||||
import su.reya.coop.humanReadable
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.shared.getExpressiveFontFamily
|
||||
import su.reya.coop.short
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ScreenerCard(viewModel: AccountViewModel, room: Room) {
|
||||
val pubkey = room.members.firstOrNull() ?: return
|
||||
|
||||
val profileCache = LocalProfileCache.current
|
||||
|
||||
var isContact by remember { mutableStateOf(false) }
|
||||
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
|
||||
var lastActivity by remember { mutableStateOf<Timestamp?>(null) }
|
||||
|
||||
val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
|
||||
val profile by profileFlow.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(pubkey) {
|
||||
// Check contact
|
||||
viewModel.verifyContact(pubkey) { isContact = it }
|
||||
// Get mutual contacts
|
||||
viewModel.mutualContacts(pubkey) { mutualContacts = it }
|
||||
// Get the last activity
|
||||
viewModel.verifyActivity(pubkey) { lastActivity = it }
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 48.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Avatar(
|
||||
picture = profile?.picture,
|
||||
description = "Profile picture",
|
||||
modifier = Modifier.size(120.dp),
|
||||
shape = MaterialShapes.Cookie12Sided.toShape(),
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = profile?.name ?: "No name",
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontFamily = getExpressiveFontFamily()
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = pubkey.short(),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
if (isContact) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
|
||||
),
|
||||
contentDescription = "Warning",
|
||||
tint = if (isContact) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||
)
|
||||
Text(
|
||||
text = if (isContact) "Contact" else "Not a contact",
|
||||
style = MaterialTheme.typography.labelMediumEmphasized
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
if (mutualContacts.isNotEmpty()) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
|
||||
),
|
||||
contentDescription = "Warning",
|
||||
tint = if (mutualContacts.isNotEmpty()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||
)
|
||||
Text(
|
||||
text = if (mutualContacts.isEmpty()) "No contacts in common" else "${mutualContacts.size} contacts in common",
|
||||
style = MaterialTheme.typography.labelMediumEmphasized
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_check_circle),
|
||||
contentDescription = "Warning",
|
||||
tint = if (lastActivity != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Text(
|
||||
text = if (lastActivity == null) "Don't have any public activities" else "Last activity at ${lastActivity?.humanReadable()}",
|
||||
style = MaterialTheme.typography.labelMediumEmphasized
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DateSeparator(date: String) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = date,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package su.reya.coop.screens.chat
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_add_circle
|
||||
import coop.composeapp.generated.resources.ic_audio
|
||||
import coop.composeapp.generated.resources.ic_send
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
|
||||
@Composable
|
||||
fun ChatInput(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
onUpload: (() -> Unit)? = null,
|
||||
onMicClick: (() -> Unit)? = null
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
TextField(
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent
|
||||
),
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
placeholder = { Text("Message") },
|
||||
maxLines = 5,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
imeAction = ImeAction.Default
|
||||
),
|
||||
leadingIcon = if (onUpload == null) {
|
||||
null
|
||||
} else {
|
||||
{
|
||||
IconButton(onClick = onUpload) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_add_circle),
|
||||
contentDescription = "Upload",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
AnimatedContent(
|
||||
targetState = value.isNotEmpty(),
|
||||
transitionSpec = { (scaleIn() + fadeIn()) togetherWith (scaleOut() + fadeOut()) },
|
||||
label = "send_mic_transition"
|
||||
) { isNotEmpty ->
|
||||
// Nothing to send and nowhere to dictate to: the send button stays, greyed out.
|
||||
if (!isNotEmpty && onMicClick != null) {
|
||||
FilledTonalIconButton(
|
||||
onClick = onMicClick,
|
||||
modifier = Modifier.size(56.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_audio),
|
||||
contentDescription = "Speech to Text"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(
|
||||
onClick = onSend,
|
||||
enabled = isNotEmpty,
|
||||
modifier = Modifier.size(56.dp),
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_send),
|
||||
contentDescription = "Send"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package su.reya.coop.screens.chat
|
||||
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import rust.nostr.sdk.EventId
|
||||
import rust.nostr.sdk.PublicKey
|
||||
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.formatAsTime
|
||||
import su.reya.coop.isImageUrl
|
||||
import su.reya.coop.removeImageUrls
|
||||
|
||||
@Immutable
|
||||
data class ReactionGroup(
|
||||
val emoji: String,
|
||||
val authors: List<PublicKey>,
|
||||
val containsMe: Boolean
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class MessageModel(
|
||||
val id: EventId,
|
||||
val author: PublicKey,
|
||||
val annotatedContent: AnnotatedString,
|
||||
val images: List<String>,
|
||||
val timestamp: String,
|
||||
val isMine: Boolean,
|
||||
val replyEventIds: List<EventId>,
|
||||
val reactions: List<ReactionGroup> = emptyList()
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun rememberMessageModel(
|
||||
event: UnsignedEvent,
|
||||
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 isMine = currentUser == event.author()
|
||||
val content = event.content()
|
||||
val replyEventIds = event.tags().eventIds()
|
||||
|
||||
val showMedia = when (settings.media) {
|
||||
MediaConfig.AlwaysEnabled -> true
|
||||
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 {
|
||||
var lastIndex = 0
|
||||
URL_REGEX.findAll(cleanedContent).forEach { matchResult ->
|
||||
append(cleanedContent.substring(lastIndex, matchResult.range.first))
|
||||
val url = matchResult.value
|
||||
pushLink(
|
||||
LinkAnnotation.Url(
|
||||
url = url,
|
||||
styles = TextLinkStyles(
|
||||
style = SpanStyle(
|
||||
textDecoration = TextDecoration.Underline,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
append(url)
|
||||
pop()
|
||||
lastIndex = matchResult.range.last + 1
|
||||
}
|
||||
append(cleanedContent.substring(lastIndex))
|
||||
}
|
||||
|
||||
val groupedReactions = reactions.groupBy { it.content() }
|
||||
.map { (emoji, events) ->
|
||||
val authors = events.map { it.author() }
|
||||
ReactionGroup(
|
||||
emoji = emoji,
|
||||
authors = authors,
|
||||
containsMe = authors.any { it == currentUser }
|
||||
)
|
||||
}
|
||||
|
||||
MessageModel(
|
||||
id = id,
|
||||
author = event.author(),
|
||||
annotatedContent = annotatedString,
|
||||
images = images,
|
||||
timestamp = event.createdAt().formatAsTime(),
|
||||
isMine = isMine,
|
||||
replyEventIds = replyEventIds,
|
||||
reactions = groupedReactions
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatMessage(
|
||||
model: MessageModel,
|
||||
modifier: Modifier = Modifier,
|
||||
onLongClick: (Rect) -> Unit = {}
|
||||
) {
|
||||
var isMessageClicked by remember { mutableStateOf(false) }
|
||||
var layoutCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
||||
|
||||
val bubbleShape = if (model.isMine) {
|
||||
RoundedCornerShape(topStart = 20.dp, topEnd = 4.dp, bottomStart = 20.dp, bottomEnd = 20.dp)
|
||||
} else {
|
||||
RoundedCornerShape(topStart = 4.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 20.dp)
|
||||
}
|
||||
|
||||
val containerColor =
|
||||
if (!model.isMine) MaterialTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.primaryContainer
|
||||
|
||||
val contentColor =
|
||||
if (!model.isMine) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onPrimaryContainer
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.onGloballyPositioned { layoutCoordinates = it }
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.combinedClickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = { isMessageClicked = !isMessageClicked },
|
||||
onLongClick = {
|
||||
layoutCoordinates?.let { coords ->
|
||||
onLongClick(coords.boundsInWindow())
|
||||
}
|
||||
}
|
||||
),
|
||||
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.BottomEnd) {
|
||||
Column(
|
||||
modifier = Modifier.padding(
|
||||
bottom = if (model.reactions.isNotEmpty()) 4.dp else 0.dp,
|
||||
end = if (model.reactions.isNotEmpty() && !model.isMine) 8.dp else 0.dp
|
||||
),
|
||||
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
if (model.annotatedContent.isNotBlank()) {
|
||||
Surface(
|
||||
modifier = Modifier.widthIn(max = 280.dp),
|
||||
color = containerColor,
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isMessageClicked) {
|
||||
Text(
|
||||
text = model.timestamp,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
modifier = Modifier.padding(top = if (model.reactions.isNotEmpty()) 8.dp else 0.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,717 @@
|
||||
package su.reya.coop.screens.chat
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ClipData
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.speech.RecognizerIntent
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
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.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.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.DropdownMenuGroup
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MenuDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_copy
|
||||
import coop.composeapp.generated.resources.ic_reply
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.EventId
|
||||
import rust.nostr.sdk.KindStandard
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalProfileCache
|
||||
import su.reya.coop.LocalSettings
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Room
|
||||
import su.reya.coop.RoomUiState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.formatAsGroup
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.uiStateFlow
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
import su.reya.coop.viewmodel.ChatScreenViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
viewModel: ChatScreenViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val navigator = LocalNavigator.current
|
||||
val profileCache = LocalProfileCache.current
|
||||
val settings = LocalSettings.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val id = viewModel.id
|
||||
val currentUser by viewModel.currentUser.collectAsStateWithLifecycle()
|
||||
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
||||
val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
|
||||
|
||||
// Show empty screen
|
||||
if (room == null) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Something went wrong.",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val loading = viewModel.loading
|
||||
val newOtherMessages = viewModel.newOtherMessages
|
||||
val requireScreening = viewModel.requireScreening
|
||||
val allEvents = viewModel.messages
|
||||
|
||||
val displayMessages by remember {
|
||||
derivedStateOf { allEvents.filter { it.kind().asStd() != KindStandard.REACTION } }
|
||||
}
|
||||
|
||||
val reactionsByMessage by remember {
|
||||
derivedStateOf {
|
||||
allEvents.filter { it.kind().asStd() == KindStandard.REACTION }
|
||||
.groupBy { it.tags().eventIds().firstOrNull() }
|
||||
}
|
||||
}
|
||||
|
||||
val groupedMessages =
|
||||
remember { derivedStateOf { displayMessages.groupBy { it.createdAt().formatAsGroup() } } }
|
||||
|
||||
val roomState by remember(id, currentUser?.publicKey) {
|
||||
(room as Room).uiStateFlow(profileCache, currentUser?.publicKey)
|
||||
}.collectAsStateWithLifecycle(RoomUiState())
|
||||
|
||||
var text by remember { mutableStateOf("") }
|
||||
var selectedMessage by remember { mutableStateOf<Pair<MessageModel, Rect>?>(null) }
|
||||
var replyingTo by remember { mutableStateOf<MessageModel?>(null) }
|
||||
|
||||
val blurAmount by animateDpAsState(
|
||||
targetValue = if (selectedMessage != null) 8.dp else 0.dp,
|
||||
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 ->
|
||||
scope.launch {
|
||||
// Read file on IO dispatcher
|
||||
val file = withContext(coroutineDispatcher) {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}
|
||||
|
||||
// Parse the file content type
|
||||
val type = context.contentResolver.getType(uri)
|
||||
|
||||
// Send message (handles errors internally via ViewModel)
|
||||
viewModel.sendFileMessage(file, type)
|
||||
}
|
||||
}
|
||||
|
||||
val fileLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
uri?.let { sendFile(it) }
|
||||
}
|
||||
|
||||
val sttLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
val data = result.data
|
||||
val results = data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
|
||||
if (!results.isNullOrEmpty()) text = results[0]
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(allEvents.size) {
|
||||
if (displayMessages.isNotEmpty()) {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Scaffold(
|
||||
modifier = Modifier.blur(blurAmount),
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable {
|
||||
room?.members?.firstOrNull()?.let { pubkey ->
|
||||
navigator.navigate(Screen.Profile(pubkey.toBech32()))
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (loading) {
|
||||
LoadingIndicator(modifier = Modifier.size(32.dp))
|
||||
} else {
|
||||
Avatar(
|
||||
picture = roomState.picture,
|
||||
description = roomState.name,
|
||||
size = 32.dp,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = roomState.name,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (newOtherMessages > 0) {
|
||||
Badge {
|
||||
Text(newOtherMessages.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
IconButton(onClick = { navigator.goBack() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
)
|
||||
)
|
||||
},
|
||||
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 (requireScreening && settings.screening) {
|
||||
room?.let { ScreenerCard(accountViewModel, it) }
|
||||
}
|
||||
|
||||
when (displayMessages.isNotEmpty()) {
|
||||
true -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
state = listState,
|
||||
reverseLayout = true,
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
) {
|
||||
groupedMessages.value.forEach { (dateHeader, messagesInGroup) ->
|
||||
items(
|
||||
items = messagesInGroup,
|
||||
key = { it.ensureId().id()?.toHex()!! }
|
||||
) { event ->
|
||||
val msgReactions = reactionsByMessage[event.id()] ?: emptyList()
|
||||
val model =
|
||||
rememberMessageModel(event, msgReactions, currentUser?.publicKey)
|
||||
|
||||
val replyPreview =
|
||||
remember(model.replyEventIds, displayMessages.size) {
|
||||
model.replyEventIds.firstOrNull()
|
||||
?.let { replyId ->
|
||||
displayMessages.find { it.ensureId().id() == replyId }
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.animateItem(),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
replyPreview?.let { previewEvent ->
|
||||
ReplyPreview(
|
||||
event = previewEvent,
|
||||
isMine = model.isMine,
|
||||
onClick = { goToMessage(previewEvent.id()) }
|
||||
)
|
||||
}
|
||||
ChatMessage(
|
||||
model = model,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
alpha =
|
||||
if (selectedMessage?.first?.id == model.id) 0f else 1f
|
||||
},
|
||||
onLongClick = { rect ->
|
||||
selectedMessage = model to rect
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
DateSeparator(dateHeader)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false -> {
|
||||
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
|
||||
)
|
||||
Text(
|
||||
text = "Your conversations will appear here.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (requireScreening && settings.screening) {
|
||||
true -> {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { navigator.goBack() },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.size(ButtonDefaults.MediumContainerHeight)
|
||||
) {
|
||||
Text(
|
||||
text = "Reject",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.requireScreening = false },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.size(ButtonDefaults.MediumContainerHeight)
|
||||
) {
|
||||
Text(
|
||||
text = "Accept",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
AnimatedVisibility(visible = replyingTo != null) {
|
||||
replyingTo?.let {
|
||||
ReplyBox(it) { replyingTo = null }
|
||||
}
|
||||
}
|
||||
ChatInput(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
onSend = {
|
||||
viewModel.sendMessage(text, replyingTo?.id)
|
||||
text = ""
|
||||
replyingTo = null
|
||||
},
|
||||
onUpload = {
|
||||
fileLauncher.launch("image/*")
|
||||
},
|
||||
onMicClick = {
|
||||
val intent =
|
||||
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||
putExtra(
|
||||
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
|
||||
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
|
||||
)
|
||||
putExtra(
|
||||
RecognizerIntent.EXTRA_PROMPT,
|
||||
"Speak now..."
|
||||
)
|
||||
}
|
||||
try {
|
||||
sttLauncher.launch(intent)
|
||||
} catch (e: Exception) {
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar("Speech recognition not available")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = selectedMessage != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
val (model, bounds) = selectedMessage ?: return@AnimatedVisibility
|
||||
|
||||
val density = LocalDensity.current
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
var menuHeight by remember { mutableFloatStateOf(0f) }
|
||||
var toolbarHeight by remember { mutableFloatStateOf(0f) }
|
||||
val spacing = with(density) { 6.dp.toPx() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.4f))
|
||||
.clickable { selectedMessage = null }
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
val totalExtraHeight = (if (menuHeight > 0) menuHeight + spacing else 300f) +
|
||||
(if (toolbarHeight > 0) toolbarHeight + spacing else 100f)
|
||||
val contentBottom = with(density) { (bounds.bottom + totalExtraHeight).toDp() }
|
||||
|
||||
Spacer(modifier = Modifier.height(contentBottom + 200.dp))
|
||||
|
||||
// Reaction Toolbar (Above)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(0, (bounds.top - toolbarHeight - spacing).toInt().coerceAtLeast(0)) }
|
||||
.onGloballyPositioned { toolbarHeight = it.size.height.toFloat() }
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
|
||||
) {
|
||||
ReactionToolbar(
|
||||
onReaction = { reaction ->
|
||||
viewModel.sendReaction(model.id, reaction)
|
||||
selectedMessage = null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Message Preview
|
||||
ChatMessage(
|
||||
model = model,
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(0, bounds.top.toInt()) }
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
|
||||
// Action Menu (Below)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(0, (bounds.bottom + spacing).toInt()) }
|
||||
.onGloballyPositioned { menuHeight = it.size.height.toFloat() }
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
|
||||
) {
|
||||
ContextMenu(
|
||||
onAction = { action ->
|
||||
when (action) {
|
||||
"Copy" -> {
|
||||
scope.launch {
|
||||
val content = model.annotatedContent
|
||||
val data = ClipData.newPlainText(content, content)
|
||||
clipboardManager.setClipEntry(ClipEntry(data))
|
||||
}
|
||||
}
|
||||
|
||||
"Reply" -> {
|
||||
replyingTo = model
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
selectedMessage = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplyBox(model: MessageModel, onDismiss: () -> Unit) {
|
||||
val profileCache = LocalProfileCache.current
|
||||
val profileFlow = remember(model) { profileCache.getMetadata(model.author) }
|
||||
val profile by profileFlow.collectAsStateWithLifecycle()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onDismiss() },
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Replying to ${profile?.name}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer.copy(
|
||||
alpha = 0.6f
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = model.annotatedContent.toString().ifBlank {
|
||||
if (model.images.isNotEmpty()) "[Image]" else ""
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplyPreview(
|
||||
event: UnsignedEvent,
|
||||
isMine: Boolean = false,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val profileCache = LocalProfileCache.current
|
||||
val profileFlow = remember(event) { profileCache.getMetadata(event.author()) }
|
||||
val profile by profileFlow.collectAsStateWithLifecycle()
|
||||
|
||||
val bubbleShape = if (isMine) {
|
||||
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp)
|
||||
} else {
|
||||
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 4.dp, bottomEnd = 20.dp)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 280.dp)
|
||||
.clickable(onClick = onClick),
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
shape = bubbleShape,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = profile?.name ?: "Unknown",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
Text(
|
||||
text = event.content(),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ReactionToolbar(
|
||||
onReaction: (String) -> Unit
|
||||
) {
|
||||
val reactionEmojis = listOf("👍", "❤️", "😂", "😮", "😢", "😡", "🎉")
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
shadowElevation = 1.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
reactionEmojis.forEach { emoji ->
|
||||
Text(
|
||||
text = emoji,
|
||||
modifier = Modifier
|
||||
.clickable { onReaction(emoji) }
|
||||
.padding(4.dp),
|
||||
fontSize = 24.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun ContextMenu(
|
||||
onAction: (String) -> Unit
|
||||
) {
|
||||
val menuItems = listOf(
|
||||
"Copy" to Res.drawable.ic_copy,
|
||||
"Reply" to Res.drawable.ic_reply
|
||||
)
|
||||
|
||||
DropdownMenuGroup(
|
||||
shapes = MenuDefaults.groupShape(1, 1),
|
||||
containerColor = MenuDefaults.groupVibrantContainerColor,
|
||||
tonalElevation = 1.dp,
|
||||
shadowElevation = 1.dp,
|
||||
modifier = Modifier.width(220.dp)
|
||||
) {
|
||||
val itemCount = menuItems.size
|
||||
|
||||
menuItems.forEachIndexed { index, (label, icon) ->
|
||||
DropdownMenuItem(
|
||||
shapes = MenuDefaults.itemShape(index, itemCount),
|
||||
colors = MenuDefaults.selectableItemVibrantColors(),
|
||||
text = { Text(label) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painter = painterResource(icon),
|
||||
contentDescription = label,
|
||||
)
|
||||
},
|
||||
checked = false,
|
||||
onCheckedChange = { _ -> onAction(label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
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
@@ -0,0 +1,185 @@
|
||||
package su.reya.coop.screens.communities
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.PlainTooltip
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TooltipAnchorPosition
|
||||
import androidx.compose.material3.TooltipBox
|
||||
import androidx.compose.material3.TooltipDefaults
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_plus
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.displayName
|
||||
import su.reya.coop.viewmodel.ConcordViewModel
|
||||
|
||||
/**
|
||||
* Every Community this device has joined, plus any Direct Invite still waiting.
|
||||
*
|
||||
* There is no file upload, no message search and no per-Community settings page — v1 is the read
|
||||
* path and sending, and the screen says so rather than pretending otherwise.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun CommunitiesScreen(concordViewModel: ConcordViewModel) {
|
||||
val navigator = LocalNavigator.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val communities by concordViewModel.communities.collectAsStateWithLifecycle()
|
||||
val directInvites by concordViewModel.directInvites.collectAsStateWithLifecycle()
|
||||
val isReady by concordViewModel.isReady.collectAsStateWithLifecycle()
|
||||
|
||||
val sorted = remember(communities) { communities.sortedBy { it.displayName().lowercase() } }
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Communities",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navigator.goBack() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
|
||||
TooltipAnchorPosition.Above,
|
||||
spacingBetweenTooltipAndAnchor = 8.dp,
|
||||
),
|
||||
tooltip = {
|
||||
PlainTooltip { Text("Join a community") }
|
||||
},
|
||||
state = rememberTooltipState(),
|
||||
) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { navigator.navigate(Screen.JoinCommunity()) },
|
||||
expanded = false,
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_plus),
|
||||
contentDescription = "Join a community"
|
||||
)
|
||||
},
|
||||
text = { Text("Join") },
|
||||
)
|
||||
}
|
||||
},
|
||||
content = { innerPadding ->
|
||||
when {
|
||||
!isReady && sorted.isEmpty() -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
LoadingIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
sorted.isEmpty() && directInvites.isEmpty() -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
CommunityEmptyState(
|
||||
title = "No communities yet",
|
||||
subtitle = "Join one with an invite link.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = innerPadding.calculateTopPadding()),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
|
||||
) {
|
||||
items(directInvites, key = { it.communityId }) { invite ->
|
||||
DirectInviteCard(
|
||||
invite = invite,
|
||||
onJoin = {
|
||||
scope.launch {
|
||||
// A Direct Invite already carries its bundle, so there is
|
||||
// nothing to fetch — only to validate and accept.
|
||||
val preview = concordViewModel.previewDirectInvite(invite)
|
||||
val joined = concordViewModel.join(preview)
|
||||
if (joined != null) {
|
||||
navigator.navigate(
|
||||
Screen.Community(joined.membership.communityId)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
concordViewModel.dismissDirectInvite(invite.communityId)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
items(sorted, key = { it.membership.communityId }) { state ->
|
||||
CommunityRow(
|
||||
state = state,
|
||||
onClick = {
|
||||
navigator.navigate(
|
||||
Screen.Community(state.membership.communityId)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
package su.reya.coop.screens.communities
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SegmentedListItem
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_communities
|
||||
import coop.composeapp.generated.resources.ic_lock
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.concord.CommunityInvite
|
||||
import su.reya.coop.concord.CommunityState
|
||||
import su.reya.coop.concord.ConcordChannel
|
||||
import su.reya.coop.displayName
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.unreadTotal
|
||||
|
||||
/**
|
||||
* Rows and small blocks shared by the Communities screens, mirroring `HomeScreen`'s `ChatRoom` and
|
||||
* `ContactListItem`: `ListItem` for the top level, `SegmentedListItem` inside a group.
|
||||
*/
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun CommunityRow(state: CommunityState, onClick: () -> Unit) {
|
||||
val unread = state.unreadTotal()
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
leadingContent = {
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (unread > 0) {
|
||||
Badge { Text(unread.toString()) }
|
||||
}
|
||||
}
|
||||
) {
|
||||
// Concord icons are encrypted blobs v1 never fetches, so this is always the placeholder.
|
||||
Avatar(picture = null, description = state.displayName())
|
||||
}
|
||||
},
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = state.displayName(),
|
||||
style = MaterialTheme.typography.titleMediumEmphasized.copy(
|
||||
fontWeight = if (unread > 0) FontWeight.SemiBold else FontWeight.Normal
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = channelCountLabel(state.channels.size),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surface)
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ChannelRow(channel: ConcordChannel, index: Int, total: Int, onClick: () -> Unit) {
|
||||
val unread = channel.unreadCount
|
||||
|
||||
SegmentedListItem(
|
||||
onClick = onClick,
|
||||
shapes = ListItemDefaults.segmentedShapes(index = index, count = total),
|
||||
leadingContent = {
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (unread > 0) {
|
||||
Badge { Text(unread.toString()) }
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center) {
|
||||
if (channel.private) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_lock),
|
||||
contentDescription = "Private",
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "#",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
content = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = channel.name,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontWeight = if (unread > 0) FontWeight.SemiBold else FontWeight.Normal
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
// A Private Channel with no key is listable but unreadable: the row says so rather
|
||||
// than opening an empty room and looking broken.
|
||||
if (!channel.hasKey) {
|
||||
Text(
|
||||
text = "No key",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A Direct Invite (CORD-05 §6) waiting to be accepted. It arrived giftwrapped to this npub, so it is
|
||||
* shown on the Communities list rather than in any chat.
|
||||
*/
|
||||
@Composable
|
||||
fun DirectInviteCard(invite: CommunityInvite, onJoin: () -> Unit, onDismiss: () -> Unit) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Invited to a community",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(4.dp))
|
||||
Text(
|
||||
text = invite.name ?: "Untitled community",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(2.dp))
|
||||
Text(
|
||||
text = shortCommunityId(invite.communityId),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(onClick = onDismiss) { Text("Dismiss") }
|
||||
Button(onClick = onJoin) { Text("Join") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The app's empty-state convention: two centred lines, with the feature's mark above them. */
|
||||
@Composable
|
||||
fun CommunityEmptyState(title: String, subtitle: String) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_communities),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(56.dp),
|
||||
tint = MaterialTheme.colorScheme.outlineVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Says out loud what v1 does not do yet. Concord's authority is a signed roster every client folds
|
||||
* (CORD-04), and this build folds none of it — a stale claim of moderation would be worse than none.
|
||||
*/
|
||||
@Composable
|
||||
fun BetaNotice() {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_lock),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.size(12.dp))
|
||||
Text(
|
||||
text = "Beta. Messages, channels and invites work. Roles, kicks and bans are not " +
|
||||
"enforced by this build yet — only by the other clients in the community.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun channelCountLabel(count: Int): String =
|
||||
if (count == 1) "1 channel" else "$count channels"
|
||||
|
||||
/** A Community's identity is a hash, so show enough of it to tell two apart. */
|
||||
internal fun shortCommunityId(communityId: String): String =
|
||||
if (communityId.length <= 16) communityId else communityId.take(16) + "..."
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
package su.reya.coop.screens.communities
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.displayName
|
||||
import su.reya.coop.viewmodel.ConcordViewModel
|
||||
|
||||
/**
|
||||
* One Community's Channels, split public and private the way the protocol splits them: a Public
|
||||
* Channel's key derives from `community_root`, so anyone in the Community can read it, while a
|
||||
* Private one is only listable to those an invite or a rekey handed a key to (CORD-03 §1).
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun CommunityScreen(communityId: String, concordViewModel: ConcordViewModel) {
|
||||
val navigator = LocalNavigator.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
|
||||
val communities by concordViewModel.communities.collectAsStateWithLifecycle()
|
||||
val state = remember(communities, communityId) {
|
||||
communities.firstOrNull { it.membership.communityId == communityId }
|
||||
}
|
||||
|
||||
if (state == null) {
|
||||
// Reachable if the Community was dropped underneath this screen (a logout while it was open),
|
||||
// so it keeps a way back rather than dead-ending here.
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Community",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navigator.goBack() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "This community is not on this device.",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val (privateChannels, publicChannels) = remember(state.channels) {
|
||||
state.channels.partition { it.private }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = state.displayName(),
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navigator.goBack() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
content = { innerPadding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = innerPadding.calculateTopPadding()),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
|
||||
) {
|
||||
item { BetaNotice() }
|
||||
|
||||
itemsIndexed(publicChannels) { index, channel ->
|
||||
ChannelRow(
|
||||
channel = channel,
|
||||
index = index,
|
||||
total = publicChannels.size,
|
||||
onClick = {
|
||||
navigator.navigate(Screen.Channel(communityId, channel.idHex))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
itemsIndexed(privateChannels) { index, channel ->
|
||||
ChannelRow(
|
||||
channel = channel,
|
||||
index = index,
|
||||
total = privateChannels.size,
|
||||
onClick = {
|
||||
navigator.navigate(Screen.Channel(communityId, channel.idHex))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (state.channels.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "No channels yet. They arrive with the community's metadata.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
package su.reya.coop.screens.communities
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_scanner
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalScanResult
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.concord.InvitePreview
|
||||
import su.reya.coop.viewmodel.ConcordViewModel
|
||||
|
||||
/**
|
||||
* Paste or scan an invite link, see what joining would mean, then join.
|
||||
*
|
||||
* The preview is deliberately a separate step: a link is fetched and decrypted, but nothing is
|
||||
* subscribed and no presence is announced until the user accepts (CORD-05 §1), so backing out here
|
||||
* leaves no trace on any relay.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun JoinCommunityScreen(concordViewModel: ConcordViewModel, initialLink: String? = null) {
|
||||
val navigator = LocalNavigator.current
|
||||
val qrScanResult = LocalScanResult.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var link by remember(initialLink) { mutableStateOf(initialLink.orEmpty()) }
|
||||
var preview by remember { mutableStateOf<InvitePreview?>(null) }
|
||||
var isBusy by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(qrScanResult.content) {
|
||||
qrScanResult.content?.let { scanned ->
|
||||
link = scanned
|
||||
preview = null
|
||||
qrScanResult.clear()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Join a community",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navigator.goBack() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_scanner),
|
||||
contentDescription = "Scanner"
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
content = { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = link,
|
||||
onValueChange = {
|
||||
link = it
|
||||
// Any edit invalidates what was fetched for the old text.
|
||||
preview = null
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Invite link") },
|
||||
placeholder = { Text("https://.../invite/...") },
|
||||
maxLines = 4,
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
isBusy = true
|
||||
preview = concordViewModel.previewInvite(link)
|
||||
isBusy = false
|
||||
}
|
||||
},
|
||||
enabled = link.isNotBlank() && !isBusy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Look it up")
|
||||
}
|
||||
|
||||
preview?.let { shown ->
|
||||
InvitePreviewCard(
|
||||
preview = shown,
|
||||
isBusy = isBusy,
|
||||
onJoin = {
|
||||
scope.launch {
|
||||
isBusy = true
|
||||
val joined = concordViewModel.join(shown)
|
||||
isBusy = false
|
||||
if (joined != null) {
|
||||
// Replace this screen with the Community it just joined.
|
||||
navigator.goBack()
|
||||
navigator.navigate(
|
||||
Screen.Community(joined.membership.communityId)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun InvitePreviewCard(preview: InvitePreview, isBusy: Boolean, onJoin: () -> Unit) {
|
||||
val invite = preview.invite
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = invite.name ?: "Untitled community",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
Text(
|
||||
text = shortCommunityId(invite.communityId),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(4.dp))
|
||||
Text(
|
||||
text = plural(invite.channels.size, "channel"),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = plural(preview.relays.size, "relay"),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
|
||||
// Everything that would stop this invite from being accepted, in plain English.
|
||||
preview.problems.forEach { problem -> Refusal(problem) }
|
||||
if (preview.expired) Refusal("This invite has expired.")
|
||||
if (preview.alreadyJoined) {
|
||||
Text(
|
||||
text = "You are already in this community.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Button(
|
||||
onClick = onJoin,
|
||||
enabled = preview.joinable && !isBusy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (isBusy) {
|
||||
LoadingIndicator(modifier = Modifier.size(20.dp))
|
||||
} else {
|
||||
Text(if (preview.alreadyJoined) "Rejoin" else "Join")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Refusal(message: String) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
private fun plural(count: Int, noun: String): String =
|
||||
if (count == 1) "1 $noun" else "$count ${noun}s"
|
||||
@@ -1,5 +1,6 @@
|
||||
package su.reya.coop.shared
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -13,6 +14,9 @@ import coil3.compose.AsyncImage
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.avatar
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalConnectivity
|
||||
import su.reya.coop.LocalSettings
|
||||
import su.reya.coop.MediaConfig
|
||||
|
||||
@Composable
|
||||
fun Avatar(
|
||||
@@ -22,17 +26,36 @@ fun Avatar(
|
||||
size: Dp = 48.dp,
|
||||
shape: Shape = CircleShape
|
||||
) {
|
||||
val settings = LocalSettings.current
|
||||
val isMobileData = LocalConnectivity.current
|
||||
val placeholder = painterResource(Res.drawable.avatar)
|
||||
|
||||
AsyncImage(
|
||||
model = picture,
|
||||
contentDescription = description,
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(shape),
|
||||
contentScale = ContentScale.Crop,
|
||||
fallback = placeholder,
|
||||
error = placeholder,
|
||||
placeholder = placeholder
|
||||
)
|
||||
val showMedia = when (settings.media) {
|
||||
MediaConfig.AlwaysEnabled -> true
|
||||
MediaConfig.Disabled -> false
|
||||
MediaConfig.DisabledForMobileData -> !isMobileData
|
||||
}
|
||||
|
||||
if (showMedia) {
|
||||
AsyncImage(
|
||||
model = picture,
|
||||
contentDescription = description,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import androidx.compose.runtime.Composable
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -53,7 +54,7 @@ import coil3.compose.AsyncImage
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_plus
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -70,11 +71,14 @@ fun ProfileEditor(
|
||||
initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL)
|
||||
isBusy: Boolean = false,
|
||||
onBack: () -> Unit,
|
||||
onConfirm: (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit
|
||||
onConfirm: (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit,
|
||||
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var name by remember(initialName) { mutableStateOf(initialName) }
|
||||
var bio by remember(initialBio) { mutableStateOf(initialBio) }
|
||||
var picture by remember(initialPicture) { mutableStateOf(initialPicture) }
|
||||
@@ -264,16 +268,19 @@ fun ProfileEditor(
|
||||
.fillMaxWidth()
|
||||
.size(ButtonDefaults.MediumContainerHeight),
|
||||
onClick = {
|
||||
val scope = CoroutineScope(Dispatchers.Main)
|
||||
scope.launch {
|
||||
val bytes = withContext(Dispatchers.IO) {
|
||||
(picture as? Uri)?.let {
|
||||
context.contentResolver.openInputStream(it)?.readBytes()
|
||||
try {
|
||||
val bytes = withContext(ioDispatcher) {
|
||||
(picture as? Uri)?.let {
|
||||
context.contentResolver.openInputStream(it)?.readBytes()
|
||||
}
|
||||
}
|
||||
val type =
|
||||
(picture as? Uri)?.let { context.contentResolver.getType(it) }
|
||||
onConfirm(name, bio, bytes, type)
|
||||
} catch (e: Exception) {
|
||||
snackbarHostState.showSnackbar(e.message ?: "Error")
|
||||
}
|
||||
val type =
|
||||
(picture as? Uri)?.let { context.contentResolver.getType(it) }
|
||||
onConfirm(name, bio, bytes, type)
|
||||
}
|
||||
},
|
||||
enabled = name.isNotBlank() && !isBusy
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
package su.reya.coop.shared
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import su.reya.coop.NostrViewModel
|
||||
import su.reya.coop.Room
|
||||
import su.reya.coop.short
|
||||
|
||||
fun Room.nameFlow(viewModel: NostrViewModel): Flow<String> {
|
||||
// Return early if there's a custom subject/room name
|
||||
subject?.takeIf { it.isNotBlank() }?.let { return flowOf(it) }
|
||||
|
||||
val displayMembers = if (isGroup()) members.take(2) else members.take(1)
|
||||
if (displayMembers.isEmpty()) return flowOf("Unknown")
|
||||
|
||||
return combine(displayMembers.map { viewModel.getMetadata(it) }) { metadataArray ->
|
||||
val names = metadataArray.mapIndexed { i, metadata ->
|
||||
val profile = metadata?.asRecord()
|
||||
profile?.name?.takeIf { it.isNotBlank() }
|
||||
?: profile?.displayName?.takeIf { it.isNotBlank() }
|
||||
?: displayMembers[i].short()
|
||||
}
|
||||
|
||||
if (isGroup()) {
|
||||
val combined = names.joinToString(", ")
|
||||
val extraCount = members.size - names.size
|
||||
if (extraCount > 0) "$combined, +$extraCount" else combined
|
||||
} else {
|
||||
val name = names.first()
|
||||
if (displayMembers.first() == viewModel.currentUser()) "$name (you)" else name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Room.pictureFlow(viewModel: NostrViewModel): Flow<String?> {
|
||||
val firstMember = members.firstOrNull() ?: return flowOf(null)
|
||||
return viewModel.getMetadata(firstMember).map { it?.asRecord()?.picture }
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package su.reya.coop.coop.storage
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.first
|
||||
import su.reya.coop.storage.SecretStorage
|
||||
|
||||
private val Context.dataStore by preferencesDataStore("secret_store")
|
||||
|
||||
class SecretStore(private val context: Context) : SecretStorage {
|
||||
private val crypto = SecretCrypto()
|
||||
|
||||
override suspend fun set(key: String, value: String) {
|
||||
val entry = crypto.encrypt(value)
|
||||
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[stringPreferencesKey("${key}_encrypted")] = entry.encrypted
|
||||
prefs[stringPreferencesKey("${key}_iv")] = entry.iv
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun get(key: String): String? {
|
||||
val prefs = context.dataStore.data.first()
|
||||
val encrypted = prefs[stringPreferencesKey("${key}_encrypted")] ?: return null
|
||||
val iv = prefs[stringPreferencesKey("${key}_iv")] ?: return null
|
||||
|
||||
return crypto.decrypt(SecretEntry(encrypted, iv))
|
||||
}
|
||||
|
||||
override suspend fun clear(key: String) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs.remove(stringPreferencesKey("${key}_encrypted"))
|
||||
prefs.remove(stringPreferencesKey("${key}_iv"))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun has(key: String): Boolean {
|
||||
val prefs = context.dataStore.data.first()
|
||||
return prefs[stringPreferencesKey("${key}_encrypted")] != null
|
||||
}
|
||||
}
|
||||
@@ -10,14 +10,14 @@ androidx-espresso = "3.7.0"
|
||||
androidx-lifecycle = "2.10.0"
|
||||
androidx-testExt = "1.3.0"
|
||||
androidx-splashscreen = "1.2.0"
|
||||
composeMultiplatform = "1.11.0"
|
||||
composeMultiplatform = "1.11.1"
|
||||
datastorePreferences = "1.2.1"
|
||||
junit = "4.13.2"
|
||||
kotlin = "2.3.21"
|
||||
kotlin = "2.4.0"
|
||||
kotlinx-serialization = "1.11.0"
|
||||
material3 = "1.11.0-alpha07"
|
||||
multiplatform-nav3-ui = "1.1.1"
|
||||
ktor = "3.5.0"
|
||||
ktor = "3.5.1"
|
||||
|
||||
[libraries]
|
||||
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
|
||||
|
||||
@@ -3,6 +3,8 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.androidLibrary)
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.composeCompiler)
|
||||
kotlin("plugin.serialization") version libs.versions.kotlin.get()
|
||||
}
|
||||
|
||||
@@ -31,10 +33,12 @@ kotlin {
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
||||
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.datastore)
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
||||
implementation("su.reya:nostr-sdk-kmp:0.3.1")
|
||||
implementation("com.squareup.okio:okio:3.16.2")
|
||||
implementation("su.reya:nostr-sdk-kmp:0.3.2")
|
||||
implementation("com.squareup.okio:okio:3.17.0")
|
||||
}
|
||||
androidMain.dependencies {
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package su.reya.coop
|
||||
|
||||
interface AppStorage {
|
||||
// Plain text storage
|
||||
suspend fun get(key: String): String?
|
||||
suspend fun set(key: String, value: String)
|
||||
|
||||
// Encrypted storage
|
||||
suspend fun getSecret(key: String): String?
|
||||
suspend fun setSecret(key: String, value: String)
|
||||
|
||||
suspend fun clear(key: String)
|
||||
suspend fun has(key: String): Boolean
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package su.reya.coop
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface ConnectivityMonitor {
|
||||
val isMobileData: StateFlow<Boolean>
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package su.reya.coop
|
||||
|
||||
import rust.nostr.sdk.PublicKey
|
||||
|
||||
val URL_REGEX = Regex("(https?://\\S+)", RegexOption.IGNORE_CASE)
|
||||
private val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp", "avif")
|
||||
|
||||
fun String.removeImageUrls(): String {
|
||||
return URL_REGEX.replace(this) { if (it.value.isImageUrl()) "" else it.value }.trim()
|
||||
}
|
||||
|
||||
fun String.isImageUrl(): Boolean {
|
||||
val extension = this.substringAfterLast('.', "").lowercase()
|
||||
return extension in imageExtensions
|
||||
}
|
||||
|
||||
fun String.sanitizeName(): String {
|
||||
return this.replace("\n", " ").replace("\r", " ").trim()
|
||||
}
|
||||
|
||||
fun PublicKey.short(): String {
|
||||
val bech32 = toBech32()
|
||||
return bech32.substring(0, 6) + "..." + bech32.substring(bech32.length - 4)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,856 +0,0 @@
|
||||
package su.reya.coop
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import rust.nostr.sdk.AsyncNostrSigner
|
||||
import rust.nostr.sdk.EventBuilder
|
||||
import rust.nostr.sdk.EventId
|
||||
import rust.nostr.sdk.Keys
|
||||
import rust.nostr.sdk.Kind
|
||||
import rust.nostr.sdk.KindStandard
|
||||
import rust.nostr.sdk.Metadata
|
||||
import rust.nostr.sdk.NostrConnect
|
||||
import rust.nostr.sdk.NostrConnectUri
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.RelayMetadata
|
||||
import rust.nostr.sdk.RelayUrl
|
||||
import rust.nostr.sdk.Tag
|
||||
import rust.nostr.sdk.Timestamp
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import su.reya.coop.blossom.BlossomClient
|
||||
import su.reya.coop.storage.SecretStorage
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class NostrViewModel(
|
||||
private val nostr: Nostr,
|
||||
private val secretStore: SecretStorage,
|
||||
private val externalSignerHandler: ExternalSignerHandler? = null,
|
||||
) : ViewModel() {
|
||||
private val _isNotificationBannerDismissed = MutableStateFlow(false)
|
||||
val isNotificationBannerDismissed = _isNotificationBannerDismissed.asStateFlow()
|
||||
|
||||
private val _signerRequired = MutableStateFlow<Boolean?>(null)
|
||||
val signerRequired = _signerRequired.asStateFlow()
|
||||
|
||||
private val _isBusy = MutableStateFlow(false)
|
||||
val isBusy = _isBusy.asStateFlow()
|
||||
|
||||
private val _isPartialProcessedGiftWrap = MutableStateFlow(false)
|
||||
val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow()
|
||||
|
||||
private val _isRelayListEmpty = MutableStateFlow(false)
|
||||
val isRelayListEmpty = _isRelayListEmpty.asStateFlow()
|
||||
|
||||
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
|
||||
val chatRooms = _chatRooms.asStateFlow()
|
||||
|
||||
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
|
||||
val contactList = _contactList.asStateFlow()
|
||||
|
||||
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
||||
val newEvents = _newEvents.asSharedFlow()
|
||||
|
||||
private val _sentReports = MutableSharedFlow<Map<EventId, List<RelayUrl>>>()
|
||||
val sentReport = _sentReports.asSharedFlow()
|
||||
|
||||
private val _errorEvents = Channel<String>(Channel.BUFFERED)
|
||||
val errorEvents = _errorEvents.receiveAsFlow()
|
||||
|
||||
private val _metadataStore = mutableMapOf<PublicKey, MutableStateFlow<Metadata?>>()
|
||||
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
|
||||
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
||||
|
||||
init {
|
||||
// Skip the splash screen if a user is already logged in
|
||||
if (nostr.signer.currentUser != null) {
|
||||
_signerRequired.value = false
|
||||
}
|
||||
|
||||
// Check if the notification banner has been dismissed
|
||||
checkNotificationBannerDismissedStatus()
|
||||
|
||||
// Check local stored secret (secret key or bunker)
|
||||
login()
|
||||
|
||||
// Automatically reconnect bootstrap relays
|
||||
reconnect()
|
||||
|
||||
// Observe the signer state and verify the relay list
|
||||
observeSignerAndCheckRelays()
|
||||
|
||||
// Get all local stored metadata
|
||||
getCacheMetadata()
|
||||
}
|
||||
|
||||
fun bindLifecycle(lifecycle: Lifecycle) {
|
||||
viewModelScope.launch {
|
||||
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
coroutineScope {
|
||||
launch { refreshChatRooms() }
|
||||
launch { runObserver() }
|
||||
launch { runMetadataBatching() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
|
||||
// Disconnect to all bootstrap relays
|
||||
viewModelScope.launch {
|
||||
withContext(NonCancellable) {
|
||||
nostr.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showError(message: String) {
|
||||
viewModelScope.launch {
|
||||
_errorEvents.send(message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkNotificationBannerDismissedStatus() {
|
||||
viewModelScope.launch {
|
||||
_isNotificationBannerDismissed.value =
|
||||
secretStore.get("notification_banner_dismissed") == "true"
|
||||
}
|
||||
}
|
||||
|
||||
private fun reconnect() {
|
||||
viewModelScope.launch {
|
||||
nostr.waitUntilInitialized()
|
||||
nostr.reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runObserver() = coroutineScope {
|
||||
// Observe new messages
|
||||
launch {
|
||||
nostr.newEvents.collect { event ->
|
||||
val roomId = event.roomId()
|
||||
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
|
||||
|
||||
if (existingRoom == null) {
|
||||
val currentUser = nostr.signer.currentUser
|
||||
if (currentUser != null) {
|
||||
val newRoom = Room.new(event, currentUser)
|
||||
_chatRooms.update { (it + newRoom).sortedDescending().toSet() }
|
||||
}
|
||||
} else {
|
||||
updateRoomList(roomId, event)
|
||||
}
|
||||
|
||||
_newEvents.emit(event)
|
||||
}
|
||||
}
|
||||
|
||||
// Observe contact list updates
|
||||
launch {
|
||||
nostr.contactListUpdates.collect { contacts ->
|
||||
_contactList.value = contacts.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
// Observe metadata updates
|
||||
launch {
|
||||
nostr.metadataUpdates.collect { (pubkey, metadata) ->
|
||||
updateMetadata(pubkey, metadata)
|
||||
}
|
||||
}
|
||||
|
||||
// Observes subscription close
|
||||
launch {
|
||||
nostr.subscriptionClosed.collect {
|
||||
getChatRooms()
|
||||
_isPartialProcessedGiftWrap.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runMetadataBatching() = coroutineScope {
|
||||
// Wait until the client is ready
|
||||
nostr.waitUntilInitialized()
|
||||
|
||||
val batch = mutableSetOf<PublicKey>()
|
||||
val timeout = 500L // 500ms timeout for batching
|
||||
|
||||
while (true) {
|
||||
val firstKey = metadataRequestChannel.receive()
|
||||
batch.add(firstKey)
|
||||
val lastFlushTime = Clock.System.now().toEpochMilliseconds()
|
||||
|
||||
while (batch.isNotEmpty()) {
|
||||
val nextKey = withTimeoutOrNull(timeout.milliseconds) {
|
||||
metadataRequestChannel.receive()
|
||||
}
|
||||
|
||||
// Only add the key if it's not null
|
||||
if (nextKey != null) batch.add(nextKey)
|
||||
|
||||
// Get current time
|
||||
val now = Clock.System.now().toEpochMilliseconds()
|
||||
|
||||
// Check if the batch is full or timeout has passed
|
||||
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
|
||||
val keysToRequest = batch.toList()
|
||||
batch.clear()
|
||||
|
||||
nostr.fetchMetadataBatch(keysToRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCacheMetadata() {
|
||||
viewModelScope.launch {
|
||||
// Wait until the client is ready
|
||||
nostr.waitUntilInitialized()
|
||||
|
||||
val results = nostr.getAllCacheMetadata()
|
||||
results.forEach { (pubkey, metadata) ->
|
||||
// Update the metadata state
|
||||
updateMetadata(pubkey, metadata)
|
||||
// Update seenPublicKeys to avoid duplicate requests
|
||||
seenPublicKeys.add(pubkey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun login() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val secret = withTimeoutOrNull(3.seconds) {
|
||||
secretStore.get("user_signer")
|
||||
}
|
||||
|
||||
if (secret == null) {
|
||||
_signerRequired.value = true
|
||||
return@launch
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val signer = createSigner(secret)
|
||||
nostr.setSigner(signer)
|
||||
}.onSuccess {
|
||||
_signerRequired.value = false
|
||||
}.onFailure { e ->
|
||||
showError("Login failed: ${e.message}")
|
||||
_signerRequired.value = true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
showError("Login failed: ${e.message}")
|
||||
_signerRequired.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeSignerAndCheckRelays() {
|
||||
viewModelScope.launch {
|
||||
while (true) {
|
||||
val pubkey = nostr.signer.currentUser
|
||||
|
||||
if (pubkey != null) {
|
||||
// Get chat rooms
|
||||
val rooms = nostr.getChatRooms() ?: emptySet()
|
||||
if (rooms.isNotEmpty()) {
|
||||
mergeChatRooms(rooms)
|
||||
_isPartialProcessedGiftWrap.value = true
|
||||
}
|
||||
|
||||
// Get all metadata for the current user
|
||||
nostr.getUserMetadata()
|
||||
|
||||
// Small delay to ensure all relays are connected
|
||||
delay(2.seconds)
|
||||
|
||||
// Check if the relay list is empty
|
||||
val relays = nostr.getMsgRelays(pubkey)
|
||||
if (relays.isEmpty()) _isRelayListEmpty.value = true
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
delay(500.milliseconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestMetadata(pubkey: PublicKey) {
|
||||
if (seenPublicKeys.add(pubkey)) {
|
||||
viewModelScope.launch {
|
||||
metadataRequestChannel.send(pubkey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateMetadata(pubkey: PublicKey, metadata: Metadata) {
|
||||
_metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.value = metadata
|
||||
}
|
||||
|
||||
fun getMetadata(pubkey: PublicKey): StateFlow<Metadata?> {
|
||||
val flow = _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }
|
||||
if (flow.value == null) {
|
||||
requestMetadata(pubkey)
|
||||
}
|
||||
return flow.asStateFlow()
|
||||
}
|
||||
|
||||
fun currentUser(): PublicKey? {
|
||||
return nostr.signer.currentUser
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
secretStore.clear("user_signer")
|
||||
nostr.signer.switch(Keys.generate())
|
||||
_signerRequired.value = true
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissNotificationBanner() {
|
||||
viewModelScope.launch {
|
||||
secretStore.set("notification_banner_dismissed", "true")
|
||||
_isNotificationBannerDismissed.value = true
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissRelayWarning() {
|
||||
_isRelayListEmpty.value = false
|
||||
}
|
||||
|
||||
private suspend fun getOrInitAppKeys(): Keys {
|
||||
val secret = secretStore.get("app_keys")
|
||||
|
||||
// If app keys are already stored, use them
|
||||
if (secret != null) {
|
||||
return Keys.parse(secret)
|
||||
}
|
||||
|
||||
// Generate new app keys and save to the secret storage
|
||||
val keys = Keys.generate()
|
||||
secretStore.set("app_keys", keys.secretKey().toBech32())
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
private suspend fun blossomUpload(file: ByteArray, contentType: String): String? {
|
||||
try {
|
||||
// Upload picture to Blossom
|
||||
val blossom = BlossomClient(
|
||||
url = "https://blossom.band",
|
||||
client = HttpClient {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
val descriptor = blossom.upload(
|
||||
file = file,
|
||||
contentType = contentType,
|
||||
signer = nostr.signer.get()
|
||||
)
|
||||
|
||||
return descriptor?.url
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateProfile(
|
||||
name: String? = null,
|
||||
bio: String? = null,
|
||||
picture: ByteArray? = null,
|
||||
contentType: String? = null
|
||||
) {
|
||||
_isBusy.value = true
|
||||
try {
|
||||
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
|
||||
val newMetadata = nostr.updateProfile(name, bio, avatarUrl)
|
||||
// Update the metadata state after successfully published
|
||||
updateMetadata(nostr.signer.currentUser!!, newMetadata)
|
||||
// Update local state
|
||||
_isBusy.value = false
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createIdentity(
|
||||
name: String,
|
||||
bio: String?,
|
||||
picture: ByteArray?,
|
||||
contentType: String? = null
|
||||
) {
|
||||
_isBusy.value = true
|
||||
|
||||
val keys = Keys.generate()
|
||||
val secret = keys.secretKey().toBech32()
|
||||
|
||||
try {
|
||||
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
|
||||
// Create identity
|
||||
nostr.createIdentity(keys = keys, name = name, bio, picture = avatarUrl)
|
||||
// Persist the secret in the secret storage
|
||||
secretStore.set("user_signer", secret)
|
||||
// Update local states
|
||||
_isBusy.value = false
|
||||
_signerRequired.value = false
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createSigner(secret: String): AsyncNostrSigner {
|
||||
return when {
|
||||
secret.startsWith("nsec1") -> Keys.parse(secret)
|
||||
|
||||
secret.startsWith("bunker://") -> {
|
||||
val appKeys = getOrInitAppKeys()
|
||||
val bunker = NostrConnectUri.parse(secret)
|
||||
val timeout = 50.seconds // or Duration.parse("50s")
|
||||
NostrConnect(uri = bunker, appKeys, timeout, null)
|
||||
}
|
||||
|
||||
secret.startsWith("nip55://") -> {
|
||||
val handler = externalSignerHandler
|
||||
?: throw IllegalStateException("External signer not available on this platform")
|
||||
|
||||
// Format: nip55://packageName/hexPubkey
|
||||
val parts = secret.removePrefix("nip55://").split("/", limit = 2)
|
||||
val packageName = parts[0]
|
||||
val pubkey = PublicKey.parse(parts[1])
|
||||
|
||||
handler.setPackageName(packageName)
|
||||
ExternalSignerProxy(handler, pubkey)
|
||||
}
|
||||
|
||||
else -> throw IllegalArgumentException("Invalid secret format")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun verifyIdentity(secret: String): PublicKey? {
|
||||
try {
|
||||
val signer = createSigner(secret)
|
||||
if (secret.startsWith("bunker://")) {
|
||||
showError("Please approve the connection.")
|
||||
}
|
||||
return signer.getPublicKeyAsync()
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun importIdentity(secret: String) {
|
||||
_isBusy.value = true
|
||||
try {
|
||||
val signer = createSigner(secret)
|
||||
// Update signer
|
||||
nostr.setSigner(signer)
|
||||
// Persist the secret in the secret storage
|
||||
secretStore.set("user_signer", secret)
|
||||
// Update local states
|
||||
_signerRequired.value = false
|
||||
_isBusy.value = false
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun connectExternalSigner() {
|
||||
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available")
|
||||
_isBusy.value = true
|
||||
try {
|
||||
val permissions = SignerPermissions.toJson(
|
||||
listOf(
|
||||
SignerPermissions.signEvent(0),
|
||||
SignerPermissions.signEvent(3),
|
||||
SignerPermissions.signEvent(10000),
|
||||
SignerPermissions.signEvent(10050),
|
||||
SignerPermissions.signEvent(10063),
|
||||
SignerPermissions.signEvent(22242),
|
||||
SignerPermissions.signEvent(30030),
|
||||
SignerPermissions.signEvent(30315),
|
||||
SignerPermissions.nip44Encrypt(),
|
||||
SignerPermissions.nip44Decrypt(),
|
||||
)
|
||||
)
|
||||
|
||||
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
|
||||
val signer = ExternalSignerProxy(handler, result.pubkey)
|
||||
|
||||
// Update signer
|
||||
nostr.setSigner(signer)
|
||||
// Store the signer in the secret storage
|
||||
secretStore.set("user_signer", "nip55://${result.packageName}/${result.pubkey.toHex()}")
|
||||
// Update local states
|
||||
_signerRequired.value = false
|
||||
_isBusy.value = false
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Notice: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun isExternalSignerAvailable(): Boolean {
|
||||
return externalSignerHandler?.isAvailable() == true
|
||||
}
|
||||
|
||||
suspend fun refetchMsgRelays(pubkey: PublicKey) {
|
||||
val relays = nostr.fetchMsgRelays(pubkey)
|
||||
if (relays.isNotEmpty()) dismissRelayWarning()
|
||||
}
|
||||
|
||||
suspend fun useDefaultMsgRelayList() {
|
||||
try {
|
||||
val defaultRelays = nostr.getDefaultMsgRelayList()
|
||||
nostr.setMsgRelays(defaultRelays)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun currentUserRelayList(): Map<RelayUrl, RelayMetadata?> {
|
||||
try {
|
||||
return nostr.getRelayList(nostr.signer.currentUser!!)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
return emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addInboxRelay(relay: String) {
|
||||
try {
|
||||
val relayUrl = RelayUrl.parse(relay)
|
||||
val relays = currentUserRelayList().toMutableMap()
|
||||
relays[relayUrl] = RelayMetadata.WRITE
|
||||
|
||||
nostr.setRelaylist(relays)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addOutboxRelay(relay: String) {
|
||||
try {
|
||||
val relayUrl = RelayUrl.parse(relay)
|
||||
val relays = currentUserRelayList().toMutableMap()
|
||||
relays[relayUrl] = RelayMetadata.READ
|
||||
|
||||
nostr.setRelaylist(relays)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeRelay(relay: String) {
|
||||
try {
|
||||
val relayUrl = RelayUrl.parse(relay)
|
||||
val relays = currentUserRelayList().toMutableMap()
|
||||
relays.remove(relayUrl)
|
||||
|
||||
nostr.setRelaylist(relays)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun currentUserMsgRelayList(): List<RelayUrl> {
|
||||
try {
|
||||
return nostr.getMsgRelays(nostr.signer.currentUser!!)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addMsgRelay(relay: String) {
|
||||
try {
|
||||
val relayUrl = RelayUrl.parse(relay)
|
||||
val relays = currentUserMsgRelayList().toMutableSet()
|
||||
relays.add(relayUrl)
|
||||
|
||||
nostr.setMsgRelays(relays.toList())
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeMsgRelay(relay: String) {
|
||||
try {
|
||||
val relayUrl = RelayUrl.parse(relay)
|
||||
val relays = currentUserMsgRelayList().toMutableSet()
|
||||
relays.remove(relayUrl)
|
||||
|
||||
nostr.setMsgRelays(relays.toList())
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun newContact(publicKey: PublicKey) {
|
||||
if (publicKey in contactList.value) return
|
||||
|
||||
try {
|
||||
val updated = contactList.value + publicKey
|
||||
// Publish new event
|
||||
nostr.setContactList(updated.toList())
|
||||
// Optimistic local update
|
||||
_contactList.update { it + publicKey }
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addContact(address: String): Boolean {
|
||||
val pubkey = try {
|
||||
if (address.contains("@")) {
|
||||
nostr.searchByAddress(address)
|
||||
} else {
|
||||
PublicKey.parse(address)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
showError("Invalid contact address: ${e.message}")
|
||||
return false
|
||||
}
|
||||
|
||||
return run {
|
||||
newContact(pubkey)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun removeContact(publicKey: PublicKey) {
|
||||
viewModelScope.launch {
|
||||
if (publicKey !in contactList.value) return@launch
|
||||
|
||||
try {
|
||||
val updated = contactList.value - publicKey
|
||||
// Publish new event
|
||||
nostr.setContactList(updated.toList())
|
||||
// Optimistic local update
|
||||
_contactList.update { it - publicKey }
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createChatRoom(to: List<PublicKey>): Long {
|
||||
try {
|
||||
if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in")
|
||||
if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required")
|
||||
|
||||
val currentUser = nostr.signer.currentUser!!
|
||||
|
||||
// Construct the rumor event
|
||||
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "")
|
||||
.tags(to.map { Tag.publicKey(it) })
|
||||
.finalizeUnsigned(currentUser)
|
||||
|
||||
// Check if the room already exists
|
||||
val id = rumor.roomId()
|
||||
val existingRoom = _chatRooms.value.firstOrNull { it.id == id }
|
||||
|
||||
// If the room already exists, return its ID
|
||||
if (existingRoom != null) {
|
||||
return existingRoom.id
|
||||
}
|
||||
|
||||
// Create a room from the rumor event
|
||||
val room = Room.new(rumor, currentUser)
|
||||
|
||||
// Update the chat rooms state
|
||||
_chatRooms.update { currentRooms ->
|
||||
(currentRooms + room).sortedDescending().toSet()
|
||||
}
|
||||
|
||||
return room.id
|
||||
} catch (e: Exception) {
|
||||
throw IllegalArgumentException("Failed to create room: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun getChatRoom(id: Long): Room? {
|
||||
return chatRooms.value.firstOrNull { it.id == id }
|
||||
}
|
||||
|
||||
private fun mergeChatRooms(rooms: Set<Room>) {
|
||||
_chatRooms.update { currentRooms ->
|
||||
val merged = currentRooms.associateBy { it.id }.toMutableMap()
|
||||
// Add or update rooms from the database
|
||||
rooms.forEach { room ->
|
||||
merged[room.id] = room
|
||||
}
|
||||
// Return as a sorted set to maintain UI consistency
|
||||
merged.values.sortedDescending().toSet()
|
||||
}
|
||||
}
|
||||
|
||||
fun getChatRooms() {
|
||||
viewModelScope.launch {
|
||||
val rooms = nostr.getChatRooms() ?: emptySet()
|
||||
mergeChatRooms(rooms)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refreshChatRooms() {
|
||||
try {
|
||||
val rooms = nostr.getChatRooms() ?: emptySet()
|
||||
mergeChatRooms(rooms)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
|
||||
try {
|
||||
return nostr.getChatRoomMessages(roomId)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
fun chatRoomConnect(roomId: Long) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||
val members = room.members
|
||||
|
||||
nostr.chatRoomConnect(members.toList())
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) {
|
||||
if (message.isEmpty()) {
|
||||
showError("Message cannot be empty")
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||
nostr.sendMessage(
|
||||
to = room.members,
|
||||
content = message,
|
||||
subject = room.subject,
|
||||
replies = replies,
|
||||
onRumorCreated = { event ->
|
||||
updateRoomList(roomId, event)
|
||||
viewModelScope.launch { _newEvents.emit(event) }
|
||||
},
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isMessageSent(id: EventId): Boolean {
|
||||
val giftWrapId = nostr.rumorMap[id]
|
||||
|
||||
if (giftWrapId != null) {
|
||||
val isSent = nostr.sentEvents[giftWrapId]?.isNotEmpty() ?: false
|
||||
return isSent
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
|
||||
_chatRooms.update { currentRooms ->
|
||||
currentRooms.map { room ->
|
||||
if (room.id == roomId) {
|
||||
room.copy(
|
||||
lastMessage = newMessage.content(),
|
||||
createdAt = newMessage.createdAt()
|
||||
)
|
||||
} else {
|
||||
room
|
||||
}
|
||||
}.sortedDescending().toSet()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun searchByAddress(query: String): PublicKey? {
|
||||
try {
|
||||
return nostr.searchByAddress(query)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun searchByNostr(query: String): List<PublicKey> {
|
||||
try {
|
||||
return nostr.searchByNostr(query)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? {
|
||||
return try {
|
||||
nostr.verifyActivity(pubkey)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun verifyContact(pubkey: PublicKey): Boolean {
|
||||
return try {
|
||||
nostr.verifyContact(pubkey)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> {
|
||||
return try {
|
||||
nostr.mutualContacts(pubkey)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
setOf()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun PublicKey.short(): String {
|
||||
val bech32 = toBech32()
|
||||
return bech32.substring(0, 6) + "..." + bech32.substring(bech32.length - 4)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package su.reya.coop
|
||||
|
||||
import rust.nostr.sdk.Metadata
|
||||
import rust.nostr.sdk.PublicKey
|
||||
|
||||
data class Profile(
|
||||
val publicKey: PublicKey,
|
||||
val metadata: Metadata
|
||||
) {
|
||||
private val record by lazy { metadata.asRecord() }
|
||||
|
||||
val name: String
|
||||
get() = record.displayName?.sanitizeName() ?: record.name ?: publicKey.short()
|
||||
|
||||
val picture: String?
|
||||
get() = record.picture
|
||||
|
||||
val shortPublicKey: String
|
||||
get() = publicKey.short()
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
package su.reya.coop
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.minus
|
||||
@@ -8,6 +11,7 @@ import kotlinx.datetime.toLocalDateTime
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.Timestamp
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import su.reya.coop.viewmodel.ProfileCache
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
@@ -26,15 +30,15 @@ data class Room(
|
||||
val subject: String?,
|
||||
val members: Set<PublicKey>,
|
||||
val kind: RoomKind = RoomKind.default(),
|
||||
val lastMessage: String? = null
|
||||
val lastMessage: String? = null,
|
||||
val unreadCount: Int = 0
|
||||
) : Comparable<Room> {
|
||||
override fun compareTo(other: Room): Int {
|
||||
return this.createdAt.asSecs().compareTo(other.createdAt.asSecs())
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room {
|
||||
val id = rumor.roomId()
|
||||
fun new(rumor: UnsignedEvent, userPubkey: PublicKey, id: Long = rumor.roomId()): Room {
|
||||
val createdAt = rumor.createdAt()
|
||||
val subject = rumor.tags().toVec().find { it.kind() == "subject" }?.content()
|
||||
|
||||
@@ -59,24 +63,48 @@ data class Room(
|
||||
}
|
||||
}
|
||||
|
||||
fun setKind(kind: RoomKind): Room {
|
||||
return this.copy(kind = kind)
|
||||
fun isGroup(): Boolean = members.size > 1
|
||||
}
|
||||
|
||||
data class RoomUiState(
|
||||
val name: String = "Loading...",
|
||||
val picture: String? = null,
|
||||
val isGroup: Boolean = false
|
||||
)
|
||||
|
||||
fun Room.uiStateFlow(
|
||||
profileCache: ProfileCache,
|
||||
currentUser: PublicKey? = null
|
||||
): Flow<RoomUiState> {
|
||||
val displayMembers = if (isGroup()) members.take(2) else members.take(1)
|
||||
|
||||
if (!subject.isNullOrBlank()) {
|
||||
return flowOf(RoomUiState(name = subject.sanitizeName(), isGroup = isGroup()))
|
||||
}
|
||||
|
||||
fun setCreatedAt(createdAt: Timestamp): Room {
|
||||
return this.copy(createdAt = createdAt)
|
||||
}
|
||||
return combine(displayMembers.map { profileCache.getMetadata(it) }) { profiles ->
|
||||
val names = profiles.mapIndexed { i, profile ->
|
||||
profile?.name?.sanitizeName() ?: displayMembers[i].short()
|
||||
}
|
||||
|
||||
fun setSubject(subject: String?): Room {
|
||||
return this.copy(subject = subject)
|
||||
}
|
||||
val name = when {
|
||||
isGroup() -> {
|
||||
val combined = names.joinToString(", ")
|
||||
val extra = members.size - names.size
|
||||
if (extra > 0) "$combined, +$extra" else combined
|
||||
}
|
||||
|
||||
fun setLastMessage(message: String?): Room {
|
||||
return this.copy(lastMessage = message)
|
||||
}
|
||||
else -> {
|
||||
val first = names.firstOrNull() ?: "Unknown"
|
||||
if (displayMembers.firstOrNull() == currentUser) "$first (you)" else first
|
||||
}
|
||||
}
|
||||
|
||||
fun isGroup(): Boolean {
|
||||
return members.size > 1
|
||||
RoomUiState(
|
||||
name = name,
|
||||
picture = profiles.firstOrNull()?.picture,
|
||||
isGroup = isGroup()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,30 +113,29 @@ fun UnsignedEvent.roomId(): Long {
|
||||
val pubkeys: MutableList<PublicKey> = mutableListOf()
|
||||
pubkeys.add(this.author())
|
||||
pubkeys.addAll(this.tags().publicKeys())
|
||||
|
||||
return pubkeys.map { it.toBech32() }.distinct().sorted().hashCode().toLong()
|
||||
}
|
||||
|
||||
// Sort and hash the list of public keys
|
||||
val sortedUniqueKeys = pubkeys
|
||||
.distinctBy { it.toBech32() }
|
||||
.sortedBy { it.toBech32() }
|
||||
|
||||
return sortedUniqueKeys.hashCode().toLong()
|
||||
fun Timestamp.formatAsTime(): String {
|
||||
val timeZone = TimeZone.currentSystemDefault()
|
||||
val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())
|
||||
val inputDateTime = inputInstant.toLocalDateTime(timeZone)
|
||||
val hour = inputDateTime.hour.toString().padStart(2, '0')
|
||||
val minute = inputDateTime.minute.toString().padStart(2, '0')
|
||||
return "$hour:$minute"
|
||||
}
|
||||
|
||||
fun Timestamp.ago(): String {
|
||||
val SECONDS_IN_MINUTE = 60L
|
||||
val MINUTES_IN_HOUR = 60L
|
||||
val HOURS_IN_DAY = 24L
|
||||
val DAYS_IN_MONTH = 30L
|
||||
|
||||
val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())
|
||||
val now = Clock.System.now()
|
||||
val duration = now - inputInstant
|
||||
|
||||
return when {
|
||||
duration.inWholeSeconds < SECONDS_IN_MINUTE -> "Now"
|
||||
duration.inWholeMinutes < MINUTES_IN_HOUR -> "${duration.inWholeMinutes}m"
|
||||
duration.inWholeHours < HOURS_IN_DAY -> "${duration.inWholeHours}h"
|
||||
duration.inWholeDays < DAYS_IN_MONTH -> "${duration.inWholeDays}d"
|
||||
duration.inWholeSeconds < 60L -> "Now"
|
||||
duration.inWholeMinutes < 60L -> "${duration.inWholeMinutes}m"
|
||||
duration.inWholeHours < 24L -> "${duration.inWholeHours}h"
|
||||
duration.inWholeDays < 30L -> "${duration.inWholeDays}d"
|
||||
else -> {
|
||||
val localDateTime = inputInstant.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||
val month =
|
||||
@@ -119,7 +146,7 @@ fun Timestamp.ago(): String {
|
||||
}
|
||||
}
|
||||
|
||||
fun Timestamp.formatAsGroupHeader(): String {
|
||||
fun Timestamp.formatAsGroup(): String {
|
||||
val timeZone = TimeZone.currentSystemDefault()
|
||||
val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())
|
||||
val inputDate = inputInstant.toLocalDateTime(timeZone).date
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
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()
|
||||
@@ -0,0 +1,92 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
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
|
||||
@@ -0,0 +1,193 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,742 @@
|
||||
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()
|
||||
@@ -0,0 +1,246 @@
|
||||
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()
|
||||
@@ -0,0 +1,179 @@
|
||||
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
|
||||
@@ -0,0 +1,102 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package su.reya.coop
|
||||
package su.reya.coop.nostr
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package su.reya.coop
|
||||
package su.reya.coop.nostr
|
||||
|
||||
import rust.nostr.sdk.AsyncNostrSigner
|
||||
import rust.nostr.sdk.Event
|
||||
@@ -0,0 +1,438 @@
|
||||
package su.reya.coop.nostr
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import rust.nostr.sdk.AckPolicy
|
||||
import rust.nostr.sdk.Alphabet
|
||||
import rust.nostr.sdk.Client
|
||||
import rust.nostr.sdk.Event
|
||||
import rust.nostr.sdk.EventBuilder
|
||||
import rust.nostr.sdk.EventId
|
||||
import rust.nostr.sdk.Filter
|
||||
import rust.nostr.sdk.Keys
|
||||
import rust.nostr.sdk.Kind
|
||||
import rust.nostr.sdk.KindStandard
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.RelayCapabilities
|
||||
import rust.nostr.sdk.RelayUrl
|
||||
import rust.nostr.sdk.ReqExitPolicy
|
||||
import rust.nostr.sdk.ReqTarget
|
||||
import rust.nostr.sdk.SendEventTarget
|
||||
import rust.nostr.sdk.SingleLetterTag
|
||||
import rust.nostr.sdk.Tag
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import rust.nostr.sdk.nip17ExtractRelayList
|
||||
import rust.nostr.sdk.nip59MakeGiftWrapAsync
|
||||
import su.reya.coop.Room
|
||||
import su.reya.coop.RoomKind
|
||||
import su.reya.coop.roomId
|
||||
import kotlin.time.Duration
|
||||
|
||||
data class MessageSyncState(
|
||||
val processedCount: Int = 0,
|
||||
val isSyncing: Boolean = false
|
||||
)
|
||||
|
||||
class MessageManager(private val nostr: Nostr) {
|
||||
private val client: Client? get() = nostr.client
|
||||
private val signer: UniversalSigner get() = nostr.signer
|
||||
|
||||
private val _messageSyncState = MutableStateFlow(MessageSyncState())
|
||||
val messageSyncState = _messageSyncState.asStateFlow()
|
||||
|
||||
val rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
|
||||
|
||||
fun updateSyncState(update: (MessageSyncState) -> MessageSyncState) {
|
||||
_messageSyncState.update(update)
|
||||
}
|
||||
|
||||
suspend fun getUserMessages(msgRelayList: Event) {
|
||||
try {
|
||||
val author =
|
||||
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||
|
||||
val relays = nip17ExtractRelayList(msgRelayList)
|
||||
|
||||
// Ensure relay connections
|
||||
relays.forEach { relay ->
|
||||
client?.addRelay(relay)
|
||||
client?.connectRelay(relay)
|
||||
}
|
||||
|
||||
// Construct a filter for gift wrap events
|
||||
val filter = Filter().kind(Kind.fromStd(KindStandard.GIFT_WRAP)).pubkey(author)
|
||||
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
||||
relays.forEach { relay ->
|
||||
target[relay] = listOf(filter)
|
||||
}
|
||||
|
||||
client?.subscribe(
|
||||
target = ReqTarget.manual(target),
|
||||
id = "gift-wraps"
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun extractRumor(event: Event): UnsignedEvent? {
|
||||
try {
|
||||
// Gift wrap must have at least one 'p' tag
|
||||
if (event.tags().publicKeys().isEmpty()) {
|
||||
println("No recipient tags found.")
|
||||
return null
|
||||
}
|
||||
|
||||
// Event must be a gift wrap
|
||||
if (event.kind().asStd().let { it != KindStandard.GIFT_WRAP }) {
|
||||
println("Event is not a gift wrap.")
|
||||
return null
|
||||
}
|
||||
|
||||
// Check if the rumor is already cached
|
||||
val cachedRumor = getCachedRumor(event.id())
|
||||
if (cachedRumor != null) return cachedRumor
|
||||
|
||||
// Decrypt the gift wrap event
|
||||
val seal = signer.nip44DecryptAsync(event.author(), event.content())
|
||||
val sealEvent = Event.fromJson(seal)
|
||||
|
||||
// Verify seal event
|
||||
if (!sealEvent.verify()) {
|
||||
println("Failed to verify seal event.")
|
||||
return null
|
||||
}
|
||||
|
||||
// Decrypt the rumor
|
||||
val rumor = signer.nip44DecryptAsync(sealEvent.author(), sealEvent.content())
|
||||
val unsignedEvent = UnsignedEvent.fromJson(rumor).ensureId()
|
||||
|
||||
// Ensure the rumor author matches the seal
|
||||
if (unsignedEvent.author() != sealEvent.author()) {
|
||||
println("Author mismatch.")
|
||||
return null
|
||||
}
|
||||
|
||||
// Cache the rumor for later use
|
||||
setCachedRumor(event.id(), unsignedEvent)
|
||||
|
||||
return unsignedEvent
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
println("Failed to unwrap gift ${event.id().toHex()}: ${e.message}")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? {
|
||||
try {
|
||||
val filter = Filter().identifier(giftId.toHex())
|
||||
val event = client?.database()?.query(filter)?.first()
|
||||
|
||||
return event?.content()?.let { UnsignedEvent.fromJson(it).ensureId() }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
|
||||
try {
|
||||
val isReaction = rumor.kind().asStd() == KindStandard.REACTION
|
||||
val kValue = if (isReaction) "reaction" else "dm"
|
||||
|
||||
// Construct reference tags
|
||||
val tags = listOf(
|
||||
Tag.identifier(giftId.toHex()),
|
||||
Tag.publicKey(rumor.author()),
|
||||
Tag.custom("r", listOf(rumor.roomId().toString())),
|
||||
Tag.custom("k", listOf("14", kValue))
|
||||
)
|
||||
|
||||
// Set event kind
|
||||
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
||||
|
||||
// Construct event
|
||||
val event = EventBuilder(kind, rumor.asJson())
|
||||
.tags(tags)
|
||||
.finalizeAsync(Keys.generate())
|
||||
|
||||
client?.database()?.saveEvent(event)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
println("Failed to set cached rumor: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getChatRooms(): Set<Room>? {
|
||||
try {
|
||||
val userPubkey =
|
||||
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||
|
||||
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
||||
val kTag = SingleLetterTag.lowercase(Alphabet.K)
|
||||
|
||||
// Get all rumors (DMs and Reactions)
|
||||
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm", "reaction"))
|
||||
val events = client?.database()?.query(filter)?.toVec() ?: return null
|
||||
|
||||
// Collect rooms
|
||||
val roomsMap: MutableMap<Long, Room> = mutableMapOf()
|
||||
val lastDmTimestampMap: MutableMap<Long, ULong> = mutableMapOf()
|
||||
|
||||
events
|
||||
.map { UnsignedEvent.fromJson(it.content()) }
|
||||
.filter { it.tags().publicKeys().isNotEmpty() }
|
||||
.forEach { rumor ->
|
||||
val id = rumor.roomId()
|
||||
val isFromMe = rumor.author() == userPubkey
|
||||
val isReaction = rumor.kind().asStd() == KindStandard.REACTION
|
||||
val existing = roomsMap[id]
|
||||
val createdAt = rumor.createdAt()
|
||||
|
||||
if (existing == null) {
|
||||
val room = Room.new(rumor = rumor, userPubkey = userPubkey, id = id)
|
||||
// If the first event we see is a reaction, don't use it as lastMessage
|
||||
roomsMap[id] = if (isReaction) {
|
||||
room.copy(lastMessage = null)
|
||||
} else {
|
||||
lastDmTimestampMap[id] = createdAt.asSecs()
|
||||
room
|
||||
}
|
||||
if (isFromMe) {
|
||||
roomsMap[id] = roomsMap[id]!!.copy(kind = RoomKind.Ongoing)
|
||||
}
|
||||
} else {
|
||||
// Update the overall room timestamp (for sorting) if this event is newer
|
||||
if (createdAt.asSecs() > existing.createdAt.asSecs()) {
|
||||
roomsMap[id] = roomsMap[id]!!.copy(createdAt = createdAt)
|
||||
}
|
||||
|
||||
// Update the last message content if this is a DM and it's newer than the last DM we've seen
|
||||
if (!isReaction) {
|
||||
val lastDmTs = lastDmTimestampMap[id] ?: 0uL
|
||||
if (createdAt.asSecs() >= lastDmTs) {
|
||||
lastDmTimestampMap[id] = createdAt.asSecs()
|
||||
roomsMap[id] = roomsMap[id]!!.copy(lastMessage = rumor.content())
|
||||
}
|
||||
}
|
||||
|
||||
// If any event is from the user, mark the room as Ongoing
|
||||
if (isFromMe && roomsMap[id]?.kind != RoomKind.Ongoing) {
|
||||
roomsMap[id] = roomsMap[id]!!.copy(kind = RoomKind.Ongoing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return roomsMap.values.sortedByDescending { it.createdAt.asSecs() }.toSet()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
println("Failed to get chat rooms: ${e.message}")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
|
||||
try {
|
||||
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
||||
val filter = Filter().kind(kind).reference(roomId.toString())
|
||||
val events = client?.database()?.query(filter)
|
||||
|
||||
// Merge the events
|
||||
return events
|
||||
?.toVec()
|
||||
?.map { UnsignedEvent.fromJson(it.content()).ensureId() }
|
||||
// Filter out events without public keys (receivers)
|
||||
?.filter { it.tags().publicKeys().isNotEmpty() }
|
||||
?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to get chat room messages: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun chatRoomConnect(members: List<PublicKey>) {
|
||||
try {
|
||||
members.forEach { member ->
|
||||
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||
val filter = Filter().kind(kind).author(member).limit(1u)
|
||||
|
||||
val stream = client?.streamEvents(
|
||||
target = ReqTarget.auto(listOf(filter)),
|
||||
id = null,
|
||||
timeout = Duration.parse("3s"),
|
||||
policy = ReqExitPolicy.ExitOnEose
|
||||
)
|
||||
|
||||
stream?.next()?.let { res ->
|
||||
val event = res.event ?: return@let
|
||||
connectMsgRelays(event)
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to fetch relays: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun connectMsgRelays(event: Event) {
|
||||
try {
|
||||
val urls = nip17ExtractRelayList(event)
|
||||
for (url in urls) {
|
||||
client?.addRelay(url, RelayCapabilities.gossip())
|
||||
client?.connectRelay(url)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to connect to relays: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendMessage(
|
||||
to: Set<PublicKey>,
|
||||
content: String,
|
||||
subject: String? = null,
|
||||
replies: List<EventId> = emptyList(),
|
||||
onRumorCreated: ((UnsignedEvent) -> Unit)? = null,
|
||||
) {
|
||||
try {
|
||||
val currentUser =
|
||||
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||
|
||||
val tags = mutableListOf<Tag>()
|
||||
|
||||
// Add a subject tag if provided
|
||||
if (subject != null) {
|
||||
tags.add(Tag.custom("subject", listOf(subject)))
|
||||
}
|
||||
|
||||
// Add event tags for replies
|
||||
if (replies.isNotEmpty()) {
|
||||
replies.forEach { replyId ->
|
||||
tags.add(Tag.event(replyId))
|
||||
}
|
||||
}
|
||||
|
||||
// Add public key tags for each recipient
|
||||
to.forEach { pubkey ->
|
||||
tags.add(Tag.publicKey(pubkey))
|
||||
}
|
||||
|
||||
for (receiver in setOf(currentUser) + to) {
|
||||
// Construct the rumor event
|
||||
// NEVER SIGN this event with the current user signer
|
||||
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), content)
|
||||
.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
|
||||
|
||||
// Collect failed outputs
|
||||
output.failed.forEach { (relayUrl, reason) ->
|
||||
println("Failed to send event to relay $relayUrl: $reason")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package su.reya.coop.nostr
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import rust.nostr.sdk.AsyncNostrSigner
|
||||
import rust.nostr.sdk.Client
|
||||
import rust.nostr.sdk.ClientBuilder
|
||||
import rust.nostr.sdk.ClientNotification
|
||||
import rust.nostr.sdk.Event
|
||||
import rust.nostr.sdk.EventId
|
||||
import rust.nostr.sdk.GossipConfig
|
||||
import rust.nostr.sdk.Keys
|
||||
import rust.nostr.sdk.KindStandard
|
||||
import rust.nostr.sdk.LogLevel
|
||||
import rust.nostr.sdk.Metadata
|
||||
import rust.nostr.sdk.NostrDatabase
|
||||
import rust.nostr.sdk.NostrGossip
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.RelayMessageEnum
|
||||
import rust.nostr.sdk.SignerAuthenticator
|
||||
import rust.nostr.sdk.SleepWhenIdle
|
||||
import rust.nostr.sdk.Timestamp
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import rust.nostr.sdk.initLogger
|
||||
import su.reya.coop.AppStorage
|
||||
import su.reya.coop.concord.ConcordManager
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
object NostrManager {
|
||||
val instance = Nostr()
|
||||
}
|
||||
|
||||
class Nostr(
|
||||
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
) {
|
||||
var client: Client? = null
|
||||
private set
|
||||
var signer: UniversalSigner = UniversalSigner(Keys.generate())
|
||||
private set
|
||||
|
||||
val messages = MessageManager(this)
|
||||
val profiles = ProfileManager(this)
|
||||
val relays = RelayManager(this)
|
||||
val concord = ConcordManager(this)
|
||||
|
||||
private val isInitialized = MutableStateFlow(false)
|
||||
|
||||
private val _newEvents = MutableSharedFlow<UnsignedEvent>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 100,
|
||||
onBufferOverflow = BufferOverflow.SUSPEND
|
||||
)
|
||||
val newEvents = _newEvents.asSharedFlow()
|
||||
|
||||
fun emitNewEvent(event: UnsignedEvent) {
|
||||
_newEvents.tryEmit(event)
|
||||
}
|
||||
|
||||
suspend fun init(
|
||||
dbPath: String,
|
||||
storage: AppStorage,
|
||||
logLevel: LogLevel = LogLevel.WARN,
|
||||
) {
|
||||
try {
|
||||
if (isInitialized.value) return
|
||||
|
||||
// Concord's keys live in the encrypted store, which only the Android layer can build.
|
||||
// Handing it over here keeps it out of the Context-free singleton's constructor.
|
||||
concord.attach(storage)
|
||||
|
||||
// Initialize the logger for nostr client
|
||||
initLogger(logLevel)
|
||||
|
||||
// Initialize configurations for nostr client
|
||||
val lmdb = NostrDatabase.lmdb(dbPath)
|
||||
val gossip = NostrGossip.inMemory()
|
||||
|
||||
// Initialize the authenticator
|
||||
val authenticator = SignerAuthenticator(signer)
|
||||
val idleTimeout = Duration.parse("5m")
|
||||
|
||||
client =
|
||||
ClientBuilder()
|
||||
.authenticator(authenticator)
|
||||
.database(lmdb)
|
||||
.gossip(gossip)
|
||||
.gossipConfig(
|
||||
GossipConfig()
|
||||
.noBackgroundRefresh()
|
||||
.fetchTimeout(Duration.parse("2s"))
|
||||
)
|
||||
.verifySubscriptions(false)
|
||||
.sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout))
|
||||
.build()
|
||||
|
||||
isInitialized.value = true
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun waitUntilInitialized() {
|
||||
withTimeoutOrNull(30.seconds) { isInitialized.first { it } }
|
||||
?: throw IllegalStateException("Nostr initialization timed out")
|
||||
}
|
||||
|
||||
suspend fun connectBootstrapRelays() {
|
||||
relays.connectBootstrapRelays()
|
||||
}
|
||||
|
||||
suspend fun prune() {
|
||||
try {
|
||||
client?.database()?.wipe()
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to prune database: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setSigner(new: AsyncNostrSigner) {
|
||||
try {
|
||||
signer.switch(new)
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to set signer: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun isSignedByUser(event: Event): Boolean {
|
||||
return try {
|
||||
signer.publicKeyFlow.value == event.author()
|
||||
} catch (e: Exception) {
|
||||
println("Failed to check if event is signed by user: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
suspend fun handleNotifications(
|
||||
onMetadataUpdate: (PublicKey, Metadata) -> Unit,
|
||||
onContactListUpdate: (List<PublicKey>) -> Unit,
|
||||
onNewMessage: (UnsignedEvent) -> Unit,
|
||||
) = supervisorScope {
|
||||
val now = Timestamp.now()
|
||||
val processedEvent = mutableSetOf<EventId>()
|
||||
val notifications = client?.notifications() ?: return@supervisorScope
|
||||
|
||||
val giftWrapQueue = Channel<Event>(1024)
|
||||
val concordQueue = Channel<Event>(1024)
|
||||
|
||||
var processedCount = 0
|
||||
var eoseReceived = false
|
||||
|
||||
try {
|
||||
concord.restore()
|
||||
} catch (e: Exception) {
|
||||
println("Failed to restore Concord state: ${e.message}")
|
||||
}
|
||||
|
||||
launch(defaultDispatcher) { concord.sync() }
|
||||
|
||||
launch(defaultDispatcher) {
|
||||
for (event in concordQueue) {
|
||||
try {
|
||||
concord.handlePlaneEvent(event)
|
||||
} catch (e: Exception) {
|
||||
println("Failed to handle Concord plane event: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
launch(defaultDispatcher) {
|
||||
for (event in giftWrapQueue) {
|
||||
val rumor = messages.extractRumor(event)
|
||||
processedCount++
|
||||
|
||||
if (rumor != null && !concord.onInboxRumor(rumor)) {
|
||||
val isSelfMessage = rumor.author() == signer.publicKeyFlow.value
|
||||
val isNew = rumor.createdAt().asSecs() >= now.asSecs()
|
||||
|
||||
if (isNew && !isSelfMessage) {
|
||||
onNewMessage(rumor)
|
||||
}
|
||||
}
|
||||
|
||||
// Update sync state
|
||||
messages.updateSyncState {
|
||||
it.copy(
|
||||
processedCount = processedCount,
|
||||
isSyncing = !eoseReceived || !giftWrapQueue.isEmpty
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
val notification = notifications.next() ?: continue
|
||||
|
||||
when (notification) {
|
||||
is ClientNotification.Message -> {
|
||||
when (val message = notification.message.asEnum()) {
|
||||
is RelayMessageEnum.EventMsg -> {
|
||||
val event = message.event
|
||||
|
||||
// Prevent processing duplicate events
|
||||
if (processedEvent.contains(event.id())) continue
|
||||
processedEvent.add(event.id())
|
||||
|
||||
when (event.kind().asStd()) {
|
||||
KindStandard.METADATA -> {
|
||||
try {
|
||||
val metadata = Metadata.fromJson(event.content())
|
||||
onMetadataUpdate(event.author(), metadata)
|
||||
} catch (e: Exception) {
|
||||
println("Failed to parse metadata: $e")
|
||||
}
|
||||
}
|
||||
|
||||
KindStandard.CONTACT_LIST -> {
|
||||
if (isSignedByUser(event = event)) {
|
||||
val pubkeys = event.tags().publicKeys()
|
||||
// Get mutual contacts
|
||||
profiles.syncMutualContacts(pubkeys)
|
||||
// Emit contact list update
|
||||
onContactListUpdate(pubkeys)
|
||||
}
|
||||
}
|
||||
|
||||
KindStandard.INBOX_RELAYS -> {
|
||||
// Get all gift wrap events for the current user
|
||||
if (isSignedByUser(event = event)) {
|
||||
messages.updateSyncState { it.copy(isSyncing = true) }
|
||||
messages.getUserMessages(msgRelayList = event)
|
||||
}
|
||||
}
|
||||
|
||||
KindStandard.GIFT_WRAP -> {
|
||||
if (concord.isPlaneAddress(event.author().toHex())) {
|
||||
concordQueue.send(event)
|
||||
} else {
|
||||
giftWrapQueue.send(event)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
is RelayMessageEnum.EndOfStoredEvents -> {
|
||||
if (message.subscriptionId == "gift-wraps") {
|
||||
eoseReceived = true
|
||||
if (giftWrapQueue.isEmpty) {
|
||||
messages.updateSyncState { it.copy(isSyncing = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
/* Ignore other message types */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is ClientNotification.Shutdown -> {
|
||||
break
|
||||
}
|
||||
|
||||
else -> {
|
||||
/* Ignore other message types */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
package su.reya.coop.nostr
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import rust.nostr.sdk.AckPolicy
|
||||
import rust.nostr.sdk.Client
|
||||
import rust.nostr.sdk.Contact
|
||||
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.Metadata
|
||||
import rust.nostr.sdk.MetadataRecord
|
||||
import rust.nostr.sdk.Nip05Address
|
||||
import rust.nostr.sdk.Nip05Profile
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.RelayCapabilities
|
||||
import rust.nostr.sdk.RelayUrl
|
||||
import rust.nostr.sdk.ReqExitPolicy
|
||||
import rust.nostr.sdk.ReqTarget
|
||||
import rust.nostr.sdk.SendEventTarget
|
||||
import rust.nostr.sdk.SubscribeAutoCloseOptions
|
||||
import rust.nostr.sdk.Timestamp
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Duration
|
||||
|
||||
class ProfileManager(private val nostr: Nostr) {
|
||||
private val client: Client? get() = nostr.client
|
||||
private val signer: UniversalSigner get() = nostr.signer
|
||||
private val httpClient by lazy { HttpClient() }
|
||||
|
||||
private val _metadataUpdates =
|
||||
MutableSharedFlow<Pair<PublicKey, Metadata>>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 100,
|
||||
onBufferOverflow = BufferOverflow.SUSPEND
|
||||
)
|
||||
val metadataUpdates = _metadataUpdates.asSharedFlow()
|
||||
|
||||
private val _contactListUpdates =
|
||||
MutableSharedFlow<List<PublicKey>>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 100,
|
||||
onBufferOverflow = BufferOverflow.SUSPEND
|
||||
)
|
||||
val contactListUpdates = _contactListUpdates.asSharedFlow()
|
||||
|
||||
suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) {
|
||||
_metadataUpdates.emit(pubkey to metadata)
|
||||
}
|
||||
|
||||
suspend fun emitContactListUpdate(contacts: List<PublicKey>) {
|
||||
_contactListUpdates.emit(contacts)
|
||||
}
|
||||
|
||||
suspend fun getUserMetadata() {
|
||||
try {
|
||||
val author =
|
||||
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||
|
||||
// Get the latest metadata event
|
||||
val metadataFilter =
|
||||
Filter().kind(Kind.fromStd(KindStandard.METADATA)).author(author).limit(1u)
|
||||
|
||||
// Get the latest contact list event
|
||||
val contactFilter =
|
||||
Filter().kind(Kind.fromStd(KindStandard.CONTACT_LIST)).author(author).limit(1u)
|
||||
|
||||
// Get the latest messaging relay list event
|
||||
val msgRelayFilter =
|
||||
Filter().kind(Kind.fromStd(KindStandard.INBOX_RELAYS)).author(author).limit(1u)
|
||||
|
||||
// Construct a target that includes all filters
|
||||
val target = ReqTarget.auto(listOf(metadataFilter, contactFilter, msgRelayFilter))
|
||||
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||
|
||||
client?.subscribe(target = target, id = "user-metadata", closeOn = opts)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to fetch user metadata: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun syncMutualContacts(pubkeys: List<PublicKey>) {
|
||||
try {
|
||||
val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
|
||||
val filter = Filter().kind(kind).authors(pubkeys).limit(pubkeys.size.toULong())
|
||||
val relays = RelayManager.BOOTSTRAP_RELAYS.map { RelayUrl.parse(it) }
|
||||
|
||||
client?.sync(filter, relays)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
println("Failed to sync mutual contacts: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) {
|
||||
// Send relay list event
|
||||
val relayList = nostr.relays.getDefaultRelayList()
|
||||
val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys)
|
||||
|
||||
client?.sendEvent(
|
||||
event = relayListEvent,
|
||||
target = SendEventTarget.broadcast(),
|
||||
ackPolicy = AckPolicy.all(),
|
||||
okTimeout = Duration.parse("3s")
|
||||
)
|
||||
|
||||
// Send messaging relay list event
|
||||
val msgRelayList = nostr.relays.getDefaultMsgRelayList()
|
||||
val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).finalizeAsync(keys)
|
||||
|
||||
client?.sendEvent(
|
||||
event = msgRelayListEvent,
|
||||
target = SendEventTarget.toNip65(),
|
||||
ackPolicy = AckPolicy.none()
|
||||
)
|
||||
|
||||
// Send metadata event
|
||||
val metadata =
|
||||
Metadata.fromRecord(MetadataRecord(displayName = name, about = bio, picture = picture))
|
||||
val metadataEvent = EventBuilder.metadata(metadata).finalizeAsync(keys)
|
||||
|
||||
client?.sendEvent(
|
||||
event = metadataEvent,
|
||||
target = SendEventTarget.broadcast(),
|
||||
ackPolicy = AckPolicy.none()
|
||||
)
|
||||
|
||||
// Send contact list event
|
||||
val defaultContact =
|
||||
Contact(PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x"))
|
||||
val contactListEvent = EventBuilder.contactList(listOf(defaultContact)).finalizeAsync(keys)
|
||||
|
||||
client?.sendEvent(
|
||||
event = contactListEvent,
|
||||
target = SendEventTarget.toNip65(),
|
||||
ackPolicy = AckPolicy.none()
|
||||
)
|
||||
|
||||
nostr.setSigner(keys)
|
||||
}
|
||||
|
||||
suspend fun updateProfile(
|
||||
name: String? = null,
|
||||
bio: String? = null,
|
||||
picture: String? = null
|
||||
): Metadata {
|
||||
try {
|
||||
val currentUser =
|
||||
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||
|
||||
// Get the latest metadata event
|
||||
val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord()
|
||||
|
||||
// Build a new metadata based on old records
|
||||
val newMetadata = Metadata.fromRecord(
|
||||
record.copy(
|
||||
displayName = name ?: record.displayName,
|
||||
about = bio ?: record.about,
|
||||
picture = picture ?: record.picture
|
||||
)
|
||||
)
|
||||
|
||||
// Send the new metadata event
|
||||
val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer)
|
||||
client?.sendEvent(
|
||||
event = event,
|
||||
target = SendEventTarget.broadcast(),
|
||||
ackPolicy = AckPolicy.none()
|
||||
)
|
||||
|
||||
return newMetadata
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to update identity: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? {
|
||||
return try {
|
||||
val kind = Kind.fromStd(KindStandard.METADATA)
|
||||
val filter = Filter().kind(kind).author(pubkey).limit(1u)
|
||||
val event = client?.database()?.query(filter)?.first() ?: return null
|
||||
|
||||
Metadata.fromJson(event.content())
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
println("Failed to get latest metadata: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getAllCacheMetadata(): Map<PublicKey, Metadata> {
|
||||
try {
|
||||
val filter = Filter().kind(Kind.fromStd(KindStandard.METADATA)).limit(100u)
|
||||
val events = client?.database()?.query(filter)
|
||||
val results = mutableMapOf<PublicKey, Metadata>()
|
||||
|
||||
events?.toVec()?.forEach { event ->
|
||||
try {
|
||||
val metadata = Metadata.fromJson(event.content())
|
||||
results[event.author()] = metadata
|
||||
} catch (e: Exception) {
|
||||
println("Failed to parse metadata: $e")
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
println("Failed to get all cache metadata: ${e.message}")
|
||||
return emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchMetadataBatch(keys: List<PublicKey>) {
|
||||
try {
|
||||
val limit = keys.size.toULong() * 2u
|
||||
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||
|
||||
// Construct a filter for metadata events
|
||||
val filter = Filter()
|
||||
.kind(Kind.fromStd(KindStandard.METADATA))
|
||||
.authors(keys)
|
||||
.limit(limit)
|
||||
|
||||
// Construct request target
|
||||
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
||||
RelayManager.BOOTSTRAP_RELAYS.forEach { relay ->
|
||||
target[RelayUrl.parse(relay)] = listOf(filter)
|
||||
}
|
||||
|
||||
client?.subscribe(target = ReqTarget.manual(target), closeOn = opts)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setContactList(contacts: List<PublicKey>) {
|
||||
try {
|
||||
val contactList = contacts.map { Contact(it) }
|
||||
val event = EventBuilder.contactList(contactList).finalizeAsync(signer)
|
||||
|
||||
client?.sendEvent(
|
||||
event = event,
|
||||
target = SendEventTarget.broadcast(),
|
||||
ackPolicy = AckPolicy.none(),
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to set contact list: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun profileFromAddress(client: HttpClient, address: Nip05Address): Nip05Profile {
|
||||
try {
|
||||
val response: HttpResponse = client.get(address.url())
|
||||
val bodyString: String = response.body()
|
||||
|
||||
return Nip05Profile.fromJson(address, bodyString)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to fetch profile from address: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun searchByAddress(query: String): PublicKey {
|
||||
try {
|
||||
val address = Nip05Address.parse(query)
|
||||
val profile = profileFromAddress(httpClient, address)
|
||||
|
||||
return profile.publicKey()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to search address: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun searchByNostr(query: String): List<PublicKey> {
|
||||
try {
|
||||
// Add search relay
|
||||
val searchRelay = RelayUrl.parse("wss://antiprimal.net")
|
||||
if (client?.relay(searchRelay) == null) {
|
||||
client?.addRelay(url = searchRelay, capabilities = RelayCapabilities.read())
|
||||
client?.connectRelay(searchRelay)
|
||||
}
|
||||
|
||||
val kinds = listOf(Kind.fromStd(KindStandard.METADATA))
|
||||
val filter = Filter().kinds(kinds).search(query).limit(10u)
|
||||
val target = ReqTarget.manual(mapOf(searchRelay to listOf(filter)))
|
||||
|
||||
val stream = client?.streamEvents(
|
||||
target = target,
|
||||
id = "search",
|
||||
timeout = Duration.parse("3s"),
|
||||
policy = ReqExitPolicy.ExitOnEose
|
||||
)
|
||||
|
||||
// Collect the results
|
||||
val results = mutableListOf<PublicKey>()
|
||||
|
||||
// Keep searching until the stream is closed or timeout
|
||||
stream?.next()?.let { event ->
|
||||
val event = event.event ?: return@let
|
||||
results.add(event.author())
|
||||
}
|
||||
|
||||
return results
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to search nostr: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? {
|
||||
try {
|
||||
val filter = Filter().author(pubkey).limit(3u)
|
||||
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
||||
RelayManager.BOOTSTRAP_RELAYS.forEach { relay ->
|
||||
target[RelayUrl.parse(relay)] = listOf(filter)
|
||||
}
|
||||
|
||||
val events = client?.fetchEvents(
|
||||
target = ReqTarget.manual(target),
|
||||
timeout = Duration.parse("3s")
|
||||
)
|
||||
|
||||
return events?.first()?.createdAt()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to get latest activity: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun verifyContact(pubkey: PublicKey): Boolean {
|
||||
try {
|
||||
val currentUser =
|
||||
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||
|
||||
val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
|
||||
val filter = Filter().kind(kind).author(currentUser).limit(1u)
|
||||
|
||||
val events = client?.database()?.query(filter)
|
||||
val pubkeys = events?.first()?.tags()?.publicKeys() ?: listOf()
|
||||
|
||||
return pubkeys.contains(pubkey)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> {
|
||||
try {
|
||||
val currentUser =
|
||||
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||
|
||||
val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
|
||||
val filter = Filter().kind(kind).pubkey(pubkey).limit(1u)
|
||||
|
||||
val events = client?.database()?.query(filter)
|
||||
val contacts = mutableSetOf<PublicKey>()
|
||||
|
||||
events?.toVec()?.filter { it.author() != currentUser }?.forEach { event ->
|
||||
contacts.add(event.author())
|
||||
}
|
||||
|
||||
return contacts.toSet()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package su.reya.coop.nostr
|
||||
|
||||
import rust.nostr.sdk.AckPolicy
|
||||
import rust.nostr.sdk.Client
|
||||
import rust.nostr.sdk.EventBuilder
|
||||
import rust.nostr.sdk.Filter
|
||||
import rust.nostr.sdk.Kind
|
||||
import rust.nostr.sdk.KindStandard
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.RelayCapabilities
|
||||
import rust.nostr.sdk.RelayMetadata
|
||||
import rust.nostr.sdk.RelayStatus
|
||||
import rust.nostr.sdk.RelayUrl
|
||||
import rust.nostr.sdk.ReqExitPolicy
|
||||
import rust.nostr.sdk.ReqTarget
|
||||
import rust.nostr.sdk.SendEventTarget
|
||||
import rust.nostr.sdk.SubscribeAutoCloseOptions
|
||||
import rust.nostr.sdk.extractRelayList
|
||||
import rust.nostr.sdk.nip17ExtractRelayList
|
||||
import kotlin.time.Duration
|
||||
|
||||
class RelayManager(private val nostr: Nostr) {
|
||||
companion object {
|
||||
val BOOTSTRAP_RELAYS = listOf(
|
||||
"wss://relay.ditto.pub",
|
||||
"wss://relay.primal.net",
|
||||
"wss://relay.nostr.net",
|
||||
"wss://profiles.nostr1.com",
|
||||
)
|
||||
|
||||
val INDEXER_RELAY = listOf(
|
||||
"wss://indexer.coracle.social",
|
||||
"wss://user.kindpag.es",
|
||||
)
|
||||
|
||||
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
|
||||
}
|
||||
|
||||
private val client: Client? get() = nostr.client
|
||||
private val signer: UniversalSigner get() = nostr.signer
|
||||
|
||||
suspend fun connectBootstrapRelays() {
|
||||
BOOTSTRAP_RELAYS.forEach { url ->
|
||||
client?.addRelay(RelayUrl.parse(url))
|
||||
}
|
||||
INDEXER_RELAY.forEach { url ->
|
||||
client?.addRelay(
|
||||
url = RelayUrl.parse(url),
|
||||
capabilities = RelayCapabilities.gossip()
|
||||
)
|
||||
}
|
||||
// Connect to all bootstrap relays
|
||||
client?.connect()
|
||||
}
|
||||
|
||||
suspend fun reconnect() {
|
||||
ALL_RELAYS.forEach { url ->
|
||||
try {
|
||||
client?.relay(RelayUrl.parse(url)).let { relay ->
|
||||
if (relay != null) {
|
||||
if (relay.status() != RelayStatus.CONNECTED) {
|
||||
relay.connect()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Failed to reconnect relay: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun disconnect() {
|
||||
ALL_RELAYS.forEach { url ->
|
||||
try {
|
||||
client?.disconnectRelay(RelayUrl.parse(url))
|
||||
} catch (e: Exception) {
|
||||
println("Failed to disconnect relay: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun getDefaultRelayList(): Map<RelayUrl, RelayMetadata> {
|
||||
// Construct a list of relays
|
||||
val relayList = mapOf(
|
||||
RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ,
|
||||
RelayUrl.parse("wss://relay.primal.net") to RelayMetadata.READ,
|
||||
RelayUrl.parse("wss://relay.nostr.net") to RelayMetadata.WRITE,
|
||||
RelayUrl.parse("wss://nostr.superfriends.online") to RelayMetadata.WRITE
|
||||
)
|
||||
|
||||
// Ensure all relays are added and connected
|
||||
relayList.forEach { (relay, metadata) ->
|
||||
client?.addRelay(
|
||||
url = relay,
|
||||
capabilities =
|
||||
when (metadata) {
|
||||
RelayMetadata.READ -> RelayCapabilities.read()
|
||||
RelayMetadata.WRITE -> RelayCapabilities.write()
|
||||
}
|
||||
)
|
||||
client?.connectRelay(relay)
|
||||
}
|
||||
|
||||
return relayList
|
||||
}
|
||||
|
||||
internal suspend fun getDefaultMsgRelayList(): List<RelayUrl> {
|
||||
// Construct a list of messaging relays
|
||||
val msgRelayList = listOf(
|
||||
RelayUrl.parse("wss://auth.nostr1.com"),
|
||||
RelayUrl.parse("wss://nip17.com"),
|
||||
)
|
||||
|
||||
// Ensure all relays are added and connected
|
||||
msgRelayList.forEach { relay ->
|
||||
client?.addRelay(relay, RelayCapabilities.none())
|
||||
client?.connectRelay(relay)
|
||||
}
|
||||
|
||||
return msgRelayList
|
||||
}
|
||||
|
||||
suspend fun getRelayList(publicKey: PublicKey): Map<RelayUrl, RelayMetadata?> {
|
||||
try {
|
||||
val kind = Kind.fromStd(KindStandard.RELAY_LIST)
|
||||
val filter = Filter().kind(kind).author(publicKey).limit(1u)
|
||||
val events = client?.database()?.query(filter)
|
||||
|
||||
return extractRelayList(events?.toVec()?.firstOrNull() ?: return emptyMap())
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to get relay list: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setRelayList(relays: Map<RelayUrl, RelayMetadata?>) {
|
||||
try {
|
||||
val event = EventBuilder.relayList(relays).finalizeAsync(signer)
|
||||
|
||||
client?.sendEvent(
|
||||
event = event,
|
||||
target = SendEventTarget.broadcast(),
|
||||
ackPolicy = AckPolicy.none(),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setMsgRelays(urls: List<RelayUrl>) {
|
||||
try {
|
||||
val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer)
|
||||
|
||||
client?.sendEvent(
|
||||
event = event,
|
||||
target = SendEventTarget.toNip65(),
|
||||
ackPolicy = AckPolicy.none(),
|
||||
)
|
||||
|
||||
val currentUser =
|
||||
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||
|
||||
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||
val filter = Filter().kind(kind).author(currentUser).limit(1u)
|
||||
|
||||
val target = ReqTarget.auto(listOf(filter))
|
||||
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||
|
||||
client?.subscribe(target = target, closeOn = opts)
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getMsgRelays(publicKey: PublicKey): List<RelayUrl> {
|
||||
try {
|
||||
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||
val filter = Filter().kind(kind).author(publicKey).limit(1u)
|
||||
val events = client?.database()?.query(filter)
|
||||
val event = events?.first() ?: return emptyList()
|
||||
|
||||
return nip17ExtractRelayList(event)
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to get msg relays: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchMsgRelays(publicKey: PublicKey): List<RelayUrl> {
|
||||
try {
|
||||
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||
val filter = Filter().kind(kind).author(publicKey).limit(1u)
|
||||
val target = ReqTarget.auto(listOf(filter))
|
||||
val events = client?.fetchEvents(target, timeout = Duration.parse("3s"))
|
||||
|
||||
return nip17ExtractRelayList(events?.toVec()?.firstOrNull() ?: return emptyList())
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to fetch msg relays: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-14
@@ -1,5 +1,7 @@
|
||||
package su.reya.coop
|
||||
package su.reya.coop.nostr
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
@@ -16,9 +18,8 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
|
||||
@Volatile
|
||||
private var signer: AsyncNostrSigner = initialSigner
|
||||
|
||||
@Volatile
|
||||
var currentUser: PublicKey? = null
|
||||
private set
|
||||
private val _publicKeyFlow = MutableStateFlow<PublicKey?>(null)
|
||||
val publicKeyFlow = _publicKeyFlow.asStateFlow()
|
||||
|
||||
/**
|
||||
* Get the current signer.
|
||||
@@ -28,16 +29,14 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
|
||||
/**
|
||||
* Switch to a new signer.
|
||||
*/
|
||||
suspend fun switch(newSigner: AsyncNostrSigner) = mutex.withLock {
|
||||
val pubkey = try {
|
||||
withTimeoutOrNull(20.seconds) {
|
||||
newSigner.getPublicKeyAsync()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to get public key from signer", e)
|
||||
suspend fun switch(newSigner: AsyncNostrSigner) {
|
||||
val pubkey = withTimeoutOrNull(20.seconds) { newSigner.getPublicKeyAsync() }
|
||||
?: throw IllegalStateException("Failed to get public key from signer")
|
||||
|
||||
mutex.withLock {
|
||||
signer = newSigner
|
||||
_publicKeyFlow.value = pubkey
|
||||
}
|
||||
signer = newSigner
|
||||
currentUser = pubkey
|
||||
}
|
||||
|
||||
override suspend fun getPublicKeyAsync(): PublicKey? {
|
||||
@@ -63,4 +62,4 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
|
||||
override suspend fun nip44DecryptAsync(publicKey: PublicKey, payload: String): String {
|
||||
return get().nip44DecryptAsync(publicKey, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
package su.reya.coop.repository
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import rust.nostr.sdk.AsyncNostrSigner
|
||||
import rust.nostr.sdk.EncryptedSecretKey
|
||||
import rust.nostr.sdk.Keys
|
||||
import rust.nostr.sdk.NostrConnect
|
||||
import rust.nostr.sdk.NostrConnectUri
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.RelayMetadata
|
||||
import rust.nostr.sdk.RelayUrl
|
||||
import rust.nostr.sdk.Timestamp
|
||||
import su.reya.coop.AppStorage
|
||||
import su.reya.coop.Profile
|
||||
import su.reya.coop.nostr.ExternalSignerHandler
|
||||
import su.reya.coop.nostr.ExternalSignerProxy
|
||||
import su.reya.coop.nostr.Nostr
|
||||
import su.reya.coop.nostr.SignerPermissions
|
||||
import su.reya.coop.viewmodel.ErrorHost
|
||||
import su.reya.coop.viewmodel.createErrorHost
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
data class AccountState(
|
||||
val signerRequired: Boolean? = null,
|
||||
val isNotificationBannerDismissed: Boolean = false,
|
||||
val isImporting: Boolean = false,
|
||||
val isRelayListEmpty: Boolean = false,
|
||||
val contactList: Set<PublicKey> = emptySet(),
|
||||
val userRelayList: Map<RelayUrl, RelayMetadata?> = emptyMap(),
|
||||
val userMsgRelayList: List<RelayUrl> = emptyList(),
|
||||
)
|
||||
|
||||
class AccountRepository(
|
||||
private val nostr: Nostr,
|
||||
private val storage: AppStorage,
|
||||
private val mediaRepository: MediaRepository,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val scope: CoroutineScope,
|
||||
private val externalSignerHandler: ExternalSignerHandler? = null,
|
||||
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
) : ErrorHost by createErrorHost() {
|
||||
companion object {
|
||||
private const val KEY_USER_SIGNER = "user_signer"
|
||||
private const val KEY_APP_KEYS = "app_keys"
|
||||
private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed"
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(AccountState())
|
||||
val state: StateFlow<AccountState> = _state.asStateFlow()
|
||||
|
||||
private val _isUpdatingProfile = MutableStateFlow(false)
|
||||
val isUpdatingProfile: StateFlow<Boolean> = _isUpdatingProfile.asStateFlow()
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val currentUserProfile: StateFlow<Profile?> = nostr.signer.publicKeyFlow
|
||||
.flatMapLatest { if (it != null) currentUserProfileFlow(it) else flowOf(null) }
|
||||
.stateIn(scope, SharingStarted.WhileSubscribed(5000), null)
|
||||
|
||||
init {
|
||||
checkNotificationBannerDismissedStatus()
|
||||
login()
|
||||
observeSignerState()
|
||||
observeContactList()
|
||||
}
|
||||
|
||||
private fun observeSignerState() {
|
||||
scope.launch {
|
||||
nostr.signer.publicKeyFlow
|
||||
.filterNotNull()
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { pubkey ->
|
||||
// Refresh metadata
|
||||
nostr.profiles.getUserMetadata()
|
||||
|
||||
// Verify messaging relays exist (wait up to 10s for initial fetch)
|
||||
val relays = withTimeoutOrNull(10.seconds) {
|
||||
var list = nostr.relays.getMsgRelays(pubkey)
|
||||
while (list.isEmpty()) {
|
||||
delay(500.milliseconds)
|
||||
list = nostr.relays.getMsgRelays(pubkey)
|
||||
}
|
||||
list
|
||||
} ?: emptyList()
|
||||
|
||||
// Automatically update the warning state
|
||||
_state.update { it.copy(isRelayListEmpty = relays.isEmpty()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkNotificationBannerDismissedStatus() {
|
||||
scope.launch {
|
||||
val dismissed = storage.get(KEY_BANNER_DISMISSED) == "true"
|
||||
_state.update { it.copy(isNotificationBannerDismissed = dismissed) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun login() {
|
||||
scope.launch {
|
||||
try {
|
||||
val secret = withTimeoutOrNull(5.seconds) {
|
||||
storage.getSecret(KEY_USER_SIGNER)
|
||||
}
|
||||
|
||||
if (secret == null) {
|
||||
_state.update { it.copy(signerRequired = true) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val (signer, _) = createSigner(secret)
|
||||
nostr.setSigner(signer)
|
||||
}.onSuccess {
|
||||
_state.update { it.copy(signerRequired = false) }
|
||||
}.onFailure { e ->
|
||||
showError("Login failed: ${e.message}")
|
||||
_state.update { it.copy(signerRequired = true) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
showError("Login failed: ${e.message}")
|
||||
_state.update { it.copy(signerRequired = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout(onLogout: () -> Unit = {}) {
|
||||
scope.launch {
|
||||
try {
|
||||
nostr.signer.switch(Keys.generate())
|
||||
nostr.prune()
|
||||
} catch (e: Exception) {
|
||||
showError("Logout encountered an error: ${e.message}")
|
||||
} finally {
|
||||
storage.clear(KEY_USER_SIGNER)
|
||||
storage.clear(KEY_BANNER_DISMISSED)
|
||||
onLogout()
|
||||
_state.update { it.copy(signerRequired = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissNotificationBanner() {
|
||||
scope.launch {
|
||||
storage.set(KEY_BANNER_DISMISSED, "true")
|
||||
_state.update { it.copy(isNotificationBannerDismissed = true) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getOrInitAppKeys(): Keys = withContext(defaultDispatcher) {
|
||||
val secret = storage.getSecret(KEY_APP_KEYS)
|
||||
if (secret != null) return@withContext Keys.parse(secret)
|
||||
val keys = Keys.generate()
|
||||
storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32())
|
||||
keys
|
||||
}
|
||||
|
||||
private suspend fun createSigner(
|
||||
secret: String,
|
||||
password: String? = null
|
||||
): Pair<AsyncNostrSigner, String?> = withContext(defaultDispatcher) {
|
||||
when {
|
||||
secret.startsWith("nsec1") -> Keys.parse(secret) to null
|
||||
|
||||
secret.startsWith("ncryptsec1") -> {
|
||||
if (password == null) throw IllegalArgumentException("Password is required")
|
||||
val enc = EncryptedSecretKey.fromBech32(secret)
|
||||
val decrypted = enc.decrypt(password)
|
||||
val keys = Keys(decrypted)
|
||||
keys to keys.secretKey().toBech32()
|
||||
}
|
||||
|
||||
secret.startsWith("bunker://") -> {
|
||||
val appKeys = getOrInitAppKeys()
|
||||
val bunker = NostrConnectUri.parse(secret)
|
||||
val timeout = 50.seconds
|
||||
NostrConnect(uri = bunker, appKeys, timeout, null) to null
|
||||
}
|
||||
|
||||
secret.startsWith("nip55://") -> {
|
||||
val handler = externalSignerHandler ?: throw IllegalStateException("Not available")
|
||||
val parts = secret.removePrefix("nip55://").split("/", limit = 2)
|
||||
val packageName = parts[0]
|
||||
val pubkey = PublicKey.parse(parts[1])
|
||||
|
||||
handler.setPackageName(packageName)
|
||||
ExternalSignerProxy(handler, pubkey) to null
|
||||
}
|
||||
|
||||
else -> throw IllegalArgumentException("Invalid secret format")
|
||||
}
|
||||
}
|
||||
|
||||
fun isExternalSignerAvailable(): Boolean {
|
||||
return externalSignerHandler?.isAvailable() == true
|
||||
}
|
||||
|
||||
fun importIdentity(secret: String, password: String? = null) {
|
||||
scope.launch {
|
||||
_state.update { it.copy(isImporting = true) }
|
||||
try {
|
||||
val (signer, decryptedSecret) = createSigner(secret, password)
|
||||
|
||||
nostr.setSigner(signer)
|
||||
|
||||
storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret)
|
||||
_state.update { it.copy(signerRequired = false, isImporting = false) }
|
||||
} catch (e: Exception) {
|
||||
showError("Import failed: ${e.message}")
|
||||
_state.update { it.copy(isImporting = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun connectExternalSigner() {
|
||||
scope.launch {
|
||||
_state.update { it.copy(isImporting = true) }
|
||||
try {
|
||||
val handler =
|
||||
externalSignerHandler ?: throw IllegalStateException("Signer not available")
|
||||
|
||||
val permissions = SignerPermissions.toJson(
|
||||
listOf(
|
||||
SignerPermissions.signEvent(0),
|
||||
SignerPermissions.signEvent(3),
|
||||
SignerPermissions.signEvent(10000),
|
||||
SignerPermissions.signEvent(10050),
|
||||
SignerPermissions.signEvent(10063),
|
||||
SignerPermissions.signEvent(22242),
|
||||
SignerPermissions.signEvent(30030),
|
||||
SignerPermissions.signEvent(30315),
|
||||
SignerPermissions.nip44Encrypt(),
|
||||
SignerPermissions.nip44Decrypt(),
|
||||
)
|
||||
)
|
||||
|
||||
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
|
||||
val signer = ExternalSignerProxy(handler, result.pubkey)
|
||||
val uri = "nip55://${result.packageName}/${result.pubkey.toHex()}"
|
||||
|
||||
nostr.setSigner(signer)
|
||||
|
||||
storage.setSecret(KEY_USER_SIGNER, uri)
|
||||
_state.update { it.copy(signerRequired = false, isImporting = false) }
|
||||
} catch (e: Exception) {
|
||||
showError("External signer connection failed: ${e.message}")
|
||||
_state.update { it.copy(isImporting = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createIdentity(
|
||||
name: String,
|
||||
bio: String?,
|
||||
picture: ByteArray?,
|
||||
contentType: String? = null
|
||||
) {
|
||||
scope.launch {
|
||||
_state.update { it.copy(isImporting = true) }
|
||||
try {
|
||||
val keys = Keys.generate()
|
||||
val secret = keys.secretKey().toBech32()
|
||||
val avatarUrl = picture?.let {
|
||||
mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg")
|
||||
}
|
||||
|
||||
nostr.profiles.createIdentity(
|
||||
keys = keys,
|
||||
name = name,
|
||||
bio = bio,
|
||||
picture = avatarUrl
|
||||
)
|
||||
|
||||
storage.setSecret(KEY_USER_SIGNER, secret)
|
||||
_state.update { it.copy(signerRequired = false, isImporting = false) }
|
||||
} catch (e: Exception) {
|
||||
showError("Identity creation failed: ${e.message}")
|
||||
_state.update { it.copy(isImporting = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private fun currentUserProfileFlow(pubkey: PublicKey) = merge(
|
||||
flow {
|
||||
nostr.waitUntilInitialized()
|
||||
val cached = nostr.profiles.getAllCacheMetadata()[pubkey]
|
||||
if (cached != null) emit(Profile(pubkey, cached))
|
||||
nostr.profiles.fetchMetadataBatch(listOf(pubkey))
|
||||
},
|
||||
nostr.profiles.metadataUpdates
|
||||
.filter { (p, _) -> p == pubkey }
|
||||
.map { (p, m) -> Profile(p, m) }
|
||||
)
|
||||
|
||||
fun updateProfile(
|
||||
name: String? = null,
|
||||
bio: String? = null,
|
||||
picture: ByteArray? = null,
|
||||
contentType: String? = null
|
||||
) {
|
||||
scope.launch {
|
||||
_isUpdatingProfile.value = true
|
||||
try {
|
||||
val avatarUrl = picture?.let {
|
||||
mediaRepository.blossomUpload(
|
||||
nostr.signer.get(),
|
||||
it,
|
||||
contentType ?: "image/jpeg"
|
||||
)
|
||||
}
|
||||
nostr.profiles.updateProfile(name, bio, avatarUrl)
|
||||
_isUpdatingProfile.value = false
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
_isUpdatingProfile.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeContactList() {
|
||||
scope.launch {
|
||||
nostr.waitUntilInitialized()
|
||||
nostr.profiles.contactListUpdates.collect { contacts ->
|
||||
_state.update { it.copy(contactList = contacts.toSet()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetInternalState() {
|
||||
_state.update { it.copy(contactList = emptySet(), isRelayListEmpty = false) }
|
||||
}
|
||||
|
||||
fun addContact(address: String) {
|
||||
scope.launch {
|
||||
val pubkey = try {
|
||||
if (address.contains("@")) {
|
||||
nostr.profiles.searchByAddress(address)
|
||||
} else {
|
||||
PublicKey.parse(address)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
showError("Invalid contact address: ${e.message}")
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (pubkey in _state.value.contactList) return@launch
|
||||
|
||||
try {
|
||||
val updated = _state.value.contactList + pubkey
|
||||
nostr.profiles.setContactList(updated.toList())
|
||||
_state.update { it.copy(contactList = it.contactList + pubkey) }
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeContact(publicKey: PublicKey) {
|
||||
scope.launch {
|
||||
if (publicKey !in _state.value.contactList) return@launch
|
||||
|
||||
try {
|
||||
val updated = _state.value.contactList - publicKey
|
||||
nostr.profiles.setContactList(updated.toList())
|
||||
_state.update { it.copy(contactList = it.contactList - publicKey) }
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) {
|
||||
scope.launch {
|
||||
try {
|
||||
onResult(nostr.profiles.searchByAddress(query))
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
onResult(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun searchByNostr(query: String, onResult: (List<PublicKey>) -> Unit) {
|
||||
scope.launch {
|
||||
try {
|
||||
onResult(nostr.profiles.searchByNostr(query))
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
onResult(emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) {
|
||||
scope.launch {
|
||||
try {
|
||||
onResult(nostr.profiles.verifyActivity(pubkey))
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
onResult(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) {
|
||||
scope.launch {
|
||||
try {
|
||||
onResult(nostr.profiles.verifyContact(pubkey))
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
onResult(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun mutualContacts(pubkey: PublicKey, onResult: (Set<PublicKey>) -> Unit) {
|
||||
scope.launch {
|
||||
try {
|
||||
onResult(nostr.profiles.mutualContacts(pubkey))
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
onResult(emptySet())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissRelayWarning() {
|
||||
_state.update { it.copy(isRelayListEmpty = false) }
|
||||
}
|
||||
|
||||
fun refetchMsgRelays() {
|
||||
scope.launch {
|
||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@launch
|
||||
val relays = nostr.relays.fetchMsgRelays(currentUser)
|
||||
|
||||
if (relays.isNotEmpty()) dismissRelayWarning()
|
||||
}
|
||||
}
|
||||
|
||||
fun useDefaultMsgRelayList() {
|
||||
scope.launch {
|
||||
try {
|
||||
val defaultRelays = nostr.relays.getDefaultMsgRelayList()
|
||||
nostr.relays.setMsgRelays(defaultRelays)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadCurrentUserRelayList() {
|
||||
scope.launch {
|
||||
try {
|
||||
val user = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||
val relayList = nostr.relays.getRelayList(user)
|
||||
_state.update { it.copy(userRelayList = relayList) }
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun currentUserRelayListInternal(): Map<RelayUrl, RelayMetadata?> {
|
||||
try {
|
||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||
return nostr.relays.getRelayList(currentUser)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
return emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
fun addInboxRelay(relay: String) {
|
||||
scope.launch {
|
||||
try {
|
||||
val relayUrl = RelayUrl.parse(relay)
|
||||
val relays = currentUserRelayListInternal().toMutableMap()
|
||||
relays[relayUrl] = RelayMetadata.WRITE
|
||||
|
||||
nostr.relays.setRelayList(relays)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addOutboxRelay(relay: String) {
|
||||
scope.launch {
|
||||
try {
|
||||
val relayUrl = RelayUrl.parse(relay)
|
||||
val relays = currentUserRelayListInternal().toMutableMap()
|
||||
relays[relayUrl] = RelayMetadata.READ
|
||||
|
||||
nostr.relays.setRelayList(relays)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeRelay(relay: String) {
|
||||
scope.launch {
|
||||
try {
|
||||
val relayUrl = RelayUrl.parse(relay)
|
||||
val relays = currentUserRelayListInternal().toMutableMap()
|
||||
relays.remove(relayUrl)
|
||||
|
||||
nostr.relays.setRelayList(relays)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadCurrentUserMsgRelayList() {
|
||||
scope.launch {
|
||||
try {
|
||||
val user = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||
val msgRelays = nostr.relays.getMsgRelays(user)
|
||||
_state.update { it.copy(userMsgRelayList = msgRelays) }
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun userLatestMsgRelayList(): List<RelayUrl> {
|
||||
try {
|
||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||
return nostr.relays.getMsgRelays(currentUser)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun addMsgRelay(relay: String) {
|
||||
scope.launch {
|
||||
try {
|
||||
val relayUrl = RelayUrl.parse(relay)
|
||||
val relays = userLatestMsgRelayList().toMutableSet()
|
||||
relays.add(relayUrl)
|
||||
|
||||
nostr.relays.setMsgRelays(relays.toList())
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeMsgRelay(relay: String) {
|
||||
scope.launch {
|
||||
try {
|
||||
val relayUrl = RelayUrl.parse(relay)
|
||||
val relays = userLatestMsgRelayList().toMutableSet()
|
||||
relays.remove(relayUrl)
|
||||
|
||||
nostr.relays.setMsgRelays(relays.toList())
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package su.reya.coop.repository
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import rust.nostr.sdk.EventBuilder
|
||||
import rust.nostr.sdk.EventId
|
||||
import rust.nostr.sdk.Kind
|
||||
import rust.nostr.sdk.KindStandard
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.Tag
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import su.reya.coop.Room
|
||||
import su.reya.coop.RoomKind
|
||||
import su.reya.coop.nostr.Nostr
|
||||
import su.reya.coop.roomId
|
||||
import su.reya.coop.viewmodel.ErrorHost
|
||||
import su.reya.coop.viewmodel.createErrorHost
|
||||
|
||||
data class ChatState(
|
||||
val rooms: Map<Long, Room> = emptyMap(),
|
||||
val isPartialProcessedGiftWrap: Boolean = false,
|
||||
)
|
||||
|
||||
class ChatRepository(
|
||||
private val nostr: Nostr,
|
||||
private val mediaRepository: MediaRepository,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val scope: CoroutineScope,
|
||||
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
) : ErrorHost by createErrorHost() {
|
||||
private val _state = MutableStateFlow(ChatState())
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
private val _newEvents = MutableSharedFlow<UnsignedEvent>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 100,
|
||||
onBufferOverflow = BufferOverflow.SUSPEND
|
||||
)
|
||||
val newEvents = _newEvents.asSharedFlow()
|
||||
|
||||
val chatRooms = state
|
||||
.map { it.rooms.values.sortedByDescending { it.createdAt.asSecs() } }
|
||||
.stateIn(scope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
val isSyncing = nostr.messages.messageSyncState
|
||||
.map { it.isSyncing }
|
||||
.stateIn(scope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
val isPartialProcessedGiftWrap = state
|
||||
.map { it.isPartialProcessedGiftWrap }
|
||||
.stateIn(scope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
init {
|
||||
scope.launch(defaultDispatcher) {
|
||||
nostr.waitUntilInitialized()
|
||||
|
||||
// Observe message sync progress
|
||||
launch {
|
||||
nostr.messages.messageSyncState.collect { syncState ->
|
||||
// When at least some messages are processed, allow UI to show the list
|
||||
if (syncState.processedCount > 0 || !syncState.isSyncing) {
|
||||
_state.update { it.copy(isPartialProcessedGiftWrap = true) }
|
||||
}
|
||||
|
||||
// Refresh UI every 100 messages OR when sync is fully done
|
||||
if (syncState.processedCount % 100 == 0 || !syncState.isSyncing) {
|
||||
refreshChatRooms()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Observe new messages
|
||||
launch {
|
||||
nostr.newEvents.collect { event ->
|
||||
updateRoomState(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createChatRoom(to: List<PublicKey>): Long {
|
||||
try {
|
||||
if (to.isEmpty()) {
|
||||
throw IllegalArgumentException("At least one recipient is required")
|
||||
}
|
||||
|
||||
// Get current user
|
||||
val currentUser = nostr.signer.publicKeyFlow.value
|
||||
?: throw IllegalStateException("User not signed in")
|
||||
|
||||
// Construct the rumor event
|
||||
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "")
|
||||
.tags(to.map { Tag.publicKey(it) })
|
||||
.finalizeUnsigned(currentUser)
|
||||
|
||||
// Check if the room already exists
|
||||
val id = rumor.roomId()
|
||||
val existingRoom = _state.value.rooms[id]
|
||||
|
||||
// If the room already exists, return its ID
|
||||
if (existingRoom != null) {
|
||||
return existingRoom.id
|
||||
}
|
||||
|
||||
// Create a room from the rumor event
|
||||
val room = Room.new(rumor, currentUser)
|
||||
|
||||
// Update the chat rooms state
|
||||
_state.update { it.copy(rooms = it.rooms + (room.id to room)) }
|
||||
|
||||
return room.id
|
||||
} catch (e: Exception) {
|
||||
throw IllegalArgumentException("Failed to create room: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun getChatRoom(id: Long): Room? {
|
||||
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() {
|
||||
scope.launch(defaultDispatcher) {
|
||||
try {
|
||||
val dbRooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||
_state.update { currentState ->
|
||||
val newMap = currentState.rooms.toMutableMap()
|
||||
dbRooms.forEach { dbRoom ->
|
||||
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)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadChatRoomMessages(roomId: Long, onResult: (List<UnsignedEvent>) -> Unit) {
|
||||
scope.launch {
|
||||
try {
|
||||
onResult(nostr.messages.getChatRoomMessages(roomId))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun chatRoomConnect(roomId: Long) {
|
||||
scope.launch(defaultDispatcher) {
|
||||
try {
|
||||
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||
val members = room.members
|
||||
|
||||
nostr.messages.chatRoomConnect(members.toList())
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) {
|
||||
if (message.isEmpty()) {
|
||||
showError("Message cannot be empty")
|
||||
return
|
||||
}
|
||||
scope.launch(defaultDispatcher) {
|
||||
try {
|
||||
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||
nostr.messages.sendMessage(
|
||||
to = room.members,
|
||||
content = message,
|
||||
subject = room.subject,
|
||||
replies = replies,
|
||||
onRumorCreated = {
|
||||
scope.launch(defaultDispatcher) {
|
||||
updateRoomState(it, roomId)
|
||||
}
|
||||
},
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendFileMessage(
|
||||
roomId: Long,
|
||||
file: ByteArray?,
|
||||
contentType: String? = "image/jpeg",
|
||||
replies: List<EventId> = emptyList()
|
||||
) {
|
||||
if (file == null) return
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType)
|
||||
if (uri != null) sendMessage(roomId, uri, replies)
|
||||
} catch (e: Exception) {
|
||||
showError("File upload failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendReaction(roomId: Long, targetEventId: EventId, reaction: String) {
|
||||
scope.launch(defaultDispatcher) {
|
||||
try {
|
||||
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||
nostr.messages.sendReaction(
|
||||
to = room.members,
|
||||
targetEventId = targetEventId,
|
||||
reaction = reaction,
|
||||
onRumorCreated = {
|
||||
scope.launch(defaultDispatcher) {
|
||||
updateRoomState(it, roomId)
|
||||
}
|
||||
},
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateRoomState(event: UnsignedEvent, roomId: Long = event.roomId()) {
|
||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: return
|
||||
val isReaction = event.kind().asStd() == KindStandard.REACTION
|
||||
|
||||
_state.update { currentState ->
|
||||
val rooms = currentState.rooms.toMutableMap()
|
||||
val existingRoom = rooms[roomId]
|
||||
|
||||
val isFromMe = event.author() == currentUser
|
||||
val newKind =
|
||||
if (isFromMe) RoomKind.Ongoing else (existingRoom?.kind ?: RoomKind.Request)
|
||||
|
||||
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() {
|
||||
_state.update {
|
||||
it.copy(
|
||||
rooms = emptyMap(),
|
||||
isPartialProcessedGiftWrap = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package su.reya.coop.repository
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import rust.nostr.sdk.AsyncNostrSigner
|
||||
import su.reya.coop.blossom.BlossomClient
|
||||
|
||||
class MediaRepository(
|
||||
private val settingsRepository: SettingsRepository
|
||||
) {
|
||||
private val httpClient = HttpClient {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun blossomUpload(
|
||||
signer: AsyncNostrSigner,
|
||||
file: ByteArray,
|
||||
contentType: String? = "image/jpeg"
|
||||
): String? {
|
||||
return try {
|
||||
val url = settingsRepository.settings.value.blossomServer ?: "https://blossom.band"
|
||||
val blossom = BlossomClient(url, httpClient)
|
||||
val descriptor = blossom.upload(file = file, contentType = contentType, signer = signer)
|
||||
descriptor?.url
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
println("Upload failed: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package su.reya.coop.storage
|
||||
|
||||
interface SecretStorage {
|
||||
suspend fun get(key: String): String?
|
||||
suspend fun set(key: String, value: String)
|
||||
suspend fun clear(key: String)
|
||||
suspend fun has(key: String): Boolean
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package su.reya.coop.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.Timestamp
|
||||
import su.reya.coop.Profile
|
||||
import su.reya.coop.repository.AccountRepository
|
||||
import su.reya.coop.repository.AccountState
|
||||
|
||||
class AccountViewModel(
|
||||
private val repository: AccountRepository,
|
||||
) : ViewModel(), ErrorHost by repository {
|
||||
val state: StateFlow<AccountState> = repository.state
|
||||
val isUpdatingProfile: StateFlow<Boolean> = repository.isUpdatingProfile
|
||||
val currentUserProfile: StateFlow<Profile?> = repository.currentUserProfile
|
||||
|
||||
fun logout(onLogout: () -> Unit = {}) = repository.logout(onLogout)
|
||||
fun dismissNotificationBanner() = repository.dismissNotificationBanner()
|
||||
fun isExternalSignerAvailable() = repository.isExternalSignerAvailable()
|
||||
fun importIdentity(secret: String, password: String? = null) =
|
||||
repository.importIdentity(secret, password)
|
||||
|
||||
fun connectExternalSigner() = repository.connectExternalSigner()
|
||||
fun createIdentity(
|
||||
name: String,
|
||||
bio: String?,
|
||||
picture: ByteArray?,
|
||||
contentType: String? = null
|
||||
) = repository.createIdentity(name, bio, picture, contentType)
|
||||
|
||||
fun updateProfile(
|
||||
name: String? = null,
|
||||
bio: String? = null,
|
||||
picture: ByteArray? = null,
|
||||
contentType: String? = null
|
||||
) = repository.updateProfile(name, bio, picture, contentType)
|
||||
|
||||
fun resetInternalState() = repository.resetInternalState()
|
||||
fun addContact(address: String) = repository.addContact(address)
|
||||
fun removeContact(publicKey: PublicKey) = repository.removeContact(publicKey)
|
||||
fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) =
|
||||
repository.searchByAddress(query, onResult)
|
||||
|
||||
fun searchByNostr(query: String, onResult: (List<PublicKey>) -> Unit) =
|
||||
repository.searchByNostr(query, onResult)
|
||||
|
||||
fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) =
|
||||
repository.verifyActivity(pubkey, onResult)
|
||||
|
||||
fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) =
|
||||
repository.verifyContact(pubkey, onResult)
|
||||
|
||||
fun mutualContacts(pubkey: PublicKey, onResult: (Set<PublicKey>) -> Unit) =
|
||||
repository.mutualContacts(pubkey, onResult)
|
||||
|
||||
fun dismissRelayWarning() = repository.dismissRelayWarning()
|
||||
fun refetchMsgRelays() = repository.refetchMsgRelays()
|
||||
fun useDefaultMsgRelayList() = repository.useDefaultMsgRelayList()
|
||||
fun loadCurrentUserRelayList() = repository.loadCurrentUserRelayList()
|
||||
fun addInboxRelay(relay: String) = repository.addInboxRelay(relay)
|
||||
fun addOutboxRelay(relay: String) = repository.addOutboxRelay(relay)
|
||||
fun loadCurrentUserMsgRelayList() = repository.loadCurrentUserMsgRelayList()
|
||||
fun addMsgRelay(relay: String) = repository.addMsgRelay(relay)
|
||||
fun removeMsgRelay(relay: String) = repository.removeMsgRelay(relay)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package su.reya.coop.viewmodel
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
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.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import rust.nostr.sdk.EventId
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import su.reya.coop.Profile
|
||||
import su.reya.coop.Room
|
||||
import su.reya.coop.repository.AccountRepository
|
||||
import su.reya.coop.repository.ChatRepository
|
||||
import su.reya.coop.roomId
|
||||
|
||||
class ChatScreenViewModel(
|
||||
val id: Long,
|
||||
screening: Boolean,
|
||||
accountRepository: AccountRepository,
|
||||
private val chatRepository: ChatRepository,
|
||||
) : ViewModel(), ErrorHost by chatRepository {
|
||||
val currentUser: StateFlow<Profile?> = accountRepository.currentUserProfile
|
||||
val chatRooms: StateFlow<List<Room>> = chatRepository.chatRooms
|
||||
|
||||
var loading by mutableStateOf(true)
|
||||
var newOtherMessages by mutableIntStateOf(0)
|
||||
var requireScreening by mutableStateOf(screening)
|
||||
val messages = mutableStateListOf<UnsignedEvent>()
|
||||
|
||||
init {
|
||||
loadMessages()
|
||||
connect()
|
||||
observeNewEvents()
|
||||
}
|
||||
|
||||
private fun loadMessages() {
|
||||
chatRepository.loadChatRoomMessages(id) { initialMessages ->
|
||||
messages.clear()
|
||||
messages.addAll(initialMessages.distinctBy { it.id() })
|
||||
loading = false
|
||||
// Mark the room as read once messages are loaded
|
||||
chatRepository.markAsRead(id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun connect() {
|
||||
chatRepository.chatRoomConnect(id)
|
||||
}
|
||||
|
||||
private fun observeNewEvents() {
|
||||
viewModelScope.launch {
|
||||
chatRepository.newEvents.collect { event ->
|
||||
if (event.roomId() == id) {
|
||||
if (messages.none { it.id() == event.id() }) {
|
||||
messages.add(0, event)
|
||||
chatRepository.markAsRead(id)
|
||||
}
|
||||
} else {
|
||||
newOtherMessages++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(text: String, replyTo: EventId? = null) {
|
||||
val replyToList = if (replyTo != null) listOf(replyTo) else emptyList()
|
||||
chatRepository.sendMessage(id, text, replyToList)
|
||||
}
|
||||
|
||||
fun sendFileMessage(file: ByteArray?, type: String?) {
|
||||
chatRepository.sendFileMessage(id, file, type)
|
||||
}
|
||||
|
||||
fun sendReaction(targetEventId: EventId, reaction: String) {
|
||||
chatRepository.sendReaction(id, targetEventId, reaction)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package su.reya.coop.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.Room
|
||||
import su.reya.coop.repository.ChatRepository
|
||||
import su.reya.coop.repository.ChatState
|
||||
|
||||
class ChatViewModel(
|
||||
private val repository: ChatRepository,
|
||||
) : ViewModel(), ErrorHost by repository {
|
||||
val state: StateFlow<ChatState> = repository.state
|
||||
val chatRooms: StateFlow<List<Room>> = repository.chatRooms
|
||||
val isSyncing: StateFlow<Boolean> = repository.isSyncing
|
||||
val isPartialProcessedGiftWrap: StateFlow<Boolean> = repository.isPartialProcessedGiftWrap
|
||||
|
||||
fun createChatRoom(to: List<PublicKey>): Long = repository.createChatRoom(to)
|
||||
fun refreshChatRooms() = repository.refreshChatRooms()
|
||||
fun resetInternalState() = repository.resetInternalState()
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package su.reya.coop.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import su.reya.coop.concord.CommunityInvite
|
||||
import su.reya.coop.concord.CommunityState
|
||||
import su.reya.coop.concord.InvitePreview
|
||||
import su.reya.coop.repository.ConcordRepository
|
||||
|
||||
class ConcordViewModel(
|
||||
private val repository: ConcordRepository,
|
||||
) : ViewModel(), ErrorHost by repository {
|
||||
val communities: StateFlow<List<CommunityState>> = repository.communities
|
||||
val directInvites: StateFlow<List<CommunityInvite>> = repository.directInvites
|
||||
val isReady: StateFlow<Boolean> = repository.isReady
|
||||
|
||||
fun previewDirectInvite(invite: CommunityInvite): InvitePreview =
|
||||
repository.previewDirectInvite(invite)
|
||||
|
||||
suspend fun previewInvite(link: String): InvitePreview? = repository.previewInvite(link)
|
||||
|
||||
suspend fun join(preview: InvitePreview): CommunityState? = repository.join(preview)
|
||||
|
||||
fun dismissDirectInvite(communityIdHex: String) = repository.dismissDirectInvite(communityIdHex)
|
||||
|
||||
fun resetInternalState() = repository.resetInternalState()
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package su.reya.coop.viewmodel
|
||||
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
|
||||
interface ErrorHost {
|
||||
val errorEvents: SharedFlow<String>
|
||||
|
||||
fun showError(message: String)
|
||||
}
|
||||
|
||||
fun createErrorHost(): ErrorHost = ErrorHostImpl()
|
||||
|
||||
private class ErrorHostImpl : ErrorHost {
|
||||
private val _errorEvents = MutableSharedFlow<String>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 10,
|
||||
onBufferOverflow = BufferOverflow.SUSPEND
|
||||
)
|
||||
|
||||
override val errorEvents: SharedFlow<String> = _errorEvents.asSharedFlow()
|
||||
|
||||
override fun showError(message: String) {
|
||||
_errorEvents.tryEmit(message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package su.reya.coop.viewmodel
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
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.coroutines.withTimeoutOrNull
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.Profile
|
||||
import su.reya.coop.nostr.Nostr
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class ProfileCache(
|
||||
private val nostr: Nostr,
|
||||
defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
) : ErrorHost by createErrorHost() {
|
||||
private val scope = CoroutineScope(SupervisorJob() + defaultDispatcher)
|
||||
private val profiles = MutableStateFlow<Map<PublicKey, MutableStateFlow<Profile?>>>(emptyMap())
|
||||
private val seenPublicKeys = MutableStateFlow<Set<PublicKey>>(emptySet())
|
||||
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
try {
|
||||
runObserver()
|
||||
} catch (e: Exception) {
|
||||
showError("Metadata observer failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
try {
|
||||
runMetadataBatching()
|
||||
} catch (e: Exception) {
|
||||
showError("Metadata batching failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
try {
|
||||
nostr.waitUntilInitialized()
|
||||
getCachedMetadata()
|
||||
} catch (e: Exception) {
|
||||
showError("Failed to load initial cache: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runObserver() {
|
||||
nostr.profiles.metadataUpdates.collect { (pubkey, metadata) ->
|
||||
updateMetadata(pubkey, Profile(pubkey, metadata))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runMetadataBatching() {
|
||||
nostr.waitUntilInitialized()
|
||||
|
||||
while (true) {
|
||||
val firstKey = metadataRequestChannel.receive()
|
||||
val batch = mutableSetOf(firstKey)
|
||||
|
||||
while (batch.size < 10) {
|
||||
val nextKey =
|
||||
withTimeoutOrNull(500.milliseconds) { metadataRequestChannel.receive() }
|
||||
?: break
|
||||
batch.add(nextKey)
|
||||
}
|
||||
|
||||
try {
|
||||
nostr.profiles.fetchMetadataBatch(batch.toList())
|
||||
} catch (e: Exception) {
|
||||
// Allow these keys to be requested again since the fetch failed
|
||||
seenPublicKeys.update { it - batch }
|
||||
println("Failed to fetch metadata batch: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getCachedMetadata() {
|
||||
val cache = nostr.profiles.getAllCacheMetadata()
|
||||
cache.forEach { (pubkey, metadata) ->
|
||||
updateMetadata(pubkey, Profile(pubkey, metadata))
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestMetadata(pubkey: PublicKey) {
|
||||
var added = false
|
||||
seenPublicKeys.update { current ->
|
||||
if (current.contains(pubkey)) {
|
||||
added = false
|
||||
current
|
||||
} else {
|
||||
added = true
|
||||
current + pubkey
|
||||
}
|
||||
}
|
||||
if (added) metadataRequestChannel.trySend(pubkey)
|
||||
}
|
||||
|
||||
private fun updateMetadata(pubkey: PublicKey, profile: Profile) {
|
||||
profiles.update { current ->
|
||||
val flow = current[pubkey] ?: MutableStateFlow<Profile?>(null)
|
||||
flow.value = profile
|
||||
if (current.containsKey(pubkey)) current else current + (pubkey to flow)
|
||||
}
|
||||
seenPublicKeys.update { it + pubkey }
|
||||
}
|
||||
|
||||
fun getMetadata(pubkey: PublicKey): StateFlow<Profile?> {
|
||||
val currentMap = profiles.value
|
||||
val existingFlow = currentMap[pubkey]
|
||||
|
||||
if (existingFlow != null) {
|
||||
if (existingFlow.value == null) requestMetadata(pubkey)
|
||||
return existingFlow.asStateFlow()
|
||||
}
|
||||
|
||||
val newFlow = MutableStateFlow<Profile?>(null)
|
||||
var resultFlow = newFlow
|
||||
|
||||
profiles.update { prev ->
|
||||
if (prev.containsKey(pubkey)) {
|
||||
resultFlow = prev[pubkey]!!
|
||||
prev
|
||||
} else {
|
||||
prev + (pubkey to newFlow)
|
||||
}
|
||||
}
|
||||
|
||||
if (resultFlow.value == null) requestMetadata(pubkey)
|
||||
|
||||
return resultFlow.asStateFlow()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# SOURCE CONFIGURATION
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
repository: https://git.reya.su/reya/coop-mobile
|
||||
release_source:
|
||||
url: https://git.reya.su/reya/coop-mobile
|
||||
type: gitea
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# APP METADATA
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
name: Coop
|
||||
summary: Chat and Co-op Privately
|
||||
license: GPLv3
|
||||
website: https://reya.su/coop
|
||||
|
||||
description: |
|
||||
Coop is a simple, cozy chat app built for Nostr. It’s designed for folks who want to stay connected without sacrificing their privacy or control over their data.
|
||||
|
||||
**Features**:
|
||||
- Private & Secure: Enjoy end-to-end encrypted messaging that keeps your private chats truly private.
|
||||
- Friendly Socializing: Connect with friends across the open Nostr network in a clean, easy-to-use interface.
|
||||
- No Identity Required: Get started without a phone number. Stay anonymous, use a pseudonym, or even your real name.
|
||||
|
||||
tags:
|
||||
- messaging
|
||||
- privacy
|
||||
- material3
|
||||
- nostr
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# MEDIA
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
icon: ./assets/icon.png
|
||||
|
||||
images:
|
||||
- ./assets/screenshot1.png
|
||||
- ./assets/screenshot2.png
|
||||
- ./assets/screenshot3.png
|
||||
- ./assets/screenshot4.png
|
||||
- ./assets/screenshot5.png
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# RELEASE CONFIGURATION
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
release_notes: ./CHANGELOG.md
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# NOSTR-SPECIFIC
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
supported_nips:
|
||||
- "01"
|
||||
- "17"
|
||||
- "46"
|
||||
- "55"
|
||||
- "59"
|
||||
|
||||
min_allowed_version_code: 100
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# METADATA SOURCES
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
metadata_sources:
|
||||
- playstore
|
||||
Reference in New Issue
Block a user