diff --git a/submodules/AvatarNode/BUILD b/submodules/AvatarNode/BUILD index b2b1a33bdf..1b43b7e3ff 100644 --- a/submodules/AvatarNode/BUILD +++ b/submodules/AvatarNode/BUILD @@ -20,6 +20,8 @@ swift_library( "//submodules/Emoji:Emoji", "//submodules/TinyThumbnail:TinyThumbnail", "//submodules/FastBlur:FastBlur", + "//submodules/ComponentFlow", + "//submodules/TelegramUI/Components/Stories/AvatarStoryIndicatorComponent", ], visibility = [ "//visibility:public", diff --git a/submodules/AvatarNode/Sources/AvatarNode.swift b/submodules/AvatarNode/Sources/AvatarNode.swift index eab03ee66f..0f3e28f6b6 100644 --- a/submodules/AvatarNode/Sources/AvatarNode.swift +++ b/submodules/AvatarNode/Sources/AvatarNode.swift @@ -10,6 +10,8 @@ import AppBundle import AccountContext import Emoji import Accelerate +import ComponentFlow +import AvatarStoryIndicatorComponent private let deletedIcon = UIImage(bundleImageName: "Avatar/DeletedIcon")?.precomposed() private let phoneIcon = generateTintedImage(image: UIImage(bundleImageName: "Avatar/PhoneIcon"), color: .white) @@ -224,102 +226,521 @@ public final class AvatarNode: ASDisplayNode { UIColor(rgb: 0x2a9ef1), UIColor(rgb: 0x72d5fd) ] - public var font: UIFont { - didSet { - if oldValue.pointSize != font.pointSize { - if let parameters = self.parameters { - self.parameters = AvatarNodeParameters(theme: parameters.theme, accountPeerId: parameters.accountPeerId, peerId: parameters.peerId, colors: parameters.colors, letters: parameters.letters, font: self.font, icon: parameters.icon, explicitColorIndex: parameters.explicitColorIndex, hasImage: parameters.hasImage, clipStyle: parameters.clipStyle) - } - - if !self.displaySuspended { - self.setNeedsDisplay() - } - } - } - } - private var parameters: AvatarNodeParameters? - private var theme: PresentationTheme? - private var overrideImage: AvatarNodeImageOverride? - public let imageNode: ImageNode - private var animationBackgroundNode: ImageNode? - private var animationNode: AnimationNode? - public var editOverlayNode: AvatarEditOverlayNode? - - private let imageReadyDisposable = MetaDisposable() - private var state: AvatarNodeState = .empty - - public var unroundedImage: UIImage? - private var currentImage: UIImage? - - public var badgeView: AvatarBadgeView? { - didSet { - if self.badgeView !== oldValue { - if let badgeView = self.badgeView, let parameters = self.parameters { - if parameters.hasImage { - if let currentImage = self.currentImage { - badgeView.update(content: .image(currentImage)) - } - } else { - let badgeColor: UIColor - if parameters.colors.isEmpty { - badgeColor = .white - } else { - badgeColor = parameters.colors[parameters.colors.count - 1] - } - badgeView.update(content: .color(badgeColor)) + public final class ContentNode: ASDisplayNode { + public var font: UIFont { + didSet { + if oldValue.pointSize != font.pointSize { + if let parameters = self.parameters { + self.parameters = AvatarNodeParameters(theme: parameters.theme, accountPeerId: parameters.accountPeerId, peerId: parameters.peerId, colors: parameters.colors, letters: parameters.letters, font: self.font, icon: parameters.icon, explicitColorIndex: parameters.explicitColorIndex, hasImage: parameters.hasImage, clipStyle: parameters.clipStyle) + } + + if !self.displaySuspended { + self.setNeedsDisplay() } } } } + private var parameters: AvatarNodeParameters? + private var theme: PresentationTheme? + private var overrideImage: AvatarNodeImageOverride? + public let imageNode: ImageNode + private var animationBackgroundNode: ImageNode? + private var animationNode: AnimationNode? + public var editOverlayNode: AvatarEditOverlayNode? + + private let imageReadyDisposable = MetaDisposable() + fileprivate var state: AvatarNodeState = .empty + + public var unroundedImage: UIImage? + private var currentImage: UIImage? + + public var badgeView: AvatarBadgeView? { + didSet { + if self.badgeView !== oldValue { + if let badgeView = self.badgeView, let parameters = self.parameters { + if parameters.hasImage { + if let currentImage = self.currentImage { + badgeView.update(content: .image(currentImage)) + } + } else { + let badgeColor: UIColor + if parameters.colors.isEmpty { + badgeColor = .white + } else { + badgeColor = parameters.colors[parameters.colors.count - 1] + } + badgeView.update(content: .color(badgeColor)) + } + } + } + } + } + + private let imageReady = Promise(false) + public var ready: Signal { + let imageReady = self.imageReady + return Signal { subscriber in + return imageReady.get().start(next: { next in + if next { + subscriber.putCompletion() + } + }) + } + } + + public init(font: UIFont) { + self.font = font + self.imageNode = ImageNode(enableHasImage: true, enableAnimatedTransition: true) + + super.init() + + self.isOpaque = false + self.displaysAsynchronously = true + + self.imageNode.isLayerBacked = true + self.addSubnode(self.imageNode) + + self.imageNode.contentUpdated = { [weak self] image in + guard let self else { + return + } + + self.currentImage = image + + guard let badgeView = self.badgeView, let parameters = self.parameters else { + return + } + + if parameters.hasImage, let image { + badgeView.update(content: .image(image)) + } + } + } + + override public func didLoad() { + super.didLoad() + + if #available(iOSApplicationExtension 11.0, iOS 11.0, *), !self.isLayerBacked { + self.view.accessibilityIgnoresInvertColors = true + } + } + + public func updateSize(size: CGSize) { + self.imageNode.frame = CGRect(origin: CGPoint(), size: size) + self.editOverlayNode?.frame = self.imageNode.frame + if !self.displaySuspended { + self.setNeedsDisplay() + self.editOverlayNode?.setNeedsDisplay() + } + } + + public func playArchiveAnimation() { + guard let theme = self.theme else { + return + } + + var iconColor = theme.chatList.unpinnedArchiveAvatarColor.foregroundColor + var backgroundColor = theme.chatList.unpinnedArchiveAvatarColor.backgroundColors.topColor + let animationBackgroundNode = ASImageNode() + animationBackgroundNode.isUserInteractionEnabled = false + animationBackgroundNode.frame = self.imageNode.frame + if let overrideImage = self.overrideImage, case let .archivedChatsIcon(hiddenByDefault) = overrideImage { + let backgroundColors: (UIColor, UIColor) + if hiddenByDefault { + backgroundColors = theme.chatList.unpinnedArchiveAvatarColor.backgroundColors.colors + iconColor = theme.chatList.unpinnedArchiveAvatarColor.foregroundColor + } else { + backgroundColors = theme.chatList.pinnedArchiveAvatarColor.backgroundColors.colors + iconColor = theme.chatList.pinnedArchiveAvatarColor.foregroundColor + } + let colors: NSArray = [backgroundColors.1.cgColor, backgroundColors.0.cgColor] + backgroundColor = backgroundColors.1.mixedWith(backgroundColors.0, alpha: 0.5) + animationBackgroundNode.image = generateGradientFilledCircleImage(diameter: self.imageNode.frame.width, colors: colors) + } + + self.addSubnode(animationBackgroundNode) + + let animationNode = AnimationNode(animation: "anim_archiveAvatar", colors: ["box1.box1.Fill 1": iconColor, "box3.box3.Fill 1": iconColor, "box2.box2.Fill 1": backgroundColor], scale: 0.1653828) + animationNode.isUserInteractionEnabled = false + animationNode.completion = { [weak animationBackgroundNode, weak self] in + self?.imageNode.isHidden = false + animationBackgroundNode?.removeFromSupernode() + } + animationBackgroundNode.addSubnode(animationNode) + + animationBackgroundNode.layer.animateScale(from: 1.0, to: 1.07, duration: 0.12, removeOnCompletion: false, completion: { [weak animationBackgroundNode] finished in + animationBackgroundNode?.layer.animateScale(from: 1.07, to: 1.0, duration: 0.12, removeOnCompletion: false) + }) + + if var size = animationNode.preferredSize() { + size = CGSize(width: ceil(size.width), height: ceil(size.height)) + animationNode.frame = CGRect(x: floor((self.bounds.width - size.width) / 2.0), y: floor((self.bounds.height - size.height) / 2.0) + 1.0, width: size.width, height: size.height) + animationNode.play() + } + self.imageNode.isHidden = true + } + + public func setPeer( + context genericContext: AccountContext, + account: Account? = nil, + theme: PresentationTheme, + peer: EnginePeer?, + authorOfMessage: MessageReference? = nil, + overrideImage: AvatarNodeImageOverride? = nil, + emptyColor: UIColor? = nil, + clipStyle: AvatarNodeClipStyle = .round, + synchronousLoad: Bool = false, + displayDimensions: CGSize = CGSize(width: 60.0, height: 60.0), + storeUnrounded: Bool = false + ) { + var synchronousLoad = synchronousLoad + var representation: TelegramMediaImageRepresentation? + var icon = AvatarNodeIcon.none + if let overrideImage = overrideImage { + switch overrideImage { + case .none: + representation = nil + case let .image(image): + representation = image + synchronousLoad = false + case .savedMessagesIcon: + representation = nil + icon = .savedMessagesIcon + case .repliesIcon: + representation = nil + icon = .repliesIcon + case let .archivedChatsIcon(hiddenByDefault): + representation = nil + icon = .archivedChatsIcon(hiddenByDefault: hiddenByDefault) + case let .editAvatarIcon(forceNone): + representation = forceNone ? nil : peer?.smallProfileImage + icon = .editAvatarIcon + case .deletedIcon: + representation = nil + icon = .deletedIcon + case .phoneIcon: + representation = nil + icon = .phoneIcon + } + } else if peer?.restrictionText(platform: "ios", contentSettings: genericContext.currentContentSettings.with { $0 }) == nil { + representation = peer?.smallProfileImage + } + let updatedState: AvatarNodeState = .peerAvatar(peer?.id ?? EnginePeer.Id(0), peer?.displayLetters ?? [], representation, clipStyle) + if updatedState != self.state || overrideImage != self.overrideImage || theme !== self.theme { + self.state = updatedState + self.overrideImage = overrideImage + self.theme = theme + + let parameters: AvatarNodeParameters + + let account = account ?? genericContext.account + + if let peer = peer, let signal = peerAvatarImage(account: account, peerReference: PeerReference(peer._asPeer()), authorOfMessage: authorOfMessage, representation: representation, displayDimensions: displayDimensions, clipStyle: clipStyle, emptyColor: emptyColor, synchronousLoad: synchronousLoad, provideUnrounded: storeUnrounded) { + self.contents = nil + self.displaySuspended = true + self.imageReady.set(self.imageNode.contentReady) + self.imageNode.setSignal(signal |> beforeNext { [weak self] next in + Queue.mainQueue().async { + self?.unroundedImage = next?.1 + } + } + |> map { next -> UIImage? in + return next?.0 + }) + + if case .editAvatarIcon = icon { + if self.editOverlayNode == nil { + let editOverlayNode = AvatarEditOverlayNode() + editOverlayNode.frame = self.imageNode.frame + editOverlayNode.isUserInteractionEnabled = false + self.addSubnode(editOverlayNode) + + self.editOverlayNode = editOverlayNode + } + self.editOverlayNode?.isHidden = false + } else { + self.editOverlayNode?.isHidden = true + } + + parameters = AvatarNodeParameters(theme: theme, accountPeerId: account.peerId, peerId: peer.id, colors: calculateColors(explicitColorIndex: nil, peerId: peer.id, icon: icon, theme: theme), letters: peer.displayLetters, font: self.font, icon: icon, explicitColorIndex: nil, hasImage: true, clipStyle: clipStyle) + } else { + self.imageReady.set(.single(true)) + self.displaySuspended = false + if self.isNodeLoaded { + self.imageNode.contents = nil + } + + self.editOverlayNode?.isHidden = true + let colors = calculateColors(explicitColorIndex: nil, peerId: peer?.id ?? EnginePeer.Id(0), icon: icon, theme: theme) + parameters = AvatarNodeParameters(theme: theme, accountPeerId: account.peerId, peerId: peer?.id ?? EnginePeer.Id(0), colors: colors, letters: peer?.displayLetters ?? [], font: self.font, icon: icon, explicitColorIndex: nil, hasImage: false, clipStyle: clipStyle) + + if let badgeView = self.badgeView { + let badgeColor: UIColor + if colors.isEmpty { + badgeColor = .white + } else { + badgeColor = colors[colors.count - 1] + } + badgeView.update(content: .color(badgeColor)) + } + } + if self.parameters == nil || self.parameters != parameters { + self.parameters = parameters + self.setNeedsDisplay() + if synchronousLoad { + self.recursivelyEnsureDisplaySynchronously(true) + } + } + } + } + + public func setCustomLetters(_ letters: [String], explicitColor: AvatarNodeColorOverride? = nil, icon: AvatarNodeExplicitIcon? = nil) { + var explicitIndex: Int? + if let explicitColor = explicitColor { + switch explicitColor { + case .blue: + explicitIndex = 5 + } + } + let updatedState: AvatarNodeState = .custom(letter: letters, explicitColorIndex: explicitIndex, explicitIcon: icon) + if updatedState != self.state { + self.state = updatedState + + let parameters: AvatarNodeParameters + if let icon = icon, case .phone = icon { + parameters = AvatarNodeParameters(theme: nil, accountPeerId: nil, peerId: nil, colors: calculateColors(explicitColorIndex: explicitIndex, peerId: nil, icon: .phoneIcon, theme: nil), letters: [], font: self.font, icon: .phoneIcon, explicitColorIndex: explicitIndex, hasImage: false, clipStyle: .round) + } else { + parameters = AvatarNodeParameters(theme: nil, accountPeerId: nil, peerId: nil, colors: calculateColors(explicitColorIndex: explicitIndex, peerId: nil, icon: .none, theme: nil), letters: letters, font: self.font, icon: .none, explicitColorIndex: explicitIndex, hasImage: false, clipStyle: .round) + } + + self.displaySuspended = true + self.contents = nil + + self.imageReady.set(.single(true)) + self.displaySuspended = false + + if self.parameters == nil || self.parameters != parameters { + self.parameters = parameters + self.setNeedsDisplay() + } + } + } + + override public func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol { + return parameters ?? NSObject() + } + + @objc override public class func draw(_ bounds: CGRect, withParameters parameters: Any?, isCancelled: () -> Bool, isRasterizing: Bool) { + assertNotOnMainThread() + + let context = UIGraphicsGetCurrentContext()! + + if !isRasterizing { + context.setBlendMode(.copy) + context.setFillColor(UIColor.clear.cgColor) + context.fill(bounds) + } + + let colors: [UIColor] + if let parameters = parameters as? AvatarNodeParameters { + colors = parameters.colors + + if case .round = parameters.clipStyle { + context.beginPath() + context.addEllipse(in: CGRect(x: 0.0, y: 0.0, width: bounds.size.width, height: + bounds.size.height)) + context.clip() + } else if case .roundedRect = parameters.clipStyle { + context.beginPath() + context.addPath(UIBezierPath(roundedRect: CGRect(x: 0.0, y: 0.0, width: bounds.size.width, height: bounds.size.height), cornerRadius: floor(bounds.size.width * 0.25)).cgPath) + context.clip() + } + } else { + colors = grayscaleColors + } + + let colorsArray: NSArray = colors.map(\.cgColor) as NSArray + + var iconColor = UIColor.white + if let parameters = parameters as? AvatarNodeParameters, parameters.icon != .none { + if case let .archivedChatsIcon(hiddenByDefault) = parameters.icon, let theme = parameters.theme { + if hiddenByDefault { + iconColor = theme.chatList.unpinnedArchiveAvatarColor.foregroundColor + } else { + iconColor = theme.chatList.pinnedArchiveAvatarColor.foregroundColor + } + } + } + + var locations: [CGFloat] = [1.0, 0.0] + + let colorSpace = CGColorSpaceCreateDeviceRGB() + let gradient = CGGradient(colorsSpace: colorSpace, colors: colorsArray, locations: &locations)! + + context.drawLinearGradient(gradient, start: CGPoint(), end: CGPoint(x: 0.0, y: bounds.size.height), options: CGGradientDrawingOptions()) + + context.setBlendMode(.normal) + + if let parameters = parameters as? AvatarNodeParameters { + if case .deletedIcon = parameters.icon { + let factor = bounds.size.width / 60.0 + context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) + context.scaleBy(x: factor, y: -factor) + context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) + + if let deletedIcon = deletedIcon { + context.draw(deletedIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - deletedIcon.size.width) / 2.0), y: floor((bounds.size.height - deletedIcon.size.height) / 2.0)), size: deletedIcon.size)) + } + } else if case .phoneIcon = parameters.icon { + let factor: CGFloat = 1.0 + context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) + context.scaleBy(x: factor, y: -factor) + context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) + + if let phoneIcon = phoneIcon { + context.draw(phoneIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - phoneIcon.size.width) / 2.0), y: floor((bounds.size.height - phoneIcon.size.height) / 2.0)), size: phoneIcon.size)) + } + } else if case .savedMessagesIcon = parameters.icon { + let factor = bounds.size.width / 60.0 + context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) + context.scaleBy(x: factor, y: -factor) + context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) + + if let savedMessagesIcon = savedMessagesIcon { + context.draw(savedMessagesIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - savedMessagesIcon.size.width) / 2.0), y: floor((bounds.size.height - savedMessagesIcon.size.height) / 2.0)), size: savedMessagesIcon.size)) + } + } else if case .repliesIcon = parameters.icon { + let factor = bounds.size.width / 60.0 + context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) + context.scaleBy(x: factor, y: -factor) + context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) + + if let repliesIcon = repliesIcon { + context.draw(repliesIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - repliesIcon.size.width) / 2.0), y: floor((bounds.size.height - repliesIcon.size.height) / 2.0)), size: repliesIcon.size)) + } + } else if case .editAvatarIcon = parameters.icon, let theme = parameters.theme, !parameters.hasImage { + context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) + context.scaleBy(x: 1.0, y: -1.0) + context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) + + if bounds.width > 90.0, let editAvatarIcon = generateTintedImage(image: UIImage(bundleImageName: "Avatar/EditAvatarIconLarge"), color: theme.list.itemAccentColor) { + context.draw(editAvatarIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - editAvatarIcon.size.width) / 2.0) + 0.5, y: floor((bounds.size.height - editAvatarIcon.size.height) / 2.0) + 1.0), size: editAvatarIcon.size)) + } else if let editAvatarIcon = generateTintedImage(image: UIImage(bundleImageName: "Avatar/EditAvatarIcon"), color: theme.list.itemAccentColor) { + context.draw(editAvatarIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - editAvatarIcon.size.width) / 2.0) + 0.5, y: floor((bounds.size.height - editAvatarIcon.size.height) / 2.0) + 1.0), size: editAvatarIcon.size)) + } + } else if case .archivedChatsIcon = parameters.icon { + let factor = bounds.size.width / 60.0 + context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) + context.scaleBy(x: factor, y: -factor) + context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) + + if let archivedChatsIcon = generateTintedImage(image: archivedChatsIcon, color: iconColor) { + context.draw(archivedChatsIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - archivedChatsIcon.size.width) / 2.0), y: floor((bounds.size.height - archivedChatsIcon.size.height) / 2.0)), size: archivedChatsIcon.size)) + } + } else { + var letters = parameters.letters + if letters.count == 2 && letters[0].isSingleEmoji && letters[1].isSingleEmoji { + letters = [letters[0]] + } + + let string = letters.count == 0 ? "" : (letters[0] + (letters.count == 1 ? "" : letters[1])) + let attributedString = NSAttributedString(string: string, attributes: [NSAttributedString.Key.font: parameters.font, NSAttributedString.Key.foregroundColor: UIColor.white]) + + let line = CTLineCreateWithAttributedString(attributedString) + let lineBounds = CTLineGetBoundsWithOptions(line, .useGlyphPathBounds) + + let lineOffset = CGPoint(x: string == "B" ? 1.0 : 0.0, y: 0.0) + let lineOrigin = CGPoint(x: floorToScreenPixels(-lineBounds.origin.x + (bounds.size.width - lineBounds.size.width) / 2.0) + lineOffset.x, y: floorToScreenPixels(-lineBounds.origin.y + (bounds.size.height - lineBounds.size.height) / 2.0)) + + context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) + context.scaleBy(x: 1.0, y: -1.0) + context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) + + context.translateBy(x: lineOrigin.x, y: lineOrigin.y) + CTLineDraw(line, context) + context.translateBy(x: -lineOrigin.x, y: -lineOrigin.y) + } + } + } } - private let imageReady = Promise(false) - public var ready: Signal { - let imageReady = self.imageReady - return Signal { subscriber in - return imageReady.get().start(next: { next in - if next { - subscriber.putCompletion() - } - }) + public let contentNode: ContentNode + private var storyIndicatorTheme: PresentationTheme? + private var storyIndicator: ComponentView? + + public struct StoryStats: Equatable { + public var totalCount: Int + public var unseenCount: Int + public var hasUnseenCloseFriendsItems: Bool + + public init( + totalCount: Int, + unseenCount: Int, + hasUnseenCloseFriendsItems: Bool + ) { + self.totalCount = totalCount + self.unseenCount = unseenCount + self.hasUnseenCloseFriendsItems = hasUnseenCloseFriendsItems } } + public private(set) var storyStats: StoryStats? + + public var font: UIFont { + get { + return self.contentNode.font + } set(value) { + self.contentNode.font = value + } + } + + public var editOverlayNode: AvatarEditOverlayNode? { + get { + return self.contentNode.editOverlayNode + } set(value) { + self.contentNode.editOverlayNode = value + } + } + + public var unroundedImage: UIImage? { + get { + return self.contentNode.unroundedImage + } set(value) { + self.contentNode.unroundedImage = value + } + } + + public var badgeView: AvatarBadgeView? { + get { + return self.contentNode.badgeView + } set(value) { + self.contentNode.badgeView = value + } + } + + public var ready: Signal { + return self.contentNode.ready + } + + public var imageNode: ImageNode { + return self.contentNode.imageNode + } + public init(font: UIFont) { - self.font = font - self.imageNode = ImageNode(enableHasImage: true, enableAnimatedTransition: true) + self.contentNode = ContentNode(font: font) super.init() - self.isOpaque = false - self.displaysAsynchronously = true - - self.imageNode.isLayerBacked = true - self.addSubnode(self.imageNode) - - self.imageNode.contentUpdated = { [weak self] image in + self.onDidLoad { [weak self] _ in guard let self else { return } - - self.currentImage = image - - guard let badgeView = self.badgeView, let parameters = self.parameters else { - return - } - - if parameters.hasImage, let image { - badgeView.update(content: .image(image)) + if let storyIndicatorTheme = self.storyIndicatorTheme { + self.updateStoryIndicator(theme: storyIndicatorTheme, transition: .immediate) } } - } - - override public func didLoad() { - super.didLoad() - if #available(iOSApplicationExtension 11.0, iOS 11.0, *), !self.isLayerBacked { - self.view.accessibilityIgnoresInvertColors = true - } + self.addSubnode(self.contentNode) } override public var frame: CGRect { @@ -335,63 +756,27 @@ public final class AvatarNode: ASDisplayNode { } } + override public func nodeDidLoad() { + super.nodeDidLoad() + } + public func updateSize(size: CGSize) { - self.imageNode.frame = CGRect(origin: CGPoint(), size: size) - self.editOverlayNode?.frame = self.imageNode.frame - if !self.displaySuspended { - self.setNeedsDisplay() - self.editOverlayNode?.setNeedsDisplay() + self.contentNode.position = CGRect(origin: CGPoint(), size: size).center + self.contentNode.bounds = CGRect(origin: CGPoint(), size: size) + + self.contentNode.updateSize(size: size) + + if let storyIndicatorTheme = self.storyIndicatorTheme { + self.updateStoryIndicator(theme: storyIndicatorTheme, transition: .immediate) } } public func playArchiveAnimation() { - guard let theme = self.theme else { - return - } - - var iconColor = theme.chatList.unpinnedArchiveAvatarColor.foregroundColor - var backgroundColor = theme.chatList.unpinnedArchiveAvatarColor.backgroundColors.topColor - let animationBackgroundNode = ASImageNode() - animationBackgroundNode.isUserInteractionEnabled = false - animationBackgroundNode.frame = self.imageNode.frame - if let overrideImage = self.overrideImage, case let .archivedChatsIcon(hiddenByDefault) = overrideImage { - let backgroundColors: (UIColor, UIColor) - if hiddenByDefault { - backgroundColors = theme.chatList.unpinnedArchiveAvatarColor.backgroundColors.colors - iconColor = theme.chatList.unpinnedArchiveAvatarColor.foregroundColor - } else { - backgroundColors = theme.chatList.pinnedArchiveAvatarColor.backgroundColors.colors - iconColor = theme.chatList.pinnedArchiveAvatarColor.foregroundColor - } - let colors: NSArray = [backgroundColors.1.cgColor, backgroundColors.0.cgColor] - backgroundColor = backgroundColors.1.mixedWith(backgroundColors.0, alpha: 0.5) - animationBackgroundNode.image = generateGradientFilledCircleImage(diameter: self.imageNode.frame.width, colors: colors) - } - - self.addSubnode(animationBackgroundNode) - - let animationNode = AnimationNode(animation: "anim_archiveAvatar", colors: ["box1.box1.Fill 1": iconColor, "box3.box3.Fill 1": iconColor, "box2.box2.Fill 1": backgroundColor], scale: 0.1653828) - animationNode.isUserInteractionEnabled = false - animationNode.completion = { [weak animationBackgroundNode, weak self] in - self?.imageNode.isHidden = false - animationBackgroundNode?.removeFromSupernode() - } - animationBackgroundNode.addSubnode(animationNode) - - animationBackgroundNode.layer.animateScale(from: 1.0, to: 1.07, duration: 0.12, removeOnCompletion: false, completion: { [weak animationBackgroundNode] finished in - animationBackgroundNode?.layer.animateScale(from: 1.07, to: 1.0, duration: 0.12, removeOnCompletion: false) - }) - - if var size = animationNode.preferredSize() { - size = CGSize(width: ceil(size.width), height: ceil(size.height)) - animationNode.frame = CGRect(x: floor((self.bounds.width - size.width) / 2.0), y: floor((self.bounds.height - size.height) / 2.0) + 1.0, width: size.width, height: size.height) - animationNode.play() - } - self.imageNode.isHidden = true + self.contentNode.playArchiveAnimation() } public func setPeer( - context genericContext: AccountContext, + context: AccountContext, account: Account? = nil, theme: PresentationTheme, peer: EnginePeer?, @@ -403,298 +788,112 @@ public final class AvatarNode: ASDisplayNode { displayDimensions: CGSize = CGSize(width: 60.0, height: 60.0), storeUnrounded: Bool = false ) { - var synchronousLoad = synchronousLoad - var representation: TelegramMediaImageRepresentation? - var icon = AvatarNodeIcon.none - if let overrideImage = overrideImage { - switch overrideImage { - case .none: - representation = nil - case let .image(image): - representation = image - synchronousLoad = false - case .savedMessagesIcon: - representation = nil - icon = .savedMessagesIcon - case .repliesIcon: - representation = nil - icon = .repliesIcon - case let .archivedChatsIcon(hiddenByDefault): - representation = nil - icon = .archivedChatsIcon(hiddenByDefault: hiddenByDefault) - case let .editAvatarIcon(forceNone): - representation = forceNone ? nil : peer?.smallProfileImage - icon = .editAvatarIcon - case .deletedIcon: - representation = nil - icon = .deletedIcon - case .phoneIcon: - representation = nil - icon = .phoneIcon - } - } else if peer?.restrictionText(platform: "ios", contentSettings: genericContext.currentContentSettings.with { $0 }) == nil { - representation = peer?.smallProfileImage - } - let updatedState: AvatarNodeState = .peerAvatar(peer?.id ?? EnginePeer.Id(0), peer?.displayLetters ?? [], representation, clipStyle) - if updatedState != self.state || overrideImage != self.overrideImage || theme !== self.theme { - self.state = updatedState - self.overrideImage = overrideImage - self.theme = theme - - let parameters: AvatarNodeParameters - - let account = account ?? genericContext.account - - if let peer = peer, let signal = peerAvatarImage(account: account, peerReference: PeerReference(peer._asPeer()), authorOfMessage: authorOfMessage, representation: representation, displayDimensions: displayDimensions, clipStyle: clipStyle, emptyColor: emptyColor, synchronousLoad: synchronousLoad, provideUnrounded: storeUnrounded) { - self.contents = nil - self.displaySuspended = true - self.imageReady.set(self.imageNode.contentReady) - self.imageNode.setSignal(signal |> beforeNext { [weak self] next in - Queue.mainQueue().async { - self?.unroundedImage = next?.1 - } - } - |> map { next -> UIImage? in - return next?.0 - }) - - if case .editAvatarIcon = icon { - if self.editOverlayNode == nil { - let editOverlayNode = AvatarEditOverlayNode() - editOverlayNode.frame = self.imageNode.frame - editOverlayNode.isUserInteractionEnabled = false - self.addSubnode(editOverlayNode) - - self.editOverlayNode = editOverlayNode - } - self.editOverlayNode?.isHidden = false - } else { - self.editOverlayNode?.isHidden = true - } - - parameters = AvatarNodeParameters(theme: theme, accountPeerId: account.peerId, peerId: peer.id, colors: calculateColors(explicitColorIndex: nil, peerId: peer.id, icon: icon, theme: theme), letters: peer.displayLetters, font: self.font, icon: icon, explicitColorIndex: nil, hasImage: true, clipStyle: clipStyle) - } else { - self.imageReady.set(.single(true)) - self.displaySuspended = false - if self.isNodeLoaded { - self.imageNode.contents = nil - } - - self.editOverlayNode?.isHidden = true - let colors = calculateColors(explicitColorIndex: nil, peerId: peer?.id ?? EnginePeer.Id(0), icon: icon, theme: theme) - parameters = AvatarNodeParameters(theme: theme, accountPeerId: account.peerId, peerId: peer?.id ?? EnginePeer.Id(0), colors: colors, letters: peer?.displayLetters ?? [], font: self.font, icon: icon, explicitColorIndex: nil, hasImage: false, clipStyle: clipStyle) - - if let badgeView = self.badgeView { - let badgeColor: UIColor - if colors.isEmpty { - badgeColor = .white - } else { - badgeColor = colors[colors.count - 1] - } - badgeView.update(content: .color(badgeColor)) - } - } - if self.parameters == nil || self.parameters != parameters { - self.parameters = parameters - self.setNeedsDisplay() - if synchronousLoad { - self.recursivelyEnsureDisplaySynchronously(true) - } - } - } + self.contentNode.setPeer( + context: context, + account: account, + theme: theme, + peer: peer, + authorOfMessage: authorOfMessage, + overrideImage: overrideImage, + emptyColor: emptyColor, + clipStyle: clipStyle, + synchronousLoad: synchronousLoad, + displayDimensions: displayDimensions, + storeUnrounded: storeUnrounded + ) } public func setCustomLetters(_ letters: [String], explicitColor: AvatarNodeColorOverride? = nil, icon: AvatarNodeExplicitIcon? = nil) { - var explicitIndex: Int? - if let explicitColor = explicitColor { - switch explicitColor { - case .blue: - explicitIndex = 5 - } - } - let updatedState: AvatarNodeState = .custom(letter: letters, explicitColorIndex: explicitIndex, explicitIcon: icon) - if updatedState != self.state { - self.state = updatedState + self.contentNode.setCustomLetters(letters, explicitColor: explicitColor, icon: icon) + } + + public func setStoryStats(storyStats: StoryStats?, theme: PresentationTheme, transition: Transition) { + if self.storyStats != storyStats || self.storyIndicatorTheme !== theme { + self.storyStats = storyStats + self.storyIndicatorTheme = theme - let parameters: AvatarNodeParameters - if let icon = icon, case .phone = icon { - parameters = AvatarNodeParameters(theme: nil, accountPeerId: nil, peerId: nil, colors: calculateColors(explicitColorIndex: explicitIndex, peerId: nil, icon: .phoneIcon, theme: nil), letters: [], font: self.font, icon: .phoneIcon, explicitColorIndex: explicitIndex, hasImage: false, clipStyle: .round) + self.updateStoryIndicator(theme: theme, transition: transition) + } + } + + private struct StoryIndicatorParams { + let lineWidth: CGFloat + let indicatorSize: CGSize + let avatarScale: CGFloat + + init(lineWidth: CGFloat, indicatorSize: CGSize, avatarScale: CGFloat) { + self.lineWidth = lineWidth + self.indicatorSize = indicatorSize + self.avatarScale = avatarScale + } + } + + private func storyIndicatorParams(size: CGSize) -> StoryIndicatorParams { + let lineWidth: CGFloat = 2.0 + + return StoryIndicatorParams( + lineWidth: lineWidth, + indicatorSize: CGSize(width: size.width - lineWidth * 4.0, height: size.height - lineWidth * 4.0), + avatarScale: (size.width - lineWidth * 4.0) / size.width + ) + } + + private func updateStoryIndicator(theme: PresentationTheme, transition: Transition) { + if !self.isNodeLoaded { + return + } + if self.bounds.isEmpty { + return + } + + self.storyIndicatorTheme = theme + + let size = self.bounds.size + + if let storyStats = self.storyStats { + let indicatorParams = self.storyIndicatorParams(size: size) + + let storyIndicator: ComponentView + var indicatorTransition = transition + if let current = self.storyIndicator { + storyIndicator = current } else { - parameters = AvatarNodeParameters(theme: nil, accountPeerId: nil, peerId: nil, colors: calculateColors(explicitColorIndex: explicitIndex, peerId: nil, icon: .none, theme: nil), letters: letters, font: self.font, icon: .none, explicitColorIndex: explicitIndex, hasImage: false, clipStyle: .round) + indicatorTransition = transition.withAnimation(.none) + storyIndicator = ComponentView() + self.storyIndicator = storyIndicator } - - self.displaySuspended = true - self.contents = nil - - self.imageReady.set(.single(true)) - self.displaySuspended = false - - if self.parameters == nil || self.parameters != parameters { - self.parameters = parameters - self.setNeedsDisplay() - } - } - } - - override public func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol { - return parameters ?? NSObject() - } - - @objc override public class func draw(_ bounds: CGRect, withParameters parameters: Any?, isCancelled: () -> Bool, isRasterizing: Bool) { - assertNotOnMainThread() - - let context = UIGraphicsGetCurrentContext()! - - if !isRasterizing { - context.setBlendMode(.copy) - context.setFillColor(UIColor.clear.cgColor) - context.fill(bounds) - } - - let colors: [UIColor] - if let parameters = parameters as? AvatarNodeParameters { - colors = parameters.colors - - if case .round = parameters.clipStyle { - context.beginPath() - context.addEllipse(in: CGRect(x: 0.0, y: 0.0, width: bounds.size.width, height: - bounds.size.height)) - context.clip() - } else if case .roundedRect = parameters.clipStyle { - context.beginPath() - context.addPath(UIBezierPath(roundedRect: CGRect(x: 0.0, y: 0.0, width: bounds.size.width, height: bounds.size.height), cornerRadius: floor(bounds.size.width * 0.25)).cgPath) - context.clip() + let _ = storyIndicator.update( + transition: indicatorTransition, + component: AnyComponent(AvatarStoryIndicatorComponent( + hasUnseen: storyStats.unseenCount != 0, + hasUnseenCloseFriendsItems: storyStats.hasUnseenCloseFriendsItems, + theme: theme, + activeLineWidth: indicatorParams.lineWidth, + inactiveLineWidth: indicatorParams.lineWidth, + isGlassBackground: false, + counters: AvatarStoryIndicatorComponent.Counters( + totalCount: storyStats.totalCount, + unseenCount: storyStats.unseenCount + ) + )), + environment: {}, + containerSize: indicatorParams.indicatorSize + ) + if let storyIndicatorView = storyIndicator.view { + if storyIndicatorView.superview == nil { + self.view.addSubview(storyIndicatorView) + } + indicatorTransition.setFrame(view: storyIndicatorView, frame: CGRect(origin: CGPoint(x: (size.width - indicatorParams.indicatorSize.width) * 0.5, y: (size.height - indicatorParams.indicatorSize.height) * 0.5), size: indicatorParams.indicatorSize)) } + transition.setScale(view: self.contentNode.view, scale: indicatorParams.avatarScale) } else { - colors = grayscaleColors - } - - let colorsArray: NSArray = colors.map(\.cgColor) as NSArray - - var iconColor = UIColor.white - if let parameters = parameters as? AvatarNodeParameters, parameters.icon != .none { - if case let .archivedChatsIcon(hiddenByDefault) = parameters.icon, let theme = parameters.theme { - if hiddenByDefault { - iconColor = theme.chatList.unpinnedArchiveAvatarColor.foregroundColor - } else { - iconColor = theme.chatList.pinnedArchiveAvatarColor.foregroundColor - } - } - } - - var locations: [CGFloat] = [1.0, 0.0] - - let colorSpace = CGColorSpaceCreateDeviceRGB() - let gradient = CGGradient(colorsSpace: colorSpace, colors: colorsArray, locations: &locations)! - - context.drawLinearGradient(gradient, start: CGPoint(), end: CGPoint(x: 0.0, y: bounds.size.height), options: CGGradientDrawingOptions()) - - context.setBlendMode(.normal) - - if let parameters = parameters as? AvatarNodeParameters { - if case .deletedIcon = parameters.icon { - let factor = bounds.size.width / 60.0 - context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) - context.scaleBy(x: factor, y: -factor) - context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) - - if let deletedIcon = deletedIcon { - context.draw(deletedIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - deletedIcon.size.width) / 2.0), y: floor((bounds.size.height - deletedIcon.size.height) / 2.0)), size: deletedIcon.size)) - } - } else if case .phoneIcon = parameters.icon { - let factor: CGFloat = 1.0 - context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) - context.scaleBy(x: factor, y: -factor) - context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) - - if let phoneIcon = phoneIcon { - context.draw(phoneIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - phoneIcon.size.width) / 2.0), y: floor((bounds.size.height - phoneIcon.size.height) / 2.0)), size: phoneIcon.size)) - } - } else if case .savedMessagesIcon = parameters.icon { - let factor = bounds.size.width / 60.0 - context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) - context.scaleBy(x: factor, y: -factor) - context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) - - if let savedMessagesIcon = savedMessagesIcon { - context.draw(savedMessagesIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - savedMessagesIcon.size.width) / 2.0), y: floor((bounds.size.height - savedMessagesIcon.size.height) / 2.0)), size: savedMessagesIcon.size)) - } - } else if case .repliesIcon = parameters.icon { - let factor = bounds.size.width / 60.0 - context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) - context.scaleBy(x: factor, y: -factor) - context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) - - if let repliesIcon = repliesIcon { - context.draw(repliesIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - repliesIcon.size.width) / 2.0), y: floor((bounds.size.height - repliesIcon.size.height) / 2.0)), size: repliesIcon.size)) - } - } else if case .editAvatarIcon = parameters.icon, let theme = parameters.theme, !parameters.hasImage { - context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) - context.scaleBy(x: 1.0, y: -1.0) - context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) - - if bounds.width > 90.0, let editAvatarIcon = generateTintedImage(image: UIImage(bundleImageName: "Avatar/EditAvatarIconLarge"), color: theme.list.itemAccentColor) { - context.draw(editAvatarIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - editAvatarIcon.size.width) / 2.0) + 0.5, y: floor((bounds.size.height - editAvatarIcon.size.height) / 2.0) + 1.0), size: editAvatarIcon.size)) - } else if let editAvatarIcon = generateTintedImage(image: UIImage(bundleImageName: "Avatar/EditAvatarIcon"), color: theme.list.itemAccentColor) { - context.draw(editAvatarIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - editAvatarIcon.size.width) / 2.0) + 0.5, y: floor((bounds.size.height - editAvatarIcon.size.height) / 2.0) + 1.0), size: editAvatarIcon.size)) - } - } else if case .archivedChatsIcon = parameters.icon { - let factor = bounds.size.width / 60.0 - context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) - context.scaleBy(x: factor, y: -factor) - context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) - - if let archivedChatsIcon = generateTintedImage(image: archivedChatsIcon, color: iconColor) { - context.draw(archivedChatsIcon.cgImage!, in: CGRect(origin: CGPoint(x: floor((bounds.size.width - archivedChatsIcon.size.width) / 2.0), y: floor((bounds.size.height - archivedChatsIcon.size.height) / 2.0)), size: archivedChatsIcon.size)) - } - } else { - var letters = parameters.letters - if letters.count == 2 && letters[0].isSingleEmoji && letters[1].isSingleEmoji { - letters = [letters[0]] - } - - let string = letters.count == 0 ? "" : (letters[0] + (letters.count == 1 ? "" : letters[1])) - let attributedString = NSAttributedString(string: string, attributes: [NSAttributedString.Key.font: parameters.font, NSAttributedString.Key.foregroundColor: UIColor.white]) - - let line = CTLineCreateWithAttributedString(attributedString) - let lineBounds = CTLineGetBoundsWithOptions(line, .useGlyphPathBounds) - - let lineOffset = CGPoint(x: string == "B" ? 1.0 : 0.0, y: 0.0) - let lineOrigin = CGPoint(x: floorToScreenPixels(-lineBounds.origin.x + (bounds.size.width - lineBounds.size.width) / 2.0) + lineOffset.x, y: floorToScreenPixels(-lineBounds.origin.y + (bounds.size.height - lineBounds.size.height) / 2.0)) - - context.translateBy(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0) - context.scaleBy(x: 1.0, y: -1.0) - context.translateBy(x: -bounds.size.width / 2.0, y: -bounds.size.height / 2.0) - - context.translateBy(x: lineOrigin.x, y: lineOrigin.y) - CTLineDraw(line, context) - context.translateBy(x: -lineOrigin.x, y: -lineOrigin.y) - } - } - } - - static func asyncLayout(_ node: AvatarNode?) -> (_ context: AccountContext, _ peer: EnginePeer, _ font: UIFont) -> () -> AvatarNode? { - let currentState = node?.state - let createNode = node == nil - return { [weak node] context, peer, font in - let state: AvatarNodeState = .peerAvatar(peer.id, peer.displayLetters, peer.smallProfileImage, .round) - if currentState != state { - } - var createdNode: AvatarNode? - if createNode { - createdNode = AvatarNode(font: font) - } - return { - let updatedNode: AvatarNode? - if let createdNode = createdNode { - updatedNode = createdNode - } else { - updatedNode = node - } - if let updatedNode = updatedNode { - return updatedNode - } else { - return nil + transition.setScale(view: self.contentNode.view, scale: 1.0) + if let storyIndicator = self.storyIndicator { + self.storyIndicator = nil + if let storyIndicatorView = storyIndicator.view { + transition.setAlpha(view: storyIndicatorView, alpha: 0.0, completion: { [weak storyIndicatorView] _ in + storyIndicatorView?.removeFromSuperview() + }) } } } diff --git a/submodules/Postbox/Sources/MessageHistoryView.swift b/submodules/Postbox/Sources/MessageHistoryView.swift index 9b2abf30a7..9a3c87e073 100644 --- a/submodules/Postbox/Sources/MessageHistoryView.swift +++ b/submodules/Postbox/Sources/MessageHistoryView.swift @@ -327,6 +327,8 @@ final class MutableMessageHistoryView { private var userId: Int64? + fileprivate var peerStoryStats: [PeerId: PeerStoryStats] = [:] + init( postbox: PostboxImpl, orderStatistics: MessageHistoryViewOrderStatistics, @@ -388,6 +390,7 @@ final class MutableMessageHistoryView { self.sampledState = self.state.sample(postbox: postbox, clipHoles: self.clipHoles) self.render(postbox: postbox) + let _ = self.updateStoryStats(postbox: postbox) } private func reset(postbox: PostboxImpl) { @@ -411,6 +414,8 @@ final class MutableMessageHistoryView { } } self.sampledState = self.state.sample(postbox: postbox, clipHoles: self.clipHoles) + + let _ = self.updateStoryStats(postbox: postbox) } func refreshDueToExternalTransaction(postbox: PostboxImpl) -> Bool { @@ -907,6 +912,11 @@ final class MutableMessageHistoryView { if hasChanges { self.render(postbox: postbox) } + if hasChanges || !transaction.currentStoryTopItemEvents.isEmpty || !transaction.storyPeerStatesEvents.isEmpty { + if self.updateStoryStats(postbox: postbox) { + hasChanges = true + } + } return hasChanges } @@ -920,6 +930,26 @@ final class MutableMessageHistoryView { } } + private func updateStoryStats(postbox: PostboxImpl) -> Bool { + //TODO:optimize refresh + var peerStoryStats: [PeerId: PeerStoryStats] = [:] + if case let .loaded(state) = self.sampledState { + for entry in state.entries { + if let author = entry.message.author { + if let value = fetchPeerStoryStats(postbox: postbox, peerId: author.id) { + peerStoryStats[author.id] = value + } + } + } + } + if self.peerStoryStats != peerStoryStats { + self.peerStoryStats = peerStoryStats + return true + } else { + return false + } + } + func firstHole() -> (MessageHistoryViewHole, MessageHistoryViewRelativeHoleDirection, Int, Int64?)? { switch self.sampledState { case let .loading(loadingSample): @@ -965,6 +995,7 @@ public final class MessageHistoryView { public let isLoading: Bool public let isLoadingEarlier: Bool public let isAddedToChatList: Bool + public let peerStoryStats: [PeerId: PeerStoryStats] public init(tagMask: MessageTags?, namespaces: MessageIdNamespaces, entries: [MessageHistoryEntry], holeEarlier: Bool, holeLater: Bool, isLoading: Bool) { self.tagMask = tagMask @@ -983,6 +1014,7 @@ public final class MessageHistoryView { self.isLoading = isLoading self.isLoadingEarlier = true self.isAddedToChatList = false + self.peerStoryStats = [:] } init(_ mutableView: MutableMessageHistoryView) { @@ -1207,6 +1239,7 @@ public final class MessageHistoryView { } self.entries = entries + self.peerStoryStats = mutableView.peerStoryStats } public init(base: MessageHistoryView, fixed combinedReadStates: MessageHistoryViewReadState?, transient transientReadStates: MessageHistoryViewReadState?) { @@ -1225,6 +1258,7 @@ public final class MessageHistoryView { self.isLoading = base.isLoading self.isLoadingEarlier = base.isLoadingEarlier self.isAddedToChatList = base.isAddedToChatList + self.peerStoryStats = base.peerStoryStats if let combinedReadStates = combinedReadStates { switch combinedReadStates { diff --git a/submodules/Postbox/Sources/StoryItemsTable.swift b/submodules/Postbox/Sources/StoryItemsTable.swift index 18e55d1c5c..783886f1a8 100644 --- a/submodules/Postbox/Sources/StoryItemsTable.swift +++ b/submodules/Postbox/Sources/StoryItemsTable.swift @@ -50,11 +50,10 @@ final class StoryTopItemsTable: Table { return ValueBoxTable(id: id, keyType: .binary, compactValuesOnCreation: false) } - private let sharedKey = ValueBoxKey(length: 8 + 4) - private func key(_ key: Key) -> ValueBoxKey { - self.sharedKey.setInt64(0, value: key.peerId.toInt64()) - return self.sharedKey + let keyValue = ValueBoxKey(length: 8) + keyValue.setInt64(0, value: key.peerId.toInt64()) + return keyValue } public func get(peerId: PeerId) -> Entry? { @@ -115,12 +114,11 @@ final class StoryItemsTable: Table { return ValueBoxTable(id: id, keyType: .binary, compactValuesOnCreation: false) } - private let sharedKey = ValueBoxKey(length: 8 + 4) - private func key(_ key: Key) -> ValueBoxKey { - self.sharedKey.setInt64(0, value: key.peerId.toInt64()) - self.sharedKey.setInt32(8, value: key.id) - return self.sharedKey + let keyValue = ValueBoxKey(length: 8 + 4) + keyValue.setInt64(0, value: key.peerId.toInt64()) + keyValue.setInt32(8, value: key.id) + return keyValue } private func lowerBound(peerId: PeerId) -> ValueBoxKey { @@ -159,6 +157,8 @@ final class StoryItemsTable: Table { self.valueBox.range(self.table, start: self.lowerBound(peerId: peerId), end: self.upperBound(peerId: peerId), values: { key, value in let id = key.getInt32(8) + assert(peerId.toInt64() == key.getInt64(0)) + let entry: CodableEntry var expirationTimestamp: Int32? diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift index fa142d88ff..52b99bfb8e 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift @@ -1127,7 +1127,7 @@ func _internal_markStoryAsSeen(account: Account, peerId: PeerId, id: Int32, asPi return .complete() } - #if DEBUG && true + #if DEBUG && false if "".isEmpty { return .complete() } @@ -1156,7 +1156,7 @@ func _internal_markStoryAsSeen(account: Account, peerId: PeerId, id: Int32, asPi account.stateManager.injectStoryUpdates(updates: [.read(peerId: peerId, maxId: id)]) - #if DEBUG && false + #if DEBUG && true if "".isEmpty { return .complete() } diff --git a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift index 443b84a1d3..1885f5943f 100644 --- a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift +++ b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift @@ -79,7 +79,7 @@ public final class ChatControllerInteraction { public enum OpenPeerSource { case `default` case reaction - case groupParticipant + case groupParticipant(storyStats: PeerStoryStats?, avatarHeaderNode: ASDisplayNode?) } public let openMessage: (Message, ChatControllerInteractionOpenMessageMode) -> Bool diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift index fe79b9deb2..c5c8be7f30 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift @@ -1426,7 +1426,8 @@ public final class StoryItemSetContainerComponent: Component { transitionViewImpl.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1) } - leftInfoView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) + contentContainerView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false) + self.controlsContainerView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false) for transitionViewImpl in transitionViewsImpl { transition.setFrame(view: transitionViewImpl, frame: sourceLocalFrame) diff --git a/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListItemComponent.swift b/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListItemComponent.swift index 25b1731eab..2b15ac920b 100644 --- a/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListItemComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListItemComponent.swift @@ -56,7 +56,7 @@ private func calculateCircleIntersection(center: CGPoint, otherCenter: CGPoint, return (point1Angle, point2Angle) } -private func calculateMergingCircleShape(center: CGPoint, leftCenter: CGPoint?, rightCenter: CGPoint?, radius: CGFloat, totalCount: Int, unseenCount: Int, isSeen: Bool) -> CGPath { +private func calculateMergingCircleShape(center: CGPoint, leftCenter: CGPoint?, rightCenter: CGPoint?, radius: CGFloat, totalCount: Int, unseenCount: Int, isSeen: Bool, segmentFraction: CGFloat) -> CGPath { let leftAngles = leftCenter.flatMap { calculateCircleIntersection(center: center, otherCenter: $0, radius: radius) } let rightAngles = rightCenter.flatMap { calculateCircleIntersection(center: center, otherCenter: $0, radius: radius) } @@ -95,7 +95,7 @@ private func calculateMergingCircleShape(center: CGPoint, leftCenter: CGPoint?, } } } else { - let segmentSpacing: CGFloat = 4.0 + let segmentSpacing: CGFloat = 4.0 * segmentFraction let segmentSpacingAngle: CGFloat = segmentSpacing / radius let segmentAngle = (2.0 * CGFloat.pi - segmentSpacingAngle * CGFloat(segmentCount)) / CGFloat(segmentCount) for i in 0 ..< segmentCount { @@ -111,7 +111,9 @@ private func calculateMergingCircleShape(center: CGPoint, leftCenter: CGPoint?, } } - let startAngle = segmentSpacingAngle * 0.5 - CGFloat.pi * 0.5 + CGFloat(i) * (segmentSpacingAngle + segmentAngle) + var startAngle = segmentSpacingAngle * 0.5 - CGFloat.pi * 0.5 + CGFloat(i) * (segmentSpacingAngle + segmentAngle) + startAngle += (1.0 - segmentFraction) * CGFloat.pi * 2.0 * 0.25 + let endAngle = startAngle + segmentAngle path.move(to: CGPoint(x: center.x + cos(startAngle) * radius, y: center.y + sin(startAngle) * radius)) path.addArc(center: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: false) @@ -765,8 +767,8 @@ public final class StoryPeerListItemComponent: Component { } Transition.immediate.setShapeLayerPath(layer: self.avatarShapeLayer, path: avatarPath) - Transition.immediate.setShapeLayerPath(layer: self.indicatorShapeSeenLayer, path: calculateMergingCircleShape(center: indicatorCenter, leftCenter: mappedLeftCenter, rightCenter: mappedRightCenter, radius: indicatorRadius - indicatorLineUnseenWidth * 0.5, totalCount: component.totalCount, unseenCount: component.unseenCount, isSeen: true)) - Transition.immediate.setShapeLayerPath(layer: self.indicatorShapeUnseenLayer, path: calculateMergingCircleShape(center: indicatorCenter, leftCenter: mappedLeftCenter, rightCenter: mappedRightCenter, radius: indicatorRadius - indicatorLineUnseenWidth * 0.5, totalCount: component.totalCount, unseenCount: component.unseenCount, isSeen: false)) + Transition.immediate.setShapeLayerPath(layer: self.indicatorShapeSeenLayer, path: calculateMergingCircleShape(center: indicatorCenter, leftCenter: mappedLeftCenter, rightCenter: mappedRightCenter, radius: indicatorRadius - indicatorLineUnseenWidth * 0.5, totalCount: component.totalCount, unseenCount: component.unseenCount, isSeen: true, segmentFraction: component.expandedAlphaFraction)) + Transition.immediate.setShapeLayerPath(layer: self.indicatorShapeUnseenLayer, path: calculateMergingCircleShape(center: indicatorCenter, leftCenter: mappedLeftCenter, rightCenter: mappedRightCenter, radius: indicatorRadius - indicatorLineUnseenWidth * 0.5, totalCount: component.totalCount, unseenCount: component.unseenCount, isSeen: false, segmentFraction: component.expandedAlphaFraction)) //TODO:localize let titleString: String @@ -810,7 +812,7 @@ public final class StoryPeerListItemComponent: Component { let titleSize = self.title.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString(string: titleString, font: Font.regular(11.0), textColor: component.theme.list.itemPrimaryTextColor)), + text: .plain(NSAttributedString(string: titleString, font: Font.regular(11.0), textColor: (component.unseenCount != 0 || component.peer.id == component.context.account.peerId) ? component.theme.list.itemPrimaryTextColor : component.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.5))), maximumNumberOfLines: 1 )), environment: {}, @@ -840,7 +842,7 @@ public final class StoryPeerListItemComponent: Component { self.progressLayer = progressLayer self.indicatorMaskUnseenLayer.addSublayer(progressLayer) } - let progressFrame = CGRect(origin: CGPoint(), size: indicatorFrame.size).insetBy(dx: 2.0, dy: 2.0) + let progressFrame = CGRect(origin: CGPoint(), size: indicatorFrame.size).insetBy(dx: 4.0, dy: 4.0) progressTransition.setFrame(layer: progressLayer, frame: progressFrame) switch ringAnimation { @@ -851,9 +853,9 @@ public final class StoryPeerListItemComponent: Component { } else { progressTransition = .easeInOut(duration: 0.3) } - progressLayer.update(size: progressFrame.size, lineWidth: 4.0, value: .progress(progress), transition: progressTransition) + progressLayer.update(size: progressFrame.size, lineWidth: indicatorLineUnseenWidth, value: .progress(progress), transition: progressTransition) case .loading: - progressLayer.update(size: progressFrame.size, lineWidth: 4.0, value: .indefinite, transition: transition) + progressLayer.update(size: progressFrame.size, lineWidth: indicatorLineUnseenWidth, value: .indefinite, transition: transition) } self.indicatorShapeSeenLayer.opacity = 0.0 self.indicatorShapeUnseenLayer.opacity = 0.0 diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 2bc20c676c..9a7412e760 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -1162,7 +1162,20 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } )) }, openPeer: { [weak self] peer, navigation, fromMessage, source in - self?.openPeer(peer: peer, navigation: navigation, fromMessage: fromMessage, fromReactionMessageId: source == .reaction ? fromMessage?.id : nil, expandAvatar: source == .groupParticipant) + var expandAvatar = false + if case let .groupParticipant(storyStats, avatarHeaderNode) = source { + if let storyStats, storyStats.totalCount != 0, let avatarHeaderNode = avatarHeaderNode as? ChatMessageAvatarHeaderNode { + self?.openStories(peerId: peer.id, avatarHeaderNode: avatarHeaderNode) + return + } else { + expandAvatar = true + } + } + var fromReactionMessageId: MessageId? + if case .reaction = source { + fromReactionMessageId = fromMessage?.id + } + self?.openPeer(peer: peer, navigation: navigation, fromMessage: fromMessage, fromReactionMessageId: fromReactionMessageId, expandAvatar: expandAvatar) }, openPeerMention: { [weak self] name in self?.openPeerMention(name) }, openMessageContextMenu: { [weak self] message, selectAll, node, frame, anyRecognizer, location in @@ -16998,6 +17011,72 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }) } + private func openStories(peerId: EnginePeer.Id, avatarHeaderNode: ChatMessageAvatarHeaderNode) { + let storyContent = StoryContentContextImpl(context: self.context, isHidden: false, focusedPeerId: peerId, singlePeer: true) + let _ = (storyContent.state + |> filter { $0.slice != nil } + |> take(1) + |> deliverOnMainQueue).start(next: { [weak self, weak avatarHeaderNode] _ in + guard let self else { + return + } + + var transitionIn: StoryContainerScreen.TransitionIn? + if let avatarHeaderNode { + transitionIn = StoryContainerScreen.TransitionIn( + sourceView: avatarHeaderNode.avatarNode.view, + sourceRect: avatarHeaderNode.avatarNode.view.bounds, + sourceCornerRadius: avatarHeaderNode.avatarNode.view.bounds.width * 0.5, + sourceIsAvatar: false + ) + avatarHeaderNode.avatarNode.isHidden = true + } + + let storyContainerScreen = StoryContainerScreen( + context: self.context, + content: storyContent, + transitionIn: transitionIn, + transitionOut: { peerId, _ in + guard let avatarHeaderNode else { + return nil + } + let destinationView = avatarHeaderNode.avatarNode.view + return StoryContainerScreen.TransitionOut( + destinationView: destinationView, + transitionView: StoryContainerScreen.TransitionView( + makeView: { [weak destinationView] in + let parentView = UIView() + if let copyView = destinationView?.snapshotContentTree(unhide: true) { + parentView.addSubview(copyView) + } + return parentView + }, + updateView: { copyView, state, transition in + guard let view = copyView.subviews.first else { + return + } + let size = state.sourceSize.interpolate(to: state.destinationSize, amount: state.progress) + transition.setPosition(view: view, position: CGPoint(x: size.width * 0.5, y: size.height * 0.5)) + transition.setScale(view: view, scale: size.width / state.destinationSize.width) + }, + insertCloneTransitionView: nil + ), + destinationRect: destinationView.bounds, + destinationCornerRadius: destinationView.bounds.width * 0.5, + destinationIsAvatar: false, + completed: { [weak avatarHeaderNode] in + guard let avatarHeaderNode else { + return + } + avatarHeaderNode.avatarNode.isHidden = false + } + ) + } + ) + self.push(storyContainerScreen) + }) + } + private func openPeerMention(_ name: String, navigation: ChatControllerInteractionNavigateToPeer = .default, sourceMessageId: MessageId? = nil) { let _ = self.presentVoiceMessageDiscardAlert(action: { let disposable: MetaDisposable diff --git a/submodules/TelegramUI/Sources/ChatHistoryEntriesForView.swift b/submodules/TelegramUI/Sources/ChatHistoryEntriesForView.swift index 0ae2246b06..3c49c2552d 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryEntriesForView.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryEntriesForView.swift @@ -101,7 +101,7 @@ func chatHistoryEntriesForView( if let maybeJoinMessage = joinMessage { if message.timestamp > maybeJoinMessage.timestamp, (!view.holeEarlier || count > 0) { - entries.append(.MessageEntry(maybeJoinMessage, presentationData, false, nil, .none, ChatMessageEntryAttributes(rank: nil, isContact: false, contentTypeHint: .generic, updatingMedia: nil, isPlaying: false, isCentered: false))) + entries.append(.MessageEntry(maybeJoinMessage, presentationData, false, nil, .none, ChatMessageEntryAttributes(rank: nil, isContact: false, contentTypeHint: .generic, updatingMedia: nil, isPlaying: false, isCentered: false, authorStoryStats: nil))) joinMessage = nil } } @@ -182,7 +182,7 @@ func chatHistoryEntriesForView( } else { selection = .none } - groupBucket.append((message, isRead, selection, ChatMessageEntryAttributes(rank: adminRank, isContact: entry.attributes.authorIsContact, contentTypeHint: contentTypeHint, updatingMedia: updatingMedia[message.id], isPlaying: false, isCentered: false), entry.location)) + groupBucket.append((message, isRead, selection, ChatMessageEntryAttributes(rank: adminRank, isContact: entry.attributes.authorIsContact, contentTypeHint: contentTypeHint, updatingMedia: updatingMedia[message.id], isPlaying: false, isCentered: false, authorStoryStats: message.author.flatMap { view.peerStoryStats[$0.id] }), entry.location)) } else { let selection: ChatHistoryMessageSelection if let selectedMessages = selectedMessages { @@ -190,7 +190,7 @@ func chatHistoryEntriesForView( } else { selection = .none } - entries.append(.MessageEntry(message, presentationData, isRead, entry.location, selection, ChatMessageEntryAttributes(rank: adminRank, isContact: entry.attributes.authorIsContact, contentTypeHint: contentTypeHint, updatingMedia: updatingMedia[message.id], isPlaying: message.index == associatedData.currentlyPlayingMessageId, isCentered: false))) + entries.append(.MessageEntry(message, presentationData, isRead, entry.location, selection, ChatMessageEntryAttributes(rank: adminRank, isContact: entry.attributes.authorIsContact, contentTypeHint: contentTypeHint, updatingMedia: updatingMedia[message.id], isPlaying: message.index == associatedData.currentlyPlayingMessageId, isCentered: false, authorStoryStats: message.author.flatMap { view.peerStoryStats[$0.id] }))) } } else { let selection: ChatHistoryMessageSelection @@ -199,7 +199,7 @@ func chatHistoryEntriesForView( } else { selection = .none } - entries.append(.MessageEntry(message, presentationData, isRead, entry.location, selection, ChatMessageEntryAttributes(rank: adminRank, isContact: entry.attributes.authorIsContact, contentTypeHint: contentTypeHint, updatingMedia: updatingMedia[message.id], isPlaying: message.index == associatedData.currentlyPlayingMessageId, isCentered: false))) + entries.append(.MessageEntry(message, presentationData, isRead, entry.location, selection, ChatMessageEntryAttributes(rank: adminRank, isContact: entry.attributes.authorIsContact, contentTypeHint: contentTypeHint, updatingMedia: updatingMedia[message.id], isPlaying: message.index == associatedData.currentlyPlayingMessageId, isCentered: false, authorStoryStats: message.author.flatMap { view.peerStoryStats[$0.id] }))) } } @@ -222,7 +222,7 @@ func chatHistoryEntriesForView( } if let maybeJoinMessage = joinMessage, !view.holeLater { - entries.append(.MessageEntry(maybeJoinMessage, presentationData, false, nil, .none, ChatMessageEntryAttributes(rank: nil, isContact: false, contentTypeHint: .generic, updatingMedia: nil, isPlaying: false, isCentered: false))) + entries.append(.MessageEntry(maybeJoinMessage, presentationData, false, nil, .none, ChatMessageEntryAttributes(rank: nil, isContact: false, contentTypeHint: .generic, updatingMedia: nil, isPlaying: false, isCentered: false, authorStoryStats: nil))) joinMessage = nil } @@ -283,12 +283,12 @@ func chatHistoryEntriesForView( if messages.count > 1, let groupInfo = messages[0].groupInfo { var groupMessages: [(Message, Bool, ChatHistoryMessageSelection, ChatMessageEntryAttributes, MessageHistoryEntryLocation?)] = [] for message in messages { - groupMessages.append((message, false, .none, ChatMessageEntryAttributes(rank: adminRank, isContact: false, contentTypeHint: contentTypeHint, updatingMedia: updatingMedia[message.id], isPlaying: false, isCentered: false), nil)) + groupMessages.append((message, false, .none, ChatMessageEntryAttributes(rank: adminRank, isContact: false, contentTypeHint: contentTypeHint, updatingMedia: updatingMedia[message.id], isPlaying: false, isCentered: false, authorStoryStats: message.author.flatMap { view.peerStoryStats[$0.id] }), nil)) } entries.insert(.MessageGroupEntry(groupInfo, groupMessages, presentationData), at: 0) } else { if !hasTopicCreated { - entries.insert(.MessageEntry(messages[0], presentationData, false, nil, selection, ChatMessageEntryAttributes(rank: adminRank, isContact: false, contentTypeHint: contentTypeHint, updatingMedia: updatingMedia[messages[0].id], isPlaying: false, isCentered: false)), at: 0) + entries.insert(.MessageEntry(messages[0], presentationData, false, nil, selection, ChatMessageEntryAttributes(rank: adminRank, isContact: false, contentTypeHint: contentTypeHint, updatingMedia: updatingMedia[messages[0].id], isPlaying: false, isCentered: false, authorStoryStats: messages[0].author.flatMap { view.peerStoryStats[$0.id] })), at: 0) } } @@ -362,7 +362,7 @@ func chatHistoryEntriesForView( if !dynamicAdMessages.isEmpty { assert(entries.sorted() == entries) for message in dynamicAdMessages { - entries.append(.MessageEntry(message, presentationData, false, nil, .none, ChatMessageEntryAttributes(rank: nil, isContact: false, contentTypeHint: .generic, updatingMedia: nil, isPlaying: false, isCentered: false))) + entries.append(.MessageEntry(message, presentationData, false, nil, .none, ChatMessageEntryAttributes(rank: nil, isContact: false, contentTypeHint: .generic, updatingMedia: nil, isPlaying: false, isCentered: false, authorStoryStats: nil))) } entries.sort() } @@ -396,7 +396,7 @@ func chatHistoryEntriesForView( associatedStories: message.associatedStories ) nextAdMessageId += 1 - entries.append(.MessageEntry(updatedMessage, presentationData, false, nil, .none, ChatMessageEntryAttributes(rank: nil, isContact: false, contentTypeHint: .generic, updatingMedia: nil, isPlaying: false, isCentered: false))) + entries.append(.MessageEntry(updatedMessage, presentationData, false, nil, .none, ChatMessageEntryAttributes(rank: nil, isContact: false, contentTypeHint: .generic, updatingMedia: nil, isPlaying: false, isCentered: false, authorStoryStats: nil))) } } } else if includeSearchEntry { diff --git a/submodules/TelegramUI/Sources/ChatHistoryEntry.swift b/submodules/TelegramUI/Sources/ChatHistoryEntry.swift index 113aa42e3d..3c0a1f1cb1 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryEntry.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryEntry.swift @@ -18,14 +18,16 @@ public struct ChatMessageEntryAttributes: Equatable { var updatingMedia: ChatUpdatingMessageMedia? var isPlaying: Bool var isCentered: Bool + var authorStoryStats: PeerStoryStats? - init(rank: CachedChannelAdminRank?, isContact: Bool, contentTypeHint: ChatMessageEntryContentType, updatingMedia: ChatUpdatingMessageMedia?, isPlaying: Bool, isCentered: Bool) { + init(rank: CachedChannelAdminRank?, isContact: Bool, contentTypeHint: ChatMessageEntryContentType, updatingMedia: ChatUpdatingMessageMedia?, isPlaying: Bool, isCentered: Bool, authorStoryStats: PeerStoryStats?) { self.rank = rank self.isContact = isContact self.contentTypeHint = contentTypeHint self.updatingMedia = updatingMedia self.isPlaying = isPlaying self.isCentered = isCentered + self.authorStoryStats = authorStoryStats } public init() { @@ -35,6 +37,7 @@ public struct ChatMessageEntryAttributes: Equatable { self.updatingMedia = nil self.isPlaying = false self.isCentered = false + self.authorStoryStats = nil } } diff --git a/submodules/TelegramUI/Sources/ChatMessageDateHeader.swift b/submodules/TelegramUI/Sources/ChatMessageDateHeader.swift index 067bd83752..914e021087 100644 --- a/submodules/TelegramUI/Sources/ChatMessageDateHeader.swift +++ b/submodules/TelegramUI/Sources/ChatMessageDateHeader.swift @@ -374,8 +374,9 @@ final class ChatMessageAvatarHeader: ListViewItemHeader { let presentationData: ChatPresentationData let context: AccountContext let controllerInteraction: ChatControllerInteraction + let storyStats: PeerStoryStats? - init(timestamp: Int32, peerId: PeerId, peer: Peer?, messageReference: MessageReference?, message: Message, presentationData: ChatPresentationData, context: AccountContext, controllerInteraction: ChatControllerInteraction) { + init(timestamp: Int32, peerId: PeerId, peer: Peer?, messageReference: MessageReference?, message: Message, presentationData: ChatPresentationData, context: AccountContext, controllerInteraction: ChatControllerInteraction, storyStats: PeerStoryStats?) { self.peerId = peerId self.peer = peer self.messageReference = messageReference @@ -395,6 +396,7 @@ final class ChatMessageAvatarHeader: ListViewItemHeader { self.context = context self.controllerInteraction = controllerInteraction self.id = ListViewItemNode.HeaderId(space: 1, id: Id(peerId: peerId, timestampId: dateHeaderTimestampId(timestamp: timestamp))) + self.storyStats = storyStats } let stickDirection: ListViewItemHeaderStickDirection = .top @@ -414,14 +416,15 @@ final class ChatMessageAvatarHeader: ListViewItemHeader { } func node(synchronousLoad: Bool) -> ListViewItemHeaderNode { - return ChatMessageAvatarHeaderNode(peerId: self.peerId, peer: self.peer, messageReference: self.messageReference, adMessageId: self.adMessageId, presentationData: self.presentationData, context: self.context, controllerInteraction: self.controllerInteraction, synchronousLoad: synchronousLoad) + return ChatMessageAvatarHeaderNode(peerId: self.peerId, peer: self.peer, messageReference: self.messageReference, adMessageId: self.adMessageId, presentationData: self.presentationData, context: self.context, controllerInteraction: self.controllerInteraction, storyStats: self.storyStats, synchronousLoad: synchronousLoad) } func updateNode(_ node: ListViewItemHeaderNode, previous: ListViewItemHeader?, next: ListViewItemHeader?) { - guard let node = node as? ChatMessageAvatarHeaderNode, let next = next as? ChatMessageAvatarHeader else { + guard let node = node as? ChatMessageAvatarHeaderNode else { return } - node.updatePresentationData(next.presentationData, context: next.context) + node.updatePresentationData(self.presentationData, context: self.context) + node.updateStoryStats(storyStats: self.storyStats, theme: self.presentationData.theme.theme, force: false) } } @@ -433,6 +436,7 @@ final class ChatMessageAvatarHeaderNode: ListViewItemHeaderNode { private let context: AccountContext private var presentationData: ChatPresentationData private let controllerInteraction: ChatControllerInteraction + private var storyStats: PeerStoryStats? private let peerId: PeerId private let messageReference: MessageReference? @@ -440,7 +444,7 @@ final class ChatMessageAvatarHeaderNode: ListViewItemHeaderNode { private let adMessageId: EngineMessage.Id? private let containerNode: ContextControllerSourceNode - private let avatarNode: AvatarNode + let avatarNode: AvatarNode private var avatarVideoNode: AvatarVideoNode? private var cachedDataDisposable = MetaDisposable() @@ -459,7 +463,7 @@ final class ChatMessageAvatarHeaderNode: ListViewItemHeaderNode { } } - init(peerId: PeerId, peer: Peer?, messageReference: MessageReference?, adMessageId: EngineMessage.Id?, presentationData: ChatPresentationData, context: AccountContext, controllerInteraction: ChatControllerInteraction, synchronousLoad: Bool) { + init(peerId: PeerId, peer: Peer?, messageReference: MessageReference?, adMessageId: EngineMessage.Id?, presentationData: ChatPresentationData, context: AccountContext, controllerInteraction: ChatControllerInteraction, storyStats: PeerStoryStats?, synchronousLoad: Bool) { self.peerId = peerId self.peer = peer self.messageReference = messageReference @@ -467,6 +471,7 @@ final class ChatMessageAvatarHeaderNode: ListViewItemHeaderNode { self.presentationData = presentationData self.context = context self.controllerInteraction = controllerInteraction + self.storyStats = storyStats self.containerNode = ContextControllerSourceNode() @@ -482,6 +487,10 @@ final class ChatMessageAvatarHeaderNode: ListViewItemHeaderNode { if let peer = peer { self.setPeer(context: context, theme: presentationData.theme.theme, synchronousLoad: synchronousLoad, peer: peer, authorOfMessage: messageReference, emptyColor: .black) } + + if let storyStats { + self.updateStoryStats(storyStats: storyStats, theme: presentationData.theme.theme, force: true) + } self.containerNode.activated = { [weak self] gesture, _ in guard let strongSelf = self, let peer = strongSelf.peer else { @@ -595,6 +604,18 @@ final class ChatMessageAvatarHeaderNode: ListViewItemHeaderNode { self.hierarchyTrackingLayer = nil } } + + func updateStoryStats(storyStats: PeerStoryStats?, theme: PresentationTheme, force: Bool) { + if self.storyStats != storyStats || self.presentationData.theme.theme !== theme || force { + self.avatarNode.setStoryStats(storyStats: storyStats.flatMap { storyStats in + return AvatarNode.StoryStats( + totalCount: storyStats.totalCount, + unseenCount: storyStats.unseenCount, + hasUnseenCloseFriendsItems: false + ) + }, theme: theme, transition: .immediate) + } + } override func didLoad() { super.didLoad() @@ -603,9 +624,10 @@ final class ChatMessageAvatarHeaderNode: ListViewItemHeaderNode { } func updatePresentationData(_ presentationData: ChatPresentationData, context: AccountContext) { - self.presentationData = presentationData - - self.setNeedsLayout() + if self.presentationData !== presentationData { + self.presentationData = presentationData + self.setNeedsLayout() + } } override func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat) { @@ -663,7 +685,7 @@ final class ChatMessageAvatarHeaderNode: ListViewItemHeaderNode { if let channel = peer as? TelegramChannel, case .broadcast = channel.info { self.controllerInteraction.openPeer(EnginePeer(peer), .chat(textInputState: nil, subject: nil, peekData: nil), self.messageReference, .default) } else { - self.controllerInteraction.openPeer(EnginePeer(peer), .info, self.messageReference, .groupParticipant) + self.controllerInteraction.openPeer(EnginePeer(peer), .info, self.messageReference, .groupParticipant(storyStats: self.storyStats, avatarHeaderNode: self)) } } } diff --git a/submodules/TelegramUI/Sources/ChatMessageItem.swift b/submodules/TelegramUI/Sources/ChatMessageItem.swift index 0a4aab7c8c..976eece1c1 100644 --- a/submodules/TelegramUI/Sources/ChatMessageItem.swift +++ b/submodules/TelegramUI/Sources/ChatMessageItem.swift @@ -406,7 +406,15 @@ public final class ChatMessageItem: ListViewItem, CustomStringConvertible { if hasAvatar { if let effectiveAuthor = effectiveAuthor { - avatarHeader = ChatMessageAvatarHeader(timestamp: content.index.timestamp, peerId: effectiveAuthor.id, peer: effectiveAuthor, messageReference: MessageReference(message), message: message, presentationData: presentationData, context: context, controllerInteraction: controllerInteraction) + let storyStats: PeerStoryStats? + switch content { + case let .message(_, _, _, attributes, _): + storyStats = attributes.authorStoryStats + case let .group(messages): + storyStats = messages.first?.3.authorStoryStats + } + + avatarHeader = ChatMessageAvatarHeader(timestamp: content.index.timestamp, peerId: effectiveAuthor.id, peer: effectiveAuthor, messageReference: MessageReference(message), message: message, presentationData: presentationData, context: context, controllerInteraction: controllerInteraction, storyStats: storyStats) } } }