Merge pull request #4210 from vitorpamplona/claude/affectionate-ptolemy-0q909o

refactor(commonsUI): move ScreenLayout, TimeAgo, the theme core, chat bubbles and single-blocker components
This commit is contained in:
Vitor Pamplona
2026-09-26 21:50:42 -04:00
committed by GitHub
97 changed files with 1421 additions and 656 deletions
@@ -24768,17 +24768,17 @@ SPLcom/vitorpamplona/amethyst/ui/components/WindowUtilsKt;->getActivityOrNull(La
Lcom/vitorpamplona/amethyst/commons/ui/navigation/topbars/ShorterTopAppBarKt;
SPLcom/vitorpamplona/amethyst/commons/ui/navigation/topbars/ShorterTopAppBarKt;-><clinit>()V
SPLcom/vitorpamplona/amethyst/commons/ui/navigation/topbars/ShorterTopAppBarKt;->getTopBarSize()F
Lcom/vitorpamplona/amethyst/ui/note/elements/NowProviderKt;
SPLcom/vitorpamplona/amethyst/ui/note/elements/NowProviderKt;-><clinit>()V
SPLcom/vitorpamplona/amethyst/ui/note/elements/NowProviderKt;->NowProvider(Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V
Lcom/vitorpamplona/amethyst/ui/note/elements/NowProviderKt$$ExternalSyntheticLambda1;
SPLcom/vitorpamplona/amethyst/ui/note/elements/NowProviderKt$$ExternalSyntheticLambda1;-><init>()V
Lcom/vitorpamplona/amethyst/ui/note/elements/NowProviderKt$NowProvider$now$1$1;
SPLcom/vitorpamplona/amethyst/ui/note/elements/NowProviderKt$NowProvider$now$1$1;-><init>(Lkotlin/coroutines/Continuation;)V
SPLcom/vitorpamplona/amethyst/ui/note/elements/NowProviderKt$NowProvider$now$1$1;->create(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation;
SPLcom/vitorpamplona/amethyst/ui/note/elements/NowProviderKt$NowProvider$now$1$1;->invoke(Landroidx/compose/runtime/ProduceStateScope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
SPLcom/vitorpamplona/amethyst/ui/note/elements/NowProviderKt$NowProvider$now$1$1;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
SPLcom/vitorpamplona/amethyst/ui/note/elements/NowProviderKt$NowProvider$now$1$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object;
Lcom/vitorpamplona/amethyst/commons/ui/note/elements/NowProviderKt;
SPLcom/vitorpamplona/amethyst/commons/ui/note/elements/NowProviderKt;-><clinit>()V
SPLcom/vitorpamplona/amethyst/commons/ui/note/elements/NowProviderKt;->NowProvider(Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V
Lcom/vitorpamplona/amethyst/commons/ui/note/elements/NowProviderKt$$ExternalSyntheticLambda1;
SPLcom/vitorpamplona/amethyst/commons/ui/note/elements/NowProviderKt$$ExternalSyntheticLambda1;-><init>()V
Lcom/vitorpamplona/amethyst/commons/ui/note/elements/NowProviderKt$NowProvider$now$1$1;
SPLcom/vitorpamplona/amethyst/commons/ui/note/elements/NowProviderKt$NowProvider$now$1$1;-><init>(Lkotlin/coroutines/Continuation;)V
SPLcom/vitorpamplona/amethyst/commons/ui/note/elements/NowProviderKt$NowProvider$now$1$1;->create(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation;
SPLcom/vitorpamplona/amethyst/commons/ui/note/elements/NowProviderKt$NowProvider$now$1$1;->invoke(Landroidx/compose/runtime/ProduceStateScope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
SPLcom/vitorpamplona/amethyst/commons/ui/note/elements/NowProviderKt$NowProvider$now$1$1;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
SPLcom/vitorpamplona/amethyst/commons/ui/note/elements/NowProviderKt$NowProvider$now$1$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object;
Lcom/vitorpamplona/amethyst/ui/screen/AccountScreenKt;
SPLcom/vitorpamplona/amethyst/ui/screen/AccountScreenKt;->AccountScreen$lambda$0(Landroidx/compose/runtime/State;)Lcom/vitorpamplona/amethyst/ui/screen/AccountState;
SPLcom/vitorpamplona/amethyst/ui/screen/AccountScreenKt;->AccountScreen$lambda$2(Lcom/vitorpamplona/amethyst/ui/screen/AccountSessionManager;Lcom/vitorpamplona/amethyst/ui/screen/AccountState;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit;
@@ -25632,6 +25632,7 @@ SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;->getSize40dp()F
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;->getSize50dp()F
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;->getSmallBorder()Landroidx/compose/foundation/shape/RoundedCornerShape;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;->getSmallestBorder()Landroidx/compose/foundation/shape/RoundedCornerShape;
Lcom/vitorpamplona/amethyst/commons/ui/theme/AmethystThemeKt;
Lcom/vitorpamplona/amethyst/ui/theme/ThemeKt;
Lcom/vitorpamplona/amethyst/commons/ui/theme/MarkdownStyleKt;
Lcom/vitorpamplona/amethyst/commons/ui/theme/AmethystColorSchemeKt;
@@ -25648,13 +25649,13 @@ SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->AmethystTheme$lambda$9(Landroid
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->AmethystTheme(Lcom/vitorpamplona/amethyst/commons/model/ThemeType;Lcom/vitorpamplona/amethyst/commons/model/AccentColorType;Lcom/vitorpamplona/amethyst/commons/model/FontFamilyType;Lcom/vitorpamplona/amethyst/commons/model/FontSizeType;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->AmethystTheme(Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystColorSchemeKt;->accentContainer-DxMtmZc(JZ)J
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->accentPrimary(Lcom/vitorpamplona/amethyst/commons/model/AccentColorType;Z)J
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->accentSecondary(Lcom/vitorpamplona/amethyst/commons/model/AccentColorType;Z)J
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->darkColors(Lcom/vitorpamplona/amethyst/commons/model/AccentColorType;)Landroidx/compose/material3/ColorScheme;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystThemeKt;->accentPrimary(Lcom/vitorpamplona/amethyst/commons/model/AccentColorType;Z)J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystThemeKt;->accentSecondary(Lcom/vitorpamplona/amethyst/commons/model/AccentColorType;Z)J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystThemeKt;->amethystDarkColors(Lcom/vitorpamplona/amethyst/commons/model/AccentColorType;)Landroidx/compose/material3/ColorScheme;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystColorSchemeKt;->getPlaceholderText(Landroidx/compose/material3/ColorScheme;)J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystColorSchemeKt;->getTransparentBackground(Landroidx/compose/material3/ColorScheme;)J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystColorSchemeKt;->isLight(Landroidx/compose/material3/ColorScheme;)Z
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->lightColors(Lcom/vitorpamplona/amethyst/commons/model/AccentColorType;)Landroidx/compose/material3/ColorScheme;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystThemeKt;->amethystLightColors(Lcom/vitorpamplona/amethyst/commons/model/AccentColorType;)Landroidx/compose/material3/ColorScheme;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystColorSchemeKt;->onAccent-8_81llA(J)J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystColorSchemeKt;->onAccentContainer-DxMtmZc(JZ)J
Lcom/vitorpamplona/amethyst/ui/theme/ThemeKt$$ExternalSyntheticLambda0;
@@ -25685,7 +25686,7 @@ SPLcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt;->getDefaultParagraphSpaci
SPLcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt;->getFont14SP()J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt;->getFont17SP()J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt;->getTypography()Landroidx/compose/material3/Typography;
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->toFontFamily(Lcom/vitorpamplona/amethyst/commons/model/FontFamilyType;)Landroidx/compose/ui/text/font/FontFamily;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystThemeKt;->toFontFamily(Lcom/vitorpamplona/amethyst/commons/model/FontFamilyType;)Landroidx/compose/ui/text/font/FontFamily;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt;->withFontFamily(Landroidx/compose/material3/Typography;Landroidx/compose/ui/text/font/FontFamily;)Landroidx/compose/material3/Typography;
Lcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt$$ExternalSyntheticLambda0;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt$$ExternalSyntheticLambda0;-><init>()V
@@ -36,9 +36,9 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import com.linc.audiowaveform.infiniteLinearGradient
import com.vitorpamplona.amethyst.commons.ui.components.AudioWaveformReadOnly
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
import com.vitorpamplona.amethyst.ui.components.AudioWaveformReadOnly
@Composable
fun Waveform(
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.model.navigation.routeFor
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.ui.note.elements.NowProvider
import com.vitorpamplona.amethyst.debugState
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
@@ -43,7 +44,6 @@ import com.vitorpamplona.amethyst.ui.navigation.findParameterValue
import com.vitorpamplona.amethyst.ui.navigation.findQueryParameterValue
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.navigation.routes.routeForPointer
import com.vitorpamplona.amethyst.ui.note.elements.NowProvider
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink
@@ -57,8 +57,8 @@ import com.vitorpamplona.amethyst.commons.resources.re_record
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.stringRes
import com.vitorpamplona.amethyst.ui.components.AudioWaveformReadOnly
import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.delay
@@ -24,9 +24,6 @@ import android.Manifest
import android.os.Build
import android.view.Window
import android.widget.Toast
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.ExperimentalLayoutApi
@@ -47,19 +44,13 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
@@ -75,6 +66,7 @@ import com.vitorpamplona.amethyst.commons.resources.download_to_phone
import com.vitorpamplona.amethyst.commons.resources.media_download_has_started_toast
import com.vitorpamplona.amethyst.commons.resources.quick_action_share
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
import com.vitorpamplona.amethyst.commons.ui.components.animatedViewerChromeInset
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp
@@ -83,29 +75,12 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
// Chrome shared by the full-screen media viewers -- the zoomable image/video dialog and the PDF
// viewer. Both are opened the same way (tap a media card in a feed), so they immerse, auto-hide,
// and lay their controls out identically.
// Opening churn -- insets arriving, then the bars being hidden -- must not look like a user
// gesture, so the row snaps through it and only animates afterwards.
private const val CONTROLS_SETTLE_BEFORE_ANIMATING_MS = 350L
// Roughly the system bars' own show/hide duration, so the row travels with them rather than
// trailing after they have already arrived.
private const val CONTROLS_SLIDE_MS = 200
// Keeps the row off the screen edge -- and off the rounded corners -- while the bars are hidden.
private val VIEWER_CHROME_EDGE_GAP = 16.dp
// How long the controls stay up before the viewer fades them out on its own.
private const val CONTROLS_AUTO_HIDE_DELAY_MS = 2000L
/**
* Goes fully immersive for as long as the viewer is on screen: hides both OS bars and restores them
* on the way out. BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE lets the user swipe to peek the bars back.
@@ -123,77 +98,6 @@ fun ImmersiveSystemBarsEffect(window: Window?) {
}
}
/**
* Visibility of the viewer controls: they start on screen, fade out on their own after
* [CONTROLS_AUTO_HIDE_DELAY_MS], and the caller flips the returned state on tap.
*
* [holdOpen] freezes the timer while something anchored to the controls -- the share sheet, say --
* is up, and re-arms it once that closes. [armed] withholds the countdown until there is something
* to look at, so a viewer that spends three seconds fetching its media doesn't reveal the first
* frame with the controls already gone.
*
* A tap that brings the controls back deliberately gets no timer: the user asked for them, so they
* stay until tapped away. That is why the countdown races the controls going away rather than just
* sleeping -- a timer left over from an earlier show would otherwise wipe controls the user tapped
* back up in the meantime.
*/
@Composable
fun rememberViewerControlsVisibility(
holdOpen: Boolean,
armed: Boolean = true,
): MutableState<Boolean> {
val visible = remember { mutableStateOf(true) }
LaunchedEffect(armed, holdOpen) {
if (!armed || holdOpen) return@LaunchedEffect
val hiddenFirst =
withTimeoutOrNull(CONTROLS_AUTO_HIDE_DELAY_MS) {
snapshotFlow { visible.value }.first { !it }
}
if (hiddenFirst == null) visible.value = false
}
return visible
}
/**
* How far the viewer chrome sits from a screen edge: the system bar's own height while the bar is
* on screen, and a thin constant once it is hidden -- so the chrome follows the bar instead of
* reserving space for one that is not there.
*
* This only works because the viewer asks for BEHAVIOR_DEFAULT rather than transient bars. Under
* BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE a peeked bar is painted over the content and dispatches no
* insets at all -- `systemBars` stays 0 and `isVisible` stays false the whole time it is on screen
* -- so nothing here could react to it.
*
* The value is animated, but snapped for [CONTROLS_SETTLE_BEFORE_ANIMATING_MS] after the chrome
* appears. Opening moves the inset twice for reasons the user did not cause: the window has not
* been told its insets yet (they read 0), and ImmersiveSystemBarsEffect hides the bars from a
* DisposableEffect that runs after composition. Animating either would play a slide on open.
*/
@Composable
fun animatedViewerChromeInset(atBottom: Boolean): Dp {
val density = LocalDensity.current
val bars = WindowInsets.systemBars
val barPx = if (atBottom) bars.getBottom(density) else bars.getTop(density)
val target = with(density) { maxOf(barPx, VIEWER_CHROME_EDGE_GAP.roundToPx()).toDp() }
var settled by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(CONTROLS_SETTLE_BEFORE_ANIMATING_MS)
settled = true
}
val animated by animateDpAsState(
targetValue = target,
animationSpec = if (settled) tween(durationMillis = CONTROLS_SLIDE_MS) else snap(),
label = "viewerChromeInset",
)
return animated
}
/**
* Lays a viewer control row along a screen edge -- the top by default, the bottom when [atBottom].
*
@@ -80,8 +80,10 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.ui.components.SlidingCarousel
import com.vitorpamplona.amethyst.commons.ui.components.getActivityWindow
import com.vitorpamplona.amethyst.commons.ui.components.getDialogWindow
import com.vitorpamplona.amethyst.commons.ui.components.rememberViewerControlsVisibility
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.theme.imageModifier
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
@@ -1,79 +0,0 @@
/*
* 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.ui.components.pdf
import coil3.disk.DiskCache
import com.vitorpamplona.amethyst.Amethyst
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.coroutines.executeAsync
import java.io.IOException
object PdfFetcher {
/**
* Returns a snapshot of the cached PDF for [url], downloading it if necessary. The caller is
* responsible for closing the returned snapshot; while it's open the cache entry cannot be
* evicted, so the underlying file stays valid for `PdfRenderer`.
*
* Reuses the Coil disk cache (`Amethyst.instance.diskCache`) so PDFs share the same LRU
* eviction and disk budget as images.
*/
suspend fun fetchSnapshot(
url: String,
okHttpClient: (String) -> OkHttpClient,
): DiskCache.Snapshot =
withContext(Dispatchers.IO) {
val diskCache = Amethyst.instance.diskCache
// Covers the cache-hit fast path too, not just the download below it. openSnapshot()
// contends on the global DiskLruCache lock, which Coil's cleanup pass holds across a
// burst of unlink syscalls (see DeferredDeleteFileSystem) — calling it from a caller
// that happens to be on the main thread stalls the frame for that whole burst, and the
// hit path is exactly the one a feed takes when a PDF card scrolls back into view.
diskCache.openSnapshot(url)?.let { return@withContext it }
val editor = diskCache.openEditor(url) ?: throw IOException("Unable to open cache editor for $url")
try {
val request =
Request
.Builder()
.url(url)
.get()
.build()
okHttpClient(url).newCall(request).executeAsync().use { response ->
if (!response.isSuccessful) {
throw IOException("PDF download failed: ${response.code}")
}
diskCache.fileSystem.write(editor.data) {
val bytes = writeAll(response.body.source())
if (bytes == 0L) throw IOException("PDF download failed: empty response body")
}
}
editor.commitAndOpenSnapshot() ?: throw IOException("Unable to commit cache editor for $url")
} catch (t: Throwable) {
runCatching { editor.abort() }
throw t
}
}
}
@@ -44,22 +44,23 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.core.graphics.createBitmap
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.commons.service.pdf.PdfFetcher
import com.vitorpamplona.amethyst.commons.ui.components.FileAttachmentRow
import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.Size40dp
import com.vitorpamplona.amethyst.commons.ui.theme.Size6dp
import com.vitorpamplona.amethyst.commons.ui.theme.innerPostModifier
import com.vitorpamplona.amethyst.commons.util.extractFilename
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.components.FileAttachmentRow
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
// Hard ceiling on the inline thumbnail bitmap, in pixels. Prevents OOM on very tall/large pages.
private const val THUMBNAIL_MAX_DIM_PX = 1600
@@ -155,12 +156,12 @@ private fun LoadedPdfPreviewCard(
value =
try {
PdfFetcher
.fetchSnapshot(content.url) { url ->
accountViewModel.httpClientBuilder.okHttpClientForPreview(url)
}.use { snapshot ->
withContext(Dispatchers.IO) {
renderFirstPage(snapshot.data.toFile(), targetWidthPx)
}
.useSnapshot(
url = content.url,
diskCache = { Amethyst.instance.diskCache },
okHttpClient = { url -> accountViewModel.httpClientBuilder.okHttpClientForPreview(url) },
) { snapshot ->
renderFirstPage(snapshot.data.toFile(), targetWidthPx)
}.also { result ->
// Same cache the image and video paths use, so a PDF that has been rendered
// once lays out at its real shape on every later visit instead of growing
@@ -327,10 +328,4 @@ internal fun cappedRenderSize(
return w to h
}
internal fun extractFilename(url: String): String {
val afterQuery = url.substringBefore('?').substringBefore('#')
val name = afterQuery.substringAfterLast('/', afterQuery)
return if (name.isBlank()) url else name
}
internal fun pageCountLabel(pageCount: Int): String = if (pageCount == 1) "1 page" else "$pageCount pages"
@@ -61,8 +61,11 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.graphics.createBitmap
import coil3.disk.DiskCache
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.commons.service.pdf.PdfFetcher
import com.vitorpamplona.amethyst.commons.ui.components.getDialogWindow
import com.vitorpamplona.amethyst.commons.ui.components.rememberViewerControlsVisibility
import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp
import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.components.ImmersiveSystemBarsEffect
@@ -70,7 +73,6 @@ import com.vitorpamplona.amethyst.ui.components.ViewerBackButton
import com.vitorpamplona.amethyst.ui.components.ViewerControlsRow
import com.vitorpamplona.amethyst.ui.components.ViewerSaveToGalleryButton
import com.vitorpamplona.amethyst.ui.components.ViewerShareButton
import com.vitorpamplona.amethyst.ui.components.rememberViewerControlsVisibility
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
@@ -80,6 +82,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@@ -194,7 +197,7 @@ private fun PdfViewerContent(
try {
withContext(Dispatchers.IO) {
val snapshot =
PdfFetcher.fetchSnapshot(content.url) { url ->
PdfFetcher.fetchSnapshot(content.url, { Amethyst.instance.diskCache }) { url ->
accountViewModel.httpClientBuilder.okHttpClientForPreview(url)
}
try {
@@ -221,7 +224,14 @@ private fun PdfViewerContent(
val handleForDispose = handleState
DisposableEffect(handleForDispose) {
onDispose {
handleForDispose?.close()
// Off the main thread: closing the cache snapshot takes Coil's global DiskLruCache
// lock, which its cleanup pass holds across bursts of unlinks. Under the render mutex,
// so a page render still in flight finishes before the renderer is closed under it.
handleForDispose?.let { handle ->
Amethyst.instance.applicationIOScope.launch {
handle.mutex.withLock { handle.close() }
}
}
}
}
@@ -38,12 +38,12 @@ import com.vitorpamplona.amethyst.commons.resources.profile_banner
import com.vitorpamplona.amethyst.commons.ui.components.NewItemsBubble
import com.vitorpamplona.amethyst.commons.ui.layouts.ChatHeaderLayout
import com.vitorpamplona.amethyst.commons.ui.layouts.listItem.SlimListItem
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.commons.ui.theme.Height4dpModifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -20,126 +20,14 @@
*/
package com.vitorpamplona.amethyst.ui.layouts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.ui.layouts.NavigationStyle
import com.vitorpamplona.amethyst.commons.ui.layouts.ScreenLayoutSpec
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberScreenLayoutSpec
/**
* Minimum window width for the docked notification panel: a leading navigation pane, a
* readable center pane and the panel ([NotificationPanelWidth]) only coexist comfortably from
* a landscape-tablet-sized window up. Sized against the widest leading pane, the permanent
* drawer ([PermanentDrawerWidth]); the rail is narrower, so a railed window that clears this
* gets a roomier center pane rather than a tighter one.
*/
private const val NOTIFICATION_PANEL_MIN_WINDOW_DP = 1200
val PermanentDrawerWidth = 300.dp
val NotificationPanelWidth = 360.dp
/**
* Maximum width of a screen's content column inside a wide center pane. Every NavHost
* destination is wrapped in [CappedScreenContent] (via the builders in NavigationEffects),
* so the whole screen — top bar, tabs, feed, settings rows — shares one centered reading
* column instead of stretching across the pane. Screens that genuinely need the full pane
* (Messages' two-pane split, the embedded browser surfaces) opt out at registration.
*/
val FeedContentMaxWidth = 600.dp
/**
* Minimum window height for the docked drawer. Higher than Material's 480dp Compact/Medium
* height boundary on purpose: the permanent drawer's own header — banner, avatar, status
* editor, follower counts — fills most of a ~540dp column before the first navigation row, so
* below this the rail shows more of the menu than the dock does.
*/
private const val DOCK_MIN_WINDOW_HEIGHT_DP = 600
/**
* The navigation tier for a window of this shape.
*
* The dock is not a width decision. A tablet is past the Expanded breakpoint in both
* orientations, so keying on width alone pins 300dp of menu open in portrait with no closed
* state to fall back on (issue #4024). It docks only when the window is wide, landscape, and
* tall enough for the drawer's own content to be usable; everything else that is not Compact
* falls through to the rail, which pairs with the existing swipe-in modal drawer.
*
* A square window counts as landscape and docks; `Configuration.ORIENTATION_LANDSCAPE`
* breaks that tie the other way, so the two disagree at exactly width == height.
*/
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
internal fun decideNavigationStyle(
windowWidthDp: Int,
windowHeightDp: Int,
): NavigationStyle {
val widthSizeClass =
WindowSizeClass
.calculateFromSize(DpSize(windowWidthDp.dp, windowHeightDp.dp))
.widthSizeClass
return when {
widthSizeClass == WindowWidthSizeClass.Expanded &&
windowWidthDp >= windowHeightDp &&
windowHeightDp >= DOCK_MIN_WINDOW_HEIGHT_DP -> NavigationStyle.PERMANENT_DRAWER
widthSizeClass != WindowWidthSizeClass.Compact -> NavigationStyle.NAV_RAIL
else -> NavigationStyle.BOTTOM_BAR
}
}
/**
* Whether the window is wide enough to dock the notification feed beside the content.
*
* Deliberately not keyed on [NavigationStyle]: a wide portrait window now gets the rail, and
* gating on the dock would strip a panel it has today.
*/
internal fun hasRoomForNotificationPanel(windowWidthDp: Int): Boolean = windowWidthDp >= NOTIFICATION_PANEL_MIN_WINDOW_DP
/**
* Centers a destination's content at [FeedContentMaxWidth]. The outer box paints the theme
* background so the gutters match the screens' own surfaces; on Compact windows the cap is
* wider than the pane and this is a visual no-op.
*/
@Composable
fun CappedScreenContent(content: @Composable () -> Unit) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.TopCenter,
) {
Box(
Modifier
.widthIn(max = FeedContentMaxWidth)
.fillMaxSize(),
) {
content()
}
}
}
/** Android's window size is the configuration's, which already excludes system decorations. */
@Composable
fun rememberScreenLayoutSpec(): ScreenLayoutSpec {
val configuration = LocalConfiguration.current
val windowWidthDp = configuration.screenWidthDp
val windowHeightDp = configuration.screenHeightDp
return remember(windowWidthDp, windowHeightDp) {
ScreenLayoutSpec(
navigationStyle = decideNavigationStyle(windowWidthDp, windowHeightDp),
hasRoomForNotificationPanel = hasRoomForNotificationPanel(windowWidthDp),
)
}
return rememberScreenLayoutSpec(configuration.screenWidthDp, configuration.screenHeightDp)
}
@@ -38,12 +38,12 @@ import com.vitorpamplona.amethyst.commons.resources.profile_banner
import com.vitorpamplona.amethyst.commons.ui.components.NewItemsBubble
import com.vitorpamplona.amethyst.commons.ui.layouts.ChatHeaderLayout
import com.vitorpamplona.amethyst.commons.ui.layouts.listItem.SlimListItem
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.commons.ui.theme.Height4dpModifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -40,7 +40,7 @@ import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import com.vitorpamplona.amethyst.ui.layouts.CappedScreenContent
import com.vitorpamplona.amethyst.commons.ui.layouts.CappedScreenContent
import java.util.concurrent.ConcurrentHashMap
import kotlin.reflect.KClass
@@ -119,6 +119,7 @@ import com.vitorpamplona.amethyst.commons.resources.show_npub_as_a_qr_code
import com.vitorpamplona.amethyst.commons.resources.status_update
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStatus
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.layouts.PermanentDrawerWidth
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.richtext.CreateTextWithEmoji
@@ -150,7 +151,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserAssertionsFollowerCount
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserStatuses
import com.vitorpamplona.amethyst.ui.layouts.PermanentDrawerWidth
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItemDef
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
@@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.commons.model.navigation.routeFor
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.new_badge_award_notif
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.timeAgo
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
@@ -96,6 +96,9 @@ import com.vitorpamplona.amethyst.commons.ui.note.RenderCashuMint
import com.vitorpamplona.amethyst.commons.ui.note.RenderFedimint
import com.vitorpamplona.amethyst.commons.ui.note.RenderMintRecommendation
import com.vitorpamplona.amethyst.commons.ui.note.elements.StaleRelayHint
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.commons.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.ui.state.produceCachedStateAsync
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -133,8 +136,6 @@ import com.vitorpamplona.amethyst.ui.note.elements.DisplayReward
import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
import com.vitorpamplona.amethyst.ui.note.elements.Reward
import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay
import com.vitorpamplona.amethyst.ui.note.types.DisplayBlockedRelayList
import com.vitorpamplona.amethyst.ui.note.types.DisplayBroadcastRelayList
@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.add
import com.vitorpamplona.amethyst.commons.resources.posts_received
import com.vitorpamplona.amethyst.commons.resources.remove
import com.vitorpamplona.amethyst.commons.ui.note.timeAgo
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.commons.ui.theme.ButtonPadding
@@ -63,10 +63,10 @@ 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.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.amethyst.ui.note.timeAheadNoDot
import com.vitorpamplona.quartz.utils.TimeUtils
import java.time.Instant
import java.time.ZoneId
@@ -58,8 +58,8 @@ 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.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel
import com.vitorpamplona.quartz.utils.TimeUtils
import java.time.Instant
@@ -76,10 +76,10 @@ import com.vitorpamplona.amethyst.commons.resources.schedule_post_time_label
import com.vitorpamplona.amethyst.commons.resources.schedule_post_warning_multi
import com.vitorpamplona.amethyst.commons.resources.schedule_post_warning_single
import com.vitorpamplona.amethyst.commons.resources.schedule_post_warning_title
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.amethyst.ui.note.timeAheadNoDot
import com.vitorpamplona.quartz.utils.TimeUtils
import java.time.DayOfWeek
import java.time.Instant
@@ -58,8 +58,8 @@ 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.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel
import com.vitorpamplona.quartz.utils.TimeUtils
import java.time.Instant
@@ -29,9 +29,9 @@ import com.vitorpamplona.amethyst.commons.resources.ots_info_pending_description
import com.vitorpamplona.amethyst.commons.resources.ots_info_title
import com.vitorpamplona.amethyst.commons.resources.timestamp_pending_short
import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill
import com.vitorpamplona.amethyst.commons.ui.note.timeAgoNoDot
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.note.LoadOts
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import java.text.SimpleDateFormat
import java.util.Date
@@ -30,7 +30,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.ui.components.FileAttachmentCard
import com.vitorpamplona.amethyst.commons.ui.components.FileAttachmentCard
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.commons.resources.preview_card_image_for
import com.vitorpamplona.amethyst.commons.ui.components.util.setText
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.CopyIcon
import com.vitorpamplona.amethyst.commons.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size18Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
@@ -59,7 +60,6 @@ import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader
import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -68,6 +68,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.ui.components.FileAttachmentCard
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -76,7 +77,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier
import com.vitorpamplona.amethyst.commons.util.countToHumanReadableBytes
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.components.FileAttachmentCard
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
@@ -52,6 +52,7 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.long_form_reading_minutes
import com.vitorpamplona.amethyst.commons.resources.preview_card_image_for
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Font10SP
import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp
@@ -63,7 +64,6 @@ import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.WatchAuthor
import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader
import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent
import kotlinx.collections.immutable.toImmutableList
@@ -273,7 +273,7 @@ private fun PermanentDrawerShell(
/**
* Hosts the navigation content. Screen width capping happens per NavHost destination
* ([com.vitorpamplona.amethyst.ui.layouts.CappedScreenContent] via the NavigationEffects
* ([com.vitorpamplona.amethyst.commons.ui.layouts.CappedScreenContent] via the NavigationEffects
* builders), so this pane just claims the leftover row width.
*/
@Composable
@@ -95,8 +95,8 @@ import com.vitorpamplona.amethyst.commons.resources.backup_conflict_count_remove
import com.vitorpamplona.amethyst.commons.resources.backup_conflict_title
import com.vitorpamplona.amethyst.commons.resources.backup_review_changed_by_other_app
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.timeAgoNoDot
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff
@@ -90,6 +90,7 @@ import com.vitorpamplona.amethyst.commons.resources.backup_review_title_updated
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.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.note.timeAgoNoDot
import com.vitorpamplona.amethyst.commons.ui.theme.Size35dp
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
@@ -101,7 +102,6 @@ import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
@@ -90,11 +90,11 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.commons.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@@ -82,15 +82,16 @@ import com.vitorpamplona.amethyst.commons.resources.buzz_dm_more
import com.vitorpamplona.amethyst.commons.resources.buzz_dm_new
import com.vitorpamplona.amethyst.commons.resources.buzz_dm_title
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.now
import com.vitorpamplona.amethyst.commons.resources.remove_from_messages
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.note.TimeAgoLabels
import com.vitorpamplona.amethyst.commons.ui.note.rememberTimeAgoLabels
import com.vitorpamplona.amethyst.commons.ui.note.timeAgoShort
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.timeAgoShort
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.isValid
@@ -127,6 +128,8 @@ fun BuzzDmListScreen(
// Hidden conversations stay collapsed behind a header — they are off Messages by the user's own
// choice, so they must not compete with the live inbox; they only need to be *reachable* again.
var showHidden by remember { mutableStateOf(false) }
// Resolved once for the list, not per row.
val timeLabels = rememberTimeAgoLabels()
Scaffold(
topBar = { TopBarWithBackButton(stringRes(Res.string.buzz_dm_title), nav) },
@@ -149,7 +152,7 @@ fun BuzzDmListScreen(
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(rows, key = { it.channelId }) { row ->
DmRowCard(row, isHidden = false, viewModel = viewModel, accountViewModel = accountViewModel, nav = nav)
DmRowCard(row, isHidden = false, viewModel = viewModel, timeLabels = timeLabels, accountViewModel = accountViewModel, nav = nav)
}
if (hiddenRows.isNotEmpty()) {
item(key = "hidden-header") {
@@ -161,7 +164,7 @@ fun BuzzDmListScreen(
}
if (showHidden) {
items(hiddenRows, key = { "hidden-${it.channelId}" }) { row ->
DmRowCard(row, isHidden = true, viewModel = viewModel, accountViewModel = accountViewModel, nav = nav)
DmRowCard(row, isHidden = true, viewModel = viewModel, timeLabels = timeLabels, accountViewModel = accountViewModel, nav = nav)
}
}
}
@@ -211,6 +214,7 @@ private fun DmRowCard(
row: BuzzDmListViewModel.DmRow,
isHidden: Boolean,
viewModel: BuzzDmListViewModel,
timeLabels: TimeAgoLabels,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -256,7 +260,7 @@ private fun DmRowCard(
}
Spacer(Modifier.width(8.dp))
Text(
text = timeAgoShort(row.lastActivity, stringRes(Res.string.now)),
text = timeAgoShort(row.lastActivity, timeLabels),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -54,11 +54,11 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChann
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.relay_group_member_count
import com.vitorpamplona.amethyst.commons.resources.relay_group_no_messages_yet
import com.vitorpamplona.amethyst.commons.ui.note.timeAgo
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.buzzTimelinePreviewSummary
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription
@@ -88,12 +88,12 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.buzz_job_board_title
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@@ -160,12 +160,12 @@ import com.vitorpamplona.amethyst.commons.resources.buzz_workflow_waiting_for_ap
import com.vitorpamplona.amethyst.commons.resources.buzz_workflow_waiting_on
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@@ -118,10 +118,10 @@ import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable_deta
import com.vitorpamplona.amethyst.commons.resources.cordn_member_count
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.timeAgoNoDot
import com.vitorpamplona.amethyst.model.cordn.CordnCoverage
import com.vitorpamplona.amethyst.model.cordn.CordnGroupCreation
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex
@@ -69,6 +69,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.chats.ui.AutoScrollToNewest
import com.vitorpamplona.amethyst.commons.cordn.CordnGroupManager
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@@ -121,7 +122,6 @@ import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioWaveformPlayer
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.AutoScrollToNewest
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
@@ -55,8 +55,13 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.chats.ui.CHAT_GROUP_WINDOW_SECONDS
import com.vitorpamplona.amethyst.commons.chats.ui.ChatBubbleLayout
import com.vitorpamplona.amethyst.commons.chats.ui.ChatDivisor
import com.vitorpamplona.amethyst.commons.chats.ui.ChatGroupPosition
import com.vitorpamplona.amethyst.commons.chats.ui.UserDisplayNameLayout
import com.vitorpamplona.amethyst.commons.chats.ui.jumboEmojiCount
import com.vitorpamplona.amethyst.commons.chats.ui.jumboEmojiFontSize
import com.vitorpamplona.amethyst.commons.cordn.CordnMentions
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@@ -89,6 +94,8 @@ import com.vitorpamplona.amethyst.commons.resources.today
import com.vitorpamplona.amethyst.commons.ui.components.ClickableBox
import com.vitorpamplona.amethyst.commons.ui.components.util.setText
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.commons.ui.note.elements.ToggleableTimeAgoText
import com.vitorpamplona.amethyst.commons.ui.theme.Font12SP
import com.vitorpamplona.amethyst.commons.ui.theme.Size20dp
import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
@@ -98,8 +105,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ActionTile
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatChipFlowRow
@@ -109,11 +114,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ReactionChipView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.SectionDivider
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.TileRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.authorNameColorFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.jumboEmojiCount
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.jumboEmojiFontSize
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.CHAT_GROUP_WINDOW_SECONDS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatBubbleLayout
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatGroupPosition
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.coordinatorDisplayName
import com.vitorpamplona.amethyst.ui.stringRes
@@ -47,7 +47,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
@@ -70,6 +69,7 @@ import com.vitorpamplona.amethyst.commons.resources.chat_delivery_sending
import com.vitorpamplona.amethyst.commons.resources.close
import com.vitorpamplona.amethyst.commons.ui.components.ClickableBox
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.timeAbsolute
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Font12SP
import com.vitorpamplona.amethyst.commons.ui.theme.Size20dp
@@ -78,7 +78,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.timeAbsolute
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -203,9 +202,8 @@ private fun ChatDeliveryDetailDialog(
) {
// Absolute timestamp header (the chat time no longer toggles to absolute
// on tap — it opens this dialog instead).
val context = LocalContext.current
Text(
text = timeAbsolute(baseNote.createdAt(), context, prefix = "").trim(),
text = timeAbsolute(baseNote.createdAt(), prefix = "").trim(),
color = MaterialTheme.colorScheme.placeholderText,
fontSize = Font12SP,
)
@@ -36,6 +36,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.chats.ui.AutoScrollToNewest
import com.vitorpamplona.amethyst.commons.chats.ui.NewDateOrSubjectDivisor
import com.vitorpamplona.amethyst.commons.chats.ui.watchChatGroupPosition
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.feeds.FeedState
import com.vitorpamplona.amethyst.commons.model.Note
@@ -48,7 +51,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.watchChatGroupPosition
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import kotlinx.coroutines.launch
@@ -45,6 +45,9 @@ import androidx.compose.ui.Alignment.Companion.CenterStart
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.chats.ui.ChatBubbleLayout
import com.vitorpamplona.amethyst.commons.chats.ui.ChatGroupPosition
import com.vitorpamplona.amethyst.commons.chats.ui.jumboEmojiCount
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.latestBuzzEdit
import com.vitorpamplona.amethyst.commons.model.latestConcordEdit
@@ -60,8 +63,6 @@ import com.vitorpamplona.amethyst.ui.note.WatchBlockAndReport
import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.DisplayZapSplits
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatBubbleLayout
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatGroupPosition
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzActivityRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzDiff
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderBuzzEditedNote
@@ -40,15 +40,15 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.expiration_date_label
import com.vitorpamplona.amethyst.commons.resources.relay_group_pinned_content_description
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.commons.ui.note.elements.ToggleableTimeAgoText
import com.vitorpamplona.amethyst.commons.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Font12SP
import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.note.elements.DisplayLocation
import com.vitorpamplona.amethyst.ui.note.elements.DisplayPoW
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope
import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent
@@ -26,6 +26,8 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.commons.chats.ui.jumboEmojiCount
import com.vitorpamplona.amethyst.commons.chats.ui.jumboEmojiFontSize
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
@@ -40,8 +42,6 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAudioFromIMeta
import com.vitorpamplona.amethyst.ui.note.types.appendMissingImetaUrls
import com.vitorpamplona.amethyst.ui.note.types.getAudioMetaWithWaveform
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.jumboEmojiCount
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.jumboEmojiFontSize
@Composable
fun RenderRegularTextNote(
@@ -131,6 +131,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.util.setText
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.timeAgo
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -144,7 +145,6 @@ import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -84,13 +84,13 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_tab_new_requests_coun
import com.vitorpamplona.amethyst.commons.resources.marmot_unread_messages
import com.vitorpamplona.amethyst.commons.ui.navigation.bottombars.FabBottomBarPadded
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.commons.ui.note.elements.ToggleableTimeAgoText
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size55dp
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasEncryptedMediaV2
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasMip04Media
@@ -64,6 +64,7 @@ import com.vitorpamplona.amethyst.commons.resources.this_message_will_disappear_
import com.vitorpamplona.amethyst.commons.resources.upload_without_encryption_warning
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.text.onUiThread
import com.vitorpamplona.amethyst.commons.ui.theme.EditFieldBorder
@@ -86,7 +87,6 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.showCount
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.RoomChatFileUploadDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote
@@ -103,10 +103,10 @@ import com.vitorpamplona.amethyst.commons.resources.quick_action_share
import com.vitorpamplona.amethyst.commons.ui.components.util.setText
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.commons.ui.note.timeAgo
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelPreviewLoader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
@@ -71,13 +71,13 @@ import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncIma
import com.vitorpamplona.amethyst.commons.ui.navigation.bottombars.FabBottomBarPadded
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.commons.ui.note.timeAgo
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
@@ -52,6 +52,7 @@ import com.vitorpamplona.amethyst.commons.resources.groups_no_descriptor
import com.vitorpamplona.amethyst.commons.resources.owner
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.elements.NormalTimeAgo
import com.vitorpamplona.amethyst.commons.ui.richtext.CreateTextWithEmoji
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -66,7 +67,6 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
import com.vitorpamplona.amethyst.ui.note.elements.NormalTimeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.LinkChatButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.MuteChatButton
@@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.commons.resources.created_at
import com.vitorpamplona.amethyst.commons.resources.groups_no_descriptor
import com.vitorpamplona.amethyst.commons.resources.owner
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.elements.NormalTimeAgo
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.Size25dp
@@ -63,7 +64,6 @@ import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
import com.vitorpamplona.amethyst.ui.note.elements.NormalTimeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags
@@ -85,7 +85,6 @@ import com.vitorpamplona.amethyst.commons.resources.buzz_dm_see_all_count
import com.vitorpamplona.amethyst.commons.resources.buzz_dm_title
import com.vitorpamplona.amethyst.commons.resources.buzz_forum_create_title
import com.vitorpamplona.amethyst.commons.resources.buzz_import_loading
import com.vitorpamplona.amethyst.commons.resources.now
import com.vitorpamplona.amethyst.commons.resources.relay_group_channels_empty
import com.vitorpamplona.amethyst.commons.resources.relay_group_channels_not_nip29
import com.vitorpamplona.amethyst.commons.resources.relay_group_create_title
@@ -100,6 +99,9 @@ import com.vitorpamplona.amethyst.commons.resources.relay_tor_clearnet_title
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
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.TimeAgoLabels
import com.vitorpamplona.amethyst.commons.ui.note.rememberTimeAgoLabels
import com.vitorpamplona.amethyst.commons.ui.note.timeAgoShort
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -109,7 +111,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.timeAgoShort
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzAddPeopleDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzDmListViewModel
@@ -263,6 +264,8 @@ fun RelayGroupChannelListScreen(
// as the workspace overflow menu (Add people / Invite), whose own doc notes "any member sees them,
// the relay only serves the owner/admin ones."
val myPubkey = accountViewModel.account.signer.pubKey
// Resolved once for the list, not per DM row.
val timeLabels = rememberTimeAgoLabels()
val buzzVm: BuzzRelayImportViewModel = viewModel(key = "BuzzImport-${relay.url}")
LaunchedEffect(relay, isBuzz) { if (isBuzz) buzzVm.bind(accountViewModel.account, relay.url) }
@@ -644,6 +647,7 @@ fun RelayGroupChannelListScreen(
BuzzDmInlineRow(
row = row,
myPubkey = myPubkey,
timeLabels = timeLabels,
isHidden = false,
accountViewModel = accountViewModel,
nav = nav,
@@ -675,6 +679,7 @@ fun RelayGroupChannelListScreen(
BuzzDmInlineRow(
row = row,
myPubkey = myPubkey,
timeLabels = timeLabels,
isHidden = true,
accountViewModel = accountViewModel,
nav = nav,
@@ -828,6 +833,7 @@ private fun SectionAddButton(
private fun BuzzDmInlineRow(
row: BuzzDmListViewModel.DmRow,
myPubkey: HexKey,
timeLabels: TimeAgoLabels,
isHidden: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
@@ -885,7 +891,7 @@ private fun BuzzDmInlineRow(
}
if (row.lastActivity > 0) {
Text(
text = timeAgoShort(row.lastActivity, stringRes(Res.string.now)),
text = timeAgoShort(row.lastActivity, timeLabels),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -77,6 +77,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.commons.ui.navigation.bottombars.FabBottomBarPadded
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.timeAgo
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -96,7 +97,6 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.relayGroupDiscoveryChannelFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.toGroupConstraints
@@ -111,6 +111,8 @@ import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncIma
import com.vitorpamplona.amethyst.commons.ui.layouts.ChatHeaderLayout
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.commons.ui.note.elements.ToggleableTimeAgoText
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.AccountPictureModifier
@@ -133,8 +135,6 @@ import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContentOrNull
import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.ui.note.ObserveDraftEvent
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.buzzTimelinePreviewSummary
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex
@@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.commons.resources.git_untitled
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.StatusKind
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
@@ -71,7 +72,6 @@ import com.vitorpamplona.amethyst.ui.note.WatchAuthor
import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent
import com.vitorpamplona.amethyst.ui.note.clickableNoteModifier
import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
@@ -66,6 +66,7 @@ import com.vitorpamplona.amethyst.commons.resources.git_repo_stat_tags
import com.vitorpamplona.amethyst.commons.resources.git_repo_stat_updated
import com.vitorpamplona.amethyst.commons.resources.git_untitled
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
@@ -73,7 +74,6 @@ import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip34Git.git.GitCommit
@@ -74,6 +74,7 @@ import com.vitorpamplona.amethyst.commons.resources.wallet_loading
import com.vitorpamplona.amethyst.commons.ui.components.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.timeAgoNoDot
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertSpacer
@@ -97,7 +98,6 @@ import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.EndedFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveFlag
@@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.route_notifications
import com.vitorpamplona.amethyst.commons.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.commons.ui.layouts.NotificationPanelWidth
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
@@ -56,7 +57,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size12dp
import com.vitorpamplona.amethyst.commons.ui.theme.Size16dp
import com.vitorpamplona.amethyst.commons.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.layouts.NotificationPanelWidth
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
@@ -43,13 +43,13 @@ import com.vitorpamplona.amethyst.commons.resources.podcast_episode_number
import com.vitorpamplona.amethyst.commons.resources.podcast_season
import com.vitorpamplona.amethyst.commons.resources.podcast_season_episode
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.rememberTimeAgoLabels
import com.vitorpamplona.amethyst.commons.ui.note.timeAgoWith
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.rememberTimeAgoLabels
import com.vitorpamplona.amethyst.ui.note.timeAgoWith
import com.vitorpamplona.amethyst.ui.note.types.PodcastEpisodeAudioPlayer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.podcasts.PodcastEpisode
@@ -42,11 +42,11 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.podcast_trailer
import com.vitorpamplona.amethyst.commons.resources.podcast_trailer_season
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.rememberTimeAgoLabels
import com.vitorpamplona.amethyst.commons.ui.note.timeAgoWith
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.note.rememberTimeAgoLabels
import com.vitorpamplona.amethyst.ui.note.timeAgoWith
import com.vitorpamplona.amethyst.ui.note.types.PodcastEpisodeAudioPlayer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
@@ -94,6 +94,10 @@ import com.vitorpamplona.amethyst.commons.resources.poll_results_your_pick
import com.vitorpamplona.amethyst.commons.resources.poll_single_choice
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.commons.ui.note.timeAgoNoDot
import com.vitorpamplona.amethyst.commons.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
@@ -115,10 +119,6 @@ import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserLine
import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.ui.note.types.UserGallery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.results.datasources.PollResponsesFilterAssemblerSubscription
@@ -65,6 +65,7 @@ import com.vitorpamplona.amethyst.commons.resources.telegram
import com.vitorpamplona.amethyst.commons.resources.twitter
import com.vitorpamplona.amethyst.commons.resources.website
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.lastSeenSentence
import com.vitorpamplona.amethyst.commons.ui.richtext.CreateTextWithEmoji
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size15Modifier
@@ -78,7 +79,6 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.util.LongPressCopyText
import com.vitorpamplona.amethyst.ui.note.DrawPlayName
import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol
import com.vitorpamplona.amethyst.ui.note.lastSeenSentence
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.DisplayAppRecommendations
@@ -112,13 +112,13 @@ import com.vitorpamplona.amethyst.commons.resources.relay_info
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.note.timeAgo
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.MediumRelayIconModifier
import com.vitorpamplona.amethyst.commons.ui.theme.RelayIconFilter
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.relayAuthPurposeLabelRes
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsDivider
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSwitchTile
@@ -158,6 +158,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.commons.ui.components.appendLink
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.timeAgoNoDot
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ButtonBorder
@@ -180,7 +181,6 @@ import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.graspLink
import com.vitorpamplona.amethyst.ui.note.nipLink
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
@@ -106,11 +106,11 @@ import com.vitorpamplona.amethyst.commons.resources.event_sync_what_happens_titl
import com.vitorpamplona.amethyst.commons.resources.event_sync_wifi_warning
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.note.timeAgoNoDotNoDay
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.ui.note.formatMediumDate
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDotNoDay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -129,12 +129,12 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarSize
import com.vitorpamplona.amethyst.commons.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.commons.ui.note.timeAgoNoDot
import com.vitorpamplona.amethyst.commons.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -131,9 +131,9 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.commons.ui.theme.contentColorOnAccent
import com.vitorpamplona.amethyst.commons.ui.theme.isLight
import com.vitorpamplona.amethyst.commons.ui.theme.previewColor
import com.vitorpamplona.amethyst.commons.ui.theme.toFontFamily
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.previewColor
import com.vitorpamplona.amethyst.ui.theme.toFontFamily
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.toImmutableList
@@ -110,10 +110,10 @@ import com.vitorpamplona.amethyst.commons.ui.components.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.note.timeAgoNoDot
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.model.cordn.CordnRuntime
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CopyableKeyRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection
@@ -53,8 +53,8 @@ import com.vitorpamplona.amethyst.commons.resources.nip46_signer_act_ping
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_act_shared_pubkey
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_act_signed_kind
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_activity_denied
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
private val LiveGreen = Color(0xFF3DDC84)
@@ -102,6 +102,8 @@ import com.vitorpamplona.amethyst.commons.ui.note.RenderCashuMint
import com.vitorpamplona.amethyst.commons.ui.note.RenderCodeSnippetHeaderForThread
import com.vitorpamplona.amethyst.commons.ui.note.RenderFedimint
import com.vitorpamplona.amethyst.commons.ui.note.RenderMintRecommendation
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
@@ -164,8 +166,6 @@ import com.vitorpamplona.amethyst.ui.note.elements.DisplayReward
import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
import com.vitorpamplona.amethyst.ui.note.elements.Reward
import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.ui.note.nip22Comments.DisplayCommentScope
import com.vitorpamplona.amethyst.ui.note.observeEdits
import com.vitorpamplona.amethyst.ui.note.showAmount
@@ -33,12 +33,12 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.navigation.routeFor
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.commons.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
@@ -21,98 +21,31 @@
package com.vitorpamplona.amethyst.ui.theme
import android.app.Activity
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.Density
import androidx.core.view.WindowCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.patrykandpatrick.vico.compose.common.VicoTheme
import com.patrykandpatrick.vico.compose.common.VicoTheme.CandlestickCartesianLayerColors
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideAppIcons
import com.vitorpamplona.amethyst.commons.model.AccentColorType
import com.vitorpamplona.amethyst.commons.model.FontFamilyType
import com.vitorpamplona.amethyst.commons.model.FontSizeType
import com.vitorpamplona.amethyst.commons.model.ThemeType
import com.vitorpamplona.amethyst.commons.ui.components.LocalAnimationsEnabled
import com.vitorpamplona.amethyst.commons.ui.components.LocalProfilePictureCache
import com.vitorpamplona.amethyst.commons.ui.screen.DisplaySettings
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.ui.screen.collectDisplaySettings
import com.vitorpamplona.amethyst.commons.ui.theme.AccentBlueDark
import com.vitorpamplona.amethyst.commons.ui.theme.AccentBlueLight
import com.vitorpamplona.amethyst.commons.ui.theme.AccentGreenDark
import com.vitorpamplona.amethyst.commons.ui.theme.AccentGreenLight
import com.vitorpamplona.amethyst.commons.ui.theme.AccentOrangeDark
import com.vitorpamplona.amethyst.commons.ui.theme.AccentOrangeLight
import com.vitorpamplona.amethyst.commons.ui.theme.AccentPinkDark
import com.vitorpamplona.amethyst.commons.ui.theme.AccentPinkLight
import com.vitorpamplona.amethyst.commons.ui.theme.AccentRedDark
import com.vitorpamplona.amethyst.commons.ui.theme.AccentRedLight
import com.vitorpamplona.amethyst.commons.ui.theme.Purple200
import com.vitorpamplona.amethyst.commons.ui.theme.Purple500
import com.vitorpamplona.amethyst.commons.ui.theme.Shapes
import com.vitorpamplona.amethyst.commons.ui.theme.Teal200
import com.vitorpamplona.amethyst.commons.ui.theme.Typography
import com.vitorpamplona.amethyst.commons.ui.theme.amethystDarkColorScheme
import com.vitorpamplona.amethyst.commons.ui.theme.amethystLightColorScheme
import com.vitorpamplona.amethyst.commons.ui.theme.AmethystMaterialTheme
import com.vitorpamplona.amethyst.commons.ui.theme.amethystDarkColors
import com.vitorpamplona.amethyst.commons.ui.theme.amethystLightColors
import com.vitorpamplona.amethyst.commons.ui.theme.isDarkTheme
import com.vitorpamplona.amethyst.commons.ui.theme.isLight
import com.vitorpamplona.amethyst.commons.ui.theme.transparentBackground
import com.vitorpamplona.amethyst.commons.ui.theme.withFontFamily
// The accent color (primary/secondary/tertiary) is user-selectable in Settings -> Accent Color.
// Purple keeps the original Amethyst look (purple primary + teal secondary). Every other accent
// uses its single hue across primary and secondary for a cohesive single-color theme.
private fun accentPrimary(
accent: AccentColorType,
dark: Boolean,
): Color =
when (accent) {
AccentColorType.PURPLE -> if (dark) Purple200 else Purple500
AccentColorType.BLUE -> if (dark) AccentBlueDark else AccentBlueLight
AccentColorType.GREEN -> if (dark) AccentGreenDark else AccentGreenLight
AccentColorType.ORANGE -> if (dark) AccentOrangeDark else AccentOrangeLight
AccentColorType.RED -> if (dark) AccentRedDark else AccentRedLight
AccentColorType.PINK -> if (dark) AccentPinkDark else AccentPinkLight
}
private fun accentSecondary(
accent: AccentColorType,
dark: Boolean,
): Color = if (accent == AccentColorType.PURPLE) Teal200 else accentPrimary(accent, dark)
// Representative colour for an accent option, used by the Settings accent-picker swatches — the
// same primary the theme would apply for the given light/dark mode, so the swatch previews the
// real result.
fun AccentColorType.previewColor(dark: Boolean): Color = accentPrimary(this, dark)
private fun darkColors(accent: AccentColorType): ColorScheme =
amethystDarkColorScheme(
primary = accentPrimary(accent, dark = true),
secondary = accentSecondary(accent, dark = true),
inversePrimary = accentPrimary(accent, dark = false),
)
private fun lightColors(accent: AccentColorType): ColorScheme =
amethystLightColorScheme(
primary = accentPrimary(accent, dark = false),
secondary = accentSecondary(accent, dark = false),
inversePrimary = accentPrimary(accent, dark = true),
)
val chartLightColors =
VicoTheme(
@@ -155,6 +88,10 @@ fun AmethystTheme(content: @Composable () -> Unit) {
AmethystTheme(theme, accentColor, fontFamily, fontSize, displaySettings, content)
}
/**
* The shared [AmethystMaterialTheme] plus what only Android has: the system status and
* navigation bars, tinted to match.
*/
@Composable
fun AmethystTheme(
prefTheme: ThemeType,
@@ -168,44 +105,22 @@ fun AmethystTheme(
// MODIFY_DAY_NIGHT_MODE, which this app does not declare, so the call silently no-ops — and it
// ran on every recomposition of the theme, writing device state from inside composition. The
// in-app choice is applied through the colour scheme below, which is what actually took effect.
val darkTheme =
when (prefTheme) {
ThemeType.DARK -> true
ThemeType.LIGHT -> false
else -> isSystemInDarkTheme()
}
val darkTheme = isDarkTheme(prefTheme)
val colors =
remember(darkTheme, accentColor) {
if (darkTheme) darkColors(accentColor) else lightColors(accentColor)
if (darkTheme) amethystDarkColors(accentColor) else amethystLightColors(accentColor)
}
val resolvedFontFamily = remember(fontFamily) { fontFamily.toFontFamily() }
val typography = remember(fontFamily) { Typography.withFontFamily(resolvedFontFamily) }
val density = LocalDensity.current
val scaledDensity =
remember(density, fontSize) {
Density(density.density, density.fontScale * fontSize.scale)
}
MaterialTheme(
colorScheme = colors,
typography = typography,
shapes = Shapes,
content = {
ProvideAppIcons {
CompositionLocalProvider(
LocalDensity provides scaledDensity,
// ImageLoaderSetup registers the avatar thumbnail cache and the local Blossom bridge.
LocalProfilePictureCache provides true,
LocalDisplaySettings provides displaySettings,
// Performance mode turns decorative animations (crossfades) off app-wide.
LocalAnimationsEnabled provides !displaySettings.performanceMode,
LocalTextStyle provides LocalTextStyle.current.merge(TextStyle(fontFamily = resolvedFontFamily)),
content = content,
)
}
},
AmethystMaterialTheme(
darkTheme = darkTheme,
accentColor = accentColor,
fontFamily = fontFamily,
fontSize = fontSize,
displaySettings = displaySettings,
// ImageLoaderSetup registers the avatar thumbnail cache and the local Blossom bridge.
profilePictureCache = true,
colors = colors,
content = content,
)
val view = LocalView.current
@@ -226,13 +141,3 @@ fun AmethystTheme(
}
}
}
// Maps the user-selected font preference to a Compose [FontFamily].
// SYSTEM returns null so the platform default is used unchanged.
fun FontFamilyType.toFontFamily(): FontFamily? =
when (this) {
FontFamilyType.SYSTEM -> null
FontFamilyType.SANS_SERIF -> FontFamily.SansSerif
FontFamilyType.SERIF -> FontFamily.Serif
FontFamilyType.MONOSPACE -> FontFamily.Monospace
}
@@ -20,9 +20,9 @@
*/
package com.vitorpamplona.amethyst.cordn
import com.vitorpamplona.amethyst.commons.chats.ui.ChatGroupPosition
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.cordnGroupPositionFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.sameDayAs
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatGroupPosition
import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage
import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnEnvelope
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -1432,3 +1432,124 @@ functional bugs. The follow-ups:
Java ignored, which no browser encoder produces. `%1$d` arguments now render ASCII
digits in every locale, the same as every other migrated string.
`ChannelFeedContentState` reaches `LocalCache.appHost` for its main-thread check.
## 2026-09-27 — the rest of the single-blocker UI, `TimeAgo`, and the theme
### Moved
| What | To | The blocker, and how it went |
|---|---|---|
| `ScreenLayout` tier logic, `CappedScreenContent`, the pane widths | `commonsUI/…/ui/layouts/ScreenLayout.kt` | `material3-window-size-class` (app-only): its width breakpoints (Medium ≥ 600dp, Expanded ≥ 840dp) are inlined, and `ScreenLayoutTest` (18 cases, now in commonsUI jvmTest) passes against them. `LocalConfiguration` stays in the app, which asks the shared `rememberScreenLayoutSpec(widthDp, heightDp)`, so Desktop can supply its own window size. |
| `animatedViewerChromeInset`, `rememberViewerControlsVisibility` | `commonsUI/…/ui/components/ViewerChrome.kt` | They sat in a file full of Android window code. Only `ImmersiveSystemBarsEffect` (Window/insets controller) stays. |
| `SlidingCarousel` | `commonsUI/…/ui/components` | needed the inset above |
| `AudioWaveformReadOnly` | `commonsUI/…/ui/components` | two enums from the Android-only audiowaveform library, now local enums with the same values |
| `FileAttachmentCard` (+ `FileAttachmentRow`, now public) | `commonsUI/…/ui/components` | `extractFilename` hoisted to `commons/…/util/MimeTypeLabels.kt` |
| `PdfFetcher` | `commonsUI/src/jvmAndroid/…/service/pdf` | `Amethyst.instance.diskCache` → a `diskCache` parameter. commonsUI jvmAndroid gained `okhttp-coroutines` (already on every module that ships OkHttp). |
| `TimeAgoFormatter` (`timeAgo*`, `timeAbsolute*`, `dateFormatter`, `lastSeenSentence`, `timeAgoShort`, `TimeAgoLabels`) | `commonsUI/…/ui/note/TimeAgoFormatter.kt` | `android.text.format` — see below |
| `ToggleableTimeAgoText`, `TimeAgo`, `NormalTimeAgo`, `TimeAgoStyle`, `NowProvider`, `LocalNowSeconds` | `commonsUI/…/ui/note/elements` | followed the formatter |
| `AmethystTheme`'s scheme, typography and providers | `commonsUI/…/ui/theme/AmethystTheme.kt` (`AmethystMaterialTheme`, `amethystDark/LightColors`, `isDarkTheme`, `previewColor`, `toFontFamily`) | The app's `AmethystTheme` resolves the prefs, calls it, then tints the system bars. The Vico chart colours stay app-side (Vico is Android-only here). |
**The date-format seam.** `PlatformDateFormat.kt` (commonsUI) has four expects:
- `DateSkeletonFormatter(skeleton)`:
- Android: `getBestDateTimePattern` in a `ThreadLocal` `SimpleDateFormat`, exactly the old code.
- JVM: `DateTimeFormatter.ofLocalizedPattern` (JDK 19+, the same CLDR skeleton lookup), cached per locale and zone.
- iOS: `NSDateFormatter.setLocalizedDateFormatFromTemplate`.
- `calendarYearAndDay`: `Calendar` on jvmAndroid, `NSCalendar` on iOS.
- `rememberTimeOfDayFormatter`:
- Android: `DateFormat.getTimeFormat(context)` per call, as before, so the system 12/24-hour setting is still followed.
- JVM: the locale's SHORT time.
- iOS: `NSDateFormatterShortStyle`.
- `relativeTimeSpanShort`:
- Android: `DateUtils`, as before.
- Elsewhere: the compact "5m" form.
`DateSkeletonFormatterTest` pins the JVM side: en-US vs en-GB order from the same instance after a locale switch, the three skeletons, day/year boundaries, and the same-day branch of `timeAbsoluteWith`.
Desktop still has its own `ToggleableTimeAgoText` and the older `commons/…/util/TimeAgoFormatter.kt` (hard-coded English units, `DateFormat.MEDIUM`). Merging those onto this one is the Desktop phase.
### What is left in `amethyst/ui`, measured (2026-09-27)
A transitive-blocker sweep of the 1,385 files under `amethyst/…/ui/`, after this round:
- **80 files have no blocker left.**
- Many are headless and belong in `commons`, not `commonsUI`: the filter assemblers and
`*LastRead`, `NewMessageTagger`, `SplitConversor`, `PubKeyFormatter`, `SettingsCatalog`,
and the Tor status/dialog VM.
- The Compose ones include the chat bubble set (`ChatBubbleLayout`, `ChatGroupPosition`,
`JumboEmoji`, `NewDateOrSubjectDivisor`, `AutoScrollToNewest`). It moved to `commonsUI`
in the follow-up below. Desktop's `ui/chats/ChatBubbleLayout.kt` is an older fork of it.
- **`AccountViewModel` is the wall:**
- 892 files touch it, and 310 touch nothing else app-side. Yet swapping it for an interface frees only 76 files by itself, because the rest call hub composables that are blocked themselves.
- The ui files use 210 distinct members of it. The note renderers use 28.
- Only ~10 note renderers become movable with a context interface alone.
- **The real levers are about ten hub composables**, each blocking the ui files that call it:
| Hub | Files blocked |
|---|---|
| `UserProfilePicture` | 123 |
| `RouteMaker` | 89 |
| `UsernameDisplay` | 77 |
| `Loaders` | 66 |
| `DisappearingScaffold` | 64 (AVM only, 53 lines) |
| `RichTextViewer` | 62 |
| `NoteCompose` | 58 |
| `FeedContentStateView` / `FeedView` | AVM only |
Also on the list:
- the `reqCommand` `observe*` helpers (176 files; they take `accountViewModel` themselves);
- the flavour-only `TranslatableRichTextViewer` (54 files), which wants a slot or a CompositionLocal.
- **Two corrections to the MOVE-AFTER table above:**
- `INav`/`Route` are no longer blockers: every ui file imports the commons ones.
- `ui/note/types` is 111 files, not ~89, and "AVM threading" understates it: the hubs matter more than the parameter.
### Follow-up in the same round: the chat bubble group, and the audit
- **Moved to `commonsUI/…/commons/chats/ui`**, beside `ChatDivisor` and
`UserDisplayNameLayout`: `ChatBubbleLayout`, `ChatGroupPosition`, `JumboEmoji`,
`NewDateOrSubjectDivisor` and `AutoScrollToNewest`. `AutoScrollToNewest` and
`CHAT_GROUP_WINDOW_SECONDS` went from `internal` to public so the app can reach them.
Desktop's `ui/chats/ChatBubbleLayout.kt` is an older fork of this one (no group
position, jumbo emoji, swipe-to-reply or reaction row). Replacing it is the Desktop
phase.
- **Audit follow-ups:**
- `PdfFetcher` takes the disk cache as a provider, read on the IO dispatcher, so a PDF
card composing on a cold start doesn't build the app's lazy cache on the main thread.
- The two DM lists resolve `TimeAgoLabels` once per list instead of once per row.
- The iOS `DateSkeletonFormatter` rebuilds on a locale change, as Android's does. (The
JVM one also keys on the time zone; iOS caches its system zone until reset, so keying on
it there would cost lookups and still not notice.)
- `ScreenLayoutTest` pins the inclusive 600dp boundary.
- Two stale KDoc links and a same-package import are fixed.
- The generated baseline profile has its stable-name entries repointed:
`NowProviderKt`, and the five theme functions now in `AmethystThemeKt`. Its R8
lambda entries were already stale before this round (it still lists
`MarkDownStyleOnDark` under `ThemeKt`), and the new theme root
(`AmethystThemeKt;->AmethystMaterialTheme`, `isDarkTheme`) has no entries at all, so
**regenerate the profile** after these moves.
### Second audit, and one Desktop decision
- **Fixed:**
- `PdfPreviewCard` closed its cache snapshot on the main thread, after `withContext(IO)`
returned. That was older than this branch. Closing takes the same global DiskLruCache
lock as opening, so `PdfFetcher.useSnapshot` now fetches, runs the block and closes, all
in one IO block, leaving no suspension point for cancellation to leak through.
- The PDF viewer's `onDispose` now closes its handle on the app IO scope, under the
render mutex.
- JVM and iOS built a time-of-day formatter per feed item; they now share one cached
instance. `NSDateFormatter` is costly to build.
- `timeAgoShort` no longer allocates an unused fallback lambda per tick on Android.
- The iOS skeleton formatter keys on the locale only, like Android. iOS caches its system
zone until reset, so keying on the zone cost lookups and still never saw a change.
- New tests: `JumboEmojiTest` (counts, ZWJ/skin tone/flag/keycap sequences, bubble
shapes). `ScreenLayoutTest` moved to commonsUI jvmTest, beside the code it tests.
- **Desktop needs `jdk.localedata` before it uses these formatters.**
- `desktopApp/build.gradle.kts` `nativeDistributions.modules(...)` doesn't include it,
so the packaged runtime carries only en/root CLDR data.
- `DateTimeFormatter.ofLocalizedPattern` then gives en-GB `Jan 5, 2024` and de-DE
`2024 Jan 5`, and the JDK tests (full runtime) can't see it.
- Desktop's existing `java.time` formatting (`DesktopScheduleAtPicker`) already has the
same gap.
- Adding the module costs about +28 MB to the unpacked runtime. That is a packaging
call, left for the Desktop phase.
@@ -50,3 +50,10 @@ fun prettyMime(mime: String): String =
"application/webbundle" -> "Web Bundle"
else -> mime
}
/** The last path segment of [url], without query or fragment; the whole [url] when that is blank. */
fun extractFilename(url: String): String {
val afterQuery = url.substringBefore('?').substringBefore('#')
val name = afterQuery.substringAfterLast('/', afterQuery)
return if (name.isBlank()) url else name
}
+2
View File
@@ -160,6 +160,8 @@ kotlin {
// OkHttp for the Blossom read-auth Coil fetcher.
implementation(libs.okhttp)
// executeAsync for PdfFetcher; already on every module that ships OkHttp.
implementation(libs.okhttpCoroutines)
// Markdown rendering (richtext-commonmark). The single
// consumer (RenderMarkdown.kt) lives in jvmAndroid.
@@ -0,0 +1,77 @@
/*
* 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.note
import android.text.format.DateFormat
import android.text.format.DateUtils
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Per-thread cached [SimpleDateFormat] keyed off the current default [Locale].
*
* `SimpleDateFormat` is mutable and not thread-safe, and these formatters are read from both
* the UI thread (composition) and background coroutines. `ThreadLocal` gives each thread its
* own instance: no locks, no allocation per call, and a lazy rebuild on locale change.
*/
actual class DateSkeletonFormatter actual constructor(
private val skeleton: String,
) {
private val cache = ThreadLocal<Pair<Locale, SimpleDateFormat>>()
private fun get(): SimpleDateFormat {
val current = Locale.getDefault()
val cached = cache.get()
if (cached != null && cached.first == current) return cached.second
val fresh = SimpleDateFormat(DateFormat.getBestDateTimePattern(current, skeleton), current)
cache.set(current to fresh)
return fresh
}
actual fun format(epochMillis: Long): String = get().format(epochMillis)
}
/** Built per call, as before the move: the system 12/24-hour setting can change under a running app. */
@Composable
actual fun rememberTimeOfDayFormatter(): (epochMillis: Long) -> String {
val context = LocalContext.current
return remember(context) { { epochMillis -> DateFormat.getTimeFormat(context).format(Date(epochMillis)) } }
}
actual fun relativeTimeSpanShortOrNull(
epochMillis: Long,
nowMillis: Long,
nowLabel: String,
): String? {
val humanReadable =
DateUtils
.getRelativeTimeSpanString(
epochMillis,
nowMillis,
DateUtils.MINUTE_IN_MILLIS,
DateUtils.FORMAT_ABBREV_ALL,
).toString()
return if (humanReadable.startsWith("In") || humanReadable.startsWith("0")) nowLabel else humanReadable
}
@@ -18,7 +18,7 @@
* 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.ui.screen.loggedIn.chats.feed
package com.vitorpamplona.amethyst.commons.chats.ui
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
@@ -46,7 +46,7 @@ import androidx.compose.runtime.LaunchedEffect
* message they just wrote somewhere off-screen below.
*/
@Composable
internal fun AutoScrollToNewest(
fun AutoScrollToNewest(
listState: LazyListState,
newest: Any?,
mine: Boolean = false,
@@ -18,7 +18,7 @@
* 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.ui.screen.loggedIn.chats.feed.layouts
package com.vitorpamplona.amethyst.commons.chats.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animate
@@ -73,7 +73,6 @@ import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.chats.ui.UserDisplayNameLayout
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.ui.note.CommentIcon
@@ -18,7 +18,7 @@
* 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.ui.screen.loggedIn.chats.feed.layouts
package com.vitorpamplona.amethyst.commons.chats.ui
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
@@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.ui.note.dateFormatter
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeMe
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeMeBottom
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeMeMiddle
@@ -34,7 +35,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeThem
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeThemBottom
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeThemMiddle
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeThemTop
import com.vitorpamplona.amethyst.ui.note.dateFormatter
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip14Subject.subject
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
@@ -88,7 +88,7 @@ fun chatBubbleShapeFor(
}
/** Messages more than this far apart never group, even from the same author. */
internal const val CHAT_GROUP_WINDOW_SECONDS = 10 * 60L
const val CHAT_GROUP_WINDOW_SECONDS = 10 * 60L
/**
* Event kinds that don't render as regular bubbles (zaps, raids, clips) or that
@@ -18,7 +18,7 @@
* 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.ui.screen.loggedIn.chats.feed
package com.vitorpamplona.amethyst.commons.chats.ui
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
@@ -18,17 +18,16 @@
* 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.ui.screen.loggedIn.chats.feed
package com.vitorpamplona.amethyst.commons.chats.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.chats.ui.ChatDivisor
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.never
import com.vitorpamplona.amethyst.commons.resources.today
import com.vitorpamplona.amethyst.commons.ui.note.dateFormatter
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.note.dateFormatter
import com.vitorpamplona.quartz.nip14Subject.subject
@Composable
@@ -18,7 +18,7 @@
* 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.ui.components
package com.vitorpamplona.amethyst.commons.ui.components
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.animateFloatAsState
@@ -45,8 +45,6 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.coerceIn
import androidx.compose.ui.unit.dp
import com.linc.audiowaveform.model.AmplitudeType
import com.linc.audiowaveform.model.WaveformAlignment
import kotlin.math.ceil
import kotlin.math.roundToInt
@@ -204,3 +202,9 @@ internal fun Iterable<Float>.normalize(
}
private fun Int.safeDiv(value: Int): Float = if (value == 0) 0F else this / value.toFloat()
/** Where each bar sits in the row. Same values as the audiowaveform library's enum, which is Android-only. */
enum class WaveformAlignment { Top, Center, Bottom }
/** How a bucket of samples becomes one bar. Same values as the audiowaveform library's enum. */
enum class AmplitudeType { Avg, Min, Max }
@@ -18,7 +18,7 @@
* 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.ui.components
package com.vitorpamplona.amethyst.commons.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -44,8 +44,8 @@ import com.vitorpamplona.amethyst.commons.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.innerPostModifier
import com.vitorpamplona.amethyst.commons.util.countToHumanReadableBytes
import com.vitorpamplona.amethyst.commons.util.extractFilename
import com.vitorpamplona.amethyst.commons.util.prettyMime
import com.vitorpamplona.amethyst.ui.components.pdf.extractFilename
/**
* The renderer for a declared file that none of the media viewers can display — a webxdc app,
@@ -91,10 +91,10 @@ fun FileAttachmentCard(
/**
* The icon + title + subtitle row shared by every card that stands in for a file it can't
* render inline: this one and the PDF placeholder/skeleton in
* [com.vitorpamplona.amethyst.ui.components.pdf.PdfPreviewCard].
* the Android app's `PdfPreviewCard`.
*/
@Composable
internal fun FileAttachmentRow(
fun FileAttachmentRow(
symbol: MaterialSymbol,
title: String,
subtitle: String?,
@@ -18,7 +18,7 @@
* 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.ui.components
package com.vitorpamplona.amethyst.commons.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
@@ -0,0 +1,130 @@
/*
* 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.animation.core.animateDpAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.systemBars
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
// Chrome shared by the full-screen media viewers and the carousels inside them: how the
// controls auto-hide, and how far they sit from a screen edge. The platform half (hiding
// the OS bars) stays with each front end's viewer.
// Opening churn -- insets arriving, then the bars being hidden -- must not look like a user
// gesture, so the row snaps through it and only animates afterwards.
private const val CONTROLS_SETTLE_BEFORE_ANIMATING_MS = 350L
// Roughly the system bars' own show/hide duration, so the row travels with them rather than
// trailing after they have already arrived.
private const val CONTROLS_SLIDE_MS = 200
// Keeps the row off the screen edge -- and off the rounded corners -- while the bars are hidden.
private val VIEWER_CHROME_EDGE_GAP = 16.dp
// How long the controls stay up before the viewer fades them out on its own.
private const val CONTROLS_AUTO_HIDE_DELAY_MS = 2000L
/**
* Visibility of the viewer controls: they start on screen, fade out on their own after
* [CONTROLS_AUTO_HIDE_DELAY_MS], and the caller flips the returned state on tap.
*
* [holdOpen] freezes the timer while something anchored to the controls -- the share sheet, say --
* is up, and re-arms it once that closes. [armed] withholds the countdown until there is something
* to look at, so a viewer that spends three seconds fetching its media doesn't reveal the first
* frame with the controls already gone.
*
* A tap that brings the controls back deliberately gets no timer: the user asked for them, so they
* stay until tapped away. That is why the countdown races the controls going away rather than just
* sleeping -- a timer left over from an earlier show would otherwise wipe controls the user tapped
* back up in the meantime.
*/
@Composable
fun rememberViewerControlsVisibility(
holdOpen: Boolean,
armed: Boolean = true,
): MutableState<Boolean> {
val visible = remember { mutableStateOf(true) }
LaunchedEffect(armed, holdOpen) {
if (!armed || holdOpen) return@LaunchedEffect
val hiddenFirst =
withTimeoutOrNull(CONTROLS_AUTO_HIDE_DELAY_MS) {
snapshotFlow { visible.value }.first { !it }
}
if (hiddenFirst == null) visible.value = false
}
return visible
}
/**
* How far the viewer chrome sits from a screen edge: the system bar's own height while the bar is
* on screen, and a thin constant once it is hidden -- so the chrome follows the bar instead of
* reserving space for one that is not there.
*
* This only works because the viewer asks for BEHAVIOR_DEFAULT rather than transient bars. Under
* BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE a peeked bar is painted over the content and dispatches no
* insets at all -- `systemBars` stays 0 and `isVisible` stays false the whole time it is on screen
* -- so nothing here could react to it.
*
* The value is animated, but snapped for [CONTROLS_SETTLE_BEFORE_ANIMATING_MS] after the chrome
* appears. Opening moves the inset twice for reasons the user did not cause: the window has not
* been told its insets yet (they read 0), and ImmersiveSystemBarsEffect hides the bars from a
* DisposableEffect that runs after composition. Animating either would play a slide on open.
*/
@Composable
fun animatedViewerChromeInset(atBottom: Boolean): Dp {
val density = LocalDensity.current
val bars = WindowInsets.systemBars
val barPx = if (atBottom) bars.getBottom(density) else bars.getTop(density)
val target = with(density) { maxOf(barPx, VIEWER_CHROME_EDGE_GAP.roundToPx()).toDp() }
var settled by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(CONTROLS_SETTLE_BEFORE_ANIMATING_MS)
settled = true
}
val animated by animateDpAsState(
targetValue = target,
animationSpec = if (settled) tween(durationMillis = CONTROLS_SLIDE_MS) else snap(),
label = "viewerChromeInset",
)
return animated
}
@@ -20,8 +20,18 @@
*/
package com.vitorpamplona.amethyst.commons.ui.layouts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/** How the app shell presents its top-level navigation for the current window size. */
enum class NavigationStyle {
@@ -60,3 +70,114 @@ data class ScreenLayoutSpec(
}
val LocalScreenLayout = compositionLocalOf { ScreenLayoutSpec.Phone }
/**
* Material 3's window width classes: Compact below 600dp, Medium below 840dp, Expanded from
* there. Inlined rather than taken from `material3-window-size-class`, which only the Android
* app depends on.
*/
private const val MEDIUM_WIDTH_DP = 600
private const val EXPANDED_WIDTH_DP = 840
/**
* Minimum window width for the docked notification panel: a leading navigation pane, a
* readable center pane and the panel ([NotificationPanelWidth]) only coexist comfortably from
* a landscape-tablet-sized window up. Sized against the widest leading pane, the permanent
* drawer ([PermanentDrawerWidth]); the rail is narrower, so a railed window that clears this
* gets a roomier center pane rather than a tighter one.
*/
private const val NOTIFICATION_PANEL_MIN_WINDOW_DP = 1200
val PermanentDrawerWidth = 300.dp
val NotificationPanelWidth = 360.dp
/**
* Maximum width of a screen's content column inside a wide center pane. Every NavHost
* destination is wrapped in [CappedScreenContent] (via the builders in NavigationEffects),
* so the whole screen — top bar, tabs, feed, settings rows — shares one centered reading
* column instead of stretching across the pane. Screens that genuinely need the full pane
* (Messages' two-pane split, the embedded browser surfaces) opt out at registration.
*/
val FeedContentMaxWidth = 600.dp
/**
* Minimum window height for the docked drawer. Higher than Material's 480dp Compact/Medium
* height boundary on purpose: the permanent drawer's own header — banner, avatar, status
* editor, follower counts — fills most of a ~540dp column before the first navigation row, so
* below this the rail shows more of the menu than the dock does.
*/
private const val DOCK_MIN_WINDOW_HEIGHT_DP = 600
/**
* The navigation tier for a window of this shape.
*
* The dock is not a width decision. A tablet is past the Expanded breakpoint in both
* orientations, so keying on width alone pins 300dp of menu open in portrait with no closed
* state to fall back on (issue #4024). It docks only when the window is wide, landscape, and
* tall enough for the drawer's own content to be usable; everything else that is not Compact
* falls through to the rail, which pairs with the existing swipe-in modal drawer.
*
* A square window counts as landscape and docks; `Configuration.ORIENTATION_LANDSCAPE`
* breaks that tie the other way, so the two disagree at exactly width == height.
*/
fun decideNavigationStyle(
windowWidthDp: Int,
windowHeightDp: Int,
): NavigationStyle =
when {
windowWidthDp >= EXPANDED_WIDTH_DP &&
windowWidthDp >= windowHeightDp &&
windowHeightDp >= DOCK_MIN_WINDOW_HEIGHT_DP -> NavigationStyle.PERMANENT_DRAWER
windowWidthDp >= MEDIUM_WIDTH_DP -> NavigationStyle.NAV_RAIL
else -> NavigationStyle.BOTTOM_BAR
}
/**
* Whether the window is wide enough to dock the notification feed beside the content.
*
* Deliberately not keyed on [NavigationStyle]: a wide portrait window now gets the rail, and
* gating on the dock would strip a panel it has today.
*/
fun hasRoomForNotificationPanel(windowWidthDp: Int): Boolean = windowWidthDp >= NOTIFICATION_PANEL_MIN_WINDOW_DP
/**
* Centers a destination's content at [FeedContentMaxWidth]. The outer box paints the theme
* background so the gutters match the screens' own surfaces; on Compact windows the cap is
* wider than the pane and this is a visual no-op.
*/
@Composable
fun CappedScreenContent(content: @Composable () -> Unit) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.TopCenter,
) {
Box(
Modifier
.widthIn(max = FeedContentMaxWidth)
.fillMaxSize(),
) {
content()
}
}
}
/**
* The layout tier for a window of [windowWidthDp] x [windowHeightDp]. Each front end reads
* its own window size (Android: `Configuration.screenWidthDp`) and asks here, so the tier
* rule lives in one place.
*/
@Composable
fun rememberScreenLayoutSpec(
windowWidthDp: Int,
windowHeightDp: Int,
): ScreenLayoutSpec =
remember(windowWidthDp, windowHeightDp) {
ScreenLayoutSpec(
navigationStyle = decideNavigationStyle(windowWidthDp, windowHeightDp),
hasRoomForNotificationPanel = hasRoomForNotificationPanel(windowWidthDp),
)
}
@@ -0,0 +1,56 @@
/*
* 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.note
import androidx.compose.runtime.Composable
/**
* Formats instants with a Unicode LDML date skeleton ("yMMMd", "MMMd", "yMMM"), letting the
* platform pick the default locale's field order ("MMM d, y" in en-US, "d MMM y" in en-GB).
* Safe to call from any thread. Rebuilds itself when the default locale changes (the JVM actual
* also on a time-zone change; Android and iOS keep the zone they were built with).
*/
expect class DateSkeletonFormatter(
skeleton: String,
) {
fun format(epochMillis: Long): String
}
/** The instant's calendar year and day-of-year in the default time zone, as year * 1000 + day. */
expect fun calendarYearAndDay(epochMillis: Long): Int
/**
* A time-of-day formatter ("14:32" / "2:32 PM"). Android and iOS follow the system 12/24-hour
* setting; the JVM follows the locale. Identity-stable, so it can key a `remember`, and cheap to
* call for every item in a feed: platforms whose formatter is costly share one cached instance.
*/
@Composable
expect fun rememberTimeOfDayFormatter(): (epochMillis: Long) -> String
/**
* 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.
*/
expect fun relativeTimeSpanShortOrNull(
epochMillis: Long,
nowMillis: Long,
nowLabel: String,
): String?
@@ -18,11 +18,8 @@
* 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.ui.note
package com.vitorpamplona.amethyst.commons.ui.note
import android.content.Context
import android.text.format.DateFormat
import android.text.format.DateUtils
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
@@ -46,52 +43,23 @@ import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.quartz.utils.TimeUtils
import org.jetbrains.compose.resources.StringResource
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import kotlin.math.round
// Skeletons follow Unicode LDML — DateFormat.getBestDateTimePattern picks the
// correct locale-specific ordering (e.g. "MMM d, y" in en-US vs "d MMM y" in en-GB).
// Skeletons follow Unicode LDML: the platform picks the locale-specific ordering
// (e.g. "MMM d, y" in en-US vs "d MMM y" in en-GB). See [DateSkeletonFormatter].
private const val YEAR_SKELETON = "yMMMd"
private const val MONTH_SKELETON = "MMMd"
private const val YEAR_NO_DAY_SKELETON = "yMMM"
/**
* Per-thread cached [SimpleDateFormat] keyed off the current default [Locale].
*
* `SimpleDateFormat` is mutable and not thread-safe, and these formatters are
* read from both the UI thread (composition) and background coroutines
* (e.g. `LocalCache.justVerify` logging failed event verifications). A bare
* `var` shared across threads would race on the formatter's internal Calendar.
* Using `ThreadLocal` gives each thread its own instance — no locks, no
* allocation per call, and we rebuild lazily on locale change.
*/
private class LocaleAwareFormatter(
private val skeleton: String,
) {
private val cache = ThreadLocal<Pair<Locale, SimpleDateFormat>>()
fun get(): SimpleDateFormat {
val current = Locale.getDefault()
val cached = cache.get()
if (cached != null && cached.first == current) return cached.second
val fresh = SimpleDateFormat(DateFormat.getBestDateTimePattern(current, skeleton), current)
cache.set(current to fresh)
return fresh
}
}
private val yearFormatter = LocaleAwareFormatter(YEAR_SKELETON)
private val monthFormatter = LocaleAwareFormatter(MONTH_SKELETON)
private val yearNoDayFormatter = LocaleAwareFormatter(YEAR_NO_DAY_SKELETON)
private val yearFormatter = DateSkeletonFormatter(YEAR_SKELETON)
private val monthFormatter = DateSkeletonFormatter(MONTH_SKELETON)
private val yearNoDayFormatter = DateSkeletonFormatter(YEAR_NO_DAY_SKELETON)
/**
* The handful of unit labels the relative formatters splice into their output.
*
* Resolved once in composition so the formatters themselves can stay ordinary
* functions. [com.vitorpamplona.amethyst.ui.note.elements.TimeAgo] builds its text
* functions. [com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgo] builds its text
* inside a `derivedStateOf`, which is not a composable scope, so a @Composable
* formatter could not be called from there at all.
*/
@@ -133,8 +101,8 @@ fun timeAgoWith(
val timeDifference = TimeUtils.now() - time
return when {
timeDifference > TimeUtils.ONE_YEAR -> prefix + yearFormatter.get().format(time * 1000)
timeDifference > TimeUtils.ONE_MONTH -> prefix + monthFormatter.get().format(time * 1000)
timeDifference > TimeUtils.ONE_YEAR -> prefix + yearFormatter.format(time * 1000)
timeDifference > TimeUtils.ONE_MONTH -> prefix + monthFormatter.format(time * 1000)
timeDifference > TimeUtils.ONE_DAY -> prefix + (timeDifference / TimeUtils.ONE_DAY).toString() + labels.days
timeDifference > TimeUtils.ONE_HOUR -> prefix + (timeDifference / TimeUtils.ONE_HOUR).toString() + labels.hours
timeDifference > TimeUtils.ONE_MINUTE -> prefix + (timeDifference / TimeUtils.ONE_MINUTE).toString() + labels.minutes
@@ -142,10 +110,13 @@ fun timeAgoWith(
}
}
/** Plain-function core of [timeAbsolute], callable outside composition. */
/**
* Plain-function core of [timeAbsolute], callable outside composition. [timeOfDay] comes
* from [rememberTimeOfDayFormatter].
*/
fun timeAbsoluteWith(
time: Long?,
context: Context,
timeOfDay: (epochMillis: Long) -> String,
never: String,
prefix: String = " • ",
): String {
@@ -153,43 +124,39 @@ fun timeAbsoluteWith(
if (time == 0L) return prefix + never
val timeMs = time * 1000
val now = Calendar.getInstance()
val then = Calendar.getInstance().apply { timeInMillis = timeMs }
val now = calendarYearAndDay(TimeUtils.nowMillis())
val then = calendarYearAndDay(timeMs)
val sameYear = now.get(Calendar.YEAR) == then.get(Calendar.YEAR)
val sameDay = sameYear && now.get(Calendar.DAY_OF_YEAR) == then.get(Calendar.DAY_OF_YEAR)
val sameYear = now / 1000 == then / 1000
val sameDay = now == then
val timeOfDay = DateFormat.getTimeFormat(context).format(Date(timeMs))
val timeOfDay = timeOfDay(timeMs)
return when {
sameDay -> prefix + timeOfDay
sameYear -> prefix + monthFormatter.get().format(timeMs) + ", " + timeOfDay
else -> prefix + yearFormatter.get().format(timeMs)
sameYear -> prefix + monthFormatter.format(timeMs) + ", " + timeOfDay
else -> prefix + yearFormatter.format(timeMs)
}
}
/**
* Formats a Unix timestamp (seconds) as an absolute date/time string, picking the
* granularity from how far away the timestamp is:
* - same day → time only (locale + system 12/24-hr aware via [DateFormat.getTimeFormat])
* - same day → time only (locale + system 12/24-hr aware via [rememberTimeOfDayFormatter])
* - same year → "Jan 5, 14:32" / "5 Jan 14:32" / "Jan 5, 2:32 PM" (locale + system aware)
* - older → "Jan 5, 2024" / "5 Jan 2024" (locale aware)
*
* Used by [com.vitorpamplona.amethyst.ui.note.elements.TimeAgo] when the user
* Used by [com.vitorpamplona.amethyst.commons.ui.note.elements.TimeAgo] when the user
* taps the relative timestamp to reveal the absolute one.
*/
@Composable
fun timeAbsolute(
time: Long?,
context: Context,
prefix: String = " • ",
): String = timeAbsoluteWith(time, context, stringRes(Res.string.never), prefix)
): String = timeAbsoluteWith(time, rememberTimeOfDayFormatter(), stringRes(Res.string.never), prefix)
@Composable
fun timeAbsoluteNoDot(
time: Long?,
context: Context,
): String = timeAbsolute(time, context, prefix = "")
fun timeAbsoluteNoDot(time: Long?): String = timeAbsolute(time, prefix = "")
@Composable
fun timeAgo(
@@ -210,11 +177,11 @@ fun timeAgoNoDot(time: Long?): String {
return when {
timeDifference > TimeUtils.ONE_YEAR -> {
yearFormatter.get().format(time * 1000)
yearFormatter.format(time * 1000)
}
timeDifference > TimeUtils.ONE_MONTH -> {
monthFormatter.get().format(time * 1000)
monthFormatter.format(time * 1000)
}
timeDifference > TimeUtils.ONE_DAY -> {
@@ -244,11 +211,11 @@ fun timeAgoNoDotNoDay(time: Long?): String {
return when {
timeDifference > TimeUtils.ONE_YEAR -> {
yearNoDayFormatter.get().format(time * 1000)
yearNoDayFormatter.format(time * 1000)
}
timeDifference > TimeUtils.ONE_MONTH -> {
monthFormatter.get().format(time * 1000)
monthFormatter.format(time * 1000)
}
timeDifference > TimeUtils.ONE_DAY -> {
@@ -278,11 +245,11 @@ fun timeAheadNoDot(time: Long?): String {
return when {
timeDifference > TimeUtils.ONE_YEAR -> {
yearFormatter.get().format(time * 1000)
yearFormatter.format(time * 1000)
}
timeDifference > TimeUtils.ONE_MONTH -> {
monthFormatter.get().format(time * 1000)
monthFormatter.format(time * 1000)
}
timeDifference > TimeUtils.ONE_DAY -> {
@@ -314,9 +281,9 @@ fun dateFormatter(
val timeDifference = TimeUtils.now() - time
return if (timeDifference > TimeUtils.ONE_YEAR) {
yearFormatter.get().format(time * 1000)
yearFormatter.format(time * 1000)
} else if (timeDifference > TimeUtils.ONE_DAY) {
monthFormatter.get().format(time * 1000)
monthFormatter.format(time * 1000)
} else {
today
}
@@ -394,28 +361,19 @@ fun lastSeenSentence(time: Long?): String {
}
}
val dateText = yearFormatter.get().format(time * 1000)
val dateText = yearFormatter.format(time * 1000)
return stringRes(Res.string.last_seen_on_date, dateText, durationText)
}
/**
* "5 min. ago" style on platforms that have an abbreviated relative-span formatter (Android's
* DateUtils), the compact relative form ("5m") elsewhere.
*/
fun timeAgoShort(
mills: Long?,
stringForNow: String,
time: Long?,
labels: TimeAgoLabels,
): String {
if (mills == null) return " "
var humanReadable =
DateUtils
.getRelativeTimeSpanString(
mills * 1000,
System.currentTimeMillis(),
DateUtils.MINUTE_IN_MILLIS,
DateUtils.FORMAT_ABBREV_ALL,
).toString()
if (humanReadable.startsWith("In") || humanReadable.startsWith("0")) {
humanReadable = stringForNow
}
return humanReadable
if (time == null) return " "
return relativeTimeSpanShortOrNull(time * 1000, TimeUtils.nowMillis(), labels.now) ?: timeAgoWith(time, labels, prefix = "")
}
@@ -18,7 +18,7 @@
* 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.ui.note.elements
package com.vitorpamplona.amethyst.commons.ui.note.elements
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -18,7 +18,7 @@
* 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.ui.note.elements
package com.vitorpamplona.amethyst.commons.ui.note.elements
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -33,18 +33,15 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.now
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.note.rememberTimeAgoLabels
import com.vitorpamplona.amethyst.commons.ui.note.rememberTimeOfDayFormatter
import com.vitorpamplona.amethyst.commons.ui.note.timeAbsoluteWith
import com.vitorpamplona.amethyst.commons.ui.note.timeAgoShort
import com.vitorpamplona.amethyst.commons.ui.note.timeAgoWith
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.note.rememberTimeAgoLabels
import com.vitorpamplona.amethyst.ui.note.timeAbsoluteWith
import com.vitorpamplona.amethyst.ui.note.timeAgoShort
import com.vitorpamplona.amethyst.ui.note.timeAgoWith
/**
* Which relative-time format to use when the timestamp is *not* toggled to absolute.
@@ -94,21 +91,20 @@ fun ToggleableTimeAgoText(
// chat timestamp opening the relay/delivery dialog) receives the tap instead.
toggleable: Boolean = true,
) {
val context = LocalContext.current
val timeOfDay = rememberTimeOfDayFormatter()
val nowState = LocalNowSeconds.current
val nowStr = stringRes(id = Res.string.now)
val labels = rememberTimeAgoLabels()
val interactionSource = remember { MutableInteractionSource() }
var showAbsolute by remember(timestamp) { mutableStateOf(false) }
val text by
remember(timestamp, context, style, nowState, nowStr, labels) {
remember(timestamp, timeOfDay, style, nowState, labels) {
derivedStateOf {
if (showAbsolute) {
when (style) {
TimeAgoStyle.Dotted -> timeAbsoluteWith(timestamp, context, labels.never)
TimeAgoStyle.DottedTight -> timeAbsoluteWith(timestamp, context, labels.never).trimStart()
TimeAgoStyle.Short -> timeAbsoluteWith(timestamp, context, labels.never, prefix = "")
TimeAgoStyle.Dotted -> timeAbsoluteWith(timestamp, timeOfDay, labels.never)
TimeAgoStyle.DottedTight -> timeAbsoluteWith(timestamp, timeOfDay, labels.never).trimStart()
TimeAgoStyle.Short -> timeAbsoluteWith(timestamp, timeOfDay, labels.never, prefix = "")
}
} else {
// Read nowState only when displaying a relative time, so an item
@@ -117,7 +113,7 @@ fun ToggleableTimeAgoText(
when (style) {
TimeAgoStyle.Dotted -> timeAgoWith(timestamp, labels)
TimeAgoStyle.DottedTight -> timeAgoWith(timestamp, labels).trimStart()
TimeAgoStyle.Short -> timeAgoShort(timestamp, nowStr)
TimeAgoStyle.Short -> timeAgoShort(timestamp, labels)
}
}
}
@@ -0,0 +1,149 @@
/*
* 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.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.Density
import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideAppIcons
import com.vitorpamplona.amethyst.commons.model.AccentColorType
import com.vitorpamplona.amethyst.commons.model.FontFamilyType
import com.vitorpamplona.amethyst.commons.model.FontSizeType
import com.vitorpamplona.amethyst.commons.model.ThemeType
import com.vitorpamplona.amethyst.commons.ui.components.LocalAnimationsEnabled
import com.vitorpamplona.amethyst.commons.ui.components.LocalProfilePictureCache
import com.vitorpamplona.amethyst.commons.ui.screen.DisplaySettings
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
// The accent color (primary/secondary/tertiary) is user-selectable in Settings -> Accent Color.
// Purple keeps the original Amethyst look (purple primary + teal secondary). Every other accent
// uses its single hue across primary and secondary for a cohesive single-color theme.
private fun accentPrimary(
accent: AccentColorType,
dark: Boolean,
): Color =
when (accent) {
AccentColorType.PURPLE -> if (dark) Purple200 else Purple500
AccentColorType.BLUE -> if (dark) AccentBlueDark else AccentBlueLight
AccentColorType.GREEN -> if (dark) AccentGreenDark else AccentGreenLight
AccentColorType.ORANGE -> if (dark) AccentOrangeDark else AccentOrangeLight
AccentColorType.RED -> if (dark) AccentRedDark else AccentRedLight
AccentColorType.PINK -> if (dark) AccentPinkDark else AccentPinkLight
}
private fun accentSecondary(
accent: AccentColorType,
dark: Boolean,
): Color = if (accent == AccentColorType.PURPLE) Teal200 else accentPrimary(accent, dark)
// Representative colour for an accent option, used by the Settings accent-picker swatches — the
// same primary the theme would apply for the given light/dark mode, so the swatch previews the
// real result.
fun AccentColorType.previewColor(dark: Boolean): Color = accentPrimary(this, dark)
fun amethystDarkColors(accent: AccentColorType): ColorScheme =
amethystDarkColorScheme(
primary = accentPrimary(accent, dark = true),
secondary = accentSecondary(accent, dark = true),
inversePrimary = accentPrimary(accent, dark = false),
)
fun amethystLightColors(accent: AccentColorType): ColorScheme =
amethystLightColorScheme(
primary = accentPrimary(accent, dark = false),
secondary = accentSecondary(accent, dark = false),
inversePrimary = accentPrimary(accent, dark = true),
)
/** Whether [prefTheme] resolves to dark, following the system for anything but an explicit choice. */
@Composable
fun isDarkTheme(prefTheme: ThemeType): Boolean =
when (prefTheme) {
ThemeType.DARK -> true
ThemeType.LIGHT -> false
else -> isSystemInDarkTheme()
}
/**
* Amethyst's Material theme and the app-wide composition locals, for any front end: the
* accent-derived colour scheme, the chosen font family and size, the display settings, and the
* icon font. Platform chrome (Android's system bars) is the caller's to apply.
*
* [profilePictureCache] is true where the image loader registers the avatar thumbnail cache.
*/
@Composable
fun AmethystMaterialTheme(
darkTheme: Boolean,
accentColor: AccentColorType = AccentColorType.PURPLE,
fontFamily: FontFamilyType = FontFamilyType.SYSTEM,
fontSize: FontSizeType = FontSizeType.NORMAL,
displaySettings: DisplaySettings = DisplaySettings(),
profilePictureCache: Boolean = false,
colors: ColorScheme = remember(darkTheme, accentColor) { if (darkTheme) amethystDarkColors(accentColor) else amethystLightColors(accentColor) },
content: @Composable () -> Unit,
) {
val resolvedFontFamily = remember(fontFamily) { fontFamily.toFontFamily() }
val typography = remember(fontFamily) { Typography.withFontFamily(resolvedFontFamily) }
val density = LocalDensity.current
val scaledDensity =
remember(density, fontSize) {
Density(density.density, density.fontScale * fontSize.scale)
}
MaterialTheme(
colorScheme = colors,
typography = typography,
shapes = Shapes,
content = {
ProvideAppIcons {
CompositionLocalProvider(
LocalDensity provides scaledDensity,
LocalProfilePictureCache provides profilePictureCache,
LocalDisplaySettings provides displaySettings,
// Performance mode turns decorative animations (crossfades) off app-wide.
LocalAnimationsEnabled provides !displaySettings.performanceMode,
LocalTextStyle provides LocalTextStyle.current.merge(TextStyle(fontFamily = resolvedFontFamily)),
content = content,
)
}
},
)
}
// Maps the user-selected font preference to a Compose [FontFamily].
// SYSTEM returns null so the platform default is used unchanged.
fun FontFamilyType.toFontFamily(): FontFamily? =
when (this) {
FontFamilyType.SYSTEM -> null
FontFamilyType.SANS_SERIF -> FontFamily.SansSerif
FontFamilyType.SERIF -> FontFamily.Serif
FontFamilyType.MONOSPACE -> FontFamily.Monospace
}
@@ -0,0 +1,95 @@
/*
* 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.note
import androidx.compose.runtime.Composable
import platform.Foundation.NSCalendar
import platform.Foundation.NSCalendarUnitDay
import platform.Foundation.NSCalendarUnitYear
import platform.Foundation.NSDate
import platform.Foundation.NSDateFormatter
import platform.Foundation.NSDateFormatterNoStyle
import platform.Foundation.NSDateFormatterShortStyle
import platform.Foundation.NSLocale
import platform.Foundation.currentLocale
import platform.Foundation.dateWithTimeIntervalSince1970
import platform.Foundation.localeIdentifier
import kotlin.concurrent.Volatile
private fun dateOf(epochMillis: Long) = NSDate.dateWithTimeIntervalSince1970(epochMillis / 1000.0)
/**
* NSDateFormatter is thread-safe for formatting on iOS 7+; the template picks the locale's order.
* Rebuilt when the current locale changes, as on Android. The time zone is the one in effect when
* it was built: iOS caches the system zone until `NSTimeZone.resetSystemTimeZone()`, so keying
* on it would cost two lookups per call and still not notice a change.
*/
actual class DateSkeletonFormatter actual constructor(
private val skeleton: String,
) {
private class Cached(
val key: String,
val formatter: NSDateFormatter,
)
@Volatile private var cached: Cached? = null
actual fun format(epochMillis: Long): String {
val locale = NSLocale.currentLocale
val key = locale.localeIdentifier
val formatter =
cached?.takeIf { it.key == key }?.formatter
?: NSDateFormatter()
.apply {
this.locale = locale
setLocalizedDateFormatFromTemplate(skeleton)
}.also { cached = Cached(key, it) }
return formatter.stringFromDate(dateOf(epochMillis))
}
}
actual fun calendarYearAndDay(epochMillis: Long): Int {
val calendar = NSCalendar.currentCalendar
val date = dateOf(epochMillis)
val year = calendar.component(NSCalendarUnitYear, fromDate = date).toInt()
val day = calendar.ordinalityOfUnit(NSCalendarUnitDay, inUnit = NSCalendarUnitYear, forDate = date).toInt()
return year * 1000 + day
}
// NSDateFormatter is costly to build, so every feed item shares this one instead of each
// composing its own. It follows the system 12/24-hour setting (the short time style does).
private val timeOfDayFormatter by lazy {
NSDateFormatter().apply {
dateStyle = NSDateFormatterNoStyle
timeStyle = NSDateFormatterShortStyle
}
}
private val timeOfDay: (Long) -> String = { epochMillis -> timeOfDayFormatter.stringFromDate(dateOf(epochMillis)) }
@Composable
actual fun rememberTimeOfDayFormatter(): (epochMillis: Long) -> String = timeOfDay
actual fun relativeTimeSpanShortOrNull(
epochMillis: Long,
nowMillis: Long,
nowLabel: String,
): String? = null
@@ -0,0 +1,104 @@
/*
* 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.service.pdf
import coil3.disk.DiskCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.coroutines.executeAsync
import java.io.IOException
object PdfFetcher {
/**
* Returns a snapshot of the cached PDF for [url], downloading it if necessary. The caller is
* responsible for closing the returned snapshot; while it's open the cache entry cannot be
* evicted, so the underlying file stays valid for `PdfRenderer`.
*
* Pass the app's Coil disk cache so PDFs share the same LRU eviction and disk budget as
* images. It is a provider, read on the IO dispatcher: the app builds its cache lazily
* (statvfs, directory setup), and a card composing on a cold start must not do that on main.
*/
suspend fun fetchSnapshot(
url: String,
diskCache: () -> DiskCache,
okHttpClient: (String) -> OkHttpClient,
): DiskCache.Snapshot = withContext(Dispatchers.IO) { openOrDownload(url, diskCache, okHttpClient) }
/** The fetch itself. Callers run it on the IO dispatcher. */
private suspend fun openOrDownload(
url: String,
diskCache: () -> DiskCache,
okHttpClient: (String) -> OkHttpClient,
): DiskCache.Snapshot {
val cache = diskCache()
// Covers the cache-hit fast path too, not just the download below it. openSnapshot()
// contends on the global DiskLruCache lock, which Coil's cleanup pass holds across a
// burst of unlink syscalls (see DeferredDeleteFileSystem) — calling it from a caller
// that happens to be on the main thread stalls the frame for that whole burst, and the
// hit path is exactly the one a feed takes when a PDF card scrolls back into view.
cache.openSnapshot(url)?.let { return it }
val editor = cache.openEditor(url) ?: throw IOException("Unable to open cache editor for $url")
try {
val request =
Request
.Builder()
.url(url)
.get()
.build()
okHttpClient(url).newCall(request).executeAsync().use { response ->
if (!response.isSuccessful) {
throw IOException("PDF download failed: ${response.code}")
}
cache.fileSystem.write(editor.data) {
val bytes = writeAll(response.body.source())
if (bytes == 0L) throw IOException("PDF download failed: empty response body")
}
}
return editor.commitAndOpenSnapshot() ?: throw IOException("Unable to commit cache editor for $url")
} catch (t: Throwable) {
runCatching { editor.abort() }
throw t
}
}
/**
* Fetches like [fetchSnapshot], runs [block] on the snapshot, and closes it, all on the IO
* dispatcher. Prefer this to `fetchSnapshot(...).use { }` from a main-thread caller: there the
* close would run on main, and closing takes the same global DiskLruCache lock as opening.
* The snapshot is created by the last non-suspending step of the fetch and handed straight
* to `use`, inside one `withContext`, so a cancellation cannot land between the two and leak
* it open (which returning through [fetchSnapshot]'s own `withContext` could).
*/
suspend fun <T> useSnapshot(
url: String,
diskCache: () -> DiskCache,
okHttpClient: (String) -> OkHttpClient,
block: (DiskCache.Snapshot) -> T,
): T =
withContext(Dispatchers.IO) {
openOrDownload(url, diskCache, okHttpClient).use(block)
}
}
@@ -0,0 +1,28 @@
/*
* 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.note
import java.util.Calendar
actual fun calendarYearAndDay(epochMillis: Long): Int {
val calendar = Calendar.getInstance().apply { timeInMillis = epochMillis }
return calendar.get(Calendar.YEAR) * 1000 + calendar.get(Calendar.DAY_OF_YEAR)
}
@@ -0,0 +1,87 @@
/*
* 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.note
import androidx.compose.runtime.Composable
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
/**
* [DateTimeFormatter.ofLocalizedPattern] (JDK 19+) resolves the skeleton against the locale's
* CLDR data, the JVM counterpart of Android's `getBestDateTimePattern`. The formatter is
* immutable, so one cached instance serves every thread; it is rebuilt when the default locale
* or time zone changes.
*/
actual class DateSkeletonFormatter actual constructor(
private val skeleton: String,
) {
private class Cached(
val locale: Locale,
val zone: ZoneId,
val formatter: DateTimeFormatter,
)
@Volatile private var cached: Cached? = null
actual fun format(epochMillis: Long): String {
val locale = Locale.getDefault()
val zone = ZoneId.systemDefault()
val current = cached?.takeIf { it.locale == locale && it.zone == zone }
val formatter =
current?.formatter
?: DateTimeFormatter.ofLocalizedPattern(skeleton).withLocale(locale).withZone(zone).also {
cached = Cached(locale, zone, it)
}
return formatter.format(Instant.ofEpochMilli(epochMillis))
}
}
private class CachedTimeOfDay(
val locale: Locale,
val zone: ZoneId,
val formatter: DateTimeFormatter,
)
@Volatile private var cachedTimeOfDay: CachedTimeOfDay? = null
/** One formatter for every item, rebuilt only when the locale or zone changes. */
private val timeOfDay: (Long) -> String = { epochMillis ->
val locale = Locale.getDefault()
val zone = ZoneId.systemDefault()
val formatter =
cachedTimeOfDay?.takeIf { it.locale == locale && it.zone == zone }?.formatter
?: DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale).withZone(zone).also {
cachedTimeOfDay = CachedTimeOfDay(locale, zone, it)
}
formatter.format(Instant.ofEpochMilli(epochMillis))
}
@Composable
actual fun rememberTimeOfDayFormatter(): (epochMillis: Long) -> String = timeOfDay
actual fun relativeTimeSpanShortOrNull(
epochMillis: Long,
nowMillis: Long,
nowLabel: String,
): String? = null
@@ -0,0 +1,92 @@
/*
* 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.chats.ui
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeMe
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeMeTop
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeThemBottom
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeThemMiddle
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Test
// Emoji are written as escapes so the file stays unambiguous on disk (see CLAUDE.md).
class JumboEmojiTest {
private val grin = "\uD83D\uDE00" // U+1F600
private val thumbsUpMedium = "\uD83D\uDC4D\uD83C\uDFFD" // U+1F44D + skin tone U+1F3FD
private val family = "\uD83D\uDC69\u200D\uD83D\uDC67" // woman ZWJ girl: draws as one
private val flagUs = "\uD83C\uDDFA\uD83C\uDDF8" // two regional indicators
private val keycapOne = "1\uFE0F\u20E3" // '1' + VS16 + combining keycap
private val redHeart = "\u2764\uFE0F" // U+2764 + VS16
@Test
fun countsUpToThreeEmoji() {
assertEquals(1, jumboEmojiCount(grin))
assertEquals(2, jumboEmojiCount(grin + redHeart))
assertEquals(3, jumboEmojiCount(grin + grin + grin))
}
@Test
fun moreThanThreeIsNotJumbo() = assertEquals(0, jumboEmojiCount(grin + grin + grin + grin))
@Test
fun anyTextMakesItNotJumbo() {
assertEquals(0, jumboEmojiCount("hi $grin"))
assertEquals(0, jumboEmojiCount("${grin}a"))
}
@Test
fun whitespaceIsIgnored() = assertEquals(2, jumboEmojiCount(" $grin \n $grin "))
@Test
fun emptyOrBlankIsZero() {
assertEquals(0, jumboEmojiCount(""))
assertEquals(0, jumboEmojiCount(" "))
}
@Test
fun modifiersAndZwjSequencesCountAsOne() {
assertEquals(1, jumboEmojiCount(thumbsUpMedium))
assertEquals(1, jumboEmojiCount(family))
assertEquals(1, jumboEmojiCount(redHeart))
}
@Test
fun flagsNeedBothRegionalIndicators() {
assertEquals(1, jumboEmojiCount(flagUs))
assertEquals(0, jumboEmojiCount("\uD83C\uDDFA")) // half a flag
}
@Test
fun keycapsNeedTheirMark() {
assertEquals(1, jumboEmojiCount(keycapOne))
assertEquals(0, jumboEmojiCount("1")) // a bare digit is text
assertEquals(0, jumboEmojiCount("1 $grin"))
}
@Test
fun bubbleShapeFollowsSideAndPosition() {
assertSame(ChatBubbleShapeMe, chatBubbleShapeFor(true, ChatGroupPosition.SINGLE))
assertSame(ChatBubbleShapeMeTop, chatBubbleShapeFor(true, ChatGroupPosition.TOP))
assertSame(ChatBubbleShapeThemMiddle, chatBubbleShapeFor(false, ChatGroupPosition.MIDDLE))
assertSame(ChatBubbleShapeThemBottom, chatBubbleShapeFor(false, ChatGroupPosition.BOTTOM))
}
}
@@ -18,9 +18,8 @@
* 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.ui.layouts
package com.vitorpamplona.amethyst.commons.ui.layouts
import com.vitorpamplona.amethyst.commons.ui.layouts.NavigationStyle
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@@ -86,6 +85,10 @@ class ScreenLayoutTest {
@Test
fun compactWidthKeepsTheBottomBar() = assertStyle(NavigationStyle.BOTTOM_BAR, 599, 900)
// The Medium breakpoint is inclusive: exactly 600dp is no longer Compact.
@Test
fun exactlyAtTheMediumBreakpointRails() = assertStyle(NavigationStyle.NAV_RAIL, 600, 900)
// ---- Notification panel ----
@Test
@@ -0,0 +1,97 @@
/*
* 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.note
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.Locale
import java.util.TimeZone
class DateSkeletonFormatterTest {
private lateinit var savedLocale: Locale
private lateinit var savedZone: TimeZone
@Before
fun pin() {
savedLocale = Locale.getDefault()
savedZone = TimeZone.getDefault()
TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
}
@After
fun restore() {
Locale.setDefault(savedLocale)
TimeZone.setDefault(savedZone)
}
private fun millis(
year: Int,
month: Int,
day: Int,
hour: Int = 12,
) = LocalDateTime
.of(year, month, day, hour, 0)
.atZone(ZoneId.of("UTC"))
.toInstant()
.toEpochMilli()
@Test
fun skeletonFollowsTheLocalesFieldOrder() {
val formatter = DateSkeletonFormatter("yMMMd")
Locale.setDefault(Locale.US)
assertEquals("Jan 5, 2024", formatter.format(millis(2024, 1, 5)))
// Same instance, new locale: the cache must not hand back the US pattern.
Locale.setDefault(Locale.UK)
assertEquals("5 Jan 2024", formatter.format(millis(2024, 1, 5)))
}
@Test
fun monthDayAndYearMonthSkeletons() {
Locale.setDefault(Locale.US)
assertEquals("Jan 5", DateSkeletonFormatter("MMMd").format(millis(2024, 1, 5)))
assertEquals("Jan 2024", DateSkeletonFormatter("yMMM").format(millis(2024, 1, 5)))
}
@Test
fun calendarDayDistinguishesDaysAndYears() {
assertEquals(calendarYearAndDay(millis(2024, 3, 1, hour = 1)), calendarYearAndDay(millis(2024, 3, 1, hour = 23)))
assertTrue(calendarYearAndDay(millis(2024, 3, 1)) != calendarYearAndDay(millis(2024, 3, 2)))
assertEquals(2024, calendarYearAndDay(millis(2024, 12, 31)) / 1000)
assertEquals(366, calendarYearAndDay(millis(2024, 12, 31)) % 1000)
}
@Test
fun absoluteTimeUsesTimeOfDayOnlyForToday() {
Locale.setDefault(Locale.US)
val nowSec = System.currentTimeMillis() / 1000
val timeOfDay: (Long) -> String = { "T" }
assertEquals(" • T", timeAbsoluteWith(nowSec, timeOfDay, "never"))
assertEquals("never", timeAbsoluteWith(0L, timeOfDay, "never", prefix = ""))
// Two years back is always a different year: the date, with no time of day.
val old = nowSec - 2L * 366 * 24 * 3600
assertTrue(!timeAbsoluteWith(old, timeOfDay, "never").contains("T"))
}
}