mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-07-06 03:33:41 +02:00
Merge branch 'master' of gitlab.com:peter-iakovlev/telegram-ios
This commit is contained in:
commit
88c60997ca
86 changed files with 1483 additions and 407 deletions
19
.vscode/launch.json
vendored
19
.vscode/launch.json
vendored
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
110
.vscode/tasks.json
vendored
110
.vscode/tasks.json
vendored
|
|
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
Subproject commit e2e532ab9d9296343ce77c93286dd7d472947157
|
||||
Subproject commit feea27cfc88eccc58af0cfe5674444e945cfb75f
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
124
submodules/Display/Source/ListViewProtocol.swift
Normal file
124
submodules/Display/Source/ListViewProtocol.swift
Normal file
|
|
@ -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<Bool, NoError> { 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1215,7 +1215,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
|
|||
playbackControlsIsSeekable = seekable
|
||||
playbackControlsIsPlaying = !paused
|
||||
}
|
||||
if !self.areControlsVisible || hideControls {
|
||||
if !self.controlsVisibility() || hideControls {
|
||||
playbackControlsIsVisible = false
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Never, NoError> {
|
||||
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<Api.Bool, NoError> in
|
||||
return .single(.boolFalse)
|
||||
}
|
||||
|> ignoreValues
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<TelegramMessageReadMetric>()
|
||||
public var completedMetrics: Signal<TelegramMessageReadMetric, NoError> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -180,7 +180,8 @@ public final class ChatFloatingTopicsPanel: Component {
|
|||
}
|
||||
}
|
||||
|
||||
if case .top = component.location {
|
||||
switch component.location {
|
||||
case .top, .bottom:
|
||||
let topPanel: ComponentView<ChatSidePanelEnvironment>
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<VertexTemplate>
|
||||
var faces: ContiguousArray<MeshTransform.Face>
|
||||
}
|
||||
|
||||
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<GlassMeshTemplate.VertexTemplate>()
|
||||
var faces = ContiguousArray<MeshTransform.Face>()
|
||||
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..<numRows {
|
||||
for col in 0..<numCols {
|
||||
addQuadFace(
|
||||
indexGrid[row][col],
|
||||
indexGrid[row][col + 1],
|
||||
indexGrid[row + 1][col + 1],
|
||||
indexGrid[row + 1][col]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Corner wedge template
|
||||
func buildCornerTemplate(
|
||||
centerBaseX: CGFloat, centerScaleX: CGFloat,
|
||||
centerBaseY: CGFloat, centerScaleY: CGFloat,
|
||||
startAngle: CGFloat, endAngle: CGFloat
|
||||
) {
|
||||
let ringRadials = outerToInner.filter { $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<UInt8>,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
12
submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarBottomIcon.imageset/Contents.json
vendored
Normal file
12
submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarBottomIcon.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "tabsdown.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarBottomIcon.imageset/tabsdown.pdf
vendored
Normal file
BIN
submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarBottomIcon.imageset/tabsdown.pdf
vendored
Normal file
Binary file not shown.
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "list_24.pdf",
|
||||
"filename" : "tabsleft.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
|
|
|
|||
Binary file not shown.
BIN
submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarIcon.imageset/tabsleft.pdf
vendored
Normal file
BIN
submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarIcon.imageset/tabsleft.pdf
vendored
Normal file
Binary file not shown.
12
submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarTopIcon.imageset/Contents.json
vendored
Normal file
12
submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarTopIcon.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "tabsup.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarTopIcon.imageset/tabsup.pdf
vendored
Normal file
BIN
submodules/TelegramUI/Images.xcassets/Chat/Title Panels/SidebarTopIcon.imageset/tabsup.pdf
vendored
Normal file
Binary file not shown.
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ final class HorizontalListContextResultsChatInputContextPanelNode: ChatInputCont
|
|||
private let batchVideoContext: QueueLocalObject<BatchVideoRenderingContext>
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
198
submodules/TelegramUI/Sources/MusicListenTracker.swift
Normal file
198
submodules/TelegramUI/Sources/MusicListenTracker.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue