Skip to content

Commit ac870ab

Browse files
authored
feat: add more details to "Active Devices" info sheet (#172)
* Expand active session details and add location previews * Mark new session strings for localization inline * Use PresentationTheme verifyDialog, put application locale to date formatter and change “Last Signed At” to “Last Active”
1 parent 7b3a083 commit ac870ab

5 files changed

Lines changed: 256 additions & 21 deletions

File tree

‎Telegram-Mac/packages-integration/hooks/FeatureNavigationHooksInstall.swift‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,9 @@ func installFeatureNavigationHooks() {
279279
}
280280
}
281281
}
282+
hooks.makeLocationPreviewController = { context, address in
283+
return LocationModalPreview(context, address: address) |> map { $0 as ModalViewController? }
284+
}
282285
hooks.fullExportRowTitle = {
283286
return fullExportRowTitle()
284287
}

‎packages/InputDataUI/Sources/InputDataUI/FeatureNavigationHooks.swift‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ public struct FeatureNavigationHooks {
207207
public var emptyChatControllerType: () -> ViewController.Type? = { nil }
208208
public var openInstantView: (any AccountContextProtocol, String, TelegramMediaWebpage) -> Void = { _, _, _ in }
209209
public var requestUserLocation: () -> Signal<UserLocationRequestResult, NoError> = { .single(.disabled) }
210+
public var makeLocationPreviewController: (any AccountContextProtocol, String) -> Signal<ModalViewController?, NoError> = { _, _ in .single(nil) }
210211
public var fullExportRowTitle: () -> String = { "" }
211212
public var fullExportRowInfo: () -> String = { "" }
212213
public var showFullExportModal: (any AccountContextProtocol) -> Void = { _ in }

‎packages/LocationUI/Sources/LocationUI/Geocoding.swift‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,23 @@ import Foundation
22
import CoreLocation
33
import SwiftSignalKit
44

5+
public func geocodeLocation(address: String) -> Signal<(Double, Double)?, NoError> {
6+
return Signal { subscriber in
7+
let geocoder = CLGeocoder()
8+
geocoder.geocodeAddressString(address, completionHandler: { placemarks, _ in
9+
if let location = placemarks?.first?.location {
10+
subscriber.putNext((location.coordinate.latitude, location.coordinate.longitude))
11+
} else {
12+
subscriber.putNext(nil)
13+
}
14+
subscriber.putCompletion()
15+
})
16+
return ActionDisposable {
17+
geocoder.cancelGeocode()
18+
}
19+
}
20+
}
21+
522
public func geocodeLocation(dictionary: [String: String]) -> Signal<(Double, Double)?, NoError> {
623
return Signal { subscriber in
724
let geocoder = CLGeocoder()

‎packages/LocationUI/Sources/LocationUI/LocationPreviewController.swift‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,17 @@ private func entries(_ state:State, arguments: Arguments) -> [InputDataEntry] {
510510

511511
return entries
512512
}
513+
@available(macOS 10.13, *)
514+
public func LocationModalPreview(_ context: any AccountContextProtocol, address: String) -> Signal<InputDataModalController?, NoError> {
515+
return geocodeLocation(address: address) |> deliverOnMainQueue |> map { coordinates in
516+
guard let coordinates else {
517+
return nil
518+
}
519+
let venue = MediaArea.Venue(latitude: coordinates.0, longitude: coordinates.1, venue: MapVenue(title: address, address: nil, provider: nil, id: nil, type: nil), address: nil, queryId: nil, resultId: nil)
520+
return LocationModalPreview(context, venue: venue, peer: nil, presentation: theme)
521+
}
522+
}
523+
513524
@available(macOS 10.13, *)
514525
public func LocationModalPreview(_ context: (any AccountContextProtocol), venue: MediaArea.Venue, peer: Peer?, presentation: TelegramPresentationTheme) -> InputDataModalController {
515526

‎packages/SessionsUI/Sources/SessionsUI/SessionModalController.swift‎

Lines changed: 224 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import Cocoa
2-
import Foundation
32
import SwiftSignalKit
43
import TelegramCore
54
import TGUIKit
@@ -21,20 +20,165 @@ private final class Arguments {
2120
public let context: any AccountContextProtocol
2221
public let toggleChats:(Bool)->Void
2322
public let toggleIncomingCalls: (Bool) -> Void
23+
public let openLocation: (String) -> Void
2424
public let rename: (NSView) -> Void
25-
public init(context: any AccountContextProtocol, toggleChats:@escaping(Bool)->Void, toggleIncomingCalls: @escaping(Bool) -> Void, rename: @escaping(NSView) -> Void) {
25+
public init(context: any AccountContextProtocol, toggleChats:@escaping(Bool)->Void, toggleIncomingCalls: @escaping(Bool) -> Void, openLocation: @escaping(String) -> Void, rename: @escaping(NSView) -> Void) {
2626
self.context = context
2727
self.toggleChats = toggleChats
2828
self.toggleIncomingCalls = toggleIncomingCalls
29+
self.openLocation = openLocation
2930
self.rename = rename
3031
}
3132
}
3233

3334
private struct State : Equatable {
3435
public let session: RecentAccountSession
36+
public let isGeocoding: Bool
3537
public let deviceModel: String?
3638
}
3739

40+
private final class SessionDetailRowItem: GeneralInteractedRowItem {
41+
let valueLayout: TextViewLayout
42+
let accessoryImage: CGImage?
43+
let accessoryTooltip: String?
44+
let accessoryAction: (() -> Void)?
45+
let accessoryLoading: Bool
46+
47+
var accessoryInset: CGFloat {
48+
return accessoryImage.map { $0.systemSize.width + 10 } ?? 0
49+
}
50+
51+
var valueWidth: CGFloat {
52+
return max(1, blockWidth - nameLayout.layoutSize.width - viewType.innerInset.right - inset.left - 10 - accessoryInset)
53+
}
54+
55+
init(_ initialSize: NSSize, stableId: AnyHashable, name: String, value: String, viewType: GeneralViewType, accessoryImage: CGImage?, accessoryTooltip: String?, accessoryAction: (() -> Void)?, accessoryLoading: Bool) {
56+
self.accessoryImage = accessoryImage
57+
self.accessoryTooltip = accessoryTooltip
58+
self.accessoryAction = accessoryAction
59+
self.accessoryLoading = accessoryLoading
60+
valueLayout = TextViewLayout(.initialize(string: value, color: theme.colors.grayText, font: .normal(.title)), alignment: .right)
61+
let type: GeneralInteractedType
62+
if let accessoryImage, accessoryAction == nil {
63+
type = .imageContext(accessoryImage, value)
64+
} else {
65+
type = .context(value)
66+
}
67+
super.init(initialSize, stableId: stableId, name: name, type: type, viewType: viewType)
68+
}
69+
70+
override func makeSize(_ width: CGFloat, oldWidth: CGFloat) -> Bool {
71+
let result = super.makeSize(width, oldWidth: oldWidth)
72+
valueLayout.measure(width: valueWidth)
73+
return result
74+
}
75+
76+
override var height: CGFloat {
77+
return max(super.height, valueLayout.layoutSize.height + viewType.innerInset.top + viewType.innerInset.bottom)
78+
}
79+
80+
override func viewClass() -> AnyClass {
81+
return SessionDetailRowView.self
82+
}
83+
84+
override var accessibilityHelp: String? {
85+
return accessoryLoading ? strings().voiceOverCommonLoading : accessoryTooltip
86+
}
87+
88+
override var accessibilityRole: NSAccessibility.Role? {
89+
return accessoryAction == nil ? super.accessibilityRole : .group
90+
}
91+
92+
override var accessibilityIncludesChildren: Bool {
93+
return accessoryAction != nil
94+
}
95+
}
96+
97+
private final class SessionDetailRowView: GeneralInteractedRowView {
98+
private var accessoryControl: Control?
99+
private var accessoryProgress: ProgressIndicator?
100+
101+
override func set(item: TableRowItem, animated: Bool = false) {
102+
super.set(item: item, animated: animated)
103+
guard let item = item as? SessionDetailRowItem else {
104+
return
105+
}
106+
if let image = item.accessoryImage {
107+
let control: Control
108+
if let current = accessoryControl {
109+
control = current
110+
} else {
111+
control = item.accessoryAction == nil ? OverlayControl() : ImageButton()
112+
control.set(handler: { control in
113+
if control.controlState != .Hover {
114+
dismissTooltip(for: control)
115+
}
116+
}, for: .Other)
117+
if item.accessoryAction != nil {
118+
control.set(handler: { [weak self] _ in
119+
(self?.item as? SessionDetailRowItem)?.accessoryAction?()
120+
}, for: .Click)
121+
}
122+
containerView.addSubview(control)
123+
accessoryControl = control
124+
}
125+
(control as? ImageButton)?.set(image: image, for: .Normal)
126+
control.setFrameSize(NSMakeSize(image.systemSize.width + 8, image.systemSize.height + 8))
127+
control.appTooltip = item.accessoryLoading ? nil : item.accessoryTooltip
128+
control.setAccessibilityLabel(item.accessoryTooltip)
129+
control.isAccessibilityDecorative = item.accessoryAction == nil
130+
control.isEnabled = !item.accessoryLoading
131+
control.isHidden = item.accessoryLoading
132+
if item.accessoryLoading {
133+
dismissTooltip(for: control)
134+
}
135+
} else {
136+
if let accessoryControl {
137+
dismissTooltip(for: accessoryControl)
138+
}
139+
accessoryControl?.removeFromSuperview()
140+
accessoryControl = nil
141+
}
142+
if item.accessoryLoading {
143+
let progress: ProgressIndicator
144+
if let current = accessoryProgress {
145+
progress = current
146+
} else {
147+
progress = ProgressIndicator(frame: NSMakeRect(0, 0, 20, 20))
148+
progress.userInteractionEnabled = false
149+
progress.isAccessibilityDecorative = true
150+
containerView.addSubview(progress)
151+
accessoryProgress = progress
152+
}
153+
progress.progressColor = theme.colors.accent
154+
} else {
155+
accessoryProgress?.removeFromSuperview()
156+
accessoryProgress = nil
157+
}
158+
}
159+
160+
override func layout() {
161+
super.layout()
162+
guard let item = item as? SessionDetailRowItem, let textView = textView else {
163+
return
164+
}
165+
item.valueLayout.measure(width: item.valueWidth)
166+
textView.update(item.valueLayout)
167+
textView.centerY(x: containerView.frame.width - item.viewType.innerInset.right - textView.frame.width - item.accessoryInset)
168+
if let accessoryControl, let image = item.accessoryImage {
169+
accessoryControl.centerY(x: containerView.frame.width - item.viewType.innerInset.right - image.systemSize.width - 4, addition: -1)
170+
accessoryProgress?.centerY(x: accessoryControl.frame.midX - 10, addition: -1)
171+
}
172+
}
173+
}
174+
175+
private func sessionDetailEntry(sectionId: Int32, index: Int32, identifier: InputDataIdentifier, name: String, value: String, viewType: GeneralViewType, accessoryImage: CGImage? = nil, accessoryTooltip: String? = nil, accessoryAction: (() -> Void)? = nil, accessoryLoading: Bool = false) -> InputDataEntry {
176+
let type: GeneralInteractedType = accessoryLoading ? .loading : accessoryImage.map { .imageContext($0, value) } ?? .context(value)
177+
let data = InputDataGeneralData(name: name, color: theme.colors.text, type: type, viewType: viewType, description: accessoryTooltip)
178+
return .custom(sectionId: sectionId, index: index, value: .none, identifier: identifier, equatable: InputDataEquatable(data), comparable: nil, item: { initialSize, stableId in
179+
return SessionDetailRowItem(initialSize, stableId: stableId, name: name, value: value, viewType: viewType, accessoryImage: accessoryImage, accessoryTooltip: accessoryTooltip, accessoryAction: accessoryAction, accessoryLoading: accessoryLoading)
180+
})
181+
}
38182

39183
private func entries(_ state: State, arguments: Arguments) -> [InputDataEntry] {
40184
var entries:[InputDataEntry] = []
@@ -69,22 +213,57 @@ private func entries(_ state: State, arguments: Arguments) -> [InputDataEntry] {
69213

70214
entries.append(.sectionId(sectionId, type: .normal))
71215
sectionId += 1
72-
73-
74-
75-
entries.append(.general(sectionId: sectionId, index: index, value: .none, error: nil, identifier: .init("application"), data: InputDataGeneralData(name: strings().sessionPreviewApp, color: theme.colors.text, type: .context(state.session.appName + ", " + state.session.appVersion), viewType: state.session.ip.isEmpty ? .singleItem : .firstItem, enabled: true)))
216+
entries.append(.desc(sectionId: sectionId, index: index, text: .plain("Device"), data: .init(color: theme.colors.listGrayText, viewType: .textTopItem))) //TODOLANG
76217
index += 1
77-
78-
if !state.session.ip.isEmpty {
79-
entries.append(.general(sectionId: sectionId, index: index, value: .none, error: nil, identifier: .init("ip"), data: InputDataGeneralData(name: strings().sessionPreviewIp, color: theme.colors.text, type: .context(state.session.ip), viewType: .innerItem, enabled: true)))
218+
219+
let isOfficial = state.session.flags.contains(.isOfficial)
220+
entries.append(sessionDetailEntry(sectionId: sectionId, index: index, identifier: .init("application"), name: strings().sessionPreviewApp, value: state.session.appName, viewType: .firstItem, accessoryImage: isOfficial ? theme.icons.verifyDialog : nil, accessoryTooltip: isOfficial ? "This session is from the official Telegram app" : nil)) //TODOLANG
221+
index += 1
222+
223+
entries.append(.general(sectionId: sectionId, index: index, value: .none, error: nil, identifier: .init("version"), data: InputDataGeneralData(name: "Version", color: theme.colors.text, type: .context(state.session.appVersion), viewType: .innerItem, enabled: true))) //TODOLANG
224+
index += 1
225+
226+
let system = [state.session.platform, state.session.systemVersion].map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }.joined(separator: " ")
227+
entries.append(.general(sectionId: sectionId, index: index, value: .none, error: nil, identifier: .init("os"), data: InputDataGeneralData(name: "OS", color: theme.colors.text, type: .context(system), viewType: .lastItem, enabled: true))) //TODOLANG
228+
index += 1
229+
230+
entries.append(.sectionId(sectionId, type: .normal))
231+
sectionId += 1
232+
233+
entries.append(.desc(sectionId: sectionId, index: index, text: .plain("Connection"), data: .init(color: theme.colors.listGrayText, viewType: .textTopItem))) //TODOLANG
234+
index += 1
235+
236+
let ip = state.session.ip.trimmingCharacters(in: .whitespacesAndNewlines)
237+
let location = [state.session.country, state.session.region].map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }.joined(separator: " ")
238+
239+
if !ip.isEmpty {
240+
entries.append(sessionDetailEntry(sectionId: sectionId, index: index, identifier: .init("ip"), name: strings().sessionPreviewIp, value: ip, viewType: .firstItem))
80241
index += 1
81-
82-
entries.append(.general(sectionId: sectionId, index: index, value: .none, error: nil, identifier: .init("location"), data: InputDataGeneralData(name: strings().sessionPreviewLocation, color: theme.colors.text, type: .context(state.session.country), viewType: .lastItem, enabled: true)))
242+
}
243+
244+
if !location.isEmpty {
245+
entries.append(sessionDetailEntry(sectionId: sectionId, index: index, identifier: .init("location"), name: strings().sessionPreviewLocation, value: location, viewType: ip.isEmpty ? .firstItem : .innerItem, accessoryImage: theme.icons.chatAttachLocation, accessoryTooltip: strings().locationPreviewTitle, accessoryAction: {
246+
arguments.openLocation(location)
247+
}, accessoryLoading: state.isGeocoding))
83248
index += 1
84-
249+
}
250+
251+
let dateFormatter = DateFormatter()
252+
dateFormatter.locale = appAppearance.locale
253+
dateFormatter.timeZone = .current
254+
dateFormatter.dateStyle = .medium
255+
dateFormatter.timeStyle = .medium
256+
dateFormatter.doesRelativeDateFormatting = false
257+
258+
entries.append(sessionDetailEntry(sectionId: sectionId, index: index, identifier: .init("first_signed_at"), name: "First Signed At", value: dateFormatter.string(from: Date(timeIntervalSince1970: TimeInterval(state.session.creationDate))), viewType: ip.isEmpty && location.isEmpty ? .firstItem : .innerItem)) //TODOLANG
259+
index += 1
260+
261+
entries.append(sessionDetailEntry(sectionId: sectionId, index: index, identifier: .init("last_active"), name: "Last Active", value: dateFormatter.string(from: Date(timeIntervalSince1970: TimeInterval(state.session.activityDate))), viewType: .lastItem)) //TODOLANG
262+
index += 1
263+
264+
if !location.isEmpty {
85265
entries.append(.desc(sectionId: sectionId, index: index, text: .plain(strings().sessionPreviewIpDesc), data: .init(color: theme.colors.listGrayText, viewType: .textBottomItem)))
86266
index += 1
87-
88267
}
89268

90269

@@ -119,6 +298,9 @@ public func SessionModalController(context: any AccountContextProtocol, session:
119298

120299
var close:(()->Void)? = nil
121300
var isUpdating = false
301+
let locationDisposable = MetaDisposable()
302+
let geocodingPromise = ValuePromise(false, ignoreRepeated: true)
303+
var isGeocoding = false
122304
var deviceModelUpdates = 0
123305
let deviceModelDisposable = renamedDeviceModel.get().start(next: { _ in
124306
deviceModelUpdates += 1
@@ -153,15 +335,36 @@ public func SessionModalController(context: any AccountContextProtocol, session:
153335
updateSession(context.activeSessionsContext.updateSessionAcceptsSecretChats(session, accepts: updated))
154336
}, toggleIncomingCalls: { updated in
155337
updateSession(context.activeSessionsContext.updateSessionAcceptsIncomingCalls(session, accepts: updated))
338+
}, openLocation: { location in
339+
guard !isGeocoding else {
340+
return
341+
}
342+
isGeocoding = true
343+
geocodingPromise.set(true)
344+
let signal = FeatureNavigationHooks.shared.makeLocationPreviewController(context, location)
345+
|> deliverOnMainQueue
346+
|> afterDisposed {
347+
Queue.mainQueue().async {
348+
isGeocoding = false
349+
geocodingPromise.set(false)
350+
}
351+
}
352+
locationDisposable.set(signal.start(next: { controller in
353+
if let controller {
354+
showModal(with: controller, for: context.window)
355+
} else {
356+
alert(for: context.window, info: "Couldn't find this location. Please try again.") //TODOLANG
357+
}
358+
}))
156359
}, rename: { source in
157360
showModal(with: RenameDeviceController(context: context), for: context.window, morphSource: { [weak source] in
158361
return source
159362
})
160363
})
161364

162-
let signal = combineLatest(context.activeSessionsContext.state, renamedDeviceModel.get())
163-
|> map { state, deviceModel in
164-
return State(session: state.sessions.first(where: { $0.hash == session.hash }) ?? session, deviceModel: session.isCurrent ? deviceModel : nil)
365+
let signal = combineLatest(context.activeSessionsContext.state, geocodingPromise.get(), renamedDeviceModel.get())
366+
|> map { state, isGeocoding, deviceModel in
367+
return State(session: state.sessions.first(where: { $0.hash == session.hash }) ?? session, isGeocoding: isGeocoding, deviceModel: session.isCurrent ? deviceModel : nil)
165368
}
166369
|> distinctUntilChanged
167370
|> deliverOnPrepareQueue
@@ -170,6 +373,10 @@ public func SessionModalController(context: any AccountContextProtocol, session:
170373
}
171374

172375
let controller = InputDataController(dataSignal: signal, title: strings().sessionPreviewTitle)
376+
controller.onDeinit = {
377+
locationDisposable.dispose()
378+
deviceModelDisposable.dispose()
379+
}
173380

174381
let modalInteractions: ModalInteractions
175382
if session.isCurrent {
@@ -205,13 +412,9 @@ public func SessionModalController(context: any AccountContextProtocol, session:
205412
}
206413
}
207414

208-
controller.onDeinit = {
209-
deviceModelDisposable.dispose()
210-
}
211-
212415
let modalController = InputDataModalController(controller, modalInteractions: modalInteractions, closeHandler: { _ in
213416
close?()
214-
})
417+
}, size: NSMakeSize(440, 300))
215418

216419
controller.leftModalHeader = ModalHeaderData(image: theme.icons.modalClose, handler: { [weak modalController] in
217420
modalController?.close()

0 commit comments

Comments
 (0)