Compare commits
6 Commits
90f75aba6f
...
beb7af003b
| Author | SHA1 | Date | |
|---|---|---|---|
| beb7af003b | |||
| 11710263e0 | |||
| 08e485f9b4 | |||
| 1903e86a02 | |||
| 73bd36e428 | |||
| 2e98a3e4fb |
@ -148,7 +148,7 @@ struct PrivateChatView: View {
|
|||||||
|
|
||||||
private var messagesList: some View {
|
private var messagesList: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 12) {
|
LazyVStack(alignment: .leading, spacing: 0) {
|
||||||
Color.clear
|
Color.clear
|
||||||
.frame(height: 1)
|
.frame(height: 1)
|
||||||
.id(bottomAnchorId)
|
.id(bottomAnchorId)
|
||||||
@ -162,13 +162,13 @@ struct PrivateChatView: View {
|
|||||||
.scaleEffect(x: 1, y: -1, anchor: .center)
|
.scaleEffect(x: 1, y: -1, anchor: .center)
|
||||||
}
|
}
|
||||||
|
|
||||||
ForEach(viewModel.messages.reversed()) { message in
|
ForEach(decoratedMessages.reversed()) { decoratedMessage in
|
||||||
messageRow(for: message)
|
messageRow(for: decoratedMessage)
|
||||||
.id(message.id)
|
.id(decoratedMessage.id)
|
||||||
.scaleEffect(x: 1, y: -1, anchor: .center)
|
.scaleEffect(x: 1, y: -1, anchor: .center)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
guard hasPositionedToBottom else { return }
|
guard hasPositionedToBottom else { return }
|
||||||
viewModel.loadMoreIfNeeded(for: message)
|
viewModel.loadMoreIfNeeded(for: decoratedMessage.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -183,7 +183,7 @@ struct PrivateChatView: View {
|
|||||||
.scaleEffect(x: 1, y: -1, anchor: .center)
|
.scaleEffect(x: 1, y: -1, anchor: .center)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
.scaleEffect(x: 1, y: -1, anchor: .center)
|
.scaleEffect(x: 1, y: -1, anchor: .center)
|
||||||
.simultaneousGesture(
|
.simultaneousGesture(
|
||||||
@ -236,21 +236,39 @@ struct PrivateChatView: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func messageRow(for message: MessageItem) -> some View {
|
private func messageRow(for decoratedMessage: DecoratedMessage) -> some View {
|
||||||
|
let message = decoratedMessage.message
|
||||||
let isCurrentUser = currentUserId.map { $0 == message.senderId } ?? false
|
let isCurrentUser = currentUserId.map { $0 == message.senderId } ?? false
|
||||||
return HStack(alignment: .bottom, spacing: 12) {
|
let hasDecorations = decoratedMessage.showHorns || decoratedMessage.showLegs
|
||||||
|
|
||||||
|
let topPadding: CGFloat = decoratedMessage.showHorns ? 5 : -12
|
||||||
|
let verticalPadding: CGFloat = hasDecorations ? 0 : 0
|
||||||
|
|
||||||
|
return HStack(alignment: .bottom, spacing: 8) {
|
||||||
if isCurrentUser { Spacer(minLength: 32) }
|
if isCurrentUser { Spacer(minLength: 32) }
|
||||||
|
|
||||||
messageBubble(for: message, isCurrentUser: isCurrentUser)
|
messageBubble(
|
||||||
|
for: message,
|
||||||
|
decorations: decoratedMessage,
|
||||||
|
isCurrentUser: isCurrentUser
|
||||||
|
)
|
||||||
|
|
||||||
if !isCurrentUser { Spacer(minLength: 32) }
|
if !isCurrentUser { Spacer(minLength: 32) }
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.top, topPadding)
|
||||||
|
.padding(.vertical, verticalPadding)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func messageBubble(for message: MessageItem, isCurrentUser: Bool) -> some View {
|
private func messageBubble(
|
||||||
|
for message: MessageItem,
|
||||||
|
decorations: DecoratedMessage,
|
||||||
|
isCurrentUser: Bool
|
||||||
|
) -> some View {
|
||||||
let timeText = timestamp(for: message)
|
let timeText = timestamp(for: message)
|
||||||
|
|
||||||
|
let bubbleColor = isCurrentUser ? Color.accentColor : Color(.secondarySystemBackground)
|
||||||
|
|
||||||
return VStack(alignment: isCurrentUser ? .trailing : .leading, spacing: 4) {
|
return VStack(alignment: isCurrentUser ? .trailing : .leading, spacing: 4) {
|
||||||
Text(contentText(for: message))
|
Text(contentText(for: message))
|
||||||
.font(.body)
|
.font(.body)
|
||||||
@ -265,8 +283,13 @@ struct PrivateChatView: View {
|
|||||||
}
|
}
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.background(isCurrentUser ? Color.accentColor : Color(.secondarySystemBackground))
|
.background(
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
MessageBubbleShape(
|
||||||
|
showHornsRaw: decorations.showHorns,
|
||||||
|
showLegsRaw: decorations.showLegs
|
||||||
|
)
|
||||||
|
.fill(bubbleColor)
|
||||||
|
)
|
||||||
.frame(maxWidth: messageBubbleMaxWidth, alignment: isCurrentUser ? .trailing : .leading)
|
.frame(maxWidth: messageBubbleMaxWidth, alignment: isCurrentUser ? .trailing : .leading)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
@ -302,6 +325,31 @@ struct PrivateChatView: View {
|
|||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var decoratedMessages: [DecoratedMessage] {
|
||||||
|
let messages = viewModel.messages
|
||||||
|
guard !messages.isEmpty else { return [] }
|
||||||
|
|
||||||
|
var result: [DecoratedMessage] = []
|
||||||
|
result.reserveCapacity(messages.count)
|
||||||
|
|
||||||
|
for (index, message) in messages.enumerated() {
|
||||||
|
let previousSender = index > 0 ? messages[index - 1].senderId : nil
|
||||||
|
let nextSender = index < messages.count - 1 ? messages[index + 1].senderId : nil
|
||||||
|
|
||||||
|
let showHorns = previousSender != message.senderId
|
||||||
|
let showLegs = nextSender != message.senderId
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
DecoratedMessage(
|
||||||
|
message: message,
|
||||||
|
showHorns: showHorns,
|
||||||
|
showLegs: showLegs
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
private func errorBanner(message: String) -> some View {
|
private func errorBanner(message: String) -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
@ -765,6 +813,133 @@ private var headerPlaceholderAvatar: some View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Helper model that stores a message alongside horn/leg flags for grouping sequences.
|
||||||
|
private struct DecoratedMessage: Identifiable {
|
||||||
|
let message: MessageItem
|
||||||
|
let showHorns: Bool
|
||||||
|
let showLegs: Bool
|
||||||
|
|
||||||
|
var id: MessageItem.ID { message.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decorative bubble with two horns on top and two legs on bottom to mimic cartoon-style speech clouds.
|
||||||
|
private struct MessageBubbleShape: Shape {
|
||||||
|
var cornerRadius: CGFloat = 18
|
||||||
|
var hornHeight: CGFloat = 10
|
||||||
|
var hornWidth: CGFloat = 16
|
||||||
|
var hornSpacing: CGFloat = 12
|
||||||
|
var legHeight: CGFloat = 6
|
||||||
|
var legWidth: CGFloat = 18
|
||||||
|
var legSpacing: CGFloat = 14
|
||||||
|
|
||||||
|
var showHornsRaw: Bool = true
|
||||||
|
var showLegsRaw: Bool = true
|
||||||
|
|
||||||
|
// Фактические флаги отрисовки
|
||||||
|
var showHorns: Bool {
|
||||||
|
AppConfig.ENABLE_CHAT_BUBBLE_DECORATIONS && showHornsRaw
|
||||||
|
}
|
||||||
|
|
||||||
|
var showLegs: Bool {
|
||||||
|
AppConfig.ENABLE_CHAT_BUBBLE_DECORATIONS && showLegsRaw
|
||||||
|
}
|
||||||
|
|
||||||
|
func path(in rect: CGRect) -> Path {
|
||||||
|
var path = Path()
|
||||||
|
guard rect.width > 0, rect.height > 0 else { return path }
|
||||||
|
|
||||||
|
let radius = min(cornerRadius, min(rect.width, rect.height) / 2)
|
||||||
|
let innerTop = rect.minY + hornHeight
|
||||||
|
let innerBottom = rect.maxY - legHeight
|
||||||
|
let left = rect.minX
|
||||||
|
let right = rect.maxX
|
||||||
|
|
||||||
|
let horizontalSpan = max(0, rect.width - 2 * radius)
|
||||||
|
|
||||||
|
let hornsEnabled = showHorns && horizontalSpan > 0.5
|
||||||
|
let legsEnabled = showLegs && horizontalSpan > 0.5
|
||||||
|
|
||||||
|
let effectiveHornWidth = hornsEnabled ? min(hornWidth, horizontalSpan / 2) : 0
|
||||||
|
let effectiveLegWidth = legsEnabled ? min(legWidth, horizontalSpan / 2) : 0
|
||||||
|
|
||||||
|
let hornSpacingCandidate = max(horizontalSpan - effectiveHornWidth * 2, 0) / 2
|
||||||
|
let legSpacingCandidate = max(horizontalSpan - effectiveLegWidth * 2, 0) / 2
|
||||||
|
|
||||||
|
let effectiveHornSpacing = hornsEnabled ? min(hornSpacing, hornSpacingCandidate) : 0
|
||||||
|
let effectiveLegSpacing = legsEnabled ? min(legSpacing, legSpacingCandidate) : 0
|
||||||
|
|
||||||
|
let leftHornStart = left + radius + effectiveHornSpacing
|
||||||
|
let rightHornStart = right - radius - effectiveHornSpacing - effectiveHornWidth
|
||||||
|
|
||||||
|
let leftLegStart = left + radius + effectiveLegSpacing
|
||||||
|
let rightLegStart = right - radius - effectiveLegSpacing - effectiveLegWidth
|
||||||
|
|
||||||
|
path.move(to: CGPoint(x: left + radius, y: innerTop))
|
||||||
|
path.addQuadCurve(to: CGPoint(x: left, y: innerTop + radius), control: CGPoint(x: left, y: innerTop))
|
||||||
|
path.addLine(to: CGPoint(x: left, y: innerBottom - radius))
|
||||||
|
path.addQuadCurve(to: CGPoint(x: left + radius, y: innerBottom), control: CGPoint(x: left, y: innerBottom))
|
||||||
|
|
||||||
|
if legsEnabled {
|
||||||
|
path.addLine(to: CGPoint(x: leftLegStart, y: innerBottom))
|
||||||
|
path.addQuadCurve(
|
||||||
|
to: CGPoint(x: leftLegStart + effectiveLegWidth / 2, y: innerBottom + legHeight),
|
||||||
|
control: CGPoint(x: leftLegStart + effectiveLegWidth * 0.15, y: innerBottom + legHeight)
|
||||||
|
)
|
||||||
|
path.addQuadCurve(
|
||||||
|
to: CGPoint(x: leftLegStart + effectiveLegWidth, y: innerBottom),
|
||||||
|
control: CGPoint(x: leftLegStart + effectiveLegWidth * 0.85, y: innerBottom + legHeight)
|
||||||
|
)
|
||||||
|
|
||||||
|
path.addLine(to: CGPoint(x: rightLegStart, y: innerBottom))
|
||||||
|
path.addQuadCurve(
|
||||||
|
to: CGPoint(x: rightLegStart + effectiveLegWidth / 2, y: innerBottom + legHeight),
|
||||||
|
control: CGPoint(x: rightLegStart + effectiveLegWidth * 0.15, y: innerBottom + legHeight)
|
||||||
|
)
|
||||||
|
path.addQuadCurve(
|
||||||
|
to: CGPoint(x: rightLegStart + effectiveLegWidth, y: innerBottom),
|
||||||
|
control: CGPoint(x: rightLegStart + effectiveLegWidth * 0.85, y: innerBottom + legHeight)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
path.addLine(to: CGPoint(x: right - radius, y: innerBottom))
|
||||||
|
path.addQuadCurve(to: CGPoint(x: right, y: innerBottom - radius), control: CGPoint(x: right, y: innerBottom))
|
||||||
|
path.addLine(to: CGPoint(x: right, y: innerTop + radius))
|
||||||
|
path.addQuadCurve(to: CGPoint(x: right - radius, y: innerTop), control: CGPoint(x: right, y: innerTop))
|
||||||
|
|
||||||
|
if hornsEnabled {
|
||||||
|
let hornOutset = effectiveHornWidth * 0.45
|
||||||
|
let hornTipY = rect.minY - hornHeight * 0.35
|
||||||
|
|
||||||
|
// Right horn leans outward to the right
|
||||||
|
path.addLine(to: CGPoint(x: rightHornStart + effectiveHornWidth, y: innerTop))
|
||||||
|
path.addQuadCurve(
|
||||||
|
to: CGPoint(x: rightHornStart + effectiveHornWidth + hornOutset, y: hornTipY),
|
||||||
|
control: CGPoint(x: rightHornStart + effectiveHornWidth + hornOutset * 0.65, y: innerTop - hornHeight * 0.35)
|
||||||
|
)
|
||||||
|
path.addQuadCurve(
|
||||||
|
to: CGPoint(x: rightHornStart, y: innerTop),
|
||||||
|
control: CGPoint(x: rightHornStart + hornOutset * 0.25, y: innerTop - hornHeight)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Move across the top between horns and draw the left horn leaning outward
|
||||||
|
path.addLine(to: CGPoint(x: leftHornStart + effectiveHornWidth, y: innerTop))
|
||||||
|
path.addQuadCurve(
|
||||||
|
to: CGPoint(x: leftHornStart - hornOutset, y: hornTipY),
|
||||||
|
control: CGPoint(x: leftHornStart + effectiveHornWidth - hornOutset * 0.65, y: innerTop - hornHeight * 0.35)
|
||||||
|
)
|
||||||
|
path.addQuadCurve(
|
||||||
|
to: CGPoint(x: leftHornStart, y: innerTop),
|
||||||
|
control: CGPoint(x: leftHornStart - hornOutset * 0.25, y: innerTop - hornHeight)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
path.addLine(to: CGPoint(x: left + radius, y: innerTop))
|
||||||
|
path.closeSubpath()
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
private struct LegacyMultilineTextView: UIViewRepresentable {
|
private struct LegacyMultilineTextView: UIViewRepresentable {
|
||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
|
|||||||
@ -13,6 +13,8 @@ struct AppConfig {
|
|||||||
static let APP_VERSION = "0.1"
|
static let APP_VERSION = "0.1"
|
||||||
|
|
||||||
static let DISABLE_DB = false
|
static let DISABLE_DB = false
|
||||||
|
/// Temporary flag to toggle whimsical chat bubble horns/legs rendering.
|
||||||
|
static let ENABLE_CHAT_BUBBLE_DECORATIONS = true
|
||||||
/// Controls whether incoming chat opens as a modal sheet (`true`) or navigates to Chats tab (`false`).
|
/// Controls whether incoming chat opens as a modal sheet (`true`) or navigates to Chats tab (`false`).
|
||||||
static let PRESENT_CHAT_AS_SHEET = false
|
static let PRESENT_CHAT_AS_SHEET = false
|
||||||
/// Fallback SQLCipher key used until the user sets an application password.
|
/// Fallback SQLCipher key used until the user sets an application password.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user