Files
Telegram-iOS/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreenDrafts.swift
T
isaacisaacClaude Opus 4.7
86d1456552 Postbox -> TelegramEngine waves 107-137 (squashed)
31 waves of consumer-side migration from `import Postbox` to TelegramEngine
typealiases. Net: 173 import drops + 39 BUILD-dep drops + 1 new typealias
(`EngineStoryId = StoryId`, wave 113).

Wave shapes used:
- Orphan-import sweeps (107, 108, 128): drop `import Postbox` from files
  whose only Postbox-symbol reference was the import line itself, then
  resolve build failures. Methodology requires token-level (`grep -oE`)
  filtering, not line-level, to avoid masking real Postbox usage on lines
  that also contain `Namespaces.X` references.
- Identifier-swap mini-waves (109-127, 129-134, 136-137): rename
  Postbox-typealiased identifiers to engine equivalents
  (PeerId -> EnginePeer.Id, MessageId -> EngineMessage.Id,
  MediaId -> EngineMedia.Id, MessageIndex -> EngineMessage.Index,
  StoryId -> EngineStoryId, ItemCollectionId -> EngineItemCollectionId,
  PreferencesEntry -> EnginePreferencesEntry,
  FetchResourceSourceType/Error -> EngineFetchResourceSourceType/Error,
  MemoryBuffer -> EngineMemoryBuffer, MessageTags -> EngineMessage.Tags,
  MessageAttribute -> EngineMessage.Attribute,
  TempBox -> EngineTempBox).
- Asset-string FP-only orphans (124).
- Typealias addition + drain (113): added `EngineStoryId` typealias to
  TelegramCore, then drained 3+11 consumer sites.

