From 61255198b3063db70d09a2bf1f25b7139553215f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 20:22:08 +0000 Subject: [PATCH 1/2] feat(roadstr): add kind 1315/1316 road event support Adds Quartz protocol support and Amethyst rendering for the Roadstr decentralized traffic-reporting events (https://github.com/jooray/roadstr): Quartz: - RoadEventReportEvent (kind 1315) and RoadEventConfirmationEvent (kind 1316), registered in EventFactory. - RoadEventType (13 categories with client-side effective TTLs) and RoadEventStatus enums; lat/lon/status tag codecs with 7-decimal coordinate formatting; NIP-40 expiration + NIP-31 alt + multi-precision geohash (4/5/6) tags. - Generalize the GeoHash encoder/decoder into commonMain (nip01Core.tags.geohash.GeoHash); the Android module keeps only the Location <-> GeoHash glue. - Unit tests for parsing, building, TTLs, coordinate formatting and the geohash prefix hierarchy. Amethyst: - RenderRoadEventReport / RenderRoadEventConfirmation cards wired into NoteCompose (feed) and ThreadFeedView/NoteMaster (thread), mirroring the Birdex renderer pattern; reverse-geocoded location line and localized category labels. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Cn4Lk53CcXqYFYHMut4YfY --- .../topNavFeeds/aroundMe/AroundMeExpander.kt | 2 +- .../service/location/LocationGeoHash.kt | 37 ++++ .../service/location/LocationState.kt | 1 + .../amethyst/ui/note/NoteCompose.kt | 12 ++ .../ui/note/creators/location/LoadCityName.kt | 3 +- .../amethyst/ui/note/types/RoadEvent.kt | 186 ++++++++++++++++ .../loggedIn/threadview/ThreadFeedView.kt | 8 + amethyst/src/main/res/values/strings.xml | 19 ++ .../roadstr/RoadEventConfirmationEvent.kt | 112 ++++++++++ .../roadstr/RoadEventReportEvent.kt | 121 +++++++++++ .../experimental/roadstr/RoadEventStatus.kt | 34 +++ .../experimental/roadstr/RoadEventType.kt | 53 +++++ .../experimental/roadstr/tags/LatitudeTag.kt | 41 ++++ .../experimental/roadstr/tags/LongitudeTag.kt | 41 ++++ .../roadstr/tags/RoadCoordinate.kt | 43 ++++ .../roadstr/tags/RoadEventStatusTag.kt | 42 ++++ .../quartz/nip01Core/tags/geohash}/GeoHash.kt | 18 +- .../quartz/utils/EventFactory.kt | 4 + .../experimental/roadstr/RoadEventTest.kt | 203 ++++++++++++++++++ 19 files changed, 967 insertions(+), 13 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationGeoHash.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventConfirmationEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventReportEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventStatus.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventType.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/LatitudeTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/LongitudeTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/RoadCoordinate.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/RoadEventStatusTag.kt rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/location => quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/geohash}/GeoHash.kt (95%) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt index dd5325011e..1e83a7dc03 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe -import com.vitorpamplona.amethyst.service.location.GeoHash +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash fun compute50kmLine(geoHash: GeoHash): List { val hashes = mutableListOf() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationGeoHash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationGeoHash.kt new file mode 100644 index 0000000000..4540a537c1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationGeoHash.kt @@ -0,0 +1,37 @@ +/* + * 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.service.location + +import android.location.Location +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash + +/** + * Android `Location` glue for the platform-agnostic [GeoHash] that now lives in + * Quartz. The encode/decode/neighbor logic is shared in `commonMain`; only these + * conversions to/from the Android `Location` type stay here. + */ +fun Location.toGeoHash(charsCount: Int = GeoHash.MAX_CHAR_PRECISION): GeoHash = GeoHash.encode(latitude, longitude, charsCount) + +fun GeoHash.toLocation(): Location = + Location("").also { + it.latitude = centerLat + it.longitude = centerLon + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt index 46b719eccf..298799e119 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service.location import android.content.Context +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeohashPrecision import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index e41f4ae5e9..cd8b844561 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -174,6 +174,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderRelayLeaveRequest import com.vitorpamplona.amethyst.ui.note.types.RenderRelayMembershipList import com.vitorpamplona.amethyst.ui.note.types.RenderRelayRemoveMember import com.vitorpamplona.amethyst.ui.note.types.RenderReport +import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventConfirmation +import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventReport import com.vitorpamplona.amethyst.ui.note.types.RenderRootSiteEvent import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareApplication import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareAsset @@ -235,6 +237,8 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAss import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent +import com.vitorpamplona.quartz.experimental.roadstr.RoadEventConfirmationEvent +import com.vitorpamplona.quartz.experimental.roadstr.RoadEventReportEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent @@ -1285,6 +1289,14 @@ private fun RenderNoteRow( RenderBirdex(baseNote) } + is RoadEventReportEvent -> { + RenderRoadEventReport(baseNote) + } + + is RoadEventConfirmationEvent -> { + RenderRoadEventConfirmation(baseNote) + } + is HighlightEvent -> { RenderHighlight( baseNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt index 05cce5516b..2fd6d60d36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt @@ -30,7 +30,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import com.vitorpamplona.amethyst.service.location.CachedReversedGeoLocations -import com.vitorpamplona.amethyst.service.location.toGeoHash +import com.vitorpamplona.amethyst.service.location.toLocation +import com.vitorpamplona.quartz.nip01Core.tags.geohash.toGeoHash import kotlinx.coroutines.delay import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume 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 new file mode 100644 index 0000000000..952e527412 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RoadEvent.kt @@ -0,0 +1,186 @@ +/* + * 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.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.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.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.theme.placeholderText +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.experimental.roadstr.RoadEventConfirmationEvent +import com.vitorpamplona.quartz.experimental.roadstr.RoadEventReportEvent +import com.vitorpamplona.quartz.experimental.roadstr.RoadEventStatus +import com.vitorpamplona.quartz.experimental.roadstr.RoadEventType + +/** Emoji marker for a road event category — 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.CONSTRUCTION -> "🚧" + RoadEventType.HAZARD -> "⚠️" + RoadEventType.ROAD_CONDITION -> "🛣️" + RoadEventType.POTHOLE -> "🕳️" + RoadEventType.FOG -> "🌫️" + RoadEventType.ICE -> "🧊" + RoadEventType.ANIMAL -> "🦌" + RoadEventType.OTHER -> "📍" + } + +private fun RoadEventType.labelRes(): Int = + when (this) { + RoadEventType.POLICE -> R.string.road_event_police + RoadEventType.SPEED_CAMERA -> R.string.road_event_speed_camera + RoadEventType.TRAFFIC_JAM -> R.string.road_event_traffic_jam + RoadEventType.ACCIDENT -> R.string.road_event_accident + RoadEventType.ROAD_CLOSURE -> R.string.road_event_road_closure + RoadEventType.CONSTRUCTION -> R.string.road_event_construction + RoadEventType.HAZARD -> R.string.road_event_hazard + RoadEventType.ROAD_CONDITION -> R.string.road_event_road_condition + RoadEventType.POTHOLE -> R.string.road_event_pothole + RoadEventType.FOG -> R.string.road_event_fog + RoadEventType.ICE -> R.string.road_event_ice + RoadEventType.ANIMAL -> R.string.road_event_animal + RoadEventType.OTHER -> R.string.road_event_other + } + +/** + * 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]. + */ +@Composable +fun RenderRoadEventReport(baseNote: Note) { + val noteEvent = baseNote.event as? RoadEventReportEvent ?: return + + val type = remember(noteEvent) { noteEvent.roadEventType() } + val comment = remember(noteEvent) { noteEvent.content.trim() } + val geohash = remember(noteEvent) { noteEvent.geohashes().maxByOrNull { it.length } } + + Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) { + val title = + if (type != null) { + "${type.emoji()} ${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 (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. + */ +@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 } } + + 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 + } + + 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 + } else { + R.string.road_event_confirms_report + }, + ), + 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 -> + Text( + text = cityName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 55cd5886a1..ec168e13f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -192,6 +192,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderRelayJoinRequest import com.vitorpamplona.amethyst.ui.note.types.RenderRelayLeaveRequest import com.vitorpamplona.amethyst.ui.note.types.RenderRelayMembershipList import com.vitorpamplona.amethyst.ui.note.types.RenderRelayRemoveMember +import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventConfirmation +import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventReport import com.vitorpamplona.amethyst.ui.note.types.RenderRootSiteEvent import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareApplication import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareAsset @@ -250,6 +252,8 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.Softw import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.roadstr.RoadEventConfirmationEvent +import com.vitorpamplona.quartz.experimental.roadstr.RoadEventReportEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope @@ -814,6 +818,10 @@ private fun FullBleedNoteCompose( RenderFundraiser(baseNote, makeItShort = false, accountViewModel, nav) } else if (noteEvent is BirdexEvent) { RenderBirdex(baseNote) + } else if (noteEvent is RoadEventReportEvent) { + RenderRoadEventReport(baseNote) + } else if (noteEvent is RoadEventConfirmationEvent) { + RenderRoadEventConfirmation(baseNote) } else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) { RenderRepost(baseNote, quotesLeft = 3, backgroundColor, accountViewModel, nav) } else if (noteEvent is RelayDiscoveryEvent) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 07da4a218b..c768b6b0aa 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3441,6 +3441,25 @@ %1$s +%2$d more %1$s +%2$d more + + Police + Speed camera + Traffic jam + Accident + Road closure + Construction + Hazard + Road condition + Pothole + Fog + Ice + Animal on the road + Road event + Road event + Still there + No longer there + Confirms a road report + Reports a road event cleared Goal amount (sats) 100000 Describe your goal diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventConfirmationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventConfirmationEvent.kt new file mode 100644 index 0000000000..bec8fd80f3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventConfirmationEvent.kt @@ -0,0 +1,112 @@ +/* + * 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.quartz.experimental.roadstr + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.roadstr.tags.LatitudeTag +import com.vitorpamplona.quartz.experimental.roadstr.tags.LongitudeTag +import com.vitorpamplona.quartz.experimental.roadstr.tags.RoadEventStatusTag +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Roadstr road event confirmation/denial (kind 1316). + * + * Validates or refutes a previously published [RoadEventReportEvent]. See + * . + * + * Tags (canonical order): + * - `e` — the referenced report event id. + * - `status` — [RoadEventStatus.STILL_THERE] (confirm) or [RoadEventStatus.NO_LONGER_THERE] (deny). + * - `g` / `lat` / `lon` — optional copy of the report's location, so confirmations + * are reachable through the same `#g` geohash queries as reports. + * - `expiration` — NIP-40 relay garbage-collection window (`created_at + 14 days`). + * - `alt` — NIP-31 human-readable fallback. + */ +@Immutable +class RoadEventConfirmationEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** The referenced report event id from the first `e` tag. */ + fun reportId() = tags.firstNotNullOfOrNull(ETag::parse)?.eventId + + fun status() = tags.firstNotNullOfOrNull(RoadEventStatusTag::parse) + + fun isConfirmation() = status() == RoadEventStatus.STILL_THERE + + fun isDenial() = status() == RoadEventStatus.NO_LONGER_THERE + + fun geohashes() = tags.geohashes() + + fun latitude() = tags.firstNotNullOfOrNull(LatitudeTag::parse) + + fun longitude() = tags.firstNotNullOfOrNull(LongitudeTag::parse) + + fun alt() = tags.firstNotNullOfOrNull(AltTag::parse) + + fun relayExpiration() = tags.firstNotNullOfOrNull(ExpirationTag::parse) + + companion object { + const val KIND = 1316 + const val ALT_DESCRIPTION = "Roadstr: event confirmation" + + fun build( + reportId: HexKey, + status: RoadEventStatus, + latitude: Double? = null, + longitude: Double? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate = + eventTemplate(KIND, "", createdAt) { + add(ETag.assemble(reportId, null, null)) + add(RoadEventStatusTag.assemble(status)) + if (latitude != null && longitude != null) { + val geohash6 = GeoHash.encode(latitude, longitude, 6).toString() + add(GeoHashTag.assembleSingle(geohash6.substring(0, 4))) + add(GeoHashTag.assembleSingle(geohash6.substring(0, 5))) + add(GeoHashTag.assembleSingle(geohash6)) + add(LatitudeTag.assemble(latitude)) + add(LongitudeTag.assemble(longitude)) + } + expiration(createdAt + RoadEventReportEvent.RELAY_TTL_SECONDS) + alt(ALT_DESCRIPTION) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventReportEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventReportEvent.kt new file mode 100644 index 0000000000..1a07d710eb --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventReportEvent.kt @@ -0,0 +1,121 @@ +/* + * 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.quartz.experimental.roadstr + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.roadstr.tags.LatitudeTag +import com.vitorpamplona.quartz.experimental.roadstr.tags.LongitudeTag +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Roadstr road event report (kind 1315). + * + * A driver-published report of a notable road condition (police, accident, + * hazard, …) at a specific location. See . + * + * Tags (canonical order): + * - `t` — the [RoadEventType] code. + * - `g` — geohash at precision 4, 5 and 6 (for multi-resolution spatial queries). + * - `lat` / `lon` — coordinates as decimal strings with 7 fractional digits. + * - `expiration` — NIP-40 relay garbage-collection window, always `created_at + 14 days`. + * - `alt` — NIP-31 human-readable fallback. + * + * The relay-side `expiration` is a fixed 14-day window; clients should instead use + * [effectiveExpirationAt] (derived from the event type's TTL) to decide when to + * stop displaying a report. The `content` field holds an optional free-text comment. + */ +@Immutable +class RoadEventReportEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** The raw event-type code from the first `t` tag (may be a value outside [RoadEventType]). */ + fun roadEventTypeCode() = tags.firstNotNullOfOrNull(HashtagTag::parse) + + /** The parsed [RoadEventType], or null when the `t` tag is missing/unknown. */ + fun roadEventType() = RoadEventType.fromCode(roadEventTypeCode()) + + /** All `g` geohash tags, finest-to-coarsest as published. */ + fun geohashes() = tags.geohashes() + + fun latitude() = tags.firstNotNullOfOrNull(LatitudeTag::parse) + + fun longitude() = tags.firstNotNullOfOrNull(LongitudeTag::parse) + + fun alt() = tags.firstNotNullOfOrNull(AltTag::parse) + + fun relayExpiration() = tags.firstNotNullOfOrNull(ExpirationTag::parse) + + /** Client-side effective expiry (`created_at + ` the type's TTL), or null if the type is unknown. */ + fun effectiveExpirationAt(): Long? = roadEventType()?.let { createdAt + it.effectiveTtlSeconds } + + /** Whether the report should no longer be displayed at [now], per its client-side TTL. */ + fun isEffectivelyExpired(now: Long = TimeUtils.now()): Boolean = effectiveExpirationAt()?.let { now > it } ?: false + + companion object { + const val KIND = 1315 + const val ALT_PREFIX = "Roadstr: " + + /** Fixed relay-side NIP-40 window (14 days) for both report and confirmation events. */ + const val RELAY_TTL_SECONDS = 1_209_600L + + fun altDescription(type: RoadEventType) = "$ALT_PREFIX${type.code} report" + + fun build( + type: RoadEventType, + latitude: Double, + longitude: Double, + comment: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + val geohash6 = GeoHash.encode(latitude, longitude, 6).toString() + return eventTemplate(KIND, comment, createdAt) { + add(HashtagTag.assemble(type.code)) + add(GeoHashTag.assembleSingle(geohash6.substring(0, 4))) + add(GeoHashTag.assembleSingle(geohash6.substring(0, 5))) + add(GeoHashTag.assembleSingle(geohash6)) + add(LatitudeTag.assemble(latitude)) + add(LongitudeTag.assemble(longitude)) + expiration(createdAt + RELAY_TTL_SECONDS) + alt(altDescription(type)) + initializer() + } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventStatus.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventStatus.kt new file mode 100644 index 0000000000..d8f4bfb63d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventStatus.kt @@ -0,0 +1,34 @@ +/* + * 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.quartz.experimental.roadstr + +/** Confirmation status carried in the `status` tag of a Roadstr confirmation (kind 1316). */ +enum class RoadEventStatus( + val code: String, +) { + STILL_THERE("still_there"), + NO_LONGER_THERE("no_longer_there"), + ; + + companion object { + fun fromCode(code: String?): RoadEventStatus? = entries.firstOrNull { it.code == code } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventType.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventType.kt new file mode 100644 index 0000000000..70ffc66bd9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventType.kt @@ -0,0 +1,53 @@ +/* + * 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.quartz.experimental.roadstr + +/** + * Road event categories carried in the `t` tag of a Roadstr report (kind 1315). + * + * Each type also defines a client-side *effective* TTL: the relay-side NIP-40 + * `expiration` tag is always a fixed 14-day garbage-collection window, but + * clients should stop showing a report after [effectiveTtlSeconds] from its + * `created_at` (subject to confirmations). + */ +enum class RoadEventType( + val code: String, + val effectiveTtlSeconds: Long, +) { + POLICE("police", 7_200L), // 2 hours + SPEED_CAMERA("speed_camera", 2_592_000L), // 30 days + TRAFFIC_JAM("traffic_jam", 3_600L), // 1 hour + ACCIDENT("accident", 10_800L), // 3 hours + ROAD_CLOSURE("road_closure", 604_800L), // 7 days + CONSTRUCTION("construction", 604_800L), // 7 days + HAZARD("hazard", 14_400L), // 4 hours + ROAD_CONDITION("road_condition", 21_600L), // 6 hours + POTHOLE("pothole", 604_800L), // 7 days + FOG("fog", 10_800L), // 3 hours + ICE("ice", 21_600L), // 6 hours + ANIMAL("animal", 3_600L), // 1 hour + OTHER("other", 7_200L), // 2 hours + ; + + companion object { + fun fromCode(code: String?): RoadEventType? = entries.firstOrNull { it.code == code } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/LatitudeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/LatitudeTag.kt new file mode 100644 index 0000000000..d3c8ab167c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/LatitudeTag.kt @@ -0,0 +1,41 @@ +/* + * 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.quartz.experimental.roadstr.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** The `lat` tag of a Roadstr event: latitude as a decimal string with 7 fractional digits. */ +class LatitudeTag { + companion object { + const val TAG_NAME = "lat" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Double? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return tag[1].toDoubleOrNull() + } + + fun assemble(latitude: Double) = arrayOf(TAG_NAME, RoadCoordinate.format(latitude)) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/LongitudeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/LongitudeTag.kt new file mode 100644 index 0000000000..18880cfebc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/LongitudeTag.kt @@ -0,0 +1,41 @@ +/* + * 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.quartz.experimental.roadstr.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** The `lon` tag of a Roadstr event: longitude as a decimal string with 7 fractional digits. */ +class LongitudeTag { + companion object { + const val TAG_NAME = "lon" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Double? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return tag[1].toDoubleOrNull() + } + + fun assemble(longitude: Double) = arrayOf(TAG_NAME, RoadCoordinate.format(longitude)) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/RoadCoordinate.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/RoadCoordinate.kt new file mode 100644 index 0000000000..193b34e122 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/RoadCoordinate.kt @@ -0,0 +1,43 @@ +/* + * 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.quartz.experimental.roadstr.tags + +import kotlin.math.abs +import kotlin.math.round + +/** + * Formatting for the `lat`/`lon` tag values of Roadstr events. The spec requires + * a decimal string with exactly 7 fractional digits (e.g. `48.8566140`), which is + * locale-independent here (unlike `String.format`, which is unavailable in + * `commonMain` anyway). + */ +object RoadCoordinate { + private const val SCALE = 10_000_000L // 7 decimal places + + fun format(value: Double): String { + val scaled = round(value * SCALE).toLong() + val negative = scaled < 0L + val absValue = abs(scaled) + val intPart = absValue / SCALE + val fracPart = (absValue % SCALE).toString().padStart(7, '0') + return (if (negative) "-" else "") + intPart + "." + fracPart + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/RoadEventStatusTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/RoadEventStatusTag.kt new file mode 100644 index 0000000000..da3c29b4e7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/RoadEventStatusTag.kt @@ -0,0 +1,42 @@ +/* + * 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.quartz.experimental.roadstr.tags + +import com.vitorpamplona.quartz.experimental.roadstr.RoadEventStatus +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** The `status` tag of a Roadstr confirmation (kind 1316): `still_there` or `no_longer_there`. */ +class RoadEventStatusTag { + companion object { + const val TAG_NAME = "status" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): RoadEventStatus? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return RoadEventStatus.fromCode(tag[1]) + } + + fun assemble(status: RoadEventStatus) = arrayOf(TAG_NAME, status.code) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/GeoHash.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeoHash.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/GeoHash.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeoHash.kt index 4741b104a5..ac2b320078 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/GeoHash.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/geohash/GeoHash.kt @@ -18,13 +18,17 @@ * 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.service.location - -import android.location.Location +package com.vitorpamplona.quartz.nip01Core.tags.geohash /** * GeoHash encoding/decoding based on Gustavo Niemeyer's algorithm (2008). * + * Pure-Kotlin and platform-agnostic (lives in `commonMain`), so any Nostr + * protocol code can derive `g` tags from coordinates and walk neighboring cells + * without depending on a platform location API. Callers that work with a + * platform `Location` type add thin conversion helpers in their own module + * (e.g. `amethyst`'s `Location.toGeoHash()` / `GeoHash.toLocation()`). + * * Bits are interleaved: even-indexed bits (0, 2, 4, ...) = longitude, * odd-indexed bits (1, 3, 5, ...) = latitude. */ @@ -237,12 +241,6 @@ class GeoHash private constructor( val easternNeighbour: GeoHash get() = fromBitArrays(latBits, numLat, mask(lonBits + 1L, numLon), numLon) val westernNeighbour: GeoHash get() = fromBitArrays(latBits, numLat, mask(lonBits - 1L, numLon), numLon) - fun toLocation(): Location = - Location("").also { - it.latitude = centerLat - it.longitude = centerLon - } - override fun toString(): String { val charCount = significantBits / BITS_PER_CHAR val sb = StringBuilder(charCount) @@ -263,6 +261,4 @@ class GeoHash private constructor( override fun hashCode(): Int = 31 * bits.hashCode() + significantBits } -fun Location.toGeoHash(charsCount: Int = GeoHash.MAX_CHAR_PRECISION): GeoHash = GeoHash.encode(latitude, longitude, charsCount) - fun String.toGeoHash(): GeoHash? = GeoHash.decode(this) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 6752dcd986..88a320c30f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -53,6 +53,8 @@ import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent +import com.vitorpamplona.quartz.experimental.roadstr.RoadEventConfirmationEvent +import com.vitorpamplona.quartz.experimental.roadstr.RoadEventReportEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent @@ -579,6 +581,8 @@ class EventFactory { ReleaseArtifactSetEvent.KIND -> ReleaseArtifactSetEvent(id, pubKey, createdAt, tags, content, sig) RelaySetEvent.KIND -> RelaySetEvent(id, pubKey, createdAt, tags, content, sig) ReportEvent.KIND -> ReportEvent(id, pubKey, createdAt, tags, content, sig) + RoadEventConfirmationEvent.KIND -> RoadEventConfirmationEvent(id, pubKey, createdAt, tags, content, sig) + RoadEventReportEvent.KIND -> RoadEventReportEvent(id, pubKey, createdAt, tags, content, sig) RootSiteEvent.KIND -> RootSiteEvent(id, pubKey, createdAt, tags, content, sig) RepostEvent.KIND -> RepostEvent(id, pubKey, createdAt, tags, content, sig) RequestToVanishEvent.KIND -> RequestToVanishEvent(id, pubKey, createdAt, tags, content, sig) 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 new file mode 100644 index 0000000000..0ff2c4493a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventTest.kt @@ -0,0 +1,203 @@ +/* + * 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.quartz.experimental.roadstr + +import com.vitorpamplona.quartz.experimental.roadstr.tags.RoadCoordinate +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RoadEventTest { + private val reportId = "fcbfc866b657a2fe514b579bcf7ff7700a195731eb6f306efb01529a683f86db" + + private fun sampleReport(): Event = + EventFactory.create( + id = "a099d4db563041bb289d3704f983fc148fc805860303a4f479a8264dc6a2d7cc", + pubKey = "932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d", + createdAt = 1_700_000_000L, + kind = RoadEventReportEvent.KIND, + tags = + arrayOf( + arrayOf("t", "police"), + arrayOf("g", "u2ed"), + arrayOf("g", "u2edc"), + arrayOf("g", "u2edcg"), + arrayOf("lat", "48.8566140"), + arrayOf("lon", "2.3522219"), + arrayOf("expiration", "1701209600"), + arrayOf("alt", "Roadstr: police report"), + ), + content = "", + sig = "00".repeat(64), + ) + + private fun sampleConfirmation(): Event = + EventFactory.create( + id = "b199d4db563041bb289d3704f983fc148fc805860303a4f479a8264dc6a2d7cc", + pubKey = "932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d", + createdAt = 1_700_003_600L, + kind = RoadEventConfirmationEvent.KIND, + tags = + arrayOf( + arrayOf("e", reportId), + arrayOf("status", "no_longer_there"), + arrayOf("expiration", "1701213200"), + arrayOf("alt", "Roadstr: event confirmation"), + ), + content = "", + sig = "00".repeat(64), + ) + + @Test + fun factoryParsesReportAndConfirmation() { + assertIs(sampleReport()) + assertIs(sampleConfirmation()) + } + + @Test + fun kindsAreKnown() { + assertTrue(EventFactory.isKnownKind(RoadEventReportEvent.KIND)) + assertTrue(EventFactory.isKnownKind(RoadEventConfirmationEvent.KIND)) + } + + @Test + fun parsesReportFields() { + val event = sampleReport() + assertIs(event) + + assertEquals(RoadEventType.POLICE, event.roadEventType()) + assertEquals(listOf("u2ed", "u2edc", "u2edcg"), event.geohashes()) + assertEquals(48.8566140, event.latitude()) + assertEquals(2.3522219, event.longitude()) + assertEquals("Roadstr: police report", event.alt()) + assertEquals(1701209600L, event.relayExpiration()) + // police TTL is 2h → created_at + 7200 + assertEquals(1_700_000_000L + 7_200L, event.effectiveExpirationAt()) + } + + @Test + fun unknownTypeYieldsNullType() { + val event = + EventFactory.create( + id = "00".repeat(32), + pubKey = "00".repeat(32), + createdAt = 1_700_000_000L, + kind = RoadEventReportEvent.KIND, + tags = arrayOf(arrayOf("t", "spaceship")), + content = "", + sig = "00".repeat(64), + ) + assertIs(event) + assertEquals("spaceship", event.roadEventTypeCode()) + assertNull(event.roadEventType()) + assertNull(event.effectiveExpirationAt()) + } + + @Test + fun parsesConfirmationFields() { + val event = sampleConfirmation() + assertIs(event) + + assertEquals(reportId, event.reportId()) + assertEquals(RoadEventStatus.NO_LONGER_THERE, event.status()) + assertTrue(event.isDenial()) + assertTrue(!event.isConfirmation()) + assertEquals("Roadstr: event confirmation", event.alt()) + } + + @Test + fun buildReportProducesCanonicalTags() { + val lat = 48.8566140 + val lon = 2.3522219 + val createdAt = 1_700_000_000L + val template = RoadEventReportEvent.build(RoadEventType.SPEED_CAMERA, lat, lon, createdAt = createdAt) + + assertEquals(RoadEventReportEvent.KIND, template.kind) + + val expectedG6 = GeoHash.encode(lat, lon, 6).toString() + val geohashes = template.tags.filter { it[0] == "g" }.map { it[1] } + assertEquals( + listOf(expectedG6.substring(0, 4), expectedG6.substring(0, 5), expectedG6), + geohashes, + ) + + assertEquals("speed_camera", template.tags.first { it[0] == "t" }[1]) + assertEquals("48.8566140", template.tags.first { it[0] == "lat" }[1]) + assertEquals("2.3522219", template.tags.first { it[0] == "lon" }[1]) + assertEquals((createdAt + RoadEventReportEvent.RELAY_TTL_SECONDS).toString(), template.tags.first { it[0] == "expiration" }[1]) + assertEquals("Roadstr: speed_camera report", template.tags.first { it[0] == "alt" }[1]) + } + + @Test + fun buildConfirmationOmitsLocationWhenAbsent() { + val template = RoadEventConfirmationEvent.build(reportId, RoadEventStatus.STILL_THERE, createdAt = 1_700_000_000L) + + 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]) + assertTrue(template.tags.none { it[0] == "g" || it[0] == "lat" || it[0] == "lon" }) + } + + @Test + fun buildConfirmationIncludesLocationWhenProvided() { + val lat = 48.8566140 + val lon = 2.3522219 + val template = RoadEventConfirmationEvent.build(reportId, RoadEventStatus.STILL_THERE, lat, lon) + + val expectedG6 = GeoHash.encode(lat, lon, 6).toString() + assertEquals(expectedG6, template.tags.filter { it[0] == "g" }.last()[1]) + assertEquals("48.8566140", template.tags.first { it[0] == "lat" }[1]) + } + + @Test + fun roadEventTypeTtls() { + assertEquals(7_200L, RoadEventType.POLICE.effectiveTtlSeconds) + assertEquals(2_592_000L, RoadEventType.SPEED_CAMERA.effectiveTtlSeconds) + assertEquals(3_600L, RoadEventType.TRAFFIC_JAM.effectiveTtlSeconds) + assertEquals(604_800L, RoadEventType.POTHOLE.effectiveTtlSeconds) + assertEquals(RoadEventType.TRAFFIC_JAM, RoadEventType.fromCode("traffic_jam")) + assertNull(RoadEventType.fromCode("nope")) + } + + @Test + fun coordinateFormattingHasSevenDecimals() { + assertEquals("48.8566140", RoadCoordinate.format(48.856614)) + assertEquals("2.3522219", RoadCoordinate.format(2.3522219)) + assertEquals("-0.0000010", RoadCoordinate.format(-0.000001)) + assertEquals("0.0000000", RoadCoordinate.format(0.0)) + assertEquals("-33.8688000", RoadCoordinate.format(-33.8688)) + } + + @Test + fun geohashEncodeProducesPrefixHierarchy() { + val g6 = GeoHash.encode(48.8566140, 2.3522219, 6).toString() + val g5 = GeoHash.encode(48.8566140, 2.3522219, 5).toString() + assertEquals(6, g6.length) + assertEquals(g6.substring(0, 5), g5) + // decode round-trips back to a hash with the same prefix + assertEquals(g6, GeoHash.decode(g6).toString()) + } +} From 4662cb75d067ec85b0e68fe029a4e568492069f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 20:57:41 +0000 Subject: [PATCH 2/2] refactor(roadstr): match NIP-88 polls architecture; register in LocalCache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the experimental roadstr events to mirror the nip88Polls file layout, and wire the kinds into LocalCache (previously unhandled, so they hit the "Event Not Supported" fallback and were dropped). - Split into report/ and confirmation/ sub-packages, each with XEvent.kt + TagArrayBuilderExt.kt + TagArrayExt.kt + tags/, matching nip88Polls/{poll,response}. build() now uses eventTemplate + typed builder-ext functions; accessors delegate to TagArray ext functions. - Bundle each enum into its tag class (RoadEventType in RoadEventTypeTag, RoadEventStatus in RoadEventStatusTag), like PollType in PollTypeTag. - Add RoadReportTag (a GenericETag `e`-reference) mirroring PollTag, and have RoadEventConfirmationEvent implement EventHintProvider so the referenced report is linkable — parallel to PollResponseEvent. - Shared lat/lon/geohash tag codecs live in roadstr/tags/ with generic builder/parse extensions. - LocalCache: consume kinds 1315/1316 as regular events. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Cn4Lk53CcXqYFYHMut4YfY --- .../amethyst/model/LocalCache.kt | 10 ++ .../amethyst/ui/note/NoteCompose.kt | 4 +- .../amethyst/ui/note/types/RoadEvent.kt | 8 +- .../loggedIn/threadview/ThreadFeedView.kt | 4 +- .../RoadEventConfirmationEvent.kt | 77 ++++++++------- .../confirmation/TagArrayBuilderExt.kt | 35 +++++++ .../roadstr/confirmation/TagArrayExt.kt | 29 ++++++ .../tags/RoadEventStatusTag.kt | 16 +++- .../confirmation/tags/RoadReportTag.kt | 96 +++++++++++++++++++ .../{ => report}/RoadEventReportEvent.kt | 50 ++++------ .../TagArrayBuilderExt.kt} | 17 +--- .../roadstr/report/TagArrayExt.kt | 28 ++++++ .../tags/RoadEventTypeTag.kt} | 31 +++++- .../roadstr/tags/TagArrayBuilderExt.kt | 46 +++++++++ .../experimental/roadstr/tags/TagArrayExt.kt | 27 ++++++ .../quartz/utils/EventFactory.kt | 4 +- .../experimental/roadstr/RoadEventTest.kt | 4 + 17 files changed, 400 insertions(+), 86 deletions(-) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/{ => confirmation}/RoadEventConfirmationEvent.kt (60%) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/TagArrayExt.kt rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/{ => confirmation}/tags/RoadEventStatusTag.kt (80%) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/tags/RoadReportTag.kt rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/{ => report}/RoadEventReportEvent.kt (69%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/{RoadEventStatus.kt => report/TagArrayBuilderExt.kt} (73%) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/TagArrayExt.kt rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/{RoadEventType.kt => report/tags/RoadEventTypeTag.kt} (66%) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/TagArrayExt.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index ff7f498420..d82d2c221a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -75,6 +75,8 @@ import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent +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.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent @@ -3870,6 +3872,14 @@ object LocalCache : ILocalCache, ICacheProvider { consume(event, relay, wasVerified) } + is RoadEventReportEvent -> { + consumeRegularEvent(event, relay, wasVerified) + } + + is RoadEventConfirmationEvent -> { + consumeRegularEvent(event, relay, wasVerified) + } + is RelayDiscoveryEvent -> { consumeBaseReplaceable(event, relay, wasVerified) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index cd8b844561..5202720733 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -237,8 +237,8 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAss import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent -import com.vitorpamplona.quartz.experimental.roadstr.RoadEventConfirmationEvent -import com.vitorpamplona.quartz.experimental.roadstr.RoadEventReportEvent +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.tags.geohash.geoHashOrScope import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent 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 952e527412..d560d3d173 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 @@ -40,10 +40,10 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.replyModifier -import com.vitorpamplona.quartz.experimental.roadstr.RoadEventConfirmationEvent -import com.vitorpamplona.quartz.experimental.roadstr.RoadEventReportEvent -import com.vitorpamplona.quartz.experimental.roadstr.RoadEventStatus -import com.vitorpamplona.quartz.experimental.roadstr.RoadEventType +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 /** Emoji marker for a road event category — keeps the card icon-light and locale-independent. */ private fun RoadEventType.emoji(): String = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index ec168e13f9..2820260a0e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -252,8 +252,8 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.Softw import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent -import com.vitorpamplona.quartz.experimental.roadstr.RoadEventConfirmationEvent -import com.vitorpamplona.quartz.experimental.roadstr.RoadEventReportEvent +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.tags.geohash.geoHashOrScope diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventConfirmationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/RoadEventConfirmationEvent.kt similarity index 60% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventConfirmationEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/RoadEventConfirmationEvent.kt index bec8fd80f3..9259df9d45 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventConfirmationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/RoadEventConfirmationEvent.kt @@ -18,24 +18,25 @@ * 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.quartz.experimental.roadstr +package com.vitorpamplona.quartz.experimental.roadstr.confirmation import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.experimental.roadstr.tags.LatitudeTag -import com.vitorpamplona.quartz.experimental.roadstr.tags.LongitudeTag -import com.vitorpamplona.quartz.experimental.roadstr.tags.RoadEventStatusTag +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadEventStatus +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadReportTag +import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent +import com.vitorpamplona.quartz.experimental.roadstr.tags.coordinates +import com.vitorpamplona.quartz.experimental.roadstr.tags.latitude +import com.vitorpamplona.quartz.experimental.roadstr.tags.longitude +import com.vitorpamplona.quartz.experimental.roadstr.tags.roadGeohashes import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder -import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate -import com.vitorpamplona.quartz.nip01Core.tags.events.ETag -import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash -import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.alt -import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.utils.TimeUtils @@ -46,7 +47,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils * . * * Tags (canonical order): - * - `e` — the referenced report event id. + * - `e` — the referenced report event id ([RoadReportTag]). * - `status` — [RoadEventStatus.STILL_THERE] (confirm) or [RoadEventStatus.NO_LONGER_THERE] (deny). * - `g` / `lat` / `lon` — optional copy of the report's location, so confirmations * are reachable through the same `#g` geohash queries as reports. @@ -61,11 +62,16 @@ class RoadEventConfirmationEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - /** The referenced report event id from the first `e` tag. */ - fun reportId() = tags.firstNotNullOfOrNull(ETag::parse)?.eventId +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider { + override fun eventHints() = tags.mapNotNull(RoadReportTag::parseAsHint) - fun status() = tags.firstNotNullOfOrNull(RoadEventStatusTag::parse) + override fun linkedEventIds() = tags.mapNotNull(RoadReportTag::parseId) + + /** The referenced report event id from the first `e` tag. */ + fun reportId() = tags.roadReport()?.eventId + + fun status() = tags.roadEventStatus() fun isConfirmation() = status() == RoadEventStatus.STILL_THERE @@ -73,13 +79,13 @@ class RoadEventConfirmationEvent( fun geohashes() = tags.geohashes() - fun latitude() = tags.firstNotNullOfOrNull(LatitudeTag::parse) + fun latitude() = tags.latitude() - fun longitude() = tags.firstNotNullOfOrNull(LongitudeTag::parse) + fun longitude() = tags.longitude() fun alt() = tags.firstNotNullOfOrNull(AltTag::parse) - fun relayExpiration() = tags.firstNotNullOfOrNull(ExpirationTag::parse) + fun relayExpiration() = tags.expiration() companion object { const val KIND = 1316 @@ -92,21 +98,28 @@ class RoadEventConfirmationEvent( longitude: Double? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ): EventTemplate = - eventTemplate(KIND, "", createdAt) { - add(ETag.assemble(reportId, null, null)) - add(RoadEventStatusTag.assemble(status)) - if (latitude != null && longitude != null) { - val geohash6 = GeoHash.encode(latitude, longitude, 6).toString() - add(GeoHashTag.assembleSingle(geohash6.substring(0, 4))) - add(GeoHashTag.assembleSingle(geohash6.substring(0, 5))) - add(GeoHashTag.assembleSingle(geohash6)) - add(LatitudeTag.assemble(latitude)) - add(LongitudeTag.assemble(longitude)) - } - expiration(createdAt + RoadEventReportEvent.RELAY_TTL_SECONDS) - alt(ALT_DESCRIPTION) - initializer() + ) = eventTemplate(KIND, "", createdAt) { + report(reportId) + status(status) + if (latitude != null && longitude != null) { + roadGeohashes(latitude, longitude) + coordinates(latitude, longitude) } + expiration(createdAt + RoadEventReportEvent.RELAY_TTL_SECONDS) + alt(ALT_DESCRIPTION) + initializer() + } + + fun build( + report: EventHintBundle, + status: RoadEventStatus, + latitude: Double? = null, + longitude: Double? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = build(report.event.id, status, latitude, longitude, createdAt) { + report(report) + initializer() + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..8d5a0debd7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/TagArrayBuilderExt.kt @@ -0,0 +1,35 @@ +/* + * 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.quartz.experimental.roadstr.confirmation + +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadEventStatus +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadEventStatusTag +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadReportTag +import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle + +fun TagArrayBuilder.report(reportId: HexKey) = addUnique(RoadReportTag.assemble(reportId, null, null)) + +fun TagArrayBuilder.report(reportHint: EventHintBundle) = addUnique(RoadReportTag.assemble(reportHint)) + +fun TagArrayBuilder.status(status: RoadEventStatus) = addUnique(RoadEventStatusTag.assemble(status)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/TagArrayExt.kt new file mode 100644 index 0000000000..6b413829f6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/TagArrayExt.kt @@ -0,0 +1,29 @@ +/* + * 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.quartz.experimental.roadstr.confirmation + +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadEventStatusTag +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags.RoadReportTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.roadReport() = firstNotNullOfOrNull(RoadReportTag::parse) + +fun TagArray.roadEventStatus() = firstNotNullOfOrNull(RoadEventStatusTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/RoadEventStatusTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/tags/RoadEventStatusTag.kt similarity index 80% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/RoadEventStatusTag.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/tags/RoadEventStatusTag.kt index da3c29b4e7..87ca3f0f3e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/RoadEventStatusTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/tags/RoadEventStatusTag.kt @@ -18,12 +18,24 @@ * 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.quartz.experimental.roadstr.tags +package com.vitorpamplona.quartz.experimental.roadstr.confirmation.tags -import com.vitorpamplona.quartz.experimental.roadstr.RoadEventStatus import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.ensure +/** Confirmation status carried in the `status` tag of a Roadstr confirmation (kind 1316). */ +enum class RoadEventStatus( + val code: String, +) { + STILL_THERE("still_there"), + NO_LONGER_THERE("no_longer_there"), + ; + + companion object { + fun fromCode(code: String?): RoadEventStatus? = entries.firstOrNull { it.code == code } + } +} + /** The `status` tag of a Roadstr confirmation (kind 1316): `still_there` or `no_longer_there`. */ class RoadEventStatusTag { companion object { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/tags/RoadReportTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/tags/RoadReportTag.kt new file mode 100644 index 0000000000..f4c3bd5597 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/confirmation/tags/RoadReportTag.kt @@ -0,0 +1,96 @@ +/* + * 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.quartz.experimental.roadstr.confirmation.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +/** The `e` tag of a Roadstr confirmation (kind 1316): the referenced report event. */ +@Immutable +data class RoadReportTag( + override val eventId: HexKey, +) : GenericETag { + override var relay: NormalizedRelayUrl? = null + override var author: HexKey? = null + + constructor(eventId: HexKey, relayHint: NormalizedRelayUrl? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { + this.relay = relayHint + this.author = authorPubKeyHex + } + + fun toNEvent(): String = NEvent.create(eventId, author, RoadEventReportEvent.KIND, relay) + + override fun toTagArray() = assemble(eventId, relay, author) + + companion object { + const val TAG_NAME = "e" + + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64 + + fun parse(tag: Array): RoadReportTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return RoadReportTag(tag[1], hint, tag.getOrNull(3)) + } + + fun parseId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return tag[1] + } + + fun parseAsHint(tag: Array): EventIdHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val hint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + + ensure(hint != null) { return null } + + return EventIdHint(tag[1], hint) + } + + fun assemble( + eventId: HexKey, + relay: NormalizedRelayUrl?, + author: HexKey?, + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, author) + + fun assemble(eventHint: EventHintBundle) = assemble(eventHint.event.id, eventHint.relay, eventHint.event.pubKey) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventReportEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/RoadEventReportEvent.kt similarity index 69% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventReportEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/RoadEventReportEvent.kt index 1a07d710eb..1d7eb8747c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventReportEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/RoadEventReportEvent.kt @@ -18,23 +18,21 @@ * 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.quartz.experimental.roadstr +package com.vitorpamplona.quartz.experimental.roadstr.report import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.experimental.roadstr.tags.LatitudeTag -import com.vitorpamplona.quartz.experimental.roadstr.tags.LongitudeTag +import com.vitorpamplona.quartz.experimental.roadstr.report.tags.RoadEventType +import com.vitorpamplona.quartz.experimental.roadstr.tags.coordinates +import com.vitorpamplona.quartz.experimental.roadstr.tags.latitude +import com.vitorpamplona.quartz.experimental.roadstr.tags.longitude +import com.vitorpamplona.quartz.experimental.roadstr.tags.roadGeohashes import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder -import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate -import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash -import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.alt -import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.utils.TimeUtils @@ -64,22 +62,22 @@ class RoadEventReportEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - /** The raw event-type code from the first `t` tag (may be a value outside [RoadEventType]). */ - fun roadEventTypeCode() = tags.firstNotNullOfOrNull(HashtagTag::parse) - /** The parsed [RoadEventType], or null when the `t` tag is missing/unknown. */ - fun roadEventType() = RoadEventType.fromCode(roadEventTypeCode()) + fun roadEventType() = tags.roadEventType() - /** All `g` geohash tags, finest-to-coarsest as published. */ + /** The raw event-type code from the first `t` tag (may be outside [RoadEventType]). */ + fun roadEventTypeCode() = tags.roadEventTypeCode() + + /** All `g` geohash tags, coarse-to-fine as published. */ fun geohashes() = tags.geohashes() - fun latitude() = tags.firstNotNullOfOrNull(LatitudeTag::parse) + fun latitude() = tags.latitude() - fun longitude() = tags.firstNotNullOfOrNull(LongitudeTag::parse) + fun longitude() = tags.longitude() fun alt() = tags.firstNotNullOfOrNull(AltTag::parse) - fun relayExpiration() = tags.firstNotNullOfOrNull(ExpirationTag::parse) + fun relayExpiration() = tags.expiration() /** Client-side effective expiry (`created_at + ` the type's TTL), or null if the type is unknown. */ fun effectiveExpirationAt(): Long? = roadEventType()?.let { createdAt + it.effectiveTtlSeconds } @@ -103,19 +101,13 @@ class RoadEventReportEvent( comment: String = "", createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ): EventTemplate { - val geohash6 = GeoHash.encode(latitude, longitude, 6).toString() - return eventTemplate(KIND, comment, createdAt) { - add(HashtagTag.assemble(type.code)) - add(GeoHashTag.assembleSingle(geohash6.substring(0, 4))) - add(GeoHashTag.assembleSingle(geohash6.substring(0, 5))) - add(GeoHashTag.assembleSingle(geohash6)) - add(LatitudeTag.assemble(latitude)) - add(LongitudeTag.assemble(longitude)) - expiration(createdAt + RELAY_TTL_SECONDS) - alt(altDescription(type)) - initializer() - } + ) = eventTemplate(KIND, comment, createdAt) { + roadEventType(type) + roadGeohashes(latitude, longitude) + coordinates(latitude, longitude) + expiration(createdAt + RELAY_TTL_SECONDS) + alt(altDescription(type)) + initializer() } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventStatus.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/TagArrayBuilderExt.kt similarity index 73% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventStatus.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/TagArrayBuilderExt.kt index d8f4bfb63d..ff4713a50a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventStatus.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/TagArrayBuilderExt.kt @@ -18,17 +18,10 @@ * 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.quartz.experimental.roadstr +package com.vitorpamplona.quartz.experimental.roadstr.report -/** Confirmation status carried in the `status` tag of a Roadstr confirmation (kind 1316). */ -enum class RoadEventStatus( - val code: String, -) { - STILL_THERE("still_there"), - NO_LONGER_THERE("no_longer_there"), - ; +import com.vitorpamplona.quartz.experimental.roadstr.report.tags.RoadEventType +import com.vitorpamplona.quartz.experimental.roadstr.report.tags.RoadEventTypeTag +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder - companion object { - fun fromCode(code: String?): RoadEventStatus? = entries.firstOrNull { it.code == code } - } -} +fun TagArrayBuilder.roadEventType(type: RoadEventType) = add(RoadEventTypeTag.assemble(type)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/TagArrayExt.kt new file mode 100644 index 0000000000..7f6f40ab1f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/TagArrayExt.kt @@ -0,0 +1,28 @@ +/* + * 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.quartz.experimental.roadstr.report + +import com.vitorpamplona.quartz.experimental.roadstr.report.tags.RoadEventTypeTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.roadEventType() = firstNotNullOfOrNull(RoadEventTypeTag::parse) + +fun TagArray.roadEventTypeCode() = firstNotNullOfOrNull(RoadEventTypeTag::parseCode) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventType.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/tags/RoadEventTypeTag.kt similarity index 66% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventType.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/tags/RoadEventTypeTag.kt index 70ffc66bd9..48188fe5ca 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/RoadEventType.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/report/tags/RoadEventTypeTag.kt @@ -18,7 +18,10 @@ * 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.quartz.experimental.roadstr +package com.vitorpamplona.quartz.experimental.roadstr.report.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure /** * Road event categories carried in the `t` tag of a Roadstr report (kind 1315). @@ -51,3 +54,29 @@ enum class RoadEventType( fun fromCode(code: String?): RoadEventType? = entries.firstOrNull { it.code == code } } } + +/** The `t` tag of a Roadstr report (kind 1315): the [RoadEventType] code. */ +class RoadEventTypeTag { + companion object { + const val TAG_NAME = "t" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + /** Parses the tag into a known [RoadEventType], or null when missing/unknown. */ + fun parse(tag: Array): RoadEventType? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return RoadEventType.fromCode(tag[1]) + } + + /** Returns the raw `t` value even when it is not a known [RoadEventType]. */ + fun parseCode(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(type: RoadEventType) = arrayOf(TAG_NAME, type.code) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..a124f24cb4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/TagArrayBuilderExt.kt @@ -0,0 +1,46 @@ +/* + * 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.quartz.experimental.roadstr.tags + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag + +/** Adds `lat`/`lon` tags (7-decimal strings) shared by Roadstr reports and confirmations. */ +fun TagArrayBuilder.coordinates( + latitude: Double, + longitude: Double, +) = apply { + add(LatitudeTag.assemble(latitude)) + add(LongitudeTag.assemble(longitude)) +} + +/** Adds the three `g` geohash tags (precision 4, 5 and 6) derived from [latitude]/[longitude]. */ +fun TagArrayBuilder.roadGeohashes( + latitude: Double, + longitude: Double, +) = apply { + val geohash6 = GeoHash.encode(latitude, longitude, 6).toString() + add(GeoHashTag.assembleSingle(geohash6.substring(0, 4))) + add(GeoHashTag.assembleSingle(geohash6.substring(0, 5))) + add(GeoHashTag.assembleSingle(geohash6)) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/TagArrayExt.kt new file mode 100644 index 0000000000..1368797d18 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/roadstr/tags/TagArrayExt.kt @@ -0,0 +1,27 @@ +/* + * 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.quartz.experimental.roadstr.tags + +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +fun TagArray.latitude() = firstNotNullOfOrNull(LatitudeTag::parse) + +fun TagArray.longitude() = firstNotNullOfOrNull(LongitudeTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 88a320c30f..cdf987c70f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -53,8 +53,8 @@ import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent -import com.vitorpamplona.quartz.experimental.roadstr.RoadEventConfirmationEvent -import com.vitorpamplona.quartz.experimental.roadstr.RoadEventReportEvent +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.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent 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 0ff2c4493a..16a7f82706 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 @@ -20,6 +20,10 @@ */ package com.vitorpamplona.quartz.experimental.roadstr +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.experimental.roadstr.tags.RoadCoordinate import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash