This commit is contained in:
Kylmakalle 2025-02-18 00:45:53 +02:00
parent b22e91bdfa
commit cdd2bafe40
34 changed files with 1349 additions and 981 deletions

View File

@ -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 {

View 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",
],
)

View File

@ -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)
}
}
}

View File

@ -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
View 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",
],
)

View 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
}

View 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
}

View 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
}

View File

@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "SwiftgramPro.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -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]

View File

@ -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.";

View File

@ -339,7 +339,10 @@ alternate_icon_folders = [
"SGNeonBlue",
"SGGlass",
"SGSparkling",
"SGBeta"
"SGBeta",
"SGPro",
"SGGold",
"SGDucky"
]
[

View File

@ -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)"] {

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -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 }

View File

@ -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 {

View File

@ -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

View File

@ -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)
})
}

View File

@ -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 {

View File

@ -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

View File

@ -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")

View File

@ -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"
]

View File

@ -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) }) {

View File

@ -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(

View File

@ -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:

View File

@ -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"),

View File

@ -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

View File

@ -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
}
}