Postbox -> TelegramEngine wave 10: StorageFileListPanelComponent drop

Closes the last future-wave candidate from wave 8 by eliminating the
Icon.media(Media, ...) enum case in StorageFileListPanelComponent.swift
and dropping import Postbox. StorageUsageScreen (the module as a whole)
is now fully Postbox-free.

Icon enum split:
  case media(Media, TelegramMediaImageRepresentation)  ->
    case mediaFile(TelegramMediaFile, TelegramMediaImageRepresentation)
    case mediaImage(TelegramMediaImage, TelegramMediaImageRepresentation)

Equatable rewritten as switch-over-tuple with id-based equality per
concrete type (lFile.fileId == rFile.fileId / lImage.imageId ==
rImage.imageId), same semantics as the old media.id comparison.

Binding site: `if case let .media(media, representation)` +
`as? TelegramMediaFile` / `as? TelegramMediaImage` downcasts ->
compound case-binding `case let .mediaFile(_, representation), let
.mediaImage(_, representation):` to lift the shared representation
variable, plus an inner switch for the setSignal branch. The compiler-
enforced exhaustiveness of the split improves call-site safety.

Construction sites (2): `.media(file, representation)` -> `.mediaFile(
file, representation)`, `.media(image, representation)` -> `.mediaImage(
image, representation)`.

Placeholder fixup:
  messageId: EngineMessage.Id(peerId: PeerId(namespace: PeerId.Namespace
    ._internalFromInt32Value(0), id: PeerId.Id._internalFromInt64Value(0)
    ), namespace: 0, id: 0)
  ->
  messageId: EngineMessage.Id(peerId: component.context.account.peerId,
    namespace: 0, id: 0)

