diff --git a/Images.xcassets/Chat/Message/ImpressionCount.imageset/Contents.json b/Images.xcassets/Chat/Message/ImpressionCount.imageset/Contents.json new file mode 100644 index 0000000000..f3c9f57857 --- /dev/null +++ b/Images.xcassets/Chat/Message/ImpressionCount.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "MessageInlineViewCountIconOutgoing@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "MessageInlineViewCountIconOutgoing@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/Chat/Message/ImpressionCount.imageset/MessageInlineViewCountIconOutgoing@2x.png b/Images.xcassets/Chat/Message/ImpressionCount.imageset/MessageInlineViewCountIconOutgoing@2x.png new file mode 100644 index 0000000000..a27330b3ad Binary files /dev/null and b/Images.xcassets/Chat/Message/ImpressionCount.imageset/MessageInlineViewCountIconOutgoing@2x.png differ diff --git a/Images.xcassets/Chat/Message/ImpressionCount.imageset/MessageInlineViewCountIconOutgoing@3x.png b/Images.xcassets/Chat/Message/ImpressionCount.imageset/MessageInlineViewCountIconOutgoing@3x.png new file mode 100644 index 0000000000..b00922522f Binary files /dev/null and b/Images.xcassets/Chat/Message/ImpressionCount.imageset/MessageInlineViewCountIconOutgoing@3x.png differ diff --git a/TelegramUI.xcodeproj/project.pbxproj b/TelegramUI.xcodeproj/project.pbxproj index 04f8453533..bd428c2a2b 100644 --- a/TelegramUI.xcodeproj/project.pbxproj +++ b/TelegramUI.xcodeproj/project.pbxproj @@ -192,6 +192,11 @@ D0568AAD1DF198130022E7DA /* AudioWaveformNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0568AAC1DF198130022E7DA /* AudioWaveformNode.swift */; }; D0568AAF1DF1B3920022E7DA /* HapticFeedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0568AAE1DF1B3920022E7DA /* HapticFeedback.swift */; }; D05811941DD5F9380057C769 /* TelegramApplicationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05811931DD5F9380057C769 /* TelegramApplicationContext.swift */; }; + D05A32DC1E6EFCC2002760B4 /* NumericFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05A32DB1E6EFCC2002760B4 /* NumericFormat.swift */; }; + D05A32DE1E6F0097002760B4 /* PrivacyAndSecurityController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05A32DD1E6F0097002760B4 /* PrivacyAndSecurityController.swift */; }; + D05A32EA1E6F143C002760B4 /* RecentSessionsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05A32E91E6F143C002760B4 /* RecentSessionsController.swift */; }; + D05A32EC1E6F1462002760B4 /* BlockedPeersController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05A32EB1E6F1462002760B4 /* BlockedPeersController.swift */; }; + D05A32EE1E6F25A0002760B4 /* ItemListRecentSessionItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05A32ED1E6F25A0002760B4 /* ItemListRecentSessionItem.swift */; }; D0613FC81E5F8AB100202CDB /* ChannelInfoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0613FC71E5F8AB100202CDB /* ChannelInfoController.swift */; }; D0613FCD1E60482300202CDB /* ChannelMembersController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0613FCC1E60482300202CDB /* ChannelMembersController.swift */; }; D0613FD51E6064D200202CDB /* ConvertToSupergroupController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0613FD41E6064D200202CDB /* ConvertToSupergroupController.swift */; }; @@ -670,6 +675,11 @@ D0568AAC1DF198130022E7DA /* AudioWaveformNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioWaveformNode.swift; sourceTree = ""; }; D0568AAE1DF1B3920022E7DA /* HapticFeedback.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HapticFeedback.swift; sourceTree = ""; }; D05811931DD5F9380057C769 /* TelegramApplicationContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TelegramApplicationContext.swift; sourceTree = ""; }; + D05A32DB1E6EFCC2002760B4 /* NumericFormat.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NumericFormat.swift; sourceTree = ""; }; + D05A32DD1E6F0097002760B4 /* PrivacyAndSecurityController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PrivacyAndSecurityController.swift; sourceTree = ""; }; + D05A32E91E6F143C002760B4 /* RecentSessionsController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecentSessionsController.swift; sourceTree = ""; }; + D05A32EB1E6F1462002760B4 /* BlockedPeersController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlockedPeersController.swift; sourceTree = ""; }; + D05A32ED1E6F25A0002760B4 /* ItemListRecentSessionItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListRecentSessionItem.swift; sourceTree = ""; }; D0613FC71E5F8AB100202CDB /* ChannelInfoController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelInfoController.swift; sourceTree = ""; }; D0613FCC1E60482300202CDB /* ChannelMembersController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelMembersController.swift; sourceTree = ""; }; D0613FD41E6064D200202CDB /* ConvertToSupergroupController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConvertToSupergroupController.swift; sourceTree = ""; }; @@ -1531,6 +1541,10 @@ D0CE1BD21E51BC6100404327 /* DebugController.swift */, D03E5E081E55C49C0029569A /* DebugAccountsController.swift */, D0528E671E65CB2C00E2FEF5 /* UsernameSetupController.swift */, + D05A32DD1E6F0097002760B4 /* PrivacyAndSecurityController.swift */, + D05A32ED1E6F25A0002760B4 /* ItemListRecentSessionItem.swift */, + D05A32E91E6F143C002760B4 /* RecentSessionsController.swift */, + D05A32EB1E6F1462002760B4 /* BlockedPeersController.swift */, ); name = Settings; sourceTree = ""; @@ -1775,8 +1789,8 @@ isa = PBXGroup; children = ( D0B843CE1DA922AD005F29E1 /* PeerInfoEntries.swift */, - D0B843D01DA922D7005F29E1 /* UserInfoEntries.swift */, D0B843CC1DA903BB005F29E1 /* PeerInfoController.swift */, + D0B843D01DA922D7005F29E1 /* UserInfoEntries.swift */, D0486F091E523C8500091F0C /* GroupInfoController.swift */, D03E5E0E1E55F8B90029569A /* ChannelVisibilityController.swift */, D0561DE71E574C3200E6B9E9 /* ChannelAdminsController.swift */, @@ -2179,6 +2193,7 @@ D01D6BFB1E42AB3C006151C6 /* EmojiUtils.swift */, D0DA44551E4E7F43005FDCA7 /* ShakeAnimation.swift */, D0E305A41E5B2BFB00D7A3A2 /* ValidateAddressNameInteractive.swift */, + D05A32DB1E6EFCC2002760B4 /* NumericFormat.swift */, ); name = Utils; sourceTree = ""; @@ -2445,6 +2460,7 @@ D0D2689D1D79D33E00C422DA /* ShareRecipientsActionSheetController.swift in Sources */, D0BC38811E40F1D80044D6FE /* ContactSelectionControllerNode.swift in Sources */, D0F69E171D6B8ACF0046BCD6 /* ChatHistoryLocation.swift in Sources */, + D05A32EE1E6F25A0002760B4 /* ItemListRecentSessionItem.swift in Sources */, D0F69E741D6B8C340046BCD6 /* ContactsControllerNode.swift in Sources */, D07827C71E01CD5900071108 /* VerticalListContextResultsChatInputPanelButtonItem.swift in Sources */, D021E0E51DB55D0A00C6B04F /* ChatMediaInputStickerPackItem.swift in Sources */, @@ -2708,6 +2724,7 @@ D0E7A1BF1D8C24B900C37A6F /* ChatHistoryViewForLocation.swift in Sources */, D0F69E891D6B8C850046BCD6 /* FastBlur.m in Sources */, D07CFF761DCA224100761F81 /* PeerSelectionControllerNode.swift in Sources */, + D05A32EA1E6F143C002760B4 /* RecentSessionsController.swift in Sources */, D0F69E7C1D6B8C470046BCD6 /* SettingsAccountInfoItem.swift in Sources */, D00C7CDE1E37770A0080C3D5 /* SecretMediaPreviewControllerNode.swift in Sources */, D01B27951E38F3BF0022A4C0 /* ItemListControllerNode.swift in Sources */, @@ -2717,6 +2734,7 @@ D050F2161E48D9E000988324 /* AuthorizationSequenceCountrySelectionController.swift in Sources */, D0561DDF1E56FE8200E6B9E9 /* ItemListSingleLineInputItem.swift in Sources */, D01749551E1082770057C89A /* StoredMessageFromSearchPeer.swift in Sources */, + D05A32DC1E6EFCC2002760B4 /* NumericFormat.swift in Sources */, D04BB32C1E48797500650E93 /* animations.c in Sources */, D049EAEE1E44BB3200A2CD3A /* ChatListRecentPeersListItem.swift in Sources */, D04BB2BB1E44EA2400650E93 /* AuthorizationSequenceSplashControllerNode.swift in Sources */, @@ -2747,6 +2765,7 @@ D0DE77231D932043002B8809 /* PeerMediaCollectionInterfaceState.swift in Sources */, D0F69D781D6B87DF0046BCD6 /* MediaTrackFrameBuffer.swift in Sources */, D01AC91F1DD5E09000E8160F /* EditAccessoryPanelNode.swift in Sources */, + D05A32EC1E6F1462002760B4 /* BlockedPeersController.swift in Sources */, D023EBB21DDA800700BD496D /* LegacyMediaPickers.swift in Sources */, D0F69DD01D6B8A0D0046BCD6 /* SearchBarPlaceholderNode.swift in Sources */, D03120F61DA534C1006A2A60 /* ItemListActionItem.swift in Sources */, @@ -2782,6 +2801,7 @@ D0215D3C1E041014001A0B1E /* InstantPageItem.swift in Sources */, D0E35A071DE4803400BC6096 /* VerticalListContextResultsChatInputContextPanelNode.swift in Sources */, D0D03AE51DECAE8900220C46 /* ManagedAudioRecorder.swift in Sources */, + D05A32DE1E6F0097002760B4 /* PrivacyAndSecurityController.swift in Sources */, D0EE971A1D88BCA0006C18E1 /* ChatInfo.swift in Sources */, D0F69DE31D6B8A420046BCD6 /* ListControllerItem.swift in Sources */, D0736F211DF41CFD00F2C02A /* ManagedAudioPlaylistPlayer.swift in Sources */, diff --git a/TelegramUI/BlockedPeersController.swift b/TelegramUI/BlockedPeersController.swift new file mode 100644 index 0000000000..9d1432b68f --- /dev/null +++ b/TelegramUI/BlockedPeersController.swift @@ -0,0 +1,279 @@ +import Foundation +import Display +import SwiftSignalKit +import Postbox +import TelegramCore + +private final class BlockedPeersControllerArguments { + let account: Account + + let setPeerIdWithRevealedOptions: (PeerId?, PeerId?) -> Void + let removePeer: (PeerId) -> Void + + init(account: Account, setPeerIdWithRevealedOptions: @escaping (PeerId?, PeerId?) -> Void, removePeer: @escaping (PeerId) -> Void) { + self.account = account + self.setPeerIdWithRevealedOptions = setPeerIdWithRevealedOptions + self.removePeer = removePeer + } +} + +private enum BlockedPeersSection: Int32 { + case peers +} + +private enum BlockedPeersEntryStableId: Hashable { + case peer(PeerId) + + var hashValue: Int { + switch self { + case let .peer(peerId): + return peerId.hashValue + } + } + + static func ==(lhs: BlockedPeersEntryStableId, rhs: BlockedPeersEntryStableId) -> Bool { + switch lhs { + case let .peer(peerId): + if case .peer(peerId) = rhs { + return true + } else { + return false + } + } + } +} + +private enum BlockedPeersEntry: ItemListNodeEntry { + case peerItem(Int32, Peer, ItemListPeerItemEditing, Bool) + + var section: ItemListSectionId { + switch self { + case .peerItem: + return BlockedPeersSection.peers.rawValue + } + } + + var stableId: BlockedPeersEntryStableId { + switch self { + case let .peerItem(_, peer, _, _): + return .peer(peer.id) + } + } + + static func ==(lhs: BlockedPeersEntry, rhs: BlockedPeersEntry) -> Bool { + switch lhs { + case let .peerItem(lhsIndex, lhsPeer, lhsEditing, lhsEnabled): + if case let .peerItem(rhsIndex, rhsPeer, rhsEditing, rhsEnabled) = rhs { + if lhsIndex != rhsIndex { + return false + } + if !lhsPeer.isEqual(rhsPeer) { + return false + } + if lhsEditing != rhsEditing { + return false + } + if lhsEnabled != rhsEnabled { + return false + } + return true + } else { + return false + } + } + } + + static func <(lhs: BlockedPeersEntry, rhs: BlockedPeersEntry) -> Bool { + switch lhs { + case let .peerItem(index, _, _, _): + switch rhs { + case let .peerItem(rhsIndex, _, _, _): + return index < rhsIndex + } + } + } + + func item(_ arguments: BlockedPeersControllerArguments) -> ListViewItem { + switch self { + case let .peerItem(_, peer, editing, enabled): + return ItemListPeerItem(account: arguments.account, peer: peer, presence: nil, text: .none, label: nil, editing: editing, switchValue: nil, enabled: enabled, sectionId: self.section, action: nil, setPeerIdWithRevealedOptions: { previousId, id in + arguments.setPeerIdWithRevealedOptions(previousId, id) + }, removePeer: { peerId in + arguments.removePeer(peerId) + }) + } + } +} + +private struct BlockedPeersControllerState: Equatable { + let editing: Bool + let peerIdWithRevealedOptions: PeerId? + let removingPeerId: PeerId? + + init() { + self.editing = false + self.peerIdWithRevealedOptions = nil + self.removingPeerId = nil + } + + init(editing: Bool, peerIdWithRevealedOptions: PeerId?, removingPeerId: PeerId?) { + self.editing = editing + self.peerIdWithRevealedOptions = peerIdWithRevealedOptions + self.removingPeerId = removingPeerId + } + + static func ==(lhs: BlockedPeersControllerState, rhs: BlockedPeersControllerState) -> Bool { + if lhs.editing != rhs.editing { + return false + } + if lhs.peerIdWithRevealedOptions != rhs.peerIdWithRevealedOptions { + return false + } + if lhs.removingPeerId != rhs.removingPeerId { + return false + } + + return true + } + + func withUpdatedEditing(_ editing: Bool) -> BlockedPeersControllerState { + return BlockedPeersControllerState(editing: editing, peerIdWithRevealedOptions: self.peerIdWithRevealedOptions, removingPeerId: self.removingPeerId) + } + + func withUpdatedPeerIdWithRevealedOptions(_ peerIdWithRevealedOptions: PeerId?) -> BlockedPeersControllerState { + return BlockedPeersControllerState(editing: self.editing, peerIdWithRevealedOptions: peerIdWithRevealedOptions, removingPeerId: self.removingPeerId) + } + + func withUpdatedRemovingPeerId(_ removingPeerId: PeerId?) -> BlockedPeersControllerState { + return BlockedPeersControllerState(editing: self.editing, peerIdWithRevealedOptions: self.peerIdWithRevealedOptions, removingPeerId: removingPeerId) + } +} + +private func blockedPeersControllerEntries(state: BlockedPeersControllerState, peers: [Peer]?) -> [BlockedPeersEntry] { + var entries: [BlockedPeersEntry] = [] + + if let peers = peers { + var index: Int32 = 0 + for peer in peers { + entries.append(.peerItem(index, peer, ItemListPeerItemEditing(editable: true, editing: state.editing, revealed: peer.id == state.peerIdWithRevealedOptions), state.removingPeerId != peer.id)) + index += 1 + } + } + + return entries +} + +public func blockedPeersController(account: Account) -> ViewController { + let statePromise = ValuePromise(BlockedPeersControllerState(), ignoreRepeated: true) + let stateValue = Atomic(value: BlockedPeersControllerState()) + let updateState: ((BlockedPeersControllerState) -> BlockedPeersControllerState) -> Void = { f in + statePromise.set(stateValue.modify { f($0) }) + } + + var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments?) -> Void)? + + let actionsDisposable = DisposableSet() + + let removePeerDisposable = MetaDisposable() + actionsDisposable.add(removePeerDisposable) + + let peersPromise = Promise<[Peer]?>(nil) + + let arguments = BlockedPeersControllerArguments(account: account, setPeerIdWithRevealedOptions: { peerId, fromPeerId in + updateState { state in + if (peerId == nil && fromPeerId == state.peerIdWithRevealedOptions) || (peerId != nil && fromPeerId == nil) { + return state.withUpdatedPeerIdWithRevealedOptions(peerId) + } else { + return state + } + } + }, removePeer: { memberId in + updateState { + return $0.withUpdatedRemovingPeerId(memberId) + } + + let applyPeers: Signal = peersPromise.get() + |> filter { $0 != nil } + |> take(1) + |> deliverOnMainQueue + |> mapToSignal { peers -> Signal in + if let peers = peers { + var updatedPeers = peers + for i in 0 ..< updatedPeers.count { + if updatedPeers[i].id == memberId { + updatedPeers.remove(at: i) + break + } + } + peersPromise.set(.single(updatedPeers)) + } + + return .complete() + } + + removePeerDisposable.set((requestUpdatePeerIsBlocked(account: account, peerId: memberId, isBlocked: false) |> then(applyPeers) |> deliverOnMainQueue).start(error: { _ in + updateState { + return $0.withUpdatedRemovingPeerId(nil) + } + }, completed: { + updateState { + return $0.withUpdatedRemovingPeerId(nil) + } + + })) + }) + + let peersSignal: Signal<[Peer]?, NoError> = .single(nil) |> then(requestBlockedPeers(account: account) |> map { Optional($0) }) + + peersPromise.set(peersSignal) + + var previousPeers: [Peer]? + + let signal = combineLatest(statePromise.get(), peersPromise.get()) + |> deliverOnMainQueue + |> map { state, peers -> (ItemListControllerState, (ItemListNodeState, BlockedPeersEntry.ItemGenerationArguments)) in + var rightNavigationButton: ItemListNavigationButton? + if let peers = peers, !peers.isEmpty { + if state.editing { + rightNavigationButton = ItemListNavigationButton(title: "Done", style: .bold, enabled: true, action: { + updateState { state in + return state.withUpdatedEditing(false) + } + }) + } else { + rightNavigationButton = ItemListNavigationButton(title: "Edit", style: .regular, enabled: true, action: { + updateState { state in + return state.withUpdatedEditing(true) + } + }) + } + } + + var emptyStateItem: ItemListControllerEmptyStateItem? + if let peers = peers { + if peers.isEmpty { + emptyStateItem = ItemListTextEmptyStateItem(text: "Blocked users can't send you messages of add you to groups. They will not see your profile pictures, online and last seen status.") + } + } else if peers == nil { + emptyStateItem = ItemListLoadingIndicatorEmptyStateItem() + } + + let previous = previousPeers + previousPeers = peers + + let controllerState = ItemListControllerState(title: "Blocked Users", leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, animateChanges: true) + let listState = ItemListNodeState(entries: blockedPeersControllerEntries(state: state, peers: peers), style: .blocks, emptyStateItem: emptyStateItem, animateChanges: previous != nil && peers != nil && previous!.count >= peers!.count) + + return (controllerState, (listState, arguments)) + } |> afterDisposed { + actionsDisposable.dispose() + } + + let controller = ItemListController(signal) + presentControllerImpl = { [weak controller] c, p in + if let controller = controller { + controller.present(c, in: .window, with: p) + } + } + return controller +} diff --git a/TelegramUI/ChatListItem.swift b/TelegramUI/ChatListItem.swift index 71ca7e1026..ac25854f7b 100644 --- a/TelegramUI/ChatListItem.swift +++ b/TelegramUI/ChatListItem.swift @@ -477,7 +477,8 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { var timeinfo = tm() localtime_r(&t, &timeinfo) - let dateText = String(format: "%02d:%02d", arguments: [Int(timeinfo.tm_hour), Int(timeinfo.tm_min)]) + let timestamp = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) + let dateText = stringForRelativeTimestamp(item.index.messageIndex.timestamp, relativeTo: timestamp) dateAttributedString = NSAttributedString(string: dateText, font: dateFont, textColor: UIColor(0x8e8e93)) diff --git a/TelegramUI/ChatListSearchContainerNode.swift b/TelegramUI/ChatListSearchContainerNode.swift index f94784f7fb..7e7da457fb 100644 --- a/TelegramUI/ChatListSearchContainerNode.swift +++ b/TelegramUI/ChatListSearchContainerNode.swift @@ -76,15 +76,15 @@ enum ChatListRecentEntry: Comparable, Identifiable { static func <(lhs: ChatListRecentEntry, rhs: ChatListRecentEntry) -> Bool { switch lhs { - case .topPeers: - return true - case let .peer(lhsIndex, _): - switch rhs { - case .topPeers: - return false - case let .peer(rhsIndex, _): - return lhsIndex < rhsIndex - } + case .topPeers: + return true + case let .peer(lhsIndex, _): + switch rhs { + case .topPeers: + return false + case let .peer(rhsIndex, _): + return lhsIndex <= rhsIndex + } } } @@ -193,7 +193,7 @@ enum ChatListSearchEntry: Comparable, Identifiable { switch lhs { case let .localPeer(_, lhsIndex): if case let .localPeer(_, rhsIndex) = rhs { - return lhsIndex < rhsIndex + return lhsIndex <= rhsIndex } else { return true } @@ -202,7 +202,7 @@ enum ChatListSearchEntry: Comparable, Identifiable { case .localPeer: return false case let .globalPeer(_, rhsIndex): - return lhsIndex < rhsIndex + return lhsIndex <= rhsIndex case .message: return true } diff --git a/TelegramUI/ChatMediaInputPanelEntries.swift b/TelegramUI/ChatMediaInputPanelEntries.swift index ba08c1149a..d074ac830e 100644 --- a/TelegramUI/ChatMediaInputPanelEntries.swift +++ b/TelegramUI/ChatMediaInputPanelEntries.swift @@ -76,7 +76,7 @@ enum ChatMediaInputPanelEntry: Comparable, Identifiable { if lhsIndex == rhsIndex { return lhsInfo.id.id < rhsInfo.id.id } else { - return lhsIndex < rhsIndex + return lhsIndex <= rhsIndex } } } diff --git a/TelegramUI/ChatMessageBubbleImages.swift b/TelegramUI/ChatMessageBubbleImages.swift index d03be07c08..685712c7ee 100644 --- a/TelegramUI/ChatMessageBubbleImages.swift +++ b/TelegramUI/ChatMessageBubbleImages.swift @@ -2,11 +2,11 @@ import Foundation import Display private let incomingFillColor = UIColor(0xffffff) -private let incomingFillHighlightedColor = UIColor(0xaaaaff) +private let incomingFillHighlightedColor = UIColor(0xd9f4ff) private let incomingStrokeColor = UIColor(0x86A9C9, 0.5) private let outgoingFillColor = UIColor(0xE1FFC7) -private let outgoingFillHighlightedColor = UIColor(0xaaffaa) +private let outgoingFillHighlightedColor = UIColor(0xc8ffa6) private let outgoingStrokeColor = UIColor(0x86A9C9, 0.5) enum MessageBubbleImageNeighbors { diff --git a/TelegramUI/ChatMessageDateAndStatusNode.swift b/TelegramUI/ChatMessageDateAndStatusNode.swift index 9108d75b83..00630a7369 100644 --- a/TelegramUI/ChatMessageDateAndStatusNode.swift +++ b/TelegramUI/ChatMessageDateAndStatusNode.swift @@ -77,6 +77,10 @@ private let clockBubbleMinImage = generateClockMinImage(color: UIColor(0x42b649) private let clockMediaFrameImage = generateClockFrameImage(color: .white) private let clockMediaMinImage = generateClockMinImage(color: .white) +private let incomingImpressionIcon = generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/ImpressionCount"), color: incomingDateColor) +private let outgoingImpressionIcon = generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/ImpressionCount"), color: outgoingDateColor) +private let mediaImpressionIcon = generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/ImpressionCount"), color: .white) + enum ChatMessageDateAndStatusOutgoingType { case Sent(read: Bool) case Sending @@ -97,6 +101,7 @@ class ChatMessageDateAndStatusNode: ASTransformLayerNode { private var clockFrameNode: ASImageNode? private var clockMinNode: ASImageNode? private let dateNode: TextNode + private var impressionIcon: ASImageNode? override init() { self.dateNode = TextNode() @@ -108,7 +113,7 @@ class ChatMessageDateAndStatusNode: ASTransformLayerNode { self.addSubnode(self.dateNode) } - func asyncLayout() -> (_ dateText: String, _ type: ChatMessageDateAndStatusType, _ constrainedSize: CGSize) -> (CGSize, (Bool) -> Void) { + func asyncLayout() -> (_ edited: Bool, _ impressionCount: Int?, _ dateText: String, _ type: ChatMessageDateAndStatusType, _ constrainedSize: CGSize) -> (CGSize, (Bool) -> Void) { let dateLayout = TextNode.asyncLayout(self.dateNode) var checkReadNode = self.checkReadNode @@ -117,8 +122,9 @@ class ChatMessageDateAndStatusNode: ASTransformLayerNode { var clockMinNode = self.clockMinNode var currentBackgroundNode = self.backgroundNode + var currentImpressionIcon = self.impressionIcon - return { dateText, type, constrainedSize in + return { edited, impressionCount, dateText, type, constrainedSize in let dateColor: UIColor var backgroundImage: UIImage? var outgoingStatus: ChatMessageDateAndStatusOutgoingType? @@ -128,6 +134,7 @@ class ChatMessageDateAndStatusNode: ASTransformLayerNode { let loadedCheckPartialImage: UIImage? let clockFrameImage: UIImage? let clockMinImage: UIImage? + var impressionImage: UIImage? switch type { case .BubbleIncoming: @@ -137,6 +144,9 @@ class ChatMessageDateAndStatusNode: ASTransformLayerNode { loadedCheckPartialImage = checkBubblePartialImage clockFrameImage = clockBubbleFrameImage clockMinImage = clockBubbleMinImage + if impressionCount != nil { + impressionImage = incomingImpressionIcon + } case let .BubbleOutgoing(status): dateColor = outgoingDateColor outgoingStatus = status @@ -145,6 +155,9 @@ class ChatMessageDateAndStatusNode: ASTransformLayerNode { loadedCheckPartialImage = checkBubblePartialImage clockFrameImage = clockBubbleFrameImage clockMinImage = clockBubbleMinImage + if impressionCount != nil { + impressionImage = outgoingImpressionIcon + } case .ImageIncoming: dateColor = .white backgroundImage = imageBackground @@ -153,6 +166,9 @@ class ChatMessageDateAndStatusNode: ASTransformLayerNode { loadedCheckPartialImage = checkMediaPartialImage clockFrameImage = clockMediaFrameImage clockMinImage = clockMediaMinImage + if impressionCount != nil { + impressionImage = mediaImpressionIcon + } case let .ImageOutgoing(status): dateColor = .white outgoingStatus = status @@ -162,9 +178,21 @@ class ChatMessageDateAndStatusNode: ASTransformLayerNode { loadedCheckPartialImage = checkMediaPartialImage clockFrameImage = clockMediaFrameImage clockMinImage = clockMediaMinImage + if impressionCount != nil { + impressionImage = mediaImpressionIcon + } } - let (date, dateApply) = dateLayout(NSAttributedString(string: dateText, font: dateFont, textColor: dateColor), nil, 1, .end, constrainedSize, nil) + var updatedDateText = dateText + if let impressionCount = impressionCount { + updatedDateText = compactNumericCountString(impressionCount) + " " + updatedDateText + } + + if edited { + updatedDateText = "edited " + updatedDateText + } + + let (date, dateApply) = dateLayout(NSAttributedString(string: updatedDateText, font: dateFont, textColor: dateColor), nil, 1, .end, constrainedSize, nil) let statusWidth: CGFloat @@ -267,7 +295,23 @@ class ChatMessageDateAndStatusNode: ASTransformLayerNode { backgroundInsets = UIEdgeInsets(top: 2.0, left: 7.0, bottom: 2.0, right: 7.0) } - let layoutSize = CGSize(width: leftInset + date.size.width + statusWidth + backgroundInsets.left + backgroundInsets.right, height: date.size.height + backgroundInsets.top + backgroundInsets.bottom) + var impressionSize = CGSize() + var impressionWidth: CGFloat = 0.0 + if let impressionImage = impressionImage { + if currentImpressionIcon == nil { + let iconNode = ASImageNode() + iconNode.isLayerBacked = true + iconNode.displayWithoutProcessing = true + iconNode.displaysAsynchronously = false + currentImpressionIcon = iconNode + } + impressionSize = impressionImage.size + impressionWidth = impressionSize.width + 3.0 + } else { + currentImpressionIcon = nil + } + + let layoutSize = CGSize(width: leftInset + impressionWidth + date.size.width + statusWidth + backgroundInsets.left + backgroundInsets.right, height: date.size.height + backgroundInsets.top + backgroundInsets.bottom) return (layoutSize, { [weak self] animated in if let strongSelf = self { @@ -288,7 +332,21 @@ class ChatMessageDateAndStatusNode: ASTransformLayerNode { let _ = dateApply() - strongSelf.dateNode.frame = CGRect(origin: CGPoint(x: leftInset + backgroundInsets.left, y: backgroundInsets.top), size: date.size) + if let currentImpressionIcon = currentImpressionIcon { + if currentImpressionIcon.image !== impressionImage { + currentImpressionIcon.image = impressionImage + } + if currentImpressionIcon.supernode == nil { + strongSelf.impressionIcon = currentImpressionIcon + strongSelf.addSubnode(currentImpressionIcon) + } + currentImpressionIcon.frame = CGRect(origin: CGPoint(x: leftInset + backgroundInsets.left, y: backgroundInsets.top + 3.0), size: impressionSize) + } else if let impressionIcon = strongSelf.impressionIcon { + impressionIcon.removeFromSupernode() + strongSelf.impressionIcon = nil + } + + strongSelf.dateNode.frame = CGRect(origin: CGPoint(x: leftInset + backgroundInsets.left + impressionWidth, y: backgroundInsets.top), size: date.size) if let clockFrameNode = clockFrameNode { if strongSelf.clockFrameNode == nil { diff --git a/TelegramUI/ChatMessageInteractiveFileNode.swift b/TelegramUI/ChatMessageInteractiveFileNode.swift index d7d1d3e247..6f00de8c4e 100644 --- a/TelegramUI/ChatMessageInteractiveFileNode.swift +++ b/TelegramUI/ChatMessageInteractiveFileNode.swift @@ -162,21 +162,15 @@ final class ChatMessageInteractiveFileNode: ASTransformNode { var edited = false var viewCount: Int? for attribute in message.attributes { - if let attribute = attribute as? EditedMessageAttribute { + if let _ = attribute as? EditedMessageAttribute { edited = true } else if let attribute = attribute as? ViewCountMessageAttribute { viewCount = attribute.count } } - var dateText = String(format: "%02d:%02d", arguments: [Int(timeinfo.tm_hour), Int(timeinfo.tm_min)]) - if let viewCount = viewCount { - dateText = "\(viewCount) " + dateText - } - if edited { - dateText = "edited " + dateText - } + let dateText = String(format: "%02d:%02d", arguments: [Int(timeinfo.tm_hour), Int(timeinfo.tm_min)]) - let (size, apply) = statusLayout(dateText, statusType, constrainedSize) + let (size, apply) = statusLayout(edited, viewCount, dateText, statusType, constrainedSize) statusSize = size statusApply = apply } diff --git a/TelegramUI/ChatMessageMediaBubbleContentNode.swift b/TelegramUI/ChatMessageMediaBubbleContentNode.swift index f1dc0028c0..0bf97edb34 100644 --- a/TelegramUI/ChatMessageMediaBubbleContentNode.swift +++ b/TelegramUI/ChatMessageMediaBubbleContentNode.swift @@ -75,12 +75,6 @@ class ChatMessageMediaBubbleContentNode: ChatMessageBubbleContentNode { } } var dateText = String(format: "%02d:%02d", arguments: [Int(timeinfo.tm_hour), Int(timeinfo.tm_min)]) - if let viewCount = viewCount { - dateText = "\(viewCount) " + dateText - } - if edited { - dateText = "edited " + dateText - } let statusType: ChatMessageDateAndStatusType? if case .None = position.bottom { @@ -105,7 +99,7 @@ class ChatMessageMediaBubbleContentNode: ChatMessageBubbleContentNode { var statusApply: ((Bool) -> Void)? if let statusType = statusType { - let (size, apply) = statusLayout(dateText, statusType, CGSize(width: imageLayoutSize.width, height: CGFloat.greatestFiniteMagnitude)) + let (size, apply) = statusLayout(edited, viewCount, dateText, statusType, CGSize(width: imageLayoutSize.width, height: CGFloat.greatestFiniteMagnitude)) statusSize = size statusApply = apply } diff --git a/TelegramUI/ChatMessageTextBubbleContentNode.swift b/TelegramUI/ChatMessageTextBubbleContentNode.swift index d969ac2113..edfaf4fa0a 100644 --- a/TelegramUI/ChatMessageTextBubbleContentNode.swift +++ b/TelegramUI/ChatMessageTextBubbleContentNode.swift @@ -56,14 +56,6 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } } var dateText = String(format: "%02d:%02d", arguments: [Int(timeinfo.tm_hour), Int(timeinfo.tm_min)]) - if let viewCount = viewCount { - dateText = "\(viewCount) " + dateText - } - if edited { - dateText = "edited " + dateText - } - - //let dateText = "\(message.id.id)" let statusType: ChatMessageDateAndStatusType? if case .None = position.bottom { @@ -86,7 +78,7 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { var statusApply: ((Bool) -> Void)? if let statusType = statusType { - let (size, apply) = statusLayout(dateText, statusType, textConstrainedSize) + let (size, apply) = statusLayout(edited, viewCount, dateText, statusType, textConstrainedSize) statusSize = size statusApply = apply } @@ -195,7 +187,7 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { strongSelf.addSubnode(strongSelf.statusNode) } else { if case let .System(duration) = animation { - let delta = CGPoint(x: previousStatusFrame.minX - adjustedStatusFrame.minX, y: previousStatusFrame.minY - adjustedStatusFrame.minY) + let delta = CGPoint(x: previousStatusFrame.maxX - adjustedStatusFrame.maxX, y: previousStatusFrame.minY - adjustedStatusFrame.minY) let statusPosition = strongSelf.statusNode.layer.position let previousPosition = CGPoint(x: statusPosition.x + delta.x, y: statusPosition.y + delta.y) strongSelf.statusNode.layer.animatePosition(from: previousPosition, to: statusPosition, duration: duration, timingFunction: kCAMediaTimingFunctionSpring) diff --git a/TelegramUI/ChatMessageWebpageBubbleContentNode.swift b/TelegramUI/ChatMessageWebpageBubbleContentNode.swift index f416b06fb1..104c050798 100644 --- a/TelegramUI/ChatMessageWebpageBubbleContentNode.swift +++ b/TelegramUI/ChatMessageWebpageBubbleContentNode.swift @@ -96,19 +96,13 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { var edited = false var viewCount: Int? for attribute in item.message.attributes { - if let attribute = attribute as? EditedMessageAttribute { + if let _ = attribute as? EditedMessageAttribute { edited = true } else if let attribute = attribute as? ViewCountMessageAttribute { viewCount = attribute.count } } - var dateText = String(format: "%02d:%02d", arguments: [Int(timeinfo.tm_hour), Int(timeinfo.tm_min)]) - if let viewCount = viewCount { - dateText = "\(viewCount) " + dateText - } - if edited { - dateText = "edited " + dateText - } + let dateText = String(format: "%02d:%02d", arguments: [Int(timeinfo.tm_hour), Int(timeinfo.tm_min)]) var textString: NSAttributedString? var inlineImageDimensions: CGSize? @@ -188,7 +182,7 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { } else if item.message.flags.isSending { statusType = .BubbleOutgoing(.Sending) } else { - statusType = .BubbleOutgoing(.Sent(read: true)) + statusType = .BubbleOutgoing(.Sent(read: item.read)) } } @@ -197,7 +191,7 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { var statusSizeAndApply: (CGSize, (Bool) -> Void)? if refineContentImageLayout == nil && refineContentFileLayout == nil { - statusSizeAndApply = statusLayout(dateText, statusType, textConstrainedSize) + statusSizeAndApply = statusLayout(edited, viewCount, dateText, statusType, textConstrainedSize) } let (textLayout, textApply) = textAsyncLayout(textString, nil, 12, .end, textConstrainedSize, textCutout) diff --git a/TelegramUI/ChatTitleView.swift b/TelegramUI/ChatTitleView.swift index 1a85388827..145845f4cb 100644 --- a/TelegramUI/ChatTitleView.swift +++ b/TelegramUI/ChatTitleView.swift @@ -143,14 +143,14 @@ final class ChatTitleView: UIView { } if onlineCount > 1 { let string = NSMutableAttributedString() - string.append(NSAttributedString(string: "\(group.participantCount) members, ", font: Font.regular(13.0), textColor: UIColor(0x787878))) + string.append(NSAttributedString(string: "\(compactNumericCountString(group.participantCount)) members, ", font: Font.regular(13.0), textColor: UIColor(0x787878))) string.append(NSAttributedString(string: "\(onlineCount) online", font: Font.regular(13.0), textColor: UIColor(0x007ee5))) if self.infoNode.attributedText == nil || !self.infoNode.attributedText!.isEqual(to: string) { self.infoNode.attributedText = string shouldUpdateLayout = true } } else { - let string = NSAttributedString(string: "\(group.participantCount) members", font: Font.regular(13.0), textColor: UIColor(0x787878)) + let string = NSAttributedString(string: "\(compactNumericCountString(group.participantCount)) members", font: Font.regular(13.0), textColor: UIColor(0x787878)) if self.infoNode.attributedText == nil || !self.infoNode.attributedText!.isEqual(to: string) { self.infoNode.attributedText = string shouldUpdateLayout = true @@ -158,7 +158,7 @@ final class ChatTitleView: UIView { } } else if let channel = peer as? TelegramChannel { if let cachedChannelData = peerView.cachedData as? CachedChannelData, let memberCount = cachedChannelData.participantsSummary.memberCount { - let string = NSAttributedString(string: "\(memberCount) members", font: Font.regular(13.0), textColor: UIColor(0x787878)) + let string = NSAttributedString(string: "\(compactNumericCountString(Int(memberCount))) members", font: Font.regular(13.0), textColor: UIColor(0x787878)) if self.infoNode.attributedText == nil || !self.infoNode.attributedText!.isEqual(to: string) { self.infoNode.attributedText = string shouldUpdateLayout = true diff --git a/TelegramUI/ItemListRecentSessionItem.swift b/TelegramUI/ItemListRecentSessionItem.swift new file mode 100644 index 0000000000..9f1ba32157 --- /dev/null +++ b/TelegramUI/ItemListRecentSessionItem.swift @@ -0,0 +1,400 @@ +import Foundation +import Display +import AsyncDisplayKit +import SwiftSignalKit +import Postbox +import TelegramCore + +struct ItemListRecentSessionItemEditing: Equatable { + let editable: Bool + let editing: Bool + let revealed: Bool + + static func ==(lhs: ItemListRecentSessionItemEditing, rhs: ItemListRecentSessionItemEditing) -> Bool { + if lhs.editable != rhs.editable { + return false + } + if lhs.editing != rhs.editing { + return false + } + if lhs.revealed != rhs.revealed { + return false + } + return true + } +} + +enum ItemListRecentSessionItemText { + case presence + case text(String) + case none +} + +final class ItemListRecentSessionItem: ListViewItem, ItemListItem { + let session: RecentAccountSession + let enabled: Bool + let editable: Bool + let editing: Bool + let revealed: Bool + let sectionId: ItemListSectionId + let setSessionIdWithRevealedOptions: (Int64?, Int64?) -> Void + let removeSession: (Int64) -> Void + + init(session: RecentAccountSession, enabled: Bool, editable: Bool, editing: Bool, revealed: Bool, sectionId: ItemListSectionId, setSessionIdWithRevealedOptions: @escaping (Int64?, Int64?) -> Void, removeSession: @escaping (Int64) -> Void) { + self.session = session + self.enabled = enabled + self.editable = editable + self.editing = editing + self.revealed = revealed + self.sectionId = sectionId + self.setSessionIdWithRevealedOptions = setSessionIdWithRevealedOptions + self.removeSession = removeSession + } + + func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, () -> Void)) -> Void) { + async { + let node = ItemListRecentSessionItemNode() + let (layout, apply) = node.asyncLayout()(self, width, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) + + node.contentSize = layout.contentSize + node.insets = layout.insets + + completion(node, { + return (nil, { apply(false) }) + }) + } + } + + func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: ListViewItemNode, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping () -> Void) -> Void) { + if let node = node as? ItemListRecentSessionItemNode { + Queue.mainQueue().async { + let makeLayout = node.asyncLayout() + + var animated = true + if case .None = animation { + animated = false + } + + async { + let (layout, apply) = makeLayout(self, width, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) + Queue.mainQueue().async { + completion(layout, { + apply(animated) + }) + } + } + } + } + } +} + +private let titleFont = Font.medium(15.0) +private let textFont = Font.regular(13.0) + +class ItemListRecentSessionItemNode: ItemListRevealOptionsItemNode { + private let backgroundNode: ASDisplayNode + private let topStripeNode: ASDisplayNode + private let bottomStripeNode: ASDisplayNode + private let highlightedBackgroundNode: ASDisplayNode + private var disabledOverlayNode: ASDisplayNode? + + private let titleNode: TextNode + private let appNode: TextNode + private let locationNode: TextNode + private let labelNode: TextNode + + private var layoutParams: (ItemListRecentSessionItem, CGFloat, ItemListNeighbors)? + + private var editableControlNode: ItemListEditableControlNode? + + init() { + self.backgroundNode = ASDisplayNode() + self.backgroundNode.isLayerBacked = true + self.backgroundNode.backgroundColor = .white + + self.topStripeNode = ASDisplayNode() + self.topStripeNode.backgroundColor = UIColor(0xc8c7cc) + self.topStripeNode.isLayerBacked = true + + self.bottomStripeNode = ASDisplayNode() + self.bottomStripeNode.backgroundColor = UIColor(0xc8c7cc) + self.bottomStripeNode.isLayerBacked = true + + self.titleNode = TextNode() + self.titleNode.isLayerBacked = true + self.titleNode.contentMode = .left + self.titleNode.contentsScale = UIScreen.main.scale + + self.appNode = TextNode() + self.appNode.isLayerBacked = true + self.appNode.contentMode = .left + self.appNode.contentsScale = UIScreen.main.scale + + self.locationNode = TextNode() + self.locationNode.isLayerBacked = true + self.locationNode.contentMode = .left + self.locationNode.contentsScale = UIScreen.main.scale + + self.labelNode = TextNode() + self.labelNode.isLayerBacked = true + self.labelNode.contentMode = .left + self.labelNode.contentsScale = UIScreen.main.scale + + self.highlightedBackgroundNode = ASDisplayNode() + self.highlightedBackgroundNode.backgroundColor = UIColor(0xd9d9d9) + self.highlightedBackgroundNode.isLayerBacked = true + + super.init(layerBacked: false, dynamicBounce: false, rotated: false) + + self.addSubnode(self.titleNode) + self.addSubnode(self.appNode) + self.addSubnode(self.locationNode) + self.addSubnode(self.labelNode) + } + + func asyncLayout() -> (_ item: ItemListRecentSessionItem, _ width: CGFloat, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, (Bool) -> Void) { + let makeTitleLayout = TextNode.asyncLayout(self.titleNode) + let makeAppLayout = TextNode.asyncLayout(self.appNode) + let makeLocationLayout = TextNode.asyncLayout(self.locationNode) + let makeLabelLayout = TextNode.asyncLayout(self.labelNode) + let editableControlLayout = ItemListEditableControlNode.asyncLayout(self.editableControlNode) + + var currentDisabledOverlayNode = self.disabledOverlayNode + + return { item, width, neighbors in + var titleAttributedString: NSAttributedString? + var appAttributedString: NSAttributedString? + var locationAttributedString: NSAttributedString? + var labelAttributedString: NSAttributedString? + + let peerRevealOptions: [ItemListRevealOption] + if item.editable && item.enabled { + peerRevealOptions = [ItemListRevealOption(key: 0, title: "Terminate", icon: nil, color: UIColor(0xff3824))] + } else { + peerRevealOptions = [] + } + + let rightInset: CGFloat = 0.0 + + titleAttributedString = NSAttributedString(string: "\(item.session.appName) \(item.session.appVersion)", font: titleFont, textColor: UIColor.black) + + var appString = "" + if !item.session.deviceModel.isEmpty { + appString = item.session.deviceModel + } + + if !item.session.platform.isEmpty { + if !appString.isEmpty { + appString += ", " + } + appString += item.session.platform + } + + if !item.session.systemVersion.isEmpty { + if !appString.isEmpty { + appString += ", " + } + appString += item.session.systemVersion + } + + appAttributedString = NSAttributedString(string: appString, font: textFont, textColor: UIColor.black) + locationAttributedString = NSAttributedString(string: "\(item.session.ip) — \(item.session.country)", font: textFont, textColor: UIColor(0x6d6d6d)) + if item.session.isCurrent { + labelAttributedString = NSAttributedString(string: "online", font: textFont, textColor: UIColor(0x007ee5)) + } else { + let timestamp = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) + let dateText = stringForRelativeTimestamp(item.session.activityDate, relativeTo: timestamp) + labelAttributedString = NSAttributedString(string: dateText, font: textFont, textColor: UIColor(0x6d6d6d)) + } + + let leftInset: CGFloat = 15.0 + + var editableControlSizeAndApply: (CGSize, () -> ItemListEditableControlNode)? + + let editingOffset: CGFloat + if item.editing { + let sizeAndApply = editableControlLayout(75.0) + editableControlSizeAndApply = sizeAndApply + editingOffset = sizeAndApply.0.width + } else { + editingOffset = 0.0 + } + + let (labelLayout, labelApply) = makeLabelLayout(labelAttributedString, nil, 1, .end, CGSize(width: width - leftInset - 8.0 - editingOffset - rightInset, height: CGFloat.greatestFiniteMagnitude), nil) + let (titleLayout, titleApply) = makeTitleLayout(titleAttributedString, nil, 1, .end, CGSize(width: width - leftInset - 8.0 - editingOffset - rightInset - labelLayout.size.width - 5.0, height: CGFloat.greatestFiniteMagnitude), nil) + let (appLayout, appApply) = makeAppLayout(appAttributedString, nil, 1, .end, CGSize(width: width - leftInset - 8.0 - editingOffset - rightInset, height: CGFloat.greatestFiniteMagnitude), nil) + let (locationLayout, locationApply) = makeLocationLayout(locationAttributedString, nil, 1, .end, CGSize(width: width - leftInset - 8.0 - editingOffset - rightInset, height: CGFloat.greatestFiniteMagnitude), nil) + + let insets = itemListNeighborsGroupedInsets(neighbors) + let contentSize = CGSize(width: width, height: 75.0) + let separatorHeight = UIScreenPixel + + let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets) + let layoutSize = layout.size + + if !item.enabled { + if currentDisabledOverlayNode == nil { + currentDisabledOverlayNode = ASDisplayNode() + currentDisabledOverlayNode?.backgroundColor = UIColor(white: 1.0, alpha: 0.5) + } + } else { + currentDisabledOverlayNode = nil + } + + return (layout, { [weak self] animated in + if let strongSelf = self { + strongSelf.layoutParams = (item, width, neighbors) + + let revealOffset = strongSelf.revealOffset + + let transition: ContainedViewLayoutTransition + if animated { + transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring) + } else { + transition = .immediate + } + + if let currentDisabledOverlayNode = currentDisabledOverlayNode { + if currentDisabledOverlayNode != strongSelf.disabledOverlayNode { + strongSelf.disabledOverlayNode = currentDisabledOverlayNode + strongSelf.addSubnode(currentDisabledOverlayNode) + currentDisabledOverlayNode.alpha = 0.0 + transition.updateAlpha(node: currentDisabledOverlayNode, alpha: 1.0) + currentDisabledOverlayNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: layout.contentSize.width, height: layout.contentSize.height - separatorHeight)) + } else { + transition.updateFrame(node: currentDisabledOverlayNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: layout.contentSize.width, height: layout.contentSize.height - separatorHeight))) + } + } else if let disabledOverlayNode = strongSelf.disabledOverlayNode { + transition.updateAlpha(node: disabledOverlayNode, alpha: 0.0, completion: { [weak disabledOverlayNode] _ in + disabledOverlayNode?.removeFromSupernode() + }) + strongSelf.disabledOverlayNode = nil + } + + if let editableControlSizeAndApply = editableControlSizeAndApply { + if strongSelf.editableControlNode == nil { + let editableControlNode = editableControlSizeAndApply.1() + editableControlNode.tapped = { + if let strongSelf = self { + strongSelf.setRevealOptionsOpened(true, animated: true) + strongSelf.revealOptionsInteractivelyOpened() + } + } + strongSelf.editableControlNode = editableControlNode + strongSelf.insertSubnode(editableControlNode, aboveSubnode: strongSelf.titleNode) + let editableControlFrame = CGRect(origin: CGPoint(x: revealOffset, y: 0.0), size: editableControlSizeAndApply.0) + editableControlNode.frame = editableControlFrame + transition.animatePosition(node: editableControlNode, from: CGPoint(x: editableControlFrame.midX - editableControlFrame.size.width, y: editableControlFrame.midY)) + editableControlNode.alpha = 0.0 + transition.updateAlpha(node: editableControlNode, alpha: 1.0) + } + strongSelf.editableControlNode?.isHidden = !item.editable + } else if let editableControlNode = strongSelf.editableControlNode { + var editableControlFrame = editableControlNode.frame + editableControlFrame.origin.x = -editableControlFrame.size.width + strongSelf.editableControlNode = nil + transition.updateAlpha(node: editableControlNode, alpha: 0.0) + transition.updateFrame(node: editableControlNode, frame: editableControlFrame, completion: { [weak editableControlNode] _ in + editableControlNode?.removeFromSupernode() + }) + } + + let _ = labelApply() + let _ = titleApply() + let _ = appApply() + let _ = locationApply() + + if strongSelf.backgroundNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.backgroundNode, at: 0) + } + if strongSelf.topStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.topStripeNode, at: 1) + } + if strongSelf.bottomStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 2) + } + switch neighbors.top { + case .sameSection(false): + strongSelf.topStripeNode.isHidden = true + default: + strongSelf.topStripeNode.isHidden = false + } + let bottomStripeInset: CGFloat + let bottomStripeOffset: CGFloat + switch neighbors.bottom { + case .sameSection(false): + bottomStripeInset = leftInset + editingOffset + bottomStripeOffset = -separatorHeight + default: + bottomStripeInset = 0.0 + bottomStripeOffset = 0.0 + } + strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight))) + transition.updateFrame(node: strongSelf.topStripeNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight))) + transition.updateFrame(node: strongSelf.bottomStripeNode, frame: CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height + bottomStripeOffset), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight))) + + transition.updateFrame(node: strongSelf.labelNode, frame: CGRect(origin: CGPoint(x: revealOffset + width - labelLayout.size.width - 15.0 - rightInset, y: 10.0), size: labelLayout.size)) + transition.updateFrame(node: strongSelf.titleNode, frame: CGRect(origin: CGPoint(x: leftInset + revealOffset + editingOffset, y: 10.0), size: titleLayout.size)) + transition.updateFrame(node: strongSelf.appNode, frame: CGRect(origin: CGPoint(x: leftInset + revealOffset + editingOffset, y: 30.0), size: appLayout.size)) + transition.updateFrame(node: strongSelf.locationNode, frame: CGRect(origin: CGPoint(x: leftInset + revealOffset + editingOffset, y: 50.0), size: locationLayout.size)) + + strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: width, height: 75.0 + UIScreenPixel + UIScreenPixel)) + + strongSelf.setRevealOptions(peerRevealOptions) + strongSelf.setRevealOptionsOpened(item.revealed, animated: animated) + } + }) + } + } + + override func animateInsertion(_ currentTimestamp: Double, duration: Double, short: Bool) { + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4) + } + + override func animateRemoved(_ currentTimestamp: Double, duration: Double) { + self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) + } + + override func updateRevealOffset(offset: CGFloat, transition: ContainedViewLayoutTransition) { + super.updateRevealOffset(offset: offset, transition: transition) + + let leftInset: CGFloat = 15.0 + let width = self.bounds.size.width + + let editingOffset: CGFloat + if let editableControlNode = self.editableControlNode { + editingOffset = editableControlNode.bounds.size.width + var editableControlFrame = editableControlNode.frame + editableControlFrame.origin.x = offset + transition.updateFrame(node: editableControlNode, frame: editableControlFrame) + } else { + editingOffset = 0.0 + } + + transition.updateFrame(node: self.labelNode, frame: CGRect(origin: CGPoint(x: revealOffset + width - self.labelNode.bounds.size.width - 15.0, y: self.labelNode.frame.minY), size: self.labelNode.bounds.size)) + transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: leftInset + revealOffset + editingOffset, y: self.titleNode.frame.minY), size: self.titleNode.bounds.size)) + transition.updateFrame(node: self.appNode, frame: CGRect(origin: CGPoint(x: leftInset + revealOffset + editingOffset, y: self.appNode.frame.minY), size: self.appNode.bounds.size)) + transition.updateFrame(node: self.locationNode, frame: CGRect(origin: CGPoint(x: leftInset + revealOffset + editingOffset, y: self.locationNode.frame.minY), size: self.locationNode.bounds.size)) + } + + override func revealOptionsInteractivelyOpened() { + if let (item, _, _) = self.layoutParams { + item.setSessionIdWithRevealedOptions(item.session.hash, nil) + } + } + + override func revealOptionsInteractivelyClosed() { + if let (item, _, _) = self.layoutParams { + item.setSessionIdWithRevealedOptions(nil, item.session.hash) + } + } + + override func revealOptionSelected(_ option: ItemListRevealOption) { + self.setRevealOptionsOpened(false, animated: true) + self.revealOptionsInteractivelyClosed() + + if let (item, _, _) = self.layoutParams { + item.removeSession(item.session.hash) + } + } +} diff --git a/TelegramUI/NumericFormat.swift b/TelegramUI/NumericFormat.swift new file mode 100644 index 0000000000..7c89e91f0a --- /dev/null +++ b/TelegramUI/NumericFormat.swift @@ -0,0 +1,22 @@ +import Foundation + +public func compactNumericCountString(_ count: Int) -> String { + if count >= 1000 * 1000 { + let remainder = (count % (1000 * 1000)) / (1000 * 100) + if remainder != 0 { + return "\(count / (1000 * 1000)),\(remainder)M" + } else { + return "\(count / (1000 * 1000))M" + } + } else if count >= 1000 { + let remainder = (count % (1000)) / (100) + if remainder != 0 { + return "\(count / 1000),\(remainder)K" + } else { + return "\(count / 1000)K" + } + } else { + return "\(count)" + } +} + diff --git a/TelegramUI/PresenceStrings.swift b/TelegramUI/PresenceStrings.swift index 3584dec001..b03116e62e 100644 --- a/TelegramUI/PresenceStrings.swift +++ b/TelegramUI/PresenceStrings.swift @@ -6,16 +6,41 @@ func stringForTimestamp(day: Int32, month: Int32, year: Int32) -> String { return String(format: "%d.%02d.%02d", day, month, year - 100) } +func stringForTimestamp(day: Int32, month: Int32) -> String { + return String(format: "%d.%02d", day, month) +} + +func shortStringForDayOfWeek(_ day: Int32) -> String { + switch day { + case 0: + return "Sun" + case 1: + return "Mon" + case 2: + return "Tue" + case 3: + return "Wed" + case 4: + return "Thu" + case 5: + return "Fri" + case 6: + return "Sat" + default: + return "" + } +} + func stringForTime(hours: Int32, minutes: Int32) -> String { return String(format: "%d:%02d", hours, minutes) } -enum UserPresenceDay { +enum RelativeTimestampFormatDay { case today case yesterday } -func stringForUserPresence(day: UserPresenceDay, hours: Int32, minutes: Int32) -> String { +func stringForUserPresence(day: RelativeTimestampFormatDay, hours: Int32, minutes: Int32) -> String { let dayString: String switch day { case .today: @@ -64,6 +89,31 @@ func relativeUserPresenceStatus(_ presence: TelegramUserPresence, relativeTo tim } } +func stringForRelativeTimestamp(_ relativeTimestamp: Int32, relativeTo timestamp: Int32) -> String { + var t: time_t = time_t(relativeTimestamp) + var timeinfo: tm = tm() + localtime_r(&t, &timeinfo) + + var now: time_t = time_t(timestamp) + var timeinfoNow: tm = tm() + localtime_r(&now, &timeinfoNow) + + if timeinfo.tm_year != timeinfoNow.tm_year { + return stringForTimestamp(day: timeinfo.tm_mday, month: timeinfo.tm_mon + 1, year: timeinfo.tm_year) + } + + let dayDifference = timeinfo.tm_yday - timeinfoNow.tm_yday + if dayDifference > -7 { + if dayDifference == 0 { + return stringForTime(hours: timeinfo.tm_hour, minutes: timeinfo.tm_min) + } else { + return shortStringForDayOfWeek(timeinfo.tm_wday) + } + } else { + return stringForTimestamp(day: timeinfo.tm_mday, month: timeinfo.tm_mon + 1) + } +} + func stringAndActivityForUserPresence(_ presence: TelegramUserPresence, relativeTo timestamp: Int32) -> (String, Bool) { switch presence.status { case .none: @@ -97,7 +147,7 @@ func stringAndActivityForUserPresence(_ presence: TelegramUserPresence, relative let dayDifference = timeinfo.tm_yday - timeinfoNow.tm_yday if dayDifference == 0 || dayDifference == -1 { - let day: UserPresenceDay + let day: RelativeTimestampFormatDay if dayDifference == 0 { day = .today } else { diff --git a/TelegramUI/PrivacyAndSecurityController.swift b/TelegramUI/PrivacyAndSecurityController.swift new file mode 100644 index 0000000000..38cb4e2b6f --- /dev/null +++ b/TelegramUI/PrivacyAndSecurityController.swift @@ -0,0 +1,262 @@ +import Foundation +import Display +import SwiftSignalKit +import Postbox +import TelegramCore + +private final class PrivacyAndSecurityControllerArguments { + let account: Account + let openBlockedUsers: () -> Void + let openLastSeenPrivacy: () -> Void + let openGroupsPrivacy: () -> Void + let openVoiceCallPrivacy: () -> Void + let openPasscode: () -> Void + let openTwoStepVerification: () -> Void + let openActiveSessions: () -> Void + let setupAccountAutoremove: () -> Void + + init(account: Account, openBlockedUsers: @escaping () -> Void, openLastSeenPrivacy: @escaping () -> Void, openGroupsPrivacy: @escaping () -> Void, openVoiceCallPrivacy: @escaping () -> Void, openPasscode: @escaping () -> Void, openTwoStepVerification: @escaping () -> Void, openActiveSessions: @escaping () -> Void, setupAccountAutoremove: @escaping () -> Void) { + self.account = account + self.openBlockedUsers = openBlockedUsers + self.openLastSeenPrivacy = openLastSeenPrivacy + self.openGroupsPrivacy = openGroupsPrivacy + self.openVoiceCallPrivacy = openVoiceCallPrivacy + self.openPasscode = openPasscode + self.openTwoStepVerification = openTwoStepVerification + self.openActiveSessions = openActiveSessions + self.setupAccountAutoremove = setupAccountAutoremove + } +} + +private enum PrivacyAndSecuritySection: Int32 { + case privacy + case security + case account +} + +private enum PrivacyAndSecurityEntry: ItemListNodeEntry { + case privacyHeader + case blockedPeers + case lastSeenPrivacy(String) + case groupPrivacy(String) + case voiceCallPrivacy(String) + case securityHeader + case passcode + case twoStepVerification + case activeSessions + case accountHeader + case accountTimeout(String) + case accountInfo + + var section: ItemListSectionId { + switch self { + case .privacyHeader, .blockedPeers, .lastSeenPrivacy, .groupPrivacy, .voiceCallPrivacy: + return PrivacyAndSecuritySection.privacy.rawValue + case .securityHeader, .passcode, .twoStepVerification, .activeSessions: + return PrivacyAndSecuritySection.security.rawValue + case .accountHeader, .accountTimeout, .accountInfo: + return PrivacyAndSecuritySection.account.rawValue + } + } + + var stableId: Int32 { + switch self { + case .privacyHeader: + return 0 + case .blockedPeers: + return 1 + case .lastSeenPrivacy: + return 2 + case .groupPrivacy: + return 3 + case .voiceCallPrivacy: + return 4 + case .securityHeader: + return 5 + case .passcode: + return 6 + case .twoStepVerification: + return 7 + case .activeSessions: + return 8 + case .accountHeader: + return 9 + case .accountTimeout: + return 10 + case .accountInfo: + return 11 + } + } + + static func ==(lhs: PrivacyAndSecurityEntry, rhs: PrivacyAndSecurityEntry) -> Bool { + switch lhs { + case .privacyHeader, .blockedPeers, .securityHeader, .passcode, .twoStepVerification, .activeSessions, .accountHeader, .accountInfo: + return lhs.stableId == rhs.stableId + case let .lastSeenPrivacy(text): + if case .lastSeenPrivacy(text) = rhs { + return true + } else { + return false + } + case let .groupPrivacy(text): + if case .groupPrivacy(text) = rhs { + return true + } else { + return false + } + case let .voiceCallPrivacy(text): + if case .voiceCallPrivacy(text) = rhs { + return true + } else { + return false + } + case let .accountTimeout(text): + if case .accountTimeout(text) = rhs { + return true + } else { + return false + } + } + } + + static func <(lhs: PrivacyAndSecurityEntry, rhs: PrivacyAndSecurityEntry) -> Bool { + return lhs.stableId < rhs.stableId + } + + func item(_ arguments: PrivacyAndSecurityControllerArguments) -> ListViewItem { + switch self { + case .privacyHeader: + return ItemListSectionHeaderItem(text: "PRIVACY", sectionId: self.section) + case .blockedPeers: + return ItemListDisclosureItem(title: "Blocked Users", label: "", sectionId: self.section, style: .blocks, action: { + arguments.openBlockedUsers() + }) + case let .lastSeenPrivacy(text): + return ItemListDisclosureItem(title: "Last Seen", label: text, sectionId: self.section, style: .blocks, action: { + arguments.openLastSeenPrivacy() + }) + case let .groupPrivacy(text): + return ItemListDisclosureItem(title: "Groups", label: text, sectionId: self.section, style: .blocks, action: { + arguments.openGroupsPrivacy() + }) + case let .voiceCallPrivacy(text): + return ItemListDisclosureItem(title: "Voice Calls", label: text, sectionId: self.section, style: .blocks, action: { + arguments.openVoiceCallPrivacy() + }) + case .securityHeader: + return ItemListSectionHeaderItem(text: "SECURITY", sectionId: self.section) + case .passcode: + return ItemListDisclosureItem(title: "Passcode Lock", label: "", sectionId: self.section, style: .blocks, action: { + arguments.openPasscode() + }) + case .twoStepVerification: + return ItemListDisclosureItem(title: "Two-Step Verification", label: "", sectionId: self.section, style: .blocks, action: { + arguments.openTwoStepVerification() + }) + case .activeSessions: + return ItemListDisclosureItem(title: "Active Sessions", label: "", sectionId: self.section, style: .blocks, action: { + arguments.openActiveSessions() + }) + case .accountHeader: + return ItemListSectionHeaderItem(text: "DELETE MY ACCOUNT", sectionId: self.section) + case let .accountTimeout(text): + return ItemListDisclosureItem(title: "If Away For", label: text, sectionId: self.section, style: .blocks, action: { + arguments.setupAccountAutoremove() + }) + case .accountInfo: + return ItemListTextItem(text: "If you do not log in at least once within this period, your account will be deleted along with all groups, messages and contacts.", sectionId: self.section) + } + } +} + +private struct PrivacyAndSecurityControllerState: Equatable { + init() { + } + + static func ==(lhs: PrivacyAndSecurityControllerState, rhs: PrivacyAndSecurityControllerState) -> Bool { + return true + } +} + +private func privacyAndSecurityControllerEntries(state: PrivacyAndSecurityControllerState, privacySettings: AccountPrivacySettings?) -> [PrivacyAndSecurityEntry] { + var entries: [PrivacyAndSecurityEntry] = [] + + entries.append(.privacyHeader) + entries.append(.blockedPeers) + entries.append(.lastSeenPrivacy("")) + entries.append(.groupPrivacy("")) + entries.append(.voiceCallPrivacy("")) + + entries.append(.securityHeader) + entries.append(.passcode) + entries.append(.twoStepVerification) + entries.append(.activeSessions) + entries.append(.accountHeader) + entries.append(.accountTimeout("")) + entries.append(.accountInfo) + + return entries +} + +public func privacyAndSecurityController(account: Account) -> ViewController { + let statePromise = ValuePromise(PrivacyAndSecurityControllerState(), ignoreRepeated: true) + let stateValue = Atomic(value: PrivacyAndSecurityControllerState()) + let updateState: ((PrivacyAndSecurityControllerState) -> PrivacyAndSecurityControllerState) -> Void = { f in + statePromise.set(stateValue.modify { f($0) }) + } + + var pushControllerImpl: ((ViewController) -> Void)? + + let actionsDisposable = DisposableSet() + + let checkAddressNameDisposable = MetaDisposable() + actionsDisposable.add(checkAddressNameDisposable) + + let updateAddressNameDisposable = MetaDisposable() + actionsDisposable.add(updateAddressNameDisposable) + + let arguments = PrivacyAndSecurityControllerArguments(account: account, openBlockedUsers: { + pushControllerImpl?(blockedPeersController(account: account)) + }, openLastSeenPrivacy: { + + }, openGroupsPrivacy: { + + }, openVoiceCallPrivacy: { + + }, openPasscode: { + + }, openTwoStepVerification: { + + }, openActiveSessions: { + pushControllerImpl?(recentSessionsController(account: account)) + }, setupAccountAutoremove: { + + }) + + let privacySettings: Signal = .single(nil) |> then(updatedAccountPrivacySettings(account: account) |> map { Optional($0) }) + |> deliverOnMainQueue + + let signal = combineLatest(statePromise.get() |> deliverOnMainQueue, privacySettings) + |> map { state, privacySettings -> (ItemListControllerState, (ItemListNodeState, PrivacyAndSecurityEntry.ItemGenerationArguments)) in + + var rightNavigationButton: ItemListNavigationButton? + if privacySettings == nil { + rightNavigationButton = ItemListNavigationButton(title: "", style: .activity, enabled: true, action: {}) + } + + let controllerState = ItemListControllerState(title: "Privacy and Security", leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, animateChanges: false) + let listState = ItemListNodeState(entries: privacyAndSecurityControllerEntries(state: state, privacySettings: privacySettings), style: .blocks, animateChanges: false) + + return (controllerState, (listState, arguments)) + } |> afterDisposed { + actionsDisposable.dispose() + } + + let controller = ItemListController(signal) + controller.navigationItem.backBarButtonItem = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil) + pushControllerImpl = { [weak controller] c in + (controller?.navigationController as? NavigationController)?.pushViewController(c) + } + + return controller +} diff --git a/TelegramUI/RecentSessionsController.swift b/TelegramUI/RecentSessionsController.swift new file mode 100644 index 0000000000..d350cc08c0 --- /dev/null +++ b/TelegramUI/RecentSessionsController.swift @@ -0,0 +1,342 @@ +import Foundation +import Display +import SwiftSignalKit +import Postbox +import TelegramCore + +private final class RecentSessionsControllerArguments { + let account: Account + + let setSessionIdWithRevealedOptions: (Int64?, Int64?) -> Void + let removeSession: (Int64) -> Void + + init(account: Account, setSessionIdWithRevealedOptions: @escaping (Int64?, Int64?) -> Void, removeSession: @escaping (Int64) -> Void) { + self.account = account + self.setSessionIdWithRevealedOptions = setSessionIdWithRevealedOptions + self.removeSession = removeSession + } +} + +private enum RecentSessionsSection: Int32 { + case currentSession + case otherSessions +} + +private enum RecentSessionsEntryStableId: Hashable { + case session(Int64) + case index(Int32) + + var hashValue: Int { + switch self { + case let .session(hash): + return hash.hashValue + case let .index(index): + return index.hashValue + } + } + + static func ==(lhs: RecentSessionsEntryStableId, rhs: RecentSessionsEntryStableId) -> Bool { + switch lhs { + case let .session(hash): + if case .session(hash) = rhs { + return true + } else { + return false + } + case let .index(index): + if case .index(index) = rhs { + return true + } else { + return false + } + } + } +} + +private enum RecentSessionsEntry: ItemListNodeEntry { + case currentSessionHeader + case currentSession(RecentAccountSession) + case terminateOtherSessions + case currentSessionInfo + + case otherSessionsHeader + case session(index: Int32, session: RecentAccountSession, enabled: Bool, editing: Bool, revealed: Bool) + + var section: ItemListSectionId { + switch self { + case .currentSessionHeader, .currentSession, .terminateOtherSessions, .currentSessionInfo: + return RecentSessionsSection.currentSession.rawValue + case .otherSessionsHeader, .session: + return RecentSessionsSection.otherSessions.rawValue + } + } + + var stableId: RecentSessionsEntryStableId { + switch self { + case .currentSessionHeader: + return .index(0) + case .currentSession: + return .index(1) + case .terminateOtherSessions: + return .index(2) + case .currentSessionInfo: + return .index(3) + case .otherSessionsHeader: + return .index(4) + case let .session(_, session, _, _, _): + return .session(session.hash) + } + } + + static func ==(lhs: RecentSessionsEntry, rhs: RecentSessionsEntry) -> Bool { + switch lhs { + case .currentSessionHeader, .terminateOtherSessions, .currentSessionInfo, .otherSessionsHeader: + return lhs.stableId == rhs.stableId + case let .currentSession(session): + if case .currentSession(session) = rhs { + return true + } else { + return false + } + case let .session(index, session, enabled, editing, revealed): + if case .session(index, session, enabled, editing, revealed) = rhs { + return true + } else { + return false + } + } + } + + static func <(lhs: RecentSessionsEntry, rhs: RecentSessionsEntry) -> Bool { + switch lhs.stableId { + case let .index(lhsIndex): + if case let .index(rhsIndex) = rhs.stableId { + return lhsIndex <= rhsIndex + } else { + return true + } + case .session: + switch lhs { + case let .session(lhsIndex, _, _, _, _): + if case let .session(rhsIndex, _, _, _, _) = rhs { + return lhsIndex <= rhsIndex + } else { + return false + } + default: + preconditionFailure() + } + } + } + + func item(_ arguments: RecentSessionsControllerArguments) -> ListViewItem { + switch self { + case .currentSessionHeader: + return ItemListSectionHeaderItem(text: "CURRENT SESSION", sectionId: self.section) + case let .currentSession(session): + return ItemListRecentSessionItem(session: session, enabled: true, editable: false, editing: false, revealed: false, sectionId: self.section, setSessionIdWithRevealedOptions: { _, _ in + }, removeSession: { _ in + }) + case .terminateOtherSessions: + return ItemListActionItem(title: "Terminate all other sessions", kind: .destructive, alignment: .natural, sectionId: self.section, style: .blocks, action: { + + }) + case .currentSessionInfo: + return ItemListTextItem(text: "Logs out all devices except for this one.", sectionId: self.section) + case .otherSessionsHeader: + return ItemListSectionHeaderItem(text: "ACTIVE SESSIONS", sectionId: self.section) + case let .session(_, session, enabled, editing, revealed): + return ItemListRecentSessionItem(session: session, enabled: enabled, editable: true, editing: editing, revealed: revealed, sectionId: self.section, setSessionIdWithRevealedOptions: { previousId, id in + arguments.setSessionIdWithRevealedOptions(previousId, id) + }, removeSession: { id in + arguments.removeSession(id) + }) + } + } +} + +private struct RecentSessionsControllerState: Equatable { + let editing: Bool + let sessionIdWithRevealedOptions: Int64? + let removingSessionId: Int64? + + init() { + self.editing = false + self.sessionIdWithRevealedOptions = nil + self.removingSessionId = nil + } + + init(editing: Bool, sessionIdWithRevealedOptions: Int64?, removingSessionId: Int64?) { + self.editing = editing + self.sessionIdWithRevealedOptions = sessionIdWithRevealedOptions + self.removingSessionId = removingSessionId + } + + static func ==(lhs: RecentSessionsControllerState, rhs: RecentSessionsControllerState) -> Bool { + if lhs.editing != rhs.editing { + return false + } + if lhs.sessionIdWithRevealedOptions != rhs.sessionIdWithRevealedOptions { + return false + } + if lhs.removingSessionId != rhs.removingSessionId { + return false + } + + return true + } + + func withUpdatedEditing(_ editing: Bool) -> RecentSessionsControllerState { + return RecentSessionsControllerState(editing: editing, sessionIdWithRevealedOptions: self.sessionIdWithRevealedOptions, removingSessionId: self.removingSessionId) + } + + func withUpdatedSessionIdWithRevealedOptions(_ sessionIdWithRevealedOptions: Int64?) -> RecentSessionsControllerState { + return RecentSessionsControllerState(editing: self.editing, sessionIdWithRevealedOptions: sessionIdWithRevealedOptions, removingSessionId: self.removingSessionId) + } + + func withUpdatedRemovingSessionId(_ removingSessionId: Int64?) -> RecentSessionsControllerState { + return RecentSessionsControllerState(editing: self.editing, sessionIdWithRevealedOptions: self.sessionIdWithRevealedOptions, removingSessionId: removingSessionId) + } +} + +private func recentSessionsControllerEntries(state: RecentSessionsControllerState, sessions: [RecentAccountSession]?) -> [RecentSessionsEntry] { + var entries: [RecentSessionsEntry] = [] + + if let sessions = sessions { + var existingSessionIds = Set() + entries.append(.currentSessionHeader) + if let index = sessions.index(where: { $0.hash == 0 }) { + existingSessionIds.insert(sessions[index].hash) + entries.append(.currentSession(sessions[index])) + } + entries.append(.terminateOtherSessions) + entries.append(.currentSessionInfo) + + if sessions.count > 1 { + entries.append(.otherSessionsHeader) + + let filteredSessions: [RecentAccountSession] = sessions.sorted(by: { lhs, rhs in + return lhs.activityDate > rhs.activityDate + }) + + for i in 0 ..< filteredSessions.count { + if !existingSessionIds.contains(sessions[i].hash) { + existingSessionIds.insert(sessions[i].hash) + entries.append(.session(index: Int32(i), session: sessions[i], enabled: state.removingSessionId != sessions[i].hash, editing: state.editing, revealed: state.sessionIdWithRevealedOptions == sessions[i].hash)) + } + } + } + } + + return entries +} + +public func recentSessionsController(account: Account) -> ViewController { + let statePromise = ValuePromise(RecentSessionsControllerState(), ignoreRepeated: true) + let stateValue = Atomic(value: RecentSessionsControllerState()) + let updateState: ((RecentSessionsControllerState) -> RecentSessionsControllerState) -> Void = { f in + statePromise.set(stateValue.modify { f($0) }) + } + + var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments?) -> Void)? + + let actionsDisposable = DisposableSet() + + let removeSessionDisposable = MetaDisposable() + actionsDisposable.add(removeSessionDisposable) + + let sessionsPromise = Promise<[RecentAccountSession]?>(nil) + + let arguments = RecentSessionsControllerArguments(account: account, setSessionIdWithRevealedOptions: { sessionId, fromSessionId in + updateState { state in + if (sessionId == nil && fromSessionId == state.sessionIdWithRevealedOptions) || (sessionId != nil && fromSessionId == nil) { + return state.withUpdatedSessionIdWithRevealedOptions(sessionId) + } else { + return state + } + } + }, removeSession: { sessionId in + updateState { + return $0.withUpdatedRemovingSessionId(sessionId) + } + + let applySessions: Signal = sessionsPromise.get() + |> filter { $0 != nil } + |> take(1) + |> deliverOnMainQueue + |> mapToSignal { sessions -> Signal in + if let sessions = sessions { + var updatedSessions = sessions + for i in 0 ..< updatedSessions.count { + if updatedSessions[i].hash == sessionId { + updatedSessions.remove(at: i) + break + } + } + sessionsPromise.set(.single(updatedSessions)) + } + + return .complete() + } + + removeSessionDisposable.set((terminateAccountSession(account: account, hash: sessionId) |> then(applySessions) |> deliverOnMainQueue).start(error: { _ in + updateState { + return $0.withUpdatedRemovingSessionId(nil) + } + }, completed: { + updateState { + return $0.withUpdatedRemovingSessionId(nil) + } + })) + }) + + let sessionsSignal: Signal<[RecentAccountSession]?, NoError> = .single(nil) |> then(requestRecentAccountSessions(account: account) |> map { Optional($0) }) + + sessionsPromise.set(sessionsSignal) + + var previousSessions: [RecentAccountSession]? + + let signal = combineLatest(statePromise.get(), sessionsPromise.get()) + |> deliverOnMainQueue + |> map { state, sessions -> (ItemListControllerState, (ItemListNodeState, RecentSessionsEntry.ItemGenerationArguments)) in + var rightNavigationButton: ItemListNavigationButton? + if let sessions = sessions, !sessions.isEmpty { + if state.editing { + rightNavigationButton = ItemListNavigationButton(title: "Done", style: .bold, enabled: true, action: { + updateState { state in + return state.withUpdatedEditing(false) + } + }) + } else { + rightNavigationButton = ItemListNavigationButton(title: "Edit", style: .regular, enabled: true, action: { + updateState { state in + return state.withUpdatedEditing(true) + } + }) + } + } + + var emptyStateItem: ItemListControllerEmptyStateItem? + if sessions == nil { + emptyStateItem = ItemListLoadingIndicatorEmptyStateItem() + } + + let previous = previousSessions + previousSessions = sessions + + let controllerState = ItemListControllerState(title: "Active Sessions", leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, animateChanges: true) + let listState = ItemListNodeState(entries: recentSessionsControllerEntries(state: state, sessions: sessions), style: .blocks, emptyStateItem: emptyStateItem, animateChanges: previous != nil && sessions != nil && previous!.count >= sessions!.count) + + return (controllerState, (listState, arguments)) + } |> afterDisposed { + actionsDisposable.dispose() + } + + let controller = ItemListController(signal) + presentControllerImpl = { [weak controller] c, p in + if let controller = controller { + controller.present(c, in: .window, with: p) + } + } + return controller +} diff --git a/TelegramUI/SettingsController.swift b/TelegramUI/SettingsController.swift index a3529ce64a..c7f38b076b 100644 --- a/TelegramUI/SettingsController.swift +++ b/TelegramUI/SettingsController.swift @@ -203,7 +203,7 @@ private enum SettingsEntry: ItemListNodeEntry { }) case .privacyAndSecurity: return ItemListDisclosureItem(title: "Privacy and Security", label: "", sectionId: ItemListSectionId(self.section), style: .blocks, action: { - + arguments.pushController(privacyAndSecurityController(account: arguments.account)) }) case .dataAndStorage: return ItemListDisclosureItem(title: "Data and Storage", label: "", sectionId: ItemListSectionId(self.section), style: .blocks, action: { @@ -392,6 +392,7 @@ public func settingsController(account: Account, accountManager: AccountManager) } let controller = ItemListController(signal) + controller.navigationItem.backBarButtonItem = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil) controller.tabBarItem.title = "Settings" controller.tabBarItem.image = UIImage(bundleImageName: "Chat List/Tabs/IconSettings")?.precomposed() controller.tabBarItem.selectedImage = UIImage(bundleImageName: "Chat List/Tabs/IconSettingsSelected")?.precomposed()