mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 07:34:39 +00:00
Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bda3ab31b | ||
|
|
87b83a7c16 | ||
|
|
551add4be0 | ||
|
|
3eb8914c3e | ||
|
|
74be87c305 | ||
|
|
ebf04de516 | ||
|
|
79e70e1161 | ||
|
|
3411f75c4a | ||
|
|
defb898987 | ||
|
|
7d1c45607b | ||
|
|
bcfe2745f2 | ||
|
|
723c90c0d7 | ||
|
|
096fd0833a | ||
|
|
1a82c9b9c8 | ||
|
|
7d1caf590a | ||
|
|
493aed8609 | ||
|
|
cdfc275891 | ||
|
|
26e09339b5 | ||
|
|
5f3848c47d | ||
|
|
9e69198623 | ||
|
|
4e42528591 | ||
|
|
02c2bea1f3 | ||
|
|
ddbee398ca | ||
|
|
15a389b155 | ||
|
|
4c5fe75f0a | ||
|
|
0373032339 | ||
|
|
74579778e2 | ||
|
|
e9475dd079 | ||
|
|
fa1a32e91c | ||
|
|
ae3218249a | ||
|
|
ae6f56283b | ||
|
|
c9e79e31f8 | ||
|
|
de86e54cf8 | ||
|
|
39f69de5e6 | ||
|
|
89aea073ed | ||
|
|
4f65058e87 | ||
|
|
a17e23d796 | ||
|
|
0851768a8b | ||
|
|
618ef66e75 | ||
|
|
4d24e2b09c | ||
|
|
58004fa744 | ||
|
|
311b3ec7f0 | ||
|
|
83ec9b490b | ||
|
|
af04267f9a | ||
|
|
88ee46df05 | ||
|
|
ab5a59a106 | ||
|
|
c49e2d3390 | ||
|
|
f3db2c73dc | ||
|
|
456684edb1 | ||
|
|
31582320f2 | ||
|
|
5cb84e16dc | ||
|
|
68664bfac1 | ||
|
|
d9d02110dd | ||
|
|
8a41129dfc | ||
|
|
baf5f37532 | ||
|
|
3842ab14bb | ||
|
|
49e4234f99 | ||
|
|
48eea9a350 | ||
|
|
7718e3ab19 | ||
|
|
47be9909da | ||
|
|
636331487a | ||
|
|
153d28b420 | ||
|
|
8ce93f3700 | ||
|
|
14db81177f | ||
|
|
65e30c0acd | ||
|
|
d9ae9cbd37 | ||
|
|
a585fe3664 | ||
|
|
b085c8cc37 | ||
|
|
8145bedd15 | ||
|
|
6666637f9c | ||
|
|
6086e48859 | ||
|
|
0b0abeebbb | ||
|
|
a569095e19 | ||
|
|
5174c8e5a3 | ||
|
|
84a96f1f86 | ||
|
|
3ffa259cff | ||
|
|
a87187151a |
@@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa
|
||||
|
||||
Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects.
|
||||
|
||||
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.13.0` (Maven Central)
|
||||
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.13.1` (Maven Central)
|
||||
**Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`)
|
||||
**License**: MIT
|
||||
|
||||
@@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr
|
||||
|
||||
```toml
|
||||
[versions]
|
||||
quartz = "1.13.0"
|
||||
quartz = "1.13.1"
|
||||
|
||||
[libraries]
|
||||
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
|
||||
@@ -41,7 +41,7 @@ kotlin {
|
||||
|
||||
```kotlin
|
||||
dependencies {
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.13.0")
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.13.1")
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Current version
|
||||
|
||||
```
|
||||
com.vitorpamplona.quartz:quartz:1.13.0
|
||||
com.vitorpamplona.quartz:quartz:1.13.1
|
||||
```
|
||||
|
||||
Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz
|
||||
@@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua
|
||||
|
||||
```toml
|
||||
[versions]
|
||||
quartz = "1.13.0"
|
||||
quartz = "1.13.1"
|
||||
|
||||
[libraries]
|
||||
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
|
||||
@@ -55,7 +55,7 @@ kotlin {
|
||||
```kotlin
|
||||
// build.gradle.kts (app module)
|
||||
dependencies {
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.13.0")
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.13.1")
|
||||
}
|
||||
```
|
||||
|
||||
@@ -70,7 +70,7 @@ plugins {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.13.0")
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.13.1")
|
||||
// JNA needed for libsodium (NIP-44) on JVM
|
||||
implementation("net.java.dev.jna:jna:5.18.1")
|
||||
}
|
||||
|
||||
@@ -328,16 +328,16 @@ repositories {
|
||||
Add the following line to your `commonMain` dependencies:
|
||||
|
||||
```gradle
|
||||
implementation('com.vitorpamplona.quartz:quartz:1.13.0')
|
||||
implementation('com.vitorpamplona.quartz:quartz:1.13.1')
|
||||
```
|
||||
|
||||
Variations to each platform are also available:
|
||||
|
||||
```gradle
|
||||
implementation('com.vitorpamplona.quartz:quartz-android:1.13.0')
|
||||
implementation('com.vitorpamplona.quartz:quartz-jvm:1.13.0')
|
||||
implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.13.0')
|
||||
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.13.0')
|
||||
implementation('com.vitorpamplona.quartz:quartz-android:1.13.1')
|
||||
implementation('com.vitorpamplona.quartz:quartz-jvm:1.13.1')
|
||||
implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.13.1')
|
||||
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.13.1')
|
||||
```
|
||||
|
||||
Check versions on [MavenCentral](https://central.sonatype.com/search?q=com.vitorpamplona.quartz)
|
||||
|
||||
@@ -73,6 +73,8 @@ import com.vitorpamplona.amethyst.service.notifications.AlwaysOnNotificationServ
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationDispatcher
|
||||
import com.vitorpamplona.amethyst.service.notifications.NwcPaymentNotificationWatcher
|
||||
import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver
|
||||
import com.vitorpamplona.amethyst.service.okhttp.BlossomReadAuthInterceptor
|
||||
import com.vitorpamplona.amethyst.service.okhttp.BlossomReadAuthTokenProvider
|
||||
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
|
||||
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays
|
||||
import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
|
||||
@@ -424,6 +426,16 @@ class AppModules(
|
||||
},
|
||||
onionCache = onionLocationCache,
|
||||
usageInterceptor = httpUsageInterceptor,
|
||||
// Retries auth-gated Blossom blob downloads (e.g. Buzz's private
|
||||
// media relay) with a BUD-01 read-auth token signed by the current
|
||||
// account on a 401. Reads the signer at call time so it always
|
||||
// tracks the logged-in account.
|
||||
blossomReadAuth =
|
||||
BlossomReadAuthInterceptor(
|
||||
BlossomReadAuthTokenProvider(
|
||||
signerProvider = { sessionManager.loggedInAccount()?.signer },
|
||||
)::authHeader,
|
||||
),
|
||||
)
|
||||
|
||||
// Offers easy methods to know when connections are happening through Tor or not
|
||||
|
||||
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntr
|
||||
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.HomeFeedType
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.model.UiSettings
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
@@ -188,6 +189,10 @@ private object PrefKeys {
|
||||
// Stores the DISABLED chat feed types (comma-joined codes) so absence = all-on and any newly
|
||||
// added type defaults enabled for accounts that customized before it existed.
|
||||
const val DISABLED_CHAT_FEEDS = "disabled_chat_feeds"
|
||||
|
||||
// Same convention as DISABLED_CHAT_FEEDS but for the Home feed's event-kind groups: stores the
|
||||
// DISABLED codes so absence = all-on and any newly added group defaults enabled.
|
||||
const val DISABLED_HOME_FEED_TYPES = "disabled_home_feed_types"
|
||||
const val RELAY_AUTH_TRUST_MY_RELAYS = "relay_auth_trust_my_relays_and_venues"
|
||||
const val RELAY_AUTH_TRUST_READ_FOLLOWS = "relay_auth_trust_read_follows"
|
||||
const val RELAY_AUTH_TRUST_MESSAGE_FOLLOWS = "relay_auth_trust_message_follows"
|
||||
@@ -604,6 +609,7 @@ object LocalPreferences {
|
||||
putString(PrefKeys.RELAY_GROUP_VIEW_MODE, settings.relayGroupViewMode.value.name)
|
||||
putString(PrefKeys.CONCORD_VIEW_MODE, settings.concordViewMode.value.name)
|
||||
putString(PrefKeys.DISABLED_CHAT_FEEDS, ChatFeedType.encode(ChatFeedType.ALL - settings.enabledChatFeeds.value))
|
||||
putString(PrefKeys.DISABLED_HOME_FEED_TYPES, HomeFeedType.encode(HomeFeedType.ALL - settings.enabledHomeFeedTypes.value))
|
||||
putBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, settings.relayAuthTrustMyRelaysAndVenues.value)
|
||||
putBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, settings.relayAuthTrustReadFollows.value)
|
||||
putBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, settings.relayAuthTrustMessageFollows.value)
|
||||
@@ -731,17 +737,10 @@ object LocalPreferences {
|
||||
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
|
||||
val callsEnabled = getBoolean(PrefKeys.CALLS_ENABLED, true)
|
||||
val alwaysOnNotificationService = getBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, false)
|
||||
val defaultRelayAuthPolicy =
|
||||
getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null)
|
||||
?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() }
|
||||
?: RelayAuthPolicy.CUSTOM
|
||||
val relayGroupViewMode = RelayGroupViewMode.fromName(getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null))
|
||||
val concordViewMode = ConcordViewMode.fromName(getString(PrefKeys.CONCORD_VIEW_MODE, null))
|
||||
val enabledChatFeeds = ChatFeedType.ALL - ChatFeedType.decode(getString(PrefKeys.DISABLED_CHAT_FEEDS, null))
|
||||
val relayAuthTrustMyRelays = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, true)
|
||||
val relayAuthTrustReadFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, true)
|
||||
val relayAuthTrustMessageFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, true)
|
||||
val relayAuthTrustMessageStrangers = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS, false)
|
||||
// Read as a group via a helper: this load lambda sits right at the JVM's
|
||||
// per-method bytecode limit (see the note above the awaits below), so keeping
|
||||
// these heavy string/enum decodes out of it preserves headroom.
|
||||
val inboxPrefs = readInboxPrefs()
|
||||
val splitNotificationsEnabled = getBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, false)
|
||||
val showMessagesInNotifications = getBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, true)
|
||||
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
|
||||
@@ -968,14 +967,15 @@ object LocalPreferences {
|
||||
hideBlockAlertDialog = hideBlockAlertDialog,
|
||||
hideNIP17WarningDialog = hideNIP17WarningDialog,
|
||||
alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService),
|
||||
defaultRelayAuthPolicy = MutableStateFlow(defaultRelayAuthPolicy),
|
||||
relayGroupViewMode = MutableStateFlow(relayGroupViewMode),
|
||||
concordViewMode = MutableStateFlow(concordViewMode),
|
||||
enabledChatFeeds = MutableStateFlow(enabledChatFeeds),
|
||||
relayAuthTrustMyRelaysAndVenues = MutableStateFlow(relayAuthTrustMyRelays),
|
||||
relayAuthTrustReadFollows = MutableStateFlow(relayAuthTrustReadFollows),
|
||||
relayAuthTrustMessageFollows = MutableStateFlow(relayAuthTrustMessageFollows),
|
||||
relayAuthTrustMessageStrangers = MutableStateFlow(relayAuthTrustMessageStrangers),
|
||||
defaultRelayAuthPolicy = MutableStateFlow(inboxPrefs.defaultRelayAuthPolicy),
|
||||
relayGroupViewMode = MutableStateFlow(inboxPrefs.relayGroupViewMode),
|
||||
concordViewMode = MutableStateFlow(inboxPrefs.concordViewMode),
|
||||
enabledChatFeeds = MutableStateFlow(inboxPrefs.enabledChatFeeds),
|
||||
enabledHomeFeedTypes = MutableStateFlow(inboxPrefs.enabledHomeFeedTypes),
|
||||
relayAuthTrustMyRelaysAndVenues = MutableStateFlow(inboxPrefs.relayAuthTrustMyRelays),
|
||||
relayAuthTrustReadFollows = MutableStateFlow(inboxPrefs.relayAuthTrustReadFollows),
|
||||
relayAuthTrustMessageFollows = MutableStateFlow(inboxPrefs.relayAuthTrustMessageFollows),
|
||||
relayAuthTrustMessageStrangers = MutableStateFlow(inboxPrefs.relayAuthTrustMessageStrangers),
|
||||
splitNotificationsEnabled = MutableStateFlow(splitNotificationsEnabled),
|
||||
showMessagesInNotifications = MutableStateFlow(showMessagesInNotifications),
|
||||
backupUserMetadata = latestUserMetadataResolved,
|
||||
@@ -1184,3 +1184,36 @@ object LocalPreferences {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The inbox / relay-auth / feed-type preferences, read as one group. Extracted out of
|
||||
* [LocalPreferences]' account-load lambda (which is right at the JVM's per-method bytecode limit)
|
||||
* so these enum/set decodes don't count against that method's budget.
|
||||
*/
|
||||
private class InboxPrefs(
|
||||
val defaultRelayAuthPolicy: RelayAuthPolicy,
|
||||
val relayGroupViewMode: RelayGroupViewMode,
|
||||
val concordViewMode: ConcordViewMode,
|
||||
val enabledChatFeeds: Set<ChatFeedType>,
|
||||
val enabledHomeFeedTypes: Set<HomeFeedType>,
|
||||
val relayAuthTrustMyRelays: Boolean,
|
||||
val relayAuthTrustReadFollows: Boolean,
|
||||
val relayAuthTrustMessageFollows: Boolean,
|
||||
val relayAuthTrustMessageStrangers: Boolean,
|
||||
)
|
||||
|
||||
private fun SharedPreferences.readInboxPrefs() =
|
||||
InboxPrefs(
|
||||
defaultRelayAuthPolicy =
|
||||
getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null)
|
||||
?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() }
|
||||
?: RelayAuthPolicy.CUSTOM,
|
||||
relayGroupViewMode = RelayGroupViewMode.fromName(getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null)),
|
||||
concordViewMode = ConcordViewMode.fromName(getString(PrefKeys.CONCORD_VIEW_MODE, null)),
|
||||
enabledChatFeeds = ChatFeedType.ALL - ChatFeedType.decode(getString(PrefKeys.DISABLED_CHAT_FEEDS, null)),
|
||||
enabledHomeFeedTypes = HomeFeedType.ALL - HomeFeedType.decode(getString(PrefKeys.DISABLED_HOME_FEED_TYPES, null)),
|
||||
relayAuthTrustMyRelays = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, true),
|
||||
relayAuthTrustReadFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, true),
|
||||
relayAuthTrustMessageFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, true),
|
||||
relayAuthTrustMessageStrangers = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS, false),
|
||||
)
|
||||
|
||||
@@ -299,6 +299,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.hTag
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent
|
||||
@@ -3451,6 +3452,18 @@ class Account(
|
||||
unfollow(channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the whole group with a kind 9008 delete-group event (owner/admin only — the relay
|
||||
* enforces this). Unlike [leaveRelayGroup], this destroys the channel for everyone rather than
|
||||
* just removing me; the relay drops the group and its messages. Also drops it from our own list
|
||||
* so it disappears from Messages immediately instead of lingering as a now-dead id.
|
||||
*/
|
||||
suspend fun deleteRelayGroup(channel: RelayGroupChannel) {
|
||||
val template = DeleteGroupEvent.build(channel.groupId.id)
|
||||
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
unfollow(channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new group on [relay]: kind 9007 (create-group) then kind 9002
|
||||
* (edit-metadata) with the chosen name/visibility, then remember it. Returns
|
||||
|
||||
@@ -341,6 +341,9 @@ class AccountSettings(
|
||||
// Which conversation protocols the Messages inbox loads and shows. A disabled type is both hidden
|
||||
// from the inbox and dropped from the always-on downloading routes. Defaults to everything on.
|
||||
val enabledChatFeeds: MutableStateFlow<Set<ChatFeedType>> = MutableStateFlow(ChatFeedType.ALL),
|
||||
// Which event-kind groups the Home feed downloads (assembler) and renders (DAL). A disabled group
|
||||
// is both dropped from the always-on home relay filters and hidden from the tabs. Everything on by default.
|
||||
val enabledHomeFeedTypes: MutableStateFlow<Set<HomeFeedType>> = MutableStateFlow(HomeFeedType.ALL),
|
||||
// The per-situation toggles applied under RelayAuthPolicy.CUSTOM.
|
||||
val relayAuthTrustMyRelaysAndVenues: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
val relayAuthTrustReadFollows: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
@@ -391,6 +394,20 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun isHomeFeedTypeEnabled(type: HomeFeedType): Boolean = type in enabledHomeFeedTypes.value
|
||||
|
||||
fun setHomeFeedTypeEnabled(
|
||||
type: HomeFeedType,
|
||||
enabled: Boolean,
|
||||
) {
|
||||
val current = enabledHomeFeedTypes.value
|
||||
val next = if (enabled) current + type else current - type
|
||||
if (next != current) {
|
||||
enabledHomeFeedTypes.tryEmit(next)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// Always-on Notification Service
|
||||
// ---
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
|
||||
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent
|
||||
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
||||
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
|
||||
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
|
||||
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
|
||||
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
|
||||
/**
|
||||
* The distinct event-kind groups the Home feed downloads (in the relay assembler) and renders (in
|
||||
* the DAL). Each is independently toggleable in Settings › Home: turning one off both drops its
|
||||
* kinds from the always-on home relay filters AND hides them from the New Threads / Conversations /
|
||||
* Everything tabs.
|
||||
*
|
||||
* [code] is the stable on-disk identifier (do NOT rename — it is what [encode]/[decode] persist);
|
||||
* the enum ordinal is never stored, so entries may be reordered freely. [kinds] are the Nostr event
|
||||
* kinds this group governs; they must stay disjoint across entries so a single toggle owns each kind.
|
||||
*/
|
||||
enum class HomeFeedType(
|
||||
val code: String,
|
||||
val kinds: List<Int>,
|
||||
) {
|
||||
TEXT_NOTES("text_notes", listOf(TextNoteEvent.KIND)),
|
||||
REPOSTS("reposts", listOf(RepostEvent.KIND, GenericRepostEvent.KIND)),
|
||||
COMMENTS("comments", listOf(CommentEvent.KIND)),
|
||||
ARTICLES("articles", listOf(LongTextNoteEvent.KIND)),
|
||||
WIKI("wiki", listOf(WikiNoteEvent.KIND)),
|
||||
HIGHLIGHTS("highlights", listOf(HighlightEvent.KIND)),
|
||||
POLLS("polls", listOf(PollEvent.KIND, ZapPollEvent.KIND, PollResponseEvent.KIND)),
|
||||
CLASSIFIEDS("classifieds", listOf(ClassifiedsEvent.KIND)),
|
||||
VOICE("voice", listOf(VoiceEvent.KIND, VoiceReplyEvent.KIND)),
|
||||
LIVE_ACTIVITIES("live_activities", listOf(LiveActivitiesEvent.KIND, LiveActivitiesChatMessageEvent.KIND)),
|
||||
EPHEMERAL_CHAT("ephemeral_chat", listOf(EphemeralChatEvent.KIND)),
|
||||
INTERACTIVE_STORIES("interactive_stories", listOf(InteractiveStoryPrologueEvent.KIND)),
|
||||
CHESS("chess", listOf(ChessGameEvent.KIND, LiveChessGameChallengeEvent.KIND, LiveChessGameEndEvent.KIND)),
|
||||
BIRDS("birds", listOf(BirdDetectionEvent.KIND, BirdexEvent.KIND)),
|
||||
ATTESTATIONS(
|
||||
"attestations",
|
||||
listOf(
|
||||
AttestationEvent.KIND,
|
||||
AttestationRequestEvent.KIND,
|
||||
AttestorRecommendationEvent.KIND,
|
||||
AttestorProficiencyEvent.KIND,
|
||||
),
|
||||
),
|
||||
NIPS("nips", listOf(NipTextEvent.KIND)),
|
||||
MUSIC("music", listOf(AudioTrackEvent.KIND, MusicTrackEvent.KIND, MusicPlaylistEvent.KIND, AudioHeaderEvent.KIND)),
|
||||
PODCASTS("podcasts", listOf(PodcastEpisodeEvent.KIND, PodcastMetadataEvent.KIND)),
|
||||
FUNDRAISERS("fundraisers", listOf(FundraiserEvent.KIND)),
|
||||
;
|
||||
|
||||
companion object {
|
||||
/** Every group, enabled by default so a fresh (or never-customized) account loads everything. */
|
||||
val ALL: Set<HomeFeedType> = entries.toSet()
|
||||
|
||||
fun fromCode(code: String?): HomeFeedType? = entries.firstOrNull { it.code == code }
|
||||
|
||||
/** Serializes a set of groups as their comma-joined [code]s, for SharedPreferences. */
|
||||
fun encode(types: Set<HomeFeedType>): String = types.joinToString(",") { it.code }
|
||||
|
||||
/** Parses a comma-joined [code] list back to a set, dropping any unknown codes. */
|
||||
fun decode(joined: String?): Set<HomeFeedType> =
|
||||
joined
|
||||
?.split(",")
|
||||
?.mapNotNull { fromCode(it.trim()) }
|
||||
?.toSet()
|
||||
?: emptySet()
|
||||
|
||||
/**
|
||||
* The event kinds to drop from the home relay filters and the home DAL, given the currently
|
||||
* [enabled] set. A kind stays live if ANY enabled group still owns it (guards against a
|
||||
* future overlap between two groups), so disabling one group never silently hides a kind a
|
||||
* still-enabled group also wants.
|
||||
*/
|
||||
fun disabledKinds(enabled: Set<HomeFeedType>): Set<Int> {
|
||||
if (enabled.size == ALL.size) return emptySet()
|
||||
val enabledKinds = enabled.flatMapTo(HashSet()) { it.kinds }
|
||||
return (ALL - enabled).flatMapTo(HashSet()) { it.kinds }.apply { removeAll(enabledKinds) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -687,12 +687,12 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
|
||||
fun load(keys: Set<String>): Set<User> = keys.mapNotNullTo(mutableSetOf(), ::checkGetOrCreateUser)
|
||||
|
||||
override fun getOrCreateUser(pubkey: HexKey): User {
|
||||
require(isValidHex(key = pubkey)) { "$pubkey is not a valid hex" }
|
||||
override fun getOrCreateUser(hex: HexKey): User {
|
||||
require(isValidHex(key = hex)) { "$hex is not a valid hex" }
|
||||
// Pass `this` as the UserContext — User now resolves each pinned
|
||||
// addressable note (kind:10002 / 10050 / 10019) lazily on first
|
||||
// read, instead of all-or-nothing at construction time.
|
||||
return users.getOrCreate(pubkey) { User(it, userContext) }
|
||||
return users.getOrCreate(hex) { User(it, userContext) }
|
||||
}
|
||||
|
||||
/** [UserContext] bridge to this cache's addressable lookup. */
|
||||
@@ -823,11 +823,11 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
}
|
||||
}
|
||||
|
||||
override fun getOrCreateNote(idHex: String): Note {
|
||||
require(isValidHex(idHex)) { "$idHex is not a valid hex" }
|
||||
override fun getOrCreateNote(hex: String): Note {
|
||||
require(isValidHex(hex)) { "$hex is not a valid hex" }
|
||||
|
||||
return notes.getOrCreate(idHex) {
|
||||
Note(idHex)
|
||||
return notes.getOrCreate(hex) {
|
||||
Note(hex)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -945,11 +945,11 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
|
||||
fun getOrCreateAddressableNoteInternal(key: Address): AddressableNote = addressables.getOrCreate(key) { AddressableNote(key) }
|
||||
|
||||
override fun getOrCreateAddressableNote(key: Address): AddressableNote {
|
||||
val note = getOrCreateAddressableNoteInternal(key)
|
||||
override fun getOrCreateAddressableNote(address: Address): AddressableNote {
|
||||
val note = getOrCreateAddressableNoteInternal(address)
|
||||
// Loads the user outside a Syncronized block to avoid blocking
|
||||
if (note.author == null) {
|
||||
note.author = checkGetOrCreateUser(key.pubKeyHex)
|
||||
note.author = checkGetOrCreateUser(address.pubKeyHex)
|
||||
}
|
||||
return note
|
||||
}
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Retries a Blossom blob download with a BUD-01 `t=get` authorization when the
|
||||
* server answers `401`.
|
||||
*
|
||||
* Most Blossom / NIP-96 hosts serve blobs anonymously, so images load without
|
||||
* any signing overhead. A few gate reads behind auth — Buzz's private media
|
||||
* relay (`*.communities.buzz.xyz`) returns
|
||||
* `401 {"error":"authentication failed"}` to an anonymous `GET`. For those we
|
||||
* sign a kind-24242 read-auth event (via [authHeaderProvider]) and replay the
|
||||
* request once with `Authorization: Nostr <base64-event>`.
|
||||
*
|
||||
* Gating is deliberately narrow so we never turn an unrelated `401` into a
|
||||
* second request storm:
|
||||
* - only `GET` requests,
|
||||
* - only when the request doesn't already carry an `Authorization` header,
|
||||
* - only when the URL's last path segment is a Blossom sha256 filename
|
||||
* (`<64-hex>` optionally followed by an extension such as `.png` or
|
||||
* `.thumb.jpg`),
|
||||
* - only after the anonymous attempt actually returned `401`,
|
||||
* - and at most one retry (an application interceptor's second `chain.proceed`
|
||||
* runs the downstream chain again, it does not re-enter this interceptor).
|
||||
*
|
||||
* [authHeaderProvider] is `(host, sha256) -> header?`. It is synchronous by
|
||||
* contract (the caller bridges the suspend signer), returns `null` when no
|
||||
* signer is available or signing times out, and is only consulted on a real
|
||||
* `401`, so an unauthenticated user simply keeps seeing the broken image
|
||||
* rather than paying any signing cost.
|
||||
*
|
||||
* The first blob from an auth-gated host costs an extra round trip (anonymous
|
||||
* `GET` → `401` → signed retry), but that host is then remembered in
|
||||
* [knownAuthHosts] so every later blob from it is signed **up front** — one
|
||||
* round trip, not two. This matters on a Buzz community feed where nearly every
|
||||
* image comes from the same gated host: without it each image would keep paying
|
||||
* the wasted 401 probe. The learned host also short-circuits to anonymous when
|
||||
* no signer is available, so a logged-out user never re-probes needlessly.
|
||||
*/
|
||||
class BlossomReadAuthInterceptor(
|
||||
private val authHeaderProvider: (host: String, sha256: HexKey) -> String?,
|
||||
) : Interceptor {
|
||||
// Hosts observed to answer 401 to an anonymous Blossom GET. Small (a user
|
||||
// follows a handful of auth-gated servers at most) and shared across all
|
||||
// clients derived from the same factory. newKeySet() is thread-safe for the
|
||||
// concurrent reads/writes of parallel feed downloads.
|
||||
private val knownAuthHosts: MutableSet<String> = ConcurrentHashMap.newKeySet()
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
|
||||
if (!request.method.equals("GET", ignoreCase = true) ||
|
||||
request.header("Authorization") != null
|
||||
) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
val sha256 = blossomHashOrNull(request.url.encodedPath) ?: return chain.proceed(request)
|
||||
val host = request.url.host
|
||||
|
||||
// Known-gated host: skip the anonymous probe and sign the first attempt.
|
||||
// Falls through to anonymous only when we can't produce a token (no
|
||||
// signer / timeout) — the server would 401 either way.
|
||||
if (host in knownAuthHosts) {
|
||||
authHeaderProvider(host, sha256)?.let { header ->
|
||||
return chain.proceed(request.withAuth(header))
|
||||
}
|
||||
}
|
||||
|
||||
val response = chain.proceed(request)
|
||||
if (response.code != 401) return response
|
||||
|
||||
// Learn the host so its next blob is signed up front.
|
||||
knownAuthHosts.add(host)
|
||||
|
||||
val header = authHeaderProvider(host, sha256) ?: return response
|
||||
|
||||
// Close the 401 body before replaying so the connection can be reused.
|
||||
response.close()
|
||||
|
||||
return chain.proceed(request.withAuth(header))
|
||||
}
|
||||
|
||||
private fun Request.withAuth(header: String) =
|
||||
newBuilder()
|
||||
.header("Authorization", header)
|
||||
.build()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Extracts the sha256 blob id from a Blossom URL path. The blob is the
|
||||
* last path segment, up to its first `.` — so both `<hash>.png` and the
|
||||
* derived `<hash>.thumb.jpg` resolve to `<hash>`. Returns `null` when the
|
||||
* segment isn't a 64-char hex string.
|
||||
*
|
||||
* Uses Quartz's unrolled [Hex.isHex64] rather than a regex — this runs on
|
||||
* every media URL the feed loads. [Hex.isHex64] only checks the first 64
|
||||
* chars and doesn't verify total length, so the `length == 64` guard is
|
||||
* what rejects longer segments.
|
||||
*/
|
||||
fun blossomHashOrNull(encodedPath: String): HexKey? {
|
||||
val base = encodedPath.substringAfterLast('/').substringBefore('.').lowercase()
|
||||
return if (base.length == 64 && Hex.isHex64(base)) base else null
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.BlossomAuth
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Signs and caches BUD-01 read-auth headers for [BlossomReadAuthInterceptor].
|
||||
*
|
||||
* The interceptor is synchronous (it runs on an OkHttp dispatcher thread) but
|
||||
* signing is `suspend`, so [authHeader] bridges with [runBlocking] guarded by a
|
||||
* timeout: an internal key signs instantly, while a remote (NIP-46) or external
|
||||
* (NIP-55) signer that hangs or needs user interaction simply yields `null` and
|
||||
* the download stays unauthenticated instead of pinning the thread.
|
||||
*
|
||||
* Tokens are cached per host, not per blob. A BUD-11 `server`-scoped token
|
||||
* grants reads for every blob on the host (thumbnails included), so one signed
|
||||
* event covers a whole feed's worth of images from an auth-gated host for the
|
||||
* life of the token. The blob hash of the request that first triggered signing
|
||||
* is still included as the `x` tag for BUD-01 servers that check it.
|
||||
*/
|
||||
class BlossomReadAuthTokenProvider(
|
||||
private val signerProvider: () -> NostrSigner?,
|
||||
private val clock: () -> Long = { System.currentTimeMillis() },
|
||||
) {
|
||||
private class CachedToken(
|
||||
val header: String,
|
||||
val expiresAtMs: Long,
|
||||
)
|
||||
|
||||
private val cache = ConcurrentHashMap<String, CachedToken>()
|
||||
|
||||
fun authHeader(
|
||||
host: String,
|
||||
sha256: HexKey,
|
||||
): String? {
|
||||
val now = clock()
|
||||
|
||||
cache[host]?.let { if (it.expiresAtMs > now) return it.header }
|
||||
|
||||
val signer = signerProvider() ?: return null
|
||||
|
||||
val header =
|
||||
runBlocking {
|
||||
withTimeoutOrNull(SIGN_TIMEOUT_MS) {
|
||||
BlossomAuth.createGetAuth(
|
||||
hash = sha256,
|
||||
alt = "Downloading media from $host",
|
||||
signer = signer,
|
||||
servers = listOf(host),
|
||||
)
|
||||
}
|
||||
} ?: return null
|
||||
|
||||
cache[host] = CachedToken(header, now + CACHE_TTL_MS)
|
||||
return header
|
||||
}
|
||||
|
||||
companion object {
|
||||
// The signed event expires one hour out (BlossomAuthorizationEvent), so
|
||||
// refresh a little early to avoid handing over a token that dies mid-flight.
|
||||
private const val CACHE_TTL_MS = 55L * 60L * 1000L
|
||||
|
||||
// Bounds how long an image download may block waiting on a slow signer.
|
||||
private const val SIGN_TIMEOUT_MS = 8_000L
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -50,8 +50,11 @@ class DualHttpClientManager(
|
||||
// Resource-usage ledger counter, installed on the shared base client so
|
||||
// every derived client is accounted. See [OkHttpClientFactory].
|
||||
usageInterceptor: Interceptor? = null,
|
||||
// Signs BUD-01 read-auth to retry auth-gated Blossom downloads on 401.
|
||||
// See [BlossomReadAuthInterceptor].
|
||||
blossomReadAuth: Interceptor? = null,
|
||||
) : IHttpClientManager {
|
||||
val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache, onionCache, usageInterceptor)
|
||||
val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache, onionCache, usageInterceptor, blossomReadAuth)
|
||||
|
||||
val defaultHttpClient: StateFlow<OkHttpClient> =
|
||||
combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile ->
|
||||
|
||||
+13
@@ -69,6 +69,13 @@ class OkHttpClientFactory(
|
||||
* tests / pre-configuration call sites.
|
||||
*/
|
||||
private val usageInterceptor: Interceptor? = null,
|
||||
/**
|
||||
* Retries auth-gated Blossom blob downloads with a signed BUD-01 `t=get`
|
||||
* token when the host answers `401` (e.g. Buzz's private media relay). Null
|
||||
* in tests / pre-configuration call sites, in which case such downloads stay
|
||||
* anonymous. See [BlossomReadAuthInterceptor].
|
||||
*/
|
||||
private val blossomReadAuth: Interceptor? = null,
|
||||
) {
|
||||
// val logging = LoggingInterceptor()
|
||||
val keyDecryptor = EncryptedBlobInterceptor(keyCache)
|
||||
@@ -116,6 +123,12 @@ class OkHttpClientFactory(
|
||||
.apply {
|
||||
blossomCacheRedirect?.let { addInterceptor(it) }
|
||||
}
|
||||
// Sits outside the network interceptors so its retry re-runs the
|
||||
// full stack (content-type, blossom cache, key decryptor) for the
|
||||
// authenticated response. Only signs on an actual 401.
|
||||
.apply {
|
||||
blossomReadAuth?.let { addInterceptor(it) }
|
||||
}
|
||||
// .addNetworkInterceptor(logging)
|
||||
.addNetworkInterceptor(keyDecryptor)
|
||||
// Passively populates [onionCache] from any HTTP/WebSocket response
|
||||
|
||||
+7
@@ -221,6 +221,13 @@ class AuthCoordinator(
|
||||
relayUrl = relayUrl,
|
||||
pendingEvents = client.activeOutboxEvents(relayUrl),
|
||||
myRelays = account.trustedRelays.flow.value,
|
||||
// A NIP-29 relay group the user explicitly joined (kind-10009) is a first-party reason
|
||||
// to authenticate with its host relay: private/closed group content is `#h`-scoped and
|
||||
// never names the user, so it fails the pubkey checks above — without this, a joined
|
||||
// private group's messages are refused with `auth-required` and the group stays empty.
|
||||
myGroupRelays =
|
||||
account.relayGroupList.liveRelayGroupIds.value
|
||||
.mapTo(mutableSetOf()) { it.relayUrl },
|
||||
)
|
||||
|
||||
fun destroy() {
|
||||
|
||||
+11
-2
@@ -34,7 +34,14 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
* - it is publishing its own event there ([pendingEvents] authored by it — e.g. delivering a DM to
|
||||
* the recipient's inbox relay), or
|
||||
* - the relay is one it configured itself ([myRelays] — its NIP-65 / DM / search / … lists, which
|
||||
* is where its own inbox/outbox reads are routed anyway).
|
||||
* is where its own inbox/outbox reads are routed anyway), or
|
||||
* - the relay hosts a NIP-29 relay group the account explicitly joined ([myGroupRelays], from its
|
||||
* kind-10009 list). A private/closed group's content (kind-9 chat, kind-11 threads, …) is
|
||||
* `#h`-scoped and served only to authenticated members; that read never names the user, so it
|
||||
* can't qualify via [pendingEvents], and a group host relay is not one of the account's own
|
||||
* NIP-65/DM/… lists, so it can't qualify via [myRelays] either. Without this, a joined private
|
||||
* group is refused with `auth-required` and renders empty — the group's own metadata (39000) is
|
||||
* public and still loads, so the group appears but shows no messages.
|
||||
*
|
||||
* Crucially, an active subscription merely *naming* the account (a `#p` tag or `authors` entry) is
|
||||
* NOT a first-party reason: the app packs several accounts' pubkeys into one merged filter and fans
|
||||
@@ -49,8 +56,10 @@ object RelayAuthFirstParty {
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
pendingEvents: List<Event>,
|
||||
myRelays: Set<NormalizedRelayUrl>,
|
||||
myGroupRelays: Set<NormalizedRelayUrl> = emptySet(),
|
||||
): Boolean {
|
||||
if (pendingEvents.any { it.pubKey == me }) return true
|
||||
return relayUrl in myRelays
|
||||
if (relayUrl in myRelays) return true
|
||||
return relayUrl in myGroupRelays
|
||||
}
|
||||
}
|
||||
|
||||
+10
-10
@@ -46,25 +46,25 @@ object ResourceUsageAlerts {
|
||||
val value: Long,
|
||||
)
|
||||
|
||||
/** > 50 MB of background traffic on cellular in one day. */
|
||||
const val BG_MOBILE_BYTES_PER_DAY = 50L * 1024L * 1024L
|
||||
/** > 500 MB of background traffic on cellular in one day. */
|
||||
const val BG_MOBILE_BYTES_PER_DAY = 500L * 1024L * 1024L
|
||||
|
||||
/** > 12 relay-connection-hours while backgrounded on cellular in one day. */
|
||||
const val BG_MOBILE_RELAY_CONN_MS_PER_DAY = 12L * 60L * 60L * 1000L
|
||||
/** > 24 relay-connection-hours while backgrounded on cellular in one day. */
|
||||
const val BG_MOBILE_RELAY_CONN_MS_PER_DAY = 24L * 60L * 60L * 1000L
|
||||
|
||||
/** > 30 minutes of notification wakelock held in one day. */
|
||||
const val WAKELOCK_MS_PER_DAY = 30L * 60L * 1000L
|
||||
/** > 60 minutes of notification wakelock held in one day. */
|
||||
const val WAKELOCK_MS_PER_DAY = 60L * 60L * 1000L
|
||||
|
||||
/** > 75 process starts in one day (WorkManager/restart churn). */
|
||||
const val APP_STARTS_PER_DAY = 75L
|
||||
/** > 150 process starts in one day (WorkManager/restart churn). */
|
||||
const val APP_STARTS_PER_DAY = 150L
|
||||
|
||||
/**
|
||||
* > 5000 completed relay (re)connections in one day. A healthy day is a
|
||||
* > 10000 completed relay (re)connections in one day. A healthy day is a
|
||||
* few hundred to ~2000 even with a large relay set; sustained thousands
|
||||
* means something is cycling (a stuck relay tier, a flapping network, a
|
||||
* Tor bootstrap loop) and every cycle pays a TLS handshake.
|
||||
*/
|
||||
const val RELAY_CONNECTS_PER_DAY = 5_000L
|
||||
const val RELAY_CONNECTS_PER_DAY = 10_000L
|
||||
|
||||
const val MIN_DAYS_BETWEEN_PROMPTS = 7L
|
||||
|
||||
|
||||
+8
-5
@@ -115,6 +115,9 @@ import kotlinx.coroutines.launch
|
||||
import net.engawapg.lib.zoomable.rememberZoomState
|
||||
import net.engawapg.lib.zoomable.zoomable
|
||||
|
||||
private const val MIME_IMAGE_PREFIX = "image/"
|
||||
private const val MIME_VIDEO_PREFIX = "video/"
|
||||
|
||||
@Composable
|
||||
fun BlossomBlobManagerScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -291,7 +294,7 @@ private fun OverflowMenuIcon(symbol: MaterialSymbol) {
|
||||
|
||||
/** Whether a blob is an image or a video, i.e. it can be previewed and shown full-screen. */
|
||||
private val BlobRow.isViewable: Boolean
|
||||
get() = type?.let { it.startsWith("image/") || it.startsWith("video/") } == true
|
||||
get() = type?.let { it.startsWith(MIME_IMAGE_PREFIX) || it.startsWith(MIME_VIDEO_PREFIX) } == true
|
||||
|
||||
@Composable
|
||||
private fun CenteredState(content: @Composable () -> Unit) {
|
||||
@@ -381,7 +384,7 @@ private fun BlobPreview(
|
||||
glyphSize: Dp = 34.dp,
|
||||
playIconSize: Dp = 40.dp,
|
||||
) {
|
||||
val isVideo = row.type?.startsWith("video/") == true
|
||||
val isVideo = row.type?.startsWith(MIME_VIDEO_PREFIX) == true
|
||||
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||
if (row.url != null && row.isViewable) {
|
||||
SubcomposeAsyncImage(
|
||||
@@ -483,7 +486,7 @@ private fun BlossomBlobViewer(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var drawerOpen by remember { mutableStateOf(false) }
|
||||
val isVideo = row.type?.startsWith("video/") == true
|
||||
val isVideo = row.type?.startsWith(MIME_VIDEO_PREFIX) == true
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -739,8 +742,8 @@ private fun DetailAction(
|
||||
|
||||
private fun glyphFor(type: String?): MaterialSymbol =
|
||||
when {
|
||||
type?.startsWith("image/") == true -> MaterialSymbols.Image
|
||||
type?.startsWith("video/") == true -> MaterialSymbols.PlayCircle
|
||||
type?.startsWith(MIME_IMAGE_PREFIX) == true -> MaterialSymbols.Image
|
||||
type?.startsWith(MIME_VIDEO_PREFIX) == true -> MaterialSymbols.PlayCircle
|
||||
else -> MaterialSymbols.Storage
|
||||
}
|
||||
|
||||
|
||||
@@ -28,9 +28,10 @@ import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import com.vitorpamplona.amethyst.ui.navigation.BOTTOM_NAV_ROOT_KEY
|
||||
import com.vitorpamplona.amethyst.ui.navigation.isBottomNavRoot
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
@@ -115,19 +116,35 @@ class Nav(
|
||||
|
||||
@Composable
|
||||
override fun canPop(): Boolean {
|
||||
// Observe the current entry as State so consumers recompose when the
|
||||
// back stack settles after a navigation or back-swipe transition.
|
||||
// A non-reactive read would leave a stale value behind: e.g. on
|
||||
// back-swipe to Home, previousBackStackEntry is still the popping
|
||||
// entry until the gesture finishes, and nothing would re-evaluate
|
||||
// canPop afterwards.
|
||||
val current by controller.currentBackStackEntryAsState()
|
||||
val entry = current ?: return false
|
||||
// Decide the back arrow / bottom-bar visibility from THIS screen's own
|
||||
// back-stack entry — the one the NavHost hands to each destination
|
||||
// through LocalViewModelStoreOwner — instead of the globally-current
|
||||
// entry.
|
||||
//
|
||||
// A pop commits the moment it is accepted (most visibly during a
|
||||
// predictive back-swipe, whose exit animation is long and finger-driven):
|
||||
// controller.currentBackStackEntry flips to the destination while the
|
||||
// screen being dismissed is still on screen, sliding out and still
|
||||
// composing its top bar. Reading the global entry there re-evaluated
|
||||
// canPop against the incoming destination and dropped the arrow before
|
||||
// the outgoing screen had finished leaving. An entry is intrinsic to its
|
||||
// screen and never changes for the life of that composition, so the arrow
|
||||
// now stays put until the screen itself is gone.
|
||||
//
|
||||
// Outside a NavHost destination (shell chrome, drawer) the current owner
|
||||
// is the account-scoped ViewModelStoreOwner, not an entry; fall back to
|
||||
// the globally-current entry so those callers keep their prior behavior.
|
||||
val entry =
|
||||
(LocalViewModelStoreOwner.current as? NavBackStackEntry)
|
||||
?: controller.currentBackStackEntry
|
||||
?: return false
|
||||
|
||||
// Hidden on tab roots (reached via the bottom nav) and on Home (the
|
||||
// graph's start destination): nothing sits below either that a back
|
||||
// arrow could return to. Every other entry is a push on top of Home,
|
||||
// so it can always pop.
|
||||
if (entry.isBottomNavRoot()) return false
|
||||
// Home is the graph's start destination and nothing can sit below
|
||||
// it, so a back arrow there is never meaningful.
|
||||
if (entry.destination.id == controller.graph.findStartDestination().id) return false
|
||||
return controller.previousBackStackEntry != null
|
||||
return entry.destination.id != controller.graph.findStartDestination().id
|
||||
}
|
||||
|
||||
override fun popBack() {
|
||||
|
||||
+1
@@ -766,6 +766,7 @@ private fun geohashBounds(geohash: String): BoundingBox? {
|
||||
/** A rough physical size for a geohash cell at this precision, for the chip subtitle. */
|
||||
private fun GeohashChannelLevel.areaSize(): String =
|
||||
when (this) {
|
||||
GeohashChannelLevel.CONTINENT -> "~5000 km"
|
||||
GeohashChannelLevel.REGION -> "~1250 km"
|
||||
GeohashChannelLevel.PROVINCE -> "~39 km"
|
||||
GeohashChannelLevel.CITY -> "~5 km"
|
||||
|
||||
+2
-2
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.nip22Comments
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -66,7 +67,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
|
||||
@@ -177,7 +177,7 @@ fun GenericCommentPostScreen(
|
||||
|
||||
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
|
||||
|
||||
KeyboardAwareBackHandler {
|
||||
BackHandler {
|
||||
accountViewModel.launchSigner {
|
||||
postViewModel.sendDraftSync()
|
||||
postViewModel.cancel()
|
||||
|
||||
@@ -40,10 +40,13 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.commons.model.highlights.HighlightQuote
|
||||
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.HighlightQuoteIndent
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.HighlightQuoteSpacing
|
||||
@@ -54,7 +57,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.DisplayEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.RenderUserAsClickableText
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.components.measureSpaceWidth
|
||||
@@ -64,6 +66,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindNameFor
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.firstTagValueFor
|
||||
@@ -91,6 +94,7 @@ fun RenderHighlight(
|
||||
|
||||
DisplayHighlight(
|
||||
comment = noteEvent.comment(),
|
||||
commentTags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() },
|
||||
highlight = noteEvent.quote(),
|
||||
context = noteEvent.contextOrReconstructed(),
|
||||
authorHex = noteEvent.author(),
|
||||
@@ -160,6 +164,7 @@ fun DisplayHighlightPreviewNewLine() {
|
||||
@Composable
|
||||
fun DisplayHighlight(
|
||||
comment: String?,
|
||||
commentTags: ImmutableListOfLists<String> = EmptyTagList,
|
||||
highlight: String,
|
||||
context: String?,
|
||||
authorHex: String?,
|
||||
@@ -181,7 +186,7 @@ fun DisplayHighlight(
|
||||
canPreview = canPreview && !makeItShort,
|
||||
quotesLeft = quotesLeft,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tags = EmptyTagList,
|
||||
tags = commentTags,
|
||||
backgroundColor = backgroundColor,
|
||||
id = it,
|
||||
callbackUri = null,
|
||||
@@ -406,7 +411,11 @@ fun DisplayEntryForNote(
|
||||
|
||||
val noteEvent = noteState.note.event as? BaseThreadedEvent ?: return
|
||||
|
||||
val description = remember(noteEvent) { noteEvent.tags.firstTagValueFor("title", "subject", "alt") }
|
||||
// A real title/subject from an article or wiki page describes the source well. `alt`
|
||||
// (NIP-31) is deliberately excluded: it's accessibility fallback text, not a caption, and
|
||||
// clients such as Jumble fill it with a generic "This event was published by …" line that
|
||||
// has nothing to do with the highlighted passage.
|
||||
val description = remember(noteEvent) { noteEvent.tags.firstTagValueFor("title", "subject") }
|
||||
|
||||
Text("-", maxLines = 1)
|
||||
|
||||
@@ -416,7 +425,13 @@ fun DisplayEntryForNote(
|
||||
onClick = { routeFor(note, accountViewModel.account)?.let { nav.nav(it) } },
|
||||
)
|
||||
} else {
|
||||
DisplayEvent(noteEvent.id, note.toNostrUri(), null, accountViewModel, nav)
|
||||
// No title to show — name the source by its event kind (e.g. "Note", "Blogs") rather
|
||||
// than a raw @note1… id, and keep it clickable through to the source event.
|
||||
val kindName = kindNameFor(LocalContext.current, noteEvent.kind)
|
||||
ClickableTextPrimary(
|
||||
text = kindName,
|
||||
onClick = { routeFor(note, accountViewModel.account)?.let { nav.nav(it) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
@@ -248,6 +248,19 @@ class AccountFeedContentStates(
|
||||
}
|
||||
}
|
||||
|
||||
// Toggling a Home content type on/off in Settings › Home changes which event kinds the tabs
|
||||
// render, but no event flows through LocalCache — force a rebuild of all three home feeds so
|
||||
// hidden kinds disappear (and re-enabled ones reappear from cache) immediately.
|
||||
scope.launch(Dispatchers.IO) {
|
||||
account.settings.enabledHomeFeedTypes
|
||||
.drop(1)
|
||||
.collect {
|
||||
homeNewThreads.invalidateData()
|
||||
homeReplies.invalidateData()
|
||||
homeEverything.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
// Pinning/unpinning a room only changes sort order, not membership, so no
|
||||
// chat event flows through LocalCache. Force a rebuild to re-sort. This
|
||||
// also fires when pins arrive via the synced AppSpecificData event.
|
||||
|
||||
+28
-4
@@ -1672,18 +1672,42 @@ class AccountViewModel(
|
||||
|
||||
fun leaveRelayGroup(channel: RelayGroupChannel) = launchSigner { account.leaveRelayGroup(channel) }
|
||||
|
||||
/** Delete the channel/group for everyone (kind-9008). Owner/admin only; the relay enforces it. */
|
||||
fun deleteRelayGroup(channel: RelayGroupChannel) = launchSigner { account.deleteRelayGroup(channel) }
|
||||
|
||||
/**
|
||||
* Accept a channel somebody added me to: write it into my kind-10009 so it shows on Messages and
|
||||
* follows me to other devices. No kind-9021 join — the relay already put me in the roster, which is
|
||||
* why the channel opens and accepts posts today; this only records *my* decision to surface it.
|
||||
* Take a relay group off Messages WITHOUT leaving it: drop it from my kind-10009 list so it stops
|
||||
* showing, but send no kind-9022 — I stay in the relay roster and can still read/post, and re-joining
|
||||
* re-surfaces it instantly. Also records it in `dismissedChannelInvites` so a Buzz relay re-announcing
|
||||
* my membership (kind-44100) can't bounce it back in as a pending invite. This is the soft counterpart
|
||||
* to [leaveRelayGroup]; "Remove from Messages" vs "Leave" is the same split the invite card offers.
|
||||
*/
|
||||
fun acceptChannelInvite(channel: RelayGroupChannel) =
|
||||
fun removeRelayGroupFromMessages(channel: RelayGroupChannel) =
|
||||
launchSigner {
|
||||
account.settings.dismissChannelInvite(channel.groupId.id)
|
||||
account.unfollow(channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a relay group (back) on Messages: write it into my kind-10009 so it shows and follows me to
|
||||
* other devices, and clear the dismissal [removeRelayGroupFromMessages] left behind so a Buzz relay
|
||||
* re-announcing my membership isn't filtered out. No kind-9021 join — the relay roster is untouched
|
||||
* by both halves of this toggle; this only records *my* decision to surface the channel.
|
||||
*/
|
||||
fun addRelayGroupToMessages(channel: RelayGroupChannel) =
|
||||
launchSigner {
|
||||
account.settings.undismissChannelInvite(channel.groupId.id)
|
||||
account.follow(channel)
|
||||
BuzzChannelInvites.remove(account.userProfile().pubkeyHex, channel.groupId.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a channel somebody added me to. Identical to [addRelayGroupToMessages] — accepting an
|
||||
* invite *is* surfacing the channel, since the relay already put me in the roster (which is why
|
||||
* the channel opens and accepts posts today).
|
||||
*/
|
||||
fun acceptChannelInvite(channel: RelayGroupChannel) = addRelayGroupToMessages(channel)
|
||||
|
||||
/**
|
||||
* Keep the channel off Messages without touching membership. Local and reversible — I stay in the
|
||||
* roster and can still open and post; [leaveChannelInvite] is the one that actually removes me.
|
||||
|
||||
+2
-2
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.award
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -51,7 +52,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.Nip05OrPubkeyLine
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
@@ -88,7 +88,7 @@ fun AwardBadgeScreen(
|
||||
onDispose { userSuggestions.reset() }
|
||||
}
|
||||
|
||||
KeyboardAwareBackHandler {
|
||||
BackHandler {
|
||||
nav.popBack()
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -619,6 +619,10 @@ private fun List<Note>.toDiscoverApps(
|
||||
matchAuthor(author)
|
||||
}.mapNotNull { it.toDiscoverNostrApp() }
|
||||
.filter { it.app.coordinate !in excludeCoordinates }
|
||||
// The observed store can surface the same addressable manifest twice (a replaceable note whose
|
||||
// new version mutates its sort key inside the backing set), so collapse by coordinate to keep the
|
||||
// grid keys unique — otherwise LazyVerticalGrid throws on the duplicate "ns:"/"np:" key.
|
||||
.distinctBy { it.app.coordinate }
|
||||
.take(DISCOVER_NOSTR_LIMIT)
|
||||
.toList()
|
||||
|
||||
|
||||
-2
@@ -46,7 +46,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -134,7 +133,6 @@ fun BuzzCanvasScreen(
|
||||
Column {
|
||||
Text(
|
||||
text = stringRes(R.string.buzz_canvas_title),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
+78
-10
@@ -56,7 +56,9 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -82,6 +84,12 @@ import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Bottom room the inbox leaves for its extended FAB — same clearance the community view uses, and
|
||||
* wide enough for the taller extended variant.
|
||||
*/
|
||||
private val FAB_CLEARANCE = 96.dp
|
||||
|
||||
/**
|
||||
* The **Buzz Direct Messages** inbox. A Buzz DM is a relay-authoritative NIP-29 group
|
||||
* whose id is a UUID, so tapping a row opens the very same [Route.RelayGroup] chat screen
|
||||
@@ -99,6 +107,11 @@ fun BuzzDmListScreen(
|
||||
viewModel.bind(accountViewModel.account, relayUrl)
|
||||
|
||||
val rows by viewModel.rows.collectAsStateWithLifecycle()
|
||||
val hiddenRows by viewModel.hiddenRows.collectAsStateWithLifecycle()
|
||||
|
||||
// Hidden conversations stay collapsed behind a header — they are off Messages by the user's own
|
||||
// choice, so they must not compete with the live inbox; they only need to be *reachable* again.
|
||||
var showHidden by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton(stringRes(R.string.buzz_dm_title), nav) },
|
||||
@@ -110,26 +123,79 @@ fun BuzzDmListScreen(
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
if (rows.isEmpty()) {
|
||||
if (rows.isEmpty() && hiddenRows.isEmpty()) {
|
||||
EmptyDmInbox(modifier = Modifier.padding(padding))
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(padding).fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
// Extra room at the bottom so the last row's overflow clears the FAB, which the
|
||||
// Scaffold's padding deliberately doesn't account for (a FAB overlays content).
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = FAB_CLEARANCE),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
items(rows, key = { it.channelId }) { row ->
|
||||
DmRowCard(row, accountViewModel, nav)
|
||||
DmRowCard(row, isHidden = false, viewModel = viewModel, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
if (hiddenRows.isNotEmpty()) {
|
||||
item(key = "hidden-header") {
|
||||
HiddenDmHeader(
|
||||
count = hiddenRows.size,
|
||||
expanded = showHidden,
|
||||
onToggle = { showHidden = !showHidden },
|
||||
)
|
||||
}
|
||||
if (showHidden) {
|
||||
items(hiddenRows, key = { "hidden-${it.channelId}" }) { row ->
|
||||
DmRowCard(row, isHidden = true, viewModel = viewModel, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The collapsible "Hidden (N)" divider between the live inbox and the conversations I took off it.
|
||||
* Shared with the community view's inline Direct Messages section, so a hidden DM is reachable from
|
||||
* wherever the user's DMs are — this inbox screen is only reachable behind a "see all" row.
|
||||
*/
|
||||
@Composable
|
||||
fun HiddenDmHeader(
|
||||
count: Int,
|
||||
expanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onToggle)
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = if (expanded) MaterialSymbols.ExpandMore else MaterialSymbols.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.buzz_dm_hidden_count, count, count),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One conversation: a participant avatar (stacked for group DMs), names, host, last-seen, kebab. */
|
||||
@Composable
|
||||
private fun DmRowCard(
|
||||
row: BuzzDmListViewModel.DmRow,
|
||||
isHidden: Boolean,
|
||||
viewModel: BuzzDmListViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
@@ -156,7 +222,9 @@ private fun DmRowCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
// A hidden DM is still openable (it's a live conversation I merely took off the list), so
|
||||
// it renders faded rather than disabled — visibly parked, not broken.
|
||||
modifier = Modifier.padding(12.dp).alpha(if (isHidden) 0.55f else 1f),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
DmAvatars(row.others, accountViewModel, nav)
|
||||
@@ -204,21 +272,21 @@ private fun DmRowCard(
|
||||
addMemberOpen = true
|
||||
},
|
||||
)
|
||||
// A toggle, like the channel rows: hiding a DM is a per-viewer, reversible
|
||||
// relay-side flag (kind-41012 / the 30622 snapshot), never a departure — so a
|
||||
// hidden conversation must offer its own way back rather than vanishing for good.
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.buzz_dm_hide)) },
|
||||
text = { Text(stringRes(if (isHidden) R.string.add_to_messages else R.string.remove_from_messages)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.VisibilityOff,
|
||||
symbol = if (isHidden) MaterialSymbols.Add else MaterialSymbols.VisibilityOff,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
scope.launch {
|
||||
val channel = LocalCache.getOrCreateRelayGroupChannel(groupId)
|
||||
accountViewModel.account.hideBuzzDm(channel)
|
||||
}
|
||||
if (isHidden) viewModel.addToMessages(row) else viewModel.removeFromMessages(row)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+47
-5
@@ -71,8 +71,11 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
* `t` = `dm` — that same 39000 also carries the roster the shared chat composer's member gate
|
||||
* needs, and the DM participants;
|
||||
* - subscribes the per-viewer [DmVisibilityEvent] (`kind:30622`) so a hidden DM (tracked in
|
||||
* [BuzzDmRegistry]) drops out;
|
||||
* - projects the visible DMs into [rows], sorted by last message time.
|
||||
* [BuzzDmRegistry]) moves from [rows] to [hiddenRows];
|
||||
* - projects the visible DMs into [rows] and the hidden ones into [hiddenRows], both sorted by
|
||||
* last message time. Hidden DMs stay projected (rather than being dropped on the floor) so the
|
||||
* inbox can offer them back — hiding is reversible, and a conversation with no way back is a
|
||||
* conversation the user has lost.
|
||||
*/
|
||||
class BuzzDmListViewModel : ViewModel() {
|
||||
@Volatile private var account: Account? = null
|
||||
@@ -88,6 +91,10 @@ class BuzzDmListViewModel : ViewModel() {
|
||||
private val _rows = MutableStateFlow<List<DmRow>>(emptyList())
|
||||
val rows: StateFlow<List<DmRow>> = _rows.asStateFlow()
|
||||
|
||||
/** The DMs I hid (per the relay's 30622 snapshot), newest-first — offered back under "Hidden". */
|
||||
private val _hiddenRows = MutableStateFlow<List<DmRow>>(emptyList())
|
||||
val hiddenRows: StateFlow<List<DmRow>> = _hiddenRows.asStateFlow()
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
@@ -211,14 +218,17 @@ class BuzzDmListViewModel : ViewModel() {
|
||||
account.client.fetchAllWithHooks(filters = byRelay, timeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false }
|
||||
}
|
||||
|
||||
/** Project the discovered DM channels (metadata `t` = `dm`), minus my hidden set, newest-first. */
|
||||
/**
|
||||
* Project the discovered DM channels (metadata `t` = `dm`) newest-first, split by my hidden set
|
||||
* (the relay's 30622 snapshot) into [rows] and [hiddenRows]. Both halves come from one pass so a
|
||||
* DM can only ever be in one of them.
|
||||
*/
|
||||
private fun rebuildRows(account: Account) {
|
||||
val myPubkey = account.userProfile().pubkeyHex
|
||||
val hidden = BuzzDmRegistry.hiddenFor(myPubkey)
|
||||
_rows.value =
|
||||
val (hiddenDms, visibleDms) =
|
||||
memberChannels.entries
|
||||
.mapNotNull { (channelId, relay) ->
|
||||
if (channelId in hidden) return@mapNotNull null
|
||||
val channel = LocalCache.getOrCreateRelayGroupChannel(GroupId(channelId, relay))
|
||||
val metadata = channel.event ?: return@mapNotNull null
|
||||
if (!metadata.isBuzzDm()) return@mapNotNull null
|
||||
@@ -231,6 +241,38 @@ class BuzzDmListViewModel : ViewModel() {
|
||||
lastActivity = lastActivityFor(channelId),
|
||||
)
|
||||
}.sortedByDescending { it.lastActivity }
|
||||
.partition { it.channelId in hidden }
|
||||
_rows.value = visibleDms
|
||||
_hiddenRows.value = hiddenDms
|
||||
}
|
||||
|
||||
/**
|
||||
* Take [row] off Messages with a kind-41012 hide command. Server-side and per-viewer: the relay
|
||||
* republishes my 30622 snapshot with this channel in it, which moves the row to [hiddenRows].
|
||||
* Membership is untouched — nobody else's inbox changes, and [addToMessages] brings it back.
|
||||
*/
|
||||
fun removeFromMessages(row: DmRow) {
|
||||
val account = account ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
account.hideBuzzDm(LocalCache.getOrCreateRelayGroupChannel(GroupId(row.channelId, row.relayUrl)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a hidden DM back on Messages. Buzz has no "unhide" command — re-opening the conversation is
|
||||
* the un-hide: a kind-41010 with the same participants resolves to the same canonical channel and
|
||||
* drops it from the 30622 hidden snapshot. A self-DM has no `others`, so send myself, which is
|
||||
* what the relay derived that channel from (and satisfies kind-41010's 1-8 participant rule).
|
||||
*/
|
||||
fun addToMessages(row: DmRow) {
|
||||
val account = account ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val me = account.userProfile().pubkeyHex
|
||||
account.openBuzzDm(row.relayUrl, row.others.ifEmpty { listOf(me) })
|
||||
// The relay's new 30622 normally arrives on the live subscription; refresh anyway so the
|
||||
// row returns even if this screen's socket missed the snapshot.
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
/** Newest message `created_at` for [channelId] from [LocalCache], or 0 when the DM is empty. */
|
||||
|
||||
+21
-9
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -31,7 +32,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -84,7 +84,8 @@ private const val CARD_WARMUP_LIMIT = 10
|
||||
* recent-posters facepile, a preview of the last message (author + snippet, or the Buzz activity
|
||||
* summary for system/diff/job rows), the relative time of that message, and an unread-count badge.
|
||||
* Tapping the card opens the channel ([onOpen]); the trailing overflow (3-dot) menu holds the
|
||||
* per-channel actions — Pin/Unpin and Add-to-my-list — so the row stays clean.
|
||||
* per-channel actions — Pin/Unpin and the Add/Remove-from-Messages toggle ([isAdded] says which half
|
||||
* to show, and it must come from the live kind-10009 list) — so the row stays clean.
|
||||
*
|
||||
* Reused by the relay group-list screen where Buzz membership discovery is folded in.
|
||||
*
|
||||
@@ -99,6 +100,7 @@ fun BuzzImportRow(
|
||||
groupId: GroupId,
|
||||
isAdded: Boolean,
|
||||
onAdd: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
onOpen: (() -> Unit)? = null,
|
||||
isStarred: Boolean = false,
|
||||
@@ -168,13 +170,18 @@ fun BuzzImportRow(
|
||||
isStarred = isStarred,
|
||||
onToggleStar = onToggleStar,
|
||||
onAdd = onAdd,
|
||||
onRemove = onRemove,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
// A plain row on the screen background, not a filled Card. Each row used to be its own Card, and
|
||||
// because they stack with no gaps their container colour merged into one grey slab behind the
|
||||
// whole Channels section — reading as a box around the channels that the Direct Messages rows
|
||||
// right below (plain rows) didn't have. Matches [BuzzDmInlineRow] and the Concord server list.
|
||||
if (onOpen != null) {
|
||||
Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) { content() }
|
||||
Box(Modifier.fillMaxWidth().clickable(onClick = onOpen)) { content() }
|
||||
} else {
|
||||
Card(modifier = Modifier.fillMaxWidth()) { content() }
|
||||
Box(Modifier.fillMaxWidth()) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +199,7 @@ private fun BuzzImportRowContent(
|
||||
isStarred: Boolean,
|
||||
onToggleStar: (() -> Unit)?,
|
||||
onAdd: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
Row(
|
||||
@@ -249,6 +257,7 @@ private fun BuzzImportRowContent(
|
||||
BuzzChannelRowMenu(
|
||||
isAdded = isAdded,
|
||||
onAdd = onAdd,
|
||||
onRemove = onRemove,
|
||||
isStarred = isStarred,
|
||||
onToggleStar = onToggleStar,
|
||||
)
|
||||
@@ -307,6 +316,7 @@ private fun BuzzChannelPreviewLine(
|
||||
private fun BuzzChannelRowMenu(
|
||||
isAdded: Boolean,
|
||||
onAdd: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
isStarred: Boolean,
|
||||
onToggleStar: (() -> Unit)?,
|
||||
) {
|
||||
@@ -338,20 +348,22 @@ private fun BuzzChannelRowMenu(
|
||||
},
|
||||
)
|
||||
}
|
||||
// A toggle, not a one-way "Added" badge: a channel already on the kind-10009 list offers
|
||||
// the way back off it. Neither half touches the relay roster, so the channel stays in this
|
||||
// list (and readable) either way — only whether it shows on Messages changes.
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
symbol = if (isAdded) MaterialSymbols.Check else MaterialSymbols.Add,
|
||||
symbol = if (isAdded) MaterialSymbols.VisibilityOff else MaterialSymbols.Add,
|
||||
contentDescription = null,
|
||||
tint = if (isAdded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
},
|
||||
text = { Text(stringRes(if (isAdded) R.string.buzz_import_added else R.string.buzz_import_add)) },
|
||||
enabled = !isAdded,
|
||||
text = { Text(stringRes(if (isAdded) R.string.remove_from_messages else R.string.add_to_messages)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onAdd()
|
||||
if (isAdded) onRemove() else onAdd()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -24,9 +24,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.MenuAnchorType
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -77,7 +77,7 @@ fun EditableSuggestDropdown(
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showMenu) },
|
||||
keyboardOptions = keyboardOptions,
|
||||
supportingText = supportingText?.let { { Text(it) } },
|
||||
modifier = modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryEditable),
|
||||
modifier = modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable),
|
||||
)
|
||||
ExposedDropdownMenu(expanded = showMenu, onDismissRequest = { expanded = false }) {
|
||||
filtered.forEach { opt ->
|
||||
|
||||
+34
-10
@@ -39,7 +39,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Collections
|
||||
|
||||
@@ -79,7 +78,12 @@ class BuzzRelayImportViewModel : ViewModel() {
|
||||
private val _channels = MutableStateFlow<List<GroupId>>(emptyList())
|
||||
val channels: StateFlow<List<GroupId>> = _channels.asStateFlow()
|
||||
|
||||
/** Channel ids already present in the user's kind-10009 list (seeded + updated as they add). */
|
||||
/**
|
||||
* Channel ids on this relay that are currently in the user's kind-10009 list. Mirrors the live
|
||||
* list rather than snapshotting it: it used to be seeded once at [bind] and only ever grow, so a
|
||||
* channel taken off Messages anywhere else still rendered as "Added" — with the Add action
|
||||
* disabled, leaving no way back.
|
||||
*/
|
||||
private val _added = MutableStateFlow<Set<String>>(emptySet())
|
||||
val added: StateFlow<Set<String>> = _added.asStateFlow()
|
||||
|
||||
@@ -110,12 +114,13 @@ class BuzzRelayImportViewModel : ViewModel() {
|
||||
// every other `#p=me`-gated read on the shared socket.
|
||||
if (newlyJoined) account.client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true)
|
||||
|
||||
// Seed "already added" from the current kind-10009 list so channels the user already has
|
||||
// render as added rather than offering a duplicate Add.
|
||||
_added.value =
|
||||
account.relayGroupList.liveRelayGroupList.value
|
||||
.filter { RelayUrlNormalizer.normalizeOrNull(it.relayUrl) == normalized }
|
||||
.mapTo(mutableSetOf()) { it.groupId }
|
||||
// Track "already added" against the live kind-10009 list, scoped to this relay, so the rows
|
||||
// follow every add/remove — from here, from the channel's top bar, or from another device.
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
account.relayGroupList.liveRelayGroupIds.collect { groups ->
|
||||
_added.value = groups.filter { it.relayUrl == normalized }.mapTo(mutableSetOf()) { it.id }
|
||||
}
|
||||
}
|
||||
|
||||
discover(account, normalized)
|
||||
}
|
||||
@@ -176,13 +181,32 @@ class BuzzRelayImportViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Append [groupId] to the user's kind-10009 list (public group tag), so it shows in Messages. */
|
||||
/**
|
||||
* Append [groupId] to the user's kind-10009 list (public group tag), so it shows in Messages, and
|
||||
* clear any earlier dismissal so the relay's kind-44100 re-announcement isn't filtered back out.
|
||||
* [_added] is not touched here — the live-list collector in [bind] reflects the new event.
|
||||
*/
|
||||
fun add(groupId: GroupId) {
|
||||
val account = account ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val channel = LocalCache.getOrCreateRelayGroupChannel(groupId)
|
||||
account.settings.undismissChannelInvite(groupId.id)
|
||||
account.follow(channel)
|
||||
_added.update { it + groupId.id }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Take [groupId] off the kind-10009 list without leaving the channel: no kind-9022, so the relay
|
||||
* roster (and therefore this very list of channels) is untouched and it can be added back. The
|
||||
* dismissal keeps a Buzz relay's kind-44100 re-announcement from bouncing it back as an invite —
|
||||
* mirrors `AccountViewModel.removeRelayGroupFromMessages`.
|
||||
*/
|
||||
fun remove(groupId: GroupId) {
|
||||
val account = account ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val channel = LocalCache.getOrCreateRelayGroupChannel(groupId)
|
||||
account.settings.dismissChannelInvite(groupId.id)
|
||||
account.unfollow(channel)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -55,13 +55,13 @@ import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MenuAnchorType
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
@@ -823,7 +823,7 @@ private fun WorkflowPicker(
|
||||
label = { Text(stringRes(R.string.buzz_workflow_picker_label)) },
|
||||
placeholder = { Text(if (definitions.isEmpty()) stringRes(R.string.buzz_workflow_picker_empty) else stringRes(R.string.buzz_workflow_picker_choose)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable),
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||
)
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
definitions.forEach { def ->
|
||||
|
||||
-1
@@ -138,7 +138,6 @@ fun CalendarEventDetailScreen(
|
||||
title = {
|
||||
Text(
|
||||
text = stringRes(R.string.route_calendar_event_detail),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
|
||||
+2
-3
@@ -54,7 +54,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -427,10 +426,10 @@ private fun GeohashChatTopBar(
|
||||
LoadCityName(
|
||||
geohashStr = geohash,
|
||||
onLoading = {
|
||||
Text("#$geohash", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("#$geohash")
|
||||
},
|
||||
) { cityName ->
|
||||
Text(cityName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text(cityName)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
||||
+1
-2
@@ -26,7 +26,6 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
@@ -52,7 +51,7 @@ fun GeohashTeleportScreen(
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBarExtensibleWithBackButton(
|
||||
title = { Text(stringRes(R.string.geohash_teleport_title), fontWeight = FontWeight.Bold) },
|
||||
title = { Text(stringRes(R.string.geohash_teleport_title)) },
|
||||
popBack = nav::popBack,
|
||||
)
|
||||
},
|
||||
|
||||
+2
-1
@@ -97,7 +97,7 @@ fun NewGeohashChatScreen(
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBarExtensibleWithBackButton(
|
||||
title = { Text("New location channel", fontWeight = FontWeight.Bold) },
|
||||
title = { Text("New location channel") },
|
||||
popBack = nav::popBack,
|
||||
)
|
||||
},
|
||||
@@ -377,6 +377,7 @@ private fun TeleportCard(onClick: () -> Unit) {
|
||||
|
||||
internal fun GeohashChannelLevel.label(): String =
|
||||
when (this) {
|
||||
GeohashChannelLevel.CONTINENT -> "Continent"
|
||||
GeohashChannelLevel.REGION -> "Region"
|
||||
GeohashChannelLevel.PROVINCE -> "Province"
|
||||
GeohashChannelLevel.CITY -> "City"
|
||||
|
||||
+2
@@ -36,6 +36,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
@@ -103,6 +104,7 @@ fun MarmotGroupChatScreen(
|
||||
DisplayUserSetAsSubject(
|
||||
userList = memberPubkeys,
|
||||
accountViewModel = accountViewModel,
|
||||
fontWeight = FontWeight.Normal,
|
||||
)
|
||||
} else {
|
||||
Text(stringRes(R.string.marmot_group_default_name))
|
||||
|
||||
+18
-2
@@ -25,6 +25,7 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -321,7 +322,15 @@ fun ConcordChannelListScreen(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
|
||||
// Bottom room for the FAB, which the Scaffold's `padding` deliberately doesn't account for
|
||||
// (a FAB overlays content) — without it the last row's manager overflow menu sits under it
|
||||
// and can't be tapped. As contentPadding so rows scroll *through* the strip rather than the
|
||||
// viewport shrinking. Gated on [canManageChannels] because that is what renders the FAB:
|
||||
// a plain member has nothing to clear, and no reason to lose the space.
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize().padding(padding),
|
||||
contentPadding = PaddingValues(bottom = if (canManageChannels) FAB_CLEARANCE else 0.dp),
|
||||
) {
|
||||
items(channels, key = { it.key }) { entry ->
|
||||
val def = entry.value.definition
|
||||
val name = def.name.ifBlank { entry.key }
|
||||
@@ -452,6 +461,13 @@ private fun ConcordChannelListRow(
|
||||
/** How many recent-poster avatars a channel row's facepile shows at most. */
|
||||
private const val FACEPILE_MAX = 4
|
||||
|
||||
/**
|
||||
* Bottom room the list leaves for the floating action button: a 56dp FAB + the Scaffold's 16dp margin
|
||||
* + slack, so the last row's overflow menu stays tappable instead of sitting under the FAB. Matches
|
||||
* the value `JobBoardScreen` and the relay-group list screens use.
|
||||
*/
|
||||
private val FAB_CLEARANCE = 96.dp
|
||||
|
||||
/**
|
||||
* The line under a channel name. When someone is composing it shows a live italic "X is typing…";
|
||||
* otherwise the last message's author + a snippet ("author: hello"), or a muted "No messages yet"
|
||||
@@ -529,7 +545,7 @@ private fun rememberConcordDisplayName(
|
||||
* so leaving is what actually retires the community for them.
|
||||
*/
|
||||
@Composable
|
||||
private fun ConcordLeaveDialog(
|
||||
internal fun ConcordLeaveDialog(
|
||||
communityName: String,
|
||||
isOwner: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
|
||||
+18
@@ -251,11 +251,29 @@ fun ConcordChannelScreen(
|
||||
nav = nav,
|
||||
onMessageSent = { feedViewModel.feedState.sendToTop() },
|
||||
)
|
||||
} else if (channel.dissolved) {
|
||||
// CORD-02 §9: an owner-signed tombstone seals the community read-only — the composer is
|
||||
// gone (canPost() is false) and this replaces it so the seal is explained, not silent.
|
||||
ConcordDissolvedNotice()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The read-only notice shown where the composer would be once a community is dissolved (CORD-02 §9).
|
||||
* The tombstone seals the community: history stays readable, but no member may post again.
|
||||
*/
|
||||
@Composable
|
||||
private fun ConcordDissolvedNotice() {
|
||||
Text(
|
||||
text = stringRes(R.string.concord_dissolved_read_only),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** The number of messages a freshly-opened channel eagerly backfills to before paging goes demand-driven. */
|
||||
private const val CONCORD_HISTORY_TARGET = 50
|
||||
|
||||
|
||||
+15
-39
@@ -20,65 +20,41 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.vitorpamplona.amethyst.ui.note.DisplayBlankAuthor
|
||||
import com.vitorpamplona.amethyst.ui.note.ObserveAndDrawInnerUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
/**
|
||||
* A horizontal stack of overlapping avatars for the recent posters in a channel — the "who's here"
|
||||
* cue that makes a busy channel feel alive. Each avatar wears a thin ring in the row's background
|
||||
* colour so the overlap reads as a deck of cards; the newest poster sits on top (leftmost, highest
|
||||
* z-index). Renders nothing for an empty [authorHexes], so callers can drop it in unconditionally.
|
||||
* A horizontal strip of the recent posters in a channel — the "who's here" cue that makes a busy
|
||||
* channel feel alive. Each poster is drawn with the app's standard profile avatar
|
||||
* ([ClickableUserPicture]), so it carries the same following badge (top-right) and trust-score tag
|
||||
* (bottom-centre) shown everywhere else a user appears, instead of a bare cropped image. Laid out
|
||||
* with a small gap rather than an overlapping stack so those badges stay readable; the newest poster
|
||||
* is leftmost. Renders nothing for an empty [authorHexes], so callers can drop it in unconditionally.
|
||||
*/
|
||||
@Composable
|
||||
fun ConcordAuthorFacepile(
|
||||
authorHexes: List<HexKey>,
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
avatarSize: Dp = 22.dp,
|
||||
avatarSize: Dp = 24.dp,
|
||||
maxShown: Int = 4,
|
||||
) {
|
||||
if (authorHexes.isEmpty()) return
|
||||
val shown = authorHexes.take(maxShown)
|
||||
// A ring one-and-a-half dp wide, and each avatar pulled left over its neighbour by a third of
|
||||
// its width — enough overlap to read as a stack without hiding faces.
|
||||
val ring = 1.5.dp
|
||||
val overlap = avatarSize * 0.36f
|
||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(-overlap)) {
|
||||
shown.forEachIndexed { index, hex ->
|
||||
Box(
|
||||
Modifier
|
||||
// Newest (index 0) on top so it isn't clipped by the one after it.
|
||||
.zIndex((shown.size - index).toFloat())
|
||||
.size(avatarSize)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(ring),
|
||||
) {
|
||||
LoadUser(baseUserHex = hex, accountViewModel) { user ->
|
||||
if (user != null) {
|
||||
ObserveAndDrawInnerUserPicture(user, avatarSize - ring * 2, accountViewModel)
|
||||
} else {
|
||||
DisplayBlankAuthor(avatarSize - ring * 2, Modifier, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
shown.forEach { hex ->
|
||||
ClickableUserPicture(
|
||||
baseUserHex = hex,
|
||||
size = avatarSize,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -310,7 +310,11 @@ private fun CommunityHeader(
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
autoPlayGif = autoPlayGif,
|
||||
)
|
||||
Column(Modifier.weight(1f)) {
|
||||
// The identity block — icon *and* name/subtitle — opens the community; the rest of the row and
|
||||
// the chevron expand it. Tapping a title to open the thing it names is the convention users
|
||||
// arrive with, and the name previously fell through to the row's expand, leaving the 40dp
|
||||
// avatar as the only way in with nothing to suggest it was a separate target.
|
||||
Column(Modifier.weight(1f).clickable(onClick = onOpen)) {
|
||||
Text(
|
||||
name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
|
||||
-1
@@ -109,7 +109,6 @@ fun RelayGroupBrowseScreen(
|
||||
title = {
|
||||
Text(
|
||||
text = stringRes(R.string.relay_group_browse_title),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
+118
-15
@@ -24,6 +24,7 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -31,11 +32,12 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -53,6 +55,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
@@ -90,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzDmListViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzImportRow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzRelayImportViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzWorkspaceOverflowMenu
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.HiddenDmHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.PresenceDot
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.buzzTimelinePreviewSummary
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription
|
||||
@@ -98,7 +102,6 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.warningColor
|
||||
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_DM
|
||||
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_FORUM
|
||||
import com.vitorpamplona.quartz.buzz.workspace.buzzChannelType
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
@@ -114,6 +117,13 @@ private const val CHANNEL_LIST_WARMUP_LIMIT = 10
|
||||
/** Grace period before offering the Tor→clearnet escape hatch, so a slow-but-working relay isn't nagged. */
|
||||
private const val TOR_CLEARNET_HINT_DELAY_MS = 6_000L
|
||||
|
||||
/**
|
||||
* Bottom room the list leaves for the floating action button: a 56dp FAB + the Scaffold's 16dp margin
|
||||
* + slack, so the last row's overflow menu stays tappable instead of sitting under the FAB. Matches
|
||||
* the value `JobBoardScreen` already uses.
|
||||
*/
|
||||
private val FAB_CLEARANCE = 96.dp
|
||||
|
||||
/**
|
||||
* Lists every channel a relay hosts (its kind 39000-39003 directory), so the user
|
||||
* can browse and open channels on that relay. The relay's directory is streamed by
|
||||
@@ -201,6 +211,12 @@ fun RelayGroupChannelListScreen(
|
||||
val dmVm: BuzzDmListViewModel = viewModel(key = "BuzzDmInline-${relay.url}")
|
||||
LaunchedEffect(relay, isBuzz) { if (isBuzz) dmVm.bind(accountViewModel.account, relay.url) }
|
||||
val dmRows by dmVm.rows.collectAsStateWithLifecycle()
|
||||
val hiddenDmRows by dmVm.hiddenRows.collectAsStateWithLifecycle()
|
||||
// DMs I took off Messages, parked behind a collapsed "Hidden (N)" tail below the section. They
|
||||
// live here and not only on the full inbox screen because that screen sits behind a "see all"
|
||||
// row that never appears until a community has more DMs than fit inline — so without this, a
|
||||
// hidden DM in a small workspace would have no way back at all.
|
||||
var showHiddenDms by remember { mutableStateOf(false) }
|
||||
|
||||
// A Buzz workspace's channels come in three flavours, distinguished by the relay-signed 39000
|
||||
// `channel_type`: chat "stream" channels, "forum" channels (threaded posts), and "dm" channels
|
||||
@@ -281,7 +297,6 @@ fun RelayGroupChannelListScreen(
|
||||
title = {
|
||||
Text(
|
||||
text = relay.displayUrl(),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
)
|
||||
@@ -347,7 +362,14 @@ fun RelayGroupChannelListScreen(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.padding(padding)) {
|
||||
// The Scaffold's `padding` carries the top/bottom bars but deliberately not the FAB — a FAB
|
||||
// overlays content by design, so clearing it is the list's job. As contentPadding (not a
|
||||
// modifier) so rows scroll *through* that strip and only come to rest clear of it; the
|
||||
// modifier form would shrink the viewport and leave the FAB floating over dead space.
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(padding),
|
||||
contentPadding = PaddingValues(bottom = FAB_CLEARANCE),
|
||||
) {
|
||||
if (showTorHint) {
|
||||
item(key = "tor-hint") {
|
||||
TorClearnetBanner(
|
||||
@@ -388,11 +410,13 @@ fun RelayGroupChannelListScreen(
|
||||
)
|
||||
}
|
||||
if (!channelsCollapsed) {
|
||||
items(buzzChatChannels, key = { "chat-${it.id}" }) { groupId ->
|
||||
itemsIndexed(buzzChatChannels, key = { _, it -> "chat-${it.id}" }) { index, groupId ->
|
||||
RowHairline(index)
|
||||
BuzzImportRow(
|
||||
groupId = groupId,
|
||||
isAdded = groupId.id in buzzAdded,
|
||||
onAdd = { buzzVm.add(groupId) },
|
||||
onRemove = { buzzVm.remove(groupId) },
|
||||
accountViewModel = accountViewModel,
|
||||
onOpen = { nav.nav(Route.RelayGroup(groupId.id, relay.url)) },
|
||||
isStarred = groupId.id in starred,
|
||||
@@ -413,11 +437,13 @@ fun RelayGroupChannelListScreen(
|
||||
)
|
||||
}
|
||||
if (!forumsCollapsed) {
|
||||
items(buzzForumChannels, key = { "forum-${it.id}" }) { groupId ->
|
||||
itemsIndexed(buzzForumChannels, key = { _, it -> "forum-${it.id}" }) { index, groupId ->
|
||||
RowHairline(index)
|
||||
BuzzImportRow(
|
||||
groupId = groupId,
|
||||
isAdded = groupId.id in buzzAdded,
|
||||
onAdd = { buzzVm.add(groupId) },
|
||||
onRemove = { buzzVm.remove(groupId) },
|
||||
accountViewModel = accountViewModel,
|
||||
// A forum channel's primary content is its threads (kind-45001 posts), not a
|
||||
// kind-9 chat, so open the forum/threads view directly instead of the chat.
|
||||
@@ -456,8 +482,16 @@ fun RelayGroupChannelListScreen(
|
||||
}
|
||||
} else {
|
||||
val shown = dmRows.take(INLINE_DM_LIMIT)
|
||||
items(shown, key = { "dm-${it.channelId}" }) { row ->
|
||||
BuzzDmInlineRow(row, myPubkey, accountViewModel, nav) {
|
||||
itemsIndexed(shown, key = { _, it -> "dm-${it.channelId}" }) { index, row ->
|
||||
RowHairline(index)
|
||||
BuzzDmInlineRow(
|
||||
row = row,
|
||||
myPubkey = myPubkey,
|
||||
isHidden = false,
|
||||
onToggleMessages = { dmVm.removeFromMessages(row) },
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
) {
|
||||
nav.nav(Route.RelayGroup(row.channelId, row.relayUrl.url))
|
||||
}
|
||||
}
|
||||
@@ -470,13 +504,36 @@ fun RelayGroupChannelListScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hiddenDmRows.isNotEmpty()) {
|
||||
item(key = "dm-hidden-header") {
|
||||
HiddenDmHeader(
|
||||
count = hiddenDmRows.size,
|
||||
expanded = showHiddenDms,
|
||||
onToggle = { showHiddenDms = !showHiddenDms },
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
if (showHiddenDms) {
|
||||
itemsIndexed(hiddenDmRows, key = { _, it -> "dm-hidden-${it.channelId}" }) { index, row ->
|
||||
RowHairline(index)
|
||||
BuzzDmInlineRow(
|
||||
row = row,
|
||||
myPubkey = myPubkey,
|
||||
isHidden = true,
|
||||
onToggleMessages = { dmVm.addToMessages(row) },
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
) {
|
||||
nav.nav(Route.RelayGroup(row.channelId, row.relayUrl.url))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Agent Console now lives in the community's top-bar overflow menu, not a footer card.
|
||||
} else {
|
||||
// Vanilla NIP-29 relay: flat channel directory (no forums/DMs/console).
|
||||
itemsIndexed(channels, key = { _, channel -> channel.groupId.id }) { index, channel ->
|
||||
if (index > 0) {
|
||||
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
RowHairline(index)
|
||||
RelayGroupChannelRow(channel, myPubkey, accountViewModel) { nav.nav(routeFor(channel)) }
|
||||
}
|
||||
}
|
||||
@@ -495,6 +552,17 @@ fun RelayGroupChannelListScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The hairline separating two adjacent rows within a section. Drawn *before* row [index], and never
|
||||
* before the first one, so a section's own header keeps providing the separation at its boundaries.
|
||||
*/
|
||||
@Composable
|
||||
private fun RowHairline(index: Int) {
|
||||
if (index > 0) {
|
||||
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
}
|
||||
|
||||
/** A first screen's worth of a community's DMs shown inline; the rest live behind the See-all row. */
|
||||
private const val INLINE_DM_LIMIT = 6
|
||||
|
||||
@@ -574,18 +642,25 @@ private fun RelayGroupSectionHeader(
|
||||
|
||||
/**
|
||||
* One inline Direct-Message conversation row inside the community view: the counterpart's avatar +
|
||||
* name (or a "+N" cluster label for a group DM), a preview of the last message, and a compact
|
||||
* last-activity time. The channel's recent content is warmed while the row is visible so the preview
|
||||
* fills in ahead of a tap. Tapping opens the DM as its relay-group chat.
|
||||
* name (or a "+N" cluster label for a group DM), a preview of the last message, a compact
|
||||
* last-activity time, and an overflow holding the Add/Remove-from-Messages toggle. The channel's
|
||||
* recent content is warmed while the row is visible so the preview fills in ahead of a tap. Tapping
|
||||
* opens the DM as its relay-group chat.
|
||||
*
|
||||
* [isHidden] renders the row faded and flips the overflow to "Add to Messages" — a hidden DM is a
|
||||
* live conversation the viewer merely parked, so it stays openable and reversible.
|
||||
*/
|
||||
@Composable
|
||||
private fun BuzzDmInlineRow(
|
||||
row: BuzzDmListViewModel.DmRow,
|
||||
myPubkey: HexKey,
|
||||
isHidden: Boolean,
|
||||
onToggleMessages: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
val others = row.others.ifEmpty { listOf(myPubkey) }
|
||||
val leadHex = others.first()
|
||||
val leadUser = remember(leadHex) { LocalCache.getOrCreateUser(leadHex) }
|
||||
@@ -614,7 +689,8 @@ private fun BuzzDmInlineRow(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
.padding(start = 16.dp, end = 4.dp, top = 10.dp, bottom = 10.dp)
|
||||
.alpha(if (isHidden) 0.55f else 1f),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
@@ -642,6 +718,33 @@ private fun BuzzDmInlineRow(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Box {
|
||||
IconButton(onClick = { menuOpen = true }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.MoreVert,
|
||||
contentDescription = stringRes(R.string.more_options),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
symbol = if (isHidden) MaterialSymbols.Add else MaterialSymbols.VisibilityOff,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
},
|
||||
text = { Text(stringRes(if (isHidden) R.string.add_to_messages else R.string.remove_from_messages)) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
onToggleMessages()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -178,7 +178,6 @@ private fun RelayGroupMembers(
|
||||
Column {
|
||||
Text(
|
||||
text = stringRes(R.string.relay_group_members_title),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
-2
@@ -71,7 +71,6 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent
|
||||
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_FORUM
|
||||
import com.vitorpamplona.quartz.buzz.workspace.buzzChannelType
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
@@ -155,7 +154,6 @@ private fun RelayGroupThreads(
|
||||
Column {
|
||||
Text(
|
||||
text = stringRes(R.string.relay_group_threads_title),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
+81
-5
@@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
@@ -35,6 +36,7 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
@@ -45,7 +47,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -113,8 +114,16 @@ fun RelayGroupTopBar(
|
||||
val dmOther = if (isDm) channel.event?.buzzParticipants()?.firstOrNull { it != myPubkey } else null
|
||||
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
// My kind-10009 list, live: drives the Add/Remove-from-Messages toggle in the overflow below.
|
||||
val joinedGroupIds by accountViewModel.account.relayGroupList.liveRelayGroupIds
|
||||
.collectAsStateWithLifecycle()
|
||||
// Read once here (nav.canPop() is @Composable) so the post-action navigation can pop from a menu
|
||||
// callback — leaving a group shouldn't strand the user on the screen of a group they left.
|
||||
val canPop = nav.canPop()
|
||||
var showInvite by remember { mutableStateOf(false) }
|
||||
var showJoinCode by remember { mutableStateOf(false) }
|
||||
var confirmDelete by remember { mutableStateOf(false) }
|
||||
val isBuzzRelay = remember(channel.groupId.relayUrl) { BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) }
|
||||
|
||||
TopBarExtensibleWithBackButton(
|
||||
title = {
|
||||
@@ -128,7 +137,6 @@ fun RelayGroupTopBar(
|
||||
} else {
|
||||
Text(
|
||||
text = channel.toBestDisplayName(),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
@@ -183,7 +191,6 @@ fun RelayGroupTopBar(
|
||||
// outright. So a DM shows the icon only when a canvas already exists — which is also what
|
||||
// keeps us from advertising "start a shared doc" in a two-person conversation.
|
||||
val hasCanvas by observeBuzzCanvas(channel.groupId.id)
|
||||
val isBuzzRelay = remember(channel.groupId.relayUrl) { BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) }
|
||||
if (isBuzzRelay && (!isDm || hasCanvas)) {
|
||||
IconButton(onClick = { nav.nav(Route.BuzzCanvas(channel.groupId.id, channel.groupId.relayUrl.url)) }) {
|
||||
Icon(
|
||||
@@ -298,13 +305,53 @@ fun RelayGroupTopBar(
|
||||
},
|
||||
)
|
||||
}
|
||||
// Two distinct actions, never conflated: the Messages toggle adds/drops the group
|
||||
// on my kind-10009 list but keeps my relay membership either way; "Leave" sends
|
||||
// the kind-9022 that actually removes me. Same split as the channel-invite card.
|
||||
//
|
||||
// Reads the live kind-10009 list rather than assuming the group is on it: this
|
||||
// bar also opens for channels reached from the workspace browse (a Buzz relay
|
||||
// lists every channel you're a member of, joined or not) and for ones you removed
|
||||
// earlier — both need the "Add" half. And because it's a reversible toggle, remove
|
||||
// does NOT pop back: you're still a member reading the channel, and staying is
|
||||
// what makes the entry flip so the action is visibly undoable. Leave still pops.
|
||||
val onMyList = channel.groupId in joinedGroupIds
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.leave)) },
|
||||
text = { Text(stringRes(if (onMyList) R.string.remove_from_messages else R.string.add_to_messages)) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
if (onMyList) {
|
||||
accountViewModel.removeRelayGroupFromMessages(channel)
|
||||
} else {
|
||||
accountViewModel.addRelayGroupToMessages(channel)
|
||||
}
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.leave), color = MaterialTheme.colorScheme.error) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
accountViewModel.leaveRelayGroup(channel)
|
||||
if (canPop) nav.popBack()
|
||||
},
|
||||
)
|
||||
// Deleting the whole channel/group (kind-9008) is destructive for everyone, so it's
|
||||
// shown ONLY to an admin/owner — the same authorization gate as Edit above — and
|
||||
// routed through a confirmation dialog rather than firing on tap.
|
||||
if (displayMembership == RelayGroupMembership.ADMIN) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = stringRes(if (isBuzzRelay) R.string.buzz_channel_delete else R.string.relay_group_delete),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
confirmDelete = true
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -326,6 +373,36 @@ fun RelayGroupTopBar(
|
||||
onDismiss = { showJoinCode = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (confirmDelete) {
|
||||
val deleteLabel = stringRes(if (isBuzzRelay) R.string.buzz_channel_delete else R.string.relay_group_delete)
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmDelete = false },
|
||||
title = { Text(deleteLabel) },
|
||||
text = {
|
||||
Text(
|
||||
stringRes(
|
||||
if (isBuzzRelay) R.string.buzz_channel_delete_confirm else R.string.relay_group_delete_confirm,
|
||||
channel.toBestDisplayName(),
|
||||
),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
confirmDelete = false
|
||||
accountViewModel.deleteRelayGroup(channel)
|
||||
if (canPop) nav.popBack()
|
||||
}) {
|
||||
Text(deleteLabel, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmDelete = false }) {
|
||||
Text(stringRes(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Title for a Buzz DM: the OTHER participant's display name (reactive), falling back to the channel name. */
|
||||
@@ -340,7 +417,6 @@ private fun DmParticipantTitle(
|
||||
val name by observeUserName(user, accountViewModel)
|
||||
Text(
|
||||
text = name.ifBlank { channel.toBestDisplayName() },
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier,
|
||||
|
||||
+130
-62
@@ -93,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberM
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.reportWarningContentDescription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCommunityPill
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordLeaveDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordChannelLastReadRoute
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordCommunityHasUnreadFlow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel
|
||||
@@ -489,36 +490,61 @@ private fun RelayGroupRoomCompose(
|
||||
// A placeholder row (no messages yet) has a null createdAt and never lights the dot.
|
||||
val lastReadTime by accountViewModel.account.loadLastReadFlow(relayGroupChannelLastReadRoute(channel.groupId)).collectAsStateWithLifecycle()
|
||||
|
||||
ChannelName(
|
||||
channelIdHex = channel.groupId.id,
|
||||
channelPicture = channelPicture,
|
||||
channelTitle = { modifier ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
|
||||
Text(
|
||||
text = channel.toBestDisplayName(),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
RelayNameChip(
|
||||
label = channel.groupId.relayUrl.displayUrl(),
|
||||
onClick = { nav.nav(Route.RelayGroupServer(channel.groupId.relayUrl.url)) },
|
||||
)
|
||||
}
|
||||
},
|
||||
channelLastTime = lastMessage.createdAt(),
|
||||
channelLastContent = lastContent,
|
||||
hasNewMessages = (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime,
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
autoPlayGif =
|
||||
accountViewModel.settings.autoPlayVideosFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
.value,
|
||||
onClick = { nav.nav(Route.RelayGroup(channel.groupId.id, channel.groupId.relayUrl.url)) },
|
||||
)
|
||||
// Long-press brings the group's membership actions to the Messages row itself, mirroring the group
|
||||
// top bar so "Remove from Messages" (drop from my list, stay a member) and "Leave" (kind-9022) are
|
||||
// reachable without opening the group first.
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
|
||||
Box {
|
||||
ChannelName(
|
||||
channelIdHex = channel.groupId.id,
|
||||
channelPicture = channelPicture,
|
||||
channelTitle = { modifier ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
|
||||
Text(
|
||||
text = channel.toBestDisplayName(),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
RelayNameChip(
|
||||
label = channel.groupId.relayUrl.displayUrl(),
|
||||
onClick = { nav.nav(Route.RelayGroupServer(channel.groupId.relayUrl.url)) },
|
||||
)
|
||||
}
|
||||
},
|
||||
channelLastTime = lastMessage.createdAt(),
|
||||
channelLastContent = lastContent,
|
||||
hasNewMessages = (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime,
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
autoPlayGif =
|
||||
accountViewModel.settings.autoPlayVideosFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
.value,
|
||||
onClick = { nav.nav(Route.RelayGroup(channel.groupId.id, channel.groupId.relayUrl.url)) },
|
||||
onLongClick = { menuOpen = true },
|
||||
)
|
||||
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.remove_from_messages)) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
accountViewModel.removeRelayGroupFromMessages(channel)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.leave), color = MaterialTheme.colorScheme.error) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
accountViewModel.leaveRelayGroup(channel)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -550,40 +576,78 @@ private fun ConcordRoomCompose(
|
||||
.loadLastReadFlow(concordChannelLastReadRoute(channel.channelId.communityId, channel.channelId.channelId))
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
ChannelName(
|
||||
channelIdHex = channel.channelId.channelId,
|
||||
channelPicture = rememberConcordImageModel(channel.communityIcon, accountViewModel),
|
||||
channelTitle = { modifier ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
|
||||
Text(
|
||||
text = channel.toBestDisplayName(),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
channel.communityName?.let { communityName ->
|
||||
Spacer(Modifier.width(6.dp))
|
||||
// The chip names the parent community and, when tapped, opens that community's
|
||||
// channel list — the "chip that opens the Concord Channel" entry point.
|
||||
ConcordCommunityPill(
|
||||
communityName = communityName,
|
||||
onClick = { nav.nav(Route.ConcordServer(channel.channelId.communityId)) },
|
||||
// Concord has no server-side membership beyond my own kind-13302 list, so there is no soft
|
||||
// "Remove from Messages" distinct from leaving — the only action is "Leave" (drop the community
|
||||
// from my list = I'm out). Long-press surfaces it on the row with the same confirm the community
|
||||
// screen uses; leaving a channel row leaves the whole community it belongs to (the dialog names it).
|
||||
val communityId = channel.channelId.communityId
|
||||
val isOwner =
|
||||
accountViewModel.account.concordSessions
|
||||
.sessionFor(communityId)
|
||||
?.entry
|
||||
?.owner == accountViewModel.account.signer.pubKey
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
var showLeave by remember { mutableStateOf(false) }
|
||||
|
||||
if (showLeave) {
|
||||
ConcordLeaveDialog(
|
||||
communityName = channel.communityName ?: channel.toBestDisplayName(),
|
||||
isOwner = isOwner,
|
||||
onDismiss = { showLeave = false },
|
||||
onConfirm = {
|
||||
showLeave = false
|
||||
accountViewModel.leaveConcordCommunity(communityId)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Box {
|
||||
ChannelName(
|
||||
channelIdHex = channel.channelId.channelId,
|
||||
channelPicture = rememberConcordImageModel(channel.communityIcon, accountViewModel),
|
||||
channelTitle = { modifier ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
|
||||
Text(
|
||||
text = channel.toBestDisplayName(),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
channel.communityName?.let { communityName ->
|
||||
Spacer(Modifier.width(6.dp))
|
||||
// The chip names the parent community and, when tapped, opens that community's
|
||||
// channel list — the "chip that opens the Concord Channel" entry point.
|
||||
ConcordCommunityPill(
|
||||
communityName = communityName,
|
||||
onClick = { nav.nav(Route.ConcordServer(channel.channelId.communityId)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
channelLastTime = lastMessage.createdAt(),
|
||||
channelLastContent = lastContent,
|
||||
hasNewMessages = (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime,
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
autoPlayGif =
|
||||
accountViewModel.settings.autoPlayVideosFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
.value,
|
||||
onClick = { nav.nav(Route.Concord(channel.channelId.communityId, channel.channelId.channelId)) },
|
||||
)
|
||||
},
|
||||
channelLastTime = lastMessage.createdAt(),
|
||||
channelLastContent = lastContent,
|
||||
hasNewMessages = (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime,
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
autoPlayGif =
|
||||
accountViewModel.settings.autoPlayVideosFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
.value,
|
||||
onClick = { nav.nav(Route.Concord(channel.channelId.communityId, channel.channelId.channelId)) },
|
||||
onLongClick = { menuOpen = true },
|
||||
)
|
||||
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.leave), color = MaterialTheme.colorScheme.error) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
showLeave = true
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -935,6 +999,7 @@ fun ChannelName(
|
||||
loadRobohash: Boolean,
|
||||
autoPlayGif: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
) {
|
||||
ChannelName(
|
||||
channelPicture = {
|
||||
@@ -953,6 +1018,7 @@ fun ChannelName(
|
||||
channelLastContent,
|
||||
hasNewMessages,
|
||||
onClick,
|
||||
onLongClick,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -964,6 +1030,7 @@ fun ChannelName(
|
||||
channelLastContent: String?,
|
||||
hasNewMessages: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
) {
|
||||
ChatHeaderLayout(
|
||||
channelPicture = channelPicture,
|
||||
@@ -998,6 +1065,7 @@ fun ChannelName(
|
||||
}
|
||||
},
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -95,7 +96,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
|
||||
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer
|
||||
@@ -149,7 +149,7 @@ fun LongFormPostScreen(
|
||||
|
||||
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
|
||||
|
||||
KeyboardAwareBackHandler {
|
||||
BackHandler {
|
||||
accountViewModel.launchSigner {
|
||||
postViewModel.sendDraftSync()
|
||||
postViewModel.cancel()
|
||||
|
||||
+2
-2
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -52,7 +53,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||
@@ -141,7 +141,7 @@ fun NewProductScreen(
|
||||
|
||||
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
|
||||
|
||||
KeyboardAwareBackHandler {
|
||||
BackHandler {
|
||||
accountViewModel.launchSigner {
|
||||
postViewModel.sendDraftSync()
|
||||
postViewModel.cancel()
|
||||
|
||||
-2
@@ -26,7 +26,6 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
@@ -124,7 +123,6 @@ fun DisplayGeoTagHeader(
|
||||
LoadCityName(geohashStr = geohash) { cityName ->
|
||||
Text(
|
||||
cityName,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
-1
@@ -99,7 +99,6 @@ fun RepoTitleBar(
|
||||
Column(Modifier.weight(1f, fill = false)) {
|
||||
Text(
|
||||
text = event?.name() ?: fallback,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
|
||||
-1
@@ -754,7 +754,6 @@ private fun TopBarTitle(
|
||||
) {
|
||||
Text(
|
||||
text = event?.name() ?: fallback,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
|
||||
+2
-2
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -92,7 +93,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview
|
||||
import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||
@@ -235,7 +235,7 @@ internal fun NewPostScreenInner(
|
||||
|
||||
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
|
||||
|
||||
KeyboardAwareBackHandler {
|
||||
BackHandler {
|
||||
accountViewModel.launchSigner {
|
||||
postViewModel.sendDraftSync()
|
||||
postViewModel.cancel()
|
||||
|
||||
+20
-13
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.HomeFeedType
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter
|
||||
@@ -50,10 +51,11 @@ class HomeConversationsFeedFilter(
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val filterParams = buildFilterParams(account)
|
||||
val disabledKinds = HomeFeedType.disabledKinds(account.settings.enabledHomeFeedTypes.value)
|
||||
|
||||
return sort(
|
||||
LocalCache.notes.filterIntoSet { _, it ->
|
||||
acceptableEvent(it, filterParams)
|
||||
acceptableEvent(it, filterParams, disabledKinds)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -68,9 +70,10 @@ class HomeConversationsFeedFilter(
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val filterParams = buildFilterParams(account)
|
||||
val disabledKinds = HomeFeedType.disabledKinds(account.settings.enabledHomeFeedTypes.value)
|
||||
|
||||
return collection.filterTo(HashSet()) {
|
||||
acceptableEvent(it, filterParams)
|
||||
acceptableEvent(it, filterParams, disabledKinds)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,23 +81,27 @@ class HomeConversationsFeedFilter(
|
||||
event: Event?,
|
||||
relays: List<NormalizedRelayUrl>,
|
||||
filterParams: FilterByListParams,
|
||||
disabledKinds: Set<Int>,
|
||||
): Boolean =
|
||||
(
|
||||
event is TextNoteEvent ||
|
||||
event is ZapPollEvent ||
|
||||
event is PollResponseEvent ||
|
||||
event is ChannelMessageEvent ||
|
||||
event is CommentEvent ||
|
||||
event is VoiceReplyEvent ||
|
||||
event is PublicMessageEvent ||
|
||||
event is LiveActivitiesChatMessageEvent
|
||||
) &&
|
||||
event != null &&
|
||||
event.kind !in disabledKinds &&
|
||||
(
|
||||
event is TextNoteEvent ||
|
||||
event is ZapPollEvent ||
|
||||
event is PollResponseEvent ||
|
||||
event is ChannelMessageEvent ||
|
||||
event is CommentEvent ||
|
||||
event is VoiceReplyEvent ||
|
||||
event is PublicMessageEvent ||
|
||||
event is LiveActivitiesChatMessageEvent
|
||||
) &&
|
||||
filterParams.match(event, relays)
|
||||
|
||||
fun acceptableEvent(
|
||||
note: Note,
|
||||
filterParams: FilterByListParams,
|
||||
): Boolean = acceptableEvent(note.event, note.relays, filterParams) && !note.isNewThread()
|
||||
disabledKinds: Set<Int>,
|
||||
): Boolean = acceptableEvent(note.event, note.relays, filterParams, disabledKinds) && !note.isNewThread()
|
||||
|
||||
override fun sort(items: Set<Note>): List<Note> = items.sortedByDefaultFeedOrder()
|
||||
}
|
||||
|
||||
+9
-4
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.isRenderableRepost
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.HomeFeedType
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.filterIntoSet
|
||||
@@ -93,18 +94,19 @@ class HomeNewThreadFeedFilter(
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val filterParams = buildFilterParams(account)
|
||||
val disabledKinds = HomeFeedType.disabledKinds(account.settings.enabledHomeFeedTypes.value)
|
||||
|
||||
val notes =
|
||||
LocalCache.notes.filterIntoSet { _, note ->
|
||||
// Avoids processing addressables twice.
|
||||
(note.event?.kind ?: 99999) < 10000 && acceptableEvent(note, filterParams)
|
||||
(note.event?.kind ?: 99999) < 10000 && acceptableEvent(note, filterParams, disabledKinds)
|
||||
}
|
||||
|
||||
val longFormNotes =
|
||||
LocalCache.addressables.filterIntoSet(
|
||||
kinds = ADDRESSABLE_KINDS,
|
||||
) { _, note ->
|
||||
acceptableEvent(note, filterParams)
|
||||
acceptableEvent(note, filterParams, disabledKinds)
|
||||
}
|
||||
|
||||
return sort(notes + longFormNotes)
|
||||
@@ -114,17 +116,20 @@ class HomeNewThreadFeedFilter(
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val filterParams = buildFilterParams(account)
|
||||
val disabledKinds = HomeFeedType.disabledKinds(account.settings.enabledHomeFeedTypes.value)
|
||||
|
||||
return collection.filterTo(HashSet()) {
|
||||
acceptableEvent(it, filterParams)
|
||||
acceptableEvent(it, filterParams, disabledKinds)
|
||||
}
|
||||
}
|
||||
|
||||
private fun acceptableEvent(
|
||||
it: Note,
|
||||
filterParams: FilterByListParams,
|
||||
disabledKinds: Set<Int>,
|
||||
): Boolean {
|
||||
val noteEvent = it.event
|
||||
val noteEvent = it.event ?: return false
|
||||
if (noteEvent.kind in disabledKinds) return false
|
||||
return (
|
||||
noteEvent is TextNoteEvent ||
|
||||
noteEvent is ClassifiedsEvent ||
|
||||
|
||||
+45
-13
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HomeFeedType
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
|
||||
@@ -49,6 +50,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -63,19 +65,24 @@ class HomeOutboxEventsEoseManager(
|
||||
val feedSettings = key.followsPerRelay()
|
||||
val newThreadSince = key.feedState.homeNewThreads.lastNoteCreatedAtIfFilled()
|
||||
val repliesSince = key.feedState.homeReplies.lastNoteCreatedAtIfFilled()
|
||||
return when (feedSettings) {
|
||||
is AllCommunitiesTopNavPerRelayFilterSet -> filterHomePostsByAllCommunities(feedSettings, since, newThreadSince)
|
||||
is AllFollowsTopNavPerRelayFilterSet -> filterHomePostsByAllFollows(feedSettings, since, newThreadSince, repliesSince)
|
||||
is AuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince)
|
||||
is GlobalTopNavPerRelayFilterSet -> filterHomePostsByGlobal(feedSettings, since, newThreadSince, repliesSince)
|
||||
is HashtagTopNavPerRelayFilterSet -> filterHomePostsByHashtags(feedSettings, since, newThreadSince)
|
||||
is LocationTopNavPerRelayFilterSet -> filterHomePostsByGeohashes(feedSettings, since, newThreadSince)
|
||||
is MutedAuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince)
|
||||
is RelayTopNavPerRelayFilterSet -> filterHomePostsByRelay(feedSettings, since, newThreadSince, repliesSince)
|
||||
is SingleCommunityTopNavPerRelayFilterSet -> filterHomePostsByCommunity(feedSettings, since, newThreadSince)
|
||||
is FavoriteAlgoFeedTopNavPerRelayFilterSet -> filterHomePostsByAlgoFeedIds(feedSettings, since, newThreadSince)
|
||||
else -> emptyList()
|
||||
}
|
||||
val base =
|
||||
when (feedSettings) {
|
||||
is AllCommunitiesTopNavPerRelayFilterSet -> filterHomePostsByAllCommunities(feedSettings, since, newThreadSince)
|
||||
is AllFollowsTopNavPerRelayFilterSet -> filterHomePostsByAllFollows(feedSettings, since, newThreadSince, repliesSince)
|
||||
is AuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince)
|
||||
is GlobalTopNavPerRelayFilterSet -> filterHomePostsByGlobal(feedSettings, since, newThreadSince, repliesSince)
|
||||
is HashtagTopNavPerRelayFilterSet -> filterHomePostsByHashtags(feedSettings, since, newThreadSince)
|
||||
is LocationTopNavPerRelayFilterSet -> filterHomePostsByGeohashes(feedSettings, since, newThreadSince)
|
||||
is MutedAuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince)
|
||||
is RelayTopNavPerRelayFilterSet -> filterHomePostsByRelay(feedSettings, since, newThreadSince, repliesSince)
|
||||
is SingleCommunityTopNavPerRelayFilterSet -> filterHomePostsByCommunity(feedSettings, since, newThreadSince)
|
||||
is FavoriteAlgoFeedTopNavPerRelayFilterSet -> filterHomePostsByAlgoFeedIds(feedSettings, since, newThreadSince)
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
// Drop the kinds the user turned off in Settings › Home from every home relay filter, so a
|
||||
// disabled group is never downloaded regardless of which top-nav strategy built the filters.
|
||||
return base.removeDisabledHomeKinds(HomeFeedType.disabledKinds(key.account.settings.enabledHomeFeedTypes.value))
|
||||
}
|
||||
|
||||
override fun user(key: HomeQueryState) = key.account.userProfile()
|
||||
@@ -108,6 +115,13 @@ class HomeOutboxEventsEoseManager(
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
key.scope.launch(Dispatchers.IO) {
|
||||
// Re-arm the home subscriptions when a content-type toggle flips, so a disabled
|
||||
// group leaves the live REQ and a re-enabled one comes back without a restart.
|
||||
key.account.settings.enabledHomeFeedTypes
|
||||
.drop(1)
|
||||
.collectLatest { invalidateFilters() }
|
||||
},
|
||||
key.account.scope.launch(Dispatchers.IO) {
|
||||
key.feedState.homeNewThreads.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
|
||||
invalidateFilters()
|
||||
@@ -131,3 +145,21 @@ class HomeOutboxEventsEoseManager(
|
||||
userJobMap[key]?.forEach { it.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the [disabled] kinds from each home filter. A filter with no `kinds` (e.g. an
|
||||
* algo-feed id/address fetch) is left untouched; a filter whose kinds all become disabled is
|
||||
* dropped entirely, since sending it with an empty `kinds` would wrongly match every kind.
|
||||
*/
|
||||
private fun List<RelayBasedFilter>.removeDisabledHomeKinds(disabled: Set<Int>): List<RelayBasedFilter> {
|
||||
if (disabled.isEmpty()) return this
|
||||
return mapNotNull { relayFilter ->
|
||||
val kinds = relayFilter.filter.kinds ?: return@mapNotNull relayFilter
|
||||
val kept = kinds.filterNot { it in disabled }
|
||||
when {
|
||||
kept.size == kinds.size -> relayFilter
|
||||
kept.isEmpty() -> null
|
||||
else -> RelayBasedFilter(relayFilter.relay, relayFilter.filter.copy(kinds = kept))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.nip75Goals
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -47,7 +48,6 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||
@@ -81,7 +81,7 @@ fun NewGoalScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
KeyboardAwareBackHandler {
|
||||
BackHandler {
|
||||
goalViewModel.cancel()
|
||||
nav.popBack()
|
||||
}
|
||||
|
||||
+2
-2
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -63,7 +64,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||
@@ -134,7 +134,7 @@ fun NewPublicMessageScreen(
|
||||
|
||||
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
|
||||
|
||||
KeyboardAwareBackHandler {
|
||||
BackHandler {
|
||||
accountViewModel.launchSigner {
|
||||
postViewModel.sendDraftSync()
|
||||
postViewModel.cancel()
|
||||
|
||||
+91
-36
@@ -34,7 +34,9 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.HomeFeedType
|
||||
import com.vitorpamplona.amethyst.model.UiSettingsFlow
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
@@ -65,53 +67,106 @@ fun HomeTabsSettingsScreen(
|
||||
TopBarWithBackButton(stringRes(id = R.string.home_tabs_settings), nav)
|
||||
},
|
||||
) { padding ->
|
||||
HomeTabsSettingsContent(accountViewModel.settings.uiSettingsFlow, Modifier.padding(padding))
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
HomeTabsSection(accountViewModel.settings.uiSettingsFlow)
|
||||
HomeContentTypesSection(accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeTabsSettingsContent(
|
||||
ui: UiSettingsFlow,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
private fun HomeTabsSection(ui: UiSettingsFlow) {
|
||||
val showNewThreads by ui.showHomeNewThreadsTab.collectAsStateWithLifecycle()
|
||||
val showConversations by ui.showHomeConversationsTab.collectAsStateWithLifecycle()
|
||||
val showEverything by ui.showHomeEverythingTab.collectAsStateWithLifecycle()
|
||||
|
||||
val activeCount = listOf(showNewThreads, showConversations, showEverything).count { it }
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
SettingsSection(R.string.settings_section_home_tabs) {
|
||||
SettingsSection(R.string.settings_section_home_tabs) {
|
||||
SettingsSwitchTile(
|
||||
icon = MaterialSymbols.Forum,
|
||||
title = R.string.new_threads,
|
||||
checked = showNewThreads,
|
||||
// Don't allow disabling the last remaining tab.
|
||||
enabled = !(showNewThreads && activeCount == 1),
|
||||
onCheckedChange = { ui.showHomeNewThreadsTab.tryEmit(it) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsSwitchTile(
|
||||
icon = MaterialSymbols.Chat,
|
||||
title = R.string.conversations,
|
||||
checked = showConversations,
|
||||
enabled = !(showConversations && activeCount == 1),
|
||||
onCheckedChange = { ui.showHomeConversationsTab.tryEmit(it) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsSwitchTile(
|
||||
icon = MaterialSymbols.Public,
|
||||
title = R.string.home_tab_everything,
|
||||
checked = showEverything,
|
||||
enabled = !(showEverything && activeCount == 1),
|
||||
onCheckedChange = { ui.showHomeEverythingTab.tryEmit(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One toggleable Home content group, mapping a [HomeFeedType] to its display title + icon. */
|
||||
private data class HomeFeedTypeUi(
|
||||
val type: HomeFeedType,
|
||||
val titleRes: Int,
|
||||
val icon: MaterialSymbol,
|
||||
)
|
||||
|
||||
// Ordered by how common each group is on a typical home feed (everyday posts first, niche last).
|
||||
private val HOME_FEED_TYPES =
|
||||
listOf(
|
||||
HomeFeedTypeUi(HomeFeedType.TEXT_NOTES, R.string.home_content_type_text_notes, MaterialSymbols.EditNote),
|
||||
HomeFeedTypeUi(HomeFeedType.REPOSTS, R.string.home_content_type_reposts, MaterialSymbols.Forward),
|
||||
HomeFeedTypeUi(HomeFeedType.COMMENTS, R.string.home_content_type_comments, MaterialSymbols.Chat),
|
||||
HomeFeedTypeUi(HomeFeedType.ARTICLES, R.string.home_content_type_articles, MaterialSymbols.AutoMirrored.Article),
|
||||
HomeFeedTypeUi(HomeFeedType.WIKI, R.string.home_content_type_wiki, MaterialSymbols.MenuBook),
|
||||
HomeFeedTypeUi(HomeFeedType.HIGHLIGHTS, R.string.home_content_type_highlights, MaterialSymbols.FormatQuote),
|
||||
HomeFeedTypeUi(HomeFeedType.POLLS, R.string.home_content_type_polls, MaterialSymbols.Poll),
|
||||
HomeFeedTypeUi(HomeFeedType.CLASSIFIEDS, R.string.home_content_type_classifieds, MaterialSymbols.Storefront),
|
||||
HomeFeedTypeUi(HomeFeedType.VOICE, R.string.home_content_type_voice, MaterialSymbols.Mic),
|
||||
HomeFeedTypeUi(HomeFeedType.LIVE_ACTIVITIES, R.string.home_content_type_live_activities, MaterialSymbols.Sensors),
|
||||
HomeFeedTypeUi(HomeFeedType.EPHEMERAL_CHAT, R.string.home_content_type_ephemeral_chat, MaterialSymbols.Forum),
|
||||
HomeFeedTypeUi(HomeFeedType.INTERACTIVE_STORIES, R.string.home_content_type_interactive_stories, MaterialSymbols.AutoAwesome),
|
||||
HomeFeedTypeUi(HomeFeedType.CHESS, R.string.home_content_type_chess, MaterialSymbols.ChessKnight),
|
||||
HomeFeedTypeUi(HomeFeedType.BIRDS, R.string.home_content_type_birds, MaterialSymbols.TravelExplore),
|
||||
HomeFeedTypeUi(HomeFeedType.ATTESTATIONS, R.string.home_content_type_attestations, MaterialSymbols.Shield),
|
||||
HomeFeedTypeUi(HomeFeedType.NIPS, R.string.home_content_type_nips, MaterialSymbols.Code),
|
||||
HomeFeedTypeUi(HomeFeedType.MUSIC, R.string.home_content_type_music, MaterialSymbols.MusicNote),
|
||||
HomeFeedTypeUi(HomeFeedType.PODCASTS, R.string.home_content_type_podcasts, MaterialSymbols.Podcasts),
|
||||
HomeFeedTypeUi(HomeFeedType.FUNDRAISERS, R.string.home_content_type_fundraisers, MaterialSymbols.Paid),
|
||||
)
|
||||
|
||||
/**
|
||||
* Per-content-type load toggles for the Home feed. Turning one off both drops its event kinds from
|
||||
* the always-on home relay filters AND hides them from the New Threads / Conversations / Everything
|
||||
* tabs. Everything is on by default.
|
||||
*/
|
||||
@Composable
|
||||
private fun HomeContentTypesSection(accountViewModel: AccountViewModel) {
|
||||
val enabled by accountViewModel.account.settings.enabledHomeFeedTypes
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
SettingsSection(R.string.settings_section_home_content_types) {
|
||||
HOME_FEED_TYPES.forEachIndexed { index, item ->
|
||||
if (index > 0) SettingsDivider()
|
||||
SettingsSwitchTile(
|
||||
icon = MaterialSymbols.Forum,
|
||||
title = R.string.new_threads,
|
||||
checked = showNewThreads,
|
||||
// Don't allow disabling the last remaining tab.
|
||||
enabled = !(showNewThreads && activeCount == 1),
|
||||
onCheckedChange = { ui.showHomeNewThreadsTab.tryEmit(it) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsSwitchTile(
|
||||
icon = MaterialSymbols.Chat,
|
||||
title = R.string.conversations,
|
||||
checked = showConversations,
|
||||
enabled = !(showConversations && activeCount == 1),
|
||||
onCheckedChange = { ui.showHomeConversationsTab.tryEmit(it) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsSwitchTile(
|
||||
icon = MaterialSymbols.Public,
|
||||
title = R.string.home_tab_everything,
|
||||
checked = showEverything,
|
||||
enabled = !(showEverything && activeCount == 1),
|
||||
onCheckedChange = { ui.showHomeEverythingTab.tryEmit(it) },
|
||||
icon = item.icon,
|
||||
title = item.titleRes,
|
||||
checked = item.type in enabled,
|
||||
onCheckedChange = { accountViewModel.account.settings.setHomeFeedTypeEnabled(item.type, it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ import android.annotation.SuppressLint
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
|
||||
@@ -114,7 +113,6 @@ fun DisplayUrlHeader(
|
||||
) {
|
||||
Text(
|
||||
url,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = modifier,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
|
||||
+2
-2
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -60,7 +61,6 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||
@@ -80,7 +80,7 @@ fun NewWorkoutScreen(
|
||||
postViewModel.init(accountViewModel)
|
||||
postViewModel.prefill(prefill)
|
||||
|
||||
KeyboardAwareBackHandler {
|
||||
BackHandler {
|
||||
postViewModel.cancel()
|
||||
nav.popBack()
|
||||
}
|
||||
|
||||
@@ -341,6 +341,7 @@
|
||||
<string name="concord_leave_message">Opustit %1$s? Bude odebrána ze seznamu tohoto účtu a přestane se synchronizovat na vašich zařízeních. Komunita o tom nedostane oznámení a nebudete odebráni z jejího seznamu členů. Zprávy, které již nedokážete dešifrovat, mohou být neobnovitelné, a vrátit se můžete pouze s novou pozvánkou.</string>
|
||||
<string name="concord_leave_owner_warning">Tuto komunitu jste vytvořili vy. Opuštěním ji nesmažete ani ji nepředáte nikomu jinému, ale zahodíte klíč vlastníka uložený ve vašem seznamu — už byste ji nemohli spravovat.</string>
|
||||
<string name="concord_edit_relays_desc">Kde jsou publikovány a čteny šifrované roviny této komunity.</string>
|
||||
<string name="concord_dissolved_read_only">Tato komunita byla rozpuštěna a je nyní pouze pro čtení. Její historii si stále můžete přečíst, ale nové zprávy už nelze odesílat.</string>
|
||||
<string name="concord_typing_one">%1$s píše…</string>
|
||||
<string name="concord_typing_two">%1$s a %2$s píší…</string>
|
||||
<string name="concord_typing_many">Píše několik lidí…</string>
|
||||
@@ -3347,7 +3348,6 @@
|
||||
<string name="buzz_dm_empty_title">Zatím žádné přímé zprávy</string>
|
||||
<string name="buzz_dm_empty_body">Začněte soukromou konverzaci s kýmkoli v pracovním prostoru Buzz.</string>
|
||||
<string name="buzz_dm_more">Více</string>
|
||||
<string name="buzz_dm_hide">Skrýt konverzaci</string>
|
||||
<string name="buzz_dm_add_member">Přidat člena</string>
|
||||
<string name="buzz_dm_add_member_title">Přidat někoho do této konverzace</string>
|
||||
<string name="buzz_dm_add_member_invalid">Neplatný npub nebo hex klíč</string>
|
||||
@@ -4704,6 +4704,7 @@
|
||||
<string name="buzz_workflow_def_title">Nová definice workflow</string>
|
||||
<string name="buzz_workflow_def_desc">Pojmenuje ho pro kanál a publikuje jeho YAML recept (kind-30620). Skutečný relay Buzz ten YAML spouští. Při vlastním hostování runner spouští svůj nakonfigurovaný příkaz — zde definice běh jen pojmenovává a kataloguje.</string>
|
||||
<string name="buzz_workflow_def_name">Název</string>
|
||||
<string name="buzz_workflow_def_name_hint">build-a-test</string>
|
||||
<string name="buzz_workflow_def_yaml">YAML recept</string>
|
||||
<string name="buzz_workflow_def_publish_failed">Definici se nepodařilo publikovat — ověřte, že můžete přispívat do tohoto pracovního prostoru.</string>
|
||||
<string name="buzz_workflow_publishing">Publikování…</string>
|
||||
|
||||
@@ -333,6 +333,7 @@
|
||||
<string name="concord_leave_message">%1$s verlassen? Sie wird aus der Liste dieses Kontos entfernt und hört auf, sich auf deinen Geräten zu synchronisieren. Die Community wird nicht benachrichtigt und du wirst nicht aus ihrer Mitgliederliste entfernt. Nachrichten, die du nicht mehr entschlüsseln kannst, sind möglicherweise nicht wiederherstellbar, und du kannst nur mit einer neuen Einladung zurückkehren.</string>
|
||||
<string name="concord_leave_owner_warning">Du hast diese Community erstellt. Beim Verlassen wird sie weder gelöscht noch an jemand anderen übergeben, aber der auf deiner Liste gespeicherte Eigentümerschlüssel wird verworfen — du könntest sie nicht mehr verwalten.</string>
|
||||
<string name="concord_edit_relays_desc">Wo die verschlüsselten Planes dieser Community veröffentlicht und gelesen werden.</string>
|
||||
<string name="concord_dissolved_read_only">Diese Community wurde aufgelöst und ist jetzt schreibgeschützt. Du kannst ihren Verlauf weiterhin lesen, aber es können keine neuen Nachrichten mehr gepostet werden.</string>
|
||||
<string name="concord_typing_one">%1$s schreibt…</string>
|
||||
<string name="concord_typing_two">%1$s und %2$s schreiben…</string>
|
||||
<string name="concord_typing_many">Mehrere Personen schreiben…</string>
|
||||
@@ -3231,7 +3232,6 @@
|
||||
<string name="buzz_dm_empty_title">Noch keine Direktnachrichten</string>
|
||||
<string name="buzz_dm_empty_body">Beginne eine private Unterhaltung mit jemandem in einem Buzz-Workspace.</string>
|
||||
<string name="buzz_dm_more">Mehr</string>
|
||||
<string name="buzz_dm_hide">Unterhaltung ausblenden</string>
|
||||
<string name="buzz_dm_add_member">Mitglied hinzufügen</string>
|
||||
<string name="buzz_dm_add_member_title">Jemanden zu dieser DM hinzufügen</string>
|
||||
<string name="buzz_dm_add_member_invalid">Kein gültiger npub- oder Hex-Schlüssel</string>
|
||||
@@ -4537,6 +4537,7 @@
|
||||
<string name="buzz_workflow_new_definition">Neue Definition…</string>
|
||||
<string name="buzz_workflow_def_title">Neue Workflow-Definition</string>
|
||||
<string name="buzz_workflow_def_desc">Benennt ihn für den Kanal und veröffentlicht sein YAML-Rezept (kind-30620). Ein echtes Buzz-Relay führt das YAML aus. Selbst gehostet führt der Runner seinen konfigurierten Befehl aus — hier benennt und katalogisiert die Definition den Lauf nur.</string>
|
||||
<string name="buzz_workflow_def_name_hint">build-und-test</string>
|
||||
<string name="buzz_workflow_def_yaml">YAML-Rezept</string>
|
||||
<string name="buzz_workflow_def_publish_failed">Die Definition konnte nicht veröffentlicht werden — prüfe, ob du in diesem Workspace posten kannst.</string>
|
||||
<string name="buzz_workflow_publishing">Wird veröffentlicht…</string>
|
||||
|
||||
@@ -3232,7 +3232,6 @@
|
||||
<string name="buzz_dm_empty_title">कोई सीधेसन्देश नहीं अब तक</string>
|
||||
<string name="buzz_dm_empty_body">आरम्भ करें निजी वार्तालप किसी से भी एक बज्स्स कार्यशाला में।</string>
|
||||
<string name="buzz_dm_more">और अधिक</string>
|
||||
<string name="buzz_dm_hide">संवाद छिपाएँ</string>
|
||||
<string name="buzz_dm_add_member">सदस्य जोडें</string>
|
||||
<string name="buzz_dm_add_member_title">किसी को जोडें इस सीधेसन्देश में</string>
|
||||
<string name="buzz_dm_add_member_invalid">मान्य एनपुब॰ अथवा षोडषांक कुंचिका नहीं</string>
|
||||
|
||||
@@ -3297,7 +3297,6 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="buzz_dm_empty_title">Brak bezpośrednich wiadomości</string>
|
||||
<string name="buzz_dm_empty_body">Rozpocznij prywatną rozmowę z kimkolwiek w przestrzeni roboczej Buzz.</string>
|
||||
<string name="buzz_dm_more">Więcej</string>
|
||||
<string name="buzz_dm_hide">Ukryj konwersację</string>
|
||||
<string name="buzz_dm_add_member">Dodaj członka</string>
|
||||
<string name="buzz_dm_add_member_title">Dodaj kogoś do tej wiadomości DM</string>
|
||||
<string name="buzz_dm_add_member_invalid">Nieprawidłowy klucz npub lub hex</string>
|
||||
|
||||
@@ -333,6 +333,7 @@
|
||||
<string name="concord_leave_message">Sair de %1$s? Ela é removida da lista desta conta e para de sincronizar nos seus dispositivos. A comunidade não é notificada e você não é removido da lista de membros. Mensagens que você não conseguir mais descriptografar podem ser irrecuperáveis, e você só poderá voltar com um novo convite.</string>
|
||||
<string name="concord_leave_owner_warning">Você criou esta comunidade. Sair não a exclui nem a entrega a mais ninguém, mas descarta a chave de proprietário armazenada na sua lista — você não conseguiria gerenciá-la novamente.</string>
|
||||
<string name="concord_edit_relays_desc">Onde os planos criptografados desta comunidade são publicados e lidos.</string>
|
||||
<string name="concord_dissolved_read_only">Esta comunidade foi dissolvida e agora é somente leitura. Você ainda pode ler o histórico, mas não é possível publicar novas mensagens.</string>
|
||||
<string name="concord_typing_one">%1$s está digitando…</string>
|
||||
<string name="concord_typing_two">%1$s e %2$s estão digitando…</string>
|
||||
<string name="concord_typing_many">Várias pessoas estão digitando…</string>
|
||||
@@ -3229,7 +3230,6 @@
|
||||
<string name="buzz_dm_empty_title">Nenhuma mensagem direta ainda</string>
|
||||
<string name="buzz_dm_empty_body">Inicie uma conversa privada com qualquer pessoa em um espaço de trabalho Buzz.</string>
|
||||
<string name="buzz_dm_more">Mais</string>
|
||||
<string name="buzz_dm_hide">Ocultar conversa</string>
|
||||
<string name="buzz_dm_add_member">Adicionar membro</string>
|
||||
<string name="buzz_dm_add_member_title">Adicionar alguém a esta DM</string>
|
||||
<string name="buzz_dm_add_member_invalid">Não é uma chave npub ou hex válida</string>
|
||||
@@ -4538,6 +4538,7 @@
|
||||
<string name="buzz_workflow_def_title">Nova definição de fluxo de trabalho</string>
|
||||
<string name="buzz_workflow_def_desc">Dá um nome a ele para o canal e publica sua receita YAML (kind-30620). Um relay Buzz real executa o YAML. Auto-hospedado, o runner executa o comando configurado — aqui a definição apenas nomeia e cataloga a execução.</string>
|
||||
<string name="buzz_workflow_def_name">Nome</string>
|
||||
<string name="buzz_workflow_def_name_hint">build-e-teste</string>
|
||||
<string name="buzz_workflow_def_yaml">Receita YAML</string>
|
||||
<string name="buzz_workflow_def_publish_failed">Não foi possível publicar a definição — verifique se você pode publicar neste espaço de trabalho.</string>
|
||||
<string name="buzz_workflow_publishing">Publicando…</string>
|
||||
|
||||
@@ -3362,7 +3362,6 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
|
||||
<string name="buzz_dm_empty_title">Ni še zasebnih sporočil</string>
|
||||
<string name="buzz_dm_empty_body">Začnite zasebni pogovor s komerkoli v delovnem prostoru Buzz.</string>
|
||||
<string name="buzz_dm_more">Več</string>
|
||||
<string name="buzz_dm_hide">Skrij pogovore</string>
|
||||
<string name="buzz_dm_add_member">Dodaj člana</string>
|
||||
<string name="buzz_dm_add_member_title">Dodaj nekoga v to ZS</string>
|
||||
<string name="buzz_dm_add_member_invalid">Neveljaven npub ali hex ključ </string>
|
||||
|
||||
@@ -333,6 +333,7 @@
|
||||
<string name="concord_leave_message">Lämna %1$s? Den tas bort från det här kontots lista och slutar synkas på dina enheter. Gemenskapen meddelas inte och du tas inte bort från dess medlemslista. Meddelanden som du inte längre kan dekryptera kan gå förlorade, och du kan bara återvända med en ny inbjudan.</string>
|
||||
<string name="concord_leave_owner_warning">Du skapade den här gemenskapen. Att lämna raderar den inte och överlämnar den inte till någon annan, men det kasserar ägarnyckeln som lagras i din lista — du skulle inte kunna hantera den igen.</string>
|
||||
<string name="concord_edit_relays_desc">Var den här gemenskapens krypterade plan publiceras och läses.</string>
|
||||
<string name="concord_dissolved_read_only">Den här gemenskapen har upplösts och är nu skrivskyddad. Du kan fortfarande läsa dess historik, men inga nya meddelanden kan publiceras.</string>
|
||||
<string name="concord_typing_one">%1$s skriver…</string>
|
||||
<string name="concord_typing_two">%1$s och %2$s skriver…</string>
|
||||
<string name="concord_typing_many">Flera personer skriver…</string>
|
||||
@@ -3229,7 +3230,6 @@
|
||||
<string name="buzz_dm_empty_title">Inga direktmeddelanden än</string>
|
||||
<string name="buzz_dm_empty_body">Starta en privat konversation med vem som helst i en Buzz-arbetsyta.</string>
|
||||
<string name="buzz_dm_more">Mer</string>
|
||||
<string name="buzz_dm_hide">Dölj konversation</string>
|
||||
<string name="buzz_dm_add_member">Lägg till medlem</string>
|
||||
<string name="buzz_dm_add_member_title">Lägg till någon i det här DM:et</string>
|
||||
<string name="buzz_dm_add_member_invalid">Inte en giltig npub- eller hexnyckel</string>
|
||||
@@ -4539,6 +4539,7 @@
|
||||
<string name="buzz_workflow_def_title">Ny arbetsflödesdefinition</string>
|
||||
<string name="buzz_workflow_def_desc">Namnger det för kanalen och publicerar dess YAML-recept (kind-30620). Ett riktigt Buzz-relä kör YAML:en. Självhostat kör runnern sitt konfigurerade kommando — här namnger och katalogiserar definitionen bara körningen.</string>
|
||||
<string name="buzz_workflow_def_name">Namn</string>
|
||||
<string name="buzz_workflow_def_name_hint">build-och-testa</string>
|
||||
<string name="buzz_workflow_def_yaml">YAML-recept</string>
|
||||
<string name="buzz_workflow_def_publish_failed">Kunde inte publicera definitionen — kontrollera att du kan posta i den här arbetsytan.</string>
|
||||
<string name="buzz_workflow_publishing">Publicerar…</string>
|
||||
|
||||
@@ -344,6 +344,7 @@
|
||||
<string name="concord_leave_message">Leave %1$s? It is removed from this account\'s list and stops syncing on your devices. The community is not notified and you are not removed from its member roster. Messages you can no longer decrypt may be unrecoverable, and you can only return with a new invite.</string>
|
||||
<string name="concord_leave_owner_warning">You created this community. Leaving does not delete it or hand it to anyone else, but it discards the owner key stored on your list — you would not be able to manage it again.</string>
|
||||
<string name="concord_edit_relays_desc">Where this community\'s encrypted planes are published and read.</string>
|
||||
<string name="concord_dissolved_read_only">This community has been dissolved and is now read-only. You can still read its history, but no new messages can be posted.</string>
|
||||
<string name="concord_typing_one">%1$s is typing…</string>
|
||||
<string name="concord_typing_two">%1$s and %2$s are typing…</string>
|
||||
<string name="concord_typing_many">Several people are typing…</string>
|
||||
@@ -448,6 +449,8 @@
|
||||
<string name="refresh">Refresh</string>
|
||||
<string name="changed_chat_profile_to">New chat profile:</string>
|
||||
<string name="leave">Leave</string>
|
||||
<string name="remove_from_messages">Remove from Messages</string>
|
||||
<string name="add_to_messages">Add to Messages</string>
|
||||
<string name="unfollow">Unfollow</string>
|
||||
<string name="channel_created">Channel created</string>
|
||||
<string name="channel_information_changed_to">"Channel Information changed to"</string>
|
||||
@@ -2327,6 +2330,26 @@
|
||||
<string name="settings_section_tor_routing">Route through Tor</string>
|
||||
<string name="settings_section_profile_sections">Sections & feeds</string>
|
||||
<string name="settings_section_home_tabs">Visible tabs</string>
|
||||
<string name="settings_section_home_content_types">Content in the feed</string>
|
||||
<string name="home_content_type_text_notes">Text notes</string>
|
||||
<string name="home_content_type_reposts">Reposts</string>
|
||||
<string name="home_content_type_comments">Comments & replies</string>
|
||||
<string name="home_content_type_articles">Articles</string>
|
||||
<string name="home_content_type_wiki">Wiki pages</string>
|
||||
<string name="home_content_type_highlights">Highlights</string>
|
||||
<string name="home_content_type_polls">Polls</string>
|
||||
<string name="home_content_type_classifieds">Classifieds</string>
|
||||
<string name="home_content_type_voice">Voice messages</string>
|
||||
<string name="home_content_type_live_activities">Live activities</string>
|
||||
<string name="home_content_type_ephemeral_chat">Ephemeral chats</string>
|
||||
<string name="home_content_type_interactive_stories">Interactive stories</string>
|
||||
<string name="home_content_type_chess">Chess games</string>
|
||||
<string name="home_content_type_birds">Bird sightings</string>
|
||||
<string name="home_content_type_attestations">Attestations</string>
|
||||
<string name="home_content_type_nips">NIP drafts</string>
|
||||
<string name="home_content_type_music">Music & audio</string>
|
||||
<string name="home_content_type_podcasts">Podcasts</string>
|
||||
<string name="home_content_type_fundraisers">Fundraisers</string>
|
||||
<string name="settings_section_reminders">Reminders</string>
|
||||
<string name="wallet_connect">Wallet Connect</string>
|
||||
<string name="language">Language</string>
|
||||
@@ -2585,6 +2608,10 @@
|
||||
<string name="relay_group_edit_confirm">Save</string>
|
||||
<string name="relay_group_menu_members">Members</string>
|
||||
<string name="relay_group_menu_edit">Edit group</string>
|
||||
<string name="relay_group_delete">Delete group</string>
|
||||
<string name="relay_group_delete_confirm">Delete \"%1$s\"? This removes the group and its history for everyone, and cannot be undone.</string>
|
||||
<string name="buzz_channel_delete">Delete channel</string>
|
||||
<string name="buzz_channel_delete_confirm">Delete \"%1$s\"? This removes the channel and its messages for everyone, and cannot be undone.</string>
|
||||
<string name="relay_group_threads_title">Threads</string>
|
||||
<string name="relay_group_pin_message">Pin message</string>
|
||||
<string name="relay_group_unpin_message">Unpin message</string>
|
||||
@@ -3535,7 +3562,6 @@
|
||||
<string name="buzz_dm_empty_title">No direct messages yet</string>
|
||||
<string name="buzz_dm_empty_body">Start a private conversation with anyone on a Buzz workspace.</string>
|
||||
<string name="buzz_dm_more">More</string>
|
||||
<string name="buzz_dm_hide">Hide conversation</string>
|
||||
<string name="buzz_dm_add_member">Add member</string>
|
||||
<string name="buzz_dm_add_member_title">Add someone to this DM</string>
|
||||
<string name="buzz_dm_add_member_invalid">Not a valid npub or hex key</string>
|
||||
@@ -3581,6 +3607,10 @@
|
||||
<string name="buzz_pin">Pin channel</string>
|
||||
<string name="buzz_unpin">Unpin channel</string>
|
||||
<string name="buzz_dm_section_empty">No conversations yet</string>
|
||||
<plurals name="buzz_dm_hidden_count">
|
||||
<item quantity="one">%1$d hidden conversation</item>
|
||||
<item quantity="other">%1$d hidden conversations</item>
|
||||
</plurals>
|
||||
<plurals name="buzz_dm_see_all_count">
|
||||
<item quantity="one">See %1$d more conversation</item>
|
||||
<item quantity="other">See all %1$d conversations</item>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class HomeFeedTypeTest {
|
||||
@Test
|
||||
fun allContainsEveryEntry() {
|
||||
assertEquals(HomeFeedType.entries.toSet(), HomeFeedType.ALL)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun kindsAreDisjointAcrossTypes() {
|
||||
val seen = mutableSetOf<Int>()
|
||||
HomeFeedType.entries.forEach { type ->
|
||||
type.kinds.forEach { kind ->
|
||||
assertTrue("kind $kind is owned by more than one HomeFeedType", seen.add(kind))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodeThenDecodeRoundTrips() {
|
||||
val disabled = setOf(HomeFeedType.CHESS, HomeFeedType.BIRDS)
|
||||
val enabled = HomeFeedType.ALL - disabled
|
||||
val stored = HomeFeedType.encode(HomeFeedType.ALL - enabled)
|
||||
assertEquals(enabled, HomeFeedType.ALL - HomeFeedType.decode(stored))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodeNullOrBlankIsEmpty() {
|
||||
assertEquals(emptySet<HomeFeedType>(), HomeFeedType.decode(null))
|
||||
// Absence of a stored value means "nothing disabled" -> everything enabled.
|
||||
assertEquals(HomeFeedType.ALL, HomeFeedType.ALL - HomeFeedType.decode(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodeDropsUnknownCodes() {
|
||||
val decoded = HomeFeedType.decode("chess,future-kind")
|
||||
assertEquals(setOf(HomeFeedType.CHESS), decoded)
|
||||
assertNull(HomeFeedType.fromCode("future-kind"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disabledKindsEmptyWhenEverythingEnabled() {
|
||||
assertTrue(HomeFeedType.disabledKinds(HomeFeedType.ALL).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disabledKindsAreExactlyTheDisabledGroupsKinds() {
|
||||
val enabled = HomeFeedType.ALL - HomeFeedType.TEXT_NOTES - HomeFeedType.REPOSTS
|
||||
val disabled = HomeFeedType.disabledKinds(enabled)
|
||||
|
||||
assertTrue(TextNoteEvent.KIND in disabled)
|
||||
HomeFeedType.REPOSTS.kinds.forEach { assertTrue(it in disabled) }
|
||||
// A still-enabled group's kinds must not leak into the disabled set.
|
||||
HomeFeedType.POLLS.kinds.forEach { assertFalse(it in disabled) }
|
||||
}
|
||||
}
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.lang.reflect.Proxy
|
||||
|
||||
class BlossomReadAuthInterceptorTest {
|
||||
private val sha = "2c5287a55cc550c9d6bc4206a4663900e083315f4a544ea3bc189e43dc330af6"
|
||||
private val host = "nosfabrica.communities.buzz.xyz"
|
||||
|
||||
// --- blossomHashOrNull ------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun hashParsedFromPlainBlob() {
|
||||
assertEquals(sha, BlossomReadAuthInterceptor.blossomHashOrNull("/media/$sha.png"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hashParsedFromThumbnailVariant() {
|
||||
// Buzz thumbnails use the dot form <hash>.thumb.jpg — the base is still the hash.
|
||||
assertEquals(sha, BlossomReadAuthInterceptor.blossomHashOrNull("/media/$sha.thumb.jpg"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hashParsedWithoutExtension() {
|
||||
assertEquals(sha, BlossomReadAuthInterceptor.blossomHashOrNull("/$sha"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hashLowercasedFromUppercaseSegment() {
|
||||
assertEquals(sha, BlossomReadAuthInterceptor.blossomHashOrNull("/media/${sha.uppercase()}.png"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonBlobPathsReturnNull() {
|
||||
assertNull(BlossomReadAuthInterceptor.blossomHashOrNull("/media/avatar.png"))
|
||||
assertNull(BlossomReadAuthInterceptor.blossomHashOrNull("/media/nostr.build_$sha.jpg"))
|
||||
assertNull(BlossomReadAuthInterceptor.blossomHashOrNull("/media/${sha}_thumb.jpg"))
|
||||
// 65 hex chars: isHex64 checks only the first 64, so the length guard must reject it.
|
||||
assertNull(BlossomReadAuthInterceptor.blossomHashOrNull("/media/${sha}a.png"))
|
||||
assertNull(BlossomReadAuthInterceptor.blossomHashOrNull("/"))
|
||||
}
|
||||
|
||||
// --- intercept behavior ----------------------------------------------
|
||||
|
||||
@Test
|
||||
fun retriesWithAuthOn401() {
|
||||
val provider = RecordingProvider(header = "Nostr token")
|
||||
val chain = fakeChain("https://$host/media/$sha.png", codes = listOf(401, 200))
|
||||
|
||||
val response = BlossomReadAuthInterceptor(provider::header).intercept(chain.asChain())
|
||||
|
||||
assertEquals(200, response.code)
|
||||
assertEquals(2, chain.requests.size)
|
||||
assertNull("first attempt is anonymous", chain.requests[0].header("Authorization"))
|
||||
assertEquals("Nostr token", chain.requests[1].header("Authorization"))
|
||||
assertEquals(host to sha, provider.calls.single())
|
||||
response.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thumbnailUrlAlsoRetries() {
|
||||
val provider = RecordingProvider(header = "Nostr token")
|
||||
val chain = fakeChain("https://$host/media/$sha.thumb.jpg", codes = listOf(401, 200))
|
||||
|
||||
val response = BlossomReadAuthInterceptor(provider::header).intercept(chain.asChain())
|
||||
|
||||
assertEquals(200, response.code)
|
||||
assertEquals(sha, provider.calls.single().second)
|
||||
response.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun successfulRequestNeverSigns() {
|
||||
val provider = RecordingProvider(header = "Nostr token")
|
||||
val chain = fakeChain("https://blossom.example.com/$sha.png", codes = listOf(200))
|
||||
|
||||
val response = BlossomReadAuthInterceptor(provider::header).intercept(chain.asChain())
|
||||
|
||||
assertEquals(200, response.code)
|
||||
assertEquals(1, chain.requests.size)
|
||||
assertTrue("public host must not be signed", provider.calls.isEmpty())
|
||||
response.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keepsThe401WhenNoSignerAvailable() {
|
||||
val provider = RecordingProvider(header = null)
|
||||
val chain = fakeChain("https://$host/media/$sha.png", codes = listOf(401))
|
||||
|
||||
val response = BlossomReadAuthInterceptor(provider::header).intercept(chain.asChain())
|
||||
|
||||
assertEquals(401, response.code)
|
||||
assertEquals(1, chain.requests.size)
|
||||
assertEquals(host to sha, provider.calls.single())
|
||||
response.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonBlobUrlNeverSignsEvenOn401() {
|
||||
val provider = RecordingProvider(header = "Nostr token")
|
||||
val chain = fakeChain("https://example.com/media/avatar.png", codes = listOf(401))
|
||||
|
||||
val response = BlossomReadAuthInterceptor(provider::header).intercept(chain.asChain())
|
||||
|
||||
assertEquals(401, response.code)
|
||||
assertEquals(1, chain.requests.size)
|
||||
assertTrue(provider.calls.isEmpty())
|
||||
response.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun requestWithExistingAuthPassesThrough() {
|
||||
val provider = RecordingProvider(header = "Nostr token")
|
||||
val chain = fakeChain("https://$host/media/$sha.png", codes = listOf(401), preAuthHeader = "Nostr existing")
|
||||
|
||||
val response = BlossomReadAuthInterceptor(provider::header).intercept(chain.asChain())
|
||||
|
||||
assertEquals(401, response.code)
|
||||
assertEquals(1, chain.requests.size)
|
||||
assertTrue(provider.calls.isEmpty())
|
||||
response.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonGetRequestPassesThrough() {
|
||||
val provider = RecordingProvider(header = "Nostr token")
|
||||
val chain = fakeChain("https://$host/media/$sha.png", codes = listOf(401), method = "PUT")
|
||||
|
||||
val response = BlossomReadAuthInterceptor(provider::header).intercept(chain.asChain())
|
||||
|
||||
assertEquals(401, response.code)
|
||||
assertEquals(1, chain.requests.size)
|
||||
assertTrue(provider.calls.isEmpty())
|
||||
response.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun learnsHostThenSignsSubsequentBlobsUpFront() {
|
||||
val provider = RecordingProvider(header = "Nostr token")
|
||||
val interceptor = BlossomReadAuthInterceptor(provider::header)
|
||||
val otherSha = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"
|
||||
|
||||
// First blob learns the host via the 401 probe + signed retry.
|
||||
val first = fakeChain("https://$host/media/$sha.png", codes = listOf(401, 200))
|
||||
interceptor.intercept(first.asChain()).close()
|
||||
assertEquals(2, first.requests.size)
|
||||
|
||||
// Second, different blob on the same host is signed on the first attempt —
|
||||
// no anonymous probe, so a single request.
|
||||
val second = fakeChain("https://$host/media/$otherSha.png", codes = listOf(200))
|
||||
val response = interceptor.intercept(second.asChain())
|
||||
|
||||
assertEquals(200, response.code)
|
||||
assertEquals("no anonymous probe on a known-gated host", 1, second.requests.size)
|
||||
assertEquals("Nostr token", second.requests.single().header("Authorization"))
|
||||
response.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun learnedHostWithoutSignerDoesNotLoop() {
|
||||
val provider = RecordingProvider(header = null)
|
||||
val interceptor = BlossomReadAuthInterceptor(provider::header)
|
||||
|
||||
val first = fakeChain("https://$host/media/$sha.png", codes = listOf(401))
|
||||
interceptor.intercept(first.asChain()).close()
|
||||
|
||||
// Host is now known, but with no signer the preemptive path must fall
|
||||
// back to a single anonymous request rather than retrying endlessly.
|
||||
val second = fakeChain("https://$host/media/$sha.png", codes = listOf(401))
|
||||
val response = interceptor.intercept(second.asChain())
|
||||
|
||||
assertEquals(401, response.code)
|
||||
assertEquals(1, second.requests.size)
|
||||
response.close()
|
||||
}
|
||||
|
||||
private class RecordingProvider(
|
||||
private val header: String?,
|
||||
) {
|
||||
val calls = mutableListOf<Pair<String, HexKey>>()
|
||||
|
||||
fun header(
|
||||
host: String,
|
||||
sha256: HexKey,
|
||||
): String? {
|
||||
calls.add(host to sha256)
|
||||
return header
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records every [Request] it is asked to proceed and answers each with the
|
||||
* next code from [codes], so `[401, 200]` models "anonymous fails,
|
||||
* authenticated succeeds".
|
||||
*/
|
||||
private class FakeChain(
|
||||
private val request: Request,
|
||||
private val codes: List<Int>,
|
||||
) {
|
||||
val requests = mutableListOf<Request>()
|
||||
|
||||
fun asChain(): Interceptor.Chain =
|
||||
Proxy.newProxyInstance(
|
||||
Interceptor.Chain::class.java.classLoader,
|
||||
arrayOf(Interceptor.Chain::class.java),
|
||||
) { _, method, args ->
|
||||
when (method.name) {
|
||||
"request" -> request
|
||||
"proceed" -> {
|
||||
val proceeded = args[0] as Request
|
||||
requests.add(proceeded)
|
||||
Response
|
||||
.Builder()
|
||||
.request(proceeded)
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(codes[requests.size - 1])
|
||||
.message("msg")
|
||||
.body("".toResponseBody(null))
|
||||
.build()
|
||||
}
|
||||
else -> throw UnsupportedOperationException(method.name)
|
||||
}
|
||||
} as Interceptor.Chain
|
||||
}
|
||||
|
||||
private fun fakeChain(
|
||||
url: String,
|
||||
codes: List<Int>,
|
||||
method: String = "GET",
|
||||
preAuthHeader: String? = null,
|
||||
): FakeChain {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url.toHttpUrl())
|
||||
.apply {
|
||||
if (method == "GET") get() else method(method, "".toRequestBody())
|
||||
preAuthHeader?.let { header("Authorization", it) }
|
||||
}.build()
|
||||
return FakeChain(request, codes)
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class BlossomReadAuthTokenProviderTest {
|
||||
private val sha = "2c5287a55cc550c9d6bc4206a4663900e083315f4a544ea3bc189e43dc330af6"
|
||||
private val host = "nosfabrica.communities.buzz.xyz"
|
||||
private val signer = NostrSignerInternal(KeyPair())
|
||||
|
||||
@Test
|
||||
fun signsAndFormatsHeader() {
|
||||
val provider = BlossomReadAuthTokenProvider(signerProvider = { signer })
|
||||
val header = provider.authHeader(host, sha)
|
||||
assertTrue("expected a Nostr auth header, got $header", header!!.startsWith("Nostr "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun returnsNullWhenNoSigner() {
|
||||
val provider = BlossomReadAuthTokenProvider(signerProvider = { null })
|
||||
assertNull(provider.authHeader(host, sha))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cachesPerHostWithinTtl() {
|
||||
// signerProvider is consulted only on a cache miss, so its invocation
|
||||
// count is the number of times a fresh token was signed.
|
||||
var lookups = 0
|
||||
val provider =
|
||||
BlossomReadAuthTokenProvider(
|
||||
signerProvider = {
|
||||
lookups++
|
||||
signer
|
||||
},
|
||||
clock = { 0L },
|
||||
)
|
||||
|
||||
val first = provider.authHeader(host, sha)
|
||||
val second = provider.authHeader(host, sha)
|
||||
|
||||
assertEquals("second call must be served from cache", first, second)
|
||||
assertEquals("signer must run only once for the same host", 1, lookups)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun differentHostSignsSeparately() {
|
||||
val provider = BlossomReadAuthTokenProvider(signerProvider = { signer }, clock = { 0L })
|
||||
|
||||
val a = provider.authHeader(host, sha)
|
||||
val b = provider.authHeader("other.example.com", sha)
|
||||
|
||||
assertNotEquals(a, b)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshesAfterExpiry() {
|
||||
var now = 0L
|
||||
val provider = BlossomReadAuthTokenProvider(signerProvider = { signer }, clock = { now })
|
||||
|
||||
val first = provider.authHeader(host, sha)
|
||||
now += 60L * 60L * 1000L // one hour later — past the 55-min TTL
|
||||
val second = provider.authHeader(host, sha)
|
||||
|
||||
assertNotEquals("expired token must be re-signed", first, second)
|
||||
}
|
||||
}
|
||||
+17
@@ -71,4 +71,21 @@ class RelayAuthFirstPartyTest {
|
||||
// Delivering my own DM/post to the recipient's relay, even one not in my list.
|
||||
assertTrue(RelayAuthFirstParty.hasReason(me, relay, listOf(event(me)), emptySet()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRelayHostingAJoinedGroupIsFirstParty() {
|
||||
// A NIP-29 group host relay is in none of my NIP-65/DM/… lists, and a private group's content
|
||||
// is `#h`-scoped so it never names me — the joined-group set is the only signal that lets us
|
||||
// AUTH so the relay serves the group's `auth-required` content instead of leaving it empty.
|
||||
val groupRelay = NormalizedRelayUrl("wss://chat.wisp.talk/")
|
||||
assertTrue(RelayAuthFirstParty.hasReason(me, groupRelay, emptyList(), emptySet(), setOf(groupRelay)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aGroupRelayIHaveNotJoinedIsNotFirstParty() {
|
||||
// Merely knowing a group relay exists (e.g. browsing its public directory) must not AUTH it;
|
||||
// only a group on my own kind-10009 list counts.
|
||||
val groupRelay = NormalizedRelayUrl("wss://chat.wisp.talk/")
|
||||
assertFalse(RelayAuthFirstParty.hasReason(me, groupRelay, emptyList(), emptySet(), emptySet()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,12 +146,12 @@ object BuzzAgentCommands {
|
||||
val repo = args.flag("repo") ?: return Output.error("bad_args", "pass --repo DIR (your git checkout — the agent works and opens PRs here)")
|
||||
if (!File(repo).resolve(".git").exists()) return Output.error("bad_args", "--repo is not a git repository: $repo")
|
||||
val approver = args.flag("approver") ?: return Output.error("bad_args", "pass --approver NPUB (the human who signs off each run)")
|
||||
val baseRef = args.flag("base-ref")
|
||||
val baseRef = args.flag(FLAG_BASE_REF)
|
||||
val poll = args.flag("poll")
|
||||
val timeout = args.flag("timeout")
|
||||
val once = args.bool("once")
|
||||
val explicitChannel = args.flag("channel")
|
||||
args.rejectUnknown("repo", "approver", "channel", "base-ref", "poll", "timeout", "once")
|
||||
args.rejectUnknown("repo", "approver", "channel", FLAG_BASE_REF, "poll", "timeout", "once")
|
||||
|
||||
val channel =
|
||||
explicitChannel
|
||||
@@ -320,7 +320,7 @@ object BuzzAgentCommands {
|
||||
val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: 8
|
||||
val execTimeoutSecs = args.flag("exec-timeout")?.toLongOrNull() ?: 1800
|
||||
val worktreeBase = args.flag("worktree")
|
||||
val baseRef = args.flag("base-ref") ?: "HEAD"
|
||||
val baseRef = args.flag(FLAG_BASE_REF) ?: "HEAD"
|
||||
val branchPrefix = args.flag("branch-prefix") ?: "claude/job-"
|
||||
val fromChannel = args.bool("accept-from-channel")
|
||||
val acceptFrom =
|
||||
@@ -340,7 +340,7 @@ object BuzzAgentCommands {
|
||||
"claim-untargeted",
|
||||
"parallel",
|
||||
"worktree",
|
||||
"base-ref",
|
||||
FLAG_BASE_REF,
|
||||
"branch-prefix",
|
||||
"poll",
|
||||
"exec-timeout",
|
||||
@@ -714,4 +714,7 @@ object BuzzAgentCommands {
|
||||
}
|
||||
|
||||
private const val MAX_BODY = 60_000
|
||||
|
||||
/** Flag name for the git ref each job's worktree branches off. */
|
||||
private const val FLAG_BASE_REF = "base-ref"
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ object BuzzCommands {
|
||||
ctx.prepare()
|
||||
val me = ctx.identity.pubKeyHex
|
||||
val relays = relaysFor(ctx, relaysFlag)
|
||||
if (relays.isEmpty()) return Output.error("no_relays", "no relays: pass --relays ws://…")
|
||||
if (relays.isEmpty()) return Output.error("no_relays", NO_RELAYS_MSG)
|
||||
|
||||
// The deployed Buzz relay does NOT emit kind-41001; instead it (a) confirms a DM's
|
||||
// channel id synchronously in the open OK, and (b) addresses each member a kind-44100
|
||||
@@ -518,7 +518,7 @@ object BuzzCommands {
|
||||
ctx.prepare()
|
||||
val me = ctx.identity.pubKeyHex
|
||||
val relays = relaysFor(ctx, relaysFlag)
|
||||
if (relays.isEmpty()) return Output.error("no_relays", "no relays: pass --relays ws://…")
|
||||
if (relays.isEmpty()) return Output.error("no_relays", NO_RELAYS_MSG)
|
||||
|
||||
val filter = Filter(kinds = listOf(AgentTurnMetricEvent.KIND), tags = mapOf("p" to listOf(me)))
|
||||
val decrypted =
|
||||
@@ -572,7 +572,7 @@ object BuzzCommands {
|
||||
ctx.prepare()
|
||||
val me = ctx.identity.pubKeyHex
|
||||
val relays = relaysFor(ctx, relaysFlag)
|
||||
if (relays.isEmpty()) return Output.error("no_relays", "no relays: pass --relays ws://…")
|
||||
if (relays.isEmpty()) return Output.error("no_relays", NO_RELAYS_MSG)
|
||||
|
||||
val filter = Filter(kinds = listOf(PersonaEvent.KIND), authors = listOf(me))
|
||||
val personas =
|
||||
@@ -600,6 +600,9 @@ object BuzzCommands {
|
||||
}
|
||||
}
|
||||
|
||||
/** Message for the `no_relays` error: neither `--relays` nor the account's outbox had one. */
|
||||
private const val NO_RELAYS_MSG = "no relays: pass --relays ws://…"
|
||||
|
||||
/** The `--relays` set if given, else the account's outbox relays. */
|
||||
private suspend fun relaysFor(
|
||||
ctx: Context,
|
||||
|
||||
@@ -125,7 +125,7 @@ object BuzzWorkflowCommands {
|
||||
val wfId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
|
||||
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
|
||||
val task = args.flag("task")?.takeIf { it.isNotBlank() } ?: return Output.error("bad_args", "pass --task TEXT")
|
||||
val channel = args.flag("channel") ?: return Output.error("bad_args", "pass --channel GID")
|
||||
val channel = args.flag("channel") ?: return Output.error("bad_args", MISSING_CHANNEL_MSG)
|
||||
args.rejectUnknown("task", "channel")
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
@@ -181,7 +181,7 @@ object BuzzWorkflowCommands {
|
||||
val usage = "buzz workflow list RELAY --channel GID [--timeout SECS]"
|
||||
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
|
||||
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
|
||||
val channel = args.flag("channel") ?: return Output.error("bad_args", "pass --channel GID")
|
||||
val channel = args.flag("channel") ?: return Output.error("bad_args", MISSING_CHANNEL_MSG)
|
||||
val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: 8
|
||||
args.rejectUnknown("channel", "timeout")
|
||||
|
||||
@@ -301,7 +301,7 @@ object BuzzWorkflowCommands {
|
||||
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", USAGE)
|
||||
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
|
||||
val exec = args.flag("exec") ?: return Output.error("bad_args", "pass --exec CMD (the agent's work step)")
|
||||
val channel = args.flag("channel") ?: return Output.error("bad_args", "pass --channel GID")
|
||||
val channel = args.flag("channel") ?: return Output.error("bad_args", MISSING_CHANNEL_MSG)
|
||||
val approverInput = args.flag("approver") ?: return Output.error("bad_args", "pass --approver NPUB (who signs off the gate)")
|
||||
val approver =
|
||||
decodePublicKeyAsHexOrNull(approverInput.trim())?.takeIf { it.isValid() }
|
||||
@@ -597,6 +597,10 @@ object BuzzWorkflowCommands {
|
||||
.orEmpty()
|
||||
|
||||
private const val MAX = 60_000
|
||||
|
||||
/** Message for the `bad_args` error raised when `--channel` is missing. */
|
||||
private const val MISSING_CHANNEL_MSG = "pass --channel GID"
|
||||
|
||||
private val LIFECYCLE_KINDS =
|
||||
listOf(
|
||||
WorkflowTriggeredEvent.KIND,
|
||||
|
||||
@@ -50,6 +50,8 @@ object ConcordChannelCommands {
|
||||
mapOf(
|
||||
"name" to state.metadata?.name,
|
||||
"description" to state.metadata?.description,
|
||||
// CORD-02 §9: once dissolved the community is sealed read-only (history only, no new posts).
|
||||
"dissolved" to state.dissolved,
|
||||
"icon" to state.metadata?.icon?.let { mapOf("url" to it.url, "key" to it.key, "nonce" to it.nonce, "hash" to it.hash) },
|
||||
"banner" to state.metadata?.banner?.let { mapOf("url" to it.url, "key" to it.key, "nonce" to it.nonce, "hash" to it.hash) },
|
||||
"channels" to
|
||||
@@ -75,6 +77,11 @@ object ConcordChannelCommands {
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
// CORD-02 §9: a dissolved community is sealed read-only — held keys still open history, but
|
||||
// nothing new is honored, so refuse to post before we ever build/publish a wrap.
|
||||
if (foldState(ctx, sc).dissolved) {
|
||||
return Output.error("dissolved", "community '$handle' has been dissolved and is read-only (CORD-02 §9)")
|
||||
}
|
||||
val channelId = resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'")
|
||||
val channel = ConcordActions.publicChannel(sc.root.hexToByteArray(), channelId.hexToByteArray(), sc.rootEpoch)
|
||||
val wrap = ConcordActions.buildChannelMessage(ctx.signer, channel, channelId, sc.rootEpoch, text, TimeUtils.now())
|
||||
|
||||
@@ -38,13 +38,16 @@ import java.io.File
|
||||
* pushing objects back is still out of scope (see cli/ROADMAP.md).
|
||||
*/
|
||||
object GitBrowseCommands {
|
||||
/** Name of the first positional argument: the repo's naddr or `kind:pubkey:d` coordinates. */
|
||||
private const val ARG_REPO_COORD = "repo-naddr-or-coordinates"
|
||||
|
||||
/** `git browse REPO [PATH]` — list the tree entries at PATH (default: repo root). */
|
||||
suspend fun browse(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val coord = args.positional(0, "repo-naddr-or-coordinates")
|
||||
val coord = args.positional(0, ARG_REPO_COORD)
|
||||
val path = args.positionalOrNull(1).orEmpty()
|
||||
val ref = args.flag("ref")
|
||||
val cloneOverride = args.flag("clone")
|
||||
@@ -79,7 +82,7 @@ object GitBrowseCommands {
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val coord = args.positional(0, "repo-naddr-or-coordinates")
|
||||
val coord = args.positional(0, ARG_REPO_COORD)
|
||||
val path = args.positional(1, "file-path")
|
||||
val ref = args.flag("ref")
|
||||
val cloneOverride = args.flag("clone")
|
||||
@@ -118,7 +121,7 @@ object GitBrowseCommands {
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val coord = args.positional(0, "repo-naddr-or-coordinates")
|
||||
val coord = args.positional(0, ARG_REPO_COORD)
|
||||
val ref = args.flag("ref")
|
||||
val cloneOverride = args.flag("clone")
|
||||
val depth = args.intFlag("depth", 50)
|
||||
|
||||
+21
-2
@@ -78,6 +78,16 @@ class ConcordChannel(
|
||||
var membership: ConcordMembership = ConcordMembership.MEMBER
|
||||
private set
|
||||
|
||||
/**
|
||||
* True once the community has been dissolved by an owner-signed tombstone (CORD-02 §9). On sight
|
||||
* the client seals the Community read-only: held keys still open history, but nothing new is
|
||||
* honored, so no member — not even the owner — may post. Terminal and one-way (there is no
|
||||
* un-dissolve). The one carve-out (a member's delete of their own past message stays honored even
|
||||
* post-seal) runs through the note context menu, not the composer, so this gate never blocks it.
|
||||
*/
|
||||
var dissolved: Boolean = false
|
||||
private set
|
||||
|
||||
/**
|
||||
* Per-relay backward-pagination cursors for this channel's history (CORD-03). The live
|
||||
* subscription only holds the recent tail the relay serves for the channel plane; older
|
||||
@@ -110,6 +120,7 @@ class ConcordChannel(
|
||||
val newCommunityIcon = state.metadata?.icon
|
||||
val newCommunityBanner = state.metadata?.banner
|
||||
val newMembership = ConcordMembership.of(state.authority, myPubKey)
|
||||
val newDissolved = state.dissolved
|
||||
|
||||
val changed =
|
||||
channelName != newChannelName ||
|
||||
@@ -118,7 +129,8 @@ class ConcordChannel(
|
||||
communityName != newCommunityName ||
|
||||
communityIcon != newCommunityIcon ||
|
||||
communityBanner != newCommunityBanner ||
|
||||
membership != newMembership
|
||||
membership != newMembership ||
|
||||
dissolved != newDissolved
|
||||
|
||||
channelName = newChannelName
|
||||
isVoice = newVoice
|
||||
@@ -128,6 +140,7 @@ class ConcordChannel(
|
||||
communityBanner = newCommunityBanner
|
||||
communityRelays = relays
|
||||
membership = newMembership
|
||||
dissolved = newDissolved
|
||||
return changed
|
||||
}
|
||||
|
||||
@@ -136,7 +149,13 @@ class ConcordChannel(
|
||||
|
||||
override fun toBestDisplayName(): String = channelName ?: channelId.channelId
|
||||
|
||||
fun canPost(): Boolean = membership.isMember()
|
||||
/**
|
||||
* Whether this account may post to the channel: it must hold a live standing in the community
|
||||
* ([ConcordMembership.isMember]) **and** the community must not have been dissolved (CORD-02 §9 —
|
||||
* a tombstone seals it read-only for everyone). Deleting one's own past message stays allowed even
|
||||
* after dissolution and does not go through this gate.
|
||||
*/
|
||||
fun canPost(): Boolean = membership.isMember() && !dissolved
|
||||
|
||||
// Synthetic note representing this channel in the Messages list before any
|
||||
// message has loaded (so a just-joined channel appears immediately). Mirrors
|
||||
|
||||
+83
-5
@@ -24,9 +24,11 @@ package com.vitorpamplona.amethyst.commons.model.highlights
|
||||
* What to render for a NIP-84 highlight: the passage to show, and the range inside it that
|
||||
* the user actually marked.
|
||||
*
|
||||
* When the event carries a `context` tag the whole surrounding sentence is shown with the
|
||||
* quote marked inside it. When it doesn't — or the quote can't be located in the context —
|
||||
* [text] is the quote alone and [marked] is null, meaning "mark all of it".
|
||||
* When the event carries a `context` tag the surrounding passage is shown with the quote
|
||||
* marked inside it, trimmed to a bounded window on each side so a quote pulled from the
|
||||
* middle of a long article doesn't drag whole paragraphs into the feed. When it doesn't —
|
||||
* or the quote can't be located in the context — [text] is the quote alone and [marked] is
|
||||
* null, meaning "mark all of it".
|
||||
*/
|
||||
data class HighlightQuote(
|
||||
val text: String,
|
||||
@@ -49,7 +51,7 @@ data class HighlightQuote(
|
||||
|
||||
val at = locate(context, highlight, prefix)
|
||||
return if (at != null) {
|
||||
HighlightQuote(context, at until (at + highlight.length))
|
||||
window(context, at, at + highlight.length)
|
||||
} else {
|
||||
// Context that doesn't actually contain the quote is worse than no context:
|
||||
// it would mark nothing and silently show text the user never highlighted.
|
||||
@@ -57,6 +59,78 @@ data class HighlightQuote(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Most surrounding context to keep on each side of the marked quote, in characters.
|
||||
* A highlight taken from the middle of a long article can carry the whole article in
|
||||
* its `context` tag; without a cap the feed card would render several paragraphs around
|
||||
* a one-sentence highlight. Just enough to frame the quote, no more.
|
||||
*/
|
||||
private const val MAX_CONTEXT_CHARS_PER_SIDE = 160
|
||||
|
||||
private const val ELLIPSIS = "…"
|
||||
|
||||
/**
|
||||
* Trims the context down to [MAX_CONTEXT_CHARS_PER_SIDE] on each side of the quote,
|
||||
* snapping the cut to a whole-word boundary and marking it with an ellipsis. The quote
|
||||
* itself ([start] until [endExclusive]) is always kept in full, and the returned
|
||||
* [marked] range is re-based onto the trimmed text.
|
||||
*/
|
||||
private fun window(
|
||||
context: String,
|
||||
start: Int,
|
||||
endExclusive: Int,
|
||||
): HighlightQuote {
|
||||
val lead = trimLead(context.substring(0, start))
|
||||
val trail = trimTrail(context.substring(endExclusive))
|
||||
val quote = context.substring(start, endExclusive)
|
||||
|
||||
val prefix = if (lead.trimmed) "$ELLIPSIS " else ""
|
||||
val suffix = if (trail.trimmed) " $ELLIPSIS" else ""
|
||||
|
||||
// Drop any blank lines sitting at the very edges of the context — with the far text
|
||||
// trimmed (or even when it isn't) they would otherwise render as empty space above or
|
||||
// below the quote. Only the outer edges are touched; the whitespace framing the quote
|
||||
// itself is preserved.
|
||||
val raw = prefix + lead.text + quote + trail.text + suffix
|
||||
val leadingBlank = raw.length - raw.trimStart().length
|
||||
val text = raw.trim()
|
||||
|
||||
val markStart = (prefix.length + lead.text.length - leadingBlank).coerceAtLeast(0)
|
||||
val markEnd = (markStart + quote.length).coerceAtMost(text.length)
|
||||
return HighlightQuote(text, markStart until markEnd)
|
||||
}
|
||||
|
||||
private class Side(
|
||||
val text: String,
|
||||
val trimmed: Boolean,
|
||||
)
|
||||
|
||||
/** Keeps the tail of the leading context, starting at a whole word. */
|
||||
private fun trimLead(text: String): Side {
|
||||
if (text.length <= MAX_CONTEXT_CHARS_PER_SIDE) return Side(text, false)
|
||||
|
||||
var i = text.length - MAX_CONTEXT_CHARS_PER_SIDE
|
||||
// Skip the partial word the budget landed inside, then the whitespace after it, so
|
||||
// the kept text begins at the start of a whole word rather than mid-word.
|
||||
while (i < text.length && !text[i].isWhitespace()) i++
|
||||
while (i < text.length && text[i].isWhitespace()) i++
|
||||
val cut = if (i >= text.length) text.length - MAX_CONTEXT_CHARS_PER_SIDE else i
|
||||
return Side(text.substring(cut), true)
|
||||
}
|
||||
|
||||
/** Keeps the head of the trailing context, ending at a whole word. */
|
||||
private fun trimTrail(text: String): Side {
|
||||
if (text.length <= MAX_CONTEXT_CHARS_PER_SIDE) return Side(text, false)
|
||||
|
||||
var i = MAX_CONTEXT_CHARS_PER_SIDE
|
||||
// Retreat over the partial word the budget landed inside, then the whitespace before
|
||||
// it, so the kept text ends at the end of a whole word rather than mid-word.
|
||||
while (i > 0 && !text[i - 1].isWhitespace()) i--
|
||||
while (i > 0 && text[i - 1].isWhitespace()) i--
|
||||
val cut = if (i <= 0) MAX_CONTEXT_CHARS_PER_SIDE else i
|
||||
return Side(text.substring(0, cut), true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds [highlight] inside [context], preferring the occurrence whose preceding text
|
||||
* ends with [prefix]. Highlighters emit that prefix precisely so a repeated quote can
|
||||
@@ -67,9 +141,13 @@ data class HighlightQuote(
|
||||
highlight: String,
|
||||
prefix: String?,
|
||||
): Int? {
|
||||
// The common case — no prefix to disambiguate with — needs only the first match, so
|
||||
// don't scan the whole context enumerating every occurrence.
|
||||
if (prefix.isNullOrBlank()) return context.indexOf(highlight).takeIf { it >= 0 }
|
||||
|
||||
val occurrences = occurrencesOf(context, highlight)
|
||||
if (occurrences.isEmpty()) return null
|
||||
if (occurrences.size == 1 || prefix.isNullOrBlank()) return occurrences.first()
|
||||
if (occurrences.size == 1) return occurrences.first()
|
||||
|
||||
val tail = prefix.trimEnd()
|
||||
return occurrences.firstOrNull { context.substring(0, it).trimEnd().endsWith(tail) }
|
||||
|
||||
+23
@@ -23,7 +23,9 @@ package com.vitorpamplona.amethyst.commons.model.nip29RelayGroups
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.NoteState
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
@@ -97,6 +99,27 @@ class RelayGroupListState(
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptySet())
|
||||
|
||||
/**
|
||||
* The joined groups as normalized [GroupId]s — what the UI asks when it needs to know whether a
|
||||
* channel is on my Messages list ("Remove from Messages" vs "Add to Messages"). The stored tag
|
||||
* carries a raw relay url string (another client may not have normalized it the way we do), so
|
||||
* normalize before comparing instead of matching [GroupTag] strings.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val liveRelayGroupIds: StateFlow<Set<GroupId>> =
|
||||
liveRelayGroupList
|
||||
.transformLatest { groups ->
|
||||
emit(
|
||||
groups.mapNotNullTo(mutableSetOf()) { tag ->
|
||||
RelayUrlNormalizer.normalizeOrNull(tag.relayUrl)?.let { GroupId(tag.groupId, it) }
|
||||
},
|
||||
)
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptySet())
|
||||
|
||||
/** Is [groupId] currently on my kind-10009 list (i.e. does it show on Messages)? */
|
||||
fun isOnMyList(groupId: GroupId) = groupId in liveRelayGroupIds.value
|
||||
|
||||
private fun RelayGroupChannel.toGroupTag() = GroupTag(groupId.id, groupId.relayUrl.url, event?.name())
|
||||
|
||||
suspend fun follow(channel: RelayGroupChannel): SimpleGroupListEvent {
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.concord
|
||||
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* A member of a **dissolved** Concord community must not be able to post: CORD-02 §9 seals the
|
||||
* community read-only on an owner-signed tombstone. History stays readable (the composer gate is the
|
||||
* only thing this changes), so [ConcordChannel.canPost] must fold the dissolution flag in alongside
|
||||
* membership.
|
||||
*/
|
||||
class ConcordChannelDissolvedTest {
|
||||
private val owner = "0f".repeat(32)
|
||||
private val channelId = "ce".repeat(32)
|
||||
|
||||
private fun ed(
|
||||
kind: ControlEntityKind,
|
||||
eid: String,
|
||||
content: String,
|
||||
author: String = owner,
|
||||
) = ControlEdition(kind, eid.hexToByteArray(), 0, null, null, content, author, "r-$eid", 0)
|
||||
|
||||
private fun state(dissolved: Boolean): ConcordCommunityState {
|
||||
val editions =
|
||||
buildList {
|
||||
add(ed(ControlEntityKind.CHANNEL, channelId, """{"name":"general"}"""))
|
||||
if (dissolved) add(ed(ControlEntityKind.DISSOLVED, "dd".repeat(32), """{}"""))
|
||||
}
|
||||
return ConcordCommunityState.fold(editions, owner)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun liveCommunityLetsTheOwnerPost() {
|
||||
val channel = ConcordChannel(ConcordChannelId(owner, channelId))
|
||||
channel.updateFrom(state(dissolved = false), emptySet(), owner)
|
||||
assertFalse(channel.dissolved)
|
||||
assertTrue(channel.canPost(), "a live community with a live standing must be postable")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dissolvedCommunitySealsPostingEvenForTheOwner() {
|
||||
val channel = ConcordChannel(ConcordChannelId(owner, channelId))
|
||||
// The owner is the most privileged member; if even they can't post, no one can.
|
||||
val changed = channel.updateFrom(state(dissolved = true), emptySet(), owner)
|
||||
assertTrue(changed, "flipping to dissolved is a displayed-field change (drives recomposition)")
|
||||
assertTrue(channel.dissolved)
|
||||
assertFalse(channel.canPost(), "a dissolved community is read-only (CORD-02 §9)")
|
||||
}
|
||||
}
|
||||
+46
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.model.highlights
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class HighlightQuoteTest {
|
||||
@Test
|
||||
@@ -89,4 +90,49 @@ class HighlightQuoteTest {
|
||||
assertEquals("some context", quote.text)
|
||||
assertNull(quote.marked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keepsShortContextWholeWithoutEllipsis() {
|
||||
val quote = HighlightQuote.of("the merge happens slowly", "We think the merge happens slowly. It does.")
|
||||
|
||||
assertEquals("We think the merge happens slowly. It does.", quote.text)
|
||||
assertTrue('…' !in quote.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trimsLongContextToAWindowAroundTheQuote() {
|
||||
val filler = "word ".repeat(200).trim() // ~1000 chars of context on each side
|
||||
val context = "$filler the marked quote $filler"
|
||||
val quote = HighlightQuote.of("the marked quote", context)
|
||||
|
||||
// The whole quote survives and stays marked...
|
||||
assertEquals("the marked quote", quote.text.substring(quote.marked!!))
|
||||
// ...but the surrounding text is trimmed with an ellipsis on each side...
|
||||
assertTrue(quote.text.startsWith("… "))
|
||||
assertTrue(quote.text.endsWith(" …"))
|
||||
// ...and the result is a small fraction of the original two-paragraph context.
|
||||
assertTrue(quote.text.length < context.length / 2, "expected windowing, got ${quote.text.length} of ${context.length}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dropsBlankLinesAtTheEdgesOfAShortContext() {
|
||||
// A context whose paragraph boundaries left blank lines at its very start and end would
|
||||
// otherwise render as empty space above and below the quote.
|
||||
val quote = HighlightQuote.of("Forward Secrecy", "\n\nForward Secrecy is nice.\n\n")
|
||||
|
||||
assertEquals("Forward Secrecy is nice.", quote.text)
|
||||
assertEquals(0 until 15, quote.marked)
|
||||
assertEquals("Forward Secrecy", quote.text.substring(quote.marked!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trimmingSnapsToWholeWordsSoNoWordIsCutInHalf() {
|
||||
val lead = "alpha bravo charlie delta echo foxtrot ".repeat(20) // long, space-separated
|
||||
val context = "${lead}QUOTE"
|
||||
val quote = HighlightQuote.of("QUOTE", context)
|
||||
|
||||
// The kept lead-in starts right after the ellipsis with a whole word, never a fragment.
|
||||
val keptLead = quote.text.removePrefix("… ").removeSuffix("QUOTE")
|
||||
assertTrue(keptLead.split(" ").first() in setOf("alpha", "bravo", "charlie", "delta", "echo", "foxtrot"))
|
||||
}
|
||||
}
|
||||
|
||||
+108
-20
@@ -24,22 +24,76 @@ import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import java.util.SortedSet
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ConcurrentSkipListSet
|
||||
|
||||
/**
|
||||
* Creates a list of events (regular and addressable)
|
||||
* that is updated every time a new event that matches
|
||||
* the filter is received, including addressables.
|
||||
* Creates a list of events (regular and addressable), sorted by created_at, that
|
||||
* is updated every time a new matching event is received — INCLUDING newer
|
||||
* versions of addressables, whose refreshed content re-emits and re-sorts.
|
||||
*
|
||||
* Like [NoteListMatchingFilter], this cannot store mutable [Note]s in a set
|
||||
* ordered on their live created_at: a newer replaceable event mutates the SAME
|
||||
* [AddressableNote] instance in place, which strands its node and lets the same
|
||||
* instance be inserted twice — the emitted list would then carry the same event
|
||||
* twice. So the sort key is snapshotted into an immutable [Entry], [sorted] is
|
||||
* ordered on that snapshot, and [byId] is the membership source of truth keyed
|
||||
* by the stable idHex (the address for addressables, the event id otherwise).
|
||||
*
|
||||
* Unlike [NoteListMatchingFilter], an addressable update is NOT ignored: the
|
||||
* list re-emits so consumers pick up the refreshed [Event] (read live off the
|
||||
* note). The entry's captured position is kept — re-sorting an updated entry
|
||||
* would mean a remove+add on [sorted], and two entries with different captured
|
||||
* keys for the same note would then transiently coexist and both read the same
|
||||
* live event, duplicating it. Keeping the position matches the original's only
|
||||
* non-corrupting behavior (it never reliably re-sorted either); consumers that
|
||||
* care about order re-sort downstream.
|
||||
*
|
||||
* Observer callbacks fire concurrently from several consume threads (relay
|
||||
* ingest + UI-side justConsume), so it stays lock-free: [sorted] is only ever
|
||||
* written for a key INSIDE that key's `compute` critical section, and only while
|
||||
* the key is absent. ConcurrentHashMap stripes per key, so same-idHex ops
|
||||
* serialize while different keys run in parallel. An entry is added only while
|
||||
* its key is absent from [byId], and every path that frees a key removes its
|
||||
* entry from [sorted] first.
|
||||
*
|
||||
* That keeps [sorted] converged to one entry per idHex, but a
|
||||
* ConcurrentSkipListSet iterator is only weakly consistent: under concurrent
|
||||
* add/remove churn a single traversal can momentarily surface a key twice
|
||||
* (lazy-deleted node not yet unlinked while its replacement is inserted). The
|
||||
* emitted list must never carry a duplicate id — a LazyColumn keyed on it would
|
||||
* crash — so [snapshot] deduplicates by the stable idHex as it materializes.
|
||||
*/
|
||||
class EventListMatchingFilter<T : Event>(
|
||||
private val filter: Filter,
|
||||
private val atOnce: (filter: Filter) -> SortedSet<Note>,
|
||||
private val update: (List<T>) -> Unit,
|
||||
) : Observable {
|
||||
// Keeping this here blocks it from being cleared from memory
|
||||
var currentResults: ConcurrentSkipListSet<Note> = ConcurrentSkipListSet(CreatedAtIdHexComparator)
|
||||
/** A note plus the sort key captured at insertion time, so ordering never depends on mutable state. */
|
||||
private class Entry(
|
||||
val note: Note,
|
||||
val createdAt: Long,
|
||||
val id: HexKey,
|
||||
)
|
||||
|
||||
// created_at descending, id ascending as a stable tiebreak. Both fields are
|
||||
// immutable snapshots, so an Entry never moves once inserted.
|
||||
private val order =
|
||||
Comparator<Entry> { a, b ->
|
||||
val byCreatedAt = b.createdAt.compareTo(a.createdAt)
|
||||
if (byCreatedAt != 0) byCreatedAt else a.id.compareTo(b.id)
|
||||
}
|
||||
|
||||
private val sorted = ConcurrentSkipListSet(order)
|
||||
private val byId = ConcurrentHashMap<HexKey, Entry>()
|
||||
|
||||
private fun entryFor(note: Note): Entry {
|
||||
val event = note.event
|
||||
return Entry(note, note.createdAt() ?: Long.MIN_VALUE, event?.id ?: note.idHex)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun new(
|
||||
@@ -47,34 +101,68 @@ class EventListMatchingFilter<T : Event>(
|
||||
note: Note,
|
||||
) {
|
||||
if (event is AddressableEvent && note !is AddressableNote) {
|
||||
// event update
|
||||
if (currentResults.contains(note)) {
|
||||
update(currentResults.mapNotNull { it.event as? T })
|
||||
}
|
||||
// The "version" note (a regular note holding an addressable event) is
|
||||
// never stored — the AddressableNote is. Re-emit if that addressable
|
||||
// is already listed so consumers pick up the refreshed content.
|
||||
if (byId.containsKey(event.address().toValue())) update(snapshot())
|
||||
return
|
||||
}
|
||||
|
||||
if (filter.match(event)) {
|
||||
currentResults.add(note)
|
||||
val limit = filter.limit
|
||||
if (limit != null && currentResults.size > limit) {
|
||||
currentResults.remove(currentResults.last())
|
||||
}
|
||||
if (!filter.match(event)) return
|
||||
|
||||
update(currentResults.mapNotNull { it.event as? T })
|
||||
// Add to [sorted] atomically with claiming the idHex slot, only when the
|
||||
// key is absent. An update keeps its entry (and position) — the re-emit
|
||||
// below reflects the refreshed event read live off the note.
|
||||
var added = false
|
||||
byId.compute(note.idHex) { _, existing ->
|
||||
existing ?: entryFor(note).also {
|
||||
sorted.add(it)
|
||||
added = true
|
||||
}
|
||||
}
|
||||
|
||||
if (added) {
|
||||
val limit = filter.limit
|
||||
if (limit != null && sorted.size > limit) {
|
||||
// Drop the oldest (sorts last under [order]).
|
||||
sorted.pollLast()?.let { byId.remove(it.note.idHex, it) }
|
||||
}
|
||||
}
|
||||
|
||||
// Always re-emit on a match: a first insert grows the list, an update
|
||||
// refreshes the event content the snapshot reads off the note.
|
||||
update(snapshot())
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun remove(note: Note) {
|
||||
if (currentResults.remove(note)) {
|
||||
update(currentResults.mapNotNull { it.event as? T })
|
||||
var removed = false
|
||||
byId.compute(note.idHex) { _, existing ->
|
||||
if (existing != null) {
|
||||
sorted.remove(existing)
|
||||
removed = true
|
||||
}
|
||||
null
|
||||
}
|
||||
if (removed) update(snapshot())
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun init() {
|
||||
currentResults = ConcurrentSkipListSet(atOnce(filter))
|
||||
update(currentResults.mapNotNull { it.event as? T })
|
||||
sorted.clear()
|
||||
byId.clear()
|
||||
atOnce(filter).forEach { note ->
|
||||
byId.computeIfAbsent(note.idHex) { entryFor(note).also { sorted.add(it) } }
|
||||
}
|
||||
update(snapshot())
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun snapshot(): List<T> {
|
||||
// Dedup by the stable idHex: the weakly-consistent iterator can transiently
|
||||
// surface a key twice under concurrent churn. Both would read the same live
|
||||
// event off the same note, so keeping the first (newest position) is correct.
|
||||
val seen = HashSet<HexKey>()
|
||||
return sorted.mapNotNull { e -> if (seen.add(e.note.idHex)) e.note.event as? T else null }
|
||||
}
|
||||
}
|
||||
|
||||
+98
-15
@@ -24,22 +24,76 @@ import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import java.util.SortedSet
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ConcurrentSkipListSet
|
||||
|
||||
/**
|
||||
* Creates a list of notes (regular and addressable)
|
||||
* that only gets updated when a new note appears.
|
||||
* Creates a list of notes (regular and addressable), sorted by created_at like a
|
||||
* relay, that only grows when a new note appears.
|
||||
*
|
||||
* New versions of addressables do not update the list
|
||||
* New versions of addressables do not update the list.
|
||||
*
|
||||
* There is exactly one [Note] instance per id/address (LocalCache owns their
|
||||
* creation), so uniqueness is a non-issue in principle — except a note's sort
|
||||
* key is mutable: a newer replaceable event swaps the event on the SAME
|
||||
* [AddressableNote] instance, changing its created_at in place. A sorted set
|
||||
* ordered on that live value cannot survive it — the moved node is no longer on
|
||||
* the search path of add()/remove(), so the same instance gets inserted twice
|
||||
* and the emitted list carries a duplicate idHex, crashing any LazyColumn keyed
|
||||
* on it.
|
||||
*
|
||||
* So the sort key is snapshotted into an immutable [Entry] when the note first
|
||||
* enters and never read live again; [sorted] is ordered on that snapshot
|
||||
* (stable) and [byId] is the membership source of truth, keyed by the stable
|
||||
* idHex.
|
||||
*
|
||||
* Observer callbacks fire concurrently from several consume threads (relay
|
||||
* ingest + UI-side justConsume), and this observer is used everywhere, so it
|
||||
* stays lock-free: [byId] is a ConcurrentHashMap and every write to [sorted] for
|
||||
* a given idHex happens INSIDE that key's `compute` critical section.
|
||||
* ConcurrentHashMap stripes per key, so same-idHex ops serialize while different
|
||||
* keys run fully in parallel. An entry is added to [sorted] only while its key
|
||||
* is absent from [byId], and every path that makes a key absent removes its
|
||||
* entry from [sorted] first, keeping [sorted] converged to one entry per idHex.
|
||||
*
|
||||
* That convergence isn't enough on its own: a ConcurrentSkipListSet iterator is
|
||||
* only weakly consistent, so under concurrent add/remove churn a single
|
||||
* traversal can momentarily surface a key twice (a lazy-deleted node not yet
|
||||
* unlinked while its replacement is inserted). The emitted list must never carry
|
||||
* a duplicate idHex — the LazyColumn keyed on it would crash — so [snapshot]
|
||||
* deduplicates by idHex as it materializes.
|
||||
*/
|
||||
class NoteListMatchingFilter(
|
||||
private val filter: Filter,
|
||||
private val atOnce: (filter: Filter) -> SortedSet<Note>,
|
||||
private val update: (List<Note>) -> Unit,
|
||||
) : Observable {
|
||||
var currentResults: ConcurrentSkipListSet<Note> = ConcurrentSkipListSet(CreatedAtIdHexComparator)
|
||||
/** A note plus the sort key captured at insertion time, so ordering never depends on mutable state. */
|
||||
private class Entry(
|
||||
val note: Note,
|
||||
val createdAt: Long,
|
||||
val id: HexKey,
|
||||
)
|
||||
|
||||
// created_at descending, id ascending as a stable tiebreak. Both fields are
|
||||
// immutable snapshots, so an Entry never moves once inserted.
|
||||
private val order =
|
||||
Comparator<Entry> { a, b ->
|
||||
val byCreatedAt = b.createdAt.compareTo(a.createdAt)
|
||||
if (byCreatedAt != 0) byCreatedAt else a.id.compareTo(b.id)
|
||||
}
|
||||
|
||||
private val sorted = ConcurrentSkipListSet(order)
|
||||
private val byId = ConcurrentHashMap<HexKey, Entry>()
|
||||
|
||||
private fun entryFor(note: Note): Entry {
|
||||
// A null event (unresolved note) sorts last, matching CreatedAtIdHexComparator.
|
||||
val event = note.event
|
||||
return Entry(note, note.createdAt() ?: Long.MIN_VALUE, event?.id ?: note.idHex)
|
||||
}
|
||||
|
||||
override fun new(
|
||||
event: Event,
|
||||
@@ -47,26 +101,55 @@ class NoteListMatchingFilter(
|
||||
) {
|
||||
if (event is AddressableEvent && note !is AddressableNote) return
|
||||
|
||||
if (filter.match(event)) {
|
||||
if (currentResults.add(note)) {
|
||||
val limit = filter.limit
|
||||
if (limit != null && currentResults.size > limit) {
|
||||
currentResults.remove(currentResults.last())
|
||||
}
|
||||
if (!filter.match(event)) return
|
||||
|
||||
update(currentResults.toList())
|
||||
// Add to [sorted] atomically with claiming the idHex slot. New versions
|
||||
// of an already listed note return the existing entry untouched.
|
||||
var added = false
|
||||
byId.compute(note.idHex) { _, existing ->
|
||||
existing ?: entryFor(note).also {
|
||||
sorted.add(it)
|
||||
added = true
|
||||
}
|
||||
}
|
||||
if (!added) return
|
||||
|
||||
val limit = filter.limit
|
||||
if (limit != null && sorted.size > limit) {
|
||||
// Drop the oldest (sorts last under [order]).
|
||||
sorted.pollLast()?.let { byId.remove(it.note.idHex, it) }
|
||||
}
|
||||
|
||||
update(snapshot())
|
||||
}
|
||||
|
||||
override fun remove(note: Note) {
|
||||
if (currentResults.remove(note)) {
|
||||
update(currentResults.toList())
|
||||
// Remove from [sorted] atomically with releasing the idHex slot.
|
||||
var removed = false
|
||||
byId.compute(note.idHex) { _, existing ->
|
||||
if (existing != null) {
|
||||
sorted.remove(existing)
|
||||
removed = true
|
||||
}
|
||||
null
|
||||
}
|
||||
if (removed) update(snapshot())
|
||||
}
|
||||
|
||||
fun init() {
|
||||
currentResults = ConcurrentSkipListSet(atOnce(filter))
|
||||
update(currentResults.toList())
|
||||
sorted.clear()
|
||||
byId.clear()
|
||||
atOnce(filter).forEach { note ->
|
||||
byId.computeIfAbsent(note.idHex) { entryFor(note).also { sorted.add(it) } }
|
||||
}
|
||||
update(snapshot())
|
||||
}
|
||||
|
||||
private fun snapshot(): List<Note> {
|
||||
// Dedup by idHex: the weakly-consistent iterator can transiently surface a
|
||||
// key twice under concurrent churn. Keeping the first (newest position) is
|
||||
// correct — both nodes point at the same note.
|
||||
val seen = HashSet<HexKey>()
|
||||
return sorted.mapNotNull { e -> e.note.takeIf { seen.add(it.idHex) } }
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -25,6 +25,20 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
|
||||
|
||||
object BlossomAuth {
|
||||
/**
|
||||
* BUD-01 read auth (`t=get`). Servers that gate downloads (e.g. Buzz's
|
||||
* private media relay) require this on `GET /<sha256>`. The [servers] list
|
||||
* adds BUD-11 `server` tags so a single token can be scoped to a whole host
|
||||
* (which also covers derived blobs like `.thumb.jpg` whose hash differs
|
||||
* from [hash]).
|
||||
*/
|
||||
suspend fun createGetAuth(
|
||||
hash: HexKey,
|
||||
alt: String,
|
||||
signer: NostrSigner,
|
||||
servers: List<String> = emptyList(),
|
||||
): String = BlossomAuthorizationEvent.createGetAuth(hash, alt, signer, servers).toAuthorizationHeader()
|
||||
|
||||
suspend fun createUploadAuth(
|
||||
hash: HexKey,
|
||||
size: Long,
|
||||
|
||||
+98
-6
@@ -87,6 +87,28 @@ actual class SecureKeyStorage private actual constructor() {
|
||||
private var fallbackPassword: String? = null
|
||||
private val fallbackMutex = Mutex() // Protects concurrent access to fallback file
|
||||
|
||||
/**
|
||||
* Cached Keyring instance. Opening a Keyring session is expensive and, on
|
||||
* some OSes (notably macOS and locked GNOME/KWallet sessions), triggers a
|
||||
* user-visible unlock prompt every time. Callers hit the storage at least
|
||||
* twice on cold start (metadata AES key, then active account nsec), so a
|
||||
* per-call [Keyring.create] would prompt the user twice on startup — the
|
||||
* exact bug this cache fixes.
|
||||
*
|
||||
* Guarded by [keyringLock] so probing/opening the backend happens exactly
|
||||
* once per process; the [Keyring] itself is thread-safe once obtained.
|
||||
*/
|
||||
@Volatile
|
||||
private var cachedKeyring: KeyringHandle? = null
|
||||
private val keyringLock = Any()
|
||||
|
||||
/**
|
||||
* Package-private factory used by tests to inject a stub Keyring backend
|
||||
* and count backend-open invocations. Production code always defers to
|
||||
* [Keyring.create] through [RealKeyringHandle].
|
||||
*/
|
||||
internal var keyringFactory: () -> KeyringHandle = { RealKeyringHandle(Keyring.create()) }
|
||||
|
||||
actual suspend fun savePrivateKey(
|
||||
npub: String,
|
||||
privKeyHex: String,
|
||||
@@ -146,26 +168,40 @@ actual class SecureKeyStorage private actual constructor() {
|
||||
actual suspend fun hasPrivateKey(npub: String): Boolean = getPrivateKey(npub) != null
|
||||
|
||||
// Keyring-based storage
|
||||
|
||||
/**
|
||||
* Returns the process-wide [Keyring] instance, opening the OS-native
|
||||
* backend on first call. Subsequent calls reuse the same handle so the
|
||||
* user is only prompted (macOS Keychain Access, Secret Service unlock,
|
||||
* KWallet unlock) once per app run.
|
||||
*
|
||||
* Callers must handle [BackendNotSupportedException] — it can escape on
|
||||
* the very first call if no backend is available at all.
|
||||
*/
|
||||
private fun keyring(): KeyringHandle {
|
||||
cachedKeyring?.let { return it }
|
||||
return synchronized(keyringLock) {
|
||||
cachedKeyring ?: keyringFactory().also { cachedKeyring = it }
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveToKeyring(
|
||||
npub: String,
|
||||
privKeyHex: String,
|
||||
) {
|
||||
val keyring = Keyring.create()
|
||||
keyring.setPassword(SERVICE_NAME, npub, privKeyHex)
|
||||
keyring().setPassword(SERVICE_NAME, npub, privKeyHex)
|
||||
}
|
||||
|
||||
private fun getFromKeyring(npub: String): String? =
|
||||
try {
|
||||
val keyring = Keyring.create()
|
||||
keyring.getPassword(SERVICE_NAME, npub)
|
||||
keyring().getPassword(SERVICE_NAME, npub)
|
||||
} catch (e: PasswordAccessException) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun deleteFromKeyring(npub: String): Boolean =
|
||||
try {
|
||||
val keyring = Keyring.create()
|
||||
keyring.deletePassword(SERVICE_NAME, npub)
|
||||
keyring().deletePassword(SERVICE_NAME, npub)
|
||||
true
|
||||
} catch (e: PasswordAccessException) {
|
||||
false
|
||||
@@ -397,3 +433,59 @@ actual class SecureKeyStorage private actual constructor() {
|
||||
return String(decrypted)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Small package-private abstraction over `com.github.javakeyring.Keyring`,
|
||||
* mirroring the three operations `SecureKeyStorage` actually uses. The real
|
||||
* implementation is a thin delegator; tests substitute an in-memory version
|
||||
* so the desktop unit test suite doesn't touch the OS Keychain (which would
|
||||
* be non-hermetic and slow, and on macOS would surface a user-visible prompt
|
||||
* during test runs).
|
||||
*
|
||||
* Not part of the public API — kept in this file so it stays private to the
|
||||
* keystorage package.
|
||||
*/
|
||||
internal interface KeyringHandle {
|
||||
@Throws(PasswordAccessException::class)
|
||||
fun getPassword(
|
||||
service: String,
|
||||
account: String,
|
||||
): String
|
||||
|
||||
@Throws(PasswordAccessException::class)
|
||||
fun setPassword(
|
||||
service: String,
|
||||
account: String,
|
||||
password: String,
|
||||
)
|
||||
|
||||
@Throws(PasswordAccessException::class)
|
||||
fun deletePassword(
|
||||
service: String,
|
||||
account: String,
|
||||
)
|
||||
}
|
||||
|
||||
internal class RealKeyringHandle(
|
||||
private val keyring: Keyring,
|
||||
) : KeyringHandle {
|
||||
override fun getPassword(
|
||||
service: String,
|
||||
account: String,
|
||||
): String = keyring.getPassword(service, account)
|
||||
|
||||
override fun setPassword(
|
||||
service: String,
|
||||
account: String,
|
||||
password: String,
|
||||
) {
|
||||
keyring.setPassword(service, account, password)
|
||||
}
|
||||
|
||||
override fun deletePassword(
|
||||
service: String,
|
||||
account: String,
|
||||
) {
|
||||
keyring.deletePassword(service, account)
|
||||
}
|
||||
}
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.keystorage
|
||||
|
||||
import com.github.javakeyring.PasswordAccessException
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* Regression tests for the desktop `SecureKeyStorage` keyring-instance cache.
|
||||
*
|
||||
* Prior to the fix, every `savePrivateKey` / `getPrivateKey` / `deletePrivateKey`
|
||||
* call opened a fresh `Keyring.create()` handle. On macOS, each fresh handle
|
||||
* incurs a Security Framework session open; on GNOME/KWallet a fresh handle can
|
||||
* re-trigger the OS unlock prompt. The Amethyst cold-boot path calls the
|
||||
* storage at least twice (metadata AES key, then the active account nsec), so
|
||||
* the user was seeing the keychain unlock prompt twice on startup.
|
||||
*
|
||||
* These tests pin the invariant that at most one `Keyring` is opened for the
|
||||
* process lifetime of a `SecureKeyStorage` instance, no matter how many
|
||||
* save/get/delete calls happen.
|
||||
*/
|
||||
class SecureKeyStorageKeyringCacheTest {
|
||||
private class InMemoryKeyring : KeyringHandle {
|
||||
private val store: ConcurrentHashMap<Pair<String, String>, String> = ConcurrentHashMap()
|
||||
|
||||
override fun getPassword(
|
||||
service: String,
|
||||
account: String,
|
||||
): String = store[service to account] ?: throw PasswordAccessException("no entry")
|
||||
|
||||
override fun setPassword(
|
||||
service: String,
|
||||
account: String,
|
||||
password: String,
|
||||
) {
|
||||
store[service to account] = password
|
||||
}
|
||||
|
||||
override fun deletePassword(
|
||||
service: String,
|
||||
account: String,
|
||||
) {
|
||||
if (store.remove(service to account) == null) {
|
||||
throw PasswordAccessException("no entry")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun newStorage(counter: AtomicInteger): SecureKeyStorage {
|
||||
val storage = SecureKeyStorage.create()
|
||||
storage.keyringFactory = {
|
||||
counter.incrementAndGet()
|
||||
InMemoryKeyring()
|
||||
}
|
||||
return storage
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single instance across many save-get-delete calls (double-prompt regression)`() =
|
||||
runBlocking {
|
||||
val opens = AtomicInteger(0)
|
||||
val storage = newStorage(opens)
|
||||
|
||||
// Simulate the cold-boot storm: metadata key + active-account nsec
|
||||
// + a handful of subsequent NWC / bunker ephemeral key touches.
|
||||
storage.savePrivateKey("account-metadata-key", "aaaa")
|
||||
assertEquals("aaaa", storage.getPrivateKey("account-metadata-key"))
|
||||
storage.savePrivateKey("npub1alice", "bbbb")
|
||||
assertEquals("bbbb", storage.getPrivateKey("npub1alice"))
|
||||
storage.savePrivateKey("bunker-ephemeral-npub1alice", "cccc")
|
||||
assertEquals("cccc", storage.getPrivateKey("bunker-ephemeral-npub1alice"))
|
||||
assertEquals(true, storage.deletePrivateKey("bunker-ephemeral-npub1alice"))
|
||||
assertNull(storage.getPrivateKey("bunker-ephemeral-npub1alice"))
|
||||
|
||||
// The cache must open the keyring exactly once for the process
|
||||
// lifetime; each additional Keyring.create() call would surface as
|
||||
// an OS-level unlock prompt on macOS / GNOME / KWallet.
|
||||
assertEquals(
|
||||
"SecureKeyStorage must open Keyring exactly once per process",
|
||||
1,
|
||||
opens.get(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasPrivateKey reuses the cached keyring`() =
|
||||
runBlocking {
|
||||
val opens = AtomicInteger(0)
|
||||
val storage = newStorage(opens)
|
||||
|
||||
storage.savePrivateKey("npub1x", "1111")
|
||||
repeat(5) {
|
||||
assertEquals(true, storage.hasPrivateKey("npub1x"))
|
||||
assertEquals(false, storage.hasPrivateKey("npub1missing"))
|
||||
}
|
||||
assertEquals(1, opens.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `concurrent first-touches still open the keyring exactly once`() =
|
||||
runBlocking {
|
||||
val opens = AtomicInteger(0)
|
||||
val storage = newStorage(opens)
|
||||
|
||||
val threads =
|
||||
List(16) { idx ->
|
||||
Thread {
|
||||
runBlocking {
|
||||
// Alternate between get and save so both call paths race to
|
||||
// grab the cached keyring on the first invocation.
|
||||
if (idx % 2 == 0) {
|
||||
storage.getPrivateKey("npub1concurrent-$idx")
|
||||
} else {
|
||||
storage.savePrivateKey("npub1concurrent-$idx", "0x$idx")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
threads.forEach { it.start() }
|
||||
threads.forEach { it.join() }
|
||||
|
||||
// Race-free single-open guarantee under contention.
|
||||
assertEquals(
|
||||
"Concurrent first-touches must not open the Keyring more than once",
|
||||
1,
|
||||
opens.get(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.observables
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.utils.EventFactory
|
||||
import java.util.TreeSet
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class EventListMatchingFilterTest {
|
||||
private val author = "d0d0a746b44c9de8422165aef520b1fe041eedf5794f7592505477eeac122c18"
|
||||
|
||||
private val filter = Filter(kinds = listOf(AppDefinitionEvent.KIND))
|
||||
|
||||
private fun noteFor(dTag: String) = AddressableNote(Address(AppDefinitionEvent.KIND, author, dTag))
|
||||
|
||||
private fun appDefinition(
|
||||
dTag: String,
|
||||
createdAt: Long,
|
||||
): Event =
|
||||
EventFactory.create(
|
||||
// Unique per (dTag, createdAt): 8 hex of the dTag hash + 56 hex of createdAt,
|
||||
// so distinct addresses never collide on an event id.
|
||||
id = "%08x".format(dTag.hashCode()) + "%056x".format(createdAt),
|
||||
pubKey = author,
|
||||
createdAt = createdAt,
|
||||
kind = AppDefinitionEvent.KIND,
|
||||
tags = arrayOf(arrayOf("d", dTag)),
|
||||
content = "{}",
|
||||
sig = "00".repeat(64),
|
||||
)
|
||||
|
||||
private fun AddressableNote.load(createdAt: Long): Event = appDefinition(dTag(), createdAt).also { this.event = it }
|
||||
|
||||
private fun newFilter(
|
||||
withFilter: Filter = filter,
|
||||
sink: (List<Event>) -> Unit,
|
||||
) = EventListMatchingFilter<Event>(
|
||||
filter = withFilter,
|
||||
atOnce = { TreeSet(CreatedAtIdHexComparator) },
|
||||
update = sink,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun newerVersionReflectsUpdatedEventWithoutDuplicate() {
|
||||
var last: List<Event> = emptyList()
|
||||
val subject = newFilter { last = it }
|
||||
subject.init()
|
||||
|
||||
// A crowd of app definitions, so a stale skip-set node could be bypassed.
|
||||
val target = noteFor("nostr-dvm-labeler")
|
||||
val other = noteFor("other-app")
|
||||
val top = noteFor("top-app")
|
||||
|
||||
val v1 = target.load(1000)
|
||||
subject.new(v1, target)
|
||||
subject.new(other.load(2000), other)
|
||||
subject.new(top.load(4000), top)
|
||||
|
||||
// Newer version replaces the event on the SAME instance, created_at 1000 -> 3000.
|
||||
val v2 = target.load(3000)
|
||||
subject.new(v2, target)
|
||||
|
||||
// Exactly one entry for the target, and it is the NEW version (reflected + re-sorted).
|
||||
assertEquals(3, last.size, "no duplicate event for the updated addressable")
|
||||
assertEquals(1, last.count { it.id == v2.id }, "the updated addressable appears exactly once")
|
||||
assertEquals(0, last.count { it.id == v1.id }, "the old version is gone")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listStaysSortedByCreatedAtDescending() {
|
||||
var last: List<Event> = emptyList()
|
||||
val subject = newFilter { last = it }
|
||||
subject.init()
|
||||
|
||||
val a = noteFor("app-a")
|
||||
val b = noteFor("app-b")
|
||||
val c = noteFor("app-c")
|
||||
|
||||
val ea = a.load(2000)
|
||||
subject.new(ea, a)
|
||||
val eb = b.load(4000)
|
||||
subject.new(eb, b)
|
||||
val ec = c.load(1000)
|
||||
subject.new(ec, c)
|
||||
|
||||
assertEquals(listOf(eb.id, ea.id, ec.id), last.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun versionNoteReEmitsWhenAddressableIsListed() {
|
||||
val emissions = mutableListOf<List<Event>>()
|
||||
val subject = newFilter { emissions.add(it) }
|
||||
subject.init()
|
||||
|
||||
val target = noteFor("nostr-dvm-labeler")
|
||||
val event = target.load(1000)
|
||||
subject.new(event, target)
|
||||
val countAfterInsert = emissions.size
|
||||
|
||||
// The "version" note: a regular Note holding the addressable event.
|
||||
val versionNote = Note(event.id).apply { this.event = event }
|
||||
subject.new(event, versionNote)
|
||||
|
||||
// It re-emits (addressable is listed) but never adds a second entry.
|
||||
assertEquals(countAfterInsert + 1, emissions.size, "version note triggers a re-emit")
|
||||
assertEquals(listOf(event.id), emissions.last().map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removeDropsTheEventEvenAfterCreatedAtChanged() {
|
||||
var last: List<Event> = emptyList()
|
||||
val subject = newFilter { last = it }
|
||||
subject.init()
|
||||
|
||||
val target = noteFor("nostr-dvm-labeler")
|
||||
subject.new(target.load(1000), target)
|
||||
assertEquals(1, last.size)
|
||||
|
||||
target.load(2000) // sort key moves before the delete arrives
|
||||
subject.remove(target)
|
||||
assertEquals(0, last.size, "remove finds the event despite the created_at change")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun concurrentUpdatesNeverEmitDuplicateEvents() {
|
||||
assertNoDuplicateUnderConcurrency(filter)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun concurrentUpdatesWithLimitNeverEmitDuplicateEvents() {
|
||||
assertNoDuplicateUnderConcurrency(Filter(kinds = listOf(AppDefinitionEvent.KIND), limit = 5))
|
||||
}
|
||||
|
||||
private fun assertNoDuplicateUnderConcurrency(withFilter: Filter) {
|
||||
val firstViolation = AtomicReference<List<String>?>(null)
|
||||
val subject =
|
||||
newFilter(withFilter) { emitted ->
|
||||
val ids = emitted.map { it.id }
|
||||
if (ids.size != ids.toSet().size) {
|
||||
firstViolation.compareAndSet(null, ids)
|
||||
}
|
||||
}
|
||||
subject.init()
|
||||
|
||||
val addresses = (0 until 12).map { "app-$it" }
|
||||
val notes = addresses.associateWith { noteFor(it) }
|
||||
val threadCount = 8
|
||||
val iterations = 5_000
|
||||
val start = CountDownLatch(1)
|
||||
|
||||
val threads =
|
||||
(0 until threadCount).map { t ->
|
||||
thread {
|
||||
start.await()
|
||||
var seed = t * 31 + 7
|
||||
repeat(iterations) { i ->
|
||||
seed = seed * 1103515245 + 12345
|
||||
val note = notes.getValue(addresses[(seed ushr 16) % addresses.size])
|
||||
val event = note.load(1_000L + (i % 9))
|
||||
if ((seed ushr 8) % 3 == 0) {
|
||||
subject.remove(note)
|
||||
} else {
|
||||
subject.new(event, note)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
start.countDown()
|
||||
threads.forEach { it.join() }
|
||||
|
||||
assertNull(firstViolation.get(), "an emission carried a duplicate event id: ${firstViolation.get()}")
|
||||
}
|
||||
}
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.observables
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.utils.EventFactory
|
||||
import java.util.TreeSet
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class NoteListMatchingFilterTest {
|
||||
private val author = "d0d0a746b44c9de8422165aef520b1fe041eedf5794f7592505477eeac122c18"
|
||||
|
||||
private val filter = Filter(kinds = listOf(AppDefinitionEvent.KIND))
|
||||
|
||||
// Amethyst reuses a single AddressableNote instance per address (LocalCache),
|
||||
// so a newer replaceable event mutates createdAt on the SAME object.
|
||||
private fun noteFor(dTag: String) = AddressableNote(Address(AppDefinitionEvent.KIND, author, dTag))
|
||||
|
||||
private fun appDefinition(
|
||||
dTag: String,
|
||||
createdAt: Long,
|
||||
): Event =
|
||||
EventFactory.create(
|
||||
id = "%064x".format(createdAt),
|
||||
pubKey = author,
|
||||
createdAt = createdAt,
|
||||
kind = AppDefinitionEvent.KIND,
|
||||
tags = arrayOf(arrayOf("d", dTag)),
|
||||
content = "{}",
|
||||
sig = "00".repeat(64),
|
||||
)
|
||||
|
||||
private fun AddressableNote.load(createdAt: Long) {
|
||||
event = appDefinition(dTag(), createdAt)
|
||||
}
|
||||
|
||||
private fun newFilter(
|
||||
withFilter: Filter = filter,
|
||||
sink: (List<Note>) -> Unit,
|
||||
) = NoteListMatchingFilter(
|
||||
filter = withFilter,
|
||||
atOnce = { TreeSet(CreatedAtIdHexComparator) },
|
||||
update = sink,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun newerVersionOfAnAddressableDoesNotDuplicateTheKey() {
|
||||
var last: List<Note> = emptyList()
|
||||
val subject = newFilter { last = it }
|
||||
subject.init()
|
||||
|
||||
// Three app definitions arrive. Their createdAt spread matters: after the
|
||||
// target moves, the sorted set's search path for the new key must be able
|
||||
// to bypass the stale node, which is what corrupts a createdAt-ordered set.
|
||||
val target = noteFor("nostr-dvm-labeler")
|
||||
val newer = noteFor("other-app")
|
||||
val newest = noteFor("top-app")
|
||||
|
||||
target.load(1000)
|
||||
subject.new(target.event!!, target)
|
||||
newer.load(2000)
|
||||
subject.new(newer.event!!, newer)
|
||||
newest.load(4000)
|
||||
subject.new(newest.event!!, newest)
|
||||
assertEquals(3, last.size)
|
||||
|
||||
// A newer definition replaces the event on the SAME target instance,
|
||||
// moving its createdAt from 1000 to 3000 (now between 2000 and 4000).
|
||||
// LocalCache then notifies the observer again. This must NOT insert the
|
||||
// note a second time.
|
||||
target.load(3000)
|
||||
subject.new(target.event!!, target)
|
||||
|
||||
assertEquals(
|
||||
listOf(newest.idHex, newer.idHex, target.idHex).sorted(),
|
||||
last.map { it.idHex }.sorted(),
|
||||
"each addressable must appear exactly once",
|
||||
)
|
||||
assertEquals(last.size, last.map { it.idHex }.toSet().size, "no duplicate keys")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listStaysSortedByCreatedAtDescendingAsNotesArriveOutOfOrder() {
|
||||
var last: List<Note> = emptyList()
|
||||
val subject = newFilter { last = it }
|
||||
subject.init()
|
||||
|
||||
val a = noteFor("app-a")
|
||||
val b = noteFor("app-b")
|
||||
val c = noteFor("app-c")
|
||||
|
||||
// Arrive out of order; the emitted list must always be newest-first.
|
||||
a.load(2000)
|
||||
subject.new(a.event!!, a)
|
||||
b.load(4000)
|
||||
subject.new(b.event!!, b)
|
||||
c.load(1000)
|
||||
subject.new(c.event!!, c)
|
||||
|
||||
assertEquals(listOf(b.idHex, a.idHex, c.idHex), last.map { it.idHex })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun concurrentNewRemoveNeverEmitsDuplicateKeys() {
|
||||
// No limit: exercises the compute/remove per-key critical sections.
|
||||
assertNoDuplicateUnderConcurrency(filter)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun concurrentNewRemoveWithLimitNeverEmitsDuplicateKeys() {
|
||||
// With a limit: also exercises the cross-key eviction (pollLast + byId.remove).
|
||||
assertNoDuplicateUnderConcurrency(Filter(kinds = listOf(AppDefinitionEvent.KIND), limit = 5))
|
||||
}
|
||||
|
||||
private fun assertNoDuplicateUnderConcurrency(withFilter: Filter) {
|
||||
// Observer callbacks fire from several consume threads at once (relay
|
||||
// ingest + UI-side justConsume). new()/remove() for the same idHex must
|
||||
// keep the sorted index and the membership map consistent, or a duplicate
|
||||
// idHex leaks into an emission and crashes the LazyColumn.
|
||||
val firstViolation = AtomicReference<List<String>?>(null)
|
||||
val subject =
|
||||
newFilter(withFilter) { emitted ->
|
||||
val ids = emitted.map { it.idHex }
|
||||
if (ids.size != ids.toSet().size) {
|
||||
firstViolation.compareAndSet(null, ids)
|
||||
}
|
||||
}
|
||||
subject.init()
|
||||
|
||||
val addresses = (0 until 12).map { "app-$it" }
|
||||
val notes = addresses.associateWith { noteFor(it) }
|
||||
val threadCount = 8
|
||||
val iterations = 5_000
|
||||
val start = CountDownLatch(1)
|
||||
|
||||
val threads =
|
||||
(0 until threadCount).map { t ->
|
||||
thread {
|
||||
start.await()
|
||||
var seed = t * 31 + 7
|
||||
repeat(iterations) { i ->
|
||||
seed = seed * 1103515245 + 12345
|
||||
val note = notes.getValue(addresses[(seed ushr 16) % addresses.size])
|
||||
// Move created_at around so the sort key keeps changing under the set.
|
||||
note.event = appDefinition(note.dTag(), 1_000L + (i % 9))
|
||||
if ((seed ushr 8) % 3 == 0) {
|
||||
subject.remove(note)
|
||||
} else {
|
||||
subject.new(note.event!!, note)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
start.countDown()
|
||||
threads.forEach { it.join() }
|
||||
|
||||
assertNull(firstViolation.get(), "an emission carried a duplicate idHex: ${firstViolation.get()}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresTheVersionNoteThatHoldsAnAddressableEvent() {
|
||||
// consumeBaseReplaceable also notifies observers with the "version" note:
|
||||
// getOrCreateNote(event.id), a regular Note (not AddressableNote) carrying
|
||||
// the AddressableEvent. It must never enter the addressable list.
|
||||
var last: List<Note> = emptyList()
|
||||
val subject = newFilter { last = it }
|
||||
subject.init()
|
||||
|
||||
val event = appDefinition("nostr-dvm-labeler", 1000)
|
||||
val versionNote = Note(event.id).apply { this.event = event }
|
||||
subject.new(event, versionNote)
|
||||
|
||||
assertEquals(emptyList(), last)
|
||||
|
||||
// The addressable note for the same event, however, is listed.
|
||||
val addressable = noteFor("nostr-dvm-labeler").apply { this.event = event }
|
||||
subject.new(event, addressable)
|
||||
assertEquals(listOf(addressable.idHex), last.map { it.idHex })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removeDropsTheNoteEvenAfterCreatedAtChanged() {
|
||||
var last: List<Note> = emptyList()
|
||||
val subject = newFilter { last = it }
|
||||
subject.init()
|
||||
|
||||
val target = noteFor("nostr-dvm-labeler")
|
||||
target.load(1000)
|
||||
subject.new(target.event!!, target)
|
||||
assertEquals(1, last.size)
|
||||
|
||||
// The createdAt sort key moves before the delete arrives.
|
||||
target.load(2000)
|
||||
subject.remove(target)
|
||||
|
||||
assertEquals(0, last.size, "remove must find the note despite the createdAt change")
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,8 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideMaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.moderation.LocalHashtagSpamSettings
|
||||
import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys
|
||||
import com.vitorpamplona.amethyst.commons.moderation.PreferencesHashtagSpamSettings
|
||||
import com.vitorpamplona.amethyst.commons.moderation.notifications.PreferencesNotificationReadState
|
||||
import com.vitorpamplona.amethyst.commons.moderation.notifications.PreferencesNotificationSettings
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalBanner
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.DmInboxRelayResolver
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull
|
||||
@@ -131,6 +133,9 @@ import com.vitorpamplona.amethyst.desktop.ui.deck.param
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
|
||||
import com.vitorpamplona.amethyst.desktop.ui.notifications.LocalNotificationDispatcher
|
||||
import com.vitorpamplona.amethyst.desktop.ui.notifications.LocalNotificationReadState
|
||||
import com.vitorpamplona.amethyst.desktop.ui.notifications.LocalNotificationSettings
|
||||
import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard
|
||||
@@ -1262,10 +1267,25 @@ private fun AppInner(
|
||||
// Auto-dispatcher: subscribes to newEventBundles and fires OS toasts.
|
||||
// Only starts once the user is logged in — pubKey and settings must exist.
|
||||
val loggedIn = accountState as? AccountState.LoggedIn
|
||||
// Single, process-wide NotificationSettings instance. Hoisted here (not
|
||||
// inside NotificationSettingsScreen / NotificationsScreen) so the
|
||||
// settings UI, the inbox column's OS-toasts banner, and the auto-
|
||||
// dispatcher all read + write the same MutableStateFlow. Without this,
|
||||
// each screen fell back to its own PreferencesNotificationSettings()
|
||||
// instance — the disk-backed prefs stayed in sync, but the in-memory
|
||||
// StateFlow updates never crossed instances, so toggling the master
|
||||
// switch or granting permission never actually notified the auto-
|
||||
// dispatcher until the app restarted.
|
||||
val notifSettings =
|
||||
remember {
|
||||
com.vitorpamplona.amethyst.commons.moderation.notifications
|
||||
.PreferencesNotificationSettings()
|
||||
remember { PreferencesNotificationSettings() }
|
||||
// Per-account read-state for the notification inbox. Keyed on
|
||||
// pubKey so switching accounts gets a fresh cursor. `remember` here
|
||||
// is intentionally keyed on pubKeyHex; a null pubKey (Loading /
|
||||
// LoggedOut branches never render the settings screen anyway, but
|
||||
// guard against a stale ReadState leaking between accounts).
|
||||
val notifReadState =
|
||||
remember(loggedIn?.pubKeyHex) {
|
||||
loggedIn?.pubKeyHex?.let { pk -> PreferencesNotificationReadState(pk) }
|
||||
}
|
||||
DisposableEffect(loggedIn?.pubKeyHex, notifDispatcher, localCache) {
|
||||
val myPk = loggedIn?.pubKeyHex
|
||||
@@ -1293,7 +1313,9 @@ private fun AppInner(
|
||||
LocalScheduledPostStore provides scheduledPostStore,
|
||||
com.vitorpamplona.amethyst.desktop.service.drafts.LocalNoteDraftStore provides noteDraftStore,
|
||||
LocalHashtagSpamSettings provides hashtagSpamSettings,
|
||||
com.vitorpamplona.amethyst.desktop.ui.notifications.LocalNotificationDispatcher provides notifDispatcher,
|
||||
LocalNotificationDispatcher provides notifDispatcher,
|
||||
LocalNotificationSettings provides notifSettings,
|
||||
LocalNotificationReadState provides notifReadState,
|
||||
) {
|
||||
when (accountState) {
|
||||
is AccountState.Loading -> {
|
||||
@@ -1451,37 +1473,52 @@ private fun AppInner(
|
||||
modifier = bannerModifier,
|
||||
)
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
MainContent(
|
||||
layoutMode = layoutMode,
|
||||
deckState = deckState,
|
||||
workspaceManager = workspaceManager,
|
||||
singlePaneState = singlePaneState,
|
||||
pinnedNavBarState = pinnedNavBarState,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
accountManager = accountManager,
|
||||
account = account,
|
||||
iAccount = iAccount,
|
||||
accountRelays = accountRelays,
|
||||
dmSendTracker = dmSendTracker,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
indexRelaysStore = indexRelaysStore,
|
||||
nip11Fetcher = nip11Fetcher,
|
||||
dmInboxResolver = dmInboxResolver,
|
||||
appScope = scope,
|
||||
torStatus = currentTorStatus,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onEditInComposer = onEditInComposer,
|
||||
onShowAppDrawer = onShowAppDrawer,
|
||||
onOpenFeedsDrawer = {
|
||||
appDrawerInitialTab =
|
||||
com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab.FEEDS
|
||||
onShowAppDrawer()
|
||||
},
|
||||
onShowImportFollowListDialog = onShowImportFollowListDialog,
|
||||
)
|
||||
// Force a Compose subtree teardown when the active
|
||||
// account changes. Without this, the currently-open
|
||||
// column keeps its account-A `remember { ... }`
|
||||
// state (LazyListState scroll position, expanded
|
||||
// rows, filter-tab selection, in-flight metadata
|
||||
// observers, per-column view-models) even though the
|
||||
// outer `iAccount` / `accountRelays` swap correctly.
|
||||
// Users saw account A's notifications / profile /
|
||||
// messages page rendered under account B's identity
|
||||
// until they navigated away and back. `key(pubKeyHex)`
|
||||
// is the idiomatic Compose way to reset an entire
|
||||
// subtree on identity change while keeping the outer
|
||||
// deck layout / workspace state (declared above) alive.
|
||||
androidx.compose.runtime.key(account.pubKeyHex) {
|
||||
MainContent(
|
||||
layoutMode = layoutMode,
|
||||
deckState = deckState,
|
||||
workspaceManager = workspaceManager,
|
||||
singlePaneState = singlePaneState,
|
||||
pinnedNavBarState = pinnedNavBarState,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
accountManager = accountManager,
|
||||
account = account,
|
||||
iAccount = iAccount,
|
||||
accountRelays = accountRelays,
|
||||
dmSendTracker = dmSendTracker,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
indexRelaysStore = indexRelaysStore,
|
||||
nip11Fetcher = nip11Fetcher,
|
||||
dmInboxResolver = dmInboxResolver,
|
||||
appScope = scope,
|
||||
torStatus = currentTorStatus,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onEditInComposer = onEditInComposer,
|
||||
onShowAppDrawer = onShowAppDrawer,
|
||||
onOpenFeedsDrawer = {
|
||||
appDrawerInitialTab =
|
||||
com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab.FEEDS
|
||||
onShowAppDrawer()
|
||||
},
|
||||
onShowImportFollowListDialog = onShowImportFollowListDialog,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-1
@@ -183,9 +183,20 @@ fun NotificationSettingsScreen(onBack: (() -> Unit)? = null) {
|
||||
} finally {
|
||||
requestingPermission = false
|
||||
}
|
||||
// Match the button label: "Enable OS notifications"
|
||||
// must actually enable them end-to-end. Prior to this,
|
||||
// clicking through the OS prompt granted permission
|
||||
// but left the master toggle OFF, so the auto-
|
||||
// dispatcher stayed muted and the user had to hunt
|
||||
// for the switch above. Flip it here on grant — the
|
||||
// switch UI observes settings.enabled and
|
||||
// recomposes automatically.
|
||||
if (newState == PermissionState.Granted && !enabled) {
|
||||
settings.setEnabled(true)
|
||||
}
|
||||
testStatus =
|
||||
when (newState) {
|
||||
PermissionState.Granted -> "Permission granted. Try the test toast below."
|
||||
PermissionState.Granted -> "Permission granted. Notifications are on — try the test toast below."
|
||||
PermissionState.Denied -> "Permission denied. Enable in System Settings if you change your mind."
|
||||
PermissionState.BundleRequired -> "Notifications need a bundled app — run `./gradlew :desktopApp:runDistributable`."
|
||||
else -> null
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
Release notes for Amethyst, one file per version. Files are named with zero-padded version numbers so they sort correctly in any file browser. Use [`TEMPLATE.md`](TEMPLATE.md) as the starting point for the next release.
|
||||
|
||||
- [v1.13.1 — Follow-up Fixes for Buzz, Concord and the Keyboard](v1.13.01.md)
|
||||
- [v1.13.0 — Web Apps, Communities & Git](v1.13.00.md)
|
||||
- [v1.12.6 — Napplets, Static Sites & Cashu Multi-Mint](v1.12.06.md)
|
||||
- [v1.12.5 — macOS Desktop Signing Fix (cont.)](v1.12.05.md)
|
||||
|
||||
+42
-103
@@ -75,112 +75,11 @@
|
||||
"rchk": ""
|
||||
},
|
||||
"sinceLastTag": {
|
||||
"tag": "v1.12.6",
|
||||
"since": "2026-06-19T19:01:59-04:00",
|
||||
"tag": "v1.13.0",
|
||||
"since": "2026-07-28T10:52:38-04:00",
|
||||
"translators": [
|
||||
{
|
||||
"user": "vitorpamplona",
|
||||
"languages": [
|
||||
"Arabic, Saudi Arabia",
|
||||
"Bengali",
|
||||
"Chinese Simplified",
|
||||
"Chinese Simplified, Singapore",
|
||||
"Chinese Traditional",
|
||||
"Chinese Traditional, Hong Kong",
|
||||
"Czech",
|
||||
"Dutch",
|
||||
"Esperanto",
|
||||
"Finnish",
|
||||
"French",
|
||||
"French, Canada",
|
||||
"German",
|
||||
"Greek",
|
||||
"Hindi",
|
||||
"Hungarian",
|
||||
"Indonesian",
|
||||
"Italian",
|
||||
"Japanese",
|
||||
"Korean",
|
||||
"Latvian",
|
||||
"Persian",
|
||||
"Polish",
|
||||
"Portuguese",
|
||||
"Portuguese, Brazilian",
|
||||
"Russian",
|
||||
"Rսssian, Սkraine",
|
||||
"Serbian (Cyrillic)",
|
||||
"Slovenian",
|
||||
"Spanish",
|
||||
"Spanish, Mexico",
|
||||
"Spanish, United States",
|
||||
"Swahili, Kenya",
|
||||
"Swedish",
|
||||
"Tamil",
|
||||
"Thai",
|
||||
"Turkish",
|
||||
"Ukrainian",
|
||||
"Uzbek",
|
||||
"Vietnamese"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "rajs19420616",
|
||||
"languages": [
|
||||
"Hindi"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "StellarStoic",
|
||||
"languages": [
|
||||
"Slovenian"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "maxblake2015",
|
||||
"languages": [
|
||||
"Polish"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "hypnotichemionus4",
|
||||
"languages": [
|
||||
"Chinese Simplified"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "summoner001",
|
||||
"languages": [
|
||||
"Hungarian"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "anthony-robin",
|
||||
"languages": [
|
||||
"French"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "crowdin.pretended462",
|
||||
"languages": [
|
||||
"German"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "Bardesss",
|
||||
"languages": [
|
||||
"Dutch"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "BitByBit21",
|
||||
"languages": [
|
||||
"Spanish",
|
||||
"Spanish, Mexico",
|
||||
"Spanish, United States"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "davotoula",
|
||||
"languages": [
|
||||
"Czech",
|
||||
"German",
|
||||
@@ -220,6 +119,14 @@
|
||||
"user": "vazw",
|
||||
"languages": []
|
||||
},
|
||||
{
|
||||
"user": "Bardesss",
|
||||
"languages": []
|
||||
},
|
||||
{
|
||||
"user": "anthony-robin",
|
||||
"languages": []
|
||||
},
|
||||
{
|
||||
"user": "Pextar",
|
||||
"languages": []
|
||||
@@ -252,10 +159,18 @@
|
||||
"user": "adhrasreoshiathoi",
|
||||
"languages": []
|
||||
},
|
||||
{
|
||||
"user": "davotoula",
|
||||
"languages": []
|
||||
},
|
||||
{
|
||||
"user": "crackadoo",
|
||||
"languages": []
|
||||
},
|
||||
{
|
||||
"user": "BitByBit21",
|
||||
"languages": []
|
||||
},
|
||||
{
|
||||
"user": "csavastel",
|
||||
"languages": []
|
||||
@@ -312,6 +227,10 @@
|
||||
"user": "flobstr",
|
||||
"languages": []
|
||||
},
|
||||
{
|
||||
"user": "rajs19420616",
|
||||
"languages": []
|
||||
},
|
||||
{
|
||||
"user": "Maxblake",
|
||||
"languages": []
|
||||
@@ -328,10 +247,18 @@
|
||||
"user": "hlcbump",
|
||||
"languages": []
|
||||
},
|
||||
{
|
||||
"user": "StellarStoic",
|
||||
"languages": []
|
||||
},
|
||||
{
|
||||
"user": "D4rkFIow",
|
||||
"languages": []
|
||||
},
|
||||
{
|
||||
"user": "summoner001",
|
||||
"languages": []
|
||||
},
|
||||
{
|
||||
"user": "fiddleway",
|
||||
"languages": []
|
||||
@@ -348,6 +275,14 @@
|
||||
"user": "Coool",
|
||||
"languages": []
|
||||
},
|
||||
{
|
||||
"user": "hypnotichemionus4",
|
||||
"languages": []
|
||||
},
|
||||
{
|
||||
"user": "maxblake2015",
|
||||
"languages": []
|
||||
},
|
||||
{
|
||||
"user": "eiie7",
|
||||
"languages": []
|
||||
@@ -368,6 +303,10 @@
|
||||
"user": "Kevin_1",
|
||||
"languages": []
|
||||
},
|
||||
{
|
||||
"user": "crowdin.pretended462",
|
||||
"languages": []
|
||||
},
|
||||
{
|
||||
"user": "rchk",
|
||||
"languages": []
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# v1.13.1: Follow-up Fixes for Buzz, Concord and the Keyboard
|
||||
|
||||
Highlights:
|
||||
|
||||
- Fixes the soft keyboard getting stuck open in chats.
|
||||
- Fixes duplicate items crashing the Discover and feed lists.
|
||||
- Adds per-event-kind toggles to the Home feed.
|
||||
- Signs Blossom read requests so media on auth-gated hosts loads.
|
||||
|
||||
## New Features
|
||||
|
||||
- Adds per-event-kind toggles for the Home feed.
|
||||
- Signs Blossom read-auth so media hosted on auth-gated servers displays.
|
||||
- Lets admins delete a Buzz channel or relay group.
|
||||
- Honors CORD-02 §9: seals dissolved Concord communities read-only.
|
||||
- Allows single-character (continent) geohash precision in location channels.
|
||||
|
||||
## Performance
|
||||
|
||||
- Preemptively signs Blossom reads for known auth-gated hosts, so the first
|
||||
request succeeds instead of round-tripping through a 401.
|
||||
- Makes the `observeNotes` dedup lock-free and race-safe under concurrent
|
||||
consumption.
|
||||
|
||||
## Improvements and Bug fixes
|
||||
|
||||
- Stops the soft keyboard state from getting stuck open.
|
||||
- Stops nav-bar padding from stacking on top of the IME inset in chats.
|
||||
- Unions nav-bar and IME insets on the two remaining bare-Scaffold forms.
|
||||
- Prevents a duplicate LazyColumn key from `observeNotes` on addressable
|
||||
updates, and keeps the list sorted while deduping by id.
|
||||
- Dedupes Discover apps by coordinate to avoid duplicate LazyGrid keys.
|
||||
- Dedups `EventListMatchingFilter` (`observeEvents`) and hardens its emission
|
||||
contract.
|
||||
- Authenticates to the NIP-29 host relays of joined groups so private group
|
||||
content loads.
|
||||
- Keeps the back arrow visible until the exiting screen finishes leaving.
|
||||
- Bounds the context shown around a highlight to a window, trims edge blank
|
||||
lines, and collapses scraped whitespace.
|
||||
- Attributes a highlight to the author-marked `p` tag and drops alt captions.
|
||||
- Renders custom emoji in highlight comments.
|
||||
- Only tags explicit http(s) URLs as `r` references.
|
||||
- Opens a Concord community by tapping its name, not just its avatar.
|
||||
- Leaves room for the FAB in the Concord community channel list.
|
||||
- Makes the Buzz Messages toggle read the list it claims to change.
|
||||
- Renders the Buzz channel recent-poster facepile with the standard avatar.
|
||||
- Separates Buzz community rows with a hairline and drops the grey slab behind
|
||||
the Channels section.
|
||||
- Aligns leave vs remove-from-messages actions across chat types.
|
||||
- Normalizes top nav bar title font sizes and weights.
|
||||
- Raises the background mobile-data trigger to 500 MB and doubles the
|
||||
report-prompt thresholds.
|
||||
- Removes orphaned `buzz_dm_hide` translations.
|
||||
|
||||
## Desktop
|
||||
|
||||
- Caches the OS Keyring handle so startup only prompts once.
|
||||
- Makes the "Enable OS notifications" button actually enable them.
|
||||
- Reloads the visible page when switching accounts.
|
||||
|
||||
## Quartz
|
||||
|
||||
- Stops Namecoin lookup exceptions from leaking through `resolve()`.
|
||||
- Uses `Hex.isHex64` instead of a regex for the blob-id check.
|
||||
|
||||
## Build & Documentation
|
||||
|
||||
- Resolves the `LocalCache` override, dropdown deprecation, and shadowed
|
||||
extension warnings.
|
||||
- Extracts duplicated string literals.
|
||||
|
||||
## Contributors
|
||||
|
||||
- @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z
|
||||
- @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
|
||||
- mstrofnone
|
||||
|
||||
## Translations
|
||||
|
||||
- Czech, German, Brazilian Portuguese, and Swedish by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
|
||||
+3
-3
@@ -32,7 +32,7 @@ docker run -d --name geode -p 7447:7447 \
|
||||
```
|
||||
|
||||
with `in_memory = false` and `file = "/var/lib/geode/events.db"` in your
|
||||
`geode.toml`. Pin a version (`:1.13.0`) instead of `:latest` for reproducible
|
||||
`geode.toml`. Pin a version (`:1.13.1`) instead of `:latest` for reproducible
|
||||
deploys. To build the image yourself, from the repo root:
|
||||
|
||||
```bash
|
||||
@@ -47,7 +47,7 @@ it — a minimal JRE is bundled, so no system Java is required. It installs to
|
||||
`/opt/geode/` with the launcher at `/opt/geode/bin/geode`.
|
||||
|
||||
```bash
|
||||
sudo dpkg -i geode-1.13.0-linux-x64.deb # or: sudo rpm -i geode-1.13.0-linux-x64.rpm
|
||||
sudo dpkg -i geode-1.13.1-linux-x64.deb # or: sudo rpm -i geode-1.13.1-linux-x64.rpm
|
||||
```
|
||||
|
||||
To run it as a managed service, wire up the shipped systemd unit
|
||||
@@ -69,7 +69,7 @@ formula: [`packaging/homebrew/geode.rb`](packaging/homebrew/geode.rb).
|
||||
Download `geode-<version>-<os>-<arch>.tar.gz`, unpack, and run:
|
||||
|
||||
```bash
|
||||
tar xzf geode-1.13.0-linux-x64.tar.gz
|
||||
tar xzf geode-1.13.1-linux-x64.tar.gz
|
||||
./geode/bin/geode --config geode/share/geode/config.example.toml
|
||||
```
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# Amethyst app version — single source of truth consumed by both Android (amethyst/)
|
||||
# and Desktop (desktopApp/). `appCode` is the Android versionCode: a monotonic
|
||||
# integer that must increment on every release, even when `app` is unchanged.
|
||||
app = "1.13.0"
|
||||
appCode = "455"
|
||||
app = "1.13.1"
|
||||
appCode = "456"
|
||||
accompanistAdaptive = "0.37.3"
|
||||
cachemapVersion = "0.2.4"
|
||||
composeMultiplatform = "1.11.1"
|
||||
|
||||
-4
@@ -21,7 +21,6 @@
|
||||
package com.vitorpamplona.quartz.buzz.workspace
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
@@ -38,9 +37,6 @@ import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
* (emit_group_discovery_events).
|
||||
*/
|
||||
|
||||
/** The Buzz channel type from the relay's `t` tag ("stream" / "forum" / "dm"), or null. */
|
||||
fun GroupMetadataEvent.buzzChannelType(): String? = tags.firstTagValue("t")
|
||||
|
||||
/** True when the relay marks this channel a DM (`t` = "dm"). */
|
||||
fun GroupMetadataEvent.isBuzzDm(): Boolean = buzzChannelType() == BUZZ_CHANNEL_TYPE_DM
|
||||
|
||||
|
||||
+9
-3
@@ -21,8 +21,13 @@
|
||||
package com.vitorpamplona.quartz.experimental.bitchat.geohash
|
||||
|
||||
/**
|
||||
* The named geohash precision levels Bitchat exposes as location channels, each a
|
||||
* fixed geohash character length. Coarser (fewer chars) = larger area.
|
||||
* The named geohash precision levels exposed as location channels, each a fixed
|
||||
* geohash character length. Coarser (fewer chars) = larger area.
|
||||
*
|
||||
* [REGION]…[BUILDING] (2–8 chars) mirror the levels Bitchat exposes, so those cells
|
||||
* interoperate with Bitchat peers. [CONTINENT] (1 char) is an Amethyst extension: a
|
||||
* single character splits the globe into 32 ~5000 km cells, coarser than any Bitchat
|
||||
* channel, for a whole-continent room.
|
||||
*
|
||||
* Because a geohash is a prefix code, the channel for any level is just the first
|
||||
* [chars] characters of a finer cell — so one precise fix yields every level via
|
||||
@@ -31,6 +36,7 @@ package com.vitorpamplona.quartz.experimental.bitchat.geohash
|
||||
enum class GeohashChannelLevel(
|
||||
val chars: Int,
|
||||
) {
|
||||
CONTINENT(1),
|
||||
REGION(2),
|
||||
PROVINCE(4),
|
||||
CITY(5),
|
||||
@@ -47,7 +53,7 @@ enum class GeohashChannelLevel(
|
||||
|
||||
companion object {
|
||||
/** Coarsest → finest, the order a location-channel picker should list them. */
|
||||
val ordered = listOf(REGION, PROVINCE, CITY, NEIGHBORHOOD, BLOCK, BUILDING)
|
||||
val ordered = listOf(CONTINENT, REGION, PROVINCE, CITY, NEIGHBORHOOD, BLOCK, BUILDING)
|
||||
|
||||
/** The named level whose precision matches [chars], if any. */
|
||||
fun forChars(chars: Int): GeohashChannelLevel? = entries.firstOrNull { it.chars == chars }
|
||||
|
||||
+23
-2
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -222,7 +223,27 @@ class NamecoinNameResolver(
|
||||
// ── Lookup & Value Parsing ─────────────────────────────────────────
|
||||
|
||||
private suspend fun performLookup(parsed: ParsedIdentifier): NamecoinNostrResult? {
|
||||
val nameResult = electrumxClient.nameShowWithFallback(parsed.namecoinName, serverListProvider()) ?: return null
|
||||
// nameShowWithFallback never returns null in practice — every failure
|
||||
// path throws a NamecoinLookupException subtype (NameNotFound,
|
||||
// NameExpired, ServersUnreachable). If we don't catch them here, they
|
||||
// propagate out through resolve() and up to Nip05State.checkAndUpdate,
|
||||
// which lumps every lookup failure into markAsError() → red icon with
|
||||
// no way for the user to tell "servers unreachable" from "wrong pubkey".
|
||||
//
|
||||
// resolve() is documented as returning null on any failure. Preserve
|
||||
// that contract by mapping expected lookup exceptions to null. Callers
|
||||
// that want the specific failure reason use resolveDetailed() instead.
|
||||
val nameResult =
|
||||
try {
|
||||
electrumxClient.nameShowWithFallback(parsed.namecoinName, serverListProvider())
|
||||
?: return null
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: NamecoinLookupException) {
|
||||
// NameNotFound / NameExpired / ServersUnreachable → null per
|
||||
// resolve()'s contract. resolveDetailed() surfaces which one.
|
||||
return null
|
||||
}
|
||||
val valueJson = tryParseJson(nameResult.value) ?: return null
|
||||
val merged = expandImportsIfPresent(valueJson)
|
||||
|
||||
@@ -296,7 +317,7 @@ class NamecoinNameResolver(
|
||||
return NamecoinImportResolver.expandImports(root) { name ->
|
||||
try {
|
||||
electrumxClient.nameShowWithFallback(name, serverListProvider())?.value
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: NamecoinLookupException) {
|
||||
// Best-effort: missing/expired/unreachable → contribute nothing.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user