mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
refactor(commons): unify the rich-text parser cache across Android + Desktop
Replaces the two forked cached parsers (amethyst CachedRichTextParser on android.util.LruCache + desktop DesktopCachedRichTextParser on ConcurrentLruCache with a naive isMarkdown) with one shared object in commons/jvmAndroid/richtext, built on quartz ConcurrentLruCache and keeping amethyst's CommonMark-aware computeIsMarkdown and content-addressed key (content+tags+callbackUri+authorPubKey). - Add ConcurrentLruCache.trimToSize(maxItems) (+ tests) for the onTrimMemory path. - Repoint all amethyst callers (incl. the markdown unit test) and both desktop callers; delete both forks. Verified: :commons JVM, :desktopApp, :amethyst play debug + unit tests compile. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
This commit is contained in:
@@ -28,6 +28,7 @@ import coil3.disk.DiskCache
|
||||
import coil3.memory.MemoryCache
|
||||
import com.vitorpamplona.amethyst.commons.model.NoteState
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient
|
||||
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
|
||||
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStore
|
||||
import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResolver
|
||||
@@ -51,7 +52,6 @@ import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector
|
||||
import com.vitorpamplona.amethyst.model.torState.TorRelayState
|
||||
import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore
|
||||
import com.vitorpamplona.amethyst.napplet.DataStoreNostrSignerPermissionStore
|
||||
import com.vitorpamplona.amethyst.service.CachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs
|
||||
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
|
||||
import com.vitorpamplona.amethyst.service.cast.CastRegistry
|
||||
|
||||
@@ -35,6 +35,7 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BlossomUriSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.richtext.CashuSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.ClinkOfferSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.ConcordInviteLinkSegment
|
||||
@@ -54,7 +55,6 @@ import com.vitorpamplona.amethyst.commons.ui.richtext.LocalRichTextInteractions
|
||||
import com.vitorpamplona.amethyst.commons.ui.richtext.LocalRichTextSegmentRenderer
|
||||
import com.vitorpamplona.amethyst.commons.ui.richtext.RichTextInteractions
|
||||
import com.vitorpamplona.amethyst.commons.ui.richtext.RichTextSegmentRenderer
|
||||
import com.vitorpamplona.amethyst.service.CachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
|
||||
@@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.commons.richtext.Base64Segment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BechSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BlossomUriSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.richtext.CashuSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.ClinkOfferSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.ConcordInviteLinkSegment
|
||||
@@ -102,7 +103,6 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
|
||||
import com.vitorpamplona.amethyst.service.CachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname
|
||||
|
||||
@@ -35,6 +35,7 @@ import coil3.imageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.SuccessResult
|
||||
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
@@ -44,7 +45,6 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
|
||||
import com.vitorpamplona.amethyst.service.CachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
|
||||
@@ -69,11 +69,11 @@ import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Cashu
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
|
||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.AnimatedBorderTextCornerRadius
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.CachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserContactCardsScore
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.note.creators.previews
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.service.CachedUrlParser
|
||||
import com.vitorpamplona.amethyst.commons.richtext.CachedUrlParser
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
+1
-1
@@ -27,9 +27,9 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextColor
|
||||
import com.vitorpamplona.amethyst.service.CachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import com.vitorpamplona.amethyst.service.CachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
+25
-19
@@ -18,21 +18,27 @@
|
||||
* 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.service
|
||||
package com.vitorpamplona.amethyst.commons.richtext
|
||||
|
||||
import android.util.LruCache
|
||||
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
|
||||
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
|
||||
import com.vitorpamplona.quartz.utils.cache.ConcurrentLruCache
|
||||
|
||||
/**
|
||||
* The shared, cross-platform cache in front of [RichTextParser]. Both Amethyst
|
||||
* Android and Amethyst Desktop render the same parsed [RichTextViewerState] from
|
||||
* one place, so the same content quoted in multiple notes is only parsed once.
|
||||
*
|
||||
* Lives in `jvmAndroid` because it depends on [ConcurrentLruCache] (a JCA-free,
|
||||
* lock-free-read LRU that is not in `commonMain`). iOS/`commonMain` callers use
|
||||
* the uncached [RichTextParser] directly until a KMP cache is available.
|
||||
*/
|
||||
object CachedRichTextParser {
|
||||
// Global across every feed. Sized to hold the active feed's visible + prefetched
|
||||
// (see PrefetchFeedMedia) working set plus a few other feeds' recent entries, so
|
||||
// pre-parsed bodies survive until the render reads them and feed switches don't
|
||||
// thrash. Each entry is one note's parsed segments — typically single-digit KB.
|
||||
private val richTextCache = LruCache<Int, RichTextViewerState>(500)
|
||||
private val isMarkdownCache = LruCache<Int, Boolean>(200)
|
||||
// working set plus a few other feeds' recent entries, so pre-parsed bodies survive
|
||||
// until the render reads them and feed switches don't thrash. Each entry is one
|
||||
// note's parsed segments — typically single-digit KB.
|
||||
private val richTextCache = ConcurrentLruCache<Int, RichTextViewerState>(500)
|
||||
private val isMarkdownCache = ConcurrentLruCache<Int, Boolean>(200)
|
||||
|
||||
private fun hashCodeCache(
|
||||
content: String,
|
||||
@@ -69,7 +75,7 @@ object CachedRichTextParser {
|
||||
tags: ImmutableListOfLists<String>,
|
||||
callbackUri: String? = null,
|
||||
authorPubKey: String? = null,
|
||||
): RichTextViewerState? = richTextCache[hashCodeCache(content, tags, callbackUri, authorPubKey)]
|
||||
): RichTextViewerState? = richTextCache.get(hashCodeCache(content, tags, callbackUri, authorPubKey))
|
||||
|
||||
fun parseText(
|
||||
content: String,
|
||||
@@ -78,13 +84,13 @@ object CachedRichTextParser {
|
||||
authorPubKey: String? = null,
|
||||
): RichTextViewerState {
|
||||
val key = hashCodeCache(content, tags, callbackUri, authorPubKey)
|
||||
val cached = richTextCache[key]
|
||||
val cached = richTextCache.get(key)
|
||||
return if (cached != null) {
|
||||
cached
|
||||
} else {
|
||||
val newUrls = RichTextParser().parseText(content, tags, callbackUri, authorPubKey)
|
||||
richTextCache.put(key, newUrls)
|
||||
newUrls
|
||||
val newState = RichTextParser().parseText(content, tags, callbackUri, authorPubKey)
|
||||
richTextCache.put(key, newState)
|
||||
newState
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +98,7 @@ object CachedRichTextParser {
|
||||
// notes only pays for the scan once. The decision is purely a function of `content`.
|
||||
fun isMarkdown(content: String): Boolean {
|
||||
val key = content.hashCode()
|
||||
isMarkdownCache[key]?.let { return it }
|
||||
isMarkdownCache.get(key)?.let { return it }
|
||||
val result = computeIsMarkdown(content)
|
||||
isMarkdownCache.put(key, result)
|
||||
return result
|
||||
@@ -296,15 +302,15 @@ object CachedRichTextParser {
|
||||
}
|
||||
|
||||
object CachedUrlParser {
|
||||
private val parsedUrlsCache = LruCache<Int, List<String>>(10)
|
||||
private val parsedUrlsCache = ConcurrentLruCache<Int, List<String>>(10)
|
||||
|
||||
fun cachedParseValidUrls(content: String): List<String> = parsedUrlsCache[content.hashCode()]
|
||||
fun cachedParseValidUrls(content: String): List<String>? = parsedUrlsCache.get(content.hashCode())
|
||||
|
||||
fun parseValidUrls(content: String): List<String> {
|
||||
if (content.isEmpty()) return emptyList()
|
||||
|
||||
val key = content.hashCode()
|
||||
val cached = parsedUrlsCache[key]
|
||||
val cached = parsedUrlsCache.get(key)
|
||||
return if (cached != null) {
|
||||
cached
|
||||
} else {
|
||||
-54
@@ -1,54 +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.desktop.service
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
|
||||
import com.vitorpamplona.quartz.utils.cache.ConcurrentLruCache
|
||||
|
||||
object DesktopCachedRichTextParser {
|
||||
private const val MAX_CACHE_SIZE = 50
|
||||
|
||||
// Lock-free get on the feed rich-text render path; the previous access-order
|
||||
// synchronizedMap took a monitor even on reads.
|
||||
private val cache = ConcurrentLruCache<String, RichTextViewerState>(MAX_CACHE_SIZE)
|
||||
|
||||
fun parseText(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
callbackUri: String? = null,
|
||||
): RichTextViewerState {
|
||||
cache.get(content)?.let { return it }
|
||||
val state = RichTextParser().parseText(content, tags, callbackUri)
|
||||
cache.put(content, state)
|
||||
return state
|
||||
}
|
||||
|
||||
fun isMarkdown(content: String): Boolean =
|
||||
content.startsWith("> ") ||
|
||||
content.startsWith("# ") ||
|
||||
content.contains("##") ||
|
||||
content.contains("__") ||
|
||||
content.contains("**") ||
|
||||
content.contains("```") ||
|
||||
content.contains("](")
|
||||
}
|
||||
+2
-2
@@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.richtext.Base64Segment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BechSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.richtext.CashuSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.ImageGalleryParagraph
|
||||
import com.vitorpamplona.amethyst.commons.richtext.ImageSegment
|
||||
@@ -75,7 +76,6 @@ import com.vitorpamplona.amethyst.commons.ui.richtext.LocalRichTextSegmentRender
|
||||
import com.vitorpamplona.amethyst.commons.ui.richtext.RichTextInteractions
|
||||
import com.vitorpamplona.amethyst.commons.ui.richtext.RichTextSegmentRenderer
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.service.DesktopCachedRichTextParser
|
||||
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
|
||||
@@ -112,7 +112,7 @@ fun DesktopRichText(
|
||||
callbacks: RichTextCallbacks = RichTextCallbacks(),
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (DesktopCachedRichTextParser.isMarkdown(content)) {
|
||||
if (CachedRichTextParser.isMarkdown(content)) {
|
||||
RenderMarkdown(
|
||||
content = content,
|
||||
onLinkClick = { url -> handleDesktopLinkClick(url, callbacks) },
|
||||
|
||||
+2
-2
@@ -56,12 +56,12 @@ import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.ReplyContext
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.ReplyToLabel
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.service.DesktopCachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.desktop.ui.components.ToggleableTimeAgoText
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.AnimatedGifImage
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer
|
||||
@@ -293,7 +293,7 @@ fun NoteCard(
|
||||
if (strippedContent.isNotBlank()) {
|
||||
val richState =
|
||||
remember(strippedContent, note.tags) {
|
||||
DesktopCachedRichTextParser.parseText(strippedContent, note.tags)
|
||||
CachedRichTextParser.parseText(strippedContent, note.tags)
|
||||
}
|
||||
DesktopRichText(
|
||||
content = strippedContent,
|
||||
|
||||
+14
@@ -87,5 +87,19 @@ class ConcurrentLruCache<K : Any, V : Any>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evicts the oldest entries until at most [maxItems] remain. Used to shed
|
||||
* memory on pressure (e.g. `onTrimMemory`). A [maxItems] of 0 or less clears
|
||||
* the cache; values above the current size are a no-op.
|
||||
*/
|
||||
fun trimToSize(maxItems: Int) {
|
||||
synchronized(writeLock) {
|
||||
while (order.size > maxItems) {
|
||||
val oldest = order.removeFirstOrNull() ?: break
|
||||
map.remove(oldest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun size(): Int = map.size
|
||||
}
|
||||
|
||||
Vendored
+32
@@ -100,6 +100,38 @@ class ConcurrentLruCacheTest {
|
||||
assertNull(cache.get("a"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trimToSize evicts oldest down to the target`() {
|
||||
val cache = ConcurrentLruCache<String, Int>(10)
|
||||
cache.put("a", 1)
|
||||
cache.put("b", 2)
|
||||
cache.put("c", 3)
|
||||
cache.trimToSize(1)
|
||||
assertEquals(1, cache.size())
|
||||
// Oldest (a, b) evicted; newest (c) kept.
|
||||
assertNull(cache.get("a"))
|
||||
assertNull(cache.get("b"))
|
||||
assertEquals(3, cache.get("c"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trimToSize above current size is a no-op`() {
|
||||
val cache = ConcurrentLruCache<String, Int>(10)
|
||||
cache.put("a", 1)
|
||||
cache.put("b", 2)
|
||||
cache.trimToSize(5)
|
||||
assertEquals(2, cache.size())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trimToSize to zero clears`() {
|
||||
val cache = ConcurrentLruCache<String, Int>(10)
|
||||
cache.put("a", 1)
|
||||
cache.trimToSize(0)
|
||||
assertEquals(0, cache.size())
|
||||
assertNull(cache.get("a"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `size never exceeds capacity under concurrent puts`() {
|
||||
val cap = 100
|
||||
|
||||
Reference in New Issue
Block a user