164 lines
6.7 KiB
Swift
164 lines
6.7 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - In-App Notification Banner (Telegram parity)
|
|
|
|
/// Telegram-style in-app notification banner shown when a message arrives
|
|
/// while the app is in foreground and the user is NOT in that chat.
|
|
///
|
|
/// Specs (from Telegram iOS `ChatMessageNotificationItem.swift` + `NotificationItemContainerNode.swift`):
|
|
/// - Panel: 74pt min height, 24pt corner radius, 8pt horizontal margin
|
|
/// - Avatar: 54pt circle, 12pt left inset, 23pt avatar-to-text spacing
|
|
/// - Title: semibold 16pt, white, 1 line
|
|
/// - Message: regular 16pt, white (full), max 2 lines
|
|
/// - Background: TelegramGlass (CABackdropLayer / UIGlassEffect)
|
|
/// - Slide from top: 0.4s, auto-dismiss: 5s, swipe dismiss with 0.55 damping
|
|
struct InAppBannerView: View {
|
|
let senderName: String
|
|
let messagePreview: String
|
|
let senderKey: String
|
|
let isGroup: Bool
|
|
let onTap: () -> Void
|
|
let onDismiss: () -> Void
|
|
|
|
@State private var dragOffset: CGFloat = 0
|
|
|
|
private let panelHeight: CGFloat = 74
|
|
private let cornerRadius: CGFloat = 24
|
|
private let avatarSize: CGFloat = 54
|
|
private let horizontalMargin: CGFloat = 8
|
|
|
|
var body: some View {
|
|
HStack(spacing: 23) {
|
|
avatarView
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(senderName)
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.lineLimit(1)
|
|
Text(messagePreview)
|
|
.font(.system(size: 16))
|
|
.foregroundStyle(.white)
|
|
.lineLimit(2)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.leading, 12)
|
|
.padding(.trailing, 10)
|
|
.frame(minHeight: panelHeight)
|
|
.frame(maxWidth: .infinity)
|
|
.glass(cornerRadius: cornerRadius)
|
|
.padding(.horizontal, horizontalMargin)
|
|
.offset(y: dragOffset)
|
|
.gesture(
|
|
DragGesture(minimumDistance: 5)
|
|
.onChanged { value in
|
|
let translation = value.translation.height
|
|
if translation < 0 {
|
|
// Dragging up — full 1:1 tracking.
|
|
dragOffset = translation
|
|
} else {
|
|
// Dragging down — logarithmic rubber-band (Telegram: 0.55/50 cap).
|
|
// Formula: -((1 - 1/((delta*0.55/50)+1)) * 50)
|
|
let delta = translation
|
|
dragOffset = (1.0 - (1.0 / ((delta * 0.55 / 50.0) + 1.0))) * 50.0
|
|
}
|
|
}
|
|
.onEnded { value in
|
|
let velocity = value.predictedEndTranslation.height - value.translation.height
|
|
// Dismiss: swiped up > 20pt or fast upward velocity (Telegram: -20pt / 300pt/s).
|
|
if value.translation.height < -20 || velocity < -300 {
|
|
withAnimation(.easeOut(duration: 0.4)) {
|
|
dragOffset = -200
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
|
|
onDismiss()
|
|
}
|
|
} else {
|
|
// Spring back (Telegram: 0.3s easeInOut).
|
|
withAnimation(.easeInOut(duration: 0.3)) {
|
|
dragOffset = 0
|
|
}
|
|
}
|
|
}
|
|
)
|
|
.onTapGesture {
|
|
onTap()
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var avatarView: some View {
|
|
let colorIndex = Self.avatarColorIndex(for: senderName, publicKey: senderKey)
|
|
let pair = Self.avatarColors[colorIndex % Self.avatarColors.count]
|
|
|
|
if isGroup {
|
|
// Group: solid tint circle + person.2.fill (ChatRowView parity).
|
|
ZStack {
|
|
Circle().fill(Color(hex: UInt(pair.tint)))
|
|
Image(systemName: "person.2.fill")
|
|
.font(.system(size: 20, weight: .medium))
|
|
.foregroundStyle(.white.opacity(0.9))
|
|
}
|
|
.frame(width: avatarSize, height: avatarSize)
|
|
} else {
|
|
// Personal: Mantine "light" variant (AvatarView parity).
|
|
let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: senderKey)
|
|
if let image = avatarImage {
|
|
Image(uiImage: image)
|
|
.resizable()
|
|
.scaledToFill()
|
|
.frame(width: avatarSize, height: avatarSize)
|
|
.clipShape(Circle())
|
|
} else {
|
|
ZStack {
|
|
Circle().fill(Color(hex: 0x1A1B1E))
|
|
Circle().fill(Color(hex: UInt(pair.tint)).opacity(0.15))
|
|
Text(Self.initials(name: senderName, publicKey: senderKey))
|
|
.font(.system(size: avatarSize * 0.38, weight: .bold, design: .rounded))
|
|
.foregroundStyle(Color(hex: UInt(pair.text)))
|
|
}
|
|
.frame(width: avatarSize, height: avatarSize)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Color helpers (duplicated from Colors.swift — NSE can't import main target)
|
|
|
|
private static let avatarColors: [(tint: UInt32, text: UInt32)] = [
|
|
(0x228be6, 0x74c0fc), (0x15aabf, 0x66d9e8), (0xbe4bdb, 0xe599f7),
|
|
(0x40c057, 0x8ce99a), (0x4c6ef5, 0x91a7ff), (0x82c91e, 0xc0eb75),
|
|
(0xfd7e14, 0xffc078), (0xe64980, 0xfaa2c1), (0xfa5252, 0xffa8a8),
|
|
(0x12b886, 0x63e6be), (0x7950f2, 0xb197fc),
|
|
]
|
|
|
|
private static func avatarColorIndex(for name: String, publicKey: String) -> Int {
|
|
let trimmed = name.trimmingCharacters(in: .whitespaces)
|
|
let input = trimmed.isEmpty ? String(publicKey.prefix(7)) : trimmed
|
|
var hash: Int32 = 0
|
|
for char in input.unicodeScalars {
|
|
hash = (hash &<< 5) &- hash &+ Int32(truncatingIfNeeded: char.value)
|
|
}
|
|
let count = Int32(avatarColors.count)
|
|
var index = abs(hash) % count
|
|
if index < 0 { index += count }
|
|
return Int(index)
|
|
}
|
|
|
|
private static func initials(name: String, publicKey: String) -> String {
|
|
let words = name.trimmingCharacters(in: .whitespaces)
|
|
.split(whereSeparator: { $0.isWhitespace })
|
|
.filter { !$0.isEmpty }
|
|
switch words.count {
|
|
case 0:
|
|
return publicKey.isEmpty ? "??" : String(publicKey.prefix(2)).uppercased()
|
|
case 1:
|
|
return String(words[0].prefix(2)).uppercased()
|
|
default:
|
|
let first = words[0].first.map(String.init) ?? ""
|
|
let second = words[1].first.map(String.init) ?? ""
|
|
return (first + second).uppercased()
|
|
}
|
|
}
|
|
}
|