mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 10:33:45 +00:00
Set up Analytics to track data per session (#780)
This commit is contained in:
@@ -16,21 +16,13 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct AnalyticsPromptCoordinatorParameters {
|
||||
/// The user session to use if analytics are enabled.
|
||||
let userSession: UserSessionProtocol
|
||||
}
|
||||
|
||||
final class AnalyticsPromptCoordinator: CoordinatorProtocol {
|
||||
private let parameters: AnalyticsPromptCoordinatorParameters
|
||||
private var viewModel: AnalyticsPromptViewModel
|
||||
|
||||
var callback: (@MainActor () -> Void)?
|
||||
|
||||
init(parameters: AnalyticsPromptCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
viewModel = AnalyticsPromptViewModel(termsURL: ServiceLocator.shared.settings.analyticsConfiguration.termsURL)
|
||||
init() {
|
||||
viewModel = AnalyticsPromptViewModel()
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
@@ -42,11 +34,11 @@ final class AnalyticsPromptCoordinator: CoordinatorProtocol {
|
||||
switch result {
|
||||
case .enable:
|
||||
MXLog.info("Enable Analytics")
|
||||
Analytics.shared.optIn(with: self.parameters.userSession)
|
||||
ServiceLocator.shared.analytics.optIn()
|
||||
self.callback?()
|
||||
case .disable:
|
||||
MXLog.info("Disable Analytics")
|
||||
Analytics.shared.optOut()
|
||||
ServiceLocator.shared.analytics.optOut()
|
||||
self.callback?()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,22 +32,24 @@ enum AnalyticsPromptViewModelAction {
|
||||
|
||||
struct AnalyticsPromptViewState: BindableState {
|
||||
/// Attributed strings created from localized HTML.
|
||||
let strings = AnalyticsPromptStrings()
|
||||
let strings: AnalyticsPromptStrings
|
||||
}
|
||||
|
||||
/// A collection of strings for the UI that need to be parsed from HTML
|
||||
struct AnalyticsPromptStrings {
|
||||
let optInContent: AttributedString
|
||||
let point1 = AttributedStringBuilder().fromHTML(UntranslatedL10n.analyticsOptInListItem1) ?? AttributedString(UntranslatedL10n.analyticsOptInListItem1)
|
||||
let point2 = AttributedStringBuilder().fromHTML(UntranslatedL10n.analyticsOptInListItem2) ?? AttributedString(UntranslatedL10n.analyticsOptInListItem2)
|
||||
let point1 = AttributedStringBuilder().fromHTML(L10n.screenAnalyticsPromptDataUsage) ?? AttributedString(L10n.screenAnalyticsPromptDataUsage)
|
||||
let point2 = AttributedStringBuilder().fromHTML(L10n.screenAnalyticsPromptThirdPartySharing) ?? AttributedString(L10n.screenAnalyticsPromptThirdPartySharing)
|
||||
let point3 = L10n.screenAnalyticsPromptSettings
|
||||
|
||||
init() {
|
||||
init(termsURL: URL) {
|
||||
let content = AttributedString(L10n.screenAnalyticsPromptHelpUsImprove(InfoPlistReader.main.bundleDisplayName))
|
||||
// Create the opt in content with a placeholder.
|
||||
let linkPlaceholder = "{link}"
|
||||
var optInContent = AttributedString(UntranslatedL10n.analyticsOptInContent(InfoPlistReader.main.bundleDisplayName, linkPlaceholder))
|
||||
optInContent.replace(linkPlaceholder,
|
||||
with: UntranslatedL10n.analyticsOptInContentLink,
|
||||
asLinkTo: ServiceLocator.shared.settings.analyticsConfiguration.termsURL)
|
||||
self.optInContent = optInContent
|
||||
var readTerms = AttributedString(L10n.screenAnalyticsPromptReadTerms(linkPlaceholder))
|
||||
readTerms.replace(linkPlaceholder,
|
||||
with: L10n.screenAnalyticsPromptReadTermsContentLink,
|
||||
asLinkTo: termsURL)
|
||||
optInContent = content + "\n\n" + readTerms
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,14 +20,12 @@ import SwiftUI
|
||||
typealias AnalyticsPromptViewModelType = StateStoreViewModel<AnalyticsPromptViewState, AnalyticsPromptViewAction>
|
||||
|
||||
class AnalyticsPromptViewModel: AnalyticsPromptViewModelType, AnalyticsPromptViewModelProtocol {
|
||||
private let termsURL: URL
|
||||
|
||||
var callback: (@MainActor (AnalyticsPromptViewModelAction) -> Void)?
|
||||
|
||||
/// Initialize a view model with the specified prompt type and app display name.
|
||||
init(termsURL: URL) {
|
||||
self.termsURL = termsURL
|
||||
super.init(initialViewState: AnalyticsPromptViewState())
|
||||
init() {
|
||||
let promptStrings = AnalyticsPromptStrings(termsURL: ServiceLocator.shared.settings.analyticsConfiguration.termsURL)
|
||||
super.init(initialViewState: AnalyticsPromptViewState(strings: promptStrings))
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
@@ -57,9 +57,9 @@ struct AnalyticsPrompt: View {
|
||||
private var mainContent: some View {
|
||||
VStack {
|
||||
Image(uiImage: Asset.Images.analyticsLogo.image)
|
||||
.padding(.bottom, 25)
|
||||
.padding(.bottom, 24)
|
||||
|
||||
Text(UntranslatedL10n.analyticsOptInTitle(InfoPlistReader.main.bundleDisplayName))
|
||||
Text(L10n.screenAnalyticsPromptTitle(InfoPlistReader.main.bundleDisplayName))
|
||||
.font(.element.title2Bold)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.element.primaryContent)
|
||||
@@ -73,7 +73,7 @@ struct AnalyticsPrompt: View {
|
||||
|
||||
Divider()
|
||||
.background(Color.element.quinaryContent)
|
||||
.padding(.vertical, 28)
|
||||
.padding(.vertical, 20)
|
||||
|
||||
checkmarkList
|
||||
}
|
||||
@@ -81,10 +81,10 @@ struct AnalyticsPrompt: View {
|
||||
|
||||
/// The list of re-assurances about analytics.
|
||||
private var checkmarkList: some View {
|
||||
VStack(alignment: .leading) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
AnalyticsPromptCheckmarkItem(attributedString: context.viewState.strings.point1)
|
||||
AnalyticsPromptCheckmarkItem(attributedString: context.viewState.strings.point1)
|
||||
AnalyticsPromptCheckmarkItem(string: UntranslatedL10n.analyticsOptInListItem3)
|
||||
AnalyticsPromptCheckmarkItem(attributedString: context.viewState.strings.point2)
|
||||
AnalyticsPromptCheckmarkItem(string: context.viewState.strings.point3)
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.font(.element.body)
|
||||
@@ -113,7 +113,7 @@ struct AnalyticsPrompt: View {
|
||||
// MARK: - Previews
|
||||
|
||||
struct AnalyticsPrompt_Previews: PreviewProvider {
|
||||
static let viewModel = AnalyticsPromptViewModel(termsURL: ServiceLocator.shared.settings.analyticsConfiguration.termsURL)
|
||||
static let viewModel = AnalyticsPromptViewModel()
|
||||
static var previews: some View {
|
||||
AnalyticsPrompt(context: viewModel.context)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ struct AnalyticsPromptCheckmarkItem: View {
|
||||
|
||||
var body: some View {
|
||||
Label { Text(attributedString) } icon: {
|
||||
Image(uiImage: Asset.Images.analyticsCheckmark.image)
|
||||
Image(systemName: "checkmark.circle")
|
||||
.foregroundColor(.element.accent)
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ struct AnalyticsPromptCheckmarkItem: View {
|
||||
// MARK: - Previews
|
||||
|
||||
struct AnalyticsPromptCheckmarkItem_Previews: PreviewProvider {
|
||||
static let strings = AnalyticsPromptStrings()
|
||||
static let strings = AnalyticsPromptStrings(termsURL: ServiceLocator.shared.settings.analyticsConfiguration.termsURL)
|
||||
|
||||
static var previews: some View {
|
||||
VStack(alignment: .leading) {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//
|
||||
// Copyright 2022 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 Combine
|
||||
import SwiftUI
|
||||
|
||||
final class AnalyticsSettingsScreenCoordinator: CoordinatorProtocol {
|
||||
private let viewModel: AnalyticsSettingsScreenViewModel
|
||||
|
||||
init() {
|
||||
viewModel = AnalyticsSettingsScreenViewModel()
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(AnalyticsSettingsScreen(context: viewModel.context))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//
|
||||
// Copyright 2022 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 Foundation
|
||||
|
||||
struct AnalyticsSettingsScreenViewState: BindableState {
|
||||
/// Attributed strings created from localized HTML.
|
||||
let strings: AnalyticsSettingsScreenStrings
|
||||
var bindings: AnalyticsSettingsScreenViewStateBindings
|
||||
}
|
||||
|
||||
struct AnalyticsSettingsScreenViewStateBindings {
|
||||
var enableAnalytics: Bool
|
||||
}
|
||||
|
||||
enum AnalyticsSettingsScreenViewAction {
|
||||
case toggleAnalytics
|
||||
}
|
||||
|
||||
struct AnalyticsSettingsScreenStrings {
|
||||
let sectionFooter: AttributedString
|
||||
|
||||
init(termsURL: URL) {
|
||||
let content = AttributedString(L10n.screenAnalyticsHelpUsImprove(InfoPlistReader.main.bundleDisplayName))
|
||||
// Create the 'read terms' with a placeholder.
|
||||
let linkPlaceholder = "{link}"
|
||||
var readTerms = AttributedString(L10n.screenAnalyticsReadTerms(linkPlaceholder))
|
||||
readTerms.replace(linkPlaceholder,
|
||||
with: L10n.screenAnalyticsReadTermsContentLink,
|
||||
asLinkTo: termsURL)
|
||||
sectionFooter = content + "\n\n" + readTerms
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// Copyright 2022 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 Combine
|
||||
import SwiftUI
|
||||
|
||||
typealias AnalyticsSettingsScreenViewModelType = StateStoreViewModel<AnalyticsSettingsScreenViewState, AnalyticsSettingsScreenViewAction>
|
||||
|
||||
class AnalyticsSettingsScreenViewModel: AnalyticsSettingsScreenViewModelType, AnalyticsSettingsScreenViewModelProtocol {
|
||||
init() {
|
||||
let strings = AnalyticsSettingsScreenStrings(termsURL: ServiceLocator.shared.settings.analyticsConfiguration.termsURL)
|
||||
let bindings = AnalyticsSettingsScreenViewStateBindings(enableAnalytics: ServiceLocator.shared.settings.enableAnalytics)
|
||||
let state = AnalyticsSettingsScreenViewState(strings: strings, bindings: bindings)
|
||||
|
||||
super.init(initialViewState: state)
|
||||
|
||||
ServiceLocator.shared.settings.$enableAnalytics
|
||||
.weakAssign(to: \.state.bindings.enableAnalytics, on: self)
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
override func process(viewAction: AnalyticsSettingsScreenViewAction) {
|
||||
switch viewAction {
|
||||
case .toggleAnalytics:
|
||||
if ServiceLocator.shared.settings.enableAnalytics {
|
||||
ServiceLocator.shared.analytics.optOut()
|
||||
} else {
|
||||
ServiceLocator.shared.analytics.optIn()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// Copyright 2022 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 Combine
|
||||
|
||||
@MainActor
|
||||
protocol AnalyticsSettingsScreenViewModelProtocol {
|
||||
var context: AnalyticsSettingsScreenViewModelType.Context { get }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//
|
||||
// Copyright 2022 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 AnalyticsSettingsScreen: View {
|
||||
@ObservedObject var context: AnalyticsSettingsScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
analyticsSection
|
||||
}
|
||||
.compoundForm()
|
||||
.navigationTitle(L10n.commonAnalytics)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
var analyticsSection: some View {
|
||||
Section {
|
||||
Toggle(isOn: $context.enableAnalytics) {
|
||||
Label(L10n.screenAnalyticsShareData, systemImage: "chart.bar")
|
||||
}
|
||||
.toggleStyle(.compoundForm)
|
||||
.onChange(of: context.enableAnalytics) { _ in
|
||||
context.send(viewAction: .toggleAnalytics)
|
||||
}
|
||||
} footer: {
|
||||
Text(context.viewState.strings.sectionFooter)
|
||||
.compoundFormSectionFooter()
|
||||
.tint(.compound.textLinkExternal)
|
||||
}
|
||||
.compoundFormSection()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct AnalyticsSettingsScreen_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let viewModel = AnalyticsSettingsScreenViewModel()
|
||||
AnalyticsSettingsScreen(context: viewModel.context)
|
||||
}
|
||||
}
|
||||
@@ -101,23 +101,30 @@ class AuthenticationCoordinator: CoordinatorProtocol {
|
||||
|
||||
switch action {
|
||||
case .signedIn(let userSession):
|
||||
self.delegate?.authenticationCoordinator(self, didLoginWithSession: userSession)
|
||||
self.userHasSignedIn(userSession: userSession)
|
||||
}
|
||||
}
|
||||
|
||||
navigationStackCoordinator.push(coordinator)
|
||||
}
|
||||
|
||||
private func showAnalyticsPrompt(with userSession: UserSessionProtocol) {
|
||||
let parameters = AnalyticsPromptCoordinatorParameters(userSession: userSession)
|
||||
let coordinator = AnalyticsPromptCoordinator(parameters: parameters)
|
||||
|
||||
coordinator.callback = { [weak self] in
|
||||
private func userHasSignedIn(userSession: UserSessionProtocol) {
|
||||
showAnalyticsPromptIfNeeded { [weak self] in
|
||||
guard let self else { return }
|
||||
self.delegate?.authenticationCoordinator(self, didLoginWithSession: userSession)
|
||||
}
|
||||
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
}
|
||||
|
||||
private func showAnalyticsPromptIfNeeded(completion: @escaping () -> Void) {
|
||||
guard ServiceLocator.shared.analytics.shouldShowAnalyticsPrompt else {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
let coordinator = AnalyticsPromptCoordinator()
|
||||
coordinator.callback = {
|
||||
completion()
|
||||
}
|
||||
navigationStackCoordinator.push(coordinator)
|
||||
}
|
||||
|
||||
static let loadingIndicatorIdentifier = "AuthenticationCoordinatorLoading"
|
||||
|
||||
@@ -62,11 +62,8 @@ struct HomeScreenViewState: BindableState {
|
||||
let userID: String
|
||||
var userDisplayName: String?
|
||||
var userAvatarURL: URL?
|
||||
|
||||
var showSessionVerificationBanner = false
|
||||
|
||||
var rooms: [HomeScreenRoom] = []
|
||||
|
||||
var roomListMode: HomeScreenRoomListMode = .skeletons
|
||||
|
||||
/// The URL that will be shared when inviting friends to use the app.
|
||||
|
||||
@@ -119,6 +119,7 @@ struct HomeScreen: View {
|
||||
}
|
||||
}
|
||||
.background(Color.element.background.ignoresSafeArea())
|
||||
.track(screen: .home)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
@@ -40,6 +40,7 @@ struct RoomScreen: View {
|
||||
.overlay { loadingIndicator }
|
||||
.alert(item: $context.alertInfo) { $0.alert }
|
||||
.sheet(item: $context.debugInfo) { TimelineItemDebugView(info: $0) }
|
||||
.track(screen: .room)
|
||||
.task(id: context.viewState.roomId) {
|
||||
// Give a couple of seconds for items to load and to see them.
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
|
||||
@@ -46,8 +46,8 @@ final class SettingsScreenCoordinator: CoordinatorProtocol {
|
||||
switch action {
|
||||
case .close:
|
||||
self.callback?(.dismiss)
|
||||
case .toggleAnalytics:
|
||||
self.toggleAnalytics()
|
||||
case .analytics:
|
||||
self.presentAnalyticsScreen()
|
||||
case .reportBug:
|
||||
self.presentBugReportScreen()
|
||||
case .sessionVerification:
|
||||
@@ -68,14 +68,11 @@ final class SettingsScreenCoordinator: CoordinatorProtocol {
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func toggleAnalytics() {
|
||||
if ServiceLocator.shared.settings.enableAnalytics {
|
||||
Analytics.shared.optOut()
|
||||
} else {
|
||||
Analytics.shared.optIn(with: parameters.userSession)
|
||||
}
|
||||
private func presentAnalyticsScreen() {
|
||||
let coordinator = AnalyticsSettingsScreenCoordinator()
|
||||
parameters.navigationStackCoordinator?.push(coordinator)
|
||||
}
|
||||
|
||||
|
||||
private func presentBugReportScreen() {
|
||||
let params = BugReportCoordinatorParameters(bugReportService: parameters.bugReportService,
|
||||
userID: parameters.userSession.userID,
|
||||
|
||||
@@ -19,7 +19,7 @@ import UIKit
|
||||
|
||||
enum SettingsScreenViewModelAction {
|
||||
case close
|
||||
case toggleAnalytics
|
||||
case analytics
|
||||
case reportBug
|
||||
case sessionVerification
|
||||
case developerOptions
|
||||
@@ -42,7 +42,7 @@ struct SettingsScreenViewStateBindings {
|
||||
|
||||
enum SettingsScreenViewAction {
|
||||
case close
|
||||
case toggleAnalytics
|
||||
case analytics
|
||||
case reportBug
|
||||
case sessionVerification
|
||||
case logout
|
||||
|
||||
@@ -77,8 +77,8 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
|
||||
switch viewAction {
|
||||
case .close:
|
||||
callback?(.close)
|
||||
case .toggleAnalytics:
|
||||
callback?(.toggleAnalytics)
|
||||
case .analytics:
|
||||
callback?(.analytics)
|
||||
case .reportBug:
|
||||
callback?(.reportBug)
|
||||
case .logout:
|
||||
|
||||
@@ -92,7 +92,7 @@ struct SettingsScreen: View {
|
||||
Label(L10n.commonDeveloperOptions, systemImage: "hammer.circle")
|
||||
}
|
||||
.buttonStyle(.compoundForm(accessory: .navigationLink))
|
||||
.accessibilityIdentifier("sessionVerificationButton")
|
||||
.accessibilityIdentifier("developerOptionsButton")
|
||||
}
|
||||
.compoundFormSection()
|
||||
}
|
||||
@@ -113,6 +113,14 @@ struct SettingsScreen: View {
|
||||
context.send(viewAction: .changedTimelineStyle)
|
||||
}
|
||||
|
||||
// Analytics
|
||||
Button { context.send(viewAction: .analytics) } label: {
|
||||
Label(L10n.commonAnalytics, systemImage: "chart.bar")
|
||||
}
|
||||
.buttonStyle(.compoundForm(accessory: .navigationLink))
|
||||
.accessibilityIdentifier("analyticsButton")
|
||||
|
||||
// Report Bug
|
||||
Button { context.send(viewAction: .reportBug) } label: {
|
||||
Label(L10n.actionReportBug, systemImage: "questionmark.circle")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user