diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index e73dc82d35..0000000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "configurations": [ - { - "name": "Debug Telegram", - "type": "lldb-dap", - "request": "attach", - "preLaunchTask": "_launch_telegram", - "debuggerRoot": "${workspaceFolder}", - "attachCommands": [ - "command script import '${workspaceFolder}/scripts/lldb_attach.py'" - ], - "terminateCommands": [ - "command script import '${workspaceFolder}/scripts/lldb_kill_app.py'" - ], - "internalConsoleOptions": "openOnSessionStart", - "timeout": 9999 - } - ] -} diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index 6a356b8eb9..0000000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,110 +0,0 @@ -{ - "version": "2.0.0", - "tasks": [ - { - "label": "Select Simulator for Apple Development", - "type": "shell", - "command": "./scripts/select_simulator.sh", - "presentation": { - "reveal": "always", - "focus": true, - "panel": "dedicated" - }, - "problemMatcher": [] - }, - { - "label": "Build Telegram", - "type": "shell", - "command": "./scripts/lldb_build.sh", - "options": { - "env": { - "BAZEL_LABEL_TO_RUN": "//Telegram:Telegram", - "BAZEL_EXTRA_BUILD_FLAGS": "" - } - }, - "group": { - "kind": "build" - }, - "problemMatcher": [ - { - "owner": "bazel", - "source": "bazel", - "fileLocation": [ - "relative", - "${workspaceFolder}" - ], - "pattern": { - "regexp": "^(.+?):(\\d+):(\\d+):\\s+(error|warning|note):\\s+(.*)$", - "file": 1, - "line": 2, - "column": 3, - "severity": 4, - "message": 5 - } - } - ] - }, - { - "label": "_launch_telegram", - "type": "shell", - "command": "./scripts/lldb_launch_and_debug.sh", - "options": { - "env": { - "BAZEL_LABEL_TO_RUN": "//Telegram:Telegram", - "BAZEL_EXTRA_BUILD_FLAGS": "", - "BAZEL_LAUNCH_ARGS": "" - } - }, - "presentation": { - "reveal": "always" - }, - "hide": true, - "isBackground": true, - "problemMatcher": [ - { - "owner": "bazel", - "source": "bazel", - "fileLocation": [ - "relative", - "${workspaceFolder}" - ], - "pattern": { - "regexp": "launcher_error in (.*): (.*)", - "kind": "file", - "file": 1, - "message": 2 - }, - "background": { - "activeOnStart": true, - "beginsPattern": "^Starting launch task\\.\\.\\.$", - "endsPattern": "^ph.telegra.Telegraph: .*" - } - }, - { - "owner": "bazel", - "source": "bazel", - "fileLocation": [ - "relative", - "${workspaceFolder}" - ], - "pattern": { - "regexp": "^(.+?):(\\d+):(\\d+):\\s+(error|warning|note):\\s+(.*)$", - "file": 1, - "line": 2, - "column": 3, - "severity": 4, - "message": 5 - }, - "background": { - "activeOnStart": true, - "beginsPattern": "^Starting launch task\\.\\.\\.$", - "endsPattern": "^ph.telegra.Telegraph: .*" - } - } - ], - "runOptions": { - "instanceLimit": 1 - } - } - ] -} diff --git a/build-system/bazel-rules/sourcekit-bazel-bsp b/build-system/bazel-rules/sourcekit-bazel-bsp index e2e532ab9d..feea27cfc8 160000 --- a/build-system/bazel-rules/sourcekit-bazel-bsp +++ b/build-system/bazel-rules/sourcekit-bazel-bsp @@ -1 +1 @@ -Subproject commit e2e532ab9d9296343ce77c93286dd7d472947157 +Subproject commit feea27cfc88eccc58af0cfe5674444e945cfb75f diff --git a/submodules/ArchivedStickerPacksNotice/Sources/ArchivedStickerPacksNoticeController.swift b/submodules/ArchivedStickerPacksNotice/Sources/ArchivedStickerPacksNoticeController.swift index 8825973840..d56c1bcec0 100644 --- a/submodules/ArchivedStickerPacksNotice/Sources/ArchivedStickerPacksNoticeController.swift +++ b/submodules/ArchivedStickerPacksNotice/Sources/ArchivedStickerPacksNoticeController.swift @@ -86,7 +86,7 @@ private final class ArchivedStickersNoticeAlertContentNode: AlertContentNode { self.textNode = ASTextNode() self.textNode.maximumNumberOfLines = 4 - self.listView = ListView() + self.listView = ListViewImpl() self.listView.isOpaque = false self.actionNodesSeparator = ASDisplayNode() diff --git a/submodules/CallListUI/Sources/CallListControllerNode.swift b/submodules/CallListUI/Sources/CallListControllerNode.swift index 6c4805c495..b4b90095fa 100644 --- a/submodules/CallListUI/Sources/CallListControllerNode.swift +++ b/submodules/CallListUI/Sources/CallListControllerNode.swift @@ -256,7 +256,7 @@ final class CallListControllerNode: ASDisplayNode { self.statePromise = ValuePromise(self.currentState, ignoreRepeated: true) self.focusOnItemTag = focusOnItemTag - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.verticalScrollIndicatorColor = self.presentationData.theme.list.scrollIndicatorColor self.listNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string diff --git a/submodules/ChatListSearchRecentPeersNode/Sources/ChatListSearchRecentPeersNode.swift b/submodules/ChatListSearchRecentPeersNode/Sources/ChatListSearchRecentPeersNode.swift index d977ba902a..f11b5a043f 100644 --- a/submodules/ChatListSearchRecentPeersNode/Sources/ChatListSearchRecentPeersNode.swift +++ b/submodules/ChatListSearchRecentPeersNode/Sources/ChatListSearchRecentPeersNode.swift @@ -225,7 +225,7 @@ public final class ChatListSearchRecentPeersNode: ASDisplayNode { self.peerContextAction = peerContextAction self.isPeerSelected = isPeerSelected - self.listView = ListView() + self.listView = ListViewImpl() self.listView.preloadPages = false self.listView.transform = CATransform3DMakeRotation(-CGFloat.pi / 2.0, 0.0, 0.0, 1.0) self.listView.accessibilityPageScrolledString = { row, count in diff --git a/submodules/ChatListUI/Sources/ChatListControllerNode.swift b/submodules/ChatListUI/Sources/ChatListControllerNode.swift index ceee49ac25..a9bdacca0a 100644 --- a/submodules/ChatListUI/Sources/ChatListControllerNode.swift +++ b/submodules/ChatListUI/Sources/ChatListControllerNode.swift @@ -1173,7 +1173,7 @@ final class ChatListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { var cancelEditing: (() -> Void)? var dismissSearch: (() -> Void)? - let debugListView = ListView() + let debugListView = ListViewImpl() init(context: AccountContext, location: ChatListControllerLocation, previewing: Bool, controlsHistoryPreload: Bool, presentationData: PresentationData, animationCache: AnimationCache, animationRenderer: MultiAnimationRenderer, controller: ChatListControllerImpl) { self.context = context diff --git a/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift b/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift index 988eb0aa81..7031f6ae98 100644 --- a/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift +++ b/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift @@ -1788,7 +1788,7 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { self.selectedMessages = interaction.getSelectedMessageIds() self.selectedMessagesPromise.set(.single(self.selectedMessages)) - self.recentListNode = ListView() + self.recentListNode = ListViewImpl() self.recentListNode.preloadPages = false self.recentListNode.verticalScrollIndicatorColor = self.presentationData.theme.list.scrollIndicatorColor self.recentListNode.accessibilityPageScrolledString = { row, count in @@ -1799,7 +1799,7 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { self.shimmerNode.isUserInteractionEnabled = false self.shimmerNode.allowsGroupOpacity = true - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode?.verticalScrollIndicatorColor = self.presentationData.theme.list.scrollIndicatorColor self.listNode?.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string diff --git a/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift b/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift index 345d51078e..5ef43faa95 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift @@ -106,6 +106,16 @@ public func chatListItemStrings(strings: PresentationStrings, nameDisplayOrder: if !message.text.isEmpty { messageText = message.text messageEntities = message._asMessage().textEntitiesAttribute?.entities ?? [] + for entity in messageEntities { + if case let .CustomEmoji(_, fileId) = entity.type { + if customEmojiRanges == nil { + customEmojiRanges = [] + } + let range = NSRange(location: entity.range.lowerBound, length: entity.range.upperBound - entity.range.lowerBound) + let attribute = ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: fileId, file: message.associatedMedia[EngineMedia.Id(namespace: Namespaces.Media.CloudFile, id: fileId)] as? TelegramMediaFile) + customEmojiRanges?.append((range, attribute)) + } + } break } } diff --git a/submodules/ChatListUI/Sources/Node/ChatListNode.swift b/submodules/ChatListUI/Sources/Node/ChatListNode.swift index 9e3e71739d..63e0e3937f 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListNode.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListNode.swift @@ -1121,7 +1121,7 @@ public enum ChatListNodeEmptyState: Equatable { case empty(isLoading: Bool, hasArchiveInfo: Bool) } -public final class ChatListNode: ListView { +public final class ChatListNode: ListViewImpl { public enum OpenStoriesSubject { case peer(EnginePeer.Id) case archive diff --git a/submodules/ContactListUI/Sources/ContactListNode.swift b/submodules/ContactListUI/Sources/ContactListNode.swift index beb6050497..3c6ce36fbb 100644 --- a/submodules/ContactListUI/Sources/ContactListNode.swift +++ b/submodules/ContactListUI/Sources/ContactListNode.swift @@ -1233,7 +1233,7 @@ public final class ContactListNode: ASDisplayNode { let presentationData = updatedPresentationData?.initial ?? context.sharedContext.currentPresentationData.with { $0 } self.presentationData = presentationData - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string } diff --git a/submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift b/submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift index c033bca502..01e8689d63 100644 --- a/submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift +++ b/submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift @@ -299,7 +299,7 @@ public final class ContactsSearchContainerNode: SearchDisplayControllerContentNo self.backgroundNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor self.backgroundNode.alpha = 0.0 - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor self.listNode.alpha = 0.0 diff --git a/submodules/ContactListUI/Sources/InviteContactsControllerNode.swift b/submodules/ContactListUI/Sources/InviteContactsControllerNode.swift index c75a190a30..7e5a8943f1 100644 --- a/submodules/ContactListUI/Sources/InviteContactsControllerNode.swift +++ b/submodules/ContactListUI/Sources/InviteContactsControllerNode.swift @@ -290,7 +290,7 @@ final class InviteContactsControllerNode: ASDisplayNode { self.presentationDataPromise = Promise(self.presentationData) - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string } diff --git a/submodules/Display/Source/ListView.swift b/submodules/Display/Source/ListView.swift index 40dadd5d0f..0a78640967 100644 --- a/submodules/Display/Source/ListView.swift +++ b/submodules/Display/Source/ListView.swift @@ -38,7 +38,7 @@ private final class ListViewBackingLayer: CALayer { } public final class ListViewBackingView: UIView { - public fileprivate(set) weak var target: ListView? + public fileprivate(set) weak var target: ListViewImpl? override public class var layerClass: AnyClass { return ListViewBackingLayer.self @@ -154,7 +154,7 @@ private func cancelContextGestures(view: UIView) { } } -open class ListView: ASDisplayNode, ASScrollViewDelegate, ASGestureRecognizerDelegate { +open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestureRecognizerDelegate { public struct ScrollingIndicatorState { public struct Item { public var index: Int @@ -470,8 +470,8 @@ open class ListView: ASDisplayNode, ASScrollViewDelegate, ASGestureRecognizerDel override public init() { class DisplayLinkProxy: NSObject { - weak var target: ListView? - init(target: ListView) { + weak var target: ListViewImpl? + init(target: ListViewImpl) { self.target = target } diff --git a/submodules/Display/Source/ListViewProtocol.swift b/submodules/Display/Source/ListViewProtocol.swift new file mode 100644 index 0000000000..d66509ef95 --- /dev/null +++ b/submodules/Display/Source/ListViewProtocol.swift @@ -0,0 +1,124 @@ +import UIKit +import AsyncDisplayKit +import SwiftSignalKit + +public protocol ListView: ASDisplayNode { + // MARK: - Configuration Properties (get/set) + var scroller: ListViewScroller { get } + var scrollEnabled: Bool { get set } + var preloadPages: Bool { get set } + var rotated: Bool { get set } + var experimentalSnapScrollToItem: Bool { get set } + var useMainQueueTransactions: Bool { get set } + var keepMinimalScrollHeightWithTopInset: CGFloat? { get set } + var itemNodeHitTest: ((CGPoint) -> Bool)? { get set } + var stackFromBottom: Bool { get set } + var limitHitTestToNodes: Bool { get set } + var keepTopItemOverscrollBackground: ListViewKeepTopItemOverscrollBackground? { get set } + var allowInsetFixWhileTracking: Bool { get set } + var visualInsets: UIEdgeInsets? { get set } + var dynamicVisualInsets: (() -> UIEdgeInsets)? { get set } + var verticalScrollIndicatorColor: UIColor? { get set } + var verticalScrollIndicatorFollowsOverscroll: Bool { get set } + var defaultToSynchronousTransactionWhileScrolling: Bool { get set } + var enableExtractedBackgrounds: Bool { get set } + var autoScrollWhenReordering: Bool { get set } + var reorderedItemHasShadow: Bool { get set } + var reorderingRequiresLongPress: Bool { get set } + var tempTopInset: CGFloat { get set } + var accessibilityPageScrolledString: ((String, String) -> String)? { get set } + + // MARK: - Read-only State Properties + var insets: UIEdgeInsets { get } + var isTracking: Bool { get } + var trackingOffset: CGFloat { get } + var beganTrackingAtTopOrigin: Bool { get } + var isDragging: Bool { get } + var edgeEffectExtension: CGFloat { get } + var displayedItemRange: ListViewDisplayedItemRange { get } + var opaqueTransactionState: Any? { get } + + // MARK: - Callbacks + var displayedItemRangeChanged: (ListViewDisplayedItemRange, Any?) -> Void { get set } + var visibleContentOffsetChanged: (ListViewVisibleContentOffset, ContainedViewLayoutTransition) -> Void { get set } + var visibleBottomContentOffsetChanged: (ListViewVisibleContentOffset) -> Void { get set } + var beganInteractiveDragging: (CGPoint) -> Void { get set } + var endedInteractiveDragging: (CGPoint) -> Void { get set } + var didEndScrolling: ((Bool) -> Void)? { get set } + var didEndScrollingWithOverscroll: (() -> Void)? { get set } + var updateFloatingHeaderOffset: ((CGFloat, ContainedViewLayoutTransition) -> Void)? { get set } + var didScrollWithOffset: ((CGFloat, ContainedViewLayoutTransition, ListViewItemNode?, Bool) -> Void)? { get set } + var addContentOffset: ((CGFloat, ListViewItemNode?) -> Void)? { get set } + var shouldStopScrolling: ((CGFloat) -> Bool)? { get set } + var onContentsUpdated: ((ContainedViewLayoutTransition) -> Void)? { get set } + var onEdgeEffectExtensionUpdated: ((ContainedViewLayoutTransition) -> Void)? { get set } + var tapped: (() -> Void)? { get set } + var reorderItem: (Int, Int, Any?) -> Signal { get set } + var reorderCompleted: (Any?) -> Void { get set } + + // MARK: - Methods + func transaction( + deleteIndices: [ListViewDeleteItem], + insertIndicesAndItems: [ListViewInsertItem], + updateIndicesAndItems: [ListViewUpdateItem], + options: ListViewDeleteAndInsertOptions, + scrollToItem: ListViewScrollToItem?, + additionalScrollDistance: CGFloat, + updateSizeAndInsets: ListViewUpdateSizeAndInsets?, + stationaryItemRange: (Int, Int)?, + updateOpaqueState: Any?, + completion: @escaping (ListViewDisplayedItemRange) -> Void + ) + + func addAfterTransactionsCompleted(_ f: @escaping () -> Void) + func visibleContentOffset() -> ListViewVisibleContentOffset + func visibleBottomContentOffset() -> ListViewVisibleContentOffset + func stopScrolling() + @discardableResult func scrollToOffsetFromTop(_ offset: CGFloat, animated: Bool) -> Bool + func transferVelocity(_ velocity: CGFloat) + func resetScrolledToItem() + + func forEachItemNode(_ f: (ASDisplayNode) -> Void) + func forEachVisibleItemNode(_ f: (ASDisplayNode) -> Void) + func enumerateItemNodes(_ f: (ASDisplayNode) -> Bool) + func forEachItemHeaderNode(_ f: (ListViewItemHeaderNode) -> Void) + func forEachAccessoryItemNode(_ f: (ListViewAccessoryItemNode) -> Void) + + func ensureItemNodeVisible(_ node: ListViewItemNode, animated: Bool, overflow: CGFloat, allowIntersection: Bool, atTop: Bool, curve: ListViewAnimationCurve) + + func clearHighlightAnimated(_ animated: Bool) +} + +// MARK: - Default Parameter Values +// Swift protocols cannot have default parameter values, so we provide them via extensions. +public extension ListView { + func transaction( + deleteIndices: [ListViewDeleteItem], + insertIndicesAndItems: [ListViewInsertItem], + updateIndicesAndItems: [ListViewUpdateItem], + options: ListViewDeleteAndInsertOptions, + scrollToItem: ListViewScrollToItem? = nil, + additionalScrollDistance: CGFloat = 0.0, + updateSizeAndInsets: ListViewUpdateSizeAndInsets? = nil, + stationaryItemRange: (Int, Int)? = nil, + updateOpaqueState: Any?, + completion: @escaping (ListViewDisplayedItemRange) -> Void = { _ in } + ) { + self.transaction( + deleteIndices: deleteIndices, + insertIndicesAndItems: insertIndicesAndItems, + updateIndicesAndItems: updateIndicesAndItems, + options: options, + scrollToItem: scrollToItem, + additionalScrollDistance: additionalScrollDistance, + updateSizeAndInsets: updateSizeAndInsets, + stationaryItemRange: stationaryItemRange, + updateOpaqueState: updateOpaqueState, + completion: completion + ) + } + + func ensureItemNodeVisible(_ node: ListViewItemNode, animated: Bool = true, overflow: CGFloat = 0.0, allowIntersection: Bool = false, atTop: Bool = false, curve: ListViewAnimationCurve = .Default(duration: 0.25)) { + self.ensureItemNodeVisible(node, animated: animated, overflow: overflow, allowIntersection: allowIntersection, atTop: atTop, curve: curve) + } +} diff --git a/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift b/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift index 3b880ea6ac..be576383cd 100644 --- a/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift +++ b/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift @@ -1215,7 +1215,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { playbackControlsIsSeekable = seekable playbackControlsIsPlaying = !paused } - if !self.areControlsVisible || hideControls { + if !self.controlsVisibility() || hideControls { playbackControlsIsVisible = false } diff --git a/submodules/HashtagSearchUI/Sources/HashtagSearchRecentListNode.swift b/submodules/HashtagSearchUI/Sources/HashtagSearchRecentListNode.swift index d2d29fda92..4e395aaf5a 100644 --- a/submodules/HashtagSearchUI/Sources/HashtagSearchRecentListNode.swift +++ b/submodules/HashtagSearchUI/Sources/HashtagSearchRecentListNode.swift @@ -385,7 +385,7 @@ final class HashtagSearchRecentListNode: ASDisplayNode { let presentationData = context.sharedContext.currentPresentationData.with { $0 } self.presentationData = presentationData - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string } diff --git a/submodules/InviteLinksUI/Sources/InviteLinkInviteController.swift b/submodules/InviteLinksUI/Sources/InviteLinkInviteController.swift index e3f95ed8cf..f1776d198d 100644 --- a/submodules/InviteLinksUI/Sources/InviteLinkInviteController.swift +++ b/submodules/InviteLinksUI/Sources/InviteLinkInviteController.swift @@ -362,7 +362,7 @@ public final class InviteLinkInviteController: ViewController { self.historyBackgroundNode.addSubnode(self.historyBackgroundContentNode) - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.verticalScrollIndicatorColor = UIColor(white: 0.0, alpha: 0.3) self.listNode.verticalScrollIndicatorFollowsOverscroll = true self.listNode.accessibilityPageScrolledString = { row, count in diff --git a/submodules/InviteLinksUI/Sources/InviteLinkViewController.swift b/submodules/InviteLinksUI/Sources/InviteLinkViewController.swift index f226e9fd49..41a6025c11 100644 --- a/submodules/InviteLinksUI/Sources/InviteLinkViewController.swift +++ b/submodules/InviteLinksUI/Sources/InviteLinkViewController.swift @@ -567,7 +567,7 @@ public final class InviteLinkViewController: ViewController { self.historyBackgroundNode.addSubnode(self.historyBackgroundContentNode) - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.verticalScrollIndicatorColor = UIColor(white: 0.0, alpha: 0.3) self.listNode.verticalScrollIndicatorFollowsOverscroll = true self.listNode.accessibilityPageScrolledString = { row, count in diff --git a/submodules/InviteLinksUI/Sources/InviteRequestsSearchItem.swift b/submodules/InviteLinksUI/Sources/InviteRequestsSearchItem.swift index 2a2b6f247e..a75cdc14d3 100644 --- a/submodules/InviteLinksUI/Sources/InviteRequestsSearchItem.swift +++ b/submodules/InviteLinksUI/Sources/InviteRequestsSearchItem.swift @@ -364,7 +364,7 @@ public final class InviteRequestsSearchContainerNode: SearchDisplayControllerCon self.dimNode = ASDisplayNode() self.dimNode.backgroundColor = UIColor.black.withAlphaComponent(0.5) - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string } diff --git a/submodules/ItemListUI/Sources/ItemListControllerNode.swift b/submodules/ItemListUI/Sources/ItemListControllerNode.swift index 52e9e7633d..76901d6706 100644 --- a/submodules/ItemListUI/Sources/ItemListControllerNode.swift +++ b/submodules/ItemListUI/Sources/ItemListControllerNode.swift @@ -310,7 +310,7 @@ open class ItemListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { self.navigationBar = navigationBar self.listNodeContainer = ASDisplayNode() - self.listNode = ListView() + self.listNode = ListViewImpl() self.leftOverlayNode = ASDisplayNode() self.leftOverlayNode.isUserInteractionEnabled = false self.rightOverlayNode = ASDisplayNode() diff --git a/submodules/LocationUI/Sources/LocationPickerControllerNode.swift b/submodules/LocationUI/Sources/LocationPickerControllerNode.swift index 1cab3b1644..5717a31311 100644 --- a/submodules/LocationUI/Sources/LocationPickerControllerNode.swift +++ b/submodules/LocationUI/Sources/LocationPickerControllerNode.swift @@ -399,7 +399,7 @@ final class LocationPickerControllerNode: ViewControllerTracingNode, CLLocationM self.state = LocationPickerState() self.statePromise = Promise(self.state) - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor self.listNode.verticalScrollIndicatorColor = UIColor(white: 0.0, alpha: 0.3) self.listNode.verticalScrollIndicatorFollowsOverscroll = true diff --git a/submodules/LocationUI/Sources/LocationSearchContainerNode.swift b/submodules/LocationUI/Sources/LocationSearchContainerNode.swift index aef7f092ae..eb47572aec 100644 --- a/submodules/LocationUI/Sources/LocationSearchContainerNode.swift +++ b/submodules/LocationUI/Sources/LocationSearchContainerNode.swift @@ -138,7 +138,7 @@ final class LocationSearchContainerNode: ASDisplayNode { self.dimNode = ASDisplayNode() self.dimNode.backgroundColor = .clear // UIColor.black.withAlphaComponent(0.5) - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor self.listNode.alpha = 0.0 self.listNode.accessibilityPageScrolledString = { row, count in diff --git a/submodules/LocationUI/Sources/LocationViewControllerNode.swift b/submodules/LocationUI/Sources/LocationViewControllerNode.swift index 7b1baca60b..5eef4f69b7 100644 --- a/submodules/LocationUI/Sources/LocationViewControllerNode.swift +++ b/submodules/LocationUI/Sources/LocationViewControllerNode.swift @@ -309,7 +309,7 @@ final class LocationViewControllerNode: ViewControllerTracingNode, CLLocationMan self.state = LocationViewState() self.statePromise = Promise(self.state) - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor self.listNode.verticalScrollIndicatorColor = UIColor(white: 0.0, alpha: 0.3) self.listNode.verticalScrollIndicatorFollowsOverscroll = true diff --git a/submodules/MediaPickerUI/Sources/MediaGroupsAlbumGridItem.swift b/submodules/MediaPickerUI/Sources/MediaGroupsAlbumGridItem.swift index dfeba38478..792d1109f0 100644 --- a/submodules/MediaPickerUI/Sources/MediaGroupsAlbumGridItem.swift +++ b/submodules/MediaPickerUI/Sources/MediaGroupsAlbumGridItem.swift @@ -280,7 +280,7 @@ private class MediaGroupsAlbumGridItemNode: ListViewItemNode { private var enqueuedTransitions: [MediaGroupsAlbumGridItemNodeTransition] = [] init() { - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.transform = CATransform3DMakeRotation(-CGFloat.pi / 2.0, 0.0, 0.0, 1.0) super.init(layerBacked: false, rotated: false, seeThrough: false) diff --git a/submodules/MediaPickerUI/Sources/MediaGroupsScreen.swift b/submodules/MediaPickerUI/Sources/MediaGroupsScreen.swift index 105c57fbef..ebb74d6763 100644 --- a/submodules/MediaPickerUI/Sources/MediaGroupsScreen.swift +++ b/submodules/MediaPickerUI/Sources/MediaGroupsScreen.swift @@ -208,7 +208,7 @@ public final class MediaGroupsScreen: ViewController, AttachmentContainable { self.containerNode = ASDisplayNode() self.backgroundNode = NavigationBackgroundNode(color: self.presentationData.theme.rootController.tabBar.backgroundColor) - self.listNode = ListView() + self.listNode = ListViewImpl() super.init() diff --git a/submodules/PeerInfoUI/Sources/ChannelDiscussionGroupSearchContainerNode.swift b/submodules/PeerInfoUI/Sources/ChannelDiscussionGroupSearchContainerNode.swift index c661eab9f0..151237435e 100644 --- a/submodules/PeerInfoUI/Sources/ChannelDiscussionGroupSearchContainerNode.swift +++ b/submodules/PeerInfoUI/Sources/ChannelDiscussionGroupSearchContainerNode.swift @@ -128,7 +128,7 @@ final class ChannelDiscussionGroupSearchContainerNode: SearchDisplayControllerCo self.presentationDataPromise = Promise(self.presentationData) self.dimNode = ASDisplayNode() - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string } diff --git a/submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift b/submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift index c8ca63b995..13fd8cad95 100644 --- a/submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift +++ b/submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift @@ -367,12 +367,12 @@ public final class ChannelMembersSearchContainerNode: SearchDisplayControllerCon } self.presentationDataPromise = Promise(self.presentationData) - self.emptyQueryListNode = ListView() + self.emptyQueryListNode = ListViewImpl() self.emptyQueryListNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string } - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string } diff --git a/submodules/PeerInfoUI/Sources/ChannelMembersSearchControllerNode.swift b/submodules/PeerInfoUI/Sources/ChannelMembersSearchControllerNode.swift index 18b0ea2db7..2925bbd07f 100644 --- a/submodules/PeerInfoUI/Sources/ChannelMembersSearchControllerNode.swift +++ b/submodules/PeerInfoUI/Sources/ChannelMembersSearchControllerNode.swift @@ -207,7 +207,7 @@ class ChannelMembersSearchControllerNode: ASDisplayNode { init(context: AccountContext, presentationData: PresentationData, forceTheme: PresentationTheme?, peerId: EnginePeer.Id, mode: ChannelMembersSearchControllerMode, filters: [ChannelMembersSearchFilter]) { self.context = context - self.listNode = ListView() + self.listNode = ListViewImpl() self.peerId = peerId self.mode = mode self.filters = filters diff --git a/submodules/Postbox/Sources/MessageHistoryView.swift b/submodules/Postbox/Sources/MessageHistoryView.swift index 3f62bc203e..68d06d679a 100644 --- a/submodules/Postbox/Sources/MessageHistoryView.swift +++ b/submodules/Postbox/Sources/MessageHistoryView.swift @@ -1295,6 +1295,10 @@ public final class MessageHistoryView: PostboxView { } init(_ mutableView: MutableMessageHistoryView) { + if case .external = mutableView.peerIds, mutableView.tag == nil, case .not = mutableView.namespaces { + assert(true) + } + self.tag = mutableView.tag self.namespaces = mutableView.namespaces self.isAddedToChatList = mutableView.isAddedToChatList diff --git a/submodules/SettingsUI/Sources/DeleteAccountPeersItem.swift b/submodules/SettingsUI/Sources/DeleteAccountPeersItem.swift index 1ce362fcd1..7ccbca5f40 100644 --- a/submodules/SettingsUI/Sources/DeleteAccountPeersItem.swift +++ b/submodules/SettingsUI/Sources/DeleteAccountPeersItem.swift @@ -166,7 +166,7 @@ class DeleteAccountPeersItemNode: ListViewItemNode, ItemListItemNode { self.maskNode = ASImageNode() self.maskNode.isUserInteractionEnabled = false - self.listView = ListView() + self.listView = ListViewImpl() self.listView.transform = CATransform3DMakeRotation(-CGFloat.pi / 2.0, 0.0, 0.0, 1.0) super.init(layerBacked: false) diff --git a/submodules/SettingsUI/Sources/Language Selection/LocalizationListControllerNode.swift b/submodules/SettingsUI/Sources/Language Selection/LocalizationListControllerNode.swift index 6fbc28259b..d92950f226 100644 --- a/submodules/SettingsUI/Sources/Language Selection/LocalizationListControllerNode.swift +++ b/submodules/SettingsUI/Sources/Language Selection/LocalizationListControllerNode.swift @@ -167,7 +167,7 @@ private final class LocalizationListSearchContainerNode: SearchDisplayController self.dimNode = ASDisplayNode() self.dimNode.backgroundColor = .clear - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string } @@ -379,7 +379,7 @@ final class LocalizationListControllerNode: ViewControllerTracingNode { self.push = push self.focusOnItemTag = focusOnItemTag - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.keepTopItemOverscrollBackground = ListViewKeepTopItemOverscrollBackground(color: presentationData.theme.list.blocksBackgroundColor, direction: true) self.listNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string diff --git a/submodules/SettingsUI/Sources/Notifications/Exceptions/NotificationExceptionControllerNode.swift b/submodules/SettingsUI/Sources/Notifications/Exceptions/NotificationExceptionControllerNode.swift index 32ca8fcbdd..4caf2e68c7 100644 --- a/submodules/SettingsUI/Sources/Notifications/Exceptions/NotificationExceptionControllerNode.swift +++ b/submodules/SettingsUI/Sources/Notifications/Exceptions/NotificationExceptionControllerNode.swift @@ -491,7 +491,7 @@ final class NotificationExceptionsControllerNode: ViewControllerTracingNode { self.present = present self.pushController = pushController self.stateValue = Atomic(value: NotificationExceptionState(mode: mode)) - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.keepTopItemOverscrollBackground = ListViewKeepTopItemOverscrollBackground(color: presentationData.theme.chatList.backgroundColor, direction: true) self.listNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string @@ -985,7 +985,7 @@ private final class NotificationExceptionsSearchContainerNode: SearchDisplayCont self.dimNode = ASDisplayNode() self.dimNode.backgroundColor = .clear - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string } diff --git a/submodules/SettingsUI/Sources/Search/SettingsSearchItem.swift b/submodules/SettingsUI/Sources/Search/SettingsSearchItem.swift index ce0598ba31..440f524fd2 100644 --- a/submodules/SettingsUI/Sources/Search/SettingsSearchItem.swift +++ b/submodules/SettingsUI/Sources/Search/SettingsSearchItem.swift @@ -285,14 +285,14 @@ public final class SettingsSearchContainerNode: SearchDisplayControllerContentNo self.presentationData = presentationData self.presentationDataPromise = Promise(self.presentationData) - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.backgroundColor = self.presentationData.theme.chatList.backgroundColor self.listNode.isHidden = true self.listNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string } - self.recentListNode = ListView() + self.recentListNode = ListViewImpl() self.recentListNode.backgroundColor = self.presentationData.theme.chatList.backgroundColor self.recentListNode.verticalScrollIndicatorColor = self.presentationData.theme.list.scrollIndicatorColor self.recentListNode.accessibilityPageScrolledString = { row, count in diff --git a/submodules/SettingsUI/Sources/Themes/ThemeSettingsAccentColorItem.swift b/submodules/SettingsUI/Sources/Themes/ThemeSettingsAccentColorItem.swift index 7d673f3531..fad4ad8e12 100644 --- a/submodules/SettingsUI/Sources/Themes/ThemeSettingsAccentColorItem.swift +++ b/submodules/SettingsUI/Sources/Themes/ThemeSettingsAccentColorItem.swift @@ -731,7 +731,7 @@ class ThemeSettingsAccentColorItemNode: ListViewItemNode, ItemListItemNode { self.maskNode = ASImageNode() - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.transform = CATransform3DMakeRotation(-CGFloat.pi / 2.0, 0.0, 0.0, 1.0) super.init(layerBacked: false) diff --git a/submodules/SparseItemGrid/Sources/SparseDiscreteScrollingArea.swift b/submodules/SparseItemGrid/Sources/SparseDiscreteScrollingArea.swift index 772edacaff..311fdf4d1d 100644 --- a/submodules/SparseItemGrid/Sources/SparseDiscreteScrollingArea.swift +++ b/submodules/SparseItemGrid/Sources/SparseDiscreteScrollingArea.swift @@ -125,7 +125,7 @@ public final class SparseDiscreteScrollingArea: ASDisplayNode { private struct State { var containerSize: CGSize var containerInsets: UIEdgeInsets - var scrollingState: ListView.ScrollingIndicatorState? + var scrollingState: ListViewImpl.ScrollingIndicatorState? var isScrolling: Bool var isDragging: Bool var theme: PresentationTheme @@ -288,7 +288,7 @@ public final class SparseDiscreteScrollingArea: ASDisplayNode { public func update( containerSize: CGSize, containerInsets: UIEdgeInsets, - scrollingState: ListView.ScrollingIndicatorState?, + scrollingState: ListViewImpl.ScrollingIndicatorState?, isScrolling: Bool, theme: PresentationTheme, transition: ContainedViewLayoutTransition diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift index 58eafb74e6..c5ee4182e2 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift @@ -1732,8 +1732,8 @@ public extension TelegramEngine { viewId: metric.id, timeInViewMs: Int32(metric.timeInViewMs), activeTimeInViewMs: Int32(metric.activeTimeInViewMs), - heightToViewportRatioPermille: Int32(metric.heightToViewportRatio * 1000.0), - seenRangeRatioPermille: Int32(metric.seenRangeRatio * 1000.0) + heightToViewportRatioPermille: Int32((metric.heightToViewportRatio * 1000.0).rounded()), + seenRangeRatioPermille: Int32((metric.seenRangeRatio * 1000.0).rounded()) )) } )) @@ -1743,6 +1743,21 @@ public extension TelegramEngine { |> ignoreValues } } + + public func reportMusicListened(file: FileMediaReference, duration: Int) -> Signal { + guard let resource = file.media.resource as? CloudDocumentMediaResource, let reference = file.resourceReference(file.media.resource) as? TelegramCloudMediaResourceWithFileReference else { + return .complete() + } + return self.account.network.request(Api.functions.messages.reportMusicListen(id: .inputDocument(Api.InputDocument.Cons_inputDocument( + id: resource.fileId, + accessHash: resource.accessHash, + fileReference: Buffer(data: reference.fileReference ?? Data()) + )), listenedDuration: Int32(clamping: duration))) + |> `catch` { _ -> Signal in + return .single(.boolFalse) + } + |> ignoreValues + } } } diff --git a/submodules/TelegramCore/Sources/Utils/MessageReadMetricsTracker.swift b/submodules/TelegramCore/Sources/Utils/MessageReadMetricsTracker.swift new file mode 100644 index 0000000000..422e73feb1 --- /dev/null +++ b/submodules/TelegramCore/Sources/Utils/MessageReadMetricsTracker.swift @@ -0,0 +1,449 @@ +import Foundation +import SwiftSignalKit +import Postbox + +public final class MessageReadMetricsTracker { + public struct VisibleMessageEntry { + public let messageId: EngineMessage.Id + public let visibleTopPx: CGFloat + public let visibleBottomPx: CGFloat + public let postHeightPx: CGFloat + public let viewportHeightPx: CGFloat + + public init(messageId: EngineMessage.Id, visibleTopPx: CGFloat, visibleBottomPx: CGFloat, postHeightPx: CGFloat, viewportHeightPx: CGFloat) { + self.messageId = messageId + self.visibleTopPx = visibleTopPx + self.visibleBottomPx = visibleBottomPx + self.postHeightPx = postHeightPx + self.viewportHeightPx = viewportHeightPx + } + } + + private enum PhaseState { + case graceStart + case tracking + case graceEnd + case paused + } + + private static let kGracePeriod: Double = 0.3 + private static let kMinViewTimeMs: Int = 300 + private static let kUserActivityTimeout: Double = 15.0 + private static let kMaxPhaseDuration: Double = 300.0 + private static let kHeartbeatInterval: Double = 0.1 + + private struct TrackingPhase { + let viewId: Int64 + let messageId: EngineMessage.Id + var state: PhaseState + + var totalViewTimeMs: Int = 0 + var activeViewTimeMs: Int = 0 + var seenTopPx: CGFloat = CGFloat.greatestFiniteMagnitude + var seenBottomPx: CGFloat = 0.0 + var postHeightPx: CGFloat = 0.0 + var viewportHeightPx: CGFloat = 0.0 + + var lastTickTime: CFAbsoluteTime? + let phaseStartTime: CFAbsoluteTime + var graceTimer: SwiftSignalKit.Timer? + var graceDeadline: CFAbsoluteTime? + + var stateBeforePause: PhaseState? + var graceRemainingAtPause: Double? + + init(messageId: EngineMessage.Id) { + self.viewId = Int64.random(in: Int64.min ... Int64.max) + self.messageId = messageId + self.state = .graceStart + let now = CFAbsoluteTimeGetCurrent() + self.phaseStartTime = now + self.lastTickTime = now + } + + mutating func updateSeenRange(visibleTopPx: CGFloat, visibleBottomPx: CGFloat, postHeightPx: CGFloat, viewportHeightPx: CGFloat) { + self.seenTopPx = min(self.seenTopPx, visibleTopPx) + self.seenBottomPx = max(self.seenBottomPx, visibleBottomPx) + self.postHeightPx = postHeightPx + self.viewportHeightPx = viewportHeightPx + } + + mutating func flushTime(now: CFAbsoluteTime, lastActivityTime: CFAbsoluteTime) { + if let lastTick = self.lastTickTime { + let deltaMs = Int((now - lastTick) * 1000.0) + self.totalViewTimeMs += deltaMs + if (now - lastActivityTime) < kUserActivityTimeout { + self.activeViewTimeMs += deltaMs + } + } + self.lastTickTime = now + } + + mutating func stopTicking(now: CFAbsoluteTime, lastActivityTime: CFAbsoluteTime) { + if let lastTick = self.lastTickTime { + let deltaMs = Int((now - lastTick) * 1000.0) + self.totalViewTimeMs += deltaMs + if (now - lastActivityTime) < kUserActivityTimeout { + self.activeViewTimeMs += deltaMs + } + } + self.lastTickTime = nil + } + + func buildMetric() -> TelegramMessageReadMetric? { + guard self.totalViewTimeMs >= kMinViewTimeMs else { + return nil + } + guard self.postHeightPx > 0.0 && self.viewportHeightPx > 0.0 else { + return nil + } + let heightToViewportRatio = Double(self.postHeightPx / self.viewportHeightPx) + var seenRangeRatio: Double = 0.0 + if self.seenBottomPx > self.seenTopPx { + seenRangeRatio = min(1.0, max(0.0, Double((self.seenBottomPx - self.seenTopPx) / self.postHeightPx))) + } + return TelegramMessageReadMetric( + id: self.viewId, + messageId: self.messageId, + timeInViewMs: self.totalViewTimeMs, + activeTimeInViewMs: self.activeViewTimeMs, + heightToViewportRatio: heightToViewportRatio, + seenRangeRatio: seenRangeRatio + ) + } + } + + private let pipe = ValuePipe() + public var completedMetrics: Signal { + return self.pipe.signal() + } + + private var phases: [EngineMessage.Id: TrackingPhase] = [:] + private var isActive: Bool = true + private var lastActivityTime: CFAbsoluteTime = CFAbsoluteTimeGetCurrent() + private var heartbeatTimer: SwiftSignalKit.Timer? + + public init() { + } + + deinit { + self.heartbeatTimer?.invalidate() + let now = CFAbsoluteTimeGetCurrent() + for (_, phase) in self.phases { + phase.graceTimer?.invalidate() + var phase = phase + if phase.state == .tracking || phase.state == .graceStart { + phase.stopTicking(now: now, lastActivityTime: self.lastActivityTime) + } + if let metric = phase.buildMetric() { + self.pipe.putNext(metric) + } + } + self.phases.removeAll() + } + + public func updateVisibleMessages(_ entries: [VisibleMessageEntry]) { + assert(Queue.mainQueue().isCurrent()) + guard self.isActive else { return } + + let visibleIds = Set(entries.map { $0.messageId }) + + var toDiscard: [EngineMessage.Id] = [] + var toBeginGraceEnd: [EngineMessage.Id] = [] + var toResumeGraceEnd: [EngineMessage.Id] = [] + for (messageId, phase) in self.phases { + if !visibleIds.contains(messageId) { + switch phase.state { + case .graceStart: + toDiscard.append(messageId) + case .tracking: + toBeginGraceEnd.append(messageId) + case .graceEnd: + break + case .paused: + switch phase.stateBeforePause { + case .graceStart, .none: + toDiscard.append(messageId) + case .tracking, .graceEnd: + toResumeGraceEnd.append(messageId) + case .paused: + break + } + } + } + } + for messageId in toDiscard { + self.phases[messageId]?.graceTimer?.invalidate() + self.phases.removeValue(forKey: messageId) + } + for messageId in toBeginGraceEnd { + self.beginGraceEnd(messageId: messageId) + } + for messageId in toResumeGraceEnd { + self.resumeGraceEnd(messageId: messageId) + } + + for entry in entries { + if var phase = self.phases[entry.messageId] { + switch phase.state { + case .graceStart, .tracking: + phase.updateSeenRange( + visibleTopPx: entry.visibleTopPx, + visibleBottomPx: entry.visibleBottomPx, + postHeightPx: entry.postHeightPx, + viewportHeightPx: entry.viewportHeightPx + ) + self.phases[entry.messageId] = phase + case .graceEnd: + phase.graceTimer?.invalidate() + phase.graceTimer = nil + phase.graceDeadline = nil + phase.state = .tracking + phase.lastTickTime = CFAbsoluteTimeGetCurrent() + phase.updateSeenRange( + visibleTopPx: entry.visibleTopPx, + visibleBottomPx: entry.visibleBottomPx, + postHeightPx: entry.postHeightPx, + viewportHeightPx: entry.viewportHeightPx + ) + self.phases[entry.messageId] = phase + self.startHeartbeatIfNeeded() + case .paused: + self.resumePausedPhase(messageId: entry.messageId, entry: entry) + } + } else { + self.beginGraceStart(messageId: entry.messageId, entry: entry) + } + } + } + + public func reportUserActivity() { + assert(Queue.mainQueue().isCurrent()) + self.lastActivityTime = CFAbsoluteTimeGetCurrent() + } + + public func setIsActive(_ isActive: Bool) { + assert(Queue.mainQueue().isCurrent()) + guard self.isActive != isActive else { return } + self.isActive = isActive + + if !isActive { + let now = CFAbsoluteTimeGetCurrent() + for (messageId, phase) in self.phases { + var phase = phase + switch phase.state { + case .tracking: + phase.stopTicking(now: now, lastActivityTime: self.lastActivityTime) + phase.stateBeforePause = .tracking + phase.state = .paused + self.phases[messageId] = phase + case .graceStart: + phase.stopTicking(now: now, lastActivityTime: self.lastActivityTime) + if let deadline = phase.graceDeadline { + phase.graceRemainingAtPause = max(0, deadline - now) + } + phase.graceTimer?.invalidate() + phase.graceTimer = nil + phase.graceDeadline = nil + phase.stateBeforePause = .graceStart + phase.state = .paused + self.phases[messageId] = phase + case .graceEnd: + if let deadline = phase.graceDeadline { + phase.graceRemainingAtPause = max(0, deadline - now) + } + phase.graceTimer?.invalidate() + phase.graceTimer = nil + phase.graceDeadline = nil + phase.stateBeforePause = .graceEnd + phase.state = .paused + self.phases[messageId] = phase + case .paused: + break + } + } + self.heartbeatTimer?.invalidate() + self.heartbeatTimer = nil + } + } + + private func startHeartbeatIfNeeded() { + guard self.heartbeatTimer == nil else { return } + let hasTrackingPhases = self.phases.values.contains(where: { $0.state == .tracking }) + guard hasTrackingPhases else { return } + let timer = SwiftSignalKit.Timer(timeout: MessageReadMetricsTracker.kHeartbeatInterval, repeat: true, completion: { [weak self] in + self?.heartbeatTick() + }, queue: Queue.mainQueue()) + self.heartbeatTimer = timer + timer.start() + } + + private func stopHeartbeatIfIdle() { + let hasTrackingPhases = self.phases.values.contains(where: { $0.state == .tracking }) + if !hasTrackingPhases { + self.heartbeatTimer?.invalidate() + self.heartbeatTimer = nil + } + } + + private func heartbeatTick() { + let now = CFAbsoluteTimeGetCurrent() + + var toFinalize: [EngineMessage.Id] = [] + for (messageId, phase) in self.phases { + guard phase.state == .tracking else { continue } + + var phase = phase + phase.flushTime(now: now, lastActivityTime: self.lastActivityTime) + + if (now - phase.phaseStartTime) >= MessageReadMetricsTracker.kMaxPhaseDuration { + toFinalize.append(messageId) + } + self.phases[messageId] = phase + } + + for messageId in toFinalize { + self.finalizePhase(messageId: messageId) + } + } + + private func beginGraceStart(messageId: EngineMessage.Id, entry: VisibleMessageEntry) { + var phase = TrackingPhase(messageId: messageId) + phase.updateSeenRange( + visibleTopPx: entry.visibleTopPx, + visibleBottomPx: entry.visibleBottomPx, + postHeightPx: entry.postHeightPx, + viewportHeightPx: entry.viewportHeightPx + ) + let now = CFAbsoluteTimeGetCurrent() + phase.graceDeadline = now + MessageReadMetricsTracker.kGracePeriod + let timer = SwiftSignalKit.Timer(timeout: MessageReadMetricsTracker.kGracePeriod, repeat: false, completion: { [weak self] in + self?.graceStartCompleted(messageId: messageId) + }, queue: Queue.mainQueue()) + phase.graceTimer = timer + self.phases[messageId] = phase + timer.start() + } + + private func graceStartCompleted(messageId: EngineMessage.Id) { + guard var phase = self.phases[messageId], phase.state == .graceStart else { return } + let now = CFAbsoluteTimeGetCurrent() + phase.flushTime(now: now, lastActivityTime: self.lastActivityTime) + phase.graceTimer = nil + phase.graceDeadline = nil + phase.state = .tracking + self.phases[messageId] = phase + self.startHeartbeatIfNeeded() + } + + private func beginGraceEnd(messageId: EngineMessage.Id) { + guard var phase = self.phases[messageId], phase.state == .tracking else { return } + let now = CFAbsoluteTimeGetCurrent() + phase.stopTicking(now: now, lastActivityTime: self.lastActivityTime) + phase.state = .graceEnd + phase.graceDeadline = now + MessageReadMetricsTracker.kGracePeriod + let timer = SwiftSignalKit.Timer(timeout: MessageReadMetricsTracker.kGracePeriod, repeat: false, completion: { [weak self] in + self?.graceEndCompleted(messageId: messageId) + }, queue: Queue.mainQueue()) + phase.graceTimer = timer + self.phases[messageId] = phase + timer.start() + self.stopHeartbeatIfIdle() + } + + private func resumeGraceEnd(messageId: EngineMessage.Id) { + guard var phase = self.phases[messageId], phase.state == .paused else { return } + let now = CFAbsoluteTimeGetCurrent() + let remaining: Double + if phase.stateBeforePause == .graceEnd { + remaining = phase.graceRemainingAtPause ?? MessageReadMetricsTracker.kGracePeriod + } else { + remaining = MessageReadMetricsTracker.kGracePeriod + } + phase.stateBeforePause = nil + phase.graceRemainingAtPause = nil + phase.state = .graceEnd + phase.graceDeadline = now + remaining + let timer = SwiftSignalKit.Timer(timeout: remaining, repeat: false, completion: { [weak self] in + self?.graceEndCompleted(messageId: messageId) + }, queue: Queue.mainQueue()) + phase.graceTimer = timer + self.phases[messageId] = phase + timer.start() + } + + private func graceEndCompleted(messageId: EngineMessage.Id) { + if let phase = self.phases[messageId], phase.state == .graceEnd { + self.finalizePhase(messageId: messageId) + } + } + + private func resumePausedPhase(messageId: EngineMessage.Id, entry: VisibleMessageEntry) { + guard var phase = self.phases[messageId], phase.state == .paused else { return } + let now = CFAbsoluteTimeGetCurrent() + + if (now - phase.phaseStartTime) >= MessageReadMetricsTracker.kMaxPhaseDuration { + self.finalizePhase(messageId: messageId) + return + } + + let resumeTo = phase.stateBeforePause ?? .tracking + phase.stateBeforePause = nil + + switch resumeTo { + case .tracking: + phase.state = .tracking + phase.lastTickTime = now + phase.graceRemainingAtPause = nil + phase.updateSeenRange( + visibleTopPx: entry.visibleTopPx, + visibleBottomPx: entry.visibleBottomPx, + postHeightPx: entry.postHeightPx, + viewportHeightPx: entry.viewportHeightPx + ) + self.phases[messageId] = phase + self.startHeartbeatIfNeeded() + case .graceStart: + let remaining = phase.graceRemainingAtPause ?? MessageReadMetricsTracker.kGracePeriod + phase.graceRemainingAtPause = nil + phase.state = .graceStart + phase.lastTickTime = now + phase.graceDeadline = now + remaining + let timer = SwiftSignalKit.Timer(timeout: remaining, repeat: false, completion: { [weak self] in + self?.graceStartCompleted(messageId: messageId) + }, queue: Queue.mainQueue()) + phase.graceTimer = timer + phase.updateSeenRange( + visibleTopPx: entry.visibleTopPx, + visibleBottomPx: entry.visibleBottomPx, + postHeightPx: entry.postHeightPx, + viewportHeightPx: entry.viewportHeightPx + ) + self.phases[messageId] = phase + timer.start() + case .graceEnd: + phase.graceRemainingAtPause = nil + phase.state = .tracking + phase.lastTickTime = now + phase.updateSeenRange( + visibleTopPx: entry.visibleTopPx, + visibleBottomPx: entry.visibleBottomPx, + postHeightPx: entry.postHeightPx, + viewportHeightPx: entry.viewportHeightPx + ) + self.phases[messageId] = phase + self.startHeartbeatIfNeeded() + case .paused: + break + } + } + + private func finalizePhase(messageId: EngineMessage.Id) { + guard let phase = self.phases.removeValue(forKey: messageId) else { return } + phase.graceTimer?.invalidate() + if let metric = phase.buildMetric() { + self.pipe.putNext(metric) + } + self.stopHeartbeatIfIdle() + } +} diff --git a/submodules/TelegramUI/Components/AsyncListComponent/Sources/AsyncListComponent.swift b/submodules/TelegramUI/Components/AsyncListComponent/Sources/AsyncListComponent.swift index 1a537db8de..cd85867620 100644 --- a/submodules/TelegramUI/Components/AsyncListComponent/Sources/AsyncListComponent.swift +++ b/submodules/TelegramUI/Components/AsyncListComponent/Sources/AsyncListComponent.swift @@ -465,7 +465,7 @@ public final class AsyncListComponent: Component { private var ignoreUpdateVisibleItems: Bool = false public override init(frame: CGRect) { - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.useMainQueueTransactions = true self.listNode.scroller.delaysContentTouches = false self.listNode.reorderedItemHasShadow = false diff --git a/submodules/TelegramUI/Components/AttachmentFileController/Sources/AttachmentFileSearchItem.swift b/submodules/TelegramUI/Components/AttachmentFileController/Sources/AttachmentFileSearchItem.swift index d1b6135765..cf18f0c2a8 100644 --- a/submodules/TelegramUI/Components/AttachmentFileController/Sources/AttachmentFileSearchItem.swift +++ b/submodules/TelegramUI/Components/AttachmentFileController/Sources/AttachmentFileSearchItem.swift @@ -317,7 +317,7 @@ public final class AttachmentFileSearchContainerNode: SearchDisplayControllerCon self.backgroundNode = ASDisplayNode() - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string } diff --git a/submodules/TelegramUI/Components/Chat/ChatHistorySearchContainerNode/Sources/ChatHistorySearchContainerNode.swift b/submodules/TelegramUI/Components/Chat/ChatHistorySearchContainerNode/Sources/ChatHistorySearchContainerNode.swift index 417e776dda..2f272d51d0 100644 --- a/submodules/TelegramUI/Components/Chat/ChatHistorySearchContainerNode/Sources/ChatHistorySearchContainerNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatHistorySearchContainerNode/Sources/ChatHistorySearchContainerNode.swift @@ -159,7 +159,7 @@ public final class ChatHistorySearchContainerNode: SearchDisplayControllerConten self.dimNode = ASDisplayNode() self.dimNode.backgroundColor = .clear - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string } diff --git a/submodules/TelegramUI/Components/Chat/ChatInlineSearchResultsListComponent/Sources/ChatInlineSearchResultsListComponent.swift b/submodules/TelegramUI/Components/Chat/ChatInlineSearchResultsListComponent/Sources/ChatInlineSearchResultsListComponent.swift index c84ec35a84..cf160abb9f 100644 --- a/submodules/TelegramUI/Components/Chat/ChatInlineSearchResultsListComponent/Sources/ChatInlineSearchResultsListComponent.swift +++ b/submodules/TelegramUI/Components/Chat/ChatInlineSearchResultsListComponent/Sources/ChatInlineSearchResultsListComponent.swift @@ -281,7 +281,7 @@ public final class ChatInlineSearchResultsListComponent: Component { private var hintAnimateListTransition: Bool = false override public init(frame: CGRect) { - self.listNode = ListView() + self.listNode = ListViewImpl() super.init(frame: frame) diff --git a/submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift b/submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift index 88f49088f4..f09dfe4ae6 100644 --- a/submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift +++ b/submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift @@ -883,7 +883,7 @@ private class ChatQrCodeScreenNode: ViewControllerTracingNode, ASScrollViewDeleg self.scanButton.title = presentationData.strings.PeerInfo_QRCode_Scan self.scanButton.icon = UIImage(bundleImageName: "Settings/ScanQr") - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.transform = CATransform3DMakeRotation(-CGFloat.pi / 2.0, 0.0, 0.0, 1.0) super.init() diff --git a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift index 8ac6cb0dfc..ae38d3783b 100644 --- a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift @@ -129,7 +129,7 @@ final class ChatRecentActionsControllerNode: ViewControllerTracingNode { self.panelButtonNode.setTitle(self.presentationData.strings.Channel_AdminLog_Settings, with: Font.regular(17.0), with: self.presentationData.theme.chat.inputPanel.panelControlAccentColor, for: []) self.panelInfoButtonNode = HighlightableButtonNode() - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.transform = CATransform3DMakeRotation(CGFloat(Double.pi), 0.0, 0.0, 1.0) self.listNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string diff --git a/submodules/TelegramUI/Components/Chat/ChatSideTopicsPanel/Sources/ChatFloatingTopicsPanel.swift b/submodules/TelegramUI/Components/Chat/ChatSideTopicsPanel/Sources/ChatFloatingTopicsPanel.swift index caf617d4ee..56133e1a15 100644 --- a/submodules/TelegramUI/Components/Chat/ChatSideTopicsPanel/Sources/ChatFloatingTopicsPanel.swift +++ b/submodules/TelegramUI/Components/Chat/ChatSideTopicsPanel/Sources/ChatFloatingTopicsPanel.swift @@ -180,7 +180,8 @@ public final class ChatFloatingTopicsPanel: Component { } } - if case .top = component.location { + switch component.location { + case .top, .bottom: let topPanel: ComponentView var topPanelTransition = transition if let current = self.topPanel { @@ -196,7 +197,7 @@ public final class ChatFloatingTopicsPanel: Component { context: component.context, theme: component.theme, strings: component.strings, - location: .top, + location: component.location == .top ? .top : .bottom, peerId: component.peerId, kind: component.kind, topicId: component.topicId, @@ -232,13 +233,15 @@ public final class ChatFloatingTopicsPanel: Component { } transition.setFrame(view: topPanelView, frame: topPanelFrame) } - } else if let topPanel = self.topPanel { - self.topPanel = nil - if let topPanelView = topPanel.view as? ChatSideTopicsPanel.View { - topPanelView.clipsToBounds = true - transition.setFrame(view: topPanelView, frame: CGRect(origin: CGPoint(x: 8.0, y: 0.0), size: CGSize(width: 16.0 + 72.0, height: topPanelView.bounds.height)), completion: { [weak topPanelView] _ in - topPanelView?.removeFromSuperview() - }) + default: + if let topPanel = self.topPanel { + self.topPanel = nil + if let topPanelView = topPanel.view as? ChatSideTopicsPanel.View { + topPanelView.clipsToBounds = true + transition.setFrame(view: topPanelView, frame: CGRect(origin: CGPoint(x: 8.0, y: 0.0), size: CGSize(width: 16.0 + 72.0, height: topPanelView.bounds.height)), completion: { [weak topPanelView] _ in + topPanelView?.removeFromSuperview() + }) + } } } @@ -291,11 +294,17 @@ public final class ChatFloatingTopicsPanel: Component { } public final class ChatTopicsHeaderPanelComponent: Component { + public enum Location { + case top + case bottom + } + public let context: AccountContext public let theme: PresentationTheme public let strings: PresentationStrings public let peerId: EnginePeer.Id public let kind: ChatSideTopicsPanel.Kind + public let location: Location public let topicId: Int64? public let controller: () -> ViewController? public let togglePanel: () -> Void @@ -308,6 +317,7 @@ public final class ChatTopicsHeaderPanelComponent: Component { strings: PresentationStrings, peerId: EnginePeer.Id, kind: ChatSideTopicsPanel.Kind, + location: Location, topicId: Int64?, controller: @escaping () -> ViewController?, togglePanel: @escaping () -> Void, @@ -319,6 +329,7 @@ public final class ChatTopicsHeaderPanelComponent: Component { self.strings = strings self.peerId = peerId self.kind = kind + self.location = location self.topicId = topicId self.controller = controller self.togglePanel = togglePanel @@ -342,6 +353,9 @@ public final class ChatTopicsHeaderPanelComponent: Component { if lhs.kind != rhs.kind { return false } + if lhs.location != rhs.location { + return false + } if lhs.topicId != rhs.topicId { return false } @@ -371,7 +385,7 @@ public final class ChatTopicsHeaderPanelComponent: Component { context: component.context, theme: component.theme, strings: component.strings, - location: .top, + location: component.location == .top ? .top : .bottom, peerId: component.peerId, kind: component.kind, topicId: component.topicId, diff --git a/submodules/TelegramUI/Components/Chat/ChatSideTopicsPanel/Sources/ChatSideTopicsPanel.swift b/submodules/TelegramUI/Components/Chat/ChatSideTopicsPanel/Sources/ChatSideTopicsPanel.swift index 41cc0cedce..f5d15db9e7 100644 --- a/submodules/TelegramUI/Components/Chat/ChatSideTopicsPanel/Sources/ChatSideTopicsPanel.swift +++ b/submodules/TelegramUI/Components/Chat/ChatSideTopicsPanel/Sources/ChatSideTopicsPanel.swift @@ -44,6 +44,7 @@ public final class ChatSideTopicsPanel: Component { public enum Location { case side case top + case bottom } public enum Kind: Equatable { @@ -1105,11 +1106,20 @@ public final class ChatSideTopicsPanel: Component { } } + let normalIconName: String + switch location { + case .top: + normalIconName = "Chat/Title Panels/SidebarIcon" + case .side: + normalIconName = "Chat/Title Panels/SidebarBottomIcon" + case .bottom: + normalIconName = "Chat/Title Panels/SidebarTopIcon" + } let iconSize = self.icon.update( transition: .immediate, component: AnyComponent(BundleIconComponent( - name: isReordering ? "Media Editor/Done" : "Chat/Title Panels/SidebarIcon", - tintColor: location == .side ? theme.rootController.navigationBar.accentTextColor : theme.chat.inputPanel.panelControlColor, + name: isReordering ? "Media Editor/Done" : normalIconName, + tintColor: theme.chat.inputPanel.panelControlColor, maxSize: CGSize(width: 24.0, height: 24.0), scaleFactor: 1.0 )), @@ -1519,7 +1529,7 @@ public final class ChatSideTopicsPanel: Component { switch component.location { case .side: transition.setTransform(view: tabItemView, transform: CATransform3DMakeTranslation(-globalOffset, 0.0, 0.0)) - case .top: + case .top, .bottom: transition.setTransform(view: tabItemView, transform: CATransform3DMakeTranslation(0.0, -globalOffset, 0.0)) } } @@ -1560,7 +1570,7 @@ public final class ChatSideTopicsPanel: Component { switch component.location { case .side: selectedItemFrame = item.frame - case .top: + case .top, .bottom: selectedItemFrame = CGRect(origin: CGPoint(x: item.frame.minX + 5.0, y: item.frame.minY), size: CGSize(width: item.frame.width - 4.0 - 11.0, height: item.frame.height)) } } @@ -1568,7 +1578,7 @@ public final class ChatSideTopicsPanel: Component { switch component.location { case .side: beforePinnedItemsPosition = item.frame.maxY - case .top: + case .top, .bottom: beforePinnedItemsPosition = item.frame.maxX } } @@ -1597,7 +1607,7 @@ public final class ChatSideTopicsPanel: Component { switch component.location { case .side: beforePinnedItemsPosition = item.frame.maxY - case .top: + case .top, .bottom: beforePinnedItemsPosition = item.frame.maxX } } else { @@ -1605,7 +1615,7 @@ public final class ChatSideTopicsPanel: Component { switch component.location { case .side: afterPinnedItemsPosition = item.frame.minY - case .top: + case .top, .bottom: afterPinnedItemsPosition = item.frame.minX } } @@ -1622,7 +1632,7 @@ public final class ChatSideTopicsPanel: Component { switch component.location { case .side: afterPinnedItemsPosition = listView.bounds.height + 500.0 - case .top: + case .top, .bottom: afterPinnedItemsPosition = listView.bounds.width + 500.0 } } @@ -1638,7 +1648,7 @@ public final class ChatSideTopicsPanel: Component { switch component.location { case .side: selectedLineFrame = CGRect(origin: CGPoint(x: 0.0, y: selectedItemFrame.minY), size: CGSize(width: 4.0, height: selectedItemFrame.height)) - case .top: + case .top, .bottom: selectedLineFrame = CGRect(origin: CGPoint(x: selectedItemFrame.minX, y: listView.frame.maxY - 3.0), size: CGSize(width: selectedItemFrame.width, height: 3.0)) } @@ -1658,7 +1668,7 @@ public final class ChatSideTopicsPanel: Component { switch component.location { case .side: pinnedItemsBackgroundFrame = CGRect(origin: CGPoint(x: 5.0, y: beforePinnedItemsPosition), size: CGSize(width: listView.bounds.width - 5.0 - 4.0, height: afterPinnedItemsPosition - beforePinnedItemsPosition)) - case .top: + case .top, .bottom: pinnedItemsBackgroundFrame = CGRect(origin: CGPoint(x: beforePinnedItemsPosition, y: 4.0), size: CGSize(width: afterPinnedItemsPosition - beforePinnedItemsPosition, height: listView.bounds.height - 5.0 - 4.0)) } self.pinnedBackgroundContainer.updatePosition(position: pinnedItemsBackgroundFrame.origin, transition: pinnedItemsTransition) @@ -1754,7 +1764,7 @@ public final class ChatSideTopicsPanel: Component { image.draw(in: CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: image.size.width)), blendMode: .destinationOut, alpha: 1.0) } })?.stretchableImage(withLeftCapWidth: Int(cornerRadius) + 1, topCapHeight: Int(cornerRadius) + 1) - case .top: + case .top, .bottom: self.scrollViewMask.image = generateImage(CGSize(width: 8.0 + 1.0, height: 1.0), rotatedContext: { size, context in UIGraphicsPushContext(context) defer { @@ -1785,7 +1795,7 @@ public final class ChatSideTopicsPanel: Component { context.setFillColor(component.theme.rootController.navigationBar.accentTextColor.cgColor) context.fillEllipse(in: CGRect(origin: CGPoint(x: size.width - size.height, y: 0.0), size: CGSize(width: size.height, height: size.height))) })?.stretchableImage(withLeftCapWidth: 1, topCapHeight: 4) - case .top: + case .top, .bottom: self.selectedLineView.image = generateImage(CGSize(width: 4.0, height: 3.0), rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.setFillColor(component.theme.rootController.navigationBar.accentTextColor.cgColor) @@ -1817,7 +1827,7 @@ public final class ChatSideTopicsPanel: Component { switch component.location { case .side: directionContainerInset = containerInsets.top - case .top: + case .top, .bottom: directionContainerInset = containerInsets.left } @@ -1876,7 +1886,7 @@ public final class ChatSideTopicsPanel: Component { case .side: itemFrame = CGRect(origin: CGPoint(x: 8.0 + 4.0, y: directionContainerInset + 6.0), size: itemSize) directionContainerInset += itemSize.height - case .top: + case .top, .bottom: itemFrame = CGRect(origin: CGPoint(x: 12.0, y: 6.0), size: itemSize) directionContainerInset += itemSize.width - 20.0 } @@ -1900,7 +1910,7 @@ public final class ChatSideTopicsPanel: Component { scrollSize = CGSize(width: availableSize.width, height: availableSize.height - directionContainerInset - environment.insets.top - containerInsets.bottom - additionalInsets.top - additionalInsets.bottom) scrollFrame = CGRect(origin: CGPoint(x: additionalInsets.left, y: directionContainerInset + environment.insets.top + additionalInsets.top), size: scrollSize) listContentInsets = UIEdgeInsets(top: 8.0, left: 0.0, bottom: 8.0, right: 0.0) - case .top: + case .top, .bottom: additionalInsets = UIEdgeInsets(top: 8.0, left: 8.0, bottom: 0.0, right: 8.0) scrollSize = CGSize(width: availableSize.width - additionalInsets.left - additionalInsets.right - directionContainerInset, height: availableSize.height - additionalInsets.top) scrollFrame = CGRect(origin: CGPoint(x: additionalInsets.left + directionContainerInset, y: additionalInsets.top), size: scrollSize) @@ -1940,7 +1950,7 @@ public final class ChatSideTopicsPanel: Component { } ))) ) - case .top: + case .top, .bottom: listItems.append(AnyComponentWithIdentity( id: ScrollId.all, component: AnyComponent(HorizontalAllItemComponent( @@ -2205,7 +2215,7 @@ public final class ChatSideTopicsPanel: Component { contextGesture: itemContextGesture ))) ) - case .top: + case .top, .bottom: listItems.append(AnyComponentWithIdentity( id: scrollId, component: AnyComponent(HorizontalItemComponent( diff --git a/submodules/TelegramUI/Components/ChatThemeScreen/Sources/ChatThemeScreen.swift b/submodules/TelegramUI/Components/ChatThemeScreen/Sources/ChatThemeScreen.swift index 9eb26fb647..d33e7e4012 100644 --- a/submodules/TelegramUI/Components/ChatThemeScreen/Sources/ChatThemeScreen.swift +++ b/submodules/TelegramUI/Components/ChatThemeScreen/Sources/ChatThemeScreen.swift @@ -930,7 +930,7 @@ private class ChatThemeScreenNode: ViewControllerTracingNode, ASScrollViewDelega self.otherButton = HighlightableButtonNode() - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.transform = CATransform3DMakeRotation(-CGFloat.pi / 2.0, 0.0, 0.0, 1.0) super.init() diff --git a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerActionsStackNode.swift b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerActionsStackNode.swift index ddec57771f..3d9e9e9f9d 100644 --- a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerActionsStackNode.swift +++ b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerActionsStackNode.swift @@ -756,7 +756,7 @@ public final class ContextControllerActionsListStackItem: ContextControllerActio private var tip: ContextController.Tip? private var tipDisposable: Disposable? - private var tipNode: InnerTextSelectionTipContainerNode? + private(set) var tipNode: InnerTextSelectionTipContainerNode? private var tipSeparatorNode: ContextControllerActionsListSeparatorItemNode? private var hapticFeedback: HapticFeedback? @@ -2378,6 +2378,9 @@ public final class ContextControllerActionsStackNodeImpl: ASDisplayNode, Context if let tipNode = itemContainer.tipNode { tipNode.animateIn() } + if let topNode = itemContainer.node as? ContextControllerActionsListStackItem.Node { + topNode.tipNode?.animateIn() + } } } diff --git a/submodules/TelegramUI/Components/GlassBackgroundComponent/Sources/LegacyGlassView.swift b/submodules/TelegramUI/Components/GlassBackgroundComponent/Sources/LegacyGlassView.swift index b73133d2b2..fd7a918447 100644 --- a/submodules/TelegramUI/Components/GlassBackgroundComponent/Sources/LegacyGlassView.swift +++ b/submodules/TelegramUI/Components/GlassBackgroundComponent/Sources/LegacyGlassView.swift @@ -179,31 +179,29 @@ final class LegacyGlassView: UIView { let displacementMagnitudeV = displacementMagnitudePoints / size.height let outerEdgeDistance = 2.0 - if let displacementMap = generateDisplacementMap(size: size, cornerRadius: cornerRadius, edgeDistance: min(12.0, cornerRadius), scale: 1.0) { - let meshTransform = generateGlassMeshFromDisplacementMap( - size: size, - cornerRadius: cornerRadius, - displacementMap: displacementMap, - displacementMagnitudeU: displacementMagnitudeU, - displacementMagnitudeV: displacementMagnitudeV, - cornerResolution: 12, - outerEdgeDistance: outerEdgeDistance, - bezier: DisplacementBezier( - x1: 0.816137566137566, - y1: 0.20502645502645533, - x2: 0.5806878306878306, - y2: 0.873015873015873 - ) - ).mesh.makeValue() - - if let meshTransform { - if !transition.animation.isImmediate, let previousTransform = backdropLayer.value(forKey: "meshTransform") as? NSObject { - backdropLayer.removeAnimation(forKey: "meshTransform") - backdropLayer.setValue(meshTransform, forKey: "meshTransform") - transition.animateMeshTransform(layer: backdropLayer, from: previousTransform, to: meshTransform) - } else { - backdropLayer.setValue(meshTransform, forKey: "meshTransform") - } + let meshTransform = generateGlassMesh( + size: size, + cornerRadius: cornerRadius, + edgeDistance: min(12.0, cornerRadius), + displacementMagnitudeU: displacementMagnitudeU, + displacementMagnitudeV: displacementMagnitudeV, + cornerResolution: 12, + outerEdgeDistance: outerEdgeDistance, + bezier: DisplacementBezier( + x1: 0.816137566137566, + y1: 0.20502645502645533, + x2: 0.5806878306878306, + y2: 0.873015873015873 + ) + ).mesh.makeValue() + + if let meshTransform { + if !transition.animation.isImmediate, let previousTransform = backdropLayer.value(forKey: "meshTransform") as? NSObject { + backdropLayer.removeAnimation(forKey: "meshTransform") + backdropLayer.setValue(meshTransform, forKey: "meshTransform") + transition.animateMeshTransform(layer: backdropLayer, from: previousTransform, to: meshTransform) + } else { + backdropLayer.setValue(meshTransform, forKey: "meshTransform") } } } diff --git a/submodules/TelegramUI/Components/GroupStickerPackSetupController/Sources/GroupStickerSearchContainerNode.swift b/submodules/TelegramUI/Components/GroupStickerPackSetupController/Sources/GroupStickerSearchContainerNode.swift index 29f9b0d3c6..e8224ba5ca 100644 --- a/submodules/TelegramUI/Components/GroupStickerPackSetupController/Sources/GroupStickerSearchContainerNode.swift +++ b/submodules/TelegramUI/Components/GroupStickerPackSetupController/Sources/GroupStickerSearchContainerNode.swift @@ -118,7 +118,7 @@ public final class GroupStickerSearchContainerNode: SearchDisplayControllerConte } self.presentationDataPromise = Promise(self.presentationData) - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string } diff --git a/submodules/TelegramUI/Components/HeaderPanelContainerComponent/Sources/HeaderPanelContainerComponent.swift b/submodules/TelegramUI/Components/HeaderPanelContainerComponent/Sources/HeaderPanelContainerComponent.swift index 94f7c0ca30..d4e26a4f58 100644 --- a/submodules/TelegramUI/Components/HeaderPanelContainerComponent/Sources/HeaderPanelContainerComponent.swift +++ b/submodules/TelegramUI/Components/HeaderPanelContainerComponent/Sources/HeaderPanelContainerComponent.swift @@ -258,7 +258,7 @@ public final class HeaderPanelContainerComponent: Component { } panelCount += component.panels.count var cornerRadius: CGFloat = 0.0 - if panelCount == 1 { + if panelCount == 1 && backgroundFrame.height <= 50.0 * 2.0 { cornerRadius = backgroundFrame.height * 0.5 } else { cornerRadius = 20.0 diff --git a/submodules/TelegramUI/Components/MeshTransform/Sources/GenerateMesh.swift b/submodules/TelegramUI/Components/MeshTransform/Sources/GenerateMesh.swift index e6aa570450..ba7eacfed2 100644 --- a/submodules/TelegramUI/Components/MeshTransform/Sources/GenerateMesh.swift +++ b/submodules/TelegramUI/Components/MeshTransform/Sources/GenerateMesh.swift @@ -68,6 +68,310 @@ public struct DisplacementBezier { } } +private struct GlassMeshCacheKey: Hashable { + var cornerRadius: CGFloat + var edgeDistance: CGFloat + var cornerResolution: Int + var outerEdgeDistance: CGFloat + var bezierX1: CGFloat + var bezierY1: CGFloat + var bezierX2: CGFloat + var bezierY2: CGFloat + + init(cornerRadius: CGFloat, edgeDistance: CGFloat, cornerResolution: Int, outerEdgeDistance: CGFloat, bezier: DisplacementBezier) { + self.cornerRadius = cornerRadius + self.edgeDistance = edgeDistance + self.cornerResolution = cornerResolution + self.outerEdgeDistance = outerEdgeDistance + self.bezierX1 = bezier.x1 + self.bezierY1 = bezier.y1 + self.bezierX2 = bezier.x2 + self.bezierY2 = bezier.y2 + } +} + +private struct GlassMeshTemplate { + struct VertexTemplate { + /// worldX = baseX + sizeScaleX * width + var baseX: CGFloat + var sizeScaleX: CGFloat + /// worldY = baseY + sizeScaleY * height + var baseY: CGFloat + var sizeScaleY: CGFloat + /// Unitless displacement (direction * weight * bezier * edgeBoost), range roughly -1...1 + var dispX: CGFloat + var dispY: CGFloat + var depth: CGFloat + } + + var vertices: ContiguousArray + var faces: ContiguousArray +} + +private var glassMeshTemplateCache: [GlassMeshCacheKey: GlassMeshTemplate] = [:] + +private func instantiateGlassMesh( + from template: GlassMeshTemplate, + size: CGSize, + displacementMagnitudeU: CGFloat, + displacementMagnitudeV: CGFloat +) -> MeshTransform { + let W = size.width + let H = size.height + let insetPoints: CGFloat = -1.0 + let insetUOffset = insetPoints / W + let insetVOffset = insetPoints / H + let usableUNorm = (W - insetPoints * 2) / W + let usableVNorm = (H - insetPoints * 2) / H + + let transform = MeshTransform() + for v in template.vertices { + let worldX = v.baseX + v.sizeScaleX * W + let worldY = v.baseY + v.sizeScaleY * H + let u = worldX / W + let vCoord = worldY / H + let mappedU = insetUOffset + u * usableUNorm + let mappedV = insetVOffset + vCoord * usableVNorm + let fromX = max(0.0, min(1.0, mappedU + v.dispX * displacementMagnitudeU)) + let fromY = max(0.0, min(1.0, mappedV + v.dispY * displacementMagnitudeV)) + transform.add(MeshTransform.Vertex( + from: CGPoint(x: fromX, y: fromY), + to: MeshTransform.Point3D(x: mappedU, y: mappedV, z: v.depth) + )) + } + for face in template.faces { + transform.add(face) + } + return transform +} + +private func generateGlassMeshTemplate( + cornerRadius: CGFloat, + edgeDistance: CGFloat, + cornerResolution: Int, + outerEdgeDistance: CGFloat, + bezier: DisplacementBezier +) -> GlassMeshTemplate { + let clampedRadius = cornerRadius + + // Reference size for displacement computation (must be >= 2R per axis) + let refW = max(4 * clampedRadius, 100) + let refH = max(4 * clampedRadius, 100) + + var vertices = ContiguousArray() + var faces = ContiguousArray() + var vertexIndex: Int = 0 + + // Compute unitless displacement (direction * weight * bezier * edgeBoost) at reference size + func templateDisplacement(worldX: CGFloat, worldY: CGFloat) -> (CGFloat, CGFloat) { + let (rawDispX, rawDispY, sdf) = computeDisplacement( + x: worldX, y: worldY, + width: refW, height: refH, + cornerRadius: clampedRadius, + edgeDistance: edgeDistance, + bezier: bezier + ) + let distToEdge = max(0.0, -sdf) + let edgeBand = max(0.0, outerEdgeDistance) + let edgeBoost: CGFloat + if edgeBand > 0 { + let t = max(0.0, min(1.0, (edgeBand - distToEdge) / edgeBand)) + edgeBoost = 1.0 + t * t * (3 - 2 * t) * 0.5 + } else { + edgeBoost = 1.0 + } + return (rawDispX * edgeBoost, rawDispY * edgeBoost) + } + + func addVertex(baseX: CGFloat, scaleX: CGFloat, baseY: CGFloat, scaleY: CGFloat, depth: CGFloat = 0) -> Int { + let worldX = baseX + scaleX * refW + let worldY = baseY + scaleY * refH + let (dispX, dispY) = templateDisplacement(worldX: worldX, worldY: worldY) + vertices.append(GlassMeshTemplate.VertexTemplate( + baseX: baseX, sizeScaleX: scaleX, + baseY: baseY, sizeScaleY: scaleY, + dispX: dispX, dispY: dispY, depth: depth + )) + let idx = vertexIndex + vertexIndex += 1 + return idx + } + + func addQuadFace(_ i0: Int, _ i1: Int, _ i2: Int, _ i3: Int) { + faces.append(MeshTransform.Face( + indices: (UInt32(i0), UInt32(i1), UInt32(i2), UInt32(i3)), + w: (0.0, 0.0, 0.0, 0.0) + )) + } + + // Topology parameters (same formulas as generateGlassMesh) + let angularStepsBase = max(3, cornerResolution) + let angularSteps = angularStepsBase % 2 == 0 ? angularStepsBase : angularStepsBase + 1 + let radialSteps = max(2, cornerResolution) + let horizontalSegments = max(2, cornerResolution / 2 + 1) + let verticalSegments = max(2, cornerResolution / 2 + 1) + let R = clampedRadius + + func depthFactorsWithOuterBand(count: Int, band: CGFloat, maxRadius: CGFloat) -> [CGFloat] { + guard count > 0, maxRadius > 0 else { return [0, 1] } + let bandNorm = max(0, min(1, band / maxRadius)) + let innerSegments = max(1, count - 1) + let innerMax = max(0, 1 - bandNorm) + var factors: [CGFloat] = (0...innerSegments).map { i in + innerMax * CGFloat(i) / CGFloat(innerSegments) + } + func appendUnique(_ value: CGFloat) { + if let last = factors.last, abs(last - value) < 1e-4 { return } + factors.append(value) + } + appendUnique(innerMax) + appendUnique(1.0) + return factors + } + + let depthFactors = depthFactorsWithOuterBand(count: radialSteps, band: outerEdgeDistance, maxRadius: R) + let angularFactors = (0...angularSteps).map { CGFloat($0) / CGFloat(angularSteps) } + let outerToInner = depthFactors.reversed() + + // Affine coefficient arrays for strip/grid positions + let topXCoeffs: [(base: CGFloat, scale: CGFloat)] = (0...horizontalSegments).map { i in + let t = CGFloat(i) / CGFloat(horizontalSegments) + return (base: R * (1 - 2 * t), scale: t) + } + let sideYCoeffs: [(base: CGFloat, scale: CGFloat)] = (0...verticalSegments).map { j in + let t = CGFloat(j) / CGFloat(verticalSegments) + return (base: R * (1 - 2 * t), scale: t) + } + let topYCoeffs: [(base: CGFloat, scale: CGFloat)] = outerToInner.map { factor in + (base: R * (1 - factor), scale: 0) + } + let bottomYCoeffs: [(base: CGFloat, scale: CGFloat)] = depthFactors.map { factor in + (base: -R * (1 - factor), scale: 1) + } + let leftXCoeffs: [(base: CGFloat, scale: CGFloat)] = outerToInner.map { factor in + (base: R * (1 - factor), scale: 0) + } + let rightXCoeffs: [(base: CGFloat, scale: CGFloat)] = depthFactors.map { factor in + (base: -R * (1 - factor), scale: 1) + } + + // Build a grid of vertices from coefficient arrays and emit quad faces + func buildGridTemplate( + xCoeffs: [(base: CGFloat, scale: CGFloat)], + yCoeffs: [(base: CGFloat, scale: CGFloat)] + ) { + var indexGrid: [[Int]] = [] + for yc in yCoeffs { + var row: [Int] = [] + for xc in xCoeffs { + row.append(addVertex(baseX: xc.base, scaleX: xc.scale, baseY: yc.base, scaleY: yc.scale)) + } + indexGrid.append(row) + } + let numRows = indexGrid.count - 1 + let numCols = indexGrid.first!.count - 1 + for row in 0.. 0 } + guard !ringRadials.isEmpty else { return } + + var ringIndices: [[Int]] = [] + for radial in ringRadials { + let r = R * radial + var row: [Int] = [] + for t in angularFactors { + let angle = startAngle + (endAngle - startAngle) * t + let offsetX = r * cos(angle) + let offsetY = r * sin(angle) + row.append(addVertex( + baseX: centerBaseX + offsetX, scaleX: centerScaleX, + baseY: centerBaseY + offsetY, scaleY: centerScaleY + )) + } + ringIndices.append(row) + } + + // Quad rings between concentric samples + for r in 0..<(ringIndices.count - 1) { + let outerRing = ringIndices[r] + let innerRing = ringIndices[r + 1] + for i in 0..<(outerRing.count - 1) { + addQuadFace(outerRing[i], outerRing[i + 1], innerRing[i + 1], innerRing[i]) + } + } + + // Center fan collapse (same logic as original) + if let innermostRing = ringIndices.last { + let ringSegments = innermostRing.count - 1 + guard ringSegments >= 2 else { return } + + let centerAnchor = addVertex( + baseX: centerBaseX, scaleX: centerScaleX, + baseY: centerBaseY, scaleY: centerScaleY, + depth: -0.02 + ) + let stride = 2 + var i = 0 + while i + 2 <= ringSegments { + addQuadFace(centerAnchor, innermostRing[i], innermostRing[i + 1], innermostRing[i + 2]) + i += stride + } + if i < ringSegments { + addQuadFace(centerAnchor, innermostRing[ringSegments - 1], innermostRing[ringSegments], innermostRing[ringSegments]) + } + } + } + + // Edge strips + buildGridTemplate(xCoeffs: topXCoeffs, yCoeffs: topYCoeffs) + buildGridTemplate(xCoeffs: topXCoeffs, yCoeffs: bottomYCoeffs) + buildGridTemplate(xCoeffs: leftXCoeffs, yCoeffs: sideYCoeffs) + buildGridTemplate(xCoeffs: rightXCoeffs, yCoeffs: sideYCoeffs) + + // Center patch + buildGridTemplate(xCoeffs: topXCoeffs, yCoeffs: sideYCoeffs) + + // Corners + buildCornerTemplate( + centerBaseX: R, centerScaleX: 0, + centerBaseY: R, centerScaleY: 0, + startAngle: .pi, endAngle: 1.5 * .pi + ) + buildCornerTemplate( + centerBaseX: -R, centerScaleX: 1, + centerBaseY: R, centerScaleY: 0, + startAngle: 1.5 * .pi, endAngle: 2 * .pi + ) + buildCornerTemplate( + centerBaseX: -R, centerScaleX: 1, + centerBaseY: -R, centerScaleY: 1, + startAngle: .pi / 2, endAngle: 0 + ) + buildCornerTemplate( + centerBaseX: R, centerScaleX: 0, + centerBaseY: -R, centerScaleY: 1, + startAngle: .pi, endAngle: .pi / 2 + ) + + return GlassMeshTemplate(vertices: vertices, faces: faces) +} + /// Computes signed distance from a point to the edge of a rounded rectangle. /// Returns negative inside, zero on edge, positive outside. /// All values in points. @@ -132,123 +436,39 @@ public func roundedRectGradient(x: CGFloat, y: CGFloat, width: CGFloat, height: return (nx, ny) } -/// Generates a displacement map image as a signed distance field from rounded rect edges. -/// - edgeDistance: The distance (in points) over which displacement is applied -/// - R channel: X displacement (127 = neutral, 0 = max left, 255 = max right) -/// - G channel: Y displacement (127 = neutral, 0 = max up, 255 = max down) -/// - B channel: Unused (always 0) -/// Displacement is maximum at the edge and fades linearly to zero at edgeDistance. -/// Actual displacement magnitude is applied when sampling the map. -public func generateDisplacementMap(size: CGSize, cornerRadius: CGFloat, edgeDistance: CGFloat, scale: CGFloat) -> CGImage? { - let width = Int(size.width * scale) - let height = Int(size.height * scale) - - // Clamp corner radius - let maxCornerRadius = min(size.width, size.height) / 2.0 - let clampedRadius = min(cornerRadius, maxCornerRadius) - - // Create bitmap context - var pixelData = [UInt8](repeating: 0, count: width * height * 4) - - for py in 0 ..< height { - for px in 0 ..< width { - // Convert pixel to point coordinates - let x = CGFloat(px) / scale - let y = CGFloat(py) / scale - - // Get signed distance (negative inside, positive outside) - let sdf = roundedRectSDF(x: x, y: y, width: size.width, height: size.height, cornerRadius: clampedRadius) - - // Get gradient (outward normal direction) - let (nx, ny) = roundedRectGradient(x: x, y: y, width: size.width, height: size.height, cornerRadius: clampedRadius) - - // Inward normal (content moves away from edge, toward center) - let inwardX = -nx - let inwardY = -ny - - // Distance from edge (positive inside the shape) - let distFromEdge = -sdf - - // Weight: 1 at edge, 0 at edgeDistance (linear falloff) - let weight = max(0, min(1, 1.0 - distFromEdge / edgeDistance)) - - // Displacement modulated by distance from edge - let displacementX = inwardX * weight - let displacementY = inwardY * weight - - // Encode in R/G: 127 = neutral, map -1..1 to 0..254 - let r = UInt8(max(0, min(255, Int(127 + displacementX * 127)))) - let g = UInt8(max(0, min(255, Int(127 + displacementY * 127)))) - - let idx = (py * width + px) * 4 - pixelData[idx + 0] = r // X displacement - pixelData[idx + 1] = g // Y displacement - pixelData[idx + 2] = 0 // Unused - pixelData[idx + 3] = 255 // A - } - } - - let colorSpace = CGColorSpaceCreateDeviceRGB() - guard let context = CGContext( - data: &pixelData, - width: width, - height: height, - bitsPerComponent: 8, - bytesPerRow: width * 4, - space: colorSpace, - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue - ) else { - return nil - } - - return context.makeImage() -} - -/// Samples displacement from a displacement map with bilinear interpolation and bezier easing +/// Computes displacement at a point analytically using the rounded rect SDF. /// - Parameters: -/// - x, y: Coordinates in the displacement map's pixel space -/// - pixels: Pointer to displacement map pixel data -/// - width, height: Displacement map dimensions -/// - bytesPerRow, bytesPerPixel: Displacement map layout -/// - bezier: Bezier control points for easing curve -/// - Returns: Displacement (dx, dy) in range -1..1 with bezier easing applied -public func sampleDisplacement( +/// - x, y: Point coordinates in the shape's coordinate space +/// - width, height: Dimensions of the rounded rectangle +/// - cornerRadius: Already-clamped corner radius +/// - edgeDistance: Distance (in points) over which displacement fades from edge inward +/// - bezier: Bezier control points for easing the displacement magnitude +/// - Returns: (dx, dy) displacement in range -1..1 with bezier easing, plus the raw SDF value +public func computeDisplacement( x: CGFloat, y: CGFloat, - pixels: UnsafePointer, - width: Int, - height: Int, - bytesPerRow: Int, - bytesPerPixel: Int, + width: CGFloat, + height: CGFloat, + cornerRadius: CGFloat, + edgeDistance: CGFloat, bezier: DisplacementBezier -) -> (dx: CGFloat, dy: CGFloat) { - let clampedX = max(0, min(CGFloat(width - 1), x)) - let clampedY = max(0, min(CGFloat(height - 1), y)) +) -> (dx: CGFloat, dy: CGFloat, sdf: CGFloat) { + let sdf = roundedRectSDF(x: x, y: y, width: width, height: height, cornerRadius: cornerRadius) + let (nx, ny) = roundedRectGradient(x: x, y: y, width: width, height: height, cornerRadius: cornerRadius) - let x0 = Int(clampedX) - let y0 = Int(clampedY) - let x1 = min(x0 + 1, width - 1) - let y1 = min(y0 + 1, height - 1) + // Inward normal (content moves away from edge, toward center) + let inwardX = -nx + let inwardY = -ny - let fx = clampedX - CGFloat(x0) - let fy = clampedY - CGFloat(y0) + // Distance from edge (positive inside the shape) + let distFromEdge = -sdf - func sample(_ sx: Int, _ sy: Int) -> (r: CGFloat, g: CGFloat) { - let offset = sy * bytesPerRow + sx * bytesPerPixel - return (CGFloat(pixels[offset + 0]), CGFloat(pixels[offset + 1])) - } + // Weight: 1 at edge, 0 at edgeDistance inward (linear falloff) + let weight = max(0, min(1, 1.0 - distFromEdge / edgeDistance)) - let c00 = sample(x0, y0) - let c10 = sample(x1, y0) - let c01 = sample(x0, y1) - let c11 = sample(x1, y1) - - let r = (c00.r * (1 - fx) + c10.r * fx) * (1 - fy) + (c01.r * (1 - fx) + c11.r * fx) * fy - let g = (c00.g * (1 - fx) + c10.g * fx) * (1 - fy) + (c01.g * (1 - fx) + c11.g * fx) * fy - - // Decode: 127 = neutral, map 0..254 to -1..1 - var dx = (r - 127.0) / 127.0 - var dy = (g - 127.0) / 127.0 + // Displacement direction modulated by distance + var dx = inwardX * weight + var dy = inwardY * weight // Apply bezier easing to vector magnitude, preserving direction let mag = hypot(dx, dy) @@ -259,7 +479,7 @@ public func sampleDisplacement( dy *= scale } - return (dx, dy) + return (dx, dy, sdf) } /// Generates a glass mesh with corner-aware topology. @@ -268,10 +488,10 @@ public func sampleDisplacement( /// - 1 center patch /// Corner/edge seams share the same coordinates (but do not reuse vertices) so /// the neighbouring faces fit perfectly without T-junctions. -public func generateGlassMeshFromDisplacementMap( +public func generateGlassMesh( size: CGSize, cornerRadius: CGFloat, - displacementMap: CGImage, + edgeDistance: CGFloat, displacementMagnitudeU: CGFloat, displacementMagnitudeV: CGFloat, cornerResolution: Int, @@ -279,19 +499,39 @@ public func generateGlassMeshFromDisplacementMap( bezier: DisplacementBezier, generateWireframe: Bool = false ) -> (mesh: MeshTransform, wireframe: CGPath?) { - guard let dispDataProvider = displacementMap.dataProvider, - let dispData = dispDataProvider.data, - let dispPixels = CFDataGetBytePtr(dispData) else { - return (mesh: MeshTransform(), wireframe: nil) - } - - let dispWidth = displacementMap.width - let dispHeight = displacementMap.height - let dispBytesPerRow = displacementMap.bytesPerRow - let dispBytesPerPixel = displacementMap.bitsPerPixel / 8 - let clampedRadius = min(cornerRadius, min(size.width, size.height) / 2) + // Fast cached path (non-wireframe) + if !generateWireframe { + let key = GlassMeshCacheKey( + cornerRadius: clampedRadius, + edgeDistance: edgeDistance, + cornerResolution: cornerResolution, + outerEdgeDistance: outerEdgeDistance, + bezier: bezier + ) + let template: GlassMeshTemplate + if let cached = glassMeshTemplateCache[key] { + template = cached + } else { + template = generateGlassMeshTemplate( + cornerRadius: clampedRadius, + edgeDistance: edgeDistance, + cornerResolution: cornerResolution, + outerEdgeDistance: outerEdgeDistance, + bezier: bezier + ) + glassMeshTemplateCache[key] = template + } + let mesh = instantiateGlassMesh( + from: template, + size: size, + displacementMagnitudeU: displacementMagnitudeU, + displacementMagnitudeV: displacementMagnitudeV + ) + return (mesh: mesh, wireframe: nil) + } + let transform = MeshTransform() var wireframe: CGMutablePath? if generateWireframe { @@ -311,7 +551,7 @@ public func generateGlassMeshFromDisplacementMap( let usableUNorm = usableWidth / size.width let usableVNorm = usableHeight / size.height - // Helper to sample displacement and create vertex + // Helper to compute displacement analytically and create vertex func makeVertex(u: CGFloat, v: CGFloat, depth: CGFloat = 0) -> (vertex: MeshTransform.Vertex, point: CGPoint) { let mappedU = insetUOffset + u * usableUNorm let mappedV = insetVOffset + v * usableVNorm @@ -322,28 +562,27 @@ public func generateGlassMeshFromDisplacementMap( fromX = mappedU fromY = mappedV } else { - let (dispX, dispY) = sampleDisplacement( - x: mappedU * CGFloat(dispWidth - 1), - y: mappedV * CGFloat(dispHeight - 1), - pixels: dispPixels, - width: dispWidth, - height: dispHeight, - bytesPerRow: dispBytesPerRow, - bytesPerPixel: dispBytesPerPixel, + let worldX = insetPoints + u * usableWidth + let worldY = insetPoints + v * usableHeight + + let (dispX, dispY, sdf) = computeDisplacement( + x: worldX, + y: worldY, + width: size.width, + height: size.height, + cornerRadius: clampedRadius, + edgeDistance: edgeDistance, bezier: bezier ) - // Slight boost near the edge to emphasize the outer strip (rounded-corner aware) - let worldX = insetPoints + u * usableWidth - let worldY = insetPoints + v * usableHeight - let sdf = roundedRectSDF(x: worldX, y: worldY, width: size.width, height: size.height, cornerRadius: clampedRadius) - let distToEdge = max(0.0, -sdf) // distance inside the rounded rect to the edge + // Edge boost: slight displacement boost near the silhouette + let distToEdge = max(0.0, -sdf) let edgeBand = max(0.0, outerEdgeDistance) - let edgeBoostGain: CGFloat = 0.5 // up to +50% displacement at the edge, fades inside + let edgeBoostGain: CGFloat = 0.5 let edgeBoost: CGFloat if edgeBand > 0 { let t = max(0.0, min(1.0, (edgeBand - distToEdge) / edgeBand)) - let eased = t * t * (3 - 2 * t) // smoothstep + let eased = t * t * (3 - 2 * t) edgeBoost = 1.0 + eased * edgeBoostGain } else { edgeBoost = 1.0 diff --git a/submodules/TelegramUI/Components/MiniAppListScreen/Sources/MiniAppListScreen.swift b/submodules/TelegramUI/Components/MiniAppListScreen/Sources/MiniAppListScreen.swift index 4216255eed..7e7f049436 100644 --- a/submodules/TelegramUI/Components/MiniAppListScreen/Sources/MiniAppListScreen.swift +++ b/submodules/TelegramUI/Components/MiniAppListScreen/Sources/MiniAppListScreen.swift @@ -113,7 +113,7 @@ final class MiniAppListScreenComponent: Component { } } - private final class ContentListNode: ListView { + private final class ContentListNode: ListViewImpl { weak var parentView: View? let context: AccountContext var presentationData: PresentationData diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGroupsInCommonPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGroupsInCommonPaneNode.swift index bc83ff6839..1f69cbc65a 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGroupsInCommonPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGroupsInCommonPaneNode.swift @@ -110,7 +110,7 @@ final class PeerInfoGroupsInCommonPaneNode: ASDisplayNode, PeerInfoPaneNode { self.groupsInCommonContext = groupsInCommonContext let presentationData = context.sharedContext.currentPresentationData.with { $0 } - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string } diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoMembersPane.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoMembersPane.swift index 10e3ff045f..284992c656 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoMembersPane.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoMembersPane.swift @@ -320,7 +320,7 @@ final class PeerInfoMembersPaneNode: ASDisplayNode, PeerInfoPaneNode { self.action = action let presentationData = context.sharedContext.currentPresentationData.with { $0 } - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string } diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoRecommendedPeersPane.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoRecommendedPeersPane.swift index 4db8557ff1..31e13a2fd3 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoRecommendedPeersPane.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoRecommendedPeersPane.swift @@ -158,7 +158,7 @@ final class PeerInfoRecommendedPeersPaneNode: ASDisplayNode, PeerInfoPaneNode { self.openPeerContextAction = openPeerContextAction let presentationData = context.sharedContext.currentPresentationData.with { $0 } - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string } diff --git a/submodules/TelegramUI/Components/PeerManagement/OldChannelsController/Sources/OldChannelsSearch.swift b/submodules/TelegramUI/Components/PeerManagement/OldChannelsController/Sources/OldChannelsSearch.swift index 9f563290ee..8259a5ab74 100644 --- a/submodules/TelegramUI/Components/PeerManagement/OldChannelsController/Sources/OldChannelsSearch.swift +++ b/submodules/TelegramUI/Components/PeerManagement/OldChannelsController/Sources/OldChannelsSearch.swift @@ -198,7 +198,7 @@ private final class OldChannelsSearchContainerNode: SearchDisplayControllerConte self.presentationData = presentationData self.presentationDataPromise = Promise(self.presentationData) - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.backgroundColor = self.presentationData.theme.chatList.backgroundColor self.listNode.isHidden = true self.listNode.accessibilityPageScrolledString = { row, count in diff --git a/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/QuickReplySetupScreen.swift b/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/QuickReplySetupScreen.swift index ea2f1620ee..95b256ae73 100644 --- a/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/QuickReplySetupScreen.swift +++ b/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/QuickReplySetupScreen.swift @@ -301,7 +301,7 @@ final class QuickReplySetupScreenComponent: Component { } } - private final class ContentListNode: ListView { + private final class ContentListNode: ListViewImpl { weak var parentView: View? let context: AccountContext var presentationData: PresentationData diff --git a/submodules/TelegramUI/Components/Settings/LanguageSelectionScreen/Sources/LanguageSelectionScreenNode.swift b/submodules/TelegramUI/Components/Settings/LanguageSelectionScreen/Sources/LanguageSelectionScreenNode.swift index 357421eff3..13761c553c 100644 --- a/submodules/TelegramUI/Components/Settings/LanguageSelectionScreen/Sources/LanguageSelectionScreenNode.swift +++ b/submodules/TelegramUI/Components/Settings/LanguageSelectionScreen/Sources/LanguageSelectionScreenNode.swift @@ -115,7 +115,7 @@ private final class LocalizationListSearchContainerNode: SearchDisplayController self.dimNode = ASDisplayNode() self.dimNode.backgroundColor = UIColor.black.withAlphaComponent(0.5) - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string } @@ -329,7 +329,7 @@ final class LanguageSelectionScreenNode: ViewControllerTracingNode { self.push = push self.selectLocalization = selectLocalization - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.keepTopItemOverscrollBackground = ListViewKeepTopItemOverscrollBackground(color: presentationData.theme.list.blocksBackgroundColor, direction: true) self.listNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string diff --git a/submodules/TelegramUI/Components/Settings/PeerSelectionScreen/Sources/PeerSelectionScreen.swift b/submodules/TelegramUI/Components/Settings/PeerSelectionScreen/Sources/PeerSelectionScreen.swift index f62491bf65..0cb04d686f 100644 --- a/submodules/TelegramUI/Components/Settings/PeerSelectionScreen/Sources/PeerSelectionScreen.swift +++ b/submodules/TelegramUI/Components/Settings/PeerSelectionScreen/Sources/PeerSelectionScreen.swift @@ -142,7 +142,7 @@ final class PeerSelectionScreenComponent: Component { } } - private final class ContentListNode: ListView { + private final class ContentListNode: ListViewImpl { weak var parentView: View? let context: AccountContext var presentationData: PresentationData diff --git a/submodules/TelegramUI/Components/Settings/ThemeCarouselItem/Sources/ThemeCarouselItem.swift b/submodules/TelegramUI/Components/Settings/ThemeCarouselItem/Sources/ThemeCarouselItem.swift index 7f8a58041d..c3fda9e89b 100644 --- a/submodules/TelegramUI/Components/Settings/ThemeCarouselItem/Sources/ThemeCarouselItem.swift +++ b/submodules/TelegramUI/Components/Settings/ThemeCarouselItem/Sources/ThemeCarouselItem.swift @@ -722,7 +722,7 @@ public class ThemeCarouselThemeItemNode: ListViewItemNode, ItemListItemNode { self.maskNode = ASImageNode() - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.transform = CATransform3DMakeRotation(-CGFloat.pi / 2.0, 0.0, 0.0, 1.0) super.init(layerBacked: false) diff --git a/submodules/TelegramUI/Components/Settings/ThemeSettingsThemeItem/Sources/ThemeSettingsThemeItem.swift b/submodules/TelegramUI/Components/Settings/ThemeSettingsThemeItem/Sources/ThemeSettingsThemeItem.swift index 4108dd4274..4b8a806e25 100644 --- a/submodules/TelegramUI/Components/Settings/ThemeSettingsThemeItem/Sources/ThemeSettingsThemeItem.swift +++ b/submodules/TelegramUI/Components/Settings/ThemeSettingsThemeItem/Sources/ThemeSettingsThemeItem.swift @@ -546,7 +546,7 @@ public class ThemeSettingsThemeItemNode: ListViewItemNode, ItemListItemNode { self.maskNode = ASImageNode() - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.transform = CATransform3DMakeRotation(-CGFloat.pi / 2.0, 0.0, 0.0, 1.0) super.init(layerBacked: false) diff --git a/submodules/TelegramUI/Components/Settings/TimezoneSelectionScreen/Sources/TimezoneSelectionScreenNode.swift b/submodules/TelegramUI/Components/Settings/TimezoneSelectionScreen/Sources/TimezoneSelectionScreenNode.swift index 7db2097550..f67d1f3b13 100644 --- a/submodules/TelegramUI/Components/Settings/TimezoneSelectionScreen/Sources/TimezoneSelectionScreenNode.swift +++ b/submodules/TelegramUI/Components/Settings/TimezoneSelectionScreen/Sources/TimezoneSelectionScreenNode.swift @@ -111,7 +111,7 @@ private final class TimezoneListSearchContainerNode: SearchDisplayControllerCont self.dimNode = ASDisplayNode() self.dimNode.backgroundColor = .clear - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string } @@ -392,7 +392,7 @@ final class TimezoneSelectionScreenNode: ViewControllerTracingNode { self.present = present self.push = push - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string } diff --git a/submodules/TelegramUI/Components/Settings/WallpaperGridScreen/Sources/ThemeGridSearchContentNode.swift b/submodules/TelegramUI/Components/Settings/WallpaperGridScreen/Sources/ThemeGridSearchContentNode.swift index fea9ef8c23..17074511e3 100644 --- a/submodules/TelegramUI/Components/Settings/WallpaperGridScreen/Sources/ThemeGridSearchContentNode.swift +++ b/submodules/TelegramUI/Components/Settings/WallpaperGridScreen/Sources/ThemeGridSearchContentNode.swift @@ -354,7 +354,7 @@ final class ThemeGridSearchContentNode: SearchDisplayControllerContentNode { self.presentationDataPromise = Promise(self.presentationData) self.dimNode = ASDisplayNode() - self.recentListNode = ListView() + self.recentListNode = ListViewImpl() self.recentListNode.verticalScrollIndicatorColor = self.presentationData.theme.list.scrollIndicatorColor self.recentListNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarBottomIcon.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarBottomIcon.imageset/Contents.json new file mode 100644 index 0000000000..a231c86ae2 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarBottomIcon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tabsdown.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarBottomIcon.imageset/tabsdown.pdf b/submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarBottomIcon.imageset/tabsdown.pdf new file mode 100644 index 0000000000..359c0df43b Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarBottomIcon.imageset/tabsdown.pdf differ diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarIcon.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarIcon.imageset/Contents.json index de77409537..23c3b202b2 100644 --- a/submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarIcon.imageset/Contents.json +++ b/submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarIcon.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "list_24.pdf", + "filename" : "tabsleft.pdf", "idiom" : "universal" } ], diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarIcon.imageset/list_24.pdf b/submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarIcon.imageset/list_24.pdf deleted file mode 100644 index 5b9942caa1..0000000000 Binary files a/submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarIcon.imageset/list_24.pdf and /dev/null differ diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarIcon.imageset/tabsleft.pdf b/submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarIcon.imageset/tabsleft.pdf new file mode 100644 index 0000000000..14472ccd1f Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarIcon.imageset/tabsleft.pdf differ diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarTopIcon.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarTopIcon.imageset/Contents.json new file mode 100644 index 0000000000..fa1eeb030c --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarTopIcon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tabsup.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarTopIcon.imageset/tabsup.pdf b/submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarTopIcon.imageset/tabsup.pdf new file mode 100644 index 0000000000..4504d41eab Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarTopIcon.imageset/tabsup.pdf differ diff --git a/submodules/TelegramUI/Sources/ChatControllerNode.swift b/submodules/TelegramUI/Sources/ChatControllerNode.swift index 6904f6162b..09e0372e91 100644 --- a/submodules/TelegramUI/Sources/ChatControllerNode.swift +++ b/submodules/TelegramUI/Sources/ChatControllerNode.swift @@ -1351,7 +1351,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { var headerPanels: [HeaderPanelContainerComponent.Panel] = [] var footerPanels: [HeaderPanelContainerComponent.Panel] = [] - if let headerTopicsPanel = headerTopicsPanelForChatPresentationInterfaceState(self.chatPresentationInterfaceState, context: self.context, controllerInteraction: self.controllerInteraction, interfaceInteraction: self.interfaceInteraction, force: false) { + if self.chatPresentationInterfaceState.search == nil, let headerTopicsPanel = headerTopicsPanelForChatPresentationInterfaceState(self.chatPresentationInterfaceState, context: self.context, controllerInteraction: self.controllerInteraction, interfaceInteraction: self.interfaceInteraction, force: false) { let panel = HeaderPanelContainerComponent.Panel( key: "topics", orderIndex: 0, @@ -1363,7 +1363,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { footerPanels.append(panel) } } - if let mediaPlayback = self.controller?.globalControlPanelsContextState?.mediaPlayback { + if self.chatPresentationInterfaceState.search == nil, let mediaPlayback = self.controller?.globalControlPanelsContextState?.mediaPlayback { headerPanels.append(HeaderPanelContainerComponent.Panel( key: "media", orderIndex: 1, @@ -1378,7 +1378,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { ))) ) } - if let liveLocation = self.controller?.globalControlPanelsContextState?.liveLocation { + if self.chatPresentationInterfaceState.search == nil, let liveLocation = self.controller?.globalControlPanelsContextState?.liveLocation { headerPanels.append(HeaderPanelContainerComponent.Panel( key: "liveLocation", orderIndex: 2, @@ -1393,7 +1393,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { ))) ) } - if let groupCall = self.controller?.globalControlPanelsContextState?.groupCall { + if self.chatPresentationInterfaceState.search == nil, let groupCall = self.controller?.globalControlPanelsContextState?.groupCall { headerPanels.append(HeaderPanelContainerComponent.Panel( key: "groupCall", orderIndex: 3, @@ -1651,7 +1651,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { switch floatingTopicsPanelComponent.location { case .side: floatingTopicsPanelInsets.left += 72.0 + 10.0 + 10.0 - case .top: + case .top, .bottom: floatingTopicsPanelInsets.top += 40.0 + 10.0 } } else if let floatingTopicsPanel = self.floatingTopicsPanel { diff --git a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift index abeec27d4f..a8af8cef1f 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift @@ -463,7 +463,7 @@ private struct ChatHistoryAnimatedEmojiConfiguration { private var nextClientId: Int32 = 1 -public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHistoryListNode { +public final class ChatHistoryListNodeImpl: ListViewImpl, ChatHistoryNode, ChatHistoryListNode { static let fixedAdMessageStableId: UInt32 = UInt32.max - 5000 public let context: AccountContext @@ -671,6 +671,11 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto public private(set) var loadStateUpdated: ((ChatHistoryNodeLoadState, Bool) -> Void)? private var additionalLoadStateUpdated: [(ChatHistoryNodeLoadState, Bool) -> Void] = [] + private var messageReadMetricsTracker: MessageReadMetricsTracker? + private var messageReadMetricsTrackerDisposable: Disposable? + private var messageReadMetricsTrackerPendingMetrics: [TelegramMessageReadMetric] = [] + private var messageReadMetricsTrackerPendingMetricTimer: Foundation.Timer? + public private(set) var hasAtLeast3Messages: Bool = false public var hasAtLeast3MessagesUpdated: ((Bool) -> Void)? @@ -1139,6 +1144,9 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto if let maxMessage { strongSelf.updateMaxVisibleReadIncomingMessageIndex(maxMessage) } + + strongSelf.messageReadMetricsTracker?.reportUserActivity() + strongSelf.updateMessageReadTracker() } } @@ -1239,6 +1247,16 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto self.genericReactionEffectDisposable?.dispose() self.adMessagesDisposable?.dispose() self.presentationDataDisposable?.dispose() + self.messageReadMetricsTrackerPendingMetricTimer?.invalidate() + self.messageReadMetricsTrackerDisposable?.dispose() + self.messageReadMetricsTracker = nil + if !self.messageReadMetricsTrackerPendingMetrics.isEmpty { + let metrics = self.messageReadMetricsTrackerPendingMetrics + self.messageReadMetricsTrackerPendingMetrics.removeAll() + if !metrics.isEmpty, let peerId = self.chatLocation.peerId { + let _ = self.context.engine.messages.reportPeerReadMetrics(peerId: peerId, metrics: metrics).startStandalone() + } + } } public func updateTag(tag: HistoryViewInputTag?) { @@ -2426,6 +2444,7 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto strongSelf.canReadHistoryValue = value strongSelf.controllerInteraction.canReadHistory = value strongSelf.updateReadHistoryActions() + strongSelf.messageReadMetricsTracker?.setIsActive(strongSelf.canReadHistoryValue) if strongSelf.canReadHistoryValue && !strongSelf.suspendReadingReactions && !strongSelf.messageIdsScheduledForMarkAsSeen.isEmpty { let messageIds = strongSelf.messageIdsScheduledForMarkAsSeen @@ -3325,6 +3344,87 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto self.isInteractivelyScrollingPromise.set(true) self.isInteractivelyScrollingPromise.set(false) } + + self.updateMessageReadTracker() + } + + private func updateMessageReadTracker() { + guard let historyView = self.historyView?.originalView else { + return + } + var channelPeer: TelegramChannel? + for entry in historyView.additionalData { + if case let .peer(_, value) = entry { + if let channel = value as? TelegramChannel, case .broadcast = channel.info { + channelPeer = channel + } + } + } + guard let channelPeer else { + return + } + + let messageReadMetricsTracker: MessageReadMetricsTracker + if let current = self.messageReadMetricsTracker { + messageReadMetricsTracker = current + } else { + messageReadMetricsTracker = MessageReadMetricsTracker() + self.messageReadMetricsTracker = messageReadMetricsTracker + + self.messageReadMetricsTrackerDisposable = (messageReadMetricsTracker.completedMetrics |> deliverOnMainQueue).startStrict(next: { [weak self] metric in + guard let self else { + return + } + self.messageReadMetricsTrackerPendingMetrics.append(metric) + + if self.messageReadMetricsTrackerPendingMetricTimer == nil { + self.messageReadMetricsTrackerPendingMetricTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false, block: { [weak self] timer in + guard let self else { + return + } + if self.messageReadMetricsTrackerPendingMetricTimer === timer { + self.messageReadMetricsTrackerPendingMetricTimer = nil + } + let metrics = self.messageReadMetricsTrackerPendingMetrics + self.messageReadMetricsTrackerPendingMetrics.removeAll() + + if !metrics.isEmpty { + let _ = self.context.engine.messages.reportPeerReadMetrics(peerId: channelPeer.id, metrics: metrics).startStandalone() + } + }) + } + }) + } + + messageReadMetricsTracker.setIsActive(self.canReadHistoryValue) + + var visibleMessages: [MessageReadMetricsTracker.VisibleMessageEntry] = [] + let viewportMinY = self.insets.top + let viewportMaxY = max(1.0, self.visibleSize.height - self.insets.bottom) + let viewportHeight = viewportMaxY - viewportMinY + self.forEachVisibleMessageItemNode { itemView in + guard let item = itemView.item else { + return + } + let messageId = item.message.id + if messageId.namespace != Namespaces.Message.Cloud { + return + } + let frame = itemView.frame + let visibleStart = max(frame.minY, viewportMinY) + let visibleEnd = min(frame.maxY, viewportMaxY) + if visibleStart >= visibleEnd { + return + } + visibleMessages.append(MessageReadMetricsTracker.VisibleMessageEntry( + messageId: messageId, + visibleTopPx: visibleStart - frame.minY, + visibleBottomPx: visibleEnd - frame.minY, + postHeightPx: frame.height, + viewportHeightPx: viewportHeight + )) + } + messageReadMetricsTracker.updateVisibleMessages(visibleMessages) } public func scrollScreenToTop() { diff --git a/submodules/TelegramUI/Sources/ChatHistoryViewForLocation.swift b/submodules/TelegramUI/Sources/ChatHistoryViewForLocation.swift index b293723063..00ba078433 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryViewForLocation.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryViewForLocation.swift @@ -461,6 +461,9 @@ func fetchAndPreloadReplyThreadInfo(context: AccountContext, subject: ReplyThrea case .Loading: return nil case let .HistoryView(view, _, _, _, _, _, _): + if view.isLoading { + return nil + } return view.entries.isEmpty } } diff --git a/submodules/TelegramUI/Sources/ChatInterfaceTitlePanelNodes.swift b/submodules/TelegramUI/Sources/ChatInterfaceTitlePanelNodes.swift index 315d8dbb5c..2dcee15350 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceTitlePanelNodes.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceTitlePanelNodes.swift @@ -260,6 +260,7 @@ func headerTopicsPanelForChatPresentationInterfaceState(_ chatPresentationInterf strings: chatPresentationInterfaceState.strings, peerId: peerId, kind: .monoforum, + location: chatPresentationInterfaceState.persistentData.topicListPanelLocation == .top ? .top : .bottom, topicId: chatPresentationInterfaceState.chatLocation.threadId, controller: { [weak interfaceInteraction] in return interfaceInteraction?.chatController() @@ -290,6 +291,7 @@ func headerTopicsPanelForChatPresentationInterfaceState(_ chatPresentationInterf strings: chatPresentationInterfaceState.strings, peerId: peerId, kind: .forum, + location: chatPresentationInterfaceState.persistentData.topicListPanelLocation == .top ? .top : .bottom, topicId: chatPresentationInterfaceState.chatLocation.threadId, controller: { [weak interfaceInteraction] in return interfaceInteraction?.chatController() @@ -322,6 +324,7 @@ func headerTopicsPanelForChatPresentationInterfaceState(_ chatPresentationInterf strings: chatPresentationInterfaceState.strings, peerId: peerId, kind: .botForum(forumManagedByUser: botInfo.flags.contains(.forumManagedByUser)), + location: chatPresentationInterfaceState.persistentData.topicListPanelLocation == .top ? .top : .bottom, topicId: chatPresentationInterfaceState.chatLocation.threadId, controller: { [weak interfaceInteraction] in return interfaceInteraction?.chatController() @@ -374,7 +377,7 @@ func floatingTopicsPanelForChatPresentationInterfaceState(_ chatPresentationInte theme: chatPresentationInterfaceState.theme, preferClearGlass: chatPresentationInterfaceState.preferredGlassType == .clear, strings: chatPresentationInterfaceState.strings, - location: topicListDisplayModeOnTheSide ? .side : .top, + location: .side, peerId: peerId, kind: .monoforum, topicId: chatPresentationInterfaceState.chatLocation.threadId, @@ -406,7 +409,7 @@ func floatingTopicsPanelForChatPresentationInterfaceState(_ chatPresentationInte theme: chatPresentationInterfaceState.theme, preferClearGlass: chatPresentationInterfaceState.preferredGlassType == .clear, strings: chatPresentationInterfaceState.strings, - location: topicListDisplayModeOnTheSide ? .side : .top, + location: .side, peerId: peerId, kind: .forum, topicId: chatPresentationInterfaceState.chatLocation.threadId, @@ -440,7 +443,7 @@ func floatingTopicsPanelForChatPresentationInterfaceState(_ chatPresentationInte theme: chatPresentationInterfaceState.theme, preferClearGlass: chatPresentationInterfaceState.preferredGlassType == .clear, strings: chatPresentationInterfaceState.strings, - location: topicListDisplayModeOnTheSide ? .side : .top, + location: .side, peerId: peerId, kind: .botForum(forumManagedByUser: botInfo.flags.contains(.forumManagedByUser)), topicId: chatPresentationInterfaceState.chatLocation.threadId, diff --git a/submodules/TelegramUI/Sources/ChatSearchResultsContollerNode.swift b/submodules/TelegramUI/Sources/ChatSearchResultsContollerNode.swift index 69fc59bb37..447afbdc96 100644 --- a/submodules/TelegramUI/Sources/ChatSearchResultsContollerNode.swift +++ b/submodules/TelegramUI/Sources/ChatSearchResultsContollerNode.swift @@ -197,7 +197,7 @@ class ChatSearchResultsControllerNode: ViewControllerTracingNode, ASScrollViewDe self.animationCache = context.animationCache self.animationRenderer = context.animationRenderer - self.listNode = ListView() + self.listNode = ListViewImpl() self.listNode.verticalScrollIndicatorColor = self.presentationData.theme.list.scrollIndicatorColor self.listNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string diff --git a/submodules/TelegramUI/Sources/CommandChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/CommandChatInputContextPanelNode.swift index cb8f712299..534f29ac7f 100644 --- a/submodules/TelegramUI/Sources/CommandChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/CommandChatInputContextPanelNode.swift @@ -297,7 +297,7 @@ final class CommandChatInputContextPanelNode: ChatInputContextPanelNode { self.backgroundView = GlassBackgroundView() self.backgroundView.layer.anchorPoint = CGPoint() - self.listView = ListView() + self.listView = ListViewImpl() self.listView.anchorPoint = CGPoint() self.listView.isOpaque = false self.listView.stackFromBottom = true diff --git a/submodules/TelegramUI/Sources/CommandMenuChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/CommandMenuChatInputContextPanelNode.swift index e0c4c9f491..d424abb064 100644 --- a/submodules/TelegramUI/Sources/CommandMenuChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/CommandMenuChatInputContextPanelNode.swift @@ -79,7 +79,7 @@ final class CommandMenuChatInputContextPanelNode: ChatInputContextPanelNode { self.backgroundView = GlassBackgroundView() self.backgroundView.layer.anchorPoint = CGPoint() - self.listView = ListView() + self.listView = ListViewImpl() self.listView.clipsToBounds = false self.listView.isOpaque = false self.listView.stackFromBottom = true diff --git a/submodules/TelegramUI/Sources/EmojisChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/EmojisChatInputContextPanelNode.swift index e66b38c535..c85b9b2421 100644 --- a/submodules/TelegramUI/Sources/EmojisChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/EmojisChatInputContextPanelNode.swift @@ -152,7 +152,7 @@ final class EmojisChatInputContextPanelNode: ChatInputContextPanelNode { self.clippingNode = ASDisplayNode() self.clippingNode.clipsToBounds = true - self.listView = ListView() + self.listView = ListViewImpl() self.listView.isOpaque = false self.listView.view.disablesInteractiveTransitionGestureRecognizer = true self.listView.transform = CATransform3DMakeRotation(-CGFloat.pi / 2.0, 0.0, 0.0, 1.0) diff --git a/submodules/TelegramUI/Sources/HashtagChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/HashtagChatInputContextPanelNode.swift index fe9ce4eaa2..5a57356c20 100644 --- a/submodules/TelegramUI/Sources/HashtagChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/HashtagChatInputContextPanelNode.swift @@ -96,7 +96,7 @@ final class HashtagChatInputContextPanelNode: ChatInputContextPanelNode { self.backgroundView = GlassBackgroundView() self.backgroundView.layer.anchorPoint = CGPoint() - self.listView = ListView() + self.listView = ListViewImpl() self.listView.isOpaque = false self.listView.stackFromBottom = true self.listView.limitHitTestToNodes = true diff --git a/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputContextPanelNode.swift index 17b1fbd232..c76495b3ec 100644 --- a/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputContextPanelNode.swift @@ -96,7 +96,7 @@ final class HorizontalListContextResultsChatInputContextPanelNode: ChatInputCont private let batchVideoContext: QueueLocalObject override init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize, chatPresentationContext: ChatPresentationContext) { - self.listView = ListView() + self.listView = ListViewImpl() self.listView.isOpaque = true self.listView.backgroundColor = theme.list.plainBackgroundColor self.listView.transform = CATransform3DMakeRotation(-CGFloat(CGFloat.pi / 2.0), 0.0, 0.0, 1.0) diff --git a/submodules/TelegramUI/Sources/MediaManager.swift b/submodules/TelegramUI/Sources/MediaManager.swift index a15b8172b7..f838410fdf 100644 --- a/submodules/TelegramUI/Sources/MediaManager.swift +++ b/submodules/TelegramUI/Sources/MediaManager.swift @@ -131,6 +131,7 @@ public final class MediaManagerImpl: NSObject, MediaManager { } |> deliverOnMainQueue) } else { self.musicMediaPlayerStateValue.set(.single(nil)) + self.musicListenTracker?.playerClosed() } } } @@ -186,7 +187,10 @@ public final class MediaManagerImpl: NSObject, MediaManager { private let globalControlsArtwork = Promise<(Account, SharedMediaPlaybackAlbumArt)?>(nil) private let globalControlsStatusDisposable = MetaDisposable() private let globalAudioSessionForegroundDisposable = MetaDisposable() - + + private var musicListenTracker: MusicListenTracker? + private let musicListenTrackingDisposable = MetaDisposable() + public let universalVideoManager: UniversalVideoManager = UniversalVideoManagerImpl() public let galleryHiddenMediaManager: GalleryHiddenMediaManager = GalleryHiddenMediaManagerImpl() @@ -429,7 +433,12 @@ public final class MediaManagerImpl: NSObject, MediaManager { } } })) - + + self.musicListenTrackingDisposable.set((self.musicMediaPlayerState + |> deliverOnMainQueue).startStrict(next: { [weak self] stateAndType in + self?.musicListenTracker?.update(with: stateAndType) + })) + self.globalAudioSessionForegroundDisposable.set((shouldKeepAudioSession |> deliverOnMainQueue).startStrict(next: { [weak self] value in guard let strongSelf = self else { return @@ -446,6 +455,7 @@ public final class MediaManagerImpl: NSObject, MediaManager { self.globalControlsStatusDisposable.dispose() self.setPlaylistByTypeDisposables.dispose() self.mediaPlaybackStateDisposable.dispose() + self.musicListenTrackingDisposable.dispose() self.globalAudioSessionForegroundDisposable.dispose() self.voiceMediaPlayerStateDisposable.dispose() } @@ -550,6 +560,7 @@ public final class MediaManagerImpl: NSObject, MediaManager { strongSelf.musicMediaPlayer?.stop() let musicMediaPlayer = SharedMediaPlayer(context: context, mediaManager: strongSelf, inForeground: strongSelf.inForeground, account: context.account, audioSession: strongSelf.audioSession, overlayMediaManager: strongSelf.overlayMediaManager, playlist: playlist, initialOrder: settings.order, initialLooping: settings.looping, initialPlaybackRate: storedState?.playbackRate ?? .x1, playerIndex: nextPlayerIndex, controlPlaybackWithProximity: false, type: type, continueInstantVideoLoopAfterFinish: true) strongSelf.musicMediaPlayer = musicMediaPlayer + strongSelf.musicListenTracker = MusicListenTracker(engine: context.engine) musicMediaPlayer.cancelled = { [weak musicMediaPlayer] in if let strongSelf = self, let musicMediaPlayer = musicMediaPlayer, musicMediaPlayer === strongSelf.musicMediaPlayer { musicMediaPlayer.stop() diff --git a/submodules/TelegramUI/Sources/MentionChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/MentionChatInputContextPanelNode.swift index 16b9326cbc..c0d7a4c6ca 100644 --- a/submodules/TelegramUI/Sources/MentionChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/MentionChatInputContextPanelNode.swift @@ -80,7 +80,7 @@ final class MentionChatInputContextPanelNode: ChatInputContextPanelNode { self.backgroundView = GlassBackgroundView() self.backgroundView.layer.anchorPoint = CGPoint() - self.listView = ListView() + self.listView = ListViewImpl() self.listView.isOpaque = false self.listView.stackFromBottom = true self.listView.limitHitTestToNodes = true diff --git a/submodules/TelegramUI/Sources/MusicListenTracker.swift b/submodules/TelegramUI/Sources/MusicListenTracker.swift new file mode 100644 index 0000000000..0b9d0de397 --- /dev/null +++ b/submodules/TelegramUI/Sources/MusicListenTracker.swift @@ -0,0 +1,198 @@ +import Foundation +import SwiftSignalKit +import TelegramCore +import AccountContext +import UniversalMediaPlayer + +final class MusicListenTracker { + private let engine: TelegramEngine + + // Current listening session + private var currentFileReference: FileMediaReference? + private var currentItemStableId: AnyHashable? + private var trackDuration: Double = 0 + + // Position-based duration accumulation + private var accumulatedDuration: Double = 0 + private var lastPosition: Double = 0 + private var lastGenerationTimestamp: Double = 0 + private var lastBaseRate: Double = 1.0 + private var lastSeekId: Int = 0 + private var isPlaying: Bool = false + + // Pause tracking + private var pauseTimer: SwiftSignalKit.Timer? + + // Disposables + private let reportDisposable = MetaDisposable() + + private static let pauseTimeoutSeconds: Double = 60.0 + + init(engine: TelegramEngine) { + self.engine = engine + } + + deinit { + self.pauseTimer?.invalidate() + self.reportDisposable.dispose() + } + + // MARK: - Public Interface + + /// Called by MediaManagerImpl with each music player state update. + func update(with stateAndType: (Account, SharedMediaPlayerItemPlaybackStateOrLoading, MediaManagerPlayerType)?) { + assert(Queue.mainQueue().isCurrent()) + guard let (_, stateOrLoading, type) = stateAndType, type == .music else { + // Player closed or switched to non-music — report and clear + self.reportAndReset() + return + } + + guard case let .state(state) = stateOrLoading else { + return // loading state, ignore + } + + let itemStableId = state.item.stableId + + // Detect track switch + if let currentId = self.currentItemStableId, currentId != itemStableId { + self.reportAndReset() + } + + // Start new session if needed + if self.currentItemStableId == nil { + self.startSession(item: state.item, status: state.status) + } + + // Update current item reference + self.currentItemStableId = itemStableId + + // Process playback status + self.processStatus(state.status) + } + + /// Called when musicMediaPlayer is set to nil (player closed). + func playerClosed() { + assert(Queue.mainQueue().isCurrent()) + self.reportAndReset() + } + + // MARK: - Session Management + + private func startSession(item: SharedMediaPlaylistItem, status: MediaPlayerStatus) { + // Extract FileMediaReference from the playlist item + guard let playbackData = item.playbackData, + case let .telegramFile(fileReference, _, _) = playbackData.source else { + return + } + + self.currentFileReference = fileReference + self.trackDuration = status.duration + self.accumulatedDuration = 0 + self.lastPosition = status.timestamp + self.lastGenerationTimestamp = status.generationTimestamp + self.lastBaseRate = status.baseRate + self.lastSeekId = status.seekId + self.isPlaying = false + self.pauseTimer?.invalidate() + self.pauseTimer = nil + } + + private func processStatus(_ status: MediaPlayerStatus) { + let wasPlaying = self.isPlaying + let nowPlaying: Bool + + switch status.status { + case .playing: + nowPlaying = true + case let .buffering(_, whilePlaying, _, _): + nowPlaying = whilePlaying + case .paused: + nowPlaying = false + } + + // Accumulate duration if we were playing (and no seek occurred) + let seekOccurred = status.seekId != self.lastSeekId + if wasPlaying && !seekOccurred { + let positionDelta = status.timestamp - self.lastPosition + let wallDelta = status.generationTimestamp - self.lastGenerationTimestamp + let maxExpected = wallDelta * max(self.lastBaseRate, 0.5) * 1.5 + + if positionDelta > 0 && (wallDelta <= 0 || positionDelta <= maxExpected) { + self.accumulatedDuration += positionDelta + } + } + + self.lastPosition = status.timestamp + self.lastGenerationTimestamp = status.generationTimestamp + self.lastBaseRate = status.baseRate + self.lastSeekId = status.seekId + self.trackDuration = status.duration + + // Handle play/pause transitions + if nowPlaying && !wasPlaying { + // Resumed playing + self.pauseTimer?.invalidate() + self.pauseTimer = nil + } else if !nowPlaying && wasPlaying { + // Just paused — start pause timer + self.startPauseTimer() + } + + self.isPlaying = nowPlaying + } + + // MARK: - Pause Timer + + private func startPauseTimer() { + self.pauseTimer?.invalidate() + let timer = SwiftSignalKit.Timer(timeout: MusicListenTracker.pauseTimeoutSeconds, repeat: false, completion: { [weak self] in + self?.pauseTimerFired() + }, queue: Queue.mainQueue()) + self.pauseTimer = timer + timer.start() + } + + private func pauseTimerFired() { + // Paused > 60s — report current session + self.reportAndReset() + } + + // MARK: - Reporting + + private func reportAndReset() { + self.pauseTimer?.invalidate() + self.pauseTimer = nil + + guard let fileReference = self.currentFileReference else { + self.resetSession() + return + } + + let duration = self.accumulatedDuration + let trackDuration = self.trackDuration + + // Check minimum threshold: min(30s, 90% of track) + let threshold = min(30.0, trackDuration * 0.9) + if duration >= threshold && duration > 0 && trackDuration > 0 { + let reportedDuration = Int(duration) + self.reportDisposable.set( + self.engine.messages.reportMusicListened(file: fileReference, duration: reportedDuration).startStrict() + ) + } + + self.resetSession() + } + + private func resetSession() { + self.currentFileReference = nil + self.currentItemStableId = nil + self.trackDuration = 0 + self.accumulatedDuration = 0 + self.lastPosition = 0 + self.lastGenerationTimestamp = 0 + self.lastBaseRate = 1.0 + self.lastSeekId = 0 + self.isPlaying = false + } +} diff --git a/submodules/TelegramUI/Sources/VerticalListContextResultsChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/VerticalListContextResultsChatInputContextPanelNode.swift index 1b037fed07..5ef372684d 100644 --- a/submodules/TelegramUI/Sources/VerticalListContextResultsChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/VerticalListContextResultsChatInputContextPanelNode.swift @@ -144,7 +144,7 @@ final class VerticalListContextResultsChatInputContextPanelNode: ChatInputContex self.backgroundView = GlassBackgroundView() self.backgroundView.layer.anchorPoint = CGPoint() - self.listView = ListView() + self.listView = ListViewImpl() self.listView.isOpaque = false self.listView.stackFromBottom = true self.listView.limitHitTestToNodes = true diff --git a/submodules/WebSearchUI/Sources/WebSearchControllerNode.swift b/submodules/WebSearchUI/Sources/WebSearchControllerNode.swift index b1fcd23e6e..75d2142a62 100644 --- a/submodules/WebSearchUI/Sources/WebSearchControllerNode.swift +++ b/submodules/WebSearchUI/Sources/WebSearchControllerNode.swift @@ -226,7 +226,7 @@ class WebSearchControllerNode: ASDisplayNode { self.gridNode = GridNode() self.gridNode.backgroundColor = theme.list.plainBackgroundColor - self.recentQueriesNode = ListView() + self.recentQueriesNode = ListViewImpl() self.recentQueriesNode.backgroundColor = theme.list.plainBackgroundColor self.recentQueriesNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string