mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-07-05 19:28:46 +02:00
Update tgwatch
This commit is contained in:
parent
befacaa5fe
commit
65eac2fa94
14 changed files with 142 additions and 264 deletions
|
|
@ -13,10 +13,8 @@ import SwiftUI
|
|||
struct AudioBubbleView: View {
|
||||
let audio: AudioVisual
|
||||
let caption: String
|
||||
let time: String
|
||||
let isOutgoing: Bool
|
||||
let replyHeader: ReplyHeader?
|
||||
let sendingState: SendingState
|
||||
|
||||
@Environment(ChatHistoryStore.self) private var store
|
||||
@Environment(\.bubbleMetrics) private var metrics
|
||||
|
|
@ -73,13 +71,12 @@ struct AudioBubbleView: View {
|
|||
.font(.caption)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
timeRow
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: metrics.bubbleMaxWidth, alignment: .leading)
|
||||
.frame(minWidth: BubbleShape.minSize, maxWidth: metrics.bubbleMaxWidth, minHeight: BubbleShape.minSize, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
RoundedRectangle(cornerRadius: BubbleShape.cornerRadius)
|
||||
.fill(style.fill)
|
||||
)
|
||||
.foregroundStyle(style.content)
|
||||
|
|
@ -118,28 +115,6 @@ struct AudioBubbleView: View {
|
|||
.shadow(radius: audio.albumArt != nil ? 1 : 0)
|
||||
}
|
||||
|
||||
// Time + send-state inline at the bottom-right inside the chrome, matching the
|
||||
// text-bubble convention (and VoiceNoteBubbleView).
|
||||
private var timeRow: some View {
|
||||
HStack(spacing: 2) {
|
||||
Spacer(minLength: 0)
|
||||
Text(time)
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(style.secondary)
|
||||
if isOutgoing { sendStateGlyph }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var sendStateGlyph: some View {
|
||||
switch sendingState {
|
||||
case .sent: EmptyView()
|
||||
case .pending: Image(systemName: "clock").font(.system(size: 8))
|
||||
.foregroundStyle(Color.white.opacity(0.7))
|
||||
case .failed: Image(systemName: "exclamationmark.circle.fill").font(.system(size: 8))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
|
@ -186,8 +161,8 @@ private func previewAudio(
|
|||
#Preview("Audio — incoming, idle") {
|
||||
AudioBubbleView(
|
||||
audio: previewAudio(), caption: "",
|
||||
time: "12:34", isOutgoing: false,
|
||||
replyHeader: nil, sendingState: .sent
|
||||
isOutgoing: false,
|
||||
replyHeader: nil
|
||||
)
|
||||
.bubblePreview()
|
||||
.environment(previewStore())
|
||||
|
|
@ -196,8 +171,8 @@ private func previewAudio(
|
|||
#Preview("Audio — outgoing, idle") {
|
||||
AudioBubbleView(
|
||||
audio: previewAudio(id: 2), caption: "",
|
||||
time: "12:35", isOutgoing: true,
|
||||
replyHeader: nil, sendingState: .sent
|
||||
isOutgoing: true,
|
||||
replyHeader: nil
|
||||
)
|
||||
.bubblePreview()
|
||||
.environment(previewStore())
|
||||
|
|
@ -207,8 +182,8 @@ private func previewAudio(
|
|||
AudioBubbleView(
|
||||
audio: previewAudio(id: 3, title: "A Very Long Track Title That Will Truncate", performer: "Some Long Performer Name"),
|
||||
caption: "",
|
||||
time: "12:36", isOutgoing: false,
|
||||
replyHeader: nil, sendingState: .sent
|
||||
isOutgoing: false,
|
||||
replyHeader: nil
|
||||
)
|
||||
.bubblePreview()
|
||||
.environment(previewStore())
|
||||
|
|
@ -218,8 +193,8 @@ private func previewAudio(
|
|||
AudioBubbleView(
|
||||
audio: previewAudio(id: 4, title: "Untitled", performer: ""),
|
||||
caption: "",
|
||||
time: "12:37", isOutgoing: false,
|
||||
replyHeader: nil, sendingState: .sent
|
||||
isOutgoing: false,
|
||||
replyHeader: nil
|
||||
)
|
||||
.bubblePreview()
|
||||
.environment(previewStore())
|
||||
|
|
@ -229,8 +204,8 @@ private func previewAudio(
|
|||
AudioBubbleView(
|
||||
audio: previewAudio(id: 5),
|
||||
caption: "this song goes hard",
|
||||
time: "12:38", isOutgoing: false,
|
||||
replyHeader: nil, sendingState: .sent
|
||||
isOutgoing: false,
|
||||
replyHeader: nil
|
||||
)
|
||||
.bubblePreview()
|
||||
.environment(previewStore())
|
||||
|
|
@ -239,13 +214,12 @@ private func previewAudio(
|
|||
#Preview("Audio — incoming with reply") {
|
||||
AudioBubbleView(
|
||||
audio: previewAudio(id: 6), caption: "",
|
||||
time: "12:39", isOutgoing: false,
|
||||
isOutgoing: false,
|
||||
replyHeader: ReplyHeader(
|
||||
senderName: "Bob",
|
||||
snippet: "anchor message earlier in the chat",
|
||||
minithumbnail: nil, isOutgoing: false
|
||||
),
|
||||
sendingState: .sent
|
||||
)
|
||||
)
|
||||
.bubblePreview()
|
||||
.environment(previewStore())
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
import CoreGraphics
|
||||
|
||||
/// Single source of truth for message-bubble geometry. Every bubble surface
|
||||
/// (text, voice, audio, document, poll, photo, video, map) clips/fills to this
|
||||
/// radius; fill-chrome bubbles also enforce the minimum so a one-word message
|
||||
/// renders as a 28×28 pill (radius == half-size → circular ends).
|
||||
enum BubbleShape {
|
||||
static let cornerRadius: CGFloat = 14
|
||||
/// Minimum bubble edge (min-width and min-height). Equals `2 × cornerRadius`
|
||||
/// so a one-word fill-chrome bubble renders as a circle/pill rather than a
|
||||
/// rounded-rect with concave-looking corners.
|
||||
static let minSize: CGFloat = 28
|
||||
}
|
||||
|
|
@ -1,11 +1,10 @@
|
|||
import SwiftUI
|
||||
|
||||
/// Renders one generic-document bubble: BubbleStyle chrome with a rounded-square doc icon,
|
||||
/// filename, size, optional caption, and a time footer. Display-only (no download/open on
|
||||
/// filename, size, optional caption. Display-only (no download/open on
|
||||
/// watch in milestone #4).
|
||||
struct DocumentBubbleView: View {
|
||||
let document: DocumentVisual
|
||||
let time: String
|
||||
let isOutgoing: Bool
|
||||
let replyHeader: ReplyHeader?
|
||||
|
||||
|
|
@ -35,15 +34,11 @@ struct DocumentBubbleView: View {
|
|||
.font(.caption)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
HStack(spacing: 2) {
|
||||
Spacer(minLength: 0)
|
||||
Text(time).font(.system(size: 8)).foregroundStyle(style.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: metrics.bubbleMaxWidth, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(style.fill))
|
||||
.frame(minWidth: BubbleShape.minSize, maxWidth: metrics.bubbleMaxWidth, minHeight: BubbleShape.minSize, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: BubbleShape.cornerRadius).fill(style.fill))
|
||||
.foregroundStyle(style.content)
|
||||
}
|
||||
|
||||
|
|
@ -61,7 +56,7 @@ struct DocumentBubbleView: View {
|
|||
DocumentBubbleView(
|
||||
document: DocumentVisual(documentFileId: 1, fileName: "report.pdf",
|
||||
sizeBytes: 2_400_000, localPath: nil, caption: ""),
|
||||
time: "12:34", isOutgoing: false, replyHeader: nil
|
||||
isOutgoing: false, replyHeader: nil
|
||||
).bubblePreview()
|
||||
}
|
||||
|
||||
|
|
@ -69,7 +64,7 @@ struct DocumentBubbleView: View {
|
|||
DocumentBubbleView(
|
||||
document: DocumentVisual(documentFileId: 2, fileName: "quarterly-financial-summary-2026.xlsx",
|
||||
sizeBytes: 52_428_800, localPath: nil, caption: ""),
|
||||
time: "12:35", isOutgoing: true, replyHeader: nil
|
||||
isOutgoing: true, replyHeader: nil
|
||||
).bubblePreview()
|
||||
}
|
||||
|
||||
|
|
@ -77,7 +72,7 @@ struct DocumentBubbleView: View {
|
|||
DocumentBubbleView(
|
||||
document: DocumentVisual(documentFileId: 3, fileName: "archive.zip",
|
||||
sizeBytes: 52_428_800, localPath: nil, caption: "here are the files"),
|
||||
time: "12:36", isOutgoing: false, replyHeader: nil
|
||||
isOutgoing: false, replyHeader: nil
|
||||
).bubblePreview()
|
||||
}
|
||||
|
||||
|
|
@ -85,7 +80,7 @@ struct DocumentBubbleView: View {
|
|||
DocumentBubbleView(
|
||||
document: DocumentVisual(documentFileId: 4, fileName: "notes.txt",
|
||||
sizeBytes: 1024, localPath: nil, caption: ""),
|
||||
time: "12:37", isOutgoing: false,
|
||||
isOutgoing: false,
|
||||
replyHeader: ReplyHeader(senderName: "Bob", snippet: "the doc", minithumbnail: nil, isOutgoing: false)
|
||||
).bubblePreview()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import UIKit
|
|||
|
||||
/// Renders one location / venue bubble: a rounded static map image
|
||||
/// (`MKMapSnapshotter`) with a centered pin, a "LIVE" badge for live
|
||||
/// locations, and an inside time footer. The snapshot renders in `.task(id:)`,
|
||||
/// locations. The snapshot renders in `.task(id:)`,
|
||||
/// keyed by the cache key, so live-location coordinate changes re-render
|
||||
/// automatically.
|
||||
///
|
||||
|
|
@ -17,7 +17,6 @@ import UIKit
|
|||
/// Tap builds an `MKMapItem` and hands the coordinate to the system Maps app.
|
||||
struct LocationBubbleView: View {
|
||||
let location: LocationVisual
|
||||
let time: String
|
||||
let isOutgoing: Bool
|
||||
let replyHeader: ReplyHeader?
|
||||
|
||||
|
|
@ -91,7 +90,7 @@ struct LocationBubbleView: View {
|
|||
.frame(width: mapWidth, alignment: .leading)
|
||||
.background(style.fill)
|
||||
.foregroundStyle(style.content)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: BubbleShape.cornerRadius))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
|
@ -107,9 +106,8 @@ struct LocationBubbleView: View {
|
|||
private func mapView(roundedCorners: Bool) -> some View {
|
||||
mapImage
|
||||
.frame(width: mapWidth, height: mapHeight)
|
||||
.clipShape(RoundedRectangle(cornerRadius: roundedCorners ? 12 : 0))
|
||||
.clipShape(RoundedRectangle(cornerRadius: roundedCorners ? BubbleShape.cornerRadius : 0))
|
||||
.overlay { pin }
|
||||
.overlay(alignment: .bottomTrailing) { timeFooter }
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { openInMaps() }
|
||||
}
|
||||
|
|
@ -150,16 +148,6 @@ struct LocationBubbleView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private var timeFooter: some View {
|
||||
Text(time)
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 1)
|
||||
.background(Capsule().fill(.black.opacity(0.5)))
|
||||
.padding(4)
|
||||
}
|
||||
|
||||
private func openInMaps() {
|
||||
let mapItem = MKMapItem(
|
||||
location: CLLocation(latitude: location.latitude, longitude: location.longitude),
|
||||
|
|
@ -178,7 +166,7 @@ struct LocationBubbleView: View {
|
|||
title: nil, address: nil, isLive: false, heading: 0, isExpired: false,
|
||||
liveUpdatedAt: nil
|
||||
),
|
||||
time: "12:34", isOutgoing: false, replyHeader: nil
|
||||
isOutgoing: false, replyHeader: nil
|
||||
)
|
||||
.bubblePreview()
|
||||
}
|
||||
|
|
@ -190,7 +178,7 @@ struct LocationBubbleView: View {
|
|||
title: "Eiffel Tower", address: "Champ de Mars, 75007 Paris",
|
||||
isLive: false, heading: 0, isExpired: false, liveUpdatedAt: nil
|
||||
),
|
||||
time: "12:35", isOutgoing: true, replyHeader: nil
|
||||
isOutgoing: true, replyHeader: nil
|
||||
)
|
||||
.bubblePreview()
|
||||
}
|
||||
|
|
@ -202,7 +190,7 @@ struct LocationBubbleView: View {
|
|||
title: nil, address: nil, isLive: true, heading: 90, isExpired: false,
|
||||
liveUpdatedAt: Date().addingTimeInterval(-90)
|
||||
),
|
||||
time: "12:36", isOutgoing: false, replyHeader: nil
|
||||
isOutgoing: false, replyHeader: nil
|
||||
)
|
||||
.bubblePreview()
|
||||
}
|
||||
|
|
@ -214,7 +202,7 @@ struct LocationBubbleView: View {
|
|||
title: nil, address: nil, isLive: true, heading: 0, isExpired: true,
|
||||
liveUpdatedAt: Date().addingTimeInterval(-7200)
|
||||
),
|
||||
time: "12:30", isOutgoing: false, replyHeader: nil
|
||||
isOutgoing: false, replyHeader: nil
|
||||
)
|
||||
.bubblePreview()
|
||||
}
|
||||
|
|
@ -226,7 +214,7 @@ struct LocationBubbleView: View {
|
|||
title: nil, address: nil, isLive: false, heading: 0, isExpired: false,
|
||||
liveUpdatedAt: nil
|
||||
),
|
||||
time: "12:34", isOutgoing: false,
|
||||
isOutgoing: false,
|
||||
replyHeader: ReplyHeader(
|
||||
senderName: "Bob",
|
||||
snippet: "where are you?",
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ struct MessageBubbleView: View {
|
|||
sticker: sticker,
|
||||
senderName: bubble.senderName,
|
||||
senderColorIndex: bubble.senderColorIndex,
|
||||
time: bubble.time,
|
||||
isOutgoing: bubble.isOutgoing,
|
||||
replyHeader: bubble.replyHeader
|
||||
)
|
||||
|
|
@ -32,7 +31,6 @@ struct MessageBubbleView: View {
|
|||
PhotoBubbleView(
|
||||
photo: photo,
|
||||
caption: bubble.body,
|
||||
time: bubble.time,
|
||||
isOutgoing: bubble.isOutgoing,
|
||||
replyHeader: bubble.replyHeader,
|
||||
onTap: onPhotoTap
|
||||
|
|
@ -41,7 +39,6 @@ struct MessageBubbleView: View {
|
|||
VideoBubbleView(
|
||||
video: video,
|
||||
caption: bubble.body,
|
||||
time: bubble.time,
|
||||
isOutgoing: bubble.isOutgoing,
|
||||
replyHeader: bubble.replyHeader,
|
||||
onTap: { onVideoTap(video) }
|
||||
|
|
@ -49,7 +46,6 @@ struct MessageBubbleView: View {
|
|||
} else if let note = bubble.videoNote {
|
||||
VideoNoteBubbleView(
|
||||
note: note,
|
||||
time: bubble.time,
|
||||
isOutgoing: bubble.isOutgoing,
|
||||
replyHeader: bubble.replyHeader,
|
||||
onTap: { onVideoNoteTap(note) }
|
||||
|
|
@ -58,38 +54,31 @@ struct MessageBubbleView: View {
|
|||
VoiceNoteBubbleView(
|
||||
note: voice,
|
||||
caption: bubble.body,
|
||||
time: bubble.time,
|
||||
isOutgoing: bubble.isOutgoing,
|
||||
replyHeader: bubble.replyHeader,
|
||||
sendingState: bubble.sendingState
|
||||
replyHeader: bubble.replyHeader
|
||||
)
|
||||
} else if let audio = bubble.audio {
|
||||
AudioBubbleView(
|
||||
audio: audio,
|
||||
caption: bubble.body,
|
||||
time: bubble.time,
|
||||
isOutgoing: bubble.isOutgoing,
|
||||
replyHeader: bubble.replyHeader,
|
||||
sendingState: bubble.sendingState
|
||||
replyHeader: bubble.replyHeader
|
||||
)
|
||||
} else if let document = bubble.document {
|
||||
DocumentBubbleView(
|
||||
document: document,
|
||||
time: bubble.time,
|
||||
isOutgoing: bubble.isOutgoing,
|
||||
replyHeader: bubble.replyHeader
|
||||
)
|
||||
} else if let location = bubble.location {
|
||||
LocationBubbleView(
|
||||
location: location,
|
||||
time: bubble.time,
|
||||
isOutgoing: bubble.isOutgoing,
|
||||
replyHeader: bubble.replyHeader
|
||||
)
|
||||
} else if let poll = bubble.poll {
|
||||
PollBubbleView(
|
||||
poll: poll,
|
||||
time: bubble.time,
|
||||
isOutgoing: bubble.isOutgoing,
|
||||
replyHeader: bubble.replyHeader,
|
||||
onVote: { onPollTap(bubble.messageId, poll) }
|
||||
|
|
@ -101,14 +90,7 @@ struct MessageBubbleView: View {
|
|||
textBubbleContent
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottomLeading) {
|
||||
if bubble.isUnreadOutgoing {
|
||||
Circle()
|
||||
.fill(Color.accentColor)
|
||||
.frame(width: 7, height: 7)
|
||||
.offset(x: -11)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottomLeading) { statusIndicator }
|
||||
if !bubble.isOutgoing { Spacer(minLength: 16) }
|
||||
}
|
||||
.accessibilityIdentifier("bubble.\(bubble.messageId)")
|
||||
|
|
@ -127,42 +109,42 @@ struct MessageBubbleView: View {
|
|||
if let header = bubble.replyHeader {
|
||||
ReplyHeaderView(header: header, style: BubbleStyle.resolve(isOutgoing: bubble.isOutgoing))
|
||||
}
|
||||
HStack(alignment: .lastTextBaseline, spacing: 4) {
|
||||
Text(bubble.body)
|
||||
.font(.caption)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
HStack(spacing: 2) {
|
||||
Text(bubble.time)
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(style.secondary)
|
||||
if bubble.isOutgoing {
|
||||
sendStateGlyph
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(bubble.body)
|
||||
.font(.caption)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.horizontal, 9)
|
||||
.padding(.vertical, 4)
|
||||
.frame(minWidth: BubbleShape.minSize, minHeight: BubbleShape.minSize)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
RoundedRectangle(cornerRadius: BubbleShape.cornerRadius)
|
||||
.fill(style.fill)
|
||||
)
|
||||
.foregroundStyle(style.content)
|
||||
}
|
||||
|
||||
/// The single outgoing delivery indicator, in the bubble's leading gutter.
|
||||
/// Glyphs sit slightly further left than the dot so they clear the bubble edge.
|
||||
@ViewBuilder
|
||||
private var sendStateGlyph: some View {
|
||||
switch bubble.sendingState {
|
||||
case .sent:
|
||||
private var statusIndicator: some View {
|
||||
switch bubble.outgoingStatus {
|
||||
case .none:
|
||||
EmptyView()
|
||||
case .unread:
|
||||
Circle()
|
||||
.fill(Color.accentColor)
|
||||
.frame(width: 7, height: 7)
|
||||
.offset(x: -11)
|
||||
case .pending:
|
||||
Image(systemName: "clock")
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(Color.white.opacity(0.7))
|
||||
.offset(x: -13)
|
||||
case .failed:
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(.red)
|
||||
.offset(x: -13)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -171,7 +153,6 @@ struct MessageBubbleView: View {
|
|||
private let previewSampleBubble = MessageBubble(
|
||||
messageId: 1, isOutgoing: false, senderName: "Alice",
|
||||
body: "this is a reply to text",
|
||||
time: "12:34",
|
||||
photo: nil, video: nil, videoNote: nil, voiceNote: nil, audio: nil, document: nil, sticker: nil, location: nil, poll: nil,
|
||||
sendingState: .sent,
|
||||
replyHeader: ReplyHeader(
|
||||
|
|
@ -197,7 +178,6 @@ private let previewSampleBubble = MessageBubble(
|
|||
bubble: MessageBubble(
|
||||
messageId: 2, isOutgoing: true, senderName: nil,
|
||||
body: "ok",
|
||||
time: "12:35",
|
||||
photo: nil, video: nil, videoNote: nil, voiceNote: nil, audio: nil, document: nil, sticker: nil, location: nil, poll: nil,
|
||||
sendingState: .sent,
|
||||
replyHeader: ReplyHeader(
|
||||
|
|
@ -219,7 +199,6 @@ private let previewSampleBubble = MessageBubble(
|
|||
bubble: MessageBubble(
|
||||
messageId: 3, isOutgoing: false, senderName: "Alice",
|
||||
body: "colored name above me",
|
||||
time: "12:36",
|
||||
photo: nil, video: nil, videoNote: nil, voiceNote: nil, audio: nil, document: nil, sticker: nil, location: nil, poll: nil,
|
||||
sendingState: .sent,
|
||||
replyHeader: nil,
|
||||
|
|
@ -236,7 +215,7 @@ private let previewSampleBubble = MessageBubble(
|
|||
#Preview("Emoji only — incoming 1") {
|
||||
MessageBubbleView(
|
||||
bubble: MessageBubble(
|
||||
messageId: 10, isOutgoing: false, senderName: nil, body: "🥰", time: "12:40",
|
||||
messageId: 10, isOutgoing: false, senderName: nil, body: "🥰",
|
||||
photo: nil, video: nil, videoNote: nil, voiceNote: nil, audio: nil, document: nil,
|
||||
sticker: nil, location: nil, poll: nil, sendingState: .sent, replyHeader: nil
|
||||
),
|
||||
|
|
@ -250,7 +229,6 @@ private let previewSampleBubble = MessageBubble(
|
|||
bubble: MessageBubble(
|
||||
messageId: 12, isOutgoing: true, senderName: nil,
|
||||
body: "delivered but not yet read",
|
||||
time: "12:42",
|
||||
photo: nil, video: nil, videoNote: nil, voiceNote: nil, audio: nil, document: nil,
|
||||
sticker: nil, location: nil, poll: nil, sendingState: .sent, replyHeader: nil,
|
||||
isUnreadOutgoing: true
|
||||
|
|
@ -263,7 +241,7 @@ private let previewSampleBubble = MessageBubble(
|
|||
#Preview("Emoji only — outgoing 3") {
|
||||
MessageBubbleView(
|
||||
bubble: MessageBubble(
|
||||
messageId: 11, isOutgoing: true, senderName: nil, body: "😀🎉🥰", time: "12:41",
|
||||
messageId: 11, isOutgoing: true, senderName: nil, body: "😀🎉🥰",
|
||||
photo: nil, video: nil, videoNote: nil, voiceNote: nil, audio: nil, document: nil,
|
||||
sticker: nil, location: nil, poll: nil, sendingState: .sent, replyHeader: nil
|
||||
),
|
||||
|
|
|
|||
|
|
@ -24,8 +24,6 @@ struct MessageBubble: Equatable, Hashable {
|
|||
let senderName: String?
|
||||
/// Body text — for text bubbles, the message body; for photo / video bubbles, the caption (may be "").
|
||||
let body: String
|
||||
/// `HH:mm` of the message date in the user's time zone.
|
||||
let time: String
|
||||
/// Photo metadata for `messagePhoto` content; nil for non-photo bubbles.
|
||||
let photo: PhotoVisual?
|
||||
/// Video metadata for `messageVideo` content; nil for non-video bubbles.
|
||||
|
|
@ -57,6 +55,27 @@ struct MessageBubble: Equatable, Hashable {
|
|||
var senderColorIndex: Int? = nil
|
||||
/// True for a sent outgoing message the recipient hasn't read yet (`id > lastReadOutboxMessageId`).
|
||||
var isUnreadOutgoing: Bool = false
|
||||
|
||||
/// The delivery-status indicator to render for this bubble. Outgoing only;
|
||||
/// `isUnreadOutgoing` already encodes "sent, unread, not Saved Messages".
|
||||
var outgoingStatus: OutgoingStatus {
|
||||
guard isOutgoing else { return .none }
|
||||
switch sendingState {
|
||||
case .pending: return .pending
|
||||
case .failed: return .failed
|
||||
case .sent: return isUnreadOutgoing ? .unread : .none
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapses an outgoing message's delivery state into the single indicator the
|
||||
/// chat UI shows at the bubble's bottom-leading corner. Incoming messages — and
|
||||
/// outgoing messages that are sent and already read — show nothing.
|
||||
enum OutgoingStatus: Equatable {
|
||||
case none
|
||||
case pending
|
||||
case failed
|
||||
case unread
|
||||
}
|
||||
|
||||
struct ServiceLine: Equatable, Hashable {
|
||||
|
|
@ -106,12 +125,6 @@ func messageRows(
|
|||
dayKeyFormatter.timeZone = calendar.timeZone
|
||||
dayKeyFormatter.dateFormat = "yyyy-MM-dd"
|
||||
|
||||
let timeFormatter = DateFormatter()
|
||||
timeFormatter.calendar = calendar
|
||||
timeFormatter.timeZone = calendar.timeZone
|
||||
timeFormatter.locale = locale
|
||||
timeFormatter.dateFormat = "HH:mm"
|
||||
|
||||
var rows: [MessageRow] = []
|
||||
var lastDayKey: String? = nil
|
||||
var dividerPlaced = false
|
||||
|
|
@ -148,7 +161,6 @@ func messageRows(
|
|||
isOutgoing: msg.isOutgoing,
|
||||
senderName: sender?.name,
|
||||
body: messageBody(msg.content),
|
||||
time: timeFormatter.string(from: date),
|
||||
photo: photoVisual(for: msg.content, fileLocals: fileLocals),
|
||||
video: videoVisual(for: msg.content, fileLocals: fileLocals),
|
||||
videoNote: videoNoteVisual(for: msg.content, fileLocals: fileLocals),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import UIKit
|
|||
|
||||
/// Renders one photo bubble: aspect-fit image (full-resolution when downloaded,
|
||||
/// minithumbnail otherwise, gray placeholder if neither), optional caption below the
|
||||
/// image, time stamp footer.
|
||||
/// image.
|
||||
///
|
||||
/// Drives viewport-based download via `.onScrollVisibilityChange` on the store —
|
||||
/// `.onAppear` would fire for every off-screen bubble too, because the enclosing
|
||||
|
|
@ -13,7 +13,6 @@ import UIKit
|
|||
struct PhotoBubbleView: View {
|
||||
let photo: PhotoVisual
|
||||
let caption: String
|
||||
let time: String
|
||||
let isOutgoing: Bool
|
||||
let replyHeader: ReplyHeader?
|
||||
let onTap: (PhotoVisual) -> Void
|
||||
|
|
@ -45,9 +44,8 @@ struct PhotoBubbleView: View {
|
|||
private var bareImage: some View {
|
||||
imageView
|
||||
.frame(width: displaySize.width, height: displaySize.height)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(alignment: .bottomTrailing) { timePill }
|
||||
.contentShape(RoundedRectangle(cornerRadius: 12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: BubbleShape.cornerRadius))
|
||||
.contentShape(RoundedRectangle(cornerRadius: BubbleShape.cornerRadius))
|
||||
.onTapGesture { if photo.localPath != nil { onTap(photo) } }
|
||||
}
|
||||
|
||||
|
|
@ -69,25 +67,10 @@ struct PhotoBubbleView: View {
|
|||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
Text(time)
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(style.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.frame(width: displaySize.width, alignment: .leading)
|
||||
.background(style.fill)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private var timePill: some View {
|
||||
Text(time)
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 1)
|
||||
.background(Capsule().fill(.black.opacity(0.5)))
|
||||
.padding(4)
|
||||
.clipShape(RoundedRectangle(cornerRadius: BubbleShape.cornerRadius))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
|
@ -154,7 +137,6 @@ private func photoPreviewStore() -> ChatHistoryStore {
|
|||
PhotoBubbleView(
|
||||
photo: PhotoVisual(fileId: 0, width: 800, height: 600, minithumbnail: nil, localPath: nil),
|
||||
caption: "look at this",
|
||||
time: "12:34",
|
||||
isOutgoing: false,
|
||||
replyHeader: ReplyHeader(
|
||||
senderName: "Bob",
|
||||
|
|
@ -172,7 +154,6 @@ private func photoPreviewStore() -> ChatHistoryStore {
|
|||
PhotoBubbleView(
|
||||
photo: PhotoVisual(fileId: 0, width: 800, height: 600, minithumbnail: nil, localPath: nil),
|
||||
caption: "",
|
||||
time: "12:34",
|
||||
isOutgoing: false,
|
||||
replyHeader: nil,
|
||||
onTap: { _ in }
|
||||
|
|
@ -185,7 +166,6 @@ private func photoPreviewStore() -> ChatHistoryStore {
|
|||
PhotoBubbleView(
|
||||
photo: PhotoVisual(fileId: 0, width: 800, height: 600, minithumbnail: nil, localPath: nil),
|
||||
caption: "Benji athlete today!",
|
||||
time: "12:34",
|
||||
isOutgoing: false,
|
||||
replyHeader: nil,
|
||||
onTap: { _ in }
|
||||
|
|
|
|||
|
|
@ -2,12 +2,11 @@ import SwiftUI
|
|||
|
||||
/// Renders one poll / quiz bubble inside the standard gray-incoming / accent-outgoing
|
||||
/// chrome. Shows the question, option rows (with result bars + percentages once
|
||||
/// `resultsVisible`), quiz correct/chosen markers + explanation, a voter-count + time
|
||||
/// footer, and a "Vote" button when the poll is still actionable. Tapping Vote calls
|
||||
/// `resultsVisible`), quiz correct/chosen markers + explanation, a voter-count footer,
|
||||
/// and a "Vote" button when the poll is still actionable. Tapping Vote calls
|
||||
/// `onVote`, which opens the dedicated `PollVoteView`.
|
||||
struct PollBubbleView: View {
|
||||
let poll: PollVisual
|
||||
let time: String
|
||||
let isOutgoing: Bool
|
||||
let replyHeader: ReplyHeader?
|
||||
let onVote: () -> Void
|
||||
|
|
@ -63,18 +62,14 @@ struct PollBubbleView: View {
|
|||
.buttonStyle(.bordered)
|
||||
.controlSize(.mini)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
Text(voterCountLabel)
|
||||
Spacer(minLength: 0)
|
||||
Text(time)
|
||||
}
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(secondaryColor)
|
||||
Text(voterCountLabel)
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(secondaryColor)
|
||||
}
|
||||
.padding(8)
|
||||
.frame(maxWidth: maxWidth, alignment: .leading)
|
||||
.frame(minWidth: BubbleShape.minSize, maxWidth: maxWidth, minHeight: BubbleShape.minSize, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
RoundedRectangle(cornerRadius: BubbleShape.cornerRadius)
|
||||
.fill(style.fill)
|
||||
)
|
||||
.foregroundStyle(style.content)
|
||||
|
|
@ -147,7 +142,7 @@ private func previewOption(
|
|||
totalVoterCount: 0, hasVoted: false, explanation: nil,
|
||||
options: [previewOption("Red", position: 0), previewOption("Green", position: 1), previewOption("Blue", position: 2)]
|
||||
),
|
||||
time: "12:34", isOutgoing: false, replyHeader: nil, onVote: {}
|
||||
isOutgoing: false, replyHeader: nil, onVote: {}
|
||||
).bubblePreview()
|
||||
}
|
||||
|
||||
|
|
@ -160,7 +155,7 @@ private func previewOption(
|
|||
options: [previewOption("Red", position: 0, pct: 75, chosen: true),
|
||||
previewOption("Green", position: 1, pct: 25)]
|
||||
),
|
||||
time: "12:35", isOutgoing: true, replyHeader: nil, onVote: {}
|
||||
isOutgoing: true, replyHeader: nil, onVote: {}
|
||||
).bubblePreview()
|
||||
}
|
||||
|
||||
|
|
@ -174,7 +169,7 @@ private func previewOption(
|
|||
previewOption("Paris", position: 1, pct: 60, correct: true),
|
||||
previewOption("Rome", position: 2, pct: 10, correct: false)]
|
||||
),
|
||||
time: "12:36", isOutgoing: false, replyHeader: nil, onVote: {}
|
||||
isOutgoing: false, replyHeader: nil, onVote: {}
|
||||
).bubblePreview()
|
||||
}
|
||||
|
||||
|
|
@ -186,7 +181,7 @@ private func previewOption(
|
|||
totalVoterCount: 12, hasVoted: false, explanation: nil,
|
||||
options: [previewOption("Sat", position: 0, pct: 58), previewOption("Sun", position: 1, pct: 42)]
|
||||
),
|
||||
time: "09:00", isOutgoing: false, replyHeader: nil, onVote: {}
|
||||
isOutgoing: false, replyHeader: nil, onVote: {}
|
||||
).bubblePreview()
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ struct ReviewVoiceBubble: View {
|
|||
}
|
||||
.padding(.horizontal, 8).padding(.vertical, 6)
|
||||
.frame(maxWidth: 200, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(Color.accentColor))
|
||||
.background(RoundedRectangle(cornerRadius: BubbleShape.cornerRadius).fill(Color.accentColor))
|
||||
.foregroundStyle(.white)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { controller.toggle(note: note) }
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import UIKit
|
|||
|
||||
/// Renders one video bubble: aspect-fit preview thumbnail (downloaded preview, blurred
|
||||
/// minithumbnail, or gray placeholder), centered play overlay, top-trailing duration
|
||||
/// pill, optional caption below the image, time stamp footer.
|
||||
/// pill, optional caption below the image.
|
||||
///
|
||||
/// Drives viewport-based download of the *preview* file id (not the full video) via
|
||||
/// `.onScrollVisibilityChange` on the store — `.onAppear` would fire for every
|
||||
|
|
@ -15,7 +15,6 @@ import UIKit
|
|||
struct VideoBubbleView: View {
|
||||
let video: VideoVisual
|
||||
let caption: String
|
||||
let time: String
|
||||
let isOutgoing: Bool
|
||||
let replyHeader: ReplyHeader?
|
||||
let onTap: () -> Void
|
||||
|
|
@ -46,8 +45,8 @@ struct VideoBubbleView: View {
|
|||
}
|
||||
|
||||
private var bareVideo: some View {
|
||||
videoContent(clipImage: true, showTimePill: true)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 12))
|
||||
videoContent(clipImage: true)
|
||||
.contentShape(RoundedRectangle(cornerRadius: BubbleShape.cornerRadius))
|
||||
.onTapGesture { onTap() }
|
||||
}
|
||||
|
||||
|
|
@ -58,7 +57,7 @@ struct VideoBubbleView: View {
|
|||
.padding(.horizontal, 8)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
videoContent(clipImage: false, showTimePill: false)
|
||||
videoContent(clipImage: false)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onTap() }
|
||||
if !caption.isEmpty {
|
||||
|
|
@ -68,22 +67,17 @@ struct VideoBubbleView: View {
|
|||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
Text(time)
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(style.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.frame(width: displaySize.width, alignment: .leading)
|
||||
.background(style.fill)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: BubbleShape.cornerRadius))
|
||||
}
|
||||
|
||||
private func videoContent(clipImage: Bool, showTimePill: Bool) -> some View {
|
||||
private func videoContent(clipImage: Bool) -> some View {
|
||||
ZStack {
|
||||
imageView
|
||||
.frame(width: displaySize.width, height: displaySize.height)
|
||||
.clipShape(RoundedRectangle(cornerRadius: clipImage ? 12 : 0))
|
||||
.clipShape(RoundedRectangle(cornerRadius: clipImage ? BubbleShape.cornerRadius : 0))
|
||||
.overlay(alignment: .topTrailing) {
|
||||
Text(formatDuration(video.duration))
|
||||
.font(.system(size: 9))
|
||||
|
|
@ -93,23 +87,10 @@ struct VideoBubbleView: View {
|
|||
.background(Capsule().fill(.black.opacity(0.5)))
|
||||
.padding(4)
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if showTimePill { timePill }
|
||||
}
|
||||
playOverlay
|
||||
}
|
||||
}
|
||||
|
||||
private var timePill: some View {
|
||||
Text(time)
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 1)
|
||||
.background(Capsule().fill(.black.opacity(0.5)))
|
||||
.padding(4)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var imageView: some View {
|
||||
if let path = video.preview.previewLocalPath, let img = UIImage(contentsOfFile: path) {
|
||||
|
|
@ -186,7 +167,6 @@ private func videoPreviewStore() -> ChatHistoryStore {
|
|||
videoLocalPath: nil
|
||||
),
|
||||
caption: "check this clip",
|
||||
time: "12:34",
|
||||
isOutgoing: false,
|
||||
replyHeader: ReplyHeader(
|
||||
senderName: "Bob",
|
||||
|
|
@ -209,7 +189,6 @@ private func videoPreviewStore() -> ChatHistoryStore {
|
|||
videoLocalPath: nil
|
||||
),
|
||||
caption: "",
|
||||
time: "12:34",
|
||||
isOutgoing: false,
|
||||
replyHeader: nil,
|
||||
onTap: {}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@ import UIKit
|
|||
/// Renders one round-video-note bubble: a chrome-less 150 pt circle with the
|
||||
/// preview image (downloaded thumb → blurred minithumbnail → gray placeholder)
|
||||
/// behind a centered play overlay and a bottom-inside duration pill. Optional
|
||||
/// reply mini-card (`StickerBubbleView` precedent) sits above. Timestamp footer
|
||||
/// renders below as plain text on the system background.
|
||||
/// reply mini-card (`StickerBubbleView` precedent) sits above.
|
||||
///
|
||||
/// Drives viewport-based download of the thumbnail file id (not the playable
|
||||
/// video file) via `.onScrollVisibilityChange`. The playable file is downloaded
|
||||
|
|
@ -14,7 +13,6 @@ import UIKit
|
|||
/// Tap is unconditional — the viewer handles the not-yet-downloaded state.
|
||||
struct VideoNoteBubbleView: View {
|
||||
let note: VideoNoteVisual
|
||||
let time: String
|
||||
let isOutgoing: Bool
|
||||
let replyHeader: ReplyHeader?
|
||||
let onTap: () -> Void
|
||||
|
|
@ -61,10 +59,6 @@ struct VideoNoteBubbleView: View {
|
|||
store.cancelFileDownload(fileId: id)
|
||||
}
|
||||
}
|
||||
Text(time)
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -126,7 +120,6 @@ private func videoNotePreviewStore() -> ChatHistoryStore {
|
|||
thumbFileId: nil, minithumbnail: nil,
|
||||
thumbLocalPath: nil, videoLocalPath: nil
|
||||
),
|
||||
time: "12:34",
|
||||
isOutgoing: false,
|
||||
replyHeader: nil,
|
||||
onTap: {}
|
||||
|
|
@ -142,7 +135,6 @@ private func videoNotePreviewStore() -> ChatHistoryStore {
|
|||
thumbFileId: nil, minithumbnail: nil,
|
||||
thumbLocalPath: nil, videoLocalPath: nil
|
||||
),
|
||||
time: "12:36",
|
||||
isOutgoing: true,
|
||||
replyHeader: nil,
|
||||
onTap: {}
|
||||
|
|
@ -158,7 +150,6 @@ private func videoNotePreviewStore() -> ChatHistoryStore {
|
|||
thumbFileId: nil, minithumbnail: nil,
|
||||
thumbLocalPath: nil, videoLocalPath: nil
|
||||
),
|
||||
time: "12:34",
|
||||
isOutgoing: false,
|
||||
replyHeader: ReplyHeader(
|
||||
senderName: "Bob",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import SwiftUI
|
|||
/// outgoing) containing a play/pause glyph + 32-bar waveform + duration
|
||||
/// label. Optional reply header sits inside the chrome above the row.
|
||||
/// Caption (when non-empty) renders below the waveform inside the chrome.
|
||||
/// Time footer renders below the chrome.
|
||||
///
|
||||
/// Drives priority-1 viewport download via `.onScrollVisibilityChange`.
|
||||
/// Tap goes through `ChatHistoryStore.togglePlayback(_:)`; the store kicks
|
||||
|
|
@ -13,10 +12,8 @@ import SwiftUI
|
|||
struct VoiceNoteBubbleView: View {
|
||||
let note: VoiceNoteVisual
|
||||
let caption: String
|
||||
let time: String
|
||||
let isOutgoing: Bool
|
||||
let replyHeader: ReplyHeader?
|
||||
let sendingState: SendingState
|
||||
|
||||
@Environment(ChatHistoryStore.self) private var store
|
||||
@Environment(\.bubbleMetrics) private var metrics
|
||||
|
|
@ -58,13 +55,12 @@ struct VoiceNoteBubbleView: View {
|
|||
.font(.caption)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
timeRow
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: metrics.bubbleMaxWidth, alignment: .leading)
|
||||
.frame(minWidth: BubbleShape.minSize, maxWidth: metrics.bubbleMaxWidth, minHeight: BubbleShape.minSize, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
RoundedRectangle(cornerRadius: BubbleShape.cornerRadius)
|
||||
.fill(style.fill)
|
||||
)
|
||||
.foregroundStyle(style.content)
|
||||
|
|
@ -113,28 +109,6 @@ struct VoiceNoteBubbleView: View {
|
|||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
|
||||
// Time + send-state inline at the bottom-right inside the chrome, matching the
|
||||
// text-bubble convention.
|
||||
private var timeRow: some View {
|
||||
HStack(spacing: 2) {
|
||||
Spacer(minLength: 0)
|
||||
Text(time)
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(style.secondary)
|
||||
if isOutgoing { sendStateGlyph }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var sendStateGlyph: some View {
|
||||
switch sendingState {
|
||||
case .sent: EmptyView()
|
||||
case .pending: Image(systemName: "clock").font(.system(size: 8))
|
||||
.foregroundStyle(Color.white.opacity(0.7))
|
||||
case .failed: Image(systemName: "exclamationmark.circle.fill").font(.system(size: 8))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
|
@ -178,8 +152,8 @@ private func previewVoice(id: Int = 1) -> VoiceNoteVisual {
|
|||
#Preview("Voice — incoming, idle") {
|
||||
VoiceNoteBubbleView(
|
||||
note: previewVoice(), caption: "",
|
||||
time: "12:34", isOutgoing: false,
|
||||
replyHeader: nil, sendingState: .sent
|
||||
isOutgoing: false,
|
||||
replyHeader: nil
|
||||
)
|
||||
.bubblePreview()
|
||||
.environment(previewStore())
|
||||
|
|
@ -188,8 +162,8 @@ private func previewVoice(id: Int = 1) -> VoiceNoteVisual {
|
|||
#Preview("Voice — outgoing, idle") {
|
||||
VoiceNoteBubbleView(
|
||||
note: previewVoice(id: 2), caption: "",
|
||||
time: "12:35", isOutgoing: true,
|
||||
replyHeader: nil, sendingState: .sent
|
||||
isOutgoing: true,
|
||||
replyHeader: nil
|
||||
)
|
||||
.bubblePreview()
|
||||
.environment(previewStore())
|
||||
|
|
@ -198,13 +172,12 @@ private func previewVoice(id: Int = 1) -> VoiceNoteVisual {
|
|||
#Preview("Voice — incoming with reply") {
|
||||
VoiceNoteBubbleView(
|
||||
note: previewVoice(id: 3), caption: "",
|
||||
time: "12:36", isOutgoing: false,
|
||||
isOutgoing: false,
|
||||
replyHeader: ReplyHeader(
|
||||
senderName: "Bob",
|
||||
snippet: "anchor message earlier in the chat",
|
||||
minithumbnail: nil, isOutgoing: false
|
||||
),
|
||||
sendingState: .sent
|
||||
)
|
||||
)
|
||||
.bubblePreview()
|
||||
.environment(previewStore())
|
||||
|
|
@ -214,8 +187,8 @@ private func previewVoice(id: Int = 1) -> VoiceNoteVisual {
|
|||
VoiceNoteBubbleView(
|
||||
note: previewVoice(id: 4),
|
||||
caption: "Listen to this part carefully",
|
||||
time: "12:37", isOutgoing: false,
|
||||
replyHeader: nil, sendingState: .sent
|
||||
isOutgoing: false,
|
||||
replyHeader: nil
|
||||
)
|
||||
.bubblePreview()
|
||||
.environment(previewStore())
|
||||
|
|
@ -227,8 +200,8 @@ private func previewVoice(id: Int = 1) -> VoiceNoteVisual {
|
|||
voiceFileId: 5, duration: 5, mimeType: "audio/ogg",
|
||||
waveform: Data(), caption: "", localPath: nil
|
||||
),
|
||||
caption: "", time: "12:38", isOutgoing: false,
|
||||
replyHeader: nil, sendingState: .sent
|
||||
caption: "", isOutgoing: false,
|
||||
replyHeader: nil
|
||||
)
|
||||
.bubblePreview()
|
||||
.environment(previewStore())
|
||||
|
|
|
|||
|
|
@ -2,13 +2,12 @@ import SwiftUI
|
|||
import TDLibKit
|
||||
|
||||
/// One sticker bubble. No rounded-rect chat-bubble chrome — sticker renders directly
|
||||
/// against the chat background, with sender name above (incoming groups only) and time
|
||||
/// stamp below. Drives the same visibility-based download flow as PhotoBubbleView.
|
||||
/// against the chat background, with sender name above (incoming groups only).
|
||||
/// Drives the same visibility-based download flow as PhotoBubbleView.
|
||||
struct StickerBubbleView: View {
|
||||
let sticker: StickerVisual
|
||||
let senderName: String?
|
||||
var senderColorIndex: Int? = nil
|
||||
let time: String
|
||||
let isOutgoing: Bool
|
||||
let replyHeader: ReplyHeader?
|
||||
|
||||
|
|
@ -47,10 +46,6 @@ struct StickerBubbleView: View {
|
|||
.onScrollVisibilityChange(threshold: 0.01) { visible in
|
||||
handleVisibility(visible)
|
||||
}
|
||||
Text(time)
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -76,8 +71,9 @@ struct StickerBubbleView: View {
|
|||
.font(.caption)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.frame(minWidth: BubbleShape.minSize, minHeight: BubbleShape.minSize)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
RoundedRectangle(cornerRadius: BubbleShape.cornerRadius)
|
||||
.fill(style.fill)
|
||||
)
|
||||
.foregroundStyle(style.content)
|
||||
|
|
@ -182,7 +178,7 @@ private func previewSticker(format: StickerFormatKind, withFile: Bool, withThumb
|
|||
#Preview("WEBP — downloaded") {
|
||||
StickerBubbleView(
|
||||
sticker: previewSticker(format: .webp, withFile: true),
|
||||
senderName: nil, time: "12:34", isOutgoing: false, replyHeader: nil
|
||||
senderName: nil, isOutgoing: false, replyHeader: nil
|
||||
)
|
||||
.bubblePreview()
|
||||
.environment(previewStore())
|
||||
|
|
@ -191,7 +187,7 @@ private func previewSticker(format: StickerFormatKind, withFile: Bool, withThumb
|
|||
#Preview("WEBP — downloading, thumbnail available") {
|
||||
StickerBubbleView(
|
||||
sticker: previewSticker(format: .webp, withFile: false, withThumb: true),
|
||||
senderName: nil, time: "12:34", isOutgoing: false, replyHeader: nil
|
||||
senderName: nil, isOutgoing: false, replyHeader: nil
|
||||
)
|
||||
.bubblePreview()
|
||||
.environment(previewStore())
|
||||
|
|
@ -200,7 +196,7 @@ private func previewSticker(format: StickerFormatKind, withFile: Bool, withThumb
|
|||
#Preview("WEBP — downloading, no thumbnail") {
|
||||
StickerBubbleView(
|
||||
sticker: previewSticker(format: .webp, withFile: false),
|
||||
senderName: nil, time: "12:34", isOutgoing: false, replyHeader: nil
|
||||
senderName: nil, isOutgoing: false, replyHeader: nil
|
||||
)
|
||||
.bubblePreview()
|
||||
.environment(previewStore())
|
||||
|
|
@ -209,7 +205,7 @@ private func previewSticker(format: StickerFormatKind, withFile: Bool, withThumb
|
|||
#Preview("TGS — downloaded") {
|
||||
StickerBubbleView(
|
||||
sticker: previewSticker(format: .tgs, withFile: true),
|
||||
senderName: nil, time: "12:34", isOutgoing: false, replyHeader: nil
|
||||
senderName: nil, isOutgoing: false, replyHeader: nil
|
||||
)
|
||||
.bubblePreview()
|
||||
.environment(previewStore())
|
||||
|
|
@ -218,7 +214,7 @@ private func previewSticker(format: StickerFormatKind, withFile: Bool, withThumb
|
|||
#Preview("TGS — downloading, thumbnail available") {
|
||||
StickerBubbleView(
|
||||
sticker: previewSticker(format: .tgs, withFile: false, withThumb: true),
|
||||
senderName: nil, time: "12:34", isOutgoing: false, replyHeader: nil
|
||||
senderName: nil, isOutgoing: false, replyHeader: nil
|
||||
)
|
||||
.bubblePreview()
|
||||
.environment(previewStore())
|
||||
|
|
@ -227,7 +223,7 @@ private func previewSticker(format: StickerFormatKind, withFile: Bool, withThumb
|
|||
#Preview("WEBM — unsupported fallback") {
|
||||
StickerBubbleView(
|
||||
sticker: previewSticker(format: .unsupported, withFile: false),
|
||||
senderName: nil, time: "12:34", isOutgoing: false, replyHeader: nil
|
||||
senderName: nil, isOutgoing: false, replyHeader: nil
|
||||
)
|
||||
.bubblePreview()
|
||||
.environment(previewStore())
|
||||
|
|
@ -236,7 +232,7 @@ private func previewSticker(format: StickerFormatKind, withFile: Bool, withThumb
|
|||
#Preview("Group sticker — sender name above") {
|
||||
StickerBubbleView(
|
||||
sticker: previewSticker(format: .webp, withFile: true),
|
||||
senderName: "Alice", time: "12:34", isOutgoing: false, replyHeader: nil
|
||||
senderName: "Alice", isOutgoing: false, replyHeader: nil
|
||||
)
|
||||
.bubblePreview()
|
||||
.environment(previewStore())
|
||||
|
|
@ -246,7 +242,7 @@ private func previewSticker(format: StickerFormatKind, withFile: Bool, withThumb
|
|||
StickerBubbleView(
|
||||
sticker: previewSticker(format: .webp, withFile: true),
|
||||
senderName: "Alice", senderColorIndex: paletteIndex(for: 200),
|
||||
time: "12:34", isOutgoing: false, replyHeader: nil
|
||||
isOutgoing: false, replyHeader: nil
|
||||
)
|
||||
.bubblePreview()
|
||||
.environment(previewStore())
|
||||
|
|
@ -255,7 +251,7 @@ private func previewSticker(format: StickerFormatKind, withFile: Bool, withThumb
|
|||
#Preview("Outgoing sticker") {
|
||||
StickerBubbleView(
|
||||
sticker: previewSticker(format: .webp, withFile: true),
|
||||
senderName: nil, time: "12:34", isOutgoing: true, replyHeader: nil
|
||||
senderName: nil, isOutgoing: true, replyHeader: nil
|
||||
)
|
||||
.bubblePreview()
|
||||
.environment(previewStore())
|
||||
|
|
@ -275,7 +271,7 @@ private func previewReplyHeader(toText: String = "anchor", isOutgoing: Bool = fa
|
|||
#Preview("Sticker WEBP — with reply card above") {
|
||||
StickerBubbleView(
|
||||
sticker: previewSticker(format: .webp, withFile: true),
|
||||
senderName: nil, time: "12:34", isOutgoing: false,
|
||||
senderName: nil, isOutgoing: false,
|
||||
replyHeader: previewReplyHeader(toText: "anchor message")
|
||||
)
|
||||
.bubblePreview()
|
||||
|
|
@ -285,7 +281,7 @@ private func previewReplyHeader(toText: String = "anchor", isOutgoing: Bool = fa
|
|||
#Preview("Sticker TGS — with reply card above") {
|
||||
StickerBubbleView(
|
||||
sticker: previewSticker(format: .tgs, withFile: true),
|
||||
senderName: nil, time: "12:34", isOutgoing: false,
|
||||
senderName: nil, isOutgoing: false,
|
||||
replyHeader: previewReplyHeader(toText: "look at this clip")
|
||||
)
|
||||
.bubblePreview()
|
||||
|
|
@ -295,7 +291,7 @@ private func previewReplyHeader(toText: String = "anchor", isOutgoing: Bool = fa
|
|||
#Preview("Sticker outgoing — with reply card above") {
|
||||
StickerBubbleView(
|
||||
sticker: previewSticker(format: .webp, withFile: true),
|
||||
senderName: nil, time: "12:34", isOutgoing: true,
|
||||
senderName: nil, isOutgoing: true,
|
||||
replyHeader: previewReplyHeader(toText: "thanks!", isOutgoing: true)
|
||||
)
|
||||
.bubblePreview()
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@
|
|||
C62226963E54795B2808A52C /* MapSnapshotRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFFF41E60C5477C20897CD00 /* MapSnapshotRenderer.swift */; };
|
||||
C6A64A26FC7DC74BDEC91308 /* ChatHistoryLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2C84B842AD08D079BFF08A0 /* ChatHistoryLoader.swift */; };
|
||||
C94BD040D985FC0090CDBF84 /* StickerPickerLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC230FDDDFAA063019744A0E /* StickerPickerLoader.swift */; };
|
||||
CBA3FEDEEA413559F700B6E3 /* BubbleShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD18F48DC98A3857B32BBC6F /* BubbleShape.swift */; };
|
||||
D1082945B5785B08F820FB78 /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93535C2021AB2E7B17F593AA /* LoadingView.swift */; };
|
||||
D1E96E63A3AE93646AD3F532 /* AudioBubbleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C3B71804A398A0100375E86 /* AudioBubbleView.swift */; };
|
||||
D5515C5A9286E0372DBFB7F9 /* QRCodeGenerator in Frameworks */ = {isa = PBXBuildFile; productRef = FD6A04697FEA6C4614973F19 /* QRCodeGenerator */; };
|
||||
|
|
@ -206,6 +207,7 @@
|
|||
D4AF5A180BB3A37832AC5349 /* VideoNoteBubbleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteBubbleView.swift; sourceTree = "<group>"; };
|
||||
D50662DC1777F2BEE53A0129 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
D9CD96277A18D97BD335BBC0 /* StickerSetDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickerSetDetailView.swift; sourceTree = "<group>"; };
|
||||
DD18F48DC98A3857B32BBC6F /* BubbleShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BubbleShape.swift; sourceTree = "<group>"; };
|
||||
DDCE599C8D50EE8832B4A797 /* ReplyFormatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyFormatters.swift; sourceTree = "<group>"; };
|
||||
DF0F4F15CB43635717B31B92 /* PollVoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollVoteView.swift; sourceTree = "<group>"; };
|
||||
DF393D904BF677AC5721EC7E /* ServiceMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceMessageView.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -335,6 +337,7 @@
|
|||
CBAFC996C8EF743C7C598389 /* AudioVisual.swift */,
|
||||
0F9127473C7DAD18736234B3 /* AVRecordingBackend.swift */,
|
||||
674916153E803CA73D43BE6C /* BubbleMetrics.swift */,
|
||||
DD18F48DC98A3857B32BBC6F /* BubbleShape.swift */,
|
||||
3EEAE6237003C5CDA788E928 /* BubbleStyle.swift */,
|
||||
C2C84B842AD08D079BFF08A0 /* ChatHistoryLoader.swift */,
|
||||
182DA99AD90B93DE2FB37A41 /* ChatHistoryStore.swift */,
|
||||
|
|
@ -520,6 +523,7 @@
|
|||
5E9A6B94F1B917CB849B3EBC /* AvatarView.swift in Sources */,
|
||||
03074928DC54019BE6106ECD /* AvatarVisual.swift in Sources */,
|
||||
4236CA9B5AA90ACEB17814DC /* BubbleMetrics.swift in Sources */,
|
||||
CBA3FEDEEA413559F700B6E3 /* BubbleShape.swift in Sources */,
|
||||
868426A2768FE7349CD4AAAD /* BubbleStyle.swift in Sources */,
|
||||
4505C5628AF9258938D623D7 /* CachedChat.swift in Sources */,
|
||||
C6A64A26FC7DC74BDEC91308 /* ChatHistoryLoader.swift in Sources */,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue