WinterGram/submodules/MediaPickerUI/Sources/MediaPickerGridItem.swift
isaac 69bfc65da7 Postbox refactor waves 138–276: 168-commit squash
Consolidates 137 wave commits + 31 supporting commits (CLAUDE.md bump,
typealias additions, AnyObject→EngineMedia restoration) into one squashed
commit. Migrates dozens of consumer-side public APIs, struct fields,
protocol methods, and enum payloads from Postbox protocols/structs to
TelegramEngine engine wrappers and typealiases. Drops `import Postbox`
from many files. Adds new TelegramCore typealiases and one
TelegramEngineUnauthorized facade.

Notable changes by category:

**TelegramCore typealias additions** (rule 2 — narrow utility typealiases):
- EngineChatListIndex, EngineTempBoxFile, EngineItemCollectionItemIndex,
  EngineItemCollectionViewEntryIndex, EngineValueBoxEncryptionParameters,
  EngineMessageAndThreadId, EnginePeerStoryStats, EngineMessageHistoryAnchorIndex,
  EngineChatListTotalUnreadStateCategory, EngineChatListTotalUnreadStateStats,
  EnginePeerSummaryCounterTags, EngineChatListTotalUnreadState,
  EngineItemCacheEntryId, EngineHashFunctions,
  EngineCachedMediaResourceRepresentationResult,
  EngineMediaResourceDataFetchResult, EngineMediaResourceDataFetchError,
  EngineMediaResourceStatus, EngineCachedPeerData

**TelegramCore engine extensions/forwarders**:
- EngineMessage.engineMedia, EngineMessage.enginePeers,
  EngineMessage.adAttribute, EngineMessage.effectivelyIncoming
- engineFileSize forwarder
- TelegramEngine.Resources.clearCachedMediaResources(mediaResourceIds: Set<EngineMediaResource.Id>)
- TelegramEngine.Resources.fetchStatus(id:resourceSize:)
- TelegramEngineUnauthorized.UnauthorizedResources facade with storeResourceData

**Public API/struct migrations to engine types**:
- ChatAvailableMessageActions.banAuthor/banAuthors → EnginePeer?/[EnginePeer]
- WebSessionsContextState.peers → [EnginePeer.Id: EnginePeer]
- CacheUsageStats.peers → [EnginePeer.Id: EnginePeer]
- PeerCommand.peer → EnginePeer
- PeerInfoControllerMode.calls(messages:) → [EngineMessage]
- CallControllerNodeProtocol.updatePeer → EnginePeer params
- ChatHistoryListNode.messageInCurrentHistoryView (and 4 variants) → EngineMessage?
- ChatHistorySearchContainerNode.messageForGallery → EngineMessage?
- PeerInfoPaneNode.findLoadedMessage / ensureMessageIsVisible /
  transitionNodeForGallery → engine-typed
- GalleryHiddenMediaTarget.getTransitionInfo /
  GalleryHiddenMediaManager.findTarget → engine-typed
- ChatPanelInterfaceInteraction.presentReactionDeletionOptions /
  presentBan*MessageOptions → EnginePeer
- DrawingMessageRenderer.messages → [EngineMessage]
- ChatVideoGalleryItemScrubberView.setFetchStatusSignal →
  EngineMediaResource.FetchStatus
- ChannelDiscussionGroupActionSheetItem.peer, VoiceChatPeerEntry.peer,
  VoiceChatFullscreenParticipantItem.peer, MediaStreamComponent.chatPeer,
  MediaStreamVideoComponent.callPeer, ChatMessageContactBubbleContentNode.contactPeer,
  ChatMessageForwardInfoNode.peer, ChatMessageCommentFooterContentNode.replyPeers,
  ChatReportPeerTitlePanelNode.peer, ChatMessageActionUrlAuthController.bot,
  PeerMediaCollectionInterfaceState.peer, ChatMessageCallBubbleContentNode.peopleAvatars,
  ChatLoadingNode.renderedPeer (→ EngineRenderedPeer) — all to engine types

**Wave-71-shadow stored-field migrations** (Postbox Peer/Message → Engine wrapper):
- LegacyCallControllerNode.peer
- CallStatusBarNode.currentPeer

**Dead-code / dead-field removals**:
- CallController.peer, CallControllerNodeV2.account,
  ContactMultiselectionController PeerNameIndex fields,
  preparedChatListNodeViewTransition account: Account param,
  FetchResource.swift entirely (unused function)

**Module-level Postbox import drops**: 30+ files including TelegramRootController,
EditStories, GiftViewScreen, AnimatedStickerUtils, FetchPhotoLibraryImageResource,
PeerInfoGiftsPaneNode, PeerInfoPaneContainerNode, PresentAddMembers,
PeerInfoProfileItems, ChatControllerAdminBanUsers, PresentationData typealiases,
DefaultDayPresentationTheme, ChatListViewTransition, GalleryHiddenMediaManager,
RecentSessionsController, GifContext, AuthorizationSequenceController,
PeerInfoHeaderEditingContentNode, PeerInfoHeaderNode,
PeerAllowedReactionListController, CallControllerNodeV2, and 6 PeerInfo pane files.

**AnyObject restoration**: rule 8 added (never substitute Postbox protocols
with Any/AnyObject) — undid previous AnyObject substitutions in waves 141/143
back to EngineMedia.

Doc maintenance: CLAUDE.md updated to reflect new typealiases and forwarders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 00:44:27 +02:00

964 lines
40 KiB
Swift

import Foundation
import UIKit
import Display
import TelegramCore
import SwiftSignalKit
import AsyncDisplayKit
import AccountContext
import TelegramPresentationData
import TelegramStringFormatting
import Photos
import CheckNode
import LegacyComponents
import PhotoResources
import InvisibleInkDustNode
import ImageBlur
import FastBlur
import MediaEditor
import RadialStatusNode
import MediaAssetsContext
private let leftShadowImage: UIImage = {
let baseImage = UIImage(bundleImageName: "Peer Info/MediaGridShadow")!
let image = generateImage(baseImage.size, rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.translateBy(x: size.width / 2.0, y: size.height / 2.0)
context.scaleBy(x: -1.0, y: -1.0)
context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0)
UIGraphicsPushContext(context)
baseImage.draw(in: CGRect(origin: CGPoint(), size: size))
UIGraphicsPopContext()
})
return image!
}()
private let rightShadowImage: UIImage = {
let baseImage = UIImage(bundleImageName: "Peer Info/MediaGridShadow")!
let image = generateImage(baseImage.size, rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
UIGraphicsPushContext(context)
baseImage.draw(in: CGRect(origin: CGPoint(), size: size))
UIGraphicsPopContext()
})
return image!
}()
enum MediaPickerGridItemContent: Equatable {
case asset(PHFetchResult<PHAsset>, Int)
case media(MediaPickerScreenImpl.Subject.Media, Int)
case draft(MediaEditorDraft, Int)
}
final class MediaPickerGridItem: GridItem {
let content: MediaPickerGridItemContent
let interaction: MediaPickerInteraction
let theme: PresentationTheme
let strings: PresentationStrings
let selectable: Bool
let enableAnimations: Bool
let stories: Bool
let section: GridSection? = nil
init(content: MediaPickerGridItemContent, interaction: MediaPickerInteraction, theme: PresentationTheme, strings: PresentationStrings, selectable: Bool, enableAnimations: Bool, stories: Bool) {
self.content = content
self.interaction = interaction
self.strings = strings
self.theme = theme
self.selectable = selectable
self.enableAnimations = enableAnimations
self.stories = stories
}
func node(layout: GridNodeLayout, synchronousLoad: Bool) -> GridItemNode {
switch self.content {
case let .asset(fetchResult, index):
let node = MediaPickerGridItemNode()
node.setup(interaction: self.interaction, fetchResult: fetchResult, index: index, theme: self.theme, selectable: self.selectable, enableAnimations: self.enableAnimations, stories: self.stories)
return node
case let .media(media, index):
let node = MediaPickerGridItemNode()
node.setup(interaction: self.interaction, media: media, index: index, theme: self.theme, selectable: self.selectable, enableAnimations: self.enableAnimations, stories: self.stories)
return node
case let .draft(draft, index):
let node = MediaPickerGridItemNode()
node.setup(interaction: self.interaction, draft: draft, index: index, theme: self.theme, strings: self.strings, selectable: self.selectable, enableAnimations: self.enableAnimations, stories: self.stories)
return node
}
}
func update(node: GridItemNode) {
guard let node = node as? MediaPickerGridItemNode else {
assertionFailure()
return
}
switch self.content {
case let .asset(fetchResult, index):
node.setup(interaction: self.interaction, fetchResult: fetchResult, index: index, theme: self.theme, selectable: self.selectable, enableAnimations: self.enableAnimations, stories: self.stories)
case let .media(media, index):
node.setup(interaction: self.interaction, media: media, index: index, theme: self.theme, selectable: self.selectable, enableAnimations: self.enableAnimations, stories: self.stories)
case let .draft(draft, index):
node.setup(interaction: self.interaction, draft: draft, index: index, theme: self.theme, strings: self.strings, selectable: self.selectable, enableAnimations: self.enableAnimations, stories: self.stories)
}
}
}
final class MediaPickerGridItemNode: GridItemNode {
var currentMediaState: (TGMediaSelectableItem, Int)?
var currentAssetState: (PHFetchResult<PHAsset>, Int)?
var currentAsset: PHAsset?
var isLivePhoto = false
var currentDraftState: (MediaEditorDraft, Int)?
var enableAnimations: Bool = true
var stories: Bool = false
private var selectable: Bool = false
private let backgroundNode: ASImageNode
private let imageNode: ImageNode
private var checkNode: InteractiveCheckNode?
private let leftShadowNode: ASImageNode
private let rightShadowNode: ASImageNode
private let typeIconNode: ASImageNode
private let durationNode: ImmediateTextNode
private let draftNode: ImmediateTextNode
private var statusNode: RadialStatusNode?
private let activateAreaNode: AccessibilityAreaNode
private var interaction: MediaPickerInteraction?
private var theme: PresentationTheme?
private struct SelectionState: Equatable {
let selected: Bool
let index: Int?
let count: Int
}
private let selectionPromise = ValuePromise<SelectionState>(SelectionState(selected: false, index: nil, count: 0))
private let spoilerDisposable = MetaDisposable()
var spoilerNode: SpoilerOverlayNode?
var priceNode: PriceNode?
private let progressDisposable = MetaDisposable()
private var currentIsPreviewing = false
var selected: (() -> Void)?
override init() {
self.backgroundNode = ASImageNode()
self.backgroundNode.contentMode = .scaleToFill
self.backgroundNode.isLayerBacked = true
self.imageNode = ImageNode()
self.imageNode.clipsToBounds = true
self.imageNode.contentMode = .scaleAspectFill
self.imageNode.isLayerBacked = true
self.imageNode.animateFirstTransition = false
self.leftShadowNode = ASImageNode()
self.leftShadowNode.displaysAsynchronously = false
self.leftShadowNode.displayWithoutProcessing = true
self.leftShadowNode.image = leftShadowImage
self.leftShadowNode.isLayerBacked = true
self.rightShadowNode = ASImageNode()
self.rightShadowNode.displaysAsynchronously = false
self.rightShadowNode.displayWithoutProcessing = true
self.rightShadowNode.image = rightShadowImage
self.rightShadowNode.isLayerBacked = true
self.typeIconNode = ASImageNode()
self.typeIconNode.displaysAsynchronously = false
self.typeIconNode.displayWithoutProcessing = true
self.typeIconNode.isLayerBacked = true
self.typeIconNode.contentMode = .center
self.durationNode = ImmediateTextNode()
self.durationNode.isLayerBacked = true
self.durationNode.textShadowColor = UIColor(white: 0.0, alpha: 0.4)
self.durationNode.textShadowBlur = 4.0
self.draftNode = ImmediateTextNode()
self.activateAreaNode = AccessibilityAreaNode()
self.activateAreaNode.accessibilityTraits = [.image]
super.init()
self.clipsToBounds = true
self.addSubnode(self.imageNode)
self.addSubnode(self.activateAreaNode)
self.imageNode.contentUpdated = { [weak self] image in
self?.spoilerNode?.setImage(image)
}
}
deinit {
self.spoilerDisposable.dispose()
}
var identifier: String {
if let (draft, _) = self.currentDraftState {
return draft.path
} else {
return self.selectableItem?.uniqueIdentifier ?? ""
}
}
var selectableItem: TGMediaSelectableItem? {
if let (media, _) = self.currentMediaState {
return media
} else if let (fetchResult, index) = self.currentAssetState {
return TGMediaAsset(phAsset: fetchResult[index])
} else {
return nil
}
}
var _cachedTag: Int32?
var tag: Int32? {
if let tag = self._cachedTag {
return tag
} else if let (fetchResult, index) = self.currentAssetState {
let asset = fetchResult.object(at: index)
if let localTimestamp = asset.creationDate?.timeIntervalSince1970 {
let tag = Month(localTimestamp: Int32(exactly: floor(localTimestamp)) ?? 0).packedValue
self._cachedTag = tag
return tag
} else {
return nil
}
} else if let (draft, _) = self.currentDraftState {
let tag = Month(localTimestamp: draft.timestamp).packedValue
self._cachedTag = tag
return tag
} else {
return nil
}
}
func updateSelectionState(isFirstTime: Bool = false, animated: Bool = false) {
if self.checkNode == nil, let _ = self.interaction?.selectionState, self.selectable, let theme = self.theme {
let checkNode = InteractiveCheckNode(theme: CheckNodeTheme(theme: theme, style: .overlay))
checkNode.valueChanged = { [weak self] value in
if let strongSelf = self, let interaction = strongSelf.interaction, let selectableItem = strongSelf.selectableItem {
if !interaction.toggleSelection(selectableItem, value, false) {
strongSelf.checkNode?.setSelected(false, animated: false)
}
}
}
self.addSubnode(checkNode)
self.checkNode = checkNode
self.setNeedsLayout()
if !isFirstTime {
checkNode.layer.animateScale(from: 0.2, to: 1.0, duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring)
checkNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring)
}
}
if let interaction = self.interaction, let selectionState = interaction.selectionState {
let selected = selectionState.isIdentifierSelected(self.identifier)
var selectionIndex: Int?
if let selectableItem = self.selectableItem {
let index = selectionState.index(of: selectableItem)
if index != NSNotFound {
self.checkNode?.content = .counter(Int(index))
selectionIndex = Int(index)
}
}
self.checkNode?.setSelected(selected, animated: animated)
self.selectionPromise.set(SelectionState(selected: selected, index: selectionIndex, count: selectionState.selectedItems().count))
}
}
private var innerIsHidden = false
func updateHiddenMedia() {
let wasHidden = self.innerIsHidden
if self.identifier == self.interaction?.hiddenMediaId {
self.isHidden = true
self.innerIsHidden = true
} else {
self.isHidden = false
self.innerIsHidden = false
if wasHidden {
self.animateFadeIn(animateCheckNode: true, animateSpoilerNode: true)
}
}
}
func animateFadeIn(animateCheckNode: Bool, animateSpoilerNode: Bool) {
if animateCheckNode {
self.checkNode?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
}
self.leftShadowNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
self.rightShadowNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
self.typeIconNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
self.durationNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
self.draftNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
self.priceNode?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
if animateSpoilerNode || self.priceNode != nil {
self.spoilerNode?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
}
}
override func didLoad() {
super.didLoad()
self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.imageNodeTap(_:))))
}
func updateProgress(_ value: Float?, animated: Bool) {
if let value {
let statusNode: RadialStatusNode
if let current = self.statusNode {
statusNode = current
} else {
statusNode = RadialStatusNode(backgroundNodeColor: UIColor(rgb: 0x000000, alpha: 0.6))
statusNode.isUserInteractionEnabled = false
self.addSubnode(statusNode)
self.statusNode = statusNode
}
let adjustedProgress = max(0.027, CGFloat(value))
let state: RadialStatusNodeState = .progress(color: .white, lineWidth: nil, value: adjustedProgress, cancelEnabled: true, animateRotation: true)
statusNode.transitionToState(state)
} else if let statusNode = self.statusNode {
self.statusNode = nil
if animated {
statusNode.transitionToState(.none, animated: true, completion: { [weak statusNode] in
statusNode?.removeFromSupernode()
})
} else {
statusNode.removeFromSupernode()
}
}
}
func setup(interaction: MediaPickerInteraction, draft: MediaEditorDraft, index: Int, theme: PresentationTheme, strings: PresentationStrings, selectable: Bool, enableAnimations: Bool, stories: Bool) {
self.interaction = interaction
self.theme = theme
self.selectable = selectable
self.enableAnimations = enableAnimations
self.backgroundColor = theme.list.mediaPlaceholderColor
if self.currentDraftState == nil || self.currentDraftState?.0.path != draft.path || self.currentDraftState!.1 != index || self.currentAssetState != nil {
let imageSignal: Signal<UIImage?, NoError> = .single(draft.thumbnail)
self.imageNode.setSignal(imageSignal)
self.currentDraftState = (draft, index)
if self.currentAssetState != nil {
self.currentAsset = nil
self.currentAssetState = nil
self.typeIconNode.removeFromSupernode()
self.progressDisposable.set(nil)
self.updateProgress(nil, animated: false)
self.backgroundNode.image = nil
self.imageNode.contentMode = .scaleAspectFill
}
if self.draftNode.supernode == nil {
self.draftNode.attributedText = NSAttributedString(string: strings.MediaEditor_Draft, font: Font.semibold(12.0), textColor: .white)
self.addSubnode(self.draftNode)
}
if draft.isVideo {
self.typeIconNode.image = UIImage(bundleImageName: "Media Editor/MediaVideo")
self.durationNode.attributedText = NSAttributedString(string: stringForDuration(Int32(draft.duration ?? 0.0)), font: Font.semibold(11.0), textColor: .white)
if self.typeIconNode.supernode == nil {
self.addSubnode(self.rightShadowNode)
self.addSubnode(self.typeIconNode)
self.addSubnode(self.durationNode)
self.setNeedsLayout()
}
} else {
if self.typeIconNode.supernode != nil {
self.typeIconNode.removeFromSupernode()
}
if self.durationNode.supernode != nil {
self.durationNode.removeFromSupernode()
}
if self.leftShadowNode.supernode != nil {
self.leftShadowNode.removeFromSupernode()
}
if self.rightShadowNode.supernode != nil {
self.rightShadowNode.removeFromSupernode()
}
}
self.setNeedsLayout()
}
self.updateSelectionState()
self.updateHiddenMedia()
}
func setup(interaction: MediaPickerInteraction, media: MediaPickerScreenImpl.Subject.Media, index: Int, theme: PresentationTheme, selectable: Bool, enableAnimations: Bool, stories: Bool) {
self.interaction = interaction
self.theme = theme
self.selectable = selectable
self.enableAnimations = enableAnimations
self.stories = stories
self.backgroundColor = theme.list.mediaPlaceholderColor
if stories {
if self.backgroundNode.supernode == nil {
self.insertSubnode(self.backgroundNode, at: 0)
}
}
if self.draftNode.supernode != nil {
self.draftNode.removeFromSupernode()
}
if self.currentMediaState == nil || self.currentMediaState!.0.uniqueIdentifier != media.identifier || self.currentMediaState!.1 != index {
self.currentMediaState = (media.asset, index)
if self.draftNode.supernode != nil {
self.draftNode.removeFromSupernode()
}
self.setNeedsLayout()
}
self.updateSelectionState()
self.updateHiddenMedia()
}
func setup(interaction: MediaPickerInteraction, fetchResult: PHFetchResult<PHAsset>, index: Int, theme: PresentationTheme, selectable: Bool, enableAnimations: Bool, stories: Bool) {
let isFirstTime = self.currentAssetState == nil
self.interaction = interaction
self.theme = theme
self.selectable = selectable
self.enableAnimations = enableAnimations
self.stories = stories
self.backgroundColor = theme.list.mediaPlaceholderColor
if stories {
if self.backgroundNode.supernode == nil {
self.insertSubnode(self.backgroundNode, at: 0)
}
}
if self.draftNode.supernode != nil {
self.draftNode.removeFromSupernode()
}
if self.currentAssetState == nil || self.currentAssetState!.0 !== fetchResult || self.currentAssetState!.1 != index || self.currentDraftState != nil {
let editingContext = interaction.editingState
let asset = fetchResult.object(at: index)
if asset.localIdentifier == self.currentAsset?.localIdentifier {
return
}
self.backgroundNode.image = nil
self.progressDisposable.set(
(interaction.downloadManager.downloadProgress(identifier: asset.localIdentifier)
|> deliverOnMainQueue).start(next: { [weak self] status in
if let self {
switch status {
case .none, .completed:
self.updateProgress(nil, animated: true)
case let .progress(progress):
self.updateProgress(progress, animated: true)
}
}
})
)
self.backgroundNode.image = nil
if #available(iOS 15.0, *) {
self.activateAreaNode.accessibilityLabel = "Photo \(asset.creationDate?.formatted(date: .abbreviated, time: .standard) ?? "")"
}
let editedSignal = Signal<UIImage?, NoError> { subscriber in
if let signal = editingContext.thumbnailImageSignal(forIdentifier: asset.localIdentifier) {
let disposable = signal.start(next: { next in
if let image = next as? UIImage {
subscriber.putNext(image)
} else {
subscriber.putNext(nil)
}
}, error: { _ in
}, completed: nil)!
return ActionDisposable {
disposable.dispose()
}
} else {
return EmptyDisposable
}
}
let scale = min(2.0, UIScreenScale)
let targetSize: CGSize
if stories {
targetSize = CGSize(width: 128.0 * UIScreenScale, height: 128.0 * UIScreenScale)
} else {
targetSize = CGSize(width: 128.0 * scale, height: 128.0 * scale)
}
let assetImageSignal = assetImage(fetchResult: fetchResult, index: index, targetSize: targetSize, exact: false, deliveryMode: .opportunistic, synchronous: false)
// |> then(
// assetImage(fetchResult: fetchResult, index: index, targetSize: targetSize, exact: false, deliveryMode: .highQualityFormat, synchronous: false)
// |> delay(0.03, queue: Queue.concurrentDefaultQueue())
// )
if stories {
self.imageNode.contentUpdated = { [weak self] image in
if let self {
if self.backgroundNode.image == nil {
if let image, image.size.width > image.size.height {
self.imageNode.contentMode = .scaleAspectFit
Queue.concurrentDefaultQueue().async {
let colors = mediaEditorGetGradientColors(from: image)
let gradientImage = mediaEditorGenerateGradientImage(size: CGSize(width: 3.0, height: 128.0), colors: colors.array)
Queue.mainQueue().async {
self.backgroundNode.image = gradientImage
}
}
} else {
self.imageNode.contentMode = .scaleAspectFill
}
}
}
}
}
let originalSignal = assetImageSignal
let imageSignal: Signal<UIImage?, NoError> = editedSignal
|> mapToSignal { result in
if let result = result {
return .single(result)
} else {
return originalSignal
}
}
self.imageNode.setSignal(imageSignal)
if self.currentDraftState != nil {
self.currentDraftState = nil
}
var typeIcon: UIImage?
var duration: String?
if asset.mediaType == .video {
if asset.mediaSubtypes.contains(.videoHighFrameRate) {
typeIcon = UIImage(bundleImageName: "Media Editor/MediaSlomo")
} else if asset.mediaSubtypes.contains(.videoTimelapse) {
typeIcon = UIImage(bundleImageName: "Media Editor/MediaTimelapse")
} else {
typeIcon = UIImage(bundleImageName: "Media Editor/MediaVideo")
}
duration = stringForDuration(Int32(asset.duration))
} else {
if asset.mediaSubtypes.contains(.photoLive), self.interaction?.displayLivePhotoIcon == true {
self.isLivePhoto = true
}
}
let spoilerSignal = Signal<Bool, NoError> { subscriber in
if let signal = editingContext.spoilerSignal(forIdentifier: asset.localIdentifier) {
let disposable = signal.start(next: { next in
if let next = next as? Bool {
subscriber.putNext(next)
}
}, error: { _ in
}, completed: nil)!
return ActionDisposable {
disposable.dispose()
}
} else {
return EmptyDisposable
}
}
let priceSignal = Signal<Int64?, NoError> { subscriber in
if let signal = editingContext.priceSignal(forIdentifier: asset.localIdentifier) {
let disposable = signal.start(next: { next in
subscriber.putNext(next as? Int64)
}, error: { _ in
}, completed: nil)!
return ActionDisposable {
disposable.dispose()
}
} else {
return EmptyDisposable
}
}
let livePhotoModeSignal: Signal<TGMediaLivePhotoMode?, NoError>
if self.isLivePhoto {
let itemSignal = Signal<TGMediaLivePhotoMode?, NoError> { subscriber in
if let signal = editingContext.livePhotoMode(forIdentifier: asset.localIdentifier) {
let disposable = signal.start(next: { next in
subscriber.putNext((next as? UInt).flatMap { TGMediaLivePhotoMode(rawValue: $0) })
}, error: { _ in
}, completed: nil)!
return ActionDisposable {
disposable.dispose()
}
} else {
return EmptyDisposable
}
}
let forceSignal = Signal<Bool?, NoError> { subscriber in
if let signal = editingContext.forceLivePhotoEnabled() {
let disposable = signal.start(next: { next in
subscriber.putNext((next as? Bool) ?? false)
}, error: { _ in
}, completed: nil)!
return ActionDisposable {
disposable.dispose()
}
} else {
return EmptyDisposable
}
}
livePhotoModeSignal = combineLatest(itemSignal, forceSignal)
|> map { item, _ in
return item
}
} else {
livePhotoModeSignal = .single(nil)
}
self.spoilerDisposable.set((combineLatest(spoilerSignal, priceSignal, livePhotoModeSignal, self.selectionPromise.get())
|> deliverOnMainQueue).start(next: { [weak self] hasSpoiler, price, livePhotoMode, selectionState in
guard let self else {
return
}
self.updateHasSpoiler(hasSpoiler, price: selectionState.selected ? price : nil, isSingle: selectionState.count == 1 || selectionState.index == 1)
self.updateLivePhotoMode(livePhotoMode)
}))
if asset.isFavorite {
typeIcon = generateTintedImage(image: UIImage(bundleImageName: "Media Grid/Favorite"), color: .white)
}
if typeIcon != nil {
if self.leftShadowNode.supernode == nil {
self.addSubnode(self.leftShadowNode)
}
} else if !self.isLivePhoto, self.leftShadowNode.supernode != nil {
self.leftShadowNode.removeFromSupernode()
}
if duration != nil {
if self.rightShadowNode.supernode == nil {
self.addSubnode(self.rightShadowNode)
}
} else if self.rightShadowNode.supernode != nil {
self.rightShadowNode.removeFromSupernode()
}
if let typeIcon {
self.typeIconNode.image = typeIcon
if self.typeIconNode.supernode == nil {
self.addSubnode(self.typeIconNode)
}
} else if !self.isLivePhoto, self.typeIconNode.supernode != nil {
self.typeIconNode.removeFromSupernode()
}
if let duration {
self.durationNode.attributedText = NSAttributedString(string: duration, font: Font.semibold(11.0), textColor: .white)
if self.durationNode.supernode == nil {
self.addSubnode(self.durationNode)
}
} else if self.durationNode.supernode != nil {
self.durationNode.removeFromSupernode()
}
self.currentAssetState = (fetchResult, index)
self.currentAsset = asset
self.setNeedsLayout()
}
self.updateSelectionState(isFirstTime: isFirstTime)
self.updateHiddenMedia()
}
private var currentPrice: Int64?
private var didSetupSpoiler = false
private func updateHasSpoiler(_ hasSpoiler: Bool, price: Int64?, isSingle: Bool) {
var animated = true
if !self.didSetupSpoiler {
animated = false
self.didSetupSpoiler = true
}
self.currentPrice = isSingle ? price : nil
if hasSpoiler || price != nil {
if self.spoilerNode == nil {
let spoilerNode = SpoilerOverlayNode(enableAnimations: self.enableAnimations)
self.insertSubnode(spoilerNode, aboveSubnode: self.imageNode)
self.spoilerNode = spoilerNode
spoilerNode.setImage(self.imageNode.image)
if animated {
spoilerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
}
let bounds = self.bounds
self.spoilerNode?.update(size: bounds.size, transition: .immediate)
self.spoilerNode?.frame = CGRect(origin: .zero, size: bounds.size)
if let price {
let priceNode: PriceNode
if let currentPriceNode = self.priceNode {
priceNode = currentPriceNode
} else {
priceNode = PriceNode()
if let spoilerNode = self.spoilerNode {
self.insertSubnode(priceNode, aboveSubnode: spoilerNode)
}
self.priceNode = priceNode
if animated {
priceNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
}
self.priceNode?.update(size: bounds.size, price: isSingle ? price : nil, small: true, transition: .immediate)
}
} else if let spoilerNode = self.spoilerNode {
self.spoilerNode = nil
spoilerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak spoilerNode] _ in
spoilerNode?.removeFromSupernode()
})
if let priceNode = self.priceNode {
self.priceNode = nil
priceNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak priceNode] _ in
priceNode?.removeFromSupernode()
})
}
}
}
private var livePhotoIconName: String?
private func updateLivePhotoMode(_ mode: TGMediaLivePhotoMode?) {
guard self.isLivePhoto, self.interaction?.displayLivePhotoIcon == true else {
return
}
var effectiveMode: TGMediaLivePhotoMode
if let mode, mode != .off {
effectiveMode = mode
} else {
if self.interaction?.editingState.isForceLivePhotoEnabled() == true {
effectiveMode = .live
} else {
effectiveMode = .off
}
}
var iconName: String
switch effectiveMode {
case .off:
iconName = "Chat/Message/LivePhotoOff"
default:
iconName = "Chat/Message/LivePhoto"
}
if self.livePhotoIconName != iconName {
self.livePhotoIconName = iconName
if self.leftShadowNode.supernode == nil {
if self.typeIconNode.supernode != nil {
self.insertSubnode(self.leftShadowNode, belowSubnode: self.typeIconNode)
} else {
self.addSubnode(self.leftShadowNode)
}
}
self.typeIconNode.image = UIImage(bundleImageName: iconName)
if self.typeIconNode.supernode == nil {
self.addSubnode(self.typeIconNode)
}
self.setNeedsLayout()
}
}
override func layout() {
super.layout()
let backgroundSize = CGSize(width: self.bounds.width, height: floorToScreenPixels(self.bounds.height / 9.0 * 16.0))
self.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: floorToScreenPixels((self.bounds.height - backgroundSize.height) / 2.0)), size: backgroundSize)
self.imageNode.frame = self.bounds
self.leftShadowNode.frame = CGRect(x: 0.0, y: 0.0, width: min(leftShadowImage.size.width, self.bounds.width), height: leftShadowImage.size.height)
self.rightShadowNode.frame = CGRect(x: self.bounds.width - min(rightShadowImage.size.width, self.bounds.width), y: self.bounds.height - rightShadowImage.size.height, width: min(rightShadowImage.size.width, self.bounds.width), height: rightShadowImage.size.height)
self.typeIconNode.frame = CGRect(x: 0.0, y: 1.0, width: 19.0, height: 19.0)
self.activateAreaNode.frame = self.bounds
if self.durationNode.supernode != nil {
let durationSize = self.durationNode.updateLayout(self.bounds.size)
self.durationNode.frame = CGRect(origin: CGPoint(x: self.bounds.size.width - durationSize.width - 6.0, y: self.bounds.height - durationSize.height - 6.0), size: durationSize)
}
if self.draftNode.supernode != nil {
let draftSize = self.draftNode.updateLayout(self.bounds.size)
self.draftNode.frame = CGRect(origin: CGPoint(x: 7.0, y: 5.0), size: draftSize)
}
let checkSize = CGSize(width: 29.0, height: 29.0)
self.checkNode?.frame = CGRect(origin: CGPoint(x: self.bounds.width - checkSize.width - 3.0, y: 3.0), size: checkSize)
if let spoilerNode = self.spoilerNode, self.bounds.width > 0.0 {
spoilerNode.frame = self.bounds
spoilerNode.update(size: self.bounds.size, transition: .immediate)
}
if let priceNode = self.priceNode, self.bounds.width > 0.0 {
priceNode.frame = self.bounds
priceNode.update(size: self.bounds.size, price: self.currentPrice, small: true, transition: .immediate)
}
let statusSize = CGSize(width: 40.0, height: 40.0)
if let statusNode = self.statusNode {
statusNode.view.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((self.bounds.width - statusSize.width) / 2.0), y: floorToScreenPixels((self.bounds.height - statusSize.height) / 2.0)), size: statusSize)
}
}
func transitionView(snapshot: Bool) -> UIView {
if snapshot {
let view = self.imageNode.layer.snapshotContentTreeAsView(unhide: true)!
view.frame = self.convert(self.bounds, to: nil)
return view
} else {
return self.view
}
}
func transitionImage() -> UIImage? {
if let backgroundImage = self.backgroundNode.image {
let size = CGSize(width: self.bounds.width, height: self.bounds.height / 9.0 * 16.0)
return generateImage(size, contextGenerator: { size, context in
if let cgImage = backgroundImage.cgImage {
context.draw(cgImage, in: CGRect(origin: .zero, size: size))
if let image = self.imageNode.image, let cgImage = image.cgImage {
let fittedSize = image.size.fitted(size)
let fittedFrame = CGRect(origin: CGPoint(x: (size.width - fittedSize.width) / 2.0, y: (size.height - fittedSize.height) / 2.0), size: fittedSize)
context.draw(cgImage, in: fittedFrame)
}
}
})
} else {
return self.imageNode.image
}
}
@objc func imageNodeTap(_ recognizer: UITapGestureRecognizer) {
if let (draft, _) = self.currentDraftState {
self.interaction?.openDraft(draft, self.imageNode.image)
return
}
guard let (fetchResult, index) = self.currentAssetState else {
return
}
if self.statusNode != nil {
if let asset = self.currentAsset {
self.interaction?.downloadManager.cancel(identifier: asset.localIdentifier)
}
} else {
self.interaction?.openMedia(fetchResult, index, self.imageNode.image)
}
}
}
class SpoilerOverlayNode: ASDisplayNode {
private let blurNode: ASImageNode
let dustNode: MediaDustNode
private var maskView: UIView?
private var maskLayer: CAShapeLayer?
init(enableAnimations: Bool) {
self.blurNode = ASImageNode()
self.blurNode.displaysAsynchronously = false
self.blurNode.contentMode = .scaleAspectFill
self.dustNode = MediaDustNode(enableAnimations: enableAnimations)
super.init()
self.clipsToBounds = true
self.isUserInteractionEnabled = false
self.addSubnode(self.blurNode)
self.addSubnode(self.dustNode)
}
override func didLoad() {
super.didLoad()
let maskView = UIView()
self.maskView = maskView
// self.dustNode.view.mask = maskView
let maskLayer = CAShapeLayer()
maskLayer.fillRule = .evenOdd
maskLayer.fillColor = UIColor.white.cgColor
maskView.layer.addSublayer(maskLayer)
self.maskLayer = maskLayer
}
func setImage(_ image: UIImage?) {
self.blurNode.image = image.flatMap { blurredImage($0) }
}
func update(size: CGSize, transition: ContainedViewLayoutTransition) {
transition.updateFrame(node: self.blurNode, frame: CGRect(origin: .zero, size: size))
transition.updateFrame(node: self.dustNode, frame: CGRect(origin: .zero, size: size))
self.dustNode.update(size: size, color: .white, transition: transition)
}
}
private func blurredImage(_ image: UIImage) -> UIImage? {
guard let image = image.cgImage else {
return nil
}
let thumbnailSize = CGSize(width: image.width, height: image.height)
let thumbnailContextSize = thumbnailSize.aspectFilled(CGSize(width: 20.0, height: 20.0))
if let thumbnailContext = DrawingContext(size: thumbnailContextSize, scale: 1.0) {
thumbnailContext.withFlippedContext { c in
c.interpolationQuality = .none
c.draw(image, in: CGRect(origin: CGPoint(), size: thumbnailContextSize))
}
imageFastBlur(Int32(thumbnailContextSize.width), Int32(thumbnailContextSize.height), Int32(thumbnailContext.bytesPerRow), thumbnailContext.bytes)
let thumbnailContext2Size = thumbnailSize.aspectFitted(CGSize(width: 100.0, height: 100.0))
if let thumbnailContext2 = DrawingContext(size: thumbnailContext2Size, scale: 1.0) {
thumbnailContext2.withFlippedContext { c in
c.interpolationQuality = .none
if let image = thumbnailContext.generateImage()?.cgImage {
c.draw(image, in: CGRect(origin: CGPoint(), size: thumbnailContext2Size))
}
}
imageFastBlur(Int32(thumbnailContext2Size.width), Int32(thumbnailContext2Size.height), Int32(thumbnailContext2.bytesPerRow), thumbnailContext2.bytes)
adjustSaturationInContext(context: thumbnailContext2, saturation: 1.7)
return thumbnailContext2.generateImage()
}
}
return nil
}