mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
refactor(commons): move the favorites and browser-history registries out of the app
FavoriteAppsRegistry and BrowserHistoryRegistry were the two stores that
2539e510 repointed at AppPreferenceStores without relocating. That commit's
scope was removing the Context.preferencesDataStore delegates, so these kept
sitting in amethyst/ — by inertia, not because anything platform-specific held
them there.
Nothing in either was Android. The one real tie was that they reached out for
their store:
private val favoriteAppsDataStore: DataStore<Preferences>
get() = Amethyst.instance.appStores.getDataStore("favorite_apps")
Every store that already lives in commons takes its DataStore instead, for the
reason DataStoreSearchHistoryStorage documents: DataStore refuses a second live
instance on a path that already has one, so the caller's holder has to stay the
single registry. Both are classes taking (store, scope) now, and the Android
side does the binding in AppModules from appStores.getDataStore(FILE_NAME) and
applicationIOScope. File names are unchanged, so nothing migrates.
The Context parameter turned out to be dead. It was written once in init() and
then only read as `appContext ?: return` — a has-init-run gate that happened to
be typed Context?, never used as a Context. It is a Boolean now. The other
platform call, System.currentTimeMillis() in record(), is TimeUtils.nowMillis().
ConcurrentHashMap.newKeySet() becomes commons' ConcurrentSet, which exists for
exactly this (lock-striped on JVM, lock-guarded on iOS).
BrowserHistoryRegistry's `hydrated` flag is gone: it was assigned and never
read, unlike FavoriteAppsRegistry's, which gates the removal tombstones.
The point of the move is that the disk lifecycle is now testable — as objects
reaching into a singleton these had no unit coverage at all. 14 new tests, the
ones worth naming being the two sides of the hydration window: a favorite
removed after init() but before the merge lands must not be resurrected by it,
and an add made in that same window must not be dropped by it. Both are driven
on the test scheduler, so the window is a state the test controls rather than
races. Also pinned: nothing is written before init() (without that gate a write
in the window flushes a partial list over the stored one), a corrupt file
hydrates empty, the history cap at 500, and that a blank title on a revisit
does not overwrite the name the user recognises.
Writing those tests surfaced the single-instance rule the hard way: the first
restart tests opened a second DataStore on a live path and read an empty store,
which looks exactly like data loss. Each test session now owns its Job and is
cancelled before the next opens — the same rule production follows by keeping
one instance per file.
Verified: :commons:jvmTest, :commons:verifyKmpPurity,
:commons:compileCommonMainKotlinMetadata, :amethyst:compilePlayDebugKotlin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L
This commit is contained in:
@@ -25,9 +25,7 @@ import android.content.ComponentCallbacks2
|
||||
import android.os.Build
|
||||
import com.vitorpamplona.amethyst.commons.service.http.HttpClientEnvironment
|
||||
import com.vitorpamplona.amethyst.commons.service.http.MediaCallEventListener
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
|
||||
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
|
||||
import com.vitorpamplona.amethyst.service.logging.Logging
|
||||
import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook
|
||||
@@ -143,10 +141,10 @@ class Amethyst : Application() {
|
||||
WorkerThreadPriorityGovernor.start(this)
|
||||
|
||||
// Hydrate the device-local favorite-apps list (main process only; the sandbox never reads it).
|
||||
FavoriteAppsRegistry.init(this)
|
||||
instance.favoriteApps.init()
|
||||
|
||||
// Hydrate the device-local browser visit history (main process only; feeds the omnibox suggestions).
|
||||
BrowserHistoryRegistry.init(this)
|
||||
instance.browserHistory.init()
|
||||
|
||||
// Index device-local captured favicons (main process only; decorates favorites + suggestions).
|
||||
BrowserIconRegistry.init(this)
|
||||
|
||||
@@ -27,8 +27,10 @@ import android.os.SystemClock
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.memory.MemoryCache
|
||||
import com.vitorpamplona.amethyst.commons.browser.BrowserHistoryRegistry
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.DataStoreNostrSignerPermissionStore
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.DataStoreNip46ClientStore
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppsRegistry
|
||||
import com.vitorpamplona.amethyst.commons.model.NoteState
|
||||
import com.vitorpamplona.amethyst.commons.model.UiSettings
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
@@ -887,6 +889,16 @@ class AppModules(
|
||||
// Display + relay info for connected NIP-46 remote-signer clients.
|
||||
val nip46ClientStore by lazy { DataStoreNip46ClientStore(appStores.getDataStore(DataStoreNip46ClientStore.FILE_NAME)) }
|
||||
|
||||
// The device-local favorite-apps list behind the bottom bar, the Favorite Apps grid and the
|
||||
// browser launcher, plus the browser's visit history behind the omnibox suggestions. Both live in
|
||||
// commons and take their store and scope from here — that is the whole of their Android binding.
|
||||
//
|
||||
// Main process only: the keyless `:napplet` sandbox never builds AppModules, so it never builds
|
||||
// these either. One instance each, so DataStore only ever sees one live reader per file.
|
||||
val favoriteApps by lazy { FavoriteAppsRegistry(appStores.getDataStore(FavoriteAppsRegistry.FILE_NAME), applicationIOScope) }
|
||||
|
||||
val browserHistory by lazy { BrowserHistoryRegistry(appStores.getDataStore(BrowserHistoryRegistry.FILE_NAME), applicationIOScope) }
|
||||
|
||||
// Authenticates with relays.
|
||||
val authCoordinator = AuthCoordinator(client, applicationIOScope)
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ object FavoriteAppLauncher {
|
||||
if (nightMask == Configuration.UI_MODE_NIGHT_YES) "DARK" else "LIGHT"
|
||||
}
|
||||
}
|
||||
val isFavorite = FavoriteAppsRegistry.isFavorite("url:$url")
|
||||
val isFavorite = Amethyst.instance.favoriteApps.isFavorite("url:$url")
|
||||
val intent =
|
||||
NappletBrowserActivity
|
||||
.intent(
|
||||
|
||||
@@ -42,9 +42,7 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletIdentityWatch
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletIpc
|
||||
@@ -203,8 +201,9 @@ class NappletBrokerService : Service() {
|
||||
if (msg.what == NappletIpc.MSG_RECORD_HISTORY) {
|
||||
val data = msg.data ?: return true
|
||||
val url = data.getString(NappletIpc.KEY_HISTORY_URL)?.takeIf { it.isNotBlank() } ?: return true
|
||||
BrowserHistoryRegistry.init(applicationContext)
|
||||
BrowserHistoryRegistry.record(url, data.getString(NappletIpc.KEY_HISTORY_TITLE).orEmpty())
|
||||
val history = Amethyst.instance.browserHistory
|
||||
history.init()
|
||||
history.record(url, data.getString(NappletIpc.KEY_HISTORY_TITLE).orEmpty())
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -223,12 +222,13 @@ class NappletBrokerService : Service() {
|
||||
val data = msg.data ?: return true
|
||||
val url = data.getString(NappletIpc.KEY_FAVORITE_URL)?.takeIf { it.isNotBlank() } ?: return true
|
||||
val label = data.getString(NappletIpc.KEY_FAVORITE_LABEL).orEmpty().ifBlank { url }
|
||||
FavoriteAppsRegistry.init(applicationContext)
|
||||
val favorites = Amethyst.instance.favoriteApps
|
||||
favorites.init()
|
||||
val id = "url:$url"
|
||||
if (FavoriteAppsRegistry.isFavorite(id)) {
|
||||
FavoriteAppsRegistry.remove(id)
|
||||
if (favorites.isFavorite(id)) {
|
||||
favorites.remove(id)
|
||||
} else {
|
||||
FavoriteAppsRegistry.add(FavoriteApp.WebApp(url, label, System.currentTimeMillis()))
|
||||
favorites.add(FavoriteApp.WebApp(url, label, System.currentTimeMillis()))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
+3
-2
@@ -43,6 +43,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
|
||||
@@ -60,7 +61,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size25Modifier
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.Size27Modifier
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.onSurface65
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@@ -112,7 +112,8 @@ fun AppBottomBar(
|
||||
// Favorite entries in the unified list resolve to a live favorite for their icon/label and to an
|
||||
// embedded-tab route. Both kinds embed in-process (WebApp → browser surface, NostrApp → napplet
|
||||
// surface), so such a tab swaps in place rather than launching an activity from the bottom row.
|
||||
val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
|
||||
val favorites by Amethyst.instance.favoriteApps.favorites
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
val isKeyboardState by keyboardAsState()
|
||||
if (isKeyboardState == KeyboardState.Closed) {
|
||||
|
||||
+3
-2
@@ -36,9 +36,9 @@ import androidx.navigation.NavDestination
|
||||
import androidx.navigation.NavDestination.Companion.hasRoute
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.model.navigation.BottomBarEntry
|
||||
import com.vitorpamplona.amethyst.commons.model.navigation.Route
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.LoggedInUserPictureDrawer
|
||||
@@ -58,7 +58,8 @@ fun AppNavigationRail(
|
||||
) {
|
||||
val items by accountViewModel.account.settings.syncedSettings.navigation.bottomBarItems
|
||||
.collectAsStateWithLifecycle()
|
||||
val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
|
||||
val favorites by Amethyst.instance.favoriteApps.favorites
|
||||
.collectAsStateWithLifecycle()
|
||||
val favoritesById = remember(favorites) { favorites.associateBy { it.id } }
|
||||
|
||||
val reselectCoordinator = LocalTabReselectCoordinator.current
|
||||
|
||||
+12
-12
@@ -75,6 +75,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.browser.BrowserHistoryEntry
|
||||
import com.vitorpamplona.amethyst.commons.browser.DefaultWebClients
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxSuggestions
|
||||
@@ -101,11 +102,8 @@ import com.vitorpamplona.amethyst.commons.resources.favorite_app_remove
|
||||
import com.vitorpamplona.amethyst.commons.resources.favorite_app_still_loading
|
||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.ArrowBackIcon
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserHistoryEntry
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.PreloadFavoriteNostrApps
|
||||
import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
@@ -154,8 +152,10 @@ private fun BrowserLauncher(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val appStillLoadingStr = stringRes(Res.string.favorite_app_still_loading)
|
||||
val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
|
||||
val history by BrowserHistoryRegistry.history.collectAsStateWithLifecycle()
|
||||
val apps by Amethyst.instance.favoriteApps.favorites
|
||||
.collectAsStateWithLifecycle()
|
||||
val history by Amethyst.instance.browserHistory.history
|
||||
.collectAsStateWithLifecycle()
|
||||
val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle()
|
||||
|
||||
// Fetch favorited nsite/napplet manifests up front so tapping one launches immediately instead of
|
||||
@@ -235,10 +235,10 @@ private fun BrowserLauncher(
|
||||
label: String,
|
||||
) {
|
||||
val id = "url:$url"
|
||||
if (FavoriteAppsRegistry.isFavorite(id)) {
|
||||
FavoriteAppsRegistry.remove(id)
|
||||
if (Amethyst.instance.favoriteApps.isFavorite(id)) {
|
||||
Amethyst.instance.favoriteApps.remove(id)
|
||||
} else {
|
||||
FavoriteAppsRegistry.add(FavoriteApp.WebApp(url, label.ifBlank { OmniboxInput.hostOf(url) ?: url }, System.currentTimeMillis()))
|
||||
Amethyst.instance.favoriteApps.add(FavoriteApp.WebApp(url, label.ifBlank { OmniboxInput.hostOf(url) ?: url }, System.currentTimeMillis()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,7 +290,7 @@ private fun BrowserLauncher(
|
||||
historyUrls = historyUrls,
|
||||
onOpen = { open(it.url) },
|
||||
onToggleFavorite = { toggleFavorite(it.url, it.label) },
|
||||
onRemoveFromHistory = { BrowserHistoryRegistry.remove(it) },
|
||||
onRemoveFromHistory = { Amethyst.instance.browserHistory.remove(it) },
|
||||
modifier = contentModifier,
|
||||
)
|
||||
else -> {
|
||||
@@ -306,11 +306,11 @@ private fun BrowserLauncher(
|
||||
nsites = followedNsites,
|
||||
napplets = followedNapplets,
|
||||
onOpenApp = { FavoriteAppLauncher.launch(context, it, appStillLoadingStr) },
|
||||
onRemoveApp = { FavoriteAppsRegistry.remove(it.id) },
|
||||
onAddApp = { FavoriteAppsRegistry.add(it) },
|
||||
onRemoveApp = { Amethyst.instance.favoriteApps.remove(it.id) },
|
||||
onAddApp = { Amethyst.instance.favoriteApps.add(it) },
|
||||
onOpenUrl = { open(it) },
|
||||
onToggleRecentFavorite = { entry -> toggleFavorite(entry.url, entry.title.ifBlank { entry.host }) },
|
||||
onRemoveRecent = { BrowserHistoryRegistry.remove(it) },
|
||||
onRemoveRecent = { Amethyst.instance.browserHistory.remove(it) },
|
||||
modifier = contentModifier,
|
||||
)
|
||||
}
|
||||
|
||||
+5
-5
@@ -54,7 +54,6 @@ import com.vitorpamplona.amethyst.commons.resources.browser_unsupported
|
||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
|
||||
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -110,7 +109,8 @@ private fun EmbeddedWebAppTab(
|
||||
// can opt one out and it must stick). Only meaningful when Tor is actually available.
|
||||
var torOn by remember { mutableStateOf(proxyAvailable && WebAppNetworkRegistry.useTor(url)) }
|
||||
|
||||
val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
|
||||
val apps by Amethyst.instance.favoriteApps.favorites
|
||||
.collectAsStateWithLifecycle()
|
||||
val isFavorite = remember(apps, currentUrl) { apps.any { it is FavoriteApp.WebApp && it.url == currentUrl } }
|
||||
|
||||
val backgroundColor = MaterialTheme.colorScheme.background.toArgb()
|
||||
@@ -147,10 +147,10 @@ private fun EmbeddedWebAppTab(
|
||||
isFavorite = isFavorite,
|
||||
onFavorite = {
|
||||
val favId = "url:$currentUrl"
|
||||
if (FavoriteAppsRegistry.isFavorite(favId)) {
|
||||
FavoriteAppsRegistry.remove(favId)
|
||||
if (Amethyst.instance.favoriteApps.isFavorite(favId)) {
|
||||
Amethyst.instance.favoriteApps.remove(favId)
|
||||
} else {
|
||||
FavoriteAppsRegistry.add(FavoriteApp.WebApp(currentUrl, hostLabel(currentUrl), System.currentTimeMillis()))
|
||||
Amethyst.instance.favoriteApps.add(FavoriteApp.WebApp(currentUrl, hostLabel(currentUrl), System.currentTimeMillis()))
|
||||
}
|
||||
},
|
||||
// NIP-07 grants for a plain web client are keyed per visited origin as `browser:<origin>`
|
||||
|
||||
+4
-2
@@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.model.navigation.favoriteIds
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
|
||||
import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry
|
||||
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -113,7 +113,9 @@ object EmbeddedTabPreloadSweeper {
|
||||
NappletNetworkRegistry.awaitReady()
|
||||
var attempt = 0
|
||||
while (isActive) {
|
||||
val byId = FavoriteAppsRegistry.favorites.value.associateBy { it.id }
|
||||
val byId =
|
||||
Amethyst.instance.favoriteApps.favorites.value
|
||||
.associateBy { it.id }
|
||||
var stillPending = false
|
||||
for (id in favoriteIds) {
|
||||
val app = byId[id] ?: continue
|
||||
|
||||
+5
-4
@@ -26,9 +26,9 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
@@ -60,7 +60,8 @@ private const val MANIFEST_OFFLINE_FALLBACK_MS = 2_000L
|
||||
*/
|
||||
@Composable
|
||||
fun FavoriteAppManifestPreloader(accountViewModel: AccountViewModel) {
|
||||
val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
|
||||
val favorites by Amethyst.instance.favoriteApps.favorites
|
||||
.collectAsStateWithLifecycle()
|
||||
val coordinates =
|
||||
remember(favorites) {
|
||||
favorites.filterIsInstance<FavoriteApp.NostrApp>().map { it.coordinate }
|
||||
@@ -91,7 +92,7 @@ private fun WatchFavoriteManifest(
|
||||
LaunchedEffect(event?.id) {
|
||||
val resolved = event ?: return@LaunchedEffect
|
||||
withContext(Dispatchers.IO) {
|
||||
FavoriteAppsRegistry.cacheManifest(coordinate, resolved.toJson())
|
||||
Amethyst.instance.favoriteApps.cacheManifest(coordinate, resolved.toJson())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +104,7 @@ private fun WatchFavoriteManifest(
|
||||
delay(MANIFEST_OFFLINE_FALLBACK_MS)
|
||||
if (LocalCache.getAddressableNoteIfExists(coordinate)?.event != null) return@LaunchedEffect
|
||||
withContext(Dispatchers.IO) {
|
||||
val cached = FavoriteAppsRegistry.cachedManifest(coordinate) ?: return@withContext
|
||||
val cached = Amethyst.instance.favoriteApps.cachedManifest(coordinate) ?: return@withContext
|
||||
Event.fromJsonOrNull(cached)?.let { LocalCache.justConsume(it, null, false) }
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -59,6 +59,7 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
|
||||
@@ -74,7 +75,6 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.PreloadFavoriteNostrApps
|
||||
import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
@@ -94,7 +94,8 @@ fun FavoriteAppsScreen(
|
||||
) {
|
||||
val appStillLoadingStr = stringRes(Res.string.favorite_app_still_loading)
|
||||
val context = LocalContext.current
|
||||
val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
|
||||
val apps by Amethyst.instance.favoriteApps.favorites
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
// Fetch favorited nsite/napplet manifests up front so a tap launches immediately instead of showing
|
||||
// "isn't loaded yet" until the user happens to visit the nsite/napplet feed.
|
||||
@@ -126,7 +127,7 @@ fun FavoriteAppsScreen(
|
||||
FavoriteAppsGrid(
|
||||
apps = apps,
|
||||
onOpen = { FavoriteAppLauncher.launch(context, it, appStillLoadingStr) },
|
||||
onRemove = { FavoriteAppsRegistry.remove(it.id) },
|
||||
onRemove = { Amethyst.instance.favoriteApps.remove(it.id) },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
|
||||
+5
-4
@@ -27,6 +27,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
@@ -34,7 +35,6 @@ import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.favorite_app_add
|
||||
import com.vitorpamplona.amethyst.commons.resources.favorite_app_remove
|
||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
|
||||
|
||||
/**
|
||||
* A star toggle that pins/unpins an nsite or napplet (a [FavoriteApp.NostrApp]) by its addressable
|
||||
@@ -47,16 +47,17 @@ fun FavoriteToggleButton(
|
||||
label: String,
|
||||
iconUrl: String? = null,
|
||||
) {
|
||||
val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
|
||||
val apps by Amethyst.instance.favoriteApps.favorites
|
||||
.collectAsStateWithLifecycle()
|
||||
val id = "nostr:$coordinate"
|
||||
val isFavorite = remember(apps, id) { apps.any { it.id == id } }
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (isFavorite) {
|
||||
FavoriteAppsRegistry.remove(id)
|
||||
Amethyst.instance.favoriteApps.remove(id)
|
||||
} else {
|
||||
FavoriteAppsRegistry.add(FavoriteApp.NostrApp(coordinate, label, System.currentTimeMillis(), iconUrl))
|
||||
Amethyst.instance.favoriteApps.add(FavoriteApp.NostrApp(coordinate, label, System.currentTimeMillis(), iconUrl))
|
||||
}
|
||||
},
|
||||
) {
|
||||
|
||||
+6
-5
@@ -54,6 +54,7 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.amethyst.commons.model.navigation.Route
|
||||
import com.vitorpamplona.amethyst.commons.model.navigation.favoriteIds
|
||||
@@ -72,7 +73,6 @@ import com.vitorpamplona.amethyst.commons.resources.favorite_notice_uploaded
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
|
||||
import com.vitorpamplona.amethyst.napplethost.HostProfile
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
|
||||
@@ -147,7 +147,8 @@ private fun EmbeddedNostrAppTab(
|
||||
var canGoBack by remember { mutableStateOf(false) }
|
||||
var showAccess by remember { mutableStateOf(false) }
|
||||
|
||||
val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
|
||||
val apps by Amethyst.instance.favoriteApps.favorites
|
||||
.collectAsStateWithLifecycle()
|
||||
val isFavorite = remember(apps, coordinate) { apps.any { it.id == "nostr:$coordinate" } }
|
||||
|
||||
val controller =
|
||||
@@ -182,10 +183,10 @@ private fun EmbeddedNostrAppTab(
|
||||
isFavorite = isFavorite,
|
||||
onFavorite = {
|
||||
val favId = "nostr:$coordinate"
|
||||
if (FavoriteAppsRegistry.isFavorite(favId)) {
|
||||
FavoriteAppsRegistry.remove(favId)
|
||||
if (Amethyst.instance.favoriteApps.isFavorite(favId)) {
|
||||
Amethyst.instance.favoriteApps.remove(favId)
|
||||
} else {
|
||||
FavoriteAppsRegistry.add(FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis()))
|
||||
Amethyst.instance.favoriteApps.add(FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis()))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
+5
-3
@@ -67,6 +67,7 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
@@ -92,7 +93,6 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.Size22Modifier
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarCategories
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.GroupEntryAvatar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.GroupEntryDisplay
|
||||
@@ -498,7 +498,8 @@ private fun PickerChildren(
|
||||
) {
|
||||
when (item) {
|
||||
NavBarItem.BROWSER -> {
|
||||
val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
|
||||
val favorites by Amethyst.instance.favoriteApps.favorites
|
||||
.collectAsStateWithLifecycle()
|
||||
if (favorites.isEmpty()) {
|
||||
EmptyChildHint(Res.string.bottom_bar_settings_no_favorites)
|
||||
} else {
|
||||
@@ -817,7 +818,8 @@ private fun rememberPinnedVisual(
|
||||
PinnedVisual.Glyph(def?.icon ?: MaterialSymbols.Apps, def?.let { stringRes(it.labelRes) } ?: "")
|
||||
}
|
||||
is BottomBarEntry.Favorite -> {
|
||||
val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
|
||||
val favorites by Amethyst.instance.favoriteApps.favorites
|
||||
.collectAsStateWithLifecycle()
|
||||
val app = favorites.firstOrNull { it.id == entry.favoriteId }
|
||||
if (app != null) PinnedVisual.Favorite(app) else PinnedVisual.Glyph(MaterialSymbols.Public, "")
|
||||
}
|
||||
|
||||
+38
-42
@@ -18,36 +18,23 @@
|
||||
* 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.favorites
|
||||
package com.vitorpamplona.amethyst.commons.browser
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The browser-history file, on the app-wide holder rather than a `Context` delegate.
|
||||
* Same path the delegate resolved to, so nothing migrates.
|
||||
*
|
||||
* Main process only: [Amethyst.instance] is deliberately unset in the
|
||||
* `:napplet` sandbox.
|
||||
*/
|
||||
private val browserHistoryDataStore: DataStore<Preferences>
|
||||
get() = Amethyst.instance.appStores.getDataStore("browser_history")
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
/**
|
||||
* One device-local visited site, keyed by full [url]. [visitCount]/[lastVisitedAt] drive frecency ranking
|
||||
@@ -65,39 +52,40 @@ data class BrowserHistoryEntry(
|
||||
/**
|
||||
* The browser's visit history — the data behind the omnibox suggestions, alongside the user's favorites.
|
||||
*
|
||||
* **Only pages that actually loaded land here.** [record] is called from the `:napplet` browser host
|
||||
* (relayed over IPC through `NappletBrokerService`) on a *successful* main-frame page-finish — never from
|
||||
* the address bar as the user types — so misspelled/never-resolved hosts never pollute the list. Bounded
|
||||
* to [MAX_ENTRIES] most-recent entries.
|
||||
* **Only pages that actually loaded land here.** [record] is meant to be called on a *successful*
|
||||
* main-frame page-finish — never from the address bar as the user types — so misspelled/never-resolved
|
||||
* hosts never pollute the list. Bounded to [MAX_ENTRIES] most-recent entries. On Android the call is
|
||||
* relayed from the `:napplet` browser host over IPC through `NappletBrokerService`.
|
||||
*
|
||||
* Lives only in the **main process** (the launcher/omnibox consume it; the keyless `:napplet` sandbox
|
||||
* never reads it). Same shape as [FavoriteAppsRegistry]: an authoritative in-memory [StateFlow] for
|
||||
* synchronous Compose reads, with write-through persistence to a DataStore on a background scope.
|
||||
* Same shape as [com.vitorpamplona.amethyst.commons.favorites.FavoriteAppsRegistry]: an authoritative
|
||||
* in-memory [StateFlow] for synchronous Compose reads, write-through persistence to [store] on [scope],
|
||||
* and the [DataStore] handed in rather than reached for, so the caller's store holder stays the single
|
||||
* registry and nothing here depends on a front end.
|
||||
*
|
||||
* One instance per process. On Android the launcher/omnibox in the **main** process own it; the keyless
|
||||
* `:napplet` sandbox never builds one.
|
||||
*/
|
||||
object BrowserHistoryRegistry {
|
||||
private val KEY = stringPreferencesKey("history")
|
||||
private const val MAX_ENTRIES = 500
|
||||
|
||||
class BrowserHistoryRegistry(
|
||||
private val store: DataStore<Preferences>,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val _history = MutableStateFlow<List<BrowserHistoryEntry>>(emptyList())
|
||||
val history: StateFlow<List<BrowserHistoryEntry>> = _history.asStateFlow()
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
// Gates persistence until init() has been called: writing before hydration has been scheduled
|
||||
// would flush a partial list over the stored one. Set synchronously in init(), so the merge it
|
||||
// launches still persists whatever the session recorded in the meantime.
|
||||
@Volatile private var started = false
|
||||
|
||||
@Volatile private var appContext: Context? = null
|
||||
|
||||
@Volatile private var hydrated = false
|
||||
|
||||
/** Binds the app context and hydrates the on-disk list into [history]. Idempotent. */
|
||||
fun init(context: Context) {
|
||||
if (appContext != null) return
|
||||
val ctx = context.applicationContext
|
||||
appContext = ctx
|
||||
/** Hydrates the on-disk list into [history]. Idempotent. */
|
||||
fun init() {
|
||||
if (started) return
|
||||
started = true
|
||||
scope.launch {
|
||||
val json = browserHistoryDataStore.data.first()[KEY]
|
||||
val json = store.data.first()[KEY]
|
||||
val loaded = if (json != null) decode(json) else emptyList()
|
||||
// Merge disk under anything already recorded this session (session wins, newest-first).
|
||||
update { current -> dedupeNewestFirst(current + loaded) }
|
||||
hydrated = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +98,7 @@ object BrowserHistoryRegistry {
|
||||
title: String,
|
||||
) {
|
||||
val host = OmniboxInput.hostOf(url) ?: url
|
||||
val now = System.currentTimeMillis()
|
||||
val now = TimeUtils.nowMillis()
|
||||
update { current ->
|
||||
val existing = current.firstOrNull { it.url == url }
|
||||
val entry =
|
||||
@@ -146,9 +134,9 @@ object BrowserHistoryRegistry {
|
||||
}
|
||||
|
||||
private fun persist(json: String) {
|
||||
appContext ?: return
|
||||
if (!started) return
|
||||
scope.launch {
|
||||
browserHistoryDataStore.edit { it[KEY] = json }
|
||||
store.edit { it[KEY] = json }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,4 +149,12 @@ object BrowserHistoryRegistry {
|
||||
Log.w("BrowserHistoryRegistry", "Failed to decode history", e)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Same file the `Context.preferencesDataStore("browser_history")` delegate resolved to. */
|
||||
const val FILE_NAME = "browser_history"
|
||||
|
||||
private val KEY = stringPreferencesKey("history")
|
||||
private const val MAX_ENTRIES = 500
|
||||
}
|
||||
}
|
||||
+57
-60
@@ -18,89 +18,77 @@
|
||||
* 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.favorites
|
||||
package com.vitorpamplona.amethyst.commons.favorites
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.amethyst.commons.util.ConcurrentSet
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* The favorite-apps file, on the app-wide holder rather than a `Context` delegate.
|
||||
* Same path the delegate resolved to, so nothing migrates.
|
||||
*
|
||||
* Main process only: [Amethyst.instance] is deliberately unset in the
|
||||
* `:napplet` sandbox.
|
||||
*/
|
||||
private val favoriteAppsDataStore: DataStore<Preferences>
|
||||
get() = Amethyst.instance.appStores.getDataStore("favorite_apps")
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
/**
|
||||
* The user's device-local list of [FavoriteApp]s — the single source of truth shared by the bottom
|
||||
* bar, the Favorite Apps grid, and the browser launcher. Ordered (the user can reorder); de-duplicated
|
||||
* by [FavoriteApp.id].
|
||||
*
|
||||
* Lives only in the **main process** (the launcher/UI consume it); the keyless `:napplet` sandbox never
|
||||
* touches it. An in-memory [StateFlow] is authoritative for the session so Compose can observe it
|
||||
* synchronously, with write-through persistence to a DataStore on a background scope. The list is
|
||||
* stored as a single JSON array under one key (small, bounded, hand-curated data — no need for one key
|
||||
* per entry).
|
||||
* An in-memory [StateFlow] is authoritative for the session so Compose can observe it synchronously,
|
||||
* with write-through persistence to [store] on [scope]. The list is stored as a single JSON array
|
||||
* under one key (small, bounded, hand-curated data — no need for one key per entry).
|
||||
*
|
||||
* Takes its [DataStore] rather than reaching for one, for the same reason
|
||||
* `DataStoreSearchHistoryStorage` does: DataStore refuses a second live instance on a path that
|
||||
* already has one, so the caller's store holder stays the single registry. That is also what keeps
|
||||
* this class off any one front end — the Android app builds it in `AppModules` from
|
||||
* `appStores.getDataStore(FILE_NAME)`, and nothing here knows about `Context` or the app singleton.
|
||||
*
|
||||
* One instance per process. On Android the launcher/UI in the **main** process own it; the keyless
|
||||
* `:napplet` sandbox never builds one.
|
||||
*/
|
||||
object FavoriteAppsRegistry {
|
||||
private val KEY = stringPreferencesKey("favorites")
|
||||
|
||||
// Raw manifest event JSON for each favorited [FavoriteApp.NostrApp], keyed by its addressable
|
||||
// coordinate. Cached so a pinned nsite/napplet resolves instantly on the next cold start — and
|
||||
// offline — instead of waiting on a relay round-trip the way a [FavoriteApp.WebApp]'s URL never
|
||||
// has to. The relay subscription that warms these favorites keeps the cache fresh.
|
||||
private val MANIFESTS_KEY = stringPreferencesKey("manifests")
|
||||
|
||||
class FavoriteAppsRegistry(
|
||||
private val store: DataStore<Preferences>,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val _favorites = MutableStateFlow<List<FavoriteApp>>(emptyList())
|
||||
val favorites: StateFlow<List<FavoriteApp>> = _favorites.asStateFlow()
|
||||
|
||||
private val manifestCache = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
// Gates persistence until init() has been called: writing before hydration has been scheduled
|
||||
// would flush a partial list over the stored one. Set synchronously in init(), so the merge it
|
||||
// launches still persists whatever the session added in the meantime.
|
||||
@Volatile private var started = false
|
||||
|
||||
@Volatile private var appContext: Context? = null
|
||||
|
||||
// Hydration runs async on a background scope, so the user can add/remove before the disk list
|
||||
// merges in. [removedBeforeHydration] tombstones any id removed in that window, so the merge can't
|
||||
// Hydration runs async on [scope], so the user can add/remove before the disk list merges in.
|
||||
// [removedBeforeHydration] tombstones any id removed in that window, so the merge can't
|
||||
// resurrect a just-deleted favorite from disk.
|
||||
@Volatile private var hydrated = false
|
||||
private val removedBeforeHydration = ConcurrentHashMap.newKeySet<String>()
|
||||
private val removedBeforeHydration = ConcurrentSet<String>()
|
||||
|
||||
/** Binds the app context and hydrates the on-disk list into [favorites]. Idempotent. */
|
||||
fun init(context: Context) {
|
||||
if (appContext != null) return
|
||||
val ctx = context.applicationContext
|
||||
appContext = ctx
|
||||
/** Hydrates the on-disk list into [favorites]. Idempotent. */
|
||||
fun init() {
|
||||
if (started) return
|
||||
started = true
|
||||
scope.launch {
|
||||
val prefs = favoriteAppsDataStore.data.first()
|
||||
val prefs = store.data.first()
|
||||
val loaded = prefs[KEY]?.let { decode(it) } ?: emptyList()
|
||||
// Don't clobber adds made in this session before hydration finished, and don't resurrect
|
||||
// anything the user removed in that same window.
|
||||
update { current -> (loaded.filterNot { it.id in removedBeforeHydration } + current).distinctBy { it.id } }
|
||||
update { current -> (loaded.filterNot { removedBeforeHydration.contains(it.id) } + current).distinctBy { it.id } }
|
||||
|
||||
// Same race rules for the manifest cache: a cacheManifest() in this session wins over the
|
||||
// disk copy, and a manifest whose favorite was removed pre-hydration must not come back.
|
||||
val loadedManifests = prefs[MANIFESTS_KEY]?.let { decodeManifests(it) } ?: emptyMap()
|
||||
updateManifests { current -> loadedManifests.filterKeys { "nostr:$it" !in removedBeforeHydration } + current }
|
||||
updateManifests { current -> loadedManifests.filterKeys { !removedBeforeHydration.contains("nostr:$it") } + current }
|
||||
|
||||
hydrated = true
|
||||
removedBeforeHydration.clear()
|
||||
@@ -138,27 +126,23 @@ object FavoriteAppsRegistry {
|
||||
val next = transform(_favorites.value)
|
||||
if (next == _favorites.value) return
|
||||
_favorites.value = next
|
||||
persist(encode(next))
|
||||
persist(KEY, encode(next))
|
||||
}
|
||||
|
||||
private inline fun updateManifests(transform: (Map<String, String>) -> Map<String, String>) {
|
||||
val next = transform(manifestCache.value)
|
||||
if (next == manifestCache.value) return
|
||||
manifestCache.value = next
|
||||
persistManifests(encodeManifests(next))
|
||||
persist(MANIFESTS_KEY, encodeManifests(next))
|
||||
}
|
||||
|
||||
private fun persist(json: String) {
|
||||
appContext ?: return
|
||||
private fun persist(
|
||||
key: Preferences.Key<String>,
|
||||
json: String,
|
||||
) {
|
||||
if (!started) return
|
||||
scope.launch {
|
||||
favoriteAppsDataStore.edit { it[KEY] = json }
|
||||
}
|
||||
}
|
||||
|
||||
private fun persistManifests(json: String) {
|
||||
appContext ?: return
|
||||
scope.launch {
|
||||
favoriteAppsDataStore.edit { it[MANIFESTS_KEY] = json }
|
||||
store.edit { it[key] = json }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,9 +183,6 @@ object FavoriteAppsRegistry {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
private const val TYPE_NOSTR = "nostr"
|
||||
private const val TYPE_URL = "url"
|
||||
|
||||
// --- Manifest cache persistence -------------------------------------------------------------
|
||||
// Stored as a flat list of (coordinate, json) records under one key — same single-key, hand-curated
|
||||
// shape as the favorites list, so we never serialize a raw polymorphic map.
|
||||
@@ -221,4 +202,20 @@ object FavoriteAppsRegistry {
|
||||
Log.w("FavoriteAppsRegistry", "Failed to decode favorite manifests", e)
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Same file the `Context.preferencesDataStore("favorite_apps")` delegate resolved to. */
|
||||
const val FILE_NAME = "favorite_apps"
|
||||
|
||||
private val KEY = stringPreferencesKey("favorites")
|
||||
|
||||
// Raw manifest event JSON for each favorited [FavoriteApp.NostrApp], keyed by its addressable
|
||||
// coordinate. Cached so a pinned nsite/napplet resolves instantly on the next cold start — and
|
||||
// offline — instead of waiting on a relay round-trip the way a [FavoriteApp.WebApp]'s URL never
|
||||
// has to. The relay subscription that warms these favorites keeps the cache fresh.
|
||||
private val MANIFESTS_KEY = stringPreferencesKey("manifests")
|
||||
|
||||
private const val TYPE_NOSTR = "nostr"
|
||||
private const val TYPE_URL = "url"
|
||||
}
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* 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.browser
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The visit history's ranking inputs and its bound, neither of which had coverage while this was an
|
||||
* Android-side `object`. The omnibox ranks on [BrowserHistoryEntry.visitCount] and
|
||||
* [BrowserHistoryEntry.lastVisitedAt], so a bump that does not bump is a silently wrong suggestion
|
||||
* order rather than a crash.
|
||||
*/
|
||||
class BrowserHistoryRegistryTest {
|
||||
@get:Rule
|
||||
val folder = TemporaryFolder()
|
||||
|
||||
private var seq = 0
|
||||
|
||||
private fun newFile() = File(folder.root, "browser_history_${seq++}.preferences_pb")
|
||||
|
||||
private fun store(
|
||||
scope: CoroutineScope,
|
||||
file: File,
|
||||
): DataStore<Preferences> = PreferenceDataStoreFactory.createWithPath(scope = scope, produceFile = { file.toOkioPath() })
|
||||
|
||||
/**
|
||||
* One app "session" over [file]. Each gets its own [Job] so the DataStore it opened is released
|
||||
* when the session is cancelled: DataStore refuses a second live instance on a path that already
|
||||
* has one, which is exactly why production keeps a single instance per file in the store holder.
|
||||
* A restart test that skipped this would read an empty store and look like data loss.
|
||||
*/
|
||||
private fun TestScope.session(file: File): Pair<BrowserHistoryRegistry, CoroutineScope> {
|
||||
val scope = CoroutineScope(coroutineContext + Job())
|
||||
return BrowserHistoryRegistry(store(scope, file), scope) to scope
|
||||
}
|
||||
|
||||
/** A revisit is a bump, not a second row — this is what makes frecency mean anything. */
|
||||
@Test
|
||||
fun revisitingAUrlBumpsTheCountInsteadOfAddingARow() =
|
||||
runTest {
|
||||
val (registry, _) = session(newFile())
|
||||
registry.init()
|
||||
advanceUntilIdle()
|
||||
|
||||
registry.record("https://example.com/a", "A")
|
||||
registry.record("https://example.com/a", "A again")
|
||||
|
||||
assertEquals("one row for one url", 1, registry.history.value.size)
|
||||
assertEquals(
|
||||
"visit count bumped",
|
||||
2,
|
||||
registry.history.value
|
||||
.single()
|
||||
.visitCount,
|
||||
)
|
||||
assertEquals(
|
||||
"title refreshed",
|
||||
"A again",
|
||||
registry.history.value
|
||||
.single()
|
||||
.title,
|
||||
)
|
||||
}
|
||||
|
||||
/** A page that finishes loading with no <title> must not blank out the name the user recognises. */
|
||||
@Test
|
||||
fun aBlankTitleOnARevisitKeepsTheOldOne() =
|
||||
runTest {
|
||||
val (registry, _) = session(newFile())
|
||||
registry.init()
|
||||
advanceUntilIdle()
|
||||
|
||||
registry.record("https://example.com/a", "Real Title")
|
||||
registry.record("https://example.com/a", " ")
|
||||
|
||||
assertEquals(
|
||||
"the blank did not overwrite it",
|
||||
"Real Title",
|
||||
registry.history.value
|
||||
.single()
|
||||
.title,
|
||||
)
|
||||
}
|
||||
|
||||
/** Most recent first, because that is the order the omnibox shows them in. */
|
||||
@Test
|
||||
fun theMostRecentVisitLeads() =
|
||||
runTest {
|
||||
val (registry, _) = session(newFile())
|
||||
registry.init()
|
||||
advanceUntilIdle()
|
||||
|
||||
registry.record("https://first.example", "First")
|
||||
registry.record("https://second.example", "Second")
|
||||
|
||||
assertEquals(
|
||||
"newest at the front",
|
||||
listOf("https://second.example", "https://first.example"),
|
||||
registry.history.value.map { it.url },
|
||||
)
|
||||
}
|
||||
|
||||
/** The bound is what stops an unbounded JSON blob being rewritten on every page load. */
|
||||
@Test
|
||||
fun historyIsCappedAtFiveHundredEntries() =
|
||||
runTest {
|
||||
val (registry, _) = session(newFile())
|
||||
registry.init()
|
||||
advanceUntilIdle()
|
||||
|
||||
repeat(505) { registry.record("https://example.com/page$it", "Page $it") }
|
||||
|
||||
assertEquals("capped", 500, registry.history.value.size)
|
||||
assertEquals(
|
||||
"and it is the oldest that fell off",
|
||||
"https://example.com/page504",
|
||||
registry.history.value
|
||||
.first()
|
||||
.url,
|
||||
)
|
||||
assertTrue("page0 is gone", registry.history.value.none { it.url == "https://example.com/page0" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun historySurvivesARestart() =
|
||||
runTest {
|
||||
val file = newFile()
|
||||
|
||||
val (first, firstScope) = session(file)
|
||||
first.init()
|
||||
advanceUntilIdle()
|
||||
first.record("https://example.com/a", "A")
|
||||
advanceUntilIdle()
|
||||
firstScope.cancel()
|
||||
|
||||
val (second, _) = session(file)
|
||||
second.init()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("hydrated from disk", listOf("https://example.com/a"), second.history.value.map { it.url })
|
||||
assertEquals(
|
||||
"with its count",
|
||||
1,
|
||||
second.history.value
|
||||
.single()
|
||||
.visitCount,
|
||||
)
|
||||
}
|
||||
|
||||
/** Clearing is a privacy action: it has to reach disk, not just the in-memory flow. */
|
||||
@Test
|
||||
fun clearingEmptiesTheStoredHistoryToo() =
|
||||
runTest {
|
||||
val file = newFile()
|
||||
|
||||
val (first, firstScope) = session(file)
|
||||
first.init()
|
||||
advanceUntilIdle()
|
||||
first.record("https://example.com/a", "A")
|
||||
advanceUntilIdle()
|
||||
first.clear()
|
||||
advanceUntilIdle()
|
||||
firstScope.cancel()
|
||||
|
||||
val (second, _) = session(file)
|
||||
second.init()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue("nothing came back", second.history.value.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removingOneUrlLeavesTheRest() =
|
||||
runTest {
|
||||
val (registry, _) = session(newFile())
|
||||
registry.init()
|
||||
advanceUntilIdle()
|
||||
|
||||
registry.record("https://keep.example", "Keep")
|
||||
registry.record("https://drop.example", "Drop")
|
||||
registry.remove("https://drop.example")
|
||||
|
||||
assertEquals("only the one", listOf("https://keep.example"), registry.history.value.map { it.url })
|
||||
}
|
||||
}
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
* 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.favorites
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The registry's disk lifecycle, which had no coverage while it was an Android-side `object` reaching
|
||||
* into `Amethyst.instance` for its store. Taking the [DataStore] as a constructor argument is what
|
||||
* makes these reachable.
|
||||
*
|
||||
* Every test drives the registry on the test scheduler, so "hydration has not finished yet" is a state
|
||||
* the test controls rather than races: [advanceUntilIdle] is the only thing that lets the coroutine
|
||||
* [FavoriteAppsRegistry.init] launches actually run.
|
||||
*/
|
||||
class FavoriteAppsRegistryTest {
|
||||
@get:Rule
|
||||
val folder = TemporaryFolder()
|
||||
|
||||
private var seq = 0
|
||||
|
||||
private fun newFile() = File(folder.root, "favorite_apps_${seq++}.preferences_pb")
|
||||
|
||||
private fun store(
|
||||
scope: CoroutineScope,
|
||||
file: File,
|
||||
): DataStore<Preferences> = PreferenceDataStoreFactory.createWithPath(scope = scope, produceFile = { file.toOkioPath() })
|
||||
|
||||
/**
|
||||
* One app "session" over [file]. Each gets its own [Job] so the DataStore it opened is released
|
||||
* when the session is cancelled: DataStore refuses a second live instance on a path that already
|
||||
* has one, which is exactly why production keeps a single instance per file in the store holder.
|
||||
* A restart test that skipped this would read an empty store and look like data loss.
|
||||
*/
|
||||
private fun TestScope.session(file: File): Pair<FavoriteAppsRegistry, CoroutineScope> {
|
||||
val scope = CoroutineScope(coroutineContext + Job())
|
||||
return FavoriteAppsRegistry(store(scope, file), scope) to scope
|
||||
}
|
||||
|
||||
private fun web(
|
||||
url: String,
|
||||
label: String = url,
|
||||
) = FavoriteApp.WebApp(url, label, addedAt = 1L)
|
||||
|
||||
/** What the user actually notices: a favorite added in one session is there in the next. */
|
||||
@Test
|
||||
fun aFavoriteSurvivesARestart() =
|
||||
runTest {
|
||||
val file = newFile()
|
||||
|
||||
val (first, firstScope) = session(file)
|
||||
first.init()
|
||||
advanceUntilIdle()
|
||||
first.add(web("https://example.com"))
|
||||
advanceUntilIdle()
|
||||
firstScope.cancel()
|
||||
|
||||
val (second, _) = session(file)
|
||||
second.init()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(
|
||||
"the favorite written by the first session is what the second one hydrates",
|
||||
listOf("url:https://example.com"),
|
||||
second.favorites.value.map { it.id },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The tombstone. Removing between init() and the merge landing must win, or the disk copy
|
||||
* resurrects a favorite the user just deleted — and then persists it again.
|
||||
*/
|
||||
@Test
|
||||
fun hydrationDoesNotResurrectAFavoriteRemovedBeforeItFinished() =
|
||||
runTest {
|
||||
val file = newFile()
|
||||
|
||||
val (seeded, seededScope) = session(file)
|
||||
seeded.init()
|
||||
advanceUntilIdle()
|
||||
seeded.add(web("https://gone.example"))
|
||||
seeded.add(web("https://kept.example"))
|
||||
advanceUntilIdle()
|
||||
seededScope.cancel()
|
||||
|
||||
val (reopened, _) = session(file)
|
||||
reopened.init()
|
||||
// Still inside the window: init() launched the merge but nothing has run it yet.
|
||||
reopened.remove("url:https://gone.example")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(
|
||||
"the removal beat the merge and must survive it",
|
||||
listOf("url:https://kept.example"),
|
||||
reopened.favorites.value.map { it.id },
|
||||
)
|
||||
}
|
||||
|
||||
/** The other side of the same window: an add made before the merge must not be dropped by it. */
|
||||
@Test
|
||||
fun hydrationKeepsAnAddMadeBeforeItFinished() =
|
||||
runTest {
|
||||
val file = newFile()
|
||||
|
||||
val (seeded, seededScope) = session(file)
|
||||
seeded.init()
|
||||
advanceUntilIdle()
|
||||
seeded.add(web("https://ondisk.example"))
|
||||
advanceUntilIdle()
|
||||
seededScope.cancel()
|
||||
|
||||
val (reopened, _) = session(file)
|
||||
reopened.init()
|
||||
reopened.add(web("https://thissession.example"))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(
|
||||
"both the disk copy and the pre-merge add are present",
|
||||
setOf("url:https://ondisk.example", "url:https://thissession.example"),
|
||||
reopened.favorites.value
|
||||
.map { it.id }
|
||||
.toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistence is gated on init(). Without the gate a write in that window flushes a list that has
|
||||
* not merged with disk yet, which is the stored list being replaced by a partial one.
|
||||
*/
|
||||
@Test
|
||||
fun nothingIsWrittenBeforeInit() =
|
||||
runTest {
|
||||
val file = newFile()
|
||||
|
||||
val (registry, writerScope) = session(file)
|
||||
registry.add(web("https://notpersisted.example"))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(
|
||||
"the add is live in memory",
|
||||
listOf("url:https://notpersisted.example"),
|
||||
registry.favorites.value.map { it.id },
|
||||
)
|
||||
writerScope.cancel()
|
||||
advanceUntilIdle()
|
||||
val readerScope = CoroutineScope(coroutineContext + Job())
|
||||
assertNull(
|
||||
"but nothing reached disk, because init() never ran",
|
||||
store(readerScope, file).data.first()[stringPreferencesKey("favorites")],
|
||||
)
|
||||
readerScope.cancel()
|
||||
}
|
||||
|
||||
/** A favorite's cached manifest must not outlive the favorite. */
|
||||
@Test
|
||||
fun removingANostrFavoriteDropsItsCachedManifest() =
|
||||
runTest {
|
||||
val (registry, _) = session(newFile())
|
||||
registry.init()
|
||||
advanceUntilIdle()
|
||||
|
||||
val coordinate = "31990:pubkey:slug"
|
||||
registry.add(FavoriteApp.NostrApp(coordinate, "An App", addedAt = 1L))
|
||||
registry.cacheManifest(coordinate, """{"kind":31990}""")
|
||||
assertEquals("cached while favorited", """{"kind":31990}""", registry.cachedManifest(coordinate))
|
||||
|
||||
registry.remove("nostr:$coordinate")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull("and gone with the favorite", registry.cachedManifest(coordinate))
|
||||
}
|
||||
|
||||
/** Garbage on disk must degrade to an empty list, never take the launcher down with it. */
|
||||
@Test
|
||||
fun aCorruptStoredListHydratesAsEmpty() =
|
||||
runTest {
|
||||
val file = newFile()
|
||||
val seedScope = CoroutineScope(coroutineContext + Job())
|
||||
store(seedScope, file).edit { it[stringPreferencesKey("favorites")] = "}not json[" }
|
||||
advanceUntilIdle()
|
||||
seedScope.cancel()
|
||||
advanceUntilIdle()
|
||||
|
||||
val (registry, _) = session(file)
|
||||
registry.init()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue("decode failed softly", registry.favorites.value.isEmpty())
|
||||
}
|
||||
|
||||
/** Ids are the identity: adding the same app twice is a no-op, not a duplicate row. */
|
||||
@Test
|
||||
fun addingTheSameAppTwiceKeepsOneEntry() =
|
||||
runTest {
|
||||
val (registry, _) = session(newFile())
|
||||
registry.init()
|
||||
advanceUntilIdle()
|
||||
|
||||
registry.add(web("https://example.com", "First"))
|
||||
registry.add(web("https://example.com", "Second"))
|
||||
|
||||
assertEquals("de-duplicated by id", 1, registry.favorites.value.size)
|
||||
assertEquals(
|
||||
"and the first one won",
|
||||
"First",
|
||||
registry.favorites.value
|
||||
.single()
|
||||
.label,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user