mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
Pro
This commit is contained in:
parent
b22e91bdfa
commit
cdd2bafe40
@ -20,813 +20,6 @@ import OverlayStatusController
|
||||
#if DEBUG
|
||||
import FLEX
|
||||
#endif
|
||||
import Security
|
||||
|
||||
|
||||
let BACKUP_SERVICE: String = "\(Bundle.main.bundleIdentifier!).sessionsbackup"
|
||||
|
||||
enum KeychainError: Error {
|
||||
case duplicateEntry
|
||||
case unknown(OSStatus)
|
||||
case itemNotFound
|
||||
case invalidItemFormat
|
||||
}
|
||||
|
||||
class KeychainBackupManager {
|
||||
static let shared = KeychainBackupManager()
|
||||
private let service = "\(Bundle.main.bundleIdentifier!).sessionsbackup"
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Save Credentials
|
||||
func saveSession(id: String, _ session: Data) throws {
|
||||
// Create query dictionary
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: id,
|
||||
kSecValueData as String: session,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked
|
||||
]
|
||||
|
||||
// Add to keychain
|
||||
let status = SecItemAdd(query as CFDictionary, nil)
|
||||
|
||||
if status == errSecDuplicateItem {
|
||||
// Item already exists, update it
|
||||
let updateQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: id
|
||||
]
|
||||
|
||||
let attributesToUpdate: [String: Any] = [
|
||||
kSecValueData as String: session
|
||||
]
|
||||
|
||||
let updateStatus = SecItemUpdate(updateQuery as CFDictionary,
|
||||
attributesToUpdate as CFDictionary)
|
||||
|
||||
if updateStatus != errSecSuccess {
|
||||
throw KeychainError.unknown(updateStatus)
|
||||
}
|
||||
} else if status != errSecSuccess {
|
||||
throw KeychainError.unknown(status)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Retrieve Credentials
|
||||
func retrieveSession(for id: String) throws -> Data {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: id,
|
||||
kSecReturnData as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status == errSecSuccess, let sessionData = result as? Data else {
|
||||
throw KeychainError.itemNotFound
|
||||
}
|
||||
|
||||
return sessionData
|
||||
}
|
||||
|
||||
// MARK: - Delete Credentials
|
||||
func deleteSession(for id: String) throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: id
|
||||
]
|
||||
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
|
||||
if status != errSecSuccess && status != errSecItemNotFound {
|
||||
throw KeychainError.unknown(status)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Retrieve All Accounts
|
||||
func getAllSessons() throws -> [Data] {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitAll
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
if status == errSecItemNotFound {
|
||||
return []
|
||||
}
|
||||
|
||||
guard status == errSecSuccess,
|
||||
let credentialsDataArray = result as? [Data] else {
|
||||
throw KeychainError.unknown(status)
|
||||
}
|
||||
|
||||
return credentialsDataArray
|
||||
}
|
||||
|
||||
// MARK: - Delete All Sessions
|
||||
func deleteAllSessions() throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service
|
||||
]
|
||||
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
|
||||
// If no items were found, that's fine - just return
|
||||
if status == errSecItemNotFound {
|
||||
return
|
||||
}
|
||||
|
||||
// For any other error, throw
|
||||
if status != errSecSuccess {
|
||||
throw KeychainError.unknown(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionBackup: Codable {
|
||||
var name: String? = nil
|
||||
var date: Date = Date()
|
||||
let accountRecord: AccountRecord<TelegramAccountManagerTypes.Attribute>
|
||||
|
||||
var peerIdInternal: Int64 {
|
||||
var userId: Int64 = 0
|
||||
for attribute in accountRecord.attributes {
|
||||
if case let .backupData(backupData) = attribute, let backupPeerID = backupData.data?.peerId {
|
||||
userId = backupPeerID
|
||||
break
|
||||
}
|
||||
}
|
||||
return userId
|
||||
}
|
||||
|
||||
var userId: Int64 {
|
||||
return PeerId(peerIdInternal).id._internalGetInt64Value()
|
||||
}
|
||||
}
|
||||
|
||||
import SwiftUI
|
||||
import SGSwiftUI
|
||||
import LegacyUI
|
||||
import SGStrings
|
||||
|
||||
|
||||
@available(iOS 13.0, *)
|
||||
struct SessionBackupRow: View {
|
||||
let backup: SessionBackup
|
||||
let isLoggedIn: Bool
|
||||
|
||||
|
||||
private let dateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .short
|
||||
formatter.timeStyle = .short
|
||||
return formatter
|
||||
}()
|
||||
|
||||
var formattedDate: String {
|
||||
if #available(iOS 15.0, *) {
|
||||
return backup.date.formatted(date: .abbreviated, time: .shortened)
|
||||
} else {
|
||||
return dateFormatter.string(from: backup.date)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(backup.name ?? String(backup.userId))
|
||||
.font(.body)
|
||||
|
||||
Text("ID: \(backup.userId)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("Last Backup: \(formattedDate)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(isLoggedIn ? "Logged In" : "Logged Out")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.secondary.opacity(0.1))
|
||||
.cornerRadius(4)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@available(iOS 13.0, *)
|
||||
struct BorderedButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.accentColor, lineWidth: 1)
|
||||
)
|
||||
.opacity(configuration.isPressed ? 0.7 : 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 13.0, *)
|
||||
struct SessionBackupManagerView: View {
|
||||
weak var wrapperController: LegacyController?
|
||||
let context: AccountContext
|
||||
|
||||
@State private var sessions: [SessionBackup] = []
|
||||
@State private var loggedInPeerIDs: [Int64] = []
|
||||
@State private var loggedInAccountsDisposable: Disposable? = nil
|
||||
|
||||
private func performBackup() {
|
||||
let controller = OverlayStatusController(theme: context.sharedContext.currentPresentationData.with { $0 }.theme, type: .loading(cancelled: nil))
|
||||
|
||||
let signal = context.sharedContext.accountManager.accountRecords()
|
||||
|> take(1)
|
||||
|> deliverOnMainQueue
|
||||
|
||||
let signal2 = context.sharedContext.activeAccountsWithInfo
|
||||
|> take(1)
|
||||
|> deliverOnMainQueue
|
||||
|
||||
wrapperController?.present(controller, in: .window(.root), with: nil)
|
||||
|
||||
Task {
|
||||
if let result = try? await combineLatest(signal, signal2).awaitable() {
|
||||
let (view, accountsWithInfo) = result
|
||||
backupSessionsFromView(view, accountsWithInfo: accountsWithInfo.1)
|
||||
withAnimation {
|
||||
sessions = getBackedSessions()
|
||||
}
|
||||
controller.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func performRestore() {
|
||||
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
||||
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: nil))
|
||||
|
||||
let _ = (context.sharedContext.accountManager.accountRecords()
|
||||
|> take(1)
|
||||
|> deliverOnMainQueue).start(next: { [weak controller] view in
|
||||
|
||||
let backupSessions = getBackedSessions()
|
||||
var restoredSessions: Int64 = 0
|
||||
|
||||
func importNextBackup(index: Int) {
|
||||
// Check if we're done
|
||||
if index >= backupSessions.count {
|
||||
// All done, update UI
|
||||
withAnimation {
|
||||
sessions = getBackedSessions()
|
||||
}
|
||||
controller?.dismiss()
|
||||
wrapperController?.present(
|
||||
okUndoController("OK: \(restoredSessions) Sessions restored", presentationData),
|
||||
in: .current
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let backup = backupSessions[index]
|
||||
|
||||
// Check for existing record
|
||||
let existingRecord = view.records.first { record in
|
||||
var userId: Int64 = 0
|
||||
for attribute in record.attributes {
|
||||
if case let .backupData(backupData) = attribute {
|
||||
userId = backupData.data?.peerId ?? 0
|
||||
}
|
||||
}
|
||||
return userId == backup.peerIdInternal
|
||||
}
|
||||
|
||||
if existingRecord != nil {
|
||||
print("Record \(backup.userId) already exists, skipping")
|
||||
importNextBackup(index: index + 1)
|
||||
return
|
||||
}
|
||||
|
||||
var importAttributes = backup.accountRecord.attributes
|
||||
importAttributes.removeAll { attribute in
|
||||
if case .sortOrder = attribute {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
let importBackupSignal = context.sharedContext.accountManager.transaction { transaction -> Void in
|
||||
let nextSortOrder = (transaction.getRecords().map({ record -> Int32 in
|
||||
for attribute in record.attributes {
|
||||
if case let .sortOrder(sortOrder) = attribute {
|
||||
return sortOrder.order
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}).max() ?? 0) + 1
|
||||
importAttributes.append(.sortOrder(AccountSortOrderAttribute(order: nextSortOrder)))
|
||||
let accountRecordId = transaction.createRecord(importAttributes)
|
||||
print("Imported record \(accountRecordId) for \(backup.userId)")
|
||||
restoredSessions += 1
|
||||
}
|
||||
|> deliverOnMainQueue
|
||||
|
||||
let _ = importBackupSignal.start(completed: {
|
||||
importNextBackup(index: index + 1)
|
||||
})
|
||||
}
|
||||
|
||||
// Start the import chain
|
||||
importNextBackup(index: 0)
|
||||
})
|
||||
|
||||
wrapperController?.present(controller, in: .window(.root), with: nil)
|
||||
}
|
||||
|
||||
private func performDeleteAll() {
|
||||
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
||||
|
||||
let controller = textAlertController(context: context, title: "Delete All Backups?", text: "All sessions will be removed from Keychain.\n\nAccounts will not be logged out from Swiftgram.", actions: [
|
||||
TextAlertAction(type: .destructiveAction, title: presentationData.strings.Common_Delete, action: {
|
||||
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: nil))
|
||||
wrapperController?.present(controller, in: .window(.root), with: nil)
|
||||
do {
|
||||
try KeychainBackupManager.shared.deleteAllSessions()
|
||||
withAnimation {
|
||||
sessions = getBackedSessions()
|
||||
}
|
||||
controller.dismiss()
|
||||
} catch let e {
|
||||
print("Error deleting all sessions: \(e)")
|
||||
}
|
||||
}),
|
||||
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {})
|
||||
])
|
||||
|
||||
wrapperController?.present(controller, in: .window(.root), with: nil)
|
||||
}
|
||||
|
||||
private func performDelete(_ session: SessionBackup) {
|
||||
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
||||
|
||||
let controller = textAlertController(context: context, title: "Delete 1 Backup?", text: "\(session.name ?? "\(session.userId)") session will be removed from Keychain.\n\nAccount will not be logged out from Swiftgram.", actions: [
|
||||
TextAlertAction(type: .destructiveAction, title: presentationData.strings.Common_Delete, action: {
|
||||
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: nil))
|
||||
wrapperController?.present(controller, in: .window(.root), with: nil)
|
||||
do {
|
||||
try KeychainBackupManager.shared.deleteSession(for: "\(session.peerIdInternal)")
|
||||
withAnimation {
|
||||
sessions = getBackedSessions()
|
||||
}
|
||||
controller.dismiss()
|
||||
} catch let e {
|
||||
print("Error deleting session: \(e)")
|
||||
}
|
||||
}),
|
||||
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {})
|
||||
])
|
||||
|
||||
wrapperController?.present(controller, in: .window(.root), with: nil)
|
||||
}
|
||||
|
||||
|
||||
#if DEBUG
|
||||
private func performRemoveSessionFromApp(session: SessionBackup) {
|
||||
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
||||
|
||||
let controller = textAlertController(context: context, title: "Remove session from App?", text: "\(session.name ?? "\(session.userId)") session will be removed from app? Account WILL BE logged out of Swiftgram.", actions: [
|
||||
TextAlertAction(type: .destructiveAction, title: presentationData.strings.Common_Delete, action: {
|
||||
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: nil))
|
||||
wrapperController?.present(controller, in: .window(.root), with: nil)
|
||||
|
||||
let signal = context.sharedContext.accountManager.accountRecords()
|
||||
|> take(1)
|
||||
|> deliverOnMainQueue
|
||||
|
||||
let _ = signal.start(next: { [weak controller] view in
|
||||
|
||||
// Find record to delete
|
||||
let accountRecord = view.records.first { record in
|
||||
var userId: Int64 = 0
|
||||
for attribute in record.attributes {
|
||||
if case let .backupData(backupData) = attribute {
|
||||
userId = backupData.data?.peerId ?? 0
|
||||
}
|
||||
}
|
||||
return userId == session.peerIdInternal
|
||||
}
|
||||
|
||||
if let record = accountRecord {
|
||||
let deleteSignal = context.sharedContext.accountManager.transaction { transaction -> Void in
|
||||
transaction.updateRecord(record.id, { _ in return nil})
|
||||
}
|
||||
|> deliverOnMainQueue
|
||||
|
||||
let _ = deleteSignal.start(next: {
|
||||
withAnimation {
|
||||
sessions = getBackedSessions()
|
||||
}
|
||||
controller?.dismiss()
|
||||
})
|
||||
} else {
|
||||
controller?.dismiss()
|
||||
}
|
||||
})
|
||||
|
||||
}),
|
||||
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {})
|
||||
])
|
||||
|
||||
wrapperController?.present(controller, in: .window(.root), with: nil)
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section(header: Text("Actions")) {
|
||||
Button(action: performBackup) {
|
||||
HStack {
|
||||
Image(systemName: "key.fill")
|
||||
.frame(width: 30)
|
||||
Text("Backup to Keychain")
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: performRestore) {
|
||||
HStack {
|
||||
Image(systemName: "arrow.2.circlepath")
|
||||
.frame(width: 30)
|
||||
Text("Restore from Keychain")
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: performDeleteAll) {
|
||||
HStack {
|
||||
Image(systemName: "trash")
|
||||
.frame(width: 30)
|
||||
Text("Delete Keychain Backup")
|
||||
}
|
||||
}
|
||||
.foregroundColor(.red)
|
||||
// Text("Removing sessions from Keychain. This will not affect logged-in accounts.")
|
||||
// .font(.caption)
|
||||
}
|
||||
|
||||
Section(header: Text("Backups")) {
|
||||
ForEach(sessions, id: \.peerIdInternal) { session in
|
||||
SessionBackupRow(
|
||||
backup: session,
|
||||
isLoggedIn: loggedInPeerIDs.contains(session.peerIdInternal)
|
||||
)
|
||||
.contextMenu {
|
||||
Button(action: {
|
||||
performDelete(session)
|
||||
}, label: {
|
||||
HStack(spacing: 4) {
|
||||
Text("Delete from Backup")
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
})
|
||||
#if DEBUG
|
||||
Button(action: {
|
||||
performRemoveSessionFromApp(session: session)
|
||||
}, label: {
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Text("Remove from App")
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
})
|
||||
#endif
|
||||
}
|
||||
}
|
||||
// .onDelete { indexSet in
|
||||
// performDelete(indexSet)
|
||||
// }
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation {
|
||||
sessions = getBackedSessions()
|
||||
}
|
||||
|
||||
let accountsSignal = context.sharedContext.accountManager.accountRecords()
|
||||
|> deliverOnMainQueue
|
||||
|
||||
loggedInAccountsDisposable = accountsSignal.start(next: { view in
|
||||
var result: [Int64] = []
|
||||
for record in view.records {
|
||||
var isLoggedOut: Bool = false
|
||||
var userId: Int64 = 0
|
||||
for attribute in record.attributes {
|
||||
if case .loggedOut = attribute {
|
||||
isLoggedOut = true
|
||||
} else if case let .backupData(backupData) = attribute {
|
||||
userId = backupData.data?.peerId ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
if !isLoggedOut && userId != 0 {
|
||||
result.append(userId)
|
||||
}
|
||||
}
|
||||
|
||||
print("Will check logged in accounts")
|
||||
if loggedInPeerIDs != result {
|
||||
print("Updating logged in accounts", result)
|
||||
loggedInPeerIDs = result
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
.onDisappear {
|
||||
loggedInAccountsDisposable?.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
func getBackedSessions() -> [SessionBackup] {
|
||||
var sessions: [SessionBackup] = []
|
||||
do {
|
||||
let backupSessionsData = try KeychainBackupManager.shared.getAllSessons()
|
||||
for sessionBackupData in backupSessionsData {
|
||||
do {
|
||||
let backup = try JSONDecoder().decode(SessionBackup.self, from: sessionBackupData)
|
||||
sessions.append(backup)
|
||||
} catch let e {
|
||||
print("IMPORT ERROR: \(e)")
|
||||
}
|
||||
}
|
||||
} catch let e {
|
||||
print("Error getting all sessions: \(e)")
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
|
||||
|
||||
func backupSessionsFromView(_ view: AccountRecordsView<TelegramAccountManagerTypes>, accountsWithInfo: [AccountWithInfo] = []) {
|
||||
var recordsToBackup: [Int64: AccountRecord<TelegramAccountManagerTypes.Attribute>] = [:]
|
||||
for record in view.records {
|
||||
var sortOrder: Int32 = 0
|
||||
var isLoggedOut: Bool = false
|
||||
var isTestingEnvironment: Bool = false
|
||||
var peerId: Int64 = 0
|
||||
for attribute in record.attributes {
|
||||
if case let .sortOrder(value) = attribute {
|
||||
sortOrder = value.order
|
||||
} else if case .loggedOut = attribute {
|
||||
isLoggedOut = true
|
||||
} else if case let .environment(environment) = attribute, case .test = environment.environment {
|
||||
isTestingEnvironment = true
|
||||
} else if case let .backupData(backupData) = attribute {
|
||||
peerId = backupData.data?.peerId ?? 0
|
||||
}
|
||||
}
|
||||
let _ = sortOrder
|
||||
let _ = isTestingEnvironment
|
||||
|
||||
if !isLoggedOut && peerId != 0 {
|
||||
recordsToBackup[peerId] = record
|
||||
}
|
||||
}
|
||||
|
||||
for (peerId, record) in recordsToBackup {
|
||||
var backupName: String? = nil
|
||||
if let accountWithInfo = accountsWithInfo.first(where: { $0.peer.id == PeerId(peerId) }) {
|
||||
if let user = accountWithInfo.peer as? TelegramUser {
|
||||
if let username = user.username {
|
||||
backupName = "@\(username)"
|
||||
} else {
|
||||
backupName = user.nameOrPhone
|
||||
}
|
||||
}
|
||||
}
|
||||
let backup = SessionBackup(name: backupName, accountRecord: record)
|
||||
do {
|
||||
let data = try JSONEncoder().encode(backup)
|
||||
try KeychainBackupManager.shared.saveSession(id: "\(backup.peerIdInternal)", data)
|
||||
} catch let e {
|
||||
print("BACKUP ERROR: \(e)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@available(iOS 13.0, *)
|
||||
public func sgSessionBackupManagerController(context: AccountContext, presentationData: PresentationData? = nil) -> ViewController {
|
||||
let theme = presentationData?.theme ?? (UITraitCollection.current.userInterfaceStyle == .dark ? defaultDarkColorPresentationTheme : defaultPresentationTheme)
|
||||
let strings = presentationData?.strings ?? defaultPresentationStrings
|
||||
|
||||
let legacyController = LegacySwiftUIController(
|
||||
presentation: .navigation,
|
||||
theme: theme,
|
||||
strings: strings
|
||||
)
|
||||
legacyController.statusBar.statusBarStyle = theme.rootController
|
||||
.statusBarStyle.style
|
||||
legacyController.title = "Session Backup" //i18n("BackupManager.Title", strings.baseLanguageCode)
|
||||
|
||||
let swiftUIView = SGSwiftUIView<SessionBackupManagerView>(
|
||||
legacyController: legacyController,
|
||||
manageSafeArea: true,
|
||||
content: {
|
||||
SessionBackupManagerView(wrapperController: legacyController, context: context)
|
||||
}
|
||||
)
|
||||
let controller = UIHostingController(rootView: swiftUIView, ignoreSafeArea: true)
|
||||
legacyController.bind(controller: controller)
|
||||
|
||||
return legacyController
|
||||
}
|
||||
|
||||
|
||||
@available(iOS 13.0, *)
|
||||
struct MessageFilterKeywordInputFieldModifier: ViewModifier {
|
||||
@Binding var newKeyword: String
|
||||
let onAdd: () -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 15.0, *) {
|
||||
content
|
||||
.submitLabel(.return)
|
||||
.submitScope(false) // TODO(swiftgram): Keyboard still closing
|
||||
.interactiveDismissDisabled()
|
||||
.onSubmit {
|
||||
onAdd()
|
||||
}
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@available(iOS 13.0, *)
|
||||
struct MessageFilterKeywordInputView: View {
|
||||
@Binding var newKeyword: String
|
||||
let onAdd: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
TextField("Enter keyword", text: $newKeyword)
|
||||
.autocorrectionDisabled(true)
|
||||
.autocapitalization(.none)
|
||||
.keyboardType(.default)
|
||||
.modifier(MessageFilterKeywordInputFieldModifier(newKeyword: $newKeyword, onAdd: onAdd))
|
||||
|
||||
|
||||
Button(action: onAdd) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.foregroundColor(newKeyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? .secondary : .accentColor)
|
||||
.imageScale(.large)
|
||||
}
|
||||
.disabled(newKeyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 13.0, *)
|
||||
struct MessageFilterView: View {
|
||||
weak var wrapperController: LegacyController?
|
||||
|
||||
@State private var newKeyword: String = ""
|
||||
@State private var keywords: [String] {
|
||||
didSet {
|
||||
SGSimpleSettings.shared.messageFilterKeywords = keywords
|
||||
}
|
||||
}
|
||||
|
||||
init(wrapperController: LegacyController?) {
|
||||
self.wrapperController = wrapperController
|
||||
_keywords = State(initialValue: SGSimpleSettings.shared.messageFilterKeywords)
|
||||
}
|
||||
|
||||
var bodyContent: some View {
|
||||
List {
|
||||
Section {
|
||||
// Icon and title
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "nosign.app.fill")
|
||||
.font(.system(size: 50))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("Message Filter")
|
||||
.font(.title)
|
||||
.bold()
|
||||
|
||||
Text("Remove distraction and reduce visibility of messages containing keywords below.\nKeywords are case-sensitive.")
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
.listRowInsets(EdgeInsets())
|
||||
|
||||
}
|
||||
|
||||
Section {
|
||||
MessageFilterKeywordInputView(newKeyword: $newKeyword, onAdd: addKeyword)
|
||||
}
|
||||
|
||||
Section(header: Text("Keywords")) {
|
||||
ForEach(keywords.reversed(), id: \.self) { keyword in
|
||||
Text(keyword)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
let originalIndices = IndexSet(
|
||||
indexSet.map { keywords.count - 1 - $0 }
|
||||
)
|
||||
deleteKeywords(at: originalIndices)
|
||||
}
|
||||
}
|
||||
}
|
||||
.tgNavigationBackButton(wrapperController: wrapperController)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
if #available(iOS 14.0, *) {
|
||||
bodyContent
|
||||
.toolbar {
|
||||
EditButton()
|
||||
}
|
||||
} else {
|
||||
bodyContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addKeyword() {
|
||||
let trimmedKeyword = newKeyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedKeyword.isEmpty else { return }
|
||||
|
||||
let keywordExists = keywords.contains {
|
||||
$0 == trimmedKeyword
|
||||
}
|
||||
|
||||
guard !keywordExists else {
|
||||
return
|
||||
}
|
||||
|
||||
withAnimation {
|
||||
keywords.append(trimmedKeyword)
|
||||
}
|
||||
newKeyword = ""
|
||||
|
||||
}
|
||||
|
||||
private func deleteKeywords(at offsets: IndexSet) {
|
||||
withAnimation {
|
||||
keywords.remove(atOffsets: offsets)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 13.0, *)
|
||||
public func sgMessageFilterController(presentationData: PresentationData? = nil) -> ViewController {
|
||||
let theme = presentationData?.theme ?? (UITraitCollection.current.userInterfaceStyle == .dark ? defaultDarkColorPresentationTheme : defaultPresentationTheme)
|
||||
let strings = presentationData?.strings ?? defaultPresentationStrings
|
||||
|
||||
let legacyController = LegacySwiftUIController(
|
||||
presentation: .navigation,
|
||||
theme: theme,
|
||||
strings: strings
|
||||
)
|
||||
// Status bar color will break if theme changed
|
||||
legacyController.statusBar.statusBarStyle = theme.rootController
|
||||
.statusBarStyle.style
|
||||
legacyController.displayNavigationBar = false
|
||||
let swiftUIView = MessageFilterView(wrapperController: legacyController)
|
||||
let controller = UIHostingController(rootView: swiftUIView, ignoreSafeArea: true)
|
||||
legacyController.bind(controller: controller)
|
||||
|
||||
return legacyController
|
||||
}
|
||||
|
||||
|
||||
private enum SGDebugControllerSection: Int32, SGItemListSection {
|
||||
@ -868,25 +61,13 @@ private func SGDebugControllerEntries(presentationData: PresentationData) -> [SG
|
||||
#if DEBUG
|
||||
entries.append(.action(id: id.count, section: .base, actionType: .flexing, text: "FLEX", kind: .generic))
|
||||
entries.append(.action(id: id.count, section: .base, actionType: .fileManager, text: "FileManager", kind: .generic))
|
||||
entries.append(.disclosure(id: id.count, section: .base, link: .debugIAP, text: "Pro"))
|
||||
entries.append(.action(id: id.count, section: .base, actionType: .resetIAP, text: "Reset Pro", kind: .destructive))
|
||||
#endif
|
||||
|
||||
if SGSimpleSettings.shared.b {
|
||||
entries.append(.disclosure(id: id.count, section: .base, link: .sessionBackupManager, text: "Session Backup"))
|
||||
entries.append(.disclosure(id: id.count, section: .base, link: .messageFilter, text: "Message Filter"))
|
||||
if #available(iOS 13.0, *) {
|
||||
entries.append(.toggle(id: id.count, section: .base, settingName: .inputToolbar, value: SGSimpleSettings.shared.inputToolbar, text: "Message Formatting Toolbar", enabled: true))
|
||||
}
|
||||
}
|
||||
|
||||
entries.append(.action(id: id.count, section: .base, actionType: .clearRegDateCache, text: "Clear Regdate cache", kind: .generic))
|
||||
entries.append(.toggle(id: id.count, section: .base, settingName: .forceImmediateShareSheet, value: SGSimpleSettings.shared.forceSystemSharing, text: "Force System Share Sheet", enabled: true))
|
||||
entries.append(.action(id: id.count, section: .base, actionType: .resetIAP, text: "Reset Pro", kind: .destructive))
|
||||
|
||||
entries.append(.header(id: id.count, section: .notifications, text: "NOTIFICATIONS", badge: nil))
|
||||
entries.append(.toggle(id: id.count, section: .notifications, settingName: .legacyNotificationsFix, value: SGSimpleSettings.shared.legacyNotificationsFix, text: "[Legacy] Fix empty notifications", enabled: true))
|
||||
entries.append(.oneFromManySelector(id: id.count, section: .notifications, settingName: .pinnedMessageNotifications, text: "Pinned Messages", value: SGSimpleSettings.shared.pinnedMessageNotifications, enabled: true))
|
||||
entries.append(.oneFromManySelector(id: id.count, section: .notifications, settingName: .mentionsAndRepliesNotifications, text: "@Mentions and Replies", value: SGSimpleSettings.shared.mentionsAndRepliesNotifications, enabled: true))
|
||||
|
||||
entries.append(.toggle(id: id.count, section: .notifications, settingName: .legacyNotificationsFix, value: SGSimpleSettings.shared.legacyNotificationsFix, text: "[OLD] Fix empty notifications", enabled: true))
|
||||
return entries
|
||||
}
|
||||
private func okUndoController(_ text: String, _ presentationData: PresentationData) -> UndoOverlayController {
|
||||
@ -913,44 +94,11 @@ public func sgDebugController(context: AccountContext) -> ViewController {
|
||||
}, setOneFromManyValue: { setting in
|
||||
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
||||
let actionSheet = ActionSheetController(presentationData: presentationData)
|
||||
var items: [ActionSheetItem] = []
|
||||
let items: [ActionSheetItem] = []
|
||||
// var items: [ActionSheetItem] = []
|
||||
|
||||
switch (setting) {
|
||||
case .pinnedMessageNotifications:
|
||||
let setAction: (String) -> Void = { value in
|
||||
SGSimpleSettings.shared.pinnedMessageNotifications = value
|
||||
SGSimpleSettings.shared.synchronizeShared()
|
||||
simplePromise.set(true)
|
||||
}
|
||||
|
||||
for value in SGSimpleSettings.PinnedMessageNotificationsSettings.allCases {
|
||||
items.append(ActionSheetButtonItem(title: value.rawValue, color: .accent, action: { [weak actionSheet] in
|
||||
actionSheet?.dismissAnimated()
|
||||
if SGSimpleSettings.shared.b {
|
||||
setAction(value.rawValue)
|
||||
} else {
|
||||
setAction(SGSimpleSettings.PinnedMessageNotificationsSettings.default.rawValue)
|
||||
}
|
||||
}))
|
||||
}
|
||||
case .mentionsAndRepliesNotifications:
|
||||
let setAction: (String) -> Void = { value in
|
||||
SGSimpleSettings.shared.mentionsAndRepliesNotifications = value
|
||||
SGSimpleSettings.shared.synchronizeShared()
|
||||
simplePromise.set(true)
|
||||
}
|
||||
|
||||
for value in SGSimpleSettings.MentionsAndRepliesNotificationsSettings.allCases {
|
||||
items.append(ActionSheetButtonItem(title: value.rawValue, color: .accent, action: { [weak actionSheet] in
|
||||
actionSheet?.dismissAnimated()
|
||||
if SGSimpleSettings.shared.b {
|
||||
setAction(value.rawValue)
|
||||
} else {
|
||||
setAction(SGSimpleSettings.MentionsAndRepliesNotificationsSettings.default.rawValue)
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
// switch (setting) {
|
||||
// }
|
||||
|
||||
actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [
|
||||
ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
|
||||
@ -958,53 +106,7 @@ public func sgDebugController(context: AccountContext) -> ViewController {
|
||||
})
|
||||
])])
|
||||
presentControllerImpl?(actionSheet, ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
|
||||
}, openDisclosureLink: { link in
|
||||
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
||||
switch (link) {
|
||||
case .sessionBackupManager:
|
||||
if #available(iOS 13.0, *) {
|
||||
pushControllerImpl?(sgSessionBackupManagerController(context: context, presentationData: presentationData))
|
||||
} else {
|
||||
presentControllerImpl?(UndoOverlayController(
|
||||
presentationData: presentationData,
|
||||
content: .info(title: nil, text: "Update OS to access this feature", timeout: nil, customUndoText: nil),
|
||||
elevatedLayout: false,
|
||||
action: { _ in return false }
|
||||
), nil)
|
||||
}
|
||||
case .messageFilter:
|
||||
if #available(iOS 13.0, *) {
|
||||
pushControllerImpl?(sgMessageFilterController(presentationData: presentationData))
|
||||
} else {
|
||||
presentControllerImpl?(UndoOverlayController(
|
||||
presentationData: presentationData,
|
||||
content: .info(title: nil, text: "Update OS to access this feature", timeout: nil, customUndoText: nil),
|
||||
elevatedLayout: false,
|
||||
action: { _ in return false }
|
||||
), nil)
|
||||
}
|
||||
case .debugIAP:
|
||||
#if DEBUG
|
||||
if #available(iOS 13.0, *) {
|
||||
if let sgIAPManager = context.sharedContext.SGIAP {
|
||||
let statusSignal = context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.sgStatus])
|
||||
|> map { sharedData -> Int64 in
|
||||
let sgStatus = sharedData.entries[ApplicationSpecificSharedDataKeys.sgStatus]?.get(SGStatus.self) ?? SGStatus.default
|
||||
return sgStatus.status
|
||||
}
|
||||
presentControllerImpl?(sgPayWallController(statusSignal: statusSignal, replacementController: sgDebugController(context: context), presentationData: presentationData, SGIAPManager: sgIAPManager), ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
|
||||
}
|
||||
} else {
|
||||
presentControllerImpl?(UndoOverlayController(
|
||||
presentationData: presentationData,
|
||||
content: .info(title: nil, text: "Update OS to access this feature", timeout: nil, customUndoText: nil),
|
||||
elevatedLayout: false,
|
||||
action: { _ in return false }
|
||||
), nil)
|
||||
}
|
||||
|
||||
#endif
|
||||
}
|
||||
}, openDisclosureLink: { _ in
|
||||
}, action: { actionType in
|
||||
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
||||
switch actionType {
|
||||
|
17
Swiftgram/SGKeychainBackupManager/BUILD
Normal file
17
Swiftgram/SGKeychainBackupManager/BUILD
Normal file
@ -0,0 +1,17 @@
|
||||
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
|
||||
|
||||
swift_library(
|
||||
name = "SGKeychainBackupManager",
|
||||
module_name = "SGKeychainBackupManager",
|
||||
srcs = glob([
|
||||
"Sources/**/*.swift",
|
||||
]),
|
||||
copts = [
|
||||
"-warnings-as-errors",
|
||||
],
|
||||
deps = [
|
||||
],
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
],
|
||||
)
|
@ -0,0 +1,131 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
public enum KeychainError: Error {
|
||||
case duplicateEntry
|
||||
case unknown(OSStatus)
|
||||
case itemNotFound
|
||||
case invalidItemFormat
|
||||
}
|
||||
|
||||
public class KeychainBackupManager {
|
||||
public static let shared = KeychainBackupManager()
|
||||
private let service = "\(Bundle.main.bundleIdentifier!).sessionsbackup"
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Save Credentials
|
||||
public func saveSession(id: String, _ session: Data) throws {
|
||||
// Create query dictionary
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: id,
|
||||
kSecValueData as String: session,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked
|
||||
]
|
||||
|
||||
// Add to keychain
|
||||
let status = SecItemAdd(query as CFDictionary, nil)
|
||||
|
||||
if status == errSecDuplicateItem {
|
||||
// Item already exists, update it
|
||||
let updateQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: id
|
||||
]
|
||||
|
||||
let attributesToUpdate: [String: Any] = [
|
||||
kSecValueData as String: session
|
||||
]
|
||||
|
||||
let updateStatus = SecItemUpdate(updateQuery as CFDictionary,
|
||||
attributesToUpdate as CFDictionary)
|
||||
|
||||
if updateStatus != errSecSuccess {
|
||||
throw KeychainError.unknown(updateStatus)
|
||||
}
|
||||
} else if status != errSecSuccess {
|
||||
throw KeychainError.unknown(status)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Retrieve Credentials
|
||||
public func retrieveSession(for id: String) throws -> Data {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: id,
|
||||
kSecReturnData as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status == errSecSuccess, let sessionData = result as? Data else {
|
||||
throw KeychainError.itemNotFound
|
||||
}
|
||||
|
||||
return sessionData
|
||||
}
|
||||
|
||||
// MARK: - Delete Credentials
|
||||
public func deleteSession(for id: String) throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: id
|
||||
]
|
||||
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
|
||||
if status != errSecSuccess && status != errSecItemNotFound {
|
||||
throw KeychainError.unknown(status)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Retrieve All Accounts
|
||||
public func getAllSessons() throws -> [Data] {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitAll
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
if status == errSecItemNotFound {
|
||||
return []
|
||||
}
|
||||
|
||||
guard status == errSecSuccess,
|
||||
let credentialsDataArray = result as? [Data] else {
|
||||
throw KeychainError.unknown(status)
|
||||
}
|
||||
|
||||
return credentialsDataArray
|
||||
}
|
||||
|
||||
// MARK: - Delete All Sessions
|
||||
public func deleteAllSessions() throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service
|
||||
]
|
||||
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
|
||||
// If no items were found, that's fine - just return
|
||||
if status == errSecItemNotFound {
|
||||
return
|
||||
}
|
||||
|
||||
// For any other error, throw
|
||||
if status != errSecSuccess {
|
||||
throw KeychainError.unknown(status)
|
||||
}
|
||||
}
|
||||
}
|
@ -13,7 +13,7 @@ import TelegramUIPreferences
|
||||
|
||||
|
||||
@available(iOS 13.0, *)
|
||||
public func sgPayWallController(statusSignal: Signal<Int64, NoError>, replacementController: ViewController, presentationData: PresentationData? = nil, SGIAPManager: SGIAPManager) -> ViewController {
|
||||
public func sgPayWallController(statusSignal: Signal<Int64, NoError>, replacementController: ViewController, presentationData: PresentationData? = nil, SGIAPManager: SGIAPManager, openUrl: @escaping (String) -> Void) -> ViewController {
|
||||
// let theme = presentationData?.theme ?? (UITraitCollection.current.userInterfaceStyle == .dark ? defaultDarkColorPresentationTheme : defaultPresentationTheme)
|
||||
let theme = defaultDarkColorPresentationTheme
|
||||
let strings = presentationData?.strings ?? defaultPresentationStrings
|
||||
@ -30,7 +30,7 @@ public func sgPayWallController(statusSignal: Signal<Int64, NoError>, replacemen
|
||||
let swiftUIView = SGSwiftUIView<SGPayWallView>(
|
||||
legacyController: legacyController,
|
||||
content: {
|
||||
SGPayWallView(wrapperController: legacyController, replacementController: replacementController, SGIAP: SGIAPManager, statusSignal: statusSignal)
|
||||
SGPayWallView(wrapperController: legacyController, replacementController: replacementController, SGIAP: SGIAPManager, statusSignal: statusSignal, openUrl: openUrl)
|
||||
}
|
||||
)
|
||||
let controller = UIHostingController(rootView: swiftUIView, ignoreSafeArea: true)
|
||||
@ -105,6 +105,7 @@ struct SGPayWallView: View {
|
||||
let replacementController: ViewController
|
||||
let SGIAP: SGIAPManager
|
||||
let statusSignal: Signal<Int64, NoError>
|
||||
let openUrl: (String) -> Void
|
||||
|
||||
private enum PayWallState: Equatable {
|
||||
case ready // ready to buy
|
||||
@ -147,7 +148,7 @@ struct SGPayWallView: View {
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
|
||||
Text("Supercharged with Pro features".i18n(lang))
|
||||
Text("PayWall.Text".i18n(lang))
|
||||
.font(.callout)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
@ -156,14 +157,18 @@ struct SGPayWallView: View {
|
||||
// Features
|
||||
VStack(spacing: 8) {
|
||||
featuresSection
|
||||
legalSection
|
||||
restorePurchasesButton
|
||||
}
|
||||
|
||||
|
||||
// Spacer for purchase buttons
|
||||
Color.clear.frame(height: 50)
|
||||
}
|
||||
.padding(.vertical, 50)
|
||||
}
|
||||
.padding(.leading, max(innerShadowWidth + 8.0, sgLeftSafeAreaInset(containerViewLayout)))
|
||||
.padding(.trailing, max(innerShadowWidth + 8.0, sgRightSafeAreaInset(containerViewLayout)))
|
||||
|
||||
// Fixed purchase button at bottom
|
||||
purchaseSection
|
||||
@ -220,7 +225,7 @@ struct SGPayWallView: View {
|
||||
if let userInfo = notification.userInfo, let error = userInfo["error"] as? String, !error.isEmpty {
|
||||
showErrorAlert(error)
|
||||
} else {
|
||||
showErrorAlert("Validation Error")
|
||||
showErrorAlert("PayWall.ValidationError".i18n(lang))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -230,35 +235,33 @@ struct SGPayWallView: View {
|
||||
VStack(spacing: 8) {
|
||||
FeatureRow(
|
||||
icon: FeatureIcon(icon: "lock.fill", backgroundColor: .blue),
|
||||
title: "Session Backup",
|
||||
subtitle: "Restore sessions from encrypted local Apple Keychain backup."
|
||||
title: "PayWall.SessionBackup.Title".i18n(lang),
|
||||
subtitle: "PayWall.SessionBackup.Notice".i18n(lang)
|
||||
)
|
||||
|
||||
FeatureRow(
|
||||
icon: FeatureIcon(icon: "nosign", backgroundColor: .gray, fontWeight: .bold),
|
||||
title: "Message Filter",
|
||||
subtitle: "Reduce visibility of spam, promotions and annoying messages."
|
||||
title: "PayWall.MessageFilter.Title".i18n(lang),
|
||||
subtitle: "PayWall.MessageFilter.Notice".i18n(lang)
|
||||
)
|
||||
|
||||
FeatureRow(
|
||||
icon: FeatureIcon(icon: "bell.badge.slash.fill", backgroundColor: .red),
|
||||
title: "Disable @mentions and replies",
|
||||
subtitle: "Hide or silence non-important notifications."
|
||||
title: "PayWall.Notifications.Title".i18n(lang),
|
||||
subtitle: "PayWall.MessageFilter.Notice".i18n(lang)
|
||||
)
|
||||
|
||||
FeatureRow(
|
||||
icon: FeatureIcon(icon: "bold.underline", backgroundColor: .blue, iconSize: 16),
|
||||
title: "Quick Formatting panel",
|
||||
subtitle: "Save time preparing your posts with a panel right above your keyboard."
|
||||
title: "PayWall.InputToolbar.Title".i18n(lang),
|
||||
subtitle: "PayWall.InputToolbar.Notice".i18n(lang)
|
||||
)
|
||||
}
|
||||
.padding(.leading, max(innerShadowWidth + 8.0, sgLeftSafeAreaInset(containerViewLayout)))
|
||||
.padding(.trailing, max(innerShadowWidth + 8.0, sgRightSafeAreaInset(containerViewLayout)))
|
||||
}
|
||||
|
||||
private var restorePurchasesButton: some View {
|
||||
Button(action: handleRestorePurchases) {
|
||||
Text("Restore Purchases")
|
||||
Text("PayWall.RestorePurchases".i18n(lang))
|
||||
.font(.footnote)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(Color(hex: accentColorHex))
|
||||
@ -290,6 +293,41 @@ struct SGPayWallView: View {
|
||||
.shadow(radius: 8, y: -4)
|
||||
}
|
||||
|
||||
private var legalSection: some View {
|
||||
Group {
|
||||
if #available(iOS 15.0, *) {
|
||||
Text(LocalizedStringKey("PayWall.Notice.Markdown".i18n(lang, args: "PayWall.TermsURL".i18n(lang), "PayWall.PrivacyURL".i18n(lang))))
|
||||
.font(.caption)
|
||||
.tint(Color(hex: accentColorHex))
|
||||
.foregroundColor(.secondary)
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
openUrl(url.absoluteString)
|
||||
return .handled
|
||||
})
|
||||
} else {
|
||||
Text("PayWall.Notice.Raw".i18n(lang))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Button(action: {
|
||||
openUrl("PayWall.PrivacyURL".i18n(lang))
|
||||
}) {
|
||||
Text("PayWall.Privacy".i18n(lang))
|
||||
.font(.caption)
|
||||
.foregroundColor(Color(hex: accentColorHex))
|
||||
}
|
||||
Button(action: {
|
||||
openUrl("PayWall.TermsURL".i18n(lang))
|
||||
}) {
|
||||
Text("PayWall.Terms".i18n(lang))
|
||||
.font(.caption)
|
||||
.foregroundColor(Color(hex: accentColorHex))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var closeButtonView: some View {
|
||||
Button(action: {
|
||||
wrapperController?.dismiss(animated: true)
|
||||
@ -306,22 +344,22 @@ struct SGPayWallView: View {
|
||||
|
||||
private var buttonTitle: String {
|
||||
if currentStatus > 1 {
|
||||
return "Use Pro features".i18n(lang)
|
||||
return "PayWall.Button.OpenPro".i18n(lang)
|
||||
} else {
|
||||
if state == .purchasing {
|
||||
return "Purchasing...".i18n(lang)
|
||||
return "PayWall.Button.Purchasing".i18n(lang)
|
||||
} else if state == .restoring {
|
||||
return "Restoring Purchases...".i18n(lang)
|
||||
return "PayWall.Button.Restoring".i18n(lang)
|
||||
} else if state == .validating {
|
||||
return "Validating Purchase...".i18n(lang)
|
||||
return "PayWall.Button.Validating".i18n(lang)
|
||||
} else if let product = product {
|
||||
if !SGIAP.canMakePayments {
|
||||
return "Payments unavailable".i18n(lang)
|
||||
return "PayWall.Button.PaymentsUnavailable".i18n(lang)
|
||||
} else {
|
||||
return "Subscribe for \(product.price) / month".i18n(lang, args: product.price)
|
||||
return "PayWall.Button.Subscribe".i18n(lang, args: product.price)
|
||||
}
|
||||
} else {
|
||||
return "Contacting App Store...".i18n(lang)
|
||||
return "Paywall.Button.ContactingAppStore".i18n(lang)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
41
Swiftgram/SGProUI/BUILD
Normal file
41
Swiftgram/SGProUI/BUILD
Normal file
@ -0,0 +1,41 @@
|
||||
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
|
||||
|
||||
|
||||
swift_library(
|
||||
name = "SGProUI",
|
||||
module_name = "SGProUI",
|
||||
srcs = glob([
|
||||
"Sources/**/*.swift",
|
||||
]),
|
||||
copts = [
|
||||
"-warnings-as-errors",
|
||||
],
|
||||
deps = [
|
||||
"//Swiftgram/SGKeychainBackupManager:SGKeychainBackupManager",
|
||||
"//Swiftgram/SGItemListUI:SGItemListUI",
|
||||
"//Swiftgram/SGLogging:SGLogging",
|
||||
"//Swiftgram/SGSimpleSettings:SGSimpleSettings",
|
||||
"//Swiftgram/SGStrings:SGStrings",
|
||||
"//Swiftgram/SGAPI:SGAPI",
|
||||
"//Swiftgram/SGAPIToken:SGAPIToken",
|
||||
"//Swiftgram/SGSwiftUI:SGSwiftUI",
|
||||
#
|
||||
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
|
||||
"//submodules/Display:Display",
|
||||
"//submodules/Postbox:Postbox",
|
||||
"//submodules/TelegramCore:TelegramCore",
|
||||
"//submodules/MtProtoKit:MtProtoKit",
|
||||
"//submodules/TelegramPresentationData:TelegramPresentationData",
|
||||
"//submodules/TelegramUIPreferences:TelegramUIPreferences",
|
||||
"//submodules/ItemListUI:ItemListUI",
|
||||
"//submodules/PresentationDataUtils:PresentationDataUtils",
|
||||
"//submodules/OverlayStatusController:OverlayStatusController",
|
||||
"//submodules/AccountContext:AccountContext",
|
||||
"//submodules/AppBundle:AppBundle",
|
||||
"//submodules/TelegramUI/Components/Settings/PeerNameColorScreen",
|
||||
"//submodules/UndoUI:UndoUI",
|
||||
],
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
],
|
||||
)
|
181
Swiftgram/SGProUI/Sources/MessageFilterController.swift
Normal file
181
Swiftgram/SGProUI/Sources/MessageFilterController.swift
Normal file
@ -0,0 +1,181 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import SGSwiftUI
|
||||
import SGStrings
|
||||
import SGSimpleSettings
|
||||
import LegacyUI
|
||||
import Display
|
||||
import TelegramPresentationData
|
||||
|
||||
@available(iOS 13.0, *)
|
||||
struct MessageFilterKeywordInputFieldModifier: ViewModifier {
|
||||
@Binding var newKeyword: String
|
||||
let onAdd: () -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 15.0, *) {
|
||||
content
|
||||
.submitLabel(.return)
|
||||
.submitScope(false) // TODO(swiftgram): Keyboard still closing
|
||||
.interactiveDismissDisabled()
|
||||
.onSubmit {
|
||||
onAdd()
|
||||
}
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@available(iOS 13.0, *)
|
||||
struct MessageFilterKeywordInputView: View {
|
||||
@Environment(\.lang) var lang: String
|
||||
@Binding var newKeyword: String
|
||||
let onAdd: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
TextField("MessageFilter.InputPlaceholder".i18n(lang), text: $newKeyword)
|
||||
.autocorrectionDisabled(true)
|
||||
.autocapitalization(.none)
|
||||
.keyboardType(.default)
|
||||
.modifier(MessageFilterKeywordInputFieldModifier(newKeyword: $newKeyword, onAdd: onAdd))
|
||||
|
||||
|
||||
Button(action: onAdd) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.foregroundColor(newKeyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? .secondary : .accentColor)
|
||||
.imageScale(.large)
|
||||
}
|
||||
.disabled(newKeyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 13.0, *)
|
||||
struct MessageFilterView: View {
|
||||
weak var wrapperController: LegacyController?
|
||||
@Environment(\.lang) var lang: String
|
||||
|
||||
@State private var newKeyword: String = ""
|
||||
@State private var keywords: [String] {
|
||||
didSet {
|
||||
SGSimpleSettings.shared.messageFilterKeywords = keywords
|
||||
}
|
||||
}
|
||||
|
||||
init(wrapperController: LegacyController?) {
|
||||
self.wrapperController = wrapperController
|
||||
_keywords = State(initialValue: SGSimpleSettings.shared.messageFilterKeywords)
|
||||
}
|
||||
|
||||
var bodyContent: some View {
|
||||
List {
|
||||
Section {
|
||||
// Icon and title
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "nosign.app.fill")
|
||||
.font(.system(size: 50))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("MessageFilter.Title".i18n(lang))
|
||||
.font(.title)
|
||||
.bold()
|
||||
|
||||
Text("MessageFilter.SubTitle".i18n(lang))
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
.listRowInsets(EdgeInsets())
|
||||
|
||||
}
|
||||
|
||||
Section {
|
||||
MessageFilterKeywordInputView(newKeyword: $newKeyword, onAdd: addKeyword)
|
||||
}
|
||||
|
||||
Section(header: Text("MessageFilter.Keywords.Title".i18n(lang))) {
|
||||
ForEach(keywords.reversed(), id: \.self) { keyword in
|
||||
Text(keyword)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
let originalIndices = IndexSet(
|
||||
indexSet.map { keywords.count - 1 - $0 }
|
||||
)
|
||||
deleteKeywords(at: originalIndices)
|
||||
}
|
||||
}
|
||||
}
|
||||
.tgNavigationBackButton(wrapperController: wrapperController)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
if #available(iOS 14.0, *) {
|
||||
bodyContent
|
||||
.toolbar {
|
||||
EditButton()
|
||||
}
|
||||
} else {
|
||||
bodyContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addKeyword() {
|
||||
let trimmedKeyword = newKeyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedKeyword.isEmpty else { return }
|
||||
|
||||
let keywordExists = keywords.contains {
|
||||
$0 == trimmedKeyword
|
||||
}
|
||||
|
||||
guard !keywordExists else {
|
||||
return
|
||||
}
|
||||
|
||||
withAnimation {
|
||||
keywords.append(trimmedKeyword)
|
||||
}
|
||||
newKeyword = ""
|
||||
|
||||
}
|
||||
|
||||
private func deleteKeywords(at offsets: IndexSet) {
|
||||
withAnimation {
|
||||
keywords.remove(atOffsets: offsets)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 13.0, *)
|
||||
public func sgMessageFilterController(presentationData: PresentationData? = nil) -> ViewController {
|
||||
let theme = presentationData?.theme ?? (UITraitCollection.current.userInterfaceStyle == .dark ? defaultDarkColorPresentationTheme : defaultPresentationTheme)
|
||||
let strings = presentationData?.strings ?? defaultPresentationStrings
|
||||
|
||||
let legacyController = LegacySwiftUIController(
|
||||
presentation: .navigation,
|
||||
theme: theme,
|
||||
strings: strings
|
||||
)
|
||||
// Status bar color will break if theme changed
|
||||
legacyController.statusBar.statusBarStyle = theme.rootController
|
||||
.statusBarStyle.style
|
||||
legacyController.displayNavigationBar = false
|
||||
let swiftUIView = SGSwiftUIView<MessageFilterView>(
|
||||
legacyController: legacyController,
|
||||
content: {
|
||||
MessageFilterView(wrapperController: legacyController)
|
||||
}
|
||||
)
|
||||
let controller = UIHostingController(rootView: swiftUIView, ignoreSafeArea: true)
|
||||
legacyController.bind(controller: controller)
|
||||
|
||||
return legacyController
|
||||
}
|
159
Swiftgram/SGProUI/Sources/SGProUI.swift
Normal file
159
Swiftgram/SGProUI/Sources/SGProUI.swift
Normal file
@ -0,0 +1,159 @@
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
import SGItemListUI
|
||||
import UndoUI
|
||||
import AccountContext
|
||||
import Display
|
||||
import TelegramCore
|
||||
import Postbox
|
||||
import ItemListUI
|
||||
import SwiftSignalKit
|
||||
import TelegramPresentationData
|
||||
import PresentationDataUtils
|
||||
import TelegramUIPreferences
|
||||
|
||||
// Optional
|
||||
import SGSimpleSettings
|
||||
import SGLogging
|
||||
|
||||
|
||||
private enum SGProControllerSection: Int32, SGItemListSection {
|
||||
case base
|
||||
case notifications
|
||||
}
|
||||
|
||||
private enum SGProDisclosureLink: String {
|
||||
case sessionBackupManager
|
||||
case messageFilter
|
||||
}
|
||||
|
||||
private enum SGProToggles: String {
|
||||
case inputToolbar
|
||||
}
|
||||
|
||||
private enum SGProOneFromManySetting: String {
|
||||
case pinnedMessageNotifications
|
||||
case mentionsAndRepliesNotifications
|
||||
}
|
||||
|
||||
private typealias SGProControllerEntry = SGItemListUIEntry<SGProControllerSection, SGProToggles, AnyHashable, SGProOneFromManySetting, SGProDisclosureLink, AnyHashable>
|
||||
|
||||
private func SGProControllerEntries(presentationData: PresentationData) -> [SGProControllerEntry] {
|
||||
var entries: [SGProControllerEntry] = []
|
||||
let lang = presentationData.strings.baseLanguageCode
|
||||
|
||||
let id = SGItemListCounter()
|
||||
|
||||
entries.append(.disclosure(id: id.count, section: .base, link: .sessionBackupManager, text: "SessionBackup.Title".i18n(lang)))
|
||||
entries.append(.disclosure(id: id.count, section: .base, link: .messageFilter, text: "MessageFilter.Title".i18n(lang)))
|
||||
entries.append(.toggle(id: id.count, section: .base, settingName: .inputToolbar, value: SGSimpleSettings.shared.inputToolbar, text: "InputToolbar.Title".i18n(lang), enabled: true))
|
||||
|
||||
entries.append(.header(id: id.count, section: .notifications, text: presentationData.strings.Notifications_Title.uppercased(), badge: nil))
|
||||
entries.append(.oneFromManySelector(id: id.count, section: .notifications, settingName: .pinnedMessageNotifications, text: "Notifications.PinnedMessages.Title".i18n(lang), value: "Notifications.PinnedMessages.value.\(SGSimpleSettings.shared.pinnedMessageNotifications)".i18n(lang), enabled: true))
|
||||
entries.append(.oneFromManySelector(id: id.count, section: .notifications, settingName: .mentionsAndRepliesNotifications, text: "Notifications.MentionsAndReplies.Title".i18n(lang), value: "Notifications.MentionsAndReplies.value.\(SGSimpleSettings.shared.mentionsAndRepliesNotifications)".i18n(lang), enabled: true))
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
public func okUndoController(_ text: String, _ presentationData: PresentationData) -> UndoOverlayController {
|
||||
return UndoOverlayController(presentationData: presentationData, content: .succeed(text: text, timeout: nil, customUndoText: nil), elevatedLayout: false, action: { _ in return false })
|
||||
}
|
||||
|
||||
public func sgProController(context: AccountContext) -> ViewController {
|
||||
var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments?) -> Void)?
|
||||
var pushControllerImpl: ((ViewController) -> Void)?
|
||||
|
||||
let simplePromise = ValuePromise(true, ignoreRepeated: false)
|
||||
|
||||
let arguments = SGItemListArguments<SGProToggles, AnyHashable, SGProOneFromManySetting, SGProDisclosureLink, AnyHashable>(context: context, setBoolValue: { toggleName, value in
|
||||
switch toggleName {
|
||||
case .inputToolbar:
|
||||
SGSimpleSettings.shared.inputToolbar = value
|
||||
}
|
||||
}, setOneFromManyValue: { setting in
|
||||
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
||||
let lang = presentationData.strings.baseLanguageCode
|
||||
let actionSheet = ActionSheetController(presentationData: presentationData)
|
||||
var items: [ActionSheetItem] = []
|
||||
|
||||
switch (setting) {
|
||||
case .pinnedMessageNotifications:
|
||||
let setAction: (String) -> Void = { value in
|
||||
SGSimpleSettings.shared.pinnedMessageNotifications = value
|
||||
SGSimpleSettings.shared.synchronizeShared()
|
||||
simplePromise.set(true)
|
||||
}
|
||||
|
||||
for value in SGSimpleSettings.PinnedMessageNotificationsSettings.allCases {
|
||||
items.append(ActionSheetButtonItem(title: "Notifications.PinnedMessages.value.\(value.rawValue)".i18n(lang), color: .accent, action: { [weak actionSheet] in
|
||||
actionSheet?.dismissAnimated()
|
||||
setAction(value.rawValue)
|
||||
}))
|
||||
}
|
||||
case .mentionsAndRepliesNotifications:
|
||||
let setAction: (String) -> Void = { value in
|
||||
SGSimpleSettings.shared.mentionsAndRepliesNotifications = value
|
||||
SGSimpleSettings.shared.synchronizeShared()
|
||||
simplePromise.set(true)
|
||||
}
|
||||
|
||||
for value in SGSimpleSettings.MentionsAndRepliesNotificationsSettings.allCases {
|
||||
items.append(ActionSheetButtonItem(title: "Notifications.MentionsAndReplies.value.\(value.rawValue)".i18n(lang), color: .accent, action: { [weak actionSheet] in
|
||||
actionSheet?.dismissAnimated()
|
||||
setAction(value.rawValue)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [
|
||||
ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
|
||||
actionSheet?.dismissAnimated()
|
||||
})
|
||||
])])
|
||||
presentControllerImpl?(actionSheet, ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
|
||||
}, openDisclosureLink: { link in
|
||||
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
||||
switch (link) {
|
||||
case .sessionBackupManager:
|
||||
if #available(iOS 13.0, *) {
|
||||
pushControllerImpl?(sgSessionBackupManagerController(context: context, presentationData: presentationData))
|
||||
} else {
|
||||
presentControllerImpl?(context.sharedContext.makeSGUpdateIOSController(), nil)
|
||||
}
|
||||
case .messageFilter:
|
||||
if #available(iOS 13.0, *) {
|
||||
pushControllerImpl?(sgMessageFilterController(presentationData: presentationData))
|
||||
} else {
|
||||
presentControllerImpl?(context.sharedContext.makeSGUpdateIOSController(), nil)
|
||||
}
|
||||
}
|
||||
}, action: { _ in
|
||||
|
||||
})
|
||||
|
||||
let signal = combineLatest(context.sharedContext.presentationData, simplePromise.get())
|
||||
|> map { presentationData, _ -> (ItemListControllerState, (ItemListNodeState, Any)) in
|
||||
|
||||
let entries = SGProControllerEntries(presentationData: presentationData)
|
||||
|
||||
let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .text("Swiftgram Pro"), leftNavigationButton: nil, rightNavigationButton: nil, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back))
|
||||
|
||||
let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: entries, style: .blocks, ensureVisibleItemTag: /*focusOnItemTag*/ nil, initialScrollToItem: nil /* scrollToItem*/ )
|
||||
|
||||
return (controllerState, (listState, arguments))
|
||||
}
|
||||
|
||||
let controller = ItemListController(context: context, state: signal)
|
||||
presentControllerImpl = { [weak controller] c, a in
|
||||
controller?.present(c, in: .window(.root), with: a)
|
||||
}
|
||||
pushControllerImpl = { [weak controller] c in
|
||||
(controller?.navigationController as? NavigationController)?.pushViewController(c)
|
||||
}
|
||||
// Workaround
|
||||
let _ = pushControllerImpl
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
|
520
Swiftgram/SGProUI/Sources/SessionBackupController.swift
Normal file
520
Swiftgram/SGProUI/Sources/SessionBackupController.swift
Normal file
@ -0,0 +1,520 @@
|
||||
import Foundation
|
||||
import UndoUI
|
||||
import AccountContext
|
||||
import TelegramCore
|
||||
import Postbox
|
||||
import Display
|
||||
import SwiftSignalKit
|
||||
import TelegramPresentationData
|
||||
import PresentationDataUtils
|
||||
import SGSimpleSettings
|
||||
import SGLogging
|
||||
import SGKeychainBackupManager
|
||||
|
||||
struct SessionBackup: Codable {
|
||||
var name: String? = nil
|
||||
var date: Date = Date()
|
||||
let accountRecord: AccountRecord<TelegramAccountManagerTypes.Attribute>
|
||||
|
||||
var peerIdInternal: Int64 {
|
||||
var userId: Int64 = 0
|
||||
for attribute in accountRecord.attributes {
|
||||
if case let .backupData(backupData) = attribute, let backupPeerID = backupData.data?.peerId {
|
||||
userId = backupPeerID
|
||||
break
|
||||
}
|
||||
}
|
||||
return userId
|
||||
}
|
||||
|
||||
var userId: Int64 {
|
||||
return PeerId(peerIdInternal).id._internalGetInt64Value()
|
||||
}
|
||||
}
|
||||
|
||||
import SwiftUI
|
||||
import SGSwiftUI
|
||||
import LegacyUI
|
||||
import SGStrings
|
||||
|
||||
|
||||
@available(iOS 13.0, *)
|
||||
struct SessionBackupRow: View {
|
||||
@Environment(\.lang) var lang: String
|
||||
let backup: SessionBackup
|
||||
let isLoggedIn: Bool
|
||||
|
||||
|
||||
private let dateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .short
|
||||
formatter.timeStyle = .short
|
||||
return formatter
|
||||
}()
|
||||
|
||||
var formattedDate: String {
|
||||
if #available(iOS 15.0, *) {
|
||||
return backup.date.formatted(date: .abbreviated, time: .shortened)
|
||||
} else {
|
||||
return dateFormatter.string(from: backup.date)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(backup.name ?? String(backup.userId))
|
||||
.font(.body)
|
||||
|
||||
Text("ID: \(backup.userId)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("SessionBackup.LastBackupAt".i18n(lang, args: formattedDate))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text((isLoggedIn ? "SessionBackup.LoggedIn" : "SessionBackup.LoggedOut").i18n(lang))
|
||||
.font(.caption)
|
||||
.foregroundColor(isLoggedIn ? .white : .secondary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(isLoggedIn ? Color.accentColor : Color.secondary.opacity(0.1))
|
||||
.cornerRadius(4)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@available(iOS 13.0, *)
|
||||
struct BorderedButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.accentColor, lineWidth: 1)
|
||||
)
|
||||
.opacity(configuration.isPressed ? 0.7 : 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 13.0, *)
|
||||
struct SessionBackupManagerView: View {
|
||||
@Environment(\.lang) var lang: String
|
||||
weak var wrapperController: LegacyController?
|
||||
let context: AccountContext
|
||||
|
||||
@State private var sessions: [SessionBackup] = []
|
||||
@State private var loggedInPeerIDs: [Int64] = []
|
||||
@State private var loggedInAccountsDisposable: Disposable? = nil
|
||||
|
||||
private func performBackup() {
|
||||
let controller = OverlayStatusController(theme: context.sharedContext.currentPresentationData.with { $0 }.theme, type: .loading(cancelled: nil))
|
||||
|
||||
let signal = context.sharedContext.accountManager.accountRecords()
|
||||
|> take(1)
|
||||
|> deliverOnMainQueue
|
||||
|
||||
let signal2 = context.sharedContext.activeAccountsWithInfo
|
||||
|> take(1)
|
||||
|> deliverOnMainQueue
|
||||
|
||||
wrapperController?.present(controller, in: .window(.root), with: nil)
|
||||
|
||||
Task {
|
||||
if let result = try? await combineLatest(signal, signal2).awaitable() {
|
||||
let (view, accountsWithInfo) = result
|
||||
backupSessionsFromView(view, accountsWithInfo: accountsWithInfo.1)
|
||||
withAnimation {
|
||||
sessions = getBackedSessions()
|
||||
}
|
||||
controller.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func performRestore() {
|
||||
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
||||
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: nil))
|
||||
|
||||
let _ = (context.sharedContext.accountManager.accountRecords()
|
||||
|> take(1)
|
||||
|> deliverOnMainQueue).start(next: { [weak controller] view in
|
||||
|
||||
let backupSessions = getBackedSessions()
|
||||
var restoredSessions: Int64 = 0
|
||||
|
||||
func importNextBackup(index: Int) {
|
||||
// Check if we're done
|
||||
if index >= backupSessions.count {
|
||||
// All done, update UI
|
||||
withAnimation {
|
||||
sessions = getBackedSessions()
|
||||
}
|
||||
controller?.dismiss()
|
||||
wrapperController?.present(
|
||||
okUndoController("SessionBackup.RestoreOK".i18n(lang, args: "\(restoredSessions)"), presentationData),
|
||||
in: .current
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let backup = backupSessions[index]
|
||||
|
||||
// Check for existing record
|
||||
let existingRecord = view.records.first { record in
|
||||
var userId: Int64 = 0
|
||||
for attribute in record.attributes {
|
||||
if case let .backupData(backupData) = attribute {
|
||||
userId = backupData.data?.peerId ?? 0
|
||||
}
|
||||
}
|
||||
return userId == backup.peerIdInternal
|
||||
}
|
||||
|
||||
if existingRecord != nil {
|
||||
SGLogger.shared.log("SessionBackup", "Record \(backup.userId) already exists, skipping")
|
||||
importNextBackup(index: index + 1)
|
||||
return
|
||||
}
|
||||
|
||||
var importAttributes = backup.accountRecord.attributes
|
||||
importAttributes.removeAll { attribute in
|
||||
if case .sortOrder = attribute {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
let importBackupSignal = context.sharedContext.accountManager.transaction { transaction -> Void in
|
||||
let nextSortOrder = (transaction.getRecords().map({ record -> Int32 in
|
||||
for attribute in record.attributes {
|
||||
if case let .sortOrder(sortOrder) = attribute {
|
||||
return sortOrder.order
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}).max() ?? 0) + 1
|
||||
importAttributes.append(.sortOrder(AccountSortOrderAttribute(order: nextSortOrder)))
|
||||
let accountRecordId = transaction.createRecord(importAttributes)
|
||||
SGLogger.shared.log("SessionBackup", "Imported record \(accountRecordId) for \(backup.userId)")
|
||||
restoredSessions += 1
|
||||
}
|
||||
|> deliverOnMainQueue
|
||||
|
||||
let _ = importBackupSignal.start(completed: {
|
||||
importNextBackup(index: index + 1)
|
||||
})
|
||||
}
|
||||
|
||||
// Start the import chain
|
||||
importNextBackup(index: 0)
|
||||
})
|
||||
|
||||
wrapperController?.present(controller, in: .window(.root), with: nil)
|
||||
}
|
||||
|
||||
private func performDeleteAll() {
|
||||
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
||||
|
||||
let controller = textAlertController(context: context, title: "SessionBackup.DeleteAllBackups".i18n(lang), text: "SessionBackup.DeleteAllBackups.Subtitle".i18n(lang), actions: [
|
||||
TextAlertAction(type: .destructiveAction, title: presentationData.strings.Common_Delete, action: {
|
||||
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: nil))
|
||||
wrapperController?.present(controller, in: .window(.root), with: nil)
|
||||
do {
|
||||
try KeychainBackupManager.shared.deleteAllSessions()
|
||||
withAnimation {
|
||||
sessions = getBackedSessions()
|
||||
}
|
||||
controller.dismiss()
|
||||
} catch let e {
|
||||
SGLogger.shared.log("SessionBackup", "Error deleting all sessions: \(e)")
|
||||
}
|
||||
}),
|
||||
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {})
|
||||
])
|
||||
|
||||
wrapperController?.present(controller, in: .window(.root), with: nil)
|
||||
}
|
||||
|
||||
private func performDelete(_ session: SessionBackup) {
|
||||
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
||||
|
||||
let controller = textAlertController(context: context, title: "SessionBackup.DeleteSingle.Title".i18n(lang), text: "Sessionbackup.DeleteSingle.Text".i18n(lang, args: "\(session.name ?? "\(session.userId)")"), actions: [
|
||||
TextAlertAction(type: .destructiveAction, title: presentationData.strings.Common_Delete, action: {
|
||||
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: nil))
|
||||
wrapperController?.present(controller, in: .window(.root), with: nil)
|
||||
do {
|
||||
try KeychainBackupManager.shared.deleteSession(for: "\(session.peerIdInternal)")
|
||||
withAnimation {
|
||||
sessions = getBackedSessions()
|
||||
}
|
||||
controller.dismiss()
|
||||
} catch let e {
|
||||
SGLogger.shared.log("SessionBackup", "Error deleting session: \(e)")
|
||||
}
|
||||
}),
|
||||
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {})
|
||||
])
|
||||
|
||||
wrapperController?.present(controller, in: .window(.root), with: nil)
|
||||
}
|
||||
|
||||
|
||||
private func performRemoveSessionFromApp(session: SessionBackup) {
|
||||
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
||||
|
||||
let controller = textAlertController(context: context, title: "SessionBackup.RemoveFromApp.Title".i18n(lang), text: "SessionBackup.RemoveFromApp.Text".i18n(lang, args: "\(session.name ?? "\(session.userId)")"), actions: [
|
||||
TextAlertAction(type: .destructiveAction, title: presentationData.strings.Common_Delete, action: {
|
||||
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: nil))
|
||||
wrapperController?.present(controller, in: .window(.root), with: nil)
|
||||
|
||||
let signal = context.sharedContext.accountManager.accountRecords()
|
||||
|> take(1)
|
||||
|> deliverOnMainQueue
|
||||
|
||||
let _ = signal.start(next: { [weak controller] view in
|
||||
|
||||
// Find record to delete
|
||||
let accountRecord = view.records.first { record in
|
||||
var userId: Int64 = 0
|
||||
for attribute in record.attributes {
|
||||
if case let .backupData(backupData) = attribute {
|
||||
userId = backupData.data?.peerId ?? 0
|
||||
}
|
||||
}
|
||||
return userId == session.peerIdInternal
|
||||
}
|
||||
|
||||
if let record = accountRecord {
|
||||
let deleteSignal = context.sharedContext.accountManager.transaction { transaction -> Void in
|
||||
transaction.updateRecord(record.id, { _ in return nil})
|
||||
}
|
||||
|> deliverOnMainQueue
|
||||
|
||||
let _ = deleteSignal.start(next: {
|
||||
withAnimation {
|
||||
sessions = getBackedSessions()
|
||||
}
|
||||
controller?.dismiss()
|
||||
})
|
||||
} else {
|
||||
controller?.dismiss()
|
||||
}
|
||||
})
|
||||
|
||||
}),
|
||||
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {})
|
||||
])
|
||||
|
||||
wrapperController?.present(controller, in: .window(.root), with: nil)
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section() {
|
||||
Button(action: performBackup) {
|
||||
HStack {
|
||||
Image(systemName: "key.fill")
|
||||
.frame(width: 30)
|
||||
Text("SessionBackup.Actions.Backup".i18n(lang))
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: performRestore) {
|
||||
HStack {
|
||||
Image(systemName: "arrow.2.circlepath")
|
||||
.frame(width: 30)
|
||||
Text("SessionBackup.Actions.Restore".i18n(lang))
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: performDeleteAll) {
|
||||
HStack {
|
||||
Image(systemName: "trash")
|
||||
.frame(width: 30)
|
||||
Text("SessionBackup.Actions.DeleteAll".i18n(lang))
|
||||
}
|
||||
}
|
||||
.foregroundColor(.red)
|
||||
|
||||
}
|
||||
|
||||
Text("SessionBackup.Notice".i18n(lang))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Section(header: Text("SessionBackup.Sessions.Title".i18n(lang))) {
|
||||
ForEach(sessions, id: \.peerIdInternal) { session in
|
||||
SessionBackupRow(
|
||||
backup: session,
|
||||
isLoggedIn: loggedInPeerIDs.contains(session.peerIdInternal)
|
||||
)
|
||||
.contextMenu {
|
||||
Button(action: {
|
||||
performDelete(session)
|
||||
}, label: {
|
||||
HStack(spacing: 4) {
|
||||
Text("SessionBackup.Actions.DeleteOne".i18n(lang))
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
})
|
||||
Button(action: {
|
||||
performRemoveSessionFromApp(session: session)
|
||||
}, label: {
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Text("SessionBackup.Actions.RemoveFromApp".i18n(lang))
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
// .onDelete { indexSet in
|
||||
// performDelete(indexSet)
|
||||
// }
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation {
|
||||
sessions = getBackedSessions()
|
||||
}
|
||||
|
||||
let accountsSignal = context.sharedContext.accountManager.accountRecords()
|
||||
|> deliverOnMainQueue
|
||||
|
||||
loggedInAccountsDisposable = accountsSignal.start(next: { view in
|
||||
var result: [Int64] = []
|
||||
for record in view.records {
|
||||
var isLoggedOut: Bool = false
|
||||
var userId: Int64 = 0
|
||||
for attribute in record.attributes {
|
||||
if case .loggedOut = attribute {
|
||||
isLoggedOut = true
|
||||
} else if case let .backupData(backupData) = attribute {
|
||||
userId = backupData.data?.peerId ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
if !isLoggedOut && userId != 0 {
|
||||
result.append(userId)
|
||||
}
|
||||
}
|
||||
|
||||
SGLogger.shared.log("SessionBackup", "Logged in accounts: \(result)")
|
||||
if loggedInPeerIDs != result {
|
||||
SGLogger.shared.log("SessionBackup", "Updating logged in accounts: \(result)")
|
||||
loggedInPeerIDs = result
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
.onDisappear {
|
||||
loggedInAccountsDisposable?.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
func getBackedSessions() -> [SessionBackup] {
|
||||
var sessions: [SessionBackup] = []
|
||||
do {
|
||||
let backupSessionsData = try KeychainBackupManager.shared.getAllSessons()
|
||||
for sessionBackupData in backupSessionsData {
|
||||
do {
|
||||
let backup = try JSONDecoder().decode(SessionBackup.self, from: sessionBackupData)
|
||||
sessions.append(backup)
|
||||
} catch let e {
|
||||
SGLogger.shared.log("SessionBackup", "IMPORT ERROR: \(e)")
|
||||
}
|
||||
}
|
||||
} catch let e {
|
||||
SGLogger.shared.log("SessionBackup", "Error getting all sessions: \(e)")
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
|
||||
|
||||
func backupSessionsFromView(_ view: AccountRecordsView<TelegramAccountManagerTypes>, accountsWithInfo: [AccountWithInfo] = []) {
|
||||
var recordsToBackup: [Int64: AccountRecord<TelegramAccountManagerTypes.Attribute>] = [:]
|
||||
for record in view.records {
|
||||
var sortOrder: Int32 = 0
|
||||
var isLoggedOut: Bool = false
|
||||
var isTestingEnvironment: Bool = false
|
||||
var peerId: Int64 = 0
|
||||
for attribute in record.attributes {
|
||||
if case let .sortOrder(value) = attribute {
|
||||
sortOrder = value.order
|
||||
} else if case .loggedOut = attribute {
|
||||
isLoggedOut = true
|
||||
} else if case let .environment(environment) = attribute, case .test = environment.environment {
|
||||
isTestingEnvironment = true
|
||||
} else if case let .backupData(backupData) = attribute {
|
||||
peerId = backupData.data?.peerId ?? 0
|
||||
}
|
||||
}
|
||||
let _ = sortOrder
|
||||
let _ = isTestingEnvironment
|
||||
|
||||
if !isLoggedOut && peerId != 0 {
|
||||
recordsToBackup[peerId] = record
|
||||
}
|
||||
}
|
||||
|
||||
for (peerId, record) in recordsToBackup {
|
||||
var backupName: String? = nil
|
||||
if let accountWithInfo = accountsWithInfo.first(where: { $0.peer.id == PeerId(peerId) }) {
|
||||
if let user = accountWithInfo.peer as? TelegramUser {
|
||||
if let username = user.username {
|
||||
backupName = "@\(username)"
|
||||
} else {
|
||||
backupName = user.nameOrPhone
|
||||
}
|
||||
}
|
||||
}
|
||||
let backup = SessionBackup(name: backupName, accountRecord: record)
|
||||
do {
|
||||
let data = try JSONEncoder().encode(backup)
|
||||
try KeychainBackupManager.shared.saveSession(id: "\(backup.peerIdInternal)", data)
|
||||
} catch let e {
|
||||
SGLogger.shared.log("SessionBackup", "BACKUP ERROR: \(e)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@available(iOS 13.0, *)
|
||||
public func sgSessionBackupManagerController(context: AccountContext, presentationData: PresentationData? = nil) -> ViewController {
|
||||
let theme = presentationData?.theme ?? (UITraitCollection.current.userInterfaceStyle == .dark ? defaultDarkColorPresentationTheme : defaultPresentationTheme)
|
||||
let strings = presentationData?.strings ?? defaultPresentationStrings
|
||||
|
||||
let legacyController = LegacySwiftUIController(
|
||||
presentation: .navigation,
|
||||
theme: theme,
|
||||
strings: strings
|
||||
)
|
||||
legacyController.statusBar.statusBarStyle = theme.rootController
|
||||
.statusBarStyle.style
|
||||
legacyController.title = "SessionBackup.Title".i18n(strings.baseLanguageCode)
|
||||
|
||||
let swiftUIView = SGSwiftUIView<SessionBackupManagerView>(
|
||||
legacyController: legacyController,
|
||||
manageSafeArea: true,
|
||||
content: {
|
||||
SessionBackupManagerView(wrapperController: legacyController, context: context)
|
||||
}
|
||||
)
|
||||
let controller = UIHostingController(rootView: swiftUIView, ignoreSafeArea: true)
|
||||
legacyController.bind(controller: controller)
|
||||
|
||||
return legacyController
|
||||
}
|
13
Swiftgram/SGSettingsUI/Images.xcassets/SwiftgramPro.imageset/Contents.json
vendored
Normal file
13
Swiftgram/SGSettingsUI/Images.xcassets/SwiftgramPro.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "SwiftgramPro.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
BIN
Swiftgram/SGSettingsUI/Images.xcassets/SwiftgramPro.imageset/SwiftgramPro.pdf
vendored
Normal file
BIN
Swiftgram/SGSettingsUI/Images.xcassets/SwiftgramPro.imageset/SwiftgramPro.pdf
vendored
Normal file
Binary file not shown.
@ -127,6 +127,7 @@ public class SGSimpleSettings {
|
||||
case pinnedMessageNotifications
|
||||
case mentionsAndRepliesNotifications
|
||||
case primaryUserId
|
||||
case status
|
||||
}
|
||||
|
||||
public enum DownloadSpeedBoostValues: String, CaseIterable {
|
||||
@ -244,7 +245,8 @@ public class SGSimpleSettings {
|
||||
public static let groupDefaultValues: [String: Any] = [
|
||||
Keys.legacyNotificationsFix.rawValue: false,
|
||||
Keys.pinnedMessageNotifications.rawValue: PinnedMessageNotificationsSettings.default.rawValue,
|
||||
Keys.mentionsAndRepliesNotifications.rawValue: MentionsAndRepliesNotificationsSettings.default.rawValue
|
||||
Keys.mentionsAndRepliesNotifications.rawValue: MentionsAndRepliesNotificationsSettings.default.rawValue,
|
||||
Keys.status.rawValue: 1
|
||||
]
|
||||
|
||||
@UserDefault(key: Keys.hidePhoneInSettings.rawValue)
|
||||
@ -426,10 +428,10 @@ public class SGSimpleSettings {
|
||||
@UserDefault(key: Keys.legacyNotificationsFix.rawValue, userDefaults: UserDefaults(suiteName: APP_GROUP_IDENTIFIER) ?? .standard)
|
||||
public var legacyNotificationsFix: Bool
|
||||
|
||||
@UserDefault(key: Keys.legacyNotificationsFix.rawValue, userDefaults: UserDefaults(suiteName: APP_GROUP_IDENTIFIER) ?? .standard)
|
||||
@UserDefault(key: Keys.status.rawValue, userDefaults: UserDefaults(suiteName: APP_GROUP_IDENTIFIER) ?? .standard)
|
||||
public var status: Int64
|
||||
|
||||
public var b: Bool = true
|
||||
|
||||
public var ephemeralStatus: Int64 = 1
|
||||
|
||||
@UserDefault(key: Keys.messageFilterKeywords.rawValue)
|
||||
public var messageFilterKeywords: [String]
|
||||
|
@ -84,6 +84,7 @@
|
||||
"Common.RestartNow" = "Restart Now";
|
||||
"Common.OpenTelegram" = "Open Telegram";
|
||||
"Common.UseTelegramForPremium" = "Please note that to get Telegram Premium, you must use the official Telegram app. Once you have obtained Telegram Premium, all its features will become available in Swiftgram.";
|
||||
"Common.UpdateOS" = "iOS update required";
|
||||
|
||||
"Message.HoldToShowOrReport" = "Hold to Show or Report.";
|
||||
|
||||
@ -151,3 +152,73 @@
|
||||
|
||||
"Settings.swipeForVideoPIP" = "Video PIP with Swipe";
|
||||
"Settings.swipeForVideoPIP.Notice" = "If enabled, swiping video will open it in Picture-in-Picture mode.";
|
||||
|
||||
"SessionBackup.Title" = "Session Backup";
|
||||
"SessionBackup.Sessions.Title" = "Sessions";
|
||||
"SessionBackup.Actions.Backup" = "Backup to Keychain";
|
||||
"SessionBackup.Actions.Restore" = "Restore from Keychain";
|
||||
"SessionBackup.Actions.DeleteAll" = "Delete Keychain Backup";
|
||||
"SessionBackup.Actions.DeleteOne" = "Delete from Backup";
|
||||
"SessionBackup.Actions.RemoveFromApp" = "Remove from App";
|
||||
"SessionBackup.LastBackupAt" = "Last Backup: %@";
|
||||
"SessionBackup.RestoreOK" = "OK. Sessions restored: %@";
|
||||
"SessionBackup.LoggedIn" = "Logged In";
|
||||
"SessionBackup.LoggedOut" = "Logged Out";
|
||||
"SessionBackup.DeleteAll.Title" = "Delete All Sessions?";
|
||||
"SessionBackup.DeleteAll.Text" = "All sessions will be removed from Keychain.\n\nAccounts will not be logged out from Swiftgram.";
|
||||
"SessionBackup.DeleteSingle.Title" = "Delete 1 (one) Session?";
|
||||
"SessionBackup.DeleteSingle.Text" = "%@ session will be removed from Keychain.\n\nAccount will not be logged out from Swiftgram.";
|
||||
"SessionBackup.RemoveFromApp.Title" = "Remove account from App?";
|
||||
"SessionBackup.RemoveFromApp.Text" = "%@ session WILL BE REMOVED from Swiftgram! Session will remain active, so you can restore it later.";
|
||||
"SessionBackup.Notice" = "Sessions are stored in the Apple Keychain and are encrypted with your device's passcode. Sessions never leave your device.";
|
||||
|
||||
"MessageFilter.Title" = "Message Filter";
|
||||
"MessageFilter.SubTitle" = "Remove distractions and reduce visibility of messages containing keywords below.\nKeywords are case-sensitive.";
|
||||
"MessageFilter.Keywords.Title" = "Keywords";
|
||||
"MessageFilter.InputPlaceholder" = "Enter keyword";
|
||||
|
||||
"InputToolbar.Title" = "Formatting Panel";
|
||||
|
||||
"Notifications.MentionsAndReplies.Title" = "@Mentions and Replies";
|
||||
"Notifications.MentionsAndReplies.value.default" = "Default";
|
||||
"Notifications.MentionsAndReplies.value.silenced" = "Muted";
|
||||
"Notifications.MentionsAndReplies.value.disabled" = "Disabled";
|
||||
"Notifications.PinnedMessages.Title" = "Pinned Messages";
|
||||
"Notifications.PinnedMessages.value.default" = "Default";
|
||||
"Notifications.PinnedMessages.value.silenced" = "Muted";
|
||||
"Notifications.PinnedMessages.value.disabled" = "Disabled";
|
||||
|
||||
|
||||
"PayWall.Text" = "Supercharged with Pro features";
|
||||
|
||||
"PayWall.SessionBackup.Title" = "Session Backup";
|
||||
"PayWall.SessionBackup.Notice" = "Restore sessions from encrypted local Apple Keychain backup.";
|
||||
|
||||
"PayWall.MessageFilter.Title" = "Message Filter";
|
||||
"PayWall.MessageFilter.Notice" = "Reduce visibility of SPAM, promotions and annoying messages.";
|
||||
|
||||
"PayWall.Notifications.Title" = "Disable @mentions and replies";
|
||||
"PayWall.MessageFilter.Notice" = "Hide or mute non-important notifications.";
|
||||
|
||||
"PayWall.InputToolbar.Title" = "Formatting Panel";
|
||||
"PayWall.InputToolbar.Notice" = "Save time preparing your posts with a panel right above your keyboard.";
|
||||
|
||||
"PayWall.RestorePurchases" = "Restore Purchases";
|
||||
"PayWall.Terms" = "Terms of Service";
|
||||
"PayWall.Privacy" = "Privacy Policy";
|
||||
"PayWall.TermsURL" = "https://swiftgram.app/terms";
|
||||
"PayWall.PrivacyURL" = "https://swiftgram.app/privacy";
|
||||
"PayWall.Notice.Markdown" = "By subscribing to Swiftgram Pro you agree to the [Swiftgram Terms of Service](%1$@) and [Privacy Policy](%2$@).";
|
||||
"PayWall.Notice.Raw" = "By subscribing to Swiftgram Pro you agree to the Swiftgram Terms of Service and Privacy Policy.";
|
||||
|
||||
"PayWall.Button.OpenPro" = "Use Pro features";
|
||||
"PayWall.Button.Purchasing" = "Purchasing...";
|
||||
"PayWall.Button.Restoring" = "Restoring Purchases...";
|
||||
"PayWall.Button.Validating" = "Validating Purchase...";
|
||||
"PayWall.Button.PaymentsUnavailable" = "Payments unavailable";
|
||||
"PayWall.Button.Subscribe" = "Subscribe for %@ / month";
|
||||
"PayWall.Button.ContactingAppStore" = "Contacting App Store...";
|
||||
|
||||
"Paywall.Error.Title" = "Error";
|
||||
"PayWall.ValidationError" = "Validation Error";
|
||||
"PayWall.ValidationError.TryAgain" = "Something went wrong during purchase validation. No worries! Try to Restore Purchases a bit later.";
|
||||
|
@ -339,7 +339,10 @@ alternate_icon_folders = [
|
||||
"SGNeonBlue",
|
||||
"SGGlass",
|
||||
"SGSparkling",
|
||||
"SGBeta"
|
||||
"SGBeta",
|
||||
"SGPro",
|
||||
"SGGold",
|
||||
"SGDucky"
|
||||
]
|
||||
|
||||
[
|
||||
|
@ -509,13 +509,15 @@ private struct NotificationContent: CustomStringConvertible {
|
||||
var isMentionOrReply: Bool
|
||||
var isPinned: Bool = false
|
||||
let chatId: Int64?
|
||||
let sgStatus: SGStatus
|
||||
|
||||
var senderPerson: INPerson?
|
||||
var senderImage: INImage?
|
||||
|
||||
var isLockedMessage: String?
|
||||
|
||||
init(isLockedMessage: String?, isEmpty: Bool = false, isMentionOrReply: Bool = false, chatId: Int64? = nil) {
|
||||
init(sgStatus: SGStatus, isLockedMessage: String?, isEmpty: Bool = false, isMentionOrReply: Bool = false, chatId: Int64? = nil) {
|
||||
self.sgStatus = sgStatus
|
||||
self.isLockedMessage = isLockedMessage
|
||||
self.isEmpty = isEmpty
|
||||
self.isMentionOrReply = isMentionOrReply
|
||||
@ -541,6 +543,7 @@ private struct NotificationContent: CustomStringConvertible {
|
||||
string += " isPinned: \(self.isPinned),\n"
|
||||
string += " forceIsEmpty: \(self.forceIsEmpty),\n"
|
||||
string += " forceIsSilent: \(self.forceIsSilent),\n"
|
||||
string += " sgStatus: \(self.sgStatus.status),\n"
|
||||
string += "}"
|
||||
return string
|
||||
}
|
||||
@ -828,7 +831,8 @@ private final class NotificationServiceHandler {
|
||||
ApplicationSpecificSharedDataKeys.inAppNotificationSettings,
|
||||
ApplicationSpecificSharedDataKeys.voiceCallSettings,
|
||||
ApplicationSpecificSharedDataKeys.automaticMediaDownloadSettings,
|
||||
SharedDataKeys.loggingSettings
|
||||
SharedDataKeys.loggingSettings,
|
||||
ApplicationSpecificSharedDataKeys.sgStatus
|
||||
])
|
||||
)
|
||||
|> take(1)
|
||||
@ -861,6 +865,7 @@ private final class NotificationServiceHandler {
|
||||
}
|
||||
|
||||
let inAppNotificationSettings = sharedData.entries[ApplicationSpecificSharedDataKeys.inAppNotificationSettings]?.get(InAppNotificationSettings.self) ?? InAppNotificationSettings.defaultSettings
|
||||
let sgStatus = sharedData.entries[ApplicationSpecificSharedDataKeys.sgStatus]?.get(SGStatus.self) ?? SGStatus.default
|
||||
|
||||
let voiceCallSettings: VoiceCallSettings
|
||||
if let value = sharedData.entries[ApplicationSpecificSharedDataKeys.voiceCallSettings]?.get(VoiceCallSettings.self) {
|
||||
@ -872,7 +877,7 @@ private final class NotificationServiceHandler {
|
||||
guard let strongSelf = self, let recordId = recordId else {
|
||||
Logger.shared.log("NotificationService \(episode)", "Couldn't find a matching decryption key")
|
||||
|
||||
let content = NotificationContent(isLockedMessage: nil)
|
||||
let content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil)
|
||||
updateCurrentContent(content)
|
||||
completed()
|
||||
|
||||
@ -894,7 +899,7 @@ private final class NotificationServiceHandler {
|
||||
guard let stateManager = stateManager else {
|
||||
Logger.shared.log("NotificationService \(episode)", "Didn't receive stateManager")
|
||||
|
||||
let content = NotificationContent(isLockedMessage: nil)
|
||||
let content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil)
|
||||
updateCurrentContent(content)
|
||||
completed()
|
||||
return
|
||||
@ -912,7 +917,7 @@ private final class NotificationServiceHandler {
|
||||
settings
|
||||
) |> deliverOn(strongSelf.queue)).start(next: { notificationsKey, notificationSoundList in
|
||||
guard let strongSelf = self else {
|
||||
let content = NotificationContent(isLockedMessage: nil)
|
||||
let content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil)
|
||||
updateCurrentContent(content)
|
||||
completed()
|
||||
|
||||
@ -921,7 +926,7 @@ private final class NotificationServiceHandler {
|
||||
guard let notificationsKey = notificationsKey else {
|
||||
Logger.shared.log("NotificationService \(episode)", "Didn't receive decryption key")
|
||||
|
||||
let content = NotificationContent(isLockedMessage: nil)
|
||||
let content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil)
|
||||
updateCurrentContent(content)
|
||||
completed()
|
||||
|
||||
@ -930,7 +935,7 @@ private final class NotificationServiceHandler {
|
||||
guard let decryptedPayload = decryptedNotificationPayload(key: notificationsKey, data: payloadData) else {
|
||||
Logger.shared.log("NotificationService \(episode)", "Couldn't decrypt payload")
|
||||
|
||||
let content = NotificationContent(isLockedMessage: nil)
|
||||
let content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil)
|
||||
updateCurrentContent(content)
|
||||
completed()
|
||||
|
||||
@ -939,7 +944,7 @@ private final class NotificationServiceHandler {
|
||||
guard let payloadJson = try? JSONSerialization.jsonObject(with: decryptedPayload, options: []) as? [String: Any] else {
|
||||
Logger.shared.log("NotificationService \(episode)", "Couldn't process payload as JSON")
|
||||
|
||||
let content = NotificationContent(isLockedMessage: nil)
|
||||
let content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil)
|
||||
updateCurrentContent(content)
|
||||
completed()
|
||||
|
||||
@ -1047,7 +1052,7 @@ private final class NotificationServiceHandler {
|
||||
action = .logout
|
||||
case "MESSAGE_MUTED":
|
||||
if let peerId = peerId {
|
||||
action = .poll(peerId: peerId, content: NotificationContent(isLockedMessage: nil, isEmpty: true, isMentionOrReply: isMentionOrReply, chatId: chatId), messageId: nil, reportDelivery: false)
|
||||
action = .poll(peerId: peerId, content: NotificationContent(sgStatus: sgStatus, isLockedMessage: nil, isEmpty: true, isMentionOrReply: isMentionOrReply, chatId: chatId), messageId: nil, reportDelivery: false)
|
||||
}
|
||||
case "MESSAGE_DELETED":
|
||||
if let peerId = peerId {
|
||||
@ -1098,7 +1103,7 @@ private final class NotificationServiceHandler {
|
||||
}
|
||||
} else {
|
||||
if let aps = payloadJson["aps"] as? [String: Any], var peerId = peerId {
|
||||
var content: NotificationContent = NotificationContent(isLockedMessage: isLockedMessage, isMentionOrReply: isMentionOrReply, chatId: chatId)
|
||||
var content: NotificationContent = NotificationContent(sgStatus: sgStatus, isLockedMessage: isLockedMessage, isMentionOrReply: isMentionOrReply, chatId: chatId)
|
||||
if let alert = aps["alert"] as? [String: Any] {
|
||||
if let topicTitleValue = payloadJson["topic_title"] as? String {
|
||||
topicTitle = topicTitleValue
|
||||
@ -1249,7 +1254,7 @@ private final class NotificationServiceHandler {
|
||||
switch action {
|
||||
case let .call(callData):
|
||||
if let stateManager = strongSelf.stateManager {
|
||||
let content = NotificationContent(isLockedMessage: nil)
|
||||
let content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil)
|
||||
updateCurrentContent(content)
|
||||
|
||||
let _ = (stateManager.postbox.transaction { transaction -> String? in
|
||||
@ -1272,7 +1277,7 @@ private final class NotificationServiceHandler {
|
||||
|
||||
if #available(iOS 14.5, *), voiceCallSettings.enableSystemIntegration {
|
||||
Logger.shared.log("NotificationService \(episode)", "Will report voip notification")
|
||||
let content = NotificationContent(isLockedMessage: nil)
|
||||
let content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil)
|
||||
updateCurrentContent(content)
|
||||
|
||||
CXProvider.reportNewIncomingVoIPPushPayload(voipPayload, completion: { error in
|
||||
@ -1281,7 +1286,7 @@ private final class NotificationServiceHandler {
|
||||
completed()
|
||||
})
|
||||
} else {
|
||||
var content = NotificationContent(isLockedMessage: nil)
|
||||
var content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil)
|
||||
if let peer = callData.peer {
|
||||
content.title = peer.debugDisplayTitle
|
||||
content.body = incomingCallMessage
|
||||
@ -1297,7 +1302,7 @@ private final class NotificationServiceHandler {
|
||||
case .logout:
|
||||
Logger.shared.log("NotificationService \(episode)", "Will logout")
|
||||
|
||||
let content = NotificationContent(isLockedMessage: nil, isEmpty: true)
|
||||
let content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil, isEmpty: true)
|
||||
updateCurrentContent(content)
|
||||
completed()
|
||||
case let .poll(peerId, initialContent, messageId, reportDelivery):
|
||||
@ -1315,7 +1320,7 @@ private final class NotificationServiceHandler {
|
||||
|
||||
queue.async {
|
||||
guard let strongSelf = self, let stateManager = strongSelf.stateManager else {
|
||||
let content = NotificationContent(isLockedMessage: isLockedMessage)
|
||||
let content = NotificationContent(sgStatus: sgStatus, isLockedMessage: isLockedMessage)
|
||||
updateCurrentContent(content)
|
||||
completed()
|
||||
return
|
||||
@ -1621,7 +1626,7 @@ private final class NotificationServiceHandler {
|
||||
Logger.shared.log("NotificationService \(episode)", "Updating content to \(content)")
|
||||
|
||||
if wasDisplayed {
|
||||
content = NotificationContent(isLockedMessage: nil, isMentionOrReply: isMentionOrReply, chatId: chatId)
|
||||
content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil, isMentionOrReply: isMentionOrReply, chatId: chatId)
|
||||
Logger.shared.log("NotificationService \(episode)", "Was already displayed, skipping content")
|
||||
} else if let messageId {
|
||||
let _ = (stateManager.postbox.transaction { transaction -> Void in
|
||||
@ -1708,7 +1713,7 @@ private final class NotificationServiceHandler {
|
||||
case let .idBased(maxIncomingReadId, _, _, _, _):
|
||||
if maxIncomingReadId >= messageId.id {
|
||||
Logger.shared.log("NotificationService \(episode)", "maxIncomingReadId: \(maxIncomingReadId), messageId: \(messageId.id), skipping")
|
||||
content = NotificationContent(isLockedMessage: nil, isMentionOrReply: isMentionOrReply, chatId: chatId)
|
||||
content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil, isMentionOrReply: isMentionOrReply, chatId: chatId)
|
||||
} else {
|
||||
Logger.shared.log("NotificationService \(episode)", "maxIncomingReadId: \(maxIncomingReadId), messageId: \(messageId.id), not skipping")
|
||||
}
|
||||
@ -1771,7 +1776,7 @@ private final class NotificationServiceHandler {
|
||||
|
||||
queue.async {
|
||||
guard let strongSelf = self, let stateManager = strongSelf.stateManager else {
|
||||
let content = NotificationContent(isLockedMessage: isLockedMessage, isEmpty: true)
|
||||
let content = NotificationContent(sgStatus: sgStatus, isLockedMessage: isLockedMessage, isEmpty: true)
|
||||
updateCurrentContent(content)
|
||||
completed()
|
||||
return
|
||||
@ -1971,7 +1976,7 @@ private final class NotificationServiceHandler {
|
||||
|
||||
var content = content
|
||||
if wasDisplayed {
|
||||
content = NotificationContent(isLockedMessage: nil)
|
||||
content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil)
|
||||
} else {
|
||||
let _ = (stateManager.postbox.transaction { transaction -> Void in
|
||||
_internal_setStoryNotificationWasDisplayed(transaction: transaction, id: StoryId(peerId: peerId, id: storyId))
|
||||
@ -2059,7 +2064,7 @@ private final class NotificationServiceHandler {
|
||||
postbox: stateManager.postbox
|
||||
)
|
||||
|> deliverOn(strongSelf.queue)).start(next: { value in
|
||||
var content = NotificationContent(isLockedMessage: nil, isEmpty: true)
|
||||
var content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil, isEmpty: true)
|
||||
if isCurrentAccount {
|
||||
content.badge = Int(value.0)
|
||||
}
|
||||
@ -2101,7 +2106,7 @@ private final class NotificationServiceHandler {
|
||||
}
|
||||
|
||||
let completeRemoval: () -> Void = {
|
||||
let content = NotificationContent(isLockedMessage: nil, isEmpty: true)
|
||||
let content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil, isEmpty: true)
|
||||
Logger.shared.log("NotificationService \(episode)", "Updating content to \(content)")
|
||||
|
||||
updateCurrentContent(content)
|
||||
@ -2153,7 +2158,7 @@ private final class NotificationServiceHandler {
|
||||
postbox: stateManager.postbox
|
||||
)
|
||||
|> deliverOn(strongSelf.queue)).start(next: { value in
|
||||
var content = NotificationContent(isLockedMessage: nil, isEmpty: true)
|
||||
var content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil, isEmpty: true)
|
||||
if isCurrentAccount {
|
||||
content.badge = Int(value.0)
|
||||
}
|
||||
@ -2194,7 +2199,7 @@ private final class NotificationServiceHandler {
|
||||
}
|
||||
|
||||
let completeRemoval: () -> Void = {
|
||||
let content = NotificationContent(isLockedMessage: nil, isEmpty: true)
|
||||
let content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil, isEmpty: true)
|
||||
updateCurrentContent(content)
|
||||
|
||||
completed()
|
||||
@ -2213,7 +2218,7 @@ private final class NotificationServiceHandler {
|
||||
})
|
||||
}
|
||||
} else {
|
||||
let content = NotificationContent(isLockedMessage: nil)
|
||||
let content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil)
|
||||
updateCurrentContent(content)
|
||||
|
||||
completed()
|
||||
@ -2377,7 +2382,7 @@ final class NotificationService: UNNotificationServiceExtension {
|
||||
|
||||
extension NotificationContent {
|
||||
var forceIsEmpty: Bool {
|
||||
if !self.isEmpty {
|
||||
if self.sgStatus.status > 2 && !self.isEmpty {
|
||||
if self.isPinned {
|
||||
var desiredAction = PINNED_MESSAGE_ACTION
|
||||
if let chatId = chatId, let exceptionAction = PINNED_MESSAGE_ACTION_EXCEPTIONS["\(chatId)"] {
|
||||
@ -2400,7 +2405,7 @@ extension NotificationContent {
|
||||
return false
|
||||
}
|
||||
var forceIsSilent: Bool {
|
||||
if !self.silent {
|
||||
if self.sgStatus.status > 2 && !self.silent {
|
||||
if self.isPinned {
|
||||
var desiredAction = PINNED_MESSAGE_ACTION
|
||||
if let chatId = chatId, let exceptionAction = PINNED_MESSAGE_ACTION_EXCEPTIONS["\(chatId)"] {
|
||||
|
BIN
Telegram/Telegram-iOS/SGDucky.alticon/SGDucky@2x.png
Normal file
BIN
Telegram/Telegram-iOS/SGDucky.alticon/SGDucky@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.9 KiB |
BIN
Telegram/Telegram-iOS/SGDucky.alticon/SGDucky@3x.png
Normal file
BIN
Telegram/Telegram-iOS/SGDucky.alticon/SGDucky@3x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
BIN
Telegram/Telegram-iOS/SGGold.alticon/SGGold@2x.png
Normal file
BIN
Telegram/Telegram-iOS/SGGold.alticon/SGGold@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
BIN
Telegram/Telegram-iOS/SGGold.alticon/SGGold@3x.png
Normal file
BIN
Telegram/Telegram-iOS/SGGold.alticon/SGGold@3x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 28 KiB |
BIN
Telegram/Telegram-iOS/SGPro.alticon/SGPro@2x.png
Normal file
BIN
Telegram/Telegram-iOS/SGPro.alticon/SGPro@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
BIN
Telegram/Telegram-iOS/SGPro.alticon/SGPro@3x.png
Normal file
BIN
Telegram/Telegram-iOS/SGPro.alticon/SGPro@3x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 28 KiB |
@ -937,6 +937,10 @@ public protocol SharedAccountContext: AnyObject {
|
||||
// MARK: Swiftgram
|
||||
var immediateSGStatus: SGStatus { get }
|
||||
var SGIAP: SGIAPManager? { get }
|
||||
func makeSGProController(context: AccountContext) -> ViewController
|
||||
func makeSGPayWallController(context: AccountContext) -> ViewController?
|
||||
func makeSGUpdateIOSController() -> ViewController
|
||||
|
||||
var currentInAppNotificationSettings: Atomic<InAppNotificationSettings> { get }
|
||||
var currentMediaInputSettings: Atomic<MediaInputSettings> { get }
|
||||
var currentStickerSettings: Atomic<StickerSettings> { get }
|
||||
|
@ -18,7 +18,7 @@ public func activeAccountsAndPeers(context: AccountContext, includePrimary: Bool
|
||||
func accountWithPeer(_ context: AccountContext) -> Signal<(AccountContext, EnginePeer, Int32)?, NoError> {
|
||||
return combineLatest(context.account.postbox.peerView(id: context.account.peerId), renderedTotalUnreadCount(accountManager: sharedContext.accountManager, engine: context.engine))
|
||||
|> map { view, totalUnreadCount -> (EnginePeer?, Int32) in
|
||||
return (view.peers[view.peerId].flatMap(EnginePeer.init) ?? EnginePeer.init(TelegramUser(id: view.peerId, accessHash: nil, firstName: "IMPORTED", lastName: "\(view.peerId.id._internalGetInt64Value())", username: nil, phone: nil, photo: [], botInfo: nil, restrictionInfo: nil, flags: UserInfoFlags(), emojiStatus: nil, usernames: [], storiesHidden: nil, nameColor: nil, backgroundEmojiId: nil, profileColor: nil, profileBackgroundEmojiId: nil, subscriberCount: nil, verificationIconFileId: nil)), totalUnreadCount.0)
|
||||
return (view.peers[view.peerId].flatMap(EnginePeer.init) ?? EnginePeer.init(TelegramUser(id: view.peerId, accessHash: nil, firstName: "RESTORED", lastName: "\(view.peerId.id._internalGetInt64Value())", username: nil, phone: nil, photo: [], botInfo: nil, restrictionInfo: nil, flags: UserInfoFlags(), emojiStatus: nil, usernames: [], storiesHidden: nil, nameColor: nil, backgroundEmojiId: nil, profileColor: nil, profileBackgroundEmojiId: nil, subscriberCount: nil, verificationIconFileId: nil)), totalUnreadCount.0)
|
||||
}
|
||||
|> distinctUntilChanged { lhs, rhs in
|
||||
if lhs.0 != rhs.0 {
|
||||
|
@ -509,7 +509,7 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS
|
||||
}
|
||||
|
||||
// MARK: Swiftgram
|
||||
self.initToolbarIfNeeded()
|
||||
self.initToolbarIfNeeded(context: context)
|
||||
}
|
||||
|
||||
public var sendPressed: ((NSAttributedString?) -> Void)?
|
||||
@ -636,7 +636,7 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS
|
||||
textInputNode.view.addGestureRecognizer(recognizer)
|
||||
|
||||
textInputNode.textView.accessibilityHint = self.textPlaceholderNode.attributedText?.string
|
||||
self.initToolbarIfNeeded()
|
||||
self.initToolbarIfNeeded(context: self.context)
|
||||
}
|
||||
|
||||
private func textFieldMaxHeight(_ maxHeight: CGFloat, metrics: LayoutMetrics) -> CGFloat {
|
||||
@ -1914,10 +1914,10 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS
|
||||
// MARK: Swiftgram
|
||||
extension AttachmentTextInputPanelNode {
|
||||
|
||||
func initToolbarIfNeeded() {
|
||||
func initToolbarIfNeeded(context: AccountContext) {
|
||||
guard #available(iOS 13.0, *) else { return }
|
||||
guard SGSimpleSettings.shared.inputToolbar else { return }
|
||||
guard SGSimpleSettings.shared.b else { return }
|
||||
guard context.sharedContext.immediateSGStatus.status > 1 else { return }
|
||||
guard self.toolbarNode == nil else { return }
|
||||
let toolbarView = ChatToolbarView(
|
||||
onQuote: { [weak self] in
|
||||
|
@ -394,6 +394,15 @@ class ThemeSettingsAppIconItemNode: ListViewItemNode, ItemListItemNode {
|
||||
case "SGBeta":
|
||||
name = "β Beta"
|
||||
bordered = false
|
||||
case "SGPro":
|
||||
name = "Pro"
|
||||
bordered = false
|
||||
case "SGGold":
|
||||
name = "Gold"
|
||||
bordered = false
|
||||
case "SGDucky":
|
||||
name = "Ducky"
|
||||
bordered = false
|
||||
case "BlueIcon":
|
||||
name = item.strings.Appearance_AppIconDefault
|
||||
case "BlackIcon":
|
||||
@ -424,7 +433,7 @@ class ThemeSettingsAppIconItemNode: ListViewItemNode, ItemListItemNode {
|
||||
name = icon.name
|
||||
}
|
||||
|
||||
imageNode.setup(theme: item.theme, icon: image, title: NSAttributedString(string: name, font: selected ? selectedTextFont : textFont, textColor: selected ? item.theme.list.itemAccentColor : item.theme.list.itemPrimaryTextColor, paragraphAlignment: .center), locked: !item.isPremium && icon.isPremium, color: item.theme.list.itemPrimaryTextColor, bordered: bordered, selected: selected, action: {
|
||||
imageNode.setup(theme: item.theme, icon: image, title: NSAttributedString(string: name, font: selected ? selectedTextFont : textFont, textColor: selected ? item.theme.list.itemAccentColor : item.theme.list.itemPrimaryTextColor, paragraphAlignment: .center), locked: !item.isPremium && icon.isSGPro, color: item.theme.list.itemPrimaryTextColor, bordered: bordered, selected: selected, action: {
|
||||
item.updated(icon)
|
||||
})
|
||||
}
|
||||
|
@ -567,6 +567,13 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The
|
||||
controller?.replace(with: c)
|
||||
}
|
||||
pushControllerImpl?(controller)
|
||||
// MARK: Swiftgram
|
||||
} else if icon.isSGPro && context.sharedContext.immediateSGStatus.status < 2 {
|
||||
if let payWallController = context.sharedContext.makeSGPayWallController(context: context) {
|
||||
presentControllerImpl?(payWallController, ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
|
||||
} else {
|
||||
presentControllerImpl?(context.sharedContext.makeSGUpdateIOSController(), nil)
|
||||
}
|
||||
} else {
|
||||
currentAppIconName.set(icon.name)
|
||||
context.sharedContext.applicationBindings.requestSetAlternateIconName(icon.name, { _ in
|
||||
@ -1027,12 +1034,14 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The
|
||||
})
|
||||
})
|
||||
|
||||
let signal = combineLatest(queue: .mainQueue(), context.sharedContext.presentationData, context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.presentationThemeSettings, SharedDataKeys.chatThemes, ApplicationSpecificSharedDataKeys.mediaDisplaySettings]), cloudThemes.get(), availableAppIcons, currentAppIconName.get(), removedThemeIndexesPromise.get(), animatedEmojiStickers, context.account.postbox.peerView(id: context.account.peerId), context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)))
|
||||
let signal = combineLatest(queue: .mainQueue(), context.sharedContext.presentationData, context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.presentationThemeSettings, SharedDataKeys.chatThemes, ApplicationSpecificSharedDataKeys.mediaDisplaySettings, ApplicationSpecificSharedDataKeys.sgStatus]), cloudThemes.get(), availableAppIcons, currentAppIconName.get(), removedThemeIndexesPromise.get(), animatedEmojiStickers, context.account.postbox.peerView(id: context.account.peerId), context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)))
|
||||
|> map { presentationData, sharedData, cloudThemes, availableAppIcons, currentAppIconName, removedThemeIndexes, animatedEmojiStickers, peerView, accountPeer -> (ItemListControllerState, (ItemListNodeState, Any)) in
|
||||
let settings = sharedData.entries[ApplicationSpecificSharedDataKeys.presentationThemeSettings]?.get(PresentationThemeSettings.self) ?? PresentationThemeSettings.defaultSettings
|
||||
let mediaSettings = sharedData.entries[ApplicationSpecificSharedDataKeys.mediaDisplaySettings]?.get(MediaDisplaySettings.self) ?? MediaDisplaySettings.defaultSettings
|
||||
|
||||
let isPremium = peerView.peers[peerView.peerId]?.isPremium ?? false
|
||||
// MARK: Swiftgram
|
||||
let sgStatus = sharedData.entries[ApplicationSpecificSharedDataKeys.sgStatus]?.get(SGStatus.self) ?? SGStatus.default
|
||||
let isPremium = sgStatus.status > 1
|
||||
|
||||
let themeReference: PresentationThemeReference
|
||||
if presentationData.autoNightModeTriggered {
|
||||
|
@ -46,8 +46,10 @@ public struct PresentationAppIcon: Equatable {
|
||||
public let imageName: String
|
||||
public let isDefault: Bool
|
||||
public let isPremium: Bool
|
||||
public let isSGPro: Bool
|
||||
|
||||
public init(name: String, imageName: String, isDefault: Bool = false, isPremium: Bool = false) {
|
||||
public init(isSGPro: Bool = false, name: String, imageName: String, isDefault: Bool = false, isPremium: Bool = false) {
|
||||
self.isSGPro = isSGPro
|
||||
self.name = name
|
||||
self.imageName = imageName
|
||||
self.isDefault = isDefault
|
||||
|
@ -64,6 +64,7 @@ private func renderIcon(name: String, scaleFactor: CGFloat = 1.0, backgroundColo
|
||||
|
||||
public struct PresentationResourcesSettings {
|
||||
public static let swiftgram = renderIcon(name: "SwiftgramSettings", scaleFactor: 30.0 / 512.0)
|
||||
public static let swiftgramPro = renderIcon(name: "SwiftgramPro", scaleFactor: 30.0 / 256.0)
|
||||
public static let editProfile = renderIcon(name: "Settings/Menu/EditProfile")
|
||||
public static let proxy = renderIcon(name: "Settings/Menu/Proxy")
|
||||
public static let savedMessages = renderIcon(name: "Settings/Menu/SavedMessages")
|
||||
|
@ -19,7 +19,9 @@ sgdeps = [
|
||||
"//Swiftgram/SGDebugUI:SGDebugUI",
|
||||
"//Swiftgram/SGInputToolbar:SGInputToolbar",
|
||||
"//Swiftgram/SGIAP:SGIAP",
|
||||
"//Swiftgram/SGPayWall:SGPayWall"
|
||||
"//Swiftgram/SGPayWall:SGPayWall",
|
||||
"//Swiftgram/SGProUI:SGProUI",
|
||||
"//Swiftgram/SGKeychainBackupManager:SGKeychainBackupManager",
|
||||
# "//Swiftgram/SGContentAnalysis:SGContentAnalysis"
|
||||
]
|
||||
|
||||
|
@ -693,7 +693,7 @@ open class ChatMessageItemView: ListViewItemNode, ChatMessageItemNodeProtocol {
|
||||
open func setupItem(_ item: ChatMessageItem, synchronousLoad: Bool) {
|
||||
self.item = item
|
||||
|
||||
if !self.wasFilteredKeywordTested && !SGSimpleSettings.shared.messageFilterKeywords.isEmpty {
|
||||
if !self.wasFilteredKeywordTested && !SGSimpleSettings.shared.messageFilterKeywords.isEmpty && SGSimpleSettings.shared.ephemeralStatus > 1 {
|
||||
let incomingMessage = item.message.effectivelyIncoming(item.context.account.peerId)
|
||||
if incomingMessage {
|
||||
if let matchedKeyword = SGSimpleSettings.shared.messageFilterKeywords.first(where: { item.message.text.contains($0) }) {
|
||||
|
@ -528,8 +528,8 @@ public final class MessageInputPanelComponent: Component {
|
||||
public var likeIconView: UIView? {
|
||||
return (self.likeButton.view as? MessageInputActionButtonComponent.View)?.likeIconView
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
// MARK: Swifgtram
|
||||
init(context: AccountContext, frame: CGRect) {
|
||||
self.fieldBackgroundView = BlurredBackgroundView(color: UIColor(white: 0.0, alpha: 0.5), enableBlur: true)
|
||||
|
||||
self.vibrancyEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: UIBlurEffect(style: .dark)))
|
||||
@ -573,7 +573,7 @@ public final class MessageInputPanelComponent: Component {
|
||||
)
|
||||
|
||||
// MARK: Swiftgram
|
||||
self.initToolbarIfNeeded()
|
||||
self.initToolbarIfNeeded(context: context)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
@ -2277,7 +2277,7 @@ public final class MessageInputPanelComponent: Component {
|
||||
}
|
||||
|
||||
public func makeView() -> View {
|
||||
return View(frame: CGRect())
|
||||
return View(context: self.context, frame: CGRect())
|
||||
}
|
||||
|
||||
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
|
||||
@ -2328,10 +2328,10 @@ final class ViewForOverlayContent: UIView {
|
||||
|
||||
|
||||
extension MessageInputPanelComponent.View {
|
||||
func initToolbarIfNeeded() {
|
||||
func initToolbarIfNeeded(context: AccountContext) {
|
||||
guard #available(iOS 13.0, *) else { return }
|
||||
guard SGSimpleSettings.shared.inputToolbar else { return }
|
||||
guard SGSimpleSettings.shared.b else { return }
|
||||
guard context.sharedContext.immediateSGStatus.status > 1 else { return }
|
||||
guard self.toolbarView == nil else { return }
|
||||
let notificationName = Notification.Name("sgToolbarAction")
|
||||
let toolbar = ChatToolbarView(
|
||||
|
@ -504,6 +504,7 @@ private enum PeerInfoContextSubject {
|
||||
|
||||
private enum PeerInfoSettingsSection {
|
||||
case swiftgram
|
||||
case swiftgramPro
|
||||
case avatar
|
||||
case edit
|
||||
case proxy
|
||||
@ -947,7 +948,6 @@ private func settingsItems(showProfileId: Bool, data: PeerInfoScreenData?, conte
|
||||
}
|
||||
}
|
||||
|
||||
let sgSectionId = 0
|
||||
// let locale = presentationData.strings.baseLanguageCode
|
||||
// MARK: Swiftgram
|
||||
let hasNewSGFeatures = {
|
||||
@ -960,7 +960,10 @@ private func settingsItems(showProfileId: Bool, data: PeerInfoScreenData?, conte
|
||||
swiftgramLabel = .none
|
||||
}
|
||||
|
||||
items[.swiftgram]!.append(PeerInfoScreenDisclosureItem(id: sgSectionId, label: swiftgramLabel, text: "Swiftgram", icon: PresentationResourcesSettings.swiftgram, action: {
|
||||
items[.swiftgram]!.append(PeerInfoScreenDisclosureItem(id: 0, label: .titleBadge(presentationData.strings.Settings_New, presentationData.theme.list.itemAccentColor), text: "Swiftgram Pro", icon: PresentationResourcesSettings.swiftgramPro, action: {
|
||||
interaction.openSettings(.swiftgramPro)
|
||||
}))
|
||||
items[.swiftgram]!.append(PeerInfoScreenDisclosureItem(id: 1, label: swiftgramLabel, text: "Swiftgram", icon: PresentationResourcesSettings.swiftgram, action: {
|
||||
interaction.openSettings(.swiftgram)
|
||||
}))
|
||||
|
||||
@ -10247,6 +10250,16 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro
|
||||
switch section {
|
||||
case .swiftgram:
|
||||
self.controller?.push(sgSettingsController(context: self.context))
|
||||
case .swiftgramPro:
|
||||
if self.context.sharedContext.immediateSGStatus.status > 1 {
|
||||
self.controller?.push(self.context.sharedContext.makeSGProController(context: self.context))
|
||||
} else {
|
||||
if let payWallController = self.context.sharedContext.makeSGPayWallController(context: self.context) {
|
||||
self.controller?.present(payWallController, in: .window(.root), with: ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
|
||||
} else {
|
||||
self.controller?.present(self.context.sharedContext.makeSGUpdateIOSController(), animated: true)
|
||||
}
|
||||
}
|
||||
case .avatar:
|
||||
self.controller?.openAvatarForEditing()
|
||||
case .edit:
|
||||
|
@ -850,6 +850,10 @@ private func extractAccountManagerState(records: AccountRecordsView<TelegramAcco
|
||||
PresentationAppIcon(name: "SGNight", imageName: "SGNight"),
|
||||
PresentationAppIcon(name: "SGSky", imageName: "SGSky"),
|
||||
PresentationAppIcon(name: "SGTitanium", imageName: "SGTitanium"),
|
||||
PresentationAppIcon(isSGPro: true, name: "SGPro", imageName: "SGPro"),
|
||||
PresentationAppIcon(isSGPro: true, name: "SGGold", imageName: "SGGold"),
|
||||
PresentationAppIcon(isSGPro: true, name: "SGDucky", imageName: "SGDucky"),
|
||||
PresentationAppIcon(name: "", imageName: ""), // Empty
|
||||
PresentationAppIcon(name: "SGNeon", imageName: "SGNeon"),
|
||||
PresentationAppIcon(name: "SGNeonBlue", imageName: "SGNeonBlue"),
|
||||
PresentationAppIcon(name: "SGGlass", imageName: "SGGlass"),
|
||||
|
@ -941,7 +941,7 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate, Ch
|
||||
self.addSubnode(self.clippingNode)
|
||||
|
||||
// MARK: Swiftgram
|
||||
self.initToolbarIfNeeded()
|
||||
self.initToolbarIfNeeded(context: context)
|
||||
|
||||
self.sendAsAvatarContainerNode.activated = { [weak self] gesture, _ in
|
||||
guard let strongSelf = self else {
|
||||
@ -5064,10 +5064,10 @@ private final class BoostSlowModeButton: HighlightTrackingButtonNode {
|
||||
// MARK: Swiftgram
|
||||
extension ChatTextInputPanelNode {
|
||||
|
||||
func initToolbarIfNeeded() {
|
||||
func initToolbarIfNeeded(context: AccountContext) {
|
||||
guard #available(iOS 13.0, *) else { return }
|
||||
guard SGSimpleSettings.shared.inputToolbar else { return }
|
||||
guard SGSimpleSettings.shared.b else { return }
|
||||
guard context.sharedContext.immediateSGStatus.status > 1 else { return }
|
||||
guard self.toolbarNode == nil else { return }
|
||||
let toolbarView = ChatToolbarView(
|
||||
onQuote: { [weak self] in
|
||||
|
@ -1,5 +1,9 @@
|
||||
// MARK: Swiftgram
|
||||
import SGIAP
|
||||
import SGPayWall
|
||||
import SGProUI
|
||||
import SGSimpleSettings
|
||||
//
|
||||
import Foundation
|
||||
import UIKit
|
||||
import AsyncDisplayKit
|
||||
@ -484,6 +488,8 @@ public final class SharedAccountContextImpl: SharedAccountContext {
|
||||
|> deliverOnMainQueue).start(next: { sharedData in
|
||||
if let settings = sharedData.entries[ApplicationSpecificSharedDataKeys.sgStatus]?.get(SGStatus.self) {
|
||||
let _ = immediateSGStatusValue.swap(settings)
|
||||
SGSimpleSettings.shared.ephemeralStatus = settings.status
|
||||
SGSimpleSettings.shared.status = settings.status
|
||||
}
|
||||
})
|
||||
self.initSGIAP(isMainApp: applicationBindings.isMainApp)
|
||||
@ -3050,4 +3056,39 @@ extension SharedAccountContextImpl {
|
||||
self.SGIAP = nil
|
||||
}
|
||||
}
|
||||
|
||||
public func makeSGProController(context: AccountContext) -> ViewController {
|
||||
let controller = sgProController(context: context)
|
||||
return controller
|
||||
}
|
||||
|
||||
public func makeSGPayWallController(context: AccountContext) -> ViewController? {
|
||||
guard #available(iOS 13.0, *) else {
|
||||
return nil
|
||||
}
|
||||
guard let sgIAP = self.SGIAP else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let statusSignal = self.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.sgStatus])
|
||||
|> map { sharedData -> Int64 in
|
||||
let sgStatus = sharedData.entries[ApplicationSpecificSharedDataKeys.sgStatus]?.get(SGStatus.self) ?? SGStatus.default
|
||||
return sgStatus.status
|
||||
}
|
||||
|
||||
let proController = self.makeSGProController(context: context)
|
||||
let payWallController = sgPayWallController(statusSignal: statusSignal, replacementController: proController, presentationData: self.currentPresentationData.with { $0 }, SGIAPManager: sgIAP, openUrl: self.applicationBindings.openUrl)
|
||||
return payWallController
|
||||
}
|
||||
|
||||
public func makeSGUpdateIOSController() -> ViewController {
|
||||
let presentationData = self.currentPresentationData.with { $0 }
|
||||
let controller = UndoOverlayController(
|
||||
presentationData: presentationData,
|
||||
content: .info(title: nil, text: "Common.UpdateOS".i18n(presentationData.strings.baseLanguageCode), timeout: nil, customUndoText: nil),
|
||||
elevatedLayout: false,
|
||||
action: { _ in return false }
|
||||
)
|
||||
return controller
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user