Merge branch 'master' of gitlab.com:peter-iakovlev/telegram-ios
This commit is contained in:
commit
ec72568429
27 changed files with 1439 additions and 3819 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue