From 6df559ff973624e49248ec5dbce3eaa7a962546b Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Sat, 28 Mar 2026 20:47:35 +0100 Subject: [PATCH] Various fixes --- .../Sources/MultilineTextComponent.swift | 11 +- .../Items/UniversalVideoGalleryItem.swift | 60 ++++----- .../TGMediaPickerGalleryPhotoItemView.m | 12 +- .../Sources/SaveToCameraRoll.swift | 8 +- .../ChatMessagePollBubbleContentNode.swift | 10 +- .../Sources/ComposePollScreen.swift | 13 +- .../Sources/ComposeTodoScreen.swift | 4 +- .../Sources/ListActionItemComponent.swift | 21 ++- .../Sources/ExternalVideoPlayer.swift | 121 ------------------ 9 files changed, 71 insertions(+), 189 deletions(-) delete mode 100644 submodules/TelegramUniversalVideoContent/Sources/ExternalVideoPlayer.swift diff --git a/submodules/Components/MultilineTextComponent/Sources/MultilineTextComponent.swift b/submodules/Components/MultilineTextComponent/Sources/MultilineTextComponent.swift index 11f08ffc52..734d5471b7 100644 --- a/submodules/Components/MultilineTextComponent/Sources/MultilineTextComponent.swift +++ b/submodules/Components/MultilineTextComponent/Sources/MultilineTextComponent.swift @@ -5,6 +5,11 @@ import Display import Markdown public final class MultilineTextComponent: Component { + public final class CrossfadeTransition { + public init() { + } + } + public enum TextContent: Equatable { case plain(NSAttributedString) case markdown(text: String, attributes: MarkdownAttributes) @@ -141,7 +146,7 @@ public final class MultilineTextComponent: Component { attributedString = parseMarkdownIntoAttributedString(text, attributes: attributes) } - //let previousText = self.attributedText?.string + let previousText = self.attributedText?.string self.attributedText = attributedString self.maximumNumberOfLines = component.maximumNumberOfLines @@ -160,7 +165,7 @@ public final class MultilineTextComponent: Component { self.tapAttributeAction = component.tapAction self.longTapAttributeAction = component.longTapAction - /*if case let .curve(duration, _) = transition.animation, let previousText = previousText, previousText != attributedString.string { + if case let .curve(duration, _) = transition.animation, let _ = transition.userData(CrossfadeTransition.self), let previousText = previousText, previousText != attributedString.string { if let snapshotView = self.snapshotView(afterScreenUpdates: false) { snapshotView.center = self.center self.superview?.addSubview(snapshotView) @@ -170,7 +175,7 @@ public final class MultilineTextComponent: Component { }) self.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration) } - }*/ + } let size = self.updateLayout(availableSize) diff --git a/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift b/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift index 7d37c55c25..1729afa707 100644 --- a/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift +++ b/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift @@ -162,7 +162,6 @@ private let placeholderFont = Font.regular(16.0) private final class UniversalVideoGalleryItemPictureInPictureNode: ASDisplayNode { enum Mode { case pictureInPicture - case airplay } private let iconNode: ASImageNode @@ -183,8 +182,6 @@ private final class UniversalVideoGalleryItemPictureInPictureNode: ASDisplayNode switch mode { case .pictureInPicture: text = strings.Embed_PlayingInPIP - case .airplay: - text = strings.Gallery_AirPlayPlaceholder } self.textNode.attributedText = NSAttributedString(string: text, font: placeholderFont, textColor: UIColor(rgb: 0x8e8e93)) @@ -1868,7 +1865,9 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { var hasMoreButton = false var file: TelegramMediaFile? for m in message.media { - if let m = m as? TelegramMediaFile, m.isVideo { + if let _ = m as? TelegramMediaImage { + hasMoreButton = true + } else if let m = m as? TelegramMediaFile, m.isVideo { file = m break } else if let m = m as? TelegramMediaWebpage, case let .Loaded(content) = m.content, let f = content.file, f.isVideo { @@ -2059,13 +2058,13 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { } private func updateDisplayPlaceholder() { - self.updateDisplayPlaceholder(!(self.videoNode?.ownsContentNode ?? true) || self.isAirPlayActive) + self.updateDisplayPlaceholder(!(self.videoNode?.ownsContentNode ?? true)) } private func updateDisplayPlaceholder(_ displayPlaceholder: Bool) { if displayPlaceholder && !self.disablePictureInPicturePlaceholder { if self.pictureInPictureNode == nil { - let pictureInPictureNode = UniversalVideoGalleryItemPictureInPictureNode(strings: self.presentationData.strings, mode: self.isAirPlayActive ? .airplay : .pictureInPicture) + let pictureInPictureNode = UniversalVideoGalleryItemPictureInPictureNode(strings: self.presentationData.strings, mode: .pictureInPicture) pictureInPictureNode.isUserInteractionEnabled = false self.pictureInPictureNode = pictureInPictureNode self.insertSubnode(pictureInPictureNode, aboveSubnode: self.scrollNode) @@ -3772,16 +3771,24 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { }))) } - // if #available(iOS 11.0, *) { - // items.append(.action(ContextMenuActionItem(text: "AirPlay", textColor: .primary, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Media Gallery/AirPlay"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in - // f(.default) - // guard let strongSelf = self else { - // return - // } - // strongSelf.beginAirPlaySetup() - // }))) - // } - + if let (message, _, _) = strongSelf.contentInfo(), let image = message.media.first(where: { $0 is TelegramMediaImage }) as? TelegramMediaImage, !message.isCopyProtected() && !item.peerIsCopyProtected && message.paidContent == nil { + let context = strongSelf.context + items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.Gallery_SaveImage, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Download"), color: theme.actionSheet.primaryTextColor) }, action: { [weak self] _, f in + f(.default) + + let _ = (SaveToCameraRoll.saveToCameraRoll(context: context, postbox: context.account.postbox, userLocation: .peer(message.id.peerId), mediaReference: .message(message: MessageReference(message), media: image)) + |> deliverOnMainQueue).start(completed: { [weak self] in + guard let strongSelf = self else { + return + } + guard let controller = strongSelf.galleryController() else { + return + } + controller.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .mediaSaved(text: strongSelf.presentationData.strings.Gallery_ImageSaved), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root)) + }) + }))) + } + if let (message, _, _) = strongSelf.contentInfo() { for media in message.media { if let webpage = media as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content { @@ -3839,27 +3846,6 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { return (items, topItems) } } - - private var isAirPlayActive = false - private var externalVideoPlayer: ExternalVideoPlayer? - func beginAirPlaySetup() { - guard let content = self.item?.content as? NativeVideoContent else { - return - } - if #available(iOS 11.0, *) { - self.externalVideoPlayer = ExternalVideoPlayer(context: self.context, content: content) - self.externalVideoPlayer?.openRouteSelection() - self.externalVideoPlayer?.isActiveUpdated = { [weak self] isActive in - if let strongSelf = self { - if strongSelf.isAirPlayActive && !isActive { - strongSelf.externalVideoPlayer = nil - } - strongSelf.isAirPlayActive = isActive - strongSelf.updateDisplayPlaceholder() - } - } - } - } @objc func openStickersButtonPressed() { guard let content = self.item?.content as? NativeVideoContent else { diff --git a/submodules/LegacyComponents/Sources/TGMediaPickerGalleryPhotoItemView.m b/submodules/LegacyComponents/Sources/TGMediaPickerGalleryPhotoItemView.m index 943cd1e0d7..30fe6aaf28 100644 --- a/submodules/LegacyComponents/Sources/TGMediaPickerGalleryPhotoItemView.m +++ b/submodules/LegacyComponents/Sources/TGMediaPickerGalleryPhotoItemView.m @@ -207,7 +207,11 @@ static TGMediaLivePhotoMode TGMediaPickerGalleryResolvedLivePhotoMode(TGMediaEdi [super setItem:item synchronously:synchronously]; [self stopAndCleanupLivePhotoPlayback]; - _livePhotoMode = TGMediaPickerGalleryResolvedLivePhotoMode(item.editingContext, item.editableMediaItem, (TGMediaAsset *)item.asset); + if ([item.asset isKindOfClass:[TGMediaAsset class]]) { + _livePhotoMode = TGMediaPickerGalleryResolvedLivePhotoMode(item.editingContext, item.editableMediaItem, (TGMediaAsset *)item.asset); + } else { + _livePhotoMode = TGMediaLivePhotoModeOff; + } [self updatePaintingImage:[item.editingContext adjustmentsForItem:item.editableMediaItem]]; if (_entitiesView == nil) { @@ -722,7 +726,11 @@ static TGMediaLivePhotoMode TGMediaPickerGalleryResolvedLivePhotoMode(TGMediaEdi _paintingImageView.hidden = (_paintingImageView.image == nil) || _livePhotoVideoView.alpha <= FLT_EPSILON; _contentView.hidden = false; - _livePhotoMode = TGMediaPickerGalleryResolvedLivePhotoMode(self.item.editingContext, self.item.editableMediaItem, (TGMediaAsset *)self.item.asset); + if ([self.item.asset isKindOfClass:[TGMediaAsset class]]) { + _livePhotoMode = TGMediaPickerGalleryResolvedLivePhotoMode(self.item.editingContext, self.item.editableMediaItem, (TGMediaAsset *)self.item.asset); + } else { + _livePhotoMode = TGMediaLivePhotoModeOff; + } [self updatePaintingImage:[self currentAdjustments]]; [self layoutEditedSubviews]; diff --git a/submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift b/submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift index e4c44da7b9..dd92620f00 100644 --- a/submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift +++ b/submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift @@ -110,13 +110,7 @@ public func saveToCameraRoll(context: AccountContext, postbox: Postbox, userLoca PHPhotoLibrary.shared().performChanges({ if isImage { if let fileData = try? Data(contentsOf: URL(fileURLWithPath: data.path)) { - if #available(iOSApplicationExtension 9.0, iOS 9.0, *) { - PHAssetCreationRequest.forAsset().addResource(with: .photo, data: fileData, options: nil) - } else { - if let image = UIImage(data: fileData) { - PHAssetChangeRequest.creationRequestForAsset(from: image) - } - } + PHAssetCreationRequest.forAsset().addResource(with: .photo, data: fileData, options: nil) } } else { if let _ = try? FileManager.default.copyItem(atPath: data.path, toPath: tempVideoPath) { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift index 680de32103..0dfdad36f2 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift @@ -719,13 +719,21 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { self.fetchDisposable.dispose() } + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + var result = super.hitTest(point, with: event) + if let mediaFrame = self.mediaFrame, mediaFrame.contains(point) { + result = self.view + } + return result + } + override func didLoad() { super.didLoad() self.highlightedBackgroundNode.view.tag = 0x1bad self.separatorNode.view.tag = 0x3bad } - + @objc private func buttonPressed() { guard !self.ignoreNextTap else { self.ignoreNextTap = false diff --git a/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift b/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift index 125cf38947..c7f9fdcb36 100644 --- a/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift +++ b/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift @@ -1419,10 +1419,10 @@ final class ComposePollScreenComponent: Component { self.component = component self.state = state - let topInset: CGFloat = 24.0 + let topInset: CGFloat = 16.0 let bottomInset: CGFloat = 8.0 let sideInset: CGFloat = 16.0 + environment.safeInsets.left - let sectionSpacing: CGFloat = 24.0 + let sectionSpacing: CGFloat = 30.0 if themeUpdated { self.backgroundColor = theme.list.blocksBackgroundColor @@ -1900,6 +1900,7 @@ final class ComposePollScreenComponent: Component { ))) ], alignment: .left, spacing: 4.0)), verticalAlignment: .middle, + contentInsets: UIEdgeInsets(top: 10.0, left: 0.0, bottom: 10.0, right: 0.0), leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent( Image(image: self.cachedViewIcon, size: CGSize(width: 30.0, height: 30.0)) )), false), @@ -1939,6 +1940,7 @@ final class ComposePollScreenComponent: Component { ))) ], alignment: .left, spacing: 4.0)), verticalAlignment: .middle, + contentInsets: UIEdgeInsets(top: 10.0, left: 0.0, bottom: 10.0, right: 0.0), leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent( Image(image: self.cachedMultipleIcon, size: CGSize(width: 30.0, height: 30.0)) )), false), @@ -1958,7 +1960,7 @@ final class ComposePollScreenComponent: Component { } } - self.state?.updated(transition: .spring(duration: 0.4)) + self.state?.updated(transition: .spring(duration: 0.4).withUserData(MultilineTextComponent.CrossfadeTransition())) })), action: nil )))) @@ -1987,6 +1989,7 @@ final class ComposePollScreenComponent: Component { ))) ], alignment: .left, spacing: 4.0)), verticalAlignment: .middle, + contentInsets: UIEdgeInsets(top: 10.0, left: 0.0, bottom: 10.0, right: 0.0), leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent( Image(image: self.cachedAddIcon, size: CGSize(width: 30.0, height: 30.0)) )), false), @@ -2027,6 +2030,7 @@ final class ComposePollScreenComponent: Component { ))) ], alignment: .left, spacing: 4.0)), verticalAlignment: .middle, + contentInsets: UIEdgeInsets(top: 10.0, left: 0.0, bottom: 10.0, right: 0.0), leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent( Image(image: self.cachedRevoteIcon, size: CGSize(width: 30.0, height: 30.0)) )), false), @@ -2063,6 +2067,7 @@ final class ComposePollScreenComponent: Component { ))) ], alignment: .left, spacing: 4.0)), verticalAlignment: .middle, + contentInsets: UIEdgeInsets(top: 10.0, left: 0.0, bottom: 10.0, right: 0.0), leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent( Image(image: self.cachedShuffleIcon, size: CGSize(width: 30.0, height: 30.0)) )), false), @@ -2099,6 +2104,7 @@ final class ComposePollScreenComponent: Component { ))) ], alignment: .left, spacing: 4.0)), verticalAlignment: .middle, + contentInsets: UIEdgeInsets(top: 10.0, left: 0.0, bottom: 10.0, right: 0.0), leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent( Image(image: self.cachedQuizIcon, size: CGSize(width: 30.0, height: 30.0)) )), false), @@ -2141,6 +2147,7 @@ final class ComposePollScreenComponent: Component { ))) ], alignment: .left, spacing: 4.0)), verticalAlignment: .middle, + contentInsets: UIEdgeInsets(top: 10.0, left: 0.0, bottom: 10.0, right: 0.0), leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent( Image(image: self.cachedDurationIcon, size: CGSize(width: 30.0, height: 30.0)) )), false), diff --git a/submodules/TelegramUI/Components/ComposeTodoScreen/Sources/ComposeTodoScreen.swift b/submodules/TelegramUI/Components/ComposeTodoScreen/Sources/ComposeTodoScreen.swift index abb73b1a0c..7a8f2eeadf 100644 --- a/submodules/TelegramUI/Components/ComposeTodoScreen/Sources/ComposeTodoScreen.swift +++ b/submodules/TelegramUI/Components/ComposeTodoScreen/Sources/ComposeTodoScreen.swift @@ -762,10 +762,10 @@ final class ComposeTodoScreenComponent: Component { self.component = component self.state = state - let topInset: CGFloat = 24.0 + let topInset: CGFloat = 8.0 let bottomInset: CGFloat = 8.0 let sideInset: CGFloat = 16.0 + environment.safeInsets.left - let sectionSpacing: CGFloat = 24.0 + let sectionSpacing: CGFloat = 30.0 if themeUpdated { self.backgroundColor = theme.list.blocksBackgroundColor diff --git a/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift b/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift index 16ee26f0a4..d4ca89a2b5 100644 --- a/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift +++ b/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift @@ -496,17 +496,8 @@ public final class ListActionItemComponent: Component { contentRightInset = customAccessorySizeValue.width + customAccessory.insets.left + customAccessory.insets.right } - var contentInsets = component.contentInsets - switch component.style { - case .glass: - if contentInsets.top == 12.0 && contentInsets.bottom == 12.0 { - contentInsets.top = 16.0 - contentInsets.bottom = 16.0 - } - case .legacy: - break - } - + let contentInsets = component.contentInsets + var contentHeight: CGFloat = 0.0 contentHeight += contentInsets.top @@ -531,11 +522,15 @@ public final class ListActionItemComponent: Component { contentLeftInset = floor((availableSize.width - titleSize.width) / 2.0) } - let titleY = contentHeight contentHeight += titleSize.height - contentHeight += contentInsets.bottom + if case .glass = component.style, contentHeight < 52.0 { + contentHeight = 52.0 + } + + let titleY = floorToScreenPixels((contentHeight - titleSize.height) / 2.0) + if let iconValue = component.icon { if previousComponent?.icon?.component.id != iconValue.component.id, let icon = self.icon { self.icon = nil diff --git a/submodules/TelegramUniversalVideoContent/Sources/ExternalVideoPlayer.swift b/submodules/TelegramUniversalVideoContent/Sources/ExternalVideoPlayer.swift deleted file mode 100644 index eceba5b1dd..0000000000 --- a/submodules/TelegramUniversalVideoContent/Sources/ExternalVideoPlayer.swift +++ /dev/null @@ -1,121 +0,0 @@ -import Foundation -import AVFoundation -import SwiftSignalKit -import UniversalMediaPlayer -import AccountContext -import AVKit - -public class ExternalVideoPlayer: NSObject, AVRoutePickerViewDelegate { - private let context: AccountContext - let content: NativeVideoContent - - let player: AVPlayer? - private var didPlayToEndTimeObserver: NSObjectProtocol? - private var timeObserver: Any? - - private var statusValue = MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: .buffering(initial: true, whilePlaying: false, progress: 0.0, display: true), soundEnabled: true) - private let _status = ValuePromise() - var status: Signal { - return self._status.get() - } - private var seekId: Int = 0 - - private weak var routePickerView: UIView? - - public var isActiveUpdated: (Bool) -> Void = { _ in } - - public init(context: AccountContext, content: NativeVideoContent) { - self.context = context - self.content = content - - if let path = context.account.postbox.mediaBox.completedResourcePath(content.fileReference.media.resource, pathExtension: "mp4") { - let player = AVPlayer(url: URL(fileURLWithPath: path)) - self.player = player - } else { - self.player = nil - } - - super.init() - - self.startObservingForAirPlayStatusChanges() - self.isActiveUpdated(self.player?.isExternalPlaybackActive ?? false) - - if let player = self.player { - self.didPlayToEndTimeObserver = NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: nil, using: { [weak self] notification in - if let strongSelf = self { - strongSelf.player?.seek(to: CMTime(seconds: 0.0, preferredTimescale: 30)) - strongSelf.play() - } - }) - - self.timeObserver = player.addPeriodicTimeObserver(forInterval: CMTimeMake(value: 1, timescale: 10), queue: DispatchQueue.main) { [weak self] time in - guard let strongSelf = self else { - return - } - strongSelf.statusValue = MediaPlayerStatus(generationTimestamp: 0.0, duration: strongSelf.statusValue.duration, dimensions: CGSize(), timestamp: CMTimeGetSeconds(time), baseRate: 1.0, seekId: strongSelf.seekId, status: strongSelf.statusValue.status, soundEnabled: true) - strongSelf._status.set(strongSelf.statusValue) - } - } - - self._status.set(self.statusValue) - } - - deinit { - if let timeObserver = self.timeObserver { - self.player?.removeTimeObserver(timeObserver) - } - - if let didPlayToEndTimeObserver = self.didPlayToEndTimeObserver { - NotificationCenter.default.removeObserver(didPlayToEndTimeObserver) - } - - self.stopObservingForAirPlayStatusChanges() - } - - public func play() { - self.player?.play() - } - - public func openRouteSelection() { - if #available(iOS 11.0, *) { - let routePickerView = AVRoutePickerView() - routePickerView.delegate = self - if #available(iOS 13.0, *) { - routePickerView.prioritizesVideoDevices = true - } - self.context.sharedContext.mainWindow?.viewController?.view.addSubview(routePickerView) - - if let routePickerButton = routePickerView.subviews.first(where: { $0 is UIButton }) as? UIButton { - routePickerButton.sendActions(for: .touchUpInside) - } - } - } - - @available(iOS 11.0, *) - public func routePickerViewDidEndPresentingRoutes(_ routePickerView: AVRoutePickerView) { - routePickerView.removeFromSuperview() - - self.play() - } - - private var observerContextAirplay = 1 - - func startObservingForAirPlayStatusChanges() - { - self.player?.addObserver(self, forKeyPath: #keyPath(AVPlayer.isExternalPlaybackActive), options: .new, context: &observerContextAirplay) - } - - func stopObservingForAirPlayStatusChanges() - { - self.player?.removeObserver(self, forKeyPath: #keyPath(AVPlayer.isExternalPlaybackActive)) - } - - public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { - if context == &observerContextAirplay { - self.isActiveUpdated(self.player?.isExternalPlaybackActive ?? false) - } - else { - super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) - } - } -}