feat(geohash-chat): map teleport + composer teleport toggle (Phase E)

- LocationPickerMap: an interactive osmdroid map (tap/long-press to pick a
  coordinate) built on the existing display-only map's tile/lifecycle setup.
- GeohashTeleportScreen (Route.GeohashTeleport): tap a spot, pick a precision
  level (region -> building), see the resolved cell + place name, and "Teleport
  here" -> follow the cell (kind 10081) + open its chat with teleported=true.
  Reachable from the builder's new "Teleport to a place on the map" button.
- Route.GeohashChat carries a teleported flag; the chat VM owns the teleport
  state (seeded from the route) and stamps ["t","teleport"] on outgoing messages.
- Composer gains a "Teleport" FilterChip so the user can toggle it per session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
This commit is contained in:
Claude
2026-07-16 01:21:19 +00:00
parent 57933dc64c
commit a5c6eeaf03
7 changed files with 322 additions and 11 deletions
@@ -103,6 +103,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalenda
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalendarEventScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.detail.CalendarEventDetailScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat.GeohashChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat.GeohashTeleportScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat.NewGeohashChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.CreateGroupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.EditGroupInfoScreen
@@ -631,6 +632,7 @@ fun BuildNavigation(
composableFromEndArgs<Route.GeohashChat> {
GeohashChatScreen(
geohash = it.geohash,
teleported = it.teleported,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -638,6 +640,8 @@ fun BuildNavigation(
composableFromBottomArgs<Route.NewGeohashChat> { NewGeohashChatScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.GeohashTeleport> { GeohashTeleportScreen(accountViewModel, nav) }
composableFromEndArgs<Route.RelayGroup> {
RelayGroupChatScreen(
id = it.id,
@@ -643,10 +643,15 @@ sealed class Route {
@Serializable data class GeohashChat(
val geohash: String,
// True when the user is not physically in the cell (jumped in via teleport). Seeds the
// composer's teleport toggle so their messages carry the ["t","teleport"] marker.
val teleported: Boolean = false,
) : Route()
@Serializable object NewGeohashChat : Route()
@Serializable object GeohashTeleport : Route()
@Serializable data class RelayGroup(
val id: String,
val relayUrl: String,
@@ -0,0 +1,126 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.creators.location
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import org.osmdroid.config.Configuration
import org.osmdroid.events.MapEventsReceiver
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.MapEventsOverlay
import org.osmdroid.views.overlay.Marker
/**
* An interactive OpenStreetMap (osmdroid) picker: tapping the map reports the
* tapped coordinate via [onPick], and [pickedLatitude]/[pickedLongitude] (when
* set) show a marker there. Used to "teleport" into a remote geohash cell.
*
* Shares the tile/User-Agent/lifecycle setup with [LocationPreviewMap]; unlike
* that display-only map, this one installs a [MapEventsOverlay] for tap picking.
*/
@Composable
fun LocationPickerMap(
latitude: Double,
longitude: Double,
pickedLatitude: Double?,
pickedLongitude: Double?,
modifier: Modifier = Modifier,
zoom: Double = 4.0,
onPick: (Double, Double) -> Unit,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val currentOnPick by rememberUpdatedState(onPick)
val mapView =
remember(context) {
Configuration.getInstance().userAgentValue = context.packageName
MapView(context).apply {
setTileSource(TileSourceFactory.MAPNIK)
setMultiTouchControls(true)
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
controller.setZoom(zoom)
controller.setCenter(GeoPoint(latitude, longitude))
val receiver =
object : MapEventsReceiver {
override fun singleTapConfirmedHelper(p: GeoPoint): Boolean {
currentOnPick(p.latitude, p.longitude)
return true
}
override fun longPressHelper(p: GeoPoint): Boolean {
currentOnPick(p.latitude, p.longitude)
return true
}
}
overlays.add(0, MapEventsOverlay(receiver))
}
}
DisposableEffect(lifecycleOwner, mapView) {
val observer =
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> mapView.onResume()
Lifecycle.Event.ON_PAUSE -> mapView.onPause()
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
mapView.onDetach()
}
}
AndroidView(
modifier = modifier,
factory = { mapView },
update = { map ->
map.overlays.removeAll { it is Marker }
if (pickedLatitude != null && pickedLongitude != null) {
val point = GeoPoint(pickedLatitude, pickedLongitude)
val marker =
Marker(map).apply {
position = point
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
setInfoWindow(null)
}
map.overlays.add(marker)
}
map.invalidate()
},
)
}
@@ -31,6 +31,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -75,16 +76,18 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
@Composable
fun GeohashChatScreen(
geohash: String,
teleported: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
) {
val viewModel: GeohashChatViewModel = viewModel(key = "GeohashChat/$geohash")
viewModel.init(geohash, accountViewModel)
viewModel.init(geohash, accountViewModel, teleported)
val messages by viewModel.messages.collectAsStateWithLifecycle()
val participants by viewModel.participants.collectAsStateWithLifecycle()
val relays by viewModel.relays.collectAsStateWithLifecycle()
val myPubKey by viewModel.myPubKey.collectAsStateWithLifecycle()
val teleporting by viewModel.teleported.collectAsStateWithLifecycle()
var nickname by remember { mutableStateOf("") }
var draft by remember { mutableStateOf("") }
@@ -117,13 +120,24 @@ fun GeohashChatScreen(
}
HorizontalDivider()
OutlinedTextField(
value = nickname,
onValueChange = { nickname = it },
singleLine = true,
label = { Text("Nickname (optional)") },
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
)
Row(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = nickname,
onValueChange = { nickname = it },
singleLine = true,
label = { Text("Nickname (optional)") },
modifier = Modifier.weight(1f),
)
FilterChip(
selected = teleporting,
onClick = { viewModel.setTeleported(!teleporting) },
label = { Text("✈ Teleport") },
modifier = Modifier.padding(start = 8.dp),
)
}
Row(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -78,6 +78,10 @@ class GeohashChatViewModel : ViewModel() {
private val _myPubKey = MutableStateFlow<String?>(null)
val myPubKey: StateFlow<String?> = _myPubKey.asStateFlow()
/** Whether our outgoing messages carry the ["t","teleport"] marker (not physically in the cell). */
private val _teleported = MutableStateFlow(false)
val teleported: StateFlow<Boolean> = _teleported.asStateFlow()
private val seen = HashSet<String>()
private val present = HashSet<String>()
private val subId = newSubId()
@@ -87,14 +91,20 @@ class GeohashChatViewModel : ViewModel() {
fun init(
geohash: String,
accountViewModel: AccountViewModel,
teleported: Boolean = false,
) {
if (started) return
started = true
this.geohash = geohash
this.accountViewModel = accountViewModel
_teleported.value = teleported
viewModelScope.launch { start() }
}
fun setTeleported(value: Boolean) {
_teleported.value = value
}
private suspend fun start() {
_myPubKey.value = withContext(Dispatchers.IO) { GeohashChatIdentity.keyPair(accountViewModel.account, geohash).pubKey.toHexKey() }
@@ -152,7 +162,6 @@ class GeohashChatViewModel : ViewModel() {
fun sendMessage(
text: String,
nickname: String?,
teleported: Boolean = false,
) {
val trimmed = text.trim()
if (trimmed.isEmpty()) return
@@ -163,7 +172,7 @@ class GeohashChatViewModel : ViewModel() {
val keyPair = withContext(Dispatchers.IO) { GeohashChatIdentity.keyPair(accountViewModel.account, geohash) }
val signer = NostrSignerInternal(keyPair)
var template = GeohashChatEvent.build(trimmed, geohash, nickname = nickname?.ifBlank { null }, teleported = teleported)
var template = GeohashChatEvent.build(trimmed, geohash, nickname = nickname?.ifBlank { null }, teleported = _teleported.value)
template =
withContext(Dispatchers.Default) {
val deadline = System.nanoTime() + POW_TIMEOUT_NANOS
@@ -0,0 +1,145 @@
/*
* 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.screen.loggedIn.chats.geohashChat
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
import com.vitorpamplona.amethyst.ui.note.creators.location.LocationPickerMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChannelLevel
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash
/**
* Teleport: tap a point on the map to join a remote geohash cell (at a chosen
* precision level) you are not physically in. Joining follows the cell (kind
* 10081) and opens the chat with the teleport flag set, so outgoing messages
* carry the ["t","teleport"] marker.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GeohashTeleportScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
var pickedLat by remember { mutableStateOf<Double?>(null) }
var pickedLon by remember { mutableStateOf<Double?>(null) }
var level by remember { mutableStateOf(GeohashChannelLevel.CITY) }
val cell =
remember(pickedLat, pickedLon, level) {
val lat = pickedLat
val lon = pickedLon
if (lat != null && lon != null) GeoHash.encode(lat, lon, level.chars).toString() else null
}
Scaffold(
topBar = {
TopBarExtensibleWithBackButton(
title = { Text("Teleport", fontWeight = FontWeight.Bold) },
popBack = nav::popBack,
)
},
) { pad ->
Column(
Modifier
.fillMaxSize()
.padding(top = pad.calculateTopPadding(), bottom = pad.calculateBottomPadding()),
) {
LocationPickerMap(
latitude = 20.0,
longitude = 0.0,
pickedLatitude = pickedLat,
pickedLongitude = pickedLon,
modifier = Modifier.weight(1f).fillMaxWidth(),
onPick = { lat, lon ->
pickedLat = lat
pickedLon = lon
},
)
Column(Modifier.fillMaxWidth().padding(16.dp)) {
Text(
if (cell == null) "Tap the map to pick a spot." else "Precision",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
GeohashChannelLevel.ordered.forEach { lvl ->
FilterChip(
selected = lvl == level,
onClick = { level = lvl },
label = { Text(lvl.label()) },
)
}
}
if (cell != null) {
LoadCityName(geohashStr = cell) { cityName ->
Text(
"$cityName · #$cell",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp),
)
}
Button(
onClick = {
accountViewModel.followGeohash(cell)
nav.popBack()
nav.nav(Route.GeohashChat(cell, teleported = true))
},
modifier = Modifier.fillMaxWidth(),
) {
Text("Teleport here")
}
}
}
}
}
}
@@ -105,6 +105,14 @@ fun NewGeohashChatScreen(
ManualEntrySection(onOpen = ::joinAndOpen)
HorizontalDivider(Modifier.padding(vertical = 16.dp))
NearMeSection(onOpen = ::joinAndOpen)
HorizontalDivider(Modifier.padding(vertical = 16.dp))
OutlinedButton(
onClick = { nav.nav(Route.GeohashTeleport) },
modifier = Modifier.fillMaxWidth(),
) {
SymbolIcon(symbol = MaterialSymbols.LocationOn, contentDescription = null)
Text(" Teleport to a place on the map")
}
}
}
}
@@ -259,7 +267,7 @@ private fun LevelRow(
}
}
private fun GeohashChannelLevel.label(): String =
internal fun GeohashChannelLevel.label(): String =
when (this) {
GeohashChannelLevel.REGION -> "Region"
GeohashChannelLevel.PROVINCE -> "Province"