mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 12:07:55 +00:00
Various UI tweaks (#1112)
* Tweak invites button frame and padding * Implement persistent selections on the room list * Stop showing rooms when focusing the room list search field with an empty query * Tweaks after code review * Improve invite button UI * Fix unit tests
This commit is contained in:
@@ -36,6 +36,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
||||
private let sidebarNavigationStackCoordinator: NavigationStackCoordinator
|
||||
private let detailNavigationStackCoordinator: NavigationStackCoordinator
|
||||
|
||||
private let selectedRoomSubject = CurrentValueSubject<String?, Never>(nil)
|
||||
|
||||
var callback: ((UserSessionFlowCoordinatorAction) -> Void)?
|
||||
|
||||
init(userSession: UserSessionProtocol,
|
||||
@@ -109,7 +111,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
// swiftlint:disable:next cyclomatic_complexity function_body_length
|
||||
private func setupStateMachine() {
|
||||
stateMachine.addTransitionHandler { [weak self] context in
|
||||
guard let self else { return }
|
||||
@@ -160,6 +162,15 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
stateMachine.addTransitionHandler { [weak self] context in
|
||||
switch context.toState {
|
||||
case .roomList(let selectedRoomId):
|
||||
self?.selectedRoomSubject.send(selectedRoomId)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
stateMachine.addErrorHandler { context in
|
||||
fatalError("Failed transition with context: \(context)")
|
||||
}
|
||||
@@ -170,7 +181,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
||||
let parameters = HomeScreenCoordinatorParameters(userSession: userSession,
|
||||
attributedStringBuilder: AttributedStringBuilder(),
|
||||
bugReportService: bugReportService,
|
||||
navigationStackCoordinator: detailNavigationStackCoordinator)
|
||||
navigationStackCoordinator: detailNavigationStackCoordinator,
|
||||
selectedRoomPublisher: selectedRoomSubject.asCurrentValuePublisher())
|
||||
let coordinator = HomeScreenCoordinator(parameters: parameters)
|
||||
|
||||
coordinator.callback = { [weak self] action in
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
/// A view modifier that applies a shimmering effect to the view.
|
||||
@@ -72,12 +73,13 @@ extension View {
|
||||
struct ShimmerOverlay_Previews: PreviewProvider {
|
||||
static let viewModel = HomeScreenViewModel(userSession: MockUserSession(clientProxy: MockClientProxy(userID: ""),
|
||||
mediaProvider: MockMediaProvider()),
|
||||
attributedStringBuilder: AttributedStringBuilder())
|
||||
attributedStringBuilder: AttributedStringBuilder(),
|
||||
selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher())
|
||||
|
||||
static var previews: some View {
|
||||
VStack {
|
||||
ForEach(0...8, id: \.self) { _ in
|
||||
HomeScreenRoomCell(room: .placeholder(), context: viewModel.context)
|
||||
HomeScreenRoomCell(room: .placeholder(), context: viewModel.context, isSelected: false)
|
||||
}
|
||||
}
|
||||
.redacted(reason: .placeholder)
|
||||
|
||||
@@ -22,6 +22,7 @@ struct HomeScreenCoordinatorParameters {
|
||||
let attributedStringBuilder: AttributedStringBuilderProtocol
|
||||
let bugReportService: BugReportServiceProtocol
|
||||
let navigationStackCoordinator: NavigationStackCoordinator
|
||||
let selectedRoomPublisher: CurrentValuePublisher<String?, Never>
|
||||
}
|
||||
|
||||
enum HomeScreenCoordinatorAction {
|
||||
@@ -48,7 +49,8 @@ final class HomeScreenCoordinator: CoordinatorProtocol {
|
||||
self.parameters = parameters
|
||||
|
||||
viewModel = HomeScreenViewModel(userSession: parameters.userSession,
|
||||
attributedStringBuilder: parameters.attributedStringBuilder)
|
||||
attributedStringBuilder: parameters.attributedStringBuilder,
|
||||
selectedRoomPublisher: parameters.selectedRoomPublisher)
|
||||
|
||||
viewModel.callback = { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
@@ -74,6 +74,8 @@ struct HomeScreenViewState: BindableState {
|
||||
var hasPendingInvitations = false
|
||||
var hasUnreadPendingInvitations = false
|
||||
|
||||
var selectedRoomID: String?
|
||||
|
||||
var visibleRooms: [HomeScreenRoom] {
|
||||
if roomListMode == .skeletons {
|
||||
return placeholderRooms
|
||||
|
||||
@@ -31,7 +31,9 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
|
||||
var callback: ((HomeScreenViewModelAction) -> Void)?
|
||||
|
||||
// swiftlint:disable:next function_body_length cyclomatic_complexity
|
||||
init(userSession: UserSessionProtocol, attributedStringBuilder: AttributedStringBuilderProtocol) {
|
||||
init(userSession: UserSessionProtocol,
|
||||
attributedStringBuilder: AttributedStringBuilderProtocol,
|
||||
selectedRoomPublisher: CurrentValuePublisher<String?, Never>) {
|
||||
self.userSession = userSession
|
||||
self.attributedStringBuilder = attributedStringBuilder
|
||||
|
||||
@@ -59,6 +61,10 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
|
||||
.weakAssign(to: \.state.userAvatarURL, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
selectedRoomPublisher
|
||||
.weakAssign(to: \.state.selectedRoomID, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
guard let roomSummaryProvider, let inviteSummaryProvider else {
|
||||
MXLog.error("Room summary provider unavailable")
|
||||
return
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
@@ -25,27 +26,27 @@ struct HomeScreen: View {
|
||||
@ObservedObject var context: HomeScreenViewModel.Context
|
||||
|
||||
@State private var scrollViewAdapter = ScrollViewAdapter()
|
||||
@State private var showingLogoutConfirmation = false
|
||||
@State private var showingBottomToolbar = true
|
||||
@State private var isSearching = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
if context.viewState.showSessionVerificationBanner {
|
||||
sessionVerificationBanner
|
||||
}
|
||||
|
||||
if context.viewState.hasPendingInvitations {
|
||||
|
||||
if context.viewState.hasPendingInvitations, !isSearching {
|
||||
HomeScreenInvitesButton(title: L10n.actionInvitesList, hasBadge: context.viewState.hasUnreadPendingInvitations) {
|
||||
context.send(viewAction: .selectInvites)
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
.padding(.vertical, -8.0)
|
||||
}
|
||||
|
||||
if context.viewState.roomListMode == .skeletons {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(context.viewState.visibleRooms) { room in
|
||||
HomeScreenRoomCell(room: room, context: context)
|
||||
HomeScreenRoomCell(room: room, context: context, isSelected: false)
|
||||
.redacted(reason: .placeholder)
|
||||
}
|
||||
}
|
||||
@@ -53,29 +54,7 @@ struct HomeScreen: View {
|
||||
.disabled(true)
|
||||
} else {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(context.viewState.visibleRooms) { room in
|
||||
Group {
|
||||
if room.isPlaceholder {
|
||||
HomeScreenRoomCell(room: room, context: context)
|
||||
.redacted(reason: .placeholder)
|
||||
} else {
|
||||
HomeScreenRoomCell(room: room, context: context)
|
||||
.contextMenu {
|
||||
Button {
|
||||
context.send(viewAction: .showRoomDetails(roomIdentifier: room.id))
|
||||
} label: {
|
||||
Label(L10n.commonSettings, systemImage: "gearshape")
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
context.send(viewAction: .leaveRoom(roomIdentifier: room.id))
|
||||
} label: {
|
||||
Label(L10n.actionLeaveRoom, systemImage: "rectangle.portrait.and.arrow.right")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
HomeScreenRoomList(context: context, isSearching: $isSearching)
|
||||
}
|
||||
.searchable(text: $context.searchQuery)
|
||||
.compoundSearchField()
|
||||
@@ -132,7 +111,7 @@ struct HomeScreen: View {
|
||||
@ToolbarContentBuilder
|
||||
private var toolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
userMenuButton
|
||||
HomeScreenUserMenuButton(context: context)
|
||||
}
|
||||
|
||||
ToolbarItemGroup(placement: .bottomBar) {
|
||||
@@ -140,51 +119,11 @@ struct HomeScreen: View {
|
||||
newRoomButton
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var userMenuButton: some View {
|
||||
Menu {
|
||||
Section {
|
||||
Button(action: settings) {
|
||||
Label(L10n.commonSettings, systemImage: "gearshape")
|
||||
}
|
||||
}
|
||||
Section {
|
||||
MatrixUserShareLink(userID: context.viewState.userID) {
|
||||
Label(L10n.actionInvite, systemImage: "square.and.arrow.up")
|
||||
}
|
||||
Button(action: feedback) {
|
||||
Label(L10n.commonReportABug, systemImage: "ladybug")
|
||||
}
|
||||
}
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
showingLogoutConfirmation = true
|
||||
} label: {
|
||||
Label(L10n.screenSignoutPreferenceItem, systemImage: "rectangle.portrait.and.arrow.right")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
LoadableAvatarImage(url: context.viewState.userAvatarURL,
|
||||
name: context.viewState.userDisplayName,
|
||||
contentID: context.viewState.userID,
|
||||
avatarSize: .user(on: .home),
|
||||
imageProvider: context.imageProvider)
|
||||
.accessibilityIdentifier(A11yIdentifiers.homeScreen.userAvatar)
|
||||
}
|
||||
.alert(L10n.screenSignoutConfirmationDialogTitle,
|
||||
isPresented: $showingLogoutConfirmation) {
|
||||
Button(L10n.screenSignoutConfirmationDialogSubmit,
|
||||
role: .destructive,
|
||||
action: signOut)
|
||||
} message: {
|
||||
Text(L10n.screenSignoutConfirmationDialogContent)
|
||||
}
|
||||
.accessibilityLabel(L10n.a11yUserMenu)
|
||||
}
|
||||
|
||||
private var newRoomButton: some View {
|
||||
Button(action: startChat) {
|
||||
Button {
|
||||
context.send(viewAction: .startChat)
|
||||
} label: {
|
||||
Image(systemName: "square.and.pencil")
|
||||
}
|
||||
}
|
||||
@@ -224,22 +163,6 @@ struct HomeScreen: View {
|
||||
.cornerRadius(14)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
private func settings() {
|
||||
context.send(viewAction: .userMenu(action: .settings))
|
||||
}
|
||||
|
||||
private func startChat() {
|
||||
context.send(viewAction: .startChat)
|
||||
}
|
||||
|
||||
private func feedback() {
|
||||
context.send(viewAction: .userMenu(action: .feedback))
|
||||
}
|
||||
|
||||
private func signOut() {
|
||||
context.send(viewAction: .userMenu(action: .signOut))
|
||||
}
|
||||
|
||||
private func updateVisibleRange() {
|
||||
guard let scrollView = scrollViewAdapter.scrollView,
|
||||
@@ -292,7 +215,8 @@ struct HomeScreen_Previews: PreviewProvider {
|
||||
mediaProvider: MockMediaProvider())
|
||||
|
||||
let viewModel = HomeScreenViewModel(userSession: userSession,
|
||||
attributedStringBuilder: AttributedStringBuilder())
|
||||
attributedStringBuilder: AttributedStringBuilder(),
|
||||
selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher())
|
||||
|
||||
return NavigationStack {
|
||||
HomeScreen(context: viewModel.context)
|
||||
|
||||
@@ -25,7 +25,7 @@ struct HomeScreenInvitesButton: View {
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: 8.0) {
|
||||
Text(title)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
.font(.compound.bodyMDSemibold)
|
||||
@@ -34,7 +34,9 @@ struct HomeScreenInvitesButton: View {
|
||||
badge
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.padding(.trailing, 16.0)
|
||||
.padding(.leading, 64.0)
|
||||
.padding(.vertical, 8.0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct HomeScreenRoomCell: View {
|
||||
@@ -22,6 +23,7 @@ struct HomeScreenRoomCell: View {
|
||||
|
||||
let room: HomeScreenRoom
|
||||
let context: HomeScreenViewModel.Context
|
||||
let isSelected: Bool
|
||||
|
||||
private let verticalInsets = 12.0
|
||||
private let horizontalInsets = 16.0
|
||||
@@ -47,7 +49,7 @@ struct HomeScreenRoomCell: View {
|
||||
.padding(.horizontal, horizontalInsets)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
.buttonStyle(HomeScreenRoomCellButtonStyle(isSelected: false))
|
||||
.buttonStyle(HomeScreenRoomCellButtonStyle(isSelected: isSelected))
|
||||
.accessibilityIdentifier(A11yIdentifiers.homeScreen.roomName(room.name))
|
||||
}
|
||||
|
||||
@@ -149,8 +151,9 @@ struct HomeScreenRoomCellButtonStyle: ButtonStyle {
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.background(configuration.isPressed || isSelected ? Color.compound.bgSubtleSecondary : .clear)
|
||||
.background(isSelected ? Color.compound.bgSubtleSecondary : Color.compound.bgCanvasDefault)
|
||||
.contentShape(Rectangle())
|
||||
.animation(.elementDefault, value: isSelected)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +174,8 @@ struct HomeScreenRoomCell_Previews: PreviewProvider {
|
||||
mediaProvider: MockMediaProvider())
|
||||
|
||||
let viewModel = HomeScreenViewModel(userSession: userSession,
|
||||
attributedStringBuilder: AttributedStringBuilder())
|
||||
attributedStringBuilder: AttributedStringBuilder(),
|
||||
selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher())
|
||||
|
||||
let rooms: [HomeScreenRoom] = summaryProvider.roomListPublisher.value.compactMap { summary -> HomeScreenRoom? in
|
||||
switch summary {
|
||||
@@ -190,14 +194,14 @@ struct HomeScreenRoomCell_Previews: PreviewProvider {
|
||||
|
||||
return VStack(spacing: 0) {
|
||||
ForEach(rooms) { room in
|
||||
HomeScreenRoomCell(room: room, context: viewModel.context)
|
||||
HomeScreenRoomCell(room: room, context: viewModel.context, isSelected: false)
|
||||
}
|
||||
|
||||
HomeScreenRoomCell(room: .placeholder(), context: viewModel.context)
|
||||
HomeScreenRoomCell(room: .placeholder(), context: viewModel.context, isSelected: false)
|
||||
.redacted(reason: .placeholder)
|
||||
HomeScreenRoomCell(room: .placeholder(), context: viewModel.context)
|
||||
HomeScreenRoomCell(room: .placeholder(), context: viewModel.context, isSelected: false)
|
||||
.redacted(reason: .placeholder)
|
||||
HomeScreenRoomCell(room: .placeholder(), context: viewModel.context)
|
||||
HomeScreenRoomCell(room: .placeholder(), context: viewModel.context, isSelected: false)
|
||||
.redacted(reason: .placeholder)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct HomeScreenRoomList: View {
|
||||
@Environment(\.isSearching) var isSearchFieldFocused
|
||||
|
||||
@ObservedObject var context: HomeScreenViewModel.Context
|
||||
@Binding var isSearching: Bool
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.onChange(of: isSearchFieldFocused) { isSearching = $0 }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if isSearchFieldFocused, context.searchQuery.count == 0 {
|
||||
EmptyView()
|
||||
} else {
|
||||
ForEach(context.viewState.visibleRooms) { room in
|
||||
if room.isPlaceholder {
|
||||
HomeScreenRoomCell(room: room, context: context, isSelected: false)
|
||||
.redacted(reason: .placeholder)
|
||||
} else {
|
||||
let isSelected = context.viewState.selectedRoomID == room.id
|
||||
HomeScreenRoomCell(room: room, context: context, isSelected: isSelected)
|
||||
.contextMenu {
|
||||
Button {
|
||||
context.send(viewAction: .showRoomDetails(roomIdentifier: room.id))
|
||||
} label: {
|
||||
Label(L10n.commonSettings, systemImage: "gearshape")
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
context.send(viewAction: .leaveRoom(roomIdentifier: room.id))
|
||||
} label: {
|
||||
Label(L10n.actionLeaveRoom, systemImage: "rectangle.portrait.and.arrow.right")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct HomeScreenUserMenuButton: View {
|
||||
@State private var showingLogoutConfirmation = false
|
||||
|
||||
let context: HomeScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
Section {
|
||||
Button {
|
||||
context.send(viewAction: .userMenu(action: .settings))
|
||||
} label: {
|
||||
Label(L10n.commonSettings, systemImage: "gearshape")
|
||||
}
|
||||
}
|
||||
Section {
|
||||
MatrixUserShareLink(userID: context.viewState.userID) {
|
||||
Label(L10n.actionInvite, systemImage: "square.and.arrow.up")
|
||||
}
|
||||
Button {
|
||||
context.send(viewAction: .userMenu(action: .feedback))
|
||||
} label: {
|
||||
Label(L10n.commonReportABug, systemImage: "ladybug")
|
||||
}
|
||||
}
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
showingLogoutConfirmation = true
|
||||
} label: {
|
||||
Label(L10n.screenSignoutPreferenceItem, systemImage: "rectangle.portrait.and.arrow.right")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
LoadableAvatarImage(url: context.viewState.userAvatarURL,
|
||||
name: context.viewState.userDisplayName,
|
||||
contentID: context.viewState.userID,
|
||||
avatarSize: .user(on: .home),
|
||||
imageProvider: context.imageProvider)
|
||||
.accessibilityIdentifier(A11yIdentifiers.homeScreen.userAvatar)
|
||||
}
|
||||
.alert(L10n.screenSignoutConfirmationDialogTitle,
|
||||
isPresented: $showingLogoutConfirmation) {
|
||||
Button(L10n.screenSignoutConfirmationDialogSubmit,
|
||||
role: .destructive) {
|
||||
context.send(viewAction: .userMenu(action: .signOut))
|
||||
}
|
||||
} message: {
|
||||
Text(L10n.screenSignoutConfirmationDialogContent)
|
||||
}
|
||||
.accessibilityLabel(L10n.a11yUserMenu)
|
||||
}
|
||||
}
|
||||
@@ -117,7 +117,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
// swiftlint:disable:next function_body_length
|
||||
private func setupSubscriptions() {
|
||||
timelineController.callbacks
|
||||
.receive(on: DispatchQueue.main)
|
||||
|
||||
@@ -134,7 +134,8 @@ class MockScreen: Identifiable {
|
||||
let coordinator = HomeScreenCoordinator(parameters: .init(userSession: session,
|
||||
attributedStringBuilder: AttributedStringBuilder(),
|
||||
bugReportService: BugReportServiceMock(),
|
||||
navigationStackCoordinator: navigationStackCoordinator))
|
||||
navigationStackCoordinator: navigationStackCoordinator,
|
||||
selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher()))
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
return navigationStackCoordinator
|
||||
case .settings:
|
||||
|
||||
Reference in New Issue
Block a user