mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
+155
-116
@@ -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,
|
||||
|
||||
+10
@@ -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
|
||||
|
||||
+61
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user