Фикс клавиатуры, скругления input, iOS 26 layout, доставка сообщений и синхронизация
This commit is contained in:
@@ -27,7 +27,7 @@ private struct KeyboardSpacer: View {
|
||||
// Inverted scroll: spacer at VStack START. Growing it pushes
|
||||
// messages away from offset=0 → visually UP. CADisplayLink
|
||||
// animates keyboardPadding in sync with keyboard curve.
|
||||
return composerHeight + max(keyboard.keyboardPadding, keyboard.spacerPadding) + 8
|
||||
return composerHeight + keyboard.spacerPadding + 8
|
||||
}
|
||||
}()
|
||||
#if DEBUG
|
||||
@@ -220,22 +220,9 @@ struct ChatDetailView: View {
|
||||
}
|
||||
.modifier(IgnoreKeyboardSafeAreaLegacy())
|
||||
.background {
|
||||
ZStack(alignment: .bottom) {
|
||||
ZStack {
|
||||
RosettaColors.Adaptive.background
|
||||
tiledChatBackground
|
||||
// Telegram-style: dark gradient at screen bottom (home indicator area).
|
||||
// In background (not overlay) so it never moves with keyboard.
|
||||
if #unavailable(iOS 26) {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.black.opacity(0.0),
|
||||
Color.black.opacity(0.55)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: 34)
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
@@ -594,7 +581,39 @@ private extension ChatDetailView {
|
||||
@ViewBuilder
|
||||
var chatEdgeGradients: some View {
|
||||
if #available(iOS 26, *) {
|
||||
EmptyView()
|
||||
VStack(spacing: 0) {
|
||||
// Top: dark gradient behind nav bar (same style as iOS < 26).
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: Color.black.opacity(0.85), location: 0.0),
|
||||
.init(color: Color.black.opacity(0.75), location: 0.2),
|
||||
.init(color: Color.black.opacity(0.55), location: 0.4),
|
||||
.init(color: Color.black.opacity(0.3), location: 0.6),
|
||||
.init(color: Color.black.opacity(0.12), location: 0.78),
|
||||
.init(color: Color.black.opacity(0.0), location: 1.0),
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: 100)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Bottom: dark gradient for home indicator area.
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: Color.black.opacity(0.0), location: 0.0),
|
||||
.init(color: Color.black.opacity(0.3), location: 0.3),
|
||||
.init(color: Color.black.opacity(0.65), location: 0.6),
|
||||
.init(color: Color.black.opacity(0.85), location: 1.0),
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: 54)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.allowsHitTesting(false)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
// Telegram-style: dark gradient that smoothly fades content into
|
||||
@@ -615,8 +634,20 @@ private extension ChatDetailView {
|
||||
.frame(height: 90)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Bottom: dark gradient for home indicator area below composer.
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: Color.black.opacity(0.0), location: 0.0),
|
||||
.init(color: Color.black.opacity(0.55), location: 0.35),
|
||||
.init(color: Color.black.opacity(0.85), location: 1.0),
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: 44)
|
||||
}
|
||||
.ignoresSafeArea(edges: .top)
|
||||
.ignoresSafeArea()
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
@@ -1443,7 +1474,7 @@ private extension ChatDetailView {
|
||||
if message.deliveryStatus == .error {
|
||||
errorMenu(for: message)
|
||||
} else {
|
||||
deliveryIndicator(message.deliveryStatus)
|
||||
deliveryIndicator(message.deliveryStatus, read: message.isRead)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1463,7 +1494,7 @@ private extension ChatDetailView {
|
||||
if message.deliveryStatus == .error {
|
||||
errorMenu(for: message)
|
||||
} else {
|
||||
mediaDeliveryIndicator(message.deliveryStatus)
|
||||
mediaDeliveryIndicator(message.deliveryStatus, read: message.isRead)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1600,6 +1631,9 @@ private extension ChatDetailView {
|
||||
},
|
||||
onUserTextInsertion: handleComposerUserTyping,
|
||||
onMultilineChange: { multiline in
|
||||
#if DEBUG
|
||||
print("📐 onMultilineChange callback: \(multiline) (was \(isMultilineInput)) → radius will be \(multiline ? 16 : 21)")
|
||||
#endif
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isMultilineInput = multiline
|
||||
}
|
||||
@@ -1611,7 +1645,9 @@ private extension ChatDetailView {
|
||||
.frame(maxWidth: .infinity, minHeight: 36, alignment: .bottomLeading)
|
||||
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
Button { } label: {
|
||||
Button {
|
||||
switchToEmojiKeyboard()
|
||||
} label: {
|
||||
TelegramVectorIcon(
|
||||
pathData: TelegramIconPath.emojiMoon,
|
||||
viewBox: CGSize(width: 19, height: 19),
|
||||
@@ -1620,8 +1656,8 @@ private extension ChatDetailView {
|
||||
.frame(width: 19, height: 19)
|
||||
.frame(width: 20, height: 36)
|
||||
}
|
||||
.accessibilityLabel("Quick actions")
|
||||
.buttonStyle(ChatDetailGlassPressButtonStyle())
|
||||
.accessibilityLabel("Emoji")
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.trailing, 8 + (sendButtonWidth * sendButtonProgress))
|
||||
.frame(height: 36, alignment: .center)
|
||||
@@ -1804,6 +1840,12 @@ private extension ChatDetailView {
|
||||
else { isInputFocused = true }
|
||||
}
|
||||
|
||||
/// Opens the keyboard — emoji button acts as a focus trigger.
|
||||
/// System emoji keyboard is accessible via the 🌐 key on the keyboard.
|
||||
func switchToEmojiKeyboard() {
|
||||
if !isInputFocused { isInputFocused = true }
|
||||
}
|
||||
|
||||
var composerDismissGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 10)
|
||||
.onChanged { value in
|
||||
@@ -1815,9 +1857,10 @@ private extension ChatDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
func deliveryTint(_ status: DeliveryStatus) -> Color {
|
||||
/// Android/Desktop parity: delivery tint depends on both delivery status AND read flag.
|
||||
func deliveryTint(_ status: DeliveryStatus, read: Bool) -> Color {
|
||||
if status == .delivered && read { return Color(hex: 0xA4E2FF) }
|
||||
switch status {
|
||||
case .read: return Color(hex: 0xA4E2FF)
|
||||
case .delivered: return Color.white.opacity(0.5)
|
||||
case .error: return RosettaColors.error
|
||||
default: return Color.white.opacity(0.78)
|
||||
@@ -1825,47 +1868,51 @@ private extension ChatDetailView {
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func deliveryIndicator(_ status: DeliveryStatus) -> some View {
|
||||
switch status {
|
||||
case .read:
|
||||
func deliveryIndicator(_ status: DeliveryStatus, read: Bool) -> some View {
|
||||
if status == .delivered && read {
|
||||
DoubleCheckmarkShape()
|
||||
.fill(deliveryTint(status))
|
||||
.fill(deliveryTint(status, read: read))
|
||||
.frame(width: 16, height: 8.7)
|
||||
case .delivered:
|
||||
SingleCheckmarkShape()
|
||||
.fill(deliveryTint(status))
|
||||
.frame(width: 12, height: 8.8)
|
||||
case .waiting:
|
||||
Image(systemName: "clock")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(deliveryTint(status))
|
||||
case .error:
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(deliveryTint(status))
|
||||
} else {
|
||||
switch status {
|
||||
case .delivered:
|
||||
SingleCheckmarkShape()
|
||||
.fill(deliveryTint(status, read: read))
|
||||
.frame(width: 12, height: 8.8)
|
||||
case .waiting:
|
||||
Image(systemName: "clock")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(deliveryTint(status, read: read))
|
||||
case .error:
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(deliveryTint(status, read: read))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delivery indicator with white tint for on-image media overlay.
|
||||
@ViewBuilder
|
||||
func mediaDeliveryIndicator(_ status: DeliveryStatus) -> some View {
|
||||
switch status {
|
||||
case .read:
|
||||
func mediaDeliveryIndicator(_ status: DeliveryStatus, read: Bool) -> some View {
|
||||
if status == .delivered && read {
|
||||
DoubleCheckmarkShape()
|
||||
.fill(Color.white)
|
||||
.frame(width: 16, height: 8.7)
|
||||
case .delivered:
|
||||
SingleCheckmarkShape()
|
||||
.fill(Color.white.opacity(0.8))
|
||||
.frame(width: 12, height: 8.8)
|
||||
case .waiting:
|
||||
Image(systemName: "clock")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
case .error:
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.error)
|
||||
} else {
|
||||
switch status {
|
||||
case .delivered:
|
||||
SingleCheckmarkShape()
|
||||
.fill(Color.white.opacity(0.8))
|
||||
.frame(width: 12, height: 8.8)
|
||||
case .waiting:
|
||||
Image(systemName: "clock")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
case .error:
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1980,7 +2027,7 @@ private extension ChatDetailView {
|
||||
|
||||
private func contextMenuReadStatus(for message: ChatMessage) -> String? {
|
||||
let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
|
||||
guard outgoing, message.deliveryStatus == .read else { return nil }
|
||||
guard outgoing, message.deliveryStatus == .delivered, message.isRead else { return nil }
|
||||
return "Read"
|
||||
}
|
||||
|
||||
@@ -2788,11 +2835,17 @@ enum TelegramIconPath {
|
||||
/// iOS 26+: SwiftUI handles keyboard natively.
|
||||
private struct IgnoreKeyboardSafeAreaLegacy: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
// Both iOS versions: disable SwiftUI's native keyboard avoidance.
|
||||
// Inverted scroll (scaleEffect y: -1) breaks native avoidance — it pushes
|
||||
// content in the wrong direction. KeyboardSpacer + ComposerOverlay handle
|
||||
// keyboard offset manually via KeyboardTracker.
|
||||
content.ignoresSafeArea(.keyboard)
|
||||
if #available(iOS 26, *) {
|
||||
// iOS 26+: SwiftUI handles keyboard natively — don't block it.
|
||||
// KeyboardTracker is inert on iOS 26 (init returns early).
|
||||
content
|
||||
} else {
|
||||
// iOS < 26: disable SwiftUI's native keyboard avoidance.
|
||||
// Inverted scroll (scaleEffect y: -1) breaks native avoidance — it pushes
|
||||
// content in the wrong direction. KeyboardSpacer + ComposerHostView handle
|
||||
// keyboard offset manually via KeyboardTracker.
|
||||
content.ignoresSafeArea(.keyboard)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2846,6 +2899,7 @@ private struct ComposerOverlay<C: View>: View {
|
||||
}
|
||||
)
|
||||
.padding(.bottom, pad)
|
||||
.frame(maxHeight: .infinity, alignment: .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,8 +50,15 @@ struct ChatRowView: View {
|
||||
|
||||
// MARK: - Avatar
|
||||
|
||||
private extension ChatRowView {
|
||||
var avatarSection: some View {
|
||||
/// Observation-isolated: reads `AvatarRepository.avatarVersion` in its own
|
||||
/// scope so only the avatar re-renders when opponent avatar changes — not the
|
||||
/// entire ChatRowView (title, message preview, badge, etc.).
|
||||
private struct ChatRowAvatar: View {
|
||||
let dialog: Dialog
|
||||
|
||||
var body: some View {
|
||||
// Establish @Observable tracking — re-renders this view on avatar save/remove.
|
||||
let _ = AvatarRepository.shared.avatarVersion
|
||||
AvatarView(
|
||||
initials: dialog.initials,
|
||||
colorIndex: dialog.avatarColorIndex,
|
||||
@@ -63,6 +70,12 @@ private extension ChatRowView {
|
||||
}
|
||||
}
|
||||
|
||||
private extension ChatRowView {
|
||||
var avatarSection: some View {
|
||||
ChatRowAvatar(dialog: dialog)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content Section
|
||||
// Figma "Contents": flex-col, h-full, items-start, justify-center, pb-px
|
||||
// └─ "Title and Trailing Accessories": flex-1, gap-6, items-center
|
||||
@@ -209,22 +222,24 @@ private extension ChatRowView {
|
||||
|
||||
@ViewBuilder
|
||||
var deliveryIcon: some View {
|
||||
switch dialog.lastMessageDelivered {
|
||||
case .waiting:
|
||||
// Timer isolated to sub-view — only .waiting rows create a timer.
|
||||
DeliveryWaitingIcon(sentTimestamp: dialog.lastMessageTimestamp)
|
||||
case .delivered:
|
||||
SingleCheckmarkShape()
|
||||
.fill(RosettaColors.Adaptive.textSecondary)
|
||||
.frame(width: 14, height: 10.3)
|
||||
case .read:
|
||||
if dialog.lastMessageDelivered == .delivered && dialog.lastMessageRead {
|
||||
DoubleCheckmarkShape()
|
||||
.fill(RosettaColors.figmaBlue)
|
||||
.frame(width: 17, height: 9.3)
|
||||
case .error:
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(RosettaColors.error)
|
||||
} else {
|
||||
switch dialog.lastMessageDelivered {
|
||||
case .waiting:
|
||||
// Timer isolated to sub-view — only .waiting rows create a timer.
|
||||
DeliveryWaitingIcon(sentTimestamp: dialog.lastMessageTimestamp)
|
||||
case .delivered:
|
||||
SingleCheckmarkShape()
|
||||
.fill(RosettaColors.Adaptive.textSecondary)
|
||||
.frame(width: 14, height: 10.3)
|
||||
case .error:
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(RosettaColors.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,7 +356,8 @@ private extension ChatRowView {
|
||||
unreadCount: 3, isOnline: true, lastSeen: 0,
|
||||
verified: 1, iHaveSent: true,
|
||||
isPinned: false, isMuted: false,
|
||||
lastMessageFromMe: true, lastMessageDelivered: .read
|
||||
lastMessageFromMe: true, lastMessageDelivered: .delivered,
|
||||
lastMessageRead: true
|
||||
)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
|
||||
Reference in New Issue
Block a user