mirror of
https://github.com/ichmagmaus111/ghostgram.git
synced 2026-07-11 18:26:34 +02:00
Update Ghostgram features
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import SGSwiftUI
|
||||
import SGStrings
|
||||
import SGSimpleSettings
|
||||
import LegacyUI
|
||||
import Display
|
||||
import TelegramPresentationData
|
||||
import AccountContext
|
||||
|
||||
|
||||
struct AppBadge: Identifiable, Hashable {
|
||||
let id: UUID = .init()
|
||||
let displayName: String
|
||||
let assetName: String
|
||||
}
|
||||
|
||||
func getAvailableAppBadges() -> [AppBadge] {
|
||||
var appBadges: [AppBadge] = [
|
||||
.init(displayName: "Default", assetName: "Components/AppBadge"),
|
||||
.init(displayName: "Sky", assetName: "SkyAppBadge"),
|
||||
.init(displayName: "Night", assetName: "NightAppBadge"),
|
||||
.init(displayName: "Titanium", assetName: "TitaniumAppBadge"),
|
||||
.init(displayName: "Pro", assetName: "ProAppBadge"),
|
||||
.init(displayName: "Day", assetName: "DayAppBadge"),
|
||||
]
|
||||
|
||||
if SGSimpleSettings.shared.duckyAppIconAvailable {
|
||||
appBadges.append(.init(displayName: "Ducky", assetName: "DuckyAppBadge"))
|
||||
}
|
||||
appBadges += [
|
||||
.init(displayName: "Sparkling", assetName: "SparklingAppBadge"),
|
||||
]
|
||||
|
||||
return appBadges
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
struct AppBadgeSettingsView: View {
|
||||
weak var wrapperController: LegacyController?
|
||||
let context: AccountContext
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Environment(\.lang) var lang: String
|
||||
|
||||
@State var selectedBadge: AppBadge
|
||||
let availableAppBadges: [AppBadge] = getAvailableAppBadges()
|
||||
|
||||
private enum Layout {
|
||||
static let cardCorner: CGFloat = 12
|
||||
static let imageHeight: CGFloat = 56
|
||||
static let columnSpacing: CGFloat = 16
|
||||
static let horizontalPadding: CGFloat = 20
|
||||
}
|
||||
|
||||
private var columns: [SwiftUI.GridItem] {
|
||||
Array(repeating: GridItem(.flexible(), spacing: Layout.columnSpacing), count: 2)
|
||||
}
|
||||
|
||||
init(wrapperController: LegacyController?, context: AccountContext) {
|
||||
self.wrapperController = wrapperController
|
||||
self.context = context
|
||||
|
||||
for badge in self.availableAppBadges {
|
||||
if badge.assetName == SGSimpleSettings.shared.customAppBadge {
|
||||
self._selectedBadge = State(initialValue: badge)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
self._selectedBadge = State(initialValue: self.availableAppBadges.first!)
|
||||
}
|
||||
|
||||
private func onSelectBadge(_ badge: AppBadge) {
|
||||
self.selectedBadge = badge
|
||||
let image = UIImage(bundleImageName: selectedBadge.assetName) ?? UIImage(bundleImageName: "Components/AppBadge")
|
||||
DispatchQueue.main.async {
|
||||
SGSimpleSettings.shared.customAppBadge = selectedBadge.assetName
|
||||
self.context.sharedContext.mainWindow?.badgeView.image = image
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, alignment: .center, spacing: Layout.columnSpacing) {
|
||||
ForEach(availableAppBadges) { badge in
|
||||
Button {
|
||||
onSelectBadge(badge)
|
||||
} label: {
|
||||
VStack(spacing: 8) {
|
||||
Image(badge.assetName)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(height: Layout.imageHeight)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Text(badge.displayName)
|
||||
.font(.footnote)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color(colorScheme == .dark ? .secondarySystemBackground : .systemBackground))
|
||||
.cornerRadius(Layout.cardCorner)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Layout.cardCorner)
|
||||
.stroke(selectedBadge == badge ? Color.accentColor : Color.clear, lineWidth: 2)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Layout.horizontalPadding)
|
||||
.padding(.vertical, 24)
|
||||
|
||||
}
|
||||
.background(Color(colorScheme == .light ? .secondarySystemBackground : .systemBackground).ignoresSafeArea())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
public func sgAppBadgeSettingsController(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 = "AppBadge.Title".i18n(strings.baseLanguageCode)
|
||||
|
||||
let swiftUIView = SGSwiftUIView<AppBadgeSettingsView>(
|
||||
legacyController: legacyController,
|
||||
manageSafeArea: true,
|
||||
content: {
|
||||
AppBadgeSettingsView(wrapperController: legacyController, context: context)
|
||||
}
|
||||
)
|
||||
let controller = UIHostingController(rootView: swiftUIView, ignoreSafeArea: true)
|
||||
legacyController.bind(controller: controller)
|
||||
|
||||
return legacyController
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
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
|
||||
import SettingsUI
|
||||
|
||||
// Optional
|
||||
import SGSimpleSettings
|
||||
import SGLogging
|
||||
|
||||
|
||||
private enum SGProControllerSection: Int32, SGItemListSection {
|
||||
case base
|
||||
case appearance
|
||||
case notifications
|
||||
case footer
|
||||
}
|
||||
|
||||
private enum SGProDisclosureLink: String {
|
||||
case sessionBackupManager
|
||||
case messageFilter
|
||||
case appIcons
|
||||
case appBages
|
||||
}
|
||||
|
||||
private enum SGProToggles: String {
|
||||
case inputToolbar
|
||||
}
|
||||
|
||||
private enum SGProOneFromManySetting: String {
|
||||
case pinnedMessageNotifications
|
||||
case mentionsAndRepliesNotifications
|
||||
}
|
||||
|
||||
private enum SGProAction {
|
||||
case resetIAP
|
||||
}
|
||||
|
||||
private typealias SGProControllerEntry = SGItemListUIEntry<SGProControllerSection, SGProToggles, AnyHashable, SGProOneFromManySetting, SGProDisclosureLink, SGProAction>
|
||||
|
||||
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))
|
||||
entries.append(.header(id: id.count, section: .appearance, text: presentationData.strings.Appearance_Title.uppercased(), badge: nil))
|
||||
entries.append(.disclosure(id: id.count, section: .appearance, link: .appIcons, text: presentationData.strings.Appearance_AppIcon))
|
||||
entries.append(.disclosure(id: id.count, section: .appearance, link: .appBages, text: "AppBadge.Title".i18n(lang)))
|
||||
entries.append(.notice(id: id.count, section: .appearance, text: "AppBadge.Notice".i18n(lang)))
|
||||
|
||||
#if DEBUG
|
||||
entries.append(.action(id: id.count, section: .footer, actionType: .resetIAP, text: "Reset Pro", kind: .destructive))
|
||||
#endif
|
||||
|
||||
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, SGProAction>(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:
|
||||
pushControllerImpl?(sgSessionBackupManagerController(context: context, presentationData: presentationData))
|
||||
case .messageFilter:
|
||||
pushControllerImpl?(sgMessageFilterController(presentationData: presentationData))
|
||||
case .appIcons:
|
||||
pushControllerImpl?(themeSettingsController(context: context, focusOnItemTag: .icon))
|
||||
case .appBages:
|
||||
if #available(iOS 14.0, *) {
|
||||
pushControllerImpl?(sgAppBadgeSettingsController(context: context, presentationData: presentationData))
|
||||
} else {
|
||||
presentControllerImpl?(UndoOverlayController(
|
||||
presentationData: presentationData,
|
||||
content: .info(title: nil, text: "iOS 14 or newer is required for this feature.", timeout: nil, customUndoText: nil),
|
||||
elevatedLayout: false,
|
||||
action: { _ in return false }
|
||||
), nil)
|
||||
}
|
||||
}
|
||||
}, action: { action in
|
||||
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
||||
switch action {
|
||||
case .resetIAP:
|
||||
presentControllerImpl?(UndoOverlayController(
|
||||
presentationData: presentationData,
|
||||
content: .info(title: nil, text: "Reset is unavailable in this build.", timeout: nil, customUndoText: nil),
|
||||
elevatedLayout: false,
|
||||
action: { _ in return false }
|
||||
), nil)
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
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)
|
||||
|
||||
let _ = (combineLatest(signal, signal2)
|
||||
|> take(1)
|
||||
|> deliverOnMainQueue).start(next: { view, accountsWithInfo in
|
||||
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.DeleteAll.Title".i18n(lang), text: "SessionBackup.DeleteAll.Text".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
|
||||
}
|
||||
Reference in New Issue
Block a user