Compare commits
9 Commits
84a8706b0a
...
2679f31c4e
| Author | SHA1 | Date | |
|---|---|---|---|
| 2679f31c4e | |||
| 1e61409501 | |||
| 1269a29ae8 | |||
| 4442dfc821 | |||
| cb0a8f249e | |||
| 216b8f50be | |||
| eef02dcca1 | |||
| 43a9d477a9 | |||
| dd61970357 |
@ -120,6 +120,42 @@
|
|||||||
"Email не подтверждён. Подтвердите, чтобы активировать дополнительные проверки." : {
|
"Email не подтверждён. Подтвердите, чтобы активировать дополнительные проверки." : {
|
||||||
"comment" : "Описание необходимости подтверждения email"
|
"comment" : "Описание необходимости подтверждения email"
|
||||||
},
|
},
|
||||||
|
"ForceUpdate.Message" : {
|
||||||
|
"comment" : "Force update alert message",
|
||||||
|
"extractionState" : "stale",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "This version is no longer supported. Please update the app."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Эта версия приложения устарела и больше не поддерживается. Пожалуйста, обновите приложение."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ForceUpdate.Title" : {
|
||||||
|
"comment" : "Force update alert title",
|
||||||
|
"extractionState" : "stale",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Update required"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Требуется обновление"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Fun Fest" : {
|
"Fun Fest" : {
|
||||||
"comment" : "Fun Fest",
|
"comment" : "Fun Fest",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -204,6 +240,42 @@
|
|||||||
},
|
},
|
||||||
"Qr" : {
|
"Qr" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"SoftUpdate.Message" : {
|
||||||
|
"comment" : "Soft update alert message",
|
||||||
|
"extractionState" : "stale",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "A new version is available. Some features may stop working correctly soon."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Вышла новая версия приложения. Некоторые функции могут скоро работать некорректно."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SoftUpdate.Title" : {
|
||||||
|
"comment" : "Soft update alert title",
|
||||||
|
"extractionState" : "stale",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Update available"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Доступно обновление"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Yobble" : {
|
"Yobble" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -510,6 +582,9 @@
|
|||||||
},
|
},
|
||||||
"Выключено" : {
|
"Выключено" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Вышла новая версия приложения с улучшениями и исправлениями." : {
|
||||||
|
"comment" : "Soft update alert message"
|
||||||
},
|
},
|
||||||
"Где найти сохранённые черновики?" : {
|
"Где найти сохранённые черновики?" : {
|
||||||
"comment" : "FAQ question: drafts"
|
"comment" : "FAQ question: drafts"
|
||||||
@ -572,6 +647,9 @@
|
|||||||
},
|
},
|
||||||
"Для начала, мы рекомендуем настроить параметры безопасности вашего аккаунта." : {
|
"Для начала, мы рекомендуем настроить параметры безопасности вашего аккаунта." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Для продолжения работы необходимо обновить приложение до последней версии." : {
|
||||||
|
"comment" : "Need update alert message"
|
||||||
},
|
},
|
||||||
"Добавить в контакты" : {
|
"Добавить в контакты" : {
|
||||||
"comment" : "Message profile add to contacts title"
|
"comment" : "Message profile add to contacts title"
|
||||||
@ -605,6 +683,9 @@
|
|||||||
"Дополнительные действия." : {
|
"Дополнительные действия." : {
|
||||||
"comment" : "Message profile more action description"
|
"comment" : "Message profile more action description"
|
||||||
},
|
},
|
||||||
|
"Доступно обновление" : {
|
||||||
|
"comment" : "Soft update alert title"
|
||||||
|
},
|
||||||
"Другие устройства (%d)" : {
|
"Другие устройства (%d)" : {
|
||||||
"comment" : "Заголовок секции других устройств с количеством"
|
"comment" : "Заголовок секции других устройств с количеством"
|
||||||
},
|
},
|
||||||
@ -1767,6 +1848,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Обновить приложение" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Обновление обязательно" : {
|
||||||
|
"comment" : "Need update alert title"
|
||||||
|
},
|
||||||
"Обратная связь" : {
|
"Обратная связь" : {
|
||||||
"comment" : "feedback: navigation title",
|
"comment" : "feedback: navigation title",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -2672,6 +2759,9 @@
|
|||||||
"Рейтинг собеседника" : {
|
"Рейтинг собеседника" : {
|
||||||
"comment" : "Message profile rating title"
|
"comment" : "Message profile rating title"
|
||||||
},
|
},
|
||||||
|
"Рекомендуется обновление" : {
|
||||||
|
"comment" : "Force update alert title"
|
||||||
|
},
|
||||||
"Рожки и ножки у сообщений" : {
|
"Рожки и ножки у сообщений" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@ -3231,6 +3321,9 @@
|
|||||||
},
|
},
|
||||||
"Экспериментальная поддержка iOS 15/16" : {
|
"Экспериментальная поддержка iOS 15/16" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Эта версия приложения устарела. Некоторые функции могут работать некорректно." : {
|
||||||
|
"comment" : "Force update alert message"
|
||||||
},
|
},
|
||||||
"Это устройство" : {
|
"Это устройство" : {
|
||||||
"comment" : "Заголовок секции текущего устройства"
|
"comment" : "Заголовок секции текущего устройства"
|
||||||
|
|||||||
186
yobble/Services/AppUpdateChecker.swift
Normal file
186
yobble/Services/AppUpdateChecker.swift
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct AppUpdateNotice: Identifiable {
|
||||||
|
enum Kind {
|
||||||
|
case need
|
||||||
|
case force
|
||||||
|
case soft
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = UUID()
|
||||||
|
let kind: Kind
|
||||||
|
let appStoreURL: URL
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch kind {
|
||||||
|
case .need:
|
||||||
|
return NSLocalizedString("Обновление обязательно", comment: "Need update alert title")
|
||||||
|
case .force:
|
||||||
|
return NSLocalizedString("Рекомендуется обновление", comment: "Force update alert title")
|
||||||
|
case .soft:
|
||||||
|
return NSLocalizedString("Доступно обновление", comment: "Soft update alert title")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var message: String {
|
||||||
|
switch kind {
|
||||||
|
case .need:
|
||||||
|
return NSLocalizedString("Для продолжения работы необходимо обновить приложение до последней версии.", comment: "Need update alert message")
|
||||||
|
case .force:
|
||||||
|
return NSLocalizedString("Эта версия приложения устарела. Некоторые функции могут работать некорректно.", comment: "Force update alert message")
|
||||||
|
case .soft:
|
||||||
|
return NSLocalizedString("Вышла новая версия приложения с улучшениями и исправлениями.", comment: "Soft update alert message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class AppUpdateChecker: ObservableObject {
|
||||||
|
@AppStorage("appIsBlocked") private var isAppBlocked: Bool = false
|
||||||
|
@AppStorage("lastCheckedAppBuild") private var lastCheckedAppBuild: Int = 0
|
||||||
|
|
||||||
|
@Published private(set) var needUpdateNotice: AppUpdateNotice?
|
||||||
|
@Published private(set) var softUpdateNotice: AppUpdateNotice?
|
||||||
|
@Published private(set) var forceUpdateNotice: AppUpdateNotice?
|
||||||
|
|
||||||
|
private let session: URLSession
|
||||||
|
private var didStartCheck = false
|
||||||
|
|
||||||
|
init(session: URLSession = .shared) {
|
||||||
|
self.session = session
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkForUpdatesIfNeeded() {
|
||||||
|
guard !didStartCheck else { return }
|
||||||
|
didStartCheck = true
|
||||||
|
|
||||||
|
Task { await fetchRemoteConfig() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismissSoftUpdateIfNeeded() {
|
||||||
|
softUpdateNotice = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func openAppStore(link overrideURL: URL? = nil) {
|
||||||
|
guard let url = overrideURL
|
||||||
|
?? needUpdateNotice?.appStoreURL
|
||||||
|
?? forceUpdateNotice?.appStoreURL
|
||||||
|
?? softUpdateNotice?.appStoreURL else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchRemoteConfig() async {
|
||||||
|
guard
|
||||||
|
let buildType = AppConfig.APP_BUILD.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
|
||||||
|
let url = URL(string: "https://static.yobble.org/config/ios/\(buildType).json")
|
||||||
|
else {
|
||||||
|
log("Unable to build remote config URL")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let (data, response) = try await session.data(from: url)
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
|
||||||
|
log("Unexpected response when fetching remote config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
let remoteConfig = try decoder.decode(RemoteBuildConfiguration.self, from: data)
|
||||||
|
await MainActor.run {
|
||||||
|
self.apply(remoteConfig)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
log("Failed to fetch remote config: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func apply(_ config: RemoteBuildConfiguration) {
|
||||||
|
guard let buildNumber = currentBuildNumber() else {
|
||||||
|
log("Unable to read current build number")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
needUpdateNotice = nil
|
||||||
|
forceUpdateNotice = nil
|
||||||
|
softUpdateNotice = nil
|
||||||
|
|
||||||
|
guard let appStoreURL = config.appStoreURL else {
|
||||||
|
log("Config missing App Store URL")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// print("buildNumber", buildNumber)
|
||||||
|
// print("config", config.notSupportedBuild, config.minSupportedBuild, config.recommendedBuild)
|
||||||
|
|
||||||
|
let requiresNeedUpdate = buildNumber <= config.notSupportedBuild
|
||||||
|
if requiresNeedUpdate {
|
||||||
|
isAppBlocked = true
|
||||||
|
needUpdateNotice = AppUpdateNotice(kind: .need, appStoreURL: appStoreURL)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
isAppBlocked = false
|
||||||
|
}
|
||||||
|
|
||||||
|
let requiresForcedUpdate = buildNumber < config.minSupportedBuild
|
||||||
|
if requiresForcedUpdate {
|
||||||
|
softUpdateNotice = AppUpdateNotice(kind: .force, appStoreURL: appStoreURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if buildNumber < config.recommendedBuild && config.recommendedBuild != lastCheckedAppBuild {
|
||||||
|
// lastCheckedAppBuild = config.recommendedBuild
|
||||||
|
softUpdateNotice = AppUpdateNotice(kind: .soft, appStoreURL: appStoreURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func currentBuildNumber() -> Int? {
|
||||||
|
guard let rawValue = Bundle.main.infoDictionary?["CFBundleVersion"] as? String else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return Int(rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func log(_ message: String) {
|
||||||
|
if AppConfig.DEBUG {
|
||||||
|
print("[AppUpdateChecker]", message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct RemoteBuildConfiguration: Decodable {
|
||||||
|
let schemaVersion: Int
|
||||||
|
let notSupportedBuild: Int
|
||||||
|
let minSupportedBuild: Int
|
||||||
|
let recommendedBuild: Int
|
||||||
|
let appStoreURL: URL?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case schemaVersion = "schema_version"
|
||||||
|
case notSupportedBuild = "not_supported_build"
|
||||||
|
case minSupportedBuild = "min_supported_build"
|
||||||
|
case recommendedBuild = "recommended_build"
|
||||||
|
case appStoreURL = "appstore_url"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
|
||||||
|
notSupportedBuild = try container.decode(Int.self, forKey: .notSupportedBuild)
|
||||||
|
minSupportedBuild = try container.decode(Int.self, forKey: .minSupportedBuild)
|
||||||
|
recommendedBuild = try container.decode(Int.self, forKey: .recommendedBuild)
|
||||||
|
if let urlString = try container.decodeIfPresent(String.self, forKey: .appStoreURL) {
|
||||||
|
appStoreURL = URL(string: urlString)
|
||||||
|
} else {
|
||||||
|
appStoreURL = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,8 +7,11 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
class LoginViewModel: ObservableObject {
|
class LoginViewModel: ObservableObject {
|
||||||
|
// @AppStorage("appIsBlocked") private var isAppBlocked: Bool = false
|
||||||
|
|
||||||
@Published var username: String = ""
|
@Published var username: String = ""
|
||||||
@Published var userId: String = ""
|
@Published var userId: String = ""
|
||||||
@Published var password: String = ""
|
@Published var password: String = ""
|
||||||
|
|||||||
62
yobble/Views/NeedUpdateView.swift
Normal file
62
yobble/Views/NeedUpdateView.swift
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct NeedUpdateView: View {
|
||||||
|
let title: String
|
||||||
|
let message: String
|
||||||
|
let onUpdate: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color(.systemBackground), Color(.systemGray6)],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.system(size: 56, weight: .bold))
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text(title)
|
||||||
|
.font(.title2.weight(.semibold))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Text(message)
|
||||||
|
.font(.body)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: onUpdate) {
|
||||||
|
Text(NSLocalizedString("Обновить приложение", comment: ""))
|
||||||
|
.font(.headline)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
.background(Color.accentColor)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.cornerRadius(14)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(32)
|
||||||
|
.frame(maxWidth: 480)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NeedUpdateView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
NeedUpdateView(title: "Требуется обновление",
|
||||||
|
message: "Эта версия приложения устарела и больше не поддерживается.",
|
||||||
|
onUpdate: {})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct AppConfig {
|
struct AppConfig {
|
||||||
static var DEBUG: Bool = true
|
static var DEBUG: Bool = false
|
||||||
//static let SERVICE = Bundle.main.bundleIdentifier ?? "default.service"
|
//static let SERVICE = Bundle.main.bundleIdentifier ?? "default.service"
|
||||||
static let PROTOCOL = "https"
|
static let PROTOCOL = "https"
|
||||||
static let API_SERVER = "\(PROTOCOL)://api.yobble.org"
|
static let API_SERVER = "\(PROTOCOL)://api.yobble.org"
|
||||||
|
|||||||
@ -16,10 +16,19 @@ struct yobbleApp: App {
|
|||||||
@StateObject private var themeManager = ThemeManager()
|
@StateObject private var themeManager = ThemeManager()
|
||||||
@StateObject private var viewModel = LoginViewModel()
|
@StateObject private var viewModel = LoginViewModel()
|
||||||
@StateObject private var messageCenter = IncomingMessageCenter()
|
@StateObject private var messageCenter = IncomingMessageCenter()
|
||||||
|
@StateObject private var updateChecker = AppUpdateChecker()
|
||||||
private let persistenceController = PersistenceController.shared
|
private let persistenceController = PersistenceController.shared
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
|
Group {
|
||||||
|
if let notice = updateChecker.needUpdateNotice {
|
||||||
|
NeedUpdateView(
|
||||||
|
title: notice.title,
|
||||||
|
message: notice.message,
|
||||||
|
onUpdate: { updateChecker.openAppStore(link: notice.appStoreURL) }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
Group {
|
Group {
|
||||||
if viewModel.isInitialLoading {
|
if viewModel.isInitialLoading {
|
||||||
@ -59,11 +68,33 @@ struct yobbleApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.alert(item: Binding(
|
||||||
|
get: { updateChecker.softUpdateNotice },
|
||||||
|
set: { newValue in
|
||||||
|
if newValue == nil {
|
||||||
|
updateChecker.dismissSoftUpdateIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)) { notice in
|
||||||
|
Alert(
|
||||||
|
title: Text(notice.title),
|
||||||
|
message: Text(notice.message),
|
||||||
|
primaryButton: .default(Text(NSLocalizedString("Обновить", comment: ""))) {
|
||||||
|
updateChecker.openAppStore(link: notice.appStoreURL)
|
||||||
|
},
|
||||||
|
secondaryButton: .cancel(Text(NSLocalizedString("Позже", comment: ""))) {
|
||||||
|
updateChecker.dismissSoftUpdateIfNeeded()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.environmentObject(messageCenter)
|
.environmentObject(messageCenter)
|
||||||
.environmentObject(themeManager)
|
.environmentObject(themeManager)
|
||||||
.preferredColorScheme(themeManager.theme.colorScheme)
|
.preferredColorScheme(themeManager.theme.colorScheme)
|
||||||
.environment(\.managedObjectContext, persistenceController.viewContext)
|
.environment(\.managedObjectContext, persistenceController.viewContext)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
updateChecker.checkForUpdatesIfNeeded()
|
||||||
messageCenter.currentUserId = viewModel.userId.isEmpty ? nil : viewModel.userId
|
messageCenter.currentUserId = viewModel.userId.isEmpty ? nil : viewModel.userId
|
||||||
}
|
}
|
||||||
.onChange(of: viewModel.userId) { newValue in
|
.onChange(of: viewModel.userId) { newValue in
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user