Hard blockers identified during these waves (must restore `import Postbox`
when present): MediaResource[A-Za-z]* (any suffix -- the literal
`MediaResource` matches don't catch MediaResourceData/MediaResourceId/etc.),
Postbox/MediaBox/MediaResource raw types, PostboxCoding/PostboxEncoder/
PostboxDecoder, TempBoxFile, ValueBoxKey, PostboxView, combinedView,
HashFunctions, postboxLog, openPostbox, declareEncodable, PeerView,
MessageHistoryView, MessageHistoryThreadData, CachedPeerData, RenderedPeer,
SelectivePrivacyPeer, SimpleDictionary, ItemCollectionInfosView,
ItemCollectionItem, ItemCollectionItemIndex, ItemCollectionViewEntryIndex,
ChatListIndex, ChatListEntrySummaryComponents, CodableEntry,
MessageHistoryThread, MessageHistoryAnchorIndex,
MessageHistoryEntryLocation, PeerStoryStats, PeerNameIndex,
PeerSummaryCounterTags, ChatListTotalUnreadStateCategory/Stats,
arePeersEqual. Protocol-shape blockers: bare `Peer`/`Message`/`Media`
in function signatures, generic args, enum-case payloads, or dict value
types (e.g., `[PeerId: Peer]`, `case messages([Message])`,
`Signal<(Peer?, ...), NoError>`).

`replace_all PeerId -> EnginePeer.Id` is dangerous: mangles compound
names like `failedPeerId`, `ContactListPeerId`, `nextRemoteMediaId`,
`replyToMessageId`. Pre-flight grep `\b[a-z][a-zA-Z]*PeerId\b` and only
replace_all if 0 matches.

Also removes unneeded design/plan docs from a separate (link-highlighting)
feature branch:
- docs/superpowers/plans/2026-05-02-link-highlighting-modern-path-fixes.md
- docs/superpowers/specs/2026-05-02-link-highlighting-modern-path-fixes-design.md

Squashed commits: 6d82c2980d..e6de5d53a3 (59 commits, including
per-wave content commits and per-wave CLAUDE.md bumps).

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

269 lines
11 KiB
Swift

import Foundation
import UIKit
import Display
import CoreLocation
import Photos
import TelegramCore
import AccountContext
import MediaEditor
import DrawingUI
extension MediaEditorScreenImpl {
func isEligibleForDraft() -> Bool {
guard !self.isEditingStory else {
return false
}
if case .avatarEditor = self.mode {
return false
}
if case .coverEditor = self.mode {
return false
}
if case .multiple = self.node.actualSubject {
return false
}
guard let mediaEditor = self.node.mediaEditor else {
return false
}
let entities = self.node.entitiesView.entities.filter { !($0 is DrawingMediaEntity) }
let codableEntities = DrawingEntitiesView.encodeEntities(entities, entitiesView: self.node.entitiesView)
mediaEditor.setDrawingAndEntities(data: nil, image: mediaEditor.values.drawing, entities: codableEntities)
let filteredEntities = self.node.entitiesView.entities.filter { entity in
if entity is DrawingMediaEntity {
return false
} else if let entity = entity as? DrawingStickerEntity {
switch entity.content {
case .message, .gift:
return false
default:
break
}
}
return true
}
let values = mediaEditor.values
let filteredValues = values.withUpdatedEntities([])
let caption = self.node.getCaption()
if let subject = self.node.subject {
switch subject {
case .asset:
if !values.hasChanges && caption.string.isEmpty {
return false
}
case .message, .gift:
if !filteredValues.hasChanges && filteredEntities.isEmpty && caption.string.isEmpty {
return false
}
case .empty:
if !self.node.hasAnyChanges && !self.node.drawingView.internalState.canUndo {
return false
}
case .videoCollage:
return false
default:
break
}
}
return true
}
func saveDraft(id: Int64?, isEdit: Bool = false, completion: ((MediaEditorDraft) -> Void)? = nil) {
guard case .storyEditor = self.mode, let subject = self.node.subject, let actualSubject = self.node.actualSubject, let mediaEditor = self.node.mediaEditor else {
return
}
try? FileManager.default.createDirectory(atPath: draftPath(engine: self.context.engine), withIntermediateDirectories: true)
let values = mediaEditor.values
let privacy = self.state.privacy
let forwardSource = self.forwardSource
let caption = self.node.getCaption()
let duration = mediaEditor.duration ?? 0.0
let currentTimestamp = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970)
var timestamp: Int32
var location: CLLocationCoordinate2D?
let expiresOn: Int32
if case let .draft(draft, _) = actualSubject {
timestamp = draft.timestamp
location = draft.location
if let _ = id {
expiresOn = draft.expiresOn ?? currentTimestamp + 3600 * 24 * 7
} else {
expiresOn = currentTimestamp + 3600 * 24 * 7
}
} else {
timestamp = currentTimestamp
if case let .asset(asset) = subject {
location = asset.location?.coordinate
}
if let _ = id {
expiresOn = currentTimestamp + Int32(self.state.privacy.timeout)
} else {
expiresOn = currentTimestamp + 3600 * 24 * 7
}
}
if let resultImage = mediaEditor.resultImage {
if !isEdit {
mediaEditor.seek(0.0, andPlay: false)
}
makeEditorImageComposition(
context: self.node.ciContext,
postbox: self.context.account.postbox,
inputImage: resultImage,
dimensions: storyDimensions,
values: values,
time: .zero,
textScale: 2.0,
completion: { resultImage in
guard let resultImage else {
return
}
enum MediaInput {
case image(image: UIImage, dimensions: PixelDimensions)
case video(path: String, dimensions: PixelDimensions, duration: Double)
var isVideo: Bool {
switch self {
case .video:
return true
case .image:
return false
}
}
var dimensions: PixelDimensions {
switch self {
case let .image(_, dimensions):
return dimensions
case let .video(_, dimensions, _):
return dimensions
}
}
var duration: Double? {
switch self {
case .image:
return nil
case let .video(_, _, duration):
return duration
}
}
var fileExtension: String {
switch self {
case .image:
return "jpg"
case .video:
return "mp4"
}
}
}
let context = self.context
func innerSaveDraft(media: MediaInput, save: Bool = true) -> MediaEditorDraft? {
let fittedSize = resultImage.size.aspectFitted(CGSize(width: 128.0, height: 128.0))
guard let thumbnailImage = generateScaledImage(image: resultImage, size: fittedSize) else {
return nil
}
let path = "\(Int64.random(in: .min ... .max)).\(media.fileExtension)"
let draft = MediaEditorDraft(
path: path,
isVideo: media.isVideo,
thumbnail: thumbnailImage,
dimensions: media.dimensions,
duration: media.duration,
values: values,
caption: caption,
privacy: privacy,
forwardInfo: forwardSource.flatMap { EngineStoryId(peerId: $0.0.id, id: $0.1.id) },
timestamp: timestamp,
location: location,
expiresOn: expiresOn
)
switch media {
case let .image(image, _):
if let data = image.jpegData(compressionQuality: 0.87) {
try? data.write(to: URL(fileURLWithPath: draft.fullPath(engine: context.engine)))
}
case let .video(path, _, _):
try? FileManager.default.copyItem(atPath: path, toPath: draft.fullPath(engine: context.engine))
}
if save {
if let id {
saveStorySource(engine: context.engine, item: draft, peerId: context.account.peerId, id: id)
} else {
addStoryDraft(engine: context.engine, item: draft)
}
}
return draft
}
switch subject {
case .empty:
break
case let .image(image, dimensions, _, _, _):
if let draft = innerSaveDraft(media: .image(image: image, dimensions: dimensions)) {
completion?(draft)
}
case let .video(path, _, _, _, _, dimensions, _, _, _, _):
if let draft = innerSaveDraft(media: .video(path: path, dimensions: dimensions, duration: duration)) {
completion?(draft)
}
case let .videoCollage(items):
let _ = items
case let .asset(asset):
if asset.mediaType == .video {
PHImageManager.default().requestAVAsset(forVideo: asset, options: nil) { avAsset, _, _ in
if let urlAsset = avAsset as? AVURLAsset {
if let draft = innerSaveDraft(media: .video(path: urlAsset.url.relativePath, dimensions: PixelDimensions(width: Int32(asset.pixelWidth), height: Int32(asset.pixelHeight)), duration: duration)) {
completion?(draft)
}
}
}
} else {
let options = PHImageRequestOptions()
options.deliveryMode = .highQualityFormat
PHImageManager.default().requestImage(for: asset, targetSize: PHImageManagerMaximumSize, contentMode: .default, options: options) { image, _ in
if let image {
if let draft = innerSaveDraft(media: .image(image: image, dimensions: PixelDimensions(image.size))) {
completion?(draft)
}
}
}
}
case let .draft(draft, _):
if draft.isVideo {
if let draft = innerSaveDraft(media: .video(path: draft.fullPath(engine: context.engine), dimensions: draft.dimensions, duration: draft.duration ?? 0.0)) {
completion?(draft)
}
} else if let image = UIImage(contentsOfFile: draft.fullPath(engine: context.engine)) {
if let draft = innerSaveDraft(media: .image(image: image, dimensions: draft.dimensions)) {
completion?(draft)
}
}
case .message, .gift:
if let pixel = generateSingleColorImage(size: CGSize(width: 1, height: 1), color: .black) {
if let draft = innerSaveDraft(media: .image(image: pixel, dimensions: PixelDimensions(width: 1080, height: 1920))) {
completion?(draft)
}
}
case .sticker:
break
case .multiple:
break
}
if case let .draft(draft, _) = actualSubject {
removeStoryDraft(engine: self.context.engine, path: draft.path, delete: false)
}
})
}
}
}