diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/platform/AndroidAppPlatform.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/platform/AndroidAppPlatform.kt index 468d992788..5be307c587 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/platform/AndroidAppPlatform.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/platform/AndroidAppPlatform.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.UriHandler +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.model.navigation.Route @@ -37,6 +38,7 @@ import com.vitorpamplona.amethyst.commons.ui.settings.SettingsCategory import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher import com.vitorpamplona.amethyst.ui.navigation.topbars.AndroidAroundMeLocationLabel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestActivity import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.amethyst.commons.ui.navigation.bottombars.AppBottomBar as AppBottomBarImpl import com.vitorpamplona.amethyst.favorites.rememberManifestIconModel as AppRememberManifestIconModel @@ -52,6 +54,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.legalSettingsCateg object AndroidAppPlatform : AppPlatform { override val isCastingAvailable: Boolean get() = BuildConfig.IS_CASTING_AVAILABLE + // NestActivity.launch adds FLAG_ACTIVITY_NEW_TASK, so the application context can start it. + override fun openNestRoom(addressValue: String) = NestActivity.launch(Amethyst.instance.appContext, addressValue) + // Per flavour: Play links the hosted policies, F-Droid surfaces none. override fun legalSettingsCategory(uriHandler: UriHandler): SettingsCategory? = flavorLegalSettingsCategory(uriHandler) 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 0d0353faff..ea2d79f4e8 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 @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup -import android.widget.Toast import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets @@ -42,7 +41,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.marmot.MarmotGroupIconChange import com.vitorpamplona.amethyst.commons.marmot.ui.MarmotRetentionChoice @@ -60,6 +58,7 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_keypackage_relays_not import com.vitorpamplona.amethyst.commons.resources.marmot_skip_for_now import com.vitorpamplona.amethyst.commons.resources.marmot_use_outbox_relays import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +import com.vitorpamplona.amethyst.commons.ui.components.rememberLongNotice import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav @@ -93,7 +92,7 @@ fun CreateGroupScreen( var isCreating by remember { mutableStateOf(false) } var showKeyPackageRelayDialog by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() - val context = LocalContext.current + val longNotice = rememberLongNotice() /** * Create the group, optionally after [prepare]. @@ -154,12 +153,7 @@ fun CreateGroupScreen( } catch (e: Exception) { isCreating = false launch(Dispatchers.Main) { - Toast - .makeText( - context, - loadStringRes(Res.string.marmot_failed_to_create_group, e.message), - Toast.LENGTH_LONG, - ).show() + longNotice.show(loadStringRes(Res.string.marmot_failed_to_create_group, e.message)) } } } 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 5e9c16d579..03336f8f07 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 @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup -import android.widget.Toast import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets @@ -40,7 +39,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.marmot.MarmotGroupIconChange @@ -58,6 +56,8 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_group_name_placeholde import com.vitorpamplona.amethyst.commons.resources.marmot_legacy_group_no_avatar_url import com.vitorpamplona.amethyst.commons.resources.save import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia +import com.vitorpamplona.amethyst.commons.ui.components.rememberLongNotice +import com.vitorpamplona.amethyst.commons.ui.components.rememberShortNotice import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav @@ -92,7 +92,8 @@ fun EditGroupInfoScreen( var removeIcon by remember { mutableStateOf(false) } var isSaving by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() - val context = LocalContext.current + val shortNotice = rememberShortNotice() + val longNotice = rememberLongNotice() val iconChanged = pickedIcon != null || removeIcon val avatarUrlChanged = avatarUrl.trim() != currentAvatarUrl?.url.orEmpty() @@ -136,20 +137,13 @@ fun EditGroupInfoScreen( accountViewModel.setMarmotGroupAvatarUrl(nostrGroupId, avatarUrl.trim()) } launch(Dispatchers.Main) { - Toast - .makeText(context, loadStringRes(Res.string.marmot_group_info_updated), Toast.LENGTH_SHORT) - .show() + shortNotice.show(loadStringRes(Res.string.marmot_group_info_updated)) } nav.popBack() } catch (e: Exception) { isSaving = false launch(Dispatchers.Main) { - Toast - .makeText( - context, - loadStringRes(Res.string.marmot_failed_to_update, e.message), - Toast.LENGTH_LONG, - ).show() + longNotice.show(loadStringRes(Res.string.marmot_failed_to_update, e.message)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt index 287b79bcc5..fed2108c79 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt @@ -220,7 +220,7 @@ class VoiceReplyViewModel : ViewModel() { loadStringRes(Res.string.upload_error_title), loadStringRes( Res.string.upload_error_voice_message_exception, - e.message ?: e.javaClass.simpleName, + e.message ?: e::class.simpleName.orEmpty(), ), ) } finally { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/RenderLiveActivityBubble.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/RenderLiveActivityBubble.kt index 50ea9a4b59..4a7eb7ade0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/RenderLiveActivityBubble.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/RenderLiveActivityBubble.kt @@ -29,7 +29,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.navigation.routeFor @@ -39,9 +38,9 @@ import com.vitorpamplona.amethyst.commons.relayClient.reqCommand.channel.observe import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.commons.ui.note.Gallery +import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppPlatform import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestActivity import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent @Composable @@ -65,7 +64,7 @@ fun RenderLiveActivityBubble( null } } - val context = LocalContext.current + val appPlatform = LocalAppPlatform.current FilledTonalButton( contentPadding = PaddingValues(start = 8.dp, end = 10.dp, bottom = 0.dp, top = 0.dp), onClick = { @@ -75,10 +74,7 @@ fun RenderLiveActivityBubble( val dTag = meetingEvent.address().dTag if (!service.isNullOrBlank() && !endpoint.isNullOrBlank() && dTag.isNotBlank()) { NestBridge.set(accountViewModel) - NestActivity.launch( - context = context, - addressValue = meetingEvent.address().toValue(), - ) + appPlatform.openNestRoom(meetingEvent.address().toValue()) } else { // Fall back to the channel route so the user // still lands somewhere — same as a malformed diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lobby/NestLobbyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lobby/NestLobbyScreen.kt index c52dd74e07..80e025a7f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lobby/NestLobbyScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lobby/NestLobbyScreen.kt @@ -55,7 +55,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -85,11 +84,11 @@ import com.vitorpamplona.amethyst.commons.ui.note.types.MeetingSpaceClosedFlag import com.vitorpamplona.amethyst.commons.ui.note.types.MeetingSpaceOpenFlag import com.vitorpamplona.amethyst.commons.ui.note.types.MeetingSpacePlannedFlag import com.vitorpamplona.amethyst.commons.ui.note.types.MeetingSpacePrivateFlag +import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppPlatform import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.ChatroomMessageCompose import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestActivity import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.chat.NestEditFieldRow import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.chat.NestNewMessageViewModel import com.vitorpamplona.quartz.nip01Core.core.Address @@ -292,7 +291,7 @@ private fun OpenNestRoomAction( val roomId = event.address().dTag if (serviceBase.isNullOrBlank() || endpoint.isNullOrBlank() || roomId.isBlank()) return - val context = LocalContext.current + val appPlatform = LocalAppPlatform.current // Filled button so the primary CTA is unmistakable; the actions // Row in the AppBar already centers its children vertically, but // we trim the default end padding so the button sits flush with @@ -300,10 +299,7 @@ private fun OpenNestRoomAction( Button( onClick = { NestBridge.set(accountViewModel) - NestActivity.launch( - context = context, - addressValue = event.address().toValue(), - ) + appPlatform.openNestRoom(event.address().toValue()) }, contentPadding = ButtonDefaults.ContentPadding, modifier = Modifier.padding(end = 8.dp), diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/IcsExport.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/IcsExport.kt similarity index 89% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/IcsExport.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/IcsExport.kt index 2c5e7b579b..ece4e7b3b4 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/IcsExport.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/IcsExport.kt @@ -20,14 +20,11 @@ */ package com.vitorpamplona.amethyst.commons.model.nip52Calendar +import com.vitorpamplona.amethyst.commons.search.calendar.SearchDate import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent -import java.time.Instant -import java.time.LocalDate -import java.time.ZoneOffset -import java.time.format.DateTimeFormatter /** * Serialises NIP-52 calendar events to RFC 5545 iCalendar (`.ics`) text. The output is the @@ -47,8 +44,6 @@ import java.time.format.DateTimeFormatter object IcsExport { private const val PRODID = "-//Amethyst//NIP-52//EN" private const val CRLF = "\r\n" - private val UtcStamp: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'") - private val IsoBasicDate: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd") fun appointmentToIcs( event: Any, @@ -189,14 +184,21 @@ object IcsExport { sb.append("CATEGORIES:").append(hashtags.joinToString(",") { escapeText(it) }).append(CRLF) } - private fun formatUtcInstant(unixSeconds: Long): String = UtcStamp.format(Instant.ofEpochSecond(unixSeconds).atOffset(ZoneOffset.UTC)) + /** `yyyyMMdd'T'HHmmss'Z'` in UTC. */ + private fun formatUtcInstant(unixSeconds: Long): String { + val date = SearchDate.civilFromDays(unixSeconds.floorDiv(SECONDS_PER_DAY)) + val secondOfDay = unixSeconds.mod(SECONDS_PER_DAY) + return basicDate(date) + "T" + pad2(secondOfDay / 3600) + pad2(secondOfDay / 60 % 60) + pad2(secondOfDay % 60) + "Z" + } - private fun tryFormatBasicDate(iso: String): String? = - try { - IsoBasicDate.format(LocalDate.parse(iso)) - } catch (_: Throwable) { - null - } + /** `yyyy-MM-dd` as `yyyyMMdd`, or null when [iso] is not a valid date. */ + private fun tryFormatBasicDate(iso: String): String? = SearchDate.parse(iso)?.let(::basicDate) + + private fun basicDate(date: SearchDate): String = date.year.toString().padStart(4, '0') + pad2(date.month.toLong()) + pad2(date.day.toLong()) + + private fun pad2(value: Long): String = value.toString().padStart(2, '0') + + private const val SECONDS_PER_DAY = 86_400L /** * Escapes text per RFC 5545 §3.3.11: backslash, semicolon, comma, newline. Carriage diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/platform/AppPlatform.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/platform/AppPlatform.kt index 830d968259..3df0c16cbb 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/platform/AppPlatform.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/platform/AppPlatform.kt @@ -111,6 +111,12 @@ interface AppPlatform { @Composable fun rememberWebAppIconModel(url: String): String? = null + /** + * Opens the audio room at [addressValue] (a kind-30312 address) in its own window; callers + * hand the account over through `NestBridge` first. Does nothing where rooms are not wired. + */ + fun openNestRoom(addressValue: String) {} + /** Draws nothing: previews, and front ends still wiring their pieces. */ object None : AppPlatform }