Исправление winding direction хвостика incoming-баблов + выравнивание баблов в группе

This commit is contained in:
2026-03-10 19:31:09 +05:00
parent 2cc780201d
commit 0f5094df10
17 changed files with 929 additions and 202 deletions

View File

@@ -0,0 +1,224 @@
import SwiftUI
// MARK: - Bubble Position
enum BubblePosition: Sendable, Equatable {
case single, top, mid, bottom
}
// MARK: - Message Bubble Shape
/// Unified message bubble shape: rounded-rect body + tail drawn as a **single fill**.
///
/// The body and tail are two closed subpaths inside one `Path`.
/// Non-zero winding rule fills the overlap area seamlessly
/// no anti-aliasing seam between body and tail.
///
/// For positions without a tail (`.top`, `.mid`), only the body subpath is drawn.
/// For positions with a tail (`.single`, `.bottom`), both subpaths are drawn.
///
/// The shape's `rect` includes space for the tail protrusion on the near side.
/// The body is inset from that side; the tail fills the protrusion area.
struct MessageBubbleShape: Shape {
let position: BubblePosition
let outgoing: Bool
let hasTail: Bool
/// How far the tail protrudes beyond the bubble body edge (points).
static let tailProtrusion: CGFloat = 6
init(position: BubblePosition, outgoing: Bool) {
self.position = position
self.outgoing = outgoing
switch position {
case .single, .bottom: self.hasTail = true
case .top, .mid: self.hasTail = false
}
}
func path(in rect: CGRect) -> Path {
var p = Path()
// Body rect: inset on the near side when tail is present
let bodyRect: CGRect
if hasTail {
if outgoing {
bodyRect = CGRect(x: rect.minX, y: rect.minY,
width: rect.width - Self.tailProtrusion, height: rect.height)
} else {
bodyRect = CGRect(x: rect.minX + Self.tailProtrusion, y: rect.minY,
width: rect.width - Self.tailProtrusion, height: rect.height)
}
} else {
bodyRect = rect
}
addBody(to: &p, rect: bodyRect)
if hasTail {
addTail(to: &p, bodyRect: bodyRect)
}
return p
}
// MARK: - Body (Rounded Rect with Per-Corner Radii)
private func addBody(to p: inout Path, rect: CGRect) {
let r: CGFloat = 18
let s: CGFloat = 8
let (tl, tr, bl, br) = cornerRadii(r: r, s: s)
// Clamp to half the smallest dimension
let maxR = min(rect.width, rect.height) / 2
let cTL = min(tl, maxR)
let cTR = min(tr, maxR)
let cBL = min(bl, maxR)
let cBR = min(br, maxR)
p.move(to: CGPoint(x: rect.minX + cTL, y: rect.minY))
// Top edge top-right corner
p.addLine(to: CGPoint(x: rect.maxX - cTR, y: rect.minY))
p.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.minY),
tangent2End: CGPoint(x: rect.maxX, y: rect.minY + cTR),
radius: cTR)
// Right edge bottom-right corner
p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - cBR))
p.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.maxY),
tangent2End: CGPoint(x: rect.maxX - cBR, y: rect.maxY),
radius: cBR)
// Bottom edge bottom-left corner
p.addLine(to: CGPoint(x: rect.minX + cBL, y: rect.maxY))
p.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.maxY),
tangent2End: CGPoint(x: rect.minX, y: rect.maxY - cBL),
radius: cBL)
// Left edge top-left corner
p.addLine(to: CGPoint(x: rect.minX, y: rect.minY + cTL))
p.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.minY),
tangent2End: CGPoint(x: rect.minX + cTL, y: rect.minY),
radius: cTL)
p.closeSubpath()
}
/// Figma corner radii: 8px on "connecting" side, 18px elsewhere.
private func cornerRadii(r: CGFloat, s: CGFloat)
-> (topLeading: CGFloat, topTrailing: CGFloat,
bottomLeading: CGFloat, bottomTrailing: CGFloat) {
switch position {
case .single:
return (r, r, r, r)
case .top:
return outgoing
? (r, r, r, s)
: (r, r, s, r)
case .mid:
return outgoing
? (r, s, r, s)
: (s, r, s, r)
case .bottom:
return outgoing
? (r, s, r, r)
: (s, r, r, r)
}
}
// MARK: - Tail (Figma SVG separate subpath)
/// Draws the tail as a second closed subpath that overlaps the body at the
/// bottom-near corner. Both subpaths are filled together in one `.fill()` call,
/// so the overlapping area has no visible seam.
///
/// Uses the exact Figma SVG path (viewBox 0 0 13.6216 33.3).
/// Raw SVG: straight edge at x5.6, tip protrudes LEFT to x=0.
/// The `dir` multiplier flips the protrusion direction for outgoing.
private func addTail(to p: inout Path, bodyRect: CGRect) {
// Figma SVG straight edge X defines the body attachment line
let svgStraightX: CGFloat = 5.59961
let svgMaxY: CGFloat = 33.2305
// Uniform scale: maps SVG protrusion (5.6 units) to screen protrusion
let sc = Self.tailProtrusion / svgStraightX
// Tail height in points
let tailH = svgMaxY * sc
let bodyEdge = outgoing ? bodyRect.maxX : bodyRect.minX
let bottom = bodyRect.maxY
let top = bottom - tailH
// +1 = protrude RIGHT (outgoing), 1 = protrude LEFT (incoming)
let dir: CGFloat = outgoing ? 1 : -1
// Map raw Figma SVG coord screen coord
func tp(_ svgX: CGFloat, _ svgY: CGFloat) -> CGPoint {
let dx = (svgStraightX - svgX) * sc * dir
return CGPoint(x: bodyEdge + dx, y: top + svgY * sc)
}
// -- Exact Figma SVG path (from Figma API, viewBox 0 0 13.6216 33.3) --
// M5.59961 24.2305
// C5.42042 28.0524 3.19779 31.339 0 33.0244
// C0.851596 33.1596 1.72394 33.2305 2.6123 33.2305
// C6.53776 33.2305 10.1517 31.8599 13.0293 29.5596
// C10.7434 27.898 8.86922 25.7134 7.57422 23.1719
// C5.61235 19.3215 5.6123 14.281 5.6123 4.2002
// V0 H5.59961 V24.2305 Z
if outgoing {
// Forward order clockwise winding (matches body)
p.move(to: tp(5.59961, 24.2305))
p.addCurve(to: tp(0, 33.0244),
control1: tp(5.42042, 28.0524),
control2: tp(3.19779, 31.339))
p.addCurve(to: tp(2.6123, 33.2305),
control1: tp(0.851596, 33.1596),
control2: tp(1.72394, 33.2305))
p.addCurve(to: tp(13.0293, 29.5596),
control1: tp(6.53776, 33.2305),
control2: tp(10.1517, 31.8599))
p.addCurve(to: tp(7.57422, 23.1719),
control1: tp(10.7434, 27.898),
control2: tp(8.86922, 25.7134))
p.addCurve(to: tp(5.6123, 4.2002),
control1: tp(5.61235, 19.3215),
control2: tp(5.6123, 14.281))
p.addLine(to: tp(5.6123, 0))
p.addLine(to: tp(5.59961, 0))
p.addLine(to: tp(5.59961, 24.2305))
p.closeSubpath()
} else {
// Reversed order clockwise winding for incoming
// (mirroring X flips winding; reversing path order restores it)
p.move(to: tp(5.59961, 24.2305))
p.addLine(to: tp(5.59961, 0))
p.addLine(to: tp(5.6123, 0))
p.addLine(to: tp(5.6123, 4.2002))
// Curve 5 reversed (swap control points)
p.addCurve(to: tp(7.57422, 23.1719),
control1: tp(5.6123, 14.281),
control2: tp(5.61235, 19.3215))
// Curve 4 reversed
p.addCurve(to: tp(13.0293, 29.5596),
control1: tp(8.86922, 25.7134),
control2: tp(10.7434, 27.898))
// Curve 3 reversed
p.addCurve(to: tp(2.6123, 33.2305),
control1: tp(10.1517, 31.8599),
control2: tp(6.53776, 33.2305))
// Curve 2 reversed
p.addCurve(to: tp(0, 33.0244),
control1: tp(1.72394, 33.2305),
control2: tp(0.851596, 33.1596))
// Curve 1 reversed
p.addCurve(to: tp(5.59961, 24.2305),
control1: tp(3.19779, 31.339),
control2: tp(5.42042, 28.0524))
p.closeSubpath()
}
}
}

