From a9ce0df8f24521307a0e8420804a986fcaae34d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 21:36:56 +0000 Subject: [PATCH 01/12] feat: render road event (kind 1315/1316) location on an OSM map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the reverse-geocoded "๐Ÿ“ City" line on the Roadstr road event report and confirmation cards with an osmdroid OpenStreetMap preview pinned at the event's coordinates. - Add osmdroid-android 6.1.20 (Apache-2.0) to the version catalog and the amethyst module. - New LocationPreviewMap composable: an AndroidView-wrapped MapView with MAPNIK tiles, a single marker, lifecycle-aware onResume/onPause/onDetach, and nested-scroll-friendly touch handling so panning the map doesn't fight the feed scroll. Sets the OSM User-Agent to the package name (required or OSM returns 403). - RoadEvent cards resolve a point from the explicit lat/lon tags, falling back to the center of the finest published geohash. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017tYbcy4UGWxqQbcycyL7Yd --- amethyst/build.gradle.kts | 3 + .../creators/location/LocationPreviewMap.kt | 128 ++++++++++++++++++ .../amethyst/ui/note/types/RoadEvent.kt | 70 ++++++---- gradle/libs.versions.toml | 2 + 4 files changed, 175 insertions(+), 28 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index c44598b810..c11ed91602 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -408,6 +408,9 @@ dependencies { implementation(libs.zxing) implementation(libs.zxing.embedded) + // OpenStreetMap tiles for road event location maps (kind 1315/1316) + implementation(libs.osmdroid.android) + // Markdown // implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0" // implementation "com.halilibo.compose-richtext:richtext-ui-material:0.16.0" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt new file mode 100644 index 0000000000..2918045f30 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt @@ -0,0 +1,128 @@ +/* + * 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.note.creators.location + +import android.view.MotionEvent +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import org.osmdroid.config.Configuration +import org.osmdroid.tileprovider.tilesource.TileSourceFactory +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.CustomZoomButtonsController +import org.osmdroid.views.MapView +import org.osmdroid.views.overlay.Marker + +/** Default close-up zoom for a single pinned location (street-level). */ +private const val DEFAULT_ZOOM = 16.0 + +/** + * A small OpenStreetMap (osmdroid) preview centered on [latitude]/[longitude] + * with a single pin at that point. + * + * Used by the Roadstr road event cards (kind 1315/1316) in place of a + * reverse-geocoded city name. Tiles are fetched from the public OSM tile + * servers, which requires the app's package name as the User-Agent (set on + * [Configuration] below) โ€” without it OSM returns HTTP 403. + * + * Pan/zoom stay enabled, but a touch listener asks the parent to stop + * intercepting gestures while the finger is on the map, so dragging the map + * pans it instead of scrolling the surrounding feed. + */ +@Composable +fun LocationPreviewMap( + latitude: Double, + longitude: Double, + modifier: Modifier = Modifier, + zoom: Double = DEFAULT_ZOOM, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + val mapView = + remember(context) { + // Must be set before the MapView is created so OSM tile requests + // carry a valid User-Agent (the default "osmdroid" is rejected). + Configuration.getInstance().userAgentValue = context.packageName + + MapView(context).apply { + setTileSource(TileSourceFactory.MAPNIK) + setMultiTouchControls(true) + zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) + setOnTouchListener { view, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> view.parent?.requestDisallowInterceptTouchEvent(true) + MotionEvent.ACTION_UP -> view.parent?.requestDisallowInterceptTouchEvent(false) + } + // Returning false lets the MapView still handle the pan/zoom. + false + } + } + } + + // osmdroid's MapView is a plain Android View with its own lifecycle: it + // needs onResume/onPause to (re)start its tile threads, and onDetach to + // free the tile cache when the composable leaves the tree. + DisposableEffect(lifecycleOwner, mapView) { + val observer = + LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> mapView.onResume() + Lifecycle.Event.ON_PAUSE -> mapView.onPause() + else -> Unit + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + mapView.onDetach() + } + } + + AndroidView( + modifier = modifier.fillMaxWidth().height(180.dp), + factory = { mapView }, + update = { map -> + val point = GeoPoint(latitude, longitude) + map.controller.setZoom(zoom) + map.controller.setCenter(point) + + map.overlays.removeAll { it is Marker } + val marker = + Marker(map).apply { + position = point + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + setInfoWindow(null) + } + map.overlays.add(marker) + map.invalidate() + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt index d560d3d173..0c71ef351c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt @@ -20,30 +20,30 @@ */ package com.vitorpamplona.amethyst.ui.note.types -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.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName +import com.vitorpamplona.amethyst.ui.note.creators.location.LocationPreviewMap import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadEventStatus import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent import com.vitorpamplona.quartz.experimental.roadstr.report.tags.RoadEventType +import com.vitorpamplona.quartz.nip01Core.tags.geohash.toGeoHash /** Emoji marker for a road event category โ€” keeps the card icon-light and locale-independent. */ private fun RoadEventType.emoji(): String = @@ -84,8 +84,8 @@ private fun RoadEventType.labelRes(): Int = * Self-contained card for a Roadstr road event report (kind 1315). * * Shows the category (emoji + localized label), the optional free-text comment, - * and the location resolved from the finest published geohash. Identical in the - * feed and the opened thread view, so it takes no `makeItShort` flag โ€” mirroring + * and a map pinned at the event's coordinates. Identical in the feed and the + * opened thread view, so it takes no `makeItShort` flag โ€” mirroring * [RenderBirdex]. */ @Composable @@ -94,7 +94,7 @@ fun RenderRoadEventReport(baseNote: Note) { val type = remember(noteEvent) { noteEvent.roadEventType() } val comment = remember(noteEvent) { noteEvent.content.trim() } - val geohash = remember(noteEvent) { noteEvent.geohashes().maxByOrNull { it.length } } + val point = remember(noteEvent) { noteEvent.roadEventPoint() } Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) { val title = @@ -116,9 +116,9 @@ fun RenderRoadEventReport(baseNote: Note) { ) } - if (geohash != null) { - Spacer(Modifier.height(6.dp)) - RoadEventLocation(geohash) + if (point != null) { + Spacer(Modifier.height(8.dp)) + RoadEventMap(point) } } } @@ -134,7 +134,7 @@ fun RenderRoadEventConfirmation(baseNote: Note) { val noteEvent = baseNote.event as? RoadEventConfirmationEvent ?: return val status = remember(noteEvent) { noteEvent.status() } - val geohash = remember(noteEvent) { noteEvent.geohashes().maxByOrNull { it.length } } + val point = remember(noteEvent) { noteEvent.roadEventPoint() } Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) { val emoji = if (status == RoadEventStatus.NO_LONGER_THERE) "โŒ" else "โœ…" @@ -161,26 +161,40 @@ fun RenderRoadEventConfirmation(baseNote: Note) { color = MaterialTheme.colorScheme.placeholderText, ) - if (geohash != null) { - Spacer(Modifier.height(6.dp)) - RoadEventLocation(geohash) + if (point != null) { + Spacer(Modifier.height(8.dp)) + RoadEventMap(point) } } } -/** A single "๐Ÿ“ City" line that reverse-geocodes [geohash], falling back to the raw geohash. */ +/** A rounded OpenStreetMap preview with a pin at the road event's [point]. */ @Composable -private fun RoadEventLocation(geohash: String) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { - Text(text = "๐Ÿ“", style = MaterialTheme.typography.bodyMedium) - LoadCityName(geohashStr = geohash) { cityName -> - Text( - text = cityName, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } +private fun RoadEventMap(point: Pair) { + LocationPreviewMap( + latitude = point.first, + longitude = point.second, + modifier = Modifier.clip(RoundedCornerShape(8.dp)), + ) +} + +/** + * Best available coordinate for the card map: the event's explicit `lat`/`lon` + * tags when present, otherwise the center of the finest published geohash. + * Returns null when the event carries no location at all. + */ +private fun RoadEventReportEvent.roadEventPoint(): Pair? = resolveRoadEventPoint(latitude(), longitude(), geohashes()) + +private fun RoadEventConfirmationEvent.roadEventPoint(): Pair? = resolveRoadEventPoint(latitude(), longitude(), geohashes()) + +private fun resolveRoadEventPoint( + latitude: Double?, + longitude: Double?, + geohashes: List, +): Pair? { + if (latitude != null && longitude != null) return latitude to longitude + + val finest = geohashes.maxByOrNull { it.length } ?: return null + val decoded = runCatching { finest.toGeoHash() }.getOrNull() ?: return null + return decoded.centerLat to decoded.centerLon } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index be11853836..60600193f5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -55,6 +55,7 @@ negentropyKmp = "v1.0.2" netUrlencoderLibVersion = "1.6.0" navigationCompose = "2.9.8" okhttp = "5.4.0" +osmdroid = "6.1.20" runner = "1.7.0" secp256k1KmpJniAndroid = "0.23.0" schnorr256k1Kmp = "1.0.5" @@ -193,6 +194,7 @@ negentropy-kmp = { module = "com.vitorpamplona.negentropy:kmp-negentropy", versi net-thauvin-erik-urlencoder-lib = { module = "net.thauvin.erik.urlencoder:urlencoder-lib", version.ref = "netUrlencoderLibVersion" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" } +osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" } ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" } ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio", version.ref = "ktor" } ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" } From 31a375034a40aa905fed16cd3d7f346f0c8abf56 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 21:52:29 +0000 Subject: [PATCH 02/12] fix: make road event location map square (1:1 aspect ratio) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017tYbcy4UGWxqQbcycyL7Yd --- .../amethyst/ui/note/creators/location/LocationPreviewMap.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt index 2918045f30..68f9a75992 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt @@ -21,14 +21,13 @@ package com.vitorpamplona.amethyst.ui.note.creators.location import android.view.MotionEvent +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -107,7 +106,7 @@ fun LocationPreviewMap( } AndroidView( - modifier = modifier.fillMaxWidth().height(180.dp), + modifier = modifier.fillMaxWidth().aspectRatio(1f), factory = { mapView }, update = { map -> val point = GeoPoint(latitude, longitude) From 2e32660d9bbfc081db37c70af0080f09b45bfef0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 22:38:07 +0000 Subject: [PATCH 03/12] feat: roadstr-accurate road event rendering (kind 1315/1316) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align Amethyst's road event cards with the roadstr reference clients (https://github.com/jooray/roadstr) for both interoperability and a richer presentation. Interop: - Match roadstr's exact emoji set: road_closure ๐Ÿšซ (was โ›”) and other โ„น๏ธ (was ๐Ÿ“). The t codes and per-type TTLs already matched. - Make the kind 1316 NIP-31 alt status-dependent ("Roadstr: event confirmed" / "Roadstr: event denied") per the spec, instead of a single "Roadstr: event confirmation". Rendering: - Colored teardrop map pin per category, using roadstr's exact color palette, with the category emoji on the head (new MapPinIcon). - Freshness: fade the report pin to 0.6 under 25% of effective TTL and 0.4 once effectively expired, matching roadstr's opacity rule. - Subtitle meta line: "๐Ÿ•’ 23m ยท expires in 1h" / "ยท Expired" on reports and "๐Ÿ•’ 23m" on confirmations. - Confirmations get a green โœ… / red โŒ status pin. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017tYbcy4UGWxqQbcycyL7Yd --- .../creators/location/LocationPreviewMap.kt | 23 +++ .../ui/note/creators/location/MapPinIcon.kt | 102 ++++++++++++ .../amethyst/ui/note/types/RoadEvent.kt | 148 +++++++++++++++--- amethyst/src/main/res/values/strings.xml | 2 + .../RoadEventConfirmationEvent.kt | 11 +- .../experimental/roadstr/RoadEventTest.kt | 6 +- 6 files changed, 268 insertions(+), 24 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/MapPinIcon.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt index 68f9a75992..8a03c6b6a8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.location +import android.graphics.drawable.BitmapDrawable import android.view.MotionEvent import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth @@ -27,6 +28,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle @@ -51,6 +54,11 @@ private const val DEFAULT_ZOOM = 16.0 * servers, which requires the app's package name as the User-Agent (set on * [Configuration] below) โ€” without it OSM returns HTTP 403. * + * When [pinColor] and [pinEmoji] are supplied the marker becomes a colored + * teardrop with the category emoji on it (see [roadEventPinBitmap]); otherwise + * osmdroid's default pin is used. [pinAlpha] fades the marker to signal + * freshness (e.g. an event close to its effective expiry). + * * Pan/zoom stay enabled, but a touch listener asks the parent to stop * intercepting gestures while the finger is on the map, so dragging the map * pans it instead of scrolling the surrounding feed. @@ -61,10 +69,23 @@ fun LocationPreviewMap( longitude: Double, modifier: Modifier = Modifier, zoom: Double = DEFAULT_ZOOM, + pinColor: Color? = null, + pinEmoji: String? = null, + pinAlpha: Float = 1f, ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current + val markerIcon = + remember(pinColor, pinEmoji) { + if (pinColor != null && pinEmoji != null) { + val bitmap = roadEventPinBitmap(pinEmoji, pinColor.toArgb(), context.resources.displayMetrics.density) + BitmapDrawable(context.resources, bitmap) + } else { + null + } + } + val mapView = remember(context) { // Must be set before the MapView is created so OSM tile requests @@ -119,6 +140,8 @@ fun LocationPreviewMap( position = point setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) setInfoWindow(null) + markerIcon?.let { icon = it } + alpha = pinAlpha } map.overlays.add(marker) map.invalidate() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/MapPinIcon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/MapPinIcon.kt new file mode 100644 index 0000000000..8e9f422ccc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/MapPinIcon.kt @@ -0,0 +1,102 @@ +/* + * 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.note.creators.location + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import kotlin.math.roundToInt + +/** + * Draws a Roadstr-style map pin: a colored teardrop in [colorArgb] with [emoji] + * centered on its head, a white ring, and a soft drop shadow. + * + * The pin's tip sits at the bottom-center of the returned bitmap, so the + * osmdroid marker should be anchored at (0.5, 1.0) โ€” the geographic point then + * lands exactly under the tip. + * + * Matches the visual language of the roadstr reference clients + * (): the same per-category color is used so + * a pin reads the same across implementations. + */ +fun roadEventPinBitmap( + emoji: String, + colorArgb: Int, + density: Float, +): Bitmap { + fun dp(value: Float) = value * density + + val head = dp(34f) // head diameter + val radius = head / 2f + val tail = dp(12f) // pointer height below the head + val pad = dp(5f) // room for the drop shadow + val ringWidth = dp(2.5f) + + val width = (head + pad * 2).roundToInt() + val height = (head + tail + pad * 2).roundToInt() + val cx = width / 2f + val cy = pad + radius + + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + + // Head circle + pointer drawn as a single path so they share one shadow + // instead of seaming where the triangle meets the circle. + val body = + Path().apply { + addCircle(cx, cy, radius, Path.Direction.CW) + val mouth = radius * 0.7f + moveTo(cx - mouth, cy + radius * 0.55f) + lineTo(cx + mouth, cy + radius * 0.55f) + lineTo(cx, pad + head + tail) + close() + } + + val fill = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = colorArgb + style = Paint.Style.FILL + setShadowLayer(dp(3f), 0f, dp(2f), 0x80000000.toInt()) + } + canvas.drawPath(body, fill) + + val ring = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + style = Paint.Style.STROKE + strokeWidth = ringWidth + } + canvas.drawCircle(cx, cy, radius - ringWidth / 2f, ring) + + // Color emoji fonts ignore the paint color, so the glyph keeps its own hue. + val text = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + textAlign = Paint.Align.CENTER + textSize = head * 0.58f + } + val metrics = text.fontMetrics + val baseline = cy - (metrics.ascent + metrics.descent) / 2f + canvas.drawText(emoji, cx, baseline, text) + + return bitmap +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt index 0c71ef351c..10eb368320 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.note.types +import android.content.Context import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height @@ -31,12 +32,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.note.creators.location.LocationPreviewMap +import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent @@ -44,15 +49,20 @@ import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadEvent import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent import com.vitorpamplona.quartz.experimental.roadstr.report.tags.RoadEventType import com.vitorpamplona.quartz.nip01Core.tags.geohash.toGeoHash +import com.vitorpamplona.quartz.utils.TimeUtils -/** Emoji marker for a road event category โ€” keeps the card icon-light and locale-independent. */ +/** + * Emoji marker for a road event category. Mirrors the icon set of the roadstr + * reference clients () so a report reads the + * same across implementations; keeps the card icon-light and locale-independent. + */ private fun RoadEventType.emoji(): String = when (this) { RoadEventType.POLICE -> "๐Ÿ‘ฎ" RoadEventType.SPEED_CAMERA -> "๐Ÿ“ท" RoadEventType.TRAFFIC_JAM -> "๐Ÿš—" RoadEventType.ACCIDENT -> "๐Ÿ’ฅ" - RoadEventType.ROAD_CLOSURE -> "โ›”" + RoadEventType.ROAD_CLOSURE -> "๐Ÿšซ" RoadEventType.CONSTRUCTION -> "๐Ÿšง" RoadEventType.HAZARD -> "โš ๏ธ" RoadEventType.ROAD_CONDITION -> "๐Ÿ›ฃ๏ธ" @@ -60,7 +70,29 @@ private fun RoadEventType.emoji(): String = RoadEventType.FOG -> "๐ŸŒซ๏ธ" RoadEventType.ICE -> "๐ŸงŠ" RoadEventType.ANIMAL -> "๐ŸฆŒ" - RoadEventType.OTHER -> "๐Ÿ“" + RoadEventType.OTHER -> "โ„น๏ธ" + } + +/** + * Per-category pin color. Uses the exact palette from the roadstr reference + * clients () so a pin's color carries the + * same meaning across the network. + */ +private fun RoadEventType.color(): Color = + when (this) { + RoadEventType.POLICE -> Color(0xFF0000FF) + RoadEventType.SPEED_CAMERA -> Color(0xFF800080) + RoadEventType.TRAFFIC_JAM -> Color(0xFFFF8C00) + RoadEventType.ACCIDENT -> Color(0xFFFF0000) + RoadEventType.ROAD_CLOSURE -> Color(0xFF8B0000) + RoadEventType.CONSTRUCTION -> Color(0xFFFFD700) + RoadEventType.HAZARD -> Color(0xFFFF4500) + RoadEventType.ROAD_CONDITION -> Color(0xFF4682B4) + RoadEventType.POTHOLE -> Color(0xFF795548) + RoadEventType.FOG -> Color(0xFF9E9E9E) + RoadEventType.ICE -> Color(0xFF00CED1) + RoadEventType.ANIMAL -> Color(0xFF4CAF50) + RoadEventType.OTHER -> Color(0xFF808080) } private fun RoadEventType.labelRes(): Int = @@ -91,10 +123,13 @@ private fun RoadEventType.labelRes(): Int = @Composable fun RenderRoadEventReport(baseNote: Note) { val noteEvent = baseNote.event as? RoadEventReportEvent ?: return + val context = LocalContext.current val type = remember(noteEvent) { noteEvent.roadEventType() } val comment = remember(noteEvent) { noteEvent.content.trim() } val point = remember(noteEvent) { noteEvent.roadEventPoint() } + val freshness = remember(noteEvent) { noteEvent.freshnessAlpha() } + val meta = remember(noteEvent) { noteEvent.metaLine(context) } Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) { val title = @@ -106,6 +141,13 @@ fun RenderRoadEventReport(baseNote: Note) { Text(text = title, style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(2.dp)) + Text( + text = meta, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.placeholderText, + ) + if (comment.isNotEmpty()) { Spacer(Modifier.height(6.dp)) Text( @@ -118,7 +160,12 @@ fun RenderRoadEventReport(baseNote: Note) { if (point != null) { Spacer(Modifier.height(8.dp)) - RoadEventMap(point) + RoadEventMap( + point = point, + pinColor = type?.color(), + pinEmoji = type?.emoji(), + pinAlpha = freshness, + ) } } } @@ -132,30 +179,33 @@ fun RenderRoadEventReport(baseNote: Note) { @Composable fun RenderRoadEventConfirmation(baseNote: Note) { val noteEvent = baseNote.event as? RoadEventConfirmationEvent ?: return + val context = LocalContext.current val status = remember(noteEvent) { noteEvent.status() } val point = remember(noteEvent) { noteEvent.roadEventPoint() } + val age = remember(noteEvent) { timeAgoNoDot(noteEvent.createdAt, context) } + + val denied = status == RoadEventStatus.NO_LONGER_THERE + val emoji = if (denied) "โŒ" else "โœ…" + val pinColor = if (denied) Color(0xFFF44336) else Color(0xFF4CAF50) Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) { - val emoji = if (status == RoadEventStatus.NO_LONGER_THERE) "โŒ" else "โœ…" - val titleRes = - if (status == RoadEventStatus.NO_LONGER_THERE) { - R.string.road_event_denied - } else { - R.string.road_event_confirmed - } + val titleRes = if (denied) R.string.road_event_denied else R.string.road_event_confirmed Text(text = "$emoji ${stringResource(titleRes)}", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(2.dp)) + Text( + text = "๐Ÿ•’ $age", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.placeholderText, + ) + Spacer(Modifier.height(4.dp)) Text( text = stringResource( - if (status == RoadEventStatus.NO_LONGER_THERE) { - R.string.road_event_denies_report - } else { - R.string.road_event_confirms_report - }, + if (denied) R.string.road_event_denies_report else R.string.road_event_confirms_report, ), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.placeholderText, @@ -163,18 +213,30 @@ fun RenderRoadEventConfirmation(baseNote: Note) { if (point != null) { Spacer(Modifier.height(8.dp)) - RoadEventMap(point) + RoadEventMap( + point = point, + pinColor = pinColor, + pinEmoji = emoji, + ) } } } -/** A rounded OpenStreetMap preview with a pin at the road event's [point]. */ +/** A rounded OpenStreetMap preview with a colored emoji pin at the road event's [point]. */ @Composable -private fun RoadEventMap(point: Pair) { +private fun RoadEventMap( + point: Pair, + pinColor: Color? = null, + pinEmoji: String? = null, + pinAlpha: Float = 1f, +) { LocationPreviewMap( latitude = point.first, longitude = point.second, modifier = Modifier.clip(RoundedCornerShape(8.dp)), + pinColor = pinColor, + pinEmoji = pinEmoji, + pinAlpha = pinAlpha, ) } @@ -198,3 +260,51 @@ private fun resolveRoadEventPoint( val decoded = runCatching { finest.toGeoHash() }.getOrNull() ?: return null return decoded.centerLat to decoded.centerLon } + +/** + * Marker opacity by freshness, matching the roadstr clients: full while the + * report has plenty of life left, dimmed to 0.6 once under 25% of its effective + * TTL remains, and faded to 0.4 once effectively expired. Returns 1f when the + * type (and therefore the TTL) is unknown. + */ +private fun RoadEventReportEvent.freshnessAlpha(now: Long = TimeUtils.now()): Float { + val expiryAt = effectiveExpirationAt() ?: return 1f + val total = expiryAt - createdAt + if (total <= 0L) return 1f + + val fraction = (expiryAt - now).toFloat() / total + return when { + fraction <= 0f -> 0.4f + fraction < 0.25f -> 0.6f + else -> 1f + } +} + +/** "๐Ÿ•’ 23m ยท expires in 1h" (or "ยท expired") for the report card subtitle. */ +private fun RoadEventReportEvent.metaLine( + context: Context, + now: Long = TimeUtils.now(), +): String { + val age = "๐Ÿ•’ ${timeAgoNoDot(createdAt, context)}" + val expiryAt = effectiveExpirationAt() ?: return age + + val remaining = expiryAt - now + val expiry = + if (remaining <= 0L) { + stringRes(context, R.string.road_event_expired) + } else { + stringRes(context, R.string.road_event_expires_in, formatDuration(context, remaining)) + } + return "$age ยท $expiry" +} + +/** Compact forward duration ("2d" / "3h" / "45m") reusing the time-ago unit strings. */ +private fun formatDuration( + context: Context, + seconds: Long, +): String = + when { + seconds >= TimeUtils.ONE_DAY -> "${seconds / TimeUtils.ONE_DAY}${stringRes(context, R.string.d)}" + seconds >= TimeUtils.ONE_HOUR -> "${seconds / TimeUtils.ONE_HOUR}${stringRes(context, R.string.h)}" + else -> "${(seconds / TimeUtils.ONE_MINUTE).coerceAtLeast(1)}${stringRes(context, R.string.m)}" + } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 56b204c08e..8c2070440f 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3464,6 +3464,8 @@ No longer there Confirms a road report Reports a road event cleared + Expires in %1$s + Expired Goal amount (sats) 100000 Describe your goal diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/RoadEventConfirmationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/RoadEventConfirmationEvent.kt index 9259df9d45..746fe9200b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/RoadEventConfirmationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/RoadEventConfirmationEvent.kt @@ -89,7 +89,14 @@ class RoadEventConfirmationEvent( companion object { const val KIND = 1316 - const val ALT_DESCRIPTION = "Roadstr: event confirmation" + + /** NIP-31 fallback for a confirmation (`still_there`), per the roadstr spec. */ + const val ALT_CONFIRMED = "Roadstr: event confirmed" + + /** NIP-31 fallback for a denial (`no_longer_there`), per the roadstr spec. */ + const val ALT_DENIED = "Roadstr: event denied" + + fun altDescription(status: RoadEventStatus) = if (status == RoadEventStatus.NO_LONGER_THERE) ALT_DENIED else ALT_CONFIRMED fun build( reportId: HexKey, @@ -106,7 +113,7 @@ class RoadEventConfirmationEvent( coordinates(latitude, longitude) } expiration(createdAt + RoadEventReportEvent.RELAY_TTL_SECONDS) - alt(ALT_DESCRIPTION) + alt(altDescription(status)) initializer() } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventTest.kt index 16a7f82706..fd66eeb1d2 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventTest.kt @@ -69,7 +69,7 @@ class RoadEventTest { arrayOf("e", reportId), arrayOf("status", "no_longer_there"), arrayOf("expiration", "1701213200"), - arrayOf("alt", "Roadstr: event confirmation"), + arrayOf("alt", "Roadstr: event denied"), ), content = "", sig = "00".repeat(64), @@ -129,7 +129,7 @@ class RoadEventTest { assertEquals(RoadEventStatus.NO_LONGER_THERE, event.status()) assertTrue(event.isDenial()) assertTrue(!event.isConfirmation()) - assertEquals("Roadstr: event confirmation", event.alt()) + assertEquals("Roadstr: event denied", event.alt()) } @Test @@ -161,7 +161,7 @@ class RoadEventTest { assertEquals(reportId, template.tags.first { it[0] == "e" }[1]) assertEquals("still_there", template.tags.first { it[0] == "status" }[1]) - assertEquals("Roadstr: event confirmation", template.tags.first { it[0] == "alt" }[1]) + assertEquals("Roadstr: event confirmed", template.tags.first { it[0] == "alt" }[1]) assertTrue(template.tags.none { it[0] == "g" || it[0] == "lat" || it[0] == "lon" }) } From aee2f776542a202c3e1aaf9a7531fabe7e08a623 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 22:47:44 +0000 Subject: [PATCH 04/12] refactor: drop redundant expiry/age row from road event cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NoteCompose's header already shows the post time and the NIP-40 expiration. The inner card's own "๐Ÿ•’ age ยท expires in โ€ฆ" line duplicated that and, worse, conflicted with it: the header reads the relay-side `expiration` tag (always created_at + 14d) while the card used the per-type effective TTL (e.g. 30d for a speed camera), so the two showed different countdowns for the same note. Remove the inner row; keep the freshness-based pin dimming, which is visual-only and doesn't conflict. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017tYbcy4UGWxqQbcycyL7Yd --- .../amethyst/ui/note/types/RoadEvent.kt | 51 ------------------- amethyst/src/main/res/values/strings.xml | 2 - 2 files changed, 53 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt index 10eb368320..1be9c18fd0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note.types -import android.content.Context import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height @@ -33,15 +32,12 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.note.creators.location.LocationPreviewMap -import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot -import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent @@ -123,13 +119,11 @@ private fun RoadEventType.labelRes(): Int = @Composable fun RenderRoadEventReport(baseNote: Note) { val noteEvent = baseNote.event as? RoadEventReportEvent ?: return - val context = LocalContext.current val type = remember(noteEvent) { noteEvent.roadEventType() } val comment = remember(noteEvent) { noteEvent.content.trim() } val point = remember(noteEvent) { noteEvent.roadEventPoint() } val freshness = remember(noteEvent) { noteEvent.freshnessAlpha() } - val meta = remember(noteEvent) { noteEvent.metaLine(context) } Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) { val title = @@ -141,13 +135,6 @@ fun RenderRoadEventReport(baseNote: Note) { Text(text = title, style = MaterialTheme.typography.titleMedium) - Spacer(Modifier.height(2.dp)) - Text( - text = meta, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.placeholderText, - ) - if (comment.isNotEmpty()) { Spacer(Modifier.height(6.dp)) Text( @@ -179,11 +166,9 @@ fun RenderRoadEventReport(baseNote: Note) { @Composable fun RenderRoadEventConfirmation(baseNote: Note) { val noteEvent = baseNote.event as? RoadEventConfirmationEvent ?: return - val context = LocalContext.current val status = remember(noteEvent) { noteEvent.status() } val point = remember(noteEvent) { noteEvent.roadEventPoint() } - val age = remember(noteEvent) { timeAgoNoDot(noteEvent.createdAt, context) } val denied = status == RoadEventStatus.NO_LONGER_THERE val emoji = if (denied) "โŒ" else "โœ…" @@ -194,13 +179,6 @@ fun RenderRoadEventConfirmation(baseNote: Note) { Text(text = "$emoji ${stringResource(titleRes)}", style = MaterialTheme.typography.titleMedium) - Spacer(Modifier.height(2.dp)) - Text( - text = "๐Ÿ•’ $age", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.placeholderText, - ) - Spacer(Modifier.height(4.dp)) Text( text = @@ -279,32 +257,3 @@ private fun RoadEventReportEvent.freshnessAlpha(now: Long = TimeUtils.now()): Fl else -> 1f } } - -/** "๐Ÿ•’ 23m ยท expires in 1h" (or "ยท expired") for the report card subtitle. */ -private fun RoadEventReportEvent.metaLine( - context: Context, - now: Long = TimeUtils.now(), -): String { - val age = "๐Ÿ•’ ${timeAgoNoDot(createdAt, context)}" - val expiryAt = effectiveExpirationAt() ?: return age - - val remaining = expiryAt - now - val expiry = - if (remaining <= 0L) { - stringRes(context, R.string.road_event_expired) - } else { - stringRes(context, R.string.road_event_expires_in, formatDuration(context, remaining)) - } - return "$age ยท $expiry" -} - -/** Compact forward duration ("2d" / "3h" / "45m") reusing the time-ago unit strings. */ -private fun formatDuration( - context: Context, - seconds: Long, -): String = - when { - seconds >= TimeUtils.ONE_DAY -> "${seconds / TimeUtils.ONE_DAY}${stringRes(context, R.string.d)}" - seconds >= TimeUtils.ONE_HOUR -> "${seconds / TimeUtils.ONE_HOUR}${stringRes(context, R.string.h)}" - else -> "${(seconds / TimeUtils.ONE_MINUTE).coerceAtLeast(1)}${stringRes(context, R.string.m)}" - } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 8c2070440f..56b204c08e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3464,8 +3464,6 @@ No longer there Confirms a road report Reports a road event cleared - Expires in %1$s - Expired Goal amount (sats) 100000 Describe your goal From 2b02a1341feeab9dd50ec04836406adca422faab Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 22:52:35 +0000 Subject: [PATCH 05/12] feat: modernize road event cards with a map-hero + floating pill Redesign the kind 1315/1316 cards from a text-title-over-small-map layout into a map-hero card: the OSM preview becomes a full-bleed hero with a floating, color-coded category pill overlaid on it (auto-contrast text), and the comment/status sits in a clean padded block below. Shared RoadEventCard + CategoryPill chrome backs both the report and the confirmation, with a graceful no-location fallback. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017tYbcy4UGWxqQbcycyL7Yd --- .../amethyst/ui/note/types/RoadEvent.kt | 181 +++++++++++------- 1 file changed, 111 insertions(+), 70 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt index 1be9c18fd0..b9f111aaff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt @@ -20,19 +20,24 @@ */ package com.vitorpamplona.amethyst.ui.note.types +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R @@ -111,10 +116,10 @@ private fun RoadEventType.labelRes(): Int = /** * Self-contained card for a Roadstr road event report (kind 1315). * - * Shows the category (emoji + localized label), the optional free-text comment, - * and a map pinned at the event's coordinates. Identical in the feed and the - * opened thread view, so it takes no `makeItShort` flag โ€” mirroring - * [RenderBirdex]. + * A map hero pinned at the event's coordinates, with a floating category pill + * (colored per [RoadEventType.color]) and the optional free-text comment below. + * Identical in the feed and the opened thread view, so it takes no `makeItShort` + * flag โ€” mirroring [RenderBirdex]. */ @Composable fun RenderRoadEventReport(baseNote: Note) { @@ -125,43 +130,25 @@ fun RenderRoadEventReport(baseNote: Note) { val point = remember(noteEvent) { noteEvent.roadEventPoint() } val freshness = remember(noteEvent) { noteEvent.freshnessAlpha() } - Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) { - val title = - if (type != null) { - "${type.emoji()} ${stringResource(type.labelRes())}" - } else { - stringResource(R.string.road_event_unknown) - } + val color = type?.color() ?: MaterialTheme.colorScheme.outline + val emoji = type?.emoji() ?: "๐Ÿ“" + val label = if (type != null) stringResource(type.labelRes()) else stringResource(R.string.road_event_unknown) - Text(text = title, style = MaterialTheme.typography.titleMedium) - - if (comment.isNotEmpty()) { - Spacer(Modifier.height(6.dp)) - Text( - text = comment, - style = MaterialTheme.typography.bodyMedium, - maxLines = 4, - overflow = TextOverflow.Ellipsis, - ) - } - - if (point != null) { - Spacer(Modifier.height(8.dp)) - RoadEventMap( - point = point, - pinColor = type?.color(), - pinEmoji = type?.emoji(), - pinAlpha = freshness, - ) - } - } + RoadEventCard( + chipColor = color, + chipEmoji = emoji, + chipLabel = label, + point = point, + pinAlpha = freshness, + comment = comment, + ) } /** * Self-contained card for a Roadstr confirmation/denial (kind 1316). * - * Shows whether the referenced report was confirmed (still there) or denied - * (no longer there), plus a one-line description of the action. + * Same hero/pill layout as a report, with a green โœ… "Still there" or red โŒ + * "No longer there" pill and a one-line description of the action. */ @Composable fun RenderRoadEventConfirmation(baseNote: Note) { @@ -172,50 +159,104 @@ fun RenderRoadEventConfirmation(baseNote: Note) { val denied = status == RoadEventStatus.NO_LONGER_THERE val emoji = if (denied) "โŒ" else "โœ…" - val pinColor = if (denied) Color(0xFFF44336) else Color(0xFF4CAF50) + val color = if (denied) Color(0xFFF44336) else Color(0xFF4CAF50) - Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) { - val titleRes = if (denied) R.string.road_event_denied else R.string.road_event_confirmed - - Text(text = "$emoji ${stringResource(titleRes)}", style = MaterialTheme.typography.titleMedium) - - Spacer(Modifier.height(4.dp)) - Text( - text = - stringResource( - if (denied) R.string.road_event_denies_report else R.string.road_event_confirms_report, - ), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.placeholderText, - ) + RoadEventCard( + chipColor = color, + chipEmoji = emoji, + chipLabel = stringResource(if (denied) R.string.road_event_denied else R.string.road_event_confirmed), + point = point, + subtitle = stringResource(if (denied) R.string.road_event_denies_report else R.string.road_event_confirms_report), + ) +} +/** + * Shared road event card chrome: a full-bleed [LocationPreviewMap] hero with a + * floating [CategoryPill] overlaid on its top-start, and an optional + * [subtitle] + [comment] block below. When [point] is null the pill stands on + * its own so the card still reads. + */ +@Composable +private fun RoadEventCard( + chipColor: Color, + chipEmoji: String, + chipLabel: String, + point: Pair?, + pinAlpha: Float = 1f, + comment: String = "", + subtitle: String? = null, +) { + Column(MaterialTheme.colorScheme.replyModifier) { if (point != null) { - Spacer(Modifier.height(8.dp)) - RoadEventMap( - point = point, - pinColor = pinColor, - pinEmoji = emoji, + Box(Modifier.fillMaxWidth()) { + LocationPreviewMap( + latitude = point.first, + longitude = point.second, + pinColor = chipColor, + pinEmoji = chipEmoji, + pinAlpha = pinAlpha, + ) + CategoryPill( + color = chipColor, + emoji = chipEmoji, + label = chipLabel, + modifier = Modifier.align(Alignment.TopStart).padding(10.dp), + ) + } + } else { + CategoryPill( + color = chipColor, + emoji = chipEmoji, + label = chipLabel, + modifier = Modifier.padding(12.dp), ) } + + if (subtitle != null || comment.isNotEmpty()) { + Column(Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + if (comment.isNotEmpty()) { + if (subtitle != null) Spacer(Modifier.height(4.dp)) + Text( + text = comment, + style = MaterialTheme.typography.bodyMedium, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) + } + } + } } } -/** A rounded OpenStreetMap preview with a colored emoji pin at the road event's [point]. */ +/** A floating rounded pill: [emoji] + [label] on a [color] background, text auto-contrasted. */ @Composable -private fun RoadEventMap( - point: Pair, - pinColor: Color? = null, - pinEmoji: String? = null, - pinAlpha: Float = 1f, +private fun CategoryPill( + color: Color, + emoji: String, + label: String, + modifier: Modifier = Modifier, ) { - LocationPreviewMap( - latitude = point.first, - longitude = point.second, - modifier = Modifier.clip(RoundedCornerShape(8.dp)), - pinColor = pinColor, - pinEmoji = pinEmoji, - pinAlpha = pinAlpha, - ) + Surface( + modifier = modifier, + color = color, + contentColor = if (color.luminance() > 0.55f) Color.Black else Color.White, + shape = RoundedCornerShape(50), + shadowElevation = 3.dp, + ) { + Text( + text = "$emoji $label", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 5.dp), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + } } /** From 595d12f81bc1333c70f1a6e6e3158106e63f1ee8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 23:00:55 +0000 Subject: [PATCH 06/12] fix: readable pill text via WCAG contrast, not a luminance threshold The category pill picked black/white text from a luminance threshold (0.55) that left white text on mid-tone colors (fog gray, ice turquoise, "other" gray), barely legible over a light map. Choose the text color by the higher WCAG contrast ratio against the pill color instead, so light pills now get black text. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017tYbcy4UGWxqQbcycyL7Yd --- .../amethyst/ui/note/types/RoadEvent.kt | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt index b9f111aaff..9033aa312c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt @@ -246,7 +246,7 @@ private fun CategoryPill( Surface( modifier = modifier, color = color, - contentColor = if (color.luminance() > 0.55f) Color.Black else Color.White, + contentColor = readableOn(color), shape = RoundedCornerShape(50), shadowElevation = 3.dp, ) { @@ -259,6 +259,19 @@ private fun CategoryPill( } } +/** + * Picks black or white โ€” whichever has the higher WCAG contrast ratio against + * [background]. A plain luminance threshold mis-handles mid-tone colors (e.g. + * turquoise/gray), leaving white text barely legible; the contrast ratio does + * not. + */ +private fun readableOn(background: Color): Color { + val l = background.luminance() + val contrastWithBlack = (l + 0.05f) / 0.05f + val contrastWithWhite = 1.05f / (l + 0.05f) + return if (contrastWithBlack >= contrastWithWhite) Color.Black else Color.White +} + /** * Best available coordinate for the card map: the event's explicit `lat`/`lon` * tags when present, otherwise the center of the finest published geohash. From ca11b0a37b16e8d4b9e971d4fbf7bb05112f8c77 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 23:06:30 +0000 Subject: [PATCH 07/12] Revert "fix: readable pill text via WCAG contrast, not a luminance threshold" This reverts commit 595d12f81bc1333c70f1a6e6e3158106e63f1ee8. --- .../amethyst/ui/note/types/RoadEvent.kt | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt index 9033aa312c..b9f111aaff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt @@ -246,7 +246,7 @@ private fun CategoryPill( Surface( modifier = modifier, color = color, - contentColor = readableOn(color), + contentColor = if (color.luminance() > 0.55f) Color.Black else Color.White, shape = RoundedCornerShape(50), shadowElevation = 3.dp, ) { @@ -259,19 +259,6 @@ private fun CategoryPill( } } -/** - * Picks black or white โ€” whichever has the higher WCAG contrast ratio against - * [background]. A plain luminance threshold mis-handles mid-tone colors (e.g. - * turquoise/gray), leaving white text barely legible; the contrast ratio does - * not. - */ -private fun readableOn(background: Color): Color { - val l = background.luminance() - val contrastWithBlack = (l + 0.05f) / 0.05f - val contrastWithWhite = 1.05f / (l + 0.05f) - return if (contrastWithBlack >= contrastWithWhite) Color.Black else Color.White -} - /** * Best available coordinate for the card map: the event's explicit `lat`/`lon` * tags when present, otherwise the center of the finest published geohash. From bdc0c192576b41ce48fcce0ee102bf499479a4c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 23:08:48 +0000 Subject: [PATCH 08/12] fix: give road event comment an explicit on-surface color The comment Text inherited LocalContentColor, which resolved to a near-white in this card and made the report content hard to read on the light map card. Color it onSurface so it stays readable in both themes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017tYbcy4UGWxqQbcycyL7Yd --- .../java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt index b9f111aaff..3594b98919 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt @@ -226,6 +226,7 @@ private fun RoadEventCard( Text( text = comment, style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, maxLines = 4, overflow = TextOverflow.Ellipsis, ) From 2d563aba077448f3250a435d2dce244ff73e9511 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 23:15:38 +0000 Subject: [PATCH 09/12] feat: make the road event map follow the app's dark theme osmdroid's MAPNIK tiles are always light, so in dark mode the bright map clashed with the UI and light overlays. Apply a night-mode colour-matrix filter to the tiles overlay when MaterialTheme.colorScheme.isLight is false (lightness-inverted + desaturated for a clean dark-grey map, not a plain invert that turns forests magenta). Light theme keeps normal tiles. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017tYbcy4UGWxqQbcycyL7Yd --- .../creators/location/LocationPreviewMap.kt | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt index 8a03c6b6a8..06c8000794 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt @@ -20,10 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.location +import android.graphics.ColorFilter +import android.graphics.ColorMatrixColorFilter import android.graphics.drawable.BitmapDrawable import android.view.MotionEvent import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember @@ -35,6 +38,7 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import com.vitorpamplona.amethyst.ui.theme.isLight import org.osmdroid.config.Configuration import org.osmdroid.tileprovider.tilesource.TileSourceFactory import org.osmdroid.util.GeoPoint @@ -45,6 +49,38 @@ import org.osmdroid.views.overlay.Marker /** Default close-up zoom for a single pinned location (street-level). */ private const val DEFAULT_ZOOM = 16.0 +/** + * Night-mode filter for the (always-light) MAPNIK tiles so the map follows the + * app theme. Inverts lightness while desaturating, which reads as a clean dark + * grey map โ€” unlike a plain colour invert, which turns forests magenta and + * water orange. + */ +private val NIGHT_TILE_FILTER: ColorFilter = + ColorMatrixColorFilter( + floatArrayOf( + -0.6f, + -0.4f, + -0.4f, + 0f, + 255f, + -0.4f, + -0.6f, + -0.4f, + 0f, + 255f, + -0.4f, + -0.4f, + -0.6f, + 0f, + 255f, + 0f, + 0f, + 0f, + 1f, + 0f, + ), + ) + /** * A small OpenStreetMap (osmdroid) preview centered on [latitude]/[longitude] * with a single pin at that point. @@ -75,6 +111,7 @@ fun LocationPreviewMap( ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current + val darkTheme = !MaterialTheme.colorScheme.isLight val markerIcon = remember(pinColor, pinEmoji) { @@ -134,6 +171,9 @@ fun LocationPreviewMap( map.controller.setZoom(zoom) map.controller.setCenter(point) + // Follow the app theme: dim the bright MAPNIK tiles in dark mode. + map.overlayManager.tilesOverlay.setColorFilter(if (darkTheme) NIGHT_TILE_FILTER else null) + map.overlays.removeAll { it is Marker } val marker = Marker(map).apply { From f0ea68f44032dac9443338abe16cdd1937412cc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 23:24:30 +0000 Subject: [PATCH 10/12] fix: night map preserves hue instead of crushing to black MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous night filter used a desaturating invert whose coefficients summed to ~1.4 per channel, which pushed every (light) OSM tone to near-black and erased forest/water/land differentiation. Replace it with a lightness invert composed with a 180ยฐ hue rotation: the map darkens but hue is preserved, so forests stay dark green, water dark blue, and labels invert to readable white. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017tYbcy4UGWxqQbcycyL7Yd --- .../creators/location/LocationPreviewMap.kt | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt index 06c8000794..984c3fc985 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt @@ -51,26 +51,28 @@ private const val DEFAULT_ZOOM = 16.0 /** * Night-mode filter for the (always-light) MAPNIK tiles so the map follows the - * app theme. Inverts lightness while desaturating, which reads as a clean dark - * grey map โ€” unlike a plain colour invert, which turns forests magenta and - * water orange. + * app theme. This is a lightness invert composed with a 180ยฐ hue rotation: it + * darkens the map (light land โ†’ near-black) while *preserving* hue, so forests + * stay green and water stays blue and the surfaces remain distinguishable โ€” + * unlike a plain colour invert (forests โ†’ magenta) or a desaturating invert + * (everything โ†’ black). */ private val NIGHT_TILE_FILTER: ColorFilter = ColorMatrixColorFilter( floatArrayOf( - -0.6f, - -0.4f, - -0.4f, + 0.574f, + -1.430f, + -0.144f, 0f, 255f, - -0.4f, - -0.6f, - -0.4f, + -0.426f, + -0.430f, + -0.144f, 0f, 255f, - -0.4f, - -0.4f, - -0.6f, + -0.426f, + -1.430f, + 0.856f, 0f, 255f, 0f, From e93dffa9b57cac6f265291b4c5015205bf9a179a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 23:36:09 +0000 Subject: [PATCH 11/12] feat: include road events (1315/1316) in hashtag and geohash feeds Add the Roadstr report (1315) and confirmation (1316) kinds to both the relay subscription filters and the local feed gates for the hashtag and geohash screens (the geohash kind list is shared with the home "around me" feed). Reports carry geohash `g` tags and a category `t` tag, so they now surface in nearby-geohash feeds and in the matching category hashtag feed; confirmations carry `g` tags and surface in geohash feeds. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017tYbcy4UGWxqQbcycyL7Yd --- .../ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt | 6 +++++- .../loggedIn/geohash/datasource/FilterPostsByGeohash.kt | 4 ++++ .../ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt | 6 +++++- .../loggedIn/hashtag/datasource/FilterPostsByHashtags.kt | 4 ++++ 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt index 52f726d396..81b4c333fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt @@ -28,6 +28,8 @@ import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent +import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -86,7 +88,9 @@ class GeoHashFeedFilter( event is MusicTrackEvent || event is MusicPlaylistEvent || event is PodcastEpisodeEvent || - event is PodcastMetadataEvent + event is PodcastMetadataEvent || + event is RoadEventReportEvent || + event is RoadEventConfirmationEvent ) && event.isTaggedGeoHash(geohash) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt index 0fa9ec6fc3..86e8f49fa0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt @@ -26,6 +26,8 @@ import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent +import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -57,6 +59,8 @@ val PostsByGeohashKinds = PodcastEpisodeEvent.KIND, PodcastMetadataEvent.KIND, WikiNoteEvent.KIND, + RoadEventReportEvent.KIND, + RoadEventConfirmationEvent.KIND, ) fun filterPostsByGeohash( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt index 34eb2eca1e..a9fce8641d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt @@ -29,6 +29,8 @@ import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent +import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -113,7 +115,9 @@ class HashtagFeedFilter( event is MusicTrackEvent || event is MusicPlaylistEvent || event is PodcastEpisodeEvent || - event is PodcastMetadataEvent + event is PodcastMetadataEvent || + event is RoadEventReportEvent || + event is RoadEventConfirmationEvent ) && event.isTaggedHash(hashTag) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt index 4941239783..e893b3b3a0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt @@ -28,6 +28,8 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent +import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -70,6 +72,8 @@ val PostsByHashtagKinds2 = PodcastMetadataEvent.KIND, NipTextEvent.KIND, ZapPollEvent.KIND, + RoadEventReportEvent.KIND, + RoadEventConfirmationEvent.KIND, ) fun filterPostsByHashtags( From 0a3ce4535e672d87becaaf46c5f119beb96a34b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 23:42:28 +0000 Subject: [PATCH 12/12] refactor: limit road event feed inclusion to geohash only Back out the hashtag-screen wiring (its `t` tag is the event category, not a topic, so road events only matched category-named hashtags). Keep the geohash data source + feed filter, where road events belong by their `g` tags. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017tYbcy4UGWxqQbcycyL7Yd --- .../ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt | 6 +----- .../loggedIn/hashtag/datasource/FilterPostsByHashtags.kt | 4 ---- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt index a9fce8641d..34eb2eca1e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt @@ -29,8 +29,6 @@ import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent -import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent -import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -115,9 +113,7 @@ class HashtagFeedFilter( event is MusicTrackEvent || event is MusicPlaylistEvent || event is PodcastEpisodeEvent || - event is PodcastMetadataEvent || - event is RoadEventReportEvent || - event is RoadEventConfirmationEvent + event is PodcastMetadataEvent ) && event.isTaggedHash(hashTag) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt index e893b3b3a0..4941239783 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt @@ -28,8 +28,6 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent -import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent -import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -72,8 +70,6 @@ val PostsByHashtagKinds2 = PodcastMetadataEvent.KIND, NipTextEvent.KIND, ZapPollEvent.KIND, - RoadEventReportEvent.KIND, - RoadEventConfirmationEvent.KIND, ) fun filterPostsByHashtags(