Merge pull request #3277 from vitorpamplona/claude/modest-planck-o0fy3v

Roadstr: visual overhaul with map hero and category pills
This commit is contained in:
Vitor Pamplona
2026-06-18 19:45:19 -04:00
committed by GitHub
9 changed files with 513 additions and 84 deletions
+3
View File
@@ -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"
@@ -0,0 +1,192 @@
/*
* 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.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
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
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
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
/**
* Night-mode filter for the (always-light) MAPNIK tiles so the map follows the
* 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.574f,
-1.430f,
-0.144f,
0f,
255f,
-0.426f,
-0.430f,
-0.144f,
0f,
255f,
-0.426f,
-1.430f,
0.856f,
0f,
255f,
0f,
0f,
0f,
1f,
0f,
),
)
/**
* 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.
*
* 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.
*/
@Composable
fun LocationPreviewMap(
latitude: Double,
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 darkTheme = !MaterialTheme.colorScheme.isLight
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
// 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().aspectRatio(1f),
factory = { mapView },
update = { map ->
val point = GeoPoint(latitude, longitude)
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 {
position = point
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
setInfoWindow(null)
markerIcon?.let { icon = it }
alpha = pinAlpha
}
map.overlays.add(marker)
map.invalidate()
},
)
}
@@ -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
* (<https://github.com/jooray/roadstr>): 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
}
@@ -20,39 +20,50 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.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.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
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
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 (<https://github.com/jooray/roadstr>) 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 +71,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 (<https://github.com/jooray/roadstr>) 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 =
@@ -83,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 the location resolved from the finest published geohash. 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) {
@@ -94,93 +127,175 @@ 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() }
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,
RoadEventCard(
chipColor = color,
chipEmoji = emoji,
chipLabel = label,
point = point,
pinAlpha = freshness,
comment = comment,
)
}
if (geohash != null) {
Spacer(Modifier.height(6.dp))
RoadEventLocation(geohash)
}
}
}
/**
* 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) {
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 "✅"
val titleRes =
if (status == RoadEventStatus.NO_LONGER_THERE) {
R.string.road_event_denied
} else {
R.string.road_event_confirmed
val denied = status == RoadEventStatus.NO_LONGER_THERE
val emoji = if (denied) "❌" else "✅"
val color = if (denied) Color(0xFFF44336) else Color(0xFF4CAF50)
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),
)
}
Text(text = "$emoji ${stringResource(titleRes)}", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
Text(
text =
stringResource(
if (status == RoadEventStatus.NO_LONGER_THERE) {
R.string.road_event_denies_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<Double, Double>?,
pinAlpha: Float = 1f,
comment: String = "",
subtitle: String? = null,
) {
Column(MaterialTheme.colorScheme.replyModifier) {
if (point != null) {
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 {
R.string.road_event_confirms_report
},
),
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 (geohash != null) {
Spacer(Modifier.height(6.dp))
RoadEventLocation(geohash)
}
}
}
/** A single "📍 City" line that reverse-geocodes [geohash], falling back to the raw geohash. */
@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 ->
if (comment.isNotEmpty()) {
if (subtitle != null) Spacer(Modifier.height(4.dp))
Text(
text = cityName,
text = comment,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
/** A floating rounded pill: [emoji] + [label] on a [color] background, text auto-contrasted. */
@Composable
private fun CategoryPill(
color: Color,
emoji: String,
label: String,
modifier: Modifier = Modifier,
) {
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,
)
}
}
/**
* 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<Double, Double>? = resolveRoadEventPoint(latitude(), longitude(), geohashes())
private fun RoadEventConfirmationEvent.roadEventPoint(): Pair<Double, Double>? = resolveRoadEventPoint(latitude(), longitude(), geohashes())
private fun resolveRoadEventPoint(
latitude: Double?,
longitude: Double?,
geohashes: List<String>,
): Pair<Double, Double>? {
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
}
/**
* 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
}
}
@@ -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)
@@ -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(
+2
View File
@@ -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" }
@@ -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()
}
@@ -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" })
}