mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +00:00
Fixes #2147 - Scroll the timeline to the bottom when sending message as well as media, polls and locations.
This commit is contained in:
committed by
Stefan Ceriu
parent
6905ac5f70
commit
8189b0a471
@@ -1939,11 +1939,11 @@ class RoomProxyMock: RoomProxyProtocol {
|
|||||||
set(value) { underlyingActiveMembersCount = value }
|
set(value) { underlyingActiveMembersCount = value }
|
||||||
}
|
}
|
||||||
var underlyingActiveMembersCount: Int!
|
var underlyingActiveMembersCount: Int!
|
||||||
var stateUpdatesPublisher: AnyPublisher<Void, Never> {
|
var actions: AnyPublisher<RoomProxyAction, Never> {
|
||||||
get { return underlyingStateUpdatesPublisher }
|
get { return underlyingActions }
|
||||||
set(value) { underlyingStateUpdatesPublisher = value }
|
set(value) { underlyingActions = value }
|
||||||
}
|
}
|
||||||
var underlyingStateUpdatesPublisher: AnyPublisher<Void, Never>!
|
var underlyingActions: AnyPublisher<RoomProxyAction, Never>!
|
||||||
var timeline: TimelineProxyProtocol {
|
var timeline: TimelineProxyProtocol {
|
||||||
get { return underlyingTimeline }
|
get { return underlyingTimeline }
|
||||||
set(value) { underlyingTimeline = value }
|
set(value) { underlyingTimeline = value }
|
||||||
@@ -2578,6 +2578,11 @@ class SessionVerificationControllerProxyMock: SessionVerificationControllerProxy
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
class TimelineProxyMock: TimelineProxyProtocol {
|
class TimelineProxyMock: TimelineProxyProtocol {
|
||||||
|
var actions: AnyPublisher<TimelineProxyAction, Never> {
|
||||||
|
get { return underlyingActions }
|
||||||
|
set(value) { underlyingActions = value }
|
||||||
|
}
|
||||||
|
var underlyingActions: AnyPublisher<TimelineProxyAction, Never>!
|
||||||
var timelineProvider: RoomTimelineProviderProtocol {
|
var timelineProvider: RoomTimelineProviderProtocol {
|
||||||
get { return underlyingTimelineProvider }
|
get { return underlyingTimelineProvider }
|
||||||
set(value) { underlyingTimelineProvider = value }
|
set(value) { underlyingTimelineProvider = value }
|
||||||
|
|||||||
@@ -32,12 +32,19 @@ struct RoomProxyMockConfiguration {
|
|||||||
var canonicalAlias: String?
|
var canonicalAlias: String?
|
||||||
var alternativeAliases: [String] = []
|
var alternativeAliases: [String] = []
|
||||||
var hasUnreadNotifications = Bool.random()
|
var hasUnreadNotifications = Bool.random()
|
||||||
|
var canUserTriggerRoomNotification = false
|
||||||
|
|
||||||
|
var timeline = {
|
||||||
|
let mock = TimelineProxyMock()
|
||||||
|
mock.underlyingActions = Empty(completeImmediately: false).eraseToAnyPublisher()
|
||||||
|
return mock
|
||||||
|
}()
|
||||||
|
|
||||||
var members: [RoomMemberProxyProtocol]?
|
var members: [RoomMemberProxyProtocol]?
|
||||||
var inviter: RoomMemberProxyMock?
|
var inviter: RoomMemberProxyMock?
|
||||||
var memberForID: RoomMemberProxyMock = .mockMe
|
var memberForID: RoomMemberProxyMock = .mockMe
|
||||||
var ownUserID = "@alice:somewhere.org"
|
var ownUserID = "@alice:somewhere.org"
|
||||||
var canUserTriggerRoomNotification = false
|
|
||||||
|
|
||||||
var invitedMembersCount = 100
|
var invitedMembersCount = 100
|
||||||
var joinedMembersCount = 50
|
var joinedMembersCount = 50
|
||||||
var activeMembersCount = 25
|
var activeMembersCount = 25
|
||||||
@@ -62,6 +69,8 @@ extension RoomProxyMock {
|
|||||||
alternativeAliases = configuration.alternativeAliases
|
alternativeAliases = configuration.alternativeAliases
|
||||||
hasUnreadNotifications = configuration.hasUnreadNotifications
|
hasUnreadNotifications = configuration.hasUnreadNotifications
|
||||||
|
|
||||||
|
timeline = configuration.timeline
|
||||||
|
|
||||||
invitedMembersCount = configuration.invitedMembersCount
|
invitedMembersCount = configuration.invitedMembersCount
|
||||||
joinedMembersCount = configuration.joinedMembersCount
|
joinedMembersCount = configuration.joinedMembersCount
|
||||||
activeMembersCount = configuration.activeMembersCount
|
activeMembersCount = configuration.activeMembersCount
|
||||||
@@ -79,7 +88,7 @@ extension RoomProxyMock {
|
|||||||
|
|
||||||
updateMembersClosure = { }
|
updateMembersClosure = { }
|
||||||
acceptInvitationClosure = { .success(()) }
|
acceptInvitationClosure = { .success(()) }
|
||||||
underlyingStateUpdatesPublisher = Empty(completeImmediately: false).eraseToAnyPublisher()
|
underlyingActions = Empty(completeImmediately: false).eraseToAnyPublisher()
|
||||||
setNameClosure = { _ in .success(()) }
|
setNameClosure = { _ in .success(()) }
|
||||||
setTopicClosure = { _ in .success(()) }
|
setTopicClosure = { _ in .success(()) }
|
||||||
getMemberUserIDReturnValue = .success(configuration.memberForID)
|
getMemberUserIDReturnValue = .success(configuration.memberForID)
|
||||||
|
|||||||
@@ -120,7 +120,8 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
|
|||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
|
|
||||||
private func setupRoomSubscription() {
|
private func setupRoomSubscription() {
|
||||||
roomProxy.stateUpdatesPublisher
|
roomProxy.actions
|
||||||
|
.filter { $0 == .stateUpdate }
|
||||||
.throttle(for: .milliseconds(200), scheduler: DispatchQueue.main, latest: true)
|
.throttle(for: .milliseconds(200), scheduler: DispatchQueue.main, latest: true)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
|||||||
@@ -279,7 +279,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
roomProxy
|
roomProxy
|
||||||
.stateUpdatesPublisher
|
.actions
|
||||||
|
.filter { $0 == .stateUpdate }
|
||||||
.throttle(for: .seconds(1), scheduler: DispatchQueue.main, latest: true)
|
.throttle(for: .seconds(1), scheduler: DispatchQueue.main, latest: true)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -288,6 +289,14 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
self.state.hasOngoingCall = roomProxy.hasOngoingCall
|
self.state.hasOngoingCall = roomProxy.hasOngoingCall
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
roomProxy.timeline.actions
|
||||||
|
.filter { $0 == .sentMessage }
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.state.timelineViewState.scrollToBottomPublisher.send(())
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
appSettings.$timelineStyle
|
appSettings.$timelineStyle
|
||||||
.weakAssign(to: \.state.timelineStyle, on: self)
|
.weakAssign(to: \.state.timelineStyle, on: self)
|
||||||
@@ -468,8 +477,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
case .recordVoiceMessage, .previewVoiceMessage:
|
case .recordVoiceMessage, .previewVoiceMessage:
|
||||||
fatalError("invalid composer mode.")
|
fatalError("invalid composer mode.")
|
||||||
}
|
}
|
||||||
|
|
||||||
state.timelineViewState.scrollToBottomPublisher.send(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func trackComposerMode(_ mode: RoomScreenComposerMode) {
|
private func trackComposerMode(_ mode: RoomScreenComposerMode) {
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ class RoomProxy: RoomProxyProtocol {
|
|||||||
membersSubject.asCurrentValuePublisher()
|
membersSubject.asCurrentValuePublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
private let stateUpdatesSubject = PassthroughSubject<Void, Never>()
|
private let actionsSubject = PassthroughSubject<RoomProxyAction, Never>()
|
||||||
var stateUpdatesPublisher: AnyPublisher<Void, Never> {
|
var actions: AnyPublisher<RoomProxyAction, Never> {
|
||||||
stateUpdatesSubject.eraseToAnyPublisher()
|
actionsSubject.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
var ownUserID: String {
|
var ownUserID: String {
|
||||||
@@ -407,7 +407,7 @@ class RoomProxy: RoomProxyProtocol {
|
|||||||
private func subscribeToRoomStateUpdates() {
|
private func subscribeToRoomStateUpdates() {
|
||||||
roomInfoObservationToken = room.subscribeToRoomInfoUpdates(listener: RoomInfoUpdateListener { [weak self] in
|
roomInfoObservationToken = room.subscribeToRoomInfoUpdates(listener: RoomInfoUpdateListener { [weak self] in
|
||||||
MXLog.info("Received room info update")
|
MXLog.info("Received room info update")
|
||||||
self?.stateUpdatesSubject.send(())
|
self?.actionsSubject.send(.stateUpdate)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ enum RoomProxyError: Error, Equatable {
|
|||||||
case failedCheckingPermission
|
case failedCheckingPermission
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum RoomProxyAction {
|
||||||
|
case stateUpdate
|
||||||
|
}
|
||||||
|
|
||||||
// sourcery: AutoMockable
|
// sourcery: AutoMockable
|
||||||
protocol RoomProxyProtocol {
|
protocol RoomProxyProtocol {
|
||||||
var id: String { get }
|
var id: String { get }
|
||||||
@@ -65,9 +69,7 @@ protocol RoomProxyProtocol {
|
|||||||
|
|
||||||
var activeMembersCount: Int { get }
|
var activeMembersCount: Int { get }
|
||||||
|
|
||||||
/// Publishes room state updates
|
var actions: AnyPublisher<RoomProxyAction, Never> { get }
|
||||||
/// The thread on which this publisher sends the output isn't defined.
|
|
||||||
var stateUpdatesPublisher: AnyPublisher<Void, Never> { get }
|
|
||||||
|
|
||||||
var timeline: TimelineProxyProtocol { get }
|
var timeline: TimelineProxyProtocol { get }
|
||||||
|
|
||||||
|
|||||||
@@ -28,17 +28,17 @@ final class TimelineProxy: TimelineProxyProtocol {
|
|||||||
private let messageSendingDispatchQueue = DispatchQueue(label: "io.element.elementx.roomproxy.message_sending", qos: .userInitiated)
|
private let messageSendingDispatchQueue = DispatchQueue(label: "io.element.elementx.roomproxy.message_sending", qos: .userInitiated)
|
||||||
private let userInitiatedDispatchQueue = DispatchQueue(label: "io.element.elementx.roomproxy.user_initiated", qos: .userInitiated)
|
private let userInitiatedDispatchQueue = DispatchQueue(label: "io.element.elementx.roomproxy.user_initiated", qos: .userInitiated)
|
||||||
|
|
||||||
init(timeline: Timeline, backgroundTaskService: BackgroundTaskServiceProtocol) {
|
|
||||||
self.timeline = timeline
|
|
||||||
self.backgroundTaskService = backgroundTaskService
|
|
||||||
}
|
|
||||||
|
|
||||||
private var backPaginationStateObservationToken: TaskHandle?
|
private var backPaginationStateObservationToken: TaskHandle?
|
||||||
private var roomTimelineObservationToken: TaskHandle?
|
private var roomTimelineObservationToken: TaskHandle?
|
||||||
private var timelineListener: RoomTimelineListener?
|
private var timelineListener: RoomTimelineListener?
|
||||||
|
|
||||||
private let backPaginationStateSubject = PassthroughSubject<BackPaginationStatus, Never>()
|
private let backPaginationStateSubject = PassthroughSubject<BackPaginationStatus, Never>()
|
||||||
private let timelineUpdatesSubject = PassthroughSubject<[TimelineDiff], Never>()
|
private let timelineUpdatesSubject = PassthroughSubject<[TimelineDiff], Never>()
|
||||||
|
|
||||||
|
private let actionsSubject = PassthroughSubject<TimelineProxyAction, Never>()
|
||||||
|
var actions: AnyPublisher<TimelineProxyAction, Never> {
|
||||||
|
actionsSubject.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
private var innerTimelineProvider: RoomTimelineProviderProtocol!
|
private var innerTimelineProvider: RoomTimelineProviderProtocol!
|
||||||
var timelineProvider: RoomTimelineProviderProtocol {
|
var timelineProvider: RoomTimelineProviderProtocol {
|
||||||
@@ -54,6 +54,11 @@ final class TimelineProxy: TimelineProxyProtocol {
|
|||||||
roomTimelineObservationToken?.cancel()
|
roomTimelineObservationToken?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init(timeline: Timeline, backgroundTaskService: BackgroundTaskServiceProtocol) {
|
||||||
|
self.timeline = timeline
|
||||||
|
self.backgroundTaskService = backgroundTaskService
|
||||||
|
}
|
||||||
|
|
||||||
func subscribeForUpdates() async {
|
func subscribeForUpdates() async {
|
||||||
guard innerTimelineProvider == nil else {
|
guard innerTimelineProvider == nil else {
|
||||||
MXLog.warning("Timeline already subscribed for updates")
|
MXLog.warning("Timeline already subscribed for updates")
|
||||||
@@ -104,6 +109,7 @@ final class TimelineProxy: TimelineProxyProtocol {
|
|||||||
do {
|
do {
|
||||||
let originalEvent = try self.timeline.getEventTimelineItemByEventId(eventId: eventID)
|
let originalEvent = try self.timeline.getEventTimelineItemByEventId(eventId: eventID)
|
||||||
try self.timeline.edit(newContent: messageContent, editItem: originalEvent)
|
try self.timeline.edit(newContent: messageContent, editItem: originalEvent)
|
||||||
|
|
||||||
return .success(())
|
return .success(())
|
||||||
} catch {
|
} catch {
|
||||||
return .failure(.failedEditingMessage)
|
return .failure(.failedEditingMessage)
|
||||||
@@ -178,6 +184,8 @@ final class TimelineProxy: TimelineProxyProtocol {
|
|||||||
return .failure(.failedSendingMedia)
|
return .failure(.failedSendingMedia)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actionsSubject.send(.sentMessage)
|
||||||
|
|
||||||
return .success(())
|
return .success(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +210,8 @@ final class TimelineProxy: TimelineProxyProtocol {
|
|||||||
return .failure(.failedSendingMedia)
|
return .failure(.failedSendingMedia)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actionsSubject.send(.sentMessage)
|
||||||
|
|
||||||
return .success(())
|
return .success(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +237,8 @@ final class TimelineProxy: TimelineProxyProtocol {
|
|||||||
return .failure(.failedSendingMedia)
|
return .failure(.failedSendingMedia)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actionsSubject.send(.sentMessage)
|
||||||
|
|
||||||
return .success(())
|
return .success(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,11 +253,15 @@ final class TimelineProxy: TimelineProxyProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return await Task.dispatch(on: messageSendingDispatchQueue) {
|
return await Task.dispatch(on: messageSendingDispatchQueue) {
|
||||||
.success(self.timeline.sendLocation(body: body,
|
self.timeline.sendLocation(body: body,
|
||||||
geoUri: geoURI.string,
|
geoUri: geoURI.string,
|
||||||
description: description,
|
description: description,
|
||||||
zoomLevel: zoomLevel,
|
zoomLevel: zoomLevel,
|
||||||
assetType: assetType))
|
assetType: assetType)
|
||||||
|
|
||||||
|
self.actionsSubject.send(.sentMessage)
|
||||||
|
|
||||||
|
return .success(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,6 +287,8 @@ final class TimelineProxy: TimelineProxyProtocol {
|
|||||||
return .failure(.failedSendingMedia)
|
return .failure(.failedSendingMedia)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actionsSubject.send(.sentMessage)
|
||||||
|
|
||||||
return .success(())
|
return .success(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,6 +314,8 @@ final class TimelineProxy: TimelineProxyProtocol {
|
|||||||
return .failure(.failedSendingMedia)
|
return .failure(.failedSendingMedia)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actionsSubject.send(.sentMessage)
|
||||||
|
|
||||||
return .success(())
|
return .success(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,6 +343,9 @@ final class TimelineProxy: TimelineProxyProtocol {
|
|||||||
} catch {
|
} catch {
|
||||||
return .failure(.failedSendingMessage)
|
return .failure(.failedSendingMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.actionsSubject.send(.sentMessage)
|
||||||
|
|
||||||
return .success(())
|
return .success(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,6 +358,9 @@ final class TimelineProxy: TimelineProxyProtocol {
|
|||||||
|
|
||||||
return await Task.dispatch(on: messageSendingDispatchQueue) {
|
return await Task.dispatch(on: messageSendingDispatchQueue) {
|
||||||
self.timeline.send(msg: messageContent)
|
self.timeline.send(msg: messageContent)
|
||||||
|
|
||||||
|
self.actionsSubject.send(.sentMessage)
|
||||||
|
|
||||||
return .success(())
|
return .success(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -376,7 +402,11 @@ final class TimelineProxy: TimelineProxyProtocol {
|
|||||||
func createPoll(question: String, answers: [String], pollKind: Poll.Kind) async -> Result<Void, TimelineProxyError> {
|
func createPoll(question: String, answers: [String], pollKind: Poll.Kind) async -> Result<Void, TimelineProxyError> {
|
||||||
await Task.dispatch(on: .global()) {
|
await Task.dispatch(on: .global()) {
|
||||||
do {
|
do {
|
||||||
return try .success(self.timeline.createPoll(question: question, answers: answers, maxSelections: 1, pollKind: .init(pollKind: pollKind)))
|
try self.timeline.createPoll(question: question, answers: answers, maxSelections: 1, pollKind: .init(pollKind: pollKind))
|
||||||
|
|
||||||
|
self.actionsSubject.send(.sentMessage)
|
||||||
|
|
||||||
|
return .success(())
|
||||||
} catch {
|
} catch {
|
||||||
MXLog.error("Failed creating a poll: \(error)")
|
MXLog.error("Failed creating a poll: \(error)")
|
||||||
return .failure(.failedCreatingPoll)
|
return .failure(.failedCreatingPoll)
|
||||||
|
|||||||
@@ -33,9 +33,16 @@ enum TimelineProxyError: Error, Equatable {
|
|||||||
case failedSendingPollResponse
|
case failedSendingPollResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum TimelineProxyAction {
|
||||||
|
case sentMessage
|
||||||
|
}
|
||||||
|
|
||||||
// sourcery: AutoMockable
|
// sourcery: AutoMockable
|
||||||
protocol TimelineProxyProtocol {
|
protocol TimelineProxyProtocol {
|
||||||
|
var actions: AnyPublisher<TimelineProxyAction, Never> { get }
|
||||||
|
|
||||||
var timelineProvider: RoomTimelineProviderProtocol { get }
|
var timelineProvider: RoomTimelineProviderProtocol { get }
|
||||||
|
|
||||||
func subscribeForUpdates() async
|
func subscribeForUpdates() async
|
||||||
|
|
||||||
/// Cancels a failed message given its transaction ID from the timeline
|
/// Cancels a failed message given its transaction ID from the timeline
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ final class CompletionSuggestionServiceTests: XCTestCase {
|
|||||||
func testUserSuggestonsIncludingAllUsers() async throws {
|
func testUserSuggestonsIncludingAllUsers() async throws {
|
||||||
let alice: RoomMemberProxyMock = .mockAlice
|
let alice: RoomMemberProxyMock = .mockAlice
|
||||||
let members: [RoomMemberProxyMock] = [alice, .mockBob, .mockCharlie, .mockMe]
|
let members: [RoomMemberProxyMock] = [alice, .mockBob, .mockCharlie, .mockMe]
|
||||||
let roomProxyMock = RoomProxyMock(with: .init(displayName: "test", members: members, canUserTriggerRoomNotification: true))
|
let roomProxyMock = RoomProxyMock(with: .init(displayName: "test", canUserTriggerRoomNotification: true, members: members))
|
||||||
let service = CompletionSuggestionService(roomProxy: roomProxyMock)
|
let service = CompletionSuggestionService(roomProxy: roomProxyMock)
|
||||||
|
|
||||||
var deferred = deferFulfillment(service.suggestionsPublisher) { suggestions in
|
var deferred = deferFulfillment(service.suggestionsPublisher) { suggestions in
|
||||||
@@ -92,7 +92,7 @@ final class CompletionSuggestionServiceTests: XCTestCase {
|
|||||||
let alice: RoomMemberProxyMock = .mockAlice
|
let alice: RoomMemberProxyMock = .mockAlice
|
||||||
let bob: RoomMemberProxyMock = .mockBob
|
let bob: RoomMemberProxyMock = .mockBob
|
||||||
let members: [RoomMemberProxyMock] = [alice, bob, .mockMe]
|
let members: [RoomMemberProxyMock] = [alice, bob, .mockMe]
|
||||||
let roomProxyMock = RoomProxyMock(with: .init(displayName: "test", members: members, canUserTriggerRoomNotification: true))
|
let roomProxyMock = RoomProxyMock(with: .init(displayName: "test", canUserTriggerRoomNotification: true, members: members))
|
||||||
let service = CompletionSuggestionService(roomProxy: roomProxyMock)
|
let service = CompletionSuggestionService(roomProxy: roomProxyMock)
|
||||||
|
|
||||||
var deferred = deferFulfillment(service.suggestionsPublisher) { suggestions in
|
var deferred = deferFulfillment(service.suggestionsPublisher) { suggestions in
|
||||||
|
|||||||
Reference in New Issue
Block a user