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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HSjTU2HWoVbg6ttC1uB1f8
This commit is contained in:
Claude
2026-10-04 15:35:06 +00:00
parent 2fe82b5c1a
commit 96944b94ee
21 changed files with 2961 additions and 1 deletions
@@ -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<Route.SoftwareApps> { SoftwareAppsScreen(accountViewModel, nav) }
composableFromEnd<Route.Napplets> { NappletsScreen(accountViewModel, nav) }
composableFromEnd<Route.Nsites> { NsitesScreen(accountViewModel, nav) }
composableFromEnd<Route.Observer> { ObserverScreen(accountViewModel, nav) }
composableFromEnd<Route.Browser>(capWidth = false) { BrowserScreen(accountViewModel, nav) }
composableFromEnd<Route.FavoriteApps> { FavoriteAppsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.WebApp>(capWidth = false) { WebAppScreen(it.url, accountViewModel, nav) }
@@ -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),
)
}
}
@@ -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 -> {}
@@ -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<String>): 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
|
@@ -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<String>,
): 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<String, Any?> =
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<String, Any?> =
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()) }
}
}
+1
View File
@@ -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. |
@@ -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)
@@ -55,6 +55,7 @@ enum class NavBarItem {
SOFTWARE_APPS,
NAPPLETS,
NSITES,
OBSERVER,
BROWSER,
FAVORITE_APPS,
CALENDARS,
@@ -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()
@@ -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<Int>,
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<Array<String>> = tags.filter { it.isNotEmpty() && it[0] == name }
/** Hashtags, lowercased and deduplicated — `t` tags are inconsistently cased in the wild. */
internal fun Event.hashtagList(): List<String> =
tags
.mapNotNull { if (it.size > 1 && it[0] == "t") it[1].trim().lowercase().removePrefix("#") else null }
.filter { it.isNotBlank() }
.distinct()
@@ -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<ObserverStory>,
val sections: List<ObserverSection>,
val trending: List<ObserverTrend>,
) {
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<ObserverStory>,
)
@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<ObserverDetail>,
) {
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<String>,
) : 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<ObserverDesk, List<Event>>,
/** Keyed by event id, and by `kind:pubkey:d` address for addressable events. */
val engagement: Map<String, ObserverEngagement>,
/** Display names from kind 0, by pubkey. */
val names: Map<HexKey, String>,
val dayNotes: Long?,
) {
fun all(): List<Event> = ranked.values.flatten()
}
@@ -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<Pair<Event, Int>> { it.second }.thenByDescending { it.first.createdAt })
.map { it.first }
val front = mutableListOf<Event>()
val frontAuthors = mutableSetOf<HexKey>()
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<Event>) -> List<Event> = { 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<Event>,
): List<Event> =
events
.withIndex()
.sortedWith(compareByDescending<IndexedValue<Event>> { engagementOf(corpus, it.value).score }.thenBy { it.index })
.map { it.value }
private fun byStart(events: List<Event>): List<Event> =
events.sortedWith(
compareBy<Event> { 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<Event>,
): List<Event> {
val counts = mutableMapOf<HexKey, Int>()
val seen = mutableSetOf<String>()
val ids = mutableSetOf<HexKey>()
val out = mutableListOf<Event>()
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<HexKey, String>,
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<HexKey, String>,
): List<ObserverDetail> =
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<HexKey, String>,
): 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<String, String> {
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<Event>): List<ObserverTrend> {
val authorsByTag = mutableMapOf<String, MutableSet<HexKey>>()
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<ObserverTrend> { 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}""")
}
@@ -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<Event>) -> 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>(State.Idle)
val state: StateFlow<State> = _state.asStateFlow()
private val _edition = MutableStateFlow<ObserverEdition?>(null)
val edition: StateFlow<ObserverEdition?> = _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<Event>,
): Set<HexKey> =
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) }
}
}
}
}
}
@@ -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:<pk>` 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<Event>,
): Map<String, ObserverEngagement> {
val ids = events.filter { it !is AddressableEvent }.map { it.id }.distinct()
val addresses = events.filterIsInstance<AddressableEvent>().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<HexKey>): Map<HexKey, String> {
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<HexKey, Event>()
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:<pk> 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<Int>,
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<Event> =
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<Event>,
ids: Set<String>,
addresses: Set<String>,
): Map<String, ObserverEngagement> {
val reactions = mutableMapOf<String, MutableSet<HexKey>>()
val reposts = mutableMapOf<String, MutableSet<HexKey>>()
val replies = mutableMapOf<String, MutableSet<HexKey>>()
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,
)
}
}
}
}
@@ -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:<pk> 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
}
}
@@ -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<Array<String>> = emptyArray(),
) = Event(key(nextId++), author, createdAt, kind, tags, content, "")
private fun corpus(
ranked: Map<ObserverDesk, List<Event>>,
engagement: Map<String, ObserverEngagement> = emptyMap(),
names: Map<String, String> = 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))
}
}
@@ -6463,4 +6463,67 @@
<item quantity="one">+%1$d more answer</item>
<item quantity="other">+%1$d more answers</item>
</plurals>
<string name="observer">Nostr Observer</string>
<string name="observer_masthead">The Nostr Observer</string>
<string name="observer_motto">All the notes fit to rank</string>
<string name="observer_intro_title">Your paper, from your web of trust</string>
<string name="observer_intro_body">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.</string>
<string name="observer_print">Print today's paper</string>
<string name="observer_reprint">Reprint</string>
<string name="observer_printing">Printing your Observer…</string>
<string name="observer_step_checking_lens">Checking your web-of-trust lens</string>
<string name="observer_step_desks">Reading the desks (%1$d of %2$d)</string>
<string name="observer_step_signals">Reading what your web of trust liked and shared</string>
<string name="observer_step_bylines">Looking up bylines</string>
<string name="observer_step_layout">Laying out the page</string>
<string name="observer_ready">Your paper is ready</string>
<string name="observer_read">Read</string>
<string name="observer_dismiss">Dismiss</string>
<string name="observer_cancel">Stop printing</string>
<string name="observer_try_again">Try again</string>
<string name="observer_failed">The press stopped before the paper was finished.</string>
<string name="observer_no_lens_title">No paper today</string>
<string name="observer_no_score_list">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.</string>
<string name="observer_no_rank_service">Your trust provider list does not name a public ranking service the search relay can read.</string>
<string name="observer_not_projected">Your web of trust is still being loaded into the search relay. This clears on its own, try again later.</string>
<string name="observer_relay_silent">The search relay did not answer. Check your connection and try again.</string>
<string name="observer_empty">Your lens surfaced nothing in the last 24 hours.</string>
<string name="observer_ranked_as">Ranked as %1$s</string>
<string name="observer_edition_code">No. %1$s</string>
<string name="observer_day_notes">%1$s notes above your trust floor today</string>
<string name="observer_trending">Trending in your web</string>
<string name="observer_top_stories">Top stories</string>
<string name="observer_section_photos">Photographs</string>
<string name="observer_section_long_reads">Long reads</string>
<string name="observer_section_live">On the air</string>
<string name="observer_section_video">Video</string>
<string name="observer_section_highlights">Highlights</string>
<string name="observer_section_polls">Polls</string>
<string name="observer_section_calendar">Calendar</string>
<string name="observer_section_classifieds">Classifieds</string>
<string name="observer_section_apps">Releases</string>
<string name="observer_section_code">Code</string>
<string name="observer_section_wiki">Wiki</string>
<string name="observer_section_wire">The wire</string>
<string name="observer_detail_price_per">%1$s per %2$s</string>
<string name="observer_detail_starts">Starts %1$s</string>
<string name="observer_detail_all_day">%1$s, all day</string>
<string name="observer_detail_location">At %1$s</string>
<string name="observer_detail_duration">Runs %1$s</string>
<string name="observer_detail_quoting">Words by %1$s</string>
<string name="observer_detail_source">From %1$s</string>
<string name="observer_detail_status">Status: %1$s</string>
<string name="observer_on_air_since">On air since %1$s</string>
<plurals name="observer_stats_stories">
<item quantity="one">%1$d story from %2$d people</item>
<item quantity="other">%1$d stories from %2$d people</item>
</plurals>
<plurals name="observer_stats_signals">
<item quantity="one">ranked by %1$d signal from your web of trust</item>
<item quantity="other">ranked by %1$d signals from your web of trust</item>
</plurals>
<plurals name="observer_detail_watching">
<item quantity="one">%1$d watching</item>
<item quantity="other">%1$d watching</item>
</plurals>
</resources>
@@ -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
}
@@ -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<ObserverStory>,
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,
)
}
}
}
@@ -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<NavBarItem, NavBarItemDef> =
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<NavBarCategory> =
Res.string.bottom_bar_category_feeds,
MaterialSymbols.Subscriptions,
listOf(
NavBarItem.OBSERVER,
NavBarItem.ARTICLES,
NavBarItem.LONGS,
NavBarItem.PICTURES,
@@ -99,6 +99,7 @@ private val DrawerYouItems: List<NavBarItem> =
private val DrawerFeedsItems: List<NavBarItem> =
listOfNotNull(
NavBarItem.OBSERVER,
NavBarItem.ARTICLES,
NavBarItem.PICTURES,
NavBarItem.SHORTS,