mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-15 07:28:30 +00:00
Add call tab event grouping.
This commit is contained in:
committed by
Greyson Parrelli
parent
fd1ff5e438
commit
e8570c3680
@@ -155,24 +155,29 @@ class CallLogAdapter(
|
||||
binding.callRecipientAvatar.setAvatar(GlideApp.with(binding.callRecipientAvatar), model.call.peer, true)
|
||||
binding.callRecipientBadge.setBadgeFromRecipient(model.call.peer)
|
||||
binding.callRecipientName.text = model.call.peer.getDisplayName(context)
|
||||
presentCallInfo(model.call.call, model.call.date)
|
||||
presentCallInfo(model.call, model.call.date)
|
||||
presentCallType(model)
|
||||
}
|
||||
|
||||
private fun presentCallInfo(call: CallTable.Call, date: Long) {
|
||||
private fun presentCallInfo(call: CallLogRow.Call, date: Long) {
|
||||
val callState = context.getString(getCallStateStringRes(call.record))
|
||||
binding.callInfo.text = context.getString(
|
||||
R.string.CallLogAdapter__s_dot_s,
|
||||
context.getString(getCallStateStringRes(call)),
|
||||
if (call.children.size > 1) {
|
||||
context.getString(R.string.CallLogAdapter__d_s, call.children.size, callState)
|
||||
} else {
|
||||
callState
|
||||
},
|
||||
DateUtils.getBriefRelativeTimeSpanString(context, Locale.getDefault(), date)
|
||||
)
|
||||
|
||||
binding.callInfo.setRelativeDrawables(
|
||||
start = getCallStateDrawableRes(call)
|
||||
start = getCallStateDrawableRes(call.record)
|
||||
)
|
||||
|
||||
val color = ContextCompat.getColor(
|
||||
context,
|
||||
if (call.event == CallTable.Event.MISSED) {
|
||||
if (call.record.event == CallTable.Event.MISSED) {
|
||||
R.color.signal_colorError
|
||||
} else {
|
||||
R.color.signal_colorOnSurface
|
||||
@@ -188,7 +193,7 @@ class CallLogAdapter(
|
||||
}
|
||||
|
||||
private fun presentCallType(model: CallModel) {
|
||||
when (model.call.call.type) {
|
||||
when (model.call.record.type) {
|
||||
CallTable.Type.AUDIO_CALL -> {
|
||||
binding.callType.setImageResource(R.drawable.symbol_phone_24)
|
||||
binding.callType.setOnClickListener { onStartAudioCallClicked(model.call.peer) }
|
||||
|
||||
@@ -72,7 +72,7 @@ class CallLogContextMenu(
|
||||
iconRes = R.drawable.symbol_info_24,
|
||||
title = fragment.getString(R.string.CallContextMenu__info)
|
||||
) {
|
||||
val intent = ConversationSettingsActivity.forCall(fragment.requireContext(), call.peer, longArrayOf(call.call.messageId!!))
|
||||
val intent = ConversationSettingsActivity.forCall(fragment.requireContext(), call.peer, longArrayOf(call.record.messageId!!))
|
||||
fragment.startActivity(intent)
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ class CallLogContextMenu(
|
||||
}
|
||||
|
||||
private fun getDeleteActionItem(call: CallLogRow.Call): ActionItem? {
|
||||
if (call.call.event == CallTable.Event.ONGOING) {
|
||||
if (call.record.event == CallTable.Event.ONGOING) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -277,7 +277,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
if (viewModel.selectionStateSnapshot.isNotEmpty(binding.recycler.adapter!!.itemCount)) {
|
||||
viewModel.toggleSelected(callLogRow.id)
|
||||
} else {
|
||||
val intent = ConversationSettingsActivity.forCall(requireContext(), callLogRow.peer, longArrayOf(callLogRow.call.messageId!!))
|
||||
val intent = ConversationSettingsActivity.forCall(requireContext(), callLogRow.peer, (callLogRow.id as CallLogRow.Id.Call).children.toLongArray())
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,18 +36,18 @@ class CallLogRepository : CallLogPagedDataSource.CallRepository {
|
||||
}
|
||||
|
||||
fun deleteSelectedCallLogs(
|
||||
selectedCallIds: Set<Long>
|
||||
selectedCallRowIds: Set<Long>
|
||||
): Completable {
|
||||
return Completable.fromAction {
|
||||
SignalDatabase.calls.deleteCallEvents(selectedCallIds)
|
||||
SignalDatabase.calls.deleteCallEvents(selectedCallRowIds)
|
||||
}.observeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun deleteAllCallLogsExcept(
|
||||
selectedCallIds: Set<Long>
|
||||
selectedCallRowIds: Set<Long>
|
||||
): Completable {
|
||||
return Completable.fromAction {
|
||||
SignalDatabase.calls.deleteAllCallEventsExcept(selectedCallIds)
|
||||
SignalDatabase.calls.deleteAllCallEventsExcept(selectedCallRowIds)
|
||||
}.observeOn(Schedulers.io())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,12 @@ sealed class CallLogRow {
|
||||
* An incoming, outgoing, or missed call.
|
||||
*/
|
||||
data class Call(
|
||||
val call: CallTable.Call,
|
||||
val record: CallTable.Call,
|
||||
val peer: Recipient,
|
||||
val date: Long,
|
||||
val groupCallState: GroupCallState,
|
||||
override val id: Id = Id.Call(call.callId)
|
||||
val children: Set<Long>,
|
||||
override val id: Id = Id.Call(children)
|
||||
) : CallLogRow()
|
||||
|
||||
/**
|
||||
@@ -34,7 +35,7 @@ sealed class CallLogRow {
|
||||
}
|
||||
|
||||
sealed class Id {
|
||||
data class Call(val callId: Long) : Id()
|
||||
data class Call(val children: Set<Long>) : Id()
|
||||
object ClearFilter : Id()
|
||||
object CreateCallLink : Id()
|
||||
}
|
||||
|
||||
@@ -28,15 +28,16 @@ class CallLogStagedDeletion(
|
||||
}
|
||||
|
||||
isCommitted = true
|
||||
val callIds = stateSnapshot.selected()
|
||||
val callRowIds = stateSnapshot.selected()
|
||||
.filterIsInstance<CallLogRow.Id.Call>()
|
||||
.map { it.callId }
|
||||
.map { it.children }
|
||||
.flatten()
|
||||
.toSet()
|
||||
|
||||
if (stateSnapshot.isExclusionary()) {
|
||||
repository.deleteAllCallLogsExcept(callIds).subscribe()
|
||||
repository.deleteAllCallLogsExcept(callRowIds).subscribe()
|
||||
} else {
|
||||
repository.deleteSelectedCallLogs(callIds).subscribe()
|
||||
repository.deleteSelectedCallLogs(callRowIds).subscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,13 +40,14 @@ class ConversationSettingsRepository(
|
||||
private val groupManagementRepository: GroupManagementRepository = GroupManagementRepository(context)
|
||||
) {
|
||||
|
||||
fun getCallEvents(callMessageIds: LongArray): Single<List<Pair<CallTable.Call, MessageRecord>>> {
|
||||
return if (callMessageIds.isEmpty()) {
|
||||
fun getCallEvents(callRowIds: LongArray): Single<List<Pair<CallTable.Call, MessageRecord>>> {
|
||||
return if (callRowIds.isEmpty()) {
|
||||
Single.just(emptyList())
|
||||
} else {
|
||||
Single.fromCallable {
|
||||
val callMap = SignalDatabase.calls.getCalls(callMessageIds.toList())
|
||||
SignalDatabase.messages.getMessages(callMessageIds.toList()).iterator().asSequence()
|
||||
val callMap = SignalDatabase.calls.getCallsByRowIds(callRowIds.toList())
|
||||
val messageIds = callMap.values.mapNotNull { it.messageId }
|
||||
SignalDatabase.messages.getMessages(messageIds).iterator().asSequence()
|
||||
.filter { callMap.containsKey(it.id) }
|
||||
.map { callMap[it.id]!! to it }
|
||||
.toList()
|
||||
|
||||
@@ -8,12 +8,15 @@ import org.signal.core.util.IntSerializer
|
||||
import org.signal.core.util.Serializer
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.delete
|
||||
import org.signal.core.util.flatten
|
||||
import org.signal.core.util.insertInto
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.readToList
|
||||
import org.signal.core.util.readToMap
|
||||
import org.signal.core.util.readToSingleLong
|
||||
import org.signal.core.util.readToSingleObject
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.requireNonNullString
|
||||
import org.signal.core.util.requireObject
|
||||
import org.signal.core.util.requireString
|
||||
import org.signal.core.util.select
|
||||
@@ -32,6 +35,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.CallEvent
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Contains details for each 1:1 call.
|
||||
@@ -40,6 +44,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(CallTable::class.java)
|
||||
private val TIME_WINDOW = TimeUnit.HOURS.toMillis(4)
|
||||
|
||||
private const val TABLE_NAME = "call"
|
||||
private const val ID = "_id"
|
||||
@@ -152,19 +157,37 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
|
||||
}
|
||||
|
||||
fun getCalls(messageIds: Collection<Long>): Map<Long, Call> {
|
||||
val calls = mutableMapOf<Long, Call>()
|
||||
val queries = SqlUtil.buildCollectionQuery(MESSAGE_ID, messageIds)
|
||||
|
||||
queries.forEach { query ->
|
||||
val cursor = readableDatabase
|
||||
val maps = queries.map { query ->
|
||||
readableDatabase
|
||||
.select()
|
||||
.from(TABLE_NAME)
|
||||
.where("$EVENT != ${Event.serialize(Event.DELETE)} AND ${query.where}", query.whereArgs)
|
||||
.run()
|
||||
|
||||
calls.putAll(cursor.readToList { c -> c.requireLong(MESSAGE_ID) to Call.deserialize(c) })
|
||||
.readToMap { c -> c.requireLong(MESSAGE_ID) to Call.deserialize(c) }
|
||||
}
|
||||
return calls
|
||||
|
||||
return maps.flatten()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callRowIds The CallTable.ID collection to query
|
||||
*
|
||||
* @return a map of raw MessageId -> Call
|
||||
*/
|
||||
fun getCallsByRowIds(callRowIds: Collection<Long>): Map<Long, Call> {
|
||||
val queries = SqlUtil.buildCollectionQuery(ID, callRowIds)
|
||||
|
||||
val maps = queries.map { query ->
|
||||
readableDatabase
|
||||
.select()
|
||||
.from(TABLE_NAME)
|
||||
.where("$EVENT != ${Event.serialize(Event.DELETE)} AND ${query.where}", query.whereArgs)
|
||||
.run()
|
||||
.readToMap { c -> c.requireLong(MESSAGE_ID) to Call.deserialize(c) }
|
||||
}
|
||||
|
||||
return maps.flatten()
|
||||
}
|
||||
|
||||
fun getOldestDeletionTimestamp(): Long {
|
||||
@@ -670,14 +693,14 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
|
||||
.run()
|
||||
}
|
||||
|
||||
fun deleteCallEvents(callIds: Set<Long>) {
|
||||
val messageIds = getMessageIds(callIds)
|
||||
fun deleteCallEvents(callRowIds: Set<Long>) {
|
||||
val messageIds = getMessageIds(callRowIds)
|
||||
SignalDatabase.messages.deleteCallUpdates(messageIds)
|
||||
updateCallEventDeletionTimestamps()
|
||||
}
|
||||
|
||||
fun deleteAllCallEventsExcept(callIds: Set<Long>) {
|
||||
val messageIds = getMessageIds(callIds)
|
||||
fun deleteAllCallEventsExcept(callRowIds: Set<Long>) {
|
||||
val messageIds = getMessageIds(callRowIds)
|
||||
SignalDatabase.messages.deleteAllCallUpdatesExcept(messageIds)
|
||||
updateCallEventDeletionTimestamps()
|
||||
}
|
||||
@@ -697,10 +720,10 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
|
||||
}
|
||||
}
|
||||
|
||||
private fun getMessageIds(callIds: Set<Long>): Set<Long> {
|
||||
private fun getMessageIds(callRowIds: Set<Long>): Set<Long> {
|
||||
val queries = SqlUtil.buildCollectionQuery(
|
||||
CALL_ID,
|
||||
callIds,
|
||||
ID,
|
||||
callRowIds,
|
||||
"$MESSAGE_ID NOT NULL AND"
|
||||
)
|
||||
|
||||
@@ -718,12 +741,12 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
|
||||
// endregion
|
||||
|
||||
private fun getCallsCursor(isCount: Boolean, offset: Int, limit: Int, searchTerm: String?, filter: CallLogFilter): Cursor {
|
||||
val filterClause = when (filter) {
|
||||
val filterClause: SqlUtil.Query = when (filter) {
|
||||
CallLogFilter.ALL -> SqlUtil.buildQuery("$EVENT != ${Event.serialize(Event.DELETE)}")
|
||||
CallLogFilter.MISSED -> SqlUtil.buildQuery("$EVENT == ${Event.serialize(Event.MISSED)}")
|
||||
}
|
||||
|
||||
val queryClause = if (!searchTerm.isNullOrEmpty()) {
|
||||
val queryClause: SqlUtil.Query = if (!searchTerm.isNullOrEmpty()) {
|
||||
val glob = SqlUtil.buildCaseInsensitiveGlobPattern(searchTerm)
|
||||
val selection =
|
||||
"""
|
||||
@@ -740,41 +763,86 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
|
||||
SqlUtil.buildQuery("")
|
||||
}
|
||||
|
||||
val whereClause = filterClause and queryClause
|
||||
val where = if (whereClause.where.isNotEmpty()) {
|
||||
"WHERE ${whereClause.where}"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
val offsetLimit = if (limit > 0) {
|
||||
"LIMIT $offset,$limit"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
val projection = if (isCount) {
|
||||
"COUNT(*),"
|
||||
} else {
|
||||
"p.$ID, $TIMESTAMP, $EVENT, $DIRECTION, $PEER, p.$TYPE, $CALL_ID, $MESSAGE_ID, $RINGER, children, ${MessageTable.DATE_RECEIVED}, ${MessageTable.BODY},"
|
||||
}
|
||||
|
||||
//language=sql
|
||||
val statement = """
|
||||
SELECT
|
||||
${if (isCount) "COUNT(*)," else "$TABLE_NAME.*, ${MessageTable.DATE_RECEIVED}, ${MessageTable.BODY},"}
|
||||
LOWER(
|
||||
COALESCE(
|
||||
NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.SYSTEM_JOINED_NAME}, ''),
|
||||
NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.SYSTEM_GIVEN_NAME}, ''),
|
||||
NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.PROFILE_JOINED_NAME}, ''),
|
||||
NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.PROFILE_GIVEN_NAME}, ''),
|
||||
NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.USERNAME}, '')
|
||||
SELECT $projection
|
||||
LOWER(
|
||||
COALESCE(
|
||||
NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.SYSTEM_JOINED_NAME}, ''),
|
||||
NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.SYSTEM_GIVEN_NAME}, ''),
|
||||
NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.PROFILE_JOINED_NAME}, ''),
|
||||
NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.PROFILE_GIVEN_NAME}, ''),
|
||||
NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.USERNAME}, '')
|
||||
)
|
||||
) AS sort_name
|
||||
FROM (
|
||||
WITH cte AS (
|
||||
SELECT
|
||||
$ID, $TIMESTAMP, $EVENT, $DIRECTION, $PEER, $TYPE, $CALL_ID, $MESSAGE_ID, $RINGER,
|
||||
(
|
||||
SELECT
|
||||
$ID
|
||||
FROM
|
||||
$TABLE_NAME
|
||||
WHERE
|
||||
$TABLE_NAME.$DIRECTION = c.$DIRECTION
|
||||
AND $TABLE_NAME.$PEER = c.$PEER
|
||||
AND $TABLE_NAME.$TIMESTAMP - $TIME_WINDOW <= c.$TIMESTAMP
|
||||
AND $TABLE_NAME.$TIMESTAMP >= c.$TIMESTAMP
|
||||
ORDER BY
|
||||
$TIMESTAMP DESC
|
||||
) as parent,
|
||||
(
|
||||
SELECT
|
||||
group_concat($ID)
|
||||
FROM
|
||||
$TABLE_NAME
|
||||
WHERE
|
||||
$TABLE_NAME.$DIRECTION = c.$DIRECTION
|
||||
AND $TABLE_NAME.$PEER = c.$PEER
|
||||
AND c.$TIMESTAMP - $TIME_WINDOW <= $TABLE_NAME.$TIMESTAMP
|
||||
AND c.$TIMESTAMP >= $TABLE_NAME.$TIMESTAMP
|
||||
) as children
|
||||
FROM
|
||||
$TABLE_NAME c
|
||||
WHERE ${filterClause.where}
|
||||
ORDER BY
|
||||
$TIMESTAMP DESC
|
||||
)
|
||||
) AS sort_name
|
||||
FROM $TABLE_NAME
|
||||
INNER JOIN ${RecipientTable.TABLE_NAME} ON ${RecipientTable.TABLE_NAME}.${RecipientTable.ID} = $TABLE_NAME.$PEER
|
||||
INNER JOIN ${MessageTable.TABLE_NAME} ON ${MessageTable.TABLE_NAME}.${MessageTable.ID} = $TABLE_NAME.$MESSAGE_ID
|
||||
$where
|
||||
ORDER BY ${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED} DESC
|
||||
SELECT
|
||||
*,
|
||||
CASE
|
||||
WHEN LAG (parent, 1, 0) OVER (
|
||||
ORDER BY
|
||||
$TIMESTAMP DESC
|
||||
) != parent THEN $ID
|
||||
ELSE parent
|
||||
END true_parent
|
||||
FROM
|
||||
cte
|
||||
) p
|
||||
INNER JOIN ${RecipientTable.TABLE_NAME} ON ${RecipientTable.TABLE_NAME}.${RecipientTable.ID} = $PEER
|
||||
INNER JOIN ${MessageTable.TABLE_NAME} ON ${MessageTable.TABLE_NAME}.${MessageTable.ID} = $MESSAGE_ID
|
||||
WHERE true_parent = p.$ID ${if (queryClause.where.isNotEmpty()) "AND ${queryClause.where}" else ""}
|
||||
$offsetLimit
|
||||
""".trimIndent()
|
||||
|
||||
return readableDatabase.query(statement, whereClause.whereArgs)
|
||||
return readableDatabase.query(
|
||||
statement,
|
||||
queryClause.whereArgs
|
||||
)
|
||||
}
|
||||
|
||||
fun getCallsCount(searchTerm: String?, filter: CallLogFilter): Int {
|
||||
@@ -785,17 +853,21 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
|
||||
}
|
||||
|
||||
fun getCalls(offset: Int, limit: Int, searchTerm: String?, filter: CallLogFilter): List<CallLogRow.Call> {
|
||||
return getCallsCursor(false, offset, limit, searchTerm, filter).readToList {
|
||||
val call = Call.deserialize(it)
|
||||
return getCallsCursor(false, offset, limit, searchTerm, filter).readToList { cursor ->
|
||||
val call = Call.deserialize(cursor)
|
||||
val recipient = Recipient.resolved(call.peer)
|
||||
val date = it.requireLong(MessageTable.DATE_RECEIVED)
|
||||
val groupCallDetails = GroupCallUpdateDetailsUtil.parse(it.requireString(MessageTable.BODY))
|
||||
val date = cursor.requireLong(MessageTable.DATE_RECEIVED)
|
||||
val groupCallDetails = GroupCallUpdateDetailsUtil.parse(cursor.requireString(MessageTable.BODY))
|
||||
|
||||
CallLogRow.Call(
|
||||
call = call,
|
||||
record = call,
|
||||
peer = recipient,
|
||||
date = date,
|
||||
groupCallState = CallLogRow.GroupCallState.fromDetails(groupCallDetails)
|
||||
groupCallState = CallLogRow.GroupCallState.fromDetails(groupCallDetails),
|
||||
children = cursor.requireNonNullString("children")
|
||||
.split(',')
|
||||
.map { it.toLong() }
|
||||
.toSet()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5784,6 +5784,8 @@
|
||||
<string name="CallLogAdapter__join">Join</string>
|
||||
<!-- Displayed on Group Call button if user is in the call -->
|
||||
<string name="CallLogAdapter__return">Return</string>
|
||||
<!-- Call state template when there is more than one call collapsed into a single row. D is a number > 1 and S is a call info string (like Missed) -->
|
||||
<string name="CallLogAdapter__d_s">(%1$d) %2$s</string>
|
||||
|
||||
<!-- Call Log context menu -->
|
||||
<!-- Displayed as a context menu item to start a video call -->
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.signal.core.util
|
||||
|
||||
/**
|
||||
* Flattens a List of Map<K, V> into a Map<K, V> using the + operator.
|
||||
*
|
||||
* @return A Map containing all of the K, V pairings of the maps contained in the original list.
|
||||
*/
|
||||
fun <K, V> List<Map<K, V>>.flatten(): Map<K, V> = foldRight(emptyMap()) { a, b -> a + b }
|
||||
@@ -137,6 +137,11 @@ inline fun <T> Cursor.readToList(predicate: (T) -> Boolean = { true }, mapper: (
|
||||
return list
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
inline fun <K, V> Cursor.readToMap(predicate: (Pair<K, V>) -> Boolean = { true }, mapper: (Cursor) -> Pair<K, V>): Map<K, V> {
|
||||
return readToList(predicate, mapper).associate { it }
|
||||
}
|
||||
|
||||
inline fun <T> Cursor.readToSet(predicate: (T) -> Boolean = { true }, mapper: (Cursor) -> T): Set<T> {
|
||||
val set = mutableSetOf<T>()
|
||||
use {
|
||||
|
||||
Reference in New Issue
Block a user