refactor: nest-room launch port, KMP ICS export and Marmot group notices

- AppPlatform.openNestRoom starts the audio room; the live bubble and the
  nest lobby call it instead of NestActivity.launch.
- IcsExport moves to commonMain: UTC stamps and basic dates are computed
  on SearchDate instead of java.time (IcsExportTest still passes).
- Marmot create/edit screens toast through the shared notices;
  VoiceReplyViewModel names exceptions with KClass.

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 12:32:31 +00:00
parent 5b9bd82a14
commit 117ed3424f
8 changed files with 42 additions and 49 deletions
@@ -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)
@@ -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))
}
}
}
@@ -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))
}
}
}
@@ -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 {
@@ -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
@@ -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),
@@ -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
@@ -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
}