mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
feat(blossom): mobile upload controls — mirror + /media toggles, manager refresh
Adds first-class Blossom upload controls to the Media Servers screen and rounds out the blob manager: - New persisted account settings (LocalPreferences + AccountSettings): `mirrorUploadsToAllServers` (BUD-04, default on) and `optimizeMediaOnUpload` (BUD-05, default off), each with a change fn. - UploadOrchestrator honours both: uploads via /media with a t=media token when optimize is on, and only mirrors when the mirror toggle is on. - BlossomUploader gains a useMediaEndpoint flag to target /media vs /upload. - "Upload behaviour" section on the Media Servers screen: mirror toggle, optimize-via-/media toggle, and a shortcut into the blob manager. - Blob manager gains a refresh FAB. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
This commit is contained in:
@@ -109,6 +109,8 @@ private object PrefKeys {
|
||||
const val STRIP_LOCATION_ON_UPLOAD = "stripLocationOnUpload"
|
||||
const val USE_LOCAL_BLOSSOM_CACHE = "useLocalBlossomCache"
|
||||
const val LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY = "localBlossomCacheProfilePicturesOnly"
|
||||
const val MIRROR_UPLOADS_TO_ALL_SERVERS = "mirrorUploadsToAllServers"
|
||||
const val OPTIMIZE_MEDIA_ON_UPLOAD = "optimizeMediaOnUpload"
|
||||
const val HIDE_COMMUNITY_RULES_VIOLATIONS = "hideCommunityRulesViolations"
|
||||
const val NIP46_SIGNER_ENABLED = "nip46SignerEnabled"
|
||||
const val NIP46_BUNKER_SECRET = "nip46BunkerSecret"
|
||||
@@ -473,6 +475,8 @@ object LocalPreferences {
|
||||
putBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, settings.stripLocationOnUpload)
|
||||
putBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, settings.useLocalBlossomCache.value)
|
||||
putBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, settings.localBlossomCacheProfilePicturesOnly.value)
|
||||
putBoolean(PrefKeys.MIRROR_UPLOADS_TO_ALL_SERVERS, settings.mirrorUploadsToAllServers.value)
|
||||
putBoolean(PrefKeys.OPTIMIZE_MEDIA_ON_UPLOAD, settings.optimizeMediaOnUpload.value)
|
||||
putBoolean(PrefKeys.HIDE_COMMUNITY_RULES_VIOLATIONS, settings.hideCommunityRulesViolations.value)
|
||||
putBoolean(PrefKeys.NIP46_SIGNER_ENABLED, settings.nip46SignerEnabled.value)
|
||||
putString(PrefKeys.NIP46_BUNKER_SECRET, settings.nip46BunkerSecret.value)
|
||||
@@ -688,6 +692,8 @@ object LocalPreferences {
|
||||
val stripLocationOnUpload = getBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, true)
|
||||
val useLocalBlossomCache = getBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, true)
|
||||
val localBlossomCacheProfilePicturesOnly = getBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, false)
|
||||
val mirrorUploadsToAllServers = getBoolean(PrefKeys.MIRROR_UPLOADS_TO_ALL_SERVERS, true)
|
||||
val optimizeMediaOnUpload = getBoolean(PrefKeys.OPTIMIZE_MEDIA_ON_UPLOAD, false)
|
||||
val hideCommunityRulesViolations = getBoolean(PrefKeys.HIDE_COMMUNITY_RULES_VIOLATIONS, false)
|
||||
val nip46SignerEnabled = getBoolean(PrefKeys.NIP46_SIGNER_ENABLED, false)
|
||||
val nip46BunkerSecret = getString(PrefKeys.NIP46_BUNKER_SECRET, "") ?: ""
|
||||
@@ -872,6 +878,8 @@ object LocalPreferences {
|
||||
stripLocationOnUpload = stripLocationOnUpload,
|
||||
useLocalBlossomCache = MutableStateFlow(useLocalBlossomCache),
|
||||
localBlossomCacheProfilePicturesOnly = MutableStateFlow(localBlossomCacheProfilePicturesOnly),
|
||||
mirrorUploadsToAllServers = MutableStateFlow(mirrorUploadsToAllServers),
|
||||
optimizeMediaOnUpload = MutableStateFlow(optimizeMediaOnUpload),
|
||||
hideCommunityRulesViolations = MutableStateFlow(hideCommunityRulesViolations),
|
||||
nip46SignerEnabled = MutableStateFlow(nip46SignerEnabled),
|
||||
nip46BunkerSecret = MutableStateFlow(nip46BunkerSecret),
|
||||
|
||||
@@ -186,6 +186,16 @@ class AccountSettings(
|
||||
var stripLocationOnUpload: Boolean = true,
|
||||
val useLocalBlossomCache: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
val localBlossomCacheProfilePicturesOnly: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||
/**
|
||||
* BUD-04: after uploading a blob to the primary Blossom server, replicate it to
|
||||
* the user's other configured servers (kind 10063) for redundancy.
|
||||
*/
|
||||
val mirrorUploadsToAllServers: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
/**
|
||||
* BUD-05: upload media through the server's `/media` endpoint so the server may
|
||||
* strip metadata and optimize it, instead of the bit-exact `/upload`.
|
||||
*/
|
||||
val optimizeMediaOnUpload: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||
/**
|
||||
* NIP-46: when true, this account acts as a remote signer (a "bunker") for
|
||||
* other apps, listening on the user's inbox relays for kind:24133 requests.
|
||||
@@ -665,6 +675,20 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun changeMirrorUploadsToAllServers(enabled: Boolean) {
|
||||
if (mirrorUploadsToAllServers.value != enabled) {
|
||||
mirrorUploadsToAllServers.tryEmit(enabled)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeOptimizeMediaOnUpload(enabled: Boolean) {
|
||||
if (optimizeMediaOnUpload.value != enabled) {
|
||||
optimizeMediaOnUpload.tryEmit(enabled)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateAddClientTag(add: Boolean): Boolean =
|
||||
if (syncedSettings.security.updateAddClientTag(add)) {
|
||||
saveAccountSettings()
|
||||
|
||||
+11
-6
@@ -202,6 +202,9 @@ class UploadOrchestrator {
|
||||
context: Context,
|
||||
): UploadingFinalState {
|
||||
updateState(0.2, UploadingState.Uploading)
|
||||
// BUD-05: route through /media (optimize) when the user opted in. The forced-signer
|
||||
// path (e.g. NIP-46 draft signing) always uses the bit-exact /upload.
|
||||
val useMedia = forcedSigner == null && account.settings.optimizeMediaOnUpload.value
|
||||
return try {
|
||||
val result =
|
||||
BlossomUploader()
|
||||
@@ -213,14 +216,16 @@ class UploadOrchestrator {
|
||||
sensitiveContent = contentWarningReason,
|
||||
serverBaseUrl = serverBaseUrl,
|
||||
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
|
||||
// Scope the upload token to the target server (BUD-11) so it can't be replayed elsewhere.
|
||||
// Scope the token to the target server (BUD-11) so it can't be replayed elsewhere,
|
||||
// and use a t=media token when optimizing via /media.
|
||||
httpAuth =
|
||||
if (forcedSigner != null) {
|
||||
{ hash, size, alt -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner, listOf(serverBaseUrl)) }
|
||||
} else {
|
||||
{ hash, size, alt -> account.createBlossomUploadAuth(hash, size, alt, listOf(serverBaseUrl)) }
|
||||
when {
|
||||
forcedSigner != null -> { hash, size, alt -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner, listOf(serverBaseUrl)) }
|
||||
useMedia -> { hash, size, alt -> account.createBlossomMediaAuth(hash, size, alt, listOf(serverBaseUrl)) }
|
||||
else -> { hash, size, alt -> account.createBlossomUploadAuth(hash, size, alt, listOf(serverBaseUrl)) }
|
||||
},
|
||||
context = context,
|
||||
useMediaEndpoint = useMedia,
|
||||
)
|
||||
|
||||
val finalState =
|
||||
@@ -234,7 +239,7 @@ class UploadOrchestrator {
|
||||
|
||||
// BUD-04: replicate the blob to the user's other Blossom servers for redundancy.
|
||||
// Best-effort — a mirror failure never fails the upload the user already completed.
|
||||
if (finalState is UploadingState.Finished && forcedSigner == null) {
|
||||
if (finalState is UploadingState.Finished && forcedSigner == null && account.settings.mirrorUploadsToAllServers.value) {
|
||||
mirrorToOtherServers(result, serverBaseUrl, account)
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -79,6 +79,7 @@ class BlossomUploader {
|
||||
okHttpClient: (String) -> OkHttpClient,
|
||||
httpAuth: suspend (hash: HexKey, size: Long, alt: String) -> BlossomAuthorizationEvent?,
|
||||
context: Context,
|
||||
useMediaEndpoint: Boolean = false,
|
||||
onProgress: ((bytesWritten: Long, totalBytes: Long) -> Unit)? = null,
|
||||
): MediaUploadResult {
|
||||
checkNotInMainThread()
|
||||
@@ -115,6 +116,7 @@ class BlossomUploader {
|
||||
okHttpClient,
|
||||
httpAuth,
|
||||
context,
|
||||
useMediaEndpoint,
|
||||
onProgress,
|
||||
)
|
||||
}.mergeLocalMetadata(localMetadata)
|
||||
@@ -132,6 +134,7 @@ class BlossomUploader {
|
||||
okHttpClient: (String) -> OkHttpClient,
|
||||
httpAuth: suspend (hash: HexKey, size: Long, alt: String) -> BlossomAuthorizationEvent?,
|
||||
context: Context,
|
||||
useMediaEndpoint: Boolean = false,
|
||||
onProgress: ((bytesWritten: Long, totalBytes: Long) -> Unit)? = null,
|
||||
): MediaUploadResult {
|
||||
checkNotInMainThread()
|
||||
@@ -142,7 +145,8 @@ class BlossomUploader {
|
||||
MimeTypeMap.getSingleton().getExtensionFromMimeType(it) ?: extensionFromMimeType(it)
|
||||
} ?: ""
|
||||
|
||||
val apiUrl = BlossomServerUrl.upload(serverBaseUrl)
|
||||
// BUD-05: /media asks the server to optimize; /upload stores the exact bytes.
|
||||
val apiUrl = if (useMediaEndpoint) BlossomServerUrl.media(serverBaseUrl) else BlossomServerUrl.upload(serverBaseUrl)
|
||||
|
||||
val client = okHttpClient(apiUrl)
|
||||
val requestBuilder = Request.Builder()
|
||||
|
||||
+101
@@ -40,8 +40,10 @@ import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -60,6 +62,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayDragState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.draggableRelayItem
|
||||
@@ -91,6 +95,7 @@ private val MonogramColors =
|
||||
fun AllMediaBody(
|
||||
blossomServersViewModel: BlossomServersViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val blossomServersState by blossomServersViewModel.fileServers.collectAsStateWithLifecycle()
|
||||
@@ -156,6 +161,11 @@ fun AllMediaBody(
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
SectionLabel(title = stringRes(id = R.string.media_servers_upload_section))
|
||||
UploadBehaviorSection(accountViewModel, nav)
|
||||
}
|
||||
|
||||
item {
|
||||
SectionLabel(title = stringRes(id = R.string.media_servers_cache_section))
|
||||
MediaCacheSection(accountViewModel)
|
||||
@@ -167,6 +177,97 @@ fun AllMediaBody(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload-side Blossom controls: mirror uploads across the user's servers (BUD-04),
|
||||
* optimize via `/media` (BUD-05), and a shortcut into the blob manager.
|
||||
*/
|
||||
@Composable
|
||||
private fun UploadBehaviorSection(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val mirror by accountViewModel.account.settings.mirrorUploadsToAllServers
|
||||
.collectAsStateWithLifecycle()
|
||||
val optimize by accountViewModel.account.settings.optimizeMediaOnUpload
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||
) {
|
||||
UploadToggleRow(
|
||||
title = stringRes(id = R.string.blossom_mirror_uploads),
|
||||
caption = stringRes(id = R.string.blossom_mirror_uploads_caption),
|
||||
checked = mirror,
|
||||
onCheckedChange = { accountViewModel.account.settings.changeMirrorUploadsToAllServers(it) },
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 14.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
)
|
||||
|
||||
UploadToggleRow(
|
||||
title = stringRes(id = R.string.blossom_optimize_media),
|
||||
caption = stringRes(id = R.string.blossom_optimize_media_caption),
|
||||
checked = optimize,
|
||||
onCheckedChange = { accountViewModel.account.settings.changeOptimizeMediaOnUpload(it) },
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 14.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { nav.nav(Route.ManageBlossomBlobs) }
|
||||
.padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(id = R.string.manage_stored_files),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Storage,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UploadToggleRow(
|
||||
title: String,
|
||||
caption: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||
Text(text = title, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
text = caption,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
}
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact section header: an accent label with an optional gray caption below. */
|
||||
@Composable
|
||||
private fun SectionLabel(
|
||||
|
||||
+1
@@ -92,6 +92,7 @@ fun MediaServersScaffold(
|
||||
AllMediaBody(
|
||||
blossomServersViewModel = blossomServersViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
|
||||
+13
@@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@@ -38,6 +39,7 @@ import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
@@ -57,6 +59,8 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -80,6 +84,15 @@ fun BlossomBlobManagerScreen(
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton(stringRes(R.string.manage_stored_files), nav) },
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = { vm.refresh() }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Sync,
|
||||
contentDescription = stringRes(R.string.retry),
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
|
||||
@@ -1545,6 +1545,11 @@
|
||||
|
||||
<string name="no_blossom_server_message">You have no Blossom servers set. You can use Amethyst\'s list, or add one below ↓</string>
|
||||
|
||||
<string name="media_servers_upload_section">Upload behaviour</string>
|
||||
<string name="blossom_mirror_uploads">Mirror uploads</string>
|
||||
<string name="blossom_mirror_uploads_caption">After uploading, copy the file to your other Blossom servers so it stays available if one goes offline.</string>
|
||||
<string name="blossom_optimize_media">Optimize media on the server</string>
|
||||
<string name="blossom_optimize_media_caption">Upload through the server\'s /media endpoint so it can strip metadata and compress the file. The stored file may differ from the original.</string>
|
||||
<string name="manage_stored_files">Manage stored files</string>
|
||||
<string name="manage_stored_files_empty">No stored files found on your Blossom servers.</string>
|
||||
<string name="blossom_mirror_to_missing">Mirror to missing</string>
|
||||
|
||||
Reference in New Issue
Block a user