Исправление winding direction хвостика incoming-баблов + выравнивание баблов в группе
This commit is contained in:
224
Rosetta/Features/Chats/ChatDetail/BubbleTailShape.swift
Normal file
224
Rosetta/Features/Chats/ChatDetail/BubbleTailShape.swift
Normal 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 x≈5.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user