Фикс: дубликат CallKit вызова, disconnect recovery, WebRTC packet buffering и E2EE rebind loop

This commit is contained in:
2026-04-02 15:29:46 +05:00
parent 4be6761492
commit de0818fe69
10 changed files with 863 additions and 295 deletions

View File

@@ -41,84 +41,87 @@ struct ForwardChatPickerView: View {
}
var body: some View {
VStack(spacing: 0) {
// MARK: - Header
ForwardPickerHeader(
isMultiSelect: isMultiSelect,
onClose: { dismiss() },
onSelect: {
withAnimation(.easeInOut(duration: 0.2)) {
isMultiSelect = true
ZStack {
Color.black.ignoresSafeArea()
VStack(spacing: 0) {
// MARK: - Header
ForwardPickerHeader(
isMultiSelect: isMultiSelect,
onClose: { dismiss() },
onSelect: {
withAnimation(.easeInOut(duration: 0.2)) {
isMultiSelect = true
}
}
}
)
)
// MARK: - Search
ForwardPickerSearchBar(searchText: $searchText)
.padding(.horizontal, 8)
.padding(.top, 8)
.padding(.bottom, 6)
// MARK: - Search
ForwardPickerSearchBar(searchText: $searchText)
.padding(.horizontal, 8)
.padding(.top, 8)
.padding(.bottom, 6)
// MARK: - Chat List
if dialogs.isEmpty && !searchText.isEmpty {
VStack {
Spacer()
Text("No chats found")
.font(.system(size: 15))
.foregroundStyle(Color(white: 0.5))
Spacer()
}
} else {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(Array(dialogs.enumerated()), id: \.element.id) { index, dialog in
ForwardPickerRow(
dialog: dialog,
isMultiSelect: isMultiSelect,
isSelected: selectedIds.contains(dialog.opponentKey)
) {
if isMultiSelect {
withAnimation(.easeInOut(duration: 0.15)) {
if selectedIds.contains(dialog.opponentKey) {
selectedIds.remove(dialog.opponentKey)
} else {
selectedIds.insert(dialog.opponentKey)
// MARK: - Chat List
if dialogs.isEmpty && !searchText.isEmpty {
VStack {
Spacer()
Text("No chats found")
.font(.system(size: 15))
.foregroundStyle(Color(white: 0.5))
Spacer()
}
} else {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(Array(dialogs.enumerated()), id: \.element.id) { index, dialog in
ForwardPickerRow(
dialog: dialog,
isMultiSelect: isMultiSelect,
isSelected: selectedIds.contains(dialog.opponentKey)
) {
if isMultiSelect {
withAnimation(.easeInOut(duration: 0.15)) {
if selectedIds.contains(dialog.opponentKey) {
selectedIds.remove(dialog.opponentKey)
} else {
selectedIds.insert(dialog.opponentKey)
}
}
} else {
onSelect([ChatRoute(dialog: dialog)])
}
} else {
onSelect([ChatRoute(dialog: dialog)])
}
}
if index < dialogs.count - 1 {
Divider()
.padding(.leading, 65)
.foregroundStyle(Color(red: 0x54 / 255.0, green: 0x54 / 255.0, blue: 0x58 / 255.0).opacity(0.55))
if index < dialogs.count - 1 {
Divider()
.padding(.leading, 65)
.foregroundStyle(Color(red: 0x54 / 255.0, green: 0x54 / 255.0, blue: 0x58 / 255.0).opacity(0.55))
}
}
}
}
.scrollDismissesKeyboard(.interactively)
}
.scrollDismissesKeyboard(.interactively)
}
// MARK: - Bottom Bar (multi-select)
if isMultiSelect {
ForwardPickerBottomBar(
selectedCount: selectedIds.count,
onSend: {
let routes = dialogs
.filter { selectedIds.contains($0.opponentKey) }
.map { ChatRoute(dialog: $0) }
if !routes.isEmpty { onSelect(routes) }
}
)
.transition(.move(edge: .bottom).combined(with: .opacity))
// MARK: - Bottom Bar (multi-select)
if isMultiSelect {
ForwardPickerBottomBar(
selectedCount: selectedIds.count,
onSend: {
let routes = dialogs
.filter { selectedIds.contains($0.opponentKey) }
.map { ChatRoute(dialog: $0) }
if !routes.isEmpty { onSelect(routes) }
}
)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
}
.background(Color.black.ignoresSafeArea())
.preferredColorScheme(.dark)
.presentationBackground(Color.black)
.presentationDragIndicator(.visible)
.presentationDragIndicator(.hidden)
.presentationDetents([.large])
}
}
@@ -205,9 +208,17 @@ private struct ForwardPickerHeader: View {
if !isMultiSelect {
HStack {
Spacer()
Button("Select", action: onSelect)
.font(.system(size: 17, weight: .regular))
.foregroundStyle(.white)
Button(action: onSelect) {
Text("Select")
.font(.system(size: 17, weight: .regular))
.foregroundStyle(.white)
.padding(.horizontal, 14)
.frame(height: 30)
.background {
Capsule()
.fill(Color(white: 0.16))
}
}
}
}
}
@@ -255,7 +266,7 @@ private struct ForwardPickerSearchBar: View {
.frame(height: 44)
.background {
RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(Color(white: 0.14))
.fill(Color.white.opacity(0.1))
}
}
}
@@ -275,7 +286,7 @@ private struct ForwardPickerRow: View {
HStack(spacing: 4) {
Text(dialog.isSavedMessages ? "Saved Messages" : dialog.opponentTitle)
.font(.system(size: 17, weight: .regular))
.font(.system(size: 17, weight: .medium))
.foregroundStyle(.white)
.lineLimit(1)
@@ -361,7 +372,7 @@ private struct ForwardPickerBottomBar: View {
.frame(height: 42)
.background {
RoundedRectangle(cornerRadius: 21, style: .continuous)
.fill(Color(white: 0.14))
.fill(Color.white.opacity(0.1))
}
// Telegram send button: 33pt circle + SVG arrow

View File

@@ -72,10 +72,9 @@ final class ImageViewerPresenter {
// MARK: - ImageGalleryViewer
/// Telegram-style multi-photo gallery viewer with hero transition animation.
/// Reference: PhotosTransition/Helpers/PhotoGridView.swift hero expand/collapse pattern.
/// Android parity: `ImageViewerScreen.kt` top bar with sender/date,
/// bottom caption bar, edge-tap navigation, velocity dismiss, share/save.
/// Multi-photo gallery viewer with hero transition animation.
/// Adapted 1:1 from PhotosTransition/Helpers/PhotoGridView.swift DetailPhotosView.
/// Hero positioning is per-page INSIDE ForEach (not on TabView).
struct ImageGalleryViewer: View {
let state: ImageViewerState
@@ -85,11 +84,8 @@ struct ImageGalleryViewer: View {
@State private var showControls = true
@State private var currentZoomScale: CGFloat = 1.0
@State private var isDismissing = false
/// Hero transition state: false = positioned at source frame, true = fullscreen.
@State private var isExpanded: Bool = false
/// Drag offset for interactive pan-to-dismiss.
@State private var dragOffset: CGSize = .zero
/// Full screen dimensions (captured from geometry).
@State private var viewSize: CGSize = UIScreen.main.bounds.size
private static let dateFormatter: DateFormatter = {
@@ -108,95 +104,69 @@ struct ImageGalleryViewer: View {
state.images.indices.contains(currentPage) ? state.images[currentPage] : nil
}
/// Whether the source frame is valid for hero animation (non-zero).
private var hasHeroSource: Bool {
state.sourceFrame.width > 0 && state.sourceFrame.height > 0
}
/// Hero animation spring matches PhotosTransition reference.
private var heroAnimation: Animation {
.interpolatingSpring(duration: 0.3, bounce: 0, initialVelocity: 0)
}
/// Opacity that decreases as user drags further from center.
private var interactiveOpacity: CGFloat {
let opacityY = abs(dragOffset.height) / (viewSize.height * 0.3)
return max(1 - opacityY, 0)
return isExpanded ? max(1 - opacityY, 0) : 0
}
var body: some View {
let sourceFrame = state.sourceFrame
ZStack {
// Background fades with hero expansion and drag progress
Color.black
.opacity(isExpanded ? interactiveOpacity : 0)
// Hero positioning per-page inside ForEach matches reference exactly
TabView(selection: $currentPage) {
ForEach(Array(state.images.enumerated()), id: \.element.attachmentId) { index, info in
ZoomableImagePage(
attachmentId: info.attachmentId,
onDismiss: { dismissAction() },
showControls: $showControls,
currentScale: $currentZoomScale,
onEdgeTap: { direction in navigateEdgeTap(direction: direction) }
)
.frame(
width: isExpanded ? viewSize.width : sourceFrame.width,
height: isExpanded ? viewSize.height : sourceFrame.height
)
.clipped()
.offset(
x: isExpanded ? 0 : sourceFrame.minX,
y: isExpanded ? 0 : sourceFrame.minY
)
.offset(dragOffset)
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: isExpanded ? .center : .topLeading
)
.tag(index)
.ignoresSafeArea()
// Pager with hero positioning
TabView(selection: $currentPage) {
ForEach(Array(state.images.enumerated()), id: \.element.attachmentId) { index, info in
ZoomableImagePage(
attachmentId: info.attachmentId,
onDismiss: { dismiss() },
showControls: $showControls,
currentScale: $currentZoomScale,
onEdgeTap: { direction in
navigateEdgeTap(direction: direction)
}
)
.tag(index)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.scrollDisabled(currentZoomScale > 1.05 || isDismissing)
// Hero frame: source rect when collapsed, full screen when expanded
.frame(
width: isExpanded ? viewSize.width : (hasHeroSource ? sourceFrame.width : viewSize.width),
height: isExpanded ? viewSize.height : (hasHeroSource ? sourceFrame.height : viewSize.height)
)
.clipped()
.offset(
x: isExpanded ? 0 : (hasHeroSource ? sourceFrame.minX : 0),
y: isExpanded ? 0 : (hasHeroSource ? sourceFrame.minY : 0)
)
.offset(dragOffset)
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: isExpanded ? .center : (hasHeroSource ? .topLeading : .center)
)
.ignoresSafeArea()
// Interactive drag gesture for hero dismiss (vertical only, when not zoomed)
.simultaneousGesture(
currentZoomScale <= 1.05 ?
DragGesture(minimumDistance: 40)
.onChanged { value in
let dy = abs(value.translation.height)
let dx = abs(value.translation.width)
guard dy > dx * 2.0 else { return }
dragOffset = .init(width: value.translation.width, height: value.translation.height)
}
.onEnded { value in
if dragOffset.height > 50 {
heroDismiss()
} else {
withAnimation(heroAnimation.speed(1.2)) {
dragOffset = .zero
}
}
}
: nil
)
// Controls overlay fades with hero expansion
controlsOverlay
.opacity(isExpanded ? 1 : 0)
.opacity(interactiveOpacity)
}
.statusBarHidden(true)
.tabViewStyle(.page(indexDisplayMode: .never))
.ignoresSafeArea()
.scrollDisabled(currentZoomScale > 1.05 || isDismissing)
.contentShape(Rectangle())
.overlay {
// Pan gesture overlay UIKit gesture for iOS 17+ compat
HeroPanGestureOverlay { gesture in
handlePanGesture(gesture)
}
.allowsHitTesting(isExpanded && currentZoomScale <= 1.05)
}
.overlay {
overlayActions
}
.background {
Color.black
.opacity(interactiveOpacity)
.opacity(isExpanded ? 1 : 0)
.ignoresSafeArea()
}
.allowsHitTesting(isExpanded)
.onGeometryChange(for: CGSize.self, of: { $0.size }) { viewSize = $0 }
.statusBarHidden(true)
.task {
prefetchAdjacentImages(around: state.initialIndex)
guard !isExpanded else { return }
@@ -209,101 +179,95 @@ struct ImageGalleryViewer: View {
}
}
// MARK: - Controls Overlay
// MARK: - Pan Gesture
private func handlePanGesture(_ gesture: UIPanGestureRecognizer) {
let panState = gesture.state
let translation = gesture.translation(in: gesture.view)
if panState == .began || panState == .changed {
dragOffset = .init(width: translation.x, height: translation.y)
} else {
if dragOffset.height > 50 {
heroDismiss()
} else {
withAnimation(heroAnimation.speed(1.2)) {
dragOffset = .zero
}
}
}
}
// MARK: - Overlay Actions (matches PhotosTransition/ContentView.swift OverlayActionView)
@ViewBuilder
private var controlsOverlay: some View {
VStack(spacing: 0) {
if showControls && !isDismissing {
topBar
.transition(.move(edge: .top).combined(with: .opacity))
}
Spacer()
if showControls && !isDismissing {
bottomBar
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(.easeOut(duration: 0.2), value: showControls)
}
private var overlayActions: some View {
let overlayOpacity: CGFloat = 1 - min(abs(dragOffset.height / 30), 1)
// MARK: - Top Bar
if showControls && !isDismissing && isExpanded {
VStack {
// Top actions
HStack {
glassButton(systemName: "chevron.left") { dismissAction() }
private var topBar: some View {
HStack(spacing: 8) {
Button { dismiss() } label: {
Image(systemName: "chevron.left")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(.white)
.frame(width: 44, height: 44)
}
Spacer(minLength: 0)
if let info = currentInfo {
VStack(alignment: .leading, spacing: 1) {
Text(info.senderName)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.lineLimit(1)
Text(Self.dateFormatter.string(from: info.timestamp))
.font(.system(size: 13))
.foregroundStyle(.white.opacity(0.7))
if state.images.count > 1 {
glassLabel("\(currentPage + 1) / \(state.images.count)")
}
}
}
Spacer()
if state.images.count > 1 {
Text("\(currentPage + 1) / \(state.images.count)")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.8))
.padding(.trailing, 8)
}
}
.padding(.horizontal, 4)
.padding(.vertical, 8)
.background(Color.black.opacity(0.5).ignoresSafeArea(edges: .top))
}
// MARK: - Bottom Bar
private var bottomBar: some View {
VStack(spacing: 0) {
if let caption = currentInfo?.caption, !caption.isEmpty {
Text(caption)
.font(.system(size: 15))
.foregroundStyle(.white)
.lineLimit(4)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.black.opacity(0.5))
}
HStack(spacing: 32) {
Button { shareCurrentImage() } label: {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(.white)
.frame(width: 44, height: 44)
.overlay {
if let info = currentInfo {
glassLabel(info.senderName)
.contentTransition(.numericText())
.animation(.easeInOut, value: currentPage)
.frame(maxWidth: .infinity)
}
}
Spacer()
Spacer(minLength: 0)
Button { saveCurrentImage() } label: {
Image(systemName: "square.and.arrow.down")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(.white)
.frame(width: 44, height: 44)
// Bottom actions
HStack {
glassButton(systemName: "square.and.arrow.up.fill") { shareCurrentImage() }
Spacer(minLength: 0)
glassButton(systemName: "square.and.arrow.down") { saveCurrentImage() }
}
}
.padding(.horizontal, 24)
.padding(.bottom, 8)
.background(Color.black.opacity(0.5).ignoresSafeArea(edges: .bottom))
.padding(.horizontal, 15)
.compositingGroup()
.opacity(overlayOpacity)
.environment(\.colorScheme, .dark)
.transition(.opacity)
.animation(.easeOut(duration: 0.2), value: showControls)
}
}
// MARK: - Edge Tap Navigation
// MARK: - Glass Button / Label helpers
private func glassButton(systemName: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: systemName)
.font(.title3)
.foregroundStyle(.white)
.frame(width: 36, height: 36)
}
.background { TelegramGlassCircle() }
}
private func glassLabel(_ text: String) -> some View {
Text(text)
.font(.callout)
.foregroundStyle(.white)
.lineLimit(1)
.padding(.horizontal, 15)
.padding(.vertical, 10)
.background { TelegramGlassCapsule() }
}
// MARK: - Navigation
private func navigateEdgeTap(direction: Int) {
let target = currentPage + direction
@@ -313,8 +277,7 @@ struct ImageGalleryViewer: View {
// MARK: - Dismiss
/// Unified dismiss: hero collapse when not zoomed, fade when zoomed.
private func dismiss() {
private func dismissAction() {
if currentZoomScale > 1.05 {
fadeDismiss()
} else {
@@ -322,7 +285,6 @@ struct ImageGalleryViewer: View {
}
}
/// Hero collapse back to source frame.
private func heroDismiss() {
guard !isDismissing else { return }
isDismissing = true
@@ -337,7 +299,6 @@ struct ImageGalleryViewer: View {
}
}
/// Fallback fade dismiss when zoomed.
private func fadeDismiss() {
guard !isDismissing else { return }
isDismissing = true
@@ -402,3 +363,69 @@ struct ImageGalleryViewer: View {
}
}
// MARK: - HeroPanGestureOverlay
/// Transparent UIView overlay with UIPanGestureRecognizer for vertical hero dismiss.
/// Uses UIKit gesture (not UIGestureRecognizerRepresentable) for iOS 17+ compat.
/// Matches PanGesture from PhotosTransition reference vertical only, single touch.
private struct HeroPanGestureOverlay: UIViewRepresentable {
var onPan: (UIPanGestureRecognizer) -> Void
func makeUIView(context: Context) -> UIView {
let view = UIView()
view.backgroundColor = .clear
let pan = UIPanGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handlePan(_:)))
pan.minimumNumberOfTouches = 1
pan.maximumNumberOfTouches = 1
pan.delegate = context.coordinator
view.addGestureRecognizer(pan)
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
context.coordinator.onPan = onPan
}
func makeCoordinator() -> Coordinator { Coordinator(onPan: onPan) }
final class Coordinator: NSObject, UIGestureRecognizerDelegate {
var onPan: (UIPanGestureRecognizer) -> Void
init(onPan: @escaping (UIPanGestureRecognizer) -> Void) {
self.onPan = onPan
}
@objc func handlePan(_ gesture: UIPanGestureRecognizer) {
onPan(gesture)
}
// Only begin for downward vertical drags
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return false }
let velocity = pan.velocity(in: pan.view)
return velocity.y > abs(velocity.x)
}
// Let TabView scroll pass through when scrolled down
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
if let scrollView = otherGestureRecognizer.view as? UIScrollView {
return scrollView.contentOffset.y <= 0
}
return false
}
// Allow simultaneous recognition with other gestures (taps, pinch, etc.)
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
return !(otherGestureRecognizer is UIPanGestureRecognizer)
}
}
}