From f46534a21930dd9648dc8d378b3f1e6f1fb45044 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 05:33:13 +0000 Subject: [PATCH 01/31] feat(home): add Pictures, Videos and Torrents to Home content-type toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the per-content-type Home feed toggles to the remaining root feed (non-chat) kinds that render as cards but weren't yet part of the Home feed: - Pictures (NIP-68, kind 20) - Videos (NIP-71: normal 21, short 22, horizontal 34235, vertical 34236) - Torrents (NIP-35, kind 2003) Each gets a HomeFeedType group (kinds stay disjoint), is added to the always-on home relay assembler filters, and is accepted by the New Threads DAL (which also feeds the Everything tab). The addressable video kinds are registered in the DAL's addressable-kind scan. Wired into Settings › Home with icons/strings; toggling still drops the kinds from both the relay REQ and the rendered feed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0198T5VTjA1x2dCfBLhXZaiL --- .../amethyst/model/HomeFeedType.kt | 17 +++++++++++ .../home/dal/HomeNewThreadFeedFilter.kt | 14 ++++++++++ .../nip65Follows/FilterHomePostsByAuthors.kt | 12 ++++++++ .../settings/HomeTabsSettingsScreen.kt | 3 ++ amethyst/src/main/res/values/strings.xml | 3 ++ .../amethyst/model/HomeFeedTypeTest.kt | 28 +++++++++++++++++++ 6 files changed, 77 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HomeFeedType.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HomeFeedType.kt index d325b552e5..3ec94d37f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HomeFeedType.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HomeFeedType.kt @@ -40,12 +40,18 @@ 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.nip35Torrents.TorrentEvent 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.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent +import com.vitorpamplona.quartz.nip71Video.VideoShortEvent +import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent @@ -72,11 +78,22 @@ enum class HomeFeedType( TEXT_NOTES("text_notes", listOf(TextNoteEvent.KIND)), REPOSTS("reposts", listOf(RepostEvent.KIND, GenericRepostEvent.KIND)), COMMENTS("comments", listOf(CommentEvent.KIND)), + PICTURES("pictures", listOf(PictureEvent.KIND)), + VIDEOS( + "videos", + listOf( + VideoNormalEvent.KIND, + VideoShortEvent.KIND, + VideoHorizontalEvent.KIND, + VideoVerticalEvent.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)), + TORRENTS("torrents", listOf(TorrentEvent.KIND)), VOICE("voice", listOf(VoiceEvent.KIND, VoiceReplyEvent.KIND)), LIVE_ACTIVITIES("live_activities", listOf(LiveActivitiesEvent.KIND, LiveActivitiesChatMessageEvent.KIND)), EPHEMERAL_CHAT("ephemeral_chat", listOf(EphemeralChatEvent.KIND)), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt index 6a01d8dc07..aa557fe4da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt @@ -49,9 +49,15 @@ 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.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent +import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent +import com.vitorpamplona.quartz.nip71Video.VideoShortEvent +import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent @@ -77,6 +83,8 @@ class HomeNewThreadFeedFilter( LongTextNoteEvent.KIND, LiveChessGameEndEvent.KIND, AttestationEvent.KIND, + VideoHorizontalEvent.KIND, + VideoVerticalEvent.KIND, ) } @@ -153,6 +161,12 @@ class HomeNewThreadFeedFilter( noteEvent is AudioHeaderEvent || noteEvent is ChessGameEvent || noteEvent is LiveChessGameEndEvent || + noteEvent is PictureEvent || + noteEvent is VideoNormalEvent || + noteEvent is VideoShortEvent || + noteEvent is VideoHorizontalEvent || + noteEvent is VideoVerticalEvent || + noteEvent is TorrentEvent || noteEvent is AttestationEvent || noteEvent is AttestationRequestEvent || noteEvent is AttestorRecommendationEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt index 2979f555f0..ad72b49bca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt @@ -39,12 +39,18 @@ 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.nip35Torrents.TorrentEvent 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.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent +import com.vitorpamplona.quartz.nip71Video.VideoShortEvent +import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent @@ -66,6 +72,11 @@ val HomePostsNewThreadKinds1 = WikiNoteEvent.KIND, AttestationEvent.KIND, NipTextEvent.KIND, + PictureEvent.KIND, + VideoNormalEvent.KIND, + VideoShortEvent.KIND, + VideoHorizontalEvent.KIND, + VideoVerticalEvent.KIND, ) val HomePostsNewThreadKinds2 = @@ -76,6 +87,7 @@ val HomePostsNewThreadKinds2 = LiveChessGameEndEvent.KIND, BirdDetectionEvent.KIND, BirdexEvent.KIND, + TorrentEvent.KIND, ) val HomePostsConversationKinds = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HomeTabsSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HomeTabsSettingsScreen.kt index ecdfded7d0..bd88862541 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HomeTabsSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HomeTabsSettingsScreen.kt @@ -131,11 +131,14 @@ private val HOME_FEED_TYPES = 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.PICTURES, R.string.home_content_type_pictures, MaterialSymbols.Image), + HomeFeedTypeUi(HomeFeedType.VIDEOS, R.string.home_content_type_videos, MaterialSymbols.Videocam), 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.TORRENTS, R.string.home_content_type_torrents, MaterialSymbols.Download), 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), diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 8cef251ec7..1973a4983e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2334,11 +2334,14 @@ Text notes Reposts Comments & replies + Pictures + Videos Articles Wiki pages Highlights Polls Classifieds + Torrents Voice messages Live activities Ephemeral chats diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/HomeFeedTypeTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/HomeFeedTypeTest.kt index 9d17589fe9..9681842fe6 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/HomeFeedTypeTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/HomeFeedTypeTest.kt @@ -21,6 +21,12 @@ package com.vitorpamplona.amethyst.model import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent +import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent +import com.vitorpamplona.quartz.nip71Video.VideoShortEvent +import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -65,6 +71,28 @@ class HomeFeedTypeTest { assertNull(HomeFeedType.fromCode("future-kind")) } + @Test + fun picturesVideosAndTorrentsOwnTheirKinds() { + assertEquals(listOf(PictureEvent.KIND), HomeFeedType.PICTURES.kinds) + assertEquals( + listOf( + VideoNormalEvent.KIND, + VideoShortEvent.KIND, + VideoHorizontalEvent.KIND, + VideoVerticalEvent.KIND, + ), + HomeFeedType.VIDEOS.kinds, + ) + assertEquals(listOf(TorrentEvent.KIND), HomeFeedType.TORRENTS.kinds) + } + + @Test + fun disablingVideosDropsEveryVideoKind() { + val disabled = HomeFeedType.disabledKinds(HomeFeedType.ALL - HomeFeedType.VIDEOS) + HomeFeedType.VIDEOS.kinds.forEach { assertTrue(it in disabled) } + assertFalse(PictureEvent.KIND in disabled) + } + @Test fun disabledKindsEmptyWhenEverythingEnabled() { assertTrue(HomeFeedType.disabledKinds(HomeFeedType.ALL).isEmpty()) From 646459e367273f5bed3c36f9fc7f553fbd89ab65 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 05:44:28 +0000 Subject: [PATCH 02/31] fix(buzz): remove a deleted channel from the community channel list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting a channel (kind-9008) published the delete and dropped it from the user's kind-10009 list, but the community's browse list is built from the cached kind-39000 metadata and the Buzz membership (kind-44100) set — neither of which the delete touched — so the channel lingered in the list, and a stale 44100 re-announcement could bring it back after a restart. Track deleted relay-group channels in a device-global RelayGroupDeletions registry (keyed by GroupId.toKey, so it stays relay-scoped), persist it via RelayGroupDeletionPreferences, mark the channel on deleteRelayGroup, and filter deleted keys out of both the directory channels and the Buzz membership list in RelayGroupChannelListScreen. The delete now removes the row live and it stays gone across restarts. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG --- .../com/vitorpamplona/amethyst/AppModules.kt | 6 ++ .../vitorpamplona/amethyst/model/Account.kt | 5 ++ .../RelayGroupDeletionPreferences.kt | 77 ++++++++++++++++ .../relayGroup/RelayGroupChannelListScreen.kt | 42 +++++---- .../nip29RelayGroups/RelayGroupDeletions.kt | 75 ++++++++++++++++ .../RelayGroupDeletionsTest.kt | 87 +++++++++++++++++++ 6 files changed, 277 insertions(+), 15 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/RelayGroupDeletionPreferences.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletions.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletionsTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index f903682bfc..9f44436f55 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.model.preferences.BuzzChannelStarPreferences import com.vitorpamplona.amethyst.model.preferences.BuzzWorkspacePreferences import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences +import com.vitorpamplona.amethyst.model.preferences.RelayGroupDeletionPreferences import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder @@ -287,6 +288,11 @@ class AppModules( // Restore + persist the user's starred Buzz workspace channels across restarts (device-global). val buzzChannelStarPrefs = BuzzChannelStarPreferences(appContext, applicationIOScope) + // Restore + persist the set of relay-group channels deleted (kind-9008) on this device, so a + // deleted channel stays hidden across a restart even if the host relay re-announces a stale + // kind-44100 for it (device-global; a delete is authoritative and terminal for everyone). + val relayGroupDeletionPrefs = RelayGroupDeletionPreferences(appContext, applicationIOScope) + // Service that will run at all times to receive events from Pokey val pokeyReceiver = PokeyReceiver() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 6c7b0597de..91f929fc61 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChann import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListState import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListState import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership @@ -3465,6 +3466,10 @@ class Account( val template = DeleteGroupEvent.build(channel.groupId.id) signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } unfollow(channel) + // Remember the deletion so the channel leaves the community's browse list immediately and + // stays gone across a restart — the relay drops the group but our cached 39000 metadata (and a + // stale re-announced 44100 on a Buzz relay) would otherwise keep it visible. + RelayGroupDeletions.markDeleted(channel.groupId) } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/RelayGroupDeletionPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/RelayGroupDeletionPreferences.kt new file mode 100644 index 0000000000..99e1f99c01 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/RelayGroupDeletionPreferences.kt @@ -0,0 +1,77 @@ +/* + * 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.preferences + +import android.content.Context +import androidx.compose.runtime.Stable +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException + +/** + * Device-global persistence for the set of deleted NIP-29 relay-group channels ([RelayGroupDeletions]), + * so a channel the user deleted (kind-9008) stays gone across a restart — even if the host relay keeps + * re-announcing a stale kind-44100 for it. Mirrors [BuzzChannelStarPreferences]: app-wide (not + * per-account), loads the saved keys into the singleton on construction, then writes every later change + * back. Construct once, eagerly. + */ +@Stable +class RelayGroupDeletionPreferences( + private val context: Context, + private val scope: CoroutineScope, +) { + init { + scope.launch { + restoreFromDisk() + // drop(1) skips the value present at collection start, which restoreFromDisk already wrote. + RelayGroupDeletions.flow.drop(1).collect { persist(it) } + } + } + + private suspend fun restoreFromDisk() { + try { + val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return + if (raw.isNotEmpty()) RelayGroupDeletions.restore(raw) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("RelayGroupDeletionPrefs") { "Error reading deleted channels: ${e.message}" } + } + } + + private suspend fun persist(keys: Set) { + try { + context.sharedPreferencesDataStore.edit { prefs -> prefs[KEY] = keys } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("RelayGroupDeletionPrefs") { "Error writing deleted channels: ${e.message}" } + } + } + + companion object { + private val KEY = stringSetPreferencesKey("nip29.deletedChannels") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index c4d62d47f9..aa20a8aea0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -73,6 +73,7 @@ import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot import com.vitorpamplona.amethyst.model.LocalCache @@ -172,6 +173,11 @@ fun RelayGroupChannelListScreen( } } + // Channels this device has deleted (kind-9008). A delete is terminal — the relay drops the group — + // but our cached 39000 (and a Buzz relay's stale re-announced 44100) would keep it in the list, so + // filter them out everywhere below. Collected as a StateFlow so a delete removes the row live. + val deletedChannels by RelayGroupDeletions.flow.collectAsStateWithLifecycle() + // Prefer the relay's own genuine, relay-signed groups (39000 author == the NIP-11 `self`). // Recomputes as the NIP-11 doc resolves so real groups fill in and fakes stay hidden. But if // NIP-11 is unreachable (e.g. a Cloudflare-fronted relay that resets the plain HTTP GET while @@ -179,20 +185,22 @@ fun RelayGroupChannelListScreen( // its de-facto signer — so its relay-signed groups still show while a stray user-published 39000 // (a different author) stays filtered. val channels = - remember(allChannels, relayInfo) { + remember(allChannels, relayInfo, deletedChannels) { val nip11Known = relayInfo.self != null || relayInfo.supported_nips != null - if (nip11Known) { - allChannels.filter { isRelaySignedRelayGroup(it, relayInfo) } - } else { - val dominantSigner = - allChannels - .mapNotNull { it.event?.pubKey } - .groupingBy { it } - .eachCount() - .maxByOrNull { it.value } - ?.key - if (dominantSigner != null) allChannels.filter { it.event?.pubKey == dominantSigner } else allChannels - } + val signed = + if (nip11Known) { + allChannels.filter { isRelaySignedRelayGroup(it, relayInfo) } + } else { + val dominantSigner = + allChannels + .mapNotNull { it.event?.pubKey } + .groupingBy { it } + .eachCount() + .maxByOrNull { it.value } + ?.key + if (dominantSigner != null) allChannels.filter { it.event?.pubKey == dominantSigner } else allChannels + } + signed.filterNot { it.groupId.toKey() in deletedChannels } } // Buzz relays expose no public group directory (membership is server-side), so `channels` above @@ -226,9 +234,13 @@ fun RelayGroupChannelListScreen( // ids so nothing the old flat list showed disappears. val channelsById = remember(allChannels) { allChannels.associateBy { it.groupId.id } } val buzzGroupIds = - remember(buzzChannels, channels) { + remember(buzzChannels, channels, deletedChannels) { val seen = LinkedHashSet() - (buzzChannels + channels.map { it.groupId }).filter { seen.add(it.id) } + // `channels` is already delete-filtered; also drop deleted ids from the membership-scoped + // `buzzChannels` (kind-44100), which the relay can keep re-announcing after a delete. + (buzzChannels + channels.map { it.groupId }) + .filterNot { it.toKey() in deletedChannels } + .filter { seen.add(it.id) } } fun buzzTypeOf(groupId: GroupId): String? = channelsById[groupId.id]?.event?.buzzChannelType() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletions.kt new file mode 100644 index 0000000000..fc3df75e65 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletions.kt @@ -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.nip29RelayGroups + +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * The set of NIP-29 relay-group channels this device has **deleted** (kind-9008), keyed by + * [GroupId.toKey] (`id@relay`, so a group is scoped to its host relay — group ids are only + * unique per relay). + * + * A delete is terminal and destroys the group for everyone: the relay drops it and stops serving + * its kind-39000 metadata. But the client already holds that metadata in `LocalCache`, and a Buzz + * relay may keep re-announcing a stale kind-44100 member-added notification that would re-surface + * the channel in the community's browse list — after a restart too, since it's re-fetched from the + * relay. So the deletion has to be remembered client-side and the channel filtered out everywhere + * the list is built. + * + * Like [BuzzChannelStars], there is no personal Nostr event for "I deleted this from my view", so + * this is a process-wide singleton mirrored to a device-global store by the platform + * ([com.vitorpamplona.amethyst] `RelayGroupDeletionPreferences`) and restored at startup. Deleting + * is authoritative and terminal, so an entry is only ever added, never removed. + */ +object RelayGroupDeletions { + private val deleted = MutableStateFlow>(emptySet()) + + /** The deleted group keys ([GroupId.toKey]); the community view collects this to hide them. */ + val flow: StateFlow> = deleted + + fun isDeleted(groupKey: String): Boolean = groupKey in deleted.value + + fun isDeleted(groupId: GroupId): Boolean = isDeleted(groupId.toKey()) + + /** Record [groupId] as deleted (idempotent). */ + fun markDeleted(groupId: GroupId) = markDeleted(groupId.toKey()) + + /** Record [groupKey] ([GroupId.toKey]) as deleted (idempotent). */ + fun markDeleted(groupKey: String) { + while (true) { + val current = deleted.value + if (groupKey in current) return + if (deleted.compareAndSet(current, current + groupKey)) return + } + } + + /** Replaces the whole set — used to restore from disk at startup. */ + fun restore(keys: Set) { + deleted.value = keys + } + + /** Test-only: clears the set so unit tests don't leak state into each other. */ + fun clearForTesting() { + deleted.value = emptySet() + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletionsTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletionsTest.kt new file mode 100644 index 0000000000..6a60adc6e1 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletionsTest.kt @@ -0,0 +1,87 @@ +/* + * 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.nip29RelayGroups + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The device-global "deleted channels" bookkeeping: a delete is relay-scoped (keyed by + * [GroupId.toKey]) and terminal (only ever added), so the same id on a different relay stays visible. + */ +class RelayGroupDeletionsTest { + private val relayA = RelayUrlNormalizer.normalize("wss://a.example.com") + private val relayB = RelayUrlNormalizer.normalize("wss://b.example.com") + private val gid = "0123456789abcdef" + + @BeforeTest + fun reset() = RelayGroupDeletions.clearForTesting() + + @AfterTest + fun tearDown() = RelayGroupDeletions.clearForTesting() + + @Test + fun marksAChannelDeletedAndReflectsInTheFlow() { + val group = GroupId(gid, relayA) + assertFalse(RelayGroupDeletions.isDeleted(group)) + + RelayGroupDeletions.markDeleted(group) + + assertTrue(RelayGroupDeletions.isDeleted(group)) + assertTrue(RelayGroupDeletions.isDeleted(group.toKey())) + assertEquals(setOf(group.toKey()), RelayGroupDeletions.flow.value) + } + + @Test + fun deletionIsRelayScoped() { + RelayGroupDeletions.markDeleted(GroupId(gid, relayA)) + + // The same group id on a different host relay is a different group, so it stays visible. + assertTrue(RelayGroupDeletions.isDeleted(GroupId(gid, relayA))) + assertFalse(RelayGroupDeletions.isDeleted(GroupId(gid, relayB))) + } + + @Test + fun markingIsIdempotent() { + val group = GroupId(gid, relayA) + RelayGroupDeletions.markDeleted(group) + RelayGroupDeletions.markDeleted(group) + + assertEquals(1, RelayGroupDeletions.flow.value.size) + } + + @Test + fun restoreReplacesTheWholeSet() { + RelayGroupDeletions.markDeleted(GroupId(gid, relayA)) + + val restored = setOf(GroupId("aaaa", relayB).toKey(), GroupId("bbbb", relayB).toKey()) + RelayGroupDeletions.restore(restored) + + assertEquals(restored, RelayGroupDeletions.flow.value) + assertFalse(RelayGroupDeletions.isDeleted(GroupId(gid, relayA))) + } +} From b666a189ac8b623c73d961b317ac5dc2bc0420cc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 13:22:12 +0000 Subject: [PATCH 03/31] feat(home): split Videos into long-form Videos and Shorts toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separate the Home feed video toggle into two independent groups, matching how the app already classifies them (see ShortsFeedFilter): - Videos (long-form): normal (21) + horizontal (34235) - Shorts: short (22) + vertical (34236) Each is its own Settings › Home tile with distinct icon/string. The relay assembler filters and New Threads DAL already list all four kinds, so only the HomeFeedType grouping changes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0198T5VTjA1x2dCfBLhXZaiL --- .../amethyst/model/HomeFeedType.kt | 11 ++--------- .../settings/HomeTabsSettingsScreen.kt | 1 + amethyst/src/main/res/values/strings.xml | 1 + .../amethyst/model/HomeFeedTypeTest.kt | 18 +++++++----------- 4 files changed, 11 insertions(+), 20 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HomeFeedType.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HomeFeedType.kt index 3ec94d37f6..716fa3d986 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HomeFeedType.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/HomeFeedType.kt @@ -79,15 +79,8 @@ enum class HomeFeedType( REPOSTS("reposts", listOf(RepostEvent.KIND, GenericRepostEvent.KIND)), COMMENTS("comments", listOf(CommentEvent.KIND)), PICTURES("pictures", listOf(PictureEvent.KIND)), - VIDEOS( - "videos", - listOf( - VideoNormalEvent.KIND, - VideoShortEvent.KIND, - VideoHorizontalEvent.KIND, - VideoVerticalEvent.KIND, - ), - ), + VIDEOS("videos", listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND)), + SHORTS("shorts", listOf(VideoShortEvent.KIND, VideoVerticalEvent.KIND)), ARTICLES("articles", listOf(LongTextNoteEvent.KIND)), WIKI("wiki", listOf(WikiNoteEvent.KIND)), HIGHLIGHTS("highlights", listOf(HighlightEvent.KIND)), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HomeTabsSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HomeTabsSettingsScreen.kt index bd88862541..0ec90ddbf3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HomeTabsSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HomeTabsSettingsScreen.kt @@ -133,6 +133,7 @@ private val HOME_FEED_TYPES = HomeFeedTypeUi(HomeFeedType.COMMENTS, R.string.home_content_type_comments, MaterialSymbols.Chat), HomeFeedTypeUi(HomeFeedType.PICTURES, R.string.home_content_type_pictures, MaterialSymbols.Image), HomeFeedTypeUi(HomeFeedType.VIDEOS, R.string.home_content_type_videos, MaterialSymbols.Videocam), + HomeFeedTypeUi(HomeFeedType.SHORTS, R.string.home_content_type_shorts, MaterialSymbols.SmartDisplay), 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), diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1973a4983e..53913183ce 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2336,6 +2336,7 @@ Comments & replies Pictures Videos + Shorts Articles Wiki pages Highlights diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/HomeFeedTypeTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/HomeFeedTypeTest.kt index 9681842fe6..0fe6373f4f 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/HomeFeedTypeTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/HomeFeedTypeTest.kt @@ -72,24 +72,20 @@ class HomeFeedTypeTest { } @Test - fun picturesVideosAndTorrentsOwnTheirKinds() { + fun picturesVideosShortsAndTorrentsOwnTheirKinds() { assertEquals(listOf(PictureEvent.KIND), HomeFeedType.PICTURES.kinds) - assertEquals( - listOf( - VideoNormalEvent.KIND, - VideoShortEvent.KIND, - VideoHorizontalEvent.KIND, - VideoVerticalEvent.KIND, - ), - HomeFeedType.VIDEOS.kinds, - ) + // Long-form videos are the horizontal/normal kinds; vertical/short kinds belong to Shorts. + assertEquals(listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND), HomeFeedType.VIDEOS.kinds) + assertEquals(listOf(VideoShortEvent.KIND, VideoVerticalEvent.KIND), HomeFeedType.SHORTS.kinds) assertEquals(listOf(TorrentEvent.KIND), HomeFeedType.TORRENTS.kinds) } @Test - fun disablingVideosDropsEveryVideoKind() { + fun disablingVideosLeavesShortsUntouched() { val disabled = HomeFeedType.disabledKinds(HomeFeedType.ALL - HomeFeedType.VIDEOS) HomeFeedType.VIDEOS.kinds.forEach { assertTrue(it in disabled) } + // Shorts is a separate toggle, so its kinds stay live. + HomeFeedType.SHORTS.kinds.forEach { assertFalse(it in disabled) } assertFalse(PictureEvent.KIND in disabled) } From 9a5b36d0ba12eaed7dec12d625d6b28cccb659f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 13:25:37 +0000 Subject: [PATCH 04/31] fix(buzz): honor the relay's channel_deleted signal so deleted channels leave the list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass only recorded a deletion when THIS device published the 9008, so a channel deleted on another Buzz client — or before the fix existed — kept showing and survived restarts. The Buzz relay's handle_delete_group soft-deletes the channel and its 39000/39001/39002 discovery events but emits NO member-removed notification and never retracts the kind-44100 that seeds the browse list; its only cross-device signal is the relay-signed kind-40099 system message with type=channel_deleted (verified against block/buzz side_effects.rs). Consume that signal: LocalCache records a relay-signed 40099 channel_deleted into RelayGroupDeletions (gated on isRelaySignedGroupEvent so a spoofed one can't hide a channel), and BuzzRelayImportViewModel.discover now also fetches the #h-scoped 40099 for its candidate channels and drops deleted ones — the relay keeps re-announcing their 44100 with blank metadata, so this is the only place they leave the list. Cross-device and retroactive: the relay replays the 40099 on subscribe. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG --- .../amethyst/model/LocalCache.kt | 28 ++++++++++++++++++- .../loggedIn/buzz/BuzzRelayImportViewModel.kt | 23 +++++++++++++-- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 820cbcaa1a..bdad25dece 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtIdHexComparator import com.vitorpamplona.amethyst.commons.model.observables.EventListMatchingFilter @@ -114,6 +115,7 @@ import com.vitorpamplona.quartz.buzz.stream.StreamMessageScheduledEvent import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.buzz.stream.StreamReminderEvent import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent +import com.vitorpamplona.quartz.buzz.stream.SystemMessagePayload import com.vitorpamplona.quartz.buzz.stream.sidecars.ChannelSummaryEvent import com.vitorpamplona.quartz.buzz.stream.sidecars.PresenceSnapshotEvent import com.vitorpamplona.quartz.buzz.teams.TeamEvent @@ -2223,6 +2225,30 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { attachToRelayGroupIfScoped(event, relay) } + /** + * A Buzz kind-40099 system message. It renders as a narration row in the channel feed (via + * [consumeBuzzTimelineEvent]), but a `channel_deleted` one is also the **authoritative signal that + * a channel is gone**: the relay soft-deletes the channel and its 39000/39001/39002 discovery + * events but emits no member-removed notification and never retracts the kind-44100 that seeds the + * browse list — so without this the deleted channel keeps re-appearing (a stale 44100 re-announced + * every restart, its metadata now blank so it shows optimistically). Recording the delete in + * [RelayGroupDeletions] filters it out of every list and persists it across restarts. + * + * Gated on [isRelaySignedGroupEvent] (the 40099 is signed by the relay keypair) so a spoofed + * system message from a stray author can't hide a channel. Cross-device by construction: the relay + * replays this on subscribe, so a channel deleted on Buzz web/desktop is honored here too. + */ + private fun consume( + event: SystemMessageEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean = + consumeBuzzTimelineEvent(event, relay, wasVerified).also { + if (relay != null && isRelaySignedGroupEvent(event, relay) && event.payload()?.type == SystemMessagePayload.CHANNEL_DELETED) { + event.channel()?.let { channelId -> RelayGroupDeletions.markDeleted(GroupId(channelId, relay)) } + } + } + /** Store-only consume for Buzz kinds that carry no channel timeline row. */ private fun consumeBuzzRegularEvent( event: Event, @@ -4798,7 +4824,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { is StreamMessageV2Event -> consumeBuzzTimelineEvent(event, relay, wasVerified) is StreamMessageEditEvent -> consume(event, relay, wasVerified) is StreamMessageDiffEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is SystemMessageEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) + is SystemMessageEvent -> consume(event, relay, wasVerified) is CanvasEvent -> consume(event, relay, wasVerified) // Forum root (45001) is a thread, not a chat row → Threads collection. Comments (45003) // and votes (45002) are store-only: the forum-thread detail loads them on demand by root. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt index 92027d1cd2..30c5f73e2a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt @@ -23,11 +23,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_METADATA_KINDS import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent +import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -154,21 +156,36 @@ class BuzzRelayImportViewModel : ViewModel() { false } - // 2. Fetch each channel's NIP-29 metadata (39000-39003) so its name + Buzz `t` type load. + // 2. Fetch each channel's NIP-29 metadata (39000-39003, `#d`-scoped) so its name + Buzz + // `t` type load, AND the relay's kind-40099 system messages (`#h`-scoped) so a + // `channel_deleted` one is seen. The relay soft-deletes a deleted channel's 39000 + // and never retracts the kind-44100 that seeded `channelIds`, so without pulling the + // 40099 a deleted channel — its metadata now blank — would show optimistically here + // forever. LocalCache records the delete into RelayGroupDeletions on consume. if (channelIds.isNotEmpty()) { account.client.fetchAllWithHooks( - filters = mapOf(relay to listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to channelIds.toList())))), + filters = + mapOf( + relay to + listOf( + Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to channelIds.toList())), + Filter(kinds = listOf(SystemMessageEvent.KIND), tags = mapOf("h" to channelIds.toList())), + ), + ), timeoutMs = 8_000, pendingOnAuthRequired = true, ) { _, _ -> false } } // 3. Keep only non-DM workspace channels; a channel whose metadata hasn't arrived - // (type unknown) is optimistically shown as a workspace channel. + // (type unknown) is optimistically shown as a workspace channel. Deleted channels + // (kind-40099 `channel_deleted`, recorded above) are dropped — the relay keeps + // re-announcing their kind-44100, so this is the only place they leave the list. _channels.value = channelIds .mapNotNull { id -> val groupId = GroupId(id, relay) + if (RelayGroupDeletions.isDeleted(groupId)) return@mapNotNull null val channel = LocalCache.getOrCreateRelayGroupChannel(groupId) if (channel.event?.isBuzzDm() == true) null else groupId }.sortedBy { it.id } From 054dffd55d73fee42f266ed52b5c5dfda4bc5575 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 29 Jul 2026 09:37:24 -0400 Subject: [PATCH 05/31] fix(ci): make the Homebrew/Winget bump workflows actually fire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four bump workflows triggered on `release: types: [released]`, which never fires here: create-release.yml publishes the release with GITHUB_TOKEN, and GitHub suppresses workflow-triggering events for GITHUB_TOKEN actions. They had zero runs across every release up to v1.13.1. Switch them to `workflow_run` on "Create Release Assets" completion, filtered to a successful tag push. That also removes a latent race: `released` fired while the matrix legs were still uploading assets, whereas workflow_run fires after all of them finish. The workflow_run payload carries no draft/prerelease flags, so add a resolve-release composite action that reads them back from the API and feeds assert-stable-release, keeping the defense-in-depth guard intact instead of inferring stability from the tag string alone. Also gate the cask/winget bumps on the package existing upstream. Neither `amethyst-nostr` nor `VitorPamplona.Amethyst` has been bootstrapped, and bump-cask-pr/winget-releaser can only update an existing package — without the gate, fixing the trigger would file a spurious [release-ops] issue on every release. Docs: correct the claims this uncovered — Homebrew/Winget are not shipping, macOS is arm64-only (no Intel DMG), the release carries 31 assets (13 Android, not 12), Maven Central publishes from a step inside deploy-android and lags repo1 by tens of minutes, RELEASE_NOTES_ID is minor-releases-only, and note the git-credential-manager hang that blocks the release push. --- .github/actions/resolve-release/action.yml | 73 ++++++++++++++ .github/workflows/bump-homebrew-formula.yml | 60 +++++++----- .../workflows/bump-homebrew-geode-formula.yml | 60 +++++++----- .github/workflows/bump-homebrew.yml | 71 +++++++++++--- .github/workflows/bump-winget.yml | 58 ++++++++++-- BUILDING.md | 44 +++++++-- RELEASE_OPS.md | 94 ++++++++++++++----- 7 files changed, 358 insertions(+), 102 deletions(-) create mode 100644 .github/actions/resolve-release/action.yml diff --git a/.github/actions/resolve-release/action.yml b/.github/actions/resolve-release/action.yml new file mode 100644 index 0000000000..01e92a0df6 --- /dev/null +++ b/.github/actions/resolve-release/action.yml @@ -0,0 +1,73 @@ +name: Resolve Release +description: >- + Resolve a bump workflow's target tag and that release's real published state. + Companion to assert-stable-release: this one FETCHES the facts, that one + ENFORCES them. Split so the enforcement stays a pure function of its inputs. + + Handles both entry points of the bump workflows: + - workflow_run -> tag comes from the triggering run's head_branch + - workflow_dispatch (manual recovery) -> tag comes from the input + In both cases draft/prerelease are read back from the GitHub API rather than + inferred, so a draft or prerelease can never slip through to a third-party + package repo just because the trigger payload lacked the flags. + +inputs: + tag: + description: "Release tag to resolve (e.g. vX.Y.Z)" + required: true + github_token: + description: "Token used to read the release via the GH API" + required: true + +outputs: + tag: + description: "The resolved tag, verbatim (e.g. v1.13.1)" + value: ${{ steps.resolve.outputs.tag }} + ver: + description: "The tag with the leading 'v' stripped (e.g. 1.13.1)" + value: ${{ steps.resolve.outputs.ver }} + is_prerelease: + description: "'true' if the GH Release is flagged prerelease" + value: ${{ steps.resolve.outputs.is_prerelease }} + is_draft: + description: "'true' if the GH Release is still a draft" + value: ${{ steps.resolve.outputs.is_draft }} + +runs: + using: composite + steps: + - name: Resolve tag and release state + id: resolve + shell: bash + env: + TAG: ${{ inputs.tag }} + GH_TOKEN: ${{ inputs.github_token }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + if [[ -z "$TAG" ]]; then + echo "::error::No tag to resolve (neither workflow_run.head_branch nor the dispatch input was set)" + exit 1 + fi + + # A missing release here is a real fault, not something to paper over: + # every caller is about to publish this version to an external package + # manager. Fail loudly and let the caller's Report-failure step file it. + if ! META=$(gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease 2>&1); then + echo "::error::No GH Release found for tag $TAG in $REPO -- refusing to bump" + echo "$META" + exit 1 + fi + + IS_DRAFT=$(echo "$META" | jq -r '.isDraft') + IS_PRERELEASE=$(echo "$META" | jq -r '.isPrerelease') + + { + echo "tag=$TAG" + echo "ver=${TAG#v}" + echo "is_draft=$IS_DRAFT" + echo "is_prerelease=$IS_PRERELEASE" + } >> "$GITHUB_OUTPUT" + + echo "resolved tag=$TAG ver=${TAG#v} draft=$IS_DRAFT prerelease=$IS_PRERELEASE" diff --git a/.github/workflows/bump-homebrew-formula.yml b/.github/workflows/bump-homebrew-formula.yml index d229d22eea..53c7ae42af 100644 --- a/.github/workflows/bump-homebrew-formula.yml +++ b/.github/workflows/bump-homebrew-formula.yml @@ -19,9 +19,14 @@ name: Bump Homebrew Formula (amy CLI) # auto-bump here (symmetric to the cask action in bump-homebrew.yml) — see the # "TODO(bootstrap)" note at the bottom of this file. +# Trigger: after "Create Release Assets" succeeds for a tag push. NOT +# `release: types: [released]` — that event never fires, because the release is +# created by create-release.yml under GITHUB_TOKEN and GitHub suppresses +# workflow-triggering events for it. See the full note in bump-homebrew.yml. on: - release: - types: [released] + workflow_run: + workflows: ["Create Release Assets"] + types: [completed] workflow_dispatch: inputs: tag: @@ -38,44 +43,49 @@ permissions: concurrency: # Serialize per tag; do not cancel in-progress runs. - group: bump-homebrew-formula-${{ github.event.release.tag_name || inputs.tag }} + group: bump-homebrew-formula-${{ github.event.workflow_run.head_branch || inputs.tag }} cancel-in-progress: false jobs: sync-formula: - if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false + # See bump-homebrew.yml for why these three conditions: successful, tag-push + # (not a dry-run dispatch), v-prefixed. Exact format enforced downstream. + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + startsWith(github.event.workflow_run.head_branch, 'v')) runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout code uses: actions/checkout@v7 + - name: Resolve release + id: rel + uses: ./.github/actions/resolve-release + with: + tag: ${{ github.event.workflow_run.head_branch || inputs.tag }} + github_token: ${{ secrets.GITHUB_TOKEN }} + - name: Re-assert stable release uses: ./.github/actions/assert-stable-release with: - tag: ${{ github.event.release.tag_name || inputs.tag }} - is_prerelease: ${{ github.event.release.prerelease || 'false' }} - is_draft: ${{ github.event.release.draft || 'false' }} - - - name: Resolve version - id: ver - run: | - set -euo pipefail - TAG="${{ github.event.release.tag_name || inputs.tag }}" - VER="${TAG#v}" - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "ver=$VER" >> "$GITHUB_OUTPUT" + tag: ${{ steps.rel.outputs.tag }} + is_prerelease: ${{ steps.rel.outputs.is_prerelease }} + is_draft: ${{ steps.rel.outputs.is_draft }} - name: Download jvm bundle and compute sha256 id: asset run: | set -euo pipefail - TAG="${{ steps.ver.outputs.tag }}" - VER="${{ steps.ver.outputs.ver }}" + TAG="${{ steps.rel.outputs.tag }}" + VER="${{ steps.rel.outputs.ver }}" URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/amy-${VER}-jvm.tar.gz" echo "Fetching $URL" - # The `released` event can fire a hair before every matrix leg finishes - # uploading; retry with backoff (mirrors the repo's push/pull retry ethos). + # workflow_run fires only after every upload leg has finished, so the + # asset should already be there. Retry anyway for release-CDN + # propagation (mirrors the repo's push/pull retry ethos). ok=0 for i in 1 2 3 4 5; do if curl -fsSL -o amy-jvm.tar.gz "$URL"; then ok=1; break; fi @@ -110,13 +120,13 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} base: main - branch: chore/bump-amy-formula-${{ steps.ver.outputs.tag }} + branch: chore/bump-amy-formula-${{ steps.rel.outputs.tag }} add-paths: cli/packaging/homebrew/amy.rb - commit-message: 'chore: sync amy Homebrew formula to ${{ steps.ver.outputs.tag }}' - title: 'chore: sync amy Homebrew formula to ${{ steps.ver.outputs.tag }}' + commit-message: 'chore: sync amy Homebrew formula to ${{ steps.rel.outputs.tag }}' + title: 'chore: sync amy Homebrew formula to ${{ steps.rel.outputs.tag }}' body: | Auto-synced `cli/packaging/homebrew/amy.rb` to the - `${{ steps.ver.outputs.tag }}` release: + `${{ steps.rel.outputs.tag }}` release: - `url` -> `${{ steps.asset.outputs.url }}` - `sha256` -> `${{ steps.asset.outputs.sha256 }}` @@ -136,7 +146,7 @@ jobs: uses: actions/github-script@v9 with: script: | - const tag = context.payload.release?.tag_name || context.payload.inputs?.tag || 'unknown'; + const tag = context.payload.workflow_run?.head_branch || context.payload.inputs?.tag || 'unknown'; const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; await github.rest.issues.create({ owner: context.repo.owner, diff --git a/.github/workflows/bump-homebrew-geode-formula.yml b/.github/workflows/bump-homebrew-geode-formula.yml index b55d926245..3c38519c64 100644 --- a/.github/workflows/bump-homebrew-geode-formula.yml +++ b/.github/workflows/bump-homebrew-geode-formula.yml @@ -17,9 +17,14 @@ name: Bump Homebrew Formula (geode relay) # human-reviewed new-formula PR (the one-time bootstrap). Once it lands, wire the # auto-bump here — see the "TODO(bootstrap)" note at the bottom of this file. +# Trigger: after "Create Release Assets" succeeds for a tag push. NOT +# `release: types: [released]` — that event never fires, because the release is +# created by create-release.yml under GITHUB_TOKEN and GitHub suppresses +# workflow-triggering events for it. See the full note in bump-homebrew.yml. on: - release: - types: [released] + workflow_run: + workflows: ["Create Release Assets"] + types: [completed] workflow_dispatch: inputs: tag: @@ -36,44 +41,49 @@ permissions: concurrency: # Serialize per tag; do not cancel in-progress runs. - group: bump-homebrew-geode-formula-${{ github.event.release.tag_name || inputs.tag }} + group: bump-homebrew-geode-formula-${{ github.event.workflow_run.head_branch || inputs.tag }} cancel-in-progress: false jobs: sync-formula: - if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false + # See bump-homebrew.yml for why these three conditions: successful, tag-push + # (not a dry-run dispatch), v-prefixed. Exact format enforced downstream. + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + startsWith(github.event.workflow_run.head_branch, 'v')) runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout code uses: actions/checkout@v7 + - name: Resolve release + id: rel + uses: ./.github/actions/resolve-release + with: + tag: ${{ github.event.workflow_run.head_branch || inputs.tag }} + github_token: ${{ secrets.GITHUB_TOKEN }} + - name: Re-assert stable release uses: ./.github/actions/assert-stable-release with: - tag: ${{ github.event.release.tag_name || inputs.tag }} - is_prerelease: ${{ github.event.release.prerelease || 'false' }} - is_draft: ${{ github.event.release.draft || 'false' }} - - - name: Resolve version - id: ver - run: | - set -euo pipefail - TAG="${{ github.event.release.tag_name || inputs.tag }}" - VER="${TAG#v}" - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "ver=$VER" >> "$GITHUB_OUTPUT" + tag: ${{ steps.rel.outputs.tag }} + is_prerelease: ${{ steps.rel.outputs.is_prerelease }} + is_draft: ${{ steps.rel.outputs.is_draft }} - name: Download jvm bundle and compute sha256 id: asset run: | set -euo pipefail - TAG="${{ steps.ver.outputs.tag }}" - VER="${{ steps.ver.outputs.ver }}" + TAG="${{ steps.rel.outputs.tag }}" + VER="${{ steps.rel.outputs.ver }}" URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/geode-${VER}-jvm.tar.gz" echo "Fetching $URL" - # The `released` event can fire a hair before every matrix leg finishes - # uploading; retry with backoff (mirrors the repo's push/pull retry ethos). + # workflow_run fires only after every upload leg has finished, so the + # asset should already be there. Retry anyway for release-CDN + # propagation (mirrors the repo's push/pull retry ethos). ok=0 for i in 1 2 3 4 5; do if curl -fsSL -o geode-jvm.tar.gz "$URL"; then ok=1; break; fi @@ -108,13 +118,13 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} base: main - branch: chore/bump-geode-formula-${{ steps.ver.outputs.tag }} + branch: chore/bump-geode-formula-${{ steps.rel.outputs.tag }} add-paths: geode/packaging/homebrew/geode.rb - commit-message: 'chore: sync geode Homebrew formula to ${{ steps.ver.outputs.tag }}' - title: 'chore: sync geode Homebrew formula to ${{ steps.ver.outputs.tag }}' + commit-message: 'chore: sync geode Homebrew formula to ${{ steps.rel.outputs.tag }}' + title: 'chore: sync geode Homebrew formula to ${{ steps.rel.outputs.tag }}' body: | Auto-synced `geode/packaging/homebrew/geode.rb` to the - `${{ steps.ver.outputs.tag }}` release: + `${{ steps.rel.outputs.tag }}` release: - `url` -> `${{ steps.asset.outputs.url }}` - `sha256` -> `${{ steps.asset.outputs.sha256 }}` @@ -134,7 +144,7 @@ jobs: uses: actions/github-script@v9 with: script: | - const tag = context.payload.release?.tag_name || context.payload.inputs?.tag || 'unknown'; + const tag = context.payload.workflow_run?.head_branch || context.payload.inputs?.tag || 'unknown'; const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; await github.rest.issues.create({ owner: context.repo.owner, diff --git a/.github/workflows/bump-homebrew.yml b/.github/workflows/bump-homebrew.yml index 257b83d5a2..f2979658bb 100644 --- a/.github/workflows/bump-homebrew.yml +++ b/.github/workflows/bump-homebrew.yml @@ -1,11 +1,25 @@ name: Bump Homebrew Cask -# Fires when a GH Release is published (not draft, not prerelease). -# `release.types: [released]` event fires only for stable releases — still -# double-checked by .github/actions/assert-stable-release for defense-in-depth. +# Fires after "Create Release Assets" finishes successfully for a tag push. +# +# NOT `release: types: [released]`. That event never fires here: the release is +# created by create-release.yml using GITHUB_TOKEN, and GitHub deliberately does +# not raise workflow-triggering events for actions taken by GITHUB_TOKEN (the +# recursion guard). This workflow sat silently dead through every release up to +# v1.13.1 for exactly that reason. +# +# `workflow_run` has no such restriction, and it is also strictly better timed: +# it fires once ALL upload legs have finished, whereas `released` fired while +# assets were still uploading (hence the download retry loops in the sibling +# formula workflows). +# +# Draft/prerelease state is not in the workflow_run payload, so it is read back +# from the API by .github/actions/resolve-release and enforced by +# .github/actions/assert-stable-release. on: - release: - types: [released] + workflow_run: + workflows: ["Create Release Assets"] + types: [completed] workflow_dispatch: inputs: tag: @@ -22,39 +36,72 @@ permissions: concurrency: # Serialize bumps per tag; do not cancel in-progress bumps. - group: bump-homebrew-${{ github.event.release.tag_name || inputs.tag }} + group: bump-homebrew-${{ github.event.workflow_run.head_branch || inputs.tag }} cancel-in-progress: false jobs: bump: - if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false + # On workflow_run, filter down to a SUCCESSFUL TAG build. `event == 'push'` + # excludes create-release.yml's workflow_dispatch dry-runs (which carry a + # synthetic test_tag and publish nothing), and the head_branch prefix keeps + # non-tag runs out. Exact tag format is still enforced downstream. + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + startsWith(github.event.workflow_run.head_branch, 'v')) runs-on: ubuntu-latest # brew runs on Linux — saves macOS runner quota timeout-minutes: 30 steps: - name: Checkout code uses: actions/checkout@v7 + - name: Resolve release + id: rel + uses: ./.github/actions/resolve-release + with: + tag: ${{ github.event.workflow_run.head_branch || inputs.tag }} + github_token: ${{ secrets.GITHUB_TOKEN }} + - name: Re-assert stable release uses: ./.github/actions/assert-stable-release with: - tag: ${{ github.event.release.tag_name || inputs.tag }} - is_prerelease: ${{ github.event.release.prerelease || 'false' }} - is_draft: ${{ github.event.release.draft || 'false' }} + tag: ${{ steps.rel.outputs.tag }} + is_prerelease: ${{ steps.rel.outputs.is_prerelease }} + is_draft: ${{ steps.rel.outputs.is_draft }} + + # `brew bump-cask-pr` can only bump a cask that ALREADY EXISTS in the tap. + # `amethyst-nostr` has never been submitted to Homebrew/homebrew-cask, so + # until the one-time bootstrap PR lands (BUILDING.md § Bootstrap) this + # workflow must no-op rather than fail — otherwise every single release + # files a spurious [release-ops] issue. + - name: Check the cask exists in homebrew-cask + id: cask + run: | + set -euo pipefail + if curl -fsSL -o /dev/null https://formulae.brew.sh/api/cask/amethyst-nostr.json; then + echo "bootstrapped=true" >> "$GITHUB_OUTPUT" + echo "cask amethyst-nostr found in homebrew-cask; proceeding with bump" + else + echo "bootstrapped=false" >> "$GITHUB_OUTPUT" + echo "::warning::Cask 'amethyst-nostr' is not in Homebrew/homebrew-cask yet, so there is nothing to bump for ${{ steps.rel.outputs.tag }}. Amethyst is NOT shipping via Homebrew. Submit the one-time new-cask PR (BUILDING.md § Bootstrap) to activate this channel." + fi - name: Bump cask (push-or-update PR) + if: steps.cask.outputs.bootstrapped == 'true' uses: macauley/action-homebrew-bump-cask@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 with: token: ${{ secrets.HOMEBREW_TOKEN }} tap: homebrew/cask cask: amethyst-nostr - tag: ${{ github.event.release.tag_name || inputs.tag }} + tag: ${{ steps.rel.outputs.tag }} - name: Report failure if: failure() uses: actions/github-script@v9 with: script: | - const tag = context.payload.release?.tag_name || context.payload.inputs?.tag || 'unknown'; + const tag = context.payload.workflow_run?.head_branch || context.payload.inputs?.tag || 'unknown'; const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; await github.rest.issues.create({ owner: context.repo.owner, diff --git a/.github/workflows/bump-winget.yml b/.github/workflows/bump-winget.yml index 0c00610d56..c50965e4e5 100644 --- a/.github/workflows/bump-winget.yml +++ b/.github/workflows/bump-winget.yml @@ -1,8 +1,16 @@ name: Bump Winget Manifest +# Fires after "Create Release Assets" finishes successfully for a tag push. +# +# NOT `release: types: [released]`. That event never fires here: the release is +# created by create-release.yml using GITHUB_TOKEN, and GitHub does not raise +# workflow-triggering events for GITHUB_TOKEN actions. This workflow sat +# silently dead through every release up to v1.13.1 for exactly that reason. +# See the longer note in bump-homebrew.yml. on: - release: - types: [released] + workflow_run: + workflows: ["Create Release Assets"] + types: [completed] workflow_dispatch: inputs: tag: @@ -18,30 +26,62 @@ permissions: issues: write concurrency: - group: bump-winget-${{ github.event.release.tag_name || inputs.tag }} + group: bump-winget-${{ github.event.workflow_run.head_branch || inputs.tag }} cancel-in-progress: false jobs: bump: - if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false + # See bump-homebrew.yml for why these three conditions: successful, tag-push + # (not a dry-run dispatch), v-prefixed. Exact format enforced downstream. + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + startsWith(github.event.workflow_run.head_branch, 'v')) runs-on: windows-latest timeout-minutes: 30 steps: - name: Checkout code uses: actions/checkout@v7 + - name: Resolve release + id: rel + uses: ./.github/actions/resolve-release + with: + tag: ${{ github.event.workflow_run.head_branch || inputs.tag }} + github_token: ${{ secrets.GITHUB_TOKEN }} + - name: Re-assert stable release uses: ./.github/actions/assert-stable-release with: - tag: ${{ github.event.release.tag_name || inputs.tag }} - is_prerelease: ${{ github.event.release.prerelease || 'false' }} - is_draft: ${{ github.event.release.draft || 'false' }} + tag: ${{ steps.rel.outputs.tag }} + is_prerelease: ${{ steps.rel.outputs.is_prerelease }} + is_draft: ${{ steps.rel.outputs.is_draft }} + + # winget-releaser UPDATES an existing package; `VitorPamplona.Amethyst` + # has never been submitted to microsoft/winget-pkgs. Until the one-time + # new-package PR lands (BUILDING.md § Bootstrap), no-op instead of failing + # every release with a spurious [release-ops] issue. + - name: Check the package exists in winget-pkgs + id: pkg + shell: bash + run: | + set -euo pipefail + URL=https://api.github.com/repos/microsoft/winget-pkgs/contents/manifests/v/VitorPamplona/Amethyst + if curl -fsSL -o /dev/null -H "Accept: application/vnd.github+json" "$URL"; then + echo "bootstrapped=true" >> "$GITHUB_OUTPUT" + echo "VitorPamplona.Amethyst found in winget-pkgs; proceeding with submission" + else + echo "bootstrapped=false" >> "$GITHUB_OUTPUT" + echo "::warning::Package 'VitorPamplona.Amethyst' is not in microsoft/winget-pkgs yet, so there is nothing to update for ${{ steps.rel.outputs.tag }}. Amethyst is NOT shipping via Winget. Submit the one-time new-package PR (BUILDING.md § Bootstrap) to activate this channel." + fi - name: Submit manifest to winget-pkgs + if: steps.pkg.outputs.bootstrapped == 'true' uses: vedantmgoyal9/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2 with: identifier: VitorPamplona.Amethyst - version: ${{ github.event.release.tag_name || inputs.tag }} + version: ${{ steps.rel.outputs.tag }} # Asset naming contract: scripts/asset-name.sh installers-regex: '^amethyst-desktop-.*-windows-x64\.msi$' token: ${{ secrets.WINGET_TOKEN }} @@ -51,7 +91,7 @@ jobs: uses: actions/github-script@v9 with: script: | - const tag = context.payload.release?.tag_name || context.payload.inputs?.tag || 'unknown'; + const tag = context.payload.workflow_run?.head_branch || context.payload.inputs?.tag || 'unknown'; const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; await github.rest.issues.create({ owner: context.repo.owner, diff --git a/BUILDING.md b/BUILDING.md index e719e3aad2..1fd7b61bf5 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -325,15 +325,29 @@ Quartz library in one pipeline. 3. **Wait** for the `Create Release Assets` workflow to finish (~25–30 min). -4. **Verify**: - - GH Release contains 8 desktop assets + 12 Android assets +4. **Verify** — the GH Release should hold **31 assets**: + - **8 desktop** — `dmg` (macOS arm64), `msi` + `zip` (Windows), `deb`, `rpm`, + `AppImage`, `flatpak`, `tar.gz` (Linux). There is **no Intel/x64 macOS + DMG** — `jpackage` cannot cross-compile and no Intel runner leg is + configured, so macOS ships arm64-only. + - **13 Android** — 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the + F-Droid `.apks` set built for Accrescent. + - **5 amy** + **5 geode** bundles. - Asset sizes look sane (see §Enforce asset size budget — CI auto-fails at 1 GB/asset) - - Intel + ARM DMGs both present - Android flow unchanged + Quick diff against the previous release, which catches a silently-dropped + matrix leg better than any count: + + ```bash + diff <(gh release view v1.13.0 --json assets --jq '.assets[].name' | sed 's/1\.13\.0/VER/g' | sort) \ + <(gh release view v1.13.1 --json assets --jq '.assets[].name' | sed 's/1\.13\.1/VER/g' | sort) + ``` + 5. **Stable vs prerelease** — a tag containing `-rc`, `-beta`, `-alpha`, `-dev`, - or `-snapshot` is auto-classified as prerelease. Stable tags trigger the - Homebrew + Winget bump workflows. + or `-snapshot` is auto-classified as prerelease. Only stable tags run the + Homebrew + Winget bump workflows (and those are no-ops until the one-time + bootstrap PRs land — see § Bootstrap). ### Dry-run (no tag push) @@ -473,11 +487,11 @@ distributes; the official Amethyst rollout for each is in | Channel | How it ships | Push or pull | |---|---|---| | **GitHub Releases** | The release workflow builds + signs all assets and attaches them to the tag's Release | Automatic (CI) | -| **Maven Central** | Same workflow runs `publishAllPublicationsToMavenCentral` for `quartz` | Automatic (CI) | +| **Maven Central** | Same workflow runs `publishAllPublicationsToMavenCentral` for `quartz` — a *step* at the end of the `deploy-android` job, not a job of its own, so it does not appear in a job list | Automatic (CI) | | **Google Play** | Download the signed `amethyst-googleplay-.aab` from the GH Release and upload it in Play Console | **Manual push** | | **F-Droid** | F-Droid's build server detects the new tag and **builds the `fdroid` flavor from source** per its recipe in the external [`fdroiddata`](https://gitlab.com/fdroid/fdroiddata) repo, then signs + publishes itself | **Pull (build-from-source)** | | **Zapstore** | The [`zsp`](https://zapstore.dev/) CLI reads [`zapstore.yaml`](zapstore.yaml) and publishes a Nostr software-release event signed with the app's nsec | **Manual push (Nostr)** | -| **Homebrew + Winget** | `bump-homebrew.yml` / `bump-winget.yml` open version-bump PRs on stable tags | Automatic (CI) | +| **Homebrew + Winget** | `bump-homebrew.yml` / `bump-winget.yml` open version-bump PRs on stable tags — **currently no-ops**: neither package has been bootstrapped upstream yet (§ Bootstrap) | Automatic (CI), inactive | Two channels need the build to stay split into product flavors (see `amethyst/build.gradle.kts` → `productFlavors`): @@ -498,6 +512,15 @@ reads an optional per-release changelog from ## Bootstrap runbook (one-time) +> **Status as of v1.13.1: neither Homebrew nor Winget has been bootstrapped.** +> `https://formulae.brew.sh/api/cask/amethyst-nostr.json` and +> `microsoft/winget-pkgs/manifests/v/VitorPamplona/Amethyst` both 404, so +> **Amethyst does not currently ship through either channel.** The bump +> workflows detect this and skip with a `::warning::` instead of failing, so a +> green release run does *not* mean Homebrew/Winget shipped. The two subsections +> below are the work that activates them; until then treat the desktop app as +> GitHub-Releases-only on macOS and Windows. + ### Secrets to provision in GitHub repo settings The full secret inventory is in [§ Secrets the CI needs](#secrets-the-ci-needs). @@ -510,8 +533,11 @@ their token type and scope: | `WINGET_TOKEN` | Submit Winget manifests | Classic PAT — `public_repo` — 90d expiry (dedicated bot account preferred; `vedantmgoyal9/winget-releaser` does not support fine-grained) | Rotate both on a 90-day cadence. Owner: assigned via `RELEASE_OPS.md` -or equivalent issue tracker. On rotation, paste new token and run -`gh workflow run bump-homebrew.yml` on the most recent stable tag to verify. +or equivalent issue tracker. On rotation, paste the new token and run +`gh workflow run bump-homebrew.yml -f tag=` to verify +(the `tag` input is required — that dispatch path exists for exactly this). +Note the verification is only meaningful once the cask exists upstream; +before that the run skips the token-using step entirely. ### Homebrew cask (one-time initial PR) diff --git a/RELEASE_OPS.md b/RELEASE_OPS.md index 1f0975423e..ab641f769c 100644 --- a/RELEASE_OPS.md +++ b/RELEASE_OPS.md @@ -14,7 +14,7 @@ specific to shipping the official Amethyst artifacts. ## At a glance -A release is one tag push that fans out to five distribution channels: +A release is one tag push that fans out to four live distribution channels: | Channel | Mechanism | Who pushes | |---|---|---| @@ -22,10 +22,11 @@ A release is one tag push that fans out to five distribution channels: | **Google Play** | **Manual** — download the signed AAB from the GH Release, upload in Play Console | Maintainer | | **F-Droid** | **Pull** — F-Droid's build server builds the `fdroid` flavor from source when it sees the new tag | F-Droid (we just maintain the recipe + metadata) | | **Zapstore** | `zsp publish` reads `zapstore.yaml`, signs a Nostr release event with Amethyst's nsec | Maintainer | -| **Homebrew + Winget** | Automatic — `bump-homebrew.yml` / `bump-winget.yml` fire on stable tags | CI | +| **Homebrew + Winget** | ⚠️ **Not shipping.** The bump workflows run, but skip: neither package exists upstream yet | Nobody (see § 3) | Maven Central (the `quartz` library) also publishes automatically from the same -workflow. +workflow — as a *step* at the end of the `deploy-android` job, not a job of its +own, so don't expect to find it in the run's job list. --- @@ -61,8 +62,10 @@ workflow. `scripts/translators.sh --seed` with `CROWDIN_PROJECT_ID` / `CROWDIN_PERSONAL_TOKEN` set, which refreshes the file.) -3. **Publish the release-notes note on Nostr** with Amethyst's account and paste - its event id into `amethyst/build.gradle.kts`: +3. **Publish the release-notes note on Nostr** — *minor releases only.* In + practice this id has only ever been bumped on `x.y.0` (1.11.0, 1.12.0, + 1.13.0); patch releases leave it pointing at their minor's note. Publish with + Amethyst's account and paste the event id into `amethyst/build.gradle.kts`: ```kotlin buildConfigField("String", "RELEASE_NOTES_ID", "\"\"") ``` @@ -85,17 +88,33 @@ workflow. Commit, tag, push — see [`BUILDING.md` § Release runbook](BUILDING.md#release-runbook) for the exact commands. The tag must equal `app` from the catalog (the workflow asserts this and fails fast otherwise). A clean `vMAJOR.MINOR.PATCH` tag is -classified **stable** and triggers the Homebrew/Winget bumps; anything with a -`-rc`/`-beta`/`-alpha`/`-dev` suffix is a prerelease and skips them. +classified **stable** and runs the Homebrew/Winget bump workflows; anything with +a `-rc`/`-beta`/`-alpha`/`-dev` suffix is a prerelease and skips them. + +Heads-up on the `git push`: this repo has `git-credential-manager` configured as +a credential helper, and it blocks on an interactive prompt (a plain +`GIT_TERMINAL_PROMPT=0` does **not** stop it — the push just hangs). If that +happens, push using `gh`'s helper for the one command: + +```bash +git -c credential.helper= -c credential.helper='!gh auth git-credential' push upstream main +``` When the `Create Release Assets` workflow finishes (~25–30 min) the GH Release -holds, per the asset-name contract: +holds **31 assets**, per the asset-name contract: -- **Android:** 5 Google Play APKs + 5 F-Droid APKs + 2 AABs - (`amethyst-googleplay-*-v…apk` / `.aab`, `amethyst-fdroid-*-v…apk` / `.aab`) -- **Desktop:** 8 assets (DMG/MSI/DEB/RPM/AppImage/zip/tar.gz) -- **CLI:** the `amy` artifacts -- **Maven Central:** `com.vitorpamplona.quartz:quartz:` published +- **Android (13):** 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the F-Droid + `.apks` set for Accrescent + (`amethyst-googleplay-*-v…apk` / `.aab`, `amethyst-fdroid-*-v…apk` / `.aab` / `.apks`) +- **Desktop (8):** DMG (macOS **arm64 only** — there is no Intel DMG), + MSI + zip, DEB, RPM, AppImage, flatpak, tar.gz +- **CLI (5):** the `amy` artifacts +- **Relay (5):** the `geode` artifacts, plus the geode Docker image +- **Maven Central:** `com.vitorpamplona.quartz:quartz:` published. + `repo1.maven.org` lags the publish by tens of minutes — a 404 right after the + run is normal. Confirm the step's log says "Deployment is being published to + Maven Central", and compare against the *previous* version's POM before + concluding anything is broken. --- @@ -165,11 +184,36 @@ RELAY_URLS="wss://relay.zapstore.dev,wss://relay.damus.io,wss://nos.lol,wss://vi Keep `wss://relay.zapstore.dev` in the list — that is the relay the Zapstore app itself reads from. -### Homebrew + Winget — automatic -`bump-homebrew.yml` and `bump-winget.yml` fire on stable tags and open PRs -against `Homebrew/homebrew-cask` (cask `amethyst-nostr`) and -`microsoft/winget-pkgs` (`VitorPamplona.Amethyst`). No action unless one fails — -then see BUILDING.md § Bootstrap and § Incident response. +### Homebrew + Winget — ⚠️ not shipping yet + +`bump-homebrew.yml` and `bump-winget.yml` are wired to open PRs against +`Homebrew/homebrew-cask` (cask `amethyst-nostr`) and `microsoft/winget-pkgs` +(`VitorPamplona.Amethyst`) — but **neither package has ever been submitted +upstream**, so both workflows detect that and skip with a `::warning::`. As of +**v1.13.1** these two channels deliver nothing; macOS and Windows users get the +desktop app from GitHub Releases only. + +Two separate faults kept this invisible until v1.13.1, both now fixed: + +1. **The workflows never ran at all** — for *any* release. They triggered on + `release: types: [released]`, and GitHub does not raise workflow-triggering + events for a release created by `GITHUB_TOKEN`, which is exactly how + `create-release.yml` creates it. They now trigger on `workflow_run` after + `Create Release Assets` succeeds, which also fixes a latent race — the old + event fired while assets were still uploading. +2. **Nothing exists upstream to bump.** `brew bump-cask-pr` and + `winget-releaser` can only *update* an existing package. The first + submission is a manual, human-reviewed PR: BUILDING.md § Homebrew cask + (one-time initial PR) and § Winget (one-time initial submission). + +Until someone does that bootstrap, a green release run means the bump workflows +*skipped cleanly* — not that Homebrew/Winget shipped. Check the run's warnings +if you want to confirm which case you're in. + +The two `amy` / `geode` Homebrew **formula** workflows are different: they only +sync the in-repo reference `.rb` files and open a PR against *this* repo, so +they do real work on every release. Merge those PRs to keep the formulae ready +for their eventual homebrew-core submission. --- @@ -222,13 +266,19 @@ Owner assignments and rotation reminders live with the team (issue tracker). ## 6. Post-release verification -- [ ] GH Release: expected asset count, Intel + ARM DMGs, sizes sane. -- [ ] Maven Central: `quartz:` resolves (allow propagation time). +- [ ] GH Release: 31 assets, sizes sane, and the asset-name set matches the + previous release (see the `diff` one-liner in BUILDING.md § Release + runbook). macOS is arm64-only — do **not** look for an Intel DMG. +- [ ] Maven Central: `quartz:` resolves (allow tens of minutes of + propagation; the publish step's log is the authoritative signal). - [ ] Play Console: rollout started, no policy rejection. - [ ] Zapstore: release event visible. - [ ] F-Droid: new version detected (may lag days). -- [ ] Homebrew + Winget bump PRs opened (stable only). -- [ ] In-app "Release Notes" link opens the note matching `RELEASE_NOTES_ID`. +- [ ] `amy` / `geode` Homebrew formula-sync PRs opened against this repo — merge them. +- [ ] Homebrew + Winget: **expected to skip** until bootstrapped (§ 3). If they + ever start opening real PRs, that means someone landed the bootstrap. +- [ ] In-app "Release Notes" link opens the note matching `RELEASE_NOTES_ID` + (only bumped on minor releases — patches keep pointing at the x.y.0 note). - [ ] Push still works on a `play` build (only if the push contract changed — see § 4); UnifiedPush still works on an `fdroid` build. From 5a665950b8ebe4fcc82e4757236d38699470e360 Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:38:36 +0000 Subject: [PATCH 06/31] chore: sync amy Homebrew formula to v1.13.1 --- cli/packaging/homebrew/amy.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/packaging/homebrew/amy.rb b/cli/packaging/homebrew/amy.rb index ec89e2d0e3..3861c83a19 100644 --- a/cli/packaging/homebrew/amy.rb +++ b/cli/packaging/homebrew/amy.rb @@ -21,8 +21,8 @@ class Amy < Formula desc "Nostr client from the Amethyst project" homepage "https://github.com/vitorpamplona/amethyst" - url "https://github.com/vitorpamplona/amethyst/releases/download/v1.12.6/amy-1.12.6-jvm.tar.gz" - sha256 "209316d704a4622ddef1fd86b958b7619e9d049c20f3543dff60348ec73affd6" + url "https://github.com/vitorpamplona/amethyst/releases/download/v1.13.1/amy-1.13.1-jvm.tar.gz" + sha256 "5cdf5f30c52722a382de45b86919746a2fd733494294436963b6ec39a7220f45" license "MIT" # Lets homebrew-core's BrewTestBot auto-open version-bump PRs when a new From 54b5ff887610bb6ddac1593418aa3d606fa55fe0 Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:39:22 +0000 Subject: [PATCH 07/31] chore: sync geode Homebrew formula to v1.13.1 --- geode/packaging/homebrew/geode.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/geode/packaging/homebrew/geode.rb b/geode/packaging/homebrew/geode.rb index bbb27ea6bc..1971dc9b69 100644 --- a/geode/packaging/homebrew/geode.rb +++ b/geode/packaging/homebrew/geode.rb @@ -24,8 +24,8 @@ class Geode < Formula desc "Standalone Nostr relay from the Amethyst project" homepage "https://github.com/vitorpamplona/amethyst" - url "https://github.com/vitorpamplona/amethyst/releases/download/v1.12.6/geode-1.12.6-jvm.tar.gz" - sha256 "0000000000000000000000000000000000000000000000000000000000000000" + url "https://github.com/vitorpamplona/amethyst/releases/download/v1.13.1/geode-1.13.1-jvm.tar.gz" + sha256 "9f6a73be83b2be7d183a035ae616da1a45ccad5b1b96e2c53636d549a3443d34" license "MIT" # Lets homebrew-core's BrewTestBot auto-open version-bump PRs when a new From 3ab87dd48e25007440ac839335900db524674f84 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 05:24:14 +0000 Subject: [PATCH 08/31] fix: don't hijack vertical scroll with swipe-to-reply in chats The chat bubble used detectHorizontalDragGestures, which claims the pointer as soon as horizontal movement crosses touch-slop regardless of how much vertical movement is happening. A mostly-vertical scroll with a little diagonal drift got grabbed as a swipe, so the bubble slid sideways while the user was just trying to scroll the conversation. Replace it with a custom awaitEachGesture detector that only consumes the pointer once the motion is clearly more horizontal than vertical (abs(over.x) > abs(over.y)). A predominantly-vertical drag leaves the pointer unconsumed, so the enclosing scroll keeps it and the bubble stays put. Once committed to a horizontal drag, behavior (clamp, haptic tick at threshold, release-past-threshold to reply, settle-back animation) is unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016f7dTu8RoJRXjnHM7FKAE2 --- .../chats/feed/layouts/ChatBubbleLayout.kt | 54 +++++++++++++------ 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt index dc6102138d..0825e2d5bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt @@ -29,7 +29,10 @@ import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitTouchSlopOrCancellation +import androidx.compose.foundation.gestures.horizontalDrag import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement @@ -56,11 +59,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight @@ -220,20 +225,7 @@ fun ChatBubbleLayout( } } - detectHorizontalDragGestures( - onDragStart = { - settleJob?.cancel() - crossedThreshold = false - }, - onDragEnd = { - if (abs(dragOffset) >= swipeThresholdPx) { - onSwipeReply() - } - settleBack() - }, - onDragCancel = { settleBack() }, - ) { change, dragAmount -> - change.consume() + val applyDrag = { dragAmount: Float -> val newOffset = if (isLoggedInUser) { (dragOffset + dragAmount).coerceIn(-swipeMaxPx, 0f) @@ -246,6 +238,38 @@ fun ChatBubbleLayout( } dragOffset = newOffset } + + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + settleJob?.cancel() + crossedThreshold = false + + // Claim the pointer only once the motion is clearly more + // horizontal than vertical; a mostly-vertical drag leaves the + // pointer unconsumed so the enclosing scroll keeps it and the + // bubble never moves while you scroll. + var overSlop = Offset.Zero + val drag = + awaitTouchSlopOrCancellation(down.id) { change, over -> + if (abs(over.x) > abs(over.y)) { + change.consume() + overSlop = over + } + } + + if (drag != null) { + applyDrag(overSlop.x) + val completed = + horizontalDrag(drag.id) { change -> + applyDrag(change.positionChange().x) + change.consume() + } + if (completed && abs(dragOffset) >= swipeThresholdPx) { + onSwipeReply() + } + settleBack() + } + } } } else { Modifier From 42bf2a43d8f20451efb0a22d6f41640c35d413bd Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 28 Jul 2026 21:32:28 +0200 Subject: [PATCH 09/31] fix: address code-review findings on the buzz CLI + wrapper scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the review of the Sonar literal-extraction pass. None were introduced by that pass — it renamed or sat next to each one. no_relays told the wrong story. `relaysFor` returns an empty *non-null* set when --relays was given but every entry failed `normalizeGroupRelay`, so the elvis to `outboxRelays()` never fires and the user was told to pass the flag they had just passed. The message now names the real problem and echoes the rejected value; the `no_relays` error code is unchanged. `participants` was string-munged. `arr.toString().trim('[',']').split(",")` turns any shape other than a flat string array into plausible-looking fake pubkeys, and splits a comma-bearing string into two entries. Now decoded as a JsonArray, with non-string elements skipped rather than stringified. Extracted to `participantsOf` so it is testable; BuzzParticipantsTest covers the shapes and was confirmed to fail (3 of 5 cases) against the old implementation. Nothing kept the two copies of the buzz-agent wrappers in sync. They exist as byte-identical trees under cli/src/main/resources/buzz-agent (what `agent up` extracts) and tools/buzz-agent (what the README tells people to use), with no build step or test pinning them — I had to hand-apply the same patch twice this week. BuzzAgentWrapperSyncTest now asserts it. The tools/ tree is declared as a `:cli:test` input, without which Gradle calls the task up-to-date after a tools/-only edit and misses exactly the drift being guarded (verified both ways). --- cli/build.gradle.kts | 11 +++ .../amethyst/cli/commands/BuzzCommands.kt | 58 ++++++++++------ .../amethyst/cli/BuzzAgentWrapperSyncTest.kt | 68 +++++++++++++++++++ .../amethyst/cli/BuzzParticipantsTest.kt | 67 ++++++++++++++++++ 4 files changed, 182 insertions(+), 22 deletions(-) create mode 100644 cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/BuzzAgentWrapperSyncTest.kt create mode 100644 cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/BuzzParticipantsTest.kt diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index 0da6af4bdb..d92227363e 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -29,6 +29,17 @@ tasks.withType().configureEach { duplicatesStrategy = DuplicatesStrategy.INCLUDE } +// BuzzAgentWrapperSyncTest compares the bundled buzz-agent wrappers against their +// tools/ reference copies. The reference tree lives outside this module, so without +// declaring it Gradle calls :cli:test up-to-date after a tools/-only edit — exactly the +// drift the test exists to catch. +tasks.named("test") { + inputs + .dir(rootProject.layout.projectDirectory.dir("tools/buzz-agent")) + .withPropertyName("buzzAgentWrapperReference") + .withPathSensitivity(PathSensitivity.RELATIVE) +} + dependencies { implementation(project(":quartz")) implementation(project(":commons")) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt index ab66424c51..43dcd861c0 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzCommands.kt @@ -46,7 +46,10 @@ import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put @@ -147,7 +150,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_MSG) + if (relays.isEmpty()) return Output.error("no_relays", noRelaysMsg(relaysFlag)) // 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 @@ -177,25 +180,9 @@ object BuzzCommands { .sortedByDescending { it.createdAt } .take(limit) .map { sys -> - // The relay's dm_created content carries a `participants` array our - // SystemMessagePayload model drops; read it from the raw content. - val participants = - runCatching { - jsonParser - .parseToJsonElement(sys.content) - .jsonObject["participants"] - ?.let { arr -> - arr - .toString() - .trim('[', ']') - .split(",") - .map { it.trim().trim('"') } - .filter { it.isNotBlank() } - } - }.getOrNull().orEmpty() mapOf( "dm_id" to sys.channel(), - "participants" to participants, + "participants" to participantsOf(sys.content), "created_at" to sys.createdAt, ) } @@ -518,7 +505,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_MSG) + if (relays.isEmpty()) return Output.error("no_relays", noRelaysMsg(relaysFlag)) val filter = Filter(kinds = listOf(AgentTurnMetricEvent.KIND), tags = mapOf("p" to listOf(me))) val decrypted = @@ -572,7 +559,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_MSG) + if (relays.isEmpty()) return Output.error("no_relays", noRelaysMsg(relaysFlag)) val filter = Filter(kinds = listOf(PersonaEvent.KIND), authors = listOf(me)) val personas = @@ -600,8 +587,35 @@ 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 relay's `dm_created` content carries a `participants` array our [SystemMessageEvent] + * payload model drops, so read it off the raw content. + * + * Decoded as JSON, not string-munged: anything that isn't a flat array of non-blank strings is + * dropped rather than stringified into a plausible-looking pubkey. A pubkey is hex so it can't + * itself contain a comma, but a nested object or a non-array `participants` used to come back + * as garbage entries the caller had no way to tell from real ones. + */ + internal fun participantsOf(content: String): List = + runCatching { + jsonParser + .parseToJsonElement(content) + .jsonObject["participants"] + ?.jsonArray + ?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.takeIf(String::isNotBlank) } + }.getOrNull().orEmpty() + + /** + * The `no_relays` detail. Passing `--relays` and having every entry rejected also lands here + * (the flag wins, so the outbox is never consulted), and telling that user to pass the flag + * they just passed is useless — name the real problem instead. + */ + private fun noRelaysMsg(relaysFlag: String?) = + if (relaysFlag == null) { + "no relays: pass --relays ws://…" + } else { + "no usable relays in --relays: expected comma-separated ws:// or wss:// urls, got '$relaysFlag'" + } /** The `--relays` set if given, else the account's outbox relays. */ private suspend fun relaysFor( diff --git a/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/BuzzAgentWrapperSyncTest.kt b/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/BuzzAgentWrapperSyncTest.kt new file mode 100644 index 0000000000..44598405db --- /dev/null +++ b/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/BuzzAgentWrapperSyncTest.kt @@ -0,0 +1,68 @@ +/* + * 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.cli + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The gated-runner wrappers exist twice: `cli/src/main/resources/buzz-agent/` is what + * `amy buzz agent up` extracts into `~/.amy/buzz-agent`, and `tools/buzz-agent/` is the + * readable reference `tools/buzz-agent/README.md` tells people to point `--exec` at. + * + * Nothing in the build copies one to the other, so a fix applied to only one half ships a + * runner that behaves differently from its own documentation. This pins them together. + */ +class BuzzAgentWrapperSyncTest { + @Test + fun bundledWrappersMatchTheToolsReference() { + val bundled = repoRoot.resolve("cli/src/main/resources/buzz-agent") + val reference = repoRoot.resolve("tools/buzz-agent") + + val scripts = + bundled + .listFiles() + ?.filter { it.isFile } + .orEmpty() + .sortedBy { it.name } + assertTrue(scripts.isNotEmpty(), "no bundled wrappers found in $bundled") + + scripts.forEach { script -> + val twin = reference.resolve(script.name) + assertTrue(twin.isFile, "${script.name} has no counterpart in tools/buzz-agent — add one") + assertEquals( + script.readText(), + twin.readText(), + "${script.name} drifted between cli/src/main/resources/buzz-agent and tools/buzz-agent — " + + "apply the change to both copies", + ) + } + } + + /** Walks up from the test's working directory to the checkout root (the dir holding both trees). */ + private val repoRoot: File + get() = + generateSequence(File(".").absoluteFile) { it.parentFile } + .firstOrNull { File(it, "tools/buzz-agent").isDirectory && File(it, "cli/src/main/resources/buzz-agent").isDirectory } + ?: error("could not locate the repo root from ${File(".").absolutePath}") +} diff --git a/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/BuzzParticipantsTest.kt b/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/BuzzParticipantsTest.kt new file mode 100644 index 0000000000..59abf37963 --- /dev/null +++ b/cli/src/test/kotlin/com/vitorpamplona/amethyst/cli/BuzzParticipantsTest.kt @@ -0,0 +1,67 @@ +/* + * 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.cli + +import com.vitorpamplona.amethyst.cli.commands.BuzzCommands +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * `buzz dm list` reads `participants` off the raw `dm_created` content. It used to do that by + * stringifying the element and splitting on commas, which turned every non-string shape into + * plausible-looking-but-fake pubkeys. These cases pin the decoded behaviour. + */ +class BuzzParticipantsTest { + private val a = "a".repeat(64) + private val b = "b".repeat(64) + + @Test + fun readsAFlatArrayOfStrings() { + assertEquals(listOf(a, b), BuzzCommands.participantsOf("""{"participants":["$a","$b"]}""")) + } + + @Test + fun emptyWhenAbsentOrEmptyOrUnparseable() { + assertEquals(emptyList(), BuzzCommands.participantsOf("""{"type":"dm_created"}""")) + assertEquals(emptyList(), BuzzCommands.participantsOf("""{"participants":[]}""")) + assertEquals(emptyList(), BuzzCommands.participantsOf("not json at all")) + assertEquals(emptyList(), BuzzCommands.participantsOf("")) + } + + @Test + fun rejectsNonArrayShapesInsteadOfStringifyingThem() { + // The old split-on-comma parse yielded ["nope"] and ["x":1}] respectively. + assertEquals(emptyList(), BuzzCommands.participantsOf("""{"participants":"nope"}""")) + assertEquals(emptyList(), BuzzCommands.participantsOf("""{"participants":{"x":1}}""")) + } + + @Test + fun skipsNonStringElementsButKeepsTheRest() { + assertEquals(listOf(b), BuzzCommands.participantsOf("""{"participants":[{"nested":"$a"},"$b"]}""")) + assertEquals(listOf(a), BuzzCommands.participantsOf("""{"participants":["$a",null,""," "]}""")) + } + + @Test + fun doesNotSplitAStringThatContainsACommaIntoTwoEntries() { + // The regression the old `arr.toString().split(",")` parse produced. + assertEquals(listOf("$a,$b"), BuzzCommands.participantsOf("""{"participants":["$a,$b"]}""")) + } +} From a302b82413b0cae934ddd788cd637304dd474bc0 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 29 Jul 2026 09:42:20 -0400 Subject: [PATCH 10/31] fix(ci): replace the unresolvable homebrew-bump-cask action with brew itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bump-homebrew.yml pinned macauley/action-homebrew-bump-cask to ad984534de44a9489a53aefd81eb77f87c70dc60, commented "v4.0.0". That SHA does not exist — the action's only release is v1 (445c4239) from 2021. GitHub resolves every `uses:` during "Set up job", so the job died before running a single step, which no `if:` gate can avoid. It stayed invisible because the workflow had never been triggered; fixing the trigger surfaced it immediately. Call `brew bump-cask-pr` directly instead. It is the same command BUILDING.md already documents for manual recovery, and it removes an unmaintained third-party action that would hold a PAT with write access to homebrew-cask. Split the workflow into check (ubuntu) + bump (macOS): cask commands are macOS-only, but the bump is gated on a cask that does not exist yet, so the 10x-billed macOS job is only created once there is something to bump. --- .github/workflows/bump-homebrew.yml | 75 ++++++++++++++++++++++++----- 1 file changed, 63 insertions(+), 12 deletions(-) diff --git a/.github/workflows/bump-homebrew.yml b/.github/workflows/bump-homebrew.yml index f2979658bb..df281a199a 100644 --- a/.github/workflows/bump-homebrew.yml +++ b/.github/workflows/bump-homebrew.yml @@ -40,7 +40,11 @@ concurrency: cancel-in-progress: false jobs: - bump: + # Split in two on purpose. Cask commands are macOS-only (Homebrew on Linux + # refuses them), but the bump is gated on a cask that does not exist yet — so + # the cheap ubuntu `check` job decides, and the 10x-billed macOS `bump` job is + # only created when there is actually something to bump. + check: # On workflow_run, filter down to a SUCCESSFUL TAG build. `event == 'push'` # excludes create-release.yml's workflow_dispatch dry-runs (which carry a # synthetic test_tag and publish nothing), and the head_branch prefix keeps @@ -50,8 +54,12 @@ jobs: (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && startsWith(github.event.workflow_run.head_branch, 'v')) - runs-on: ubuntu-latest # brew runs on Linux — saves macOS runner quota - timeout-minutes: 30 + runs-on: ubuntu-latest + timeout-minutes: 15 + outputs: + tag: ${{ steps.rel.outputs.tag }} + ver: ${{ steps.rel.outputs.ver }} + bootstrapped: ${{ steps.cask.outputs.bootstrapped }} steps: - name: Checkout code uses: actions/checkout@v7 @@ -87,15 +95,6 @@ jobs: echo "::warning::Cask 'amethyst-nostr' is not in Homebrew/homebrew-cask yet, so there is nothing to bump for ${{ steps.rel.outputs.tag }}. Amethyst is NOT shipping via Homebrew. Submit the one-time new-cask PR (BUILDING.md § Bootstrap) to activate this channel." fi - - name: Bump cask (push-or-update PR) - if: steps.cask.outputs.bootstrapped == 'true' - uses: macauley/action-homebrew-bump-cask@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 - with: - token: ${{ secrets.HOMEBREW_TOKEN }} - tap: homebrew/cask - cask: amethyst-nostr - tag: ${{ steps.rel.outputs.tag }} - - name: Report failure if: failure() uses: actions/github-script@v9 @@ -120,3 +119,55 @@ jobs: ].join('\n'), labels: ['release-ops', 'bug'] }); + + bump: + needs: check + if: needs.check.outputs.bootstrapped == 'true' + # Casks are a macOS concept; Homebrew on Linux rejects `bump-cask-pr`. + runs-on: macos-latest + timeout-minutes: 30 + steps: + # Previously this used macauley/action-homebrew-bump-cask pinned to a SHA + # that does not exist in that repo (its only release is v1 from 2021, and + # the pin was commented "v4.0.0"). Because GitHub resolves every `uses:` + # during "Set up job", that bogus pin failed the whole job before any step + # ran — invisible until the trigger was fixed, since the workflow had never + # executed. Call brew directly instead: it is the same command BUILDING.md + # documents for manual recovery, and it drops an unmaintained third-party + # action that would hold a PAT with write access to homebrew-cask. + - name: Bump cask (opens PR against Homebrew/homebrew-cask) + env: + HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_TOKEN }} + TAG: ${{ needs.check.outputs.tag }} + VER: ${{ needs.check.outputs.ver }} + run: | + set -euo pipefail + URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/amethyst-desktop-${VER}-macos-arm64.dmg" + echo "Bumping amethyst-nostr to ${VER} from ${URL}" + brew developer on + brew bump-cask-pr amethyst-nostr --version "${VER}" --url "${URL}" --no-browse + + - name: Report failure + if: failure() + uses: actions/github-script@v9 + with: + script: | + const tag = '${{ needs.check.outputs.tag }}' || 'unknown'; + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `[release-ops] bump-homebrew failed for ${tag}`, + body: [ + `Homebrew cask bump failed for release \`${tag}\`.`, + ``, + `- Run: ${runUrl}`, + `- Channel: Homebrew Cask (\`amethyst-nostr\`)`, + ``, + `Recovery options:`, + `1. Re-run the workflow once underlying issue is fixed`, + `2. Manually run \`brew bump-cask-pr amethyst-nostr --version ${tag.replace(/^v/, '')}\``, + `3. File PR directly against Homebrew/homebrew-cask` + ].join('\n'), + labels: ['release-ops', 'bug'] + }); From 592fd8f542a0c178842a846d1ead65347e7817be Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 29 Jul 2026 14:01:22 +0200 Subject: [PATCH 11/31] test: pin the amy argument surface the buzz-agent path depends on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flag names and error strings are the contract between amy and the wrapper scripts (and their operators), but nothing type-checks them: renaming --base-ref or rewording an error breaks callers while compiling clean. This harness pins the ones the buzz-agent path drives. 14 cases, all offline against an isolated ~/.amy via the `amy.home` seam the JVM tests use: the shared `repo-naddr-or-coordinates` positional on git browse/cat/log, `pass --channel GID` on the workflow verbs, --base-ref accepted while --base-reff is rejected on both agent up and agent serve, and the no_relays detail. Confirmed to discriminate: 14/14 on this branch, 10/14 when built against main-upstream's BuzzCommands, with the four failures printing the old misleading "no relays: pass --relays ws://…" for input the user did pass. Worth recording from writing it: a bare word like `garbage` is *accepted* as a relay (the normalizer prepends wss://), so the realistic trigger for the empty set is a pasted http:// url — which is what the cases use. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WopdqNoZ9tYoMNppJG17BL --- tools/buzz-agent/README.md | 13 ++++ tools/buzz-agent/test-amy-args.sh | 108 ++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100755 tools/buzz-agent/test-amy-args.sh diff --git a/tools/buzz-agent/README.md b/tools/buzz-agent/README.md index 305c98912a..0379a566bb 100644 --- a/tools/buzz-agent/README.md +++ b/tools/buzz-agent/README.md @@ -112,6 +112,19 @@ empty-worktree failure mode, and `base_branch` resolution when `gh` answers oddl non-zero) — the paths that otherwise only fail on someone else's machine, unattended, via a kind-46007 whose stderr is the only clue. +### `./test-amy-args.sh` — the `amy` argument surface + +```bash +./gradlew :cli:installDist +./tools/buzz-agent/test-amy-args.sh +AMY=/path/to/amy ./tools/buzz-agent/test-amy-args.sh # or point at another build +``` + +Pins the flag names and error strings the wrappers and their operators key on — `--base-ref`, the +`repo-naddr-or-coordinates` positional, `pass --channel GID`, and the `no_relays` detail. A renamed +flag or reworded message breaks callers without breaking any compile, and none of it is covered by a +type. Runs offline against an isolated `~/.amy` (every case is rejected before a relay is dialled). + ### `agent-exec.sh` Verified end-to-end against a throwaway repo with a stubbed `gh` + agent diff --git a/tools/buzz-agent/test-amy-args.sh b/tools/buzz-agent/test-amy-args.sh new file mode 100755 index 0000000000..1e4f1dbdf8 --- /dev/null +++ b/tools/buzz-agent/test-amy-args.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# +# test-amy-args.sh — pins the argument-validation surface of the `amy` verbs the +# buzz-agent path drives (`buzz agent`, `buzz workflow`, `buzz dm`, `git`). +# +# These are the strings a human or a wrapper script keys on: a flag name silently +# renamed, or a usage/error message reworded, breaks callers without breaking any +# compile. Everything here runs offline against an isolated ~/.amy — the failing +# cases are all rejected before a relay is contacted. +# +# ./gradlew :cli:installDist +# ./tools/buzz-agent/test-amy-args.sh +# AMY=/path/to/amy ./tools/buzz-agent/test-amy-args.sh # or point at another build +# +# Exits 0 when every case passes, 1 otherwise. + +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +AMY="${AMY:-$ROOT/cli/build/install/amy/bin/amy}" +if [[ ! -x "$AMY" ]]; then + echo "no amy binary at $AMY — build one with: ./gradlew :cli:installDist" >&2 + echo "(or set AMY=/path/to/amy)" >&2 + exit 2 +fi + +# Isolate the data dir: `amy.home` is the seam the JVM tests use too, so this never +# touches a real ~/.amy. +AMY_HOME="$(mktemp -d)" +trap 'rm -rf "$AMY_HOME"' EXIT +export JAVA_OPTS="-Damy.home=$AMY_HOME" + +pass=0 +fail=0 + +expect() { # name, expected-substring, amy args... + local name="$1" want="$2" + shift 2 + local out + out="$("$AMY" "$@" 2>&1)" + if [[ "$out" == *"$want"* ]]; then + printf ' PASS %s\n' "$name" + pass=$((pass + 1)) + else + printf ' FAIL %s\n want output containing %q\n got: %s\n' \ + "$name" "$want" "$(echo "$out" | head -3 | tr '\n' ' ')" + fail=$((fail + 1)) + fi +} + +reject() { # name, unwanted-substring, amy args... + local name="$1" unwanted="$2" + shift 2 + local out + out="$("$AMY" "$@" 2>&1)" + if [[ "$out" != *"$unwanted"* ]]; then + printf ' PASS %s\n' "$name" + pass=$((pass + 1)) + else + printf ' FAIL %s\n output must NOT contain %q\n got: %s\n' \ + "$name" "$unwanted" "$(echo "$out" | head -3 | tr '\n' ' ')" + fail=$((fail + 1)) + fi +} + +printf '\namy arg-surface harness: %s\n\n' "$AMY" + +"$AMY" --account harness init > /dev/null 2>&1 || { echo "could not create a throwaway account" >&2; exit 1; } +A=(--account harness) +DEAD_RELAY=wss://127.0.0.1:1 # closed port: connects and fails immediately + +# --- the positional name every `git` verb shares --------------------------- +for verb in browse cat log; do + expect "git $verb names its missing positional" "repo-naddr-or-coordinates" git "$verb" +done + +# --- required flags on the workflow verbs ---------------------------------- +expect "workflow trigger demands a channel" "pass --channel GID" \ + "${A[@]}" buzz workflow trigger "$DEAD_RELAY" wf1 --task "do a thing" +expect "workflow list demands a channel" "pass --channel GID" \ + "${A[@]}" buzz workflow list "$DEAD_RELAY" + +# --- the --base-ref flag name ---------------------------------------------- +# `agent up` checks --repo before parsing the rest, so point it at a real repo to +# reach rejectUnknown. +expect "agent up rejects a mistyped --base-ref" "unknown flag: --base-reff" \ + "${A[@]}" buzz agent up "$DEAD_RELAY" --repo "$ROOT" --approver npub1qqq --base-reff x --timeout 1 +reject "agent up accepts --base-ref" "unknown flag" \ + "${A[@]}" buzz agent up "$DEAD_RELAY" --repo "$ROOT" --approver npub1qqq --base-ref x --timeout 1 +expect "agent serve rejects a mistyped --base-ref" "unknown flag: --base-reff" \ + "${A[@]}" buzz agent serve "$DEAD_RELAY" --exec true --base-reff x +reject "agent serve accepts --base-ref" "unknown flag" \ + "${A[@]}" buzz agent serve "$DEAD_RELAY" --exec true --base-ref x --once --timeout 1 + +# --- the no_relays detail --------------------------------------------------- +# Passing --relays wins over the outbox, so an all-unparseable list yields an empty +# set and must NOT tell the user to pass the flag they just passed. Note a bare word +# like "garbage" is *accepted* (the normalizer prepends wss://) — a pasted http url +# is the realistic rejection. +for bad in "http://x" "ws://" "," "!!!"; do + expect "buzz dm list names the real problem for --relays '$bad'" \ + "no usable relays in --relays" "${A[@]}" buzz dm list --relays "$bad" --timeout 2 +done +reject "buzz dm list accepts a well-formed relay" "no_relays" \ + "${A[@]}" buzz dm list --relays "$DEAD_RELAY" --timeout 2 + +printf '\n %d passed, %d failed\n\n' "$pass" "$fail" +[[ "$fail" -eq 0 ]] From 8a8f58e7add598002fd81ee5fa4f7b92e061e38d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 29 Jul 2026 09:59:00 -0400 Subject: [PATCH 12/31] fix(release): actually notarize the macOS DMG, and verify it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS leg ran only `packageReleaseDmg`, which SIGNS the DMG but does not notarize it — notarization is a separate Compose task. The `notarization {}` block in desktopApp/build.gradle.kts only supplies credentials; nothing invoked it. So every release up to v1.13.1 shipped a signed but unnotarized DMG: `xcrun stapler validate` reports no ticket on either the .app or the .dmg, and Gatekeeper blocks it on first launch. Append `:desktopApp:notarizeReleaseDmg` on the macOS leg. Compose 1.11.1's AbstractNotarizationTask runs `notarytool submit --wait` and then `stapler staple` in place, so the asset-collection step still finds the same file. Raise that leg's inner timeout to 45m since the submit blocks on Apple. Gate it on the cert AND all three notary secrets, so forks and credential-less runs keep producing a plain unsigned DMG instead of failing. Add a verify step that asserts the stapled ticket. The bug survived many releases precisely because nothing ever checked the outcome. Also update the reference cask to 1.13.1 and make it pass `brew audit --new --cask` + `brew style` cleanly: add the missing conflicts_with (the tiling window manager's cask installs the same Amethyst.app), add depends_on :macos, fix stanza order, drop the unnecessary `verified:` (url and homepage share a domain), and widen the zap to the state dirs the source actually uses. Correct BUILDING.md's macOS state table, which listed a com.vitorpamplona.amethyst.desktop.plist that does not exist and omitted ~/.amethyst. Note that Java's Preferences API writes to a SHARED com.apple.java.util.prefs.plist, which is why the cask must not zap it. --- .github/workflows/create-release.yml | 40 ++++++++++++++++++- BUILDING.md | 9 ++++- .../packaging/homebrew/amethyst-nostr.rb | 27 ++++++++++--- 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 3a10ca3e3c..19ca1146e8 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -155,8 +155,44 @@ jobs: AMETHYST_NOTARY_TEAM_ID: ${{ secrets.MAC_NOTARY_TEAM_ID }} with: max_attempts: 2 - timeout_minutes: 15 - command: ./gradlew --no-daemon :desktopApp:${{ matrix.tasks }} + # macOS needs far longer: notarizeReleaseDmg blocks on `notarytool + # submit --wait`, which is minutes-to-tens-of-minutes on Apple's side. + timeout_minutes: ${{ matrix.family == 'macos' && 45 || 15 }} + # Append notarization on the macOS leg. `packageReleaseDmg` only SIGNS + # the DMG — notarization is a separate Compose task, and because it was + # never invoked every release up to v1.13.1 shipped a signed but + # UNNOTARIZED DMG that Gatekeeper blocks on first launch. The task runs + # `notarytool submit --wait` and then `stapler staple`, in place, so the + # asset-collection step below still finds the same file. + # + # Gated on the cert AND all three notary secrets being present, so forks + # and credential-less runs keep producing a plain unsigned DMG exactly as + # before instead of failing. + command: ./gradlew --no-daemon :desktopApp:${{ matrix.tasks }}${{ (matrix.family == 'macos' && steps.mac_keychain.outputs.signing == 'true' && secrets.MAC_NOTARY_APPLE_ID != '' && secrets.MAC_NOTARY_PASSWORD != '' && secrets.MAC_NOTARY_TEAM_ID != '') && ' :desktopApp:notarizeReleaseDmg' || '' }} + + # Regression guard. The missing-notarization bug was invisible for many + # releases precisely because nothing ever asserted the outcome; assert it + # now so a silently-dropped notarization step can never ship again. + - name: Verify the DMG is notarized and stapled (macOS leg) + if: matrix.family == 'macos' + env: + EXPECT_NOTARIZED: ${{ (steps.mac_keychain.outputs.signing == 'true' && secrets.MAC_NOTARY_APPLE_ID != '' && secrets.MAC_NOTARY_PASSWORD != '' && secrets.MAC_NOTARY_TEAM_ID != '') && 'true' || 'false' }} + run: | + set -euo pipefail + DMG=$(find desktopApp/build/compose/binaries -name "*.dmg" -print -quit) + [[ -n "$DMG" ]] || { echo "::error::no DMG produced"; exit 1; } + echo "Checking $DMG" + + if [[ "$EXPECT_NOTARIZED" != "true" ]]; then + echo "::warning::Apple signing/notary credentials are not configured; this DMG is unsigned and unnotarized. Gatekeeper will block it, and it is not eligible for the Homebrew cask." + exit 0 + fi + + if ! xcrun stapler validate "$DMG"; then + echo "::error::$DMG has no stapled notarization ticket -- notarizeReleaseDmg did not run or failed" + exit 1 + fi + echo "notarization ticket stapled OK" # jpackage pins libicu to the build host's version (libicu74 on # ubuntu-24.04). Rewrite the .deb so it installs across Debian/Ubuntu. diff --git a/BUILDING.md b/BUILDING.md index 1fd7b61bf5..120a23172a 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -662,12 +662,19 @@ by any other channel. | OS | App location | State directories | |---|---|---| -| macOS | `/Applications/Amethyst.app` | `~/Library/Application Support/Amethyst`
`~/Library/Preferences/com.vitorpamplona.amethyst.desktop.plist`
`~/Library/Caches/Amethyst` | +| macOS | `/Applications/Amethyst.app` | `~/.amethyst` (accounts + keys)
`~/Library/Application Support/Amethyst` (Tor)
`~/Library/Caches/AmethystDesktop` (image cache)
`~/Library/Preferences/com.apple.java.util.prefs.plist` (**shared** — see below) | | Windows | `%LOCALAPPDATA%\Amethyst` or `C:\Program Files\Amethyst` | `%APPDATA%\Amethyst`
`%LOCALAPPDATA%\Amethyst` | | Linux (deb/rpm) | `/opt/amethyst` | `~/.config/amethyst`
`~/.local/share/amethyst`
`~/.cache/amethyst` | | Linux (AppImage/tar.gz) | user-chosen | Same as above | | Linux (Flatpak) | `/var/lib/flatpak` or `~/.local/share/flatpak` | `~/.var/app/com.vitorpamplona.amethyst.Desktop/` | +**macOS preferences are in a SHARED file.** `DesktopPreferences` uses the Java +Preferences API, which on macOS writes into +`~/Library/Preferences/com.apple.java.util.prefs.plist` — one plist for *every* +Java application on the machine, not a per-app file. Never delete it to "reset +Amethyst": that wipes unrelated apps' settings. This is why the Homebrew cask's +`zap` stanza deliberately omits it. + Uninstall: - Homebrew: `brew uninstall --cask amethyst-nostr && brew zap amethyst-nostr` diff --git a/desktopApp/packaging/homebrew/amethyst-nostr.rb b/desktopApp/packaging/homebrew/amethyst-nostr.rb index c63eef6ec1..0e0ae12854 100644 --- a/desktopApp/packaging/homebrew/amethyst-nostr.rb +++ b/desktopApp/packaging/homebrew/amethyst-nostr.rb @@ -16,13 +16,12 @@ # https://github.com/vitorpamplona/amethyst/releases/download/vX.Y.Z/amethyst-desktop-X.Y.Z-macos-arm64.dmg # shasum -a 256 amethyst.dmg cask "amethyst-nostr" do - version "1.12.6" - sha256 "69882e83ebcec6723e1ad5655ec2c9d1fa151b9d1a8ae51b869a9d62feabf093" + version "1.13.1" + sha256 "ead8f4f6263417d661b0d24be3853ec3284b330abc14748ebd77a2c2afcd2789" - url "https://github.com/vitorpamplona/amethyst/releases/download/v#{version}/amethyst-desktop-#{version}-macos-arm64.dmg", - verified: "github.com/vitorpamplona/amethyst/" + url "https://github.com/vitorpamplona/amethyst/releases/download/v#{version}/amethyst-desktop-#{version}-macos-arm64.dmg" name "Amethyst" - desc "Nostr client for desktop" + desc "Nostr client" homepage "https://github.com/vitorpamplona/amethyst" livecheck do @@ -30,9 +29,25 @@ cask "amethyst-nostr" do strategy :github_latest end + # The unrelated tiling window manager (cask `amethyst`, ianyh/Amethyst) also + # installs `Amethyst.app`, so the two cannot coexist in /Applications. + conflicts_with cask: "amethyst" depends_on arch: :arm64 + depends_on :macos app "Amethyst.app" - zap trash: "~/.amethyst" + # Verified against the source, not the docs: + # ~/.amethyst DesktopAccountStorage (accounts + keys) + # ~/Library/Application Support/Amethyst DesktopTorManager (tor/) + # ~/Library/Caches/AmethystDesktop Coil image cache + # + # Deliberately NOT zapped: ~/Library/Preferences/com.apple.java.util.prefs.plist. + # The app uses the Java Preferences API, which writes to that single SHARED + # plist — deleting it would wipe every other Java app's preferences too. + zap trash: [ + "~/.amethyst", + "~/Library/Application Support/Amethyst", + "~/Library/Caches/AmethystDesktop", + ] end From 2e47e9cd566467de9f9260412fa62e7c67e31fff Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 29 Jul 2026 10:04:12 -0400 Subject: [PATCH 13/31] docs: correct the HOMEBREW_TOKEN type and scope BUILDING.md called for a fine-grained PAT scoped to Homebrew/homebrew-cask. That token cannot exist: a fine-grained PAT's repository selector only lists repos owned by the resource owner, so a repo you do not own can never be selected, and Homebrew's API layer authorises against classic OAuth scopes (x-oauth-scopes) which fine-grained tokens do not emit. brew bump-cask-pr forks homebrew-cask into the TOKEN OWNER's account, pushes a branch there, and opens the PR upstream. Homebrew declares the requirement as CREATE_ISSUE_FORK_OR_PR_SCOPES = ["repo"] in utils/github.rb, so it is a classic PAT with the `repo` scope. Add the create-token link and call out that `repo` grants write to every repo the account can reach -- including this one -- so the CI secret should belong to a dedicated bot account whose only asset is the fork. --- BUILDING.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/BUILDING.md b/BUILDING.md index 120a23172a..6cfc3ee639 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -529,9 +529,32 @@ their token type and scope: | Secret | Purpose | Scope | |---|---|---| -| `HOMEBREW_TOKEN` | Bump Homebrew cask | Fine-grained PAT — `Homebrew/homebrew-cask` only — `Contents: write` + `Pull requests: write` — 90d expiry | +| `HOMEBREW_TOKEN` | Bump Homebrew cask | **Classic** PAT — `repo` scope — 90d expiry (dedicated bot account strongly preferred; see below) | | `WINGET_TOKEN` | Submit Winget manifests | Classic PAT — `public_repo` — 90d expiry (dedicated bot account preferred; `vedantmgoyal9/winget-releaser` does not support fine-grained) | +**Why `HOMEBREW_TOKEN` must be a classic PAT, not fine-grained.** `brew +bump-cask-pr` forks `Homebrew/homebrew-cask` **into the token owner's account** +(`POST /repos/Homebrew/homebrew-cask/forks`), pushes a branch to that fork, and +opens the PR upstream. Two consequences: + +- A fine-grained PAT cannot express this. Its "Repository access" selector only + lists repos owned by the resource owner, so `Homebrew/homebrew-cask` can never + be selected — and Homebrew's API layer authorises against classic OAuth scopes + (`x-oauth-scopes`), which fine-grained tokens do not emit. +- Homebrew declares the requirement in source as + `CREATE_ISSUE_FORK_OR_PR_SCOPES = ["repo"]` (`utils/github.rb`), so the scope + is `repo`. + +Create it at +. + +**Use a dedicated bot account.** The `repo` scope grants write to *every* +repository the account can reach — including `vitorpamplona/amethyst` itself. A +bot account whose only asset is a fork of `homebrew-cask` bounds the blast +radius of a leaked CI secret to that fork. (`WINGET_TOKEN` has the same +property, which is why a bot account is already recommended for it.) The bot +must have forked `Homebrew/homebrew-cask`, or be able to. + Rotate both on a 90-day cadence. Owner: assigned via `RELEASE_OPS.md` or equivalent issue tracker. On rotation, paste the new token and run `gh workflow run bump-homebrew.yml -f tag=` to verify From a89aaed9faf5e571e5783fe2e7f9b04e9531f82d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 14:10:11 +0000 Subject: [PATCH 14/31] feat(buzz): create channels/forums from section-label "+" instead of a FAB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a Buzz community the FAB (which created a channel) is replaced by a "+" on the Channels label — matching how Direct Messages already offers a "+" — and a Forums label gains its own "+" that starts the create flow pre-set to a forum channel. Both section headers now always render so the "+" is reachable even before any channel loads; the collapse chevron is shown only when the section is non-empty. The create route gains an isForum flag (threaded onto RelayGroupCreateScreen → RelayGroupMetadataViewModel.isForum) so the Forums "+" opens the create screen on a forum, the Channels "+" on a chat channel. The vanilla NIP-29 relay (a flat directory with no sections) keeps its FAB. The stale "accept the invite in the browser" empty text is dropped — an empty community is now a starting point. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG --- .../amethyst/ui/navigation/AppNavigation.kt | 1 + .../amethyst/ui/navigation/routes/Routes.kt | 3 + .../relayGroup/RelayGroupChannelListScreen.kt | 88 +++++++++++++------ .../relayGroup/RelayGroupMetadataScreen.kt | 7 +- amethyst/src/main/res/values/strings.xml | 1 + 5 files changed, 73 insertions(+), 27 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 14faf2d41c..d520612fcb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -790,6 +790,7 @@ fun BuildNavigation( composableFromEndArgs { RelayGroupCreateScreen( relayUrl = it.relayUrl, + isForum = it.isForum, accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 57b5af66e8..4392da65e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -741,6 +741,9 @@ sealed class Route { @Serializable data class RelayGroupCreate( val relayUrl: String, + // Buzz only: start the create flow on a `forum` channel (threaded posts) instead of a + // `stream` (chat) one — set by the community screen's per-section "+" buttons. + val isForum: Boolean = false, ) : Route() @Serializable data class RelayGroupEdit( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index aa20a8aea0..b9236d68f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -338,13 +338,17 @@ fun RelayGroupChannelListScreen( } }, floatingActionButton = { - FloatingActionButton(onClick = { nav.nav(Route.RelayGroupCreate(relay.url)) }, shape = CircleShape) { - Icon( - symbol = MaterialSymbols.Add, - contentDescription = - stringRes(if (isBuzz) R.string.buzz_channel_create_title else R.string.relay_group_create_title), - modifier = Modifier.size(24.dp), - ) + // A Buzz community creates channels/forums from the per-section "+" in their labels (like + // Direct Messages), so no FAB there. A vanilla NIP-29 relay is a flat directory with no + // sections, so it keeps the FAB to create a group. + if (!isBuzz) { + FloatingActionButton(onClick = { nav.nav(Route.RelayGroupCreate(relay.url)) }, shape = CircleShape) { + Icon( + symbol = MaterialSymbols.Add, + contentDescription = stringRes(R.string.relay_group_create_title), + modifier = Modifier.size(24.dp), + ) + } } }, ) { padding -> @@ -378,9 +382,11 @@ fun RelayGroupChannelListScreen( // 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. + // Only the vanilla NIP-29 path has a FAB now; a Buzz community creates from its section + // headers, so it needs no bottom clearance. LazyColumn( modifier = Modifier.padding(padding), - contentPadding = PaddingValues(bottom = FAB_CLEARANCE), + contentPadding = PaddingValues(bottom = if (isBuzz) 0.dp else FAB_CLEARANCE), ) { if (showTorHint) { item(key = "tor-hint") { @@ -394,16 +400,15 @@ fun RelayGroupChannelListScreen( } if (isBuzz) { + // While the membership fetch is still running and nothing has loaded, show a + // "Loading…" line. The old "you're not a member — accept the invite in the browser" + // empty text is gone: the section labels below now each carry a "+" to create a + // channel/forum, so an empty community is a starting point, not a dead end. val noChannelsYet = buzzChatChannels.isEmpty() && buzzForumChannels.isEmpty() - if (noChannelsYet) { - item(key = "buzz-no-channels") { + if (noChannelsYet && buzzStatus is BuzzRelayImportViewModel.Status.Loading) { + item(key = "buzz-loading") { Text( - text = - if (buzzStatus is BuzzRelayImportViewModel.Status.Loading) { - stringRes(R.string.buzz_import_loading) - } else { - stringRes(R.string.buzz_import_empty_body) - }, + text = stringRes(R.string.buzz_import_loading), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth().padding(24.dp), @@ -411,17 +416,24 @@ fun RelayGroupChannelListScreen( } } - // -- CHANNELS -- (Add-all now lives in the community's top-bar overflow menu) - if (buzzChatChannels.isNotEmpty()) { + // -- CHANNELS -- The label carries a "+" to create a channel (the community's FAB + // moved here, like Direct Messages). Add-all lives in the top-bar overflow menu. + // The header always shows so the "+" is available even before any channel loads; + // the collapse toggle is offered only when there's something to collapse. + run { val channelsCollapsed = "channels" in collapsedSections item(key = "sec-channels") { RelayGroupSectionHeader( title = stringRes(R.string.relay_group_section_channels), collapsed = channelsCollapsed, - onToggle = { toggleSection("channels") }, - ) + onToggle = if (buzzChatChannels.isNotEmpty()) ({ toggleSection("channels") }) else null, + ) { + SectionAddButton(stringRes(R.string.buzz_channel_create_title)) { + nav.nav(Route.RelayGroupCreate(relay.url)) + } + } } - if (!channelsCollapsed) { + if (buzzChatChannels.isNotEmpty() && !channelsCollapsed) { itemsIndexed(buzzChatChannels, key = { _, it -> "chat-${it.id}" }) { index, groupId -> RowHairline(index) BuzzImportRow( @@ -438,17 +450,22 @@ fun RelayGroupChannelListScreen( } } - // -- FORUMS -- - if (buzzForumChannels.isNotEmpty()) { + // -- FORUMS -- Same treatment: an always-visible label with a "+" that starts the + // create flow on a forum channel (threaded posts) instead of a chat one. + run { val forumsCollapsed = "forums" in collapsedSections item(key = "sec-forums") { RelayGroupSectionHeader( title = stringRes(R.string.relay_group_section_forums), collapsed = forumsCollapsed, - onToggle = { toggleSection("forums") }, - ) + onToggle = if (buzzForumChannels.isNotEmpty()) ({ toggleSection("forums") }) else null, + ) { + SectionAddButton(stringRes(R.string.buzz_forum_create_title)) { + nav.nav(Route.RelayGroupCreate(relay.url, isForum = true)) + } + } } - if (!forumsCollapsed) { + if (buzzForumChannels.isNotEmpty() && !forumsCollapsed) { itemsIndexed(buzzForumChannels, key = { _, it -> "forum-${it.id}" }) { index, groupId -> RowHairline(index) BuzzImportRow( @@ -652,6 +669,25 @@ private fun RelayGroupSectionHeader( } } +/** + * The trailing "+" for a section label (Channels / Forums), matching the Direct Messages header's + * New-message icon: a primary-tinted Add glyph that creates a new item of that section's type. + */ +@Composable +private fun SectionAddButton( + contentDescription: String, + onClick: () -> Unit, +) { + IconButton(onClick = onClick) { + Icon( + symbol = MaterialSymbols.Add, + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), + ) + } +} + /** * 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, a compact diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt index c71641b669..5f2c62d1d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt @@ -97,10 +97,15 @@ fun RelayGroupCreateScreen( relayUrl: String, accountViewModel: AccountViewModel, nav: INav, + // Buzz only: pre-select the `forum` channel type (the Forums section's "+" routes here with true). + isForum: Boolean = false, ) { val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return val viewModel: RelayGroupMetadataViewModel = viewModel(key = "RelayGroupCreate:$relayUrl") - LaunchedEffect(relay) { viewModel.initCreate(accountViewModel, relay) } + LaunchedEffect(relay) { + viewModel.initCreate(accountViewModel, relay) + if (isForum) viewModel.isForum = true + } // A group only works if the relay actually runs NIP-29 (otherwise it stores our 9007/9002 as // ordinary events, never emits metadata/roster, and the "group" is a dead hex id). Gate creation diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 8cef251ec7..75d6b194a8 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2570,6 +2570,7 @@ Create a group New channel + New forum Private channel Hidden from the channel list and invite-only. Off means anyone on this relay can find and join it. Forum channel From db529444c49822d20e903be4c9b724e056750e85 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 29 Jul 2026 10:19:57 -0400 Subject: [PATCH 15/31] refactor(ci): move the Homebrew cask bump out of CI onto a maintainer machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `brew bump-cask-pr` forks homebrew-cask into the token owner's account, which requires a CLASSIC PAT with the `repo` scope. That scope cannot be narrowed: it grants write to every repository the owning account can reach, including this one. As an Actions secret it would be usable by anyone with push access here, because a pushed branch containing a workflow runs with repo secrets — a strict escalation for a channel that ships one DMG a month. Split the work so the token never becomes a CI secret: - CI (bump-homebrew.yml, now "Sync Homebrew Cask Reference") does the error-prone bookkeeping with GITHUB_TOKEN only: downloads the DMG, asserts it is notarized + stapled, computes the sha256, and opens an in-repo PR syncing desktopApp/packaging/homebrew/amethyst-nostr.rb. This makes it a third sibling of the amy/geode formula-sync workflows rather than a special case. - scripts/bump-homebrew-cask.sh does the upstream PR from a maintainer's shell, re-verifying sha256 and the notarization ticket against the live asset first, and refusing to submit if either fails. Verified against v1.13.1: the sha256 matches and it correctly refuses on the missing notarization ticket. Drop HOMEBREW_TOKEN from the secret inventory, and rewrite the two formula TODO(bootstrap) notes that proposed reintroducing it so a future homebrew-core submission follows the same local pattern. --- .github/workflows/bump-homebrew-formula.yml | 14 +- .../workflows/bump-homebrew-geode-formula.yml | 12 +- .github/workflows/bump-homebrew.yml | 218 +++++++++--------- BUILDING.md | 62 +++-- RELEASE_OPS.md | 34 ++- scripts/bump-homebrew-cask.sh | 104 +++++++++ 6 files changed, 296 insertions(+), 148 deletions(-) create mode 100755 scripts/bump-homebrew-cask.sh diff --git a/.github/workflows/bump-homebrew-formula.yml b/.github/workflows/bump-homebrew-formula.yml index 53c7ae42af..ecdc431d54 100644 --- a/.github/workflows/bump-homebrew-formula.yml +++ b/.github/workflows/bump-homebrew-formula.yml @@ -134,12 +134,14 @@ jobs: Opened by `.github/workflows/bump-homebrew-formula.yml`. Merge to keep the reference formula ready for the homebrew-core submission/bump. - # TODO(bootstrap): once `amy` is accepted into Homebrew/homebrew-core, add a - # step here that opens the homebrew-core bump PR automatically — symmetric to - # the cask bump in bump-homebrew.yml (a pinned macauley/action-homebrew-bump- - # formula, or `brew bump-formula-pr amy --url= --sha256=` with a - # HOMEBREW_TOKEN). It is intentionally omitted until then because - # bump-formula-pr errors on a formula that is not yet in the tap. + # TODO(bootstrap): once `amy` is accepted into Homebrew/homebrew-core, add + # a LOCAL script mirroring scripts/bump-homebrew-cask.sh that runs `brew + # bump-formula-pr amy --url= --sha256=` from a maintainer's + # machine. Do NOT wire that into CI: bump-formula-pr forks homebrew-core + # into the token owner's account, which needs a classic `repo`-scoped PAT, + # and that scope grants write to every repo the account can reach — + # including this one — to anyone with push access here. See + # BUILDING.md § Homebrew cask for the full reasoning. - name: Report failure if: failure() diff --git a/.github/workflows/bump-homebrew-geode-formula.yml b/.github/workflows/bump-homebrew-geode-formula.yml index 3c38519c64..bb2e965b42 100644 --- a/.github/workflows/bump-homebrew-geode-formula.yml +++ b/.github/workflows/bump-homebrew-geode-formula.yml @@ -132,12 +132,12 @@ jobs: Opened by `.github/workflows/bump-homebrew-geode-formula.yml`. Merge to keep the reference formula ready for the homebrew-core submission/bump. - # TODO(bootstrap): once `geode` is accepted into Homebrew/homebrew-core, add - # a step here that opens the homebrew-core bump PR automatically (a pinned - # macauley/action-homebrew-bump-formula, or `brew bump-formula-pr geode - # --url= --sha256=` with a HOMEBREW_TOKEN). It is intentionally - # omitted until then because bump-formula-pr errors on a formula that is not - # yet in the tap. + # TODO(bootstrap): once `geode` is accepted into Homebrew/homebrew-core, + # add a LOCAL script mirroring scripts/bump-homebrew-cask.sh that runs + # `brew bump-formula-pr geode --url= --sha256=` from a + # maintainer's machine. Do NOT wire that into CI — it needs a classic + # `repo`-scoped PAT, which as a CI secret would hand write access to this + # repo to anyone with push access. See BUILDING.md § Homebrew cask. - name: Report failure if: failure() diff --git a/.github/workflows/bump-homebrew.yml b/.github/workflows/bump-homebrew.yml index df281a199a..483afcdead 100644 --- a/.github/workflows/bump-homebrew.yml +++ b/.github/workflows/bump-homebrew.yml @@ -1,21 +1,30 @@ -name: Bump Homebrew Cask +name: Sync Homebrew Cask Reference -# Fires after "Create Release Assets" finishes successfully for a tag push. +# Sibling of bump-homebrew-formula.yml (amy) and bump-homebrew-geode-formula.yml +# (geode). Same mechanism, third artifact: +# - this workflow -> Cask `amethyst-nostr` (the desktop GUI app / DMG) # -# NOT `release: types: [released]`. That event never fires here: the release is -# created by create-release.yml using GITHUB_TOKEN, and GitHub deliberately does -# not raise workflow-triggering events for actions taken by GITHUB_TOKEN (the -# recursion guard). This workflow sat silently dead through every release up to -# v1.13.1 for exactly that reason. +# What it does: after a stable release, download the published macOS DMG, assert +# it is notarized + stapled, compute its sha256, and open a PR syncing +# `desktopApp/packaging/homebrew/amethyst-nostr.rb` to that release. # -# `workflow_run` has no such restriction, and it is also strictly better timed: -# it fires once ALL upload legs have finished, whereas `released` fired while -# assets were still uploading (hence the download retry loops in the sibling -# formula workflows). +# What it does NOT do: open a PR against Homebrew/homebrew-cask. That step is +# deliberately MANUAL and runs on a maintainer's machine — +# `scripts/bump-homebrew-cask.sh`. Reason: `brew bump-cask-pr` forks +# homebrew-cask into the token owner's account, which requires a CLASSIC PAT +# with the `repo` scope; that scope grants write to every repository the account +# can reach, and stored as a CI secret it would be usable by anyone with push +# access to this repo. Keeping it in a maintainer's shell instead of a CI secret +# removes that blast radius entirely, at the cost of one command per release. +# See BUILDING.md § Homebrew cask. # -# Draft/prerelease state is not in the workflow_run payload, so it is read back -# from the API by .github/actions/resolve-release and enforced by -# .github/actions/assert-stable-release. +# Consequence: this workflow needs NO external token. GITHUB_TOKEN is enough, +# exactly like the two formula workflows. +# +# Trigger: after "Create Release Assets" succeeds for a tag push. NOT +# `release: types: [released]` — that event never fires, because the release is +# created by create-release.yml under GITHUB_TOKEN and GitHub suppresses +# workflow-triggering events for it. See the note in bump-homebrew-formula.yml. on: workflow_run: workflows: ["Create Release Assets"] @@ -23,43 +32,36 @@ on: workflow_dispatch: inputs: tag: - description: 'Release tag to bump (for manual recovery)' + description: 'Release tag to sync (for manual recovery)' required: true type: string permissions: - contents: read - # The "Report failure" step below opens a [release-ops] issue via - # github.rest.issues.create; that needs issues:write. Without it the failure - # reporter itself 403s and no alert is ever filed. + contents: write + pull-requests: write + # The "Report failure" step opens a [release-ops] issue via + # github.rest.issues.create, which needs issues:write. issues: write concurrency: - # Serialize bumps per tag; do not cancel in-progress bumps. + # Serialize per tag; do not cancel in-progress runs. group: bump-homebrew-${{ github.event.workflow_run.head_branch || inputs.tag }} cancel-in-progress: false jobs: - # Split in two on purpose. Cask commands are macOS-only (Homebrew on Linux - # refuses them), but the bump is gated on a cask that does not exist yet — so - # the cheap ubuntu `check` job decides, and the 10x-billed macOS `bump` job is - # only created when there is actually something to bump. - check: - # On workflow_run, filter down to a SUCCESSFUL TAG build. `event == 'push'` - # excludes create-release.yml's workflow_dispatch dry-runs (which carry a - # synthetic test_tag and publish nothing), and the head_branch prefix keeps - # non-tag runs out. Exact tag format is still enforced downstream. + sync-cask: + # See bump-homebrew-formula.yml for why these three conditions: successful, + # tag-push (not a dry-run dispatch), v-prefixed. Format enforced downstream. if: >- github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && startsWith(github.event.workflow_run.head_branch, 'v')) - runs-on: ubuntu-latest - timeout-minutes: 15 - outputs: - tag: ${{ steps.rel.outputs.tag }} - ver: ${{ steps.rel.outputs.ver }} - bootstrapped: ${{ steps.cask.outputs.bootstrapped }} + # macOS runner: `xcrun stapler` is the only way to verify the notarization + # ticket, and shipping an unnotarized DMG to the cask is the failure mode + # this whole channel is most exposed to. + runs-on: macos-latest + timeout-minutes: 20 steps: - name: Checkout code uses: actions/checkout@v7 @@ -78,23 +80,81 @@ jobs: is_prerelease: ${{ steps.rel.outputs.is_prerelease }} is_draft: ${{ steps.rel.outputs.is_draft }} - # `brew bump-cask-pr` can only bump a cask that ALREADY EXISTS in the tap. - # `amethyst-nostr` has never been submitted to Homebrew/homebrew-cask, so - # until the one-time bootstrap PR lands (BUILDING.md § Bootstrap) this - # workflow must no-op rather than fail — otherwise every single release - # files a spurious [release-ops] issue. - - name: Check the cask exists in homebrew-cask - id: cask + - name: Download DMG, verify notarization, compute sha256 + id: asset run: | set -euo pipefail - if curl -fsSL -o /dev/null https://formulae.brew.sh/api/cask/amethyst-nostr.json; then - echo "bootstrapped=true" >> "$GITHUB_OUTPUT" - echo "cask amethyst-nostr found in homebrew-cask; proceeding with bump" - else - echo "bootstrapped=false" >> "$GITHUB_OUTPUT" - echo "::warning::Cask 'amethyst-nostr' is not in Homebrew/homebrew-cask yet, so there is nothing to bump for ${{ steps.rel.outputs.tag }}. Amethyst is NOT shipping via Homebrew. Submit the one-time new-cask PR (BUILDING.md § Bootstrap) to activate this channel." + TAG="${{ steps.rel.outputs.tag }}" + VER="${{ steps.rel.outputs.ver }}" + URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/amethyst-desktop-${VER}-macos-arm64.dmg" + echo "Fetching $URL" + # workflow_run fires only after every upload leg has finished, so the + # asset should already be there. Retry anyway for release-CDN + # propagation (mirrors the repo's push/pull retry ethos). + ok=0 + for i in 1 2 3 4 5; do + if curl -fsSL -o amethyst.dmg "$URL"; then ok=1; break; fi + wait=$(( 2 ** i )) + echo "attempt $i failed; retrying in ${wait}s" + sleep "$wait" + done + [[ "$ok" == 1 ]] || { echo "::error::could not download $URL"; exit 1; } + test -s amethyst.dmg + + # A cask must point at a notarized+stapled DMG or every user hits a + # Gatekeeper block. Refuse to advertise one that is not. + if ! xcrun stapler validate amethyst.dmg; then + echo "::error::${TAG} DMG has no stapled notarization ticket -- refusing to sync the cask. Check the notarizeReleaseDmg step in create-release.yml." + exit 1 fi + SHA=$(shasum -a 256 amethyst.dmg | awk '{print $1}') + echo "sha256=$SHA" >> "$GITHUB_OUTPUT" + echo "amethyst-desktop-${VER}-macos-arm64.dmg -> $SHA" + + - name: Update reference cask + run: | + set -euo pipefail + CASK=desktopApp/packaging/homebrew/amethyst-nostr.rb + VER="${{ steps.rel.outputs.ver }}" + SHA="${{ steps.asset.outputs.sha256 }}" + # Anchor on the 2-space indent so the header comment's example lines + # are never touched. + sed -i '' -E "s|^( version ).*|\1\"${VER}\"|" "$CASK" + sed -i '' -E "s|^( sha256 ).*|\1\"${SHA}\"|" "$CASK" + echo "----- $CASK -----" + grep -E "^ (version|sha256) " "$CASK" + + - name: Open or update the cask-sync PR + # peter-evans/create-pull-request is MIT-licensed CI-only tooling (not + # linked into any shipped artifact). It no-ops when there is no diff. + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.GITHUB_TOKEN }} + base: main + branch: chore/bump-amethyst-cask-${{ steps.rel.outputs.tag }} + add-paths: desktopApp/packaging/homebrew/amethyst-nostr.rb + commit-message: 'chore: sync amethyst-nostr cask to ${{ steps.rel.outputs.tag }}' + title: 'chore: sync amethyst-nostr cask to ${{ steps.rel.outputs.tag }}' + body: | + Auto-synced `desktopApp/packaging/homebrew/amethyst-nostr.rb` to the + `${{ steps.rel.outputs.tag }}` release: + + - `version` -> `${{ steps.rel.outputs.ver }}` + - `sha256` -> `${{ steps.asset.outputs.sha256 }}` + + The DMG was verified notarized + stapled before this PR was opened. + + **Merge this, then push it upstream from a maintainer machine:** + + ```bash + scripts/bump-homebrew-cask.sh ${{ steps.rel.outputs.tag }} + ``` + + That step is manual on purpose — it needs a classic PAT with the + `repo` scope, which is deliberately NOT stored as a CI secret. See + BUILDING.md § Homebrew cask. + - name: Report failure if: failure() uses: actions/github-script@v9 @@ -105,69 +165,17 @@ jobs: await github.rest.issues.create({ owner: context.repo.owner, repo: context.repo.repo, - title: `[release-ops] bump-homebrew failed for ${tag}`, + title: `[release-ops] sync-homebrew-cask failed for ${tag}`, body: [ - `Homebrew cask bump failed for release \`${tag}\`.`, + `amethyst-nostr cask sync failed for release \`${tag}\`.`, ``, `- Run: ${runUrl}`, `- Channel: Homebrew Cask (\`amethyst-nostr\`)`, ``, `Recovery options:`, - `1. Re-run the workflow once underlying issue is fixed`, - `2. Manually run \`brew bump-cask-pr amethyst-nostr --version ${tag.replace(/^v/, '')}\``, - `3. File PR directly against Homebrew/homebrew-cask` - ].join('\n'), - labels: ['release-ops', 'bug'] - }); - - bump: - needs: check - if: needs.check.outputs.bootstrapped == 'true' - # Casks are a macOS concept; Homebrew on Linux rejects `bump-cask-pr`. - runs-on: macos-latest - timeout-minutes: 30 - steps: - # Previously this used macauley/action-homebrew-bump-cask pinned to a SHA - # that does not exist in that repo (its only release is v1 from 2021, and - # the pin was commented "v4.0.0"). Because GitHub resolves every `uses:` - # during "Set up job", that bogus pin failed the whole job before any step - # ran — invisible until the trigger was fixed, since the workflow had never - # executed. Call brew directly instead: it is the same command BUILDING.md - # documents for manual recovery, and it drops an unmaintained third-party - # action that would hold a PAT with write access to homebrew-cask. - - name: Bump cask (opens PR against Homebrew/homebrew-cask) - env: - HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_TOKEN }} - TAG: ${{ needs.check.outputs.tag }} - VER: ${{ needs.check.outputs.ver }} - run: | - set -euo pipefail - URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/amethyst-desktop-${VER}-macos-arm64.dmg" - echo "Bumping amethyst-nostr to ${VER} from ${URL}" - brew developer on - brew bump-cask-pr amethyst-nostr --version "${VER}" --url "${URL}" --no-browse - - - name: Report failure - if: failure() - uses: actions/github-script@v9 - with: - script: | - const tag = '${{ needs.check.outputs.tag }}' || 'unknown'; - const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: `[release-ops] bump-homebrew failed for ${tag}`, - body: [ - `Homebrew cask bump failed for release \`${tag}\`.`, - ``, - `- Run: ${runUrl}`, - `- Channel: Homebrew Cask (\`amethyst-nostr\`)`, - ``, - `Recovery options:`, - `1. Re-run the workflow once underlying issue is fixed`, - `2. Manually run \`brew bump-cask-pr amethyst-nostr --version ${tag.replace(/^v/, '')}\``, - `3. File PR directly against Homebrew/homebrew-cask` + `1. Re-run the workflow once the underlying issue is fixed`, + `2. Check the DMG is notarized: \`xcrun stapler validate\` on the release asset`, + `3. Manually update \`desktopApp/packaging/homebrew/amethyst-nostr.rb\` (version + sha256)` ].join('\n'), labels: ['release-ops', 'bug'] }); diff --git a/BUILDING.md b/BUILDING.md index 6cfc3ee639..f817b78a0e 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -403,7 +403,7 @@ provided automatically; everything else you set yourself.) | `MAC_NOTARY_APPLE_ID` | Apple ID email of the notarization account | Apple notarization (`notarytool`) | | `MAC_NOTARY_PASSWORD` | **App-specific** password for that Apple ID (not the login password) | Same | | `MAC_NOTARY_TEAM_ID` | 10-char Apple Developer **Team ID** | Same | -| `HOMEBREW_TOKEN` | PAT for `Homebrew/homebrew-cask` | Desktop cask bump (stable tags) | +| ~~`HOMEBREW_TOKEN`~~ | *Not used.* The cask bump runs on a maintainer's machine — see § Homebrew cask | — | | `WINGET_TOKEN` | PAT for `microsoft/winget-pkgs` | Desktop winget bump (stable tags) | | `CROWDIN_PERSONAL_TOKEN`, `CROWDIN_PROJECT_ID` | Crowdin API creds | Translation sync (separate workflow, not the release) | @@ -529,38 +529,54 @@ their token type and scope: | Secret | Purpose | Scope | |---|---|---| -| `HOMEBREW_TOKEN` | Bump Homebrew cask | **Classic** PAT — `repo` scope — 90d expiry (dedicated bot account strongly preferred; see below) | | `WINGET_TOKEN` | Submit Winget manifests | Classic PAT — `public_repo` — 90d expiry (dedicated bot account preferred; `vedantmgoyal9/winget-releaser` does not support fine-grained) | -**Why `HOMEBREW_TOKEN` must be a classic PAT, not fine-grained.** `brew -bump-cask-pr` forks `Homebrew/homebrew-cask` **into the token owner's account** -(`POST /repos/Homebrew/homebrew-cask/forks`), pushes a branch to that fork, and -opens the PR upstream. Two consequences: +**There is deliberately no `HOMEBREW_TOKEN`.** The cask bump is the one release +step that runs on a maintainer's machine rather than in CI. The reasoning is +worth keeping, because the same trade-off applies to `WINGET_TOKEN`: -- A fine-grained PAT cannot express this. Its "Repository access" selector only +`brew bump-cask-pr` forks `Homebrew/homebrew-cask` **into the token owner's +account** (`POST /repos/Homebrew/homebrew-cask/forks`), pushes a branch to that +fork, then opens the PR upstream. That shape forces a **classic** PAT with the +`repo` scope: + +- A fine-grained PAT cannot express it. Its "Repository access" selector only lists repos owned by the resource owner, so `Homebrew/homebrew-cask` can never be selected — and Homebrew's API layer authorises against classic OAuth scopes (`x-oauth-scopes`), which fine-grained tokens do not emit. - Homebrew declares the requirement in source as - `CREATE_ISSUE_FORK_OR_PR_SCOPES = ["repo"]` (`utils/github.rb`), so the scope - is `repo`. + `CREATE_ISSUE_FORK_OR_PR_SCOPES = ["repo"]` (`utils/github.rb`). -Create it at +And `repo` cannot be narrowed: it grants write to *every* repository the owning +account can reach — including `vitorpamplona/amethyst` itself. Stored as an +Actions secret it would be usable by **anyone with push access to this repo**, +since a pushed branch containing a workflow runs with repo secrets. That is a +strict escalation for a channel that ships one DMG a month. + +So the split is: + +- **CI** (`bump-homebrew.yml`, `GITHUB_TOKEN` only) does the error-prone + bookkeeping: downloads the DMG, asserts it is notarized + stapled, computes + the sha256, and opens an in-repo PR syncing + `desktopApp/packaging/homebrew/amethyst-nostr.rb`. +- **A maintainer** merges that PR and runs `scripts/bump-homebrew-cask.sh`, + which re-verifies the sha256 and the notarization ticket against the live + asset before calling `brew bump-cask-pr`. + +The token then lives only in that maintainer's shell: + +```bash +export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope +scripts/bump-homebrew-cask.sh v1.13.2 +``` + +Create one at . +Prefer a dedicated bot account whose only asset is a fork of `homebrew-cask`, so +a leak reaches nothing else. -**Use a dedicated bot account.** The `repo` scope grants write to *every* -repository the account can reach — including `vitorpamplona/amethyst` itself. A -bot account whose only asset is a fork of `homebrew-cask` bounds the blast -radius of a leaked CI secret to that fork. (`WINGET_TOKEN` has the same -property, which is why a bot account is already recommended for it.) The bot -must have forked `Homebrew/homebrew-cask`, or be able to. - -Rotate both on a 90-day cadence. Owner: assigned via `RELEASE_OPS.md` -or equivalent issue tracker. On rotation, paste the new token and run -`gh workflow run bump-homebrew.yml -f tag=` to verify -(the `tag` input is required — that dispatch path exists for exactly this). -Note the verification is only meaningful once the cask exists upstream; -before that the run skips the token-using step entirely. +Rotate `WINGET_TOKEN` on a 90-day cadence. Owner: assigned via `RELEASE_OPS.md` +or equivalent issue tracker. ### Homebrew cask (one-time initial PR) diff --git a/RELEASE_OPS.md b/RELEASE_OPS.md index ab641f769c..5eca4e3694 100644 --- a/RELEASE_OPS.md +++ b/RELEASE_OPS.md @@ -210,10 +210,24 @@ Until someone does that bootstrap, a green release run means the bump workflows *skipped cleanly* — not that Homebrew/Winget shipped. Check the run's warnings if you want to confirm which case you're in. -The two `amy` / `geode` Homebrew **formula** workflows are different: they only -sync the in-repo reference `.rb` files and open a PR against *this* repo, so -they do real work on every release. Merge those PRs to keep the formulae ready -for their eventual homebrew-core submission. +**The cask bump is half-manual by design.** CI (`bump-homebrew.yml`) does the +bookkeeping with `GITHUB_TOKEN` only — verifies the DMG is notarized + stapled, +computes the sha256, and opens an in-repo PR syncing the reference cask. Pushing +that upstream needs a classic `repo`-scoped PAT, which is deliberately *not* a +CI secret (it would grant write to this repo to anyone with push access), so a +maintainer runs it: + +```bash +export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope +scripts/bump-homebrew-cask.sh v1.13.2 +``` + +The script re-verifies sha256 + notarization against the live asset and refuses +to submit otherwise. See BUILDING.md § Homebrew cask. + +The three in-repo sync workflows (`amy` formula, `geode` formula, `amethyst-nostr` +cask) all open PRs against *this* repo on every release. Merge them to keep the +reference packaging files current. --- @@ -255,7 +269,7 @@ ownership: | `SIGNING_KEY`, `KEY_ALIAS`, `KEY_STORE_PASSWORD`, `KEY_PASSWORD` | The **Android upload keystore** — losing/leaking it is the worst case; Play app signing identity | Keep the keystore backed up offline; never rotate casually (Play upload key reset is a support process) | | `SONATYPE_USERNAME`, `SONATYPE_PASSWORD` | Maven Central namespace `com.vitorpamplona` | On compromise | | `SIGNING_PRIVATE_KEY`, `SIGNING_PASSWORD` | The **GPG key** signing Maven artifacts | Per GPG key expiry | -| `HOMEBREW_TOKEN`, `WINGET_TOKEN` | Cask + winget bump PRs | **90-day cadence** (see BUILDING.md § Bootstrap) | +| `WINGET_TOKEN` | Winget bump PRs | **90-day cadence** (see BUILDING.md § Bootstrap). No `HOMEBREW_TOKEN` exists — the cask bump runs locally via `scripts/bump-homebrew-cask.sh`, so the `repo`-scoped PAT it needs never becomes a CI secret | | `CROWDIN_PERSONAL_TOKEN`, `CROWDIN_PROJECT_ID` | Translation sync | On compromise | Owner assignments and rotation reminders live with the team (issue tracker). @@ -274,9 +288,13 @@ Owner assignments and rotation reminders live with the team (issue tracker). - [ ] Play Console: rollout started, no policy rejection. - [ ] Zapstore: release event visible. - [ ] F-Droid: new version detected (may lag days). -- [ ] `amy` / `geode` Homebrew formula-sync PRs opened against this repo — merge them. -- [ ] Homebrew + Winget: **expected to skip** until bootstrapped (§ 3). If they - ever start opening real PRs, that means someone landed the bootstrap. +- [ ] `amy` / `geode` formula + `amethyst-nostr` cask sync PRs opened against + this repo — merge them. +- [ ] Cask pushed upstream: `scripts/bump-homebrew-cask.sh vX.Y.Z` (manual, needs + `HOMEBREW_GITHUB_API_TOKEN` in your shell). Skips with a clear error until + the one-time new-cask PR has been bootstrapped (§ 3). +- [ ] Winget: **expected to skip** until bootstrapped (§ 3). If it ever starts + opening real PRs, that means someone landed the bootstrap. - [ ] In-app "Release Notes" link opens the note matching `RELEASE_NOTES_ID` (only bumped on minor releases — patches keep pointing at the x.y.0 note). - [ ] Push still works on a `play` build (only if the push contract changed — diff --git a/scripts/bump-homebrew-cask.sh b/scripts/bump-homebrew-cask.sh new file mode 100755 index 0000000000..d0f68a4b42 --- /dev/null +++ b/scripts/bump-homebrew-cask.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# +# Push the amethyst-nostr cask upstream to Homebrew/homebrew-cask. +# +# This is the ONE release step that is deliberately not automated. `brew +# bump-cask-pr` forks homebrew-cask into the token owner's account, which needs +# a CLASSIC PAT with the `repo` scope — and that scope grants write to every +# repository the account can reach. As a CI secret it would be usable by anyone +# with push access to this repo; in your shell it is not. See BUILDING.md +# § Homebrew cask. +# +# Everything error-prone (version, sha256, notarization check) is already done +# in CI by .github/workflows/bump-homebrew.yml, which opens a PR syncing +# desktopApp/packaging/homebrew/amethyst-nostr.rb. Merge that PR first; this +# script reads the merged values so the two can never disagree. +# +# Usage: +# scripts/bump-homebrew-cask.sh # uses the cask file as-is +# scripts/bump-homebrew-cask.sh v1.13.2 # asserts the cask matches this tag +# DRY_RUN=1 scripts/bump-homebrew-cask.sh # print what would happen, do nothing +# +# Requires: macOS, brew, and HOMEBREW_GITHUB_API_TOKEN exported (classic PAT, +# `repo` scope). Create one at: +# https://github.com/settings/tokens/new?scopes=repo&description=Homebrew%20cask%20bump + +set -euo pipefail + +REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +CASK="$REPO_ROOT/desktopApp/packaging/homebrew/amethyst-nostr.rb" +EXPECTED_TAG="${1:-}" +DRY_RUN="${DRY_RUN:-}" + +die() { echo "error: $*" >&2; exit 1; } + +[[ "$(uname -s)" == "Darwin" ]] || die "casks are macOS-only; run this on a Mac" +command -v brew >/dev/null || die "brew not found" +[[ -f "$CASK" ]] || die "cask not found at $CASK" + +VERSION=$(grep -E '^ version "' "$CASK" | sed -E 's/.*"(.*)".*/\1/') +SHA=$(grep -E '^ sha256 "' "$CASK" | sed -E 's/.*"(.*)".*/\1/') +[[ -n "$VERSION" && -n "$SHA" ]] || die "could not parse version/sha256 out of $CASK" + +if [[ -n "$EXPECTED_TAG" && "v$VERSION" != "$EXPECTED_TAG" ]]; then + die "cask is at v$VERSION but you asked for $EXPECTED_TAG. +Merge the 'chore: sync amethyst-nostr cask to $EXPECTED_TAG' PR first, then pull." +fi + +URL="https://github.com/vitorpamplona/amethyst/releases/download/v${VERSION}/amethyst-desktop-${VERSION}-macos-arm64.dmg" + +echo "cask : amethyst-nostr" +echo "version : $VERSION" +echo "sha256 : $SHA" +echo "url : $URL" +echo + +# Re-verify against the live asset. CI already checked this, but the whole point +# of a manual gate is that a human confirms what is about to be advertised to +# every macOS user. +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +echo "==> downloading and verifying the release DMG" +curl -fsSL -o "$TMP/amethyst.dmg" "$URL" || die "could not download $URL" + +ACTUAL_SHA=$(shasum -a 256 "$TMP/amethyst.dmg" | awk '{print $1}') +[[ "$ACTUAL_SHA" == "$SHA" ]] || die "sha256 mismatch! + cask says : $SHA + actual : $ACTUAL_SHA" +echo " sha256 matches" + +xcrun stapler validate "$TMP/amethyst.dmg" >/dev/null 2>&1 \ + || die "the DMG has NO stapled notarization ticket. +Users would hit a Gatekeeper block. Do not submit this to homebrew-cask. +Check the notarizeReleaseDmg step in .github/workflows/create-release.yml." +echo " notarization ticket stapled" +echo + +if [[ -n "$DRY_RUN" ]]; then + echo "DRY_RUN set — would now run:" + echo " brew bump-cask-pr amethyst-nostr --version $VERSION --sha256 $SHA --url $URL" + exit 0 +fi + +[[ -n "${HOMEBREW_GITHUB_API_TOKEN:-}" ]] || die "HOMEBREW_GITHUB_API_TOKEN is not set. +Create a CLASSIC PAT with the 'repo' scope: + https://github.com/settings/tokens/new?scopes=repo&description=Homebrew%20cask%20bump +then: export HOMEBREW_GITHUB_API_TOKEN=ghp_... +Prefer a dedicated bot account — 'repo' reaches every repo that account can see." + +if ! brew info --cask amethyst-nostr >/dev/null 2>&1; then + cat >&2 < opening the homebrew-cask PR" +brew bump-cask-pr amethyst-nostr --version "$VERSION" --sha256 "$SHA" --url "$URL" --no-browse +echo +echo "Done. Watch the PR at https://github.com/Homebrew/homebrew-cask/pulls" From 34fa103dbd6eaa70df083a441943b13e1d6d3e25 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 14:26:23 +0000 Subject: [PATCH 16/31] feat(buzz): gate the channel/forum "+" to community members Only a Buzz community member may create a channel (the relay makes the creator its owner; a non-member's kind-9007 is rejected), so the section-label "+" now shows only when the viewer is in the community's NIP-43 roster. Reactive: the relay-signed roster snapshot (kind 13534, republished on every membership change) can arrive after first composition, so BuzzCommunityMembership is re-read whenever one lands. The section labels themselves still render for everyone; only the "+" is gated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG --- .../relayGroup/RelayGroupChannelListScreen.kt | 55 +++++++++++++++---- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index b9236d68f4..bcaedd0096 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -109,6 +109,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -208,6 +209,24 @@ fun RelayGroupChannelListScreen( // (kind-44100) so browsing the relay lists the channels you already belong to — each addable to // your kind-10009 list (so it then shows in Messages / Relay Groups). Same screen, one Browse. val isBuzz = BuzzRelayDialect.isBuzz(relay) || relayInfo.software?.contains("buzz", ignoreCase = true) == true + + // Who may create a channel/forum here — the gate for the section "+" buttons. A Buzz relay lets + // any community member create one (the creator becomes its owner); a non-member's 9007 is + // rejected, so we only offer the "+" to members of this community's NIP-43 roster. Reactive: the + // relay-signed roster snapshot (kind 13534, republished after every membership change) may land + // after first composition, so re-read [BuzzCommunityMembership] whenever one does. + val myPubkey = accountViewModel.account.signer.pubKey + val canCreateChannels by produceState( + initialValue = BuzzCommunityMembership.isMember(relay, myPubkey), + relay, + myPubkey, + ) { + value = BuzzCommunityMembership.isMember(relay, myPubkey) + LocalCache + .observeEvents(Filter(kinds = listOf(RelayMembershipListEvent.KIND))) + .collect { value = BuzzCommunityMembership.isMember(relay, myPubkey) } + } + val buzzVm: BuzzRelayImportViewModel = viewModel(key = "BuzzImport-${relay.url}") LaunchedEffect(relay, isBuzz) { if (isBuzz) buzzVm.bind(accountViewModel.account, relay.url) } val buzzChannels by buzzVm.channels.collectAsStateWithLifecycle() @@ -352,7 +371,6 @@ fun RelayGroupChannelListScreen( } }, ) { padding -> - val myPubkey = accountViewModel.userProfile().pubkeyHex // A Buzz relay is a community, so always render its sectioned list (Channels, Forums, Direct // Messages, Agent Console) even before anything loads, rather than the generic "empty" text. if (channels.isEmpty() && !isBuzz) { @@ -419,7 +437,8 @@ fun RelayGroupChannelListScreen( // -- CHANNELS -- The label carries a "+" to create a channel (the community's FAB // moved here, like Direct Messages). Add-all lives in the top-bar overflow menu. // The header always shows so the "+" is available even before any channel loads; - // the collapse toggle is offered only when there's something to collapse. + // the collapse toggle is offered only when there's something to collapse. The "+" + // is offered only to community members, who are the ones the relay lets create. run { val channelsCollapsed = "channels" in collapsedSections item(key = "sec-channels") { @@ -427,11 +446,17 @@ fun RelayGroupChannelListScreen( title = stringRes(R.string.relay_group_section_channels), collapsed = channelsCollapsed, onToggle = if (buzzChatChannels.isNotEmpty()) ({ toggleSection("channels") }) else null, - ) { - SectionAddButton(stringRes(R.string.buzz_channel_create_title)) { - nav.nav(Route.RelayGroupCreate(relay.url)) - } - } + trailing = + if (canCreateChannels) { + { + SectionAddButton(stringRes(R.string.buzz_channel_create_title)) { + nav.nav(Route.RelayGroupCreate(relay.url)) + } + } + } else { + null + }, + ) } if (buzzChatChannels.isNotEmpty() && !channelsCollapsed) { itemsIndexed(buzzChatChannels, key = { _, it -> "chat-${it.id}" }) { index, groupId -> @@ -459,11 +484,17 @@ fun RelayGroupChannelListScreen( title = stringRes(R.string.relay_group_section_forums), collapsed = forumsCollapsed, onToggle = if (buzzForumChannels.isNotEmpty()) ({ toggleSection("forums") }) else null, - ) { - SectionAddButton(stringRes(R.string.buzz_forum_create_title)) { - nav.nav(Route.RelayGroupCreate(relay.url, isForum = true)) - } - } + trailing = + if (canCreateChannels) { + { + SectionAddButton(stringRes(R.string.buzz_forum_create_title)) { + nav.nav(Route.RelayGroupCreate(relay.url, isForum = true)) + } + } + } else { + null + }, + ) } if (buzzForumChannels.isNotEmpty() && !forumsCollapsed) { itemsIndexed(buzzForumChannels, key = { _, it -> "forum-${it.id}" }) { index, groupId -> From 8a183d1cbdce9a21a688ea07e6e2babb6cc31131 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 29 Jul 2026 10:30:21 -0400 Subject: [PATCH 17/31] refactor(ci): move the Winget bump out of CI onto a maintainer machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the Homebrew cask change. The old design stored a classic `public_repo` PAT as WINGET_TOKEN and handed it to the third-party vedantmgoyal9/winget-releaser action: a token with write access to every public repo the owning account can reach, given to code we do not control, in a place any push-access collaborator could read it from (a pushed branch containing a workflow runs with repo secrets). - CI (bump-winget.yml, now "Sync Winget Manifest Reference") uses GITHUB_TOKEN only: downloads the MSI, computes the sha256, reads the ProductCode from the MSI Property table via msitools, and opens an in-repo PR syncing desktopApp/packaging/winget/. Runs on ubuntu (1x billing) rather than Windows since msitools reads the Property table fine. - scripts/bump-winget.sh does the upstream PR. It needs NO new token: it drives `gh`, which a maintainer already has authenticated, and it does not need wingetcreate (Windows-only) because the manifests are plain YAML — so it runs from macOS or Linux. Add the three reference manifests under desktopApp/packaging/winget/, matching the schema 1.12.0 shape used upstream. All three validate against Microsoft's published JSON schemas. Validation caught one real bug worth noting: an all-digit 64-char InstallerSha256 parses as a YAML *integer* and fails the schema's `string` type, so it is written quoted. ProductCode is re-read every release because jpackage regenerates it per build; it is the ARP key `winget upgrade` matches on. Drops the last package-manager PAT from the secret inventory. --- .github/workflows/bump-winget.yml | 190 ++++++++++++++---- BUILDING.md | 43 ++-- RELEASE_OPS.md | 39 ++-- .../VitorPamplona.Amethyst.installer.yaml | 32 +++ .../VitorPamplona.Amethyst.locale.en-US.yaml | 36 ++++ .../winget/VitorPamplona.Amethyst.yaml | 15 ++ scripts/bump-winget.sh | 159 +++++++++++++++ 7 files changed, 439 insertions(+), 75 deletions(-) create mode 100644 desktopApp/packaging/winget/VitorPamplona.Amethyst.installer.yaml create mode 100644 desktopApp/packaging/winget/VitorPamplona.Amethyst.locale.en-US.yaml create mode 100644 desktopApp/packaging/winget/VitorPamplona.Amethyst.yaml create mode 100755 scripts/bump-winget.sh diff --git a/.github/workflows/bump-winget.yml b/.github/workflows/bump-winget.yml index c50965e4e5..ef19cfd79a 100644 --- a/.github/workflows/bump-winget.yml +++ b/.github/workflows/bump-winget.yml @@ -1,12 +1,31 @@ -name: Bump Winget Manifest +name: Sync Winget Manifest Reference -# Fires after "Create Release Assets" finishes successfully for a tag push. +# Fourth sibling of the three Homebrew sync workflows, same shape: +# bump-homebrew-formula.yml -> Formula `amy` +# bump-homebrew-geode-formula.yml -> Formula `geode` +# bump-homebrew.yml -> Cask `amethyst-nostr` +# this workflow -> Winget `VitorPamplona.Amethyst` # -# NOT `release: types: [released]`. That event never fires here: the release is -# created by create-release.yml using GITHUB_TOKEN, and GitHub does not raise -# workflow-triggering events for GITHUB_TOKEN actions. This workflow sat -# silently dead through every release up to v1.13.1 for exactly that reason. -# See the longer note in bump-homebrew.yml. +# What it does: after a stable release, download the published Windows MSI, +# compute its sha256, read its ProductCode, and open a PR syncing +# desktopApp/packaging/winget/*.yaml to that release. +# +# What it does NOT do: open a PR against microsoft/winget-pkgs. That step is +# deliberately MANUAL and runs on a maintainer's machine — +# `scripts/bump-winget.sh`. Reason: submitting requires push access to a fork of +# winget-pkgs. The previous design stored a classic `public_repo` PAT as +# WINGET_TOKEN and handed it to a third-party action; that scope grants write to +# every public repo the account can reach, and as an Actions secret it was +# usable by anyone with push access to this repo. The local script uses the +# maintainer's existing `gh` auth instead, so no PAT is created at all. +# See BUILDING.md § Winget. +# +# Consequence: this workflow needs NO external token and no third-party action. +# +# Trigger: after "Create Release Assets" succeeds for a tag push. NOT +# `release: types: [released]` — that event never fires, because the release is +# created by create-release.yml under GITHUB_TOKEN and GitHub suppresses +# workflow-triggering events for it. See the note in bump-homebrew-formula.yml. on: workflow_run: workflows: ["Create Release Assets"] @@ -14,15 +33,15 @@ on: workflow_dispatch: inputs: tag: - description: 'Release tag to submit (for manual recovery)' + description: 'Release tag to sync (for manual recovery)' required: true type: string permissions: - contents: read - # The "Report failure" step below opens a [release-ops] issue via - # github.rest.issues.create; that needs issues:write. Without it the failure - # reporter itself 403s and no alert is ever filed. + contents: write + pull-requests: write + # The "Report failure" step opens a [release-ops] issue via + # github.rest.issues.create, which needs issues:write. issues: write concurrency: @@ -30,16 +49,18 @@ concurrency: cancel-in-progress: false jobs: - bump: - # See bump-homebrew.yml for why these three conditions: successful, tag-push - # (not a dry-run dispatch), v-prefixed. Exact format enforced downstream. + sync-manifest: + # See bump-homebrew-formula.yml for why these three conditions: successful, + # tag-push (not a dry-run dispatch), v-prefixed. Format enforced downstream. if: >- github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && startsWith(github.event.workflow_run.head_branch, 'v')) - runs-on: windows-latest - timeout-minutes: 30 + # Linux, not Windows: msitools reads the MSI Property table just as well, and + # this leg is billed 1x instead of 2x. + runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Checkout code uses: actions/checkout@v7 @@ -58,33 +79,114 @@ jobs: is_prerelease: ${{ steps.rel.outputs.is_prerelease }} is_draft: ${{ steps.rel.outputs.is_draft }} - # winget-releaser UPDATES an existing package; `VitorPamplona.Amethyst` - # has never been submitted to microsoft/winget-pkgs. Until the one-time - # new-package PR lands (BUILDING.md § Bootstrap), no-op instead of failing - # every release with a spurious [release-ops] issue. - - name: Check the package exists in winget-pkgs - id: pkg - shell: bash + - name: Install msitools + run: sudo apt-get update -qq && sudo apt-get install -y -qq msitools + + - name: Download MSI, compute sha256 + ProductCode + id: asset run: | set -euo pipefail - URL=https://api.github.com/repos/microsoft/winget-pkgs/contents/manifests/v/VitorPamplona/Amethyst - if curl -fsSL -o /dev/null -H "Accept: application/vnd.github+json" "$URL"; then - echo "bootstrapped=true" >> "$GITHUB_OUTPUT" - echo "VitorPamplona.Amethyst found in winget-pkgs; proceeding with submission" - else - echo "bootstrapped=false" >> "$GITHUB_OUTPUT" - echo "::warning::Package 'VitorPamplona.Amethyst' is not in microsoft/winget-pkgs yet, so there is nothing to update for ${{ steps.rel.outputs.tag }}. Amethyst is NOT shipping via Winget. Submit the one-time new-package PR (BUILDING.md § Bootstrap) to activate this channel." + TAG="${{ steps.rel.outputs.tag }}" + VER="${{ steps.rel.outputs.ver }}" + URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/amethyst-desktop-${VER}-windows-x64.msi" + echo "Fetching $URL" + # workflow_run fires only after every upload leg has finished, so the + # asset should already be there. Retry anyway for release-CDN + # propagation (mirrors the repo's push/pull retry ethos). + ok=0 + for i in 1 2 3 4 5; do + if curl -fsSL -o amethyst.msi "$URL"; then ok=1; break; fi + wait=$(( 2 ** i )) + echo "attempt $i failed; retrying in ${wait}s" + sleep "$wait" + done + [[ "$ok" == 1 ]] || { echo "::error::could not download $URL"; exit 1; } + test -s amethyst.msi + + SHA=$(sha256sum amethyst.msi | awk '{print $1}' | tr '[:lower:]' '[:upper:]') + + # ProductCode is the ARP key winget uses to detect an existing install. + # jpackage regenerates it per build, so read it rather than pin it. + PRODUCT_CODE=$(msiinfo export amethyst.msi Property \ + | awk -F'\t' '$1 == "ProductCode" { print $2 }' | tr -d '\r') + if [[ ! "$PRODUCT_CODE" =~ ^\{[0-9A-Fa-f-]{36}\}$ ]]; then + echo "::error::could not read a valid ProductCode from the MSI (got: '${PRODUCT_CODE}')" + exit 1 fi - - name: Submit manifest to winget-pkgs - if: steps.pkg.outputs.bootstrapped == 'true' - uses: vedantmgoyal9/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2 + echo "url=$URL" >> "$GITHUB_OUTPUT" + echo "sha256=$SHA" >> "$GITHUB_OUTPUT" + echo "product_code=$PRODUCT_CODE" >> "$GITHUB_OUTPUT" + echo "sha256=$SHA" + echo "ProductCode=$PRODUCT_CODE" + + - name: Update reference manifests + run: | + set -euo pipefail + DIR=desktopApp/packaging/winget + TAG="${{ steps.rel.outputs.tag }}" + VER="${{ steps.rel.outputs.ver }}" + SHA="${{ steps.asset.outputs.sha256 }}" + URL="${{ steps.asset.outputs.url }}" + PC="${{ steps.asset.outputs.product_code }}" + + # Anchored substitutions so the header comments are never touched. + sed -i -E "s|^(PackageVersion: ).*|\1${VER}|" \ + "$DIR/VitorPamplona.Amethyst.yaml" \ + "$DIR/VitorPamplona.Amethyst.installer.yaml" \ + "$DIR/VitorPamplona.Amethyst.locale.en-US.yaml" + sed -i -E "s|^( InstallerUrl: ).*|\1${URL}|" "$DIR/VitorPamplona.Amethyst.installer.yaml" + # Quoted: an all-digit 64-char digest would otherwise parse as a YAML + # integer and fail the schema's `string` type. + sed -i -E "s|^( InstallerSha256: ).*|\1'${SHA}'|" "$DIR/VitorPamplona.Amethyst.installer.yaml" + sed -i -E "s|^( ProductCode: ).*|\1'${PC}'|" "$DIR/VitorPamplona.Amethyst.installer.yaml" + sed -i -E "s|^(ReleaseNotesUrl: ).*|\1https://github.com/${{ github.repository }}/releases/tag/${TAG}|" \ + "$DIR/VitorPamplona.Amethyst.locale.en-US.yaml" + + echo "----- synced -----" + grep -hE "^(PackageVersion| InstallerUrl| InstallerSha256| ProductCode|ReleaseNotesUrl): " "$DIR"/*.yaml + + - name: Sanity-check the manifests still parse + run: | + set -euo pipefail + python3 - <<'PY' + import glob, sys, yaml + for f in sorted(glob.glob('desktopApp/packaging/winget/*.yaml')): + d = yaml.safe_load(open(f)) + assert d['PackageIdentifier'] == 'VitorPamplona.Amethyst', f + assert d['PackageVersion'], f + print('OK', f, d['ManifestType']) + PY + + - name: Open or update the manifest-sync PR + # peter-evans/create-pull-request is MIT-licensed CI-only tooling (not + # linked into any shipped artifact). It no-ops when there is no diff. + uses: peter-evans/create-pull-request@v8 with: - identifier: VitorPamplona.Amethyst - version: ${{ steps.rel.outputs.tag }} - # Asset naming contract: scripts/asset-name.sh - installers-regex: '^amethyst-desktop-.*-windows-x64\.msi$' - token: ${{ secrets.WINGET_TOKEN }} + token: ${{ secrets.GITHUB_TOKEN }} + base: main + branch: chore/bump-winget-manifest-${{ steps.rel.outputs.tag }} + add-paths: desktopApp/packaging/winget + commit-message: 'chore: sync winget manifests to ${{ steps.rel.outputs.tag }}' + title: 'chore: sync winget manifests to ${{ steps.rel.outputs.tag }}' + body: | + Auto-synced `desktopApp/packaging/winget/` to the + `${{ steps.rel.outputs.tag }}` release: + + - `PackageVersion` -> `${{ steps.rel.outputs.ver }}` + - `InstallerSha256` -> `${{ steps.asset.outputs.sha256 }}` + - `ProductCode` -> `${{ steps.asset.outputs.product_code }}` + + **Merge this, then push it upstream from a maintainer machine:** + + ```bash + scripts/bump-winget.sh ${{ steps.rel.outputs.tag }} + ``` + + That step is manual on purpose — it needs push access to a fork of + `microsoft/winget-pkgs`, which is deliberately NOT stored as a CI + secret. The script uses your existing `gh` auth. See + BUILDING.md § Winget. - name: Report failure if: failure() @@ -96,17 +198,17 @@ jobs: await github.rest.issues.create({ owner: context.repo.owner, repo: context.repo.repo, - title: `[release-ops] bump-winget failed for ${tag}`, + title: `[release-ops] sync-winget-manifest failed for ${tag}`, body: [ - `Winget manifest submission failed for release \`${tag}\`.`, + `Winget manifest sync failed for release \`${tag}\`.`, ``, `- Run: ${runUrl}`, `- Channel: Winget (\`VitorPamplona.Amethyst\`)`, ``, `Recovery options:`, - `1. Re-run the workflow once underlying issue is fixed`, - `2. Manually submit via \`wingetcreate update VitorPamplona.Amethyst -v ${tag.replace(/^v/, '')}\``, - `3. File PR directly against microsoft/winget-pkgs` + `1. Re-run the workflow once the underlying issue is fixed`, + `2. Check the release actually published \`amethyst-desktop-${tag.replace(/^v/, '')}-windows-x64.msi\``, + `3. Manually update \`desktopApp/packaging/winget/*.yaml\` (version, sha256, ProductCode)` ].join('\n'), labels: ['release-ops', 'bug'] }); diff --git a/BUILDING.md b/BUILDING.md index f817b78a0e..5a04173603 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -404,7 +404,7 @@ provided automatically; everything else you set yourself.) | `MAC_NOTARY_PASSWORD` | **App-specific** password for that Apple ID (not the login password) | Same | | `MAC_NOTARY_TEAM_ID` | 10-char Apple Developer **Team ID** | Same | | ~~`HOMEBREW_TOKEN`~~ | *Not used.* The cask bump runs on a maintainer's machine — see § Homebrew cask | — | -| `WINGET_TOKEN` | PAT for `microsoft/winget-pkgs` | Desktop winget bump (stable tags) | +| ~~`WINGET_TOKEN`~~ | *Not used.* The winget bump runs on a maintainer's machine — see § Winget | — | | `CROWDIN_PERSONAL_TOKEN`, `CROWDIN_PROJECT_ID` | Crowdin API creds | Translation sync (separate workflow, not the release) | Note the **three distinct signing identities** people often conflate: @@ -521,19 +521,15 @@ reads an optional per-release changelog from > below are the work that activates them; until then treat the desktop app as > GitHub-Releases-only on macOS and Windows. -### Secrets to provision in GitHub repo settings +### Package-manager credentials (and why there are none) The full secret inventory is in [§ Secrets the CI needs](#secrets-the-ci-needs). -The two that need the most setup care are the package-manager PATs, because of -their token type and scope: +Neither package-manager channel adds anything to it: -| Secret | Purpose | Scope | -|---|---|---| -| `WINGET_TOKEN` | Submit Winget manifests | Classic PAT — `public_repo` — 90d expiry (dedicated bot account preferred; `vedantmgoyal9/winget-releaser` does not support fine-grained) | - -**There is deliberately no `HOMEBREW_TOKEN`.** The cask bump is the one release -step that runs on a maintainer's machine rather than in CI. The reasoning is -worth keeping, because the same trade-off applies to `WINGET_TOKEN`: +**There are deliberately no package-manager PATs in CI.** Both the Homebrew +cask and the Winget manifest bumps run on a maintainer's machine. The reasoning +is worth keeping, because it is the reason this repo has no third secret to +rotate: `brew bump-cask-pr` forks `Homebrew/homebrew-cask` **into the token owner's account** (`POST /repos/Homebrew/homebrew-cask/forks`), pushes a branch to that @@ -575,8 +571,29 @@ Create one at Prefer a dedicated bot account whose only asset is a fork of `homebrew-cask`, so a leak reaches nothing else. -Rotate `WINGET_TOKEN` on a 90-day cadence. Owner: assigned via `RELEASE_OPS.md` -or equivalent issue tracker. +### Winget + +Same split, and it needs **no token at all**. `scripts/bump-winget.sh` drives +`gh`, which a maintainer is already authenticated with, and it does not need +`wingetcreate` (Windows-only) because winget manifests are plain YAML — so it +runs fine from macOS or Linux: + +```bash +scripts/bump-winget.sh v1.13.2 +``` + +CI (`bump-winget.yml`, `GITHUB_TOKEN` only) does the bookkeeping: downloads the +MSI, computes the sha256, reads the `ProductCode` out of the MSI Property table +with `msitools`, and opens an in-repo PR syncing +`desktopApp/packaging/winget/*.yaml`. The script re-verifies the sha256 against +the live asset, then forks `microsoft/winget-pkgs`, commits the three manifests +to `manifests/v/VitorPamplona/Amethyst//`, and opens the PR. + +The previous design stored a classic `public_repo` PAT as `WINGET_TOKEN` and +passed it to the third-party `vedantmgoyal9/winget-releaser` action — a token +with write access to every public repo the account owns, handed to code we do +not control, in a place any push-access collaborator could read it from. None of +that is needed. ### Homebrew cask (one-time initial PR) diff --git a/RELEASE_OPS.md b/RELEASE_OPS.md index 5eca4e3694..5b4b6eef39 100644 --- a/RELEASE_OPS.md +++ b/RELEASE_OPS.md @@ -210,24 +210,27 @@ Until someone does that bootstrap, a green release run means the bump workflows *skipped cleanly* — not that Homebrew/Winget shipped. Check the run's warnings if you want to confirm which case you're in. -**The cask bump is half-manual by design.** CI (`bump-homebrew.yml`) does the -bookkeeping with `GITHUB_TOKEN` only — verifies the DMG is notarized + stapled, -computes the sha256, and opens an in-repo PR syncing the reference cask. Pushing -that upstream needs a classic `repo`-scoped PAT, which is deliberately *not* a -CI secret (it would grant write to this repo to anyone with push access), so a -maintainer runs it: +**Both bumps are half-manual by design.** CI does the bookkeeping with +`GITHUB_TOKEN` only — verifying the artifact, computing hashes, and opening an +in-repo PR syncing the reference packaging files. Pushing upstream needs +credentials that would be dangerous as CI secrets (a `repo`-scoped PAT is +readable by anyone with push access here), so a maintainer runs the last step: ```bash +# after merging the sync PRs export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope scripts/bump-homebrew-cask.sh v1.13.2 + +scripts/bump-winget.sh v1.13.2 # no token — uses your `gh` auth ``` -The script re-verifies sha256 + notarization against the live asset and refuses -to submit otherwise. See BUILDING.md § Homebrew cask. +Both scripts re-verify the published artifact's sha256 before submitting, and +the Homebrew one additionally refuses if the DMG is not notarized + stapled. +See BUILDING.md § Package-manager credentials. -The three in-repo sync workflows (`amy` formula, `geode` formula, `amethyst-nostr` -cask) all open PRs against *this* repo on every release. Merge them to keep the -reference packaging files current. +All four in-repo sync workflows (`amy` formula, `geode` formula, +`amethyst-nostr` cask, winget manifests) open PRs against *this* repo on every +release. Merge them to keep the reference packaging files current. --- @@ -269,7 +272,7 @@ ownership: | `SIGNING_KEY`, `KEY_ALIAS`, `KEY_STORE_PASSWORD`, `KEY_PASSWORD` | The **Android upload keystore** — losing/leaking it is the worst case; Play app signing identity | Keep the keystore backed up offline; never rotate casually (Play upload key reset is a support process) | | `SONATYPE_USERNAME`, `SONATYPE_PASSWORD` | Maven Central namespace `com.vitorpamplona` | On compromise | | `SIGNING_PRIVATE_KEY`, `SIGNING_PASSWORD` | The **GPG key** signing Maven artifacts | Per GPG key expiry | -| `WINGET_TOKEN` | Winget bump PRs | **90-day cadence** (see BUILDING.md § Bootstrap). No `HOMEBREW_TOKEN` exists — the cask bump runs locally via `scripts/bump-homebrew-cask.sh`, so the `repo`-scoped PAT it needs never becomes a CI secret | +| *(none for Homebrew/Winget)* | — | Both bumps run on a maintainer's machine — `scripts/bump-homebrew-cask.sh` and `scripts/bump-winget.sh` — so neither channel's PAT ever becomes a CI secret. See BUILDING.md § Package-manager credentials | | `CROWDIN_PERSONAL_TOKEN`, `CROWDIN_PROJECT_ID` | Translation sync | On compromise | Owner assignments and rotation reminders live with the team (issue tracker). @@ -288,13 +291,13 @@ Owner assignments and rotation reminders live with the team (issue tracker). - [ ] Play Console: rollout started, no policy rejection. - [ ] Zapstore: release event visible. - [ ] F-Droid: new version detected (may lag days). -- [ ] `amy` / `geode` formula + `amethyst-nostr` cask sync PRs opened against - this repo — merge them. +- [ ] Four sync PRs opened against this repo (`amy` formula, `geode` formula, + `amethyst-nostr` cask, winget manifests) — merge them. - [ ] Cask pushed upstream: `scripts/bump-homebrew-cask.sh vX.Y.Z` (manual, needs - `HOMEBREW_GITHUB_API_TOKEN` in your shell). Skips with a clear error until - the one-time new-cask PR has been bootstrapped (§ 3). -- [ ] Winget: **expected to skip** until bootstrapped (§ 3). If it ever starts - opening real PRs, that means someone landed the bootstrap. + `HOMEBREW_GITHUB_API_TOKEN` in your shell). +- [ ] Winget pushed upstream: `scripts/bump-winget.sh vX.Y.Z` (manual, no token — + uses your `gh` auth). + Both scripts error clearly until the one-time bootstrap PRs land (§ 3). - [ ] In-app "Release Notes" link opens the note matching `RELEASE_NOTES_ID` (only bumped on minor releases — patches keep pointing at the x.y.0 note). - [ ] Push still works on a `play` build (only if the push contract changed — diff --git a/desktopApp/packaging/winget/VitorPamplona.Amethyst.installer.yaml b/desktopApp/packaging/winget/VitorPamplona.Amethyst.installer.yaml new file mode 100644 index 0000000000..b3c349a0bb --- /dev/null +++ b/desktopApp/packaging/winget/VitorPamplona.Amethyst.installer.yaml @@ -0,0 +1,32 @@ +# Reference Winget installer manifest for the Amethyst desktop app. +# +# InstallerUrl / InstallerSha256 / ProductCode are rewritten on each stable +# release by .github/workflows/bump-winget.yml, which downloads the published MSI, +# hashes it, and reads the ProductCode out of the MSI's Property table. +# +# InstallerType is `wix` because jpackage builds the MSI with the WiX Toolset. +# ProductCode matters: it is the ARP key winget uses to detect an existing +# install, so `winget upgrade` misbehaves without it. jpackage regenerates it per +# build, which is why CI re-reads it every release rather than pinning it here. +# (UpgradeCode is the stable one — see `upgradeUuid` in desktopApp/build.gradle.kts, +# which must never change.) +# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.12.0.schema.json + +PackageIdentifier: VitorPamplona.Amethyst +PackageVersion: 1.13.1 +Platform: +- Windows.Desktop +MinimumOSVersion: 10.0.0.0 +InstallerType: wix +Scope: machine +UpgradeBehavior: install +Installers: +- Architecture: x64 + InstallerUrl: https://github.com/vitorpamplona/amethyst/releases/download/v1.13.1/amethyst-desktop-1.13.1-windows-x64.msi + # Quoted deliberately: an all-digit 64-char value is a YAML *integer*, which + # fails the schema's `string` type. Quoting makes it a string whatever the + # digest happens to be. + InstallerSha256: '0000000000000000000000000000000000000000000000000000000000000000' + ProductCode: '{00000000-0000-0000-0000-000000000000}' +ManifestType: installer +ManifestVersion: 1.12.0 diff --git a/desktopApp/packaging/winget/VitorPamplona.Amethyst.locale.en-US.yaml b/desktopApp/packaging/winget/VitorPamplona.Amethyst.locale.en-US.yaml new file mode 100644 index 0000000000..42a1bff5ff --- /dev/null +++ b/desktopApp/packaging/winget/VitorPamplona.Amethyst.locale.en-US.yaml @@ -0,0 +1,36 @@ +# Reference Winget locale manifest for the Amethyst desktop app. +# +# Only PackageVersion and ReleaseNotesUrl are touched by +# .github/workflows/bump-winget.yml; everything else is hand-maintained. +# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.12.0.schema.json + +PackageIdentifier: VitorPamplona.Amethyst +PackageVersion: 1.13.1 +PackageLocale: en-US +Publisher: Vitor Pamplona +PublisherUrl: https://github.com/vitorpamplona +PublisherSupportUrl: https://github.com/vitorpamplona/amethyst/issues +Author: Vitor Pamplona +PackageName: Amethyst +PackageUrl: https://github.com/vitorpamplona/amethyst +License: MIT +LicenseUrl: https://github.com/vitorpamplona/amethyst/blob/main/LICENSE +Copyright: Copyright (c) Vitor Pamplona +CopyrightUrl: https://github.com/vitorpamplona/amethyst/blob/main/LICENSE +ShortDescription: The all-in-one Nostr client +Description: |- + A privacy-focused Nostr client. Built-in Tor support, the most configurable relay + system, encrypted messaging, zaps, marketplaces, live streams and complete data + sovereignty. +Moniker: amethyst +Tags: +- decentralized +- nostr +- social +- social-network +ReleaseNotesUrl: https://github.com/vitorpamplona/amethyst/releases/tag/v1.13.1 +Documentations: +- DocumentLabel: Building + DocumentUrl: https://github.com/vitorpamplona/amethyst/blob/main/BUILDING.md +ManifestType: defaultLocale +ManifestVersion: 1.12.0 diff --git a/desktopApp/packaging/winget/VitorPamplona.Amethyst.yaml b/desktopApp/packaging/winget/VitorPamplona.Amethyst.yaml new file mode 100644 index 0000000000..2e98e7de36 --- /dev/null +++ b/desktopApp/packaging/winget/VitorPamplona.Amethyst.yaml @@ -0,0 +1,15 @@ +# Reference Winget version manifest for the Amethyst desktop app. +# +# Not consumed by any build here. `scripts/bump-winget.sh` copies this directory +# into a fork of microsoft/winget-pkgs at +# manifests/v/VitorPamplona/Amethyst// and opens the PR. +# +# PackageVersion is kept in sync automatically on each stable release by +# .github/workflows/bump-winget.yml. +# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.12.0.schema.json + +PackageIdentifier: VitorPamplona.Amethyst +PackageVersion: 1.13.1 +DefaultLocale: en-US +ManifestType: version +ManifestVersion: 1.12.0 diff --git a/scripts/bump-winget.sh b/scripts/bump-winget.sh new file mode 100755 index 0000000000..2bed5a3a12 --- /dev/null +++ b/scripts/bump-winget.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# +# Push the VitorPamplona.Amethyst manifests upstream to microsoft/winget-pkgs. +# +# Like scripts/bump-homebrew-cask.sh, this is deliberately not automated: it +# needs push access to a fork of winget-pkgs, and the previous design kept that +# as a classic `public_repo` PAT in the WINGET_TOKEN Actions secret, handed to a +# third-party action. That scope grants write to every public repo the account +# can reach, and any Actions secret is usable by anyone with push access to this +# repo. See BUILDING.md § Winget. +# +# Unlike the Homebrew script this needs NO new token: it drives `gh`, which you +# are already authenticated with. It also does not need `wingetcreate` (which is +# Windows-only) — winget manifests are plain YAML, so this works from macOS or +# Linux. +# +# Everything error-prone (version, sha256, ProductCode) is already done in CI by +# .github/workflows/bump-winget.yml, which opens a PR syncing +# desktopApp/packaging/winget/. Merge that PR first; this script reads the merged +# values so the two can never disagree. +# +# Usage: +# scripts/bump-winget.sh # uses the manifests as-is +# scripts/bump-winget.sh v1.13.2 # asserts the manifests match this tag +# DRY_RUN=1 scripts/bump-winget.sh # print what would happen, do nothing + +set -euo pipefail + +REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +SRC="$REPO_ROOT/desktopApp/packaging/winget" +PKG_ID="VitorPamplona.Amethyst" +UPSTREAM="microsoft/winget-pkgs" +EXPECTED_TAG="${1:-}" +DRY_RUN="${DRY_RUN:-}" + +die() { echo "error: $*" >&2; exit 1; } + +command -v gh >/dev/null || die "gh not found — https://cli.github.com" +gh auth status >/dev/null 2>&1 || die "gh is not authenticated; run: gh auth login" +[[ -d "$SRC" ]] || die "manifests not found at $SRC" + +VERSION=$(awk '/^PackageVersion: /{print $2; exit}' "$SRC/$PKG_ID.yaml") +[[ -n "$VERSION" ]] || die "could not parse PackageVersion out of $SRC/$PKG_ID.yaml" + +if [[ -n "$EXPECTED_TAG" && "v$VERSION" != "$EXPECTED_TAG" ]]; then + die "manifests are at v$VERSION but you asked for $EXPECTED_TAG. +Merge the 'chore: sync winget manifests to $EXPECTED_TAG' PR first, then pull." +fi + +# InstallerSha256 and ProductCode are single-quoted in the YAML (see the +# comments there); strip the quotes when reading them back. +SHA=$(awk '/^ InstallerSha256: /{print $2; exit}' "$SRC/$PKG_ID.installer.yaml" | tr -d "'") +URL=$(awk '/^ InstallerUrl: /{print $2; exit}' "$SRC/$PKG_ID.installer.yaml") +PC=$(awk '/^ ProductCode: /{print $2; exit}' "$SRC/$PKG_ID.installer.yaml" | tr -d "'") + +[[ "$SHA" =~ ^0+$ ]] && die "InstallerSha256 is still the placeholder. +Run the sync workflow first: gh workflow run bump-winget.yml -f tag=v$VERSION" + +echo "package : $PKG_ID" +echo "version : $VERSION" +echo "sha256 : $SHA" +echo "product : $PC" +echo "url : $URL" +echo + +# Re-verify against the live asset. CI already did, but the whole point of a +# manual gate is that a human confirms what is about to be published. +echo "==> verifying the release MSI" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +curl -fsSL -o "$TMP/amethyst.msi" "$URL" || die "could not download $URL" + +if command -v sha256sum >/dev/null; then + ACTUAL=$(sha256sum "$TMP/amethyst.msi" | awk '{print $1}') +else + ACTUAL=$(shasum -a 256 "$TMP/amethyst.msi" | awk '{print $1}') +fi +ACTUAL=$(echo "$ACTUAL" | tr '[:lower:]' '[:upper:]') +[[ "$ACTUAL" == "$SHA" ]] || die "sha256 mismatch! + manifest says : $SHA + actual : $ACTUAL" +echo " sha256 matches" +echo + +DEST="manifests/v/VitorPamplona/Amethyst/$VERSION" +BRANCH="$PKG_ID-$VERSION" + +if [[ -n "$DRY_RUN" ]]; then + echo "DRY_RUN set — would now:" + echo " fork $UPSTREAM (if needed), branch $BRANCH" + echo " add $DEST/{$PKG_ID.yaml,$PKG_ID.installer.yaml,$PKG_ID.locale.en-US.yaml}" + echo " open a PR against $UPSTREAM" + exit 0 +fi + +# A new package needs a human-reviewed first submission; after that this script +# handles every version bump. +if ! gh api "repos/$UPSTREAM/contents/manifests/v/VitorPamplona/Amethyst" >/dev/null 2>&1; then + echo "note: $PKG_ID is not in $UPSTREAM yet — this will be the NEW-PACKAGE submission." + echo " Expect a slower, human-reviewed first pass." + echo +fi + +WORK="$TMP/winget-pkgs" +echo "==> preparing the fork" +gh repo fork "$UPSTREAM" --clone=false --remote=false >/dev/null 2>&1 || true +FORK="$(gh api user --jq .login)/winget-pkgs" + +# Shallow clone of the fork only — winget-pkgs full history is enormous. +gh repo clone "$FORK" "$WORK" -- --depth=1 >/dev/null 2>&1 \ + || die "could not clone $FORK" + +cd "$WORK" +git remote add upstream "https://github.com/$UPSTREAM.git" 2>/dev/null || true +git fetch --depth=1 upstream master >/dev/null 2>&1 || git fetch --depth=1 upstream main >/dev/null 2>&1 +DEFAULT_BRANCH=$(gh api "repos/$UPSTREAM" --jq .default_branch) +git checkout -B "$BRANCH" "upstream/$DEFAULT_BRANCH" >/dev/null 2>&1 + +mkdir -p "$DEST" +cp "$SRC/$PKG_ID.yaml" "$SRC/$PKG_ID.installer.yaml" "$SRC/$PKG_ID.locale.en-US.yaml" "$DEST/" + +# The reference copies carry an Amethyst-repo header comment; upstream manifests +# should not. Strip leading comment lines, keep the yaml-language-server line. +for f in "$DEST"/*.yaml; do + python3 - "$f" <<'PY' +import sys +p = sys.argv[1] +lines = open(p).read().splitlines(keepends=True) +out, started = [], False +for ln in lines: + if not started and ln.startswith('#') and 'yaml-language-server' not in ln: + continue + started = True + out.append(ln) +open(p, 'w').writelines(out) +PY +done + +git add "$DEST" +git -c user.name="$(gh api user --jq '.name // .login')" \ + -c user.email="$(gh api user --jq .id)+$(gh api user --jq .login)@users.noreply.github.com" \ + commit -q -m "$PKG_ID version $VERSION" +git push -q -u origin "$BRANCH" --force + +echo "==> opening the PR" +gh pr create --repo "$UPSTREAM" \ + --base "$DEFAULT_BRANCH" \ + --head "$(gh api user --jq .login):$BRANCH" \ + --title "$PKG_ID version $VERSION" \ + --body "Automated manifest sync for [Amethyst](https://github.com/vitorpamplona/amethyst) $VERSION. + +- Installer: \`amethyst-desktop-$VERSION-windows-x64.msi\` from the official GitHub release +- SHA256 verified against the published asset +- ProductCode read from the MSI Property table + +Manifests are generated from \`desktopApp/packaging/winget/\` in the upstream repo." + +echo +echo "Done. Watch the PR at https://github.com/$UPSTREAM/pulls" From bf73e0934a8e030cba8d7b7368a47e938c968942 Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:31:13 +0000 Subject: [PATCH 18/31] chore: sync winget manifests to v1.13.1 --- .../packaging/winget/VitorPamplona.Amethyst.installer.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/desktopApp/packaging/winget/VitorPamplona.Amethyst.installer.yaml b/desktopApp/packaging/winget/VitorPamplona.Amethyst.installer.yaml index b3c349a0bb..97e894bc60 100644 --- a/desktopApp/packaging/winget/VitorPamplona.Amethyst.installer.yaml +++ b/desktopApp/packaging/winget/VitorPamplona.Amethyst.installer.yaml @@ -26,7 +26,7 @@ Installers: # Quoted deliberately: an all-digit 64-char value is a YAML *integer*, which # fails the schema's `string` type. Quoting makes it a string whatever the # digest happens to be. - InstallerSha256: '0000000000000000000000000000000000000000000000000000000000000000' - ProductCode: '{00000000-0000-0000-0000-000000000000}' + InstallerSha256: '6D93B945C14FBB08F0267266EF21212CCB64A40D870B7D9F68A815D674BABCC5' + ProductCode: '{CEE4E147-245F-3621-8CBD-CCA52C3578B6}' ManifestType: installer ManifestVersion: 1.12.0 From ce252738984de2ed7a2c2ad5819e0a77287f71f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 14:33:47 +0000 Subject: [PATCH 19/31] perf: animate swipe-to-reply icon in the layer phase, not composition The reply icon's fade/scale read dragOffset in composition, and because Box is an inline composable that read invalidated the whole ChatBubbleLayout on every drag and settle frame. Gate the icon on a derivedStateOf boolean that flips only at the 0<->non-0 edges (icon added/removed twice per gesture) and move the progress math into the icon's graphicsLayer block so it's read in the layer phase. The bubble translation was already deferred; now the entire swipe and settle animate with zero recomposition. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016f7dTu8RoJRXjnHM7FKAE2 --- .../loggedIn/chats/feed/layouts/ChatBubbleLayout.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt index 0825e2d5bb..84fdc03ccf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt @@ -50,6 +50,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -192,13 +193,18 @@ fun ChatBubbleLayout( }, ) { if (onSwipeReply != null) { - val progress = (abs(dragOffset) / swipeThresholdPx).coerceIn(0f, 1f) - if (progress > 0f) { + // Gate composition on a boolean that flips only at the 0<->non-0 edges, so + // the icon is added/removed twice per gesture instead of recomposing every + // frame; the fade/scale reads dragOffset inside graphicsLayer (layer phase), + // so the whole swipe animates without recomposing ChatBubbleLayout. + val swiping by remember { derivedStateOf { dragOffset != 0f } } + if (swiping) { Box( modifier = Modifier .align(if (isLoggedInUser) Alignment.CenterEnd else Alignment.CenterStart) .graphicsLayer { + val progress = (abs(dragOffset) / swipeThresholdPx).coerceIn(0f, 1f) alpha = progress scaleX = 0.6f + 0.4f * progress scaleY = 0.6f + 0.4f * progress From 97ff9715426df5761e534c22f6baa8a6753ea5e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 14:36:42 +0000 Subject: [PATCH 20/31] feat(buzz): move per-row channel actions into the opened screen's top bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The community list rows (channels, forums, DMs) each carried a 3-dot overflow — Pin/Unpin and Add/Remove-from-Messages. Those are gone; each row is now a clean tap-to-open target and its actions live in the top-bar overflow of the screen it opens: - Channels & DMs open RelayGroupChatScreen (RelayGroupTopBar): add Pin/Unpin (Buzz, non-DM) alongside the existing Messages toggle. A DM has no kind-10009 entry, so its Messages toggle drives the DM-specific hide/unhide (kind-41012 / re-open) read from the per-viewer 30622 snapshot; the DM overflow is always shown so that action stays reachable. - Forums open RelayGroupThreadsScreen: give its top bar an overflow with Pin/Unpin and Add/Remove-from-Messages. Shared BuzzPinDropdownItem / RelayGroupMessagesDropdownItem keep the two bars consistent. AccountViewModel gains hideBuzzDm/unhideBuzzDm wrappers. The community-list top-bar overflow (Add people, Invite, Agent Console, Add all) is unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG --- .../ui/screen/loggedIn/AccountViewModel.kt | 16 +++ .../ui/screen/loggedIn/buzz/BuzzImportRow.kt | 94 +--------------- .../relayGroup/BuzzChannelMenuItems.kt | 104 ++++++++++++++++++ .../relayGroup/RelayGroupChannelListScreen.kt | 55 ++------- .../relayGroup/RelayGroupThreadsScreen.kt | 23 ++++ .../relayGroup/RelayGroupTopBar.kt | 37 ++++++- 6 files changed, 190 insertions(+), 139 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzChannelMenuItems.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index b105993fc6..363a703d06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1708,6 +1708,22 @@ class AccountViewModel( */ fun acceptChannelInvite(channel: RelayGroupChannel) = addRelayGroupToMessages(channel) + /** + * Hide a Buzz DM from Messages (kind-41012). DM-specific — a DM has no kind-10009 entry; the relay + * republishes my per-viewer 30622 hidden snapshot, dropping it from the inbox until I re-open it. + */ + fun hideBuzzDm(channel: RelayGroupChannel) = launchSigner { account.hideBuzzDm(channel) } + + /** + * Bring a hidden Buzz DM back to Messages: Buzz has no "unhide", so re-open the conversation with + * the same [participants] (a kind-41010 resolving to the same canonical channel), which drops it + * from the 30622 hidden snapshot. + */ + fun unhideBuzzDm( + relay: NormalizedRelayUrl, + participants: List, + ) = launchSigner { account.openBuzzDm(relay, participants) } + /** * 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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt index a0d45791da..387a77b0eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzImportRow.kt @@ -32,16 +32,11 @@ 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.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -83,9 +78,9 @@ private const val CARD_WARMUP_LIMIT = 10 * kind-44100), rendered like the Concord server view — a colored monogram, the channel name with a * 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 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. + * Tapping the card opens the channel ([onOpen]); the row itself is a clean tap-to-open target — its + * per-channel actions (Pin/Unpin, Add/Remove-from-Messages) live in the opened channel's/forum's + * top-bar overflow, not on the row. A pinned channel still shows a pin marker here ([isStarred]). * * Reused by the relay group-list screen where Buzz membership discovery is folded in. * @@ -98,13 +93,9 @@ private const val CARD_WARMUP_LIMIT = 10 @Composable fun BuzzImportRow( groupId: GroupId, - isAdded: Boolean, - onAdd: () -> Unit, - onRemove: () -> Unit, accountViewModel: AccountViewModel, onOpen: (() -> Unit)? = null, isStarred: Boolean = false, - onToggleStar: (() -> Unit)? = null, showActivityPreview: Boolean = true, ) { val account = accountViewModel.account @@ -166,11 +157,7 @@ fun BuzzImportRow( faceAuthors = faceAuthors, unread = unread, hasUnread = hasUnread, - isAdded = isAdded, isStarred = isStarred, - onToggleStar = onToggleStar, - onAdd = onAdd, - onRemove = onRemove, accountViewModel = accountViewModel, ) } @@ -195,15 +182,11 @@ private fun BuzzImportRowContent( faceAuthors: List, unread: Int, hasUnread: Boolean, - isAdded: Boolean, isStarred: Boolean, - onToggleStar: (() -> Unit)?, - onAdd: () -> Unit, - onRemove: () -> Unit, accountViewModel: AccountViewModel, ) { Row( - modifier = Modifier.padding(start = 12.dp, top = 10.dp, bottom = 10.dp, end = 4.dp), + modifier = Modifier.padding(start = 12.dp, top = 10.dp, bottom = 10.dp, end = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { BuzzImportAvatar(name = name, seed = seed) @@ -254,13 +237,6 @@ private fun BuzzImportRowContent( ConcordUnreadBadge(unread) } } - BuzzChannelRowMenu( - isAdded = isAdded, - onAdd = onAdd, - onRemove = onRemove, - isStarred = isStarred, - onToggleStar = onToggleStar, - ) } } @@ -308,68 +284,6 @@ private fun BuzzChannelPreviewLine( ) } -/** - * The per-channel overflow (3-dot) menu: Pin/Unpin and Add-to-my-list. Moved off the row itself so a - * channel card reads as a clean Concord-style row, with its actions one tap behind the kebab. - */ -@Composable -private fun BuzzChannelRowMenu( - isAdded: Boolean, - onAdd: () -> Unit, - onRemove: () -> Unit, - isStarred: Boolean, - onToggleStar: (() -> Unit)?, -) { - var expanded by remember { mutableStateOf(false) } - Box { - IconButton(onClick = { expanded = true }) { - Icon( - symbol = MaterialSymbols.MoreVert, - contentDescription = stringRes(R.string.more_options), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - } - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - if (onToggleStar != null) { - DropdownMenuItem( - leadingIcon = { - Icon( - symbol = MaterialSymbols.PushPin, - contentDescription = null, - tint = if (isStarred) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - }, - text = { Text(stringRes(if (isStarred) R.string.buzz_unpin else R.string.buzz_pin)) }, - onClick = { - expanded = false - onToggleStar() - }, - ) - } - // 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.VisibilityOff else MaterialSymbols.Add, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - }, - text = { Text(stringRes(if (isAdded) R.string.remove_from_messages else R.string.add_to_messages)) }, - onClick = { - expanded = false - if (isAdded) onRemove() else onAdd() - }, - ) - } - } -} - /** A round monogram whose color is derived deterministically from the channel id. */ @Composable private fun BuzzImportAvatar( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzChannelMenuItems.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzChannelMenuItems.kt new file mode 100644 index 0000000000..6240aca33a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzChannelMenuItems.kt @@ -0,0 +1,104 @@ +/* + * 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.ui.screen.loggedIn.chats.publicChannels.relayGroup + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId + +/** + * Pin/Unpin a Buzz channel (a device-local favorite — [BuzzChannelStars]). Moved off the per-channel + * list row into the opened channel's/forum's top-bar overflow, so the list row stays a clean + * tap-to-open target. Reads the live starred set so the label + icon reflect the current state. + */ +@Composable +fun BuzzPinDropdownItem( + groupId: GroupId, + closeMenu: () -> Unit, +) { + val starred by BuzzChannelStars.flow.collectAsStateWithLifecycle() + val isStarred = groupId.id in starred + DropdownMenuItem( + leadingIcon = { + Icon( + symbol = MaterialSymbols.PushPin, + contentDescription = null, + tint = if (isStarred) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + }, + text = { Text(stringRes(if (isStarred) R.string.buzz_unpin else R.string.buzz_pin)) }, + onClick = { + closeMenu() + BuzzChannelStars.toggle(groupId.id) + }, + ) +} + +/** + * Add/Remove this relay-group [channel] from my kind-10009 list (whether it shows in Messages). A + * reversible toggle that never touches my relay membership — same split as "Leave" — so it stays + * readable either way. Reads the live kind-10009 list so it flips as the change lands. Moved off the + * per-channel list row into the opened screen's top-bar overflow. + */ +@Composable +fun RelayGroupMessagesDropdownItem( + channel: RelayGroupChannel, + accountViewModel: AccountViewModel, + closeMenu: () -> Unit, +) { + val joinedGroupIds by accountViewModel.account.relayGroupList.liveRelayGroupIds + .collectAsStateWithLifecycle() + val onMyList = channel.groupId in joinedGroupIds + DropdownMenuItem( + leadingIcon = { + Icon( + symbol = if (onMyList) MaterialSymbols.VisibilityOff else MaterialSymbols.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + }, + text = { Text(stringRes(if (onMyList) R.string.remove_from_messages else R.string.add_to_messages)) }, + onClick = { + closeMenu() + if (onMyList) { + accountViewModel.removeRelayGroupFromMessages(channel) + } else { + accountViewModel.addRelayGroupToMessages(channel) + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index bcaedd0096..a1cc027c72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -36,8 +36,6 @@ 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 @@ -463,13 +461,9 @@ fun RelayGroupChannelListScreen( 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, - onToggleStar = { BuzzChannelStars.toggle(groupId.id) }, ) } } @@ -501,15 +495,11 @@ fun RelayGroupChannelListScreen( 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. onOpen = { nav.nav(Route.RelayGroupThreads(groupId.id, relay.url)) }, isStarred = groupId.id in starred, - onToggleStar = { BuzzChannelStars.toggle(groupId.id) }, // Forum posts live in a separate thread store, not the chat notes the // activity preview reads — so don't warm a kind-9 sub that returns nothing. showActivityPreview = false, @@ -548,7 +538,6 @@ fun RelayGroupChannelListScreen( row = row, myPubkey = myPubkey, isHidden = false, - onToggleMessages = { dmVm.removeFromMessages(row) }, accountViewModel = accountViewModel, nav = nav, ) { @@ -580,7 +569,6 @@ fun RelayGroupChannelListScreen( row = row, myPubkey = myPubkey, isHidden = true, - onToggleMessages = { dmVm.addToMessages(row) }, accountViewModel = accountViewModel, nav = nav, ) { @@ -721,25 +709,23 @@ private fun SectionAddButton( /** * 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, 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. + * 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; the Add/Remove-from-Messages + * (hide/unhide) action lives in that chat screen's top-bar overflow, not on this row. * - * [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. + * [isHidden] renders the row faded — a hidden DM is a live conversation the viewer merely parked, so + * it stays openable and reversible from the opened conversation. */ @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) } @@ -768,7 +754,7 @@ private fun BuzzDmInlineRow( Modifier .fillMaxWidth() .clickable(onClick = onClick) - .padding(start = 16.dp, end = 4.dp, top = 10.dp, bottom = 10.dp) + .padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 10.dp) .alpha(if (isHidden) 0.55f else 1f), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -797,33 +783,6 @@ 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() - }, - ) - } - } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt index e015e48a7b..85f0725a78 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt @@ -34,15 +34,19 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.DropdownMenu import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -166,6 +170,25 @@ private fun RelayGroupThreads( ) } }, + actions = { + // The forum's per-item actions, moved off the community-list row into this screen's + // top-bar overflow: Pin/Unpin and the Add/Remove-from-Messages toggle. Buzz-only, + // which every forum channel is. + if (isBuzz) { + var menuOpen by remember { mutableStateOf(false) } + IconButton(onClick = { menuOpen = true }) { + Icon( + symbol = MaterialSymbols.MoreVert, + contentDescription = stringRes(R.string.more_options), + modifier = Modifier.size(22.dp), + ) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + BuzzPinDropdownItem(channel.groupId) { menuOpen = false } + RelayGroupMessagesDropdownItem(channel, accountViewModel) { menuOpen = false } + } + } + }, popBack = nav::popBack, ) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt index 47a19bea25..afb2682855 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt @@ -53,6 +53,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel @@ -117,6 +118,9 @@ fun RelayGroupTopBar( // My kind-10009 list, live: drives the Add/Remove-from-Messages toggle in the overflow below. val joinedGroupIds by accountViewModel.account.relayGroupList.liveRelayGroupIds .collectAsStateWithLifecycle() + // A Buzz DM is hidden via its own per-viewer 30622 snapshot, not the kind-10009 list, so the + // Messages toggle branches on this for DMs. + val hiddenDms by BuzzDmRegistry.hidden.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() @@ -237,7 +241,9 @@ fun RelayGroupTopBar( // Buzz `t=stream` channel it is always empty (forum posts live in `t=forum` channels, which // the relay's channel list already surfaces in their own section), so it read as a broken // feature on every chat. Demoted to the overflow, where the frequency of use actually is. - if (!isDm || naddr != null || showMembershipActions) { + // A Buzz DM always gets the overflow too — its hide/unhide (below) is the DM row's old + // action, and it must be reachable even where the membership actions aren't offered. + if (!isDm || naddr != null || showMembershipActions || (isDm && isBuzzRelay)) { IconButton(onClick = { menuOpen = true }) { Icon( symbol = MaterialSymbols.MoreVert, @@ -246,6 +252,33 @@ fun RelayGroupTopBar( ) } DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + // Pin/Unpin moved here off the community-list row. A local favorite, so it's offered + // for any Buzz channel/forum regardless of membership; DMs are never pinned. + if (isBuzzRelay && !isDm) { + BuzzPinDropdownItem(channel.groupId) { menuOpen = false } + } + // A DM's Add/Remove-from-Messages, moved off the DM list row. It rides the per-viewer + // 30622 hide snapshot (kind-41012 hide / re-open), not the kind-10009 list, and is + // shown regardless of the membership gate below. + if (isBuzzRelay && isDm) { + val dmHidden = channel.groupId.id in (hiddenDms[myPubkey] ?: emptySet()) + DropdownMenuItem( + text = { Text(stringRes(if (dmHidden) R.string.add_to_messages else R.string.remove_from_messages)) }, + onClick = { + menuOpen = false + if (dmHidden) { + val participants = + channel.event + ?.buzzParticipants() + ?.filter { it != myPubkey } + .orEmpty() + accountViewModel.unhideBuzzDm(channel.groupId.relayUrl, participants.ifEmpty { listOf(myPubkey) }) + } else { + accountViewModel.hideBuzzDm(channel) + } + }, + ) + } if (!isDm) { DropdownMenuItem( text = { Text(stringRes(R.string.relay_group_threads_title)) }, @@ -308,6 +341,8 @@ 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. + // (A DM's Messages toggle is the hide/unhide item above — it rides a different + // mechanism and must show even when these membership actions don't.) // // 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 From f353389892f90b541447618f596dd39a6fe1e922 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 29 Jul 2026 16:54:25 +0200 Subject: [PATCH 21/31] update cs,sv,de,pt --- amethyst/src/main/res/values-cs/strings.xml | 46 +++++++++++++++++++ .../src/main/res/values-de-rDE/strings.xml | 38 +++++++++++++++ .../src/main/res/values-pt-rBR/strings.xml | 43 ++++++++++++++++- .../src/main/res/values-pt-rPT/strings.xml | 2 +- .../src/main/res/values-sv-rSE/strings.xml | 43 +++++++++++++++++ 5 files changed, 170 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index 6b087bfa27..d4aa9eb26f 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -4748,4 +4748,50 @@ URL avataru (volitelné) Publikování… Publikovat personu + Přidat do Zpráv + Odebrat ze Zpráv + Smazat kanál + Smazat „%1$s“? Odstraní to kanál a jeho zprávy pro všechny a nelze to vrátit zpět. + Smazat skupinu + Smazat „%1$s“? Odstraní to skupinu a její historii pro všechny a nelze to vrátit zpět. + Fronta úloh + Zvýraznit + Zvýraznění + Nové zvýraznění + Nové zvýraznění + Zvýrazněný text + Co vás zaujalo? + URL zdroje + Vaše poznámka (volitelné) + Přidejte své myšlenky… + Obsah v kanálu + Textové poznámky + Sdílení + Komentáře a odpovědi + Obrázky + Videa + Krátká videa + Články + Wiki stránky + Zvýraznění + Ankety + Inzeráty + Torrenty + Hlasové zprávy + Živé aktivity + Dočasné chaty + Interaktivní příběhy + Šachové partie + Pozorování ptáků + Atestace + Návrhy NIP + Hudba a zvuk + Podcasty + Sbírky + + %1$d skrytá konverzace + %1$d skryté konverzace + %1$d skryté konverzace + %1$d skrytých konverzací + diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 82bae0b5ca..f6ea7fe48d 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -4580,4 +4580,42 @@ Avatar-URL (optional) Wird veröffentlicht… Persona veröffentlichen + Zu Nachrichten hinzufügen + Aus Nachrichten entfernen + Kanal löschen + „%1$s“ löschen? Damit werden der Kanal und seine Nachrichten für alle entfernt, und das lässt sich nicht rückgängig machen. + Gruppe löschen + „%1$s“ löschen? Damit werden die Gruppe und ihr Verlauf für alle entfernt, und das lässt sich nicht rückgängig machen. + Laufzeitumgebung (optional) + Hervorheben + Neues Highlight + Neues Highlight + Hervorgehobener Text + Was ist dir aufgefallen? + Quell-URL + Deine Notiz (optional) + Füge deine Gedanken hinzu… + Inhalte im Feed + Textnotizen + Kommentare & Antworten + Bilder + Kurzvideos + Artikel + Wiki-Seiten + Umfragen + Kleinanzeigen + Sprachnachrichten + Live-Aktivitäten + Ephemere Chats + Interaktive Geschichten + Schachpartien + Vogelbeobachtungen + Attestierungen + NIP-Entwürfe + Musik & Audio + Spendenaktionen + + %1$d ausgeblendete Unterhaltung + %1$d ausgeblendete Unterhaltungen + diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 2f453bfef3..b5b04551df 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -3862,7 +3862,7 @@ Perfil Lista de silenciados NNS - CNPJ + NIP Nostr Connect Status do DVM Solicitação de conteúdo DVM @@ -4582,4 +4582,45 @@ URL do avatar (opcional) Publicando… Publicar persona + Adicionar às Mensagens + Remover das Mensagens + Excluir canal + Excluir “%1$s”? Isso remove o canal e suas mensagens para todos e não pode ser desfeito. + Excluir grupo + Excluir “%1$s”? Isso remove o grupo e seu histórico para todos e não pode ser desfeito. + Destacar + Destaques + Novo destaque + Novo destaque + Texto destacado + O que chamou sua atenção? + URL de origem + Sua nota (opcional) + Adicione seus comentários… + Conteúdo no feed + Notas de texto + Repostagens + Comentários e respostas + Imagens + Vídeos + Curtas + Artigos + Páginas wiki + Destaques + Enquetes + Classificados + Mensagens de voz + Atividades ao vivo + Chats efêmeros + Histórias interativas + Partidas de xadrez + Observações de aves + Atestações + Rascunhos de NIP + Música e áudio + Arrecadações + + %1$d conversa oculta + %1$d conversas ocultas + diff --git a/amethyst/src/main/res/values-pt-rPT/strings.xml b/amethyst/src/main/res/values-pt-rPT/strings.xml index 1e838e4160..18d1201025 100644 --- a/amethyst/src/main/res/values-pt-rPT/strings.xml +++ b/amethyst/src/main/res/values-pt-rPT/strings.xml @@ -2522,7 +2522,7 @@ Espaço de reunião Perfil Lista de silenciados - CNPJ + NIP Status do DVM Solicitação de conteúdo DVM Resposta de conteúdo DVM diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index e25860e071..443fb94c61 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -4583,4 +4583,47 @@ Avatar-URL (valfritt) Publicerar… Publicera persona + Lägg till i Meddelanden + Ta bort från Meddelanden + Radera kanal + Radera ”%1$s”? Det tar bort kanalen och dess meddelanden för alla, och kan inte ångras. + Radera grupp + Radera ”%1$s”? Det tar bort gruppen och dess historik för alla, och kan inte ångras. + Markera + Höjdpunkter + Ny höjdpunkt + Ny höjdpunkt + Markerad text + Vad fastnade du för? + Käll-URL + Din anteckning (valfritt) + Lägg till dina tankar… + Innehåll i flödet + Textanteckningar + Återinlägg + Kommentarer & svar + Bilder + Videor + Kortfilmer + Artiklar + Wiki-sidor + Höjdpunkter + Omröstningar + Annonser + Torrenter + Röstmeddelanden + Liveaktiviteter + Tillfälliga chattar + Interaktiva berättelser + Schackpartier + Fågelobservationer + Attesteringar + NIP-utkast + Musik & ljud + Poddar + Insamlingar + + %1$d dold konversation + %1$d dolda konversationer + From d504e43924772774a4d678eb9e109fb110c8dfe7 Mon Sep 17 00:00:00 2001 From: davotoula <1747287+davotoula@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:58:05 +0000 Subject: [PATCH 22/31] chore: sync Crowdin translations and seed translator npub placeholders --- amethyst/src/main/res/values-cs/strings.xml | 46 ------------------- .../src/main/res/values-de-rDE/strings.xml | 38 --------------- .../src/main/res/values-pt-rBR/strings.xml | 43 +---------------- .../src/main/res/values-pt-rPT/strings.xml | 2 +- .../src/main/res/values-sv-rSE/strings.xml | 43 ----------------- docs/changelog/translators.json | 13 ++++-- 6 files changed, 11 insertions(+), 174 deletions(-) diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index d4aa9eb26f..6b087bfa27 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -4748,50 +4748,4 @@ URL avataru (volitelné) Publikování… Publikovat personu - Přidat do Zpráv - Odebrat ze Zpráv - Smazat kanál - Smazat „%1$s“? Odstraní to kanál a jeho zprávy pro všechny a nelze to vrátit zpět. - Smazat skupinu - Smazat „%1$s“? Odstraní to skupinu a její historii pro všechny a nelze to vrátit zpět. - Fronta úloh - Zvýraznit - Zvýraznění - Nové zvýraznění - Nové zvýraznění - Zvýrazněný text - Co vás zaujalo? - URL zdroje - Vaše poznámka (volitelné) - Přidejte své myšlenky… - Obsah v kanálu - Textové poznámky - Sdílení - Komentáře a odpovědi - Obrázky - Videa - Krátká videa - Články - Wiki stránky - Zvýraznění - Ankety - Inzeráty - Torrenty - Hlasové zprávy - Živé aktivity - Dočasné chaty - Interaktivní příběhy - Šachové partie - Pozorování ptáků - Atestace - Návrhy NIP - Hudba a zvuk - Podcasty - Sbírky - - %1$d skrytá konverzace - %1$d skryté konverzace - %1$d skryté konverzace - %1$d skrytých konverzací - diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index f6ea7fe48d..82bae0b5ca 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -4580,42 +4580,4 @@ Avatar-URL (optional) Wird veröffentlicht… Persona veröffentlichen - Zu Nachrichten hinzufügen - Aus Nachrichten entfernen - Kanal löschen - „%1$s“ löschen? Damit werden der Kanal und seine Nachrichten für alle entfernt, und das lässt sich nicht rückgängig machen. - Gruppe löschen - „%1$s“ löschen? Damit werden die Gruppe und ihr Verlauf für alle entfernt, und das lässt sich nicht rückgängig machen. - Laufzeitumgebung (optional) - Hervorheben - Neues Highlight - Neues Highlight - Hervorgehobener Text - Was ist dir aufgefallen? - Quell-URL - Deine Notiz (optional) - Füge deine Gedanken hinzu… - Inhalte im Feed - Textnotizen - Kommentare & Antworten - Bilder - Kurzvideos - Artikel - Wiki-Seiten - Umfragen - Kleinanzeigen - Sprachnachrichten - Live-Aktivitäten - Ephemere Chats - Interaktive Geschichten - Schachpartien - Vogelbeobachtungen - Attestierungen - NIP-Entwürfe - Musik & Audio - Spendenaktionen - - %1$d ausgeblendete Unterhaltung - %1$d ausgeblendete Unterhaltungen - diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index b5b04551df..2f453bfef3 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -3862,7 +3862,7 @@ Perfil Lista de silenciados NNS - NIP + CNPJ Nostr Connect Status do DVM Solicitação de conteúdo DVM @@ -4582,45 +4582,4 @@ URL do avatar (opcional) Publicando… Publicar persona - Adicionar às Mensagens - Remover das Mensagens - Excluir canal - Excluir “%1$s”? Isso remove o canal e suas mensagens para todos e não pode ser desfeito. - Excluir grupo - Excluir “%1$s”? Isso remove o grupo e seu histórico para todos e não pode ser desfeito. - Destacar - Destaques - Novo destaque - Novo destaque - Texto destacado - O que chamou sua atenção? - URL de origem - Sua nota (opcional) - Adicione seus comentários… - Conteúdo no feed - Notas de texto - Repostagens - Comentários e respostas - Imagens - Vídeos - Curtas - Artigos - Páginas wiki - Destaques - Enquetes - Classificados - Mensagens de voz - Atividades ao vivo - Chats efêmeros - Histórias interativas - Partidas de xadrez - Observações de aves - Atestações - Rascunhos de NIP - Música e áudio - Arrecadações - - %1$d conversa oculta - %1$d conversas ocultas - diff --git a/amethyst/src/main/res/values-pt-rPT/strings.xml b/amethyst/src/main/res/values-pt-rPT/strings.xml index 18d1201025..1e838e4160 100644 --- a/amethyst/src/main/res/values-pt-rPT/strings.xml +++ b/amethyst/src/main/res/values-pt-rPT/strings.xml @@ -2522,7 +2522,7 @@ Espaço de reunião Perfil Lista de silenciados - NIP + CNPJ Status do DVM Solicitação de conteúdo DVM Resposta de conteúdo DVM diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 443fb94c61..e25860e071 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -4583,47 +4583,4 @@ Avatar-URL (valfritt) Publicerar… Publicera persona - Lägg till i Meddelanden - Ta bort från Meddelanden - Radera kanal - Radera ”%1$s”? Det tar bort kanalen och dess meddelanden för alla, och kan inte ångras. - Radera grupp - Radera ”%1$s”? Det tar bort gruppen och dess historik för alla, och kan inte ångras. - Markera - Höjdpunkter - Ny höjdpunkt - Ny höjdpunkt - Markerad text - Vad fastnade du för? - Käll-URL - Din anteckning (valfritt) - Lägg till dina tankar… - Innehåll i flödet - Textanteckningar - Återinlägg - Kommentarer & svar - Bilder - Videor - Kortfilmer - Artiklar - Wiki-sidor - Höjdpunkter - Omröstningar - Annonser - Torrenter - Röstmeddelanden - Liveaktiviteter - Tillfälliga chattar - Interaktiva berättelser - Schackpartier - Fågelobservationer - Attesteringar - NIP-utkast - Musik & ljud - Poddar - Insamlingar - - %1$d dold konversation - %1$d dolda konversationer - diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index df73903688..0d09ee4b7d 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -78,16 +78,21 @@ "tag": "v1.13.1", "since": "2026-07-28T23:17:02-04:00", "translators": [ + { + "user": "vitorpamplona", + "languages": [ + "Czech", + "German", + "Portuguese, Brazilian", + "Swedish" + ] + }, { "user": "rajs19420616", "languages": [ "Hindi" ] }, - { - "user": "vitorpamplona", - "languages": [] - }, { "user": "greenart7c3", "languages": [] From 0284c8c86ad276e364498d7f17dbe8b7724965af Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 14:58:08 +0000 Subject: [PATCH 23/31] fix: keep swipe-to-reply from stranding the chat bubble off-screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways the bubble could get stuck shifted sideways (dragOffset frozen at a non-zero value, reply icon left visible), both fixed: 1. Cancelling the settle-back animation on awaitFirstDown — which fires for every touch, including one that turns out to be a vertical scroll — while settleBack() only ran on the horizontal-drag path. A scroll started during an in-flight settle froze dragOffset. Now the settle is cancelled only once a horizontal drag is actually committed, so every cancel is paired with a settleBack(). 2. Keying pointerInput on the onSwipeReply lambda. The caller allocates a fresh lambda every recomposition (it captures the note), so the detector was torn down and restarted on every row update. A restart mid-drag cancelled horizontalDrag before settleBack() ran, stranding the bubble; idle restarts also churned the gesture coroutine across all visible bubbles. Now keyed on the stable isLoggedInUser, with the callback read through rememberUpdatedState and the drag wrapped in try/finally so an interrupted drag always settles. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016f7dTu8RoJRXjnHM7FKAE2 --- .../chats/feed/layouts/ChatBubbleLayout.kt | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt index 84fdc03ccf..1ee06c0232 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt @@ -56,6 +56,7 @@ import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -180,6 +181,12 @@ fun ChatBubbleLayout( var dragOffset by remember { mutableFloatStateOf(0f) } var settleJob by remember { mutableStateOf(null) } val swipeScope = rememberCoroutineScope() + // The caller passes a fresh onSwipeReply lambda every recomposition (it captures + // the note), so read it through updated-state and key pointerInput on the stable + // isLoggedInUser instead. Keying on the lambda would tear down and restart the + // detector on every recomposition — stranding an in-flight drag and churning the + // gesture coroutine on every idle row update. + val latestOnSwipeReply by rememberUpdatedState(onSwipeReply) val density = LocalDensity.current val swipeThresholdPx = remember(density) { with(density) { SwipeReplyThreshold.toPx() } } val swipeMaxPx = remember(density) { with(density) { SwipeReplyMaxDrag.toPx() } } @@ -219,7 +226,7 @@ fun ChatBubbleLayout( if (onSwipeReply != null) { Modifier .graphicsLayer { translationX = dragOffset } - .pointerInput(onSwipeReply) { + .pointerInput(isLoggedInUser) { // Drag toward the screen center only; a haptic tick marks the // commit point, releasing past it fires the reply. var crossedThreshold = false @@ -247,8 +254,6 @@ fun ChatBubbleLayout( awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = false) - settleJob?.cancel() - crossedThreshold = false // Claim the pointer only once the motion is clearly more // horizontal than vertical; a mostly-vertical drag leaves the @@ -264,16 +269,30 @@ fun ChatBubbleLayout( } if (drag != null) { + // Take over from any in-flight settle only now that we've + // committed to a horizontal drag. Cancelling earlier (on + // the down) would strand dragOffset when the gesture turns + // out to be a vertical scroll, since settleBack() below + // only runs on this path. + settleJob?.cancel() + crossedThreshold = false applyDrag(overSlop.x) - val completed = - horizontalDrag(drag.id) { change -> - applyDrag(change.positionChange().x) - change.consume() + try { + val completed = + horizontalDrag(drag.id) { change -> + applyDrag(change.positionChange().x) + change.consume() + } + if (completed && abs(dragOffset) >= swipeThresholdPx) { + latestOnSwipeReply?.invoke() } - if (completed && abs(dragOffset) >= swipeThresholdPx) { - onSwipeReply() + } finally { + // Settle even if the drag coroutine is cancelled (e.g. + // the bubble leaves composition mid-swipe); settleBack + // launches on swipeScope, not this pointer coroutine, + // so it still runs while that scope is alive. + settleBack() } - settleBack() } } } From 21c44cfc36a9c1fe9e9f9c24c0b908ff7f80f7b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 15:02:16 +0000 Subject: [PATCH 24/31] test(quic): de-flake PeerUniStreamDrainTest slow-consumer overflow drainPeerInitiatedUniStreamsIntoBlackHole_keeps_connection_alive passed in isolation but flaked under parallel load with the audit-4 #3 "slow consumer overflowed incoming channel" teardown. Root cause: the test ran the black-hole drainer on the shared Dispatchers.Default pool. The drainer only has to keep the 64-deep per-stream incomingChannel drained, but when the rest of the JVM test suite saturates every Default worker thread the drainer coroutine can be denied a scheduling slot long enough for the synchronous feed loop to push more than 64 chunks ahead of it, tripping the slow-consumer teardown. The fixed-delay yields (delay(1) every 16 chunks) do not help when all pool threads are pinned. Fix: run the drainer on the runBlocking coroutine's own single-threaded event loop (CoroutineScope(coroutineContext + SupervisorJob())) and pace the feed loop with cooperative yield() instead of wall-clock delays. Each yield deterministically runs the drainer's queued channel-receive continuation before the producer resumes, so the buffer never approaches its bound regardless of machine load. The drain contract under test (parser trySend -> incomingChannel -> collect -> discard) is exercised identically; only the scheduling is pinned. Verified with a temporary reproduction that pins every Default worker thread: the old Default-scheduled drainer overflows to CLOSED while the confined drainer stays CONNECTED under the same saturation. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MGZt6D298kGv2XgAi3Uuaj --- .../quic/connection/PeerUniStreamDrainTest.kt | 48 +++++++++++++------ 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/PeerUniStreamDrainTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/PeerUniStreamDrainTest.kt index 9647217a4b..1bad4685ba 100644 --- a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/PeerUniStreamDrainTest.kt +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/PeerUniStreamDrainTest.kt @@ -24,12 +24,10 @@ import com.vitorpamplona.quic.frame.StreamFrame import com.vitorpamplona.quic.stream.StreamId import com.vitorpamplona.quic.tls.InProcessTlsServer import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotEquals @@ -123,8 +121,30 @@ class PeerUniStreamDrainTest { pipe.drive(maxRounds = 16) assertEquals(QuicConnection.Status.CONNECTED, client.status) - // Wire the explicit drainer BEFORE pushing the bytes. - val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + // Run the drainer on THIS runBlocking coroutine's own + // single-threaded event loop rather than the shared + // Dispatchers.Default pool. + // + // Why this matters for flakiness: the black-hole drainer only has + // to keep the 64-deep per-stream `incomingChannel` from filling. + // On Dispatchers.Default that is a race against the pool — when the + // rest of the JVM test suite saturates every Default worker thread, + // the drainer coroutine can be denied a scheduling slot long enough + // for the synchronous feed loop below to push more than 64 chunks + // ahead of it. That trips the audit-4 #3 slow-consumer teardown and + // the test fails under parallel load while passing in isolation — + // classic shared-pool starvation flakiness. + // + // Confining the drainer to the producer's event loop makes the + // hand-off deterministic: each cooperative `yield()` below parks the + // feed loop and lets the drainer empty the channel before more + // chunks are pushed, so the buffer never approaches its bound + // regardless of machine load. The drain contract under test (parser + // `trySend` -> `incomingChannel` -> collect -> discard) is exercised + // identically; only the scheduling is pinned. The private + // SupervisorJob keeps `scope.cancel()` in the finally block from + // ever touching the runBlocking job itself. + val scope = CoroutineScope(coroutineContext + SupervisorJob()) try { client.drainPeerInitiatedUniStreamsIntoBlackHole(scope) @@ -142,16 +162,16 @@ class PeerUniStreamDrainTest { offset += chunk.size.toLong() val packet = pipe.buildServerApplicationDatagram(listOf(frame))!! feedDatagram(client, packet, nowMillis = 0L) - // Yield occasionally so the drainer coroutine actually - // gets a chance to consume — feedDatagram is synchronous - // and the drainer launched with Dispatchers.Default needs - // a scheduling tick. - if (i % 16 == 15) { - withTimeout(2_000) { delay(1) } - } + // Hand the confined drainer a turn every few chunks so it + // drains the buffer well before it can reach the 64-chunk + // bound. feedDatagram is synchronous; yield() on the shared + // event loop deterministically runs the drainer's queued + // channel-receive continuation before the producer resumes. + if (i % 16 == 15) yield() } - // Ensure the drainer has caught up before we sample status. - withTimeout(2_000) { delay(50) } + // Let the drainer consume anything still buffered before we + // sample status. + yield() assertEquals( QuicConnection.Status.CONNECTED, From f723310dc77fce717da1977a6b4fc55028ed488b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 15:05:59 +0000 Subject: [PATCH 25/31] feat(buzz): archive/unarchive channels + fix visibility edits on Buzz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two channel-management gaps vs the Buzz interface (both ride kind-9002 tags; no new protocol), verified against block/buzz: Archive/Unarchive — the reversible hide-from-the-sidebar the Buzz client has, distinct from delete. EditMetadataEvent gains an `archived` tag; Account/ AccountViewModel expose archiveRelayGroup; the channel and forum top bars offer Archive/Unarchive to admins (no confirm — it's reversible). The relay stamps `["archived","true"]` on the 39000, so GroupMetadataEvent.isArchived() / RelayGroupChannel.isArchived() read it directly; the community list drops archived channels out of Channels/Forums into a collapsed "Archived" tail from which they can be reopened and unarchived. Visibility-on-edit — a Buzz relay reads its own `visibility` (open/private) tag, not the NIP-29 `private` status flag, so the edit screen's private toggle was a silent no-op on Buzz. editRelayGroupMetadata now sends the `visibility` tag on Buzz relays (status flag still sent for vanilla NIP-29). Not gaps (checked): topic/purpose/TTL are in the relay's system-message vocabulary but not extracted on 9002/9007, so there's nothing to mirror. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG --- .../vitorpamplona/amethyst/model/Account.kt | 18 +++++ .../ui/screen/loggedIn/AccountViewModel.kt | 9 +++ .../relayGroup/RelayGroupChannelListScreen.kt | 53 ++++++++++++- .../relayGroup/RelayGroupThreadsScreen.kt | 18 ++++- .../relayGroup/RelayGroupTopBar.kt | 13 +++ amethyst/src/main/res/values/strings.xml | 3 + .../nip29RelayGroups/RelayGroupChannel.kt | 3 + .../metadata/GroupMetadataEvent.kt | 8 ++ .../moderation/EditMetadataEvent.kt | 8 ++ .../ChannelSettingsTagTest.kt | 79 +++++++++++++++++++ 10 files changed, 206 insertions(+), 6 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/ChannelSettingsTagTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 91f929fc61..68df7e7ed0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -3676,6 +3676,10 @@ class Account( parent: String? = channel.parentGroupId(), children: List = channel.childGroupIds(), ) { + // On a Buzz relay, visibility rides a `visibility` ("open"/"private") tag — the relay does NOT + // read NIP-29's `private` status flag — so a Buzz channel's visibility only actually changes on + // edit when we send that tag. A plain NIP-29 relay ignores it and honours the status flag. + val isBuzz = BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) val template = EditMetadataEvent.build( channel.groupId.id, @@ -3687,10 +3691,24 @@ class Account( geohashes = geohashes, parent = parent, children = children, + visibility = if (isBuzz) (if (isPrivate) BUZZ_VISIBILITY_PRIVATE else BUZZ_VISIBILITY_OPEN) else null, ) signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } + /** + * Archive or unarchive a Buzz channel (a minimal kind-9002 carrying only the `archived` tag). The + * relay hides an archived channel from the sidebar and stamps the 39000, but keeps it and its + * history — the reversible counterpart to [deleteRelayGroup]. Admin/owner only; the relay enforces. + */ + suspend fun archiveRelayGroup( + channel: RelayGroupChannel, + archived: Boolean, + ) { + val template = EditMetadataEvent.build(channel.groupId.id, archived = archived) + signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + } + suspend fun follow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.follow(community)) suspend fun unfollow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.unfollow(community)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 363a703d06..a247e1cf1e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1675,6 +1675,15 @@ class AccountViewModel( /** Delete the channel/group for everyone (kind-9008). Owner/admin only; the relay enforces it. */ fun deleteRelayGroup(channel: RelayGroupChannel) = launchSigner { account.deleteRelayGroup(channel) } + /** + * Archive/unarchive a Buzz channel (kind-9002 `archived` tag) — hides it from the sidebar without + * destroying it, and is reversible. Owner/admin only; the relay enforces it. + */ + fun archiveRelayGroup( + channel: RelayGroupChannel, + archived: Boolean, + ) = launchSigner { account.archiveRelayGroup(channel, archived) } + /** * 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index a1cc027c72..3cbc0f9e39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -274,21 +274,34 @@ fun RelayGroupChannelListScreen( fun buzzSortKey(groupId: GroupId): String = channelsById[groupId.id]?.toBestDisplayName()?.lowercase() ?: groupId.id + // Archived channels (relay-signed `archived` tag on the 39000) drop out of their normal section and + // gather in a collapsed "Archived" tail — the same hide-from-the-sidebar behavior the Buzz client + // has. They stay reachable there so an admin can open one and Unarchive it from the top bar. + fun isArchived(groupId: GroupId): Boolean = channelsById[groupId.id]?.isArchived() == true + val buzzChatChannels = remember(buzzGroupIds, channelsById, starred) { buzzGroupIds - .filter { buzzTypeOf(it).let { t -> t != BUZZ_CHANNEL_TYPE_FORUM && t != BUZZ_CHANNEL_TYPE_DM } } + .filter { buzzTypeOf(it).let { t -> t != BUZZ_CHANNEL_TYPE_FORUM && t != BUZZ_CHANNEL_TYPE_DM } && !isArchived(it) } .sortedWith(compareByDescending { it.id in starred }.thenBy { buzzSortKey(it) }) } val buzzForumChannels = remember(buzzGroupIds, channelsById, starred) { buzzGroupIds - .filter { buzzTypeOf(it) == BUZZ_CHANNEL_TYPE_FORUM } + .filter { buzzTypeOf(it) == BUZZ_CHANNEL_TYPE_FORUM && !isArchived(it) } .sortedWith(compareByDescending { it.id in starred }.thenBy { buzzSortKey(it) }) } + // Every archived non-DM channel (chat + forum together), newest section at the bottom. + val buzzArchivedChannels = + remember(buzzGroupIds, channelsById) { + buzzGroupIds + .filter { buzzTypeOf(it) != BUZZ_CHANNEL_TYPE_DM && isArchived(it) } + .sortedBy { buzzSortKey(it) } + } - // Which sections the user has collapsed (session-scoped). Keyed by section id below. - var collapsedSections by remember { mutableStateOf(emptySet()) } + // Which sections the user has collapsed (session-scoped). Keyed by section id below. Archived + // starts collapsed — it's the out-of-the-way tail, expanded only when someone goes looking. + var collapsedSections by remember { mutableStateOf(setOf("archived")) } fun toggleSection(key: String) { collapsedSections = if (key in collapsedSections) collapsedSections - key else collapsedSections + key @@ -508,6 +521,38 @@ fun RelayGroupChannelListScreen( } } + // -- ARCHIVED -- Channels the relay has archived (chat + forum), tucked into a + // collapsed tail. Opening one and using the top-bar Unarchive brings it back. + if (buzzArchivedChannels.isNotEmpty()) { + val archivedCollapsed = "archived" in collapsedSections + item(key = "sec-archived") { + RelayGroupSectionHeader( + title = stringRes(R.string.relay_group_section_archived), + collapsed = archivedCollapsed, + onToggle = { toggleSection("archived") }, + ) + } + if (!archivedCollapsed) { + itemsIndexed(buzzArchivedChannels, key = { _, it -> "archived-${it.id}" }) { index, groupId -> + RowHairline(index) + val isForum = buzzTypeOf(groupId) == BUZZ_CHANNEL_TYPE_FORUM + BuzzImportRow( + groupId = groupId, + accountViewModel = accountViewModel, + onOpen = { + if (isForum) { + nav.nav(Route.RelayGroupThreads(groupId.id, relay.url)) + } else { + nav.nav(Route.RelayGroup(groupId.id, relay.url)) + } + }, + isStarred = groupId.id in starred, + showActivityPreview = !isForum, + ) + } + } + } + // -- DIRECT MESSAGES -- (this community's private conversations, most recent first) item(key = "sec-dms") { RelayGroupSectionHeader(title = stringRes(R.string.buzz_dm_title)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt index 85f0725a78..33354badb9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton @@ -61,6 +62,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplyCount import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -172,10 +174,12 @@ private fun RelayGroupThreads( }, actions = { // The forum's per-item actions, moved off the community-list row into this screen's - // top-bar overflow: Pin/Unpin and the Add/Remove-from-Messages toggle. Buzz-only, - // which every forum channel is. + // top-bar overflow: Pin/Unpin and the Add/Remove-from-Messages toggle, plus the + // admin-only Archive/Unarchive (so an archived forum can be brought back from here). + // Buzz-only, which every forum channel is. if (isBuzz) { var menuOpen by remember { mutableStateOf(false) } + val isAdmin = channel.membershipOf(accountViewModel.userProfile().pubkeyHex) == RelayGroupMembership.ADMIN IconButton(onClick = { menuOpen = true }) { Icon( symbol = MaterialSymbols.MoreVert, @@ -186,6 +190,16 @@ private fun RelayGroupThreads( DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { BuzzPinDropdownItem(channel.groupId) { menuOpen = false } RelayGroupMessagesDropdownItem(channel, accountViewModel) { menuOpen = false } + if (isAdmin) { + val archived = channel.isArchived() + DropdownMenuItem( + text = { Text(stringRes(if (archived) R.string.buzz_channel_unarchive else R.string.buzz_channel_archive)) }, + onClick = { + menuOpen = false + accountViewModel.archiveRelayGroup(channel, !archived) + }, + ) + } } } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt index afb2682855..4d725b781b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt @@ -370,6 +370,19 @@ fun RelayGroupTopBar( if (canPop) nav.popBack() }, ) + // Archive/Unarchive (kind-9002 `archived` tag) — a reversible hide-from-the-sidebar, + // Buzz-only and admin-gated like Delete but NOT destructive, so no confirm dialog. + // A DM is never archived (it has its own hide), so this is channels/forums only. + if (isBuzzRelay && !isDm && displayMembership == RelayGroupMembership.ADMIN) { + val archived = channel.isArchived() + DropdownMenuItem( + text = { Text(stringRes(if (archived) R.string.buzz_channel_unarchive else R.string.buzz_channel_archive)) }, + onClick = { + menuOpen = false + accountViewModel.archiveRelayGroup(channel, !archived) + }, + ) + } // 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. diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 75d6b194a8..75d3d2a7c8 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2613,6 +2613,8 @@ Delete \"%1$s\"? This removes the group and its history for everyone, and cannot be undone. Delete channel Delete \"%1$s\"? This removes the channel and its messages for everyone, and cannot be undone. + Archive channel + Unarchive channel Threads Pin message Unpin message @@ -3604,6 +3606,7 @@ Channels Forums + Archived Working… Add member Add people to this workspace diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt index 11c5bb23bd..9c029296f8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt @@ -178,6 +178,9 @@ class RelayGroupChannel( fun isPrivate(): Boolean = event?.isPrivate() ?: false + /** Buzz-only: the relay has archived this channel (hidden from the sidebar, but not deleted). */ + fun isArchived(): Boolean = event?.isArchived() ?: false + fun isRestricted(): Boolean = event?.isRestricted() ?: false fun isClosed(): Boolean = event?.isClosed() ?: false diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt index a05f2d7b9f..aeb4721570 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt @@ -57,6 +57,14 @@ class GroupMetadataEvent( fun picture() = tags.firstTagValue("picture") + /** + * Buzz-only: whether the relay has marked this channel **archived** — a hide-from-the-sidebar + * state, distinct from a delete (the channel and its history live on). The Buzz relay stamps an + * `["archived","true"]` tag onto the 39000 for an archived channel and clients hide it; a plain + * NIP-29 relay has no such concept, so this is false there. + */ + fun isArchived() = tags.firstTagValue("archived") == "true" + /** * Topic hashtags (`t` tags) the relay advertises for this group, used by the * discovery feed's hashtag filter. NIP-29 doesn't define these; a group only diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt index b503c830d3..61ccd41f3c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt @@ -82,6 +82,12 @@ class EditMetadataEvent( parent: String? = null, children: List = emptyList(), previousEvents: List = emptyList(), + // Buzz-only channel-settings tags (a Buzz relay reads these on 9002; a plain NIP-29 relay + // ignores them). [visibility] is "open"/"private" — Buzz's own vocabulary, which it reads + // instead of the NIP-29 `private` status flag, so editing visibility on a Buzz channel needs + // this tag to take. [archived] toggles the channel's archived state ("true"/"false"). + visibility: String? = null, + archived: Boolean? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { @@ -90,6 +96,8 @@ class EditMetadataEvent( about?.let { add(arrayOf("about", it)) } picture?.let { add(arrayOf("picture", it)) } status.forEach { add(arrayOf(it.code)) } + visibility?.let { add(arrayOf("visibility", it)) } + archived?.let { add(arrayOf("archived", if (it) "true" else "false")) } addAll(HashtagTag.assemble(hashtags)) // Mip-map each geohash into every prefix so a coarser followed geohash still matches. geohashes.forEach { addAll(GeoHashTag.assemble(it).toList()) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/ChannelSettingsTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/ChannelSettingsTagTest.kt new file mode 100644 index 0000000000..080e477790 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/ChannelSettingsTagTest.kt @@ -0,0 +1,79 @@ +/* + * 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.quartz.nip29RelayGroups + +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The Buzz-only channel-settings tags on kind-9002 edit-metadata (`visibility`, `archived`) and the + * `archived` reflection on the relay-signed kind-39000. These ride the 9002 as tags — Buzz reads its + * own `visibility` vocabulary rather than the NIP-29 `private` status flag, and stamps `archived` onto + * the 39000 for an archived channel. + */ +class ChannelSettingsTagTest { + private val relaySelf = "aa".repeat(32) + private val sig = "bb".repeat(64) + private val id = "00".repeat(32) + private val gid = "0123456789abcdef" + + private fun tagValue( + tags: Array>, + name: String, + ): String? = tags.firstOrNull { it.isNotEmpty() && it[0] == name }?.getOrNull(1) + + @Test + fun editEmitsVisibilityTagOnlyWhenSet() { + val priv = EditMetadataEvent.build(gid, visibility = "private") + assertEquals("private", tagValue(priv.tags, "visibility")) + + val open = EditMetadataEvent.build(gid, visibility = "open") + assertEquals("open", tagValue(open.tags, "visibility")) + + // Absent by default so an ordinary metadata edit doesn't reclassify visibility. + assertNull(tagValue(EditMetadataEvent.build(gid, name = "x").tags, "visibility")) + } + + @Test + fun editEmitsArchivedTagAsTrueFalse() { + assertEquals("true", tagValue(EditMetadataEvent.build(gid, archived = true).tags, "archived")) + assertEquals("false", tagValue(EditMetadataEvent.build(gid, archived = false).tags, "archived")) + // Null archived means "don't touch it" — no tag emitted. + assertNull(tagValue(EditMetadataEvent.build(gid, name = "x").tags, "archived")) + } + + @Test + fun metadataReflectsArchivedFlag() { + val archivedTemplate = GroupMetadataEvent.build(gid, name = gid) { add(arrayOf("archived", "true")) } + val archived = EventFactory.create(id, relaySelf, archivedTemplate.createdAt, GroupMetadataEvent.KIND, archivedTemplate.tags, "", sig) as GroupMetadataEvent + assertTrue(archived.isArchived()) + + val plainTemplate = GroupMetadataEvent.build(gid, name = gid) + val plain = EventFactory.create(id, relaySelf, plainTemplate.createdAt, GroupMetadataEvent.KIND, plainTemplate.tags, "", sig) as GroupMetadataEvent + assertFalse(plain.isArchived()) + } +} From 9d2c5e0ceef9d2c62871d0a6ad860bd384e46d16 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 16:07:07 +0000 Subject: [PATCH 26/31] fix(buzz): show the channel/forum "+" on any community, not gated on an unloaded roster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "+" was gated on BuzzCommunityMembership (kind-13534), but that roster is only fetched by the Members screen — nothing on the channel-list screen subscribes to it, so isMember read false and the "+" hid even from admins. Match the workspace overflow menu's approach instead: offer create on any Buzz community and let the relay reject a non-member's kind-9007 (its own doc: "any member sees them, the relay only serves the owner/admin ones"). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG --- .../relayGroup/RelayGroupChannelListScreen.kt | 57 ++++++------------- 1 file changed, 17 insertions(+), 40 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index 3cbc0f9e39..7ff2e28dac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -107,7 +107,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent -import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -208,22 +207,13 @@ fun RelayGroupChannelListScreen( // your kind-10009 list (so it then shows in Messages / Relay Groups). Same screen, one Browse. val isBuzz = BuzzRelayDialect.isBuzz(relay) || relayInfo.software?.contains("buzz", ignoreCase = true) == true - // Who may create a channel/forum here — the gate for the section "+" buttons. A Buzz relay lets - // any community member create one (the creator becomes its owner); a non-member's 9007 is - // rejected, so we only offer the "+" to members of this community's NIP-43 roster. Reactive: the - // relay-signed roster snapshot (kind 13534, republished after every membership change) may land - // after first composition, so re-read [BuzzCommunityMembership] whenever one does. + // A Buzz relay lets any community member create a channel/forum (the creator becomes its owner), + // and the relay rejects a non-member's kind-9007. We don't hard-gate the "+" on membership: the + // NIP-43 roster (kind 13534) isn't fetched on this screen, so gating on it hid the "+" even from + // admins. Instead we offer it on any Buzz community and let the relay enforce — the same approach + // as the workspace overflow menu (Add people / Invite), whose own doc notes "any member sees them, + // the relay only serves the owner/admin ones." val myPubkey = accountViewModel.account.signer.pubKey - val canCreateChannels by produceState( - initialValue = BuzzCommunityMembership.isMember(relay, myPubkey), - relay, - myPubkey, - ) { - value = BuzzCommunityMembership.isMember(relay, myPubkey) - LocalCache - .observeEvents(Filter(kinds = listOf(RelayMembershipListEvent.KIND))) - .collect { value = BuzzCommunityMembership.isMember(relay, myPubkey) } - } val buzzVm: BuzzRelayImportViewModel = viewModel(key = "BuzzImport-${relay.url}") LaunchedEffect(relay, isBuzz) { if (isBuzz) buzzVm.bind(accountViewModel.account, relay.url) } @@ -448,8 +438,7 @@ fun RelayGroupChannelListScreen( // -- CHANNELS -- The label carries a "+" to create a channel (the community's FAB // moved here, like Direct Messages). Add-all lives in the top-bar overflow menu. // The header always shows so the "+" is available even before any channel loads; - // the collapse toggle is offered only when there's something to collapse. The "+" - // is offered only to community members, who are the ones the relay lets create. + // the collapse toggle is offered only when there's something to collapse. run { val channelsCollapsed = "channels" in collapsedSections item(key = "sec-channels") { @@ -457,17 +446,11 @@ fun RelayGroupChannelListScreen( title = stringRes(R.string.relay_group_section_channels), collapsed = channelsCollapsed, onToggle = if (buzzChatChannels.isNotEmpty()) ({ toggleSection("channels") }) else null, - trailing = - if (canCreateChannels) { - { - SectionAddButton(stringRes(R.string.buzz_channel_create_title)) { - nav.nav(Route.RelayGroupCreate(relay.url)) - } - } - } else { - null - }, - ) + ) { + SectionAddButton(stringRes(R.string.buzz_channel_create_title)) { + nav.nav(Route.RelayGroupCreate(relay.url)) + } + } } if (buzzChatChannels.isNotEmpty() && !channelsCollapsed) { itemsIndexed(buzzChatChannels, key = { _, it -> "chat-${it.id}" }) { index, groupId -> @@ -491,17 +474,11 @@ fun RelayGroupChannelListScreen( title = stringRes(R.string.relay_group_section_forums), collapsed = forumsCollapsed, onToggle = if (buzzForumChannels.isNotEmpty()) ({ toggleSection("forums") }) else null, - trailing = - if (canCreateChannels) { - { - SectionAddButton(stringRes(R.string.buzz_forum_create_title)) { - nav.nav(Route.RelayGroupCreate(relay.url, isForum = true)) - } - } - } else { - null - }, - ) + ) { + SectionAddButton(stringRes(R.string.buzz_forum_create_title)) { + nav.nav(Route.RelayGroupCreate(relay.url, isForum = true)) + } + } } if (buzzForumChannels.isNotEmpty() && !forumsCollapsed) { itemsIndexed(buzzForumChannels, key = { _, it -> "forum-${it.id}" }) { index, groupId -> From ca61eb107dad64c5a0cdeb175323c1a84879731a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 16:09:11 +0000 Subject: [PATCH 27/31] fix(buzz): drop deleted channels that leaked as bare UUID rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A channel deleted elsewhere (or before this device saw its metadata) keeps its kind-44100 membership — the relay never retracts it — but the relay soft-deletes its 39000, and it does NOT serve a channel_deleted 40099 for an already-deleted channel, so the discovery fetch can't catch it. Such a channel arrived with no metadata and was shown optimistically: a bare UUID row with "No messages yet". The reliable signal is that very metadata absence. BuzzRelayImportViewModel.discover now drops channels the relay serves no 39000 for — but only when the fetch clearly worked (some channel returned a 39000); if none did, the read failed (auth/ connectivity) and the whole set is kept rather than blanking the community. A genuinely new channel whose 39000 is merely slow returns on the next bind once its metadata is cached, so the prune is live (not persisted) and self-correcting. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG --- .../loggedIn/buzz/BuzzRelayImportViewModel.kt | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt index 30c5f73e2a..59a0aa3247 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt @@ -177,16 +177,28 @@ class BuzzRelayImportViewModel : ViewModel() { ) { _, _ -> false } } - // 3. Keep only non-DM workspace channels; a channel whose metadata hasn't arrived - // (type unknown) is optimistically shown as a workspace channel. Deleted channels - // (kind-40099 `channel_deleted`, recorded above) are dropped — the relay keeps - // re-announcing their kind-44100, so this is the only place they leave the list. + // 3. Keep only the non-DM workspace channels the relay still serves metadata for. + // + // A deleted (or never-really-there) channel keeps its kind-44100 — the relay never + // retracts it — but the relay soft-deletes its 39000, so it arrives here with NO + // metadata. That absence is the reliable "it's gone" signal (the relay does not serve + // a `channel_deleted` 40099 for an already-deleted channel, so the fetch above can't + // catch this case). Before, such a channel showed optimistically — as a bare UUID row + // with "No messages yet", which is exactly the leak reported. + // + // So drop channels with no metadata — but ONLY when the fetch clearly worked (at least + // one channel came back with a 39000). If none did, the read failed (auth/connectivity) + // and we keep the whole set rather than blank the community; a genuinely new channel + // whose 39000 is merely slow re-appears on the next bind once its metadata is cached. + val channels = channelIds.associateWith { LocalCache.getOrCreateRelayGroupChannel(GroupId(it, relay)) } + val fetchHadMetadata = channels.values.any { it.event != null } _channels.value = channelIds .mapNotNull { id -> val groupId = GroupId(id, relay) if (RelayGroupDeletions.isDeleted(groupId)) return@mapNotNull null - val channel = LocalCache.getOrCreateRelayGroupChannel(groupId) + val channel = channels.getValue(id) + if (fetchHadMetadata && channel.event == null) return@mapNotNull null if (channel.event?.isBuzzDm() == true) null else groupId }.sortedBy { it.id } _status.value = Status.Ready From 04243d8dfa003c808bd42de57204fa87c41390f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 16:19:17 +0000 Subject: [PATCH 28/31] fix: open edit-draft screen when tapping a draft in ThreadView Tapping a note in the thread view toggles the collapse/expand state of its children. For the user's own drafts, tap now opens the edit-draft screen (via routeEditDraftTo) so the post can be resumed, matching the behavior everywhere else a draft is tapped. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VQCNuxdYkfouNN3ujxm3mR --- .../loggedIn/threadview/ThreadFeedView.kt | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 6e603ab36a..735e3a9175 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage import com.vitorpamplona.amethyst.ui.note.CheckAndDisplayEditStatus @@ -512,7 +513,23 @@ fun RenderThreadFeed( if (item.idHex == noteId) mutableStateOf(selectedNoteColor) else null } - val onCollapse = remember(item) { { viewModel.toggleCollapsed(item.idHex) } } + // Tapping a reply collapses/expands its children, except for the user's own + // drafts: those open the edit-draft screen so the post can be resumed, matching + // the behavior everywhere else a draft is tapped. + val onClick = + remember(item) { + { + if (item.isDraft()) { + nav.nav { + withContext(Dispatchers.IO) { + routeEditDraftTo(item, accountViewModel.account) + } + } + } else { + viewModel.toggleCollapsed(item.idHex) + } + } + } NoteCompose( baseNote = item, @@ -523,7 +540,7 @@ fun RenderThreadFeed( parentBackgroundColor = background, accountViewModel = accountViewModel, nav = nav, - onClick = onCollapse, + onClick = onClick, ) } From d44264a1acb85e371268430b337ec096839ddabe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 16:22:34 +0000 Subject: [PATCH 29/31] feat(buzz): fix channel/forum type on the create screen from the caller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The create screen no longer shows a "Forum channel" toggle — the type is decided by the community's per-section "+" (Channels vs Forums) and a Buzz channel's channel_type isn't editable anyway (the relay's 9002 has no such key). The screen loads with the fixed isForum parameter and creates the right type; its top-bar title reads "New forum" when isForum, "New channel" otherwise (on Buzz). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG --- .../relayGroup/RelayGroupMetadataScreen.kt | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt index 5f2c62d1d5..b2528156c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt @@ -202,8 +202,15 @@ private fun RelayGroupMetadataScaffold( Scaffold( topBar = { if (viewModel.isNewGroup) { + // The type is fixed by the caller (the community's per-section "+"), so the title names + // it — "New forum" vs "New channel" on Buzz — rather than offering a toggle to change it. CreatingTopBar( - titleRes = if (viewModel.isBuzzRelay) R.string.buzz_channel_create_title else R.string.relay_group_create_title, + titleRes = + when { + !viewModel.isBuzzRelay -> R.string.relay_group_create_title + viewModel.isForum -> R.string.buzz_forum_create_title + else -> R.string.buzz_channel_create_title + }, isActive = { viewModel.canPost && (nip29Support == true || viewModel.isBuzzRelay) }, onCancel = nav::popBack, onPost = onSubmit, @@ -415,21 +422,11 @@ private fun GroupMetadataFields(viewModel: RelayGroupMetadataViewModel) { viewModel.markTouched() } - if (viewModel.isBuzzRelay) { - // Buzz's `channel_type`. Only offered on create: the relay takes it on the 9007 and its - // 9002 handler has no `channel_type` key, so an existing channel cannot be converted. - if (viewModel.isNewGroup) { - LabeledSwitchRow( - label = stringRes(R.string.buzz_channel_flag_forum), - description = stringRes(R.string.buzz_channel_flag_forum_desc), - checked = viewModel.isForum, - ) { - viewModel.isForum = it - viewModel.markTouched() - } - } - return - } + // A Buzz channel's type (chat vs forum) is fixed by the caller — the community's per-section "+" — + // and isn't editable (the relay's 9002 has no `channel_type` key), so there's no toggle here. The + // remaining vanilla NIP-29 flags (invite-only / restricted below) don't apply to Buzz either, so + // stop here for a Buzz relay. + if (viewModel.isBuzzRelay) return LabeledSwitchRow( label = stringRes(R.string.relay_group_flag_invite_only), From 0279938e05196e83859794704b4e3025c8ab060a Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:41:58 +0000 Subject: [PATCH 30/31] chore: sync Crowdin translations and seed translator npub placeholders --- amethyst/src/main/res/values-cs/strings.xml | 46 +++++++++++++++++++ .../src/main/res/values-de-rDE/strings.xml | 38 +++++++++++++++ .../src/main/res/values-pt-rBR/strings.xml | 41 +++++++++++++++++ .../src/main/res/values-sv-rSE/strings.xml | 43 +++++++++++++++++ 4 files changed, 168 insertions(+) diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index 6b087bfa27..9f2709970e 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -458,6 +458,8 @@ Obnovit Nový chatový profil: Opustit + Odebrat ze Zpráv + Přidat do Zpráv Přestat sledovat Kanál vytvořen "Informace kanálu změněna na" @@ -2216,6 +2218,30 @@ Směrovat přes Tor Sekce a kanály Viditelné karty + Obsah v kanálu + Textové poznámky + Sdílení + Komentáře a odpovědi + Obrázky + Videa + Krátká videa + Články + Wiki stránky + Zvýraznění + Ankety + Inzeráty + Torrenty + Hlasové zprávy + Živé aktivity + Dočasné chaty + Interaktivní příběhy + Šachové partie + Pozorování ptáků + Atestace + Návrhy NIP + Hudba a zvuk + Podcasty + Sbírky Připomenutí Připojení peněženky Jazyk @@ -2452,6 +2478,10 @@ Uložit Členové Upravit skupinu + Smazat skupinu + Smazat „%1$s“? Odstraní to skupinu a její historii pro všechny a nelze to vrátit zpět. + Smazat kanál + Smazat „%1$s“? Odstraní to kanál a jeho zprávy pro všechny a nelze to vrátit zpět. Vlákna Připnout zprávu Odepnout zprávu @@ -2605,6 +2635,14 @@ Odeslat jako DM Odeslat… Nová zpráva + Nové zvýraznění + Nové zvýraznění + Zvýrazněný text + Co vás zaujalo? + URL zdroje + Vaše poznámka (volitelné) + Přidejte své myšlenky… + Zvýraznit Kopírovat URL do schránky Kopírovat ID poznámky do schránky Přidat média do galerie @@ -3392,6 +3430,12 @@ Připnout kanál Odepnout kanál Zatím žádné konverzace + + %1$d skrytá konverzace + %1$d skryté konverzace + %1$d skryté konverzace + %1$d skrytých konverzací + Zobrazit další %1$d konverzaci Zobrazit všechny %1$d konverzace @@ -3572,6 +3616,7 @@ Témata (oddělená čárkami) Uložit Git repozitáře + Zvýraznění Statický web: %1$s nApplet: %1$s Oprávnění: @@ -4653,6 +4698,7 @@ Kompatibilní s Bitchatem; zasáhne blízké uživatele na stejných relayích. Veřejné a dočasné — žádná historie, může to číst kdokoli v buňce. + Fronta úloh Běhy workflow Práce agenta Nový běh diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 82bae0b5ca..7f06367211 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -438,6 +438,8 @@ Aktualisieren Neues Chat-Profil: Verlassen + Aus Nachrichten entfernen + Zu Nachrichten hinzufügen Entfolgen Kanal erstellt "Kanalinformationen geändert in" @@ -2134,6 +2136,25 @@ Über Tor leiten Abschnitte & Feeds Sichtbare Tabs + Inhalte im Feed + Textnotizen + Kommentare & Antworten + Bilder + Kurzvideos + Artikel + Wiki-Seiten + Umfragen + Kleinanzeigen + Sprachnachrichten + Live-Aktivitäten + Ephemere Chats + Interaktive Geschichten + Schachpartien + Vogelbeobachtungen + Attestierungen + NIP-Entwürfe + Musik & Audio + Spendenaktionen Erinnerungen Wallet Verbindung Sprache @@ -2366,6 +2387,10 @@ Speichern Mitglieder Gruppe bearbeiten + Gruppe löschen + „%1$s“ löschen? Damit werden die Gruppe und ihr Verlauf für alle entfernt, und das lässt sich nicht rückgängig machen. + Kanal löschen + „%1$s“ löschen? Damit werden der Kanal und seine Nachrichten für alle entfernt, und das lässt sich nicht rückgängig machen. Threads Nachricht anheften Nachricht loslösen @@ -2509,6 +2534,14 @@ Als DM senden Senden an… Neue Nachricht + Neues Highlight + Neues Highlight + Hervorgehobener Text + Was ist dir aufgefallen? + Quell-URL + Deine Notiz (optional) + Füge deine Gedanken hinzu… + Hervorheben URL in die Zwischenablage kopieren Notiz-ID in die Zwischenablage kopieren Medien zur Galerie hinzufügen @@ -3276,6 +3309,10 @@ Kanal anheften Kanal nicht mehr anheften Noch keine Unterhaltungen + + %1$d ausgeblendete Unterhaltung + %1$d ausgeblendete Unterhaltungen + %1$d weitere Unterhaltung anzeigen Alle %1$d Unterhaltungen anzeigen @@ -4577,6 +4614,7 @@ System-Prompt Modell (optional) Anbieter (optional) + Laufzeitumgebung (optional) Avatar-URL (optional) Wird veröffentlicht… Persona veröffentlichen diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 2f453bfef3..c2c6fa8353 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -438,6 +438,8 @@ Atualizar Novo perfil de chat: Sair + Remover das Mensagens + Adicionar às Mensagens Desseguir Canal criado "Informação do canal mudou para" @@ -2132,6 +2134,28 @@ Rotear pelo Tor Seções e feeds Abas visíveis + Conteúdo no feed + Notas de texto + Repostagens + Comentários e respostas + Imagens + Vídeos + Curtas + Artigos + Páginas wiki + Destaques + Enquetes + Classificados + Mensagens de voz + Atividades ao vivo + Chats efêmeros + Histórias interativas + Partidas de xadrez + Observações de aves + Atestações + Rascunhos de NIP + Música e áudio + Arrecadações Lembretes Conectar Carteira Linguagem @@ -2364,6 +2388,10 @@ Salvar Membros Editar grupo + Excluir grupo + Excluir “%1$s”? Isso remove o grupo e seu histórico para todos e não pode ser desfeito. + Excluir canal + Excluir “%1$s”? Isso remove o canal e suas mensagens para todos e não pode ser desfeito. Tópicos Fixar mensagem Desafixar mensagem @@ -2507,6 +2535,14 @@ Enviar como DM Enviar para… Nova mensagem + Novo destaque + Novo destaque + Texto destacado + O que chamou sua atenção? + URL de origem + Sua nota (opcional) + Adicione seus comentários… + Destacar Copiar URL para a área de transferência Copiar ID da nota para a área de transferência Adicionar Mídia à Galeria @@ -3274,6 +3310,10 @@ Fixar canal Desafixar canal Nenhuma conversa ainda + + %1$d conversa oculta + %1$d conversas ocultas + Ver mais %1$d conversa Ver todas as %1$d conversas @@ -3450,6 +3490,7 @@ Tópicos (separados por vírgula) Salvar Repositórios Git + Destaques Site Estático: %1$s nApplet: %1$s Permissões: diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index e25860e071..6f5ee3cbd0 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -438,6 +438,8 @@ Uppdatera Ny chattprofil: Lämna + Ta bort från Meddelanden + Lägg till i Meddelanden Sluta följa Kanal skapad "Kanalinformation ändrad till" @@ -2132,6 +2134,30 @@ Dirigera via Tor Sektioner & flöden Synliga flikar + Innehåll i flödet + Textanteckningar + Återinlägg + Kommentarer & svar + Bilder + Videor + Kortfilmer + Artiklar + Wiki-sidor + Höjdpunkter + Omröstningar + Annonser + Torrenter + Röstmeddelanden + Liveaktiviteter + Tillfälliga chattar + Interaktiva berättelser + Schackpartier + Fågelobservationer + Attesteringar + NIP-utkast + Musik & ljud + Poddar + Insamlingar Påminnelser Plånboksanslut Språk @@ -2364,6 +2390,10 @@ Spara Medlemmar Redigera grupp + Radera grupp + Radera ”%1$s”? Det tar bort gruppen och dess historik för alla, och kan inte ångras. + Radera kanal + Radera ”%1$s”? Det tar bort kanalen och dess meddelanden för alla, och kan inte ångras. Trådar Fäst meddelande Lossa meddelande @@ -2507,6 +2537,14 @@ Skicka som DM Skicka till… Nytt meddelande + Ny höjdpunkt + Ny höjdpunkt + Markerad text + Vad fastnade du för? + Käll-URL + Din anteckning (valfritt) + Lägg till dina tankar… + Markera Kopiera URL till urklipp Kopiera anteckningens ID till urklipp Lägg till media i galleriet @@ -3274,6 +3312,10 @@ Fäst kanal Lossa kanal Inga konversationer än + + %1$d dold konversation + %1$d dolda konversationer + Visa %1$d konversation till Visa alla %1$d konversationer @@ -3450,6 +3492,7 @@ Ämnen (kommaseparerade) Spara Git-repositories + Höjdpunkter Statisk webbplats: %1$s nApplet: %1$s Behörigheter: From ee8623bd208eac03233352e744a3a475b001bc39 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 17:01:53 +0000 Subject: [PATCH 31/31] refactor: dispatch all NIP-71 video kinds via the VideoEvent interface in NoteCompose RenderNoteRow matched the four concrete video subtypes (VideoNormalEvent 21, VideoShortEvent 22, VideoHorizontalEvent 34235, VideoVerticalEvent 34236) with four identical branches. Collapse them into a single `is VideoEvent` branch, matching how ThreadFeedView's NoteMaster already dispatches, so any future VideoEvent subtype renders through VideoDisplay automatically instead of falling through to the text fallback. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QCKP4tD2seoDpr3ZcdPJNx --- .../amethyst/ui/note/NoteCompose.kt | 21 ++++--------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 995197944e..42ce00cb0b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -327,10 +327,7 @@ import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent -import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent -import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent +import com.vitorpamplona.quartz.nip71Video.VideoEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent @@ -1514,19 +1511,9 @@ private fun RenderNoteRow( FileHeaderDisplay(baseNote, true, ContentScale.FillWidth, accountViewModel) } - is VideoHorizontalEvent -> { - VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav) - } - - is VideoVerticalEvent -> { - VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav) - } - - is VideoNormalEvent -> { - VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav) - } - - is VideoShortEvent -> { + // Covers every NIP-71 video kind (21, 22, 34235, 34236) via the shared interface, + // so new video subtypes render automatically instead of falling through to text. + is VideoEvent -> { VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav) }