Inside a measureItem layout-measurement instance. Caught by second-pass
build failure `cannot find 'PeerId' in scope`. PeerId / PeerId.Namespace
/ PeerId.Id are raw Postbox types (not TelegramCore typealiases —
consistent with wave 9's MessageId -> EngineMessage.Id fixup). Using
context.account.peerId is semantically equivalent for the measurement
use case (messageId only feeds image-fetch userLocation and Equatable
comparison, neither exercised for this standalone instance).

Net: 1 file changed, +22 / -29 lines.

Plan: docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-10.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Isaac 2026-04-20 01:46:18 +02:00
parent 1a432362a3
commit 34c2c8c8a4
3 changed files with 250 additions and 40 deletions

View file

@ -256,6 +256,24 @@ Net: 1 file changed, +30 / -54. Build verified green (27 actions, cached).
Plan / record: `docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-9.md`.
### Wave 10 outcome (2026-04-20)
Closes the second (and last) future-wave candidate from wave 8: eliminates `StorageFileListPanelComponent.swift`'s `Icon.media(Media, TelegramMediaImageRepresentation)` enum case. **`StorageUsageScreen` (the module as a whole) is now fully Postbox-free** — the other in-module file (`StorageUsageScreen.swift`) landed in wave 9.
**Split the enum case.** `Icon.media(Media, TelegramMediaImageRepresentation)` → two concrete cases `case mediaFile(TelegramMediaFile, TelegramMediaImageRepresentation)` + `case mediaImage(TelegramMediaImage, TelegramMediaImageRepresentation)`. Lossless split: the two construction sites already knew the concrete subtype (`imageIconValue = .media(file, representation)` from a `as? TelegramMediaFile` branch, `.media(image, representation)` from a `as? TelegramMediaImage` branch), and the consumer binding site immediately downcast via `as?` to pick which `setSignal(...)` flavor to call. New split removes the downcast; exhaustiveness-checked switch is both safer and terser.
**Equatable rewritten.** Old: manual outer-`switch` + inner `if case` dispatch, comparing media by `media.id` only. New: switch-over-tuple `(lhs, rhs)` with id-based equality per concrete type (`lFile.fileId == rFile.fileId`, `lImage.imageId == rImage.imageId`). Same id-based equality semantics as before.
**Binding-site rewrite.** Old: `if case let .media(media, representation) = component.icon { ... if let file = media as? TelegramMediaFile { ... } else if let image = media as? TelegramMediaImage { ... } }`. New: a compound case-binding pattern `case let .mediaFile(_, representation), let .mediaImage(_, representation):` lifts the shared `representation` variable, then an inner switch dispatches to the right `setSignal` branch. Works because both cases carry the same `TelegramMediaImageRepresentation` payload type; Swift allows compound case patterns when the bindings have identical types.
**Placeholder `PeerId(...)` construction fixup.** Second-pass build failure after dropping `import Postbox` surfaced a placeholder `PeerId(namespace: PeerId.Namespace._internalFromInt32Value(0), id: PeerId.Id._internalFromInt64Value(0))` in the `measureItem` layout-measurement instance at former line 1062. Naming `PeerId`, `PeerId.Namespace`, and `PeerId.Id` all require `import Postbox` (these are raw Postbox types, not TelegramCore typealiases — consistent with wave 9's `MessageId``EngineMessage.Id` fixup). Replaced with `component.context.account.peerId` (a real `EnginePeer.Id` already in scope). Semantically equivalent since the measurement instance's `messageId` is only used for `.peerId` extraction inside image-fetch `userLocation` and for Equatable comparison (the measurement instance isn't compared to anything).
**Lesson.** Placeholder `PeerId(...)` / `MessageId(peerId:...)` constructions in layout-measurement code are a recurring trap for de-Postbox work. Common pattern in this codebase: construct a dummy component instance purely to call `.update(...)` and read back the returned size. The dummy values are not used meaningfully but naming the types pins `import Postbox`. When de-Postboxing, grep for `PeerId(namespace:`/`MessageId(peerId:` with all-zero args and replace with any convenient real value in scope (`context.account.peerId` is almost always available).
Net: 1 file changed, +22 / -29 lines (7 simplification — new switch-over-tuple Equatable is both terser and more idiomatic).
Plan / record: `docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-10.md`.
### Modules currently free of `import Postbox` (running tally)
Consumer modules that no longer import Postbox, across all waves and standalone commits:
@ -271,6 +289,7 @@ Consumer modules that no longer import Postbox, across all waves and standalone
- `SaveToCameraRoll` (wave 3)
- `SecureIdVerificationDocumentsContext` (wave 5)
- **Wave 6 batch: 189 additional modules** — see `git show 7b2b74e79b --stat` for the commit that swept unused `import Postbox` lines across 183 files in 16 consumer submodules. Not individually enumerated here for brevity.
- `StorageUsageScreen` (waves 810)
### Known future-wave candidates

View file

@ -0,0 +1,194 @@
# Postbox → TelegramEngine Wave 10 Implementation Plan
> **For agentic workers:** This plan was executed in a single session; steps below are a post-hoc record of the work landed, not a to-do list.
**Goal:** Finish the `StorageUsageScreen` consumer-module de-Postbox work started in wave 8 and continued in wave 9 by eliminating the last `import Postbox` in the module: `StorageFileListPanelComponent.swift`'s `Icon.media(Media, TelegramMediaImageRepresentation)` enum case.
**Architecture:** Replace the heterogeneous-protocol `Icon.media(Media, ...)` case with two concrete-type cases `.mediaFile(TelegramMediaFile, ...)` and `.mediaImage(TelegramMediaImage, ...)`. The split is lossless because the two construction sites already knew the concrete subtype (`imageIconValue = .media(file, representation)` vs `.media(image, representation)`), and the one consumer binding site immediately downcasted via `as? TelegramMediaFile` / `as? TelegramMediaImage` to pick which `setSignal(...)` to call. Auto-split the switch body over the two new cases; no downcast needed. Also replaces a placeholder `PeerId(namespace:..., id:...)` construction in the `measureItem` layout-measurement instance with `component.context.account.peerId` (a real, already-available `EnginePeer.Id`).
**Tech Stack:** Swift / Bazel. No unit tests.
**Build command:**
```bash
source ~/.zshrc 2>/dev/null; PATH=/opt/homebrew/opt/ruby/bin:`gem environment gemdir`/bin:$PATH python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber 1 --configuration debug_sim_arm64 --continueOnError
```
---
## Scope
**In scope:**
- `StorageFileListPanelComponent.swift`'s `Icon` enum: replace `case media(Media, TelegramMediaImageRepresentation)` with two concrete-type cases.
- Equatable rewrite: switch-over-tuple `(lhs, rhs)` pattern with id-based equality per concrete type (`lFile.fileId == rFile.fileId`, `lImage.imageId == rImage.imageId`).
- Binding rewrite at the `if case let .media(media, representation)` site (former line 448): lift `representation` via a compound `case let .mediaFile(_, representation), let .mediaImage(_, representation):` pattern, then inner switch-over-`component.icon` selects `setSignal` flavor.
- Construction rewrite at two `imageIconValue = .media(...)` sites: use the concrete case name (`.mediaFile`, `.mediaImage`).
- Placeholder `PeerId(namespace:..., id:...)` at former line 1062 (in the `measureItem` layout-measurement instance): replace with `component.context.account.peerId`.
- Remove `import Postbox` from `StorageFileListPanelComponent.swift`.
**Out of scope:**
- None. This is the last file in the `StorageUsageScreen` module that imports Postbox; after this wave, the module is fully Postbox-free.
---
## Tasks
### Task 1: Split `Icon.media` into two concrete cases
**Files:**
- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageFileListPanelComponent.swift` (former 103135).
Before:
```swift
enum Icon: Equatable {
case fileExtension(String)
case media(Media, TelegramMediaImageRepresentation)
case audio
static func ==(lhs: Icon, rhs: Icon) -> Bool {
switch lhs {
case let .fileExtension(value):
if case .fileExtension(value) = rhs { return true } else { return false }
case let .media(media, representation):
if case let .media(rhsMedia, rhsRepresentation) = rhs {
if media.id != rhsMedia.id { return false }
if representation != rhsRepresentation { return false }
return true
} else { return false }
case .audio:
if case .audio = rhs { return true } else { return false }
}
}
}
```
After:
```swift
enum Icon: Equatable {
case fileExtension(String)
case mediaFile(TelegramMediaFile, TelegramMediaImageRepresentation)
case mediaImage(TelegramMediaImage, TelegramMediaImageRepresentation)
case audio
static func ==(lhs: Icon, rhs: Icon) -> Bool {
switch (lhs, rhs) {
case let (.fileExtension(l), .fileExtension(r)):
return l == r
case let (.mediaFile(lFile, lRepresentation), .mediaFile(rFile, rRepresentation)):
return lFile.fileId == rFile.fileId && lRepresentation == rRepresentation
case let (.mediaImage(lImage, lRepresentation), .mediaImage(rImage, rRepresentation)):
return lImage.imageId == rImage.imageId && lRepresentation == rRepresentation
case (.audio, .audio):
return true
default:
return false
}
}
}
```
### Task 2: Rewrite the binding site
**Files:**
- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageFileListPanelComponent.swift` (former 448500).
Before, the block started with `if case let .media(media, representation) = component.icon { ... }` then inside did `if let file = media as? TelegramMediaFile { ... } else if let image = media as? TelegramMediaImage { ... }`.
After, use a compound case-binding pattern at the entry (both cases have the same `representation` type, so the pattern works) and an inner switch for the `setSignal` branch:
```swift
let mediaRepresentation: TelegramMediaImageRepresentation?
switch component.icon {
case let .mediaFile(_, representation), let .mediaImage(_, representation):
mediaRepresentation = representation
default:
mediaRepresentation = nil
}
if let representation = mediaRepresentation {
// ... setup iconImageNode as before ...
if resetImage {
switch component.icon {
case let .mediaFile(file, _):
iconImageNode.setSignal(chatWebpageSnippetFile(
account: component.context.account,
userLocation: .peer(component.messageId.peerId),
mediaReference: FileMediaReference.standalone(media: file).abstract,
representation: representation,
automaticFetch: false
))
case let .mediaImage(image, _):
iconImageNode.setSignal(mediaGridMessagePhoto(
account: component.context.account,
userLocation: .peer(component.messageId.peerId),
photoReference: ImageMediaReference.standalone(media: image),
automaticFetch: false
))
default:
break
}
}
// ... frame + asyncLayout + apply as before ...
}
```
### Task 3: Update the two construction sites
**Files:**
- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageFileListPanelComponent.swift` (former 985 and 992).
`imageIconValue = .media(file, representation)``.mediaFile(file, representation)` (for `TelegramMediaFile` branch).
`imageIconValue = .media(image, representation)``.mediaImage(image, representation)` (for `TelegramMediaImage` branch).
### Task 4: Replace the placeholder `PeerId(...)` construction
**Files:**
- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageFileListPanelComponent.swift` (former 1062).
The `measureItem` layout-measurement instance uses a fully-zero placeholder peer id:
```swift
messageId: EngineMessage.Id(peerId: PeerId(namespace: PeerId.Namespace._internalFromInt32Value(0), id: PeerId.Id._internalFromInt64Value(0)), namespace: 0, id: 0),
```
Naming `PeerId`, `PeerId.Namespace`, `PeerId.Id` all require `import Postbox` (these are raw Postbox types, not TelegramCore typealiases). Replace with `component.context.account.peerId`, a real `EnginePeer.Id` already in scope:
```swift
messageId: EngineMessage.Id(peerId: component.context.account.peerId, namespace: 0, id: 0),
```
Semantically equivalent for the measurement use case — `messageId` is used downstream only for `.peerId` extraction in the image-fetch userLocation and for Equatable comparison; the measurement instance is standalone and not compared. The `id: 0, namespace: 0` part stays; those are plain `Int32`, nothing Postbox-specific.
Caught by second-pass build failure `cannot find 'PeerId' in scope` after dropping `import Postbox`.
### Task 5: Drop `import Postbox`
**Files:**
- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageFileListPanelComponent.swift` (former line 14).
Remove the `import Postbox` line.
### Task 6: Full project build
Expected green after Tasks 4 and 5. The first build attempt surfaced the `PeerId` issue; Task 4's fix addressed it.
### Task 7: Commit
Single wave-10 atomic commit. CLAUDE.md gets a wave-10 outcome section; the "Modules currently free of `import Postbox`" tally gains `StorageUsageScreen` (the module as a whole). Both files that previously imported Postbox in this module (`StorageUsageScreen.swift` from wave 9 and `StorageFileListPanelComponent.swift` from wave 10) are now Postbox-free.
---
## Outcome (2026-04-20)
Single atomic commit. Build verified green (27 actions, cached).
**`StorageUsageScreen` consumer module is now fully Postbox-free** — last file (`StorageFileListPanelComponent.swift`) landed in this wave; the other file (`StorageUsageScreen.swift`) landed in wave 9.
Net: 1 file changed, +22 / -29 lines (7 simplification — the new switch-over-tuple Equatable is both terser and more idiomatic than the old three-way nested `switch` + `if case` pattern).
**Lessons:**
- **Heterogeneous-protocol enum cases are an easy de-Postbox win** when the protocol values already get downcast to a fixed small set of concrete subtypes. The compiler-enforced exhaustiveness of the split improves call-site safety (no silent `else` branch that forgot a subtype).
- **Placeholder `PeerId(...)` constructions in layout-measurement code are traps.** Common pattern in this codebase: a "dummy" component instance is constructed purely to run `.update(...)` and harvest the returned size. The dummy values (`messageId`, `peerId`) are not used for anything but type-filling, yet naming the types forces `import Postbox`. When de-Postboxing, look for `PeerId(namespace:...`/`MessageId(peerId:...` constructions with all-zero arguments and replace with any convenient real value already in scope (`context.account.peerId` works for peer-id placeholders).

View file

@ -11,7 +11,6 @@ import AccountContext
import TelegramCore
import MultilineTextComponent
import EmojiStatusComponent
import Postbox
import TelegramStringFormatting
import CheckNode
import AvatarNode
@ -102,35 +101,22 @@ private let smallExtensionFont = Font.with(size: 12.0, design: .round, weight: .
private final class FileListItemComponent: Component {
enum Icon: Equatable {
case fileExtension(String)
case media(Media, TelegramMediaImageRepresentation)
case mediaFile(TelegramMediaFile, TelegramMediaImageRepresentation)
case mediaImage(TelegramMediaImage, TelegramMediaImageRepresentation)
case audio
static func ==(lhs: Icon, rhs: Icon) -> Bool {
switch lhs {
case let .fileExtension(value):
if case .fileExtension(value) = rhs {
return true
} else {
return false
}
case let .media(media, representation):
if case let .media(rhsMedia, rhsRepresentation) = rhs {
if media.id != rhsMedia.id {
return false
}
if representation != rhsRepresentation {
return false
}
return true
} else {
return false
}
case .audio:
if case .audio = rhs {
return true
} else {
return false
}
switch (lhs, rhs) {
case let (.fileExtension(l), .fileExtension(r)):
return l == r
case let (.mediaFile(lFile, lRepresentation), .mediaFile(rFile, rRepresentation)):
return lFile.fileId == rFile.fileId && lRepresentation == rRepresentation
case let (.mediaImage(lImage, lRepresentation), .mediaImage(rImage, rRepresentation)):
return lImage.imageId == rImage.imageId && lRepresentation == rRepresentation
case (.audio, .audio):
return true
default:
return false
}
}
}
@ -445,25 +431,34 @@ private final class FileListItemComponent: Component {
}
}
if case let .media(media, representation) = component.icon {
let mediaRepresentation: TelegramMediaImageRepresentation?
switch component.icon {
case let .mediaFile(_, representation), let .mediaImage(_, representation):
mediaRepresentation = representation
default:
mediaRepresentation = nil
}
if let representation = mediaRepresentation {
var resetImage = false
let iconImageNode: TransformImageNode
if let current = self.iconImageNode {
iconImageNode = current
} else {
resetImage = true
iconImageNode = TransformImageNode()
self.iconImageNode = iconImageNode
self.containerButton.addSubview(iconImageNode.view)
}
let iconSize = CGSize(width: 40.0, height: 40.0)
let imageSize: CGSize = representation.dimensions.cgSize
if resetImage {
if let file = media as? TelegramMediaFile {
switch component.icon {
case let .mediaFile(file, _):
iconImageNode.setSignal(chatWebpageSnippetFile(
account: component.context.account,
userLocation: .peer(component.messageId.peerId),
@ -471,19 +466,21 @@ private final class FileListItemComponent: Component {
representation: representation,
automaticFetch: false
))
} else if let image = media as? TelegramMediaImage {
case let .mediaImage(image, _):
iconImageNode.setSignal(mediaGridMessagePhoto(
account: component.context.account,
userLocation: .peer(component.messageId.peerId),
photoReference: ImageMediaReference.standalone(media: image),
automaticFetch: false
))
default:
break
}
}
let iconFrame = CGRect(origin: CGPoint(x: iconLeftInset + floor((leftInset - iconLeftInset - iconSize.width) / 2.0), y: floor((height - verticalInset * 2.0 - iconSize.height) / 2.0)), size: iconSize)
transition.setFrame(view: iconImageNode.view, frame: iconFrame)
let iconImageLayout = iconImageNode.asyncLayout()
let iconImageApply = iconImageLayout(TransformImageArguments(
corners: ImageCorners(radius: 8.0),
@ -982,14 +979,14 @@ final class StorageFileListPanelComponent: Component {
extensionIconValue = fileExtension
if let representation = smallestImageRepresentation(file.previewRepresentations) {
imageIconValue = .media(file, representation)
imageIconValue = .mediaFile(file, representation)
}
}
} else if let image = media as? TelegramMediaImage {
title = environment.strings.Message_Photo
if let representation = largestImageRepresentation(image.representations) {
imageIconValue = .media(image, representation)
imageIconValue = .mediaImage(image, representation)
}
}
}
@ -1062,7 +1059,7 @@ final class StorageFileListPanelComponent: Component {
component: AnyComponent(FileListItemComponent(
context: component.context,
theme: environment.theme,
messageId: EngineMessage.Id(peerId: PeerId(namespace: PeerId.Namespace._internalFromInt32Value(0), id: PeerId.Id._internalFromInt64Value(0)), namespace: 0, id: 0),
messageId: EngineMessage.Id(peerId: component.context.account.peerId, namespace: 0, id: 0),
title: "ABCDEF",
subtitle: "ABCDEF",
label: "1000",