Merge branch 'master' of gitlab.com:peter-iakovlev/telegram-ios

This commit is contained in:
Ilya Laktyushin 2026-05-29 10:01:53 +02:00
commit 4b2a826682
59 changed files with 2031 additions and 1350 deletions

View file

@ -1746,6 +1746,14 @@ xcode_provisioning_profile(
apple_prebuilt_watchos_application(
name = "TelegramWatchApp",
# The watch app's bundle id must be `<host>.watchkitapp` for the host
# ios_application's child-bundle-id prefix check; the rule derives the
# companion (host) bundle id from this by stripping `.watchkitapp` and the
# patch worker writes both CFBundleIdentifier and WKCompanionAppBundleIdentifier
# into the embedded watch app's Info.plist.
bundle_id = "{telegram_bundle_id}.watchkitapp".format(
telegram_bundle_id = telegram_bundle_id,
),
tags = ["manual"],
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View file

@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "ChatBG.png",
"idiom" : "universal",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,17 +1,20 @@
import SwiftUI
import UIKit
/// A 36pt circular chat avatar. Renders (priority): Saved-Messages bookmark glyph;
/// A circular chat avatar (default 36pt, customizable via `size`). Renders (priority):
/// crisp downloaded photo; blurred minithumbnail placeholder; initials on a
/// per-chat color. Drives viewport-based download of the small photo via
/// `.onScrollVisibilityChange` (same as `PhotoBubbleView`) only while the photo
/// exists and hasn't downloaded yet.
/// per-chat color. When embedded in a `ScrollView`, drives viewport-based download
/// of the small photo via `.onScrollVisibilityChange` (same as `PhotoBubbleView`)
/// only while the photo exists and hasn't downloaded yet. Outside a scroll
/// container (e.g. as a `ToolbarItem`), the visibility callback fires once on
/// appear and once on disappear, so the download path is "request on chat open,
/// cancel on chat close."
struct AvatarView: View {
let avatar: AvatarVisual
var onRequestDownload: (Int) -> Void = { _ in }
var onCancelDownload: (Int) -> Void = { _ in }
private let size: CGFloat = 36
var size: CGFloat = 36
var body: some View {
content

View file

@ -13,39 +13,35 @@ struct ChatListView: View {
@FocusState private var listFocused: Bool
var body: some View {
ZStack(alignment: .top) {
NavigationStack {
ScrollViewReader { proxy in
VStack(spacing: 0) {
if case .failed(let message) = store.loadState(for: store.currentFolder) {
banner(text: message, kind: .retry)
}
if let err = client.lastError, dismissedLastError != err {
banner(text: err, kind: .dismiss)
}
content(proxy: proxy)
NavigationStack {
ScrollViewReader { proxy in
VStack(spacing: 0) {
if case .failed(let message) = store.loadState(for: store.currentFolder) {
banner(text: message, kind: .retry)
}
.navigationDestination(for: ChatRow.self) { row in
MessageListView(row: row)
if let err = client.lastError, dismissedLastError != err {
banner(text: err, kind: .dismiss)
}
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
// Must live INSIDE the NavigationStack so the modifier's
// .navigationDestination(isPresented:) attaches to it.
.accountSwitcherSheet(presentation: .push, logoutAffordance: .allowed)
content(proxy: proxy)
}
.navigationDestination(for: ChatRow.self) { row in
MessageListView(row: row)
}
.navigationTitle("Chats")
.navigationBarTitleDisplayMode(.inline)
// `.toolbar(.visible)` forces the nav-bar container to
// materialize on the chat list. Without it, watchOS-26 skips
// chrome on a NavigationStack root view, leaving only the
// status row (clock top-right). The push to MessageListView
// then has to grow that minimal status row into a full glass
// nav bar with back chevron + title + trailing avatar, which
// glitches mid-reshape. With the bar present here, the push
// only has to morph content, not allocate a new glass surface.
.toolbar(.visible, for: .navigationBar)
// Must live INSIDE the NavigationStack so the modifier's
// .navigationDestination(isPresented:) attaches to it.
.accountSwitcherSheet(presentation: .push, logoutAffordance: .allowed)
}
// Gradient that fades chat content behind the system clock area,
// so rows scrolling up under the clock don't visually collide
// with the time readout.
LinearGradient(
colors: [.black.opacity(0.8), .black.opacity(0)],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 48)
.allowsHitTesting(false)
.ignoresSafeArea(edges: .top)
}
.accessibilityIdentifier("chatListView")
}
@ -96,18 +92,13 @@ struct ChatListView: View {
.listStyle(.plain)
.focused($listFocused)
.onAppear { listFocused = true }
// Hide the empty title bar so the chat list starts directly under the
// system clock. Pill bar is the first List row, so it scrolls with content.
.toolbar(.hidden, for: .navigationBar)
// `.toolbar(.hidden)` hides the bar visuals but watchOS still reserves
// ~30pt of layout. With a pill bar present, pull up by 38 (slot + 8)
// so the pill row sits ~8pt under the clock the gradient masks any
// overlap, and the negative bottom inset on the pill row tightens the
// gap to the first chat row to ~8pt. Without a pill bar the first row
// IS a chat row, so we stop at -22 (slot - 8) to leave ~8pt of clear
// space below the clock instead of shoving the chat row into the time
// readout.
.padding(.top, store.pills.count > 1 ? -38 : -22)
// Bar is intentionally visible (forced by `.toolbar(.visible, for:
// .navigationBar)` on the body chain). When folder pills are present,
// pull the pill row up by 24pt to tuck it under the bar's bottom edge
// without that, the natural top-of-list inset leaves an awkward gap
// between the bar and the pill row. Plain chat rows (no pills) sit at
// the natural top.
.padding(.top, store.pills.count > 1 ? -20 : 0)
}
@ViewBuilder

View file

@ -7,7 +7,7 @@ import TDLibKit
/// is summed across all chats whose `positions` include `chatList`.
struct FolderPill: Identifiable, Equatable, Hashable {
static let allChatsId: Int = -1
static let allChatsName = "All chats"
static let allChatsName = "All"
let id: Int
let chatList: ChatList

View file

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

View file

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

View file

@ -1,9 +1,8 @@
import SwiftUI
/// Resolved colors for a message-bubble surface. Incoming bubbles are a fixed light (white)
/// surface with dark content; outgoing are the accent surface with white content. Colors are
/// FIXED (non-adaptive) on purpose: watchOS runs this app in permanent dark mode, so `.primary`
/// would resolve to white and vanish on the white incoming surface.
/// Resolved colors for a message-bubble surface. Both directions use a dark fixed surface
/// with white content. Colors are FIXED (non-adaptive) on purpose: watchOS runs this app in
/// permanent dark mode.
struct BubbleStyle: Equatable {
let fill: Color
let content: Color
@ -13,15 +12,15 @@ struct BubbleStyle: Equatable {
let playIcon: Color
static let incoming = BubbleStyle(
fill: .white,
content: .black,
secondary: Color.black.opacity(0.5),
fill: Color(red: 40 / 255, green: 40 / 255, blue: 40 / 255),
content: .white,
secondary: Color.white.opacity(0.7),
replyBar: .accentColor,
playFill: .accentColor,
playIcon: .white
)
static let outgoing = BubbleStyle(
fill: .accentColor,
fill: Color(red: 19 / 255, green: 44 / 255, blue: 73 / 255),
content: .white,
secondary: Color.white.opacity(0.7),
replyBar: Color.white.opacity(0.7),

View file

@ -1,58 +0,0 @@
import SwiftUI
struct ComposeSheet: View {
let initialText: String
let onSend: (String) async -> Bool
let onDismissWithDraft: (String) async -> Void
@State private var text: String = ""
@State private var sending: Bool = false
@FocusState private var focused: Bool
@Environment(\.dismiss) private var dismiss
var body: some View {
ScrollView {
VStack(spacing: 8) {
TextField("Reply…", text: $text, axis: .vertical)
.focused($focused)
.accessibilityIdentifier("composeField")
Button(action: send) {
if sending {
ProgressView()
} else {
Label("Send", systemImage: "paperplane.fill")
}
}
.buttonStyle(.borderedProminent)
.disabled(sending || text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.accessibilityIdentifier("composeSend")
}
.padding(8)
}
.task {
text = initialText
focused = true
}
.onDisappear {
// If we're sending, the dismiss came from send() skip the draft write.
// The send call has clearDraft: true server-side, so the draft is already cleared.
guard !sending else { return }
let snapshot = text
Task { await onDismissWithDraft(snapshot) }
}
}
private func send() {
let snapshot = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !snapshot.isEmpty else { return }
sending = true
Task {
let success = await onSend(snapshot)
if success {
dismiss()
} else {
sending = false
}
}
}
}

View file

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

View file

@ -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?",

View file

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

View file

@ -16,7 +16,6 @@ struct MessageListView: View {
@State private var presentedVideo: VideoVisual?
@State private var presentedVideoNote: VideoNoteVisual?
@State private var presentedPoll: PollVoteTarget?
@State private var showCompose: Bool = false
@State private var showAttachment: Bool = false
@State private var stickerPickerStore: StickerPickerStore?
// True when the user is parked within slop of the bottom edge. Updated only on
@ -57,6 +56,17 @@ struct MessageListView: View {
}
.navigationTitle(row.title)
.accessibilityIdentifier("messageListView")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
AvatarView(
avatar: row.avatar,
onRequestDownload: { fileId in store?.requestFileDownload(fileId: fileId) },
onCancelDownload: { fileId in store?.cancelFileDownload(fileId: fileId) },
size: 36
)
.glassEffect(in: Circle())
}
}
.sheet(item: $presentedPhoto) { photo in
PhotoViewerView(photo: photo)
}
@ -79,18 +89,6 @@ struct MessageListView: View {
)
}
}
.sheet(isPresented: $showCompose) {
if let store {
ComposeSheet(
initialText: store.draftText,
onSend: { text in
await store.sendText(text)
return store.lastSendError == nil
},
onDismissWithDraft: { text in await store.saveDraft(text) }
)
}
}
.sheet(isPresented: $showAttachment) {
if let store {
AttachmentSheet(
@ -243,7 +241,9 @@ struct MessageListView: View {
if row.canSend {
ReplyBar(
onAttachTap: { showAttachment = true },
onTextTap: { showCompose = true }
onSend: { snapshot in
Task { await store.sendText(snapshot) }
}
)
.id("composeAnchor")
.padding(.top, 8)
@ -261,6 +261,14 @@ struct MessageListView: View {
}
.ignoresSafeArea(edges: .bottom)
.defaultScrollAnchor(.bottom)
.scrollContentBackground(.hidden)
.background {
Image("ChatBG")
.resizable()
.aspectRatio(contentMode: .fill)
.clipped()
.ignoresSafeArea()
}
.environment(store)
.task {
// Default branch only appears once `loadState == .loaded`

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@ import SwiftUI
struct ReplyBar: View {
let onAttachTap: () -> Void
let onTextTap: () -> Void
let onSend: (String) -> Void
// Circular "+" button and reply-field capsule share a single pill
// diameter so they line up vertically and visually mirror the
@ -25,17 +25,31 @@ struct ReplyBar: View {
.padding(.leading, 11)
.accessibilityIdentifier("replyBarAttach")
Button(action: onTextTap) {
// TextFieldLink renders an arbitrary label and, when tapped,
// presents the watchOS system text input UI (QuickboardViewService).
// It returns the committed string in onSubmit. The label IS our
// glassy pill fully styled, no double-chrome artifacts.
//
// Limitation: TextFieldLink does not accept an initial value, so
// the input UI opens blank every time. Existing server-side drafts
// are not visible or editable from the watch under this design
// accepted regression vs. the prior ComposeSheet flow.
TextFieldLink(prompt: Text("Reply…")) {
HStack(spacing: 0) {
Text("Reply…")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer(minLength: 0)
}
.padding(.horizontal, 12)
.frame(height: Self.pillHeight)
.contentShape(Rectangle())
.glassEffect()
} onSubmit: { newText in
let snapshot = newText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !snapshot.isEmpty else { return }
onSend(snapshot)
}
.buttonStyle(.plain)
.layoutPriority(1)
@ -64,7 +78,7 @@ private struct ReplyBarGeometryPreviewHost: View {
.padding(.top, 6)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
ReplyBar(onAttachTap: {}, onTextTap: {})
ReplyBar(onAttachTap: {}, onSend: { _ in })
.padding(.horizontal, 4)
.padding(.bottom, 19)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)

View file

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

View file

@ -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: {}

View file

@ -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",

View file

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

View file

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

View file

@ -75,7 +75,6 @@
A0899809BBC92D1563AC4160 /* ReplyBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7584BF25A84BAD24AB992A79 /* ReplyBar.swift */; };
A121DA7622C910B267D69B6E /* PhotoBubbleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0F7533C5A9B419B29727C86 /* PhotoBubbleView.swift */; };
A6179A26CB7FB298567682E6 /* DocumentVisual.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2515F0EDA29D5270610A69 /* DocumentVisual.swift */; };
A73233E40B531EA2D4A81B78 /* ComposeSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54149DBDF74BC221A94FB1AD /* ComposeSheet.swift */; };
A7967495A54936FE19218DC4 /* ServiceMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF393D904BF677AC5721EC7E /* ServiceMessageView.swift */; };
A8EF6F72C78B619893C05AFE /* ChatPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3448938C1F69F0D9DE679238 /* ChatPreview.swift */; };
AA3F067A7BC4CDA579CFFC39 /* PollVisual.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE4D691106D7EA2FEE35F37F /* PollVisual.swift */; };
@ -93,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 */; };
@ -149,7 +149,6 @@
4DB3C8238D41374CBAC66303 /* EmojiText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiText.swift; sourceTree = "<group>"; };
4E049ECC956A583A92C5C7CB /* SecretsValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretsValidator.swift; sourceTree = "<group>"; };
53B9831274FA56126E3F5A42 /* LocationSendView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSendView.swift; sourceTree = "<group>"; };
54149DBDF74BC221A94FB1AD /* ComposeSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeSheet.swift; sourceTree = "<group>"; };
596AF8BF88C38D2018397A33 /* LocationBubbleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationBubbleView.swift; sourceTree = "<group>"; };
5D3A8FBA5EBDBFDE5AA2FFEC /* QrLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QrLoginView.swift; sourceTree = "<group>"; };
5EA750417DF3AD62C7B788F6 /* MessageListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageListView.swift; sourceTree = "<group>"; };
@ -208,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>"; };
@ -337,10 +337,10 @@
CBAFC996C8EF743C7C598389 /* AudioVisual.swift */,
0F9127473C7DAD18736234B3 /* AVRecordingBackend.swift */,
674916153E803CA73D43BE6C /* BubbleMetrics.swift */,
DD18F48DC98A3857B32BBC6F /* BubbleShape.swift */,
3EEAE6237003C5CDA788E928 /* BubbleStyle.swift */,
C2C84B842AD08D079BFF08A0 /* ChatHistoryLoader.swift */,
182DA99AD90B93DE2FB37A41 /* ChatHistoryStore.swift */,
54149DBDF74BC221A94FB1AD /* ComposeSheet.swift */,
E855E4C62401AD1CF41D1835 /* DaySeparatorView.swift */,
BDBE08BBB88597B10CDF0142 /* DocumentBubbleView.swift */,
FF2515F0EDA29D5270610A69 /* DocumentVisual.swift */,
@ -523,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 */,
@ -536,7 +537,6 @@
E48523AFA2E23409CC0C2C2C /* ChatRow.swift in Sources */,
7BF1C89F67290EBAF50900F8 /* ChatRowView.swift in Sources */,
04CEF3F9C267D8A7CEC5EC3C /* ClosedView.swift in Sources */,
A73233E40B531EA2D4A81B78 /* ComposeSheet.swift in Sources */,
EF499261BE49A6B1C7B3B1F4 /* ContentView.swift in Sources */,
FD5515DB22C571F4D3E5F5FA /* DaySeparatorView.swift in Sources */,
F5596A4EF4D2055B10B4A183 /* DocumentBubbleView.swift in Sources */,

View file

@ -6,13 +6,17 @@ it through the providers that `ios_application(watch_application = ...)` consume
1. PrebuiltWatchosCompile (prebuilt_watchos_compile.sh) runs `xcodebuild` against
the exported tgwatch source tree with PLACEHOLDER version/api values, emitting an
unsigned .app archive. Depends only on the source snapshot.
2. PrebuiltWatchosPatchSign (prebuilt_watchos_patch.sh) rewrites the four per-build
Info.plist keys (version, build number, api id/hash) on the compiled app, then
optionally codesigns it.
2. PrebuiltWatchosPatchSign (prebuilt_watchos_patch.sh) rewrites the six per-build
Info.plist keys (version, build number, api id/hash, watch CFBundleIdentifier,
WKCompanionAppBundleIdentifier) on the compiled app, then optionally codesigns
it. The watch + companion bundle ids must track the host's `telegram_bundle_id`
(rules_apple validates both via the parent ios_application's
bundle_verification_targets), so they live in the patch action not in the
xcodebuild snapshot to keep the compile cached across host-bundle-id changes.
Splitting them lets Bazel cache the (expensive, ~4-min) compile whenever only the
version/build number/api/identity change those values never reach the compiled
binary, only the Info.plist.
version/build number/api/identity/bundle-id change those values never reach the
compiled binary, only the Info.plist.
The providers exposed:
@ -44,6 +48,19 @@ def _apple_prebuilt_watchos_application_impl(ctx):
api_hash = ctx.var.get("watchApiHash", "placeholder")
identity = ctx.var.get("watchSigningIdentity", "")
# The watch bundle id is `<host>.watchkitapp`; strip the suffix to recover the
# host bundle id, which the patch worker writes to WKCompanionAppBundleIdentifier
# (and the watch's own CFBundleIdentifier needs to be the watch bundle id, not the
# hardcoded one baked in by xcodebuild from the snapshot's pbxproj). The host
# ios_application validates both: child CFBundleIdentifier must start with
# `<host>.`, and child WKCompanionAppBundleIdentifier must equal the host's
# bundle id (see rules_apple's bundle_verification_targets in ios_rules.bzl).
watch_bundle_id = ctx.attr.bundle_id
_watchkitapp_suffix = ".watchkitapp"
if not watch_bundle_id.endswith(_watchkitapp_suffix):
fail("apple_prebuilt_watchos_application bundle_id must end with '.watchkitapp' (got %r)" % watch_bundle_id)
host_bundle_id = watch_bundle_id[:-len(_watchkitapp_suffix)]
# The provisioning profile is an external, machine-specific absolute path passed via
# --define rather than a Bazel label, so the gitignored profile need not be exposed as
# a target. The local action reads it directly. Empty => unsigned build; when set but
@ -63,7 +80,29 @@ def _apple_prebuilt_watchos_application_impl(ctx):
# separate output (resources.bzl bundle_verification crashes on a None infoplist).
infoplist = ctx.actions.declare_file(ctx.label.name + "_Info.plist")
exec_requirements = {
# The compile action runs xcodebuild locally (needs the host's Xcode + SwiftPM
# network access), but its output — an unsigned, placeholder-version .app — is
# portable across machines that share the same Xcode SDK, so it IS shared via the
# remote cache: `no-remote-exec` (not `no-remote`) lets Bazel read/write the
# `--remote_cache` while still pinning execution local on cache miss. This is the
# big win when CI builders share a remote cache — a peer's xcodebuild result is
# reused instead of every fresh worker paying the ~4-min build. Caveat: if the
# build fleet runs different Xcode major versions, mismatched artifacts could be
# served (the action key does not include the Xcode version); align Xcode across
# builders, or downgrade to `no-remote-cache` to be safe.
compile_exec_requirements = {
"no-sandbox": "1",
"no-remote-exec": "1",
"local": "1",
"requires-network": "1",
}
# The patch+sign action cannot share results across machines: its inputs include
# the absolute `--watchProvisioningProfile` path and the codesigning identity is
# resolved from the local keychain, both machine-specific. Remote-cache lookups
# would essentially never hit and uploads would just waste bandwidth, so keep the
# umbrella `no-remote` here.
patch_exec_requirements = {
"no-sandbox": "1",
"no-remote": "1",
"local": "1",
@ -84,7 +123,7 @@ def _apple_prebuilt_watchos_application_impl(ctx):
outputs = [compiled_archive],
mnemonic = "PrebuiltWatchosCompile",
progress_message = "Compiling watch app via xcodebuild",
execution_requirements = exec_requirements,
execution_requirements = compile_exec_requirements,
use_default_shell_env = True,
)
@ -104,12 +143,14 @@ def _apple_prebuilt_watchos_application_impl(ctx):
infoplist.path,
ctx.file.versions_json.path,
build_number,
host_bundle_id,
watch_bundle_id,
],
inputs = [ctx.file._patch_worker, compiled_archive, ctx.file.versions_json],
outputs = [archive, infoplist],
mnemonic = "PrebuiltWatchosPatchSign",
progress_message = "Patching%s watch app Info.plist" % (" + signing" if profile else ""),
execution_requirements = exec_requirements,
execution_requirements = patch_exec_requirements,
use_default_shell_env = True,
)

View file

@ -2,32 +2,49 @@
# Patch + sign worker for the apple_prebuilt_watchos_application Bazel rule (action 2 of 2).
#
# Takes the unsigned, placeholder-version watch .app archive produced by
# prebuilt_watchos_compile.sh, rewrites the four per-build Info.plist values
# (CFBundleShortVersionString, CFBundleVersion, TG_API_ID, TG_API_HASH) — none of
# which affect the compiled binary — then — if a provisioning profile is supplied —
# codesigns the app and its nested frameworks with the watchkitapp provisioning
# profile and a matching identity, and finally zips the .app into the rule's output
# archive.
# prebuilt_watchos_compile.sh, rewrites the six per-build Info.plist values
# (CFBundleShortVersionString, CFBundleVersion, TG_API_ID, TG_API_HASH,
# CFBundleIdentifier, WKCompanionAppBundleIdentifier) — none of which affect the
# compiled binary — then — if a provisioning profile is supplied — codesigns the
# app and its nested frameworks with the watchkitapp provisioning profile and a
# matching identity, and finally zips the .app into the rule's output archive.
#
# Bundle-id rewriting is needed because xcodebuild bakes the snapshot's pbxproj
# PRODUCT_BUNDLE_IDENTIFIER (ph.telegra.Telegraph.watchkitapp) into the compiled
# Info.plist, and WKCompanionAppBundleIdentifier is hardcoded in the snapshot's
# source Info.plist — but the host ios_application's bundle id varies by codesigning
# configuration (e.g. org.telegram.TelegramInternal for development) and rules_apple
# requires the child CFBundleIdentifier to start with the host bundle id and
# WKCompanionAppBundleIdentifier to equal it (bundle_verification_targets in
# ios_rules.bzl). Doing the rewrite here keeps the expensive xcodebuild action
# cached across host-bundle-id changes.
#
# Splitting this from the compile step lets Bazel cache the (expensive) xcodebuild
# whenever only the version/build number/api/identity change.
# whenever only the version/build number/api/identity/bundle-id change.
#
# The host ios_application embeds this archive under Watch/ and re-seals the host;
# it does NOT re-sign the watch app, so the watch signing must happen here.
#
# Args:
# $1 input_zip Compiled (unsigned, placeholder-version) .app archive from action 1
# $2 output_zip Path (declared by Bazel) to write the final .app archive to
# $3 api_id TG_API_ID Info.plist value
# $4 api_hash TG_API_HASH Info.plist value
# $5 identity Codesigning identity (SHA1 hash); empty => derived from $6's cert
# $6 profile Path to the watchkitapp .mobileprovision; empty => unsigned build
# $7 infoplist_out Path (declared by Bazel) to copy the patched Info.plist to
# $8 versions_json versions.json (key 'app' => CFBundleShortVersionString)
# $9 build_number CFBundleVersion
# $1 input_zip Compiled (unsigned, placeholder-version) .app archive from action 1
# $2 output_zip Path (declared by Bazel) to write the final .app archive to
# $3 api_id TG_API_ID Info.plist value
# $4 api_hash TG_API_HASH Info.plist value
# $5 identity Codesigning identity (SHA1 hash); empty => derived from $6's cert
# $6 profile Path to the watchkitapp .mobileprovision; empty => unsigned build
# $7 infoplist_out Path (declared by Bazel) to copy the patched Info.plist to
# $8 versions_json versions.json (key 'app' => CFBundleShortVersionString)
# $9 build_number CFBundleVersion
# $10 host_bundle_id WKCompanionAppBundleIdentifier value (host app bundle id)
# $11 watch_bundle_id CFBundleIdentifier value (must be "<host_bundle_id>.watchkitapp")
set -euo pipefail
IN_ZIP="$1"; OUT_ZIP="$2"; API_ID="$3"; API_HASH="$4"; IDENTITY="${5:-}"; PROFILE="${6:-}"; INFOPLIST_OUT="${7:-}"; VERSIONS_JSON="${8:-}"; BUILD_NUMBER="${9:-1}"
IN_ZIP="$1"; OUT_ZIP="$2"; API_ID="$3"; API_HASH="$4"; IDENTITY="${5:-}"; PROFILE="${6:-}"; INFOPLIST_OUT="${7:-}"; VERSIONS_JSON="${8:-}"; BUILD_NUMBER="${9:-1}"; HOST_BUNDLE_ID="${10:-}"; WATCH_BUNDLE_ID="${11:-}"
if [ -z "$HOST_BUNDLE_ID" ] || [ -z "$WATCH_BUNDLE_ID" ]; then
echo "error: host_bundle_id and watch_bundle_id must both be supplied (got host=$HOST_BUNDLE_ID watch=$WATCH_BUNDLE_ID)" >&2
exit 1
fi
# Match the host app's version (rules_apple requires the embedded watch app's
# CFBundleShortVersionString/CFBundleVersion to equal the parent's).
@ -46,15 +63,20 @@ if [ -z "$APP" ]; then
exit 1
fi
# Overwrite the placeholder values baked in at compile time. All four keys already
# Overwrite the placeholder values baked in at compile time. All six keys already
# exist in the compiled (binary-format) Info.plist, so PlistBuddy Set preserves their
# (string) type — matching what $(...) substitution produced and what Secrets.swift
# expects from Bundle.main.object(forInfoDictionaryKey:).
# expects from Bundle.main.object(forInfoDictionaryKey:). The bundle-id keys track
# the host's `telegram_bundle_id` (see the file header for why); xcodebuild bakes
# `ph.telegra.Telegraph.watchkitapp` / `ph.telegra.Telegraph` here from the
# snapshot's pbxproj/Info.plist regardless of what host the rest of the build uses.
PLIST="$APP/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $MARKETING_VERSION" "$PLIST"
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$PLIST"
/usr/libexec/PlistBuddy -c "Set :TG_API_ID $API_ID" "$PLIST"
/usr/libexec/PlistBuddy -c "Set :TG_API_HASH $API_HASH" "$PLIST"
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $WATCH_BUNDLE_ID" "$PLIST"
/usr/libexec/PlistBuddy -c "Set :WKCompanionAppBundleIdentifier $HOST_BUNDLE_ID" "$PLIST"
# Expose the patched watch Info.plist (the host reads it to verify the companion
# bundle-id linkage and the child version). Codesigning does not alter Info.plist