feat(location): follow a teleported place; retarget geo-posts

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 <place>"
   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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
This commit is contained in:
Claude
2026-07-19 01:25:24 +00:00
parent 7c37a8691c
commit fb9ccbaad0
4 changed files with 230 additions and 116 deletions
@@ -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,
@@ -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<ExternalId?>(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
@@ -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()
+4
View File
@@ -1631,6 +1631,10 @@
<string name="follow_list_kind3_follows_users_only">Default Follow List</string>
<string name="follow_list_aroundme">Around Me</string>
<string name="follow_list_teleport">Teleport to a place…</string>
<string name="follow_geohash">Follow this location</string>
<string name="unfollow_geohash">Unfollow this location</string>
<string name="geo_post_posting_to">Posting to</string>
<string name="geo_post_change_place">Change</string>
<string name="follow_list_global">Global</string>
<string name="follow_list_curated">Curated</string>
<string name="follow_list_mine">Mine</string>