Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26f9e577f4 | |||
| dd416711e2 | |||
| b90279e174 | |||
| 25ba03562a | |||
| 010a8b4060 | |||
| 750d46bc9e | |||
| 790d24c24b | |||
| 4fbc8446d3 | |||
| b4518ce360 | |||
| 0f4bb71a39 |
93
CHANGELOG.md
Normal file
93
CHANGELOG.md
Normal file
@@ -0,0 +1,93 @@
|
||||
### [2026-06-20] v0.2.0 (Stable)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Fixed the issue that cause app crashing
|
||||
* Added screener for new message requests
|
||||
* Added request messages screen
|
||||
* Added contact list screen
|
||||
|
||||
---
|
||||
|
||||
### [2026-06-12] v0.1.9 (Pre-Release)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Fixed the issue that cause app crashing
|
||||
* Added relay management
|
||||
|
||||
---
|
||||
|
||||
### [2026-06-09] v0.1.8 (Pre-Release)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Added support for NIP-55 (use external signer like Amber)
|
||||
|
||||
---
|
||||
|
||||
### [2026-06-07] v0.1.7 (Pre-Release)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Added a screen for update profile
|
||||
* Fixed occasional splash screen hang
|
||||
* Improved the battery usage in background
|
||||
|
||||
---
|
||||
|
||||
### [2026-06-04] v0.1.6 (Pre-Release)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Added self chat
|
||||
* Fixed empty chat rooms on refreshes
|
||||
* Fixed crash on startup
|
||||
|
||||
---
|
||||
|
||||
### [2026-06-03] v0.1.5 (Pre-Release)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Added crash report screen
|
||||
* Added notification permission banner
|
||||
* Fixed navigation issues after create or import identity
|
||||
|
||||
---
|
||||
|
||||
### [2026-06-01] v0.1.4 (Pre-Release)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Added the deep link to allow user open chat screen directly via notification
|
||||
* Fixed the issue cause app crash if room has no member
|
||||
* Improve stability and performance
|
||||
|
||||
---
|
||||
|
||||
### [2026-05-29] v0.1.3 (Pre-Release)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Added basic notification when the app in background
|
||||
* Redesign the splash screen
|
||||
* Redesign the onboarding screen
|
||||
* Improved UX for the new identity creation and import screen
|
||||
* Fixed some issues cause app crashes
|
||||
|
||||
---
|
||||
|
||||
### [2026-05-24] v0.1.2 (Pre-Release)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Fixed the issue that cause app crash when open new chat room via QR
|
||||
|
||||
---
|
||||
|
||||
### [2026-05-23] v0.1.1 (Pre-Release)
|
||||
|
||||
**What's Changed:**
|
||||
|
||||
* Fixed the issue that cause app crash on startup
|
||||
BIN
assets/icon.png
Normal file
BIN
assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/screenshot1.png
Normal file
BIN
assets/screenshot1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 296 KiB |
BIN
assets/screenshot2.png
Normal file
BIN
assets/screenshot2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 444 KiB |
BIN
assets/screenshot3.png
Normal file
BIN
assets/screenshot3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 212 KiB |
BIN
assets/screenshot4.png
Normal file
BIN
assets/screenshot4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
BIN
assets/screenshot5.png
Normal file
BIN
assets/screenshot5.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
@@ -69,7 +69,7 @@ android {
|
||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||
versionCode = 1
|
||||
versionName = "0.2.0"
|
||||
versionName = "0.2.1"
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
|
||||
@@ -65,8 +65,10 @@ class NostrForegroundService : Service() {
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to initialize Nostr Client", e)
|
||||
}
|
||||
|
||||
// Connect to bootstrap relays
|
||||
nostr.connectBootstrapRelays()
|
||||
|
||||
// Handle notifications
|
||||
nostr.handleNotifications(
|
||||
onMetadataUpdate = { pubkey, metadata ->
|
||||
@@ -75,9 +77,6 @@ class NostrForegroundService : Service() {
|
||||
onContactListUpdate = { contacts ->
|
||||
serviceScope.launch { nostr.emitContactListUpdate(contacts) }
|
||||
},
|
||||
onSubscriptionClose = {
|
||||
serviceScope.launch { nostr.emitSubscriptionClosed() }
|
||||
},
|
||||
onNewMessage = { event ->
|
||||
serviceScope.launch {
|
||||
if (!isUserInApp()) {
|
||||
|
||||
@@ -4,15 +4,11 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
@@ -238,9 +234,10 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
||||
) {
|
||||
groupedMessages.forEach { (dateHeader, messagesInGroup) ->
|
||||
items(
|
||||
messagesInGroup,
|
||||
key = { it.id()?.toBech32()!! }) { event ->
|
||||
ChatMessage(event)
|
||||
items = messagesInGroup,
|
||||
key = { it.id()?.toBech32() ?: it.hashCode() }
|
||||
) {
|
||||
ChatMessage(it)
|
||||
}
|
||||
item {
|
||||
DateSeparator(dateHeader)
|
||||
@@ -526,9 +523,7 @@ fun ChatInput(
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.height(IntrinsicSize.Min),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
TextField(
|
||||
@@ -545,9 +540,7 @@ fun ChatInput(
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
FilledTonalIconButton(
|
||||
onClick = onSend,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.aspectRatio(1f),
|
||||
modifier = Modifier.size(56.dp),
|
||||
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
@@ -127,7 +127,8 @@ fun HomeScreen() {
|
||||
val userProfile by currentUserProfile.collectAsStateWithLifecycle()
|
||||
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
||||
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsStateWithLifecycle()
|
||||
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false)
|
||||
val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle()
|
||||
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
|
||||
val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState()
|
||||
|
||||
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
|
||||
@@ -185,10 +186,18 @@ fun HomeScreen() {
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
title = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Coop",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
if (isSyncing) {
|
||||
LoadingIndicator(modifier = Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// QR Scanner
|
||||
@@ -781,7 +790,7 @@ fun BottomMenuList(
|
||||
}
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.logout() },
|
||||
onClick = { onDismiss { viewModel.logout() } },
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError
|
||||
|
||||
@@ -94,17 +94,18 @@ fun ImportScreen() {
|
||||
LaunchedEffect(qrScanResult.content) {
|
||||
qrScanResult.content?.let { result ->
|
||||
runCatching {
|
||||
if (result.startsWith("nsec")) {
|
||||
if (result.startsWith("nsec1")) {
|
||||
Keys.parse(result)
|
||||
} else if (result.startsWith("bunker://")) {
|
||||
NostrConnectUri.parse(result)
|
||||
} else {
|
||||
throw IllegalArgumentException("Invalid secret: $result")
|
||||
throw IllegalArgumentException("Unsupported secret format")
|
||||
}
|
||||
}.onSuccess {
|
||||
secret = result
|
||||
}.onFailure { e ->
|
||||
snackbarHostState.showSnackbar("Invalid secret: ${e.message}")
|
||||
}
|
||||
.onSuccess { it -> secret = result }
|
||||
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
|
||||
|
||||
// Clear the nav state
|
||||
qrScanResult.clear()
|
||||
}
|
||||
|
||||
@@ -109,14 +109,13 @@ fun NewChatScreen() {
|
||||
LaunchedEffect(qrScanResult.content) {
|
||||
qrScanResult.content?.let { result ->
|
||||
// Verify the content
|
||||
runCatching { PublicKey.parse(result) }
|
||||
.onSuccess { pubkey ->
|
||||
runCatching {
|
||||
PublicKey.parse(result)
|
||||
}.onSuccess { pubkey ->
|
||||
selectedReceivers.add(pubkey)
|
||||
}.onFailure { e ->
|
||||
snackbarHostState.showSnackbar("Invalid address: ${e.message}")
|
||||
}
|
||||
.onFailure { e ->
|
||||
println("Failed to parse QR: ${e.message}")
|
||||
}
|
||||
|
||||
// Clear the nav state
|
||||
qrScanResult.clear()
|
||||
}
|
||||
|
||||
@@ -5,16 +5,15 @@ import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -44,10 +43,10 @@ fun ScanScreen() {
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
CenterAlignedTopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Scan QR",
|
||||
text = "Scan a Nostr Address",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
},
|
||||
@@ -97,14 +96,6 @@ fun ScanScreen() {
|
||||
.align(Alignment.Center)
|
||||
.border(2.dp, Color.White, RoundedCornerShape(12.dp))
|
||||
)
|
||||
Text(
|
||||
text = "Scan a Nostr address",
|
||||
style = MaterialTheme.typography.titleSmallEmphasized,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 64.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,8 @@ fun Room.nameFlow(viewModel: NostrViewModel): Flow<String> {
|
||||
return combine(displayMembers.map { viewModel.getMetadata(it) }) { metadataArray ->
|
||||
val names = metadataArray.mapIndexed { i, metadata ->
|
||||
val profile = metadata?.asRecord()
|
||||
profile?.name?.takeIf { it.isNotBlank() }
|
||||
?: profile?.displayName?.takeIf { it.isNotBlank() }
|
||||
profile?.displayName?.takeIf { it.isNotBlank() }
|
||||
?: profile?.name?.takeIf { it.isNotBlank() }
|
||||
?: displayMembers[i].short()
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,15 @@ import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import rust.nostr.sdk.AckPolicy
|
||||
@@ -56,7 +59,6 @@ import rust.nostr.sdk.nip17ExtractRelayList
|
||||
import rust.nostr.sdk.nip59MakeGiftWrapAsync
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
object NostrManager {
|
||||
val instance = Nostr()
|
||||
@@ -95,18 +97,20 @@ class Nostr {
|
||||
private val _contactListUpdates = MutableSharedFlow<List<PublicKey>>(extraBufferCapacity = 100)
|
||||
val contactListUpdates = _contactListUpdates.asSharedFlow()
|
||||
|
||||
private val _subscriptionClosed = MutableSharedFlow<Unit>(extraBufferCapacity = 10)
|
||||
val subscriptionClosed = _subscriptionClosed.asSharedFlow()
|
||||
private val _messageSyncState = MutableStateFlow(MessageSyncState())
|
||||
val messageSyncState = _messageSyncState.asStateFlow()
|
||||
|
||||
suspend fun emitNewEvent(event: UnsignedEvent) = _newEvents.emit(event)
|
||||
suspend fun emitNewEvent(event: UnsignedEvent) {
|
||||
_newEvents.emit(event)
|
||||
}
|
||||
|
||||
suspend fun emitSubscriptionClosed() = _subscriptionClosed.emit(Unit)
|
||||
|
||||
suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) =
|
||||
suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) {
|
||||
_metadataUpdates.emit(pubkey to metadata)
|
||||
}
|
||||
|
||||
suspend fun emitContactListUpdate(contacts: List<PublicKey>) =
|
||||
suspend fun emitContactListUpdate(contacts: List<PublicKey>) {
|
||||
_contactListUpdates.emit(contacts)
|
||||
}
|
||||
|
||||
suspend fun init(
|
||||
dbPath: String,
|
||||
@@ -188,6 +192,14 @@ class Nostr {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun prune() {
|
||||
try {
|
||||
client?.database()?.wipe()
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to prune database: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setSigner(new: AsyncNostrSigner) {
|
||||
try {
|
||||
signer.switch(new)
|
||||
@@ -258,17 +270,39 @@ class Nostr {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
suspend fun handleNotifications(
|
||||
onMetadataUpdate: (PublicKey, Metadata) -> Unit,
|
||||
onContactListUpdate: (List<PublicKey>) -> Unit,
|
||||
onNewMessage: (UnsignedEvent) -> Unit,
|
||||
onSubscriptionClose: () -> Unit,
|
||||
) = supervisorScope {
|
||||
val now = Timestamp.now()
|
||||
val processedEvent = mutableSetOf<EventId>()
|
||||
val notifications = client?.notifications() ?: return@supervisorScope
|
||||
|
||||
var eoseTrackerJob: Job? = null
|
||||
val giftWrapQueue = Channel<Event>(Channel.UNLIMITED)
|
||||
var processedCount = 0
|
||||
var eoseReceived = false
|
||||
|
||||
launch(Dispatchers.Default) {
|
||||
for (event in giftWrapQueue) {
|
||||
val rumor = extractRumor(event) ?: continue
|
||||
processedCount++
|
||||
|
||||
// Trigger new message notification
|
||||
if (rumor.createdAt().asSecs() >= now.asSecs()) {
|
||||
onNewMessage(rumor)
|
||||
}
|
||||
|
||||
// Update sync state
|
||||
_messageSyncState.update {
|
||||
it.copy(
|
||||
processedCount = processedCount,
|
||||
isSyncing = !eoseReceived || !giftWrapQueue.isEmpty
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
val notification = notifications.next() ?: continue
|
||||
@@ -285,7 +319,8 @@ class Nostr {
|
||||
if (processedEvent.contains(event.id())) continue
|
||||
processedEvent.add(event.id())
|
||||
|
||||
if (event.kind().asStd()?.equals(KindStandard.METADATA) == true) {
|
||||
when (event.kind().asStd()) {
|
||||
KindStandard.METADATA -> {
|
||||
try {
|
||||
val metadata = Metadata.fromJson(event.content())
|
||||
onMetadataUpdate(event.author(), metadata)
|
||||
@@ -294,7 +329,7 @@ class Nostr {
|
||||
}
|
||||
}
|
||||
|
||||
if (event.kind().asStd()?.equals(KindStandard.CONTACT_LIST) == true) {
|
||||
KindStandard.CONTACT_LIST -> {
|
||||
if (isSignedByUser(event = event)) {
|
||||
val pubkeys = event.tags().publicKeys()
|
||||
// Get mutual contacts
|
||||
@@ -304,40 +339,27 @@ class Nostr {
|
||||
}
|
||||
}
|
||||
|
||||
if (event.kind().asStd()?.equals(KindStandard.INBOX_RELAYS) == true) {
|
||||
KindStandard.INBOX_RELAYS -> {
|
||||
// Get all gift wrap events for the current user
|
||||
if (isSignedByUser(event = event)) {
|
||||
getUserMessages(msgRelayList = event)
|
||||
}
|
||||
}
|
||||
|
||||
if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) {
|
||||
val rumor = extractRumor(event)
|
||||
|
||||
// Logic to notify UI after processing
|
||||
// Cancel previous tracker if it exists
|
||||
eoseTrackerJob?.cancel()
|
||||
|
||||
// Start a new tracker
|
||||
eoseTrackerJob = launch {
|
||||
delay(10000.milliseconds) // Wait for 10 seconds
|
||||
onSubscriptionClose()
|
||||
KindStandard.GIFT_WRAP -> {
|
||||
giftWrapQueue.send(event)
|
||||
}
|
||||
|
||||
// Handle new message
|
||||
rumor?.createdAt()?.asSecs()?.let {
|
||||
if (it >= now.asSecs()) {
|
||||
onNewMessage(rumor)
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
is RelayMessageEnum.EndOfStoredEvents -> {
|
||||
val subscriptionId = message.subscriptionId
|
||||
|
||||
if (subscriptionId == "gift-wraps") {
|
||||
onSubscriptionClose()
|
||||
if (message.subscriptionId == "gift-wraps") {
|
||||
eoseReceived = true
|
||||
if (giftWrapQueue.isEmpty) {
|
||||
_messageSyncState.update { it.copy(isSyncing = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,7 +473,7 @@ class Nostr {
|
||||
|
||||
// Decrypt the rumor
|
||||
val rumor = signer.nip44DecryptAsync(sealEvent.author(), sealEvent.content())
|
||||
val unsignedEvent = UnsignedEvent.fromJson(rumor)
|
||||
val unsignedEvent = UnsignedEvent.fromJson(rumor).ensureId()
|
||||
|
||||
// Ensure the rumor author matches the seal
|
||||
if (unsignedEvent.author() != sealEvent.author()) {
|
||||
@@ -791,7 +813,7 @@ class Nostr {
|
||||
// Merge the events
|
||||
return events
|
||||
?.toVec()
|
||||
?.map { UnsignedEvent.fromJson(it.content()) }
|
||||
?.map { UnsignedEvent.fromJson(it.content()).ensureId() }
|
||||
// Filter out events without public keys (receivers)
|
||||
?.filter { it.tags().publicKeys().isNotEmpty() }
|
||||
?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList()
|
||||
@@ -1028,3 +1050,8 @@ class Nostr {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class MessageSyncState(
|
||||
val processedCount: Int = 0,
|
||||
val isSyncing: Boolean = false
|
||||
)
|
||||
|
||||
@@ -13,10 +13,13 @@ import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -79,9 +82,14 @@ class NostrViewModel(
|
||||
val errorEvents = _errorEvents.receiveAsFlow()
|
||||
|
||||
private val _metadataStore = mutableMapOf<PublicKey, MutableStateFlow<Metadata?>>()
|
||||
|
||||
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
|
||||
|
||||
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
||||
|
||||
val isSyncing = nostr.messageSyncState.map { it.isSyncing }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
init {
|
||||
// Skip the splash screen if a user is already logged in
|
||||
if (nostr.signer.currentUser != null) {
|
||||
@@ -108,7 +116,6 @@ class NostrViewModel(
|
||||
viewModelScope.launch {
|
||||
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
coroutineScope {
|
||||
launch { refreshChatRooms() }
|
||||
launch { runObserver() }
|
||||
launch { runMetadataBatching() }
|
||||
}
|
||||
@@ -148,6 +155,21 @@ class NostrViewModel(
|
||||
}
|
||||
|
||||
private suspend fun runObserver() = coroutineScope {
|
||||
// Observe message sync progress
|
||||
launch {
|
||||
nostr.messageSyncState.collect { state ->
|
||||
// When at least some messages are processed, allow UI to show the list
|
||||
if (state.processedCount > 0) {
|
||||
_isPartialProcessedGiftWrap.value = true
|
||||
}
|
||||
|
||||
// Refresh UI every 10 messages OR when sync is fully done
|
||||
if (state.processedCount % 10 == 0 || !state.isSyncing) {
|
||||
refreshChatRooms()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Observe new messages
|
||||
launch {
|
||||
nostr.newEvents.collect { event ->
|
||||
@@ -181,14 +203,6 @@ class NostrViewModel(
|
||||
updateMetadata(pubkey, metadata)
|
||||
}
|
||||
}
|
||||
|
||||
// Observes subscription close
|
||||
launch {
|
||||
nostr.subscriptionClosed.collect {
|
||||
getChatRooms()
|
||||
_isPartialProcessedGiftWrap.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runMetadataBatching() = coroutineScope {
|
||||
@@ -325,11 +339,33 @@ class NostrViewModel(
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
secretStore.clear("user_signer")
|
||||
try {
|
||||
_isBusy.value = true
|
||||
// Reset the nostr signer and prune the database
|
||||
nostr.signer.switch(Keys.generate())
|
||||
nostr.prune()
|
||||
} catch (e: Exception) {
|
||||
showError("Logout encountered an error: ${e.message}")
|
||||
} finally {
|
||||
// Clear credentials from persistent storage
|
||||
secretStore.clear("user_signer")
|
||||
|
||||
// Reset all UI states
|
||||
resetInternalState()
|
||||
|
||||
_isBusy.value = false
|
||||
_signerRequired.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetInternalState() {
|
||||
_chatRooms.value = emptySet()
|
||||
_contactList.value = emptySet()
|
||||
_isPartialProcessedGiftWrap.value = false
|
||||
_isRelayListEmpty.value = false
|
||||
_isNotificationBannerDismissed.value = false
|
||||
}
|
||||
|
||||
fun dismissNotificationBanner() {
|
||||
viewModelScope.launch {
|
||||
|
||||
70
zapstore.yaml
Normal file
70
zapstore.yaml
Normal file
@@ -0,0 +1,70 @@
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# SOURCE CONFIGURATION
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
repository: https://git.reya.su/reya/coop-mobile
|
||||
release_source:
|
||||
url: https://git.reya.su/reya/coop-mobile
|
||||
type: gitea
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# APP METADATA
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
name: Coop
|
||||
summary: Chat and Co-op Privately
|
||||
license: GPLv3
|
||||
website: https://reya.su/coop
|
||||
|
||||
description: |
|
||||
Coop is a simple, cozy chat app built for Nostr. It’s designed for folks who want to stay connected without sacrificing their privacy or control over their data.
|
||||
|
||||
**Features**:
|
||||
- Private & Secure: Enjoy end-to-end encrypted messaging that keeps your private chats truly private.
|
||||
- Friendly Socializing: Connect with friends across the open Nostr network in a clean, easy-to-use interface.
|
||||
- No Identity Required: Get started without a phone number. Stay anonymous, use a pseudonym, or even your real name.
|
||||
|
||||
tags:
|
||||
- messaging
|
||||
- privacy
|
||||
- material3
|
||||
- nostr
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# MEDIA
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
icon: ./assets/icon.png
|
||||
|
||||
images:
|
||||
- ./assets/screenshot1.png
|
||||
- ./assets/screenshot2.png
|
||||
- ./assets/screenshot3.png
|
||||
- ./assets/screenshot4.png
|
||||
- ./assets/screenshot5.png
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# RELEASE CONFIGURATION
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
release_notes: ./CHANGELOG.md
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# NOSTR-SPECIFIC
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
supported_nips:
|
||||
- "01"
|
||||
- "17"
|
||||
- "46"
|
||||
- "55"
|
||||
- "59"
|
||||
|
||||
min_allowed_version_code: 100
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# METADATA SOURCES
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
metadata_sources:
|
||||
- playstore
|
||||
Reference in New Issue
Block a user