refactor: composer seams for location, expiration, message field and imeta

- DeviceLocation port in commons (LocationState implements it), reached
  through AccountViewModelHost and AppServices; composers and geohash
  screens stop reading Amethyst.instance.locationManager.
- rememberCoarseLocationPermission expect/actual (accompanist on Android,
  denied elsewhere) replaces direct permission-state calls.
- ExpirationDatePicker drops java.time/android.text for LocalClock's new
  utcOffsetSeconds and a rememberIs24HourClock expect.
- MessageField takes MediaUri; PreviewState moves to commonMain.
- MediaUploader.remoteFileHeader replaces IMetaAttachments' MimeTypeMap +
  OkHttp download.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P
This commit is contained in:
Claude
2026-09-30 01:03:41 +00:00
parent 56274d23ef
commit 035ed020e4
41 changed files with 390 additions and 126 deletions
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.location
import android.content.Context
import android.location.Location
import android.location.LocationManager
import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation
import com.vitorpamplona.amethyst.commons.model.location.LocationResult
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChannelLevel
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeohashPrecision
@@ -75,7 +76,7 @@ class LocationState(
LocationFlow(context.getSystemService(Context.LOCATION_SERVICE) as LocationManager)
.get(minTimeMs, minDistanceM, onListening)
},
) {
) : DeviceLocation {
companion object {
/** A 5 km cell takes 2.5 minutes to cross at 120 km/h; 60s/500m is ample. */
const val COARSE_MIN_TIME: Long = 60_000L
@@ -111,7 +112,7 @@ class LocationState(
private val latestPreciseLocation = MutableStateFlow<LocationResult>(LocationResult.Loading)
fun setLocationPermission(newValue: Boolean) {
override fun setLocationPermission(newValue: Boolean) {
if (newValue != hasLocationPermission.value) {
hasLocationPermission.tryEmit(newValue)
}
@@ -195,7 +196,7 @@ class LocationState(
}
}.stateIn(scope, SharingStarted.WhileSubscribed(SUBSCRIPTION_STOP_TIMEOUT_MS), cache.value)
val geohashStateFlow: StateFlow<LocationResult> by lazy {
override val geohashStateFlow: StateFlow<LocationResult> by lazy {
buildGeohashStateFlow(
tag = "GeohashStateFlow",
charsCount = GeohashPrecision.KM_5_X_5.digits,
@@ -217,7 +218,7 @@ class LocationState(
* building-level today. The profile is kept so the intent survives if the
* app ever requests `ACCESS_FINE_LOCATION`.
*/
val preciseGeohashStateFlow: StateFlow<LocationResult> by lazy {
override val preciseGeohashStateFlow: StateFlow<LocationResult> by lazy {
buildGeohashStateFlow(
tag = "PreciseGeohashStateFlow",
charsCount = GeohashChannelLevel.BUILDING.chars,
@@ -22,11 +22,13 @@ package com.vitorpamplona.amethyst.service.uploads
import android.content.Context
import android.net.Uri
import android.webkit.MimeTypeMap
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
import com.vitorpamplona.amethyst.commons.model.mediaServers.blossomUploadOrder
import com.vitorpamplona.amethyst.commons.service.http.IRoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException
import com.vitorpamplona.amethyst.commons.service.upload.FileHeader
@@ -52,6 +54,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import java.io.File
import java.util.Locale
import kotlin.coroutines.cancellation.CancellationException
/**
@@ -592,4 +595,14 @@ class AndroidMediaUploader(
}
}
}
override suspend fun remoteFileHeader(
url: String,
httpClients: IRoleBasedHttpClientBuilder,
): FileHeader? =
withContext(Dispatchers.IO) {
val fileExtension: String = MimeTypeMap.getFileExtensionFromUrl(url)
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension.lowercase(Locale.getDefault()))
FileHeader.prepare(url, mimeType, null) { httpClients.okHttpClientForImage(it) }.getOrNull()
}
}
@@ -34,13 +34,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.location.LocationResult
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.lack_location_permissions
import com.vitorpamplona.amethyst.commons.resources.loading_location
import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName
import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Font12SP
import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
@@ -61,7 +61,7 @@ fun AndroidAroundMeLocationLabel() {
overflow = TextOverflow.Ellipsis,
)
} else {
val location by Amethyst.instance.locationManager.geohashStateFlow
val location by LocalAppServices.current.deviceLocation.geohashStateFlow
.collectAsStateWithLifecycle()
when (val myLocation = location) {
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.expiration
import android.text.format.DateFormat
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -49,7 +48,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -64,14 +62,15 @@ import com.vitorpamplona.amethyst.commons.resources.expiration_date_select
import com.vitorpamplona.amethyst.commons.resources.expiration_expires_in
import com.vitorpamplona.amethyst.commons.resources.expiration_time
import com.vitorpamplona.amethyst.commons.resources.next
import com.vitorpamplona.amethyst.commons.search.calendar.LocalClock
import com.vitorpamplona.amethyst.commons.search.calendar.SearchDate
import com.vitorpamplona.amethyst.commons.ui.note.rememberIs24HourClock
import com.vitorpamplona.amethyst.commons.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.quartz.utils.TimeUtils
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import com.vitorpamplona.quartz.utils.currentTimeMillis
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -79,24 +78,26 @@ fun ExpirationDatePicker(model: IExpiration) {
var showDatePicker by remember { mutableStateOf(false) }
var showTimePicker by remember { mutableStateOf(false) }
val currentTime = Instant.ofEpochMilli(model.expirationDate * 1000).atZone(ZoneId.systemDefault()).toLocalDateTime()
// The expiration as a local wall-clock time: seconds since the epoch shifted by this zone's offset.
val localSeconds = model.expirationDate + LocalClock.utcOffsetSeconds(model.expirationDate)
val localSecondOfDay = localSeconds.mod(TimeUtils.ONE_DAY)
val currentYear = SearchDate.civilFromDays(localSeconds.floorDiv(TimeUtils.ONE_DAY)).year
val datePickerState =
rememberDatePickerState(
initialSelectedDateMillis = model.expirationDate * 1000,
yearRange = currentTime.year..2050,
yearRange = currentYear..2050,
selectableDates =
object : SelectableDates {
override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis >= System.currentTimeMillis() - 86400000
override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis >= currentTimeMillis() - 86400000
},
)
val context = LocalContext.current
val timePickerState =
rememberTimePickerState(
initialHour = currentTime.hour,
initialMinute = currentTime.minute,
is24Hour = DateFormat.is24HourFormat(context),
initialHour = localSecondOfDay / TimeUtils.ONE_HOUR,
initialMinute = (localSecondOfDay % TimeUtils.ONE_HOUR) / TimeUtils.ONE_MINUTE,
is24Hour = rememberIs24HourClock(),
)
Column(Modifier.fillMaxWidth()) {
@@ -183,9 +184,7 @@ fun ExpirationDatePicker(model: IExpiration) {
(timePickerState.minute * TimeUtils.ONE_MINUTE)
} ?: TimeUtils.oneDayAhead()
val offset: ZoneOffset = ZoneId.systemDefault().rules.getOffset(Instant.now())
model.expirationDate = datetimeLocalTimeZone - offset.totalSeconds
model.expirationDate = datetimeLocalTimeZone - LocalClock.utcOffsetSeconds(TimeUtils.now())
showTimePicker = false
},
@@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.lack_location_permissions
import com.vitorpamplona.amethyst.commons.resources.loading_location
import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp
import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
@@ -82,7 +82,6 @@ import androidx.compose.ui.window.DialogProperties
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.location.LocationResult
@@ -97,6 +96,8 @@ import com.vitorpamplona.amethyst.commons.resources.location_picker_search_hint
import com.vitorpamplona.amethyst.commons.resources.location_picker_title
import com.vitorpamplona.amethyst.commons.resources.location_picker_use_mine
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName
import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.service.location.ForwardGeolocation
import com.vitorpamplona.amethyst.service.location.LocationState
@@ -206,7 +207,7 @@ fun GeohashLocationPickerContent(
val context = LocalContext.current
val keyboard = LocalSoftwareKeyboardController.current
val scope = rememberCoroutineScope()
val locationManager = Amethyst.instance.locationManager
val locationManager = LocalAppServices.current.deviceLocation
val seed = remember(initialGeohash) { initialGeohash?.takeIf { it.isNotBlank() }?.let { GeoHash.decode(it) } }
val seedLen = initialGeohash?.trim()?.length ?: 0
@@ -20,12 +20,12 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.location
import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation
import com.vitorpamplona.amethyst.commons.model.location.LocationResult
import com.vitorpamplona.amethyst.service.location.LocationState
import kotlinx.coroutines.flow.StateFlow
interface ILocationGrabber {
fun locationManager(): LocationState
fun locationManager(): DeviceLocation
fun locationFlow(): StateFlow<LocationResult>
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.location
import android.Manifest
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -39,37 +38,31 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.geohash_explainer
import com.vitorpamplona.amethyst.commons.resources.geohash_title
import com.vitorpamplona.amethyst.commons.ui.components.rememberCoarseLocationPermission
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun LocationAsHash(
postViewModel: ILocationGrabber,
innerContent: @Composable () -> Unit = {},
) {
val locationPermissionState =
rememberPermissionState(
Manifest.permission.ACCESS_COARSE_LOCATION,
)
val locationPermission = rememberCoarseLocationPermission()
LaunchedEffect(locationPermissionState.status.isGranted) {
postViewModel.locationManager().setLocationPermission(locationPermissionState.status.isGranted)
LaunchedEffect(locationPermission.isGranted) {
postViewModel.locationManager().setLocationPermission(locationPermission.isGranted)
}
if (locationPermissionState.status.isGranted) {
if (locationPermission.isGranted) {
DisplayLocationBox(postViewModel, innerContent)
} else {
LaunchedEffect(locationPermissionState) { locationPermissionState.launchPermissionRequest() }
LaunchedEffect(locationPermission) { locationPermission.launchRequest() }
}
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.messagefield
import android.net.Uri
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.LocalTextStyle
@@ -40,6 +39,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUri
import com.vitorpamplona.amethyst.commons.ui.note.creators.messagefield.IMessageField
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.text.MentionPreservingInputTransformation
@@ -55,7 +55,7 @@ fun MessageField(
placeholder: StringResource,
viewModel: IMessageField,
requestFocus: Boolean = true,
onContentReceived: ((Uri, String?) -> Unit)? = null,
onContentReceived: ((MediaUri, String?) -> Unit)? = null,
) {
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
@@ -30,7 +30,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
@@ -43,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger
import com.vitorpamplona.amethyst.commons.model.composer.PreviewState
import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder
import com.vitorpamplona.amethyst.commons.model.composer.toZapSplitSetup
import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation
import com.vitorpamplona.amethyst.commons.model.location.LocationResult
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
@@ -73,7 +73,6 @@ import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.ui.text.setTextAndPlaceCursorAtBeginning
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor
@@ -1006,9 +1005,7 @@ open class CommentPostViewModel :
val wordToInsert = item.link + " "
viewModelScope.launch(Dispatchers.IO) {
iMetaAttachments.downloadAndPrepare(item.link) {
accountViewModel.httpClientBuilder.okHttpClientForImage(item.link)
}
iMetaAttachments.downloadAndPrepare(item.link, accountViewModel.host.mediaUploader, accountViewModel.httpClientBuilder)
}
message.replaceCurrentWord(wordToInsert)
@@ -1081,5 +1078,5 @@ open class CommentPostViewModel :
return location!!
}
override fun locationManager(): LocationState = Amethyst.instance.locationManager
override fun locationManager(): DeviceLocation = accountViewModel.host.deviceLocation
}
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.browser.BrowserHistoryRegistry
import com.vitorpamplona.amethyst.commons.browser.BrowserIconRegistry
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
import com.vitorpamplona.amethyst.commons.service.AppServices
import com.vitorpamplona.amethyst.commons.service.BlossomServerFinder
@@ -52,6 +53,8 @@ object AndroidAppServices : AppServices {
override val namecoinResolver: NamecoinNameResolver get() = Amethyst.instance.namecoinResolver
override val blossomServerFinder: BlossomServerFinder = AndroidBlossomServerFinder
override val deviceLocation: DeviceLocation get() = Amethyst.instance.locationManager
}
/** [BlossomServerFinder] over the app's BUD-10 resolver, read lazily (main process only). */
@@ -25,6 +25,7 @@ import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.AppModules
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation
import com.vitorpamplona.amethyst.commons.service.lnurl.LnurlHttpTransport
import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlTransport
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobFailure
@@ -74,6 +75,8 @@ class AndroidAccountViewModelHost(
override val mediaUploader: MediaUploader by lazy { AndroidMediaUploader(modules.appContext) }
override val deviceLocation: DeviceLocation get() = modules.locationManager
override val savedAccounts: Flow<Set<HexKey>> =
LocalPreferences
.accountsFlow()
@@ -45,6 +45,8 @@ import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.error_opening_external_signer
import com.vitorpamplona.amethyst.commons.resources.error_opening_external_signer_description
import com.vitorpamplona.amethyst.commons.ui.components.rememberCoarseLocationPermission
import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelPreload
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
@@ -133,13 +135,13 @@ fun LoggedInPage(
)
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun WatchLocationPermissions() {
val locationPermissionState = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION)
val locationPermissionState = rememberCoarseLocationPermission()
LaunchedEffect(locationPermissionState.status.isGranted) {
Amethyst.instance.locationManager.setLocationPermission(locationPermissionState.status.isGranted)
val locationSource = LocalAppServices.current.deviceLocation
LaunchedEffect(locationPermissionState.isGranted) {
locationSource.setLocationPermission(locationPermissionState.isGranted)
}
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat
import android.Manifest
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -57,16 +56,13 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
import com.vitorpamplona.amethyst.commons.model.location.LocationResult
import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserPicture
import com.vitorpamplona.amethyst.commons.search.SearchSeed
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.components.rememberCoarseLocationPermission
import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingScaffold
@@ -74,6 +70,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SearchTopBarAction
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName
import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.LocalChatActingIdentities
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.LocalChatDisplayNameResolver
@@ -121,7 +118,6 @@ fun GeohashChatScreen(
}
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
private fun GeohashChatRoom(
channel: GeohashChatChannel,
@@ -159,13 +155,14 @@ private fun GeohashChatRoom(
// otherwise we fall back to how the user arrived — the map picker passes teleported=true, "near me"
// and manual entry default to false. This drives the ["t","teleport"] tag honestly. rememberPermissionState
// only reads the current grant (it never prompts), so opening a location chat can't trigger a GPS dialog.
val locationPermission = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION)
LaunchedEffect(locationPermission.status.isGranted) {
if (locationPermission.status.isGranted) {
Amethyst.instance.locationManager.setLocationPermission(true)
val locationPermission = rememberCoarseLocationPermission()
val locationSource = LocalAppServices.current.deviceLocation
LaunchedEffect(locationPermission.isGranted) {
if (locationPermission.isGranted) {
locationSource.setLocationPermission(true)
}
}
val deviceLocation by Amethyst.instance.locationManager.preciseGeohashStateFlow
val deviceLocation by locationSource.preciseGeohashStateFlow
.collectAsStateWithLifecycle()
val isTeleported =
remember(deviceLocation, geohash, teleported) {
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat
import android.Manifest
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -56,18 +55,16 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.location.LocationResult
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.ui.components.rememberCoarseLocationPermission
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.commons.ui.note.platform.LoadCityName
import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChannelLevel
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash
@@ -274,18 +271,18 @@ private fun ManualEntrySection(onOpen: (String) -> Unit) {
}
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
private fun NearMeSection(onOpen: (String) -> Unit) {
Column {
SectionHeader(MaterialSymbols.Explore, "Near me")
val permission = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION)
LaunchedEffect(permission.status.isGranted) {
Amethyst.instance.locationManager.setLocationPermission(permission.status.isGranted)
val permission = rememberCoarseLocationPermission()
val locationSource = LocalAppServices.current.deviceLocation
LaunchedEffect(permission.isGranted) {
locationSource.setLocationPermission(permission.isGranted)
}
if (!permission.status.isGranted) {
if (!permission.isGranted) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
modifier = Modifier.fillMaxWidth(),
@@ -297,7 +294,7 @@ private fun NearMeSection(onOpen: (String) -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 12.dp),
)
FilledTonalButton(onClick = { permission.launchPermissionRequest() }) {
FilledTonalButton(onClick = { permission.launchRequest() }) {
SymbolIcon(symbol = MaterialSymbols.LocationOn, contentDescription = null, modifier = Modifier.size(18.dp))
Text(" Use my location")
}
@@ -306,7 +303,7 @@ private fun NearMeSection(onOpen: (String) -> Unit) {
return
}
val location by Amethyst.instance.locationManager.preciseGeohashStateFlow
val location by LocalAppServices.current.deviceLocation.preciseGeohashStateFlow
.collectAsStateWithLifecycle()
when (val loc = location) {
is LocationResult.Success -> {
@@ -30,7 +30,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
@@ -43,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger
import com.vitorpamplona.amethyst.commons.model.composer.PreviewState
import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder
import com.vitorpamplona.amethyst.commons.model.composer.toZapSplitSetup
import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation
import com.vitorpamplona.amethyst.commons.model.location.LocationResult
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
@@ -57,7 +57,6 @@ import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.ui.text.onUiThread
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileSender
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileUploader
@@ -798,9 +797,7 @@ class ChatNewMessageViewModel :
val wordToInsert = item.link + " "
viewModelScope.launch(Dispatchers.IO) {
iMetaAttachments.downloadAndPrepare(item.link) {
accountViewModel.httpClientBuilder.okHttpClientForImage(item.link)
}
iMetaAttachments.downloadAndPrepare(item.link, accountViewModel.host.mediaUploader, accountViewModel.httpClientBuilder)
}
message.replaceCurrentWord(wordToInsert)
@@ -870,5 +867,5 @@ class ChatNewMessageViewModel :
draftTag.newVersion()
}
override fun locationManager(): LocationState = Amethyst.instance.locationManager
override fun locationManager(): DeviceLocation = accountViewModel.host.deviceLocation
}
@@ -20,13 +20,12 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
import android.webkit.MimeTypeMap
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.vitorpamplona.amethyst.commons.service.upload.FileHeader
import com.vitorpamplona.amethyst.commons.service.http.IRoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.prepare
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
import com.vitorpamplona.quartz.nip92IMeta.imetaTagBuilder
@@ -40,21 +39,17 @@ import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
import com.vitorpamplona.quartz.nip94FileMetadata.size
import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash
import okhttp3.OkHttpClient
import java.util.Locale
class IMetaAttachments {
var iMetaAttachments by mutableStateOf<List<IMetaTag>>(emptyList())
suspend fun downloadAndPrepare(
url: String,
okHttpClient: (String) -> OkHttpClient,
uploader: MediaUploader,
httpClients: IRoleBasedHttpClientBuilder,
) {
val fileExtension: String = MimeTypeMap.getFileExtensionFromUrl(url)
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension.lowercase(Locale.getDefault()))
val imeta =
FileHeader.prepare(url, mimeType, null, okHttpClient).getOrNull()?.let {
uploader.remoteFileHeader(url, httpClients)?.let {
IMetaTagBuilder(url)
.apply {
hash(it.hash)
@@ -30,7 +30,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Channel
@@ -44,6 +43,7 @@ import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger
import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation
import com.vitorpamplona.amethyst.commons.model.location.LocationResult
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
@@ -71,7 +71,6 @@ import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
@@ -1016,9 +1015,7 @@ open class ChannelNewMessageViewModel :
val wordToInsert = item.link + " "
viewModelScope.launch(Dispatchers.IO) {
iMetaAttachments.downloadAndPrepare(item.link) {
accountViewModel.httpClientBuilder.okHttpClientForImage(item.link)
}
iMetaAttachments.downloadAndPrepare(item.link, accountViewModel.host.mediaUploader, accountViewModel.httpClientBuilder)
}
message.replaceCurrentWord(wordToInsert)
@@ -1041,7 +1038,7 @@ open class ChannelNewMessageViewModel :
message.insertUrlAtCursor(newElement)
}
override fun locationManager(): LocationState = Amethyst.instance.locationManager
override fun locationManager(): DeviceLocation = accountViewModel.host.deviceLocation
override fun locationFlow(): StateFlow<LocationResult> {
if (location == null) {
@@ -31,7 +31,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
@@ -43,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.model.composer.IZapRaiser
import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger
import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder
import com.vitorpamplona.amethyst.commons.model.composer.toZapSplitSetup
import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation
import com.vitorpamplona.amethyst.commons.model.location.LocationResult
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia
@@ -72,7 +72,6 @@ import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.amethyst.commons.ui.uploads.uploadToDefaultServer
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor
@@ -686,9 +685,7 @@ class LongFormPostViewModel :
val wordToInsert = item.link + " "
viewModelScope.launch(Dispatchers.IO) {
iMetaAttachments.downloadAndPrepare(item.link) {
accountViewModel.httpClientBuilder.okHttpClientForImage(item.link)
}
iMetaAttachments.downloadAndPrepare(item.link, accountViewModel.host.mediaUploader, accountViewModel.httpClientBuilder)
}
message.replaceCurrentWord(wordToInsert)
@@ -762,5 +759,5 @@ class LongFormPostViewModel :
draftTag.newVersion()
}
override fun locationManager(): LocationState = Amethyst.instance.locationManager
override fun locationManager(): DeviceLocation = accountViewModel.host.deviceLocation
}
@@ -30,7 +30,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
@@ -43,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger
import com.vitorpamplona.amethyst.commons.model.composer.PreviewState
import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder
import com.vitorpamplona.amethyst.commons.model.composer.toZapSplitSetup
import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation
import com.vitorpamplona.amethyst.commons.model.location.LocationResult
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
@@ -67,7 +67,6 @@ import com.vitorpamplona.amethyst.commons.ui.text.onUiThread
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor
@@ -579,9 +578,7 @@ open class NewProductViewModel :
val wordToInsert = item.link + " "
viewModelScope.launch(Dispatchers.IO) {
iMetaDescription.downloadAndPrepare(item.link) {
accountViewModel.httpClientBuilder.okHttpClientForImage(item.link)
}
iMetaDescription.downloadAndPrepare(item.link, accountViewModel.host.mediaUploader, accountViewModel.httpClientBuilder)
}
message.replaceCurrentWord(wordToInsert)
@@ -681,5 +678,5 @@ open class NewProductViewModel :
draftTag.newVersion()
}
override fun locationManager(): LocationState = Amethyst.instance.locationManager
override fun locationManager(): DeviceLocation = accountViewModel.host.deviceLocation
}
@@ -59,7 +59,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.feeds.ChannelFeedContentState
import com.vitorpamplona.amethyst.commons.feeds.ChannelFeedState
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
@@ -92,6 +91,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.zonedDrawerSwipeIfModal
import com.vitorpamplona.amethyst.commons.ui.note.NoteCompose
import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar
import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geohash.NewGeoPostButton
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.hashtag.NewHashtagPostButton
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -334,7 +334,7 @@ fun HomeScreenFloatingButton(
when (val filter = list.value) {
TopFilter.AroundMe -> {
val location by Amethyst.instance.locationManager.geohashStateFlow
val location by LocalAppServices.current.deviceLocation.geohashStateFlow
.collectAsStateWithLifecycle()
when (val myLocation = location) {
@@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger
import com.vitorpamplona.amethyst.commons.model.composer.PreviewState
import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder
import com.vitorpamplona.amethyst.commons.model.composer.toZapSplitSetup
import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation
import com.vitorpamplona.amethyst.commons.model.location.LocationResult
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia
@@ -91,7 +92,6 @@ import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.service.ai.WritingAssistantFactory
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationController
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
@@ -1822,9 +1822,7 @@ open class ShortNotePostViewModel :
val wordToInsert = item.link + " "
viewModelScope.launch(Dispatchers.IO) {
iMetaAttachments.downloadAndPrepare(item.link) {
accountViewModel.httpClientBuilder.okHttpClientForImage(item.link)
}
iMetaAttachments.downloadAndPrepare(item.link, accountViewModel.host.mediaUploader, accountViewModel.httpClientBuilder)
}
message.replaceCurrentWord(wordToInsert)
@@ -2120,5 +2118,5 @@ open class ShortNotePostViewModel :
draftTag.newVersion()
}
override fun locationManager(): LocationState = Amethyst.instance.locationManager
override fun locationManager(): DeviceLocation = accountViewModel.host.deviceLocation
}
@@ -30,7 +30,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
@@ -40,6 +39,7 @@ import com.vitorpamplona.amethyst.commons.model.composer.DraftTagState
import com.vitorpamplona.amethyst.commons.model.composer.IExpiration
import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger
import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder
import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation
import com.vitorpamplona.amethyst.commons.model.location.LocationResult
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
@@ -61,7 +61,6 @@ import com.vitorpamplona.amethyst.commons.ui.text.onUiThread
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
@@ -571,9 +570,7 @@ open class NestNewMessageViewModel :
val wordToInsert = item.link + " "
viewModelScope.launch(Dispatchers.IO) {
iMetaAttachments.downloadAndPrepare(item.link) {
accountViewModel.httpClientBuilder.okHttpClientForImage(item.link)
}
iMetaAttachments.downloadAndPrepare(item.link, accountViewModel.host.mediaUploader, accountViewModel.httpClientBuilder)
}
message.replaceCurrentWord(wordToInsert)
@@ -596,7 +593,7 @@ open class NestNewMessageViewModel :
message.insertUrlAtCursor(newElement)
}
override fun locationManager(): LocationState = Amethyst.instance.locationManager
override fun locationManager(): DeviceLocation = accountViewModel.host.deviceLocation
override fun locationFlow(): StateFlow<LocationResult> {
if (location == null) {
@@ -30,7 +30,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
@@ -43,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger
import com.vitorpamplona.amethyst.commons.model.composer.PreviewState
import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder
import com.vitorpamplona.amethyst.commons.model.composer.toZapSplitSetup
import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation
import com.vitorpamplona.amethyst.commons.model.location.LocationResult
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
@@ -69,7 +69,6 @@ import com.vitorpamplona.amethyst.commons.ui.text.onUiThread
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor
@@ -643,9 +642,7 @@ class NewPublicMessageViewModel :
val wordToInsert = item.link + " "
viewModelScope.launch(Dispatchers.IO) {
iMetaAttachments.downloadAndPrepare(item.link) {
accountViewModel.httpClientBuilder.okHttpClientForImage(item.link)
}
iMetaAttachments.downloadAndPrepare(item.link, accountViewModel.host.mediaUploader, accountViewModel.httpClientBuilder)
}
message.replaceCurrentWord(wordToInsert)
@@ -715,5 +712,5 @@ class NewPublicMessageViewModel :
draftTag.newVersion()
}
override fun locationManager(): LocationState = Amethyst.instance.locationManager
override fun locationManager(): DeviceLocation = accountViewModel.host.deviceLocation
}
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.richtext.CachedUrlParser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -0,0 +1,49 @@
/*
* 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.commons.model.location
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* The device's position as geohashes, for "around me" feeds, location chats and the composers'
* location tag. The platform decides when it actually listens; readers only collect.
*/
interface DeviceLocation {
/** A roughly 5 km geohash cell, or why there is none. */
val geohashStateFlow: StateFlow<LocationResult>
/** The finest geohash the platform offers, for location channels that truncate it per level. */
val preciseGeohashStateFlow: StateFlow<LocationResult>
/** Tells the source whether the user has granted location access, so it can start or stop. */
fun setLocationPermission(newValue: Boolean)
/** No location source on this platform: always reports a missing permission. */
object None : DeviceLocation {
private val lacking = MutableStateFlow<LocationResult>(LocationResult.LackPermission)
override val geohashStateFlow: StateFlow<LocationResult> = lacking
override val preciseGeohashStateFlow: StateFlow<LocationResult> = lacking
override fun setLocationPermission(newValue: Boolean) = Unit
}
}
@@ -41,6 +41,9 @@ expect object LocalClock {
/** Today, in the reader's timezone. */
fun today(): SearchDate
/** How many seconds this zone is ahead of UTC at [unixSeconds]; a clock change moves it. */
fun utcOffsetSeconds(unixSeconds: Long): Long
/**
* Which local day a unix second falls on, with the zone read once for the whole counter
* rather than per timestamp: for grouping a batch of events by day in one pass.
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.browser.BrowserHistoryRegistry
import com.vitorpamplona.amethyst.commons.browser.BrowserIconRegistry
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
@@ -56,4 +57,7 @@ interface AppServices {
/** Finds the server behind a `blossom:` URI (BUD-10). */
val blossomServerFinder: BlossomServerFinder get() = BlossomServerFinder.None
/** The device's position as geohashes. */
val deviceLocation: DeviceLocation get() = DeviceLocation.None
}
@@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.commons.service.uploads
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.service.http.IRoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.commons.service.upload.FileHeader
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.utils.ciphers.NostrCipher
@@ -74,6 +76,16 @@ interface MediaUploader {
useH265: Boolean,
convertGifToMp4: Boolean,
): MediaCompressorResult
/**
* Downloads the already-hosted file at [url] and computes its [FileHeader] (hash, size,
* dimensions, preview hashes) for an `imeta` tag, guessing the MIME type from the URL's
* extension. Null when it cannot be downloaded or decoded.
*/
suspend fun remoteFileHeader(
url: String,
httpClients: IRoleBasedHttpClientBuilder,
): FileHeader?
}
/** No media pipeline on this front end yet: every upload fails with a clear message. */
@@ -121,4 +133,9 @@ object UnavailableMediaUploader : MediaUploader {
useH265: Boolean,
convertGifToMp4: Boolean,
): MediaCompressorResult = MediaCompressorResult(uri, mimeType, null)
override suspend fun remoteFileHeader(
url: String,
httpClients: IRoleBasedHttpClientBuilder,
): FileHeader? = null
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.commons.viewmodels
import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation
import com.vitorpamplona.amethyst.commons.service.lnurl.LnurlHttpTransport
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobFailure
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
@@ -70,6 +71,9 @@ interface AccountViewModelHost {
/** Compresses, strips, encrypts and uploads picked media for the composers. */
val mediaUploader: MediaUploader
/** The device's position as geohashes. */
val deviceLocation: DeviceLocation get() = DeviceLocation.None
/** Public keys of every account saved on this device. */
val savedAccounts: Flow<Set<HexKey>>
@@ -67,6 +67,8 @@ actual object LocalClock {
actual fun today(): SearchDate = ZoneMath.dayAt(currentTimeSeconds(), localZone)
actual fun utcOffsetSeconds(unixSeconds: Long): Long = localZone.at(unixSeconds)
actual fun epochDayCounter(): EpochDayCounter = EpochDayCounter { ZoneMath.epochDayAt(it, localZone) }
/**
@@ -54,6 +54,13 @@ actual object LocalClock {
SearchDate(it.year, it.monthValue, it.dayOfMonth)
}
actual fun utcOffsetSeconds(unixSeconds: Long): Long =
zone()
.rules
.getOffset(Instant.ofEpochSecond(unixSeconds))
.totalSeconds
.toLong()
actual fun epochDayCounter(): EpochDayCounter {
val rules = zone().rules
val offsets = ZoneOffsets { rules.getOffset(Instant.ofEpochSecond(it)).totalSeconds.toLong() }
@@ -0,0 +1,41 @@
/*
* 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.commons.ui.components
import android.Manifest
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
@OptIn(ExperimentalPermissionsApi::class)
@Composable
actual fun rememberCoarseLocationPermission(): PlatformPermission {
val state = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION)
return remember(state) {
object : PlatformPermission {
override val isGranted: Boolean get() = state.status.isGranted
override fun launchRequest() = state.launchPermissionRequest()
}
}
}
@@ -70,6 +70,12 @@ actual fun rememberTimeOfDayFormatter(): (epochMillis: Long) -> String {
return remember(context) { { epochMillis -> DateFormat.getTimeFormat(context).format(Date(epochMillis)) } }
}
@Composable
actual fun rememberIs24HourClock(): Boolean {
val context = LocalContext.current
return remember(context) { DateFormat.is24HourFormat(context) }
}
actual fun relativeTimeSpanShortOrNull(
epochMillis: Long,
nowMillis: Long,
@@ -0,0 +1,30 @@
/*
* 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.commons.ui.components
import androidx.compose.runtime.Composable
/**
* The approximate-location permission the geohash features need. Android asks for
* `ACCESS_COARSE_LOCATION`; desktop and iOS have no location source wired yet and report it denied.
*/
@Composable
expect fun rememberCoarseLocationPermission(): PlatformPermission
@@ -0,0 +1,32 @@
/*
* 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.commons.ui.components
import androidx.compose.runtime.Stable
/** A runtime permission the user may grant or deny. [isGranted] is observable from composition. */
@Stable
interface PlatformPermission {
val isGranted: Boolean
/** Asks the user for the permission; [isGranted] updates when they answer. */
fun launchRequest()
}
@@ -45,6 +45,10 @@ expect fun calendarYearAndDay(epochMillis: Long): Int
@Composable
expect fun rememberTimeOfDayFormatter(): (epochMillis: Long) -> String
/** Whether times should be shown on a 24-hour clock, under the same rules as [rememberTimeOfDayFormatter]. */
@Composable
expect fun rememberIs24HourClock(): Boolean
/**
* The platform's abbreviated relative span ("5 min. ago", "Yesterday"), [nowLabel] for an
* instant that rounds to now or lies ahead, or null where the platform has no such formatter.
@@ -0,0 +1,33 @@
/*
* 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.commons.ui.components
import androidx.compose.runtime.Composable
private object NoLocationPermission : PlatformPermission {
override val isGranted: Boolean = false
override fun launchRequest() = Unit
}
/** No location source on this platform yet. */
@Composable
actual fun rememberCoarseLocationPermission(): PlatformPermission = NoLocationPermission
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.commons.ui.note
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import platform.Foundation.NSCalendar
import platform.Foundation.NSCalendarUnitDay
import platform.Foundation.NSCalendarUnitYear
@@ -88,6 +89,10 @@ private val timeOfDay: (Long) -> String = { epochMillis -> timeOfDayFormatter.st
@Composable
actual fun rememberTimeOfDayFormatter(): (epochMillis: Long) -> String = timeOfDay
// The "j" template asks for the locale's preferred hour; an "a" in the result means an AM/PM clock.
@Composable
actual fun rememberIs24HourClock(): Boolean = remember { NSDateFormatter.dateFormatFromTemplate("j", 0u, NSLocale.currentLocale)?.contains('a') != true }
actual fun relativeTimeSpanShortOrNull(
epochMillis: Long,
nowMillis: Long,
@@ -0,0 +1,33 @@
/*
* 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.commons.ui.components
import androidx.compose.runtime.Composable
private object NoLocationPermission : PlatformPermission {
override val isGranted: Boolean = false
override fun launchRequest() = Unit
}
/** No location source on this platform yet. */
@Composable
actual fun rememberCoarseLocationPermission(): PlatformPermission = NoLocationPermission
@@ -21,9 +21,12 @@
package com.vitorpamplona.amethyst.commons.ui.note
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import java.time.Instant
import java.time.ZoneId
import java.time.chrono.IsoChronology
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeFormatterBuilder
import java.time.format.FormatStyle
import java.util.Locale
@@ -80,6 +83,14 @@ private val timeOfDay: (Long) -> String = { epochMillis ->
@Composable
actual fun rememberTimeOfDayFormatter(): (epochMillis: Long) -> String = timeOfDay
@Composable
actual fun rememberIs24HourClock(): Boolean =
remember {
DateTimeFormatterBuilder
.getLocalizedDateTimePattern(null, FormatStyle.SHORT, IsoChronology.INSTANCE, Locale.getDefault())
.contains('H')
}
actual fun relativeTimeSpanShortOrNull(
epochMillis: Long,
nowMillis: Long,