Files
mobile-ios/Rosetta/DesignSystem/Components/InAppBannerView.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()
}
}
}