diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index dc3287af0c..29320ff438 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { GeohashChatScreen( geohash = it.geohash, + teleported = it.teleported, accountViewModel = accountViewModel, nav = nav, ) @@ -638,6 +640,8 @@ fun BuildNavigation( composableFromBottomArgs { NewGeohashChatScreen(accountViewModel, nav) } + composableFromBottomArgs { GeohashTeleportScreen(accountViewModel, nav) } + composableFromEndArgs { RelayGroupChatScreen( id = it.id, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 892502f4eb..a525259a3f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPickerMap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPickerMap.kt new file mode 100644 index 0000000000..22838cda71 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPickerMap.kt @@ -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() + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatScreen.kt index 8d04087bf6..82fcda1664 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatViewModel.kt index aa23266e66..51de14f317 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatViewModel.kt @@ -78,6 +78,10 @@ class GeohashChatViewModel : ViewModel() { private val _myPubKey = MutableStateFlow(null) val myPubKey: StateFlow = _myPubKey.asStateFlow() + /** Whether our outgoing messages carry the ["t","teleport"] marker (not physically in the cell). */ + private val _teleported = MutableStateFlow(false) + val teleported: StateFlow = _teleported.asStateFlow() + private val seen = HashSet() private val present = HashSet() 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashTeleportScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashTeleportScreen.kt new file mode 100644 index 0000000000..6a5732c5dd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashTeleportScreen.kt @@ -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(null) } + var pickedLon by remember { mutableStateOf(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") + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/NewGeohashChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/NewGeohashChatScreen.kt index 42276eee5f..665f2d2dcf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/NewGeohashChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/NewGeohashChatScreen.kt @@ -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"