Фикс клавиатуры, скругления input, iOS 26 layout, доставка сообщений и синхронизация

This commit is contained in:
2026-03-24 20:31:30 +05:00
parent 1cdd392cf3
commit d482cdf62b
20 changed files with 565 additions and 235 deletions

View File

@@ -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)
}
}

View File

@@ -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) {