From 96944b94eed5f3d6e1e393599f4c764b9c14f30b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 15:35:06 +0000 Subject: [PATCH] feat(observer): native Nostr Observer front page (WIP, not yet compiled) Port the Nostr Observer's relay pull into commons and lay the paper out on the device instead of with a model: - commons/observer: ObserverDesk (14 desks), ObserverReadiness (no fallback lens), ObserverPull (observer: lens on the search relay, one REQ per desk, the lens's reactions/reposts/replies as the ranking signal, bylines), ObserverEditor (pure, deterministic layout), ObserverPress (account-scoped background job with step progress). - commonsUI/observer/ui: newspaper screen + PoW-style progress banner. - Route.Observer, NavBarItem.OBSERVER, drawer + bottom-bar catalog entries. - amy observer [USER]: prints the same edition from the terminal. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HSjTU2HWoVbg6ttC1uB1f8 --- .../amethyst/ui/navigation/AppNavigation.kt | 4 + .../ui/observer/DisplayObserverProgress.kt | 83 +++ .../loggedIn/BottomBarFeedPreloaders.kt | 3 + .../com/vitorpamplona/amethyst/cli/Main.kt | 8 +- .../amethyst/cli/commands/ObserverCommand.kt | 168 +++++ commons/ARCHITECTURE.md | 1 + .../amethyst/commons/model/Account.kt | 16 + .../commons/model/navigation/NavBarItem.kt | 1 + .../commons/model/navigation/Routes.kt | 3 + .../amethyst/commons/observer/ObserverDesk.kt | 90 +++ .../commons/observer/ObserverEdition.kt | 191 +++++ .../commons/observer/ObserverEditor.kt | 459 ++++++++++++ .../commons/observer/ObserverPress.kt | 223 ++++++ .../amethyst/commons/observer/ObserverPull.kt | 321 +++++++++ .../commons/observer/ObserverReadiness.kt | 80 +++ .../commons/observer/ObserverEditorTest.kt | 314 +++++++++ .../composeResources/values/strings.xml | 63 ++ .../observer/ui/ObserverPressBanner.kt | 265 +++++++ .../commons/observer/ui/ObserverScreen.kt | 659 ++++++++++++++++++ .../ui/navigation/bottombars/NavBarItem.kt | 9 + .../ui/navigation/drawer/DrawerSections.kt | 1 + 21 files changed, 2961 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/observer/DisplayObserverProgress.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ObserverCommand.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ObserverDesk.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ObserverEdition.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ObserverEditor.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ObserverPress.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ObserverPull.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ObserverReadiness.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/observer/ObserverEditorTest.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ui/ObserverPressBanner.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ui/ObserverScreen.kt 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 7f842b1a1b..c825b1dcb8 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 @@ -168,6 +168,7 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geohash.GeoHashScre import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.gitRepositories.GitRepositoriesScreen import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.hashtag.HashtagPostScreen import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.hashtag.HashtagScreen +import com.vitorpamplona.amethyst.commons.observer.ui.ObserverScreen import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.highlights.HighlightsScreen import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.highlights.NewHighlightScreen import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.HomeScreen @@ -284,6 +285,7 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.BlossomBlobManagerScre import com.vitorpamplona.amethyst.ui.actions.mediaServers.BlossomImportScreen import com.vitorpamplona.amethyst.ui.actions.mediaServers.DisplayBlossomSyncProgress import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress +import com.vitorpamplona.amethyst.ui.observer.DisplayObserverProgress import com.vitorpamplona.amethyst.ui.call.CallActivity import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages @@ -425,6 +427,7 @@ fun AppNavigation( DisplayCrashMessages(accountViewModel, nav) DisplayResourceUsageAlert(accountViewModel, nav) DisplayBroadcastProgress(accountViewModel) + DisplayObserverProgress(accountViewModel, nav) DisplayBlossomSyncProgress() ObserveIncomingCalls(accountViewModel) @@ -502,6 +505,7 @@ fun BuildNavigation( composableFromEnd { SoftwareAppsScreen(accountViewModel, nav) } composableFromEnd { NappletsScreen(accountViewModel, nav) } composableFromEnd { NsitesScreen(accountViewModel, nav) } + composableFromEnd { ObserverScreen(accountViewModel, nav) } composableFromEnd(capWidth = false) { BrowserScreen(accountViewModel, nav) } composableFromEnd { FavoriteAppsScreen(accountViewModel, nav) } composableFromEndArgs(capWidth = false) { WebAppScreen(it.url, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/observer/DisplayObserverProgress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/observer/DisplayObserverProgress.kt new file mode 100644 index 0000000000..e3bc3e899e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/observer/DisplayObserverProgress.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.observer + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.observer.ObserverPress +import com.vitorpamplona.amethyst.commons.observer.ui.ObserverPressBanner +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel + +/** + * App-wide floating banner for the Nostr Observer's press, mounted at the navigation root + * beside the mining/broadcast banner so it survives navigation: progress while the paper + * prints, then "Your paper is ready · Read". Sits one row higher while the mining/broadcast + * banner is up, so the two stack instead of overlapping. + */ +@Composable +fun DisplayObserverProgress( + accountViewModel: AccountViewModel, + nav: INav, +) { + val press = accountViewModel.account.observerPress + val state by press.state.collectAsStateWithLifecycle() + val readerIsLooking by press.readerIsLooking.collectAsStateWithLifecycle() + + // Nothing has ever been printed: don't compose the overlay at all. + if (state is ObserverPress.State.Idle) return + + val miningJobs by Amethyst.instance.powPublishQueue.jobs + .collectAsStateWithLifecycle() + val broadcasts by accountViewModel.broadcastTracker.activeBroadcasts.collectAsStateWithLifecycle() + val stacked = miningJobs.isNotEmpty() || broadcasts.isNotEmpty() + + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { + ObserverPressBanner( + state = state, + readerIsLooking = readerIsLooking, + onOpen = { + press.markSeen() + nav.nav(Route.Observer) + }, + onCancel = press::cancel, + onDismiss = press::dismiss, + modifier = + Modifier + .navigationBarsPadding() + .padding(start = 12.dp, end = 12.dp, bottom = if (stacked) 130.dp else 58.dp) + .fillMaxWidth(0.94f) + .widthIn(max = 560.dp), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt index 52947ae777..f5838ed6b1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt @@ -120,6 +120,9 @@ private fun PreloadFor( NavBarItem.NSITES -> {} + // The Observer prints on demand through its own press; there is no feed to warm up. + NavBarItem.OBSERVER -> {} + // The browser is a "new tab" launcher with no feed to preload. NavBarItem.BROWSER -> {} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index ac5ab57c5c..a9b9a623c1 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.cli.commands.ProfileCommands import com.vitorpamplona.amethyst.cli.commands.PublishCommand import com.vitorpamplona.amethyst.cli.commands.RelayCommands import com.vitorpamplona.amethyst.cli.commands.RelayGroupCommands +import com.vitorpamplona.amethyst.cli.commands.ObserverCommand import com.vitorpamplona.amethyst.cli.commands.SearchCommand import com.vitorpamplona.amethyst.cli.commands.ServeCommand import com.vitorpamplona.amethyst.cli.commands.SnoCommands @@ -307,6 +308,7 @@ private suspend fun dispatch(argv: Array): Int { "unfollow" -> FollowCommand.unfollow(dataDir, tail) "graperank" -> GrapeRankCommand.dispatch(dataDir, tail) "search" -> SearchCommand.dispatch(dataDir, tail) + "observer" -> ObserverCommand.run(dataDir, tail) "zap" -> ZapCommand.dispatch(dataDir, tail) "bolt12" -> Bolt12Commands.dispatch(dataDir, tail) "offer" -> OfferCommands.dispatch(dataDir, tail) @@ -360,7 +362,7 @@ private fun printVerbList() { | identity: init create login logoff whoami use status | primitives: decode encode verify key filter nip kind pow namecoin | events: event publish fetch subscribe count sync encrypt decrypt gift - | social: notes profile follow unfollow search zap dm outbox + | social: notes profile follow unfollow search observer zap dm outbox | groups: marmot relaygroup concord cordn geochat | relays: relay admin serve store | trust: graperank fof @@ -796,6 +798,10 @@ private fun printUsage() { | search note QUERY [--limit N] search event content | [--kind K[,K…]] (default kind:1; e.g. 1,30023; --kinds alias) | [--timeout SECS] + | + |Nostr Observer: + | observer [USER] [--relay URL] today's front page from USER's web-of-trust lens + | (default: you); laid out locally, no server | uses your kind:10007 search-relay | list, falls back to Amethyst defaults | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ObserverCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ObserverCommand.kt new file mode 100644 index 0000000000..25ae50ba69 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ObserverCommand.kt @@ -0,0 +1,168 @@ +/* + * 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.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.observer.ObserverEdition +import com.vitorpamplona.amethyst.commons.observer.ObserverPress +import com.vitorpamplona.amethyst.commons.observer.ObserverPull +import com.vitorpamplona.amethyst.commons.observer.ObserverStory +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first + +/** + * `amy observer [USER] [--relay URL]` + * + * Prints the Nostr Observer front page for USER (default: the logged-in + * account) from the last 24 hours, ranked through their own web-of-trust lens + * on the search relay and laid out on this machine — the same press the app's + * Observer screen runs. Read-only: it signs nothing, so it works anonymously. + * + * Thin assembly only: readiness, pull, engagement and layout live in + * `commons/observer`; this file picks the reader and shapes the output. + */ +object ObserverCommand { + val USAGE: String = + """ + |Nostr Observer (a front page from your web of trust's last 24 hours): + | observer [USER] [--relay URL] USER = npub/nprofile/hex/name@domain (default: you). + | Ranked by the `observer:` lens on the search relay + | (default wss://search.brainstorm.world); exits 1 with + | the unmet readiness link when no lens can rank yet. + """.trimMargin() + + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.firstOrNull() == "--help" || rest.firstOrNull() == "-h") { + System.err.println(USAGE) + return 0 + } + val args = Args(rest) + val relay = args.flag("relay")?.let { RelayUrlNormalizer.normalizeOrNull(it) ?: return Output.error("bad_args", "not a relay url: $it") } + val userArg = args.positionalOrNull(0) + args.rejectUnknown() + + Context.openOrAnonymous(dataDir).use { ctx -> + ctx.prepare() + val reader = + when { + userArg != null -> ctx.requireUserHex(userArg) + !ctx.anonymous -> ctx.identity.pubKeyHex + else -> return Output.error("bad_args", "no account: pass a USER (npub, hex or name@domain)") + } + + val scope = CoroutineScope(SupervisorJob()) + try { + val pull = if (relay != null) ObserverPull(ctx.client, relay) else ObserverPull(ctx.client) + val press = ObserverPress(reader, pull, scope) + press.print() + val done = press.state.first { it !is ObserverPress.State.Printing && it !is ObserverPress.State.Idle } + + return when (done) { + is ObserverPress.State.NoLens -> { + Output.error("no_lens", done.reason.name.lowercase()) + } + + is ObserverPress.State.Failed -> { + Output.error("failed", done.message) + } + + else -> { + val edition = press.edition.value ?: return Output.error("failed", "no edition") + Output.emit(toJson(edition)) { render(edition) } + 0 + } + } + } finally { + scope.cancel() + } + } + } + + private fun toJson(edition: ObserverEdition): Map = + mapOf( + "reader" to edition.reader, + "code" to edition.code, + "since" to edition.since, + "until" to edition.until, + "printed" to edition.stats.printed, + "authors" to edition.stats.authors, + "day_notes" to edition.stats.dayNotes, + "signals" to edition.stats.signals, + "lead" to edition.lead?.let(::story), + "top_stories" to edition.topStories.map(::story), + "sections" to edition.sections.map { mapOf("kind" to it.kind.name.lowercase(), "stories" to it.stories.map(::story)) }, + "trending" to edition.trending.map { mapOf("hashtag" to it.hashtag, "authors" to it.authors) }, + ) + + private fun story(s: ObserverStory): Map = + mapOf( + "id" to s.event.id, + "kind" to s.event.kind, + "byline" to s.byline, + "headline" to s.headline, + "body" to s.body, + "image" to s.imageUrl, + "reactions" to s.reactions, + "reposts" to s.reposts, + "replies" to s.replies, + "details" to s.details.map { it.toString() }, + ) + + private fun render(edition: ObserverEdition): String = + buildString { + appendLine("THE NOSTR OBSERVER · ${edition.code}") + appendLine("Ranked as ${edition.readerName ?: edition.reader} · ${edition.stats.printed} stories from ${edition.stats.authors} people" + (edition.stats.dayNotes?.let { " · $it notes above the trust floor" } ?: "") + " · ${edition.stats.signals} signals") + edition.trending.takeIf { it.isNotEmpty() }?.let { appendLine("Trending: " + it.joinToString(" ") { t -> "#${t.hashtag}(${t.authors})" }) } + appendLine() + edition.lead?.let { + appendLine("== LEAD ==") + line(this, it, withBody = true) + } + if (edition.topStories.isNotEmpty()) appendLine("== TOP STORIES ==") + edition.topStories.forEach { line(this, it, withBody = true) } + edition.sections.forEach { section -> + appendLine("== ${section.kind.name} (${section.stories.size}) ==") + section.stories.forEach { line(this, it, withBody = false) } + } + } + + private fun line( + sb: StringBuilder, + s: ObserverStory, + withBody: Boolean, + ) { + sb.append("• ").append(s.headline).append(" — ").append(s.byline) + if (s.score > 0) sb.append(" [♥").append(s.reactions).append(" ↻").append(s.reposts).append(" ↩").append(s.replies).append("]") + sb.appendLine() + if (withBody && s.body.isNotBlank()) sb.append(" ").appendLine(s.body) + s.imageUrl?.let { sb.append(" image: ").appendLine(it) } + s.details.forEach { sb.append(" ").appendLine(it.toString()) } + } +} diff --git a/commons/ARCHITECTURE.md b/commons/ARCHITECTURE.md index 1606e47300..9f42d495e0 100644 --- a/commons/ARCHITECTURE.md +++ b/commons/ARCHITECTURE.md @@ -97,6 +97,7 @@ in `commonsUI`, under the same package. | `sno` | mixed | DECK-0003 object rendering math — rasterizer, lighting, face winding, default avatar — here; the Compose viewer/thumbnail and the Coil fetcher in `commonsUI` under `sno` and `sno/ui`. | | `nip53LiveActivities` | mixed | Live-activity zapper aggregation (logic) + the stream card in `nip53LiveActivities/ui`. | | `search` | no | Event search filtering/ranking, kind registry. | +| `observer` | mixed | The Nostr Observer, native: `ObserverPull` reads the reader's day off the search relay through their `observer:` web-of-trust lens (readiness probe, one REQ per desk, the lens's reactions/reposts/replies), `ObserverEditor` lays out an `ObserverEdition` on the device with no model, and `ObserverPress` runs it on the account scope. The newspaper screen and the progress banner are in `commonsUI` `observer/ui`; `amy observer` prints the same edition. | | `preview` | no | OpenGraph / meta-tag link-preview parsing. | | `emojicoder` | no | Variation-selector emoji encode/decode. | | `richtext` | no | URL/media/pattern parsing for rich text. | diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt index 557380d38c..ab18942933 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt @@ -49,6 +49,8 @@ import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces import com.vitorpamplona.amethyst.commons.model.buzz.ChannelInvitesState import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.observer.ObserverPress +import com.vitorpamplona.amethyst.commons.observer.ObserverPull import com.vitorpamplona.amethyst.commons.model.cache.filter import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel @@ -909,6 +911,20 @@ class Account( val trustProviderListDecryptionCache = TrustProviderListDecryptionCache(signer) val trustProviderList = TrustProviderListState(signer, cache, trustProviderListDecryptionCache, scope, settings) + /** + * The Nostr Observer's press: prints today's front page from this account's web-of-trust + * lens on the search relay, laid out on the device. Lazy — nothing runs until the reader asks + * for a paper. Printed events go into the cache so tapping a story opens it immediately. + */ + val observerPress: ObserverPress by lazy { + ObserverPress( + reader = signer.pubKey, + pull = ObserverPull(client), + scope = scope, + onEvents = { events -> events.forEach { cache.justConsume(it, null, false) } }, + ) + } + val followSetDecryptionCache = FollowSetDecryptionCache(signer) val blockPeopleList = BlockPeopleListState(signer, cache, followSetDecryptionCache, scope) val followSets = FollowSetsState(signer, cache, followSetDecryptionCache, scope) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/NavBarItem.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/NavBarItem.kt index e2c745f525..bc7add142f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/NavBarItem.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/NavBarItem.kt @@ -55,6 +55,7 @@ enum class NavBarItem { SOFTWARE_APPS, NAPPLETS, NSITES, + OBSERVER, BROWSER, FAVORITE_APPS, CALENDARS, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt index 35fdd3f429..bd05db3889 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt @@ -125,6 +125,9 @@ sealed class Route { @Serializable object Nsites : Route() + /** The Nostr Observer: today's front page, laid out from the reader's web-of-trust lens. */ + @Serializable object Observer : Route() + @Serializable object Browser : Route() @Serializable object FavoriteApps : Route() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ObserverDesk.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ObserverDesk.kt new file mode 100644 index 0000000000..3f5845446b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ObserverDesk.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.observer + +import com.vitorpamplona.quartz.nip01Core.core.Event + +/** + * The desks a front page is made of — ported from the Nostr Observer's generator + * (`NosFabrica/the-nostr-observer`, `nostr/Pull.kt`). + * + * Not "every kind the relay holds": the kinds that turned out to carry a story. + * Each desk is asked on its own subscription so its answer arrives already + * attributed; two desks sharing one REQ would have to be recovered by kind, and + * a desk spanning several kinds (video) would collide with any other that + * shares one. + * + * [limit] is the lens's cutoff, not pagination: `observer:` ranks the whole + * 24-hour window and `limit` is what turns that into a top-N. + * + * [perAuthor] is the prune the Observer learned from real windows — one bot + * filing its archive can own a desk without it. + */ +enum class ObserverDesk( + val kinds: List, + val limit: Int, + val perAuthor: Int, +) { + NOTES(listOf(1), 400, 20), + PICTURES(listOf(20), 60, 8), + + /** + * Streams on the air right now, and only those. A 30311 is replaceable and + * carries a `status`, so a finished stream sits in the window looking + * exactly like a running one — measured by the Observer, 7 of 18 had ended. + */ + LIVE(listOf(30311), 30, 8) { + override fun keeps(event: Event) = event.tagValue("status").equals("live", ignoreCase = true) + }, + POLLS(listOf(1068), 20, 8), + + /** + * Current and deprecated NIP-71 kinds together: the Observer measured zero + * kind 21/22 and dozens of 34235/34236 in the same window, so the + * deprecated kinds are where the video actually is. + */ + VIDEOS(listOf(21, 34235), 40, 8), + SHORTS(listOf(22, 34236), 40, 5), + HIGHLIGHTS(listOf(9802), 50, 8), + ARTICLES(listOf(30023), 100, 4), + CLASSIFIEDS(listOf(30402), 30, 8), + WIKI(listOf(30818), 30, 8), + + /** 31922 is the all-day half of NIP-52, 31923 the timed half. Reading only one drops the other silently. */ + CALENDAR(listOf(31922, 31923), 100, 6), + APPS(listOf(32267), 30, 8), + GIT(listOf(30617), 30, 8), + ; + + /** A filter cannot say "and the `status` tag is live", so the relay returns both and this drops the rest. */ + open fun keeps(event: Event): Boolean = true +} + +internal fun Event.tagValue(name: String): String? = tags.firstOrNull { it.size > 1 && it[0] == name }?.get(1) + +internal fun Event.tagValues(name: String): List> = tags.filter { it.isNotEmpty() && it[0] == name } + +/** Hashtags, lowercased and deduplicated — `t` tags are inconsistently cased in the wild. */ +internal fun Event.hashtagList(): List = + tags + .mapNotNull { if (it.size > 1 && it[0] == "t") it[1].trim().lowercase().removePrefix("#") else null } + .filter { it.isNotBlank() } + .distinct() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ObserverEdition.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ObserverEdition.kt new file mode 100644 index 0000000000..db227ba427 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ObserverEdition.kt @@ -0,0 +1,191 @@ +/* + * 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.observer + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * One day's paper, laid out on the device from what the reader's lens surfaced. + * + * Everything a reader sees is plain text taken from the source events — the + * headline is the author's own first sentence, never a summary somebody wrote + * for them — so there is nothing here to sanitize and nothing that can be + * invented. Section names are fixed ids the UI localizes. + */ +@Immutable +data class ObserverEdition( + val reader: HexKey, + val readerName: String?, + val since: Long, + val until: Long, + /** A short print-run code: who it was read for, the window, and every event id that came back. */ + val code: String, + val stats: ObserverStats, + val lead: ObserverStory?, + val topStories: List, + val sections: List, + val trending: List, +) { + val isEmpty: Boolean get() = lead == null && topStories.isEmpty() && sections.isEmpty() +} + +@Immutable +data class ObserverStats( + /** Stories printed on the page, after pruning. */ + val printed: Int, + /** Distinct authors among them. */ + val authors: Int, + /** Notes the lens surfaced above the trust floor in the window, or null when the relay would not count. */ + val dayNotes: Long?, + /** Reactions, reposts and replies from the reader's web of trust used to rank the page. */ + val signals: Int, +) + +@Immutable +data class ObserverTrend( + val hashtag: String, + val authors: Int, +) + +enum class ObserverSectionKind { + PHOTOS, + LONG_READS, + LIVE, + VIDEO, + HIGHLIGHTS, + POLLS, + CALENDAR, + CLASSIFIEDS, + APPS, + CODE, + WIKI, + WIRE, +} + +@Immutable +data class ObserverSection( + val kind: ObserverSectionKind, + val stories: List, +) + +@Immutable +data class ObserverStory( + /** The source event. Tapping a story opens it, so the paper is a front page onto the real thread. */ + val event: Event, + val desk: ObserverDesk, + val byline: String, + val headline: String, + val body: String, + val imageUrl: String?, + val reactions: Int, + val reposts: Int, + val replies: Int, + val details: List, +) { + val author: HexKey get() = event.pubKey + val createdAt: Long get() = event.createdAt + val score: Int get() = ObserverEngagement.score(reactions, reposts, replies) +} + +/** Typed facts a story carries beyond its text. The UI owns their wording. */ +@Immutable +sealed interface ObserverDetail { + data class Price( + val amount: String, + val currency: String?, + val frequency: String?, + ) : ObserverDetail + + /** [epochSeconds] for a timed event, [date] (`YYYY-MM-DD`) for an all-day one. */ + data class Starts( + val epochSeconds: Long?, + val date: String?, + val zoneId: String?, + ) : ObserverDetail + + data class Location( + val text: String, + ) : ObserverDetail + + data class Duration( + val seconds: Int, + ) : ObserverDetail + + data class PollOptions( + val options: List, + ) : ObserverDetail + + /** A highlight is somebody ELSE's sentence; this names who wrote it, when the highlight says. */ + data class QuotedAuthor( + val name: String, + ) : ObserverDetail + + data class Source( + val text: String, + ) : ObserverDetail + + data class Watching( + val count: Int, + ) : ObserverDetail + + data class ItemStatus( + val text: String, + ) : ObserverDetail +} + +/** What the reader's web of trust did with an event in the window. */ +@Immutable +data class ObserverEngagement( + val reactions: Int = 0, + val reposts: Int = 0, + val replies: Int = 0, +) { + val score: Int get() = score(reactions, reposts, replies) + + companion object { + val NONE = ObserverEngagement() + + /** A reply costs more than a like, and a repost puts your name behind it. */ + fun score( + reactions: Int, + reposts: Int, + replies: Int, + ) = reactions + 2 * reposts + 3 * replies + } +} + +/** Everything one edition is laid out from. */ +class ObserverCorpus( + val reader: HexKey, + val since: Long, + val until: Long, + /** Chosen by the lens, one list per desk, reader's own events already removed. */ + val ranked: Map>, + /** Keyed by event id, and by `kind:pubkey:d` address for addressable events. */ + val engagement: Map, + /** Display names from kind 0, by pubkey. */ + val names: Map, + val dayNotes: Long?, +) { + fun all(): List = ranked.values.flatten() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ObserverEditor.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ObserverEditor.kt new file mode 100644 index 0000000000..4a12915ffc --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ObserverEditor.kt @@ -0,0 +1,459 @@ +/* + * 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.observer + +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.IPubKeyEntity +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip92IMeta.imetas +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** + * Lays out a front page from a [ObserverCorpus], on the device, with no model. + * + * The web Observer hands the corpus to a language model that writes the page. + * This is the native counterpart: the same lens picks the material, and the + * editorial calls are made by rules that can be read and tested — + * + * - **Order is what the reader's web of trust did.** The relay selects by trust + * score but delivers by `created_at`, and the score is not recoverable from + * the response (measured by the Observer). So ranking on the page comes from + * the reactions, reposts and replies the same lens surfaced for each event — + * [ObserverEngagement.score] — and falls back to the relay's order. + * - **Headlines are the author's own words**: a title tag when the kind has + * one, else the first sentence of the post. Nothing is paraphrased, so + * nothing can be misattributed or invented. + * - **Sections are earned, not fixed.** A desk with nothing on it today is not + * printed. + * - **The Observer's prunes**: a per-author cap per desk and a duplicate + * collapse, so one account filing its archive cannot own a section. + * + * Pure: no clock, no network. [ObserverEditorTest] holds it to that. + */ +object ObserverEditor { + /** How many stories run under the lead, above the fold. */ + const val TOP_STORIES = 4 + + /** A post shorter than this is a greeting, not a story — it can run on the wire but not above the fold. */ + const val MIN_STORY_CHARS = 40 + + const val HEADLINE_CHARS = 120 + const val LEAD_BODY_CHARS = 900 + const val STORY_BODY_CHARS = 420 + const val BRIEF_BODY_CHARS = 240 + + fun edit(corpus: ObserverCorpus): ObserverEdition { + val names = corpus.names + val pruned = ObserverDesk.entries.associateWith { desk -> prune(desk, corpus.ranked[desk].orEmpty()) } + + fun storyOf( + event: Event, + desk: ObserverDesk, + bodyChars: Int, + ): ObserverStory = story(event, desk, engagementOf(corpus, event), names, bodyChars) + + // Above the fold: notes that are posts (not replies) with something to say, + // and long-form. Ranked by what the reader's web of trust did with them. + val candidates = + (pruned.getValue(ObserverDesk.NOTES).filter { !isReply(it) } + pruned.getValue(ObserverDesk.ARTICLES)) + .map { it to engagementOf(corpus, it).score } + .filter { (event, _) -> event.kind != 1 || cleanText(event.content, names).length >= MIN_STORY_CHARS } + .sortedWith(compareByDescending> { it.second }.thenByDescending { it.first.createdAt }) + .map { it.first } + + val front = mutableListOf() + val frontAuthors = mutableSetOf() + for (event in candidates) { + if (front.size > TOP_STORIES) break + // One story per author above the fold: a front page is a survey of the day. + if (!frontAuthors.add(event.pubKey)) continue + front.add(event) + } + val used = front.mapTo(mutableSetOf()) { it.id } + + val lead = front.firstOrNull()?.let { storyOf(it, deskOf(it), LEAD_BODY_CHARS) } + val top = front.drop(1).map { storyOf(it, deskOf(it), STORY_BODY_CHARS) } + + fun section( + kind: ObserverSectionKind, + vararg desks: ObserverDesk, + bodyChars: Int = BRIEF_BODY_CHARS, + sort: (List) -> List = { ranked(corpus, it) }, + keep: (Event) -> Boolean = { true }, + ): ObserverSection? { + val events = sort(desks.flatMap { pruned.getValue(it) }.filter { it.id !in used && keep(it) }) + if (events.isEmpty()) return null + return ObserverSection(kind, events.map { storyOf(it, deskOf(it), bodyChars) }) + } + + val sections = + listOfNotNull( + section(ObserverSectionKind.PHOTOS, ObserverDesk.PICTURES) { imageOf(it) != null }, + section(ObserverSectionKind.LONG_READS, ObserverDesk.ARTICLES, bodyChars = STORY_BODY_CHARS), + section(ObserverSectionKind.LIVE, ObserverDesk.LIVE), + section(ObserverSectionKind.VIDEO, ObserverDesk.VIDEOS, ObserverDesk.SHORTS), + section(ObserverSectionKind.HIGHLIGHTS, ObserverDesk.HIGHLIGHTS, bodyChars = STORY_BODY_CHARS), + section(ObserverSectionKind.POLLS, ObserverDesk.POLLS), + // A listing is news for when it HAPPENS, so the calendar runs in date order. + section(ObserverSectionKind.CALENDAR, ObserverDesk.CALENDAR, sort = { byStart(it) }), + // A page that advertises a sold item sends readers after something that is gone. + section(ObserverSectionKind.CLASSIFIEDS, ObserverDesk.CLASSIFIEDS) { !it.tagValue("status").equals("sold", ignoreCase = true) }, + section(ObserverSectionKind.APPS, ObserverDesk.APPS), + section(ObserverSectionKind.CODE, ObserverDesk.GIT), + section(ObserverSectionKind.WIKI, ObserverDesk.WIKI), + // Everything else the lens surfaced, so nothing it chose goes unprinted. + section(ObserverSectionKind.WIRE, ObserverDesk.NOTES) { !isReply(it) }, + ) + + val printed = listOfNotNull(lead) + top + sections.flatMap { it.stories } + + return ObserverEdition( + reader = corpus.reader, + readerName = names[corpus.reader], + since = corpus.since, + until = corpus.until, + code = code(corpus), + stats = + ObserverStats( + printed = printed.size, + authors = printed.mapTo(mutableSetOf()) { it.author }.size, + dayNotes = corpus.dayNotes, + signals = corpus.engagement.values.sumOf { it.reactions + it.reposts + it.replies }, + ), + lead = lead, + topStories = top, + sections = sections, + trending = trending(pruned.values.flatten()), + ) + } + + private fun deskOf(event: Event): ObserverDesk = ObserverDesk.entries.firstOrNull { event.kind in it.kinds } ?: ObserverDesk.NOTES + + private fun ranked( + corpus: ObserverCorpus, + events: List, + ): List = + events + .withIndex() + .sortedWith(compareByDescending> { engagementOf(corpus, it.value).score }.thenBy { it.index }) + .map { it.value } + + private fun byStart(events: List): List = + events.sortedWith( + compareBy { startKey(it) == null }.thenBy { startKey(it) }.thenByDescending { it.createdAt }, + ) + + /** Sortable start: unix seconds for 31923, the ISO date for 31922 (which sorts as text). */ + private fun startKey(event: Event): String? { + val raw = event.tagValue("start") ?: return null + raw.toLongOrNull()?.let { return epochDay(it) } + return raw.takeIf { DATE.matches(it) } + } + + /** `YYYY-MM-DD` for a unix time, without a clock or a timezone library: civil-from-days. */ + internal fun epochDay(seconds: Long): String { + val days = seconds.floorDiv(86_400L) + val z = days + 719_468 + val era = z.floorDiv(146_097L) + val doe = z - era * 146_097 + val yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365 + val doy = doe - (365 * yoe + yoe / 4 - yoe / 100) + val mp = (5 * doy + 2) / 153 + val d = doy - (153 * mp + 2) / 5 + 1 + val m = if (mp < 10) mp + 3 else mp - 9 + val y = yoe + era * 400 + if (m <= 2) 1 else 0 + return y.toString().padStart(4, '0') + "-" + m.toString().padStart(2, '0') + "-" + d.toString().padStart(2, '0') + } + + internal fun engagementOf( + corpus: ObserverCorpus, + event: Event, + ): ObserverEngagement { + val byId = corpus.engagement[event.id] + val byAddress = (event as? AddressableEvent)?.let { corpus.engagement[it.addressTag()] } + if (byId == null) return byAddress ?: ObserverEngagement.NONE + if (byAddress == null) return byId + return ObserverEngagement( + byId.reactions + byAddress.reactions, + byId.reposts + byAddress.reposts, + byId.replies + byAddress.replies, + ) + } + + /** + * NIP-10: a kind 1 with any `e` tag that is not marked `mention` is a reply. + * Replies are half a conversation; out of their thread they are not stories. + */ + internal fun isReply(event: Event): Boolean = + event.kind == 1 && + event.tags.any { it.size > 1 && it[0] == "e" && it.getOrNull(3) != "mention" } + + /** + * The Observer's two prunes. A per-author cap, because one bot filing its + * archive can own a whole desk; and a duplicate collapse, because the same + * post arrives repeatedly when a client retries against several hosts. + */ + internal fun prune( + desk: ObserverDesk, + events: List, + ): List { + val counts = mutableMapOf() + val seen = mutableSetOf() + val ids = mutableSetOf() + val out = mutableListOf() + for (event in events) { + if (!ids.add(event.id)) continue + val n = counts[event.pubKey] ?: 0 + if (n >= desk.perAuthor) continue + val key = fingerprint(event) + if (key.isNotEmpty() && !seen.add(key)) continue + counts[event.pubKey] = n + 1 + out.add(event) + } + return out + } + + /** Same author, same words — whitespace, case and links normalised away. */ + private fun fingerprint(event: Event): String { + val text = + event.content + .replace(URL, "") + .replace(WHITESPACE, " ") + .trim() + .lowercase() + return if (text.length < 12) "" else event.pubKey + "|" + text.take(200) + } + + internal fun story( + event: Event, + desk: ObserverDesk, + engagement: ObserverEngagement, + names: Map, + bodyChars: Int, + ): ObserverStory { + val text = cleanText(event.content, names) + val title = (event.tagValue("title") ?: event.tagValue("name"))?.let { cleanText(it, names) }?.takeIf { it.isNotBlank() } + val summary = + (event.tagValue("summary") ?: event.tagValue("description"))?.let { cleanText(it, names) }?.takeIf { it.isNotBlank() } + + val headline: String + val body: String + when { + title != null -> { + headline = clip(title, HEADLINE_CHARS) + body = clip(summary ?: text, bodyChars) + } + + desk == ObserverDesk.HIGHLIGHTS -> { + // The excerpt IS the story; it runs as a pull quote. + headline = clip(text, bodyChars) + body = "" + } + + else -> { + val (first, rest) = firstSentence(text) + headline = clip(first, HEADLINE_CHARS) + body = clip(rest, bodyChars) + } + } + + return ObserverStory( + event = event, + desk = desk, + byline = names[event.pubKey] ?: shortNpub(event.pubKey), + headline = headline, + body = body, + imageUrl = imageOf(event), + reactions = engagement.reactions, + reposts = engagement.reposts, + replies = engagement.replies, + details = details(event, desk, names), + ) + } + + private fun details( + event: Event, + desk: ObserverDesk, + names: Map, + ): List = + buildList { + when (desk) { + ObserverDesk.HIGHLIGHTS -> { + // Named when the highlight names them; the highlighter is NOT the author. + event.tagValue("p")?.takeIf { it.length == 64 }?.let { + add(ObserverDetail.QuotedAuthor(names[it] ?: shortNpub(it))) + } + event.tagValue("r")?.takeIf { it.isNotBlank() }?.let { add(ObserverDetail.Source(it.take(200))) } + } + + ObserverDesk.LIVE -> { + event.tagValue("starts")?.toLongOrNull()?.let { add(ObserverDetail.Starts(it, null, null)) } + event.tagValue("current_participants")?.toIntOrNull()?.takeIf { it > 0 }?.let { add(ObserverDetail.Watching(it)) } + } + + ObserverDesk.POLLS -> { + // Two shapes in the wild, `["option", "0", "A"]` and `["option", "Bu2a9f", "Yes"]`: the label is always the third field. + val options = event.tagValues("option").mapNotNull { it.getOrNull(2)?.trim()?.takeIf(String::isNotBlank)?.take(80) } + if (options.isNotEmpty()) add(ObserverDetail.PollOptions(options.take(8))) + } + + ObserverDesk.CALENDAR -> { + val raw = event.tagValue("start") + val zone = event.tagValue("start_tzid") + when { + raw?.toLongOrNull() != null -> add(ObserverDetail.Starts(raw.toLong(), null, zone)) + raw != null && DATE.matches(raw) -> add(ObserverDetail.Starts(null, raw, null)) + } + } + + ObserverDesk.CLASSIFIEDS -> { + event.tagValues("price").firstOrNull()?.let { price -> + val amount = price.getOrNull(1)?.trim()?.takeIf { it.isNotBlank() } ?: return@let + add( + ObserverDetail.Price( + amount.take(20), + price.getOrNull(2)?.trim()?.takeIf { it.isNotBlank() }?.take(12), + price.getOrNull(3)?.trim()?.takeIf { it.isNotBlank() }?.take(12), + ), + ) + } + event.tagValue("status")?.takeIf { it.isNotBlank() }?.let { add(ObserverDetail.ItemStatus(it.take(20))) } + } + + else -> {} + } + event.tagValue("location")?.takeIf { it.isNotBlank() }?.let { add(ObserverDetail.Location(it.take(120))) } + event.tagValue("duration")?.toIntOrNull()?.takeIf { it > 0 }?.let { add(ObserverDetail.Duration(it)) } + } + + /** + * The first image the event itself declares, hotlinked, never fetched or + * re-hosted — the Observer's rule. Video is filtered on declared MIME, not + * on the URL suffix, because `imeta` carries video as often as stills. + */ + internal fun imageOf(event: Event): String? { + for (meta in event.imetas()) { + val mime = meta.properties["m"]?.firstOrNull() + if (mime != null && mime.startsWith("image/") && isHttps(meta.url)) return meta.url + // A video's poster frame. + meta.properties["image"]?.firstOrNull { isHttps(it) }?.let { return it } + if (mime == null && IMAGE_URL.matches(meta.url) && isHttps(meta.url)) return meta.url + } + for (name in IMAGE_TAGS) { + event.tagValue(name)?.trim()?.takeIf { isHttps(it) }?.let { return it } + } + return URL.findAll(event.content).map { it.value.trimEnd('.', ',', ')') }.firstOrNull { IMAGE_URL.matches(it) && isHttps(it) } + } + + private fun isHttps(url: String) = url.startsWith("https://", ignoreCase = true) + + /** + * Prose a reader can read: `nostr:npub…` mentions become `@name`, event + * references and links are taken out (the story itself is the link), and + * whitespace is collapsed. + */ + internal fun cleanText( + content: String, + names: Map, + ): String { + val mentionsResolved = + Nip19Parser.nip19regex.replace(content) { match -> + val entity = Nip19Parser.uriToRoute(match.value)?.entity + if (entity is IPubKeyEntity) { + "@" + (names[entity.hex] ?: shortNpub(entity.hex)) + (match.groups[7]?.value ?: "") + } else { + match.groups[7]?.value ?: "" + } + } + return mentionsResolved + .replace(URL, "") + .replace(WHITESPACE, " ") + .trim() + } + + /** The first sentence (or line) as the headline, the rest as the body. */ + internal fun firstSentence(text: String): Pair { + if (text.isEmpty()) return "" to "" + val end = SENTENCE_END.find(text)?.range?.last + if (end != null && end + 1 in 20..HEADLINE_CHARS) { + return text.substring(0, end + 1).trim() to text.substring(end + 1).trim() + } + if (text.length <= HEADLINE_CHARS) return text to "" + // No usable sentence break: cut at a word boundary and run the rest as the body. + val cut = text.lastIndexOf(' ', HEADLINE_CHARS).takeIf { it > 40 } ?: HEADLINE_CHARS + return text.substring(0, cut).trim() + "…" to "…" + text.substring(cut).trim() + } + + internal fun clip( + text: String, + limit: Int, + ): String { + if (text.length <= limit) return text + val cut = text.lastIndexOf(' ', limit).takeIf { it > limit / 2 } ?: limit + return text.substring(0, cut).trimEnd(',', ';', ':', ' ') + "…" + } + + /** Tags at least two different people used today, most-used first. */ + internal fun trending(events: List): List { + val authorsByTag = mutableMapOf>() + events.forEach { event -> event.hashtagList().forEach { authorsByTag.getOrPut(it) { mutableSetOf() }.add(event.pubKey) } } + return authorsByTag + .filterValues { it.size >= 2 } + .map { ObserverTrend(it.key, it.value.size) } + .sortedWith(compareByDescending { it.authors }.thenBy { it.hashtag }) + .take(10) + } + + /** + * Who it was read for, the window, and the id of every event that came + * back. Sorted, because desks are pulled in parallel and their finishing + * order is a race. It cannot be a hash of the page — see the Observer's + * `Corpus.code()` for why that is a fixed point. + */ + internal fun code(corpus: ObserverCorpus): String { + val sb = StringBuilder() + sb.append(corpus.reader).append(corpus.since).append(corpus.until) + corpus + .all() + .map { it.id } + .sorted() + .forEach { sb.append(it) } + return sha256(sb.toString().encodeToByteArray()).toHexKey().take(6).uppercase() + } + + /** No hex in front of a person: a name if we have one, a short npub if we do not. */ + internal fun shortNpub(hex: HexKey): String { + val full = hex.hexToByteArrayOrNull()?.toNpub() ?: return "someone" + return if (full.length <= 20) full else full.take(10) + "…" + full.takeLast(5) + } + + private val IMAGE_TAGS = listOf("image", "thumb", "picture", "icon") + private val URL = Regex("""https?://\S+""", RegexOption.IGNORE_CASE) + private val IMAGE_URL = Regex("""https?://\S+\.(jpe?g|png|gif|webp|avif)(\?\S*)?""", RegexOption.IGNORE_CASE) + private val WHITESPACE = Regex("""\s+""") + private val SENTENCE_END = Regex("""[.!?](?=\s|$)""") + private val DATE = Regex("""\d{4}-\d{2}-\d{2}""") +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ObserverPress.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ObserverPress.kt new file mode 100644 index 0000000000..a815ea5731 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ObserverPress.kt @@ -0,0 +1,223 @@ +/* + * 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.observer + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.IPubKeyEntity +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Prints one reader's paper in the background and holds the result. + * + * Printing takes a while — fourteen ranked desks, the web of trust's reactions + * to everything they returned, and a byline for every author — so it runs on + * the account's scope, not a screen's: the reader can leave and the banner + * reports progress until the paper is ready to open. + * + * One press per account. [state] drives the banner and the screen; [edition] + * keeps the last good paper across a reprint or a failure, so a failed reprint + * never takes away the paper the reader already has. + */ +class ObserverPress( + private val reader: HexKey, + private val pull: ObserverPull, + private val scope: CoroutineScope, + /** Hands every event the paper prints to the app's cache, so tapping a story opens it immediately. */ + private val onEvents: (List) -> Unit = {}, + private val now: () -> Long = { TimeUtils.now() }, +) { + enum class Step { + CHECKING_LENS, + READING_DESKS, + READING_SIGNALS, + READING_BYLINES, + LAYING_OUT, + } + + @Immutable + sealed interface State { + data object Idle : State + + data class Printing( + val step: Step, + val desksDone: Int = 0, + val desksTotal: Int = ObserverDesk.entries.size, + val startedAt: Long, + ) : State { + /** A predictable bar: the desks are most of the wait, the rest is a few quick reads. */ + val fraction: Float + get() = + when (step) { + Step.CHECKING_LENS -> 0.05f + Step.READING_DESKS -> 0.05f + 0.6f * desksDone / desksTotal.coerceAtLeast(1) + Step.READING_SIGNALS -> 0.7f + Step.READING_BYLINES -> 0.85f + Step.LAYING_OUT -> 0.95f + } + } + + /** The lens cannot rank yet. No paper is printed some other way: see [ObserverReadiness]. */ + data class NoLens( + val reason: ObserverReadiness.State, + ) : State + + data class Ready( + val printedAt: Long, + /** False until the reader opens the paper; the banner offers it until then. */ + val seen: Boolean, + ) : State + + data class Failed( + val message: String?, + ) : State + } + + private val _state = MutableStateFlow(State.Idle) + val state: StateFlow = _state.asStateFlow() + + private val _edition = MutableStateFlow(null) + val edition: StateFlow = _edition.asStateFlow() + + /** + * True while the paper screen is open. The screen shows progress inline, so + * the floating banner stays out of the way, and a paper finished in front + * of the reader counts as seen at once. + */ + val readerIsLooking = MutableStateFlow(false) + + private var job: Job? = null + + val isPrinting: Boolean get() = _state.value is State.Printing + + /** Starts a fresh edition for the last 24 hours. A press already running is left alone. */ + fun print() { + if (job?.isActive == true) return + job = + scope.launch(Dispatchers.IO) { + val startedAt = now() + try { + _state.value = State.Printing(Step.CHECKING_LENS, startedAt = startedAt) + val until = now() + val since = until - WINDOW_SECONDS + + val readiness = ObserverReadiness.assess(pull.readiness(reader, since)) + if (readiness != ObserverReadiness.State.READY) { + _state.value = State.NoLens(readiness) + return@launch + } + + _state.value = State.Printing(Step.READING_DESKS, startedAt = startedAt) + val corpus = + pull.corpus(reader, since, until) { done, total -> + _state.update { current -> + if (current is State.Printing) current.copy(desksDone = done, desksTotal = total) else current + } + } + + _state.value = State.Printing(Step.READING_SIGNALS, startedAt = startedAt) + val stories = corpus.all() + val engagement = pull.engagement(reader, since, until, stories) + + _state.value = State.Printing(Step.READING_BYLINES, startedAt = startedAt) + val names = pull.names(bylinesFor(reader, stories)) + + _state.value = State.Printing(Step.LAYING_OUT, startedAt = startedAt) + val edition = + ObserverEditor.edit( + ObserverCorpus(corpus.reader, since, until, corpus.ranked, engagement, names, corpus.dayNotes), + ) + + onEvents(stories) + _edition.value = edition + _state.value = State.Ready(printedAt = now(), seen = readerIsLooking.value) + } catch (e: CancellationException) { + _state.value = State.Idle + throw e + } catch (e: Exception) { + Log.w("ObserverPress", "Could not print the paper", e) + _state.value = State.Failed(e.message) + } + } + } + + fun cancel() { + job?.cancel() + job = null + _state.value = State.Idle + } + + /** The reader opened the paper: stop offering it in the banner. */ + fun markSeen() { + _state.update { if (it is State.Ready && !it.seen) it.copy(seen = true) else it } + } + + /** Clears a finished, failed or refused run from the banner. The last edition stays. */ + fun dismiss() { + _state.update { + when (it) { + is State.Ready -> it.copy(seen = true) + is State.Failed, is State.NoLens -> State.Idle + else -> it + } + } + } + + companion object { + /** Fixed, not "since last visit": the Observer's settled decision. */ + const val WINDOW_SECONDS = 24 * 60 * 60L + + /** + * Everyone the page will name: every author, everyone a highlight + * quotes (they wrote the sentence but signed nothing in the window), and + * the reader, whose posts are excluded from every desk and so would + * otherwise never have their name looked up. + */ + internal fun bylinesFor( + reader: HexKey, + stories: List, + ): Set = + buildSet { + add(reader) + stories.forEach { event -> + add(event.pubKey) + if (event.kind == 9802) event.tagValue("p")?.takeIf { it.length == 64 }?.let(::add) + // `nostr:npub…` mentions, so the page prints `@name` rather than a key. + Nip19Parser.nip19regex.findAll(event.content).forEach { match -> + (Nip19Parser.uriToRoute(match.value)?.entity as? IPubKeyEntity)?.let { add(it.hex) } + } + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ObserverPull.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ObserverPull.kt new file mode 100644 index 0000000000..19f2f12b63 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ObserverPull.kt @@ -0,0 +1,321 @@ +/* + * 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.observer + +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.count +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.serviceProviders +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ProviderTypes +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.updateAndGet +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Reads one reader's day off the search relay, through their own lens. + * + * A port of the Observer generator's `Pull` + `ReadinessProbe`, on Amethyst's + * own [INostrClient] and quartz's `fetchAll`/`count` accessories, so the phone + * talks to the relay directly — no Observer server and no model in between. + * + * Two of the relay's rules are load-bearing and were measured by the Observer, + * not guessed: + * - Every query must name an `observer:` or say `include:spam`: the relay + * CLOSES tokenless queries ("auth-required: … no house observer to lend + * you"), which reads as an empty answer. + * - A NIP-50 search with no `since` times out on this store. Every ranked + * read here carries the 24-hour window. + */ +class ObserverPull( + private val client: INostrClient, + private val searchRelay: NormalizedRelayUrl = DEFAULT_SEARCH_RELAY, + private val trustFloor: Int = DEFAULT_TRUST_FLOOR, +) { + /** Every desk's progress, reported as each one lands. */ + fun interface Progress { + fun onDesk( + done: Int, + total: Int, + ) + } + + suspend fun readiness( + reader: HexKey, + since: Long, + ): ObserverReadiness.Facts = + coroutineScope { + val scoreList = + async { + fetch(Filter(kinds = listOf(TrustProviderListEvent.KIND), authors = listOf(reader), limit = 1, search = INCLUDE_SPAM)) + .maxByOrNull { it.createdAt } + } + val lensed = async { fetch(probe(reader, since)).size } + val anonymous = async { fetch(probe(null, since)).size } + + val list = scoreList.await() + ObserverReadiness.Facts( + scoreListSeen = list != null, + // quartz's tag parser requires all three fields, so a hintless entry + // is rejected there — the same parser the relay resolves it with. + rankService = list?.tags?.serviceProviders()?.firstOrNull { it.service == ProviderTypes.rank }?.pubkey, + probeLensed = lensed.await(), + probeAnonymous = anonymous.await(), + ) + } + + suspend fun corpus( + reader: HexKey, + since: Long, + until: Long, + progress: Progress = Progress { _, _ -> }, + ): ObserverCorpus = + coroutineScope { + val desks = ObserverDesk.entries + val done = MutableStateFlow(0) + progress.onDesk(0, desks.size) + + // ONE REQ PER DESK. Merged, two desks sharing a kind collide; asked + // separately, each answer arrives already attributed. + val asked = + desks.map { desk -> + async { + val events = + fetch(ranked(desk.kinds, since, until, desk.limit, reader), DESK_IDLE_MS) + // The reader's own posts are not the news: they rank highly through + // their own lens almost by construction and would crowd the page. + .filter { desk.keeps(it) && it.pubKey != reader } + .take(desk.limit) + progress.onDesk(done.updateAndGet { it + 1 }, desks.size) + desk to events + } + } + // One COUNT, for an honest denominator — with NO limit, or the relay counts + // to the cap and stops. Null is a supported answer and must not become a guess. + val dayNotes = + async { + withTimeoutOrNull(COUNT_DEADLINE_MS) { + runCatching { client.count(searchRelay, ranked(ObserverDesk.NOTES.kinds, since, until, null, reader))?.count?.toLong() }.getOrNull() + } + } + + val ranked = asked.awaitAll().toMap() + ObserverCorpus( + reader = reader, + since = since, + until = until, + ranked = ranked, + engagement = emptyMap(), + names = emptyMap(), + dayNotes = dayNotes.await(), + ) + } + + /** + * What the reader's web of trust did with each story today: reactions, + * reposts and replies, through the SAME lens and trust floor, so a like from + * a stranger the lens does not vouch for counts for nothing. + * + * This is the native editor's replacement for the model's judgement. The + * relay selects by trust score but delivers by `created_at`, and the score + * is not in the response — so without this the page could only be sorted + * by time. + */ + suspend fun engagement( + reader: HexKey, + since: Long, + until: Long, + events: List, + ): Map { + val ids = events.filter { it !is AddressableEvent }.map { it.id }.distinct() + val addresses = events.filterIsInstance().map { it.addressTag() }.distinct() + if (ids.isEmpty() && addresses.isEmpty()) return emptyMap() + + val lens = "observer:$reader sort:rank filter:rank:gte:$trustFloor" + val filters = + ids.chunked(ENGAGEMENT_CHUNK).map { chunk -> + Filter(kinds = REACTION_KINDS, tags = mapOf("e" to chunk), since = since, until = until, limit = ENGAGEMENT_LIMIT, search = lens) + } + + addresses.chunked(ENGAGEMENT_CHUNK).map { chunk -> + Filter(kinds = REACTION_KINDS, tags = mapOf("a" to chunk), since = since, until = until, limit = ENGAGEMENT_LIMIT, search = lens) + } + + val found = + coroutineScope { + filters.map { async { fetch(it, DESK_IDLE_MS) } }.awaitAll().flatten().distinctBy { it.id } + } + return tally(found, ids.toSet(), addresses.toSet()) + } + + /** kind 0 for everyone the page will name, batched; newest wins. */ + suspend fun names(pubkeys: Collection): Map { + if (pubkeys.isEmpty()) return emptyMap() + val found = + coroutineScope { + pubkeys + .distinct() + .chunked(PROFILE_CHUNK) + .map { chunk -> async { fetch(Filter(kinds = listOf(MetadataEvent.KIND), authors = chunk, search = INCLUDE_SPAM), PROFILE_IDLE_MS) } } + .awaitAll() + .flatten() + } + val best = mutableMapOf() + found.forEach { event -> + val seen = best[event.pubKey] + if (seen == null || seen.createdAt < event.createdAt) best[event.pubKey] = event + } + return best + .mapNotNull { (pubkey, event) -> + val meta = event as? MetadataEvent ?: MetadataEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig) + val name = + runCatching { meta.contactMetaData()?.bestName() } + .getOrNull() + ?.replace(WHITESPACE, " ") + ?.trim() + ?.take(MAX_NAME) + ?.takeIf { it.isNotBlank() } + name?.let { pubkey to it } + }.toMap() + } + + /** + * A bare `observer: sort:rank` with no search term is a valid NIP-50 + * query and returns a ranked recency feed. That is the whole product. + * `filter:rank:gte` is the trust floor, and the Observer measured that it + * is NOT redundant with `limit`: at limit 400 it replaced 49 of the 400. + */ + private fun ranked( + kinds: List, + since: Long, + until: Long, + limit: Int?, + reader: HexKey, + ) = Filter( + kinds = kinds, + since = since, + until = until, + limit = limit, + search = "observer:$reader sort:rank filter:rank:gte:$trustFloor", + ) + + /** + * The same question asked through the lens and without it. An unresolvable + * token silently becomes the anonymous ranking, so the two only differ — + * lensed empty, anonymous not — when the cards exist but are not projected. + */ + private fun probe( + reader: HexKey?, + since: Long, + ) = Filter( + kinds = listOf(1), + since = since, + limit = 12, + search = if (reader == null) "$INCLUDE_SPAM sort:rank" else "observer:$reader sort:rank", + ) + + /** A wall clock over quartz's idle clock: no read may block the press forever. */ + private suspend fun fetch( + filter: Filter, + idleMs: Long = PROBE_IDLE_MS, + ): List = + withTimeoutOrNull(idleMs * 2 + 5_000) { + runCatching { client.fetchAll(searchRelay, filter, idleMs) }.getOrDefault(emptyList()) + } ?: emptyList() + + companion object { + val DEFAULT_SEARCH_RELAY: NormalizedRelayUrl = RelayUrlNormalizer.normalize("wss://search.brainstorm.world") + + /** Opens the search relay to a query that names no observer. Only ever sent there. */ + const val INCLUDE_SPAM = "include:spam" + + /** The Observer's measured default: 35,084 → 11,838 notes in one window, the rest unvouched-for. */ + const val DEFAULT_TRUST_FLOOR = 20 + + val REACTION_KINDS = listOf(1, 6, 7, 16) + + /** ~67 bytes per id; 300 keeps one filter near 20 KB, far under the relay's 262 KB frame. */ + private const val ENGAGEMENT_CHUNK = 300 + private const val ENGAGEMENT_LIMIT = 5000 + private const val PROFILE_CHUNK = 100 + private const val MAX_NAME = 60 + + private const val PROBE_IDLE_MS = 15_000L + private const val DESK_IDLE_MS = 25_000L + private const val PROFILE_IDLE_MS = 20_000L + private const val COUNT_DEADLINE_MS = 20_000L + + private val WHITESPACE = Regex("""\s+""") + + /** + * Counts each person once per story per kind of signal: five likes from + * one account are one like. A reply is a kind 1 whose `e`/`a` points at + * the story; a quote (`q`) is not counted, it is its own post. + */ + internal fun tally( + found: List, + ids: Set, + addresses: Set, + ): Map { + val reactions = mutableMapOf>() + val reposts = mutableMapOf>() + val replies = mutableMapOf>() + + found.forEach { event -> + val bucket = + when (event.kind) { + 7 -> reactions + 6, 16 -> reposts + 1 -> replies + else -> return@forEach + } + val targets = + event.tags + .mapNotNull { tag -> + if (tag.size < 2) return@mapNotNull null + when (tag[0]) { + "e" -> tag[1].takeIf { it in ids && (event.kind != 1 || tag.getOrNull(3) != "mention") } + "a" -> tag[1].takeIf { it in addresses } + else -> null + } + }.distinct() + targets.forEach { bucket.getOrPut(it) { mutableSetOf() }.add(event.pubKey) } + } + + return (reactions.keys + reposts.keys + replies.keys).associateWith { + ObserverEngagement( + reactions = reactions[it]?.size ?: 0, + reposts = reposts[it]?.size ?: 0, + replies = replies[it]?.size ?: 0, + ) + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ObserverReadiness.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ObserverReadiness.kt new file mode 100644 index 0000000000..ae68badbb7 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ObserverReadiness.kt @@ -0,0 +1,80 @@ +/* + * 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.observer + +/** + * Can the search relay rank for this reader — and if not, which link is missing? + * + * A trimmed port of the Observer's `Readiness.assess` (itself a port of + * vespa-relay's `shared/readiness.js`). Two of its properties are kept because + * they are the reason it exists: + * + * 1. **There is no fallback lens.** `observer: sort:rank` with a token the + * relay cannot resolve does not fail — it silently becomes the anonymous + * ranking, which the Observer measured as 209 of 400 posts from one spam + * account. So a reader without a working lens gets a reason, not a paper. + * 2. **The ranked probe is not redundant** with the 10040 check. Cards can be + * present and not yet projected; only asking the lensed and anonymous + * reads the same question catches that. + * + * The COUNT links (cards here vs. upstream, own posts behind) are dropped: the + * Observer measured COUNTs hanging the check for seconds at a time on this + * relay, and neither changes whether a paper can be printed. + */ +object ObserverReadiness { + enum class State { + /** No kind 10040 on the search relay: the reader never chose who computes their web of trust. */ + NO_SCORE_LIST, + + /** A 10040 exists but names no public `30382:rank` provider with a relay hint. */ + NO_RANK_SERVICE, + + /** The lensed probe is empty while the anonymous one is not: the cards are not projected yet. */ + NOT_PROJECTED, + + /** Neither probe answered with anything — the relay is down, unreachable or refusing us. */ + RELAY_SILENT, + + READY, + } + + data class Facts( + /** Whether the search relay holds a kind 10040 for the reader. */ + val scoreListSeen: Boolean, + /** The `30382:rank` provider's pubkey, from the 10040, when it has one. */ + val rankService: String?, + /** Rows the lensed and the anonymous ranked probes returned for the same question. */ + val probeLensed: Int, + val probeAnonymous: Int, + ) + + /** The first unmet link wins; nothing below it is reported as a second failure. */ + fun assess(facts: Facts): State { + // A 24-hour window of kind 1 is never empty, so two empty probes mean the + // relay said nothing — and an absent 10040 from a silent relay is not + // evidence the reader has none. + if (facts.probeLensed == 0 && facts.probeAnonymous == 0) return State.RELAY_SILENT + if (!facts.scoreListSeen) return State.NO_SCORE_LIST + if (facts.rankService.isNullOrBlank()) return State.NO_RANK_SERVICE + if (facts.probeLensed == 0) return State.NOT_PROJECTED + return State.READY + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/observer/ObserverEditorTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/observer/ObserverEditorTest.kt new file mode 100644 index 0000000000..4189843063 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/observer/ObserverEditorTest.kt @@ -0,0 +1,314 @@ +/* + * 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.observer + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ObserverEditorTest { + private val reader = key(0) + private val alice = key(1) + private val bob = key(2) + private val carol = key(3) + private val dave = key(4) + + private var nextId = 100 + + private fun key(n: Int) = n.toString(16).padStart(64, '0') + + private fun note( + author: String, + content: String, + createdAt: Long = 1_000, + kind: Int = 1, + tags: Array> = emptyArray(), + ) = Event(key(nextId++), author, createdAt, kind, tags, content, "") + + private fun corpus( + ranked: Map>, + engagement: Map = emptyMap(), + names: Map = emptyMap(), + ) = ObserverCorpus(reader, 0, 86_400, ranked, engagement, names, dayNotes = 11_800) + + @Test + fun leadIsWhatTheWebOfTrustEngagedWithMost() { + val quiet = note(alice, "A perfectly reasonable post about gardening in the autumn rain.", createdAt = 5_000) + val loud = note(bob, "Bitcoin core developers met in Lisbon today. Here is what they decided.", createdAt = 1_000) + val edition = + ObserverEditor.edit( + corpus( + mapOf(ObserverDesk.NOTES to listOf(quiet, loud)), + engagement = mapOf(loud.id to ObserverEngagement(reactions = 3, replies = 2)), + names = mapOf(bob to "Bob"), + ), + ) + + assertEquals(loud.id, edition.lead?.event?.id) + assertEquals("Bitcoin core developers met in Lisbon today.", edition.lead?.headline) + assertEquals("Here is what they decided.", edition.lead?.body) + assertEquals("Bob", edition.lead?.byline) + assertEquals(listOf(quiet.id), edition.topStories.map { it.event.id }) + } + + @Test + fun withNoSignalsTheRelaysOrderStands() { + val first = note(alice, "The first thing the lens returned, which is long enough to run.", createdAt = 9_000) + val second = note(bob, "The second thing the lens returned, also long enough to run.", createdAt = 8_000) + val edition = ObserverEditor.edit(corpus(mapOf(ObserverDesk.NOTES to listOf(first, second)))) + + assertEquals(first.id, edition.lead?.event?.id) + } + + @Test + fun greetingsAndRepliesDoNotRunAboveTheFold() { + val gm = note(alice, "GM ☕", createdAt = 9_000) + val reply = + note( + bob, + "I disagree with this entirely and here is a long reason why that matters.", + tags = arrayOf(arrayOf("e", key(99), "", "reply")), + ) + val story = note(carol, "The relay operators agreed on a new spam policy this week.") + val edition = + ObserverEditor.edit( + corpus( + mapOf(ObserverDesk.NOTES to listOf(gm, reply, story)), + engagement = mapOf(gm.id to ObserverEngagement(reactions = 50), reply.id to ObserverEngagement(reactions = 40)), + ), + ) + + assertEquals(story.id, edition.lead?.event?.id) + // The greeting still makes the wire, so nothing the lens chose goes unprinted; the reply does not. + val wire = edition.sections.single { it.kind == ObserverSectionKind.WIRE } + assertEquals(listOf(gm.id), wire.stories.map { it.event.id }) + } + + @Test + fun oneStoryPerAuthorAboveTheFold() { + val a1 = note(alice, "Alice's first story of the day, with plenty to say about it.") + val a2 = note(alice, "Alice's second story of the day, also with plenty to say.") + val b1 = note(bob, "Bob's only story of the day, which is about the weather today.") + val edition = + ObserverEditor.edit( + corpus( + mapOf(ObserverDesk.NOTES to listOf(a1, a2, b1)), + engagement = mapOf(a1.id to ObserverEngagement(reactions = 9), a2.id to ObserverEngagement(reactions = 8)), + ), + ) + + assertEquals(a1.id, edition.lead?.event?.id) + assertEquals(listOf(b1.id), edition.topStories.map { it.event.id }) + assertEquals(listOf(a2.id), edition.sections.single { it.kind == ObserverSectionKind.WIRE }.stories.map { it.event.id }) + } + + @Test + fun articlesRunUnderTheirOwnTitleAndCountSignalsByAddress() { + val article = + LongFormContentEvent( + key(nextId++), + dave, + 2_000, + arrayOf(arrayOf("d", "essay"), arrayOf("title", "On Relays"), arrayOf("summary", "Why outboxes won.")), + "Long body text that is not the summary.", + "", + ) + val note = note(alice, "A note that would otherwise lead the paper, being long enough.") + val edition = + ObserverEditor.edit( + corpus( + mapOf(ObserverDesk.NOTES to listOf(note), ObserverDesk.ARTICLES to listOf(article)), + engagement = mapOf("30023:$dave:essay" to ObserverEngagement(reposts = 4)), + ), + ) + + assertEquals(article.id, edition.lead?.event?.id) + assertEquals("On Relays", edition.lead?.headline) + assertEquals("Why outboxes won.", edition.lead?.body) + assertEquals(4, edition.lead?.reposts) + } + + @Test + fun emptyDesksPrintNoSection() { + val edition = ObserverEditor.edit(corpus(mapOf(ObserverDesk.NOTES to listOf(note(alice, "Just one story on a very quiet day, nothing else."))))) + assertTrue(edition.sections.isEmpty()) + assertNotNull(edition.lead) + } + + @Test + fun anEmptyCorpusIsAnEmptyEdition() { + assertTrue(ObserverEditor.edit(corpus(emptyMap())).isEmpty) + } + + @Test + fun perAuthorCapAndDuplicateCollapse() { + val dupes = (1..3).map { note(alice, "Same photo of a tiger, posted again https://x.example/$it.jpg") } + val many = (1..10).map { note(bob, "Long-form archive entry number $it of many", kind = 30023) } + + assertEquals(1, ObserverEditor.prune(ObserverDesk.NOTES, dupes).size) + assertEquals(ObserverDesk.ARTICLES.perAuthor, ObserverEditor.prune(ObserverDesk.ARTICLES, many).size) + } + + @Test + fun soldClassifiedsAreNotAdvertised() { + val sold = note(alice, "Vintage radio", kind = 30402, tags = arrayOf(arrayOf("title", "Radio"), arrayOf("status", "sold"))) + val active = + note(bob, "A bike", kind = 30402, tags = arrayOf(arrayOf("title", "Bike"), arrayOf("price", "210000", "SATS"))) + val edition = ObserverEditor.edit(corpus(mapOf(ObserverDesk.CLASSIFIEDS to listOf(sold, active)))) + + val classifieds = edition.sections.single { it.kind == ObserverSectionKind.CLASSIFIEDS } + assertEquals(listOf(active.id), classifieds.stories.map { it.event.id }) + assertEquals(ObserverDetail.Price("210000", "SATS", null), classifieds.stories.single().details.single()) + } + + @Test + fun calendarRunsInDateOrderAcrossBothShapes() { + val later = note(alice, "", kind = 31923, tags = arrayOf(arrayOf("title", "Meetup"), arrayOf("start", "1790000000"), arrayOf("start_tzid", "America/New_York"))) + val sooner = note(bob, "", kind = 31922, tags = arrayOf(arrayOf("title", "Conference"), arrayOf("start", "2026-10-05"))) + val undated = note(carol, "", kind = 31923, tags = arrayOf(arrayOf("title", "Someday"))) + val edition = ObserverEditor.edit(corpus(mapOf(ObserverDesk.CALENDAR to listOf(undated, later, sooner)))) + + val calendar = edition.sections.single { it.kind == ObserverSectionKind.CALENDAR } + assertEquals(listOf("Conference", "Meetup", "Someday"), calendar.stories.map { it.headline }) + assertEquals(ObserverDetail.Starts(1790000000, null, "America/New_York"), calendar.stories[1].details.single()) + } + + @Test + fun highlightsCreditTheQuotedAuthorNotTheHighlighter() { + val highlight = + note(alice, "Human code review has very nearly run its course.", kind = 9802, tags = arrayOf(arrayOf("p", carol), arrayOf("r", "https://example.com/essay"))) + val story = ObserverEditor.story(highlight, ObserverDesk.HIGHLIGHTS, ObserverEngagement.NONE, mapOf(alice to "Alice", carol to "Carol"), 400) + + assertEquals("Alice", story.byline) + assertEquals("Human code review has very nearly run its course.", story.headline) + assertTrue(ObserverDetail.QuotedAuthor("Carol") in story.details) + } + + @Test + fun mentionsBecomeNamesAndLinksAreRemoved() { + val npub = carol.hexToByteArray().toNpub() + val text = ObserverEditor.cleanText("Thanks nostr:$npub, see https://example.com/x and nostr:note1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", mapOf(carol to "Carol")) + assertEquals("Thanks @Carol, see and", text) + } + + @Test + fun picturesNeedAnImageAndVideoIsNotOne() { + val picture = note(alice, "Sunset", kind = 20, tags = arrayOf(arrayOf("imeta", "url https://cdn.example/a.jpg", "m image/jpeg"))) + val clip = note(bob, "Clip", kind = 20, tags = arrayOf(arrayOf("imeta", "url https://cdn.example/a.mp4", "m video/mp4"))) + + assertEquals("https://cdn.example/a.jpg", ObserverEditor.imageOf(picture)) + assertNull(ObserverEditor.imageOf(clip)) + + val edition = ObserverEditor.edit(corpus(mapOf(ObserverDesk.PICTURES to listOf(picture, clip)))) + assertEquals(listOf(picture.id), edition.sections.single { it.kind == ObserverSectionKind.PHOTOS }.stories.map { it.event.id }) + } + + @Test + fun headlinesAreClippedAtAWord() { + val (headline, rest) = ObserverEditor.firstSentence("word ".repeat(60).trim()) + assertTrue(headline.length <= ObserverEditor.HEADLINE_CHARS + 1) + assertTrue(headline.endsWith("…")) + assertTrue(rest.startsWith("…")) + assertFalse(headline.contains(" ")) + } + + @Test + fun trendingNeedsTwoPeople() { + val events = + listOf( + note(alice, "a", tags = arrayOf(arrayOf("t", "Bitcoin"), arrayOf("t", "solo"))), + note(bob, "b", tags = arrayOf(arrayOf("t", "bitcoin"))), + note(alice, "c", tags = arrayOf(arrayOf("t", "solo"))), + ) + assertEquals(listOf(ObserverTrend("bitcoin", 2)), ObserverEditor.trending(events)) + } + + @Test + fun theCodeIsAPrintRunNotAFile() { + val a = note(alice, "x") + val b = note(bob, "y") + val one = ObserverEditor.code(corpus(mapOf(ObserverDesk.NOTES to listOf(a, b)))) + val sameMaterialOtherOrder = ObserverEditor.code(corpus(mapOf(ObserverDesk.NOTES to listOf(b, a)))) + val otherMaterial = ObserverEditor.code(corpus(mapOf(ObserverDesk.NOTES to listOf(a)))) + + assertEquals(6, one.length) + assertEquals(one, sameMaterialOtherOrder) + assertTrue(one != otherMaterial) + } + + @Test + fun epochDayIsTheCivilDate() { + assertEquals("1970-01-01", ObserverEditor.epochDay(0)) + assertEquals("2026-10-04", ObserverEditor.epochDay(1_791_072_000)) + assertEquals("2000-02-29", ObserverEditor.epochDay(951_782_400)) + } + + @Test + fun tallyCountsEachPersonOncePerSignal() { + val story = key(500) + val events = + listOf( + note(alice, "+", kind = 7, tags = arrayOf(arrayOf("e", story))), + note(alice, "🔥", kind = 7, tags = arrayOf(arrayOf("e", story))), + note(bob, "+", kind = 7, tags = arrayOf(arrayOf("e", story))), + note(carol, "", kind = 6, tags = arrayOf(arrayOf("e", story))), + note(dave, "Agreed", kind = 1, tags = arrayOf(arrayOf("e", story, "", "reply"))), + note(dave, "cf.", kind = 1, tags = arrayOf(arrayOf("e", story, "", "mention"))), + note(dave, "+", kind = 7, tags = arrayOf(arrayOf("e", key(501)))), + ) + assertEquals(mapOf(story to ObserverEngagement(reactions = 2, reposts = 1, replies = 1)), ObserverPull.tally(events, setOf(story), emptySet())) + } + + @Test + fun readinessFirstUnmetLinkWins() { + fun assess( + seen: Boolean, + rank: String?, + lensed: Int, + anonymous: Int, + ) = ObserverReadiness.assess(ObserverReadiness.Facts(seen, rank, lensed, anonymous)) + + assertEquals(ObserverReadiness.State.RELAY_SILENT, assess(false, null, 0, 0)) + // An unresolvable lens silently becomes the anonymous ranking, so both probes answer. + assertEquals(ObserverReadiness.State.NO_SCORE_LIST, assess(false, null, 12, 12)) + assertEquals(ObserverReadiness.State.NO_RANK_SERVICE, assess(true, null, 12, 12)) + assertEquals(ObserverReadiness.State.NOT_PROJECTED, assess(true, alice, 0, 12)) + assertEquals(ObserverReadiness.State.READY, assess(true, alice, 12, 12)) + } + + @Test + fun bylinesIncludeTheReaderQuotedAuthorsAndMentions() { + val npub = dave.hexToByteArray().toNpub() + val events = + listOf( + note(alice, "hi nostr:$npub"), + note(bob, "quote", kind = 9802, tags = arrayOf(arrayOf("p", carol))), + ) + assertEquals(setOf(reader, alice, bob, carol, dave), ObserverPress.bylinesFor(reader, events)) + } +} diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index a445e2a187..0647cf6042 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -6463,4 +6463,67 @@ +%1$d more answer +%1$d more answers + Nostr Observer + The Nostr Observer + All the notes fit to rank + Your paper, from your web of trust + The Observer reads the last 24 hours of Nostr through your own web-of-trust lens and lays it out as a front page, right here on your phone. No server and no AI write it: every headline is the author's own words, ranked by what the people you trust liked, shared and replied to. + Print today's paper + Reprint + Printing your Observer… + Checking your web-of-trust lens + Reading the desks (%1$d of %2$d) + Reading what your web of trust liked and shared + Looking up bylines + Laying out the page + Your paper is ready + Read + Dismiss + Stop printing + Try again + The press stopped before the paper was finished. + No paper today + You have not chosen who works out your web of trust (a NIP-85 trust provider list). The Observer only prints a paper ranked through your own lens, never one ranked some other way. + Your trust provider list does not name a public ranking service the search relay can read. + Your web of trust is still being loaded into the search relay. This clears on its own, try again later. + The search relay did not answer. Check your connection and try again. + Your lens surfaced nothing in the last 24 hours. + Ranked as %1$s + No. %1$s + %1$s notes above your trust floor today + Trending in your web + Top stories + Photographs + Long reads + On the air + Video + Highlights + Polls + Calendar + Classifieds + Releases + Code + Wiki + The wire + %1$s per %2$s + Starts %1$s + %1$s, all day + At %1$s + Runs %1$s + Words by %1$s + From %1$s + Status: %1$s + On air since %1$s + + %1$d story from %2$d people + %1$d stories from %2$d people + + + ranked by %1$d signal from your web of trust + ranked by %1$d signals from your web of trust + + + %1$d watching + %1$d watching + diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ui/ObserverPressBanner.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ui/ObserverPressBanner.kt new file mode 100644 index 0000000000..82c4a19fec --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ui/ObserverPressBanner.kt @@ -0,0 +1,265 @@ +/* + * 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.observer.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.observer.ObserverPress +import com.vitorpamplona.amethyst.commons.observer.ObserverReadiness +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.observer_cancel +import com.vitorpamplona.amethyst.commons.resources.observer_dismiss +import com.vitorpamplona.amethyst.commons.resources.observer_failed +import com.vitorpamplona.amethyst.commons.resources.observer_no_rank_service +import com.vitorpamplona.amethyst.commons.resources.observer_no_score_list +import com.vitorpamplona.amethyst.commons.resources.observer_not_projected +import com.vitorpamplona.amethyst.commons.resources.observer_printing +import com.vitorpamplona.amethyst.commons.resources.observer_read +import com.vitorpamplona.amethyst.commons.resources.observer_ready +import com.vitorpamplona.amethyst.commons.resources.observer_relay_silent +import com.vitorpamplona.amethyst.commons.resources.observer_step_bylines +import com.vitorpamplona.amethyst.commons.resources.observer_step_checking_lens +import com.vitorpamplona.amethyst.commons.resources.observer_step_desks +import com.vitorpamplona.amethyst.commons.resources.observer_step_layout +import com.vitorpamplona.amethyst.commons.resources.observer_step_signals +import com.vitorpamplona.amethyst.commons.ui.stringRes +import org.jetbrains.compose.resources.StringResource + +/** + * The floating "printing your paper" popup — the Observer's counterpart to the + * proof-of-work mining banner. It follows the reader around the app while the + * press runs and turns into a "your paper is ready" card with a Read button + * when it is done, so a long print never holds a screen hostage. + * + * Hidden while the reader is on the paper itself ([ObserverPress.readerIsLooking]), + * which shows the same progress inline. + */ +@Composable +fun ObserverPressBanner( + state: ObserverPress.State, + readerIsLooking: Boolean, + onOpen: () -> Unit, + onCancel: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val visible = + !readerIsLooking && + when (state) { + is ObserverPress.State.Printing -> true + is ObserverPress.State.Ready -> !state.seen + is ObserverPress.State.Failed, is ObserverPress.State.NoLens -> true + ObserverPress.State.Idle -> false + } + + AnimatedVisibility( + visible = visible, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(tween(200)), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(150)), + modifier = modifier, + ) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 3.dp, + shadowElevation = 6.dp, + shape = RoundedCornerShape(20.dp), + modifier = Modifier.fillMaxWidth().clickable(onClick = onOpen), + ) { + Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp).animateContentSize()) { + when (state) { + is ObserverPress.State.Printing -> { + BannerRow( + text = stringRes(Res.string.observer_printing), + pulsing = true, + trailing = { CloseButton(stringRes(Res.string.observer_cancel), onCancel) }, + ) + ObserverPrintingProgress(state, Modifier.padding(start = 26.dp, bottom = 4.dp)) + } + + is ObserverPress.State.Ready -> { + BannerRow( + text = stringRes(Res.string.observer_ready), + pulsing = false, + trailing = { + TextButton(onClick = onOpen) { Text(stringRes(Res.string.observer_read)) } + CloseButton(stringRes(Res.string.observer_dismiss), onDismiss) + }, + ) + } + + is ObserverPress.State.NoLens -> { + BannerRow( + text = stringRes(noLensMessage(state.reason)), + pulsing = false, + maxLines = 3, + trailing = { CloseButton(stringRes(Res.string.observer_dismiss), onDismiss) }, + ) + } + + is ObserverPress.State.Failed -> { + BannerRow( + text = stringRes(Res.string.observer_failed), + pulsing = false, + maxLines = 2, + trailing = { CloseButton(stringRes(Res.string.observer_dismiss), onDismiss) }, + ) + } + + ObserverPress.State.Idle -> {} + } + } + } + } +} + +@Composable +private fun BannerRow( + text: String, + pulsing: Boolean, + maxLines: Int = 1, + trailing: @Composable () -> Unit, +) { + // Printing has a predictable bar, but the pulse is what says "the app is working right now". + val alpha = + if (pulsing) { + val pulse = rememberInfiniteTransition(label = "observerPulse") + val value by pulse.animateFloat( + initialValue = 0.35f, + targetValue = 1f, + animationSpec = infiniteRepeatable(animation = tween(700), repeatMode = RepeatMode.Reverse), + label = "observerAlpha", + ) + value + } else { + 1f + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + symbol = MaterialSymbols.News, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary.copy(alpha = alpha), + modifier = Modifier.size(18.dp), + ) + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + trailing() + } +} + +@Composable +private fun CloseButton( + description: String, + onClick: () -> Unit, +) { + IconButton(onClick = onClick, modifier = Modifier.size(22.dp)) { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = description, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(14.dp), + ) + } +} + +/** The step the press is on, and a bar that fills over the desks — they are most of the wait. */ +@Composable +fun ObserverPrintingProgress( + state: ObserverPress.State.Printing, + modifier: Modifier = Modifier, +) { + Column(modifier) { + Text( + text = + when (state.step) { + ObserverPress.Step.CHECKING_LENS -> stringRes(Res.string.observer_step_checking_lens) + ObserverPress.Step.READING_DESKS -> stringRes(Res.string.observer_step_desks, state.desksDone, state.desksTotal) + ObserverPress.Step.READING_SIGNALS -> stringRes(Res.string.observer_step_signals) + ObserverPress.Step.READING_BYLINES -> stringRes(Res.string.observer_step_bylines) + ObserverPress.Step.LAYING_OUT -> stringRes(Res.string.observer_step_layout) + }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(4.dp)) + LinearProgressIndicator( + progress = { state.fraction }, + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } +} + +fun noLensMessage(reason: ObserverReadiness.State): StringResource = + when (reason) { + ObserverReadiness.State.NO_SCORE_LIST -> Res.string.observer_no_score_list + ObserverReadiness.State.NO_RANK_SERVICE -> Res.string.observer_no_rank_service + ObserverReadiness.State.NOT_PROJECTED -> Res.string.observer_not_projected + ObserverReadiness.State.RELAY_SILENT -> Res.string.observer_relay_silent + // READY never reaches NoLens; listed so the mapping stays exhaustive. + ObserverReadiness.State.READY -> Res.string.observer_relay_silent + } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ui/ObserverScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ui/ObserverScreen.kt new file mode 100644 index 0000000000..2b8693637a --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/observer/ui/ObserverScreen.kt @@ -0,0 +1,659 @@ +/* + * 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.observer.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.observer.ObserverDetail +import com.vitorpamplona.amethyst.commons.observer.ObserverEdition +import com.vitorpamplona.amethyst.commons.observer.ObserverPress +import com.vitorpamplona.amethyst.commons.observer.ObserverSection +import com.vitorpamplona.amethyst.commons.observer.ObserverSectionKind +import com.vitorpamplona.amethyst.commons.observer.ObserverStory +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.observer +import com.vitorpamplona.amethyst.commons.resources.observer_day_notes +import com.vitorpamplona.amethyst.commons.resources.observer_detail_all_day +import com.vitorpamplona.amethyst.commons.resources.observer_detail_duration +import com.vitorpamplona.amethyst.commons.resources.observer_detail_location +import com.vitorpamplona.amethyst.commons.resources.observer_detail_price_per +import com.vitorpamplona.amethyst.commons.resources.observer_detail_quoting +import com.vitorpamplona.amethyst.commons.resources.observer_detail_source +import com.vitorpamplona.amethyst.commons.resources.observer_detail_starts +import com.vitorpamplona.amethyst.commons.resources.observer_detail_status +import com.vitorpamplona.amethyst.commons.resources.observer_detail_watching +import com.vitorpamplona.amethyst.commons.resources.observer_edition_code +import com.vitorpamplona.amethyst.commons.resources.observer_empty +import com.vitorpamplona.amethyst.commons.resources.observer_failed +import com.vitorpamplona.amethyst.commons.resources.observer_intro_body +import com.vitorpamplona.amethyst.commons.resources.observer_intro_title +import com.vitorpamplona.amethyst.commons.resources.observer_masthead +import com.vitorpamplona.amethyst.commons.resources.observer_motto +import com.vitorpamplona.amethyst.commons.resources.observer_no_lens_title +import com.vitorpamplona.amethyst.commons.resources.observer_on_air_since +import com.vitorpamplona.amethyst.commons.resources.observer_print +import com.vitorpamplona.amethyst.commons.resources.observer_printing +import com.vitorpamplona.amethyst.commons.resources.observer_ranked_as +import com.vitorpamplona.amethyst.commons.resources.observer_reprint +import com.vitorpamplona.amethyst.commons.resources.observer_section_apps +import com.vitorpamplona.amethyst.commons.resources.observer_section_calendar +import com.vitorpamplona.amethyst.commons.resources.observer_section_classifieds +import com.vitorpamplona.amethyst.commons.resources.observer_section_code +import com.vitorpamplona.amethyst.commons.resources.observer_section_highlights +import com.vitorpamplona.amethyst.commons.resources.observer_section_live +import com.vitorpamplona.amethyst.commons.resources.observer_section_long_reads +import com.vitorpamplona.amethyst.commons.resources.observer_section_photos +import com.vitorpamplona.amethyst.commons.resources.observer_section_polls +import com.vitorpamplona.amethyst.commons.resources.observer_section_video +import com.vitorpamplona.amethyst.commons.resources.observer_section_wiki +import com.vitorpamplona.amethyst.commons.resources.observer_section_wire +import com.vitorpamplona.amethyst.commons.resources.observer_stats_signals +import com.vitorpamplona.amethyst.commons.resources.observer_stats_stories +import com.vitorpamplona.amethyst.commons.resources.observer_top_stories +import com.vitorpamplona.amethyst.commons.resources.observer_trending +import com.vitorpamplona.amethyst.commons.resources.observer_try_again +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.commons.ui.note.rememberTimeOfDayFormatter +import com.vitorpamplona.amethyst.commons.ui.note.timeAgoNoDot +import com.vitorpamplona.amethyst.commons.ui.pluralStringRes +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatLongDate +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import org.jetbrains.compose.resources.StringResource + +private val Serif = FontFamily.Serif + +/** + * The Nostr Observer, as a native screen: today's front page laid out from the + * reader's own web-of-trust lens by [ObserverPress], read like a newspaper and + * tapped through to the real notes. + */ +@Composable +fun ObserverScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val press = accountViewModel.account.observerPress + val state by press.state.collectAsStateWithLifecycle() + val edition by press.edition.collectAsStateWithLifecycle() + + // The paper is in front of the reader: the floating banner steps aside and a + // finished edition counts as read. + DisposableEffect(press) { + press.readerIsLooking.value = true + onDispose { press.readerIsLooking.value = false } + } + LaunchedEffect(state) { if (state is ObserverPress.State.Ready) press.markSeen() } + + val openStory: (ObserverStory) -> Unit = { story -> + nav.nav { routeFor(story.event, accountViewModel.account) } + } + + Scaffold( + topBar = { + TopBarWithBackButton(stringRes(Res.string.observer), nav) { + if (edition != null && state !is ObserverPress.State.Printing) { + IconButton(onClick = press::print) { + Icon(MaterialSymbols.Refresh, contentDescription = stringRes(Res.string.observer_reprint)) + } + } + } + }, + ) { padding -> + val current = edition + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.TopCenter) { + if (current == null) { + Cover(state, onPrint = press::print, modifier = Modifier.widthIn(max = 720.dp)) + } else { + Paper(current, state, onPrint = press::print, onOpen = openStory, modifier = Modifier.widthIn(max = 720.dp)) + } + } + } +} + +/** Before the first edition: what this is, a button, and the press's state. */ +@Composable +private fun Cover( + state: ObserverPress.State, + onPrint: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Masthead() + Text( + stringRes(Res.string.observer_intro_title), + fontFamily = Serif, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + Text( + stringRes(Res.string.observer_intro_body), + fontFamily = Serif, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + PressStatus(state, onPrint, showPrintButton = true) + } +} + +/** Progress, a refusal or a failure, and the button that starts (or restarts) the press. */ +@Composable +private fun PressStatus( + state: ObserverPress.State, + onPrint: () -> Unit, + showPrintButton: Boolean, +) { + Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) { + when (state) { + is ObserverPress.State.Printing -> { + Text(stringRes(Res.string.observer_printing), style = MaterialTheme.typography.titleSmall) + ObserverPrintingProgress(state, Modifier.fillMaxWidth()) + } + + is ObserverPress.State.NoLens -> { + Text(stringRes(Res.string.observer_no_lens_title), fontFamily = Serif, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text(stringRes(noLensMessage(state.reason)), style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center) + OutlinedButton(onClick = onPrint) { Text(stringRes(Res.string.observer_try_again)) } + } + + is ObserverPress.State.Failed -> { + Text(stringRes(Res.string.observer_failed), style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center) + OutlinedButton(onClick = onPrint) { Text(stringRes(Res.string.observer_try_again)) } + } + + else -> { + if (showPrintButton) Button(onClick = onPrint) { Text(stringRes(Res.string.observer_print)) } + } + } + } +} + +@Composable +private fun Masthead() { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) { + Text( + stringRes(Res.string.observer_masthead), + fontFamily = Serif, + fontWeight = FontWeight.Black, + fontSize = 34.sp, + lineHeight = 38.sp, + textAlign = TextAlign.Center, + ) + Text( + stringRes(Res.string.observer_motto), + fontFamily = Serif, + fontStyle = FontStyle.Italic, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun Paper( + edition: ObserverEdition, + state: ObserverPress.State, + onPrint: () -> Unit, + onOpen: (ObserverStory) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn(modifier = modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 32.dp)) { + if (state !is ObserverPress.State.Idle && state !is ObserverPress.State.Ready) { + item(key = "status") { Box(Modifier.padding(16.dp)) { PressStatus(state, onPrint, showPrintButton = false) } } + } + + item(key = "masthead") { FrontMatter(edition) } + + if (edition.isEmpty) { + item(key = "empty") { + Text( + stringRes(Res.string.observer_empty), + modifier = Modifier.fillMaxWidth().padding(32.dp), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (edition.trending.isNotEmpty()) { + item(key = "trending") { Trending(edition) } + } + + edition.lead?.let { lead -> + item(key = "lead") { LeadStory(lead, onOpen) } + } + + if (edition.topStories.isNotEmpty()) { + item(key = "top-header") { SectionHeader(stringRes(Res.string.observer_top_stories)) } + items(edition.topStories, key = { "top-" + it.event.id }) { TopStory(it, onOpen) } + } + + edition.sections.forEach { section(it, onOpen) } + + item(key = "colophon") { + Text( + stringRes(Res.string.observer_edition_code, edition.code), + modifier = Modifier.fillMaxWidth().padding(top = 24.dp), + textAlign = TextAlign.Center, + fontFamily = Serif, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +private fun LazyListScope.section( + section: ObserverSection, + onOpen: (ObserverStory) -> Unit, +) { + item(key = "header-" + section.kind.name) { SectionHeader(stringRes(sectionTitle(section.kind))) } + when (section.kind) { + ObserverSectionKind.PHOTOS -> { + item(key = "photos") { Photos(section.stories, onOpen) } + } + + ObserverSectionKind.HIGHLIGHTS -> { + items(section.stories, key = { section.kind.name + it.event.id }) { PullQuote(it, onOpen) } + } + + else -> { + items(section.stories, key = { section.kind.name + it.event.id }) { Brief(it, onOpen) } + } + } +} + +private fun sectionTitle(kind: ObserverSectionKind): StringResource = + when (kind) { + ObserverSectionKind.PHOTOS -> Res.string.observer_section_photos + ObserverSectionKind.LONG_READS -> Res.string.observer_section_long_reads + ObserverSectionKind.LIVE -> Res.string.observer_section_live + ObserverSectionKind.VIDEO -> Res.string.observer_section_video + ObserverSectionKind.HIGHLIGHTS -> Res.string.observer_section_highlights + ObserverSectionKind.POLLS -> Res.string.observer_section_polls + ObserverSectionKind.CALENDAR -> Res.string.observer_section_calendar + ObserverSectionKind.CLASSIFIEDS -> Res.string.observer_section_classifieds + ObserverSectionKind.APPS -> Res.string.observer_section_apps + ObserverSectionKind.CODE -> Res.string.observer_section_code + ObserverSectionKind.WIKI -> Res.string.observer_section_wiki + ObserverSectionKind.WIRE -> Res.string.observer_section_wire + } + +@Composable +private fun FrontMatter(edition: ObserverEdition) { + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Masthead() + Spacer(Modifier.height(8.dp)) + HorizontalDivider(thickness = 2.dp, color = MaterialTheme.colorScheme.onSurface) + Row( + Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(formatLongDate(edition.until), fontFamily = Serif, style = MaterialTheme.typography.labelMedium) + Text(stringRes(Res.string.observer_edition_code, edition.code), fontFamily = Serif, style = MaterialTheme.typography.labelMedium) + } + HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.onSurface) + Spacer(Modifier.height(6.dp)) + + val stats = + buildList { + edition.readerName?.let { add(stringRes(Res.string.observer_ranked_as, it)) } + add(pluralStringRes(Res.plurals.observer_stats_stories, edition.stats.printed, edition.stats.printed, edition.stats.authors)) + edition.stats.dayNotes?.let { add(stringRes(Res.string.observer_day_notes, it.toString())) } + if (edition.stats.signals > 0) add(pluralStringRes(Res.plurals.observer_stats_signals, edition.stats.signals, edition.stats.signals)) + } + Text( + stats.joinToString(" · "), + fontFamily = Serif, + fontStyle = FontStyle.Italic, + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun Trending(edition: ObserverEdition) { + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) { + Text( + stringRes(Res.string.observer_trending).uppercase(), + style = MaterialTheme.typography.labelSmall, + letterSpacing = 1.5.sp, + fontWeight = FontWeight.Bold, + ) + FlowRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + edition.trending.forEach { + Text("#" + it.hashtag, fontFamily = Serif, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary) + } + } + } +} + +@Composable +private fun SectionHeader(title: String) { + Column(Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp)) { + HorizontalDivider(thickness = 3.dp, color = MaterialTheme.colorScheme.onSurface) + Text( + title.uppercase(), + modifier = Modifier.padding(top = 4.dp), + fontFamily = Serif, + fontWeight = FontWeight.Bold, + letterSpacing = 2.sp, + style = MaterialTheme.typography.labelLarge, + ) + } +} + +@Composable +private fun LeadStory( + story: ObserverStory, + onOpen: (ObserverStory) -> Unit, +) { + Column(Modifier.fillMaxWidth().clickable { onOpen(story) }.padding(16.dp)) { + story.imageUrl?.let { + AsyncImage( + model = it, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(RoundedCornerShape(4.dp)), + ) + Spacer(Modifier.height(10.dp)) + } + Text(story.headline, fontFamily = Serif, fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 33.sp) + Spacer(Modifier.height(6.dp)) + Byline(story) + if (story.body.isNotBlank()) { + Spacer(Modifier.height(8.dp)) + Text(story.body, fontFamily = Serif, style = MaterialTheme.typography.bodyLarge) + } + Details(story) + } +} + +@Composable +private fun TopStory( + story: ObserverStory, + onOpen: (ObserverStory) -> Unit, +) { + Column(Modifier.fillMaxWidth().clickable { onOpen(story) }.padding(horizontal = 16.dp, vertical = 10.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Column(Modifier.weight(1f)) { + Text(story.headline, fontFamily = Serif, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.height(4.dp)) + Byline(story) + } + story.imageUrl?.let { Thumbnail(it, 96) } + } + if (story.body.isNotBlank()) { + Spacer(Modifier.height(6.dp)) + Text(story.body, fontFamily = Serif, style = MaterialTheme.typography.bodyMedium, maxLines = 8, overflow = TextOverflow.Ellipsis) + } + Details(story) + HorizontalDivider(Modifier.padding(top = 10.dp)) + } +} + +@Composable +private fun Brief( + story: ObserverStory, + onOpen: (ObserverStory) -> Unit, +) { + Column(Modifier.fillMaxWidth().clickable { onOpen(story) }.padding(horizontal = 16.dp, vertical = 8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Column(Modifier.weight(1f)) { + Text(story.headline, fontFamily = Serif, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleMedium) + if (story.body.isNotBlank()) { + Text( + story.body, + fontFamily = Serif, + style = MaterialTheme.typography.bodySmall, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(2.dp)) + Byline(story) + } + story.imageUrl?.let { Thumbnail(it, 64) } + } + Details(story) + HorizontalDivider(Modifier.padding(top = 8.dp)) + } +} + +@Composable +private fun PullQuote( + story: ObserverStory, + onOpen: (ObserverStory) -> Unit, +) { + Row(Modifier.fillMaxWidth().clickable { onOpen(story) }.padding(horizontal = 16.dp, vertical = 10.dp)) { + Box(Modifier.width(3.dp).height(48.dp).background(MaterialTheme.colorScheme.primary)) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text("“" + story.headline + "”", fontFamily = Serif, fontStyle = FontStyle.Italic, style = MaterialTheme.typography.bodyLarge) + Spacer(Modifier.height(4.dp)) + Details(story) + Byline(story) + } + } +} + +@Composable +private fun Photos( + stories: List, + onOpen: (ObserverStory) -> Unit, +) { + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + ) { + items(stories, key = { it.event.id }) { story -> + Column(Modifier.width(220.dp).clickable { onOpen(story) }) { + story.imageUrl?.let { + AsyncImage( + model = it, + contentDescription = story.headline.takeIf { h -> h.isNotBlank() }, + contentScale = ContentScale.Crop, + modifier = Modifier.size(220.dp).clip(RoundedCornerShape(4.dp)), + ) + } + if (story.headline.isNotBlank()) { + Text(story.headline, fontFamily = Serif, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + Byline(story) + } + } + } +} + +@Composable +private fun Thumbnail( + url: String, + sizeDp: Int, +) { + AsyncImage( + model = url, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(sizeDp.dp).clip(RoundedCornerShape(4.dp)), + ) +} + +@Composable +private fun Byline(story: ObserverStory) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + story.byline + " · " + timeAgoNoDot(story.createdAt), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + Signal(MaterialSymbols.Favorite, story.reactions) + Signal(MaterialSymbols.Sync, story.reposts) + Signal(MaterialSymbols.Reply, story.replies) + } +} + +/** One kind of trusted engagement; nothing is drawn for zero, so a quiet story stays quiet. */ +@Composable +private fun Signal( + symbol: MaterialSymbol, + count: Int, +) { + if (count <= 0) return + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(symbol, contentDescription = null, modifier = Modifier.size(12.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + Text(count.toString(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun Details(story: ObserverStory) { + if (story.details.isEmpty()) return + val timeOfDay = rememberTimeOfDayFormatter() + Column(Modifier.padding(top = 4.dp)) { + story.details.forEach { detail -> + val text = + when (detail) { + is ObserverDetail.Price -> { + val amount = listOfNotNull(detail.amount, detail.currency).joinToString(" ") + detail.frequency?.let { stringRes(Res.string.observer_detail_price_per, amount, it) } ?: amount + } + + is ObserverDetail.Starts -> { + when { + detail.epochSeconds != null && story.event.kind == 30311 -> { + stringRes(Res.string.observer_on_air_since, timeOfDay(detail.epochSeconds * 1000)) + } + + detail.epochSeconds != null -> { + stringRes(Res.string.observer_detail_starts, formatLongDate(detail.epochSeconds) + " · " + timeOfDay(detail.epochSeconds * 1000)) + } + + else -> { + stringRes(Res.string.observer_detail_all_day, detail.date ?: "") + } + } + } + + is ObserverDetail.Location -> { + stringRes(Res.string.observer_detail_location, detail.text) + } + + is ObserverDetail.Duration -> { + val minutes = detail.seconds / 60 + val seconds = detail.seconds % 60 + stringRes(Res.string.observer_detail_duration, if (minutes > 0) "${minutes}m ${seconds}s" else "${seconds}s") + } + + is ObserverDetail.PollOptions -> { + detail.options.joinToString(" · ") + } + + is ObserverDetail.QuotedAuthor -> { + stringRes(Res.string.observer_detail_quoting, detail.name) + } + + is ObserverDetail.Source -> { + stringRes(Res.string.observer_detail_source, detail.text) + } + + is ObserverDetail.Watching -> { + pluralStringRes(Res.plurals.observer_detail_watching, detail.count, detail.count) + } + + is ObserverDetail.ItemStatus -> { + stringRes(Res.string.observer_detail_status, detail.text) + } + } + Text( + text, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/bottombars/NavBarItem.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/bottombars/NavBarItem.kt index 34e54a76e6..df98361c62 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/bottombars/NavBarItem.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/bottombars/NavBarItem.kt @@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.commons.resources.napplets import com.vitorpamplona.amethyst.commons.resources.nests import com.vitorpamplona.amethyst.commons.resources.nip46_signer_title import com.vitorpamplona.amethyst.commons.resources.nsites +import com.vitorpamplona.amethyst.commons.resources.observer import com.vitorpamplona.amethyst.commons.resources.pictures import com.vitorpamplona.amethyst.commons.resources.polls import com.vitorpamplona.amethyst.commons.resources.profile @@ -289,6 +290,13 @@ val NavBarCatalog: Map = icon = MaterialSymbols.Language, resolveRoute = { Route.Nsites }, ), + NavBarItem.OBSERVER to + NavBarItemDef( + id = NavBarItem.OBSERVER, + labelRes = Res.string.observer, + icon = MaterialSymbols.News, + resolveRoute = { Route.Observer }, + ), NavBarItem.BROWSER to NavBarItemDef( id = NavBarItem.BROWSER, @@ -552,6 +560,7 @@ val BottomBarCategories: List = Res.string.bottom_bar_category_feeds, MaterialSymbols.Subscriptions, listOf( + NavBarItem.OBSERVER, NavBarItem.ARTICLES, NavBarItem.LONGS, NavBarItem.PICTURES, diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/drawer/DrawerSections.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/drawer/DrawerSections.kt index 81f99e9299..1712f08698 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/drawer/DrawerSections.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/drawer/DrawerSections.kt @@ -99,6 +99,7 @@ private val DrawerYouItems: List = private val DrawerFeedsItems: List = listOfNotNull( + NavBarItem.OBSERVER, NavBarItem.ARTICLES, NavBarItem.PICTURES, NavBarItem.SHORTS,