feat: группы — inline карточка приглашения (Desktop/Android parity) + навигация pop→push fix
This commit is contained in:
@@ -39,7 +39,7 @@ final class GroupRepository {
|
|||||||
let description: String
|
let description: String
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ParsedGroupInvite {
|
struct ParsedGroupInvite: Sendable {
|
||||||
let groupId: String
|
let groupId: String
|
||||||
let title: String
|
let title: String
|
||||||
let encryptKey: String
|
let encryptKey: String
|
||||||
@@ -53,6 +53,11 @@ final class GroupRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func normalizeGroupId(_ value: String) -> String {
|
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 trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let lower = trimmed.lowercased()
|
let lower = trimmed.lowercased()
|
||||||
if lower.hasPrefix("#group:") {
|
if lower.hasPrefix("#group:") {
|
||||||
@@ -193,6 +198,11 @@ final class GroupRepository {
|
|||||||
|
|
||||||
/// Parses an invite string into its components.
|
/// Parses an invite string into its components.
|
||||||
func parseInviteString(_ inviteString: String) -> ParsedGroupInvite? {
|
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 trimmed = inviteString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let lower = trimmed.lowercased()
|
let lower = trimmed.lowercased()
|
||||||
|
|
||||||
@@ -211,7 +221,7 @@ final class GroupRepository {
|
|||||||
|
|
||||||
guard let decryptedPayload = try? CryptoManager.shared.decryptWithPassword(
|
guard let decryptedPayload = try? CryptoManager.shared.decryptWithPassword(
|
||||||
encodedPayload,
|
encodedPayload,
|
||||||
password: Self.groupInvitePassword
|
password: groupInvitePassword
|
||||||
), let payload = String(data: decryptedPayload, encoding: .utf8) else {
|
), let payload = String(data: decryptedPayload, encoding: .utf8) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -219,7 +229,7 @@ final class GroupRepository {
|
|||||||
let parts = payload.split(separator: ":", omittingEmptySubsequences: false).map(String.init)
|
let parts = payload.split(separator: ":", omittingEmptySubsequences: false).map(String.init)
|
||||||
guard parts.count >= 3 else { return nil }
|
guard parts.count >= 3 else { return nil }
|
||||||
|
|
||||||
let groupId = normalizeGroupId(parts[0])
|
let groupId = normalizeGroupIdPure(parts[0])
|
||||||
guard !groupId.isEmpty else { return nil }
|
guard !groupId.isEmpty else { return nil }
|
||||||
|
|
||||||
return ParsedGroupInvite(
|
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
|
// MARK: - Persistence
|
||||||
|
|
||||||
/// Persists group from a joined `PacketGroupJoin` (server-pushed or self-initiated).
|
/// Persists group from a joined `PacketGroupJoin` (server-pushed or self-initiated).
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ struct MessageCellLayout: Sendable {
|
|||||||
let hasFile: Bool
|
let hasFile: Bool
|
||||||
let fileFrame: CGRect // File view frame in bubble coords
|
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)
|
// MARK: - Forward Header (optional)
|
||||||
|
|
||||||
let isForward: Bool
|
let isForward: Bool
|
||||||
@@ -90,6 +96,7 @@ struct MessageCellLayout: Sendable {
|
|||||||
case file
|
case file
|
||||||
case forward
|
case forward
|
||||||
case emojiOnly
|
case emojiOnly
|
||||||
|
case groupInvite
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +126,9 @@ extension MessageCellLayout {
|
|||||||
let forwardCaption: String?
|
let forwardCaption: String?
|
||||||
let showsDateHeader: Bool
|
let showsDateHeader: Bool
|
||||||
let dateHeaderText: String
|
let dateHeaderText: String
|
||||||
|
let groupInviteCount: Int
|
||||||
|
let groupInviteTitle: String
|
||||||
|
let groupInviteGroupId: String
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct MediaDimensions {
|
private struct MediaDimensions {
|
||||||
@@ -186,6 +196,8 @@ extension MessageCellLayout {
|
|||||||
messageType = .photo
|
messageType = .photo
|
||||||
} else if config.fileCount > 0 || config.avatarCount > 0 || config.callCount > 0 {
|
} else if config.fileCount > 0 || config.avatarCount > 0 || config.callCount > 0 {
|
||||||
messageType = .file
|
messageType = .file
|
||||||
|
} else if config.groupInviteCount > 0 {
|
||||||
|
messageType = .groupInvite
|
||||||
} else if config.hasReplyQuote {
|
} else if config.hasReplyQuote {
|
||||||
messageType = .textWithReply
|
messageType = .textWithReply
|
||||||
} else {
|
} else {
|
||||||
@@ -468,13 +480,24 @@ extension MessageCellLayout {
|
|||||||
fileOnlyTsPad = tsPad
|
fileOnlyTsPad = tsPad
|
||||||
bubbleH += tsGap + tsSize.height + tsPad
|
bubbleH += tsGap + tsSize.height + tsPad
|
||||||
fileH = bubbleH // fileContainer spans entire bubble
|
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 {
|
} else {
|
||||||
// No text, no file (forward header only, empty)
|
// No text, no file (forward header only, empty)
|
||||||
bubbleW = leftPad + metadataWidth + rightPad
|
bubbleW = leftPad + metadataWidth + rightPad
|
||||||
bubbleW = max(minW, min(bubbleW, effectiveMaxBubbleWidth))
|
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)
|
bubbleH = max(bubbleH, 37)
|
||||||
}
|
}
|
||||||
// Forward header needs minimum width for "Forwarded from" + avatar + name
|
// Forward header needs minimum width for "Forwarded from" + avatar + name
|
||||||
@@ -672,6 +695,9 @@ extension MessageCellLayout {
|
|||||||
photoCollageHeight: photoH,
|
photoCollageHeight: photoH,
|
||||||
hasFile: config.fileCount > 0 || config.avatarCount > 0 || config.callCount > 0,
|
hasFile: config.fileCount > 0 || config.avatarCount > 0 || config.callCount > 0,
|
||||||
fileFrame: fileFrame,
|
fileFrame: fileFrame,
|
||||||
|
hasGroupInvite: config.groupInviteCount > 0,
|
||||||
|
groupInviteTitle: config.groupInviteTitle,
|
||||||
|
groupInviteGroupId: config.groupInviteGroupId,
|
||||||
isForward: config.isForward,
|
isForward: config.isForward,
|
||||||
forwardHeaderFrame: fwdHeaderFrame,
|
forwardHeaderFrame: fwdHeaderFrame,
|
||||||
forwardAvatarFrame: fwdAvatarFrame,
|
forwardAvatarFrame: fwdAvatarFrame,
|
||||||
@@ -1001,13 +1027,24 @@ extension MessageCellLayout {
|
|||||||
AttachmentPreviewCodec.imageDimensions(from: $0.preview)
|
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(
|
let config = Config(
|
||||||
maxBubbleWidth: maxBubbleWidth,
|
maxBubbleWidth: maxBubbleWidth,
|
||||||
isOutgoing: isOutgoing,
|
isOutgoing: isOutgoing,
|
||||||
isDarkMode: isDarkMode,
|
isDarkMode: isDarkMode,
|
||||||
position: position,
|
position: position,
|
||||||
deliveryStatus: message.deliveryStatus,
|
deliveryStatus: message.deliveryStatus,
|
||||||
text: isForward ? (forwardCaption ?? "") : displayText,
|
text: isForward ? (forwardCaption ?? "") : (groupInviteCount > 0 ? "" : displayText),
|
||||||
timestampText: timestampText,
|
timestampText: timestampText,
|
||||||
hasReplyQuote: hasReply && !displayText.isEmpty,
|
hasReplyQuote: hasReply && !displayText.isEmpty,
|
||||||
replyName: nil,
|
replyName: nil,
|
||||||
@@ -1022,7 +1059,10 @@ extension MessageCellLayout {
|
|||||||
forwardFileCount: forwardInnerFileCount,
|
forwardFileCount: forwardInnerFileCount,
|
||||||
forwardCaption: forwardCaption,
|
forwardCaption: forwardCaption,
|
||||||
showsDateHeader: showsDateHeader,
|
showsDateHeader: showsDateHeader,
|
||||||
dateHeaderText: dateHeaderText
|
dateHeaderText: dateHeaderText,
|
||||||
|
groupInviteCount: groupInviteCount,
|
||||||
|
groupInviteTitle: groupInviteTitle,
|
||||||
|
groupInviteGroupId: groupInviteGroupId
|
||||||
)
|
)
|
||||||
|
|
||||||
var (layout, textLayout) = calculate(config: config)
|
var (layout, textLayout) = calculate(config: config)
|
||||||
|
|||||||
@@ -255,6 +255,16 @@ struct ChatDetailView: View {
|
|||||||
pendingGroupInviteTitle = parsed.title
|
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.
|
// Capture first unread incoming message BEFORE marking as read.
|
||||||
if firstUnreadMessageId == nil {
|
if firstUnreadMessageId == nil {
|
||||||
firstUnreadMessageId = messages.first(where: {
|
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
|
// MARK: - Data Types
|
||||||
|
|
||||||
/// Per-image metadata for the gallery viewer.
|
|
||||||
/// Android parity: `ViewableImage` in `ImageViewerScreen.kt`.
|
|
||||||
struct ViewableImageInfo: Equatable, Identifiable {
|
struct ViewableImageInfo: Equatable, Identifiable {
|
||||||
let attachmentId: String
|
let attachmentId: String
|
||||||
let senderName: String
|
let senderName: String
|
||||||
let timestamp: Date
|
let timestamp: Date
|
||||||
let caption: String
|
let caption: String
|
||||||
|
|
||||||
var id: String { attachmentId }
|
var id: String { attachmentId }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State for the image gallery viewer.
|
|
||||||
struct ImageViewerState: Equatable {
|
struct ImageViewerState: Equatable {
|
||||||
let images: [ViewableImageInfo]
|
let images: [ViewableImageInfo]
|
||||||
let initialIndex: Int
|
let initialIndex: Int
|
||||||
@@ -28,16 +24,10 @@ struct ImageViewerState: Equatable {
|
|||||||
/// Manages the vertical pan gesture for gallery dismiss.
|
/// Manages the vertical pan gesture for gallery dismiss.
|
||||||
/// Attached to the hosting controller's view (NOT as a SwiftUI overlay) so it
|
/// 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.
|
/// 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 {
|
final class GalleryDismissPanCoordinator: NSObject, ObservableObject, UIGestureRecognizerDelegate {
|
||||||
/// Current vertical drag offset during dismiss gesture.
|
|
||||||
@Published var dragOffset: CGSize = .zero
|
@Published var dragOffset: CGSize = .zero
|
||||||
/// Toggles on every pan-end event — use `.onChange` to react.
|
|
||||||
@Published private(set) var panEndSignal: Bool = false
|
@Published private(set) var panEndSignal: Bool = false
|
||||||
/// Y velocity at the moment the pan gesture ended (pt/s).
|
|
||||||
private(set) var endVelocityY: CGFloat = 0
|
private(set) var endVelocityY: CGFloat = 0
|
||||||
/// Set to false to disable the dismiss gesture (e.g. when zoomed in).
|
|
||||||
var isEnabled: Bool = true
|
var isEnabled: Bool = true
|
||||||
|
|
||||||
@objc func handlePan(_ gesture: UIPanGestureRecognizer) {
|
@objc func handlePan(_ gesture: UIPanGestureRecognizer) {
|
||||||
@@ -45,49 +35,38 @@ final class GalleryDismissPanCoordinator: NSObject, ObservableObject, UIGestureR
|
|||||||
if gesture.state == .began { gesture.state = .cancelled }
|
if gesture.state == .began { gesture.state = .cancelled }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let translation = gesture.translation(in: gesture.view)
|
let t = gesture.translation(in: gesture.view)
|
||||||
switch gesture.state {
|
switch gesture.state {
|
||||||
case .began, .changed:
|
case .began, .changed:
|
||||||
// Vertical only — Telegram parity (no diagonal drag).
|
dragOffset = CGSize(width: 0, height: t.y)
|
||||||
dragOffset = CGSize(width: 0, height: translation.y)
|
|
||||||
case .ended, .cancelled:
|
case .ended, .cancelled:
|
||||||
endVelocityY = gesture.velocity(in: gesture.view).y
|
endVelocityY = gesture.velocity(in: gesture.view).y
|
||||||
panEndSignal.toggle()
|
panEndSignal.toggle()
|
||||||
default:
|
default: break
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only begin for downward vertical drags.
|
func gestureRecognizerShouldBegin(_ g: UIGestureRecognizer) -> Bool {
|
||||||
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
guard isEnabled, let pan = g as? UIPanGestureRecognizer else { return false }
|
||||||
guard isEnabled else { return false }
|
|
||||||
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return false }
|
|
||||||
let v = pan.velocity(in: pan.view)
|
let v = pan.velocity(in: pan.view)
|
||||||
return v.y > abs(v.x)
|
return v.y > abs(v.x)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow simultaneous recognition with non-pan gestures (pinch, taps).
|
func gestureRecognizer(_ g: UIGestureRecognizer,
|
||||||
func gestureRecognizer(
|
shouldRecognizeSimultaneouslyWith o: UIGestureRecognizer) -> Bool {
|
||||||
_ gestureRecognizer: UIGestureRecognizer,
|
!(o is UIPanGestureRecognizer)
|
||||||
shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer
|
|
||||||
) -> Bool {
|
|
||||||
!(other is UIPanGestureRecognizer)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - ImageViewerPresenter
|
// MARK: - ImageViewerPresenter
|
||||||
|
|
||||||
/// UIHostingController subclass that hides the status bar.
|
|
||||||
private final class StatusBarHiddenHostingController: UIHostingController<AnyView> {
|
private final class StatusBarHiddenHostingController: UIHostingController<AnyView> {
|
||||||
override var prefersStatusBarHidden: Bool { true }
|
override var prefersStatusBarHidden: Bool { true }
|
||||||
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation { .fade }
|
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
|
@MainActor
|
||||||
final class ImageViewerPresenter {
|
final class ImageViewerPresenter {
|
||||||
|
|
||||||
static let shared = ImageViewerPresenter()
|
static let shared = ImageViewerPresenter()
|
||||||
private weak var presentedController: UIViewController?
|
private weak var presentedController: UIViewController?
|
||||||
private var panCoordinator: GalleryDismissPanCoordinator?
|
private var panCoordinator: GalleryDismissPanCoordinator?
|
||||||
@@ -104,13 +83,10 @@ final class ImageViewerPresenter {
|
|||||||
onDismiss: { [weak self] in self?.dismiss() }
|
onDismiss: { [weak self] in self?.dismiss() }
|
||||||
)
|
)
|
||||||
|
|
||||||
let hostingController = StatusBarHiddenHostingController(rootView: AnyView(viewer))
|
let hc = StatusBarHiddenHostingController(rootView: AnyView(viewer))
|
||||||
hostingController.modalPresentationStyle = .overFullScreen
|
hc.modalPresentationStyle = .overFullScreen
|
||||||
hostingController.view.backgroundColor = .clear
|
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(
|
let pan = UIPanGestureRecognizer(
|
||||||
target: coordinator,
|
target: coordinator,
|
||||||
action: #selector(GalleryDismissPanCoordinator.handlePan)
|
action: #selector(GalleryDismissPanCoordinator.handlePan)
|
||||||
@@ -118,18 +94,15 @@ final class ImageViewerPresenter {
|
|||||||
pan.minimumNumberOfTouches = 1
|
pan.minimumNumberOfTouches = 1
|
||||||
pan.maximumNumberOfTouches = 1
|
pan.maximumNumberOfTouches = 1
|
||||||
pan.delegate = coordinator
|
pan.delegate = coordinator
|
||||||
hostingController.view.addGestureRecognizer(pan)
|
hc.view.addGestureRecognizer(pan)
|
||||||
|
|
||||||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||||
let root = windowScene.keyWindow?.rootViewController
|
let root = scene.keyWindow?.rootViewController else { return }
|
||||||
else { return }
|
|
||||||
|
|
||||||
var presenter = root
|
var presenter = root
|
||||||
while let presented = presenter.presentedViewController {
|
while let p = presenter.presentedViewController { presenter = p }
|
||||||
presenter = presented
|
presenter.present(hc, animated: false)
|
||||||
}
|
presentedController = hc
|
||||||
presenter.present(hostingController, animated: false)
|
|
||||||
presentedController = hostingController
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func dismiss() {
|
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
|
// 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 {
|
struct ImageGalleryViewer: View {
|
||||||
|
|
||||||
let state: ImageViewerState
|
let state: ImageViewerState
|
||||||
@@ -155,13 +133,13 @@ struct ImageGalleryViewer: View {
|
|||||||
@State private var currentZoomScale: CGFloat = 1.0
|
@State private var currentZoomScale: CGFloat = 1.0
|
||||||
@State private var isDismissing = false
|
@State private var isDismissing = false
|
||||||
@State private var isExpanded: Bool = 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 = {
|
private static let dateFormatter: DateFormatter = {
|
||||||
let f = DateFormatter()
|
let f = DateFormatter()
|
||||||
f.dateStyle = .none
|
f.dateStyle = .none
|
||||||
f.timeStyle = .short
|
f.timeStyle = .short
|
||||||
f.doesRelativeDateFormatting = true
|
|
||||||
return f
|
return f
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -188,36 +166,28 @@ struct ImageGalleryViewer: View {
|
|||||||
.interpolatingSpring(duration: 0.3, bounce: 0, initialVelocity: 0)
|
.interpolatingSpring(duration: 0.3, bounce: 0, initialVelocity: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Background opacity: fades over 80pt drag (Telegram: `abs(distance) / 80`).
|
|
||||||
private var backgroundOpacity: CGFloat {
|
private var backgroundOpacity: CGFloat {
|
||||||
let progress = min(abs(panCoordinator.dragOffset.height) / 80, 1)
|
let progress = min(abs(panCoordinator.dragOffset.height) / 80, 1)
|
||||||
return isExpanded ? max(1 - progress, 0) : 0
|
return isExpanded ? max(1 - progress, 0) : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Overlay/toolbar opacity: fades over 50pt drag (Telegram: `abs(distance) / 50`).
|
|
||||||
private var overlayDragOpacity: CGFloat {
|
private var overlayDragOpacity: CGFloat {
|
||||||
1 - min(abs(panCoordinator.dragOffset.height) / 50, 1)
|
1 - min(abs(panCoordinator.dragOffset.height) / 50, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formattedDate(_ date: Date) -> String {
|
private func formattedDate(_ date: Date) -> String {
|
||||||
let dayPart = Self.relativeDateFormatter.string(from: date)
|
let day = Self.relativeDateFormatter.string(from: date)
|
||||||
let timePart = Self.dateFormatter.string(from: date)
|
let time = Self.dateFormatter.string(from: date)
|
||||||
return "\(dayPart) at \(timePart)"
|
return "\(day) at \(time)"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let sourceFrame = state.sourceFrame
|
let sf = state.sourceFrame
|
||||||
|
|
||||||
GeometryReader { geometry in
|
|
||||||
let size = geometry.size
|
|
||||||
|
|
||||||
TabView(selection: $currentPage) {
|
TabView(selection: $currentPage) {
|
||||||
ForEach(Array(state.images.enumerated()), id: \.element.attachmentId) { index, info in
|
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 isHeroPage = index == state.initialIndex
|
||||||
let heroActive = isHeroPage && !isExpanded
|
let heroActive = isHeroPage && !isExpanded
|
||||||
|
|
||||||
@@ -226,64 +196,45 @@ struct ImageGalleryViewer: View {
|
|||||||
onDismiss: { dismissAction() },
|
onDismiss: { dismissAction() },
|
||||||
showControls: $showControls,
|
showControls: $showControls,
|
||||||
currentScale: $currentZoomScale,
|
currentScale: $currentZoomScale,
|
||||||
onEdgeTap: { direction in navigateEdgeTap(direction: direction) }
|
onEdgeTap: { dir in navigateEdgeTap(direction: dir) }
|
||||||
)
|
|
||||||
.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
|
|
||||||
)
|
)
|
||||||
|
// 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)
|
.tag(index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||||
|
.ignoresSafeArea()
|
||||||
.scrollDisabled(currentZoomScale > 1.05 || isDismissing)
|
.scrollDisabled(currentZoomScale > 1.05 || isDismissing)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.overlay { galleryOverlay }
|
.overlay { galleryOverlay }
|
||||||
.background {
|
.background {
|
||||||
Color.black
|
Color.black.opacity(backgroundOpacity).ignoresSafeArea()
|
||||||
.opacity(backgroundOpacity)
|
|
||||||
}
|
}
|
||||||
.allowsHitTesting(isExpanded)
|
.allowsHitTesting(isExpanded)
|
||||||
.onAppear { viewSize = size }
|
|
||||||
}
|
|
||||||
.ignoresSafeArea()
|
|
||||||
.statusBarHidden(true)
|
.statusBarHidden(true)
|
||||||
.task {
|
.task {
|
||||||
prefetchAdjacentImages(around: state.initialIndex)
|
prefetchAdjacentImages(around: state.initialIndex)
|
||||||
guard !isExpanded else { return }
|
guard !isExpanded else { return }
|
||||||
withAnimation(heroAnimation) {
|
withAnimation(heroAnimation) { isExpanded = true }
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
.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() {
|
private func handlePanEnd() {
|
||||||
let offsetY = panCoordinator.dragOffset.height
|
let y = panCoordinator.dragOffset.height
|
||||||
let velocityY = panCoordinator.endVelocityY
|
let v = panCoordinator.endVelocityY
|
||||||
// Telegram parity: dismiss on 50pt drag OR fast downward flick (>1000 pt/s).
|
if y > 50 || v > 1000 {
|
||||||
if offsetY > 50 || velocityY > 1000 {
|
|
||||||
dismissAction()
|
dismissAction()
|
||||||
} else {
|
} else {
|
||||||
withAnimation(heroAnimation.speed(1.2)) {
|
withAnimation(heroAnimation.speed(1.2)) {
|
||||||
@@ -292,13 +243,15 @@ struct ImageGalleryViewer: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Gallery Overlay (Telegram parity)
|
// MARK: - Overlay (Telegram parity)
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var galleryOverlay: some View {
|
private var galleryOverlay: some View {
|
||||||
|
let sa = windowSafeArea
|
||||||
|
|
||||||
if !isDismissing && isExpanded {
|
if !isDismissing && isExpanded {
|
||||||
ZStack {
|
ZStack {
|
||||||
// Top panel — slides DOWN from above on show, UP on hide
|
// Top panel
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
topPanel
|
topPanel
|
||||||
if state.images.count > 1 {
|
if state.images.count > 1 {
|
||||||
@@ -307,15 +260,17 @@ struct ImageGalleryViewer: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.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)
|
.allowsHitTesting(showControls)
|
||||||
|
|
||||||
// Bottom panel — slides UP from below on show, DOWN on hide
|
// Bottom panel
|
||||||
VStack {
|
VStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
bottomPanel
|
bottomPanel
|
||||||
|
.padding(.bottom, sa.bottom > 0 ? sa.bottom : 16)
|
||||||
}
|
}
|
||||||
.offset(y: showControls ? 0 : 120)
|
.offset(y: showControls ? 0 : (sa.bottom + 120))
|
||||||
.allowsHitTesting(showControls)
|
.allowsHitTesting(showControls)
|
||||||
}
|
}
|
||||||
.compositingGroup()
|
.compositingGroup()
|
||||||
@@ -325,15 +280,15 @@ struct ImageGalleryViewer: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Top Panel
|
// MARK: - Top Panel (Telegram parity)
|
||||||
|
|
||||||
private var topPanel: some View {
|
private var topPanel: some View {
|
||||||
HStack {
|
HStack(alignment: .top) {
|
||||||
glassCircleButton(systemName: "chevron.left") { dismissAction() }
|
glassCircleButton(systemName: "chevron.left") { dismissAction() }
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
glassCircleButton(systemName: "ellipsis") { }
|
glassCircleButton(systemName: "ellipsis") { }
|
||||||
}
|
}
|
||||||
.overlay {
|
.overlay(alignment: .top) {
|
||||||
if let info = currentInfo {
|
if let info = currentInfo {
|
||||||
VStack(spacing: 2) {
|
VStack(spacing: 2) {
|
||||||
Text(info.senderName)
|
Text(info.senderName)
|
||||||
@@ -351,11 +306,10 @@ struct ImageGalleryViewer: View {
|
|||||||
.animation(.easeInOut, value: currentPage)
|
.animation(.easeInOut, value: currentPage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 15)
|
.padding(.horizontal, 16)
|
||||||
.padding(.top, 4)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Counter Badge (below name capsule)
|
// MARK: - Counter
|
||||||
|
|
||||||
private var counterBadge: some View {
|
private var counterBadge: some View {
|
||||||
Text("\(currentPage + 1) of \(state.images.count)")
|
Text("\(currentPage + 1) of \(state.images.count)")
|
||||||
@@ -364,32 +318,44 @@ struct ImageGalleryViewer: View {
|
|||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.background { TelegramGlassCapsule() }
|
.background { TelegramGlassCapsule() }
|
||||||
|
.padding(.top, 6)
|
||||||
.contentTransition(.numericText())
|
.contentTransition(.numericText())
|
||||||
.animation(.easeInOut, value: currentPage)
|
.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 {
|
private var bottomPanel: some View {
|
||||||
HStack {
|
HStack(spacing: 0) {
|
||||||
|
// Forward
|
||||||
glassCircleButton(systemName: "arrowshape.turn.up.right") { }
|
glassCircleButton(systemName: "arrowshape.turn.up.right") { }
|
||||||
Spacer(minLength: 0)
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Share
|
||||||
glassCircleButton(systemName: "square.and.arrow.up") { shareCurrentImage() }
|
glassCircleButton(systemName: "square.and.arrow.up") { shareCurrentImage() }
|
||||||
Spacer(minLength: 0)
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Save to Photos
|
||||||
glassCircleButton(systemName: "square.and.arrow.down") { saveCurrentImage() }
|
glassCircleButton(systemName: "square.and.arrow.down") { saveCurrentImage() }
|
||||||
Spacer(minLength: 0)
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Delete
|
||||||
glassCircleButton(systemName: "trash") { }
|
glassCircleButton(systemName: "trash") { }
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 15)
|
.padding(.horizontal, 16)
|
||||||
.padding(.bottom, 8)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Glass Circle Button
|
// MARK: - Glass Button
|
||||||
|
|
||||||
private func glassCircleButton(systemName: String, action: @escaping () -> Void) -> some View {
|
private func glassCircleButton(systemName: String, action: @escaping () -> Void) -> some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
Image(systemName: systemName)
|
Image(systemName: systemName)
|
||||||
.font(.system(size: 17, weight: .medium))
|
.font(.system(size: 22))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.frame(width: 44, height: 44)
|
.frame(width: 44, height: 44)
|
||||||
}
|
}
|
||||||
@@ -399,16 +365,14 @@ struct ImageGalleryViewer: View {
|
|||||||
// MARK: - Navigation
|
// MARK: - Navigation
|
||||||
|
|
||||||
private func navigateEdgeTap(direction: Int) {
|
private func navigateEdgeTap(direction: Int) {
|
||||||
let target = currentPage + direction
|
let t = currentPage + direction
|
||||||
guard target >= 0, target < state.images.count else { return }
|
guard t >= 0, t < state.images.count else { return }
|
||||||
currentPage = target
|
currentPage = t
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Dismiss
|
// MARK: - Dismiss
|
||||||
|
|
||||||
private func dismissAction() {
|
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 {
|
if currentZoomScale > 1.05 || currentPage != state.initialIndex {
|
||||||
fadeDismiss()
|
fadeDismiss()
|
||||||
} else {
|
} else {
|
||||||
@@ -420,7 +384,6 @@ struct ImageGalleryViewer: View {
|
|||||||
guard !isDismissing else { return }
|
guard !isDismissing else { return }
|
||||||
isDismissing = true
|
isDismissing = true
|
||||||
panCoordinator.isEnabled = false
|
panCoordinator.isEnabled = false
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
withAnimation(heroAnimation.speed(1.2)) {
|
withAnimation(heroAnimation.speed(1.2)) {
|
||||||
panCoordinator.dragOffset = .zero
|
panCoordinator.dragOffset = .zero
|
||||||
@@ -435,16 +398,10 @@ struct ImageGalleryViewer: View {
|
|||||||
guard !isDismissing else { return }
|
guard !isDismissing else { return }
|
||||||
isDismissing = true
|
isDismissing = true
|
||||||
panCoordinator.isEnabled = false
|
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)) {
|
withAnimation(.easeOut(duration: 0.25)) {
|
||||||
panCoordinator.dragOffset = CGSize(width: 0, height: viewSize.height * 0.4)
|
panCoordinator.dragOffset = CGSize(width: 0, height: screenSize.height * 0.4)
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.27) {
|
|
||||||
onDismiss()
|
|
||||||
}
|
}
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.27) { onDismiss() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
@@ -453,20 +410,14 @@ struct ImageGalleryViewer: View {
|
|||||||
guard let info = currentInfo,
|
guard let info = currentInfo,
|
||||||
let image = AttachmentCache.shared.loadImage(forAttachmentId: info.attachmentId)
|
let image = AttachmentCache.shared.loadImage(forAttachmentId: info.attachmentId)
|
||||||
else { return }
|
else { return }
|
||||||
|
let vc = UIActivityViewController(activityItems: [image], applicationActivities: nil)
|
||||||
let activityVC = UIActivityViewController(activityItems: [image], applicationActivities: nil)
|
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
let root = scene.keyWindow?.rootViewController {
|
||||||
let root = windowScene.keyWindow?.rootViewController {
|
var p = root; while let pp = p.presentedViewController { p = pp }
|
||||||
var presenter = root
|
vc.popoverPresentationController?.sourceView = p.view
|
||||||
while let presented = presenter.presentedViewController {
|
vc.popoverPresentationController?.sourceRect = CGRect(
|
||||||
presenter = presented
|
x: p.view.bounds.midX, y: p.view.bounds.maxY - 50, width: 0, height: 0)
|
||||||
}
|
p.present(vc, animated: true)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,7 +425,6 @@ struct ImageGalleryViewer: View {
|
|||||||
guard let info = currentInfo,
|
guard let info = currentInfo,
|
||||||
let image = AttachmentCache.shared.loadImage(forAttachmentId: info.attachmentId)
|
let image = AttachmentCache.shared.loadImage(forAttachmentId: info.attachmentId)
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
|
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
|
||||||
guard status == .authorized || status == .limited else { return }
|
guard status == .authorized || status == .limited else { return }
|
||||||
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
|
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
|
||||||
@@ -487,13 +437,39 @@ struct ImageGalleryViewer: View {
|
|||||||
for offset in [-2, -1, 1, 2] {
|
for offset in [-2, -1, 1, 2] {
|
||||||
let i = index + offset
|
let i = index + offset
|
||||||
guard i >= 0, i < state.images.count else { continue }
|
guard i >= 0, i < state.images.count else { continue }
|
||||||
let attachmentId = state.images[i].attachmentId
|
let aid = state.images[i].attachmentId
|
||||||
guard AttachmentCache.shared.cachedImage(forAttachmentId: attachmentId) == nil else { continue }
|
guard AttachmentCache.shared.cachedImage(forAttachmentId: aid) == nil else { continue }
|
||||||
Task.detached(priority: .utility) {
|
Task.detached(priority: .utility) {
|
||||||
await ImageLoadLimiter.shared.acquire()
|
await ImageLoadLimiter.shared.acquire()
|
||||||
_ = AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
|
_ = AttachmentCache.shared.loadImage(forAttachmentId: aid)
|
||||||
await ImageLoadLimiter.shared.release()
|
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 onRemove: (ChatMessage) -> Void = { _ in }
|
||||||
var onCall: (String) -> Void = { _ in } // peer public key
|
var onCall: (String) -> Void = { _ in } // peer public key
|
||||||
var onGroupInviteTap: (String) -> Void = { _ in } // invite string
|
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(
|
.modifier(ConditionalSwipeToReply(
|
||||||
enabled: !isSavedMessages && !isSystemAccount
|
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) }
|
onReply: { actions.onReply(message) }
|
||||||
))
|
))
|
||||||
.overlay {
|
.overlay {
|
||||||
@@ -96,6 +97,32 @@ struct MessageCellView: View, Equatable {
|
|||||||
message: message, reply: reply, outgoing: outgoing,
|
message: message, reply: reply, outgoing: outgoing,
|
||||||
hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position
|
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 {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
if let reply = replyData {
|
if let reply = replyData {
|
||||||
|
|||||||
@@ -183,6 +183,21 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
private let senderAvatarImageView = UIImageView()
|
private let senderAvatarImageView = UIImageView()
|
||||||
private let senderAvatarInitialLabel = UILabel()
|
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)
|
// Highlight overlay (scroll-to-message flash)
|
||||||
private let highlightOverlay = UIView()
|
private let highlightOverlay = UIView()
|
||||||
|
|
||||||
@@ -434,6 +449,35 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
|
|
||||||
bubbleView.addSubview(fileContainer)
|
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)
|
// Listen for avatar download trigger (tap-to-download, Android parity)
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self, selector: #selector(handleAttachmentDownload(_:)),
|
self, selector: #selector(handleAttachmentDownload(_:)),
|
||||||
@@ -822,6 +866,89 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
} else {
|
} else {
|
||||||
fileContainer.isHidden = true
|
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).
|
/// 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
|
// Forward
|
||||||
if layout.isForward {
|
if layout.isForward {
|
||||||
forwardLabel.frame = layout.forwardHeaderFrame
|
forwardLabel.frame = layout.forwardHeaderFrame
|
||||||
@@ -1100,6 +1244,9 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
if layout.hasFile {
|
if layout.hasFile {
|
||||||
fileContainer.frame.origin.y += senderNameShift
|
fileContainer.frame.origin.y += senderNameShift
|
||||||
}
|
}
|
||||||
|
if layout.hasGroupInvite {
|
||||||
|
groupInviteContainer.frame.origin.y += senderNameShift
|
||||||
|
}
|
||||||
if layout.isForward {
|
if layout.isForward {
|
||||||
forwardLabel.frame.origin.y += senderNameShift
|
forwardLabel.frame.origin.y += senderNameShift
|
||||||
forwardAvatarView.frame.origin.y += senderNameShift
|
forwardAvatarView.frame.origin.y += senderNameShift
|
||||||
@@ -1354,7 +1501,8 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
|
|
||||||
@objc private func handleSwipe(_ gesture: UIPanGestureRecognizer) {
|
@objc private func handleSwipe(_ gesture: UIPanGestureRecognizer) {
|
||||||
if isSavedMessages || isSystemAccount { return }
|
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 }
|
if isReplyBlocked { return }
|
||||||
|
|
||||||
let translation = gesture.translation(in: contentView)
|
let translation = gesture.translation(in: contentView)
|
||||||
@@ -2374,6 +2522,11 @@ final class NativeMessageCell: UICollectionViewCell {
|
|||||||
fileContainer.isHidden = true
|
fileContainer.isHidden = true
|
||||||
callArrowView.isHidden = true
|
callArrowView.isHidden = true
|
||||||
callBackButton.isHidden = true
|
callBackButton.isHidden = true
|
||||||
|
groupInviteContainer.isHidden = true
|
||||||
|
groupInviteString = nil
|
||||||
|
currentInviteStatus = .notJoined
|
||||||
|
inviteStatusTask?.cancel()
|
||||||
|
inviteStatusTask = nil
|
||||||
avatarImageView.image = nil
|
avatarImageView.image = nil
|
||||||
avatarImageView.isHidden = true
|
avatarImageView.isHidden = true
|
||||||
fileIconView.isHidden = false
|
fileIconView.isHidden = false
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ final class NativeMessageListController: UIViewController {
|
|||||||
private var datePillPool: [(container: UIView, label: UILabel)] = []
|
private var datePillPool: [(container: UIView, label: UILabel)] = []
|
||||||
private var dateHideTimer: Timer?
|
private var dateHideTimer: Timer?
|
||||||
private var areDatePillsVisible = false
|
private var areDatePillsVisible = false
|
||||||
|
private var animatePillFrames = false
|
||||||
|
|
||||||
// MARK: - Empty State (UIKit-managed, animates with keyboard)
|
// MARK: - Empty State (UIKit-managed, animates with keyboard)
|
||||||
private var emptyStateHosting: UIHostingController<EmptyChatContent>?
|
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) }
|
let sections = sectionMap.map { DateSection(text: $0.key, topY: $0.value.topY, bottomY: $0.value.bottomY) }
|
||||||
.sorted { $0.topY < $1.topY }
|
.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
|
var usedPillCount = 0
|
||||||
for section in sections {
|
for section in sections {
|
||||||
guard usedPillCount < datePillPool.count else { break }
|
|
||||||
|
|
||||||
// Telegram formula: headerY = min(max(sectionTop, stickyY), sectionBottom - pillH)
|
// Telegram formula: headerY = min(max(sectionTop, stickyY), sectionBottom - pillH)
|
||||||
// +9 = vertically centered in 42pt dateHeaderHeight: (42 - 24) / 2 = 9
|
// +9 = vertically centered in 42pt dateHeaderHeight: (42 - 24) / 2 = 9
|
||||||
@@ -711,7 +722,7 @@ final class NativeMessageListController: UIViewController {
|
|||||||
|
|
||||||
let pill = datePillPool[usedPillCount]
|
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.begin()
|
||||||
CATransaction.setDisableActions(true)
|
CATransaction.setDisableActions(true)
|
||||||
pill.label.text = section.text
|
pill.label.text = section.text
|
||||||
@@ -723,20 +734,34 @@ final class NativeMessageListController: UIViewController {
|
|||||||
x: round((screenW - pillW) / 2), y: headerY,
|
x: round((screenW - pillW) / 2), y: headerY,
|
||||||
width: pillW, height: pillH
|
width: pillW, height: pillH
|
||||||
)
|
)
|
||||||
pill.container.frame = pillFrame
|
|
||||||
pill.container.layer.cornerRadius = pillH / 2
|
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
|
pill.container.isHidden = false
|
||||||
// Natural-position pills always visible. Stuck pills fade with timer.
|
// Natural-position pills always visible. Stuck pills fade with timer.
|
||||||
pill.container.alpha = isStuck ? (areDatePillsVisible ? 1 : 0) : 1
|
pill.container.alpha = isStuck ? (areDatePillsVisible ? 1 : 0) : 1
|
||||||
pill.container.tag = isStuck ? 1 : 0
|
pill.container.tag = isStuck ? 1 : 0
|
||||||
CATransaction.commit()
|
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
|
usedPillCount += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -867,6 +892,10 @@ final class NativeMessageListController: UIViewController {
|
|||||||
|
|
||||||
// Capture visible cell positions BEFORE applying snapshot (for position animation)
|
// Capture visible cell positions BEFORE applying snapshot (for position animation)
|
||||||
var oldPositions: [String: CGFloat] = [:]
|
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 {
|
if isInteractive {
|
||||||
for ip in collectionView.indexPathsForVisibleItems {
|
for ip in collectionView.indexPathsForVisibleItems {
|
||||||
if let cellId = dataSource.itemIdentifier(for: ip),
|
if let cellId = dataSource.itemIdentifier(for: ip),
|
||||||
@@ -901,6 +930,30 @@ final class NativeMessageListController: UIViewController {
|
|||||||
if isInteractive {
|
if isInteractive {
|
||||||
collectionView.layoutIfNeeded()
|
collectionView.layoutIfNeeded()
|
||||||
applyInsertionAnimations(newIds: newIds, oldPositions: oldPositions)
|
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 {
|
if !hasCompletedInitialLoad && !messages.isEmpty {
|
||||||
@@ -1186,8 +1239,15 @@ final class NativeMessageListController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.view.layoutIfNeeded()
|
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
|
}, completion: { _ in
|
||||||
self.isKeyboardAnimating = false
|
self.isKeyboardAnimating = false
|
||||||
|
self.updateFloatingDateHeader()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1224,7 +1284,9 @@ final class NativeMessageListController: UIViewController {
|
|||||||
extension NativeMessageListController: UICollectionViewDelegate {
|
extension NativeMessageListController: UICollectionViewDelegate {
|
||||||
|
|
||||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
|
if !isKeyboardAnimating {
|
||||||
updateFloatingDateHeader()
|
updateFloatingDateHeader()
|
||||||
|
}
|
||||||
|
|
||||||
let offsetFromBottom = scrollView.contentOffset.y + scrollView.contentInset.top
|
let offsetFromBottom = scrollView.contentOffset.y + scrollView.contentInset.top
|
||||||
let isAtBottom = offsetFromBottom < 50
|
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:") {
|
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(
|
items.append(TelegramContextMenuItem(
|
||||||
title: "Join Group",
|
title: isJoined ? "Open Group" : "Join Group",
|
||||||
iconName: "person.2.badge.plus",
|
iconName: isJoined ? "person.2" : "person.2.badge.plus",
|
||||||
isDestructive: false,
|
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
|
// MARK: - ZoomableImagePage
|
||||||
|
|
||||||
/// Single page in the image gallery viewer with UIKit-based gesture handling.
|
/// Single page in the image gallery viewer.
|
||||||
/// Android parity: `ZoomableImage` in `ImageViewerScreen.kt` — centroid-based pinch zoom,
|
/// Uses GeometryReader + explicit frame calculation (Telegram parity) instead of
|
||||||
/// double-tap to tap point, velocity-based dismiss, axis locking, edge tap navigation.
|
/// `.scaledToFit()` which is unreliable inside TabView `.page` style overlay chains.
|
||||||
struct ZoomableImagePage: View {
|
struct ZoomableImagePage: View {
|
||||||
|
|
||||||
let attachmentId: String
|
let attachmentId: String
|
||||||
@@ -23,28 +23,27 @@ struct ZoomableImagePage: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
let effectiveScale = zoomScale * pinchScale
|
let effectiveScale = zoomScale * pinchScale
|
||||||
|
|
||||||
// Color.clear always fills ALL proposed space from the parent — TabView page,
|
GeometryReader { geo in
|
||||||
// hero frame, etc. The Image in .overlay sizes relative to Color.clear's actual
|
let viewSize = geo.size
|
||||||
// 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 {
|
if let image {
|
||||||
|
let fitted = fittedSize(image.size, in: viewSize)
|
||||||
|
|
||||||
Image(uiImage: image)
|
Image(uiImage: image)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.frame(width: fitted.width, height: fitted.height)
|
||||||
.scaleEffect(effectiveScale)
|
.scaleEffect(effectiveScale)
|
||||||
.offset(
|
.offset(
|
||||||
x: effectiveScale > 1.05 ? zoomOffset.width : 0,
|
x: effectiveScale > 1.05 ? zoomOffset.width : 0,
|
||||||
y: effectiveScale > 1.05 ? zoomOffset.height : 0
|
y: effectiveScale > 1.05 ? zoomOffset.height : 0
|
||||||
)
|
)
|
||||||
|
.position(x: viewSize.width / 2, y: viewSize.height / 2)
|
||||||
} else {
|
} else {
|
||||||
placeholder
|
placeholder
|
||||||
|
.position(x: viewSize.width / 2, y: viewSize.height / 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
// Double tap: zoom to 2.5x or reset (MUST be before single tap)
|
|
||||||
.onTapGesture(count: 2) {
|
.onTapGesture(count: 2) {
|
||||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
|
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
|
||||||
if zoomScale > 1.1 {
|
if zoomScale > 1.1 {
|
||||||
@@ -56,7 +55,6 @@ struct ZoomableImagePage: View {
|
|||||||
currentScale = zoomScale
|
currentScale = zoomScale
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Single tap: toggle controls / edge navigation
|
|
||||||
.onTapGesture { location in
|
.onTapGesture { location in
|
||||||
let width = UIScreen.main.bounds.width
|
let width = UIScreen.main.bounds.width
|
||||||
let edgeZone = width * 0.20
|
let edgeZone = width * 0.20
|
||||||
@@ -68,7 +66,6 @@ struct ZoomableImagePage: View {
|
|||||||
showControls.toggle()
|
showControls.toggle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Pinch zoom
|
|
||||||
.simultaneousGesture(
|
.simultaneousGesture(
|
||||||
MagnifyGesture()
|
MagnifyGesture()
|
||||||
.updating($pinchScale) { value, state, _ in
|
.updating($pinchScale) { value, state, _ in
|
||||||
@@ -86,16 +83,11 @@ struct ZoomableImagePage: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
// Pan when zoomed
|
|
||||||
.simultaneousGesture(
|
.simultaneousGesture(
|
||||||
zoomScale > 1.05 ?
|
zoomScale > 1.05 ?
|
||||||
DragGesture()
|
DragGesture()
|
||||||
.onChanged { value in
|
.onChanged { value in zoomOffset = value.translation }
|
||||||
zoomOffset = value.translation
|
.onEnded { _ in }
|
||||||
}
|
|
||||||
.onEnded { _ in
|
|
||||||
// Clamp offset
|
|
||||||
}
|
|
||||||
: nil
|
: nil
|
||||||
)
|
)
|
||||||
.task {
|
.task {
|
||||||
@@ -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
|
// MARK: - Placeholder
|
||||||
|
|
||||||
private var placeholder: some View {
|
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
|
.onReceive(NotificationCenter.default.publisher(for: .openChatFromNotification)) { notification in
|
||||||
guard let route = notification.object as? ChatRoute else { return }
|
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.pendingChatRoute = nil
|
||||||
AppDelegate.pendingChatRouteTimestamp = 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 {
|
.onAppear {
|
||||||
// Cold start fallback: ChatListView didn't exist when notification was posted.
|
// Cold start fallback: ChatListView didn't exist when notification was posted.
|
||||||
|
|||||||
Reference in New Issue
Block a user