some fixes
This commit is contained in:
1 parent
cf2bda9c54
commit
e28a4043ee
13 files changed
+793
-119
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user