mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
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:
@@ -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) }
|
||||
|
||||
+83
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+3
@@ -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()) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+1
@@ -55,6 +55,7 @@ enum class NavBarItem {
|
||||
SOFTWARE_APPS,
|
||||
NAPPLETS,
|
||||
NSITES,
|
||||
OBSERVER,
|
||||
BROWSER,
|
||||
FAVORITE_APPS,
|
||||
CALENDARS,
|
||||
|
||||
+3
@@ -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()
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.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()
|
||||
+191
@@ -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()
|
||||
}
|
||||
+459
@@ -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}""")
|
||||
}
|
||||
+223
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+321
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
@@ -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
|
||||
}
|
||||
}
|
||||
+314
@@ -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>
|
||||
|
||||
+265
@@ -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
|
||||
}
|
||||
+659
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -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,
|
||||
|
||||
+1
@@ -99,6 +99,7 @@ private val DrawerYouItems: List<NavBarItem> =
|
||||
|
||||
private val DrawerFeedsItems: List<NavBarItem> =
|
||||
listOfNotNull(
|
||||
NavBarItem.OBSERVER,
|
||||
NavBarItem.ARTICLES,
|
||||
NavBarItem.PICTURES,
|
||||
NavBarItem.SHORTS,
|
||||
|
||||
Reference in New Issue
Block a user