View File

@@ -137,57 +137,118 @@ private extension ChatDetailView {
@ToolbarContentBuilder
var chatDetailToolbar: some ToolbarContent {
ToolbarItem(placement: .navigationBarLeading) {
Button { dismiss() } label: { backCapsuleButtonLabel }
.buttonStyle(.plain)
.accessibilityLabel("Back")
}
ToolbarItem(placement: .principal) {
Button { dismiss() } label: {
VStack(spacing: 1) {
HStack(spacing: 4) {
Text(titleText)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
if !route.isSavedMessages && effectiveVerified > 0 {
VerifiedBadge(verified: effectiveVerified, size: 14)
}
}
Text(subtitleText)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(
isTyping || (dialog?.isOnline == true)
? RosettaColors.online
: RosettaColors.Adaptive.textSecondary
)
.lineLimit(1)
if #available(iOS 26, *) {
// iOS 26+ original compact sizes with .glassEffect()
ToolbarItem(placement: .navigationBarLeading) {
Button { dismiss() } label: {
TelegramVectorIcon(
pathData: TelegramIconPath.backChevron,
viewBox: CGSize(width: 11, height: 20),
color: .white
)
.frame(width: 11, height: 20)
.allowsHitTesting(false)
}
.padding(.horizontal, 16)
.frame(height: 44)
.background {
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
.frame(width: 36, height: 36)
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
.accessibilityLabel("Back")
}
ToolbarItem(placement: .principal) {
Button { dismiss() } label: {
VStack(spacing: 1) {
HStack(spacing: 3) {
Text(titleText)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
if !route.isSavedMessages && effectiveVerified > 0 {
VerifiedBadge(verified: effectiveVerified, size: 12)
}
}
Text(subtitleText)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(
isTyping || (dialog?.isOnline == true)
? RosettaColors.online
: RosettaColors.Adaptive.textSecondary
)
.lineLimit(1)
}
.padding(.horizontal, 12)
.frame(height: 44)
.background {
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
}
}
}
.buttonStyle(.plain)
}
ToolbarItem(placement: .navigationBarTrailing) {
AvatarView(
initials: avatarInitials,
colorIndex: avatarColorIndex,
size: 38,
isOnline: false,
isSavedMessages: route.isSavedMessages
)
.frame(width: 44, height: 44)
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
ToolbarItem(placement: .navigationBarTrailing) {
AvatarView(
initials: avatarInitials,
colorIndex: avatarColorIndex,
size: 35,
isOnline: false,
isSavedMessages: route.isSavedMessages
)
.frame(width: 36, height: 36)
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
}
} else {
// iOS < 26 capsule back button, larger avatar, .thinMaterial
ToolbarItem(placement: .navigationBarLeading) {
Button { dismiss() } label: { backCapsuleButtonLabel }
.buttonStyle(.plain)
.accessibilityLabel("Back")
}
ToolbarItem(placement: .principal) {
Button { dismiss() } label: {
VStack(spacing: 1) {
HStack(spacing: 4) {
Text(titleText)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
if !route.isSavedMessages && effectiveVerified > 0 {
VerifiedBadge(verified: effectiveVerified, size: 14)
}
}
Text(subtitleText)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(
isTyping || (dialog?.isOnline == true)
? RosettaColors.online
: RosettaColors.Adaptive.textSecondary
)
.lineLimit(1)
}
.padding(.horizontal, 16)
.frame(height: 44)
.background {
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
}
}
.buttonStyle(.plain)
}
ToolbarItem(placement: .navigationBarTrailing) {
AvatarView(
initials: avatarInitials,
colorIndex: avatarColorIndex,
size: 38,
isOnline: false,
isSavedMessages: route.isSavedMessages
)
.frame(width: 44, height: 44)
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
}
}
}
private var backCapsuleButtonLabel: some View {
TelegramVectorIcon(
@@ -322,12 +383,12 @@ private extension ChatDetailView {
private func messagesScrollView(maxBubbleWidth: CGFloat) -> some View {
ScrollViewReader { proxy in
let scroll = ScrollView(.vertical, showsIndicators: false) {
LazyVStack(spacing: 6) {
LazyVStack(spacing: 0) {
ForEach(Array(messages.enumerated()), id: \.element.id) { index, message in
messageRow(
message,
maxBubbleWidth: maxBubbleWidth,
isTailVisible: isTailVisible(for: index)
position: bubblePosition(for: index)
)
.id(message.id)
}
@@ -375,9 +436,10 @@ private extension ChatDetailView {
}
@ViewBuilder
func messageRow(_ message: ChatMessage, maxBubbleWidth: CGFloat, isTailVisible: Bool) -> some View {
func messageRow(_ message: ChatMessage, maxBubbleWidth: CGFloat, position: BubblePosition) -> some View {
let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
let messageText = message.text.isEmpty ? " " : message.text
let hasTail = position == .single || position == .bottom
// Telegram-style compact bubble: inline time+status at bottom-trailing.
// Right padding reserves space for the timestamp overlay (64pt outgoing, 48pt incoming).
@@ -407,10 +469,17 @@ private extension ChatDetailView {
.padding(.trailing, 11)
.padding(.bottom, 5)
}
.background { bubbleBackground(outgoing: outgoing, isTailVisible: isTailVisible) }
// Tail protrusion space: the unified shape draws the tail in this padding area
.padding(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
.padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
// Single unified background: body + tail drawn in one fill (no seam)
.background { bubbleBackground(outgoing: outgoing, position: position) }
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
.frame(maxWidth: .infinity, alignment: outgoing ? .trailing : .leading)
.padding(.vertical, 1)
.padding(.trailing, outgoing ? (hasTail ? 2 : MessageBubbleShape.tailProtrusion + 2) : 0)
.padding(.leading, !outgoing ? (hasTail ? 2 : MessageBubbleShape.tailProtrusion + 2) : 0)
.padding(.top, (position == .single || position == .top) ? 6 : 2)
.padding(.bottom, 0)
}
// MARK: - Composer
@@ -597,28 +666,43 @@ private extension ChatDetailView {
}
}
// MARK: - Bubble Position (Figma: Single / Top / Mid / Bottom)
/// Determines bubble position within a group of consecutive same-sender plain-text messages.
func bubblePosition(for index: Int) -> BubblePosition {
let hasPrev: Bool = {
guard index > 0 else { return false }
let prev = messages[index - 1]
let current = messages[index]
let sameSender = current.isFromMe(myPublicKey: currentPublicKey)
== prev.isFromMe(myPublicKey: currentPublicKey)
return sameSender && prev.attachments.isEmpty && current.attachments.isEmpty
}()
let hasNext: Bool = {
guard index + 1 < messages.count else { return false }
let next = messages[index + 1]
let current = messages[index]
let sameSender = current.isFromMe(myPublicKey: currentPublicKey)
== next.isFromMe(myPublicKey: currentPublicKey)
return sameSender && next.attachments.isEmpty && current.attachments.isEmpty
}()
switch (hasPrev, hasNext) {
case (false, false): return .single
case (false, true): return .top
case (true, true): return .mid
case (true, false): return .bottom
}
}
// MARK: - Bubbles / Glass
@ViewBuilder
func bubbleBackground(outgoing: Bool, isTailVisible: Bool) -> some View {
let nearRadius: CGFloat = isTailVisible ? 8 : 18
let bubbleRadius: CGFloat = 18
func bubbleBackground(outgoing: Bool, position: BubblePosition) -> some View {
let fill = outgoing ? RosettaColors.figmaBlue : incomingBubbleFill
if #available(iOS 17.0, *) {
UnevenRoundedRectangle(
cornerRadii: .init(
topLeading: bubbleRadius,
bottomLeading: outgoing ? bubbleRadius : nearRadius,
bottomTrailing: outgoing ? nearRadius : bubbleRadius,
topTrailing: bubbleRadius
),
style: .continuous
)
MessageBubbleShape(position: position, outgoing: outgoing)
.fill(fill)
} else {
RoundedRectangle(cornerRadius: bubbleRadius, style: .continuous)
.fill(fill)
}
}
enum ChatGlassShape {
@@ -721,18 +805,7 @@ private extension ChatDetailView {
}
}
func isTailVisible(for index: Int) -> Bool {
guard index < messages.count else { return true }
let current = messages[index]
guard index + 1 < messages.count else { return true }
let next = messages[index + 1]
let sameSender = current.isFromMe(myPublicKey: currentPublicKey) == next.isFromMe(myPublicKey: currentPublicKey)
let currentIsPlainText = current.attachments.isEmpty
let nextIsPlainText = next.attachments.isEmpty
return !(sameSender && currentIsPlainText && nextIsPlainText)
}
// isTailVisible replaced by bubblePosition(for:) above
func requestUserInfoIfNeeded() {
// Always request we need fresh online status even if title is already populated.
@@ -770,7 +843,12 @@ private extension ChatDetailView {
Task { @MainActor in
do {
try await SessionManager.shared.sendMessage(text: message, toPublicKey: route.publicKey)
try await SessionManager.shared.sendMessage(
text: message,
toPublicKey: route.publicKey,
opponentTitle: route.title,
opponentUsername: route.username
)
} catch {
sendError = "Failed to send message"
if messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {