Merge branch 'master' of gitlab.com:peter-iakovlev/telegram-ios

This commit is contained in:
Ilya Laktyushin 2026-06-04 01:44:43 +02:00
commit ec72568429
27 changed files with 1439 additions and 3819 deletions

View file

@ -1590,7 +1590,7 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg
}
}
}
self.context.sharedContext.mediaManager.setPlaylist((self.context, InstantPageMediaPlaylist(webPage: webPage, items: medias, initialItemIndex: initialIndex)), type: file.isVoice ? .voice : .music, control: .playback(.play))
self.context.sharedContext.mediaManager.setPlaylist((self.context, InstantPageMediaPlaylist(playlistId: .instantPage(webpageId: webPage.webpageId), webPage: webPage, messageReference: nil, items: medias, initialItemIndex: initialIndex)), type: file.isVoice ? .voice : .music, control: .playback(.play))
return
}

View file

@ -25,6 +25,7 @@ swift_library(
"//submodules/TelegramUI/Components/MultiAnimationRenderer:MultiAnimationRenderer",
"//submodules/GalleryUI:GalleryUI",
"//submodules/MusicAlbumArtResources:MusicAlbumArtResources",
"//submodules/SemanticStatusNode:SemanticStatusNode",
"//submodules/LiveLocationPositionNode:LiveLocationPositionNode",
"//submodules/MosaicLayout:MosaicLayout",
"//submodules/LocationUI:LocationUI",

View file

@ -247,7 +247,7 @@ final class InstantImageGalleryItemNode: ZoomableContentGalleryItemNode {
copyView.layer.animate(from: NSValue(caTransform3D: CATransform3DIdentity), to: NSValue(caTransform3D: CATransform3DMakeScale(scale.width, scale.height, 1.0)), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false)
if let transformedSurfaceFrame = transformedSurfaceFrame, let transformedSurfaceFinalFrame = transformedSurfaceFinalFrame {
surfaceCopyView.layer.animatePosition(from: CGPoint(x: transformedSurfaceFrame.midX, y: transformedSurfaceFrame.midY), to: CGPoint(x: transformedCopyViewFinalFrame.midX, y: transformedCopyViewFinalFrame.midY), duration: positionDuration, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { [weak surfaceCopyView] _ in
surfaceCopyView.layer.animatePosition(from: CGPoint(x: transformedSurfaceFrame.midX, y: transformedSurfaceFrame.midY), to: CGPoint(x: transformedSurfaceFinalFrame.midX, y: transformedSurfaceFinalFrame.midY), duration: positionDuration, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { [weak surfaceCopyView] _ in
surfaceCopyView?.removeFromSuperview()
})
let scale = CGSize(width: transformedSurfaceFinalFrame.size.width / transformedSurfaceFrame.size.width, height: transformedSurfaceFinalFrame.size.height / transformedSurfaceFrame.size.height)

View file

@ -24,7 +24,7 @@ public final class InstantPageAudioItem: InstantPageItem {
}
public func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? {
return InstantPageAudioNode(context: context, strings: strings, theme: theme, webPage: self.webpage, media: self.media, openMedia: openMedia)
return InstantPageAudioNode(context: context, strings: strings, theme: theme, webPage: self.webpage, media: self.media, playlistId: .instantPage(webpageId: self.webpage.webpageId), openMedia: openMedia)
}
public func matchesAnchor(_ anchor: String) -> Bool {

View file

@ -66,7 +66,7 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode {
private var playImage: UIImage
private var pauseImage: UIImage
private let buttonNode: HighlightableButtonNode
private let buttonView: UIView
private let statusNode: RadialStatusNode
private let titleNode: ASTextNode
private let scrubbingNode: MediaPlayerScrubbingNode
@ -76,7 +76,7 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode {
private var isPlaying: Bool = false
private var playbackState: SharedMediaPlayerItemPlaybackState?
init(context: AccountContext, strings: PresentationStrings, theme: InstantPageTheme, webPage: TelegramMediaWebpage, media: InstantPageMedia, openMedia: @escaping (InstantPageMedia) -> Void) {
init(context: AccountContext, strings: PresentationStrings, theme: InstantPageTheme, webPage: TelegramMediaWebpage, media: InstantPageMedia, playlistId: InstantPageMediaPlaylistId, openMedia: @escaping (InstantPageMedia) -> Void) {
self.context = context
self.strings = strings
self.theme = theme
@ -86,7 +86,7 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode {
self.playImage = generatePlayButton(color: theme.textCategories.paragraph.color)!
self.pauseImage = generatePauseButton(color: theme.textCategories.paragraph.color)!
self.buttonNode = HighlightableButtonNode()
self.buttonView = UIView()
self.statusNode = RadialStatusNode(backgroundNodeColor: .clear)
self.titleNode = ASTextNode()
self.titleNode.maximumNumberOfLines = 1
@ -112,25 +112,11 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode {
self.titleNode.attributedText = titleString(media: media, theme: theme, strings: strings)
self.addSubnode(self.statusNode)
self.addSubnode(self.buttonNode)
self.addSubnode(self.titleNode)
self.addSubnode(self.scrubbingNode)
self.statusNode.transitionToState(RadialStatusNodeState.customIcon(self.playImage), animated: false, completion: {})
self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside)
self.buttonNode.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
strongSelf.statusNode.layer.removeAnimation(forKey: "opacity")
strongSelf.statusNode.alpha = 0.4
} else {
strongSelf.statusNode.alpha = 1.0
strongSelf.statusNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
}
}
}
self.scrubbingNode.seek = { [weak self] timestamp in
if let strongSelf = self {
if let _ = strongSelf.playbackState {
@ -178,12 +164,12 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode {
}
})*/
self.scrubbingNode.status = context.sharedContext.mediaManager.filteredPlaylistState(accountId: context.account.id, playlistId: InstantPageMediaPlaylistId(webpageId: webPage.webpageId), itemId: InstantPageMediaPlaylistItemId(index: self.media.index), type: self.playlistType)
self.scrubbingNode.status = context.sharedContext.mediaManager.filteredPlaylistState(accountId: context.account.id, playlistId: playlistId, itemId: InstantPageMediaPlaylistItemId(index: self.media.index), type: self.playlistType)
|> map { playbackState -> MediaPlayerStatus in
return playbackState?.status ?? MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: .paused, soundEnabled: true)
}
self.playerStatusDisposable = (context.sharedContext.mediaManager.filteredPlaylistState(accountId: context.account.id, playlistId: InstantPageMediaPlaylistId(webpageId: webPage.webpageId), itemId: InstantPageMediaPlaylistItemId(index: self.media.index), type: playlistType)
self.playerStatusDisposable = (context.sharedContext.mediaManager.filteredPlaylistState(accountId: context.account.id, playlistId: playlistId, itemId: InstantPageMediaPlaylistItemId(index: self.media.index), type: playlistType)
|> deliverOnMainQueue).start(next: { [weak self] playbackState in
guard let strongSelf = self else {
return
@ -213,7 +199,21 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode {
deinit {
self.playerStatusDisposable?.dispose()
}
override func didLoad() {
super.didLoad()
// The play/pause tap target is a plain view + UITapGestureRecognizer, NOT an ASControl
// button. An ASControl's `.touchUpInside` is cancelled by the chat ListView's gesture
// system (the control highlights on touch-down, but the action never fires), so an
// embedded audio control in a rich-message bubble could never start playback. A gesture
// recognizer coordinates with the list's gestures and fires reliably matching the V2
// image node, the details-title hit view, and the regular file/music message. The plain
// view sits above `statusNode` and is positioned over the icon in `layout()`. (Works in
// V1's full-page Instant View too; gesture recognizers fire inside its scroll view.)
self.view.addSubview(self.buttonView)
self.buttonView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.buttonPressed)))
}
func update(strings: PresentationStrings, theme: InstantPageTheme) {
if self.strings !== strings || self.theme !== theme {
let themeUpdated = self.theme !== theme
@ -268,7 +268,7 @@ final class InstantPageAudioNode: ASDisplayNode, InstantPageNode {
let titleSize = self.titleNode.measure(CGSize(width: maxTitleWidth, height: size.height))
self.titleNode.frame = CGRect(origin: CGPoint(x: insets.left + leftInset, y: 2.0), size: titleSize)
self.buttonNode.frame = CGRect(origin: CGPoint(x: insets.left, y: 0.0), size: CGSize(width: 48.0, height: 48.0))
self.buttonView.frame = CGRect(origin: CGPoint(x: insets.left, y: 0.0), size: CGSize(width: 48.0, height: 48.0))
self.statusNode.frame = CGRect(origin: CGPoint(x: insets.left, y: 0.0), size: CGSize(width: 48.0, height: 48.0))
var topOffset: CGFloat = 0.0

View file

@ -1839,7 +1839,7 @@ final class InstantPageControllerNode: ASDisplayNode, ASScrollViewDelegate {
}
}
}
self.context.sharedContext.mediaManager.setPlaylist((self.context, InstantPageMediaPlaylist(webPage: webPage, items: medias, initialItemIndex: initialIndex)), type: file.isVoice ? .voice : .music, control: .playback(.play))
self.context.sharedContext.mediaManager.setPlaylist((self.context, InstantPageMediaPlaylist(playlistId: .instantPage(webpageId: webPage.webpageId), webPage: webPage, messageReference: nil, items: medias, initialItemIndex: initialIndex)), type: file.isVoice ? .voice : .music, control: .playback(.play))
return
}

View file

@ -20,12 +20,18 @@ func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fi
} else {
return 25.0
}
case (_, .blockQuote), (.blockQuote, _), (_, .pullQuote), (.pullQuote, _):
case (_, .blockQuote), (.blockQuote, _):
if fitToWidth {
return 11.0
} else {
return 27.0
}
case (_, .pullQuote), (.pullQuote, _):
if fitToWidth {
return 14.0
} else {
return 27.0
}
case (.kicker, .title), (.cover, .title):
return 16.0
case (_, .title):

View file

@ -30,15 +30,28 @@ private func extractFileMedia(_ item: InstantPageMedia) -> TelegramMediaFile? {
final class InstantPageMediaPlaylistItem: SharedMediaPlaylistItem {
let webPage: TelegramMediaWebpage
let messageReference: MessageReference?
let id: SharedMediaPlaylistItemId
let item: InstantPageMedia
init(webPage: TelegramMediaWebpage, item: InstantPageMedia) {
init(webPage: TelegramMediaWebpage, messageReference: MessageReference?, item: InstantPageMedia) {
self.webPage = webPage
self.messageReference = messageReference
self.id = InstantPageMediaPlaylistItemId(index: item.index)
self.item = item
}
private func fileReference(_ file: TelegramMediaFile) -> FileMediaReference {
// Require a resolvable message id (mirrors the playlist-key fallback in
// InstantPageV2MediaAudioView): a `.none`-content reference can't revalidate, so fall
// back to the webpage reference in that case.
if let messageReference = self.messageReference, messageReference.id != nil {
return .message(message: messageReference, media: file)
} else {
return .webPage(webPage: WebpageReference(self.webPage), media: file)
}
}
var stableId: AnyHashable {
return self.item.index
}
@ -49,13 +62,13 @@ final class InstantPageMediaPlaylistItem: SharedMediaPlaylistItem {
switch attribute {
case let .Audio(isVoice, _, _, _, _):
if isVoice {
return SharedMediaPlaybackData(type: .voice, source: .telegramFile(reference: .webPage(webPage: WebpageReference(self.webPage), media: file), isCopyProtected: false, isViewOnce: false))
return SharedMediaPlaybackData(type: .voice, source: .telegramFile(reference: self.fileReference(file), isCopyProtected: false, isViewOnce: false))
} else {
return SharedMediaPlaybackData(type: .music, source: .telegramFile(reference: .webPage(webPage: WebpageReference(self.webPage), media: file), isCopyProtected: false, isViewOnce: false))
return SharedMediaPlaybackData(type: .music, source: .telegramFile(reference: self.fileReference(file), isCopyProtected: false, isViewOnce: false))
}
case let .Video(_, _, flags, _, _, _):
if flags.contains(.instantRoundVideo) {
return SharedMediaPlaybackData(type: .instantVideo, source: .telegramFile(reference: .webPage(webPage: WebpageReference(self.webPage), media: file), isCopyProtected: false, isViewOnce: false))
return SharedMediaPlaybackData(type: .instantVideo, source: .telegramFile(reference: self.fileReference(file), isCopyProtected: false, isViewOnce: false))
} else {
return nil
}
@ -64,12 +77,12 @@ final class InstantPageMediaPlaylistItem: SharedMediaPlaylistItem {
}
}
if file.mimeType.hasPrefix("audio/") {
return SharedMediaPlaybackData(type: .music, source: .telegramFile(reference: .webPage(webPage: WebpageReference(self.webPage), media: file), isCopyProtected: false, isViewOnce: false))
return SharedMediaPlaybackData(type: .music, source: .telegramFile(reference: self.fileReference(file), isCopyProtected: false, isViewOnce: false))
}
if let fileName = file.fileName {
let ext = (fileName as NSString).pathExtension.lowercased()
if ext == "wav" || ext == "opus" {
return SharedMediaPlaybackData(type: .music, source: .telegramFile(reference: .webPage(webPage: WebpageReference(self.webPage), media: file), isCopyProtected: false, isViewOnce: false))
return SharedMediaPlaybackData(type: .music, source: .telegramFile(reference: self.fileReference(file), isCopyProtected: false, isViewOnce: false))
}
}
}
@ -116,14 +129,15 @@ final class InstantPageMediaPlaylistItem: SharedMediaPlaylistItem {
}
}
struct InstantPageMediaPlaylistId: SharedMediaPlaylistId {
let webpageId: EngineMedia.Id
func isEqual(to: SharedMediaPlaylistId) -> Bool {
if let to = to as? InstantPageMediaPlaylistId {
return self.webpageId == to.webpageId
public enum InstantPageMediaPlaylistId: Equatable, SharedMediaPlaylistId {
case instantPage(webpageId: EngineMedia.Id)
case richMessage(messageId: EngineMessage.Id)
public func isEqual(to: SharedMediaPlaylistId) -> Bool {
guard let to = to as? InstantPageMediaPlaylistId else {
return false
}
return false
return self == to
}
}
@ -134,15 +148,13 @@ struct InstantPagePlaylistLocation: Equatable, SharedMediaPlaylistLocation {
guard let to = to as? InstantPagePlaylistLocation else {
return false
}
if self.webpageId == to.webpageId {
return false
}
return true
return self.webpageId == to.webpageId
}
}
public final class InstantPageMediaPlaylist: SharedMediaPlaylist {
private let webPage: TelegramMediaWebpage
private let messageReference: MessageReference?
private let items: [InstantPageMedia]
private let initialItemIndex: Int
@ -164,15 +176,16 @@ public final class InstantPageMediaPlaylist: SharedMediaPlaylist {
return self.stateValue.get()
}
public init(webPage: TelegramMediaWebpage, items: [InstantPageMedia], initialItemIndex: Int) {
public init(playlistId: InstantPageMediaPlaylistId, webPage: TelegramMediaWebpage, messageReference: MessageReference?, items: [InstantPageMedia], initialItemIndex: Int) {
assert(Queue.mainQueue().isCurrent())
self.id = InstantPageMediaPlaylistId(webpageId: webPage.webpageId)
self.id = playlistId
self.webPage = webPage
self.messageReference = messageReference
self.items = items
self.initialItemIndex = initialItemIndex
self.control(.next)
}
@ -243,7 +256,7 @@ public final class InstantPageMediaPlaylist: SharedMediaPlaylist {
}
private func updateState() {
self.stateValue.set(.single(SharedMediaPlaylistState(loading: false, playedToEnd: self.playedToEnd, item: self.currentItem.flatMap({ InstantPageMediaPlaylistItem(webPage: self.webPage, item: $0) }), nextItem: nil, previousItem: nil, order: self.order, looping: self.looping)))
self.stateValue.set(.single(SharedMediaPlaylistState(loading: false, playedToEnd: self.playedToEnd, item: self.currentItem.flatMap({ InstantPageMediaPlaylistItem(webPage: self.webPage, messageReference: self.messageReference, item: $0) }), nextItem: nil, previousItem: nil, order: self.order, looping: self.looping)))
}
public func onItemPlaybackStarted(_ item: SharedMediaPlaylistItem) {

View file

@ -1,5 +1,6 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import CheckNode
import SwiftSignalKit
@ -35,7 +36,7 @@ public enum InstantPageV2StableItemId: Hashable {
}
public enum InstantPageV2ItemKind: Hashable {
case text, codeBlock, divider, listMarker, blockQuoteBar, shape, mediaPlaceholder, table, anchor, formula
case text, codeBlock, divider, listMarker, blockQuoteBar, shape, mediaPlaceholder, table, anchor, formula, slideshow
}
// MARK: - Render context
@ -58,6 +59,11 @@ public final class InstantPageV2RenderContext {
public let push: (ViewController) -> Void
public let openUrl: (InstantPageUrlItem) -> Void
public let baseNavigationController: () -> NavigationController?
/// A reference to the message hosting this page, when rendered inside a chat bubble. Used to
/// key audio playback per message (`.richMessage(message.id)`) AND to fetch audio files via a
/// message reference (so a stale file reference can revalidate); `nil` in the send preview,
/// which falls back to the webpage-keyed playlist id + webpage file reference.
public let message: MessageReference?
public init(
context: AccountContext,
@ -68,7 +74,8 @@ public final class InstantPageV2RenderContext {
present: @escaping (ViewController, Any?) -> Void,
push: @escaping (ViewController) -> Void,
openUrl: @escaping (InstantPageUrlItem) -> Void,
baseNavigationController: @escaping () -> NavigationController?
baseNavigationController: @escaping () -> NavigationController?,
message: MessageReference?
) {
self.context = context
self.webpage = webpage
@ -79,6 +86,7 @@ public final class InstantPageV2RenderContext {
self.push = push
self.openUrl = openUrl
self.baseNavigationController = baseNavigationController
self.message = message
}
/// Update the content-bearing fields for a later chunk of the SAME message. Enables the
@ -675,10 +683,18 @@ public final class InstantPageV2View: UIView {
guard let v = existingView as? InstantPageV2MediaCoverImageView, let rc = self.renderContext else { return nil }
v.update(item: media, theme: theme, renderContext: rc)
return v
case let .mediaAudio(media):
guard let v = existingView as? InstantPageV2MediaAudioView, let rc = self.renderContext else { return nil }
v.update(item: media, theme: theme, renderContext: rc)
return v
case let .thinking(thinking):
guard let v = existingView as? InstantPageV2ThinkingView else { return nil }
v.update(item: thinking, theme: theme)
return v
case let .slideshow(slideshow):
guard let v = existingView as? InstantPageV2SlideshowView, let rc = self.renderContext else { return nil }
v.update(item: slideshow, theme: theme, renderContext: rc)
return v
}
}
@ -688,6 +704,7 @@ public final class InstantPageV2View: UIView {
case let .mediaVideo(m): return .media(m.media.index)
case let .mediaMap(m): return .media(m.media.index)
case let .mediaCoverImage(m): return .media(m.media.index)
case let .mediaAudio(m): return .media(m.media.index)
case let .details(d): return .details(d.index)
case .text: return .positional(.text, position)
case .codeBlock: return .positional(.codeBlock, position)
@ -700,6 +717,7 @@ public final class InstantPageV2View: UIView {
case .anchor: return .positional(.anchor, position)
case .formula: return .positional(.formula, position)
case .thinking: return .thinking(position)
case .slideshow: return .positional(.slideshow, position)
}
}
@ -724,13 +742,8 @@ public final class InstantPageV2View: UIView {
guard let wrapperBox = self.trueRegistryRoot.mediaRegistry[media.index], let wrapper = wrapperBox.value else {
return nil
}
let imageNode: InstantPageImageNode? =
(wrapper as? InstantPageV2MediaImageView)?.wrappedNode
?? (wrapper as? InstantPageV2MediaVideoView)?.wrappedNode
?? (wrapper as? InstantPageV2MediaMapView)?.wrappedNode
?? (wrapper as? InstantPageV2MediaCoverImageView)?.wrappedNode
guard let imageNode else { return nil }
guard let transitionNode = imageNode.transitionNode(media: media) else { return nil }
guard let itemView = wrapper as? InstantPageItemView else { return nil }
guard let transitionNode = itemView.instantPageTransitionNode(for: media) else { return nil }
return GalleryTransitionArguments(transitionNode: transitionNode, addToTransitionSurface: addToTransitionSurface)
}
@ -739,10 +752,7 @@ public final class InstantPageV2View: UIView {
func applyHiddenMedia(_ hidden: InstantPageMedia?) {
for (_, weakBox) in self.trueRegistryRoot.mediaRegistry {
guard let wrapper = weakBox.value else { continue }
if let v = wrapper as? InstantPageV2MediaImageView { v.wrappedNode.updateHiddenMedia(media: hidden) }
if let v = wrapper as? InstantPageV2MediaVideoView { v.wrappedNode.updateHiddenMedia(media: hidden) }
if let v = wrapper as? InstantPageV2MediaMapView { v.wrappedNode.updateHiddenMedia(media: hidden) }
if let v = wrapper as? InstantPageV2MediaCoverImageView { v.wrappedNode.updateHiddenMedia(media: hidden) }
(wrapper as? InstantPageItemView)?.instantPageUpdateHiddenMedia(hidden)
}
}
@ -796,10 +806,22 @@ public final class InstantPageV2View: UIView {
} else {
return InstantPageV2MediaPlaceholderView(item: placeholderFallback(for: media), theme: theme)
}
case let .mediaAudio(media):
if let renderContext = self.renderContext {
return InstantPageV2MediaAudioView(item: media, renderContext: renderContext, theme: theme)
} else {
return InstantPageV2MediaPlaceholderView(item: InstantPageV2MediaPlaceholderItem(frame: media.frame, kind: .audio, cornerRadius: 0.0), theme: theme)
}
case let .formula(formula):
return InstantPageV2FormulaView(item: formula, theme: theme)
case let .thinking(thinking):
return InstantPageV2ThinkingView(item: thinking, theme: theme)
case let .slideshow(slideshow):
if let renderContext = self.renderContext {
return InstantPageV2SlideshowView(item: slideshow, renderContext: renderContext, theme: theme)
} else {
return InstantPageV2MediaPlaceholderView(item: InstantPageV2MediaPlaceholderItem(frame: slideshow.frame, kind: .slideshow, cornerRadius: 0.0), theme: theme)
}
}
}
@ -853,10 +875,18 @@ protocol InstantPageItemView: UIView {
var itemFrame: CGRect { get }
/// Recursion hook for nested layouts (details body, table cells, table title).
var subLayoutView: InstantPageV2View? { get }
/// Gallery open: the transition source for `media` if this view (or a descendant) shows it.
/// Default nil (non-media views). Media views forward to their wrapped `InstantPageImageNode`;
/// the slideshow forwards to its matching page.
func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))?
/// Gallery hidden-media tick: hide/show the source for `media`. Default no-op.
func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?)
}
extension InstantPageItemView {
var subLayoutView: InstantPageV2View? { return nil }
func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { return nil }
func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?) { }
}
// MARK: - Text view (port of V1 InstantPageTextItem.drawInTile)

View file

@ -0,0 +1,278 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import AccountContext
import UniversalMediaPlayer
import SemanticStatusNode
import MusicAlbumArtResources
import PhotoResources
// Renders an InstantPage audio block to match the standard music message bubble
// (ChatMessageInteractiveFileNode, non-thumbnail/non-voice music branch). Visual only differs
// from the file node in that playback is driven by InstantPageMediaPlaylist (our `play` closure)
// rather than the peer-messages model. V1's InstantPageAudioNode is unaffected.
final class InstantPageV2AudioContentNode: ASDisplayNode {
private let context: AccountContext
private let message: MessageReference?
private let file: TelegramMediaFile
private let incoming: Bool
private let statusNode: SemanticStatusNode
private let streamingStatusNode: SemanticStatusNode
private let titleNode: TextNode
private let descriptionNode: TextNode
private let tapView: UIView
// TextNode (unlike ASTextNode) has no stored `attributedText`; the string is an argument to
// `TextNode.asyncLayout`. Keep the current strings here and feed them in `updateLayout`.
private var titleAttributedString: NSAttributedString?
private var descriptionAttributedString: NSAttributedString?
var play: () -> Void = {}
var togglePlayPause: () -> Void = {}
var fetch: () -> Void = {}
private var resourceStatusDisposable: Disposable?
// EngineMediaResourceStatus is the TelegramCore typealias for Postbox's MediaResourceStatus;
// using it keeps this file off `import Postbox` (TelegramCore doesn't re-export Postbox).
private var fetchStatus: EngineMediaResourceStatus?
private var playbackStatusDisposable: Disposable?
private(set) var isPlaying: Bool = false
// Theme-refresh state. `incoming` is already a `let` stored above; `incomingValue` tracks the
// last theme-update's incoming flag so `updatePresentationData` can guard on change.
private var presentationData: PresentationData
private var incomingValue: Bool
private static let progressDiameter: CGFloat = 40.0
// Shifted +9pt right of the original x=3 ( 12); the Ø40 control is vertically centered in the
// 44pt row (y = (44 40)/2 = 2).
private static let progressOrigin = CGPoint(x: 12.0, y: 2.0)
private static let controlAreaWidth: CGFloat = 12.0 + 40.0 + 8.0
private static let normHeight: CGFloat = 44.0
init(context: AccountContext, message: MessageReference?, file: TelegramMediaFile, incoming: Bool, presentationData: PresentationData) {
self.context = context
self.message = message
self.file = file
self.incoming = incoming
self.presentationData = presentationData
self.incomingValue = incoming
let messageTheme = incoming ? presentationData.theme.chat.message.incoming : presentationData.theme.chat.message.outgoing
let backgroundNodeColor = messageTheme.mediaActiveControlColor
let foregroundNodeColor: UIColor = (incoming && messageTheme.mediaActiveControlColor.rgb != 0xffffff) ? .white : .clear
var title: String?
var performer: String?
for attribute in file.attributes {
if case let .Audio(_, _, t, p, _) = attribute { title = t; performer = p; break }
}
let albumArtImage: Signal<(TransformImageArguments) -> DrawingContext?, NoError>?
if file.isMusic, file.fileName?.lowercased().hasSuffix(".ogg") != true, let message = message {
let fileRef: FileMediaReference = .message(message: message, media: file)
albumArtImage = playerAlbumArt(
engine: context.engine,
fileReference: fileRef,
albumArt: SharedMediaPlaybackAlbumArt(
thumbnailResource: ExternalMusicAlbumArtResource(file: fileRef, title: title ?? "", performer: performer ?? "", isThumbnail: true),
fullSizeResource: ExternalMusicAlbumArtResource(file: fileRef, title: title ?? "", performer: performer ?? "", isThumbnail: false)
),
thumbnail: true,
overlayColor: UIColor(white: 0.0, alpha: 0.3),
drawPlaceholderWhenEmpty: false,
attemptSynchronously: false
)
} else {
albumArtImage = nil
}
self.statusNode = SemanticStatusNode(
backgroundNodeColor: backgroundNodeColor,
foregroundNodeColor: foregroundNodeColor,
image: albumArtImage,
overlayForegroundNodeColor: presentationData.theme.chat.message.mediaOverlayControlColors.foregroundColor
)
self.streamingStatusNode = SemanticStatusNode(backgroundNodeColor: .clear, foregroundNodeColor: messageTheme.mediaActiveControlColor)
self.titleNode = TextNode()
self.titleNode.displaysAsynchronously = false
self.titleNode.isUserInteractionEnabled = false
self.descriptionNode = TextNode()
self.descriptionNode.displaysAsynchronously = false
self.descriptionNode.isUserInteractionEnabled = false
self.tapView = UIView()
super.init()
self.titleAttributedString = InstantPageV2AudioContentNode.titleString(file: file, incoming: incoming, presentationData: presentationData)
self.descriptionAttributedString = InstantPageV2AudioContentNode.descriptionString(file: file, incoming: incoming, presentationData: presentationData)
self.addSubnode(self.statusNode)
self.addSubnode(self.streamingStatusNode)
self.addSubnode(self.titleNode)
self.addSubnode(self.descriptionNode)
self.statusNode.transitionToState(.play, animated: false)
self.streamingStatusNode.transitionToState(.none, animated: false)
if let messageId = self.message?.id {
self.resourceStatusDisposable = (messageMediaFileStatus(context: context, messageId: messageId, file: file)
|> deliverOnMainQueue).startStrict(next: { [weak self] status in
self?.fetchStatus = status
self?.updateStreamingState()
})
}
}
override func didLoad() {
super.didLoad()
// Tap target = plain view + UITapGestureRecognizer, NOT an ASControl: ASControl
// .touchUpInside is cancelled by the chat ListView's gesture system (see InstantPageAudioNode
// for the same reason).
self.view.addSubview(self.tapView)
self.tapView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapped)))
}
@objc private func tapped() {
self.controlTapped()
}
func controlTapped() {
switch self.fetchStatus {
case .Remote, .Paused:
self.fetch()
case .none, .Local, .Fetching:
if self.isPlaying {
self.togglePlayPause()
} else {
self.play()
}
}
}
deinit {
self.resourceStatusDisposable?.dispose()
self.playbackStatusDisposable?.dispose()
}
// Drives the big control's play/pause icon from the playlist state filtered to our
// playlistId + itemId. Mirrors InstantPageAudioNode's subscription shape.
func setPlaybackStatusSignal(_ signal: Signal<SharedMediaPlayerItemPlaybackState?, NoError>) {
self.playbackStatusDisposable?.dispose() // defensive: a re-call must not leak the prior subscription
self.playbackStatusDisposable = (signal |> deliverOnMainQueue).startStrict(next: { [weak self] state in
guard let self else { return }
let isPlaying: Bool
if let status = state?.status {
if case .playing = status.status {
isPlaying = true
} else {
isPlaying = false
}
} else {
isPlaying = false
}
self.isPlaying = isPlaying
self.statusNode.transitionToState(isPlaying ? .pause : .play)
})
}
// Refreshes title/description attributed strings and the statusNode tint/foreground/overlay
// colors when the theme or incoming direction changes. Called from the host view's
// update(item:theme:renderContext:).
func updatePresentationData(_ presentationData: PresentationData, incoming: Bool) {
if self.presentationData.theme === presentationData.theme && self.incomingValue == incoming { return }
self.presentationData = presentationData
self.incomingValue = incoming
self.titleAttributedString = InstantPageV2AudioContentNode.titleString(file: self.file, incoming: incoming, presentationData: presentationData)
self.descriptionAttributedString = InstantPageV2AudioContentNode.descriptionString(file: self.file, incoming: incoming, presentationData: presentationData)
let messageTheme = incoming ? presentationData.theme.chat.message.incoming : presentationData.theme.chat.message.outgoing
self.statusNode.backgroundNodeColor = messageTheme.mediaActiveControlColor
// foreground/overlay also depend on incoming + theme (set at construction) refresh them
// too so the play glyph isn't miscolored after an in-place theme/direction change.
self.statusNode.foregroundNodeColor = (incoming && messageTheme.mediaActiveControlColor.rgb != 0xffffff) ? .white : .clear
self.statusNode.overlayForegroundNodeColor = presentationData.theme.chat.message.mediaOverlayControlColors.foregroundColor
// No setNeedsLayout(): this node doesn't override layout(); the host calls updateLayout(width:)
// right after updatePresentationData, which re-runs the text layout with the rebuilt strings.
}
private func updateStreamingState() {
let state: SemanticStatusNodeState
switch self.fetchStatus {
case .none, .Local:
state = .none
case let .Fetching(_, progress):
state = .progress(value: CGFloat(max(progress, 0.027)), cancelEnabled: true, appearance: SemanticStatusNodeState.ProgressAppearance(inset: 1.0, lineWidth: 2.0), animateRotation: true)
case .Remote, .Paused:
state = .download
}
self.streamingStatusNode.transitionToState(state)
}
// Line 1: track title at 17pt (= baseDisplaySize at the default font setting; scales with it).
private static func titleString(file: TelegramMediaFile, incoming: Bool, presentationData: PresentationData) -> NSAttributedString {
let messageTheme = incoming ? presentationData.theme.chat.message.incoming : presentationData.theme.chat.message.outgoing
let titleFont = Font.regular(floor(presentationData.chatFontSize.baseDisplaySize * 17.0 / 17.0))
var title = file.fileName ?? "Unknown Track"
for attribute in file.attributes {
if case let .Audio(false, _, t, _, _) = attribute { title = t ?? title; break }
}
return NSAttributedString(string: title, font: titleFont, textColor: messageTheme.fileTitleColor)
}
// Line 2: "<duration> · <performer>" at 15pt (omits the "· performer" tail when there's no
// performer; omits the duration when it's absent).
private static func descriptionString(file: TelegramMediaFile, incoming: Bool, presentationData: PresentationData) -> NSAttributedString {
let messageTheme = incoming ? presentationData.theme.chat.message.incoming : presentationData.theme.chat.message.outgoing
let descriptionFont = Font.with(size: floor(presentationData.chatFontSize.baseDisplaySize * 15.0 / 17.0), design: .regular, weight: .regular, traits: [.monospacedNumbers])
var performer = ""
var durationSeconds: Int = 0
for attribute in file.attributes {
if case let .Audio(false, duration, _, p, _) = attribute {
performer = (p ?? "").trimmingCharacters(in: .whitespaces)
durationSeconds = duration
break
}
}
var text = ""
if durationSeconds > 0 {
text = String(format: "%d:%02d", Int32(durationSeconds / 60), Int32(durationSeconds % 60))
}
if !performer.isEmpty {
text += text.isEmpty ? performer : " · \(performer)"
}
return NSAttributedString(string: text, font: descriptionFont, textColor: messageTheme.fileDescriptionColor)
}
func updateLayout(width: CGFloat) {
let progressFrame = CGRect(origin: InstantPageV2AudioContentNode.progressOrigin, size: CGSize(width: InstantPageV2AudioContentNode.progressDiameter, height: InstantPageV2AudioContentNode.progressDiameter))
self.statusNode.frame = progressFrame
let streamingDiameter: CGFloat = 24.0
self.streamingStatusNode.frame = CGRect(origin: CGPoint(x: progressFrame.maxX - streamingDiameter + 2.0, y: progressFrame.maxY - streamingDiameter + 2.0), size: CGSize(width: streamingDiameter, height: streamingDiameter))
let controlAreaWidth = InstantPageV2AudioContentNode.controlAreaWidth
let textWidth = max(1.0, width - controlAreaWidth - 8.0)
let (titleLayout, titleApply) = TextNode.asyncLayout(self.titleNode)(TextNodeLayoutArguments(attributedString: self.titleAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .middle, constrainedSize: CGSize(width: textWidth, height: 100.0), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let (descLayout, descApply) = TextNode.asyncLayout(self.descriptionNode)(TextNodeLayoutArguments(attributedString: self.descriptionAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: textWidth, height: 100.0), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let _ = titleApply()
let _ = descApply()
let titleAndDescriptionHeight = titleLayout.size.height - 1.0 + descLayout.size.height
let normHeight = InstantPageV2AudioContentNode.normHeight
let titleFrame = CGRect(origin: CGPoint(x: controlAreaWidth, y: floor((normHeight - titleAndDescriptionHeight) / 2.0)), size: titleLayout.size)
self.titleNode.frame = titleFrame
self.descriptionNode.frame = CGRect(origin: CGPoint(x: titleFrame.minX, y: titleFrame.maxY - 1.0), size: descLayout.size)
// No scrubber. The tapView covers the full row so a tap anywhere toggles playback (there is
// no scrubber pan to conflict with anymore).
self.tapView.frame = CGRect(origin: .zero, size: CGSize(width: width, height: normHeight))
}
}

View file

@ -6,6 +6,7 @@ import TelegramPresentationData
import TelegramUIPreferences
import TextFormat
import TelegramStringFormatting
import MosaicLayout
// MARK: - Public layout data types
@ -49,6 +50,8 @@ public struct InstantPageV2Layout {
case let .mediaVideo(m): result.append(m.media)
case let .mediaMap(m): result.append(m.media)
case let .mediaCoverImage(m): result.append(m.media)
case let .mediaAudio(m): result.append(m.media)
case let .slideshow(s): result.append(contentsOf: s.medias)
case let .details(d):
if let inner = d.innerLayout {
collectMedias(in: inner.items, into: &result)
@ -84,8 +87,10 @@ public enum InstantPageV2LaidOutItem {
case mediaVideo(InstantPageV2MediaVideoItem)
case mediaMap(InstantPageV2MediaMapItem)
case mediaCoverImage(InstantPageV2MediaCoverImageItem)
case mediaAudio(InstantPageV2MediaAudioItem)
case formula(InstantPageV2FormulaItem)
case thinking(InstantPageV2ThinkingItem)
case slideshow(InstantPageV2SlideshowItem)
public var frame: CGRect {
switch self {
@ -103,8 +108,10 @@ public enum InstantPageV2LaidOutItem {
case let .mediaVideo(item): return item.frame
case let .mediaMap(item): return item.frame
case let .mediaCoverImage(item): return item.frame
case let .mediaAudio(item): return item.frame
case let .formula(item): return item.frame
case let .thinking(item): return item.frame
case let .slideshow(item): return item.frame
}
}
@ -127,8 +134,10 @@ public enum InstantPageV2LaidOutItem {
case var .mediaVideo(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .mediaVideo(item)
case var .mediaMap(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .mediaMap(item)
case var .mediaCoverImage(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .mediaCoverImage(item)
case var .mediaAudio(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .mediaAudio(item)
case var .formula(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .formula(item)
case var .thinking(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .thinking(item)
case var .slideshow(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .slideshow(item)
}
}
}
@ -236,6 +245,18 @@ public struct InstantPageV2MediaImageItem {
}
}
public struct InstantPageV2MediaAudioItem {
public var frame: CGRect
public let media: InstantPageMedia
public let webPage: TelegramMediaWebpage
public init(frame: CGRect, media: InstantPageMedia, webPage: TelegramMediaWebpage) {
self.frame = frame
self.media = media
self.webPage = webPage
}
}
public struct InstantPageV2MediaVideoItem {
public var frame: CGRect
public let cornerRadius: CGFloat
@ -290,6 +311,18 @@ public struct InstantPageV2MediaPlaceholderItem {
public let cornerRadius: CGFloat
}
public struct InstantPageV2SlideshowItem {
public var frame: CGRect
public let medias: [InstantPageMedia]
public let webPage: TelegramMediaWebpage
public init(frame: CGRect, medias: [InstantPageMedia], webPage: TelegramMediaWebpage) {
self.frame = frame
self.medias = medias
self.webPage = webPage
}
}
public struct InstantPageV2DetailsItem {
public var frame: CGRect
public let index: Int
@ -688,6 +721,7 @@ private func layoutBlock(
caption: caption,
isCover: isCover,
cornerRadius: 8.0,
flush: true,
boundingWidth: boundingWidth,
horizontalInset: horizontalInset,
context: &context
@ -727,6 +761,7 @@ private func layoutBlock(
caption: caption,
isCover: isCover,
cornerRadius: 8.0,
flush: true,
boundingWidth: boundingWidth,
horizontalInset: horizontalInset,
context: &context
@ -735,11 +770,34 @@ private func layoutBlock(
return []
}
case let .audio(_, caption):
return layoutMediaWithCaption(kind: .audio,
naturalSize: CGSize(width: boundingWidth, height: 56.0), caption: caption,
isCover: false, cornerRadius: 8.0, boundingWidth: boundingWidth,
horizontalInset: horizontalInset, context: &context)
case let .audio(audioId, caption):
guard case let .file(file) = context.media[audioId] else {
return []
}
let mediaIndex = context.mediaIndexCounter
context.mediaIndexCounter += 1
let instantPageMedia = InstantPageMedia(
index: mediaIndex,
media: .file(file),
url: nil,
caption: nil,
credit: nil
)
let audioFrame = CGRect(x: 0.0, y: 0.0, width: boundingWidth, height: 44.0)
var result: [InstantPageV2LaidOutItem] = [.mediaAudio(InstantPageV2MediaAudioItem(
frame: audioFrame,
media: instantPageMedia,
webPage: context.webpage
))]
let (captionItems, _) = layoutCaptionAndCredit(
caption,
offset: audioFrame.height,
boundingWidth: boundingWidth,
horizontalInset: horizontalInset,
context: &context
)
result.append(contentsOf: captionItems)
return result
case let .webEmbed(url, _, dimensions, caption, _, _, coverId):
// V1 (InstantPageLayout.swift:848): if the embed has a URL and a resolvable cover image,
@ -800,6 +858,7 @@ private func layoutBlock(
caption: caption,
isCover: false,
cornerRadius: 0.0,
flush: true,
boundingWidth: boundingWidth,
horizontalInset: horizontalInset,
context: &context
@ -809,38 +868,48 @@ private func layoutBlock(
let h: CGFloat = CGFloat(dimensions?.height ?? 240)
return layoutMediaWithCaption(kind: .webEmbed,
naturalSize: CGSize(width: boundingWidth, height: h), caption: caption,
isCover: false, cornerRadius: 0.0, boundingWidth: boundingWidth,
isCover: false, cornerRadius: 0.0, flush: true, boundingWidth: boundingWidth,
horizontalInset: horizontalInset, context: &context)
}
case let .postEmbed(_, _, _, _, _, _, caption):
return layoutMediaWithCaption(kind: .postEmbed,
naturalSize: CGSize(width: boundingWidth, height: 140.0), caption: caption,
isCover: false, cornerRadius: 8.0, boundingWidth: boundingWidth,
isCover: false, cornerRadius: 8.0, flush: true, boundingWidth: boundingWidth,
horizontalInset: horizontalInset, context: &context)
case let .collage(_, caption):
return layoutMediaWithCaption(kind: .collage,
naturalSize: CGSize(width: boundingWidth, height: 240.0), caption: caption,
isCover: false, cornerRadius: 8.0, boundingWidth: boundingWidth,
horizontalInset: horizontalInset, context: &context)
case let .collage(items, caption):
return layoutCollage(items: items, caption: caption, isCover: isCover,
boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context)
case let .slideshow(_, caption):
return layoutMediaWithCaption(kind: .slideshow,
naturalSize: CGSize(width: boundingWidth, height: 240.0), caption: caption,
isCover: false, cornerRadius: 8.0, boundingWidth: boundingWidth,
horizontalInset: horizontalInset, context: &context)
case let .slideshow(items, caption):
return layoutSlideshow(items: items, caption: caption,
boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context)
case let .channelBanner(channel):
if channel == nil { return [] }
return layoutMediaWithCaption(kind: .channelBanner,
naturalSize: CGSize(width: boundingWidth, height: 60.0),
caption: InstantPageCaption(text: .empty, credit: .empty),
isCover: false, cornerRadius: 0.0, boundingWidth: boundingWidth,
isCover: false, cornerRadius: 0.0, flush: true, boundingWidth: boundingWidth,
horizontalInset: horizontalInset, context: &context)
case let .map(latitude, longitude, zoom, dimensions, caption):
let naturalSize = CGSize(width: CGFloat(dimensions.width), height: CGFloat(dimensions.height))
// AI/server-sent `.map` blocks can arrive with zero `dimensions` (the wire `w`/`h` are
// required, but the sender may put 0). A zero `naturalSize.height` collapses the media
// frame to height 0 (`instantPageV2MediaFrame`'s else branch) the map takes no space,
// the caption slides up into it, and the pin floats over the caption and a zero-sized
// `MapSnapshotMediaResource` makes `MKMapSnapshotter` render nothing. Substitute a sensible
// default (a 2:1 map strip) for BOTH the layout size and the snapshot resource. Real web
// articles (the V1 renderer) always carry real dimensions, so only the rich-message path
// hits this; the fallback is scoped here rather than in V1 or the wire/parse layer.
let effectiveDimensions: PixelDimensions
if dimensions.width > 0 && dimensions.height > 0 {
effectiveDimensions = dimensions
} else {
effectiveDimensions = PixelDimensions(width: 600, height: 300)
}
let naturalSize = CGSize(width: CGFloat(effectiveDimensions.width), height: CGFloat(effectiveDimensions.height))
let map = TelegramMediaMap(
latitude: latitude,
longitude: longitude,
@ -850,7 +919,7 @@ private func layoutBlock(
liveBroadcastingTimeout: nil,
liveProximityNotificationRadius: nil
)
let mapAttributes: [InstantPageImageAttribute] = [InstantPageMapAttribute(zoom: zoom, dimensions: dimensions.cgSize)]
let mapAttributes: [InstantPageImageAttribute] = [InstantPageMapAttribute(zoom: zoom, dimensions: effectiveDimensions.cgSize)]
let mediaIndex = context.mediaIndexCounter
context.mediaIndexCounter += 1
let instantPageMedia = InstantPageMedia(
@ -875,6 +944,7 @@ private func layoutBlock(
caption: caption,
isCover: false,
cornerRadius: 8.0,
flush: true,
boundingWidth: boundingWidth,
horizontalInset: horizontalInset,
context: &context
@ -885,7 +955,7 @@ private func layoutBlock(
return layoutMediaWithCaption(kind: .relatedArticles,
naturalSize: CGSize(width: boundingWidth, height: max(h, 80.0)),
caption: InstantPageCaption(text: .empty, credit: .empty),
isCover: false, cornerRadius: 0.0, boundingWidth: boundingWidth,
isCover: false, cornerRadius: 0.0, flush: true, boundingWidth: boundingWidth,
horizontalInset: horizontalInset, context: &context)
case let .formula(latex):
@ -1644,6 +1714,60 @@ private func layoutCaptionAndCredit(
return (items, totalHeight)
}
// How many points a full-width flush media item bleeds past the bubble interior on the
// trailing edge so the rounded `containerNode` clip (see ChatMessageRichDataBubbleContentNode) rounds
// the trailing corners with no 1px background sliver. Harmless: the
// `contentSize.width = min(maxX, boundingWidth)` clamp keeps it from widening the bubble.
private let instantPageV2MediaEdgeBleed: CGFloat = 4.0
// Computes the laid-out frame for a block-media item.
//
// `flush == true` (every current caller): the media is edge-to-edge (x = 0, full
// `boundingWidth`) with corner radius forced to 0, relying on the bubble's rounded clipping
// container to round media that meets the bubble's top/bottom edge. A media item that fills the
// full width is widened by `instantPageV2MediaEdgeBleed` on the trailing edge (see the constant).
// A media item narrower than the full width (a small image NOT upscaled, the `min(_, 1.0)`
// scale cap is kept) stays at its natural size, flush-left at x = 0, with no bleed.
// (The `cornerRadius` argument is ignored when `flush == true` flush media is always
// un-rounded; callers may still pass their legacy radius, it has no effect.)
//
// `flush == false`: DEAD as of the V2 audio port audio was its last caller and now has its
// own `layoutAudio` arm (in `layoutBlock`), so this branch is currently unreachable (follow-up:
// drop the `flush` parameter and this branch). Legacy behavior was: inset by `horizontalInset`
// on each side with the caller-supplied corner radius.
//
// Returns the frame, the un-bled scaled content size (the caption is offset by
// `scaledSize.height`), and the effective corner radius to stamp on the item.
private func instantPageV2MediaFrame(
naturalSize: CGSize,
flush: Bool,
cornerRadius: CGFloat,
boundingWidth: CGFloat,
horizontalInset: CGFloat
) -> (frame: CGRect, scaledSize: CGSize, cornerRadius: CGFloat) {
let availableWidth = flush ? boundingWidth : (boundingWidth - horizontalInset * 2.0)
let scaledSize: CGSize
if naturalSize.width > 0.0 && naturalSize.height > 0.0 {
let scale = min(availableWidth / naturalSize.width, 1.0)
scaledSize = CGSize(width: floor(naturalSize.width * scale), height: floor(naturalSize.height * scale))
} else {
scaledSize = CGSize(width: availableWidth, height: naturalSize.height)
}
if flush {
// `floor(x) > x - 1` always, so a full-width item (scaledSize.width == floor(availableWidth))
// always trips this; a genuinely smaller image does not. (availableWidth == boundingWidth
// in the flush branch, so the bleed below extends past the full bounding width.)
let fillsWidth = scaledSize.width >= availableWidth - 1.0
let frameWidth = fillsWidth ? boundingWidth + instantPageV2MediaEdgeBleed : scaledSize.width
let frame = CGRect(x: 0.0, y: 0.0, width: frameWidth, height: scaledSize.height)
return (frame, scaledSize, 0.0)
} else {
let frame = CGRect(x: horizontalInset, y: 0.0, width: scaledSize.width, height: scaledSize.height)
return (frame, scaledSize, cornerRadius)
}
}
/// Variant of `layoutMediaWithCaption` that emits a caller-produced typed media item
/// instead of a `.mediaPlaceholder`. The frame-fitting logic + caption/credit text item
/// layout is otherwise identical.
@ -1653,21 +1777,19 @@ private func layoutTypedMediaWithCaption(
caption: InstantPageCaption,
isCover: Bool,
cornerRadius: CGFloat,
flush: Bool,
boundingWidth: CGFloat,
horizontalInset: CGFloat,
context: inout LayoutContext
) -> [InstantPageV2LaidOutItem] {
let availableWidth = boundingWidth - horizontalInset * 2.0
let scaledSize: CGSize
if naturalSize.width > 0.0 && naturalSize.height > 0.0 {
let scale = min(availableWidth / naturalSize.width, 1.0)
scaledSize = CGSize(width: floor(naturalSize.width * scale), height: floor(naturalSize.height * scale))
} else {
scaledSize = CGSize(width: availableWidth, height: naturalSize.height)
}
let mediaFrame = CGRect(x: horizontalInset, y: 0.0, width: scaledSize.width, height: scaledSize.height)
var result: [InstantPageV2LaidOutItem] = [produceItem(mediaFrame, cornerRadius)]
let (mediaFrame, scaledSize, effectiveCornerRadius) = instantPageV2MediaFrame(
naturalSize: naturalSize,
flush: flush,
cornerRadius: cornerRadius,
boundingWidth: boundingWidth,
horizontalInset: horizontalInset
)
var result: [InstantPageV2LaidOutItem] = [produceItem(mediaFrame, effectiveCornerRadius)]
let (captionItems, captionHeight) = layoutCaptionAndCredit(
caption,
@ -1695,36 +1817,154 @@ private func layoutTypedMediaWithCaption(
return result
}
/// Lays out an `InstantPageBlock.collage(items:caption:)`. Mirrors V1
/// (InstantPageLayout.swift:692-727): compute a mosaic over the inner image/video sizes, then emit
/// one existing typed media item per cell at its mosaic frame, flush (cornerRadius 0) so the bubble's
/// rounded clip handles the outer corners and the 1pt mosaic spacing handles the interior gaps. A
/// single caption renders below the whole mosaic. Cells are top-level `.mediaImage`/`.mediaVideo`
/// items, so gallery / reveal / registry / hidden-media all work with no extra code.
private func layoutCollage(
items innerBlocks: [InstantPageBlock],
caption: InstantPageCaption,
isCover: Bool,
boundingWidth: CGFloat,
horizontalInset: CGFloat,
context: inout LayoutContext
) -> [InstantPageV2LaidOutItem] {
// 1. One size per inner block (zero for unresolved V1 still reserves a mosaic slot).
var itemSizes: [CGSize] = []
for block in innerBlocks {
switch block {
case let .image(id, _, _, _):
if case let .image(image) = context.media[id], let largest = largestImageRepresentation(image.representations) {
itemSizes.append(CGSize(width: CGFloat(largest.dimensions.width), height: CGFloat(largest.dimensions.height)))
} else {
itemSizes.append(CGSize())
}
case let .video(id, _, _, _):
if case let .file(file) = context.media[id], let dimensions = file.dimensions {
itemSizes.append(CGSize(width: CGFloat(dimensions.width), height: CGFloat(dimensions.height)))
} else {
itemSizes.append(CGSize())
}
default:
itemSizes.append(CGSize())
}
}
// 2. Mosaic geometry the same engine V1 uses.
let (mosaic, mosaicSize) = chatMessageBubbleMosaicLayout(maxSize: CGSize(width: boundingWidth, height: boundingWidth), itemSizes: itemSizes)
// 3. One typed media item per resolvable cell, at its mosaic frame.
var result: [InstantPageV2LaidOutItem] = []
let webpage = context.webpage
for (i, block) in innerBlocks.enumerated() {
guard i < mosaic.count else { break }
let (cellFrame, position) = mosaic[i]
// Right-edge cells bleed 4pt so the bubble's rounded clip leaves no trailing sliver.
var frame = cellFrame
if position.contains(.right) {
frame.size.width += instantPageV2MediaEdgeBleed
}
switch block {
case let .image(id, blockCaption, url, webpageId):
guard case let .image(image) = context.media[id] else { continue }
let mediaIndex = context.mediaIndexCounter
context.mediaIndexCounter += 1
let mediaUrl: InstantPageUrlItem? = url.flatMap { InstantPageUrlItem(url: $0, webpageId: webpageId) }
let media = InstantPageMedia(index: mediaIndex, media: .image(image), url: mediaUrl, caption: blockCaption.text, credit: blockCaption.credit)
result.append(.mediaImage(InstantPageV2MediaImageItem(frame: frame, cornerRadius: 0.0, media: media, webPage: webpage, attributes: [])))
case let .video(id, blockCaption, _, _):
guard case let .file(file) = context.media[id] else { continue }
let mediaIndex = context.mediaIndexCounter
context.mediaIndexCounter += 1
let media = InstantPageMedia(index: mediaIndex, media: .file(file), url: nil, caption: blockCaption.text, credit: blockCaption.credit)
result.append(.mediaVideo(InstantPageV2MediaVideoItem(frame: frame, cornerRadius: 0.0, media: media, webPage: webpage, attributes: [])))
default:
continue
}
}
// 4. Caption below the mosaic.
let (captionItems, captionHeight) = layoutCaptionAndCredit(caption, offset: mosaicSize.height, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context)
result.append(contentsOf: captionItems)
// Cover-caption padding parity with layoutTypedMediaWithCaption.
if isCover && captionHeight > 0.0 {
if let lastIndex = result.lastIndex(where: { if case .text = $0 { return true } else { return false } }) {
if case var .text(lastText) = result[lastIndex] {
lastText.frame = CGRect(origin: lastText.frame.origin, size: CGSize(width: lastText.frame.width, height: lastText.frame.height + 14.0))
result[lastIndex] = .text(lastText)
}
}
}
return result
}
/// Lays out an `InstantPageBlock.slideshow(items:caption:)`. Mirrors V1
/// (InstantPageLayout.swift:809-843): collect the inner image medias, size the block to the tallest
/// image fitted into the bounding width (cap 1200), emit a single full-width slideshow carousel item,
/// caption below. Only `.image` inner blocks contribute (matches V1).
private func layoutSlideshow(
items innerBlocks: [InstantPageBlock],
caption: InstantPageCaption,
boundingWidth: CGFloat,
horizontalInset: CGFloat,
context: inout LayoutContext
) -> [InstantPageV2LaidOutItem] {
var medias: [InstantPageMedia] = []
var height: CGFloat = 0.0
for block in innerBlocks {
switch block {
case let .image(id, blockCaption, url, webpageId):
if case let .image(image) = context.media[id], let imageSize = largestImageRepresentation(image.representations)?.dimensions {
let mediaIndex = context.mediaIndexCounter
context.mediaIndexCounter += 1
let filledSize = imageSize.cgSize.fitted(CGSize(width: boundingWidth, height: 1200.0))
height = max(height, filledSize.height)
let mediaUrl: InstantPageUrlItem? = url.flatMap { InstantPageUrlItem(url: $0, webpageId: webpageId) }
medias.append(InstantPageMedia(index: mediaIndex, media: .image(image), url: mediaUrl, caption: blockCaption.text, credit: blockCaption.credit))
}
default:
break
}
}
var result: [InstantPageV2LaidOutItem] = []
result.append(.slideshow(InstantPageV2SlideshowItem(
frame: CGRect(x: 0.0, y: 0.0, width: boundingWidth, height: height),
medias: medias,
webPage: context.webpage
)))
let (captionItems, _) = layoutCaptionAndCredit(caption, offset: height, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context)
result.append(contentsOf: captionItems)
return result
}
private func layoutMediaWithCaption(
kind: InstantPageV2MediaPlaceholderKind,
naturalSize: CGSize,
caption: InstantPageCaption,
isCover: Bool,
cornerRadius: CGFloat,
flush: Bool,
boundingWidth: CGFloat,
horizontalInset: CGFloat,
context: inout LayoutContext
) -> [InstantPageV2LaidOutItem] {
// Scale naturalSize to fit within (boundingWidth - horizontalInset*2) × naturalSize.height.
let availableWidth = boundingWidth - horizontalInset * 2.0
let scaledSize: CGSize
if naturalSize.width > 0.0 && naturalSize.height > 0.0 {
let scale = min(availableWidth / naturalSize.width, 1.0)
scaledSize = CGSize(width: floor(naturalSize.width * scale), height: floor(naturalSize.height * scale))
} else {
scaledSize = CGSize(width: availableWidth, height: naturalSize.height)
}
let placeholderFrame = CGRect(
x: horizontalInset,
y: 0.0,
width: scaledSize.width,
height: scaledSize.height
let (placeholderFrame, scaledSize, effectiveCornerRadius) = instantPageV2MediaFrame(
naturalSize: naturalSize,
flush: flush,
cornerRadius: cornerRadius,
boundingWidth: boundingWidth,
horizontalInset: horizontalInset
)
let placeholderItem = InstantPageV2MediaPlaceholderItem(
frame: placeholderFrame,
kind: kind,
cornerRadius: cornerRadius
cornerRadius: effectiveCornerRadius
)
var result: [InstantPageV2LaidOutItem] = [.mediaPlaceholder(placeholderItem)]
@ -2134,8 +2374,8 @@ private func layoutQuoteText(
let lineWidth = boundingWidth - horizontalInset * 2.0
let topLine = InstantPageV2ShapeItem(
frame: CGRect(x: horizontalInset, y: contentHeight, width: lineWidth, height: 1.0),
kind: .line(thickness: 1.0),
color: context.theme.textCategories.caption.color
kind: .line(thickness: UIScreenPixel),
color: context.theme.separatorColor
)
result.append(.shape(topLine))
contentHeight += 1.0 + verticalInset // rule + small gap before body text

View file

@ -7,6 +7,7 @@ import AccountContext
import TelegramCore
import TelegramPresentationData
import GalleryUI
import UniversalMediaPlayer
// Mutable weak box: lets a wrapper hand its `openMedia` closure a back-reference to itself,
// filled in after `super.init` (when `self` becomes usable). SwiftSignalKit's `Weak<T>` requires
@ -19,7 +20,7 @@ private final class WrapperRef {
// Hosts a V1 `InstantPageImageNode` inside a V2 UIView wrapper. The caller sizes its own
// frame from `item.frame` and adds the returned node's view as a subview.
private func makeMediaWrapper(
func makeMediaWrapper(
frame: CGRect,
media: InstantPageMedia,
webPage: TelegramMediaWebpage,
@ -68,14 +69,14 @@ private func findEnclosingV2View(from start: UIView?) -> InstantPageV2View? {
// its `rootMediaRegistryHost` chain transitively (nested details blocks can leave an inner
// body's host pointing at an intermediate body see `trueRegistryRoot`). No-op if the wrapper
// isn't yet attached to a V2View ancestor.
private func registerInRootRegistry(wrapper: UIView, mediaIndex: Int) {
func registerInRootRegistry(wrapper: UIView, mediaIndex: Int) {
guard let v2 = findEnclosingV2View(from: wrapper.superview) else { return }
v2.trueRegistryRoot.mediaRegistry[mediaIndex] = Weak(wrapper)
}
// Routes a tap on `tapped` through `openInstantPageMedia`, sourcing sibling medias from the
// root V2View's `currentLayout`. No-op if the wrapper isn't currently in a V2View tree.
private func handleOpenMediaTap(
func handleOpenMediaTap(
tapped: InstantPageMedia,
wrapper: UIView,
renderContext: InstantPageV2RenderContext
@ -164,6 +165,14 @@ final class InstantPageV2MediaImageView: UIView, InstantPageItemView {
let strings = renderContext.context.sharedContext.currentPresentationData.with { $0 }.strings
self.wrappedNode.update(strings: strings, theme: theme)
}
func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
return self.wrappedNode.transitionNode(media: media)
}
func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?) {
self.wrappedNode.updateHiddenMedia(media: media)
}
}
final class InstantPageV2MediaVideoView: UIView, InstantPageItemView {
@ -219,6 +228,14 @@ final class InstantPageV2MediaVideoView: UIView, InstantPageItemView {
let strings = renderContext.context.sharedContext.currentPresentationData.with { $0 }.strings
self.wrappedNode.update(strings: strings, theme: theme)
}
func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
return self.wrappedNode.transitionNode(media: media)
}
func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?) {
self.wrappedNode.updateHiddenMedia(media: media)
}
}
final class InstantPageV2MediaMapView: UIView, InstantPageItemView {
@ -274,6 +291,14 @@ final class InstantPageV2MediaMapView: UIView, InstantPageItemView {
let strings = renderContext.context.sharedContext.currentPresentationData.with { $0 }.strings
self.wrappedNode.update(strings: strings, theme: theme)
}
func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
return self.wrappedNode.transitionNode(media: media)
}
func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?) {
self.wrappedNode.updateHiddenMedia(media: media)
}
}
final class InstantPageV2MediaCoverImageView: UIView, InstantPageItemView {
@ -329,4 +354,142 @@ final class InstantPageV2MediaCoverImageView: UIView, InstantPageItemView {
let strings = renderContext.context.sharedContext.currentPresentationData.with { $0 }.strings
self.wrappedNode.update(strings: strings, theme: theme)
}
func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
return self.wrappedNode.transitionNode(media: media)
}
func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?) {
self.wrappedNode.updateHiddenMedia(media: media)
}
}
// Sets up shared-media playback for an audio tap. Mirrors V1's
// `InstantPageControllerNode.openMedia(_:)` audio branch: collect the page's voice/music
// medias from the root V2View's current layout, build an `InstantPageMediaPlaylist` keyed by
// `playlistId`, and start playback. No-op if the wrapper isn't currently in a V2View tree.
func handleOpenAudioTap(
tapped: InstantPageMedia,
wrapper: UIView,
renderContext: InstantPageV2RenderContext,
playlistId: InstantPageMediaPlaylistId
) {
guard case let .file(tappedFile) = tapped.media, tappedFile.isVoice || tappedFile.isMusic else { return }
guard let v2 = findEnclosingV2View(from: wrapper.superview) else { return }
let root = v2.trueRegistryRoot
guard let layout = root.currentLayout else { return }
var audioMedias: [InstantPageMedia] = []
var initialIndex = 0
for media in layout.allMedias() {
if case let .file(file) = media.media, (file.isVoice || file.isMusic) {
if media.index == tapped.index {
initialIndex = audioMedias.count
}
audioMedias.append(media)
}
}
let playlist = InstantPageMediaPlaylist(
playlistId: playlistId,
webPage: renderContext.webpage,
messageReference: renderContext.message,
items: audioMedias,
initialItemIndex: initialIndex
)
renderContext.context.sharedContext.mediaManager.setPlaylist(
(renderContext.context, playlist),
type: tappedFile.isVoice ? .voice : .music,
control: .playback(.play)
)
}
final class InstantPageV2MediaAudioView: UIView, InstantPageItemView {
private(set) var item: InstantPageV2MediaAudioItem
var itemFrame: CGRect { return self.item.frame }
private let audioNode: InstantPageV2AudioContentNode
init(item: InstantPageV2MediaAudioItem, renderContext: InstantPageV2RenderContext, theme: InstantPageTheme) {
self.item = item
// `.richMessage(messageId)` isolates playback state per chat message; the preview (no
// message) falls back to the webpage-keyed id (only one preview is ever on screen).
let playlistId: InstantPageMediaPlaylistId
if let messageId = renderContext.message?.id {
playlistId = .richMessage(messageId: messageId)
} else {
playlistId = .instantPage(webpageId: renderContext.webpage.webpageId)
}
let wrapperRef = WrapperRef()
let renderContextRef = renderContext
let itemMedia = item.media
let presentationData = renderContext.context.sharedContext.currentPresentationData.with { $0 }
let incoming = renderContext.message?.isIncoming == true
let audioFile: TelegramMediaFile
if case let .file(f) = item.media.media { audioFile = f } else { audioFile = TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: EmptyMediaResource(), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/mpeg", size: nil, attributes: [], alternativeRepresentations: []) }
self.audioNode = InstantPageV2AudioContentNode(context: renderContext.context, message: renderContext.message, file: audioFile, incoming: incoming, presentationData: presentationData)
super.init(frame: item.frame)
self.backgroundColor = .clear // structural
self.addSubview(self.audioNode.view) // structural
wrapperRef.view = self // structural: back-reference for the play closure
self.audioNode.play = {
guard let wrapper = wrapperRef.view else { return }
handleOpenAudioTap(tapped: itemMedia, wrapper: wrapper, renderContext: renderContextRef, playlistId: playlistId)
}
let fetchContext = renderContext.context
let fetchMessage = renderContext.message
let fetchMedia = item.media
self.audioNode.fetch = {
guard case let .file(file) = fetchMedia.media, let message = fetchMessage, let messageId = message.id else { return }
// Route through the fetch manager (not freeMediaFileInteractiveFetched) so the
// messageMediaFileStatus signal which keys progress off the fetch manager's
// `hasEntry` surfaces .Fetching, letting the overlay show the animated ring.
let _ = messageMediaFileInteractiveFetched(fetchManager: fetchContext.fetchManager, messageId: messageId, messageReference: message, file: file, userInitiated: true, priority: .userInitiated).startStandalone()
}
let mediaForPlayback = item.media
let playlistTypeForPlayback: MediaManagerPlayerType
if case let .file(f) = mediaForPlayback.media, f.isVoice { playlistTypeForPlayback = .voice } else { playlistTypeForPlayback = .music }
let contextForPlayback = renderContext.context
self.audioNode.togglePlayPause = {
contextForPlayback.sharedContext.mediaManager.playlistControl(.playback(.togglePlayPause), type: playlistTypeForPlayback)
}
let stateSignal = contextForPlayback.sharedContext.mediaManager.filteredPlaylistState(accountId: contextForPlayback.account.id, playlistId: playlistId, itemId: InstantPageMediaPlaylistItemId(index: mediaForPlayback.index), type: playlistTypeForPlayback)
self.audioNode.setPlaybackStatusSignal(stateSignal)
self.update(item: item, theme: theme, renderContext: renderContext)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func layoutSubviews() {
super.layoutSubviews()
self.audioNode.frame = self.bounds
self.audioNode.updateLayout(width: self.bounds.width)
}
func update(item: InstantPageV2MediaAudioItem, theme: InstantPageTheme, renderContext: InstantPageV2RenderContext) {
self.item = item
let presentationData = renderContext.context.sharedContext.currentPresentationData.with { $0 }
let incoming = renderContext.message?.isIncoming == true
self.audioNode.updatePresentationData(presentationData, incoming: incoming)
self.audioNode.updateLayout(width: self.bounds.width)
}
// Audio is not a gallery item: explicit nil/no-op witnesses (per the existing pattern of
// explicit per-class witnesses rather than a shared protocol-extension override).
func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
return nil
}
func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?) {
}
}

View file

@ -334,7 +334,7 @@ private func computeEntries(items: [InstantPageV2LaidOutItem], cursor: inout Int
// positions are identical whether or not thinking blocks are present, so adding/
// removing a thinking block never jumps the answer's reveal position.
entries.append(.thinking(start: cursor))
case .formula, .mediaImage, .mediaVideo, .mediaMap, .mediaCoverImage, .mediaPlaceholder,
case .formula, .mediaImage, .mediaVideo, .mediaMap, .mediaCoverImage, .mediaAudio, .mediaPlaceholder, .slideshow,
.divider, .listMarker, .blockQuoteBar, .shape, .anchor:
let start = cursor
cursor += itemWidthCost(item)

View file

@ -0,0 +1,183 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import AccountContext
import TelegramCore
import TelegramPresentationData
// A paged carousel for an `InstantPageBlock.slideshow`. Ports V1's InstantPageSlideshowNode /
// InstantPageSlideshowPagerNode (InstantPageSlideshowItemNode.swift), simplified to create all pages
// eagerly (slideshows are short; this avoids V1's central±1 index bookkeeping and makes the gallery
// transition source available for every page). Each image page hosts an `InstantPageImageNode` exactly
// like the static media views; non-image medias render an empty page (matches V1).
final class InstantPageV2SlideshowView: UIView, InstantPageItemView, UIScrollViewDelegate {
private(set) var item: InstantPageV2SlideshowItem
var itemFrame: CGRect { return self.item.frame }
private let renderContext: InstantPageV2RenderContext
private var theme: InstantPageTheme
private let scrollView: UIScrollView
private let pageControlNode: PageControlNode
// One wrapper view per media (so page count stays aligned with the page control). `pageImageNodes`
// holds only the real image nodes; it may be shorter than `pageViews` if a non-image media appears
// (which `layoutSlideshow` currently filters out). Nothing relies on positional correspondence.
private var pageViews: [UIView] = []
private var pageImageNodes: [InstantPageImageNode] = []
init(item: InstantPageV2SlideshowItem, renderContext: InstantPageV2RenderContext, theme: InstantPageTheme) {
self.item = item
self.renderContext = renderContext
self.theme = theme
self.scrollView = UIScrollView()
self.pageControlNode = PageControlNode(dotColor: .white, inactiveDotColor: UIColor(white: 1.0, alpha: 0.5))
super.init(frame: item.frame)
self.backgroundColor = theme.panelSecondaryColor // structural
self.clipsToBounds = true // structural
self.scrollView.isPagingEnabled = true
self.scrollView.showsHorizontalScrollIndicator = false
self.scrollView.showsVerticalScrollIndicator = false
self.scrollView.scrollsToTop = false
if #available(iOS 11.0, *) {
self.scrollView.contentInsetAdjustmentBehavior = .never
}
self.scrollView.delegate = self
self.addSubview(self.scrollView) // structural
self.pageControlNode.isUserInteractionEnabled = false
self.addSubview(self.pageControlNode.view) // structural
self.rebuildPages()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
private func rebuildPages() {
for view in self.pageViews {
view.removeFromSuperview()
}
self.pageViews = []
self.pageImageNodes = []
let renderContext = self.renderContext
// The image node owns this closure, and is owned (transitively) by self capture weakly.
let openMedia: (InstantPageMedia) -> Void = { [weak self] tapped in
guard let self else { return }
handleOpenMediaTap(tapped: tapped, wrapper: self, renderContext: renderContext)
}
for media in self.item.medias {
let pageView = UIView()
pageView.clipsToBounds = true
if case .image = media.media {
let node = makeMediaWrapper(
frame: CGRect(origin: .zero, size: self.item.frame.size),
media: media,
webPage: self.item.webPage,
attributes: [],
renderContext: self.renderContext,
theme: self.theme,
openMedia: openMedia,
longPressMedia: { _ in }
)
pageView.addSubview(node.view)
self.pageImageNodes.append(node)
}
// Non-image medias (none in practice layoutSlideshow filters to images) get an empty page
// to keep page indices aligned with the page control.
self.scrollView.addSubview(pageView)
self.pageViews.append(pageView)
}
self.pageControlNode.pagesCount = self.item.medias.count
self.pageControlNode.setPage(0.0)
// Re-register media indices when rebuilding while already on-window (positional reuse with
// changed content); no-ops before the view is attached, where didMoveToWindow handles it.
self.registerMedias()
self.setNeedsLayout()
}
private func registerMedias() {
guard self.window != nil else { return }
// Register under every contained media index so transitionArgsFor(media) can find this view.
for media in self.item.medias {
registerInRootRegistry(wrapper: self, mediaIndex: media.index)
}
}
override func didMoveToWindow() {
super.didMoveToWindow()
self.registerMedias()
}
override func layoutSubviews() {
super.layoutSubviews()
let size = self.bounds.size
guard size.width > 0.0, size.height > 0.0 else { return }
self.scrollView.frame = CGRect(origin: .zero, size: size)
for (i, pageView) in self.pageViews.enumerated() {
pageView.frame = CGRect(x: CGFloat(i) * size.width, y: 0.0, width: size.width, height: size.height)
}
for node in self.pageImageNodes {
node.frame = CGRect(origin: .zero, size: size)
}
self.scrollView.contentSize = CGSize(width: CGFloat(self.pageViews.count) * size.width, height: size.height)
self.pageControlNode.layer.transform = CATransform3DIdentity
self.pageControlNode.frame = CGRect(x: 0.0, y: size.height - 20.0, width: size.width, height: 20.0)
let maxWidth = size.width - 36.0
let pageControlSize = self.pageControlNode.calculateSizeThatFits(size)
if pageControlSize.width > maxWidth, pageControlSize.width > 0.0 {
let scale = maxWidth / pageControlSize.width
self.pageControlNode.layer.transform = CATransform3DMakeScale(scale, scale, 1.0)
}
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let width = self.bounds.size.width
guard width > 0.0, !self.item.medias.isEmpty else { return }
let page = Int((scrollView.contentOffset.x + width / 2.0) / width)
let clamped = max(0, min(self.item.medias.count - 1, page))
self.pageControlNode.setPage(CGFloat(clamped))
}
func update(item: InstantPageV2SlideshowItem, theme: InstantPageTheme, renderContext: InstantPageV2RenderContext) {
let mediasChanged = self.item.medias.map { $0.index } != item.medias.map { $0.index }
self.item = item
self.theme = theme
self.backgroundColor = theme.panelSecondaryColor
if mediasChanged {
self.rebuildPages()
} else {
let strings = renderContext.context.sharedContext.currentPresentationData.with { $0 }.strings
for node in self.pageImageNodes {
node.update(strings: strings, theme: theme)
}
}
self.setNeedsLayout()
}
// MARK: InstantPageItemView gallery hooks
func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
for node in self.pageImageNodes {
if let transition = node.transitionNode(media: media) {
return transition
}
}
return nil
}
func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?) {
for node in self.pageImageNodes {
node.updateHiddenMedia(media: media)
}
}
}

View file

@ -901,6 +901,18 @@ func revalidateMediaResourceReference(accountPeerId: PeerId, postbox: Postbox, n
return .single(RevalidatedMediaResource(updatedResource: updatedResource, updatedReference: nil))
}
}
// Rich-text messages (`RichTextMessageAttribute`) embed their media in the
// attribute's `InstantPage`, not in `message.media` search there too so a
// stale instant-page audio/image file reference can revalidate.
for attribute in message.attributes {
if let attribute = attribute as? RichTextMessageAttribute {
for (_, pageMedia) in attribute.instantPage.media {
if let updatedResource = findUpdatedMediaResource(media: pageMedia, previousMedia: previousMedia, resource: resource) {
return .single(RevalidatedMediaResource(updatedResource: updatedResource, updatedReference: nil))
}
}
}
}
return .fail(.generic)
}
case let .stickerPack(stickerPack, media):

View file

@ -139,7 +139,8 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
},
baseNavigationController: { [weak self] in
self?.item?.controllerInteraction.navigationController()
}
},
message: messageReference
)
let view = InstantPageV2View(renderContext: renderContext)
self.pageView = view

View file

@ -453,6 +453,7 @@ final class PeerSelectionControllerNode: ASDisplayNode {
var hasOther = false
var hasNotOwnMessages = false
var hasRichMessages = false
for message in messages {
if let author = message.effectiveAuthor {
if !uniquePeerIds.contains(author.id) {
@ -479,9 +480,12 @@ final class PeerSelectionControllerNode: ASDisplayNode {
if !isDice {
hasOther = true
}
if message.richText != nil {
hasRichMessages = true
}
}
let canHideNames = hasNotOwnMessages && hasOther
let canHideNames = hasNotOwnMessages && hasOther && !hasRichMessages
let hideNames = forwardOptions.hideNames
let hideCaptions = forwardOptions.hideCaptions

View file

@ -145,6 +145,7 @@ private func chatForwardOptions(selfController: ChatControllerImpl, sourceView:
var hasOther = false
var hasNotOwnMessages = false
var hasPaid = false
var hasRichMessages = false
for message in messages {
if let author = message.effectiveAuthor {
if !uniquePeerIds.contains(author.id) {
@ -175,9 +176,12 @@ private func chatForwardOptions(selfController: ChatControllerImpl, sourceView:
if !isDice {
hasOther = true
}
if message.richText != nil {
hasRichMessages = true
}
}
var canHideNames = hasNotOwnMessages && hasOther
var canHideNames = hasNotOwnMessages && hasOther && !hasRichMessages
if case let .peer(peerId) = selfController.chatLocation, peerId.namespace == Namespaces.Peer.SecretChat {
canHideNames = false
}

View file

@ -63,7 +63,8 @@ final class ChatSendMessageRichTextPreview: ChatSendMessageContextScreenRichText
present: { _, _ in },
push: { _ in },
openUrl: { _ in },
baseNavigationController: { return nil }
baseNavigationController: { return nil },
message: nil
)
self.pageView = InstantPageV2View(renderContext: renderContext)
self.pageView.isUserInteractionEnabled = false

View file

@ -4861,7 +4861,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate {
if case .customChatContents = self.chatPresentationInterfaceState.subject {
isSpecialChatContents = true
}
if !isSpecialChatContents, let attribute = richMarkdownAttributeIfNeeded(context: self.context, attributedText: effectiveInputText) {
if !"".isEmpty, !isSpecialChatContents, let attribute = richMarkdownAttributeIfNeeded(context: self.context, attributedText: effectiveInputText) {
let attributes: [MessageAttribute] = [attribute]
var richBubbleUpEmojiOrStickersets: [ItemCollectionId] = []
for (_, packId) in bubbleUpEmojiOrStickersetsById {