import Foundation import UIKit import Display import LegacyComponents import TelegramCore import SwiftSignalKit import MergeLists import ItemListUI import ItemListVenueItem import TelegramPresentationData import TelegramStringFormatting import TelegramUIPreferences import TelegramNotices import AccountContext import AppBundle import CoreLocation import Geocoding import DeviceAccess import TooltipUI func getLocation(from message: EngineMessage) -> TelegramMediaMap? { return message.media.first(where: { $0 is TelegramMediaMap } ) as? TelegramMediaMap } private func areMessagesEqual(_ lhsMessage: EngineMessage, _ rhsMessage: EngineMessage) -> Bool { if lhsMessage.stableVersion != rhsMessage.stableVersion { return false } if lhsMessage.id != rhsMessage.id || lhsMessage.flags != rhsMessage.flags { return false } return true } private struct LocationViewTransaction { let deletions: [ListViewDeleteItem] let insertions: [ListViewInsertItem] let updates: [ListViewUpdateItem] let gotTravelTimes: Bool let count: Int } private enum LocationViewEntryId: Hashable { case info case toggleLiveLocation(Bool) case liveLocation(UInt32) } private enum LocationViewEntry: Comparable, Identifiable { case info(PresentationTheme, TelegramMediaMap, String?, Double?, ExpectedTravelTime, ExpectedTravelTime, ExpectedTravelTime, Bool) case toggleLiveLocation(PresentationTheme, String, String, Double?, Double?, Bool, EngineMessage.Id?) case liveLocation(PresentationTheme, PresentationDateTimeFormat, PresentationPersonNameOrder, EngineMessage, Double?, ExpectedTravelTime, ExpectedTravelTime, ExpectedTravelTime, Int) var stableId: LocationViewEntryId { switch self { case .info: return .info case let .toggleLiveLocation(_, _, _, _, _, additional, _): return .toggleLiveLocation(additional) case let .liveLocation(_, _, _, message, _, _, _, _, _): return .liveLocation(message.stableId) } } static func ==(lhs: LocationViewEntry, rhs: LocationViewEntry) -> Bool { switch lhs { case let .info(lhsTheme, lhsLocation, lhsAddress, lhsDistance, lhsDrivingTime, lhsTransitTime, lhsWalkingTime, lhsHasEta): if case let .info(rhsTheme, rhsLocation, rhsAddress, rhsDistance, rhsDrivingTime, rhsTransitTime, rhsWalkingTime, rhsHasEta) = rhs, lhsTheme === rhsTheme, lhsLocation.venue?.id == rhsLocation.venue?.id, lhsAddress == rhsAddress, lhsDistance == rhsDistance, lhsDrivingTime == rhsDrivingTime, lhsTransitTime == rhsTransitTime, lhsWalkingTime == rhsWalkingTime, lhsHasEta == rhsHasEta { return true } else { return false } case let .toggleLiveLocation(lhsTheme, lhsTitle, lhsSubtitle, lhsBeginTimestamp, lhsTimeout, lhsAdditional, lhsMessageId): if case let .toggleLiveLocation(rhsTheme, rhsTitle, rhsSubtitle, rhsBeginTimestamp, rhsTimeout, rhsAdditional, rhsMessageId) = rhs, lhsTheme === rhsTheme, lhsTitle == rhsTitle, lhsSubtitle == rhsSubtitle, lhsBeginTimestamp == rhsBeginTimestamp, lhsTimeout == rhsTimeout, lhsAdditional == rhsAdditional, lhsMessageId == rhsMessageId { return true } else { return false } case let .liveLocation(lhsTheme, lhsDateTimeFormat, lhsNameDisplayOrder, lhsMessage, lhsDistance, lhsDrivingTime, lhsTransitTime, lhsWalkingTime, lhsIndex): if case let .liveLocation(rhsTheme, rhsDateTimeFormat, rhsNameDisplayOrder, rhsMessage, rhsDistance, rhsDrivingTime, rhsTransitTime, rhsWalkingTime, rhsIndex) = rhs, lhsTheme === rhsTheme, lhsDateTimeFormat == rhsDateTimeFormat, lhsNameDisplayOrder == rhsNameDisplayOrder, areMessagesEqual(lhsMessage, rhsMessage), lhsDistance == rhsDistance, lhsDrivingTime == rhsDrivingTime, lhsTransitTime == rhsTransitTime, lhsWalkingTime == rhsWalkingTime, lhsIndex == rhsIndex { return true } else { return false } } } static func <(lhs: LocationViewEntry, rhs: LocationViewEntry) -> Bool { switch lhs { case .info: switch rhs { case .info: return false case .toggleLiveLocation, .liveLocation: return true } case let .toggleLiveLocation(_, _, _, _, _, lhsAdditional, _): switch rhs { case .info: return false case let .toggleLiveLocation(_, _, _, _, _, rhsAdditional, _): return !lhsAdditional && rhsAdditional case .liveLocation: return true } case let .liveLocation(_, _, _, _, _, _, _, _, lhsIndex): switch rhs { case .info, .toggleLiveLocation: return false case let .liveLocation(_, _, _, _, _, _, _, _, rhsIndex): return lhsIndex < rhsIndex } } } func item(context: AccountContext, presentationData: PresentationData, interaction: LocationViewInteraction?) -> ListViewItem { switch self { case let .info(_, location, address, distance, drivingTime, transitTime, walkingTime, hasEta): let addressString: String? if let address = address { addressString = address } else { addressString = presentationData.strings.Map_Locating } let distanceString: String? if let distance = distance { distanceString = distance < 10 ? presentationData.strings.Map_YouAreHere : presentationData.strings.Map_DistanceAway(stringForDistance(strings: presentationData.strings, distance: distance)).string } else { distanceString = nil } return LocationInfoListItem(presentationData: ItemListPresentationData(presentationData), engine: context.engine, location: location, address: addressString, distance: distanceString, drivingTime: drivingTime, transitTime: transitTime, walkingTime: walkingTime, hasEta: hasEta, action: { interaction?.goToCoordinate(location.coordinate) }, drivingAction: { interaction?.requestDirections(location, nil, .driving) }, transitAction: { interaction?.requestDirections(location, nil, .transit) }, walkingAction: { interaction?.requestDirections(location, nil, .walking) }) case let .toggleLiveLocation(_, title, subtitle, beginTimstamp, timeout, additional, messageId): var beginTimeAndTimeout: (Double, Double)? if let beginTimstamp = beginTimstamp, let timeout = timeout { beginTimeAndTimeout = (beginTimstamp, timeout) } else { beginTimeAndTimeout = nil } let icon: LocationActionListItemIcon if let timeout, Int32(timeout) != liveLocationIndefinitePeriod, !additional { icon = .extendLiveLocation } else if beginTimeAndTimeout != nil { icon = .stopLiveLocation } else { icon = .liveLocation } return LocationActionListItem(presentationData: ItemListPresentationData(presentationData), engine: context.engine, title: title, subtitle: subtitle, icon: icon, beginTimeAndTimeout: !additional ? beginTimeAndTimeout : nil, action: { if beginTimeAndTimeout != nil { if let timeout, Int32(timeout) != liveLocationIndefinitePeriod { if additional { interaction?.stopLiveLocation() } else { interaction?.sendLiveLocation(nil, true, messageId) } } else { interaction?.stopLiveLocation() } } else { interaction?.sendLiveLocation(nil, false, nil) } }, highlighted: { highlight in interaction?.updateSendActionHighlight(highlight) }) case let .liveLocation(_, dateTimeFormat, nameDisplayOrder, message, distance, drivingTime, transitTime, walkingTime, _): var title: String? if let author = message.author { title = author.displayTitle(strings: presentationData.strings, displayOrder: nameDisplayOrder) } return LocationLiveListItem(presentationData: ItemListPresentationData(presentationData), dateTimeFormat: dateTimeFormat, nameDisplayOrder: nameDisplayOrder, context: context, message: message, distance: distance, drivingTime: drivingTime, transitTime: transitTime, walkingTime: walkingTime, action: { if let location = getLocation(from: message) { interaction?.goToCoordinate(location.coordinate) } }, longTapAction: {}, drivingAction: { if let location = getLocation(from: message) { interaction?.requestDirections(location, title, .driving) } }, transitAction: { if let location = getLocation(from: message) { interaction?.requestDirections(location, title, .transit) } }, walkingAction: { if let location = getLocation(from: message) { interaction?.requestDirections(location, title, .walking) } }) } } } private func preparedTransition(from fromEntries: [LocationViewEntry], to toEntries: [LocationViewEntry], context: AccountContext, presentationData: PresentationData, interaction: LocationViewInteraction?, gotTravelTimes: Bool) -> LocationViewTransaction { let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries) let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, interaction: interaction), directionHint: nil) } let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, interaction: interaction), directionHint: nil) } return LocationViewTransaction(deletions: deletions, insertions: insertions, updates: updates, gotTravelTimes: gotTravelTimes, count: toEntries.count) } enum LocationViewLocation: Equatable { case initial case user case coordinate(CLLocationCoordinate2D, Bool) case custom } struct LocationViewState { var mapMode: LocationMapMode var displayingMapModeOptions: Bool var selectedLocation: LocationViewLocation var trackingMode: LocationTrackingMode var updatingProximityRadius: Int32? var cancellingProximityRadius: Bool init() { self.mapMode = .map self.displayingMapModeOptions = false self.selectedLocation = .initial self.trackingMode = .none self.updatingProximityRadius = nil self.cancellingProximityRadius = false } } final class LocationViewControllerNode: ViewControllerTracingNode, CLLocationManagerDelegate { private let context: AccountContext private var presentationData: PresentationData private let presentationDataPromise: Promise private var subject: EngineMessage private let interaction: LocationViewInteraction private let locationManager: LocationManager private let isStoryLocation: Bool private let listNode: ListView let headerNode: LocationMapHeaderNode private let optionsNode: LocationOptionsNode private var enqueuedTransitions: [LocationViewTransaction] = [] private var disposable: Disposable? private var state: LocationViewState private let statePromise: Promise private var validLayout: (layout: ContainerViewLayout, navigationHeight: CGFloat)? private var listOffset: CGFloat? private var displayedProximityAlertTooltip = false var reportedAnnotationsReady = false var onAnnotationsReady: (() -> Void)? private let travelDisposables = DisposableSet() private var travelTimes: [EngineMessage.Id: (Double, ExpectedTravelTime, ExpectedTravelTime, ExpectedTravelTime)] = [:] { didSet { self.travelTimesPromise.set(.single(self.travelTimes)) } } private let travelTimesPromise = Promise<[EngineMessage.Id: (Double, ExpectedTravelTime, ExpectedTravelTime, ExpectedTravelTime)]>([:]) init(context: AccountContext, presentationData: PresentationData, subject: EngineMessage, interaction: LocationViewInteraction, locationManager: LocationManager, isStoryLocation: Bool) { self.context = context self.presentationData = presentationData self.presentationDataPromise = Promise(presentationData) self.subject = subject self.interaction = interaction self.locationManager = locationManager self.isStoryLocation = isStoryLocation self.state = LocationViewState() self.statePromise = Promise(self.state) self.listNode = ListView() self.listNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor self.listNode.verticalScrollIndicatorColor = UIColor(white: 0.0, alpha: 0.3) self.listNode.verticalScrollIndicatorFollowsOverscroll = true self.listNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string } var setupProximityNotificationImpl: ((Bool) -> Void)? self.headerNode = LocationMapHeaderNode(presentationData: presentationData, toggleMapModeSelection: interaction.toggleMapModeSelection, goToUserLocation: interaction.toggleTrackingMode, setupProximityNotification: { reset in setupProximityNotificationImpl?(reset) }) self.headerNode.mapNode.isRotateEnabled = false self.optionsNode = LocationOptionsNode(presentationData: presentationData, updateMapMode: interaction.updateMapMode) super.init() self.backgroundColor = self.presentationData.theme.list.plainBackgroundColor self.addSubnode(self.listNode) self.addSubnode(self.headerNode) self.addSubnode(self.optionsNode) let userLocation: Signal = .single(nil) |> then( throttledUserLocation(self.headerNode.mapNode.userLocation) ) var eta: Signal<(ExpectedTravelTime, ExpectedTravelTime, ExpectedTravelTime), NoError> = .single((.calculating, .calculating, .calculating)) var address: Signal = .single(nil) let locale = localeWithStrings(presentationData.strings) if let location = getLocation(from: subject), location.liveBroadcastingTimeout == nil { eta = .single((.calculating, .calculating, .calculating)) |> then(combineLatest(queue: Queue.mainQueue(), getExpectedTravelTime(coordinate: location.coordinate, transportType: .automobile), getExpectedTravelTime(coordinate: location.coordinate, transportType: .transit), getExpectedTravelTime(coordinate: location.coordinate, transportType: .walking)) |> mapToSignal { drivingTime, transitTime, walkingTime -> Signal<(ExpectedTravelTime, ExpectedTravelTime, ExpectedTravelTime), NoError> in if case .calculating = drivingTime { return .complete() } if case .calculating = transitTime { return .complete() } if case .calculating = walkingTime { return .complete() } return .single((drivingTime, transitTime, walkingTime)) }) if let venue = location.venue, let venueAddress = venue.address, !venueAddress.isEmpty { address = .single(venueAddress) } else { address = .single(nil) |> then( reverseGeocodeLocation(latitude: location.latitude, longitude: location.longitude, locale: locale) |> map { placemark -> String? in return placemark?.compactDisplayAddress ?? "" } ) } } let liveLocations = context.engine.messages.topPeerActiveLiveLocationMessages(peerId: subject.id.peerId) |> map { _, messages -> [EngineMessage] in return messages.map(EngineMessage.init) } setupProximityNotificationImpl = { reset in let _ = (liveLocations |> take(1) |> deliverOnMainQueue).start(next: { messages in var ownMessageId: EngineMessage.Id? for message in messages { if message.localTags.contains(.OutgoingLiveLocation) { ownMessageId = message.id break } } interaction.setupProximityNotification(reset, ownMessageId) let _ = ApplicationSpecificNotice.incrementLocationProximityAlertTip(accountManager: context.sharedContext.accountManager, count: 4).start() }) } let previousState = Atomic(value: nil) let previousUserAnnotation = Atomic(value: nil) let previousAnnotations = Atomic<[LocationPinAnnotation]>(value: []) let previousEntries = Atomic<[LocationViewEntry]?>(value: nil) let previousHadTravelTimes = Atomic(value: false) let selfPeer = context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)) self.disposable = (combineLatest(self.presentationDataPromise.get(), self.statePromise.get(), selfPeer, liveLocations, self.headerNode.mapNode.userLocation, userLocation, address, eta, self.travelTimesPromise.get()) |> deliverOnMainQueue).start(next: { [weak self] presentationData, state, selfPeer, liveLocations, userLocation, distance, address, eta, travelTimes in if let strongSelf = self, let location = getLocation(from: subject) { var entries: [LocationViewEntry] = [] var annotations: [LocationPinAnnotation] = [] var userAnnotation: LocationPinAnnotation? = nil var effectiveLiveLocations: [EngineMessage] = liveLocations let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) var proximityNotification: Bool? = nil var proximityNotificationRadius: Int32? var index: Int = 0 var isLocationView = false if location.liveBroadcastingTimeout == nil { isLocationView = true let subjectLocation = CLLocation(latitude: location.latitude, longitude: location.longitude) let distance = userLocation.flatMap { subjectLocation.distance(from: $0) } entries.append(.info(presentationData.theme, location, address, distance, eta.0, eta.1, eta.2, !isStoryLocation)) annotations.append(LocationPinAnnotation(context: context, theme: presentationData.theme, location: location, queryId: nil, resultId: nil, forcedSelection: true)) } else { var activeOwnLiveLocation: EngineMessage? for message in effectiveLiveLocations { if message.localTags.contains(.OutgoingLiveLocation) { activeOwnLiveLocation = message if let location = getLocation(from: message), let radius = location.liveProximityNotificationRadius { proximityNotificationRadius = radius proximityNotification = true } break } } let title: String let subtitle: String let beginTime: Double? let timeout: Double? if let message = activeOwnLiveLocation { var liveBroadcastingTimeout: Int32 = 0 if let location = getLocation(from: message), let timeout = location.liveBroadcastingTimeout { liveBroadcastingTimeout = timeout } title = presentationData.strings.Map_StopLiveLocation var updateTimestamp = message.timestamp for attribute in message.attributes { if let attribute = attribute as? EditedMessageAttribute { updateTimestamp = attribute.date break } } subtitle = stringForRelativeLiveLocationTimestamp(strings: presentationData.strings, relativeTimestamp: updateTimestamp, relativeTo: currentTime, dateTimeFormat: presentationData.dateTimeFormat) beginTime = Double(message.timestamp) timeout = Double(liveBroadcastingTimeout) } else { title = presentationData.strings.Map_ShareLiveLocation subtitle = presentationData.strings.Map_ShareLiveLocationHelp beginTime = nil timeout = nil } if case let .channel(channel) = subject.author, case .broadcast = channel.info, activeOwnLiveLocation == nil { } else { if let timeout, Int32(timeout) != liveLocationIndefinitePeriod { entries.append(.toggleLiveLocation(presentationData.theme, presentationData.strings.Map_SharingLocation, presentationData.strings.Map_TapToAddTime, beginTime, timeout, false, activeOwnLiveLocation?.id)) entries.append(.toggleLiveLocation(presentationData.theme, title, subtitle, beginTime, timeout, true, nil)) } else { entries.append(.toggleLiveLocation(presentationData.theme, title, subtitle, beginTime, timeout, false, nil)) } } var sortedLiveLocations: [EngineMessage] = [] var effectiveSubject: EngineMessage? for message in effectiveLiveLocations { if message.id == subject.id { effectiveSubject = message } else { sortedLiveLocations.append(message) } } if let effectiveSubject = effectiveSubject { sortedLiveLocations.insert(effectiveSubject, at: 0) } else { sortedLiveLocations.insert(subject, at: 0) } effectiveLiveLocations = sortedLiveLocations } for message in effectiveLiveLocations { if let location = getLocation(from: message) { if let channel = message.peers[message.id.peerId] as? TelegramChannel, case .broadcast = channel.info, message.threadId != nil { continue } var liveBroadcastingTimeout: Int32 = 0 if let timeout = location.liveBroadcastingTimeout { liveBroadcastingTimeout = timeout } let remainingTime: Int32 if liveBroadcastingTimeout == liveLocationIndefinitePeriod { remainingTime = liveLocationIndefinitePeriod } else { remainingTime = max(0, message.timestamp + liveBroadcastingTimeout - currentTime) } if message.flags.contains(.Incoming) && remainingTime != 0 && proximityNotification == nil { proximityNotification = false } let subjectLocation = CLLocation(latitude: location.latitude, longitude: location.longitude) let distance = userLocation.flatMap { subjectLocation.distance(from: $0) } let timestamp = CACurrentMediaTime() if message.localTags.contains(.OutgoingLiveLocation), let selfPeer = selfPeer { userAnnotation = LocationPinAnnotation(context: context, theme: presentationData.theme, message: message, selfPeer: selfPeer, isSelf: true, heading: location.heading) } else { var drivingTime: ExpectedTravelTime = .unknown var transitTime: ExpectedTravelTime = .unknown var walkingTime: ExpectedTravelTime = .unknown if !isLocationView && message.author?.id != context.account.peerId { let signal = combineLatest(queue: Queue.mainQueue(), getExpectedTravelTime(coordinate: location.coordinate, transportType: .automobile), getExpectedTravelTime(coordinate: location.coordinate, transportType: .transit), getExpectedTravelTime(coordinate: location.coordinate, transportType: .walking)) |> mapToSignal { drivingTime, transitTime, walkingTime -> Signal<(ExpectedTravelTime, ExpectedTravelTime, ExpectedTravelTime), NoError> in if case .calculating = drivingTime { return .complete() } if case .calculating = transitTime { return .complete() } if case .calculating = walkingTime { return .complete() } return .single((drivingTime, transitTime, walkingTime)) } if let (previousTimestamp, maybeDrivingTime, maybeTransitTime, maybeWalkingTime) = travelTimes[message.id] { drivingTime = maybeDrivingTime transitTime = maybeTransitTime walkingTime = maybeWalkingTime if timestamp > previousTimestamp + 60.0 { strongSelf.travelDisposables.add(signal.start(next: { [weak self] drivingTime, transitTime, walkingTime in guard let strongSelf = self else { return } let timestamp = CACurrentMediaTime() var travelTimes = strongSelf.travelTimes travelTimes[message.id] = (timestamp, drivingTime, transitTime, walkingTime) strongSelf.travelTimes = travelTimes })) } } else { drivingTime = .calculating transitTime = .calculating walkingTime = .calculating strongSelf.travelDisposables.add(signal.start(next: { [weak self] drivingTime, transitTime, walkingTime in guard let strongSelf = self else { return } let timestamp = CACurrentMediaTime() var travelTimes = strongSelf.travelTimes travelTimes[message.id] = (timestamp, drivingTime, transitTime, walkingTime) strongSelf.travelTimes = travelTimes })) } } annotations.append(LocationPinAnnotation(context: context, theme: presentationData.theme, message: message, selfPeer: selfPeer, isSelf: message.author?.id == context.account.peerId, heading: location.heading)) entries.append(.liveLocation(presentationData.theme, presentationData.dateTimeFormat, presentationData.nameDisplayOrder, message, distance, drivingTime, transitTime, walkingTime, index)) } index += 1 } } if let currentProximityNotification = proximityNotification, currentProximityNotification && state.cancellingProximityRadius { proximityNotification = false proximityNotificationRadius = nil } else if let radius = state.updatingProximityRadius { proximityNotification = true proximityNotificationRadius = radius } if subject.id.peerId.namespace != Namespaces.Peer.CloudUser, proximityNotification == nil { proximityNotification = false } if case let .channel(channel) = subject.author, case .broadcast = channel.info { proximityNotification = nil } let previousEntries = previousEntries.swap(entries) let previousState = previousState.swap(state) let previousHadTravelTimes = previousHadTravelTimes.swap(!travelTimes.isEmpty) let transition = preparedTransition(from: previousEntries ?? [], to: entries, context: context, presentationData: presentationData, interaction: strongSelf.interaction, gotTravelTimes: !travelTimes.isEmpty && !previousHadTravelTimes) strongSelf.enqueueTransition(transition) strongSelf.headerNode.updateState(mapMode: state.mapMode, trackingMode: state.trackingMode, displayingMapModeOptions: state.displayingMapModeOptions, displayingPlacesButton: false, proximityNotification: proximityNotification, animated: false) if let proximityNotification = proximityNotification, !proximityNotification && !strongSelf.displayedProximityAlertTooltip { strongSelf.displayedProximityAlertTooltip = true let _ = (ApplicationSpecificNotice.getLocationProximityAlertTip(accountManager: context.sharedContext.accountManager) |> deliverOnMainQueue).start(next: { [weak self] counter in if let strongSelf = self, counter < 3 { let _ = ApplicationSpecificNotice.incrementLocationProximityAlertTip(accountManager: context.sharedContext.accountManager).start() strongSelf.displayProximityAlertTooltip() } }) } switch state.selectedLocation { case .initial: if previousState?.selectedLocation != .initial { strongSelf.headerNode.mapNode.setMapCenter(coordinate: location.coordinate, span: viewMapSpan, animated: previousState != nil) } case let .coordinate(coordinate, defaultSpan): if let previousState = previousState, case let .coordinate(previousCoordinate, _) = previousState.selectedLocation, previousCoordinate == coordinate { } else { strongSelf.headerNode.mapNode.setMapCenter(coordinate: coordinate, span: defaultSpan ? defaultMapSpan : viewMapSpan, animated: true) } case .user: if previousState?.selectedLocation != .user, let userLocation = userLocation { strongSelf.headerNode.mapNode.setMapCenter(coordinate: userLocation.coordinate, isUserLocation: true, animated: true) } case .custom: break } strongSelf.headerNode.mapNode.trackingMode = state.trackingMode let previousAnnotations = previousAnnotations.swap(annotations) let previousUserAnnotation = previousUserAnnotation.swap(userAnnotation) if (userAnnotation == nil) != (previousUserAnnotation == nil) { strongSelf.headerNode.mapNode.userLocationAnnotation = userAnnotation } if annotations != previousAnnotations { strongSelf.headerNode.mapNode.annotations = annotations if !strongSelf.reportedAnnotationsReady { strongSelf.reportedAnnotationsReady = true if annotations.count > 0 { strongSelf.onAnnotationsReady?() } } } if let _ = proximityNotification { strongSelf.headerNode.mapNode.activeProximityRadius = proximityNotificationRadius.flatMap { Double($0) } } else { strongSelf.headerNode.mapNode.activeProximityRadius = nil } let rightBarButtonAction: LocationViewRightBarButton if location.liveBroadcastingTimeout != nil { if annotations.count > 0 { rightBarButtonAction = .showAll } else { rightBarButtonAction = .none } } else { rightBarButtonAction = .share } strongSelf.interaction.updateRightBarButton(rightBarButtonAction) if let (layout, navigationBarHeight) = strongSelf.validLayout { var updateLayout = false let transition: ContainedViewLayoutTransition = .animated(duration: 0.45, curve: .spring) if previousState?.displayingMapModeOptions != state.displayingMapModeOptions { updateLayout = true } if updateLayout { strongSelf.containerLayoutUpdated(layout, navigationHeight: navigationBarHeight, transition: transition) } } } }) self.listNode.updateFloatingHeaderOffset = { [weak self] offset, listTransition in guard let strongSelf = self, let (layout, navigationBarHeight) = strongSelf.validLayout, strongSelf.listNode.scrollEnabled else { return } let overlap: CGFloat = 6.0 strongSelf.listOffset = max(0.0, offset) let headerFrame = CGRect(origin: CGPoint(), size: CGSize(width: layout.size.width, height: max(0.0, offset + overlap))) listTransition.updateFrame(node: strongSelf.headerNode, frame: headerFrame) strongSelf.headerNode.updateLayout(layout: layout, navigationBarHeight: navigationBarHeight, topPadding: strongSelf.state.displayingMapModeOptions ? 38.0 : 0.0, offset: 0.0, size: headerFrame.size, transition: listTransition) } self.listNode.beganInteractiveDragging = { [weak self] _ in guard let strongSelf = self else { return } strongSelf.updateState { state in var state = state state.displayingMapModeOptions = false return state } } self.headerNode.mapNode.beganInteractiveDragging = { [weak self] in guard let strongSelf = self else { return } strongSelf.updateState { state in var state = state state.displayingMapModeOptions = false state.selectedLocation = .custom state.trackingMode = .none return state } } self.headerNode.mapNode.annotationSelected = { [weak self] annotation in guard let strongSelf = self else { return } if let annotation = annotation { strongSelf.interaction.goToCoordinate(annotation.coordinate) } } self.headerNode.mapNode.userLocationAnnotationSelected = { [weak self] in if let strongSelf = self, let location = strongSelf.headerNode.mapNode.currentUserLocation { strongSelf.interaction.goToCoordinate(location.coordinate) } } self.locationManager.manager.startUpdatingHeading() self.locationManager.manager.delegate = self } deinit { self.disposable?.dispose() self.travelDisposables.dispose() self.locationManager.manager.stopUpdatingHeading() } func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) { if newHeading.headingAccuracy < 0.0 { self.headerNode.mapNode.userHeading = nil } if newHeading.trueHeading > 0.0 { self.headerNode.mapNode.userHeading = CGFloat(newHeading.trueHeading) } else { self.headerNode.mapNode.userHeading = CGFloat(newHeading.magneticHeading) } } func updatePresentationData(_ presentationData: PresentationData) { self.presentationData = presentationData self.presentationDataPromise.set(.single(presentationData)) self.backgroundColor = self.presentationData.theme.list.plainBackgroundColor self.listNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor self.headerNode.updatePresentationData(self.presentationData) self.optionsNode.updatePresentationData(self.presentationData) } func updateState(_ f: (LocationViewState) -> LocationViewState) { self.state = f(self.state) self.statePromise.set(.single(self.state)) } func updateSendActionHighlight(_ highlighted: Bool) { self.headerNode.updateHighlight(highlighted) } private func enqueueTransition(_ transition: LocationViewTransaction) { self.enqueuedTransitions.append(transition) if let _ = self.validLayout { while !self.enqueuedTransitions.isEmpty { self.dequeueTransition() } } } var initialized = false private func dequeueTransition() { guard let _ = self.validLayout, let transition = self.enqueuedTransitions.first else { return } self.enqueuedTransitions.remove(at: 0) let scrollToItem: ListViewScrollToItem? if (!self.initialized && transition.insertions.count > 0) || transition.gotTravelTimes { var index: Int = 0 var offset: CGFloat = 0.0 if transition.gotTravelTimes { if transition.count > 1 { index = 1 } else { index = 0 } offset = 0.0 } else if transition.insertions.count > 2 { index = 2 offset = 40.0 } else if transition.insertions.count == 2 { index = 1 } scrollToItem = ListViewScrollToItem(index: index, position: .bottom(offset), animated: transition.gotTravelTimes, curve: .Default(duration: 0.3), directionHint: .Up) self.initialized = true } else { scrollToItem = nil } let options = ListViewDeleteAndInsertOptions() self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, scrollToItem: scrollToItem, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { _ in }) } func scrollToTop() { self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) } func setProximityIndicator(radius: Int32?) { guard let (layout, navigationBarHeight) = self.validLayout else { return } if let radius = radius { self.headerNode.forceIsHidden = true if let coordinate = self.headerNode.mapNode.currentUserLocation?.coordinate { self.updateState { state in var state = state state.selectedLocation = .custom state.trackingMode = .none return state } var contentOffset: CGFloat = 0.0 if case let .known(offset) = self.listNode.visibleContentOffset() { contentOffset = offset } let panelHeight: CGFloat = 349.0 + layout.intrinsicInsets.bottom let inset = (layout.size.width - 260.0) / 2.0 let offset = panelHeight / 2.0 + 60.0 + inset + navigationBarHeight / 2.0 let point = CGPoint(x: layout.size.width / 2.0, y: navigationBarHeight + (layout.size.height - navigationBarHeight - panelHeight) / 2.0) let convertedPoint = self.view.convert(point, to: self.headerNode.mapNode.view) self.headerNode.mapNode.setMapCenter(coordinate: coordinate, radius: Double(radius), insets: UIEdgeInsets(top: navigationBarHeight, left: inset, bottom: offset - contentOffset, right: inset), offset: convertedPoint.y - self.headerNode.mapNode.frame.height / 2.0, animated: true) } self.headerNode.mapNode.proximityIndicatorRadius = Double(radius) } else { self.headerNode.forceIsHidden = false self.headerNode.mapNode.proximityIndicatorRadius = nil self.updateState { state in var state = state state.selectedLocation = .user state.trackingMode = .none return state } } } func showAll() { self.headerNode.mapNode.showAll() } private func displayProximityAlertTooltip() { guard let location = self.headerNode.proximityButtonFrame().flatMap({ frame -> CGRect in return self.headerNode.view.convert(frame, to: nil) }) else { return } let _ = (self.context.account.postbox.loadedPeerWithId(self.subject.id.peerId) |> deliverOnMainQueue).start(next: { [weak self] peer in guard let strongSelf = self else { return } var text: String = strongSelf.presentationData.strings.Location_ProximityGroupTip if peer.id.namespace == Namespaces.Peer.CloudUser { text = strongSelf.presentationData.strings.Location_ProximityTip(EnginePeer(peer).compactDisplayTitle).string } strongSelf.interaction.present(TooltipScreen(account: strongSelf.context.account, sharedContext: strongSelf.context.sharedContext, text: .plain(text: text), icon: nil, location: .point(location.offsetBy(dx: -9.0, dy: 0.0), .right), displayDuration: .custom(3.0), shouldDismissOnTouch: { _, _ in return .dismiss(consume: false) })) }) } func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationHeight: CGFloat, transition: ContainedViewLayoutTransition) { let isFirstLayout = self.validLayout == nil self.validLayout = (layout, navigationHeight) let optionsHeight: CGFloat = 38.0 var actionHeight: CGFloat? self.listNode.forEachItemNode { itemNode in if let itemNode = itemNode as? LocationActionListItemNode { if actionHeight == nil { actionHeight = itemNode.frame.height } } } let overlap: CGFloat = 6.0 var topInset: CGFloat = layout.size.height - layout.intrinsicInsets.bottom - overlap if !self.isStoryLocation { topInset -= 100.0 } if let location = getLocation(from: self.subject), location.liveBroadcastingTimeout != nil { topInset += 66.0 } let headerHeight: CGFloat if let listOffset = self.listOffset { headerHeight = max(0.0, listOffset + overlap) } else { headerHeight = topInset + overlap } let headerFrame = CGRect(origin: CGPoint(), size: CGSize(width: layout.size.width, height: headerHeight)) transition.updateFrame(node: self.headerNode, frame: headerFrame) self.headerNode.updateLayout(layout: layout, navigationBarHeight: navigationHeight, topPadding: self.state.displayingMapModeOptions ? optionsHeight : 0.0, offset: 0.0, size: headerFrame.size, transition: transition) let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition) let insets = UIEdgeInsets(top: topInset, left: layout.safeInsets.left, bottom: layout.intrinsicInsets.bottom, right: layout.safeInsets.right) self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: layout.size, insets: insets, headerInsets: UIEdgeInsets(top: navigationHeight, left: 0.0, bottom: layout.intrinsicInsets.bottom, right: 0.0), scrollIndicatorInsets: UIEdgeInsets(top: topInset + 3.0, left: 0.0, bottom: layout.intrinsicInsets.bottom, right: 0.0), duration: duration, curve: curve), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) let listFrame: CGRect = CGRect(origin: CGPoint(), size: layout.size) transition.updateFrame(node: self.listNode, frame: listFrame) if isFirstLayout { while !self.enqueuedTransitions.isEmpty { self.dequeueTransition() } } let optionsOffset: CGFloat = self.state.displayingMapModeOptions ? navigationHeight : navigationHeight - optionsHeight let optionsFrame = CGRect(x: 0.0, y: optionsOffset, width: layout.size.width, height: optionsHeight) transition.updateFrame(node: self.optionsNode, frame: optionsFrame) self.optionsNode.updateLayout(size: optionsFrame.size, leftInset: layout.safeInsets.left, rightInset: layout.safeInsets.right, transition: transition) self.optionsNode.isUserInteractionEnabled = self.state.displayingMapModeOptions } var coordinate: Signal { return self.headerNode.mapNode.userLocation |> filter { location in return location != nil } |> take(1) |> map { location -> CLLocationCoordinate2D in return location!.coordinate } } }