From fb9ccbaad0024797c0ea25a3a85a276a99c35357 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 01:25:24 +0000 Subject: [PATCH] feat(location): follow a teleported place; retarget geo-posts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) Follow this location from the feed: when a top-nav Geohash filter is active (e.g. after teleporting), the filter header shows a bookmark toggle to follow/unfollow that place (kind 10081), so a teleported spot can be saved to your locations — the "add to my interests" step. Respects read-only accounts. 2) Retarget a geo-post: the geohash comment composer now shows "Posting to " with a Change action that opens the map picker and re-scopes the post's NIP-73 geohash channel (CommentPostViewModel.geohashScope/setGeohashScope), instead of being locked to the feed's channel. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh --- .../navigation/topbars/FeedFilterSpinner.kt | 271 ++++++++++-------- .../nip22Comments/CommentPostViewModel.kt | 10 + .../nip22Comments/GenericCommentPostScreen.kt | 61 ++++ amethyst/src/main/res/values/strings.xml | 4 + 4 files changed, 230 insertions(+), 116 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index 69ded95d7f..8426fc9bec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -72,6 +73,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingGeohash import com.vitorpamplona.amethyst.ui.note.creators.location.GeohashLocationPickerDialog import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName import com.vitorpamplona.amethyst.ui.screen.CommunityName @@ -130,88 +132,108 @@ fun FeedFilterSpinner( val openDropdownLabel = stringRes(R.string.open_dropdown_menu) - Box( - modifier = modifier, - contentAlignment = Alignment.Center, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Spacer(modifier = Size20Modifier) + val filter = selected?.code ?: placeholderCode - // Bound the Column so long filter names (e.g. DVM titles) get truncated - // instead of wrapping to multiple lines and shoving the expand icon out. - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.weight(1f, fill = false), - ) { - // Fall back to the raw selection: a teleported geohash that isn't in the - // catalog (not followed) has no matching option, but we still want to show - // its place name rather than "Select an option". - val filter = selected?.code ?: placeholderCode - if (filter is TopFilter.Geohash) { - LoadCityName( - geohashStr = filter.tag, - onLoading = { - Row { - Text( - text = filter.tag, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(modifier = StdHorzSpacer) - LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp) - } - }, - ) { cityName -> + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier.weight(1f, fill = false), + contentAlignment = Alignment.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(modifier = Size20Modifier) + + // Bound the Column so long filter names (e.g. DVM titles) get truncated + // instead of wrapping to multiple lines and shoving the expand icon out. + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f, fill = false), + ) { + if (filter is TopFilter.Geohash) { + LoadCityName( + geohashStr = filter.tag, + onLoading = { + Row { + Text( + text = filter.tag, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = StdHorzSpacer) + LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp) + } + }, + ) { cityName -> + Text( + text = cityName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } else { Text( - text = cityName, + text = currentText, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } - } else { - Text( - text = currentText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - if (filter is TopFilter.AroundMe) { - val locationPermissionState = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION) - if (!locationPermissionState.status.isGranted) { - LaunchedEffect(locationPermissionState) { locationPermissionState.launchPermissionRequest() } + if (filter is TopFilter.AroundMe) { + val locationPermissionState = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION) + if (!locationPermissionState.status.isGranted) { + LaunchedEffect(locationPermissionState) { locationPermissionState.launchPermissionRequest() } - Text( - text = stringRes(R.string.lack_location_permissions), - fontSize = Font12SP, - lineHeight = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } else { - val location by Amethyst.instance.locationManager.geohashStateFlow - .collectAsStateWithLifecycle() + Text( + text = stringRes(R.string.lack_location_permissions), + fontSize = Font12SP, + lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + val location by Amethyst.instance.locationManager.geohashStateFlow + .collectAsStateWithLifecycle() - when (val myLocation = location) { - is LocationState.LocationResult.Success -> { - LoadCityName( - geohashStr = myLocation.geoHash.toString(), - onLoading = { - Row { - Text( - text = "(${myLocation.geoHash})", - fontSize = Font12SP, - lineHeight = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(modifier = StdHorzSpacer) - LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp) - } - }, - ) { cityName -> + when (val myLocation = location) { + is LocationState.LocationResult.Success -> { + LoadCityName( + geohashStr = myLocation.geoHash.toString(), + onLoading = { + Row { + Text( + text = "(${myLocation.geoHash})", + fontSize = Font12SP, + lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = StdHorzSpacer) + LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp) + } + }, + ) { cityName -> + Text( + text = "($cityName)", + fontSize = Font12SP, + lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + LocationState.LocationResult.LackPermission -> { Text( - text = "($cityName)", + text = stringRes(R.string.lack_location_permissions), + fontSize = Font12SP, + lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + LocationState.LocationResult.Loading -> { + Text( + text = stringRes(R.string.loading_location), fontSize = Font12SP, lineHeight = 12.sp, maxLines = 1, @@ -219,56 +241,42 @@ fun FeedFilterSpinner( ) } } - - LocationState.LocationResult.LackPermission -> { - Text( - text = stringRes(R.string.lack_location_permissions), - fontSize = Font12SP, - lineHeight = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - - LocationState.LocationResult.Loading -> { - Text( - text = stringRes(R.string.loading_location), - fontSize = Font12SP, - lineHeight = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } } } } - } - Icon( - symbol = MaterialSymbols.ExpandMore, - contentDescription = explainer, - modifier = Size20Modifier, - tint = MaterialTheme.colorScheme.placeholderText, + Icon( + symbol = MaterialSymbols.ExpandMore, + contentDescription = explainer, + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) + } + Box( + modifier = + Modifier + .matchParentSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + optionsShowing = true + }.semantics { + role = Role.DropdownList + stateDescription = accessibilityDescription + onClick(label = openDropdownLabel) { + optionsShowing = true + return@onClick true + } + }, ) } - Box( - modifier = - Modifier - .matchParentSize() - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) { - optionsShowing = true - }.semantics { - role = Role.DropdownList - stateDescription = accessibilityDescription - onClick(label = openDropdownLabel) { - optionsShowing = true - return@onClick true - } - }, - ) + + // Save/unsave the active place to the followed-locations list (kind 10081), so a + // teleported spot can be kept as a permanent chip — the "add to my interests" step. + if (filter is TopFilter.Geohash) { + FollowLocationToggle(filter.tag, accountViewModel) + } } var teleporting by remember { mutableStateOf(false) } @@ -305,6 +313,37 @@ fun FeedFilterSpinner( } } +/** A compact toggle in the filter header to follow/unfollow the active geohash location. */ +@Composable +private fun FollowLocationToggle( + tag: String, + accountViewModel: AccountViewModel, +) { + val isFollowing by observeUserIsFollowingGeohash(tag, accountViewModel) + IconButton(onClick = { + if (!accountViewModel.isWriteable()) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + if (isFollowing) R.string.login_with_a_private_key_to_be_able_to_unfollow else R.string.login_with_a_private_key_to_be_able_to_follow, + ) + } else if (isFollowing) { + accountViewModel.unfollowGeohash(tag) + } else { + accountViewModel.followGeohash(tag) + } + }) { + Icon( + symbol = if (isFollowing) MaterialSymbols.Bookmark else MaterialSymbols.BookmarkAdd, + contentDescription = + stringRes( + if (isFollowing) R.string.unfollow_geohash else R.string.follow_geohash, + ), + modifier = Size20Modifier, + tint = if (isFollowing) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + @Composable fun RenderOption( option: Name, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index d52c964d8c..f6cf99ee14 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -112,6 +112,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefiniti import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesEvent import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesValidator import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId import com.vitorpamplona.quartz.nip73ExternalIds.scope import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder import com.vitorpamplona.quartz.nip92IMeta.imetas @@ -166,6 +167,15 @@ open class CommentPostViewModel : var externalIdentity by mutableStateOf(null) var replyingTo: Note? by mutableStateOf(null) + /** The geohash channel this comment is scoped to, or null when it isn't a geo-post. */ + val geohashScope: String? + get() = (externalIdentity as? GeohashId)?.geohash + + /** Retarget a geo-post to a different location channel (from the map picker). */ + fun setGeohashScope(geohash: String) { + externalIdentity = GeohashId(geohash) + } + // The signature pre-filled by applySignature(), so an untouched signature-only // message is treated as blank instead of auto-saved as a junk draft. private var appliedSignature: String? = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index 12d9ea78d8..d466f17f9b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.verticalScroll @@ -41,8 +42,14 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -73,6 +80,8 @@ import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton import com.vitorpamplona.amethyst.ui.note.creators.location.GeoHashPostSection +import com.vitorpamplona.amethyst.ui.note.creators.location.GeohashLocationPickerDialog +import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField import com.vitorpamplona.amethyst.ui.note.creators.notify.Notifying import com.vitorpamplona.amethyst.ui.note.creators.pow.PowOverrideButton @@ -302,6 +311,8 @@ private fun GenericCommentPostBody( ) } + GeoPostLocationChannel(postViewModel) + DisplayPreviews(postViewModel.urlPreviews, accountViewModel, nav) if (postViewModel.wantsToMarkAsSensitive) { @@ -439,6 +450,56 @@ private fun GenericCommentPostBody( } } +/** + * For a geo-post (a comment scoped to a geohash channel), shows which place it will post + * to and lets the user retarget it via the map picker. Hidden for non-geo comments. + */ +@Composable +private fun GeoPostLocationChannel(postViewModel: CommentPostViewModel) { + val scope = postViewModel.geohashScope ?: return + var showPicker by remember { mutableStateOf(false) } + + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { showPicker = true } + .padding(horizontal = Size10dp, vertical = 6.dp), + verticalAlignment = CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.LocationOn, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Column(modifier = Modifier.weight(1f).padding(start = 10.dp)) { + Text( + text = stringRes(R.string.geo_post_posting_to), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + LoadCityName(geohashStr = scope) { cityName -> + Text(text = cityName, style = MaterialTheme.typography.bodyLarge, maxLines = 1) + } + } + TextButton(onClick = { showPicker = true }) { + Text(stringRes(R.string.geo_post_change_place)) + } + } + + if (showPicker) { + GeohashLocationPickerDialog( + initialGeohash = scope, + onDismiss = { showPicker = false }, + onConfirm = { cell -> + postViewModel.setGeohashScope(cell) + showPicker = false + }, + ) + } +} + @Composable private fun BottomRowActions(postViewModel: CommentPostViewModel) { val scrollState = rememberScrollState() diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d6a8854beb..deb3b3fb88 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1631,6 +1631,10 @@ Default Follow List Around Me Teleport to a place… + Follow this location + Unfollow this location + Posting to + Change Global Curated Mine