some fixes
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s

This commit is contained in:
wt committed 2026-09-07 19:40:47 +07:00
1 parent cf2bda9c54
commit e28a4043ee
13 files changed
+793 -119

No files matched your search

+88 -11
View File
@@ -61,6 +61,10 @@ struct ChatView: View {
@State private var isNearTimelineBottom = true
@State private var historyLoadAnchorID: String?
@State private var historyTopTriggerArmed = true
@State private var historyTopTriggerVisible = false
@State private var historyLoadEntryCount = 0
@State private var historyAutoContinueCount = 0
@State private var emptyHistoryRetryCount = 0
@State private var activeCaptureMode: ComposerCaptureMode?
@State private var preparingCaptureMode: ComposerCaptureMode?
@State private var captureGestureIsActive = false
@@ -122,6 +126,10 @@ struct ChatView: View {
initialValue: nil
)
_hasCompletedInitialScroll = State(initialValue: false)
_historyTopTriggerVisible = State(initialValue: false)
_historyLoadEntryCount = State(initialValue: 0)
_historyAutoContinueCount = State(initialValue: 0)
_emptyHistoryRetryCount = State(initialValue: 0)
_isNearTimelineBottom = State(initialValue: true)
_historyLoadAnchorID = State<String?>(initialValue: nil)
_historyTopTriggerArmed = State(initialValue: true)
@@ -365,8 +373,12 @@ struct ChatView: View {
isNearTimelineBottom = true
historyLoadAnchorID = nil
historyTopTriggerArmed = true
historyTopTriggerVisible = false
historyAutoContinueCount = 0
emptyHistoryRetryCount = 0
}
.onDisappear {
model.endConversationViewing(jid: conversation.jid)
model.endComposerActivity(in: liveConversation)
attachmentPreviewPresentationTask?.cancel()
attachmentPreviewPresentationTask = nil
@@ -487,17 +499,16 @@ struct ChatView: View {
.frame(height: 1)
.accessibilityHidden(true)
.onAppear {
historyTopTriggerVisible = true
guard historyTopTriggerArmed,
!model.isLoadingOlderHistory
else { return }
historyTopTriggerArmed = false
historyLoadAnchorID =
timelineEntries.first?.id
model
.loadOlderHistoryForSelectedConversation()
triggerOlderHistoryLoad()
}
.onDisappear {
historyTopTriggerVisible = false
historyTopTriggerArmed = true
historyAutoContinueCount = 0
}
if model.isLoadingOlderHistory {
ProgressView()
@@ -653,14 +664,49 @@ struct ChatView: View {
.onChange(of: model.isLoadingOlderHistory) {
wasLoading,
isLoading in
guard wasLoading, !isLoading,
let anchor = historyLoadAnchorID
else { return }
Task { @MainActor in
await Task.yield()
proxy.scrollTo(anchor, anchor: .top)
guard wasLoading, !isLoading else { return }
// Restore the scroll position so the newly loaded
// page appears above the previously visible one. Only
// while the user is still at the top: a slow page must
// never yank the timeline away from where the user
// scrolled while it was loading.
if let anchor = historyLoadAnchorID,
historyTopTriggerVisible
{
Task { @MainActor in
await Task.yield()
proxy.scrollTo(anchor, anchor: .top)
historyLoadAnchorID = nil
}
} else {
historyLoadAnchorID = nil
}
// The trigger is one-shot per visibility cycle.
// Re-arm it whenever a load settles so the next
// scroll gesture can fire again even if this page
// inserted nothing.
historyTopTriggerArmed = true
if timelineEntries.isEmpty {
handleEmptyHistoryLoadCompleted()
} else if historyTopTriggerVisible {
// The page left the sentinel on screen: it either
// inserted no rows (reactions, duplicates) or the
// anchor scroll did not push it away. While the
// user stays at the top, continue through such
// pages so scroll-up never appears dead. Every
// page advances the server cursor, so this loop
// is bounded by the archive itself.
if timelineEntries.count
== historyLoadEntryCount,
model.hasMoreOlderHistory,
historyAutoContinueCount < 6
{
historyAutoContinueCount += 1
triggerOlderHistoryLoad()
} else {
historyAutoContinueCount = 0
}
}
}
.onChange(of: timelineEntries.count) { _, _ in
selectedMessageIDs.formIntersection(
@@ -1840,6 +1886,37 @@ struct ChatView: View {
}
}
/// Starts one interactive older-history page load from the top sentinel.
/// Remembers the first visible entry so the viewport can be restored when
/// the page arrives.
private func triggerOlderHistoryLoad() {
historyTopTriggerArmed = false
historyLoadAnchorID = timelineEntries.first?.id
historyLoadEntryCount = timelineEntries.count
model.loadOlderHistoryForSelectedConversation()
}
/// A finished load left the timeline empty. Retry a bounded number of
/// times with a short delay: the first page of an empty chat can fail
/// transiently (timeout, reconnect), and an empty timeline has no scroll
/// gesture that could retry it manually.
private func handleEmptyHistoryLoadCompleted() {
guard model.hasMoreOlderHistory,
!model.isLoadingOlderHistory,
emptyHistoryRetryCount < 3
else { return }
emptyHistoryRetryCount += 1
Task { @MainActor [weak model] in
try? await Task.sleep(nanoseconds: 1_500_000_000)
guard !Task.isCancelled,
timelineEntries.isEmpty,
model?.hasMoreOlderHistory == true,
model?.isLoadingOlderHistory == false
else { return }
model?.loadOlderHistoryForSelectedConversation()
}
}
private func performInitialScrollAfterLayout(using proxy: ScrollViewProxy)
async
{
@@ -13,6 +13,7 @@ struct VideoNoteCaptureView: View {
@StateObject private var recorder = VideoNoteRecorder()
@State private var errorMessage: String?
@State private var isFinalizing = false
@State private var isSwitchingCamera = false
let onComplete: (VideoNoteRecorder.Recording) -> Void
@@ -32,6 +33,26 @@ struct VideoNoteCaptureView: View {
.frame(maxWidth: 420)
.aspectRatio(1, contentMode: .fit)
.overlay(Circle().stroke(.white.opacity(0.16), lineWidth: 1))
#if os(iOS)
.overlay(alignment: .bottomTrailing) {
if recorder.hasAlternateCamera, recorder.isPrepared {
Button(action: flipCamera) {
Image(
systemName:
"arrow.triangle.2.circlepath.camera.fill"
)
.font(.system(size: 19, weight: .semibold))
.foregroundStyle(.white)
.padding(10)
.background(.ultraThinMaterial, in: Circle())
}
.buttonStyle(.plain)
.padding(14)
.disabled(isSwitchingCamera || isFinalizing)
.accessibilityLabel("Переключить камеру")
}
}
#endif
Text(recordingStatus)
.font(.system(.title3, design: .monospaced).weight(.semibold))
@@ -120,6 +141,19 @@ struct VideoNoteCaptureView: View {
#endif
}
private func flipCamera() {
guard !isSwitchingCamera, !isFinalizing else { return }
isSwitchingCamera = true
Task { @MainActor in
do {
try await recorder.switchCamera()
} catch {
errorMessage = error.localizedDescription
}
isSwitchingCamera = false
}
}
private func formatted(_ duration: TimeInterval) -> String {
let seconds = max(0, Int(duration.rounded(.down)))
return String(format: "%d:%02d", seconds / 60, seconds % 60)