Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37003e2fc4 | |||
| 154906017e | |||
| 91e4e3b43d | |||
| ea90a43909 | |||
| 938b192136 |
@@ -24,7 +24,7 @@ kotlin {
|
|||||||
implementation(libs.jetbrains.navigation3.ui)
|
implementation(libs.jetbrains.navigation3.ui)
|
||||||
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
|
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
|
||||||
implementation(libs.androidx.core.splashscreen)
|
implementation(libs.androidx.core.splashscreen)
|
||||||
implementation("su.reya:nostr-sdk-kmp:0.2.7")
|
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-compose:3.4.0")
|
||||||
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
|
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
|
||||||
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
|
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
|
||||||
@@ -69,7 +69,7 @@ android {
|
|||||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.1.9"
|
versionName = "0.2.0"
|
||||||
}
|
}
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<queries>
|
<queries>
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
android:name=".NostrForegroundService"
|
android:name=".NostrForegroundService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="remoteMessaging" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -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="M324,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,880Q397,880 324,848.5ZM480,800Q534,800 584,782.5Q634,765 676,732L228,284Q195,326 177.5,376Q160,426 160,480Q160,614 253,707Q346,800 480,800ZM732,676Q765,634 782.5,584Q800,534 800,480Q800,346 707,253Q614,160 480,160Q426,160 376,177.5Q326,195 284,228L732,676ZM480,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="M336,680L480,536L624,680L680,624L536,480L680,336L624,280L480,424L336,280L280,336L424,480L280,624L336,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="M424,664L706,382L650,326L424,552L310,438L254,494L424,664ZM480,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="M480,680Q497,680 508.5,668.5Q520,657 520,640Q520,623 508.5,611.5Q497,600 480,600Q463,600 451.5,611.5Q440,623 440,640Q440,657 451.5,668.5Q463,680 480,680ZM440,520L520,520L520,280L440,280L440,520ZM330,840L120,630L120,330L330,120L630,120L840,330L840,630L630,840L330,840ZM364,760L596,760L760,596L760,364L596,200L364,200L200,364L200,596L364,760ZM480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,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="M720,560L720,440L600,440L600,360L720,360L720,240L800,240L800,360L920,360L920,440L800,440L800,560L720,560ZM247,433Q200,386 200,320Q200,254 247,207Q294,160 360,160Q426,160 473,207Q520,254 520,320Q520,386 473,433Q426,480 360,480Q294,480 247,433ZM40,800L40,688Q40,654 57.5,625.5Q75,597 104,582Q166,551 230,535.5Q294,520 360,520Q426,520 490,535.5Q554,551 616,582Q645,597 662.5,625.5Q680,654 680,688L680,800L40,800ZM120,720L600,720L600,688Q600,677 594.5,668Q589,659 580,654Q526,627 471,613.5Q416,600 360,600Q304,600 249,613.5Q194,627 140,654Q131,659 125.5,668Q120,677 120,688L120,720ZM416.5,376.5Q440,353 440,320Q440,287 416.5,263.5Q393,240 360,240Q327,240 303.5,263.5Q280,287 280,320Q280,353 303.5,376.5Q327,400 360,400Q393,400 416.5,376.5ZM360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320ZM360,720L360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720L360,720Z" />
|
||||||
|
</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="M330,840L120,630L120,330L330,120L630,120L840,330L840,630L630,840L330,840ZM366,650L480,536L594,650L650,594L536,480L650,366L594,310L480,424L366,310L310,366L424,480L310,594L366,650ZM364,760L596,760L760,596L760,364L596,200L364,200L200,364L200,596L364,760ZM480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480Z" />
|
||||||
|
</vector>
|
||||||
@@ -16,14 +16,12 @@ import androidx.compose.material3.darkColorScheme
|
|||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
import androidx.compose.material3.expressiveLightColorScheme
|
import androidx.compose.material3.expressiveLightColorScheme
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -37,6 +35,7 @@ import androidx.navigation3.runtime.rememberNavBackStack
|
|||||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import su.reya.coop.screens.ChatScreen
|
import su.reya.coop.screens.ChatScreen
|
||||||
|
import su.reya.coop.screens.ContactListScreen
|
||||||
import su.reya.coop.screens.HomeScreen
|
import su.reya.coop.screens.HomeScreen
|
||||||
import su.reya.coop.screens.ImportScreen
|
import su.reya.coop.screens.ImportScreen
|
||||||
import su.reya.coop.screens.MyQrScreen
|
import su.reya.coop.screens.MyQrScreen
|
||||||
@@ -45,6 +44,7 @@ import su.reya.coop.screens.NewIdentityScreen
|
|||||||
import su.reya.coop.screens.OnboardingScreen
|
import su.reya.coop.screens.OnboardingScreen
|
||||||
import su.reya.coop.screens.ProfileScreen
|
import su.reya.coop.screens.ProfileScreen
|
||||||
import su.reya.coop.screens.RelayScreen
|
import su.reya.coop.screens.RelayScreen
|
||||||
|
import su.reya.coop.screens.RequestListScreen
|
||||||
import su.reya.coop.screens.ScanScreen
|
import su.reya.coop.screens.ScanScreen
|
||||||
import su.reya.coop.screens.UpdateProfileScreen
|
import su.reya.coop.screens.UpdateProfileScreen
|
||||||
|
|
||||||
@@ -69,8 +69,6 @@ val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
|
|||||||
fun App(viewModel: NostrViewModel) {
|
fun App(viewModel: NostrViewModel) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val activity = context as? ComponentActivity
|
val activity = context as? ComponentActivity
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val sheetState = rememberModalBottomSheetState()
|
|
||||||
val backStack = rememberNavBackStack(Screen.Home)
|
val backStack = rememberNavBackStack(Screen.Home)
|
||||||
val navigator = remember(backStack) { Navigator(backStack) }
|
val navigator = remember(backStack) { Navigator(backStack) }
|
||||||
val qrScanResult = remember { QrScanResult() }
|
val qrScanResult = remember { QrScanResult() }
|
||||||
@@ -167,6 +165,9 @@ fun App(viewModel: NostrViewModel) {
|
|||||||
entry<Screen.Home> {
|
entry<Screen.Home> {
|
||||||
HomeScreen()
|
HomeScreen()
|
||||||
}
|
}
|
||||||
|
entry<Screen.RequestList> {
|
||||||
|
RequestListScreen()
|
||||||
|
}
|
||||||
entry<Screen.Onboarding> {
|
entry<Screen.Onboarding> {
|
||||||
OnboardingScreen()
|
OnboardingScreen()
|
||||||
}
|
}
|
||||||
@@ -177,7 +178,7 @@ fun App(viewModel: NostrViewModel) {
|
|||||||
NewIdentityScreen()
|
NewIdentityScreen()
|
||||||
}
|
}
|
||||||
entry<Screen.Chat> { key ->
|
entry<Screen.Chat> { key ->
|
||||||
ChatScreen(id = key.id)
|
ChatScreen(id = key.id, screening = key.screening)
|
||||||
}
|
}
|
||||||
entry<Screen.NewChat> {
|
entry<Screen.NewChat> {
|
||||||
NewChatScreen()
|
NewChatScreen()
|
||||||
@@ -194,6 +195,9 @@ fun App(viewModel: NostrViewModel) {
|
|||||||
entry<Screen.MyQr> {
|
entry<Screen.MyQr> {
|
||||||
MyQrScreen()
|
MyQrScreen()
|
||||||
}
|
}
|
||||||
|
entry<Screen.ContactList> {
|
||||||
|
ContactListScreen()
|
||||||
|
}
|
||||||
entry<Screen.Relay> {
|
entry<Screen.Relay> {
|
||||||
RelayScreen()
|
RelayScreen()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ sealed interface Screen : NavKey {
|
|||||||
|
|
||||||
return when (data.host) {
|
return when (data.host) {
|
||||||
// Matches coop://chat/{id}
|
// Matches coop://chat/{id}
|
||||||
"chat" -> data.pathSegments.firstOrNull()?.toLongOrNull()?.let { Chat(it) }
|
"chat" -> data.pathSegments.firstOrNull()?.toLongOrNull()?.let { Chat(it, false) }
|
||||||
// Matches coop://profile/{pubkey}
|
// Matches coop://profile/{pubkey}
|
||||||
"profile" -> data.pathSegments.firstOrNull()?.let { Profile(it) }
|
"profile" -> data.pathSegments.firstOrNull()?.let { Profile(it) }
|
||||||
else -> null
|
else -> null
|
||||||
@@ -24,11 +24,17 @@ sealed interface Screen : NavKey {
|
|||||||
data object Home : Screen
|
data object Home : Screen
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Chat(val id: Long) : Screen
|
data object RequestList : Screen
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Chat(val id: Long, val screening: Boolean = false) : Screen
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Profile(val pubkey: String) : Screen
|
data class Profile(val pubkey: String) : Screen
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object ContactList : Screen
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object UpdateProfile : Screen
|
data object UpdateProfile : Screen
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import kotlinx.coroutines.cancel
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
|
private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES"
|
||||||
|
|
||||||
class NostrForegroundService : Service() {
|
class NostrForegroundService : Service() {
|
||||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
private val nostr by lazy { NostrManager.instance }
|
private val nostr by lazy { NostrManager.instance }
|
||||||
@@ -29,26 +31,26 @@ class NostrForegroundService : Service() {
|
|||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
private fun isUserInApp(): Boolean {
|
|
||||||
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
val notification = createNotification()
|
startAsForeground()
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
||||||
startForeground(1, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
|
||||||
} else {
|
|
||||||
startForeground(1, notification)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (intent?.action == "STOP_SERVICE") {
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the nostr service in the foreground
|
||||||
|
startAsForeground()
|
||||||
|
|
||||||
|
// Check if the service is already running
|
||||||
if (notificationJob?.isActive == true) return START_STICKY
|
if (notificationJob?.isActive == true) return START_STICKY
|
||||||
|
|
||||||
|
// Start the Nostr client
|
||||||
notificationJob = serviceScope.launch {
|
notificationJob = serviceScope.launch {
|
||||||
try {
|
try {
|
||||||
Log.d("Coop", "Starting Nostr in background")
|
Log.d("Coop", "Starting Nostr in background")
|
||||||
@@ -93,6 +95,26 @@ class NostrForegroundService : Service() {
|
|||||||
return START_STICKY
|
return START_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isUserInApp(): Boolean {
|
||||||
|
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startAsForeground() {
|
||||||
|
val notification = createNotification()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
startForeground(1, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING)
|
||||||
|
} else {
|
||||||
|
startForeground(1, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getStopServicePendingIntent(): PendingIntent {
|
||||||
|
val intent = Intent(this, NostrForegroundService::class.java).apply {
|
||||||
|
action = "STOP_SERVICE"
|
||||||
|
}
|
||||||
|
return PendingIntent.getService(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
}
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
private fun createNotificationChannel() {
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
|
|
||||||
@@ -115,10 +137,12 @@ class NostrForegroundService : Service() {
|
|||||||
|
|
||||||
private fun createNotification(content: String? = null): Notification {
|
private fun createNotification(content: String? = null): Notification {
|
||||||
val builder = NotificationCompat.Builder(this, "nostr_service")
|
val builder = NotificationCompat.Builder(this, "nostr_service")
|
||||||
|
.setGroup(GROUP_KEY_MESSAGES)
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||||
.setCategory(Notification.CATEGORY_SERVICE)
|
.setCategory(Notification.CATEGORY_SERVICE)
|
||||||
|
.addAction(R.drawable.ic_notification, "Stop", getStopServicePendingIntent())
|
||||||
|
|
||||||
if (content != null) {
|
if (content != null) {
|
||||||
builder.setContentTitle("Coop")
|
builder.setContentTitle("Coop")
|
||||||
|
|||||||
@@ -22,11 +22,15 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Badge
|
import androidx.compose.material3.Badge
|
||||||
import androidx.compose.material3.BadgedBox
|
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.FilledTonalIconButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
import androidx.compose.material3.LoadingIndicator
|
import androidx.compose.material3.LoadingIndicator
|
||||||
|
import androidx.compose.material3.MaterialShapes
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
@@ -36,39 +40,50 @@ import androidx.compose.material3.TextField
|
|||||||
import androidx.compose.material3.TextFieldDefaults
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.toShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
|
import coop.composeapp.generated.resources.ic_cancel
|
||||||
|
import coop.composeapp.generated.resources.ic_check_circle
|
||||||
import coop.composeapp.generated.resources.ic_send
|
import coop.composeapp.generated.resources.ic_send
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.Timestamp
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
import su.reya.coop.Room
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.formatAsGroupHeader
|
import su.reya.coop.formatAsGroupHeader
|
||||||
|
import su.reya.coop.humanReadable
|
||||||
import su.reya.coop.roomId
|
import su.reya.coop.roomId
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
import su.reya.coop.shared.displayNameFlow
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
|
import su.reya.coop.shared.nameFlow
|
||||||
import su.reya.coop.shared.pictureFlow
|
import su.reya.coop.shared.pictureFlow
|
||||||
|
import su.reya.coop.short
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatScreen(id: Long) {
|
fun ChatScreen(id: Long, screening: Boolean = false) {
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val viewModel = LocalNostrViewModel.current
|
||||||
@@ -92,12 +107,13 @@ fun ChatScreen(id: Long) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val displayName by remember(room) { room!!.displayNameFlow(viewModel) }.collectAsState("Loading...")
|
val displayName by remember(room) { room!!.nameFlow(viewModel) }.collectAsStateWithLifecycle("Loading...")
|
||||||
val picture by remember(room) { room!!.pictureFlow(viewModel) }.collectAsState(null)
|
val picture by remember(room) { room!!.pictureFlow(viewModel) }.collectAsStateWithLifecycle(null)
|
||||||
|
|
||||||
var text by remember { mutableStateOf("") }
|
var text by remember { mutableStateOf("") }
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
var newOtherMessages by remember { mutableIntStateOf(0) }
|
var newOtherMessages by remember { mutableIntStateOf(0) }
|
||||||
|
var requireScreening by remember { mutableStateOf(screening) }
|
||||||
|
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val messages = remember { mutableStateListOf<UnsignedEvent>() }
|
val messages = remember { mutableStateListOf<UnsignedEvent>() }
|
||||||
@@ -155,9 +171,7 @@ fun ChatScreen(id: Long) {
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
LoadingIndicator(
|
LoadingIndicator(modifier = Modifier.size(32.dp))
|
||||||
modifier = Modifier.size(32.dp),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
Avatar(
|
Avatar(
|
||||||
picture = picture,
|
picture = picture,
|
||||||
@@ -208,66 +222,231 @@ fun ChatScreen(id: Long) {
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||||
) {
|
) {
|
||||||
if (messages.isNotEmpty()) {
|
if (requireScreening) {
|
||||||
LazyColumn(
|
room?.let { ScreenerCard(it) }
|
||||||
modifier = Modifier
|
}
|
||||||
.weight(1f)
|
|
||||||
.fillMaxWidth(),
|
when (messages.isNotEmpty()) {
|
||||||
contentPadding = PaddingValues(16.dp),
|
true -> {
|
||||||
reverseLayout = true,
|
LazyColumn(
|
||||||
state = listState,
|
modifier = Modifier
|
||||||
) {
|
.weight(1f)
|
||||||
groupedMessages.forEach { (dateHeader, messagesInGroup) ->
|
.fillMaxWidth(),
|
||||||
items(
|
contentPadding = PaddingValues(16.dp),
|
||||||
messagesInGroup,
|
reverseLayout = true,
|
||||||
key = { it.id()?.toBech32()!! }) { event ->
|
state = listState,
|
||||||
ChatMessage(event)
|
) {
|
||||||
}
|
groupedMessages.forEach { (dateHeader, messagesInGroup) ->
|
||||||
item {
|
items(
|
||||||
DateSeparator(dateHeader)
|
messagesInGroup,
|
||||||
|
key = { it.id()?.toBech32()!! }) { event ->
|
||||||
|
ChatMessage(event)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
DateSeparator(dateHeader)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Box(
|
false -> {
|
||||||
modifier = Modifier
|
Box(
|
||||||
.weight(1f)
|
modifier = Modifier
|
||||||
.fillMaxWidth(),
|
.weight(1f)
|
||||||
contentAlignment = Alignment.Center
|
.fillMaxWidth(),
|
||||||
) {
|
contentAlignment = Alignment.Center
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Column(
|
||||||
text = "No messages yet",
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
fontWeight = FontWeight.SemiBold
|
) {
|
||||||
),
|
Text(
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
text = "No messages yet",
|
||||||
)
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
Text(
|
fontWeight = FontWeight.SemiBold
|
||||||
text = "Your conversations will appear here.",
|
),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
color = MaterialTheme.colorScheme.outline
|
)
|
||||||
)
|
Text(
|
||||||
|
text = "Your conversations will appear here.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ChatInput(
|
|
||||||
value = text,
|
when (requireScreening) {
|
||||||
onValueChange = { text = it },
|
true -> {
|
||||||
onSend = {
|
Row(
|
||||||
viewModel.sendMessage(id, text)
|
modifier = Modifier
|
||||||
text = ""
|
.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
|
@Composable
|
||||||
fun DateSeparator(date: String) {
|
fun DateSeparator(date: String) {
|
||||||
Box(
|
Box(
|
||||||
@@ -345,7 +524,6 @@ fun ChatInput(
|
|||||||
onValueChange: (String) -> Unit,
|
onValueChange: (String) -> Unit,
|
||||||
onSend: () -> Unit
|
onSend: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
|
||||||
Surface(modifier = Modifier.fillMaxWidth()) {
|
Surface(modifier = Modifier.fillMaxWidth()) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
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.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.PlainTooltip
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SegmentedListItem
|
||||||
|
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.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
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
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
|
||||||
|
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.LocalSnackbarHostState
|
||||||
|
import su.reya.coop.Screen
|
||||||
|
import su.reya.coop.shared.Avatar
|
||||||
|
import su.reya.coop.short
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ContactListScreen() {
|
||||||
|
val navigator = LocalNavigator.current
|
||||||
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
|
val contactList by viewModel.contactList.collectAsStateWithLifecycle()
|
||||||
|
var openAddContactDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "My Contacts",
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
TooltipBox(
|
||||||
|
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
|
||||||
|
TooltipAnchorPosition.Above,
|
||||||
|
spacingBetweenTooltipAndAnchor = 8.dp,
|
||||||
|
),
|
||||||
|
tooltip = {
|
||||||
|
PlainTooltip { Text("New Contact") }
|
||||||
|
},
|
||||||
|
state = rememberTooltipState(),
|
||||||
|
) {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
onClick = { openAddContactDialog = true },
|
||||||
|
expanded = false,
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_plus),
|
||||||
|
contentDescription = "New Contact"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = { Text("New Contact") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content = { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
.padding(innerPadding),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
|
||||||
|
) {
|
||||||
|
if (contactList.isNotEmpty()) {
|
||||||
|
contactList.forEachIndexed { index, pubkey ->
|
||||||
|
ContactListItem(
|
||||||
|
pubkey = pubkey,
|
||||||
|
index = index,
|
||||||
|
total = contactList.size,
|
||||||
|
onClick = {})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (openAddContactDialog) {
|
||||||
|
AddContactDialog(onDismissRequest = { openAddContactDialog = false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun AddContactDialog(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()
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = { onDismissRequest() },
|
||||||
|
properties = DialogProperties(
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
|
dismissOnBackPress = true,
|
||||||
|
decorFitsSystemWindows = false,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
),
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "New Contact",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { onDismissRequest() }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_close),
|
||||||
|
contentDescription = "Close"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = {
|
||||||
|
scope.launch {
|
||||||
|
val success = viewModel.addContact(contact)
|
||||||
|
if (success) onDismissRequest()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_check),
|
||||||
|
contentDescription = "Add"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
content = { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(innerPadding)
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = contact,
|
||||||
|
onValueChange = { contact = it },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
isError = isError,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
isError = contact.isNotEmpty() && !verifyContact(contact)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
label = { Text(text = "Contact Address") },
|
||||||
|
placeholder = { Text(text = "npub1... or user@example.com") },
|
||||||
|
supportingText = {
|
||||||
|
if (isError) {
|
||||||
|
Text(text = "Contact address is invalid")
|
||||||
|
} else {
|
||||||
|
Text(text = "Only add contact you trust.")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ContactListItem(
|
||||||
|
pubkey: PublicKey,
|
||||||
|
index: Int,
|
||||||
|
total: Int = 0,
|
||||||
|
onClick: () -> 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
|
||||||
|
|
||||||
|
SegmentedListItem(
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = { viewModel.removeContact(pubkey) },
|
||||||
|
shapes = ListItemDefaults.segmentedShapes(
|
||||||
|
index = index,
|
||||||
|
count = total
|
||||||
|
),
|
||||||
|
leadingContent = {
|
||||||
|
Avatar(
|
||||||
|
picture = picture,
|
||||||
|
description = displayName,
|
||||||
|
size = 36.dp
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = { Text(text = pubkey.short()) },
|
||||||
|
content = {
|
||||||
|
Text(
|
||||||
|
text = displayName,
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun verifyContact(address: String): Boolean {
|
||||||
|
return try {
|
||||||
|
if (address.contains("@")) Nip05Address.parse(address)
|
||||||
|
else PublicKey.parse(address)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to parse contact: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,6 +76,7 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
@@ -84,7 +85,9 @@ import coop.composeapp.generated.resources.Res
|
|||||||
import coop.composeapp.generated.resources.ic_close
|
import coop.composeapp.generated.resources.ic_close
|
||||||
import coop.composeapp.generated.resources.ic_new_chat
|
import coop.composeapp.generated.resources.ic_new_chat
|
||||||
import coop.composeapp.generated.resources.ic_qr
|
import coop.composeapp.generated.resources.ic_qr
|
||||||
|
import coop.composeapp.generated.resources.ic_request
|
||||||
import coop.composeapp.generated.resources.ic_scanner
|
import coop.composeapp.generated.resources.ic_scanner
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
@@ -93,11 +96,12 @@ import su.reya.coop.LocalNostrViewModel
|
|||||||
import su.reya.coop.LocalScanResult
|
import su.reya.coop.LocalScanResult
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Room
|
import su.reya.coop.Room
|
||||||
|
import su.reya.coop.RoomKind
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.ago
|
import su.reya.coop.ago
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
import su.reya.coop.shared.displayNameFlow
|
|
||||||
import su.reya.coop.shared.getExpressiveFontFamily
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
|
import su.reya.coop.shared.nameFlow
|
||||||
import su.reya.coop.shared.pictureFlow
|
import su.reya.coop.shared.pictureFlow
|
||||||
import su.reya.coop.short
|
import su.reya.coop.short
|
||||||
|
|
||||||
@@ -111,8 +115,14 @@ fun HomeScreen() {
|
|||||||
val clipboardManager = LocalClipboard.current
|
val clipboardManager = LocalClipboard.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val sheetState = rememberModalBottomSheetState(true)
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val pullToRefreshState = rememberPullToRefreshState()
|
||||||
|
|
||||||
val currentUser = viewModel.currentUser() ?: return
|
val currentUser = viewModel.currentUser() ?: return
|
||||||
val currentUserProfile = viewModel.getMetadata(currentUser) ?: return
|
val currentUserProfile = viewModel.getMetadata(currentUser)
|
||||||
|
|
||||||
val userProfile by currentUserProfile.collectAsStateWithLifecycle()
|
val userProfile by currentUserProfile.collectAsStateWithLifecycle()
|
||||||
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
||||||
@@ -120,11 +130,6 @@ fun HomeScreen() {
|
|||||||
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false)
|
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false)
|
||||||
val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState()
|
val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState()
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val sheetState = rememberModalBottomSheetState(true)
|
|
||||||
val listState = rememberLazyListState()
|
|
||||||
val pullToRefreshState = rememberPullToRefreshState()
|
|
||||||
|
|
||||||
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
|
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
|
||||||
var showBottomSheet by remember { mutableStateOf(false) }
|
var showBottomSheet by remember { mutableStateOf(false) }
|
||||||
var isRefreshing by remember { mutableStateOf(false) }
|
var isRefreshing by remember { mutableStateOf(false) }
|
||||||
@@ -140,6 +145,11 @@ fun HomeScreen() {
|
|||||||
// State will be updated by LifecycleResumeEffect
|
// State will be updated by LifecycleResumeEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Partition chat rooms into requests and ongoing
|
||||||
|
val (requests, ongoing) = remember(chatRooms) {
|
||||||
|
chatRooms.partition { it.kind == RoomKind.Request }
|
||||||
|
}
|
||||||
|
|
||||||
LifecycleResumeEffect(context) {
|
LifecycleResumeEffect(context) {
|
||||||
isNotificationEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
|
isNotificationEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||||
onPauseOrDispose { }
|
onPauseOrDispose { }
|
||||||
@@ -350,7 +360,11 @@ fun HomeScreen() {
|
|||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
) {
|
) {
|
||||||
items(chatRooms.toList(), key = { it.id }) { room ->
|
if (requests.isNotEmpty()) {
|
||||||
|
item { NewRequests(requests) }
|
||||||
|
}
|
||||||
|
|
||||||
|
items(ongoing, key = { it.id }) { room ->
|
||||||
ChatRoom(
|
ChatRoom(
|
||||||
room = room,
|
room = room,
|
||||||
onClick = { navigator.navigate(Screen.Chat(room.id)) }
|
onClick = { navigator.navigate(Screen.Chat(room.id)) }
|
||||||
@@ -603,12 +617,95 @@ fun HomeScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NewRequests(requests: List<Room>) {
|
||||||
|
val navigator = LocalNavigator.current
|
||||||
|
val viewModel = LocalNostrViewModel.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 secondName by remember(secondRoom) {
|
||||||
|
secondRoom?.nameFlow(viewModel) ?: flowOf("")
|
||||||
|
}.collectAsStateWithLifecycle("")
|
||||||
|
|
||||||
|
val supportingText = when {
|
||||||
|
total == 1 && firstRoom != null -> {
|
||||||
|
val message = firstRoom.lastMessage ?: ""
|
||||||
|
"$firstName: $message"
|
||||||
|
}
|
||||||
|
|
||||||
|
total == 2 -> {
|
||||||
|
"$firstName and $secondName"
|
||||||
|
}
|
||||||
|
|
||||||
|
total > 2 -> {
|
||||||
|
val othersCount = total - 2
|
||||||
|
val othersText = if (othersCount == 1) "1 other" else "$othersCount others"
|
||||||
|
"$firstName, $secondName and $othersText"
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
navigator.navigate(Screen.RequestList)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(MaterialShapes.Clover4Leaf.toShape()),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = "Requests",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
if (supportingText.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = supportingText,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
||||||
val viewModel = LocalNostrViewModel.current
|
val viewModel = LocalNostrViewModel.current
|
||||||
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...")
|
val displayName by remember(room) { room.nameFlow(viewModel) }.collectAsStateWithLifecycle("Loading...")
|
||||||
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null)
|
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsStateWithLifecycle(null)
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
modifier = Modifier.clickable(onClick = onClick),
|
modifier = Modifier.clickable(onClick = onClick),
|
||||||
@@ -636,7 +733,9 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
|
|||||||
if (!room.lastMessage.isNullOrBlank()) {
|
if (!room.lastMessage.isNullOrBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = room.lastMessage!!,
|
text = room.lastMessage!!,
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -656,8 +755,7 @@ fun BottomMenuList(
|
|||||||
|
|
||||||
val defaultMenuList = listOf(
|
val defaultMenuList = listOf(
|
||||||
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
||||||
"Contact List" to { },
|
"Contact List" to { navigator.navigate(Screen.ContactList) },
|
||||||
"Spams & Blocks" to { },
|
|
||||||
"Relay Management" to { navigator.navigate(Screen.Relay) },
|
"Relay Management" to { navigator.navigate(Screen.Relay) },
|
||||||
"Settings" to { }
|
"Settings" to { }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import su.reya.coop.LocalSnackbarHostState
|
|||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
import su.reya.coop.short
|
import su.reya.coop.short
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -78,7 +79,7 @@ fun NewChatScreen() {
|
|||||||
|
|
||||||
LaunchedEffect(query) {
|
LaunchedEffect(query) {
|
||||||
if (query.length >= 3) {
|
if (query.length >= 3) {
|
||||||
delay(500) // 500ms debounce
|
delay(500.milliseconds)
|
||||||
|
|
||||||
if (query.startsWith("npub1")) {
|
if (query.startsWith("npub1")) {
|
||||||
val pubkey = try {
|
val pubkey = try {
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.toShape
|
import androidx.compose.material3.toShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -37,6 +36,7 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
import coop.composeapp.generated.resources.ic_chat
|
import coop.composeapp.generated.resources.ic_chat
|
||||||
@@ -55,26 +55,27 @@ import su.reya.coop.short
|
|||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ProfileScreen(pubkey: String) {
|
fun ProfileScreen(pubkey: String) {
|
||||||
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
|
||||||
|
|
||||||
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
||||||
val metadata by metadataFlow.collectAsState(initial = null)
|
val metadata by metadataFlow.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
val profile = metadata?.asRecord()
|
||||||
val displayName = profile?.displayName ?: profile?.name ?: "No name"
|
val displayName = profile?.displayName ?: profile?.name ?: "No name"
|
||||||
val nip05 = profile?.nip05 ?: pubkey.short()
|
val nip05 = profile?.nip05 ?: pubkey.short()
|
||||||
val picture = profile?.picture
|
val picture = profile?.picture
|
||||||
|
|
||||||
val details = remember(profile) {
|
val details = remember(profile) {
|
||||||
listOf(
|
listOf(
|
||||||
"Username:" to (profile?.name ?: "None"),
|
"Username:" to (profile?.name ?: "None"),
|
||||||
"Website:" to (profile?.website ?: "None"),
|
"Website:" to (profile?.website ?: "None"),
|
||||||
"Lightning Address:" to (profile?.lud16 ?: "None"),
|
"₿ Lightning Address:" to (profile?.lud16 ?: "None"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +84,7 @@ fun ProfileScreen(pubkey: String) {
|
|||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { },
|
title = { /* empty */ },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = { navigator.goBack() }) {
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
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.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.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
|
||||||
|
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||||
|
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
|
||||||
|
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 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
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun RequestListScreen() {
|
||||||
|
val navigator = LocalNavigator.current
|
||||||
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val pullToRefreshState = rememberPullToRefreshState()
|
||||||
|
|
||||||
|
var isRefreshing by remember { mutableStateOf(false) }
|
||||||
|
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// Get all request rooms
|
||||||
|
val requests = remember(chatRooms) {
|
||||||
|
chatRooms.filter { it.kind == RoomKind.Request }
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "New Requests",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
|
Icon(
|
||||||
|
painter = org.jetbrains.compose.resources.painterResource(Res.drawable.ic_arrow_back),
|
||||||
|
contentDescription = "Back"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||||
|
) {
|
||||||
|
PullToRefreshBox(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
isRefreshing = isRefreshing,
|
||||||
|
state = pullToRefreshState,
|
||||||
|
onRefresh = {
|
||||||
|
scope.launch {
|
||||||
|
isRefreshing = true
|
||||||
|
viewModel.refreshChatRooms()
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
indicator = {
|
||||||
|
PullToRefreshDefaults.LoadingIndicator(
|
||||||
|
state = pullToRefreshState,
|
||||||
|
isRefreshing = isRefreshing,
|
||||||
|
modifier = Modifier.align(Alignment.TopCenter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (requests.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "No requests yet",
|
||||||
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "New chat requests will appear here.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
items(requests.toList(), key = { it.id }) { room ->
|
||||||
|
ChatRoom(
|
||||||
|
room = room,
|
||||||
|
onClick = { navigator.navigate(Screen.Chat(room.id, true)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import su.reya.coop.NostrViewModel
|
|||||||
import su.reya.coop.Room
|
import su.reya.coop.Room
|
||||||
import su.reya.coop.short
|
import su.reya.coop.short
|
||||||
|
|
||||||
fun Room.displayNameFlow(viewModel: NostrViewModel): Flow<String> {
|
fun Room.nameFlow(viewModel: NostrViewModel): Flow<String> {
|
||||||
// Return early if there's a custom subject/room name
|
// Return early if there's a custom subject/room name
|
||||||
subject?.takeIf { it.isNotBlank() }?.let { return flowOf(it) }
|
subject?.takeIf { it.isNotBlank() }?.let { return flowOf(it) }
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ kotlin {
|
|||||||
implementation(libs.androidx.lifecycle.runtimeCompose)
|
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
||||||
implementation("su.reya:nostr-sdk-kmp:0.2.7")
|
implementation("su.reya:nostr-sdk-kmp:0.3.1")
|
||||||
implementation("com.squareup.okio:okio:3.16.2")
|
implementation("com.squareup.okio:okio:3.16.2")
|
||||||
}
|
}
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
|
|||||||
@@ -50,11 +50,11 @@ import rust.nostr.sdk.SubscribeAutoCloseOptions
|
|||||||
import rust.nostr.sdk.Tag
|
import rust.nostr.sdk.Tag
|
||||||
import rust.nostr.sdk.Timestamp
|
import rust.nostr.sdk.Timestamp
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import rust.nostr.sdk.UnwrappedGift
|
|
||||||
import rust.nostr.sdk.extractRelayList
|
import rust.nostr.sdk.extractRelayList
|
||||||
import rust.nostr.sdk.initLogger
|
import rust.nostr.sdk.initLogger
|
||||||
import rust.nostr.sdk.nip17ExtractRelayList
|
import rust.nostr.sdk.nip17ExtractRelayList
|
||||||
import rust.nostr.sdk.nip59MakeGiftWrapAsync
|
import rust.nostr.sdk.nip59MakeGiftWrapAsync
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@@ -78,8 +78,6 @@ class Nostr {
|
|||||||
private set
|
private set
|
||||||
var signer: UniversalSigner = UniversalSigner(Keys.generate())
|
var signer: UniversalSigner = UniversalSigner(Keys.generate())
|
||||||
private set
|
private set
|
||||||
var deviceSigner: AsyncNostrSigner? = null
|
|
||||||
private set
|
|
||||||
var sentEvents: MutableMap<EventId, List<RelayUrl>> = mutableMapOf()
|
var sentEvents: MutableMap<EventId, List<RelayUrl>> = mutableMapOf()
|
||||||
private set
|
private set
|
||||||
var rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
|
var rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
|
||||||
@@ -298,7 +296,11 @@ class Nostr {
|
|||||||
|
|
||||||
if (event.kind().asStd()?.equals(KindStandard.CONTACT_LIST) == true) {
|
if (event.kind().asStd()?.equals(KindStandard.CONTACT_LIST) == true) {
|
||||||
if (isSignedByUser(event = event)) {
|
if (isSignedByUser(event = event)) {
|
||||||
onContactListUpdate(event.tags().publicKeys())
|
val pubkeys = event.tags().publicKeys()
|
||||||
|
// Get mutual contacts
|
||||||
|
getMutualContacts(pubkeys)
|
||||||
|
// Emit contact list update
|
||||||
|
onContactListUpdate(pubkeys)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,26 +312,23 @@ class Nostr {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) {
|
if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) {
|
||||||
try {
|
val rumor = extractRumor(event)
|
||||||
val rumor = extractRumor(event)
|
|
||||||
|
|
||||||
// Logic to notify UI after processing
|
// Logic to notify UI after processing
|
||||||
// Cancel previous tracker if it exists
|
// Cancel previous tracker if it exists
|
||||||
eoseTrackerJob?.cancel()
|
eoseTrackerJob?.cancel()
|
||||||
// Start a new tracker
|
|
||||||
eoseTrackerJob = launch {
|
|
||||||
delay(10000.milliseconds) // Wait for 10 seconds
|
|
||||||
onSubscriptionClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle new message
|
// Start a new tracker
|
||||||
rumor?.createdAt()?.asSecs()?.let {
|
eoseTrackerJob = launch {
|
||||||
if (it >= now.asSecs()) {
|
delay(10000.milliseconds) // Wait for 10 seconds
|
||||||
onNewMessage(rumor)
|
onSubscriptionClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle new message
|
||||||
|
rumor?.createdAt()?.asSecs()?.let {
|
||||||
|
if (it >= now.asSecs()) {
|
||||||
|
onNewMessage(rumor)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Failed to extract rumor: $e")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -366,33 +365,50 @@ class Nostr {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun getMutualContacts(pubkeys: List<PublicKey>) {
|
||||||
|
try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
|
||||||
|
val filter = Filter().kind(kind).authors(pubkeys).limit(200u)
|
||||||
|
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||||
|
|
||||||
|
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
||||||
|
NostrManager.BOOTSTRAP_RELAYS.forEach { relay ->
|
||||||
|
target[RelayUrl.parse(relay)] = listOf(filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
client?.subscribe(
|
||||||
|
target = ReqTarget.manual(target),
|
||||||
|
id = "mutual-contacts",
|
||||||
|
closeOn = opts,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch mutual contacts: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? {
|
private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? {
|
||||||
try {
|
try {
|
||||||
val filter = Filter().identifier(giftId.toHex())
|
val filter = Filter().identifier(giftId.toHex())
|
||||||
val event = client?.database()?.query(filter)?.first()
|
val event = client?.database()?.query(filter)?.first()
|
||||||
|
|
||||||
return event?.content()?.let { UnsignedEvent.fromJson(it) }
|
return event?.content()?.let { UnsignedEvent.fromJson(it) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Throwable) {
|
||||||
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
|
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
|
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
|
||||||
try {
|
try {
|
||||||
// Construct the room id
|
|
||||||
val roomId = rumor.roomId()
|
|
||||||
|
|
||||||
// Construct reference tags
|
// Construct reference tags
|
||||||
val tags = listOf(
|
val tags = listOf(
|
||||||
Tag.identifier(giftId.toHex()),
|
Tag.identifier(giftId.toHex()),
|
||||||
Tag.publicKey(rumor.author()),
|
Tag.publicKey(rumor.author()),
|
||||||
Tag.event(rumor.id()!!),
|
Tag.custom("r", listOf(rumor.roomId().toString())),
|
||||||
Tag.custom("r", listOf(roomId.toString())),
|
|
||||||
Tag.custom("k", listOf("14"))
|
Tag.custom("k", listOf("14"))
|
||||||
)
|
)
|
||||||
|
|
||||||
// Set event kind
|
// Set event kind
|
||||||
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA);
|
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
||||||
|
|
||||||
// Construct event
|
// Construct event
|
||||||
val event = EventBuilder(kind, rumor.asJson())
|
val event = EventBuilder(kind, rumor.asJson())
|
||||||
@@ -400,36 +416,64 @@ class Nostr {
|
|||||||
.finalizeAsync(Keys.generate())
|
.finalizeAsync(Keys.generate())
|
||||||
|
|
||||||
client?.database()?.saveEvent(event)
|
client?.database()?.saveEvent(event)
|
||||||
} catch (e: Exception) {
|
} catch (e: Throwable) {
|
||||||
println("Failed to set cached rumor: ${e.message}")
|
println("Failed to set cached rumor: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun extractRumor(event: Event): UnsignedEvent? {
|
private suspend fun extractRumor(event: Event): UnsignedEvent? {
|
||||||
try {
|
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
|
// Check if the rumor is already cached
|
||||||
val cachedRumor = getCachedRumor(event.id())
|
val cachedRumor = getCachedRumor(event.id())
|
||||||
if (cachedRumor != null) return cachedRumor
|
if (cachedRumor != null) return cachedRumor
|
||||||
|
|
||||||
// Unwrap the gift with current signer
|
// Decrypt the gift wrap event
|
||||||
val gift = UnwrappedGift.fromGiftWrapAsync(signer = signer, giftWrap = event)
|
val seal = signer.nip44DecryptAsync(event.author(), event.content())
|
||||||
val rumor = gift.rumor()
|
val sealEvent = Event.fromJson(seal)
|
||||||
|
|
||||||
// Save the rumor to the database
|
// Verify seal event
|
||||||
setCachedRumor(event.id(), rumor)
|
if (!sealEvent.verify()) {
|
||||||
|
println("Failed to verify seal event.")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// Return the rumor
|
// Decrypt the rumor
|
||||||
return rumor
|
val rumor = signer.nip44DecryptAsync(sealEvent.author(), sealEvent.content())
|
||||||
} catch (e: Exception) {
|
val unsignedEvent = UnsignedEvent.fromJson(rumor)
|
||||||
println("Failed to unwrap gift: ${e.message}")
|
|
||||||
|
// 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: Throwable) {
|
||||||
|
println("Failed to unwrap gift ${event.id().toHex()}: ${e.message}")
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getDefaultRelayList(): Map<RelayUrl, RelayMetadata> {
|
private suspend fun getDefaultRelayList(): Map<RelayUrl, RelayMetadata> {
|
||||||
// Construct a list of relays
|
// Construct a list of relays
|
||||||
val relayList = mapOf<RelayUrl, RelayMetadata>(
|
val relayList = mapOf(
|
||||||
RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ,
|
RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ,
|
||||||
RelayUrl.parse("wss://relay.primal.net") to RelayMetadata.READ,
|
RelayUrl.parse("wss://relay.primal.net") to RelayMetadata.READ,
|
||||||
RelayUrl.parse("wss://relay.nostr.net") to RelayMetadata.WRITE,
|
RelayUrl.parse("wss://relay.nostr.net") to RelayMetadata.WRITE,
|
||||||
@@ -471,7 +515,7 @@ class Nostr {
|
|||||||
suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) {
|
suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) {
|
||||||
// Send relay list event
|
// Send relay list event
|
||||||
val relayList = getDefaultRelayList()
|
val relayList = getDefaultRelayList()
|
||||||
val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys);
|
val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys)
|
||||||
|
|
||||||
client?.sendEvent(
|
client?.sendEvent(
|
||||||
event = relayListEvent,
|
event = relayListEvent,
|
||||||
@@ -546,7 +590,7 @@ class Nostr {
|
|||||||
|
|
||||||
private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? {
|
private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? {
|
||||||
return try {
|
return try {
|
||||||
val kind = Kind.fromStd(KindStandard.METADATA);
|
val kind = Kind.fromStd(KindStandard.METADATA)
|
||||||
val filter = Filter().kind(kind).author(pubkey).limit(1u)
|
val filter = Filter().kind(kind).author(pubkey).limit(1u)
|
||||||
val event = client?.database()?.query(filter)?.first() ?: return null
|
val event = client?.database()?.query(filter)?.first() ?: return null
|
||||||
|
|
||||||
@@ -581,7 +625,7 @@ class Nostr {
|
|||||||
|
|
||||||
suspend fun fetchMetadataBatch(keys: List<PublicKey>) {
|
suspend fun fetchMetadataBatch(keys: List<PublicKey>) {
|
||||||
try {
|
try {
|
||||||
val limit = keys.size.toULong() * 4u;
|
val limit = keys.size.toULong() * 4u
|
||||||
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||||
|
|
||||||
// Construct a filter for metadata events
|
// Construct a filter for metadata events
|
||||||
@@ -615,7 +659,7 @@ class Nostr {
|
|||||||
ackPolicy = AckPolicy.none(),
|
ackPolicy = AckPolicy.none(),
|
||||||
)
|
)
|
||||||
|
|
||||||
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS);
|
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||||
val filter = Filter().kind(kind).author(signer.currentUser!!).limit(1u)
|
val filter = Filter().kind(kind).author(signer.currentUser!!).limit(1u)
|
||||||
val target = ReqTarget.auto(listOf(filter))
|
val target = ReqTarget.auto(listOf(filter))
|
||||||
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||||
@@ -677,6 +721,21 @@ class Nostr {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun setContactList(contacts: List<PublicKey>) {
|
||||||
|
try {
|
||||||
|
val contacts = contacts.map { Contact(it) }
|
||||||
|
val event = EventBuilder.contactList(contacts).finalizeAsync(signer)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = event,
|
||||||
|
target = SendEventTarget.broadcast(),
|
||||||
|
ackPolicy = AckPolicy.none(),
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to set contact list: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getChatRooms(): Set<Room>? {
|
suspend fun getChatRooms(): Set<Room>? {
|
||||||
try {
|
try {
|
||||||
val userPubkey =
|
val userPubkey =
|
||||||
@@ -685,8 +744,8 @@ class Nostr {
|
|||||||
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
||||||
val kTag = SingleLetterTag.lowercase(Alphabet.K)
|
val kTag = SingleLetterTag.lowercase(Alphabet.K)
|
||||||
|
|
||||||
// Get all events sent by the user
|
// Get all DM events
|
||||||
val filter = Filter().kind(kind).pubkey(userPubkey).customTags(kTag, listOf("14", "dm"))
|
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm"))
|
||||||
val events = client?.database()?.query(filter)
|
val events = client?.database()?.query(filter)
|
||||||
|
|
||||||
// Collect rooms
|
// Collect rooms
|
||||||
@@ -702,12 +761,13 @@ class Nostr {
|
|||||||
|
|
||||||
// Check if the room already exists
|
// Check if the room already exists
|
||||||
if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) {
|
if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) {
|
||||||
val kind = Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE)
|
val rTag = SingleLetterTag.lowercase(Alphabet.R)
|
||||||
val pubkeys = newRoom.members.toList()
|
val filter = Filter().kind(kind).pubkey(userPubkey)
|
||||||
val filter = Filter().kind(kind).author(userPubkey).pubkeys(pubkeys)
|
.customTag(rTag, newRoom.id.toString())
|
||||||
|
|
||||||
// Determine if it's an ongoing room
|
// Determine if it's an ongoing room
|
||||||
val isOngoing = client?.database()?.query(filter)?.isEmpty() == false
|
val isOngoing =
|
||||||
|
client?.database()?.query(filter)?.toVec()?.isNotEmpty() ?: false
|
||||||
|
|
||||||
// Append room to map
|
// Append room to map
|
||||||
roomsMap[newRoom.id] =
|
roomsMap[newRoom.id] =
|
||||||
@@ -766,7 +826,7 @@ class Nostr {
|
|||||||
|
|
||||||
suspend fun connectMsgRelays(event: Event) {
|
suspend fun connectMsgRelays(event: Event) {
|
||||||
try {
|
try {
|
||||||
val urls = nip17ExtractRelayList(event);
|
val urls = nip17ExtractRelayList(event)
|
||||||
for (url in urls) {
|
for (url in urls) {
|
||||||
client?.addRelay(url, RelayCapabilities.gossip())
|
client?.addRelay(url, RelayCapabilities.gossip())
|
||||||
client?.connectRelay(url)
|
client?.connectRelay(url)
|
||||||
@@ -910,4 +970,61 @@ class Nostr {
|
|||||||
throw IllegalStateException("Failed to search nostr: ${e.message}", e)
|
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>>()
|
||||||
|
NostrManager.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: 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: 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: Exception) {
|
||||||
|
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import rust.nostr.sdk.PublicKey
|
|||||||
import rust.nostr.sdk.RelayMetadata
|
import rust.nostr.sdk.RelayMetadata
|
||||||
import rust.nostr.sdk.RelayUrl
|
import rust.nostr.sdk.RelayUrl
|
||||||
import rust.nostr.sdk.Tag
|
import rust.nostr.sdk.Tag
|
||||||
|
import rust.nostr.sdk.Timestamp
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import su.reya.coop.blossom.BlossomClient
|
import su.reya.coop.blossom.BlossomClient
|
||||||
import su.reya.coop.storage.SecretStorage
|
import su.reya.coop.storage.SecretStorage
|
||||||
@@ -616,6 +617,54 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
fun createChatRoom(to: List<PublicKey>): Long {
|
||||||
try {
|
try {
|
||||||
if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in")
|
if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in")
|
||||||
@@ -772,6 +821,33 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
return emptyList()
|
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 {
|
fun PublicKey.short(): String {
|
||||||
|
|||||||
Reference in New Issue
Block a user