mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-03-30 16:25:57 +00:00
ios: optimise chat switching (#4663)
* ios: shooth chat switching * debug button * navigation timeout * fix scroll crash * fix merge * whitespace * wip * add spinner; extract load and nav logic * cleanup * direct chat button * cleanup * showLoadingProgress * reverse rename * rename * spinner layout * move all programmatic navigation to `openLoadChat` * remove access restriction * fix scroll on item added regression * print * fix page load regression * fix member sheet disappearing --------- Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
@@ -54,12 +54,50 @@ class ItemsModel: ObservableObject {
|
||||
willSet { publisher.send() }
|
||||
}
|
||||
|
||||
// Publishes directly to `objectWillChange` publisher,
|
||||
// this will cause reversedChatItems to be rendered without throttling
|
||||
@Published var isLoading = false
|
||||
@Published var showLoadingProgress = false
|
||||
|
||||
init() {
|
||||
publisher
|
||||
.throttle(for: 0.25, scheduler: DispatchQueue.main, latest: true)
|
||||
.sink { self.objectWillChange.send() }
|
||||
.store(in: &bag)
|
||||
}
|
||||
|
||||
func loadOpenChat(_ chatId: ChatId, willNavigate: @escaping () -> Void = {}) {
|
||||
let navigationTimeout = Task {
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 250_000000)
|
||||
await MainActor.run {
|
||||
willNavigate()
|
||||
ChatModel.shared.chatId = chatId
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
let progressTimeout = Task {
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 1500_000000)
|
||||
await MainActor.run { showLoadingProgress = true }
|
||||
} catch {}
|
||||
}
|
||||
Task {
|
||||
if let chat = ChatModel.shared.getChat(chatId) {
|
||||
await MainActor.run { self.isLoading = true }
|
||||
// try? await Task.sleep(nanoseconds: 5000_000000)
|
||||
await loadChat(chat: chat)
|
||||
navigationTimeout.cancel()
|
||||
progressTimeout.cancel()
|
||||
await MainActor.run {
|
||||
self.isLoading = false
|
||||
self.showLoadingProgress = false
|
||||
willNavigate()
|
||||
ChatModel.shared.chatId = chatId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class ChatModel: ObservableObject {
|
||||
|
||||
@@ -57,7 +57,9 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
chatModel.ntfCallInvitationAction = (chatId, ntfAction)
|
||||
}
|
||||
} else {
|
||||
chatModel.chatId = content.targetContentIdentifier
|
||||
if let chatId = content.targetContentIdentifier {
|
||||
ItemsModel.shared.loadOpenChat(chatId)
|
||||
}
|
||||
}
|
||||
handler()
|
||||
}
|
||||
|
||||
@@ -320,8 +320,8 @@ private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] {
|
||||
|
||||
let loadItemsPerPage = 50
|
||||
|
||||
func apiGetChat(type: ChatType, id: Int64, search: String = "") throws -> Chat {
|
||||
let r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: .last(count: loadItemsPerPage), search: search))
|
||||
func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws -> Chat {
|
||||
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: .last(count: loadItemsPerPage), search: search))
|
||||
if case let .apiChat(_, chat) = r { return Chat.init(chat) }
|
||||
throw r
|
||||
}
|
||||
@@ -332,16 +332,18 @@ func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, sear
|
||||
throw r
|
||||
}
|
||||
|
||||
func loadChat(chat: Chat, search: String = "") {
|
||||
func loadChat(chat: Chat, search: String = "") async {
|
||||
do {
|
||||
let cInfo = chat.chatInfo
|
||||
let m = ChatModel.shared
|
||||
let im = ItemsModel.shared
|
||||
m.chatItemStatuses = [:]
|
||||
im.reversedChatItems = []
|
||||
let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
|
||||
m.updateChatInfo(chat.chatInfo)
|
||||
im.reversedChatItems = chat.chatItems.reversed()
|
||||
await MainActor.run { im.reversedChatItems = [] }
|
||||
let chat = try await apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
|
||||
await MainActor.run {
|
||||
im.reversedChatItems = chat.chatItems.reversed()
|
||||
m.updateChatInfo(chat.chatInfo)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("loadChat error: \(responseError(error))")
|
||||
}
|
||||
@@ -701,7 +703,7 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi
|
||||
return ((.contact, connection), nil)
|
||||
case let .contactAlreadyExists(_, contact):
|
||||
if let c = m.getContactChat(contact.contactId) {
|
||||
await MainActor.run { m.chatId = c.id }
|
||||
ItemsModel.shared.loadOpenChat(c.id)
|
||||
}
|
||||
let alert = contactAlreadyExistsAlert(contact)
|
||||
return (nil, alert)
|
||||
@@ -1170,7 +1172,7 @@ func acceptContactRequest(incognito: Bool, contactRequest: UserContactRequest) a
|
||||
if contact.sndReady {
|
||||
DispatchQueue.main.async {
|
||||
dismissAllSheets(animated: true) {
|
||||
ChatModel.shared.chatId = chat.id
|
||||
ItemsModel.shared.loadOpenChat(chat.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1736,7 +1738,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
if active(user) && m.hasChat(mergedContact.id) {
|
||||
await MainActor.run {
|
||||
if m.chatId == mergedContact.id {
|
||||
m.chatId = intoContact.id
|
||||
ItemsModel.shared.loadOpenChat(mergedContact.id)
|
||||
}
|
||||
m.removeChat(mergedContact.id)
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ struct SimpleXApp: App {
|
||||
chatModel.updateChats(with: chats)
|
||||
if let id = chatModel.chatId,
|
||||
let chat = chatModel.getChat(id) {
|
||||
loadChat(chat: chat)
|
||||
Task { await loadChat(chat: chat) }
|
||||
}
|
||||
if let ncr = chatModel.ntfContactRequest {
|
||||
chatModel.ntfContactRequest = nil
|
||||
|
||||
@@ -97,7 +97,7 @@ struct ChatItemForwardingView: View {
|
||||
)
|
||||
} else {
|
||||
composeState = ComposeState.init(forwardingItem: ci, fromChatInfo: fromChatInfo)
|
||||
chatModel.chatId = chat.id
|
||||
ItemsModel.shared.loadOpenChat(chat.id)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
|
||||
@@ -351,7 +351,7 @@ struct ChatItemInfoView: View {
|
||||
Button {
|
||||
Task {
|
||||
await MainActor.run {
|
||||
chatModel.chatId = forwardedFromItem.chatInfo.id
|
||||
ItemsModel.shared.loadOpenChat(forwardedFromItem.chatInfo.id)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ struct ChatView: View {
|
||||
viewBody
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder
|
||||
private var viewBody: some View {
|
||||
let cInfo = chat.chatInfo
|
||||
@@ -130,16 +130,23 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.appSheet(item: $selectedMember) { member in
|
||||
Group {
|
||||
if case let .group(groupInfo) = chat.chatInfo {
|
||||
GroupMemberInfoView(groupInfo: groupInfo, groupMember: member, navigation: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
loadChat(chat: chat)
|
||||
initChatView()
|
||||
selectedChatItems = nil
|
||||
initChatView()
|
||||
}
|
||||
.onChange(of: chatModel.chatId) { cId in
|
||||
showChatInfoSheet = false
|
||||
selectedChatItems = nil
|
||||
scrollModel.scrollToBottom()
|
||||
stopAudioPlayer()
|
||||
if let cId {
|
||||
selectedChatItems = nil
|
||||
if let c = chatModel.getChat(cId) {
|
||||
chat = c
|
||||
}
|
||||
@@ -152,8 +159,10 @@ struct ChatView: View {
|
||||
.onChange(of: revealedChatItem) { _ in
|
||||
NotificationCenter.postReverseListNeedsLayout()
|
||||
}
|
||||
.onChange(of: im.reversedChatItems) { reversedChatItems in
|
||||
if reversedChatItems.count <= loadItemsPerPage && filtered(reversedChatItems).count < 10 {
|
||||
.onChange(of: im.isLoading) { isLoading in
|
||||
if !isLoading,
|
||||
im.reversedChatItems.count <= loadItemsPerPage,
|
||||
filtered(im.reversedChatItems).count < 10 {
|
||||
loadChatItems(chat.chatInfo)
|
||||
}
|
||||
}
|
||||
@@ -221,6 +230,7 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
let isLoading = im.isLoading && im.showLoadingProgress
|
||||
if selectedChatItems != nil {
|
||||
Button {
|
||||
withAnimation {
|
||||
@@ -243,19 +253,23 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
Menu {
|
||||
if callsPrefEnabled && chatModel.activeCall == nil {
|
||||
Button {
|
||||
CallController.shared.startCall(contact, .video)
|
||||
} label: {
|
||||
Label("Video call", systemImage: "video")
|
||||
if !isLoading {
|
||||
if callsPrefEnabled && chatModel.activeCall == nil {
|
||||
Button {
|
||||
CallController.shared.startCall(contact, .video)
|
||||
} label: {
|
||||
Label("Video call", systemImage: "video")
|
||||
}
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
}
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
searchButton()
|
||||
ToggleNtfsButton(chat: chat)
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
}
|
||||
searchButton()
|
||||
ToggleNtfsButton(chat: chat)
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.tint(isLoading ? Color.clear : nil)
|
||||
.overlay { if isLoading { ProgressView() } }
|
||||
}
|
||||
}
|
||||
case let .group(groupInfo):
|
||||
@@ -280,10 +294,14 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
Menu {
|
||||
searchButton()
|
||||
ToggleNtfsButton(chat: chat)
|
||||
if !isLoading {
|
||||
searchButton()
|
||||
ToggleNtfsButton(chat: chat)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.tint(isLoading ? Color.clear : nil)
|
||||
.overlay { if isLoading { ProgressView() } }
|
||||
}
|
||||
}
|
||||
case .local:
|
||||
@@ -349,9 +367,7 @@ struct ChatView: View {
|
||||
searchText = ""
|
||||
searchMode = false
|
||||
searchFocussed = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
loadChat(chat: chat)
|
||||
}
|
||||
Task { await loadChat(chat: chat) }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
@@ -408,18 +424,11 @@ struct ChatView: View {
|
||||
} loadPage: {
|
||||
loadChatItems(cInfo)
|
||||
}
|
||||
.opacity(ItemsModel.shared.isLoading ? 0 : 1)
|
||||
.padding(.vertical, -InvertedTableView.inset)
|
||||
.onTapGesture { hideKeyboard() }
|
||||
.onChange(of: searchText) { _ in
|
||||
loadChat(chat: chat, search: searchText)
|
||||
}
|
||||
.onChange(of: chatModel.chatId) { chatId in
|
||||
if let chatId, let c = chatModel.getChat(chatId) {
|
||||
chat = c
|
||||
showChatInfoSheet = false
|
||||
loadChat(chat: c)
|
||||
scrollModel.scrollToBottom()
|
||||
}
|
||||
Task { await loadChat(chat: chat, search: searchText) }
|
||||
}
|
||||
.onChange(of: im.reversedChatItems) { _ in
|
||||
floatingButtonModel.chatItemsChanged()
|
||||
@@ -815,8 +824,8 @@ struct ChatView: View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
MemberProfileImage(member, size: memberImageSize, backgroundColor: theme.colors.background)
|
||||
.onTapGesture {
|
||||
if m.membersLoaded {
|
||||
selectedMember = m.getGroupMember(member.groupMemberId)
|
||||
if let member = m.getGroupMember(member.groupMemberId) {
|
||||
selectedMember = member
|
||||
} else {
|
||||
Task {
|
||||
await m.loadGroupMembers(groupInfo) {
|
||||
@@ -825,9 +834,6 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.appSheet(item: $selectedMember) { member in
|
||||
GroupMemberInfoView(groupInfo: groupInfo, groupMember: member, navigation: true)
|
||||
}
|
||||
chatItemWithMenu(ci, range, maxWidth)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,24 +321,24 @@ struct GroupMemberInfoView: View {
|
||||
|
||||
func knownDirectChatButton(_ chat: Chat, width: CGFloat) -> some View {
|
||||
InfoViewButton(image: "message.fill", title: "message", width: width) {
|
||||
dismissAllSheets(animated: true)
|
||||
DispatchQueue.main.async {
|
||||
chatModel.chatId = chat.id
|
||||
ItemsModel.shared.loadOpenChat(chat.id) {
|
||||
dismissAllSheets(animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newDirectChatButton(_ contactId: Int64, width: CGFloat) -> some View {
|
||||
InfoViewButton(image: "message.fill", title: "message", width: width) {
|
||||
do {
|
||||
let chat = try apiGetChat(type: .direct, id: contactId)
|
||||
chatModel.addChat(chat)
|
||||
dismissAllSheets(animated: true)
|
||||
DispatchQueue.main.async {
|
||||
chatModel.chatId = chat.id
|
||||
Task {
|
||||
do {
|
||||
let chat = try await apiGetChat(type: .direct, id: contactId)
|
||||
chatModel.addChat(chat)
|
||||
ItemsModel.shared.loadOpenChat(chat.id) {
|
||||
dismissAllSheets(animated: true)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -352,8 +352,9 @@ struct GroupMemberInfoView: View {
|
||||
await MainActor.run {
|
||||
progressIndicator = false
|
||||
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
|
||||
dismissAllSheets(animated: true)
|
||||
chatModel.chatId = memberContact.id
|
||||
ItemsModel.shared.loadOpenChat(memberContact.id) {
|
||||
dismissAllSheets(animated: true)
|
||||
}
|
||||
chatModel.setContactNetworkStatus(memberContact, .connected)
|
||||
}
|
||||
} catch let error {
|
||||
|
||||
@@ -152,18 +152,23 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
|
||||
/// Scrolls to Item at index path
|
||||
/// - Parameter indexPath: Item to scroll to - will scroll to beginning of the list, if `nil`
|
||||
func scroll(to index: Int?, position: UITableView.ScrollPosition) {
|
||||
if let index {
|
||||
var animated = false
|
||||
if #available(iOS 16.0, *) {
|
||||
animated = true
|
||||
}
|
||||
var animated = false
|
||||
if #available(iOS 16.0, *) {
|
||||
animated = true
|
||||
}
|
||||
if let index, tableView.numberOfRows(inSection: 0) != 0 {
|
||||
tableView.scrollToRow(
|
||||
at: IndexPath(row: index, section: 0),
|
||||
at: position,
|
||||
animated: animated
|
||||
)
|
||||
Task { representer.scrollState = .atDestination }
|
||||
} else {
|
||||
tableView.setContentOffset(
|
||||
CGPoint(x: .zero, y: -InvertedTableView.inset),
|
||||
animated: animated
|
||||
)
|
||||
}
|
||||
Task { representer.scrollState = .atDestination }
|
||||
}
|
||||
|
||||
func update(items: Array<Item>) {
|
||||
|
||||
@@ -114,7 +114,7 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
} else {
|
||||
NavLinkPlain(
|
||||
tag: chat.chatInfo.id,
|
||||
chatId: chat.chatInfo.id,
|
||||
selection: $chatModel.chatId,
|
||||
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }
|
||||
)
|
||||
@@ -194,7 +194,7 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
default:
|
||||
NavLinkPlain(
|
||||
tag: chat.chatInfo.id,
|
||||
chatId: chat.chatInfo.id,
|
||||
selection: $chatModel.chatId,
|
||||
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
|
||||
disabled: !groupInfo.ready
|
||||
@@ -221,7 +221,7 @@ struct ChatListNavLink: View {
|
||||
|
||||
@ViewBuilder private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View {
|
||||
NavLinkPlain(
|
||||
tag: chat.chatInfo.id,
|
||||
chatId: chat.chatInfo.id,
|
||||
selection: $chatModel.chatId,
|
||||
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
|
||||
disabled: !noteFolder.ready
|
||||
@@ -472,9 +472,7 @@ struct ChatListNavLink: View {
|
||||
Task {
|
||||
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) })
|
||||
if ok {
|
||||
await MainActor.run {
|
||||
chatModel.chatId = contact.id
|
||||
}
|
||||
ItemsModel.shared.loadOpenChat(contact.id)
|
||||
AlertManager.shared.showAlert(connReqSentAlert(.contact))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ struct ContactListNavLink: View {
|
||||
func recentContactNavLink(_ contact: Contact) -> some View {
|
||||
Button {
|
||||
dismissAllSheets(animated: true) {
|
||||
ChatModel.shared.chatId = contact.id
|
||||
ItemsModel.shared.loadOpenChat(contact.id)
|
||||
}
|
||||
} label: {
|
||||
contactPreview(contact, titleColor: theme.colors.onBackground)
|
||||
@@ -83,7 +83,7 @@ struct ContactListNavLink: View {
|
||||
Task {
|
||||
await MainActor.run {
|
||||
dismissAllSheets(animated: true) {
|
||||
ChatModel.shared.chatId = contact.id
|
||||
ItemsModel.shared.loadOpenChat(contact.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,9 +188,7 @@ struct ContactListNavLink: View {
|
||||
Task {
|
||||
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { alert = SomeAlert(alert: $0, id: "ContactListNavLink connectContactViaAddress") })
|
||||
if ok {
|
||||
await MainActor.run {
|
||||
ChatModel.shared.chatId = contact.id
|
||||
}
|
||||
ItemsModel.shared.loadOpenChat(contact.id)
|
||||
DispatchQueue.main.async {
|
||||
dismissAllSheets(animated: true) {
|
||||
AlertManager.shared.showAlert(connReqSentAlert(.contact))
|
||||
|
||||
@@ -7,16 +7,17 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct NavLinkPlain<V: Hashable, Label: View>: View {
|
||||
@State var tag: V
|
||||
@Binding var selection: V?
|
||||
struct NavLinkPlain<Label: View>: View {
|
||||
let chatId: ChatId
|
||||
@Binding var selection: ChatId?
|
||||
@ViewBuilder var label: () -> Label
|
||||
var disabled = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Button("") { DispatchQueue.main.async { selection = tag } }
|
||||
Button("") { ItemsModel.shared.loadOpenChat(chatId) }
|
||||
.disabled(disabled)
|
||||
label()
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ struct AddGroupView: View {
|
||||
) { _ in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
dismissAllSheets(animated: true) {
|
||||
m.chatId = groupInfo.id
|
||||
ItemsModel.shared.loadOpenChat(groupInfo.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ struct AddGroupView: View {
|
||||
) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
dismissAllSheets(animated: true) {
|
||||
m.chatId = groupInfo.id
|
||||
ItemsModel.shared.loadOpenChat(groupInfo.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -898,11 +898,11 @@ func openKnownContact(_ contact: Contact, dismiss: Bool, showAlreadyExistsAlert:
|
||||
DispatchQueue.main.async {
|
||||
if dismiss {
|
||||
dismissAllSheets(animated: true) {
|
||||
m.chatId = c.id
|
||||
ItemsModel.shared.loadOpenChat(c.id)
|
||||
showAlreadyExistsAlert?()
|
||||
}
|
||||
} else {
|
||||
m.chatId = c.id
|
||||
ItemsModel.shared.loadOpenChat(c.id)
|
||||
showAlreadyExistsAlert?()
|
||||
}
|
||||
}
|
||||
@@ -917,11 +917,11 @@ func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, showAlreadyExistsAler
|
||||
DispatchQueue.main.async {
|
||||
if dismiss {
|
||||
dismissAllSheets(animated: true) {
|
||||
m.chatId = g.id
|
||||
ItemsModel.shared.loadOpenChat(g.id)
|
||||
showAlreadyExistsAlert?()
|
||||
}
|
||||
} else {
|
||||
m.chatId = g.id
|
||||
ItemsModel.shared.loadOpenChat(g.id)
|
||||
showAlreadyExistsAlert?()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user