mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-07-06 03:33:41 +02:00
Merge branch 'master' of gitlab.com:peter-iakovlev/telegram-ios
This commit is contained in:
commit
15f6811a5f
29 changed files with 1312 additions and 55 deletions
703
docs/superpowers/plans/2026-04-30-typing-draft-send-delay.md
Normal file
703
docs/superpowers/plans/2026-04-30-typing-draft-send-delay.md
Normal file
|
|
@ -0,0 +1,703 @@
|
|||
# Typing-Draft Send Delay Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Park outgoing messages in `PendingMessageManager` after their content is uploaded, releasing them only when the typing-draft for the matching `(peerId, threadId)` clears.
|
||||
|
||||
**Architecture:** Add a per-account Postbox view (`.allTypingDrafts`) that exposes the live `Set<PeerAndThreadId>` of currently-active typing drafts. Subscribe once at `PendingMessageManager.init`. Add a parked state (`.waitingForSendGate`) to `PendingMessageState` and a forward parking lot dictionary on the manager. Gate at three sites (single-send, album-send, forward-send); drain on view updates that remove keys.
|
||||
|
||||
**Tech Stack:** Swift, Bazel, Postbox view system, SwiftSignalKit.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-30-typing-draft-send-delay-design.md`
|
||||
|
||||
**Build verification command (used by every task that compiles code):**
|
||||
|
||||
```sh
|
||||
source ~/.zshrc 2>/dev/null; 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
|
||||
```
|
||||
|
||||
This codebase has **no unit tests** (per `CLAUDE.md`). Verification is "full build green" at each step plus manual exercise after final task.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Files created:**
|
||||
- `submodules/Postbox/Sources/AllTypingDraftsView.swift` — new Postbox view tracking the set of active typing-draft keys.
|
||||
|
||||
**Files modified:**
|
||||
- `submodules/Postbox/Sources/Views.swift` — register the new view key.
|
||||
- `submodules/TelegramCore/Sources/State/PendingMessageManager.swift` — gate insertion, drain, subscription, parked-state plumbing.
|
||||
|
||||
**No BUILD edits needed:** Postbox `BUILD` uses `glob(["Sources/**/*.swift"])`, so the new file is auto-picked up.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add `MutableAllTypingDraftsView` / `AllTypingDraftsView`
|
||||
|
||||
**Files:**
|
||||
- Create: `submodules/Postbox/Sources/AllTypingDraftsView.swift`
|
||||
|
||||
This task adds the Postbox view file. It is **not yet wired** into `PostboxViewKey` (Task 2), so this commit alone will not change behavior.
|
||||
|
||||
- [ ] **Step 1: Create `AllTypingDraftsView.swift`**
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
|
||||
final class MutableAllTypingDraftsView: MutablePostboxView {
|
||||
fileprivate var keys: Set<PeerAndThreadId>
|
||||
|
||||
init(postbox: PostboxImpl) {
|
||||
self.keys = Set(postbox.currentTypingDrafts.keys)
|
||||
}
|
||||
|
||||
func replay(postbox: PostboxImpl, transaction: PostboxTransaction) -> Bool {
|
||||
if transaction.updatedTypingDrafts.isEmpty {
|
||||
return false
|
||||
}
|
||||
var updated = false
|
||||
for (key, update) in transaction.updatedTypingDrafts {
|
||||
if update.value != nil {
|
||||
if self.keys.insert(key).inserted {
|
||||
updated = true
|
||||
}
|
||||
} else {
|
||||
if self.keys.remove(key) != nil {
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
func refreshDueToExternalTransaction(postbox: PostboxImpl) -> Bool {
|
||||
let new = Set(postbox.currentTypingDrafts.keys)
|
||||
if new == self.keys {
|
||||
return false
|
||||
}
|
||||
self.keys = new
|
||||
return true
|
||||
}
|
||||
|
||||
func immutableView() -> PostboxView {
|
||||
return AllTypingDraftsView(self)
|
||||
}
|
||||
}
|
||||
|
||||
public final class AllTypingDraftsView: PostboxView {
|
||||
public let keys: Set<PeerAndThreadId>
|
||||
|
||||
init(_ view: MutableAllTypingDraftsView) {
|
||||
self.keys = view.keys
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the build (file is unwired, so it must compile standalone)**
|
||||
|
||||
Run the build command from the header. Expected: **success**. Common failure modes: `currentTypingDrafts` access scope (already `fileprivate(set)`, accessible from same-module file); `transaction.updatedTypingDrafts` shape (already `[PeerAndThreadId: PostboxImpl.TypingDraftUpdate]` per `PostboxTransaction.swift:59`).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/Postbox/Sources/AllTypingDraftsView.swift
|
||||
git commit -m "Postbox: add AllTypingDraftsView tracking Set<PeerAndThreadId> of active typing drafts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Register `.allTypingDrafts` in `PostboxViewKey`
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/Postbox/Sources/Views.swift`
|
||||
|
||||
- [ ] **Step 1: Add the case to the enum**
|
||||
|
||||
In `submodules/Postbox/Sources/Views.swift` line 105, append `.allTypingDrafts` after `.typingDrafts(...)`:
|
||||
|
||||
```swift
|
||||
case typingDrafts(PeerAndThreadId)
|
||||
case allTypingDrafts
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add hash arm**
|
||||
|
||||
In the `hash(into:)` switch (the new arm goes alongside the existing `case let .typingDrafts(peerId):` arm at line 232–233):
|
||||
|
||||
```swift
|
||||
case let .typingDrafts(peerId):
|
||||
hasher.combine(peerId)
|
||||
case .allTypingDrafts:
|
||||
hasher.combine(22)
|
||||
```
|
||||
|
||||
The constant `22` is the next free hash-tag (existing tags use 0–21).
|
||||
|
||||
- [ ] **Step 3: Add `==` arm**
|
||||
|
||||
In the `==(lhs:rhs:)` switch (line 551–556 area):
|
||||
|
||||
```swift
|
||||
case let .typingDrafts(peerId):
|
||||
if case .typingDrafts(peerId) = rhs {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
case .allTypingDrafts:
|
||||
if case .allTypingDrafts = rhs {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Wire `postboxViewForKey`**
|
||||
|
||||
In the `postboxViewForKey(postbox:key:)` switch (line 684–685 area):
|
||||
|
||||
```swift
|
||||
case let .typingDrafts(peerId):
|
||||
return MutableTypingDraftsView(postbox: postbox, peerAndThreadId: peerId)
|
||||
case .allTypingDrafts:
|
||||
return MutableAllTypingDraftsView(postbox: postbox)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify the build**
|
||||
|
||||
Run the build command. Expected: **success**. The view is now constructible but no consumer subscribes yet.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/Postbox/Sources/Views.swift
|
||||
git commit -m "Postbox: wire .allTypingDrafts view key into PostboxViewKey"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Add `.waitingForSendGate` state and update related switches
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramCore/Sources/State/PendingMessageManager.swift`
|
||||
|
||||
This task introduces the new `PendingMessageState` case and extends every switch that needs to know about it. No gate insertion happens yet — the new case is unreachable, so behavior is unchanged.
|
||||
|
||||
- [ ] **Step 1: Add the case to `PendingMessageState`**
|
||||
|
||||
Edit `private enum PendingMessageState` (line 28). After the `.waitingToBeSent` case, add:
|
||||
|
||||
```swift
|
||||
case waitingToBeSent(groupId: Int64?, content: PendingMessageUploadedContentAndReuploadInfo)
|
||||
case waitingForSendGate(groupId: Int64?, content: PendingMessageUploadedContentAndReuploadInfo)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Extend the `groupId` computed property**
|
||||
|
||||
In the same `var groupId: Int64?` switch (line 37–54), add an arm right after the `.waitingToBeSent` arm:
|
||||
|
||||
```swift
|
||||
case let .waitingToBeSent(groupId, _):
|
||||
return groupId
|
||||
case let .waitingForSendGate(groupId, _):
|
||||
return groupId
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Extend `dataForPendingMessageGroup` to accept parked members as ready**
|
||||
|
||||
Find `private func dataForPendingMessageGroup(_ groupId: Int64)` (line 753). After the `.waitingToBeSent` arm, add a parallel arm:
|
||||
|
||||
```swift
|
||||
case let .waitingToBeSent(contextGroupId, content):
|
||||
if contextGroupId == groupId {
|
||||
result.append((context, id, content))
|
||||
}
|
||||
case let .waitingForSendGate(contextGroupId, content):
|
||||
if contextGroupId == groupId {
|
||||
result.append((context, id, content))
|
||||
}
|
||||
```
|
||||
|
||||
This lets a partially-parked album drain correctly once the gate opens.
|
||||
|
||||
- [ ] **Step 4: Exclude parked state from `updatePendingMediaUploads`**
|
||||
|
||||
Find `private func updatePendingMediaUploads()` (line 262). Inside its `switch context.state { ... }`, the existing arms cover `.waitingForUploadToStart` and `.uploading`. Parked state is post-upload and should NOT report progress. The switch already has a `default: break` — `.waitingForSendGate` falls through it automatically. **No edit required**, but verify by re-reading the function after the previous edits compile.
|
||||
|
||||
- [ ] **Step 5: Verify the build**
|
||||
|
||||
Run the build command. Expected: **success**. Swift will reject the build if any `switch context.state { ... }` becomes non-exhaustive — fix any such switches by adding a `case .waitingForSendGate: ...` arm matching the closest existing parallel state. Likely candidates:
|
||||
|
||||
- `private enum PendingMessageState`'s `groupId` switch — handled in Step 2.
|
||||
- `dataForPendingMessageGroup` switch — handled in Step 3.
|
||||
- The forward branch in `beginSendingMessages` (line ~614, doesn't switch directly).
|
||||
- Any other `switch <context>.state` blocks (search with `grep -n "switch.*\.state" submodules/TelegramCore/Sources/State/PendingMessageManager.swift`).
|
||||
|
||||
If the compiler reports a non-exhaustive switch elsewhere, add an arm that mirrors the `.waitingToBeSent` arm in that switch, or `case .waitingForSendGate: break` if the new state is irrelevant to that site.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramCore/Sources/State/PendingMessageManager.swift
|
||||
git commit -m "PendingMessageManager: add .waitingForSendGate state and update related switches"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Add manager-level subscription and parked-state fields
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramCore/Sources/State/PendingMessageManager.swift`
|
||||
|
||||
Adds the dictionary, the disposable, the subscription in `init`, the disposal in `deinit`, and a stub `handleLiveTypingDraftsUpdate` that only stores the set (drain is wired in Task 5).
|
||||
|
||||
- [ ] **Step 1: Add stored fields**
|
||||
|
||||
In `public final class PendingMessageManager` (line 205), after the existing `private var pendingMessageIds = Set<MessageId>()` (line 232), add:
|
||||
|
||||
```swift
|
||||
private var pendingMessageIds = Set<MessageId>()
|
||||
private var liveTypingDraftKeys: Set<PeerAndThreadId> = []
|
||||
private let allTypingDraftsDisposable = MetaDisposable()
|
||||
private var forwardSendGateGroups: [PeerAndThreadId: [[(PendingMessageContext, Message, ForwardSourceInfoAttribute)]]] = [:]
|
||||
private let beginSendingMessagesDisposables = DisposableSet()
|
||||
```
|
||||
|
||||
(The first and last lines are already present; the three new lines insert between them.)
|
||||
|
||||
- [ ] **Step 2: Subscribe in `init`**
|
||||
|
||||
In `init(network:postbox:accountPeerId:auxiliaryMethods:stateManager:localInputActivityManager:messageMediaPreuploadManager:revalidationContext:)` (line 243), after the field assignments (line 252), add:
|
||||
|
||||
```swift
|
||||
self.revalidationContext = revalidationContext
|
||||
|
||||
let queue = self.queue
|
||||
self.allTypingDraftsDisposable.set(
|
||||
(postbox.combinedView(keys: [.allTypingDrafts])
|
||||
|> deliverOn(queue)).start(next: { [weak self] view in
|
||||
self?.handleLiveTypingDraftsUpdate(view)
|
||||
})
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Dispose in `deinit`**
|
||||
|
||||
In `deinit` (line 255–260), append:
|
||||
|
||||
```swift
|
||||
deinit {
|
||||
self.beginSendingMessagesDisposables.dispose()
|
||||
for (_, disposable) in self.newTopicDisposables {
|
||||
disposable.dispose()
|
||||
}
|
||||
self.allTypingDraftsDisposable.dispose()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the handler (no drain yet)**
|
||||
|
||||
Add this new private method anywhere in the class (e.g. just before `private func updatePendingMediaUploads()` at line 262):
|
||||
|
||||
```swift
|
||||
private func handleLiveTypingDraftsUpdate(_ combined: CombinedView) {
|
||||
assert(self.queue.isCurrent())
|
||||
|
||||
let new: Set<PeerAndThreadId>
|
||||
if let view = combined.views[.allTypingDrafts] as? AllTypingDraftsView {
|
||||
new = view.keys
|
||||
} else {
|
||||
new = []
|
||||
}
|
||||
self.liveTypingDraftKeys = new
|
||||
}
|
||||
```
|
||||
|
||||
This handler is functional — it tracks the live key set correctly. Task 5 augments it to also fire `drainSendGate` for keys that just cleared.
|
||||
|
||||
- [ ] **Step 5: Verify the build**
|
||||
|
||||
Run the build command. Expected: **success**. `CombinedView` is in scope because `Postbox` is already imported at the top of the file. `MetaDisposable` and `Queue` come from `SwiftSignalKit` (already imported).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramCore/Sources/State/PendingMessageManager.swift
|
||||
git commit -m "PendingMessageManager: subscribe to .allTypingDrafts and add parking-lot fields"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Add gate predicates, drain, and wire the single-message gate
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramCore/Sources/State/PendingMessageManager.swift`
|
||||
|
||||
Combined task: introduces the predicates, the `drainSendGate` body, augments `handleLiveTypingDraftsUpdate` to call drain, and wires the **first** insertion site (single-message). After this task, single non-grouped messages park correctly when the peer is live-typing and unpark when the draft clears.
|
||||
|
||||
- [ ] **Step 1: Add `shouldGateSend` and `isSendGateOpen` private helpers**
|
||||
|
||||
Anywhere in the `PendingMessageManager` class (group them next to `handleLiveTypingDraftsUpdate` from Task 4):
|
||||
|
||||
```swift
|
||||
private func shouldGateSend(messageId: MessageId, threadId: Int64?) -> Bool {
|
||||
if messageId.namespace == Namespaces.Message.ScheduledCloud {
|
||||
return false
|
||||
}
|
||||
if messageId.peerId.namespace == Namespaces.Peer.SecretChat {
|
||||
return false
|
||||
}
|
||||
if messageId.peerId == self.accountPeerId {
|
||||
return false
|
||||
}
|
||||
if threadId == Message.newTopicThreadId {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func isSendGateOpen(for key: PeerAndThreadId) -> Bool {
|
||||
return !self.liveTypingDraftKeys.contains(key)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Augment `handleLiveTypingDraftsUpdate` to call drain**
|
||||
|
||||
Replace the body of `handleLiveTypingDraftsUpdate(_:)` (added in Task 4) with the cleared-key drain logic:
|
||||
|
||||
```swift
|
||||
private func handleLiveTypingDraftsUpdate(_ combined: CombinedView) {
|
||||
assert(self.queue.isCurrent())
|
||||
|
||||
let new: Set<PeerAndThreadId>
|
||||
if let view = combined.views[.allTypingDrafts] as? AllTypingDraftsView {
|
||||
new = view.keys
|
||||
} else {
|
||||
new = []
|
||||
}
|
||||
let cleared = self.liveTypingDraftKeys.subtracting(new)
|
||||
self.liveTypingDraftKeys = new
|
||||
for key in cleared {
|
||||
self.drainSendGate(key: key)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the `drainSendGate` body**
|
||||
|
||||
Add this new private method next to `handleLiveTypingDraftsUpdate`:
|
||||
|
||||
```swift
|
||||
private func drainSendGate(key: PeerAndThreadId) {
|
||||
assert(self.queue.isCurrent())
|
||||
|
||||
// (1) Single-message drain: snapshot then commit in messageId.id order.
|
||||
var singleDrains: [(context: PendingMessageContext, messageId: MessageId, content: PendingMessageUploadedContentAndReuploadInfo)] = []
|
||||
for (id, context) in self.messageContexts {
|
||||
if id.peerId != key.peerId {
|
||||
continue
|
||||
}
|
||||
if context.threadId != key.threadId {
|
||||
continue
|
||||
}
|
||||
if case let .waitingForSendGate(groupId, content) = context.state, groupId == nil {
|
||||
singleDrains.append((context, id, content))
|
||||
}
|
||||
}
|
||||
singleDrains.sort(by: { $0.messageId.id < $1.messageId.id })
|
||||
for entry in singleDrains {
|
||||
self.commitSendingSingleMessage(messageContext: entry.context, messageId: entry.messageId, content: entry.content)
|
||||
}
|
||||
|
||||
// (2) Grouped-album drain: collect distinct groupIds whose members match the key,
|
||||
// iterate ascending by min messageId.id, fire commitSendingMessageGroup.
|
||||
var groupKeys: [(groupId: Int64, minMessageId: Int32)] = []
|
||||
var seenGroupIds = Set<Int64>()
|
||||
for (id, context) in self.messageContexts {
|
||||
if id.peerId != key.peerId {
|
||||
continue
|
||||
}
|
||||
if context.threadId != key.threadId {
|
||||
continue
|
||||
}
|
||||
if case let .waitingForSendGate(groupId, _) = context.state, let groupId = groupId {
|
||||
if !seenGroupIds.contains(groupId) {
|
||||
seenGroupIds.insert(groupId)
|
||||
groupKeys.append((groupId, id.id))
|
||||
} else {
|
||||
if let index = groupKeys.firstIndex(where: { $0.groupId == groupId }), id.id < groupKeys[index].minMessageId {
|
||||
groupKeys[index].minMessageId = id.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
groupKeys.sort(by: { $0.minMessageId < $1.minMessageId })
|
||||
for (groupId, _) in groupKeys {
|
||||
if let data = self.dataForPendingMessageGroup(groupId) {
|
||||
self.commitSendingMessageGroup(groupId: groupId, messages: data)
|
||||
}
|
||||
}
|
||||
|
||||
// (3) Forward drain: pop parked groups for this key in FIFO order; fire each.
|
||||
if let parkedGroups = self.forwardSendGateGroups.removeValue(forKey: key) {
|
||||
for messages in parkedGroups {
|
||||
for (context, _, _) in messages {
|
||||
context.state = .sending(groupId: nil)
|
||||
}
|
||||
let sendMessage: Signal<PendingMessageResult, NoError> = self.sendGroupMessagesContent(network: self.network, postbox: self.postbox, stateManager: self.stateManager, accountPeerId: self.accountPeerId, group: messages.map { data in
|
||||
let (_, message, forwardInfo) = data
|
||||
return (message.id, PendingMessageUploadedContentAndReuploadInfo(content: .forward(forwardInfo), reuploadInfo: nil, cacheReferenceKey: nil))
|
||||
})
|
||||
|> map { _ -> PendingMessageResult in
|
||||
return .progress(1.0)
|
||||
}
|
||||
messages[0].0.sendDisposable.set((sendMessage
|
||||
|> deliverOn(self.queue)).start())
|
||||
}
|
||||
}
|
||||
|
||||
self.updateWaitingUploads(peerId: key.peerId)
|
||||
self.updatePendingMediaUploads()
|
||||
}
|
||||
```
|
||||
|
||||
The forward dispatch block mirrors the existing forward-fire code at lines 719–731 of `PendingMessageManager.swift`. Keep them in sync if either changes.
|
||||
|
||||
- [ ] **Step 4: Wire the single-message gate at `beginSendingMessage`**
|
||||
|
||||
Replace `private func beginSendingMessage(messageContext:messageId:groupId:content:)` (line 738) with:
|
||||
|
||||
```swift
|
||||
private func beginSendingMessage(messageContext: PendingMessageContext, messageId: MessageId, groupId: Int64?, content: PendingMessageUploadedContentAndReuploadInfo) {
|
||||
if let groupId = groupId {
|
||||
messageContext.state = .waitingToBeSent(groupId: groupId, content: content)
|
||||
} else {
|
||||
let key = PeerAndThreadId(peerId: messageId.peerId, threadId: messageContext.threadId)
|
||||
if self.shouldGateSend(messageId: messageId, threadId: messageContext.threadId) && !self.isSendGateOpen(for: key) {
|
||||
messageContext.state = .waitingForSendGate(groupId: nil, content: content)
|
||||
} else {
|
||||
self.commitSendingSingleMessage(messageContext: messageContext, messageId: messageId, content: content)
|
||||
}
|
||||
}
|
||||
self.updatePendingMediaUploads()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify the build**
|
||||
|
||||
Run the build command. Expected: **success**.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramCore/Sources/State/PendingMessageManager.swift
|
||||
git commit -m "PendingMessageManager: wire typing-draft gate at single-message send + drain"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Wire the album-send gate at `commitSendingMessageGroup`
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramCore/Sources/State/PendingMessageManager.swift`
|
||||
|
||||
- [ ] **Step 1: Replace `commitSendingMessageGroup`**
|
||||
|
||||
Replace `private func commitSendingMessageGroup(groupId:messages:)` (line 794) with:
|
||||
|
||||
```swift
|
||||
private func commitSendingMessageGroup(groupId: Int64, messages: [(messageContext: PendingMessageContext, messageId: MessageId, content: PendingMessageUploadedContentAndReuploadInfo)]) {
|
||||
let firstMessageId = messages[0].messageId
|
||||
let firstThreadId = messages[0].messageContext.threadId
|
||||
let key = PeerAndThreadId(peerId: firstMessageId.peerId, threadId: firstThreadId)
|
||||
if self.shouldGateSend(messageId: firstMessageId, threadId: firstThreadId) && !self.isSendGateOpen(for: key) {
|
||||
for entry in messages {
|
||||
entry.messageContext.state = .waitingForSendGate(groupId: groupId, content: entry.content)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for (context, _, _) in messages {
|
||||
context.state = .sending(groupId: groupId)
|
||||
}
|
||||
let sendMessage: Signal<PendingMessageResult, NoError> = self.sendGroupMessagesContent(network: self.network, postbox: self.postbox, stateManager: self.stateManager, accountPeerId: self.accountPeerId, group: messages.map { ($0.1, $0.2) })
|
||||
|> map { next -> PendingMessageResult in
|
||||
return .progress(1.0)
|
||||
}
|
||||
messages[0].0.sendDisposable.set((sendMessage
|
||||
|> deliverOn(self.queue)).start())
|
||||
}
|
||||
```
|
||||
|
||||
Album members share `(peerId, threadId)` by construction (the album fires once every member is post-upload in the same `groupId`).
|
||||
|
||||
- [ ] **Step 2: Verify the build**
|
||||
|
||||
Run the build command. Expected: **success**.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramCore/Sources/State/PendingMessageManager.swift
|
||||
git commit -m "PendingMessageManager: wire typing-draft gate at album send"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Wire the forward-send gate inside `beginSendingMessages`
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramCore/Sources/State/PendingMessageManager.swift`
|
||||
|
||||
- [ ] **Step 1: Replace the forward-dispatch loop**
|
||||
|
||||
The existing forward-dispatch loop is at lines 714–733 inside `beginSendingMessages`. Replace exactly the body of `for messages in countedMessageGroups { ... }` with the gate-aware version:
|
||||
|
||||
```swift
|
||||
for messages in countedMessageGroups {
|
||||
if messages.isEmpty {
|
||||
continue
|
||||
}
|
||||
|
||||
let firstMessage = messages[0].1
|
||||
let key = PeerAndThreadId(peerId: firstMessage.id.peerId, threadId: firstMessage.threadId)
|
||||
if strongSelf.shouldGateSend(messageId: firstMessage.id, threadId: firstMessage.threadId) && !strongSelf.isSendGateOpen(for: key) {
|
||||
for (context, _, forwardInfo) in messages {
|
||||
context.state = .waitingForSendGate(groupId: nil, content: PendingMessageUploadedContentAndReuploadInfo(content: .forward(forwardInfo), reuploadInfo: nil, cacheReferenceKey: nil))
|
||||
}
|
||||
if strongSelf.forwardSendGateGroups[key] == nil {
|
||||
strongSelf.forwardSendGateGroups[key] = []
|
||||
}
|
||||
strongSelf.forwardSendGateGroups[key]!.append(messages)
|
||||
continue
|
||||
}
|
||||
|
||||
for (context, _, _) in messages {
|
||||
context.state = .sending(groupId: nil)
|
||||
}
|
||||
|
||||
let sendMessage: Signal<PendingMessageResult, NoError> = strongSelf.sendGroupMessagesContent(network: strongSelf.network, postbox: strongSelf.postbox, stateManager: strongSelf.stateManager, accountPeerId: strongSelf.accountPeerId, group: messages.map { data in
|
||||
let (_, message, forwardInfo) = data
|
||||
return (message.id, PendingMessageUploadedContentAndReuploadInfo(content: .forward(forwardInfo), reuploadInfo: nil, cacheReferenceKey: nil))
|
||||
})
|
||||
|> map { next -> PendingMessageResult in
|
||||
return .progress(1.0)
|
||||
}
|
||||
messages[0].0.sendDisposable.set((sendMessage
|
||||
|> deliverOn(strongSelf.queue)).start())
|
||||
}
|
||||
```
|
||||
|
||||
The non-gated branch is the existing code, copied verbatim. The gated branch parks every context in `.waitingForSendGate` and appends the whole tuple-array onto `forwardSendGateGroups[key]`. The drain in `Task 5` reads from this dict.
|
||||
|
||||
- [ ] **Step 2: Verify the build**
|
||||
|
||||
Run the build command. Expected: **success**.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramCore/Sources/State/PendingMessageManager.swift
|
||||
git commit -m "PendingMessageManager: wire typing-draft gate at forward send"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Clean up parked forwards in `updatePendingMessageIds` removal loop
|
||||
|
||||
**Files:**
|
||||
- Modify: `submodules/TelegramCore/Sources/State/PendingMessageManager.swift`
|
||||
|
||||
When a message is removed from `pendingMessageIds` (e.g. user discarded it, or it was force-deleted), the existing loop disposes context-level state. Parked single/album state lives on the `PendingMessageContext` and gets reset when `state = .none`. Parked forwards live in `forwardSendGateGroups`, which the existing loop does not touch — patch that here.
|
||||
|
||||
- [ ] **Step 1: Add cleanup for `forwardSendGateGroups` in `updatePendingMessageIds`**
|
||||
|
||||
In `updatePendingMessageIds(_:)` (line 284), after the `for id in removedMessageIds { ... }` loop (closes around line 323), add a forward-cleanup pass before `if !addedMessageIds.isEmpty { ... }`:
|
||||
|
||||
```swift
|
||||
if !removedMessageIds.isEmpty && !self.forwardSendGateGroups.isEmpty {
|
||||
for (key, parkedGroups) in self.forwardSendGateGroups {
|
||||
var rebuilt: [[(PendingMessageContext, Message, ForwardSourceInfoAttribute)]] = []
|
||||
for group in parkedGroups {
|
||||
let filtered = group.filter { entry in
|
||||
return !removedMessageIds.contains(entry.1.id)
|
||||
}
|
||||
if !filtered.isEmpty {
|
||||
rebuilt.append(filtered)
|
||||
}
|
||||
}
|
||||
if rebuilt.isEmpty {
|
||||
self.forwardSendGateGroups.removeValue(forKey: key)
|
||||
} else {
|
||||
self.forwardSendGateGroups[key] = rebuilt
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the build**
|
||||
|
||||
Run the build command. Expected: **success**.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add submodules/TelegramCore/Sources/State/PendingMessageManager.swift
|
||||
git commit -m "PendingMessageManager: cleanup parked forwards on pending-message removal"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Manual verification
|
||||
|
||||
**Files:** None (verification-only).
|
||||
|
||||
This codebase has no unit tests, so the final acceptance gate is a manual exercise on a debug simulator build. Skip if you don't have a working two-device test setup; in that case, re-confirm only the build is green.
|
||||
|
||||
- [ ] **Step 1: Confirm full build is still green**
|
||||
|
||||
Run the build command from the header. Expected: **success**.
|
||||
|
||||
- [ ] **Step 2: 1:1 chat, text-send delay**
|
||||
|
||||
Setup: log in to two real Telegram accounts on two devices/simulators (account A and account B). Open the A↔B 1:1 chat on both.
|
||||
|
||||
- On B, begin live-typing a draft (Telegram clients with the live-typing-drafts feature emit the typing-draft updates; if B doesn't expose live drafts, simulate by triggering whatever flow populates `transaction.combineTypingDrafts(...)` in your test environment).
|
||||
- On A, immediately type and send a text message. Confirm: the message appears with a "sending" indicator and does **not** complete until B's draft clears or commits. Once B's draft is cleared, A's message sends within ~1 second.
|
||||
|
||||
- [ ] **Step 3: Album / grouped-media delay**
|
||||
|
||||
Repeat Step 2 with an album of two photos sent from A while B is live-typing. Expected: all album members upload (you can see progress finish), then all sit parked at "sending" until the gate opens.
|
||||
|
||||
- [ ] **Step 4: Forward delay**
|
||||
|
||||
Repeat with a forwarded message (forward a message from a third chat into A↔B while B is live-typing). Expected: forward parks until the gate opens.
|
||||
|
||||
- [ ] **Step 5: Negative — Saved Messages skip**
|
||||
|
||||
In Saved Messages (chat with self), send any message. Confirm: never delays, regardless of typing-draft state. There should be no typing draft for the self peer in the first place — this is just an existence check that the skip-rule does its job.
|
||||
|
||||
- [ ] **Step 6: Negative — secret chat skip**
|
||||
|
||||
In a secret chat, send a message. Confirm: never delays. Server-side, secret chats don't emit typing-draft updates — this verifies the explicit skip-check.
|
||||
|
||||
- [ ] **Step 7: Negative — scheduled message skip (defensive)**
|
||||
|
||||
The `Namespaces.Message.ScheduledCloud` skip in `shouldGateSend` is defensive — in practice, scheduled messages are stored in the scheduled queue and only enter `PendingMessageManager` at delivery time, by which point they've been re-created with cloud namespace. Verifying the defensive branch directly is awkward and not strictly required. If you have an instrumentation path that forces a scheduled-namespace message through `beginSendingMessages`, confirm it doesn't park.
|
||||
|
||||
- [ ] **Step 8: Multi-thread — gate is per-thread**
|
||||
|
||||
In a forum/topic group, on B begin live-typing in topic 1 only. On A, send a message to topic 2 of the same group. Expected: A's message to topic 2 is **not** delayed.
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes (already applied inline)
|
||||
|
||||
- **Spec coverage:** every section of `2026-04-30-typing-draft-send-delay-design.md` maps to a task — Postbox view (Tasks 1–2), state machine extension (Task 3), subscription (Task 4), predicates + drain + single-send (Task 5), album (Task 6), forwards (Task 7), removal cleanup (Task 8), manual verification (Task 9).
|
||||
- **Type names verified against actual code:** `PendingMessageState`, `PendingMessageContext`, `PendingMessageUploadedContentAndReuploadInfo`, `ForwardSourceInfoAttribute`, `PendingMessageResult`, `Signal`, `MetaDisposable`, `Queue`, `CombinedView`, `Namespaces.Message.ScheduledCloud`, `Namespaces.Peer.SecretChat`, `Message.newTopicThreadId`, `PeerAndThreadId`, `combineTypingDrafts`, `currentTypingDrafts`, `transaction.updatedTypingDrafts`, `update.value`. All names match the source.
|
||||
- **`postponeSending` (paid-message) interaction:** untouched. Composes via state-machine ordering (paid postpone gates upload-start; this gate sits post-upload).
|
||||
- **No unused-private-function warnings:** every helper is introduced together with its first caller (predicates + drain + first call site combined in Task 5).
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
# Typing-Draft Send Delay — Design
|
||||
|
||||
**Date:** 2026-04-30
|
||||
**Component:** `submodules/TelegramCore/Sources/State/PendingMessageManager.swift` (+ minimal Postbox additions)
|
||||
|
||||
## Goal
|
||||
|
||||
Delay outgoing messages while the peer in the same `(peerId, threadId)` is "live-typing" an incoming message (i.e. `Postbox.combinedView(keys: [.typingDrafts(...)])` reports a non-nil draft for that key). Messages park after their content is fully uploaded, then drain in `messageId.id` order once the typing-draft for that key clears.
|
||||
|
||||
## Behavior summary
|
||||
|
||||
- **Scope.** All "deliver-now" outgoing message types: regular text/media single sends, grouped media albums, and forwards. Excluded: scheduled messages, secret-chat messages, and Saved Messages (account-self peer).
|
||||
- **Pipeline.** Uploads run in parallel as they do today. The gate sits between "upload complete" and the actual MTProto send call.
|
||||
- **Release.** As soon as the typing-draft for the message's `(peerId, threadId)` clears (the view's set no longer contains that key) — no extra grace delay, no upper-bound timeout.
|
||||
- **Keying.** Strictly per-thread. `threadId == nil` is the normal value for non-threaded chats and gates with `PeerAndThreadId(peerId: ..., threadId: nil)`. The `Message.newTopicThreadId` sentinel does not gate (already handled by `.waitingForNewTopic`).
|
||||
- **Always-on.** No preference toggle.
|
||||
- **Composes with paid-message postpone.** Paid postpone gates upload-start; typing-draft gate gates post-upload send. Both must be clear before send.
|
||||
|
||||
## Architecture
|
||||
|
||||
All logic lives in `PendingMessageManager`. Postbox gains one new public view; no other Postbox API changes.
|
||||
|
||||
### Postbox additions
|
||||
|
||||
1. New file `submodules/Postbox/Sources/AllTypingDraftsView.swift`:
|
||||
- `MutableAllTypingDraftsView: MutablePostboxView`
|
||||
- `init(postbox:)` seeds `keys` from `postbox.currentTypingDrafts.keys`.
|
||||
- `replay(postbox:transaction:)` diffs against `transaction.updatedTypingDrafts`: insert key when `update.value != nil`, remove when nil. Returns `true` if the set changed.
|
||||
- `refreshDueToExternalTransaction(postbox:)` reloads from `postbox.currentTypingDrafts.keys` and returns `true`.
|
||||
- `immutableView()` returns an `AllTypingDraftsView`.
|
||||
- `public final class AllTypingDraftsView: PostboxView` exposes `public let keys: Set<PeerAndThreadId>`.
|
||||
2. `submodules/Postbox/Sources/Views.swift`:
|
||||
- Add `case allTypingDrafts` to `PostboxViewKey` (no associated payload).
|
||||
- Wire constant `Hashable` combine and `==` matching for the new case.
|
||||
- Add the `case .allTypingDrafts` arm to `postboxViewForKey` returning `MutableAllTypingDraftsView(postbox:)`.
|
||||
3. `PostboxImpl.currentTypingDrafts` is `fileprivate(set)`, accessible from view files in the same module. No new accessor needed.
|
||||
|
||||
### PendingMessageManager additions
|
||||
|
||||
New `PendingMessageState` case:
|
||||
|
||||
```swift
|
||||
case waitingForSendGate(groupId: Int64?, content: PendingMessageUploadedContentAndReuploadInfo)
|
||||
```
|
||||
|
||||
Added to `PendingMessageState.groupId`'s switch. Excluded from `updatePendingMediaUploads`'s upload-progress aggregation.
|
||||
|
||||
New stored state on the manager:
|
||||
|
||||
```swift
|
||||
private var liveTypingDraftKeys: Set<PeerAndThreadId> = []
|
||||
private let allTypingDraftsDisposable = MetaDisposable()
|
||||
private var forwardSendGateGroups: [PeerAndThreadId: [[(PendingMessageContext, Message, ForwardSourceInfoAttribute)]]] = [:]
|
||||
```
|
||||
|
||||
In `init`, subscribe once:
|
||||
|
||||
```swift
|
||||
self.allTypingDraftsDisposable.set(
|
||||
(postbox.combinedView(keys: [.allTypingDrafts])
|
||||
|> deliverOn(self.queue)).start(next: { [weak self] view in
|
||||
self?.handleLiveTypingDraftsUpdate(view)
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
Dispose in `deinit`.
|
||||
|
||||
## Gate predicate
|
||||
|
||||
```swift
|
||||
private func isSendGateOpen(for key: PeerAndThreadId) -> Bool {
|
||||
return !self.liveTypingDraftKeys.contains(key)
|
||||
}
|
||||
|
||||
private func shouldGateSend(messageId: MessageId, threadId: Int64?) -> Bool {
|
||||
if messageId.namespace == Namespaces.Message.ScheduledCloud { return false }
|
||||
if messageId.peerId.namespace == Namespaces.Peer.SecretChat { return false }
|
||||
if messageId.peerId == self.accountPeerId { return false }
|
||||
if threadId == Message.newTopicThreadId { return false }
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
A pending context is gate-applicable if `shouldGateSend(...)` returns true. The gate is open if `isSendGateOpen(...)` returns true. Sites delay the send only when `shouldGateSend && !isSendGateOpen`.
|
||||
|
||||
## Gate insertion points
|
||||
|
||||
### (a) Single-message — `beginSendingMessage(messageContext:messageId:groupId:content:)`
|
||||
|
||||
Today: `groupId == nil → commitSendingSingleMessage`; otherwise `state = .waitingToBeSent(groupId: ..., content: ...)`.
|
||||
|
||||
New: when `groupId == nil`, additionally check the gate:
|
||||
|
||||
```swift
|
||||
let key = PeerAndThreadId(peerId: messageId.peerId, threadId: messageContext.threadId)
|
||||
if shouldGateSend(messageId: messageId, threadId: messageContext.threadId) && !isSendGateOpen(for: key) {
|
||||
messageContext.state = .waitingForSendGate(groupId: nil, content: content)
|
||||
} else {
|
||||
self.commitSendingSingleMessage(messageContext: messageContext, messageId: messageId, content: content)
|
||||
}
|
||||
```
|
||||
|
||||
The grouped path (`groupId != nil`) is unchanged here; gating for albums happens in (b).
|
||||
|
||||
### (b) Grouped-album — `commitSendingMessageGroup(groupId:messages:)`
|
||||
|
||||
Today: flips every group context to `.sending(groupId:)`, fires `sendGroupMessagesContent`.
|
||||
|
||||
New: derive a representative key from the first message's `(peerId, threadId)`. (Group members share both by construction.) If `shouldGateSend && !isSendGateOpen`, flip every group context to `.waitingForSendGate(groupId: groupId, content: <its own content>)` and return. Otherwise unchanged.
|
||||
|
||||
`dataForPendingMessageGroup(_ groupId:)` is updated to recognize `.waitingForSendGate(groupId: contextGroupId, ...)` the same way it recognizes `.waitingToBeSent` — i.e. a group becomes "ready" when every member is in `.waitingToBeSent` OR `.waitingForSendGate`. This prevents partial-park deadlocks.
|
||||
|
||||
### (c) Forwards — inside `beginSendingMessages`, lines 714–733
|
||||
|
||||
Today: builds `countedMessageGroups` and immediately fires `sendGroupMessagesContent` per group.
|
||||
|
||||
The pre-existing `messagesToForward` bucketing is by `PeerIdAndNamespace` only — not by `threadId`. The downstream `sendGroupMessagesContent` network call requires thread homogeneity (a forward dispatch targets a single destination thread), so in practice every group already shares `threadId`. The gate uses this assumption: derive the key from `messages[0].1.threadId` of each `countedMessageGroup`. If a future caller violates the assumption, the existing dispatch path is already broken.
|
||||
|
||||
New: per group, derive `key = PeerAndThreadId(peerId: messages[0].1.id.peerId, threadId: messages[0].1.threadId)`. If `shouldGateSend && !isSendGateOpen`, flip every context in the group to `.waitingForSendGate(groupId: nil, content: PendingMessageUploadedContentAndReuploadInfo(content: .forward(forwardInfo), reuploadInfo: nil, cacheReferenceKey: nil))` and append the entire `[(PendingMessageContext, Message, ForwardSourceInfoAttribute)]` group to `forwardSendGateGroups[key]`. Otherwise fire as today.
|
||||
|
||||
Forward groups within a key drain in FIFO order.
|
||||
|
||||
## Drain logic
|
||||
|
||||
`drainSendGate(key: PeerAndThreadId)` runs on `self.queue`. Idempotent.
|
||||
|
||||
1. **Single-message drain.** Snapshot `messageContexts` filtering on `state == .waitingForSendGate(groupId: nil, ...)` AND `PeerAndThreadId(peerId: contextId.peerId, threadId: context.threadId) == key`. Sort by `messageId.id` ascending. For each, extract the parked `content`, call `commitSendingSingleMessage(messageContext:messageId:content:)`.
|
||||
2. **Grouped-album drain.** Collect distinct `groupId`s among `.waitingForSendGate(groupId: <non-nil>, ...)` contexts whose key matches. Iterate in ascending min-`messageId.id`-in-group order. For each, call `dataForPendingMessageGroup(groupId)` (which now sees the parked members as ready) and pass the result to `commitSendingMessageGroup(groupId:messages:)`.
|
||||
3. **Forward drain.** Pop `forwardSendGateGroups.removeValue(forKey: key)`. For each parked group (FIFO): flip every context to `.sending(groupId: nil)`, build the `[(MessageId, PendingMessageUploadedContentAndReuploadInfo)]` array, fire `sendGroupMessagesContent` exactly mirroring the existing forward-fire code (lines 719–731).
|
||||
4. After (1)–(3), call `updateWaitingUploads(peerId: key.peerId)` and `updatePendingMediaUploads()` once.
|
||||
|
||||
`handleLiveTypingDraftsUpdate(_ view: CombinedView)`:
|
||||
|
||||
```swift
|
||||
let view = (view.views[.allTypingDrafts] as? AllTypingDraftsView)
|
||||
let new = view?.keys ?? []
|
||||
let cleared = self.liveTypingDraftKeys.subtracting(new)
|
||||
self.liveTypingDraftKeys = new
|
||||
for key in cleared {
|
||||
self.drainSendGate(key: key)
|
||||
}
|
||||
```
|
||||
|
||||
Single-emission semantics: a `(false → true)` transition (key newly populated) parks future arrivals only; in-flight `.sending` continues. A `(true → false)` transition fires drain.
|
||||
|
||||
## Side effects on existing helpers
|
||||
|
||||
- `PendingMessageState.groupId` switch (line 37): add `.waitingForSendGate` case returning the case's `groupId` (forward parking uses `groupId == nil`; grouped-album parking uses the real groupId).
|
||||
- `updatePendingMediaUploads()` (line 262): `.waitingForSendGate` is **not** treated as uploading. Excluded from the switch (or explicitly returns `default` early).
|
||||
- `dataForPendingMessageGroup(_ groupId:)` (line 753): add `.waitingForSendGate(contextGroupId, content)` arm — if `contextGroupId == groupId`, append `(context, id, content)` to result, same as the existing `.waitingToBeSent` arm.
|
||||
- `updatePendingMessageIds(_:)` (line 284): in the existing `for id in removedMessageIds` loop, additionally drop `forwardSendGateGroups[*]` entries whose contained context matches `id`. (Single/album parking is auto-cleaned because parked state lives on the context, which gets `state = .none`.) Implementation: walk the dict, filter out the removed context from each parked group, drop any group that empties out, drop any key whose value-array empties out.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **First-emit race.** `liveTypingDraftKeys` initializes to `[]`. If a send is attempted before the first view emit and a draft is actually active, that single message slips through. Tolerated.
|
||||
- **Saved Messages / secret chats / scheduled / new-topic sentinel.** All explicit skip-cases in `shouldGateSend`.
|
||||
- **Self-typing on another device.** A draft we authored on another device is treated like any other — our outgoing send to that chat parks until it clears. This is consistent with the design intent (drafts visibly commit before subsequent sends arrive). No author filter.
|
||||
- **Removed-while-parked.** Handled by `updatePendingMessageIds(_:)` extension above.
|
||||
- **Re-entrancy.** Drain helpers snapshot work-lists before iterating, so mid-iteration mutations to `messageContexts` (e.g. a fired send completes synchronously) don't corrupt the loop.
|
||||
- **Paid postpone composition.** Paid postpone gates upload-start; once paid commit fires, upload runs; once upload completes, the typing-draft gate parks at `.waitingForSendGate`; once the draft clears, send fires. Stacked sequentially without interaction.
|
||||
- **Subscription teardown.** `allTypingDraftsDisposable.dispose()` in `deinit`.
|
||||
|
||||
## Testing
|
||||
|
||||
This codebase has no unit tests. Verification is via full build + manual exercise:
|
||||
|
||||
- Build: `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` (prefixed with `source ~/.zshrc 2>/dev/null;`).
|
||||
- Manual: in a 1:1 chat with another device, induce a live-typing draft on the peer side and confirm an outgoing text send parks (chat shows "sending" status held until draft clears or commits). Repeat for: media single send, grouped media album, forward.
|
||||
- Negative manual: scheduled message — confirm not gated. Saved Messages — confirm not gated. Secret chat — confirm not gated.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Per-chat opt-in toggle.
|
||||
- Upper-bound timeout / fallback send.
|
||||
- Grace-delay after draft clears.
|
||||
- UI affordance ("waiting for X to finish typing…").
|
||||
- Filtering self-authored drafts.
|
||||
|
|
@ -176,7 +176,7 @@ public final class BrowserBookmarksScreen: ViewController {
|
|||
}, sendGift: { _ in
|
||||
}, openUniqueGift: { _ in
|
||||
}, openMessageFeeException: {
|
||||
}, requestMessageUpdate: { _, _ in
|
||||
}, requestMessageUpdate: { _, _, _ in
|
||||
}, cancelInteractiveKeyboardGestures: {
|
||||
}, dismissTextInput: {
|
||||
}, scrollToMessageId: { _ in
|
||||
|
|
|
|||
|
|
@ -1856,7 +1856,7 @@ open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestur
|
|||
})
|
||||
}
|
||||
|
||||
public func transaction(deleteIndices: [ListViewDeleteItem], insertIndicesAndItems: [ListViewInsertItem], updateIndicesAndItems: [ListViewUpdateItem], options: ListViewDeleteAndInsertOptions, scrollToItem: ListViewScrollToItem? = nil, additionalScrollDistance: CGFloat = 0.0, updateSizeAndInsets: ListViewUpdateSizeAndInsets? = nil, stationaryItemRange: (Int, Int)? = nil, updateOpaqueState: Any?, completion: @escaping (ListViewDisplayedItemRange) -> Void = { _ in }) {
|
||||
public func transaction(deleteIndices: [ListViewDeleteItem], insertIndicesAndItems: [ListViewInsertItem], updateIndicesAndItems: [ListViewUpdateItem], options: ListViewDeleteAndInsertOptions, scrollToItem: ListViewScrollToItem? = nil, additionalScrollDistance: CGFloat = 0.0, updateSizeAndInsets: ListViewUpdateSizeAndInsets? = nil, stationaryItemRange: (Int, Int)? = nil, customAnimationTransition: ControlledTransition? = nil, updateOpaqueState: Any?, completion: @escaping (ListViewDisplayedItemRange) -> Void = { _ in }) {
|
||||
if deleteIndices.isEmpty && insertIndicesAndItems.isEmpty && updateIndicesAndItems.isEmpty && scrollToItem == nil && updateSizeAndInsets == nil && additionalScrollDistance.isZero {
|
||||
if let updateOpaqueState = updateOpaqueState {
|
||||
self.opaqueTransactionState = updateOpaqueState
|
||||
|
|
@ -1868,7 +1868,7 @@ open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestur
|
|||
self.transactionQueue.addTransaction({ [weak self] transactionCompletion in
|
||||
if let strongSelf = self {
|
||||
strongSelf.transactionOffset = 0.0
|
||||
strongSelf.deleteAndInsertItemsTransaction(deleteIndices: deleteIndices, insertIndicesAndItems: insertIndicesAndItems, updateIndicesAndItems: updateIndicesAndItems, options: options, scrollToItem: scrollToItem, additionalScrollDistance: additionalScrollDistance, updateSizeAndInsets: updateSizeAndInsets, stationaryItemRange: stationaryItemRange, updateOpaqueState: updateOpaqueState, customAnimationTransition: updateSizeAndInsets?.customAnimationTransition, completion: { [weak strongSelf] in
|
||||
strongSelf.deleteAndInsertItemsTransaction(deleteIndices: deleteIndices, insertIndicesAndItems: insertIndicesAndItems, updateIndicesAndItems: updateIndicesAndItems, options: options, scrollToItem: scrollToItem, additionalScrollDistance: additionalScrollDistance, updateSizeAndInsets: updateSizeAndInsets, stationaryItemRange: stationaryItemRange, updateOpaqueState: updateOpaqueState, customAnimationTransition: customAnimationTransition ?? updateSizeAndInsets?.customAnimationTransition, completion: { [weak strongSelf] in
|
||||
completion(strongSelf?.immediateDisplayedItemRange() ?? ListViewDisplayedItemRange(loadedRange: nil, visibleRange: nil))
|
||||
|
||||
transactionCompletion()
|
||||
|
|
|
|||
49
submodules/Postbox/Sources/AllTypingDraftsView.swift
Normal file
49
submodules/Postbox/Sources/AllTypingDraftsView.swift
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import Foundation
|
||||
|
||||
final class MutableAllTypingDraftsView: MutablePostboxView {
|
||||
fileprivate var keys: Set<PeerAndThreadId>
|
||||
|
||||
init(postbox: PostboxImpl) {
|
||||
self.keys = Set(postbox.currentTypingDrafts.keys)
|
||||
}
|
||||
|
||||
func replay(postbox: PostboxImpl, transaction: PostboxTransaction) -> Bool {
|
||||
if transaction.updatedTypingDrafts.isEmpty {
|
||||
return false
|
||||
}
|
||||
var updated = false
|
||||
for (key, update) in transaction.updatedTypingDrafts {
|
||||
if update.value != nil {
|
||||
if self.keys.insert(key).inserted {
|
||||
updated = true
|
||||
}
|
||||
} else {
|
||||
if self.keys.remove(key) != nil {
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
func refreshDueToExternalTransaction(postbox: PostboxImpl) -> Bool {
|
||||
let new = Set(postbox.currentTypingDrafts.keys)
|
||||
if new == self.keys {
|
||||
return false
|
||||
}
|
||||
self.keys = new
|
||||
return true
|
||||
}
|
||||
|
||||
func immutableView() -> PostboxView {
|
||||
return AllTypingDraftsView(self)
|
||||
}
|
||||
}
|
||||
|
||||
public final class AllTypingDraftsView: PostboxView {
|
||||
public let keys: Set<PeerAndThreadId>
|
||||
|
||||
init(_ view: MutableAllTypingDraftsView) {
|
||||
self.keys = view.keys
|
||||
}
|
||||
}
|
||||
|
|
@ -1560,13 +1560,15 @@ public final class MessageHistoryView: PostboxView {
|
|||
}
|
||||
|
||||
if !self.holeLater, let typingDraft = mutableView.typingDraft {
|
||||
entries.append(MessageHistoryEntry(
|
||||
let newEntry = MessageHistoryEntry(
|
||||
message: typingDraft,
|
||||
isRead: false,
|
||||
location: nil,
|
||||
monthLocation: nil,
|
||||
attributes: MutableMessageHistoryEntryAttributes(authorIsContact: false)
|
||||
))
|
||||
)
|
||||
entries.append(newEntry)
|
||||
entries.sort()
|
||||
}
|
||||
|
||||
self.entries = entries
|
||||
|
|
|
|||
98
submodules/Postbox/Sources/TypingDraftsView.swift
Normal file
98
submodules/Postbox/Sources/TypingDraftsView.swift
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import Foundation
|
||||
|
||||
final class MutableTypingDraftsView: MutablePostboxView {
|
||||
fileprivate let peerAndThreadId: PeerAndThreadId
|
||||
fileprivate var typingDraft: Message?
|
||||
|
||||
init(postbox: PostboxImpl, peerAndThreadId: PeerAndThreadId) {
|
||||
self.peerAndThreadId = peerAndThreadId
|
||||
|
||||
self.reload(postbox: postbox)
|
||||
}
|
||||
|
||||
private func reload(postbox: PostboxImpl) {
|
||||
if let typingDraft = postbox.currentTypingDrafts[self.peerAndThreadId] {
|
||||
self.typingDraft = self.renderTypingDraft(postbox: postbox, typingDraft: typingDraft)
|
||||
} else {
|
||||
self.typingDraft = nil
|
||||
}
|
||||
}
|
||||
|
||||
func replay(postbox: PostboxImpl, transaction: PostboxTransaction) -> Bool {
|
||||
var updated = false
|
||||
|
||||
if let typingDraftUpdate = transaction.updatedTypingDrafts[self.peerAndThreadId] {
|
||||
if let typingDraft = typingDraftUpdate.value {
|
||||
self.typingDraft = self.renderTypingDraft(postbox: postbox, typingDraft: typingDraft)
|
||||
} else {
|
||||
self.typingDraft = nil
|
||||
}
|
||||
updated = true
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
private func renderTypingDraft(postbox: PostboxImpl, typingDraft: PostboxImpl.TypingDraft) -> Message? {
|
||||
guard let peer = postbox.peerTable.get(self.peerAndThreadId.peerId), let author = postbox.peerTable.get(typingDraft.authorId) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var peers = SimpleDictionary<PeerId, Peer>()
|
||||
peers[peer.id] = peer
|
||||
peers[author.id] = author
|
||||
|
||||
var associatedThreadInfo: Message.AssociatedThreadInfo?
|
||||
if let threadId = typingDraft.threadId, let data = postbox.messageHistoryThreadIndexTable.get(peerId: self.peerAndThreadId.peerId, threadId: threadId) {
|
||||
associatedThreadInfo = postbox.seedConfiguration.decodeMessageThreadInfo(data.data)
|
||||
}
|
||||
|
||||
return Message(
|
||||
stableId: typingDraft.stableId,
|
||||
stableVersion: typingDraft.stableVersion,
|
||||
id: MessageId(
|
||||
peerId: self.peerAndThreadId.peerId,
|
||||
namespace: 1,
|
||||
id: Int32.max - 50000),
|
||||
globallyUniqueId: nil,
|
||||
groupingKey: nil,
|
||||
groupInfo: nil,
|
||||
threadId: typingDraft.threadId,
|
||||
timestamp: typingDraft.timestamp,
|
||||
flags: [.Incoming],
|
||||
tags: [],
|
||||
globalTags: [],
|
||||
localTags: [],
|
||||
customTags: [],
|
||||
forwardInfo: nil,
|
||||
author: author,
|
||||
text: typingDraft.text,
|
||||
attributes: typingDraft.attributes,
|
||||
media: [],
|
||||
peers: peers,
|
||||
associatedMessages: SimpleDictionary(),
|
||||
associatedMessageIds: [],
|
||||
associatedMedia: [:],
|
||||
associatedThreadInfo: associatedThreadInfo,
|
||||
associatedStories: [:]
|
||||
)
|
||||
}
|
||||
|
||||
func refreshDueToExternalTransaction(postbox: PostboxImpl) -> Bool {
|
||||
self.reload(postbox: postbox)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func immutableView() -> PostboxView {
|
||||
return TypingDraftsView(self)
|
||||
}
|
||||
}
|
||||
|
||||
public final class TypingDraftsView: PostboxView {
|
||||
public let typingDraft: Message?
|
||||
|
||||
init(_ view: MutableTypingDraftsView) {
|
||||
self.typingDraft = view.typingDraft
|
||||
}
|
||||
}
|
||||
|
|
@ -102,6 +102,8 @@ public enum PostboxViewKey: Hashable {
|
|||
case savedMessagesStats(peerId: PeerId)
|
||||
case chatInterfaceState(peerId: PeerId)
|
||||
case historyView(HistoryView)
|
||||
case typingDrafts(PeerAndThreadId)
|
||||
case allTypingDrafts
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
switch self {
|
||||
|
|
@ -187,7 +189,7 @@ public enum PostboxViewKey: Hashable {
|
|||
case let .topChatMessage(peerIds):
|
||||
hasher.combine(peerIds)
|
||||
case .contacts:
|
||||
hasher.combine(16)
|
||||
hasher.combine(24)
|
||||
case let .deletedMessages(peerId):
|
||||
hasher.combine(peerId)
|
||||
case let .notice(key):
|
||||
|
|
@ -228,6 +230,11 @@ public enum PostboxViewKey: Hashable {
|
|||
hasher.combine(20)
|
||||
hasher.combine(historyView.peerId)
|
||||
hasher.combine(historyView.threadId)
|
||||
case let .typingDrafts(peerId):
|
||||
hasher.combine(23)
|
||||
hasher.combine(peerId)
|
||||
case .allTypingDrafts:
|
||||
hasher.combine(22)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -545,6 +552,18 @@ public enum PostboxViewKey: Hashable {
|
|||
} else {
|
||||
return false
|
||||
}
|
||||
case let .typingDrafts(peerId):
|
||||
if case .typingDrafts(peerId) = rhs {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
case .allTypingDrafts:
|
||||
if case .allTypingDrafts = rhs {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -672,5 +691,9 @@ func postboxViewForKey(postbox: PostboxImpl, key: PostboxViewKey) -> MutablePost
|
|||
topTaggedMessages: [:],
|
||||
additionalDatas: []
|
||||
)
|
||||
case let .typingDrafts(peerId):
|
||||
return MutableTypingDraftsView(postbox: postbox, peerAndThreadId: peerId)
|
||||
case .allTypingDrafts:
|
||||
return MutableAllTypingDraftsView(postbox: postbox)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4018,7 +4018,7 @@ func replayFinalState(
|
|||
}
|
||||
|
||||
case update(Update)
|
||||
case cancel
|
||||
case cancel(updatedTimestamp: Int32)
|
||||
}
|
||||
|
||||
var liveTypingDraftUpdates: [PeerAndThreadId: [LiveTypingDraftUpdate]] = [:]
|
||||
|
|
@ -4236,11 +4236,11 @@ func replayFinalState(
|
|||
let allKey = PeerAndThreadId(peerId: chatPeerId, threadId: nil)
|
||||
|
||||
if liveTypingDraftUpdates[key] != nil {
|
||||
liveTypingDraftUpdates[key] = [.cancel]
|
||||
liveTypingDraftUpdates[allKey] = [.cancel]
|
||||
liveTypingDraftUpdates[key] = [.cancel(updatedTimestamp: message.timestamp)]
|
||||
liveTypingDraftUpdates[allKey] = [.cancel(updatedTimestamp: message.timestamp)]
|
||||
} else if let currentDraft = transaction.getCurrentTypingDraft(location: key) {
|
||||
liveTypingDraftUpdates[key] = [.cancel]
|
||||
liveTypingDraftUpdates[allKey] = [.cancel]
|
||||
liveTypingDraftUpdates[key] = [.cancel(updatedTimestamp: message.timestamp)]
|
||||
liveTypingDraftUpdates[allKey] = [.cancel(updatedTimestamp: message.timestamp)]
|
||||
messages[i] = messages[i].withUpdatedCustomStableId(currentDraft.stableId)
|
||||
}
|
||||
}
|
||||
|
|
@ -6080,6 +6080,23 @@ func replayFinalState(
|
|||
}
|
||||
|
||||
if !liveTypingDraftUpdates.isEmpty {
|
||||
for (key, updates) in liveTypingDraftUpdates {
|
||||
if key.threadId == nil {
|
||||
var maxCancelledTimestamp: Int32?
|
||||
for update in updates {
|
||||
if case let .cancel(updatedTimestamp) = update {
|
||||
if let current = maxCancelledTimestamp {
|
||||
maxCancelledTimestamp = max(current, updatedTimestamp)
|
||||
} else {
|
||||
maxCancelledTimestamp = updatedTimestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
if let maxCancelledTimestamp {
|
||||
transaction.offsetPendingMessagesTimestamps(lowerBound: MessageId(peerId: key.peerId, namespace: Namespaces.Message.Local, id: 1), excludeIds: Set(), timestamp: maxCancelledTimestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
transaction.combineTypingDrafts(locations: Set(liveTypingDraftUpdates.keys), update: { key, current in
|
||||
guard let update = liveTypingDraftUpdates[key]?.max(by: { lhs, rhs in
|
||||
switch lhs {
|
||||
|
|
@ -6105,6 +6122,11 @@ func replayFinalState(
|
|||
if let current, current.id == update.id {
|
||||
timestamp = current.timestamp
|
||||
}
|
||||
if current == nil {
|
||||
if let index = transaction.getTopPeerMessageIndex(peerId: key.peerId) {
|
||||
timestamp = max(timestamp, index.timestamp)
|
||||
}
|
||||
}
|
||||
return (
|
||||
update.id,
|
||||
Namespaces.Message.Cloud,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ private enum PendingMessageState {
|
|||
case waitingForUploadToStart(groupId: Int64?, upload: Signal<PendingMessageUploadedContentResult, PendingMessageUploadError>)
|
||||
case uploading(groupId: Int64?)
|
||||
case waitingToBeSent(groupId: Int64?, content: PendingMessageUploadedContentAndReuploadInfo)
|
||||
case waitingForSendGate(groupId: Int64?, content: PendingMessageUploadedContentAndReuploadInfo)
|
||||
case waitingForForwardSendGate
|
||||
case sending(groupId: Int64?)
|
||||
case waitingForNewTopic(message: Message)
|
||||
|
||||
|
|
@ -46,6 +48,10 @@ private enum PendingMessageState {
|
|||
return groupId
|
||||
case let .waitingToBeSent(groupId, _):
|
||||
return groupId
|
||||
case let .waitingForSendGate(groupId, _):
|
||||
return groupId
|
||||
case .waitingForForwardSendGate:
|
||||
return nil
|
||||
case let .sending(groupId):
|
||||
return groupId
|
||||
case let .waitingForNewTopic(message):
|
||||
|
|
@ -230,6 +236,9 @@ public final class PendingMessageManager {
|
|||
|
||||
private var messageContexts: [MessageId: PendingMessageContext] = [:]
|
||||
private var pendingMessageIds = Set<MessageId>()
|
||||
private var liveTypingDraftKeys: Set<PeerAndThreadId> = []
|
||||
private let allTypingDraftsDisposable = MetaDisposable()
|
||||
private var forwardSendGateGroups: [PeerAndThreadId: [[(PendingMessageContext, Message, ForwardSourceInfoAttribute)]]] = [:]
|
||||
private let beginSendingMessagesDisposables = DisposableSet()
|
||||
|
||||
private var newTopicDisposables: [PeerId: Disposable] = [:]
|
||||
|
|
@ -250,6 +259,14 @@ public final class PendingMessageManager {
|
|||
self.localInputActivityManager = localInputActivityManager
|
||||
self.messageMediaPreuploadManager = messageMediaPreuploadManager
|
||||
self.revalidationContext = revalidationContext
|
||||
|
||||
let queue = self.queue
|
||||
self.allTypingDraftsDisposable.set(
|
||||
(postbox.combinedView(keys: [.allTypingDrafts])
|
||||
|> deliverOn(queue)).start(next: { [weak self] view in
|
||||
self?.handleLiveTypingDraftsUpdate(view)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
deinit {
|
||||
|
|
@ -257,8 +274,117 @@ public final class PendingMessageManager {
|
|||
for (_, disposable) in self.newTopicDisposables {
|
||||
disposable.dispose()
|
||||
}
|
||||
self.allTypingDraftsDisposable.dispose()
|
||||
}
|
||||
|
||||
private func handleLiveTypingDraftsUpdate(_ combined: CombinedView) {
|
||||
assert(self.queue.isCurrent())
|
||||
|
||||
let new: Set<PeerAndThreadId>
|
||||
if let view = combined.views[.allTypingDrafts] as? AllTypingDraftsView {
|
||||
new = view.keys
|
||||
} else {
|
||||
new = []
|
||||
}
|
||||
let cleared = self.liveTypingDraftKeys.subtracting(new)
|
||||
self.liveTypingDraftKeys = new
|
||||
for key in cleared {
|
||||
self.drainSendGate(key: key)
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldGateSend(messageId: MessageId, threadId: Int64?) -> Bool {
|
||||
if messageId.namespace == Namespaces.Message.ScheduledCloud {
|
||||
return false
|
||||
}
|
||||
if messageId.peerId.namespace == Namespaces.Peer.SecretChat {
|
||||
return false
|
||||
}
|
||||
if messageId.peerId == self.accountPeerId {
|
||||
return false
|
||||
}
|
||||
if threadId == Message.newTopicThreadId {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func isSendGateOpen(for key: PeerAndThreadId) -> Bool {
|
||||
return !self.liveTypingDraftKeys.contains(key)
|
||||
}
|
||||
|
||||
private func drainSendGate(key: PeerAndThreadId) {
|
||||
assert(self.queue.isCurrent())
|
||||
|
||||
// (1) Single-message drain: snapshot then commit in messageId.id order.
|
||||
var singleDrains: [(context: PendingMessageContext, messageId: MessageId, content: PendingMessageUploadedContentAndReuploadInfo)] = []
|
||||
for (id, context) in self.messageContexts {
|
||||
if id.peerId != key.peerId {
|
||||
continue
|
||||
}
|
||||
if context.threadId != key.threadId {
|
||||
continue
|
||||
}
|
||||
if case let .waitingForSendGate(groupId, content) = context.state, groupId == nil {
|
||||
singleDrains.append((context, id, content))
|
||||
}
|
||||
}
|
||||
singleDrains.sort(by: { $0.messageId.id < $1.messageId.id })
|
||||
for entry in singleDrains {
|
||||
self.commitSendingSingleMessage(messageContext: entry.context, messageId: entry.messageId, content: entry.content)
|
||||
}
|
||||
|
||||
// (2) Grouped-album drain: collect distinct groupIds whose members match the key,
|
||||
// iterate ascending by min messageId.id, fire commitSendingMessageGroup.
|
||||
var groupKeys: [(groupId: Int64, minMessageId: Int32)] = []
|
||||
var seenGroupIds = Set<Int64>()
|
||||
for (id, context) in self.messageContexts {
|
||||
if id.peerId != key.peerId {
|
||||
continue
|
||||
}
|
||||
if context.threadId != key.threadId {
|
||||
continue
|
||||
}
|
||||
if case let .waitingForSendGate(groupId, _) = context.state, let groupId = groupId {
|
||||
if !seenGroupIds.contains(groupId) {
|
||||
seenGroupIds.insert(groupId)
|
||||
groupKeys.append((groupId, id.id))
|
||||
} else {
|
||||
if let index = groupKeys.firstIndex(where: { $0.groupId == groupId }), id.id < groupKeys[index].minMessageId {
|
||||
groupKeys[index].minMessageId = id.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
groupKeys.sort(by: { $0.minMessageId < $1.minMessageId })
|
||||
for (groupId, _) in groupKeys {
|
||||
if let data = self.dataForPendingMessageGroup(groupId) {
|
||||
self.commitSendingMessageGroup(groupId: groupId, messages: data)
|
||||
}
|
||||
}
|
||||
|
||||
// (3) Forward drain: pop parked groups for this key in FIFO order; fire each.
|
||||
if let parkedGroups = self.forwardSendGateGroups.removeValue(forKey: key) {
|
||||
for messages in parkedGroups {
|
||||
for (context, _, _) in messages {
|
||||
context.state = .sending(groupId: nil)
|
||||
}
|
||||
let sendMessage: Signal<PendingMessageResult, NoError> = self.sendGroupMessagesContent(network: self.network, postbox: self.postbox, stateManager: self.stateManager, accountPeerId: self.accountPeerId, group: messages.map { data in
|
||||
let (_, message, forwardInfo) = data
|
||||
return (message.id, PendingMessageUploadedContentAndReuploadInfo(content: .forward(forwardInfo), reuploadInfo: nil, cacheReferenceKey: nil))
|
||||
})
|
||||
|> map { _ -> PendingMessageResult in
|
||||
return .progress(1.0)
|
||||
}
|
||||
messages[0].0.sendDisposable.set((sendMessage
|
||||
|> deliverOn(self.queue)).start())
|
||||
}
|
||||
}
|
||||
|
||||
self.updateWaitingUploads(peerId: key.peerId)
|
||||
self.updatePendingMediaUploads()
|
||||
}
|
||||
|
||||
private func updatePendingMediaUploads() {
|
||||
assert(self.queue.isCurrent())
|
||||
|
||||
|
|
@ -321,7 +447,29 @@ public final class PendingMessageManager {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if !removedMessageIds.isEmpty && !self.forwardSendGateGroups.isEmpty {
|
||||
for key in Array(self.forwardSendGateGroups.keys) {
|
||||
guard let parkedGroups = self.forwardSendGateGroups[key] else {
|
||||
continue
|
||||
}
|
||||
var rebuilt: [[(PendingMessageContext, Message, ForwardSourceInfoAttribute)]] = []
|
||||
for group in parkedGroups {
|
||||
let filtered = group.filter { entry in
|
||||
return !removedMessageIds.contains(entry.1.id)
|
||||
}
|
||||
if !filtered.isEmpty {
|
||||
rebuilt.append(filtered)
|
||||
}
|
||||
}
|
||||
if rebuilt.isEmpty {
|
||||
self.forwardSendGateGroups.removeValue(forKey: key)
|
||||
} else {
|
||||
self.forwardSendGateGroups[key] = rebuilt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !addedMessageIds.isEmpty {
|
||||
Logger.shared.log("PendingMessageManager", "added messages: \(addedMessageIds)")
|
||||
self.beginSendingMessages(Array(addedMessageIds).sorted())
|
||||
|
|
@ -715,11 +863,24 @@ public final class PendingMessageManager {
|
|||
if messages.isEmpty {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
let firstMessage = messages[0].1
|
||||
let key = PeerAndThreadId(peerId: firstMessage.id.peerId, threadId: firstMessage.threadId)
|
||||
if strongSelf.shouldGateSend(messageId: firstMessage.id, threadId: firstMessage.threadId) && !strongSelf.isSendGateOpen(for: key) {
|
||||
for (context, _, _) in messages {
|
||||
context.state = .waitingForForwardSendGate
|
||||
}
|
||||
if strongSelf.forwardSendGateGroups[key] == nil {
|
||||
strongSelf.forwardSendGateGroups[key] = []
|
||||
}
|
||||
strongSelf.forwardSendGateGroups[key]!.append(messages)
|
||||
continue
|
||||
}
|
||||
|
||||
for (context, _, _) in messages {
|
||||
context.state = .sending(groupId: nil)
|
||||
}
|
||||
|
||||
|
||||
let sendMessage: Signal<PendingMessageResult, NoError> = strongSelf.sendGroupMessagesContent(network: strongSelf.network, postbox: strongSelf.postbox, stateManager: strongSelf.stateManager, accountPeerId: strongSelf.accountPeerId, group: messages.map { data in
|
||||
let (_, message, forwardInfo) = data
|
||||
return (message.id, PendingMessageUploadedContentAndReuploadInfo(content: .forward(forwardInfo), reuploadInfo: nil, cacheReferenceKey: nil))
|
||||
|
|
@ -739,7 +900,12 @@ public final class PendingMessageManager {
|
|||
if let groupId = groupId {
|
||||
messageContext.state = .waitingToBeSent(groupId: groupId, content: content)
|
||||
} else {
|
||||
self.commitSendingSingleMessage(messageContext: messageContext, messageId: messageId, content: content)
|
||||
let key = PeerAndThreadId(peerId: messageId.peerId, threadId: messageContext.threadId)
|
||||
if self.shouldGateSend(messageId: messageId, threadId: messageContext.threadId) && !self.isSendGateOpen(for: key) {
|
||||
messageContext.state = .waitingForSendGate(groupId: nil, content: content)
|
||||
} else {
|
||||
self.commitSendingSingleMessage(messageContext: messageContext, messageId: messageId, content: content)
|
||||
}
|
||||
}
|
||||
self.updatePendingMediaUploads()
|
||||
}
|
||||
|
|
@ -757,6 +923,8 @@ public final class PendingMessageManager {
|
|||
switch context.state {
|
||||
case .none:
|
||||
continue loop
|
||||
case .waitingForForwardSendGate:
|
||||
continue loop
|
||||
case let .collectingInfo(message):
|
||||
if message.groupingKey == groupId {
|
||||
return nil
|
||||
|
|
@ -773,6 +941,10 @@ public final class PendingMessageManager {
|
|||
if contextGroupId == groupId {
|
||||
result.append((context, id, content))
|
||||
}
|
||||
case let .waitingForSendGate(contextGroupId, content):
|
||||
if contextGroupId == groupId {
|
||||
result.append((context, id, content))
|
||||
}
|
||||
case let .sending(contextGroupId):
|
||||
if contextGroupId == groupId {
|
||||
return nil
|
||||
|
|
@ -792,6 +964,16 @@ public final class PendingMessageManager {
|
|||
}
|
||||
|
||||
private func commitSendingMessageGroup(groupId: Int64, messages: [(messageContext: PendingMessageContext, messageId: MessageId, content: PendingMessageUploadedContentAndReuploadInfo)]) {
|
||||
let firstMessageId = messages[0].messageId
|
||||
let firstThreadId = messages[0].messageContext.threadId
|
||||
let key = PeerAndThreadId(peerId: firstMessageId.peerId, threadId: firstThreadId)
|
||||
if self.shouldGateSend(messageId: firstMessageId, threadId: firstThreadId) && !self.isSendGateOpen(for: key) {
|
||||
for entry in messages {
|
||||
entry.messageContext.state = .waitingForSendGate(groupId: groupId, content: entry.content)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for (context, _, _) in messages {
|
||||
context.state = .sending(groupId: groupId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ open class ChatMessageBubbleContentNode: ASDisplayNode {
|
|||
|
||||
public var updateIsTextSelectionActive: ((Bool) -> Void)?
|
||||
public var requestInlineUpdate: (() -> Void)?
|
||||
public var requestFullUpdate: (() -> Void)?
|
||||
public var requestFullUpdate: ((ControlledTransition?) -> Void)?
|
||||
|
||||
open var disablesClipping: Bool {
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -4816,12 +4816,12 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI
|
|||
|
||||
strongSelf.internalUpdateLayout()
|
||||
}
|
||||
contentNode.requestFullUpdate = { [weak strongSelf] in
|
||||
contentNode.requestFullUpdate = { [weak strongSelf] customTransition in
|
||||
guard let strongSelf, let item = strongSelf.item else {
|
||||
return
|
||||
}
|
||||
|
||||
item.controllerInteraction.requestMessageUpdate(item.message.id, false)
|
||||
item.controllerInteraction.requestMessageUpdate(item.message.id, false, customTransition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5636,7 +5636,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI
|
|||
}
|
||||
if attribute.isQuote, !replyInfoNode.isQuoteExpanded {
|
||||
replyInfoNode.isQuoteExpanded = true
|
||||
item.controllerInteraction.requestMessageUpdate(item.message.id, false)
|
||||
item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil)
|
||||
return
|
||||
}
|
||||
var progress: Promise<Bool>?
|
||||
|
|
@ -5668,7 +5668,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI
|
|||
}
|
||||
if attribute.isQuote, !replyInfoNode.isQuoteExpanded {
|
||||
replyInfoNode.isQuoteExpanded = true
|
||||
item.controllerInteraction.requestMessageUpdate(item.message.id, false)
|
||||
item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -7309,10 +7309,10 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI
|
|||
|
||||
if item.controllerInteraction.summarizedMessageIds.contains(item.message.id) {
|
||||
item.controllerInteraction.summarizedMessageIds.remove(item.message.id)
|
||||
let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false)
|
||||
let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil)
|
||||
} else {
|
||||
item.controllerInteraction.summarizedMessageIds.insert(item.message.id)
|
||||
let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false)
|
||||
let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil)
|
||||
|
||||
let translateToLanguage = item.associatedData.translateToLanguage
|
||||
var requestSummary = true
|
||||
|
|
@ -7327,7 +7327,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI
|
|||
|> deliverOnMainQueue).start(error: { error in
|
||||
if case .limitExceededPremium = error, let parentController = item.controllerInteraction.navigationController()?.topViewController as? ViewController {
|
||||
item.controllerInteraction.summarizedMessageIds.remove(item.message.id)
|
||||
let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false)
|
||||
let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil)
|
||||
let controller = premiumAlertController(
|
||||
context: item.context,
|
||||
parentController: parentController,
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ public class ChatMessageFactCheckBubbleContentNode: ChatMessageBubbleContentNode
|
|||
guard let item = self.item else{
|
||||
return
|
||||
}
|
||||
let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false)
|
||||
let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil)
|
||||
}
|
||||
|
||||
public override func willUpdateIsExtractedToContextPreview(_ value: Bool) {
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ public class ChatMessageFileBubbleContentNode: ChatMessageBubbleContentNode {
|
|||
|
||||
self.interactiveFileNode.requestUpdateLayout = { [weak self] _ in
|
||||
if let strongSelf = self, let item = strongSelf.item {
|
||||
let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false)
|
||||
let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -308,7 +308,7 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode {
|
|||
guard let item = self.item else{
|
||||
return
|
||||
}
|
||||
let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false)
|
||||
let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil)
|
||||
}
|
||||
|
||||
private func makeProgress() -> Promise<Bool> {
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ public class ChatMessageInstantVideoBubbleContentNode: ChatMessageBubbleContentN
|
|||
|
||||
self.interactiveVideoNode.requestUpdateLayout = { [weak self] _ in
|
||||
if let strongSelf = self, let item = strongSelf.item {
|
||||
let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false)
|
||||
let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil)
|
||||
}
|
||||
}
|
||||
self.interactiveVideoNode.updateTranscriptionExpanded = { [weak self] state in
|
||||
|
|
@ -102,13 +102,13 @@ public class ChatMessageInstantVideoBubbleContentNode: ChatMessageBubbleContentN
|
|||
let previous = strongSelf.audioTranscriptionState
|
||||
strongSelf.audioTranscriptionState = state
|
||||
strongSelf.interactiveFileNode.audioTranscriptionState = state
|
||||
let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, state != .inProgress && previous != state)
|
||||
let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, state != .inProgress && previous != state, nil)
|
||||
}
|
||||
}
|
||||
self.interactiveVideoNode.updateTranscriptionText = { [weak self] text in
|
||||
if let strongSelf = self, let item = strongSelf.item {
|
||||
strongSelf.interactiveFileNode.forcedAudioTranscriptionText = text
|
||||
let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false)
|
||||
let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil)
|
||||
}
|
||||
}
|
||||
self.interactiveFileNode.updateTranscriptionExpanded = { [weak self] state in
|
||||
|
|
@ -116,7 +116,7 @@ public class ChatMessageInstantVideoBubbleContentNode: ChatMessageBubbleContentN
|
|||
let previous = strongSelf.audioTranscriptionState
|
||||
strongSelf.audioTranscriptionState = state
|
||||
strongSelf.interactiveVideoNode.audioTranscriptionState = state
|
||||
let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, previous != state)
|
||||
let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, previous != state, nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -134,7 +134,7 @@ public class ChatMessageInstantVideoBubbleContentNode: ChatMessageBubbleContentN
|
|||
|
||||
self.interactiveFileNode.requestUpdateLayout = { [weak self] _ in
|
||||
if let strongSelf = self, let item = strongSelf.item {
|
||||
let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false)
|
||||
let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -439,7 +439,7 @@ public class ChatMessageMapBubbleContentNode: ChatMessageBubbleContentNode {
|
|||
if let strongSelf = self {
|
||||
strongSelf.timeoutTimer?.0.invalidate()
|
||||
strongSelf.timeoutTimer = nil
|
||||
item.controllerInteraction.requestMessageUpdate(item.message.id, false)
|
||||
item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil)
|
||||
}
|
||||
}, queue: Queue.mainQueue())
|
||||
strongSelf.timeoutTimer = (timer, timeoutDeadline)
|
||||
|
|
|
|||
|
|
@ -2165,7 +2165,7 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode {
|
|||
guard let item = self.item else {
|
||||
return
|
||||
}
|
||||
item.controllerInteraction.requestMessageUpdate(item.message.id, false)
|
||||
item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil)
|
||||
}
|
||||
|
||||
private func updatePollAddOptionFocused(_ focus: Bool) {
|
||||
|
|
@ -2372,7 +2372,7 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode {
|
|||
item.controllerInteraction.requestOpenMessagePollResults(item.message.id, pollId)
|
||||
case .anonymous:
|
||||
self.isPreviewingResults = !self.isPreviewingResults
|
||||
item.controllerInteraction.requestMessageUpdate(item.message.id, false)
|
||||
item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil)
|
||||
}
|
||||
}
|
||||
} else if !selectedOpaqueIdentifiers.isEmpty {
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode {
|
|||
} else {
|
||||
self.expandedBlockIds.insert(blockId)
|
||||
}
|
||||
item.controllerInteraction.requestMessageUpdate(item.message.id, false)
|
||||
item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil)
|
||||
}
|
||||
self.textNode.textNode.requestDisplayContentsUnderSpoilers = { [weak self] location in
|
||||
guard let self else {
|
||||
|
|
@ -841,7 +841,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode {
|
|||
strongSelf.relativeDateTimer = nil
|
||||
}
|
||||
strongSelf.relativeDateTimer = (SwiftSignalKit.Timer(timeout: Double(formattedDateUpdatePeriod), repeat: true, completion: { [weak self] in
|
||||
self?.requestFullUpdate?()
|
||||
self?.requestFullUpdate?(ControlledTransition(duration: 0.15, curve: .easeInOut, interactive: false))
|
||||
}, queue: Queue.mainQueue()), formattedDateUpdatePeriod)
|
||||
strongSelf.relativeDateTimer?.timer.start()
|
||||
} else if let (timer, _) = strongSelf.relativeDateTimer {
|
||||
|
|
@ -1117,7 +1117,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode {
|
|||
ContainedViewLayoutTransition.animated(duration: 0.2, curve: .easeInOut).updateAlpha(node: statusNode, alpha: 1.0)
|
||||
}
|
||||
|
||||
self.requestFullUpdate?()
|
||||
self.requestFullUpdate?(ControlledTransition(duration: 0.15, curve: .easeInOut, interactive: false))
|
||||
} else {
|
||||
var requestUpdate = false
|
||||
let glyphCount = textRevealAnimationState.glyphCount(timestamp: timestamp)
|
||||
|
|
@ -1137,7 +1137,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode {
|
|||
}
|
||||
|
||||
if requestUpdate {
|
||||
self.requestFullUpdate?()
|
||||
self.requestFullUpdate?(ControlledTransition(duration: 0.15, curve: .easeInOut, interactive: false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1737,7 +1737,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode {
|
|||
}
|
||||
self.displayContentsUnderSpoilers = (value, location)
|
||||
if let item = self.item {
|
||||
item.controllerInteraction.requestMessageUpdate(item.message.id, false)
|
||||
item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ public final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContent
|
|||
}
|
||||
self.contentNode.requestUpdateLayout = { [weak self] in
|
||||
if let strongSelf = self, let item = strongSelf.item {
|
||||
let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false)
|
||||
let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil)
|
||||
}
|
||||
}
|
||||
self.contentNode.defaultContentAction = { [weak self] in
|
||||
|
|
|
|||
|
|
@ -653,7 +653,7 @@ final class ChatRecentActionsControllerNode: ViewControllerTracingNode {
|
|||
}, sendGift: { _ in
|
||||
}, openUniqueGift: { _ in
|
||||
}, openMessageFeeException: {
|
||||
}, requestMessageUpdate: { _, _ in
|
||||
}, requestMessageUpdate: { _, _, _ in
|
||||
}, cancelInteractiveKeyboardGestures: {
|
||||
}, dismissTextInput: {
|
||||
}, scrollToMessageId: { _ in
|
||||
|
|
|
|||
|
|
@ -500,7 +500,7 @@ public final class ChatSendGroupMediaMessageContextPreview: UIView, ChatSendMess
|
|||
}, sendGift: { _ in
|
||||
}, openUniqueGift: { _ in
|
||||
}, openMessageFeeException: {
|
||||
}, requestMessageUpdate: { _, _ in
|
||||
}, requestMessageUpdate: { _, _, _ in
|
||||
}, cancelInteractiveKeyboardGestures: {
|
||||
}, dismissTextInput: {
|
||||
}, scrollToMessageId: { _ in
|
||||
|
|
|
|||
|
|
@ -288,7 +288,7 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol
|
|||
public let sendGift: (EnginePeer.Id) -> Void
|
||||
public let openUniqueGift: (String) -> Void
|
||||
public let openMessageFeeException: () -> Void
|
||||
public let requestMessageUpdate: (MessageId, Bool) -> Void
|
||||
public let requestMessageUpdate: (MessageId, Bool, ControlledTransition?) -> Void
|
||||
public let cancelInteractiveKeyboardGestures: () -> Void
|
||||
public let dismissTextInput: () -> Void
|
||||
public let scrollToMessageId: (MessageIndex) -> Void
|
||||
|
|
@ -465,7 +465,7 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol
|
|||
sendGift: @escaping (EnginePeer.Id) -> Void,
|
||||
openUniqueGift: @escaping (String) -> Void,
|
||||
openMessageFeeException: @escaping () -> Void,
|
||||
requestMessageUpdate: @escaping (MessageId, Bool) -> Void,
|
||||
requestMessageUpdate: @escaping (MessageId, Bool, ControlledTransition?) -> Void,
|
||||
cancelInteractiveKeyboardGestures: @escaping () -> Void,
|
||||
dismissTextInput: @escaping () -> Void,
|
||||
scrollToMessageId: @escaping (MessageIndex) -> Void,
|
||||
|
|
|
|||
|
|
@ -1271,7 +1271,7 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro
|
|||
self.openPremiumGift()
|
||||
}, openUniqueGift: { _ in
|
||||
}, openMessageFeeException: {
|
||||
}, requestMessageUpdate: { _, _ in
|
||||
}, requestMessageUpdate: { _, _, _ in
|
||||
}, cancelInteractiveKeyboardGestures: {
|
||||
}, dismissTextInput: {
|
||||
}, scrollToMessageId: { _ in
|
||||
|
|
|
|||
|
|
@ -4377,7 +4377,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
|
|||
self.displayPollSolution(solution: solution, sourceNode: sourceNode, isAutomatic: false)
|
||||
} else if let messageId = self.controllerInteraction?.currentPollMessageWithTooltip {
|
||||
self.controllerInteraction?.currentPollMessageWithTooltip = nil
|
||||
self.controllerInteraction?.requestMessageUpdate(messageId, false)
|
||||
self.controllerInteraction?.requestMessageUpdate(messageId, false, nil)
|
||||
}
|
||||
}, displayPsa: { [weak self] type, sourceNode in
|
||||
self?.displayPsa(type: type, sourceNode: sourceNode, isAutomatic: false)
|
||||
|
|
@ -5317,9 +5317,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
|
|||
self.present(controller, in: .window(.root))
|
||||
})
|
||||
}
|
||||
}, requestMessageUpdate: { [weak self] id, scroll in
|
||||
}, requestMessageUpdate: { [weak self] id, scroll, customTransition in
|
||||
if let self {
|
||||
self.chatDisplayNode.historyNode.requestMessageUpdate(id, andScrollToItem: scroll)
|
||||
self.chatDisplayNode.historyNode.requestMessageUpdate(id, andScrollToItem: scroll, customTransition: customTransition)
|
||||
}
|
||||
}, cancelInteractiveKeyboardGestures: { [weak self] in
|
||||
if let self {
|
||||
|
|
@ -8212,7 +8212,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
|
|||
|
||||
let messageId = item.message.id
|
||||
self.controllerInteraction?.currentPollMessageWithTooltip = messageId
|
||||
self.controllerInteraction?.requestMessageUpdate(messageId, false)
|
||||
self.controllerInteraction?.requestMessageUpdate(messageId, false, nil)
|
||||
}
|
||||
|
||||
public func displayPromoAnnouncement(text: String) {
|
||||
|
|
|
|||
|
|
@ -4749,7 +4749,7 @@ public final class ChatHistoryListNodeImpl: ListViewImpl, ChatHistoryNode, ChatH
|
|||
}
|
||||
}
|
||||
|
||||
func requestMessageUpdate(_ id: MessageId, andScrollToItem scroll: Bool = false) {
|
||||
func requestMessageUpdate(_ id: MessageId, andScrollToItem scroll: Bool = false, customTransition: ControlledTransition? = nil) {
|
||||
if let historyView = self.historyView {
|
||||
var messageItem: ChatMessageItem?
|
||||
self.forEachItemNode({ itemNode in
|
||||
|
|
@ -4795,7 +4795,7 @@ public final class ChatHistoryListNodeImpl: ListViewImpl, ChatHistoryNode, ChatH
|
|||
scrollToItem = ListViewScrollToItem(index: index, position: .center(.top), animated: true, curve: .Spring(duration: 0.4), directionHint: .Down, displayLink: true)
|
||||
}
|
||||
|
||||
self.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [updateItem], options: [.AnimateInsertion, .Synchronous], scrollToItem: scrollToItem, additionalScrollDistance: 0.0, updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
|
||||
self.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [updateItem], options: [.AnimateInsertion, .Synchronous], scrollToItem: scrollToItem, additionalScrollDistance: 0.0, updateSizeAndInsets: nil, stationaryItemRange: nil, customAnimationTransition: customTransition, updateOpaqueState: nil, completion: { _ in })
|
||||
break loop
|
||||
}
|
||||
case let .MessageGroupEntry(_, messages, presentationData):
|
||||
|
|
@ -4816,7 +4816,7 @@ public final class ChatHistoryListNodeImpl: ListViewImpl, ChatHistoryNode, ChatH
|
|||
scrollToItem = ListViewScrollToItem(index: index, position: .center(.top), animated: true, curve: .Spring(duration: 0.4), directionHint: .Down, displayLink: true)
|
||||
}
|
||||
|
||||
self.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [updateItem], options: [.AnimateInsertion, .Synchronous], scrollToItem: scrollToItem, additionalScrollDistance: 0.0, updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
|
||||
self.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [updateItem], options: [.AnimateInsertion, .Synchronous], scrollToItem: scrollToItem, additionalScrollDistance: 0.0, updateSizeAndInsets: nil, stationaryItemRange: nil, customAnimationTransition: customTransition, updateOpaqueState: nil, completion: { _ in })
|
||||
break loop
|
||||
}
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -245,7 +245,7 @@ final class OverlayAudioPlayerControllerNode: ViewControllerTracingNode, ASGestu
|
|||
}, sendGift: { _ in
|
||||
}, openUniqueGift: { _ in
|
||||
}, openMessageFeeException: {
|
||||
}, requestMessageUpdate: { _, _ in
|
||||
}, requestMessageUpdate: { _, _, _ in
|
||||
}, cancelInteractiveKeyboardGestures: {
|
||||
}, dismissTextInput: {
|
||||
}, scrollToMessageId: { _ in
|
||||
|
|
|
|||
|
|
@ -2554,7 +2554,7 @@ public final class SharedAccountContextImpl: SharedAccountContext {
|
|||
},
|
||||
openMessageFeeException: {
|
||||
},
|
||||
requestMessageUpdate: { _, _ in
|
||||
requestMessageUpdate: { _, _, _ in
|
||||
},
|
||||
cancelInteractiveKeyboardGestures: {
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"app": "12.6.4",
|
||||
"app": "12.7",
|
||||
"xcode": "26.2",
|
||||
"deploy_xcode": "26.2",
|
||||
"bazel": "8.4.2:45e9388abf21d1107e146ea366ad080eb93cb6a5f3a4a3b048f78de0bc3faffa",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue