diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/AndroidMediaUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/AndroidMediaUploader.kt index f0b9ed5f4b..b21073ed51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/AndroidMediaUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/AndroidMediaUploader.kt @@ -612,4 +612,12 @@ class AndroidMediaUploader( val idx = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) if (idx >= 0 && it.moveToFirst()) it.getString(idx) else null } + + override fun discardTempFile(uri: MediaUri) { + try { + uri.path?.let { path -> File(path).takeIf { it.exists() }?.delete() } + } catch (e: Exception) { + Log.w("AndroidMediaUploader", "Failed to delete temp file", e) + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt index 7ec1097bb4..7df22cea86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt @@ -44,8 +44,6 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes import kotlinx.coroutines.delay import kotlinx.coroutines.isActive -const val MAX_VOICE_RECORD_SECONDS = 600 - @OptIn(ExperimentalPermissionsApi::class) @Composable fun RecordAudioBox( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt index 6150807b7d..8ed32a442b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt @@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.commons.resources.record_a_message import com.vitorpamplona.amethyst.commons.resources.recording_indicator_description import com.vitorpamplona.amethyst.commons.resources.remove import com.vitorpamplona.amethyst.commons.ui.components.AudioWaveformReadOnly +import com.vitorpamplona.amethyst.commons.ui.note.platform.MAX_VOICE_RECORD_SECONDS import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.util.formatSecondsToTime import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index bba98ca0a4..38be6838c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -189,6 +189,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.ZapRail import com.vitorpamplona.amethyst.commons.ui.note.ZapRailIcon import com.vitorpamplona.amethyst.commons.ui.note.ZappedIcon import com.vitorpamplona.amethyst.commons.ui.note.elements.ShareOptionsBottomSheet +import com.vitorpamplona.amethyst.commons.ui.note.platform.MAX_VOICE_RECORD_SECONDS import com.vitorpamplona.amethyst.commons.ui.note.types.EditState import com.vitorpamplona.amethyst.commons.ui.richtext.InLineIconRenderer import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.OnchainZapSendDialog @@ -229,7 +230,6 @@ import com.vitorpamplona.amethyst.model.zap.CashuRailStatus import com.vitorpamplona.amethyst.model.zap.RailCapability import com.vitorpamplona.amethyst.model.zap.RailCapabilityResolver import com.vitorpamplona.amethyst.service.payments.PayToAppAvailability -import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.PaymentTargetsDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.paymentTargetStyleFor diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollDeadlinePicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollDeadlinePicker.kt index 03642f27a3..db77c424f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollDeadlinePicker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollDeadlinePicker.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.polls -import android.text.format.DateFormat import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -45,7 +44,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon @@ -57,13 +55,14 @@ import com.vitorpamplona.amethyst.commons.resources.confirm import com.vitorpamplona.amethyst.commons.resources.next import com.vitorpamplona.amethyst.commons.resources.poll_closing_date_time import com.vitorpamplona.amethyst.commons.resources.poll_closing_in +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.screen.loggedIn.home.ShortNotePostViewModel import com.vitorpamplona.amethyst.commons.ui.stringRes 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 @@ -72,26 +71,28 @@ fun PollDeadlinePicker(model: ShortNotePostViewModel) { var showTimePicker by remember { mutableStateOf(false) } // Get current time details - val currentTime = Instant.ofEpochMilli(model.closedAt * 1000).atZone(ZoneId.systemDefault()).toLocalDateTime() + // The deadline as a local wall-clock time: seconds since the epoch shifted by this zone's offset. + val localSeconds = model.closedAt + LocalClock.utcOffsetSeconds(model.closedAt) + val localSecondOfDay = localSeconds.mod(TimeUtils.ONE_DAY) + val currentYear = SearchDate.civilFromDays(localSeconds.floorDiv(TimeUtils.ONE_DAY)).year val datePickerState = rememberDatePickerState( initialSelectedDateMillis = model.closedAt * 1000, - yearRange = currentTime.year..2050, + yearRange = currentYear..2050, selectableDates = object : SelectableDates { override fun isSelectableDate(utcTimeMillis: Long): Boolean { // Only allow today and future dates - return utcTimeMillis >= System.currentTimeMillis() - 86400000 // minus 24h buffer + return utcTimeMillis >= currentTimeMillis() - 86400000 // minus 24h buffer } }, ) - 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(), ) OutlinedCard( @@ -148,10 +149,8 @@ fun PollDeadlinePicker(model: ShortNotePostViewModel) { (timePickerState.minute * TimeUtils.ONE_MINUTE) } ?: TimeUtils.oneDayAhead() - // Get the offset from UTC for the current instant in the local time zone - val offset: ZoneOffset = ZoneId.systemDefault().rules.getOffset(Instant.now()) - - model.closedAt = datetimeLocalTimeZone - offset.totalSeconds + // Subtract this zone's current offset from UTC + model.closedAt = datetimeLocalTimeZone - LocalClock.utcOffsetSeconds(TimeUtils.now()) showTimePicker = false }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/platform/AndroidNotePlatform.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/platform/AndroidNotePlatform.kt index 88f9dd929c..9a1b12e86e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/platform/AndroidNotePlatform.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/platform/AndroidNotePlatform.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit +import com.vitorpamplona.amethyst.commons.audio.RecordingResult import com.vitorpamplona.amethyst.commons.audio.WaveformData import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent @@ -45,13 +46,17 @@ import com.vitorpamplona.amethyst.service.playback.composable.VideoViewInner import com.vitorpamplona.amethyst.ui.note.creators.location.DEFAULT_MAP_ZOOM import com.vitorpamplona.amethyst.ui.screen.loggedIn.urlPreview import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta import com.vitorpamplona.quartz.podcasts.PodcastAudio import kotlinx.collections.immutable.ImmutableList +import okio.Path import com.vitorpamplona.amethyst.commons.ui.note.types.RenderMeetingRoomEvent as AppRenderMeetingRoomEvent import com.vitorpamplona.amethyst.commons.ui.note.types.RenderMeetingRoomPresence as AppRenderMeetingRoomPresence import com.vitorpamplona.amethyst.commons.ui.note.types.RenderMeetingSpaceEvent as AppRenderMeetingSpaceEvent import com.vitorpamplona.amethyst.service.playback.composable.VideoView as AppVideoView import com.vitorpamplona.amethyst.ui.actions.EditPostView as AppEditPostView +import com.vitorpamplona.amethyst.ui.actions.uploads.RecordVoiceButton as AppRecordVoiceButton +import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview as AppVoiceMessagePreview import com.vitorpamplona.amethyst.ui.components.GifVideoView as AppGifVideoView import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview as AppLoadUrlPreview import com.vitorpamplona.amethyst.ui.components.ReusableZapButton as AppReusableZapButton @@ -622,6 +627,22 @@ object AndroidNotePlatform : NotePlatform { accountViewModel = accountViewModel, ) + @Composable + override fun RecordVoiceButton( + onVoiceTaken: (RecordingResult) -> Unit, + maxDurationSeconds: Int?, + ) = AppRecordVoiceButton(onVoiceTaken, maxDurationSeconds) + + @Composable + override fun VoiceMessagePreview( + voiceMetadata: AudioMeta, + localFile: Path?, + onRemove: () -> Unit, + onReRecord: ((RecordingResult) -> Unit)?, + isUploading: Boolean, + modifier: Modifier, + ) = AppVoiceMessagePreview(voiceMetadata, localFile, onRemove, onReRecord, isUploading, modifier) + @Composable override fun QuickZapAmountRow( note: Note, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModelAndroidActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModelAndroidActions.kt index d4d9d0c2c5..ff7f245dd7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModelAndroidActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModelAndroidActions.kt @@ -21,24 +21,20 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.content.Context -import android.net.Uri import android.os.Handler import android.os.Looper import android.widget.Toast import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.commons.marmot.MarmotGroupIconUpload import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.failed_to_save_the_video import com.vitorpamplona.amethyst.commons.resources.video_saved_to_the_gallery import com.vitorpamplona.amethyst.commons.service.isVideoOnline -import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotGroupIconUploader import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -56,19 +52,6 @@ fun AccountViewModel.urlPreview( suspend fun AccountViewModel.checkVideoIsOnline(videoUrl: String): Boolean = httpClientBuilder.isVideoOnline(videoUrl) -/** - * Encrypt + upload a picked image as a group avatar (canonical `marmot-group-image-v1` scheme). - * The returned handle is later passed to [AccountViewModel.updateMarmotGroupMetadata] as - * `MarmotGroupIconChange.Set` to commit it into the group's metadata. Uploading is separated from - * the metadata commit so the (slow) Blossom upload can show its own progress before the commit is - * signed. - */ -suspend fun AccountViewModel.uploadMarmotGroupIcon( - uri: Uri, - mimeType: String?, - uploader: MediaUploader, -): MarmotGroupIconUpload = MarmotGroupIconUploader(account).upload(uri, mimeType, account.settings.defaultFileServer, uploader) - fun AccountViewModel.saveMediaToGallery( videoUri: String?, mimeType: String?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt index 6b7216fa01..0d0353faff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt @@ -66,7 +66,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.CreatingTopBar import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.uploadMarmotGroupIcon +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.uploadMarmotGroupIcon import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.RandomInstance diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/EditGroupInfoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/EditGroupInfoScreen.kt index 40e271da12..5e9c16d579 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/EditGroupInfoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/EditGroupInfoScreen.kt @@ -64,7 +64,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.ActionTopBar import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.uploadMarmotGroupIcon +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.uploadMarmotGroupIcon import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconEditor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconEditor.kt index 3323bad523..a66e90a0a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconEditor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconEditor.kt @@ -20,9 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.PickVisualMediaRequest -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -33,10 +30,13 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +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 import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupImage import com.vitorpamplona.amethyst.commons.resources.Res @@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_change_photo import com.vitorpamplona.amethyst.commons.resources.marmot_group_icon import com.vitorpamplona.amethyst.commons.resources.marmot_remove_photo import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +import com.vitorpamplona.amethyst.commons.ui.actions.uploads.GallerySelectSingle import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupIconUrl @@ -73,11 +74,13 @@ fun MarmotGroupIconEditor( onPick: (SelectedMedia) -> Unit, onRemove: () -> Unit, ) { - val resolver = LocalContext.current.contentResolver - val launcher = - rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> - if (uri != null) onPick(SelectedMedia(uri, resolver.getType(uri))) + var showPicker by remember { mutableStateOf(false) } + if (showPicker) { + GallerySelectSingle(imagesOnly = true) { media -> + showPicker = false + media?.let(onPick) } + } val model = when { @@ -100,7 +103,7 @@ fun MarmotGroupIconEditor( Modifier .size(96.dp) .clip(CircleShape) - .let { if (enabled) it.clickable { launcher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) } else it }, + .let { if (enabled) it.clickable { showPicker = true } else it }, loadProfilePicture = LocalDisplaySettings.current.showProfilePictures, loadRobohash = LocalDisplaySettings.current.loadRobohash, ) @@ -111,7 +114,7 @@ fun MarmotGroupIconEditor( ) { TextButton( enabled = enabled, - onClick = { launcher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }, + onClick = { showPicker = true }, ) { Text(stringRes(if (hasIcon) Res.string.marmot_change_photo else Res.string.marmot_add_photo)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotGroupIconUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotGroupIconUploader.kt index 39004e420c..b497add518 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotGroupIconUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotGroupIconUploader.kt @@ -20,22 +20,21 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send -import android.net.Uri import com.vitorpamplona.amethyst.commons.marmot.MarmotGroupIconUpload import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader +import com.vitorpamplona.amethyst.commons.service.uploads.MediaUri import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource +import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageCipher import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageEncryption import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.utils.Log -import java.io.File /** * Encrypts a picked image with the MIP-01 v2 scheme (see [MarmotGroupImageEncryption]) @@ -50,7 +49,7 @@ class MarmotGroupIconUploader( val account: Account, ) { suspend fun upload( - uri: Uri, + uri: MediaUri, mimeType: String?, server: ServerName, uploader: MediaUploader, @@ -105,13 +104,7 @@ class MarmotGroupIconUploader( } finally { // Delete the intermediate compressed temp file (compress returns the original // URI unchanged when it skips compression, so only delete a distinct temp). - if (compressed.uri != uri) { - try { - compressed.uri.path?.let { path -> File(path).takeIf { it.exists() }?.delete() } - } catch (e: Exception) { - Log.w("MarmotGroupIconUploader", "Failed to delete temp icon file", e) - } - } + if (compressed.uri != uri) uploader.discardTempFile(compressed.uri) } } @@ -119,3 +112,15 @@ class MarmotGroupIconUploader( private const val DEFAULT_MIME = "image/jpeg" } } + +/** + * Encrypts and uploads a picked image as a group avatar (canonical `marmot-group-image-v1` + * scheme). The returned handle is later committed through + * [AccountViewModel.updateMarmotGroupMetadata] as `MarmotGroupIconChange.Set`; uploading is + * separate from that commit so the slow Blossom upload can show its own progress first. + */ +suspend fun AccountViewModel.uploadMarmotGroupIcon( + uri: MediaUri, + mimeType: String?, + uploader: MediaUploader, +): MarmotGroupIconUpload = MarmotGroupIconUploader(account).upload(uri, mimeType, account.settings.defaultFileServer, uploader) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 7a412dcaad..548500b82d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -140,6 +140,9 @@ import com.vitorpamplona.amethyst.commons.ui.note.creators.zapraiser.AddZapraise import com.vitorpamplona.amethyst.commons.ui.note.creators.zapraiser.ZapRaiserRequest import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.ForwardZapTo import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.ForwardZapToButton +import com.vitorpamplona.amethyst.commons.ui.note.platform.MAX_VOICE_RECORD_SECONDS +import com.vitorpamplona.amethyst.commons.ui.note.platform.RecordVoiceButton +import com.vitorpamplona.amethyst.commons.ui.note.platform.VoiceMessagePreview import com.vitorpamplona.amethyst.commons.ui.note.types.ReplyRenderType import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.ObserveInboxRelayListAndDisplayIfNotFound import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.ShortNotePostViewModel @@ -159,11 +162,8 @@ import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow -import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS -import com.vitorpamplona.amethyst.ui.actions.uploads.RecordVoiceButton import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection -import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview import com.vitorpamplona.amethyst.ui.note.creators.polls.PollOptionsField import com.vitorpamplona.amethyst.ui.note.creators.scheduling.ScheduleAtPicker import com.vitorpamplona.amethyst.ui.note.creators.scheduling.roundUpToNextQuarterHour @@ -536,10 +536,9 @@ private fun NewPostScreenBody( val alwaysOnEnabled by accountViewModel.account.settings.alwaysOnNotificationService .collectAsStateWithLifecycle() - val savedAccounts by com.vitorpamplona.amethyst.LocalPreferences - .accountsFlow() - .collectAsStateWithLifecycle() - val hasMultipleAccounts = (savedAccounts?.size ?: 0) > 1 + val savedAccounts by accountViewModel.host.savedAccounts + .collectAsStateWithLifecycle(emptySet()) + val hasMultipleAccounts = savedAccounts.size > 1 postViewModel.scheduledForSec?.let { current -> Row( verticalAlignment = CenterVertically, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt index 43185481ab..7a37f4b475 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.commons.ui.note.NoteCompose +import com.vitorpamplona.amethyst.commons.ui.note.platform.VoiceMessagePreview import com.vitorpamplona.amethyst.commons.ui.note.types.ReplyRenderType import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer @@ -51,7 +52,6 @@ import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection -import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt index 69badc6e51..156773e3ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt @@ -32,9 +32,8 @@ import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel -import com.vitorpamplona.amethyst.commons.service.OnlineChecker +import com.vitorpamplona.amethyst.commons.service.isVideoOnline import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.checkVideoIsOnline import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers @@ -97,12 +96,12 @@ private suspend fun checkChannelIsOnline( // Check if streaming URL is online, fall back to relay check val streamingUrl = channel.info?.streaming() if (!streamingUrl.isNullOrBlank()) { - accountViewModel.checkVideoIsOnline(streamingUrl) + accountViewModel.httpClientBuilder.isVideoOnline(streamingUrl) } else { // Check relay connection val relayUrl = channel.relayHintUrl() if (relayUrl != null) { - OnlineChecker.isOnline(relayUrl.url, accountViewModel.httpClientBuilder::okHttpClientForVideo) + accountViewModel.httpClientBuilder.isVideoOnline(relayUrl.url) } else { false } @@ -113,7 +112,7 @@ private suspend fun checkChannelIsOnline( is EphemeralChatChannel -> { // Check relay connection for ephemeral chat val relayUrl = channel.roomId.relayUrl - OnlineChecker.isOnline(relayUrl.url, accountViewModel.httpClientBuilder::okHttpClientForVideo) + accountViewModel.httpClientBuilder.isVideoOnline(relayUrl.url) } else -> { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUploader.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUploader.kt index 1165156444..8313475070 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUploader.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUploader.kt @@ -89,6 +89,9 @@ interface MediaUploader { /** The file name the picker reported for [uri] (Android's `DISPLAY_NAME`), if any. */ fun displayName(uri: MediaUri): String? = uri.lastPathSegmentOrNull() + + /** Deletes a temporary copy [compressIfNeeded] wrote, once the caller is done with it. */ + fun discardTempFile(uri: MediaUri) = Unit } /** No media pipeline on this front end yet: every upload fails with a clear message. */ diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/platform/NotePlatform.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/platform/NotePlatform.kt index a6a0f8cb73..fc66fd59f1 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/platform/NotePlatform.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/platform/NotePlatform.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit +import com.vitorpamplona.amethyst.commons.audio.RecordingResult import com.vitorpamplona.amethyst.commons.audio.WaveformData import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent @@ -44,8 +45,10 @@ import com.vitorpamplona.amethyst.commons.ui.note.types.EditState import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta import com.vitorpamplona.quartz.podcasts.PodcastAudio import kotlinx.collections.immutable.ImmutableList +import okio.Path /** * The platform halves of the shared note renderer: what a note card needs that only the front end @@ -439,6 +442,30 @@ interface NotePlatform { nav: INav, ) {} + /** + * The microphone button: records a voice message (up to [maxDurationSeconds]) and hands the + * finished recording to [onVoiceTaken]. + */ + @Composable + fun RecordVoiceButton( + onVoiceTaken: (RecordingResult) -> Unit, + maxDurationSeconds: Int?, + ) {} + + /** + * Plays back a voice message before it is sent: [localFile] while it is only on the device, + * else [voiceMetadata]'s URL. [onReRecord] replaces it with a new recording. + */ + @Composable + fun VoiceMessagePreview( + voiceMetadata: AudioMeta, + localFile: Path?, + onRemove: () -> Unit, + onReRecord: ((RecordingResult) -> Unit)?, + isUploading: Boolean, + modifier: Modifier, + ) {} + /** The editor for a new version of [edit], shown while [versionLookingAt] is on screen. */ @Composable fun EditPostView( diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/platform/NotePlatformCalls.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/platform/NotePlatformCalls.kt index 32c38eb268..e3f439102c 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/platform/NotePlatformCalls.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/platform/NotePlatformCalls.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit +import com.vitorpamplona.amethyst.commons.audio.RecordingResult import com.vitorpamplona.amethyst.commons.audio.WaveformData import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent @@ -47,9 +48,11 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.commons.ui.theme.Size20dp import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta import com.vitorpamplona.quartz.podcasts.PodcastAudio import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import okio.Path // Call-site shims for [NotePlatform]: the same names and defaults the app's own composables have, // so shared note code reads as it did and only its imports point here. @@ -568,6 +571,25 @@ fun QuickZapAmountRow( nav: INav, ) = LocalNotePlatform.current.QuickZapAmountRow(note, onDismiss, onOnchainRequest, accountViewModel, nav) +/** The longest voice message the recorder allows, in seconds. */ +const val MAX_VOICE_RECORD_SECONDS = 600 + +@Composable +fun RecordVoiceButton( + onVoiceTaken: (RecordingResult) -> Unit, + maxDurationSeconds: Int? = null, +) = LocalNotePlatform.current.RecordVoiceButton(onVoiceTaken, maxDurationSeconds) + +@Composable +fun VoiceMessagePreview( + voiceMetadata: AudioMeta, + localFile: Path? = null, + onRemove: () -> Unit, + onReRecord: ((RecordingResult) -> Unit)? = null, + isUploading: Boolean = false, + modifier: Modifier = Modifier, +) = LocalNotePlatform.current.VoiceMessagePreview(voiceMetadata, localFile, onRemove, onReRecord, isUploading, modifier) + @Composable fun EditPostView( onClose: () -> Unit,