feat: группы — inline карточка приглашения (Desktop/Android parity) + навигация pop→push fix
This commit is contained in:
@@ -39,7 +39,7 @@ final class GroupRepository {
|
||||
let description: String
|
||||
}
|
||||
|
||||
struct ParsedGroupInvite {
|
||||
struct ParsedGroupInvite: Sendable {
|
||||
let groupId: String
|
||||
let title: String
|
||||
let encryptKey: String
|
||||
@@ -53,6 +53,11 @@ final class GroupRepository {
|
||||
}
|
||||
|
||||
func normalizeGroupId(_ value: String) -> String {
|
||||
Self.normalizeGroupIdPure(value)
|
||||
}
|
||||
|
||||
/// Thread-safe group ID normalization — strips `#group:`, `group:`, `conversation:` prefixes.
|
||||
nonisolated static func normalizeGroupIdPure(_ value: String) -> String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let lower = trimmed.lowercased()
|
||||
if lower.hasPrefix("#group:") {
|
||||
@@ -193,6 +198,11 @@ final class GroupRepository {
|
||||
|
||||
/// Parses an invite string into its components.
|
||||
func parseInviteString(_ inviteString: String) -> ParsedGroupInvite? {
|
||||
Self.parseInviteStringPure(inviteString)
|
||||
}
|
||||
|
||||
/// Thread-safe (nonisolated) parsing — callable from background layout threads.
|
||||
nonisolated static func parseInviteStringPure(_ inviteString: String) -> ParsedGroupInvite? {
|
||||
let trimmed = inviteString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let lower = trimmed.lowercased()
|
||||
|
||||
@@ -211,7 +221,7 @@ final class GroupRepository {
|
||||
|
||||
guard let decryptedPayload = try? CryptoManager.shared.decryptWithPassword(
|
||||
encodedPayload,
|
||||
password: Self.groupInvitePassword
|
||||
password: groupInvitePassword
|
||||
), let payload = String(data: decryptedPayload, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
@@ -219,7 +229,7 @@ final class GroupRepository {
|
||||
let parts = payload.split(separator: ":", omittingEmptySubsequences: false).map(String.init)
|
||||
guard parts.count >= 3 else { return nil }
|
||||
|
||||
let groupId = normalizeGroupId(parts[0])
|
||||
let groupId = normalizeGroupIdPure(parts[0])
|
||||
guard !groupId.isEmpty else { return nil }
|
||||
|
||||
return ParsedGroupInvite(
|
||||
@@ -230,6 +240,13 @@ final class GroupRepository {
|
||||
)
|
||||
}
|
||||
|
||||
/// Fast local membership check — uses in-memory keyCache. MainActor only.
|
||||
func hasGroup(for groupId: String) -> Bool {
|
||||
let account = SessionManager.shared.currentPublicKey
|
||||
let key = cacheKey(account: account, groupId: groupId)
|
||||
return keyCache[key] != nil
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
/// Persists group from a joined `PacketGroupJoin` (server-pushed or self-initiated).
|
||||
|
||||
@@ -60,6 +60,12 @@ struct MessageCellLayout: Sendable {
|
||||
let hasFile: Bool
|
||||
let fileFrame: CGRect // File view frame in bubble coords
|
||||
|
||||
// MARK: - Group Invite (optional)
|
||||
|
||||
let hasGroupInvite: Bool
|
||||
let groupInviteTitle: String
|
||||
let groupInviteGroupId: String
|
||||
|
||||
// MARK: - Forward Header (optional)
|
||||
|
||||
let isForward: Bool
|
||||
@@ -90,6 +96,7 @@ struct MessageCellLayout: Sendable {
|
||||
case file
|
||||
case forward
|
||||
case emojiOnly
|
||||
case groupInvite
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +126,9 @@ extension MessageCellLayout {
|
||||
let forwardCaption: String?
|
||||
let showsDateHeader: Bool
|
||||
let dateHeaderText: String
|
||||
let groupInviteCount: Int
|
||||
let groupInviteTitle: String
|
||||
let groupInviteGroupId: String
|
||||
}
|
||||
|
||||
private struct MediaDimensions {
|
||||
@@ -186,6 +196,8 @@ extension MessageCellLayout {
|
||||
messageType = .photo
|
||||
} else if config.fileCount > 0 || config.avatarCount > 0 || config.callCount > 0 {
|
||||
messageType = .file
|
||||
} else if config.groupInviteCount > 0 {
|
||||
messageType = .groupInvite
|
||||
} else if config.hasReplyQuote {
|
||||
messageType = .textWithReply
|
||||
} else {
|
||||
@@ -468,13 +480,24 @@ extension MessageCellLayout {
|
||||
fileOnlyTsPad = tsPad
|
||||
bubbleH += tsGap + tsSize.height + tsPad
|
||||
fileH = bubbleH // fileContainer spans entire bubble
|
||||
} else if config.groupInviteCount > 0 {
|
||||
// Group invite card: icon row + status + button
|
||||
let inviteCardH: CGFloat = 80
|
||||
let inviteMinW: CGFloat = 220
|
||||
bubbleW = min(inviteMinW, effectiveMaxBubbleWidth)
|
||||
bubbleW = max(bubbleW, leftPad + metadataWidth + rightPad)
|
||||
let tsGap: CGFloat = 6
|
||||
let contentH: CGFloat = 60
|
||||
let tsPad = ceil((inviteCardH + tsGap - contentH) / 2)
|
||||
fileOnlyTsPad = tsPad
|
||||
bubbleH += inviteCardH + tsGap + tsSize.height + tsPad
|
||||
} else {
|
||||
// No text, no file (forward header only, empty)
|
||||
bubbleW = leftPad + metadataWidth + rightPad
|
||||
bubbleW = max(minW, min(bubbleW, effectiveMaxBubbleWidth))
|
||||
}
|
||||
|
||||
if config.text.isEmpty && photoH == 0 && fileH == 0 && !config.isForward {
|
||||
if config.text.isEmpty && photoH == 0 && fileH == 0 && !config.isForward && config.groupInviteCount == 0 {
|
||||
bubbleH = max(bubbleH, 37)
|
||||
}
|
||||
// Forward header needs minimum width for "Forwarded from" + avatar + name
|
||||
@@ -672,6 +695,9 @@ extension MessageCellLayout {
|
||||
photoCollageHeight: photoH,
|
||||
hasFile: config.fileCount > 0 || config.avatarCount > 0 || config.callCount > 0,
|
||||
fileFrame: fileFrame,
|
||||
hasGroupInvite: config.groupInviteCount > 0,
|
||||
groupInviteTitle: config.groupInviteTitle,
|
||||
groupInviteGroupId: config.groupInviteGroupId,
|
||||
isForward: config.isForward,
|
||||
forwardHeaderFrame: fwdHeaderFrame,
|
||||
forwardAvatarFrame: fwdAvatarFrame,
|
||||
@@ -1001,13 +1027,24 @@ extension MessageCellLayout {
|
||||
AttachmentPreviewCodec.imageDimensions(from: $0.preview)
|
||||
}
|
||||
|
||||
// Detect group invite (only for text-only messages, no attachments)
|
||||
let isGroupInvite = displayText.hasPrefix("#group:")
|
||||
&& images.isEmpty && files.isEmpty && avatars.isEmpty && calls.isEmpty && !isForward
|
||||
var groupInviteTitle = ""
|
||||
var groupInviteGroupId = ""
|
||||
if isGroupInvite, let parsed = GroupRepository.parseInviteStringPure(displayText) {
|
||||
groupInviteTitle = parsed.title
|
||||
groupInviteGroupId = parsed.groupId
|
||||
}
|
||||
let groupInviteCount = (!groupInviteTitle.isEmpty) ? 1 : 0
|
||||
|
||||
let config = Config(
|
||||
maxBubbleWidth: maxBubbleWidth,
|
||||
isOutgoing: isOutgoing,
|
||||
isDarkMode: isDarkMode,
|
||||
position: position,
|
||||
deliveryStatus: message.deliveryStatus,
|
||||
text: isForward ? (forwardCaption ?? "") : displayText,
|
||||
text: isForward ? (forwardCaption ?? "") : (groupInviteCount > 0 ? "" : displayText),
|
||||
timestampText: timestampText,
|
||||
hasReplyQuote: hasReply && !displayText.isEmpty,
|
||||
replyName: nil,
|
||||
@@ -1022,7 +1059,10 @@ extension MessageCellLayout {
|
||||
forwardFileCount: forwardInnerFileCount,
|
||||
forwardCaption: forwardCaption,
|
||||
showsDateHeader: showsDateHeader,
|
||||
dateHeaderText: dateHeaderText
|
||||
dateHeaderText: dateHeaderText,
|
||||
groupInviteCount: groupInviteCount,
|
||||
groupInviteTitle: groupInviteTitle,
|
||||
groupInviteGroupId: groupInviteGroupId
|
||||
)
|
||||
|
||||
var (layout, textLayout) = calculate(config: config)
|
||||
|
||||
@@ -255,6 +255,16 @@ struct ChatDetailView: View {
|
||||
pendingGroupInviteTitle = parsed.title
|
||||
}
|
||||
}
|
||||
cellActions.onGroupInviteOpen = { dialogKey in
|
||||
let title = GroupRepository.shared.groupMetadata(
|
||||
account: SessionManager.shared.currentPublicKey,
|
||||
groupDialogKey: dialogKey
|
||||
)?.title ?? ""
|
||||
NotificationCenter.default.post(
|
||||
name: .openChatFromNotification,
|
||||
object: ChatRoute(groupDialogKey: dialogKey, title: title)
|
||||
)
|
||||
}
|
||||
// Capture first unread incoming message BEFORE marking as read.
|
||||
if firstUnreadMessageId == nil {
|
||||
firstUnreadMessageId = messages.first(where: {
|
||||
|
||||
113
Rosetta/Features/Chats/ChatDetail/GroupInviteCardView.swift
Normal file
113
Rosetta/Features/Chats/ChatDetail/GroupInviteCardView.swift
Normal file
@@ -0,0 +1,113 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Inline card for group invite links — renders inside message bubble (iOS 26+ SwiftUI path).
|
||||
/// Matches Desktop `GroupInviteMessage` and Android `GroupInviteInlineCard` behavior.
|
||||
struct GroupInviteCardView: View {
|
||||
let inviteString: String
|
||||
let title: String
|
||||
let groupId: String
|
||||
let isOutgoing: Bool
|
||||
let actions: MessageCellActions
|
||||
|
||||
@State private var status: CardStatus = .notJoined
|
||||
|
||||
enum CardStatus {
|
||||
case notJoined, joined, invalid, banned
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .notJoined: Color(red: 0.14, green: 0.54, blue: 0.90)
|
||||
case .joined: Color(red: 0.20, green: 0.78, blue: 0.35)
|
||||
case .invalid, .banned: Color(red: 0.98, green: 0.23, blue: 0.19)
|
||||
}
|
||||
}
|
||||
|
||||
var statusText: String {
|
||||
switch self {
|
||||
case .notJoined: "Invite to join this group"
|
||||
case .joined: "You are a member"
|
||||
case .invalid: "This invite is invalid"
|
||||
case .banned: "You are banned"
|
||||
}
|
||||
}
|
||||
|
||||
var buttonTitle: String {
|
||||
switch self {
|
||||
case .notJoined: "Join Group"
|
||||
case .joined: "Open Group"
|
||||
case .invalid: "Invalid"
|
||||
case .banned: "Banned"
|
||||
}
|
||||
}
|
||||
|
||||
var isActionable: Bool {
|
||||
self == .notJoined || self == .joined
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Circle()
|
||||
.fill(status.color)
|
||||
.frame(width: 44, height: 44)
|
||||
.overlay {
|
||||
Image(systemName: "person.2.fill")
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title.isEmpty ? "Group" : title)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(isOutgoing ? .white : Color(status.color))
|
||||
.lineLimit(1)
|
||||
|
||||
Text(status.statusText)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(isOutgoing ? .white.opacity(0.7) : .secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
Button(action: handleTap) {
|
||||
Text(status.buttonTitle)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 5)
|
||||
.background(status.color, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!status.isActionable)
|
||||
.opacity(status.isActionable ? 1.0 : 0.5)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.task { await checkStatus() }
|
||||
}
|
||||
|
||||
private func handleTap() {
|
||||
if status == .joined {
|
||||
let dialogKey = "#group:\(groupId)"
|
||||
actions.onGroupInviteOpen(dialogKey)
|
||||
} else if status == .notJoined {
|
||||
actions.onGroupInviteTap(inviteString)
|
||||
}
|
||||
}
|
||||
|
||||
private func checkStatus() async {
|
||||
// Local check first
|
||||
let isJoined = GroupRepository.shared.hasGroup(for: groupId)
|
||||
if isJoined { status = .joined }
|
||||
|
||||
// Server check for authoritative status
|
||||
if let (_, serverStatus) = try? await GroupService.shared.checkInviteStatus(groupId: groupId) {
|
||||
let newStatus: CardStatus = switch serverStatus {
|
||||
case .joined: .joined
|
||||
case .notJoined: .notJoined
|
||||
case .invalid: .invalid
|
||||
case .banned: .banned
|
||||
}
|
||||
status = newStatus
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,18 +5,14 @@ import Photos
|
||||
|
||||
// MARK: - Data Types
|
||||
|
||||
/// Per-image metadata for the gallery viewer.
|
||||
/// Android parity: `ViewableImage` in `ImageViewerScreen.kt`.
|
||||
struct ViewableImageInfo: Equatable, Identifiable {
|
||||
let attachmentId: String
|
||||
let senderName: String
|
||||
let timestamp: Date
|
||||
let caption: String
|
||||
|
||||
var id: String { attachmentId }
|
||||
}
|
||||
|
||||
/// State for the image gallery viewer.
|
||||
struct ImageViewerState: Equatable {
|
||||
let images: [ViewableImageInfo]
|
||||
let initialIndex: Int
|
||||
@@ -28,16 +24,10 @@ struct ImageViewerState: Equatable {
|
||||
/// Manages the vertical pan gesture for gallery dismiss.
|
||||
/// Attached to the hosting controller's view (NOT as a SwiftUI overlay) so it
|
||||
/// doesn't block SwiftUI gestures (pinch zoom, taps) on the content below.
|
||||
/// Previous approach: `HeroPanGestureOverlay` (UIViewRepresentable overlay) blocked
|
||||
/// all SwiftUI gestures at the hit-test level — pinch zoom and double-tap never worked.
|
||||
final class GalleryDismissPanCoordinator: NSObject, ObservableObject, UIGestureRecognizerDelegate {
|
||||
/// Current vertical drag offset during dismiss gesture.
|
||||
@Published var dragOffset: CGSize = .zero
|
||||
/// Toggles on every pan-end event — use `.onChange` to react.
|
||||
@Published private(set) var panEndSignal: Bool = false
|
||||
/// Y velocity at the moment the pan gesture ended (pt/s).
|
||||
private(set) var endVelocityY: CGFloat = 0
|
||||
/// Set to false to disable the dismiss gesture (e.g. when zoomed in).
|
||||
var isEnabled: Bool = true
|
||||
|
||||
@objc func handlePan(_ gesture: UIPanGestureRecognizer) {
|
||||
@@ -45,49 +35,38 @@ final class GalleryDismissPanCoordinator: NSObject, ObservableObject, UIGestureR
|
||||
if gesture.state == .began { gesture.state = .cancelled }
|
||||
return
|
||||
}
|
||||
let translation = gesture.translation(in: gesture.view)
|
||||
let t = gesture.translation(in: gesture.view)
|
||||
switch gesture.state {
|
||||
case .began, .changed:
|
||||
// Vertical only — Telegram parity (no diagonal drag).
|
||||
dragOffset = CGSize(width: 0, height: translation.y)
|
||||
dragOffset = CGSize(width: 0, height: t.y)
|
||||
case .ended, .cancelled:
|
||||
endVelocityY = gesture.velocity(in: gesture.view).y
|
||||
panEndSignal.toggle()
|
||||
default:
|
||||
break
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
// Only begin for downward vertical drags.
|
||||
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
guard isEnabled else { return false }
|
||||
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return false }
|
||||
func gestureRecognizerShouldBegin(_ g: UIGestureRecognizer) -> Bool {
|
||||
guard isEnabled, let pan = g as? UIPanGestureRecognizer else { return false }
|
||||
let v = pan.velocity(in: pan.view)
|
||||
return v.y > abs(v.x)
|
||||
}
|
||||
|
||||
// Allow simultaneous recognition with non-pan gestures (pinch, taps).
|
||||
func gestureRecognizer(
|
||||
_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer
|
||||
) -> Bool {
|
||||
!(other is UIPanGestureRecognizer)
|
||||
func gestureRecognizer(_ g: UIGestureRecognizer,
|
||||
shouldRecognizeSimultaneouslyWith o: UIGestureRecognizer) -> Bool {
|
||||
!(o is UIPanGestureRecognizer)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ImageViewerPresenter
|
||||
|
||||
/// UIHostingController subclass that hides the status bar.
|
||||
private final class StatusBarHiddenHostingController: UIHostingController<AnyView> {
|
||||
override var prefersStatusBarHidden: Bool { true }
|
||||
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation { .fade }
|
||||
}
|
||||
|
||||
/// Presents the image gallery viewer using UIKit `overFullScreen` presentation.
|
||||
/// Telegram parity: the viewer appears as a fade overlay covering nav bar and tab bar.
|
||||
@MainActor
|
||||
final class ImageViewerPresenter {
|
||||
|
||||
static let shared = ImageViewerPresenter()
|
||||
private weak var presentedController: UIViewController?
|
||||
private var panCoordinator: GalleryDismissPanCoordinator?
|
||||
@@ -104,13 +83,10 @@ final class ImageViewerPresenter {
|
||||
onDismiss: { [weak self] in self?.dismiss() }
|
||||
)
|
||||
|
||||
let hostingController = StatusBarHiddenHostingController(rootView: AnyView(viewer))
|
||||
hostingController.modalPresentationStyle = .overFullScreen
|
||||
hostingController.view.backgroundColor = .clear
|
||||
let hc = StatusBarHiddenHostingController(rootView: AnyView(viewer))
|
||||
hc.modalPresentationStyle = .overFullScreen
|
||||
hc.view.backgroundColor = .clear
|
||||
|
||||
// Pan gesture on hosting controller's view — NOT a SwiftUI overlay.
|
||||
// UIKit gesture recognizers on a hosting view coexist with SwiftUI gestures
|
||||
// on child views (pinch, taps, TabView swipe) without blocking them.
|
||||
let pan = UIPanGestureRecognizer(
|
||||
target: coordinator,
|
||||
action: #selector(GalleryDismissPanCoordinator.handlePan)
|
||||
@@ -118,18 +94,15 @@ final class ImageViewerPresenter {
|
||||
pan.minimumNumberOfTouches = 1
|
||||
pan.maximumNumberOfTouches = 1
|
||||
pan.delegate = coordinator
|
||||
hostingController.view.addGestureRecognizer(pan)
|
||||
hc.view.addGestureRecognizer(pan)
|
||||
|
||||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let root = windowScene.keyWindow?.rootViewController
|
||||
else { return }
|
||||
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let root = scene.keyWindow?.rootViewController else { return }
|
||||
|
||||
var presenter = root
|
||||
while let presented = presenter.presentedViewController {
|
||||
presenter = presented
|
||||
}
|
||||
presenter.present(hostingController, animated: false)
|
||||
presentedController = hostingController
|
||||
while let p = presenter.presentedViewController { presenter = p }
|
||||
presenter.present(hc, animated: false)
|
||||
presentedController = hc
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
@@ -139,11 +112,16 @@ final class ImageViewerPresenter {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Window Safe Area Helper
|
||||
|
||||
private var windowSafeArea: UIEdgeInsets {
|
||||
UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.first?.keyWindow?.safeAreaInsets ?? .zero
|
||||
}
|
||||
|
||||
// MARK: - ImageGalleryViewer
|
||||
|
||||
/// Multi-photo gallery viewer with hero transition animation.
|
||||
/// Telegram parity: hero open/close, vertical-only interactive dismiss,
|
||||
/// slide-in panels, counter below name capsule, pinch zoom, double-tap zoom.
|
||||
struct ImageGalleryViewer: View {
|
||||
|
||||
let state: ImageViewerState
|
||||
@@ -155,13 +133,13 @@ struct ImageGalleryViewer: View {
|
||||
@State private var currentZoomScale: CGFloat = 1.0
|
||||
@State private var isDismissing = false
|
||||
@State private var isExpanded: Bool = false
|
||||
@State private var viewSize: CGSize = UIScreen.main.bounds.size
|
||||
|
||||
private let screenSize = UIScreen.main.bounds.size
|
||||
|
||||
private static let dateFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateStyle = .none
|
||||
f.timeStyle = .short
|
||||
f.doesRelativeDateFormatting = true
|
||||
return f
|
||||
}()
|
||||
|
||||
@@ -188,102 +166,75 @@ struct ImageGalleryViewer: View {
|
||||
.interpolatingSpring(duration: 0.3, bounce: 0, initialVelocity: 0)
|
||||
}
|
||||
|
||||
/// Background opacity: fades over 80pt drag (Telegram: `abs(distance) / 80`).
|
||||
private var backgroundOpacity: CGFloat {
|
||||
let progress = min(abs(panCoordinator.dragOffset.height) / 80, 1)
|
||||
return isExpanded ? max(1 - progress, 0) : 0
|
||||
}
|
||||
|
||||
/// Overlay/toolbar opacity: fades over 50pt drag (Telegram: `abs(distance) / 50`).
|
||||
private var overlayDragOpacity: CGFloat {
|
||||
1 - min(abs(panCoordinator.dragOffset.height) / 50, 1)
|
||||
}
|
||||
|
||||
private func formattedDate(_ date: Date) -> String {
|
||||
let dayPart = Self.relativeDateFormatter.string(from: date)
|
||||
let timePart = Self.dateFormatter.string(from: date)
|
||||
return "\(dayPart) at \(timePart)"
|
||||
let day = Self.relativeDateFormatter.string(from: date)
|
||||
let time = Self.dateFormatter.string(from: date)
|
||||
return "\(day) at \(time)"
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
let sourceFrame = state.sourceFrame
|
||||
let sf = state.sourceFrame
|
||||
|
||||
GeometryReader { geometry in
|
||||
let size = geometry.size
|
||||
TabView(selection: $currentPage) {
|
||||
ForEach(Array(state.images.enumerated()), id: \.element.attachmentId) { index, info in
|
||||
let isHeroPage = index == state.initialIndex
|
||||
let heroActive = isHeroPage && !isExpanded
|
||||
|
||||
TabView(selection: $currentPage) {
|
||||
ForEach(Array(state.images.enumerated()), id: \.element.attachmentId) { index, info in
|
||||
// Hero frame/offset only on the initial page — other pages are
|
||||
// always fullscreen. Prevents glitch where lazily-created pages
|
||||
// briefly render at sourceFrame size during TabView swipe.
|
||||
let isHeroPage = index == state.initialIndex
|
||||
let heroActive = isHeroPage && !isExpanded
|
||||
|
||||
ZoomableImagePage(
|
||||
attachmentId: info.attachmentId,
|
||||
onDismiss: { dismissAction() },
|
||||
showControls: $showControls,
|
||||
currentScale: $currentZoomScale,
|
||||
onEdgeTap: { direction in navigateEdgeTap(direction: direction) }
|
||||
)
|
||||
.frame(
|
||||
width: heroActive ? sourceFrame.width : size.width,
|
||||
height: heroActive ? sourceFrame.height : size.height
|
||||
)
|
||||
.clipped()
|
||||
.offset(
|
||||
x: heroActive ? sourceFrame.minX : 0,
|
||||
y: heroActive ? sourceFrame.minY : 0
|
||||
)
|
||||
.offset(panCoordinator.dragOffset)
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
maxHeight: .infinity,
|
||||
alignment: heroActive ? .topLeading : .center
|
||||
)
|
||||
.tag(index)
|
||||
}
|
||||
ZoomableImagePage(
|
||||
attachmentId: info.attachmentId,
|
||||
onDismiss: { dismissAction() },
|
||||
showControls: $showControls,
|
||||
currentScale: $currentZoomScale,
|
||||
onEdgeTap: { dir in navigateEdgeTap(direction: dir) }
|
||||
)
|
||||
// Hero page: animate from sourceFrame → fullscreen.
|
||||
// Non-hero pages: NO explicit frame — fill TabView page naturally.
|
||||
.modifier(GalleryPageModifier(
|
||||
heroActive: heroActive,
|
||||
sourceFrame: sf,
|
||||
fullSize: screenSize,
|
||||
dragOffset: panCoordinator.dragOffset
|
||||
))
|
||||
.tag(index)
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.scrollDisabled(currentZoomScale > 1.05 || isDismissing)
|
||||
.contentShape(Rectangle())
|
||||
.overlay { galleryOverlay }
|
||||
.background {
|
||||
Color.black
|
||||
.opacity(backgroundOpacity)
|
||||
}
|
||||
.allowsHitTesting(isExpanded)
|
||||
.onAppear { viewSize = size }
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.ignoresSafeArea()
|
||||
.scrollDisabled(currentZoomScale > 1.05 || isDismissing)
|
||||
.contentShape(Rectangle())
|
||||
.overlay { galleryOverlay }
|
||||
.background {
|
||||
Color.black.opacity(backgroundOpacity).ignoresSafeArea()
|
||||
}
|
||||
.allowsHitTesting(isExpanded)
|
||||
.statusBarHidden(true)
|
||||
.task {
|
||||
prefetchAdjacentImages(around: state.initialIndex)
|
||||
guard !isExpanded else { return }
|
||||
withAnimation(heroAnimation) {
|
||||
isExpanded = true
|
||||
}
|
||||
}
|
||||
.onChange(of: currentPage) { _, newPage in
|
||||
prefetchAdjacentImages(around: newPage)
|
||||
}
|
||||
.onChange(of: currentZoomScale) { _, newScale in
|
||||
panCoordinator.isEnabled = newScale <= 1.05
|
||||
}
|
||||
.onChange(of: panCoordinator.panEndSignal) { _, _ in
|
||||
handlePanEnd()
|
||||
withAnimation(heroAnimation) { isExpanded = true }
|
||||
}
|
||||
.onChange(of: currentPage) { _, p in prefetchAdjacentImages(around: p) }
|
||||
.onChange(of: currentZoomScale) { _, s in panCoordinator.isEnabled = s <= 1.05 }
|
||||
.onChange(of: panCoordinator.panEndSignal) { _, _ in handlePanEnd() }
|
||||
}
|
||||
|
||||
// MARK: - Pan End Handler
|
||||
// MARK: - Pan End
|
||||
|
||||
private func handlePanEnd() {
|
||||
let offsetY = panCoordinator.dragOffset.height
|
||||
let velocityY = panCoordinator.endVelocityY
|
||||
// Telegram parity: dismiss on 50pt drag OR fast downward flick (>1000 pt/s).
|
||||
if offsetY > 50 || velocityY > 1000 {
|
||||
let y = panCoordinator.dragOffset.height
|
||||
let v = panCoordinator.endVelocityY
|
||||
if y > 50 || v > 1000 {
|
||||
dismissAction()
|
||||
} else {
|
||||
withAnimation(heroAnimation.speed(1.2)) {
|
||||
@@ -292,13 +243,15 @@ struct ImageGalleryViewer: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Gallery Overlay (Telegram parity)
|
||||
// MARK: - Overlay (Telegram parity)
|
||||
|
||||
@ViewBuilder
|
||||
private var galleryOverlay: some View {
|
||||
let sa = windowSafeArea
|
||||
|
||||
if !isDismissing && isExpanded {
|
||||
ZStack {
|
||||
// Top panel — slides DOWN from above on show, UP on hide
|
||||
// Top panel
|
||||
VStack(spacing: 4) {
|
||||
topPanel
|
||||
if state.images.count > 1 {
|
||||
@@ -307,15 +260,17 @@ struct ImageGalleryViewer: View {
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.offset(y: showControls ? 0 : -120)
|
||||
.padding(.top, sa.top > 0 ? sa.top : 20)
|
||||
.offset(y: showControls ? 0 : -(sa.top + 120))
|
||||
.allowsHitTesting(showControls)
|
||||
|
||||
// Bottom panel — slides UP from below on show, DOWN on hide
|
||||
// Bottom panel
|
||||
VStack {
|
||||
Spacer()
|
||||
bottomPanel
|
||||
.padding(.bottom, sa.bottom > 0 ? sa.bottom : 16)
|
||||
}
|
||||
.offset(y: showControls ? 0 : 120)
|
||||
.offset(y: showControls ? 0 : (sa.bottom + 120))
|
||||
.allowsHitTesting(showControls)
|
||||
}
|
||||
.compositingGroup()
|
||||
@@ -325,15 +280,15 @@ struct ImageGalleryViewer: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Top Panel
|
||||
// MARK: - Top Panel (Telegram parity)
|
||||
|
||||
private var topPanel: some View {
|
||||
HStack {
|
||||
HStack(alignment: .top) {
|
||||
glassCircleButton(systemName: "chevron.left") { dismissAction() }
|
||||
Spacer(minLength: 0)
|
||||
glassCircleButton(systemName: "ellipsis") { }
|
||||
}
|
||||
.overlay {
|
||||
.overlay(alignment: .top) {
|
||||
if let info = currentInfo {
|
||||
VStack(spacing: 2) {
|
||||
Text(info.senderName)
|
||||
@@ -351,11 +306,10 @@ struct ImageGalleryViewer: View {
|
||||
.animation(.easeInOut, value: currentPage)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 15)
|
||||
.padding(.top, 4)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
// MARK: - Counter Badge (below name capsule)
|
||||
// MARK: - Counter
|
||||
|
||||
private var counterBadge: some View {
|
||||
Text("\(currentPage + 1) of \(state.images.count)")
|
||||
@@ -364,32 +318,44 @@ struct ImageGalleryViewer: View {
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
.background { TelegramGlassCapsule() }
|
||||
.padding(.top, 6)
|
||||
.contentTransition(.numericText())
|
||||
.animation(.easeInOut, value: currentPage)
|
||||
}
|
||||
|
||||
// MARK: - Bottom Panel
|
||||
// MARK: - Bottom Panel (Telegram parity)
|
||||
// Telegram: Forward (left) — [draw | caption center] — Delete (right)
|
||||
// Rosetta: Forward — Share — Save — Delete
|
||||
|
||||
private var bottomPanel: some View {
|
||||
HStack {
|
||||
HStack(spacing: 0) {
|
||||
// Forward
|
||||
glassCircleButton(systemName: "arrowshape.turn.up.right") { }
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Share
|
||||
glassCircleButton(systemName: "square.and.arrow.up") { shareCurrentImage() }
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Save to Photos
|
||||
glassCircleButton(systemName: "square.and.arrow.down") { saveCurrentImage() }
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Delete
|
||||
glassCircleButton(systemName: "trash") { }
|
||||
}
|
||||
.padding(.horizontal, 15)
|
||||
.padding(.bottom, 8)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
// MARK: - Glass Circle Button
|
||||
// MARK: - Glass Button
|
||||
|
||||
private func glassCircleButton(systemName: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: systemName)
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
@@ -399,16 +365,14 @@ struct ImageGalleryViewer: View {
|
||||
// MARK: - Navigation
|
||||
|
||||
private func navigateEdgeTap(direction: Int) {
|
||||
let target = currentPage + direction
|
||||
guard target >= 0, target < state.images.count else { return }
|
||||
currentPage = target
|
||||
let t = currentPage + direction
|
||||
guard t >= 0, t < state.images.count else { return }
|
||||
currentPage = t
|
||||
}
|
||||
|
||||
// MARK: - Dismiss
|
||||
|
||||
private func dismissAction() {
|
||||
// Telegram parity: hero-back only for the initially-tapped photo.
|
||||
// If user paged away, the sourceFrame belongs to a different thumbnail — fade instead.
|
||||
if currentZoomScale > 1.05 || currentPage != state.initialIndex {
|
||||
fadeDismiss()
|
||||
} else {
|
||||
@@ -420,7 +384,6 @@ struct ImageGalleryViewer: View {
|
||||
guard !isDismissing else { return }
|
||||
isDismissing = true
|
||||
panCoordinator.isEnabled = false
|
||||
|
||||
Task {
|
||||
withAnimation(heroAnimation.speed(1.2)) {
|
||||
panCoordinator.dragOffset = .zero
|
||||
@@ -435,16 +398,10 @@ struct ImageGalleryViewer: View {
|
||||
guard !isDismissing else { return }
|
||||
isDismissing = true
|
||||
panCoordinator.isEnabled = false
|
||||
|
||||
// Slide down + fade out via dragOffset (drives backgroundOpacity toward 0).
|
||||
// Do NOT set isExpanded=false — that collapses to sourceFrame (wrong thumbnail).
|
||||
withAnimation(.easeOut(duration: 0.25)) {
|
||||
panCoordinator.dragOffset = CGSize(width: 0, height: viewSize.height * 0.4)
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.27) {
|
||||
onDismiss()
|
||||
panCoordinator.dragOffset = CGSize(width: 0, height: screenSize.height * 0.4)
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.27) { onDismiss() }
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
@@ -453,20 +410,14 @@ struct ImageGalleryViewer: View {
|
||||
guard let info = currentInfo,
|
||||
let image = AttachmentCache.shared.loadImage(forAttachmentId: info.attachmentId)
|
||||
else { return }
|
||||
|
||||
let activityVC = UIActivityViewController(activityItems: [image], applicationActivities: nil)
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let root = windowScene.keyWindow?.rootViewController {
|
||||
var presenter = root
|
||||
while let presented = presenter.presentedViewController {
|
||||
presenter = presented
|
||||
}
|
||||
activityVC.popoverPresentationController?.sourceView = presenter.view
|
||||
activityVC.popoverPresentationController?.sourceRect = CGRect(
|
||||
x: presenter.view.bounds.midX, y: presenter.view.bounds.maxY - 50,
|
||||
width: 0, height: 0
|
||||
)
|
||||
presenter.present(activityVC, animated: true)
|
||||
let vc = UIActivityViewController(activityItems: [image], applicationActivities: nil)
|
||||
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let root = scene.keyWindow?.rootViewController {
|
||||
var p = root; while let pp = p.presentedViewController { p = pp }
|
||||
vc.popoverPresentationController?.sourceView = p.view
|
||||
vc.popoverPresentationController?.sourceRect = CGRect(
|
||||
x: p.view.bounds.midX, y: p.view.bounds.maxY - 50, width: 0, height: 0)
|
||||
p.present(vc, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,7 +425,6 @@ struct ImageGalleryViewer: View {
|
||||
guard let info = currentInfo,
|
||||
let image = AttachmentCache.shared.loadImage(forAttachmentId: info.attachmentId)
|
||||
else { return }
|
||||
|
||||
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
|
||||
guard status == .authorized || status == .limited else { return }
|
||||
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
|
||||
@@ -487,13 +437,39 @@ struct ImageGalleryViewer: View {
|
||||
for offset in [-2, -1, 1, 2] {
|
||||
let i = index + offset
|
||||
guard i >= 0, i < state.images.count else { continue }
|
||||
let attachmentId = state.images[i].attachmentId
|
||||
guard AttachmentCache.shared.cachedImage(forAttachmentId: attachmentId) == nil else { continue }
|
||||
let aid = state.images[i].attachmentId
|
||||
guard AttachmentCache.shared.cachedImage(forAttachmentId: aid) == nil else { continue }
|
||||
Task.detached(priority: .utility) {
|
||||
await ImageLoadLimiter.shared.acquire()
|
||||
_ = AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
|
||||
_ = AttachmentCache.shared.loadImage(forAttachmentId: aid)
|
||||
await ImageLoadLimiter.shared.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - GalleryPageModifier
|
||||
|
||||
/// Applies hero transition frame/offset ONLY for the initial page.
|
||||
/// Non-hero pages have NO explicit frame — they fill the TabView page naturally,
|
||||
/// which fixes the "tiny image" bug caused by explicit frame fighting with TabView sizing.
|
||||
private struct GalleryPageModifier: ViewModifier {
|
||||
let heroActive: Bool
|
||||
let sourceFrame: CGRect
|
||||
let fullSize: CGSize
|
||||
let dragOffset: CGSize
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if heroActive {
|
||||
content
|
||||
.frame(width: sourceFrame.width, height: sourceFrame.height)
|
||||
.clipped()
|
||||
.offset(x: sourceFrame.minX, y: sourceFrame.minY)
|
||||
.offset(dragOffset)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
} else {
|
||||
content
|
||||
.offset(dragOffset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,4 +15,5 @@ final class MessageCellActions {
|
||||
var onRemove: (ChatMessage) -> Void = { _ in }
|
||||
var onCall: (String) -> Void = { _ in } // peer public key
|
||||
var onGroupInviteTap: (String) -> Void = { _ in } // invite string
|
||||
var onGroupInviteOpen: (String) -> Void = { _ in } // group dialog key → navigate
|
||||
}
|
||||
|
||||
@@ -59,7 +59,8 @@ struct MessageCellView: View, Equatable {
|
||||
}
|
||||
.modifier(ConditionalSwipeToReply(
|
||||
enabled: !isSavedMessages && !isSystemAccount
|
||||
&& !message.attachments.contains(where: { $0.type == .avatar }),
|
||||
&& !message.attachments.contains(where: { $0.type == .avatar })
|
||||
&& !message.text.hasPrefix("#group:"),
|
||||
onReply: { actions.onReply(message) }
|
||||
))
|
||||
.overlay {
|
||||
@@ -96,6 +97,32 @@ struct MessageCellView: View, Equatable {
|
||||
message: message, reply: reply, outgoing: outgoing,
|
||||
hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position
|
||||
)
|
||||
} else if message.text.hasPrefix("#group:"),
|
||||
let parsed = GroupRepository.parseInviteStringPure(message.text) {
|
||||
GroupInviteCardView(
|
||||
inviteString: message.text,
|
||||
title: parsed.title,
|
||||
groupId: parsed.groupId,
|
||||
isOutgoing: outgoing,
|
||||
actions: actions
|
||||
)
|
||||
.frame(width: min(220, maxBubbleWidth))
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
timestampOverlay(message: message, outgoing: outgoing)
|
||||
}
|
||||
.padding(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
|
||||
.padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
|
||||
.background { bubbleBackground(outgoing: outgoing, position: position) }
|
||||
.overlay {
|
||||
BubbleContextMenuOverlay(
|
||||
items: contextMenuItems(for: message),
|
||||
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
|
||||
isOutgoing: outgoing,
|
||||
replyQuoteHeight: 0,
|
||||
onReplyQuoteTap: nil
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if let reply = replyData {
|
||||
|
||||
@@ -183,6 +183,21 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
private let senderAvatarImageView = UIImageView()
|
||||
private let senderAvatarInitialLabel = UILabel()
|
||||
|
||||
// Group Invite Card
|
||||
private let groupInviteContainer = UIView()
|
||||
private let groupInviteIconBg = UIView()
|
||||
private let groupInviteIcon = UIImageView()
|
||||
private let groupInviteTitleLabel = UILabel()
|
||||
private let groupInviteStatusLabel = UILabel()
|
||||
private let groupInviteButton = UIButton(type: .custom)
|
||||
private var groupInviteString: String?
|
||||
private var currentInviteStatus: InviteCardStatus = .notJoined
|
||||
private var inviteStatusTask: Task<Void, Never>?
|
||||
|
||||
enum InviteCardStatus {
|
||||
case notJoined, joined, invalid, banned
|
||||
}
|
||||
|
||||
// Highlight overlay (scroll-to-message flash)
|
||||
private let highlightOverlay = UIView()
|
||||
|
||||
@@ -434,6 +449,35 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
|
||||
bubbleView.addSubview(fileContainer)
|
||||
|
||||
// Group Invite Card
|
||||
groupInviteIconBg.layer.cornerRadius = 22
|
||||
groupInviteIconBg.clipsToBounds = true
|
||||
groupInviteIcon.image = UIImage(
|
||||
systemName: "person.2.fill",
|
||||
withConfiguration: UIImage.SymbolConfiguration(pointSize: 18, weight: .medium)
|
||||
)
|
||||
groupInviteIcon.tintColor = .white
|
||||
groupInviteIcon.contentMode = .scaleAspectFit
|
||||
groupInviteIconBg.addSubview(groupInviteIcon)
|
||||
groupInviteContainer.addSubview(groupInviteIconBg)
|
||||
|
||||
groupInviteTitleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||
groupInviteTitleLabel.lineBreakMode = .byTruncatingTail
|
||||
groupInviteContainer.addSubview(groupInviteTitleLabel)
|
||||
|
||||
groupInviteStatusLabel.font = .systemFont(ofSize: 12, weight: .regular)
|
||||
groupInviteContainer.addSubview(groupInviteStatusLabel)
|
||||
|
||||
groupInviteButton.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium)
|
||||
groupInviteButton.layer.cornerRadius = 14
|
||||
groupInviteButton.clipsToBounds = true
|
||||
groupInviteButton.contentEdgeInsets = UIEdgeInsets(top: 5, left: 14, bottom: 5, right: 14)
|
||||
groupInviteButton.addTarget(self, action: #selector(groupInviteCardTapped), for: .touchUpInside)
|
||||
groupInviteContainer.addSubview(groupInviteButton)
|
||||
|
||||
groupInviteContainer.isHidden = true
|
||||
bubbleView.addSubview(groupInviteContainer)
|
||||
|
||||
// Listen for avatar download trigger (tap-to-download, Android parity)
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(handleAttachmentDownload(_:)),
|
||||
@@ -822,6 +866,89 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
} else {
|
||||
fileContainer.isHidden = true
|
||||
}
|
||||
|
||||
// Group Invite Card
|
||||
if let layout = currentLayout, layout.hasGroupInvite {
|
||||
groupInviteContainer.isHidden = false
|
||||
textLabel.isHidden = true
|
||||
groupInviteString = message.text
|
||||
|
||||
groupInviteTitleLabel.text = layout.groupInviteTitle.isEmpty ? "Group" : layout.groupInviteTitle
|
||||
|
||||
// Local membership check (fast, MainActor)
|
||||
let isJoined = GroupRepository.shared.hasGroup(for: layout.groupInviteGroupId)
|
||||
applyGroupInviteStatus(isJoined ? .joined : .notJoined, isOutgoing: layout.isOutgoing)
|
||||
|
||||
// Async server check for authoritative status
|
||||
let msgId = message.id, groupId = layout.groupInviteGroupId
|
||||
inviteStatusTask?.cancel()
|
||||
inviteStatusTask = Task { @MainActor [weak self] in
|
||||
guard !Task.isCancelled else { return }
|
||||
guard let self, self.message?.id == msgId else { return }
|
||||
if let (_, status) = try? await GroupService.shared.checkInviteStatus(groupId: groupId),
|
||||
!Task.isCancelled,
|
||||
self.message?.id == msgId {
|
||||
let cardStatus: InviteCardStatus = switch status {
|
||||
case .joined: .joined
|
||||
case .notJoined: .notJoined
|
||||
case .invalid: .invalid
|
||||
case .banned: .banned
|
||||
}
|
||||
self.applyGroupInviteStatus(cardStatus, isOutgoing: layout.isOutgoing)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
groupInviteContainer.isHidden = true
|
||||
}
|
||||
}
|
||||
|
||||
private func applyGroupInviteStatus(_ status: InviteCardStatus, isOutgoing: Bool) {
|
||||
currentInviteStatus = status
|
||||
let color: UIColor
|
||||
switch status {
|
||||
case .notJoined: color = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 1) // #248AE6
|
||||
case .joined: color = UIColor(red: 0.20, green: 0.78, blue: 0.35, alpha: 1) // #34C759
|
||||
case .invalid, .banned: color = UIColor(red: 0.98, green: 0.23, blue: 0.19, alpha: 1)
|
||||
}
|
||||
groupInviteIconBg.backgroundColor = color
|
||||
groupInviteTitleLabel.textColor = isOutgoing ? .white : color
|
||||
|
||||
let statusText: String
|
||||
switch status {
|
||||
case .notJoined: statusText = "Invite to join this group"
|
||||
case .joined: statusText = "You are a member"
|
||||
case .invalid: statusText = "This invite is invalid"
|
||||
case .banned: statusText = "You are banned"
|
||||
}
|
||||
groupInviteStatusLabel.text = statusText
|
||||
groupInviteStatusLabel.textColor = isOutgoing
|
||||
? UIColor.white.withAlphaComponent(0.7)
|
||||
: .secondaryLabel
|
||||
|
||||
let buttonTitle: String
|
||||
switch status {
|
||||
case .notJoined: buttonTitle = "Join Group"
|
||||
case .joined: buttonTitle = "Open Group"
|
||||
case .invalid: buttonTitle = "Invalid"
|
||||
case .banned: buttonTitle = "Banned"
|
||||
}
|
||||
groupInviteButton.setTitle(buttonTitle, for: .normal)
|
||||
groupInviteButton.setTitleColor(.white, for: .normal)
|
||||
groupInviteButton.backgroundColor = color
|
||||
groupInviteButton.isEnabled = (status == .notJoined || status == .joined)
|
||||
groupInviteButton.alpha = groupInviteButton.isEnabled ? 1.0 : 0.5
|
||||
}
|
||||
|
||||
@objc private func groupInviteCardTapped() {
|
||||
guard let inviteStr = groupInviteString, let actions else { return }
|
||||
if currentInviteStatus == .joined {
|
||||
if let parsed = GroupRepository.parseInviteStringPure(inviteStr) {
|
||||
let dialogKey = "#group:\(parsed.groupId)"
|
||||
actions.onGroupInviteOpen(dialogKey)
|
||||
}
|
||||
} else if currentInviteStatus == .notJoined {
|
||||
actions.onGroupInviteTap(inviteStr)
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply pre-calculated layout (main thread only — just sets frames).
|
||||
@@ -1009,6 +1136,23 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
}
|
||||
}
|
||||
|
||||
// Group Invite Card
|
||||
groupInviteContainer.isHidden = !layout.hasGroupInvite
|
||||
if layout.hasGroupInvite {
|
||||
groupInviteContainer.frame = CGRect(x: 0, y: 0, width: layout.bubbleSize.width, height: layout.bubbleSize.height)
|
||||
let cW = layout.bubbleSize.width
|
||||
let topY: CGFloat = 10
|
||||
groupInviteIconBg.frame = CGRect(x: 10, y: topY, width: 44, height: 44)
|
||||
groupInviteIcon.frame = CGRect(x: 12, y: 12, width: 20, height: 20)
|
||||
let textX: CGFloat = 64
|
||||
let textW = cW - textX - 10
|
||||
groupInviteTitleLabel.frame = CGRect(x: textX, y: topY + 2, width: textW, height: 19)
|
||||
groupInviteStatusLabel.frame = CGRect(x: textX, y: topY + 22, width: textW, height: 16)
|
||||
let btnSize = groupInviteButton.sizeThatFits(CGSize(width: 200, height: 28))
|
||||
let btnW = min(btnSize.width + 28, textW)
|
||||
groupInviteButton.frame = CGRect(x: textX, y: topY + 42, width: btnW, height: 28)
|
||||
}
|
||||
|
||||
// Forward
|
||||
if layout.isForward {
|
||||
forwardLabel.frame = layout.forwardHeaderFrame
|
||||
@@ -1100,6 +1244,9 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
if layout.hasFile {
|
||||
fileContainer.frame.origin.y += senderNameShift
|
||||
}
|
||||
if layout.hasGroupInvite {
|
||||
groupInviteContainer.frame.origin.y += senderNameShift
|
||||
}
|
||||
if layout.isForward {
|
||||
forwardLabel.frame.origin.y += senderNameShift
|
||||
forwardAvatarView.frame.origin.y += senderNameShift
|
||||
@@ -1354,7 +1501,8 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
|
||||
@objc private func handleSwipe(_ gesture: UIPanGestureRecognizer) {
|
||||
if isSavedMessages || isSystemAccount { return }
|
||||
let isReplyBlocked = message?.attachments.contains(where: { $0.type == .avatar }) ?? false
|
||||
let isReplyBlocked = (message?.attachments.contains(where: { $0.type == .avatar }) ?? false)
|
||||
|| (currentLayout?.messageType == .groupInvite)
|
||||
if isReplyBlocked { return }
|
||||
|
||||
let translation = gesture.translation(in: contentView)
|
||||
@@ -2374,6 +2522,11 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
fileContainer.isHidden = true
|
||||
callArrowView.isHidden = true
|
||||
callBackButton.isHidden = true
|
||||
groupInviteContainer.isHidden = true
|
||||
groupInviteString = nil
|
||||
currentInviteStatus = .notJoined
|
||||
inviteStatusTask?.cancel()
|
||||
inviteStatusTask = nil
|
||||
avatarImageView.image = nil
|
||||
avatarImageView.isHidden = true
|
||||
fileIconView.isHidden = false
|
||||
|
||||
@@ -125,6 +125,7 @@ final class NativeMessageListController: UIViewController {
|
||||
private var datePillPool: [(container: UIView, label: UILabel)] = []
|
||||
private var dateHideTimer: Timer?
|
||||
private var areDatePillsVisible = false
|
||||
private var animatePillFrames = false
|
||||
|
||||
// MARK: - Empty State (UIKit-managed, animates with keyboard)
|
||||
private var emptyStateHosting: UIHostingController<EmptyChatContent>?
|
||||
@@ -693,10 +694,20 @@ final class NativeMessageListController: UIViewController {
|
||||
let sections = sectionMap.map { DateSection(text: $0.key, topY: $0.value.topY, bottomY: $0.value.bottomY) }
|
||||
.sorted { $0.topY < $1.topY }
|
||||
|
||||
// 2. Position each section's pill using Telegram's formula.
|
||||
// 2. Expand pool if more sections than pills (short chats spanning many days).
|
||||
while datePillPool.count < sections.count {
|
||||
let pill = makeDatePill()
|
||||
if let composer = composerView {
|
||||
view.insertSubview(pill.container, belowSubview: composer)
|
||||
} else {
|
||||
view.addSubview(pill.container)
|
||||
}
|
||||
datePillPool.append(pill)
|
||||
}
|
||||
|
||||
// 3. Position each section's pill using Telegram's formula.
|
||||
var usedPillCount = 0
|
||||
for section in sections {
|
||||
guard usedPillCount < datePillPool.count else { break }
|
||||
|
||||
// Telegram formula: headerY = min(max(sectionTop, stickyY), sectionBottom - pillH)
|
||||
// +9 = vertically centered in 42pt dateHeaderHeight: (42 - 24) / 2 = 9
|
||||
@@ -711,7 +722,7 @@ final class NativeMessageListController: UIViewController {
|
||||
|
||||
let pill = datePillPool[usedPillCount]
|
||||
|
||||
// Prevent resize animation when reusing pool pill with different text.
|
||||
// Phase 1: Internal properties — always snap (no animation).
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
pill.label.text = section.text
|
||||
@@ -723,20 +734,34 @@ final class NativeMessageListController: UIViewController {
|
||||
x: round((screenW - pillW) / 2), y: headerY,
|
||||
width: pillW, height: pillH
|
||||
)
|
||||
pill.container.frame = pillFrame
|
||||
pill.container.layer.cornerRadius = pillH / 2
|
||||
pill.label.frame = pill.container.bounds
|
||||
// Explicitly set glass frame (no autoresizingMask — prevents 1-frame inflation).
|
||||
if let glass = pill.container.subviews.first(where: { $0.tag == 42 }) {
|
||||
glass.frame = pill.container.bounds
|
||||
glass.layoutIfNeeded()
|
||||
}
|
||||
pill.container.isHidden = false
|
||||
// Natural-position pills always visible. Stuck pills fade with timer.
|
||||
pill.container.alpha = isStuck ? (areDatePillsVisible ? 1 : 0) : 1
|
||||
pill.container.tag = isStuck ? 1 : 0
|
||||
CATransaction.commit()
|
||||
|
||||
// Phase 2: Container frame — animate during keyboard, snap otherwise.
|
||||
if animatePillFrames {
|
||||
// Inside UIView.animate block → implicit animation matches keyboard curve.
|
||||
pill.container.frame = pillFrame
|
||||
} else {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
pill.container.frame = pillFrame
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
// Phase 3: Sync child frames to container (always snap).
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
pill.label.frame = pill.container.bounds
|
||||
if let glass = pill.container.subviews.first(where: { $0.tag == 42 }) {
|
||||
glass.frame = pill.container.bounds
|
||||
glass.layoutIfNeeded()
|
||||
}
|
||||
CATransaction.commit()
|
||||
|
||||
usedPillCount += 1
|
||||
}
|
||||
|
||||
@@ -867,6 +892,10 @@ final class NativeMessageListController: UIViewController {
|
||||
|
||||
// Capture visible cell positions BEFORE applying snapshot (for position animation)
|
||||
var oldPositions: [String: CGFloat] = [:]
|
||||
// Capture pill positions for matching spring animation
|
||||
let oldPillPositions = isInteractive
|
||||
? datePillPool.map { (y: $0.container.layer.position.y, visible: !$0.container.isHidden) }
|
||||
: []
|
||||
if isInteractive {
|
||||
for ip in collectionView.indexPathsForVisibleItems {
|
||||
if let cellId = dataSource.itemIdentifier(for: ip),
|
||||
@@ -901,6 +930,30 @@ final class NativeMessageListController: UIViewController {
|
||||
if isInteractive {
|
||||
collectionView.layoutIfNeeded()
|
||||
applyInsertionAnimations(newIds: newIds, oldPositions: oldPositions)
|
||||
|
||||
// Animate date pills with same spring as cells
|
||||
updateFloatingDateHeader()
|
||||
for (i, pill) in datePillPool.enumerated() {
|
||||
guard i < oldPillPositions.count,
|
||||
!pill.container.isHidden,
|
||||
oldPillPositions[i].visible else { continue }
|
||||
let dy = oldPillPositions[i].y - pill.container.layer.position.y
|
||||
guard abs(dy) > 0.5 else { continue }
|
||||
|
||||
let move = CASpringAnimation(keyPath: "position.y")
|
||||
move.fromValue = dy
|
||||
move.toValue = 0.0
|
||||
move.isAdditive = true
|
||||
move.stiffness = 555.0
|
||||
move.damping = 47.0
|
||||
move.mass = 1.0
|
||||
move.initialVelocity = 0
|
||||
move.duration = move.settlingDuration
|
||||
move.fillMode = .backwards
|
||||
pill.container.layer.add(move, forKey: "pillInsertionMove")
|
||||
}
|
||||
} else {
|
||||
updateFloatingDateHeader()
|
||||
}
|
||||
|
||||
if !hasCompletedInitialLoad && !messages.isEmpty {
|
||||
@@ -1186,8 +1239,15 @@ final class NativeMessageListController: UIViewController {
|
||||
}
|
||||
|
||||
self.view.layoutIfNeeded()
|
||||
|
||||
// Position pills at FINAL positions — implicit animation from
|
||||
// UIView.animate matches keyboard curve/duration automatically.
|
||||
self.animatePillFrames = true
|
||||
self.updateFloatingDateHeader()
|
||||
self.animatePillFrames = false
|
||||
}, completion: { _ in
|
||||
self.isKeyboardAnimating = false
|
||||
self.updateFloatingDateHeader()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1224,7 +1284,9 @@ final class NativeMessageListController: UIViewController {
|
||||
extension NativeMessageListController: UICollectionViewDelegate {
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
updateFloatingDateHeader()
|
||||
if !isKeyboardAnimating {
|
||||
updateFloatingDateHeader()
|
||||
}
|
||||
|
||||
let offsetFromBottom = scrollView.contentOffset.y + scrollView.contentInset.top
|
||||
let isAtBottom = offsetFromBottom < 50
|
||||
|
||||
@@ -33,13 +33,25 @@ enum TelegramContextMenuBuilder {
|
||||
))
|
||||
}
|
||||
|
||||
// Desktop parity: detect #group: invite strings and offer "Join Group" action.
|
||||
// Desktop parity: detect #group: invite strings and offer "Join/Open Group" action.
|
||||
if message.text.hasPrefix("#group:") {
|
||||
let isJoined: Bool
|
||||
if let parsed = GroupRepository.parseInviteStringPure(message.text) {
|
||||
isJoined = GroupRepository.shared.hasGroup(for: parsed.groupId)
|
||||
} else {
|
||||
isJoined = false
|
||||
}
|
||||
items.append(TelegramContextMenuItem(
|
||||
title: "Join Group",
|
||||
iconName: "person.2.badge.plus",
|
||||
title: isJoined ? "Open Group" : "Join Group",
|
||||
iconName: isJoined ? "person.2" : "person.2.badge.plus",
|
||||
isDestructive: false,
|
||||
handler: { actions.onGroupInviteTap(message.text) }
|
||||
handler: {
|
||||
if isJoined, let parsed = GroupRepository.parseInviteStringPure(message.text) {
|
||||
actions.onGroupInviteOpen("#group:\(parsed.groupId)")
|
||||
} else {
|
||||
actions.onGroupInviteTap(message.text)
|
||||
}
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import UIKit
|
||||
|
||||
// MARK: - ZoomableImagePage
|
||||
|
||||
/// Single page in the image gallery viewer with UIKit-based gesture handling.
|
||||
/// Android parity: `ZoomableImage` in `ImageViewerScreen.kt` — centroid-based pinch zoom,
|
||||
/// double-tap to tap point, velocity-based dismiss, axis locking, edge tap navigation.
|
||||
/// Single page in the image gallery viewer.
|
||||
/// Uses GeometryReader + explicit frame calculation (Telegram parity) instead of
|
||||
/// `.scaledToFit()` which is unreliable inside TabView `.page` style overlay chains.
|
||||
struct ZoomableImagePage: View {
|
||||
|
||||
let attachmentId: String
|
||||
@@ -23,81 +23,73 @@ struct ZoomableImagePage: View {
|
||||
var body: some View {
|
||||
let effectiveScale = zoomScale * pinchScale
|
||||
|
||||
// Color.clear always fills ALL proposed space from the parent — TabView page,
|
||||
// hero frame, etc. The Image in .overlay sizes relative to Color.clear's actual
|
||||
// rendered frame. Previous approach (.scaledToFit + .frame(maxWidth: .infinity))
|
||||
// sometimes got a stale/zero proposed size from TabView lazy page creation,
|
||||
// causing the image to render at thumbnail size.
|
||||
Color.clear
|
||||
.overlay {
|
||||
if let image {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.scaleEffect(effectiveScale)
|
||||
.offset(
|
||||
x: effectiveScale > 1.05 ? zoomOffset.width : 0,
|
||||
y: effectiveScale > 1.05 ? zoomOffset.height : 0
|
||||
)
|
||||
GeometryReader { geo in
|
||||
let viewSize = geo.size
|
||||
|
||||
if let image {
|
||||
let fitted = fittedSize(image.size, in: viewSize)
|
||||
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.frame(width: fitted.width, height: fitted.height)
|
||||
.scaleEffect(effectiveScale)
|
||||
.offset(
|
||||
x: effectiveScale > 1.05 ? zoomOffset.width : 0,
|
||||
y: effectiveScale > 1.05 ? zoomOffset.height : 0
|
||||
)
|
||||
.position(x: viewSize.width / 2, y: viewSize.height / 2)
|
||||
} else {
|
||||
placeholder
|
||||
.position(x: viewSize.width / 2, y: viewSize.height / 2)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(count: 2) {
|
||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
|
||||
if zoomScale > 1.1 {
|
||||
zoomScale = 1.0
|
||||
zoomOffset = .zero
|
||||
} else {
|
||||
placeholder
|
||||
zoomScale = 2.5
|
||||
}
|
||||
currentScale = zoomScale
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
// Double tap: zoom to 2.5x or reset (MUST be before single tap)
|
||||
.onTapGesture(count: 2) {
|
||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
|
||||
if zoomScale > 1.1 {
|
||||
zoomScale = 1.0
|
||||
zoomOffset = .zero
|
||||
} else {
|
||||
zoomScale = 2.5
|
||||
}
|
||||
currentScale = zoomScale
|
||||
}
|
||||
.onTapGesture { location in
|
||||
let width = UIScreen.main.bounds.width
|
||||
let edgeZone = width * 0.20
|
||||
if location.x < edgeZone {
|
||||
onEdgeTap?(-1)
|
||||
} else if location.x > width - edgeZone {
|
||||
onEdgeTap?(1)
|
||||
} else {
|
||||
showControls.toggle()
|
||||
}
|
||||
}
|
||||
.simultaneousGesture(
|
||||
MagnifyGesture()
|
||||
.updating($pinchScale) { value, state, _ in
|
||||
state = value.magnification
|
||||
}
|
||||
}
|
||||
// Single tap: toggle controls / edge navigation
|
||||
.onTapGesture { location in
|
||||
let width = UIScreen.main.bounds.width
|
||||
let edgeZone = width * 0.20
|
||||
if location.x < edgeZone {
|
||||
onEdgeTap?(-1)
|
||||
} else if location.x > width - edgeZone {
|
||||
onEdgeTap?(1)
|
||||
} else {
|
||||
showControls.toggle()
|
||||
}
|
||||
}
|
||||
// Pinch zoom
|
||||
.simultaneousGesture(
|
||||
MagnifyGesture()
|
||||
.updating($pinchScale) { value, state, _ in
|
||||
state = value.magnification
|
||||
}
|
||||
.onEnded { value in
|
||||
let newScale = zoomScale * value.magnification
|
||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
|
||||
zoomScale = min(max(newScale, 1.0), 5.0)
|
||||
if zoomScale <= 1.05 {
|
||||
zoomScale = 1.0
|
||||
zoomOffset = .zero
|
||||
}
|
||||
currentScale = zoomScale
|
||||
.onEnded { value in
|
||||
let newScale = zoomScale * value.magnification
|
||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
|
||||
zoomScale = min(max(newScale, 1.0), 5.0)
|
||||
if zoomScale <= 1.05 {
|
||||
zoomScale = 1.0
|
||||
zoomOffset = .zero
|
||||
}
|
||||
currentScale = zoomScale
|
||||
}
|
||||
)
|
||||
// Pan when zoomed
|
||||
.simultaneousGesture(
|
||||
zoomScale > 1.05 ?
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
zoomOffset = value.translation
|
||||
}
|
||||
.onEnded { _ in
|
||||
// Clamp offset
|
||||
}
|
||||
: nil
|
||||
)
|
||||
}
|
||||
)
|
||||
.simultaneousGesture(
|
||||
zoomScale > 1.05 ?
|
||||
DragGesture()
|
||||
.onChanged { value in zoomOffset = value.translation }
|
||||
.onEnded { _ in }
|
||||
: nil
|
||||
)
|
||||
.task {
|
||||
if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachmentId) {
|
||||
image = cached
|
||||
@@ -114,6 +106,21 @@ struct ZoomableImagePage: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Aspect-fit calculation (Telegram parity)
|
||||
|
||||
/// Calculates the image frame to fill the view while maintaining aspect ratio.
|
||||
/// Telegram: `contentSize.fitted(boundsSize)` in ZoomableContentGalleryItemNode.
|
||||
private func fittedSize(_ imageSize: CGSize, in viewSize: CGSize) -> CGSize {
|
||||
guard imageSize.width > 0, imageSize.height > 0,
|
||||
viewSize.width > 0, viewSize.height > 0 else {
|
||||
return viewSize
|
||||
}
|
||||
let scale = min(viewSize.width / imageSize.width,
|
||||
viewSize.height / imageSize.height)
|
||||
return CGSize(width: imageSize.width * scale,
|
||||
height: imageSize.height * scale)
|
||||
}
|
||||
|
||||
// MARK: - Placeholder
|
||||
|
||||
private var placeholder: some View {
|
||||
@@ -126,4 +133,3 @@ struct ZoomableImagePage: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -135,10 +135,19 @@ struct ChatListView: View {
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .openChatFromNotification)) { notification in
|
||||
guard let route = notification.object as? ChatRoute else { return }
|
||||
// Navigate to the chat from push notification tap (fast path)
|
||||
navigationState.path = [route]
|
||||
AppDelegate.pendingChatRoute = nil
|
||||
AppDelegate.pendingChatRouteTimestamp = nil
|
||||
// If already inside a chat, pop first then push after animation.
|
||||
// Direct path replacement reuses the same ChatDetailView (SwiftUI optimization),
|
||||
// which only updates the toolbar but keeps the old messages.
|
||||
if !navigationState.path.isEmpty {
|
||||
navigationState.path = []
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
navigationState.path = [route]
|
||||
}
|
||||
} else {
|
||||
navigationState.path = [route]
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// Cold start fallback: ChatListView didn't exist when notification was posted.
|
||||
|
||||
Reference in New Issue
Block a user