feat(reads): integrate long-form into deck layout with zoom and reactions
- Wire Article, Editor, Drafts into DeckColumnType + DeckColumnContainer - Add Drafts to sidebar nav, Add Column dialog, and Window menu - Add article navigation (onNavigateToArticle) in SinglePaneLayout - Add NoteActionsRow to ReadsScreen LongFormCards (zaps, reactions, replies) - Add Cmd+/Cmd- zoom in ArticleReaderScreen via fontScale on RenderMarkdown - Add kind 30023 to ProfileSubscription for long-form in user profiles - Add fontScale param to RenderMarkdown using scaled LocalDensity Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+20
-1
@@ -24,8 +24,10 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import androidx.compose.ui.unit.Density
|
||||
import com.halilibo.richtext.commonmark.CommonMarkdownParseOptions
|
||||
import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser
|
||||
import com.halilibo.richtext.markdown.BasicMarkdown
|
||||
@@ -39,6 +41,7 @@ fun RenderMarkdown(
|
||||
content: String,
|
||||
onLinkClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
fontScale: Float = 1.0f,
|
||||
) {
|
||||
val astNode =
|
||||
remember(content) {
|
||||
@@ -57,7 +60,23 @@ fun RenderMarkdown(
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalUriHandler provides uriHandler) {
|
||||
val currentDensity = LocalDensity.current
|
||||
val scaledDensity =
|
||||
remember(fontScale, currentDensity) {
|
||||
if (fontScale == 1.0f) {
|
||||
currentDensity
|
||||
} else {
|
||||
Density(
|
||||
density = currentDensity.density * fontScale,
|
||||
fontScale = currentDensity.fontScale,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalUriHandler provides uriHandler,
|
||||
LocalDensity provides scaledDensity,
|
||||
) {
|
||||
RichText(
|
||||
modifier = modifier,
|
||||
style = RichTextStyle(),
|
||||
|
||||
@@ -379,6 +379,7 @@ fun main() {
|
||||
Item("Messages", onClick = { deckState.addColumn(DeckColumnType.Messages) })
|
||||
Item("Search", onClick = { deckState.addColumn(DeckColumnType.Search) })
|
||||
Item("Reads", onClick = { deckState.addColumn(DeckColumnType.Reads) })
|
||||
Item("Drafts", onClick = { deckState.addColumn(DeckColumnType.Drafts) })
|
||||
Item("Bookmarks", onClick = { deckState.addColumn(DeckColumnType.Bookmarks) })
|
||||
Item("Global Feed", onClick = { deckState.addColumn(DeckColumnType.GlobalFeed) })
|
||||
Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) })
|
||||
|
||||
+5
-1
@@ -104,7 +104,11 @@ fun createUserPostsSubscription(
|
||||
): SubscriptionConfig =
|
||||
SubscriptionConfig(
|
||||
subId = generateSubId("posts-${pubKeyHex.take(8)}"),
|
||||
filters = listOf(FilterBuilders.textNotesFromAuthors(listOf(pubKeyHex), limit = limit)),
|
||||
filters =
|
||||
listOf(
|
||||
FilterBuilders.textNotesFromAuthors(listOf(pubKeyHex), limit = limit),
|
||||
FilterBuilders.longFormFromAuthors(listOf(pubKeyHex), limit = 50),
|
||||
),
|
||||
relays = relays,
|
||||
onEvent = onEvent,
|
||||
onEose = onEose,
|
||||
|
||||
+221
-1
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui
|
||||
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -51,6 +52,13 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.isMetaPressed
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.compose.article.ArticleHeader
|
||||
import com.vitorpamplona.amethyst.commons.compose.article.TableOfContents
|
||||
@@ -65,8 +73,17 @@ import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createReactionsSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createRepliesSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createRepostsSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createZapsSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
@@ -96,9 +113,12 @@ fun ArticleReaderScreen(
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
localCache: DesktopLocalCache,
|
||||
account: AccountState.LoggedIn?,
|
||||
nwcConnection: Nip47WalletConnect.Nip47URINorm? = null,
|
||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
||||
onBack: () -> Unit,
|
||||
onNavigateToProfile: (String) -> Unit = {},
|
||||
onNavigateToThread: (String) -> Unit = {},
|
||||
onZapFeedback: (ZapFeedback) -> Unit = {},
|
||||
) {
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
@@ -113,6 +133,14 @@ fun ArticleReaderScreen(
|
||||
var article by remember(addressTag) { mutableStateOf<LongTextNoteEvent?>(null) }
|
||||
var eoseReceived by remember(addressTag) { mutableStateOf(false) }
|
||||
|
||||
// Zoom level for article text
|
||||
var zoomLevel by remember { mutableStateOf(1.0f) }
|
||||
val focusRequester =
|
||||
remember {
|
||||
androidx.compose.ui.focus
|
||||
.FocusRequester()
|
||||
}
|
||||
|
||||
// Active ToC entry tracking (placeholder — no scroll-position-based tracking yet)
|
||||
var activeTocIndex by remember { mutableStateOf<Int?>(null) }
|
||||
|
||||
@@ -165,6 +193,120 @@ fun ArticleReaderScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Interaction state
|
||||
val articleEventId = article?.id
|
||||
val eventIds = listOfNotNull(articleEventId)
|
||||
|
||||
var zapReceipts by remember { mutableStateOf<List<ZapReceipt>>(emptyList()) }
|
||||
var reactionCount by remember { mutableStateOf(0) }
|
||||
var replyCount by remember { mutableStateOf(0) }
|
||||
var repostCount by remember { mutableStateOf(0) }
|
||||
var bookmarkList by remember { mutableStateOf<BookmarkListEvent?>(null) }
|
||||
var bookmarkedEventIds by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
|
||||
// Subscribe to zaps
|
||||
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isEmpty() || eventIds.isEmpty()) return@rememberSubscription null
|
||||
|
||||
createZapsSubscription(
|
||||
relays = configuredRelays,
|
||||
eventIds = eventIds,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is LnZapEvent) {
|
||||
val receipt = event.toZapReceipt(localCache) ?: return@createZapsSubscription
|
||||
if (zapReceipts.none { it.createdAt == receipt.createdAt && it.senderPubKey == receipt.senderPubKey }) {
|
||||
zapReceipts = zapReceipts + receipt
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Subscribe to reactions
|
||||
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isEmpty() || eventIds.isEmpty()) return@rememberSubscription null
|
||||
|
||||
val reactionIds = mutableSetOf<String>()
|
||||
createReactionsSubscription(
|
||||
relays = configuredRelays,
|
||||
eventIds = eventIds,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is ReactionEvent && reactionIds.add(event.id)) {
|
||||
reactionCount = reactionIds.size
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Subscribe to replies
|
||||
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isEmpty() || eventIds.isEmpty()) return@rememberSubscription null
|
||||
|
||||
val replyIds = mutableSetOf<String>()
|
||||
createRepliesSubscription(
|
||||
relays = configuredRelays,
|
||||
eventIds = eventIds,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (replyIds.add(event.id)) {
|
||||
replyCount = replyIds.size
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Subscribe to reposts
|
||||
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isEmpty() || eventIds.isEmpty()) return@rememberSubscription null
|
||||
|
||||
val repostIds = mutableSetOf<String>()
|
||||
createRepostsSubscription(
|
||||
relays = configuredRelays,
|
||||
eventIds = eventIds,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is RepostEvent && repostIds.add(event.id)) {
|
||||
repostCount = repostIds.size
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Subscribe to bookmark list
|
||||
rememberSubscription(relayStatuses, account, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty() && account != null) {
|
||||
SubscriptionConfig(
|
||||
subId = "article-bookmarks-${account.pubKeyHex.take(8)}",
|
||||
filters =
|
||||
listOf(
|
||||
FilterBuilders.byAuthors(
|
||||
authors = listOf(account.pubKeyHex),
|
||||
kinds = listOf(BookmarkListEvent.KIND),
|
||||
limit = 1,
|
||||
),
|
||||
),
|
||||
relays = configuredRelays,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is BookmarkListEvent) {
|
||||
bookmarkList = event
|
||||
bookmarkedEventIds =
|
||||
event
|
||||
.publicBookmarks()
|
||||
.filterIsInstance<com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark>()
|
||||
.map { it.eventId }
|
||||
.toSet()
|
||||
}
|
||||
},
|
||||
onEose = { _, _ -> },
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Derived data from article
|
||||
val title = article?.title() ?: "Untitled"
|
||||
val content = article?.content ?: ""
|
||||
@@ -189,7 +331,43 @@ fun ArticleReaderScreen(
|
||||
val authorName = authorUser?.toBestDisplayName()
|
||||
val authorPicture = authorUser?.profilePicture()
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.focusRequester(focusRequester)
|
||||
.focusable()
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown && event.isMetaPressed) {
|
||||
when (event.key) {
|
||||
Key.Equals -> {
|
||||
zoomLevel = (zoomLevel + 0.1f).coerceAtMost(2.0f)
|
||||
true
|
||||
}
|
||||
|
||||
Key.Minus -> {
|
||||
zoomLevel = (zoomLevel - 0.1f).coerceAtLeast(0.5f)
|
||||
true
|
||||
}
|
||||
|
||||
Key.Zero -> {
|
||||
zoomLevel = 1.0f
|
||||
true
|
||||
}
|
||||
|
||||
else -> {
|
||||
false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
) {
|
||||
// Top bar: back + bookmark placeholder
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
@@ -210,6 +388,14 @@ fun ArticleReaderScreen(
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
if (zoomLevel != 1.0f) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"${(zoomLevel * 100).toInt()}%",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,6 +484,7 @@ fun ArticleReaderScreen(
|
||||
RenderMarkdown(
|
||||
content = content,
|
||||
onLinkClick = onLinkClick,
|
||||
fontScale = zoomLevel,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
@@ -321,6 +508,39 @@ fun ArticleReaderScreen(
|
||||
|
||||
HorizontalDivider(thickness = 1.dp)
|
||||
|
||||
// Reaction actions
|
||||
val art = article
|
||||
if (art != null && account != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
NoteActionsRow(
|
||||
event = art,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
onReplyClick = { onNavigateToThread(art.id) },
|
||||
onZapFeedback = onZapFeedback,
|
||||
zapCount = zapReceipts.size,
|
||||
zapAmountSats = zapReceipts.sumOf { it.amountSats },
|
||||
zapReceipts = zapReceipts,
|
||||
reactionCount = reactionCount,
|
||||
replyCount = replyCount,
|
||||
repostCount = repostCount,
|
||||
nwcConnection = nwcConnection,
|
||||
isBookmarked = articleEventId in bookmarkedEventIds,
|
||||
bookmarkList = bookmarkList,
|
||||
onBookmarkChanged = { newList ->
|
||||
bookmarkList = newList
|
||||
bookmarkedEventIds =
|
||||
newList
|
||||
.publicBookmarks()
|
||||
.filterIsInstance<com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark>()
|
||||
.map { it.eventId }
|
||||
.toSet()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(48.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -68,6 +69,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createLongFormFeedSubscr
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
@@ -177,8 +179,11 @@ fun ReadsScreen(
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
localCache: DesktopLocalCache,
|
||||
account: AccountState.LoggedIn? = null,
|
||||
nwcConnection: Nip47WalletConnect.Nip47URINorm? = null,
|
||||
onNavigateToProfile: (String) -> Unit = {},
|
||||
onNavigateToArticle: (String) -> Unit = {},
|
||||
onNavigateToThread: (String) -> Unit = {},
|
||||
onZapFeedback: (ZapFeedback) -> Unit = {},
|
||||
) {
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -363,12 +368,27 @@ fun ReadsScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(events, key = { it.id }) { event ->
|
||||
LongFormCard(
|
||||
event = event,
|
||||
localCache = localCache,
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
onClick = { onNavigateToArticle(event.addressTag()) },
|
||||
)
|
||||
Column {
|
||||
LongFormCard(
|
||||
event = event,
|
||||
localCache = localCache,
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
onClick = { onNavigateToArticle(event.addressTag()) },
|
||||
)
|
||||
if (account != null) {
|
||||
NoteActionsRow(
|
||||
event = event,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
onReplyClick = { onNavigateToThread(event.id) },
|
||||
onZapFeedback = onZapFeedback,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -53,6 +53,7 @@ private val COLUMN_OPTIONS =
|
||||
DeckColumnType.Messages,
|
||||
DeckColumnType.Search,
|
||||
DeckColumnType.Reads,
|
||||
DeckColumnType.Drafts,
|
||||
DeckColumnType.Bookmarks,
|
||||
DeckColumnType.GlobalFeed,
|
||||
DeckColumnType.MyProfile,
|
||||
|
||||
+4
-1
@@ -44,9 +44,9 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.chess.ChessScreen
|
||||
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
|
||||
import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore
|
||||
import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.ArticleReaderScreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen
|
||||
@@ -237,8 +237,11 @@ internal fun RootContent(
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToArticle = onNavigateToArticle,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
onZapFeedback = onZapFeedback,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -79,6 +79,7 @@ private val navItems =
|
||||
listOf(
|
||||
NavItem(DeckColumnType.HomeFeed, Icons.Default.Home, "Home"),
|
||||
NavItem(DeckColumnType.Reads, Icons.AutoMirrored.Filled.Article, "Reads"),
|
||||
NavItem(DeckColumnType.Drafts, Icons.AutoMirrored.Filled.Article, "Drafts"),
|
||||
NavItem(DeckColumnType.Search, Icons.Default.Search, "Search"),
|
||||
NavItem(DeckColumnType.Bookmarks, Icons.Default.Bookmark, "Bookmarks"),
|
||||
NavItem(DeckColumnType.Messages, Icons.Default.Email, "Messages"),
|
||||
@@ -177,6 +178,7 @@ fun SinglePaneLayout(
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
|
||||
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
||||
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
|
||||
)
|
||||
if (currentOverlay != null) {
|
||||
Surface(
|
||||
|
||||
Reference in New Issue
Block a user