iOS Chat & Messaging — Design Engineering Skill
A taste guide for building messaging apps that feel like they belong on iOS. Every value below is opinionated and specific — pulled from studying flows on Mobbin and shipping native chat apps.
Output format — required
When this skill is invoked to review chat/messaging code or recommend changes, always output recommendations as a markdown table with three columns:
| Before |
After |
What this changes |
| The current code, value, or approach (quote the user's actual code when possible) |
The recommended replacement — specific, with exact values |
One sentence on what the user will see, feel, or experience differently |
Three rules:
- Before quotes the user's actual code where possible.
- After is specific. Exact pt values, exact corner radii, exact haptic styles, exact API calls.
- What this changes is experiential or visual, not abstract.
Output ONE table with multiple rows for multi-recommendation reviews — not one table per row. Use — for Before if the user hasn't implemented that thing yet.
Examples drawn from this skill:
| Before |
After |
What this changes |
cornerRadius: 16 applied uniformly to every message bubble |
First/last bubbles in a burst get 18pt continuous; middle bubbles get 4pt small radius on inside-edges |
Bubble grouping reads as conversation rhythm — same-speaker messages cluster visually, switches between speakers separate clearly. Reading speed up ~3× |
Tap-and-release send button with UIImpactFeedbackGenerator(.light) on .onTapGesture |
.sensoryFeedback(.impact(weight: .light), trigger: messageId) { _, new in new != nil } firing on touch-DOWN |
Haptic latency drops from ~50ms to <5ms; the send feels acknowledged instantly instead of "did that go?" |
Color(red: 0.0, green: 0.48, blue: 1.0) (iMessage blue) |
Color(.displayP3, red: 0.0, green: 0.48, blue: 1.0) |
Sent bubbles render at full chroma on every Apple device since 2017 — feels native instead of slightly washed out |
This format is required for every recommendation output by this skill.
Philosophy
A chat app is a feeling of being heard, fast.
Three things separate amazing chat from passable chat:
- The composer is the only thing that matters. Everything else is supporting cast. Time-to-typing must be < 200ms from launch. Send must be instant — show the bubble before the network responds. If you can't get this right, nothing else matters.
- Bubbles are a writing system. Spacing, grouping, tail placement, and timestamp rhythm communicate WHO said WHAT WHEN faster than reading the text itself. Get the visual language right and people skim conversations 3× faster.
- Latency is the product. A message that sends in 80ms but arrives in 2000ms feels slower than one that sends in 800ms and arrives in 900ms. Optimistic UI is not optional.
The pixel-pushers' rules:
- Bubbles, not cards. A card has shadows and borders. A bubble has a tail. Chat is conversation, not content.
- The composer is permanent. It sits at the bottom always, even during search, even during the empty state. Never push the composer above the fold.
- Read receipts are intimate. Default them OFF for new users. Telegram and Signal got this right.
- Don't infantilize. No "Looks like there are no messages here yet!" cute illustrations in a serious chat app. Empty states should be quiet.
Reference apps to study
When in doubt, copy. These are the apps you should be benchmarking against, with the specific flows worth lifting:
| App |
What to learn from it |
Mobbin flow |
| Apple Messages (iMessage) |
The gold standard for native feel — bubble tails, tapbacks, message effects, Genmoji, inline App Clips, Communication Notifications. Everything compiles against this. |
(Use the system; observe it on your own device) |
| Telegram |
The most feature-dense chat app ever built. Auto-delete timers, custom themes per chat, last-seen privacy ladders, reactions with custom emoji, animated stickers (TGS/Lottie), folder-based chat lists |
Last Seen settings, Reacting to a message |
| WhatsApp |
Voice messages done right, read receipts (gray → blue double check), reply-with-swipe-right, message info screen, end-to-end encryption banner, edit window (15min) |
Message info |
| Snapchat |
Ephemeral messages, "save in chat", time-limit picker, screenshot detection notifications, voice notes with transcription |
Setting time limit, Recording audio, Deleting a message |
| Instagram DMs |
Per-chat themes, reaction picker w/ "tap and hold to super-react", reply-with-context, Notes (lightweight broadcast) |
Reacting to a message, Creating group chat |
| LINE |
Sticker-first design language, contextual long-press menu (12 actions), stamp reactions, expressive avatars |
Reacting to a message |
| Discord |
Presence states (Online/Idle/DND/Invisible), server/channel hierarchy, voice channels w/ live activity |
Changing status |
| WeChat / Taobao |
Push-to-talk voice (hold mic button), in-bubble voice-to-text transcribe, lift-to-ear playback |
WeChat voice, Taobao voice |
| Luma / Beside |
Group chat creation with custom emoji avatar + theme color, clean conversation list, suggested replies |
Luma create group, Beside create group |
| Microsoft Teams |
Embedded calls in chat, threaded replies, suggested message starters |
Creating a chat |
| Pi (Inflection AI) / Replika |
AI chat with voice input, typing indicator that animates, transcription mid-stream |
Pi reactions, Replika voice |
| Locket |
Broadcast-style "message everyone": photo capture is the message; no library, no scroll |
Locket camera |
| PlayStation App |
Reaction picker positioning, "PLEASE WAIT" giant stickers, game-context chat |
Chat detail |
| Skype |
Status broadcast ("Share what you're up to"), DND with explanatory modal |
Availability status |
Hero interactions — the moments that matter
1. The message bubble
The bubble is the writing system. Get it perfect:
Geometry:
- Corner radius: 18pt (continuous corner / squircle, NOT system circular). Use
RoundedRectangle(cornerRadius: 18, style: .continuous) in SwiftUI or .layer.cornerCurve = .continuous in UIKit.
- Max width: 75% of screen width (
UIScreen.main.bounds.width * 0.75). Wider than that, the rag-right edge becomes ugly and reading speed drops.
- Internal padding: 12pt horizontal, 8pt vertical (single-line bubbles). For multi-line, increase vertical to 10pt.
- Bubble-to-bubble spacing within a group: 2pt.
- Bubble-to-bubble spacing across senders: 14pt.
- Bubble-to-bubble spacing across time gaps: 24pt + an inline timestamp pill.
Colors:
- Sent (self) bubble:
Color.accentColor (iMessage blue) at 100%, white text. For app brand variations, use the brand accent but ALWAYS check contrast (WCAG AA against the chosen text color).
- Received bubble:
Color(.tertiarySystemGroupedBackground) for light mode, Color(.systemGray5) for dark mode. Text color: .label (auto-adapts).
- Failed-to-send bubble: same shape as sent, but with a 1pt red border and a red exclamation icon to the right.
- Pending/queued bubble: 60% opacity of the sent bubble. Settles to 100% on delivery confirmation.
Bubble grouping (THE critical detail):
Consecutive messages from the same sender within 60 seconds form a "burst". A burst has:
- The FIRST bubble: full radius on the outside corner (top-right for sent, top-left for received), 4pt small radius on the inside (touching) corner.
- The MIDDLE bubbles: 4pt small radius on the inside corners, full 18pt on the outside.
- The LAST bubble: full radius on the outside corners (top-right + bottom-right for sent), with the tail extending from the corner.
enum BubblePosition {
case single, first, middle, last
}
func cornerRadii(for position: BubblePosition, isSent: Bool) -> RectangleCornerRadii {
let small: CGFloat = 4
let large: CGFloat = 18
let outer = isSent ? "right" : "left"
switch position {
case .single: return .init(topLeading: large, bottomLeading: large, bottomTrailing: large, topTrailing: large)
case .first: return isSent
? .init(topLeading: large, bottomLeading: large, bottomTrailing: small, topTrailing: large)
: .init(topLeading: large, bottomLeading: small, bottomTrailing: large, topTrailing: large)
case .middle: return isSent
? .init(topLeading: large, bottomLeading: large, bottomTrailing: small, topTrailing: small)
: .init(topLeading: small, bottomLeading: small, bottomTrailing: large, topTrailing: large)
case .last: return isSent
? .init(topLeading: large, bottomLeading: large, bottomTrailing: large, topTrailing: small)
: .init(topLeading: small, bottomLeading: large, bottomTrailing: large, topTrailing: large)
}
}
The tail (iMessage convention):
- Only render the tail on the LAST bubble of a burst.
- Tail is a small ~6 × 8pt curved triangle that emerges from the outside-bottom corner.
- Implement with a custom
Path (a quadratic Bezier sweeping from the bubble's edge outward and back).
- For sent: tail on bottom-right, pointing right.
- For received: tail on bottom-left, pointing left.
Avatars (received messages only):
- Show only on the LAST bubble of a received burst (matches the tail).
- 28pt circle, 8pt to the left of the bubble.
- 1pt subtle border in
Color(.separator) to give edge against light backgrounds.
Typography inside bubbles:
- Body text: SF Pro, regular, 17pt, line height 22pt.
- For shorter messages (≤ 3 emoji), AUTO-SCALE the emoji to 48pt and remove the bubble. iMessage does this. It makes single-emoji messages feel alive.
2. The composer
The composer is the most-used surface in your entire app. Treat it that way.
Geometry:
- Height (collapsed): 36pt for the input pill + 8pt vertical safe-area padding above + 8pt below.
- Pill background:
Color(.tertiarySystemBackground) with cornerRadius: 18, style: .continuous (matching bubble radius).
- Pill horizontal padding: 12pt left, 12pt right (text content area).
- Pill grows as user types up to 5 lines. After 5 lines, scroll internally.
- + button (attachment): 28pt circle to the LEFT of the pill, 8pt spacing. Subtle gray fill.
- Mic / Send button: 28pt circle to the RIGHT of the pill, 8pt spacing.
Mic ↔ Send swap (THE detail):
- When the text field is empty: mic icon appears on the right.
- When user types ANY character: mic morphs into send arrow with
scale 0 → 1 + opacity 0 → 1 (180ms .spring(response: 0.32, dampingFraction: 0.7)). Mic crossfades out simultaneously.
- When user deletes back to empty: reverse.
- Haptic
.selectionChanged on each swap.
Keyboard handling:
- The composer MUST stick to the top of the keyboard. Use
keyboardLayoutGuide (UIKit) or .ignoresSafeArea(.keyboard, edges: .bottom) with explicit padding (SwiftUI).
- When keyboard appears, the bubble list scrolls to the bottom with NO animation (or 80ms
.linear). The keyboard's animation curve (UIView.AnimationCurve from the notification) is what you should match.
- Critical: don't let the bubble list jump. Compute the offset and apply it within
UIView.animate(withDuration: keyboardAnimationDuration, delay: 0, options: .curveSetting) — this matches the keyboard's curve perfectly.
Return key polish (.submitLabel):
- Set
.submitLabel(.send) on the text field so the keyboard's return key shows "send" — not a generic return arrow. Available labels: .done, .go, .next, .return, .search, .send, .join, .route, .continue. Match the verb to the action.
- Wire
.onSubmit { send() } so the return key actually fires the send. iOS keyboards expect this.
- For multi-line composers where Return should insert a newline, don't override — let the system handle it. Pair with a dedicated send button.
TextField("Message", text: $draft, axis: .vertical)
.lineLimit(1...5)
.submitLabel(.send)
.onSubmit { send() }
Send animation:
- User taps send. IMMEDIATELY (within 16ms):
- The bubble appears at the composer's text position with full opacity but at 70% scale.
- The text field clears.
- The bubble flies up to its slot in the list with
matchedGeometryEffect (SwiftUI) or UIView.transitionWithView (UIKit), scaling 0.7 → 1.0 + slight overshoot to 1.04 → settle.
- Spring:
.spring(response: 0.42, dampingFraction: 0.78).
- Haptic on send:
UIImpactFeedbackGenerator(.light).impactOccurred() at the moment of release.
- If the send eventually fails: bubble subtly desaturates (60% opacity) and a red
! appears beside it. Tap to retry. Haptic .error.
The signature detail: the loading indicator travels. If sending is slow enough to need a progress hint, DON'T show it at the send button — show it INSIDE the optimistically-rendered bubble in the conversation. The eye follows one focal point: the bubble. A 12pt circular ProgressView aligned to the bubble's trailing edge does the job. When delivery confirms, the indicator dissolves and the read receipt fades in beside it. This is the Family Values pattern — loading states travel to their destination.
3. Reactions / tapbacks
The long-press → bubble lifts → reaction picker appears flow.
Long-press detection:
- 0.45 second long-press triggers the menu (slightly faster than iOS default of 0.5).
- During the press, the bubble subtly scales to 1.02 (signaling "you're activating me").
- At the threshold: bubble lifts to 1.04, background blurs (
UIBlurEffect(style: .systemUltraThinMaterialDark) or .glassEffect() on iOS 26+).
- Haptic on threshold cross:
UIImpactFeedbackGenerator(.medium).impactOccurred().
Reaction picker:
- A horizontal pill containing 6 quick emojis + a "+" for the full picker. Positioned ABOVE the bubble (or below if the bubble is at the top of the screen).
- Animation:
- Pill scales from 0 (origin at the bubble's nearest corner) to 1.0 with
.spring(response: 0.36, dampingFraction: 0.72).
- Emoji icons inside the pill stagger their entrance: each 0.04s after the previous, scale 0 → 1.0 with overshoot.
- Tapping an emoji:
- Haptic
.medium.
- The emoji animates from the picker to its final position on the bubble (corner overlap), shrinking from 38pt to 16pt as it lands.
- The picker dismisses with
scale 1 → 0 (180ms .easeIn).
- Tapping outside dismisses with no haptic.
Reaction badges on bubbles:
- Position: overlapping the corner of the bubble (top-right for sent, top-left for received), 8pt × 8pt overlap into the bubble.
- Geometry: pill shape, 22pt tall, dynamic width. White background (system grouped background), 0.5pt subtle border.
- Multiple reactions: each emoji + count, e.g., "❤️ 3 😂 1".
- Tap a reaction to add your own (toggles). Long-press for the "who reacted" detail sheet.
Tapback context menu (additional actions besides reaction):
- Same long-press trigger. Below the reaction pill, a context menu appears with: Reply, Copy, Forward, Pin, Translate, Edit (if your own message, within 15min), Info, Delete.
- Style:
UIMenu (UIKit) or .contextMenu (SwiftUI), but custom-positioned to appear right under the reaction pill. iMessage's choreography is the reference.
4. Reply with swipe (WhatsApp / iMessage)
The "swipe-right on a bubble to reply" gesture is essential. Implementation:
.gesture(
DragGesture(minimumDistance: 16)
.onChanged { value in
let dx = max(0, value.translation.width)
offset = min(dx, 80) // cap at 80pt
// Show a reply arrow icon fading in as dx grows
replyIndicatorOpacity = min(1.0, dx / 60)
// Haptic when crossing threshold
if dx > 60 && !hasFiredHaptic {
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
hasFiredHaptic = true
}
}
.onEnded { value in
if value.translation.width > 60 {
onReply()
}
withAnimation(.spring(response: 0.32, dampingFraction: 0.78)) {
offset = 0; replyIndicatorOpacity = 0
}
hasFiredHaptic = false
}
)
Reply chip in composer:
- Once user has tapped reply (or completed the swipe), a chip appears ABOVE the input pill:
- Vertical accent-color bar (3pt × full height) on the left.
- Original sender's name (semibold, 13pt, accent color).
- Truncated original message text (regular, 13pt, secondary label, single line, with
... truncation).
- "×" close button on the right.
- The chip animates IN with
.spring(response: 0.32, dampingFraction: 0.85) + slide-up + opacity.
- When the reply is sent, the original message reference is preserved on the new bubble (inline at the top of the bubble), and TAPPING that inline reference scrolls to the original with a yellow flash highlight (0.6s,
.easeOut).
5. Voice messages
This is where second-rate apps fail. Get the WhatsApp pattern right:
Recording:
- The mic icon (right of composer) is the trigger. Hold to record, release to send. NOT tap-toggle.
- On press-down:
- Haptic
.medium.
- The composer transforms: the text pill is replaced with a recording indicator (red dot pulsing + timer "0:03"), and a "← slide to cancel" hint appears.
- The mic icon grows to ~52pt and slides slightly left.
- A live waveform builds along the bottom of the screen.
- During hold:
- Slide LEFT past 80pt: cancel. Haptic
.warning, recording discarded, UI restores.
- Slide UP past 60pt: lock. The mic icon snaps into a "lock" position; user can release and continue recording hands-free. Haptic
.success.
- Release in place: send. Haptic
.light.
Live waveform:
- Use
AVAudioRecorder with metering enabled. Poll averagePower(forChannel: 0) every 50ms.
- Render 60–80 vertical bars, mirrored vertically (symmetric around the center). Bar width 2pt, gap 2pt.
- Each new sample shifts the bars left and appends a new bar at the right.
- Bar colors: accent color for "speech" (power > threshold), gray for "silence".
Playback bubble:
- 240pt × 56pt rounded bubble.
- Inside: 32pt play/pause button on the left (system play icon), waveform in the middle, duration text on the right.
- The waveform is a STATIC visualization of the recording (pre-computed from the audio samples, downsampled to fit the bubble width).
- Scrubbing: drag finger across the waveform to scrub through playback. Haptic
.soft every 0.5s of audio crossed.
- Tap-to-play:
UIImpactFeedbackGenerator(.light).impactOccurred() on tap.
- Speed control: small "1×" pill bottom-right; tap to cycle 1× → 1.5× → 2× → 1×.
Lift-to-ear playback (WhatsApp pattern):
- When a voice message is playing and the user lifts the phone to their ear, switch audio output from speaker to earpiece using
AVAudioSession.
- Detect proximity with
UIDevice.current.isProximityMonitoringEnabled = true and observe proximityStateDidChangeNotification.
- This is incredibly delightful when it works.
Transcription (on-device, iOS 13+):
- After recording, run
SFSpeechRecognizer on-device (requiresOnDeviceRecognition = true).
- Show a small "Aa" button on the voice bubble; tap to reveal the transcription below the waveform.
- Cache transcriptions; don't re-run.
- For new messages: kick off transcription immediately when received so the "Aa" button reveals instantly.
6. Typing indicators
The three-dot animation is iconic but you have to do it right.
Visual:
- Three dots, 7pt diameter each, 4pt gap between them.
- Inside a mini bubble: same color/shape as a received bubble, smaller (height 22pt), positioned at the receiver's NEXT bubble location.
- Dots animate: each rises by 3pt, with staggered timing. Dot 1 starts at t=0, dot 2 at t=0.15s, dot 3 at t=0.3s. Each cycle is 0.9s (rise → fall).
- Easing:
.easeInOut for the rise/fall.
Behavior:
- Show after 0.5s of detected typing (don't fire on every keystroke).
- Hide after 2.5s of no typing (or immediately when a message is received).
- Animate IN with
.spring(response: 0.32, dampingFraction: 0.78) (scale 0 → 1.0 + opacity).
- Animate OUT with
scale 1 → 0 (180ms .easeIn).
- Persistence: typing indicators should survive a brief network drop (cache the last "typing" event for 4s).
struct TypingDots: View {
@State private var animating = false
var body: some View {
HStack(spacing: 4) {
ForEach(0..<3) { i in
Circle()
.frame(width: 7, height: 7)
.offset(y: animating ? -3 : 0)
.animation(
.easeInOut(duration: 0.45)
.repeatForever(autoreverses: true)
.delay(Double(i) * 0.15),
value: animating
)
}
}
.onAppear { animating = true }
}
}
7. Read receipts
This is sociologically loaded UI — design it carefully.
Visual options (pick one and commit):
- iMessage: small "Delivered" or "Read 2:34 PM" beneath the LAST sent bubble.
- WhatsApp: single check (sent) → double gray check (delivered) → double blue check (read), positioned inside the bubble at the bottom-right.
- Telegram: single check (sent), double check (delivered/read combined).
- Signal: outlined check (sent), filled check (delivered), double check (read).
Animation:
- Each state transition animates the check icon with a tiny
.symbolEffect(.bounce) (iOS 17+) or a custom 200ms scale-up-and-back.
- Haptic on "read" transition (only for sent messages):
UIImpactFeedbackGenerator(.soft).impactOccurred() ONCE per conversation per session — multiple reads should NOT haptic-spam.
Privacy settings (Telegram's gold standard):
- Default: read receipts OFF for new users (controversial but more humane).
- Three tiers: Everybody, My Contacts, Nobody.
- "Hide Read Time" toggle (subtle: read receipts work but timestamps are hidden).
- Exception list: "Always share with these people".
8. Presence / Online status
Online indicator:
- 8pt green dot, bottom-right corner of avatar, with 2pt white border.
- Inside chat header: "Online" in 13pt regular, secondary label color.
Last-seen text:
- "Last seen at 2:34 PM" (today)
- "Last seen yesterday at 9:12 PM"
- "Last seen recently" (vague, Telegram pattern for partial-privacy users)
- "Last seen within a week" (extremely vague)
- "Last seen a long time ago" (the polite "they ghosted")
Self-disclosed status (Discord pattern):
- Online (green)
- Idle (yellow, half-moon icon)
- Do Not Disturb (red, no-entry icon)
- Invisible (gray)
- Custom Status (any emoji + text, expiring after 1h / 4h / today / never)
Active now grouping: in the chat list, surface a horizontal scroll row of avatars with green dots showing "Active Now". Tap to start a chat.
9. Message effects (iMessage-inspired)
Visual effects on send: confetti, fireworks, slam, gentle, invisible ink, etc. Optional but DELIGHTFUL.
How to implement:
- Long-press the send button (instead of tapping) to open an effects picker.
- The picker shows: Slam, Loud, Gentle, Invisible Ink, plus full-screen effects (Confetti, Balloons, Fireworks, Lasers, Heart, Spotlight).
- After pick, tap the send arrow to actually send.
- Use
CAEmitterLayer for particle effects (confetti, fireworks). For more sophisticated effects (lasers, spotlight), use SCNView or a custom Metal shader.
- Reduce Motion: respect
UIAccessibility.isReduceMotionEnabled — when true, skip the particle effects but keep the bubble itself (e.g., "Slam" sends as a normal bubble).
Performance: limit emitter cells to ~120 particles. Cap effect duration at 3.5s. Always have an "skip" tap target.
10. Stickers, GIFs, Genmoji
Sticker drawer:
- Surfaced above the keyboard (replacing it) when user taps a sticker icon.
- Top: tab bar with recent stickers, then packs the user owns.
- Each sticker: 88pt × 88pt cell, 8pt gap.
- Tap to send (no preview confirm — fast).
- Long-press to peel the sticker (iOS 17+ peel effect) and drag it onto another part of the conversation (annotation pattern).
Genmoji (iOS 18.2+, on-device on Apple Intelligence devices):
- In the keyboard, surface a "Create Genmoji" button.
- User types a prompt; on-device model generates 4 candidate emoji.
- User picks; the Genmoji is sent as a custom sticker.
- Recipients without Apple Intelligence see a PNG fallback.
GIF picker (Giphy or Tenor):
- Tab in the sticker drawer or a dedicated icon.
- Search field at top; trending row below.
- Each GIF: tap to send, long-press for preview at full size.
Trays adopt the environment
When a sticker picker, GIF browser, emoji panel, or any modal is presented from a themed chat (dark theme, custom wallpaper, brand-tinted), it should INHERIT that environment's color scheme — not snap to the system default. A sticker drawer over a dark chat should be dark. A confirmation over a Telegram custom-themed chat should pick up the theme. The visual environment follows the user across modal layers; sudden theme switches are spatially disorienting.
.sheet(isPresented: $showStickers) {
StickerPickerView()
.preferredColorScheme(chatTheme.colorScheme)
.tint(chatTheme.accent)
.presentationBackground(chatTheme.surfaceColor)
}
This is the design-with-taste "trays adapt to context" rule applied natively.
11. Group chat creation
The 3-step flow (Luma / Beside / Instagram pattern):
Step 1 — Pick people:
- Top: search field with auto-suggest.
- Below: list of contacts, with a "Suggested" section at the top (frequency-based + recent).
- Selection: tap to add. Selected people appear as chips at the top of the search field (pill, 24pt tall, with avatar + name + × to remove).
- Limit and counter: "3 selected" tracker visible.
Step 2 — Customize:
- Group emoji avatar (huge, ~80pt, in a circle): tap to pick from emoji or generate a Genmoji.
- Theme color picker: 6–8 horizontal swatches (Apple style: red, orange, yellow, green, mint, teal, blue, indigo, purple, pink, brown).
- Group name field (optional — default to comma-list of members).
- Description (optional).
Step 3 — Create:
- "Create Group Chat" button — full-width pill, 56pt tall, accent color, semibold 17pt text.
- Haptic
.success on creation.
- Animate the transition to the new chat with a smooth push from the right (standard navigation animation).
12. Ephemeral / disappearing messages
Auto-delete timer (Telegram pattern):
- Toggle in chat settings, OR a chat-wide setting "Auto-Delete Messages" with options: 24 hours, 7 days, 31 days, off.
- Once set, all messages in the chat get a small clock icon next to the timestamp.
- After the duration, messages fade out with
opacity 1 → 0 over 600ms and are deleted both locally and remotely.
View-once messages (WhatsApp / Snapchat):
- Toggle a "view once" icon in the composer before sending an image.
- Recipient sees the message as a blurred placeholder; tap to view, opens full-screen.
- Once viewed, the message turns into "Opened" placeholder forever.
- Snapchat allows a 1–10 second view window (or infinity); WhatsApp allows ONE view, period.
Screenshot detection (Snapchat pattern):
- Observe
UIApplication.userDidTakeScreenshotNotification.
- When detected during a view-once or ephemeral message, send a system message to the other party: "📸 [User] took a screenshot".
- Bonus: also detect screen recording with
UIScreen.main.isCaptured (KVO).
13. End-to-end encryption indicators
Banner:
- At the start of any new conversation, a centered system message: "🔒 Messages are end-to-end encrypted. No one outside this chat can read or listen to them. Tap to learn more."
- Visual: light background pill, no avatar, no timestamp, center-aligned text (12pt regular, secondary label).
- Tap to open a sheet explaining the encryption + safety number / verification flow.
Verification:
- Safety number / verification code, displayed as a QR code + 60-digit number.
- Both parties can scan each other's QR to verify out-of-band. Animate the QR appearing with a Vision-framework subject-lifting style shimmer.
Lock icon in nav:
- Subtle 12pt lock icon next to the chat title, tinted secondary.
- For unverified contacts: tinted system orange. Tap to see "Verify safety number?" prompt.
14. Call experiences (audio & video)
Outgoing call:
- Full-screen native CallKit UI (use
CXProvider + CXCallController). This is critical — your call appears in the system call log, on the lock screen, with Bluetooth controls.
- Custom in-app pre-call screen: avatar centered (120pt circle), name below (28pt semibold), "Calling..." subtitle, animated wave rings emanating from the avatar (CAReplicatorLayer for the rings).
Incoming call:
- Use CallKit's native incoming UI. Apple won't approve apps that try to override this.
- Pair with PushKit (VoIP push) for instant ring even when the app is suspended/killed.
In-call UI (when user enters the call screen):
- Top: tiny FaceTime/audio waveform indicator.
- Center: large avatar OR self-camera PiP.
- Bottom: mute / video / speaker / end call (large red circle) — standard 4-button row, with 64pt tap targets.
Live Activity for ongoing call:
- When the user backgrounds the app, surface a Live Activity (Dynamic Island on iPhone 14 Pro+):
- Compact: speaker icon + call duration.
- Expanded: avatar + name + duration + mute toggle + end call.
import ActivityKit
struct CallAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var duration: TimeInterval
var isMuted: Bool
}
var calleeName: String
var calleeAvatarURL: URL
}
let activity = try Activity<CallAttributes>.request(
attributes: CallAttributes(calleeName: "Sam", calleeAvatarURL: ...),
content: .init(state: .init(duration: 0, isMuted: false), staleDate: nil),
pushType: .token
)
15. Chat list (the conversation index)
Cell layout:
- 72pt tall (vertical).
- 56pt avatar on the left, 12pt right margin to content.
- Content area: vertically centered.
- Top: chat title (17pt semibold) + timestamp (right-aligned, 13pt regular, secondary).
- Bottom: last message preview (15pt regular, secondary label, 1 line truncation) + unread badge (right-aligned, blue circle with white count).
- Right-edge: chevron (
Image(systemName: "chevron.right"), 12pt, tertiary tint).
Avatar:
- 56pt circle. For group chats: 4-grid mini-avatars OR custom emoji per the group's theme.
- Active-now indicator: 14pt green dot bottom-right with 2pt white border.
Sorting:
- Default: most-recently-active first.
- Pinned chats: pin icon to the right of the title; pinned chats are always at the top of the list with a subtle background tint.
Unread state:
- Bold text for title.
- Blue unread badge with count (system blue, 22pt height, dynamic width).
- For mention/reply: red @ badge instead of blue count.
Swipe actions:
- Swipe-left: Archive, Mute, Delete (red, terminal).
- Swipe-right: Pin, Mark as Read/Unread.
- Use
swipeActions(edge:) in SwiftUI or UISwipeActionsConfiguration in UIKit.
Search:
- Pull down to reveal the search field.
- Recent searches above results.
- Search hits inline-highlight the matched substring in the preview.
Animation curves cheat sheet
| Surface |
Curve |
Notes |
| Bubble send (composer → list) |
.spring(response: 0.42, dampingFraction: 0.78) |
Slight overshoot, settles fast |
| Bubble receive |
.spring(response: 0.4, dampingFraction: 0.85) |
Subtler than send — your message arriving should be calm |
| Composer pill grow |
.linear |
Multi-line growth — NEVER spring, fights the keyboard |
| Mic ↔ send swap |
.spring(response: 0.32, dampingFraction: 0.7) |
Snappy, definite |
| Reaction pill appear |
.spring(response: 0.36, dampingFraction: 0.72) |
Bouncy |
| Reaction emoji stagger |
0.04s delay each + same spring |
Cascading |
| Reaction land on bubble |
.spring(response: 0.32, dampingFraction: 0.65) |
Bouncier — "stuck" |
| Long-press menu open |
.spring(response: 0.36, dampingFraction: 0.78) |
Match iMessage |
| Reply chip appear |
.spring(response: 0.32, dampingFraction: 0.85) |
Calm |
| Typing dots (each cycle) |
.easeInOut(duration: 0.45) repeat |
Standard |
| Chat list cell swipe |
.spring(response: 0.32, dampingFraction: 0.82) |
iOS-native feel |
| Push to chat detail |
system push |
Don't override |
| Modal sheet (e.g., contact info) |
.spring(response: 0.45, dampingFraction: 0.86) |
Standard sheet |
| Call screen present |
.spring(response: 0.5, dampingFraction: 0.92) |
Slightly slower — gravity |
Reduce Motion: replace springs with .easeInOut(duration: 0.18) crossfades, kill stagger, skip message effects.
Haptics cheat sheet
| Action |
Generator |
Style |
Notes |
| Tap send |
UIImpactFeedbackGenerator |
.light |
Prepare in keyboardWillShow |
| Message delivered (confirmation) |
none |
— |
Don't haptic on every delivery — too much noise |
| Message read (first time per session) |
UIImpactFeedbackGenerator |
.soft |
One per conversation per session |
| Receive new message (foreground) |
UIImpactFeedbackGenerator |
.soft |
Optional — many users find this jarring; opt-in |
| Long-press to open menu |
UIImpactFeedbackGenerator |
.medium |
Fire on threshold cross |
| Pick reaction |
UIImpactFeedbackGenerator |
.medium |
On tap |
| Reaction lands on bubble |
UIImpactFeedbackGenerator |
.soft |
Fire when animation finishes |
| Mic ↔ send swap |
UISelectionFeedbackGenerator |
.selectionChanged |
Prepare on text changes |
| Voice record start |
UIImpactFeedbackGenerator |
.medium |
On press-down |
| Voice record cancel (drag past threshold) |
UINotificationFeedbackGenerator |
.warning |
One-shot |
| Voice record lock (drag up) |
UINotificationFeedbackGenerator |
.success |
One-shot |
| Voice playback scrub |
UIImpactFeedbackGenerator |
.soft |
Throttle to 100ms |
| Swipe-to-reply threshold |
UIImpactFeedbackGenerator |
.soft |
On threshold cross only |
| Chat list swipe-action threshold |
UIImpactFeedbackGenerator |
.soft |
On threshold |
| Delete confirmation |
UINotificationFeedbackGenerator |
.warning |
When destructive action confirmed |
| Outgoing call initiated |
UIImpactFeedbackGenerator |
.heavy |
One-shot |
Custom haptic patterns for delight:
- Send a "slam" effect message: use CoreHaptics with a sharp transient + continuous decay (think: a small explosion).
- Bubble lands after a long send animation: a tiny "tick-tick" pattern (two
.soft impacts 80ms apart).
let engine = try CHHapticEngine()
try engine.start()
let pattern = try CHHapticPattern(events: [
CHHapticEvent(eventType: .hapticTransient, parameters: [
.init(parameterID: .hapticIntensity, value: 1.0),
.init(parameterID: .hapticSharpness, value: 0.8)
], relativeTime: 0),
CHHapticEvent(eventType: .hapticContinuous, parameters: [
.init(parameterID: .hapticIntensity, value: 0.4),
.init(parameterID: .hapticSharpness, value: 0.2)
], relativeTime: 0.05, duration: 0.4)
], parameters: [])
try engine.makePlayer(with: pattern).start(atTime: 0)
Typography for chat UIs
| Surface |
Font |
Weight |
Size |
Notes |
| Bubble text |
SF Pro |
.regular |
17pt |
Use Dynamic Type via .body |
| Solo emoji (≤3) |
system emoji |
— |
48pt |
Auto-scale when message is emoji-only |
| Timestamp (inline group) |
SF Pro |
.regular |
11pt |
Tracking 0.2, secondary label color |
| Read receipt |
SF Pro |
.regular |
11pt |
Secondary label, beneath last sent bubble |
| Chat title (header) |
SF Pro |
.semibold |
17pt |
Truncate with middle ellipsis if long |
| Chat subtitle (online status) |
SF Pro |
.regular |
13pt |
Secondary label |
| Composer text input |
SF Pro |
.regular |
17pt |
NEVER use a different size — matches bubbles for WYSIWYG |
| Chat list title |
SF Pro |
.semibold (unread) / .regular (read) |
17pt |
|
| Chat list preview |
SF Pro |
.regular |
15pt |
Secondary label, 1-line truncation |
| Chat list timestamp |
SF Pro |
.regular |
13pt |
Tertiary label |
| Unread badge |
SF Pro |
.semibold |
13pt |
White on system blue |
| System message (e.g., "X joined") |
SF Pro |
.regular |
13pt |
Center-aligned, secondary label |
| In-call name |
SF Pro |
.semibold |
28pt |
White on dark background |
| In-call duration |
SF Mono |
.regular |
17pt |
Monospace digits — they don't jitter |
Always support Dynamic Type via .body, .callout, .caption, etc. Test with extra-large accessibility sizes (AX5).
Color & material
- Sent bubble: app's accent color. For iMessage parity:
Color(.displayP3, red: 0.0, green: 0.48, blue: 1.0) (iMessage blue). For green-bubble nostalgia: Color(.displayP3, red: 0.21, green: 0.78, blue: 0.35) (SMS green). Always ship hand-coded colors as Display P3 — see the-final-5-percent §5 for the OKLCH-pick / P3-ship workflow that applies to every color in this skill.
- Received bubble:
Color(.tertiarySystemGroupedBackground) (light), Color(.systemGray5) (dark).
- Background of conversation:
Color(.systemGroupedBackground) (light), Color(.systemBackground) (dark). Telegram and WhatsApp use a subtle pattern/wallpaper — if you do this, make it OFF by default.
Push notification copywriting — the Bier rules. The notification IS the user's first impression of your app, every day. Apps that nail push copy get re-engaged daily; apps that don't get muted, then deleted.
Every notification must make the user feel something POSITIVE. Dopamine delivered, not nagging delivered:
| ❌ Bad — nag |
✅ Good — value |
| "You haven't opened the app in 3 days" |
"Sam just replied to your message" |
| "Don't lose your streak!" |
"🔥 You're 7 days in. Keep going?" |
| "New activity" |
"Alice tapped ❤️ on your photo" |
| "1 new message" |
"Sam: are we still on for tonight?" |
| "Tap to see what you missed" |
"Alice and 2 others shared photos in Family Chat" |
| "Update available" |
"Voice messages are live. Try them i |
…(truncated)
1---2name: ios-chat-and-messaging-design3description: Design and build best-in-class native iOS chat, messaging, and group chat apps with the polish of iMessage, Telegram, WhatsApp, Snapchat, Instagram DMs, and Discord. Use this skill whenever the user is building, reviewing, or refining a SwiftUI/UIKit app that involves direct messages, group chats, threads, voice messages, video calls, reactions, typing indicators, presence, read receipts, ephemeral messages, end-to-end encryption, push notifications, or anything backed by APNs, PushKit, CallKit, CryptoKit, NotificationServiceExtension, or Live Activities. Triggers on: chat, messaging, message bubble, group chat, DM, conversation, composer, reactions, tapback, reply, thread, typing indicator, presence, read receipt, voice message, waveform, end-to-end encryption, E2EE, ephemeral, disappearing message, sticker, GIF, Genmoji, iMessage app, message effect, Live Activity, Dynamic Island, push notification, notification service extension, CallKit, PushKit, video call, FaceTime, SharePlay, App Intent, CryptoKit.4---56# iOS Chat & Messaging — Design Engineering Skill78A taste guide for building messaging apps that feel like they belong on iOS. Every value below is opinionated and specific — pulled from studying flows on Mobbin and shipping native chat apps.910## Output format — required1112When this skill is invoked to review chat/messaging code or recommend changes, **always output recommendations as a markdown table** with three columns:1314| Before | After | What this changes |15| --- | --- | --- |16| The current code, value, or approach (quote the user's actual code when possible) | The recommended replacement — **specific**, with exact values | One sentence on what the user will *see, feel, or experience* differently |1718Three rules:191. **Before** quotes the user's actual code where possible.202. **After** is specific. Exact pt values, exact corner radii, exact haptic styles, exact API calls.213. **What this changes** is *experiential or visual*, not abstract.2223Output ONE table with multiple rows for multi-recommendation reviews — not one table per row. Use `—` for Before if the user hasn't implemented that thing yet.2425**Examples drawn from this skill:**2627| Before | After | What this changes |28| --- | --- | --- |29| `cornerRadius: 16` applied uniformly to every message bubble | First/last bubbles in a burst get 18pt continuous; middle bubbles get 4pt small radius on inside-edges | Bubble grouping reads as conversation rhythm — same-speaker messages cluster visually, switches between speakers separate clearly. Reading speed up ~3× |30| Tap-and-release send button with `UIImpactFeedbackGenerator(.light)` on `.onTapGesture` | `.sensoryFeedback(.impact(weight: .light), trigger: messageId) { _, new in new != nil }` firing on touch-DOWN | Haptic latency drops from ~50ms to <5ms; the send feels acknowledged instantly instead of "did that go?" |31| `Color(red: 0.0, green: 0.48, blue: 1.0)` (iMessage blue) | `Color(.displayP3, red: 0.0, green: 0.48, blue: 1.0)` | Sent bubbles render at full chroma on every Apple device since 2017 — feels native instead of slightly washed out |3233This format is required for every recommendation output by this skill.3435---3637## Philosophy3839> A chat app is a feeling of being heard, fast.4041Three things separate amazing chat from passable chat:421. **The composer is the only thing that matters.** Everything else is supporting cast. Time-to-typing must be < 200ms from launch. Send must be instant — show the bubble before the network responds. If you can't get this right, nothing else matters.432. **Bubbles are a writing system.** Spacing, grouping, tail placement, and timestamp rhythm communicate WHO said WHAT WHEN faster than reading the text itself. Get the visual language right and people skim conversations 3× faster.443. **Latency is the product.** A message that sends in 80ms but arrives in 2000ms feels slower than one that sends in 800ms and arrives in 900ms. Optimistic UI is not optional.4546The pixel-pushers' rules:47- **Bubbles, not cards.** A card has shadows and borders. A bubble has a tail. Chat is conversation, not content.48- **The composer is permanent.** It sits at the bottom always, even during search, even during the empty state. Never push the composer above the fold.49- **Read receipts are intimate.** Default them OFF for new users. Telegram and Signal got this right.50- **Don't infantilize.** No "Looks like there are no messages here yet!" cute illustrations in a serious chat app. Empty states should be quiet.5152## Reference apps to study5354When in doubt, copy. These are the apps you should be benchmarking against, with the specific flows worth lifting:5556| App | What to learn from it | Mobbin flow |57| --- | --- | --- |58| **Apple Messages (iMessage)** | The gold standard for native feel — bubble tails, tapbacks, message effects, Genmoji, inline App Clips, Communication Notifications. Everything compiles against this. | (Use the system; observe it on your own device) |59| **Telegram** | The most feature-dense chat app ever built. Auto-delete timers, custom themes per chat, last-seen privacy ladders, reactions with custom emoji, animated stickers (TGS/Lottie), folder-based chat lists | [Last Seen settings](https://mobbin.com/flows/3bc6e47e-6e9d-4f11-bcd0-0939ad3db4f9), [Reacting to a message](https://mobbin.com/flows/65a949d7-6d85-4ba3-93eb-62bfcf0ddc17) |60| **WhatsApp** | Voice messages done right, read receipts (gray → blue double check), reply-with-swipe-right, message info screen, end-to-end encryption banner, edit window (15min) | [Message info](https://mobbin.com/flows/91134f85-10a8-48e5-96cf-84f3f67830eb) |61| **Snapchat** | Ephemeral messages, "save in chat", time-limit picker, screenshot detection notifications, voice notes with transcription | [Setting time limit](https://mobbin.com/flows/4fa7c0d5-b4dd-461a-a732-61fa1b0e848d), [Recording audio](https://mobbin.com/flows/54b4693a-a82e-4346-afc5-8a0036a44952), [Deleting a message](https://mobbin.com/flows/68ff8963-fcd2-403e-88fb-05554bfb8aa5) |62| **Instagram DMs** | Per-chat themes, reaction picker w/ "tap and hold to super-react", reply-with-context, Notes (lightweight broadcast) | [Reacting to a message](https://mobbin.com/flows/bbd7c647-0100-47d8-b759-4a5b4c4d7de0), [Creating group chat](https://mobbin.com/flows/cdb301fd-273d-47cc-a760-82ed02883f71) |63| **LINE** | Sticker-first design language, contextual long-press menu (12 actions), stamp reactions, expressive avatars | [Reacting to a message](https://mobbin.com/flows/75fb375d-fd08-4a05-b652-c0f005b27681) |64| **Discord** | Presence states (Online/Idle/DND/Invisible), server/channel hierarchy, voice channels w/ live activity | [Changing status](https://mobbin.com/flows/7d691cf7-9ec9-483a-9d97-f3b29fd84633) |65| **WeChat / Taobao** | Push-to-talk voice (hold mic button), in-bubble voice-to-text transcribe, lift-to-ear playback | [WeChat voice](https://mobbin.com/flows/b043b433-ef7d-4eff-9393-8ae5958d48fc), [Taobao voice](https://mobbin.com/flows/9bc4139a-bcae-4070-bdb8-2f3423f40c6a) |66| **Luma / Beside** | Group chat creation with custom emoji avatar + theme color, clean conversation list, suggested replies | [Luma create group](https://mobbin.com/flows/45c43eee-a684-4730-a1c3-7f26eba77d38), [Beside create group](https://mobbin.com/flows/769561b5-e079-489b-a124-d99355d970d4) |67| **Microsoft Teams** | Embedded calls in chat, threaded replies, suggested message starters | [Creating a chat](https://mobbin.com/flows/beb5079a-8928-473a-9228-42d3106635c3) |68| **Pi (Inflection AI) / Replika** | AI chat with voice input, typing indicator that animates, transcription mid-stream | [Pi reactions](https://mobbin.com/flows/99931f05-5968-4759-b40e-0e891e0492e9), [Replika voice](https://mobbin.com/flows/e0d853a4-037e-415a-b18f-076b2972aa51) |69| **Locket** | Broadcast-style "message everyone": photo capture is the message; no library, no scroll | [Locket camera](https://mobbin.com/flows/a16e33e2-501a-4c26-ac00-ab960e345040) |70| **PlayStation App** | Reaction picker positioning, "PLEASE WAIT" giant stickers, game-context chat | [Chat detail](https://mobbin.com/flows/acbebeb5-566a-4985-99ec-0b29be8a3e23) |71| **Skype** | Status broadcast ("Share what you're up to"), DND with explanatory modal | [Availability status](https://mobbin.com/flows/2daa6dba-5f7b-4c14-a0ee-c3599e1b1d4d) |7273---7475## Hero interactions — the moments that matter7677### 1. The message bubble7879The bubble is the writing system. Get it perfect:8081**Geometry:**82- **Corner radius**: 18pt (continuous corner / squircle, NOT system circular). Use `RoundedRectangle(cornerRadius: 18, style: .continuous)` in SwiftUI or `.layer.cornerCurve = .continuous` in UIKit.83- **Max width**: 75% of screen width (`UIScreen.main.bounds.width * 0.75`). Wider than that, the rag-right edge becomes ugly and reading speed drops.84- **Internal padding**: 12pt horizontal, 8pt vertical (single-line bubbles). For multi-line, increase vertical to 10pt.85- **Bubble-to-bubble spacing within a group**: 2pt.86- **Bubble-to-bubble spacing across senders**: 14pt.87- **Bubble-to-bubble spacing across time gaps**: 24pt + an inline timestamp pill.8889**Colors:**90- **Sent (self) bubble**: `Color.accentColor` (iMessage blue) at 100%, white text. For app brand variations, use the brand accent but ALWAYS check contrast (WCAG AA against the chosen text color).91- **Received bubble**: `Color(.tertiarySystemGroupedBackground)` for light mode, `Color(.systemGray5)` for dark mode. Text color: `.label` (auto-adapts).92- **Failed-to-send bubble**: same shape as sent, but with a 1pt red border and a red exclamation icon to the right.93- **Pending/queued bubble**: 60% opacity of the sent bubble. Settles to 100% on delivery confirmation.9495**Bubble grouping (THE critical detail):**9697Consecutive messages from the same sender within 60 seconds form a "burst". A burst has:98- The FIRST bubble: full radius on the outside corner (top-right for sent, top-left for received), 4pt small radius on the inside (touching) corner.99- The MIDDLE bubbles: 4pt small radius on the inside corners, full 18pt on the outside.100- The LAST bubble: full radius on the outside corners (top-right + bottom-right for sent), with the tail extending from the corner.101102```swift103enum BubblePosition {104 case single, first, middle, last105}106107func cornerRadii(for position: BubblePosition, isSent: Bool) -> RectangleCornerRadii {108 let small: CGFloat = 4109 let large: CGFloat = 18110 let outer = isSent ? "right" : "left"111 switch position {112 case .single: return .init(topLeading: large, bottomLeading: large, bottomTrailing: large, topTrailing: large)113 case .first: return isSent114 ? .init(topLeading: large, bottomLeading: large, bottomTrailing: small, topTrailing: large)115 : .init(topLeading: large, bottomLeading: small, bottomTrailing: large, topTrailing: large)116 case .middle: return isSent117 ? .init(topLeading: large, bottomLeading: large, bottomTrailing: small, topTrailing: small)118 : .init(topLeading: small, bottomLeading: small, bottomTrailing: large, topTrailing: large)119 case .last: return isSent120 ? .init(topLeading: large, bottomLeading: large, bottomTrailing: large, topTrailing: small)121 : .init(topLeading: small, bottomLeading: large, bottomTrailing: large, topTrailing: large)122 }123}124```125126**The tail** (iMessage convention):127- Only render the tail on the LAST bubble of a burst.128- Tail is a small ~6 × 8pt curved triangle that emerges from the outside-bottom corner.129- Implement with a custom `Path` (a quadratic Bezier sweeping from the bubble's edge outward and back).130- For sent: tail on bottom-right, pointing right.131- For received: tail on bottom-left, pointing left.132133**Avatars** (received messages only):134- Show only on the LAST bubble of a received burst (matches the tail).135- 28pt circle, 8pt to the left of the bubble.136- 1pt subtle border in `Color(.separator)` to give edge against light backgrounds.137138**Typography inside bubbles:**139- Body text: SF Pro, regular, 17pt, line height 22pt.140- For shorter messages (≤ 3 emoji), AUTO-SCALE the emoji to 48pt and remove the bubble. iMessage does this. It makes single-emoji messages feel alive.141142### 2. The composer143144The composer is the most-used surface in your entire app. Treat it that way.145146**Geometry:**147- **Height (collapsed)**: 36pt for the input pill + 8pt vertical safe-area padding above + 8pt below.148- **Pill background**: `Color(.tertiarySystemBackground)` with `cornerRadius: 18, style: .continuous` (matching bubble radius).149- **Pill horizontal padding**: 12pt left, 12pt right (text content area).150- **Pill grows** as user types up to 5 lines. After 5 lines, scroll internally.151- **+ button (attachment)**: 28pt circle to the LEFT of the pill, 8pt spacing. Subtle gray fill.152- **Mic / Send button**: 28pt circle to the RIGHT of the pill, 8pt spacing.153154**Mic ↔ Send swap** (THE detail):155- When the text field is empty: mic icon appears on the right.156- When user types ANY character: mic morphs into send arrow with `scale 0 → 1` + `opacity 0 → 1` (180ms `.spring(response: 0.32, dampingFraction: 0.7)`). Mic crossfades out simultaneously.157- When user deletes back to empty: reverse.158- Haptic `.selectionChanged` on each swap.159160**Keyboard handling:**161- The composer MUST stick to the top of the keyboard. Use `keyboardLayoutGuide` (UIKit) or `.ignoresSafeArea(.keyboard, edges: .bottom)` with explicit padding (SwiftUI).162- When keyboard appears, the bubble list scrolls to the bottom with NO animation (or 80ms `.linear`). The keyboard's animation curve (`UIView.AnimationCurve` from the notification) is what you should match.163- **Critical**: don't let the bubble list jump. Compute the offset and apply it within `UIView.animate(withDuration: keyboardAnimationDuration, delay: 0, options: .curveSetting)` — this matches the keyboard's curve perfectly.164165**Return key polish (`.submitLabel`):**166- Set `.submitLabel(.send)` on the text field so the keyboard's return key shows "send" — not a generic return arrow. Available labels: `.done`, `.go`, `.next`, `.return`, `.search`, `.send`, `.join`, `.route`, `.continue`. Match the verb to the action.167- Wire `.onSubmit { send() }` so the return key actually fires the send. iOS keyboards expect this.168- For multi-line composers where Return should insert a newline, don't override — let the system handle it. Pair with a dedicated send button.169170```swift171TextField("Message", text: $draft, axis: .vertical)172 .lineLimit(1...5)173 .submitLabel(.send)174 .onSubmit { send() }175```176177**Send animation:**1781. User taps send. IMMEDIATELY (within 16ms):1792. The bubble appears at the composer's text position with full opacity but at 70% scale.1803. The text field clears.1814. The bubble flies up to its slot in the list with `matchedGeometryEffect` (SwiftUI) or `UIView.transitionWithView` (UIKit), scaling 0.7 → 1.0 + slight overshoot to 1.04 → settle.1825. Spring: `.spring(response: 0.42, dampingFraction: 0.78)`.1836. Haptic on send: `UIImpactFeedbackGenerator(.light).impactOccurred()` at the moment of release.1847. If the send eventually fails: bubble subtly desaturates (60% opacity) and a red `!` appears beside it. Tap to retry. Haptic `.error`.185186**The signature detail: the loading indicator travels.** If sending is slow enough to need a progress hint, DON'T show it at the send button — show it INSIDE the optimistically-rendered bubble in the conversation. The eye follows one focal point: the bubble. A 12pt circular `ProgressView` aligned to the bubble's trailing edge does the job. When delivery confirms, the indicator dissolves and the read receipt fades in beside it. This is the [Family Values pattern](https://benji.org/family-values) — loading states travel to their destination.187188### 3. Reactions / tapbacks189190The long-press → bubble lifts → reaction picker appears flow.191192**Long-press detection:**193- 0.45 second long-press triggers the menu (slightly faster than iOS default of 0.5).194- During the press, the bubble subtly scales to 1.02 (signaling "you're activating me").195- At the threshold: bubble lifts to 1.04, background blurs (`UIBlurEffect(style: .systemUltraThinMaterialDark)` or `.glassEffect()` on iOS 26+).196- Haptic on threshold cross: `UIImpactFeedbackGenerator(.medium).impactOccurred()`.197198**Reaction picker:**199- A horizontal pill containing 6 quick emojis + a "+" for the full picker. Positioned ABOVE the bubble (or below if the bubble is at the top of the screen).200- Animation:201 - Pill scales from 0 (origin at the bubble's nearest corner) to 1.0 with `.spring(response: 0.36, dampingFraction: 0.72)`.202 - Emoji icons inside the pill stagger their entrance: each 0.04s after the previous, scale 0 → 1.0 with overshoot.203- Tapping an emoji:204 - Haptic `.medium`.205 - The emoji animates from the picker to its final position on the bubble (corner overlap), shrinking from 38pt to 16pt as it lands.206 - The picker dismisses with `scale 1 → 0` (180ms `.easeIn`).207- Tapping outside dismisses with no haptic.208209**Reaction badges on bubbles:**210- Position: overlapping the corner of the bubble (top-right for sent, top-left for received), 8pt × 8pt overlap into the bubble.211- Geometry: pill shape, 22pt tall, dynamic width. White background (system grouped background), 0.5pt subtle border.212- Multiple reactions: each emoji + count, e.g., "❤️ 3 😂 1".213- Tap a reaction to add your own (toggles). Long-press for the "who reacted" detail sheet.214215**Tapback context menu** (additional actions besides reaction):216- Same long-press trigger. Below the reaction pill, a context menu appears with: Reply, Copy, Forward, Pin, Translate, Edit (if your own message, within 15min), Info, Delete.217- Style: `UIMenu` (UIKit) or `.contextMenu` (SwiftUI), but custom-positioned to appear right under the reaction pill. iMessage's choreography is the reference.218219### 4. Reply with swipe (WhatsApp / iMessage)220221The "swipe-right on a bubble to reply" gesture is essential. Implementation:222223```swift224.gesture(225 DragGesture(minimumDistance: 16)226 .onChanged { value in227 let dx = max(0, value.translation.width)228 offset = min(dx, 80) // cap at 80pt229 // Show a reply arrow icon fading in as dx grows230 replyIndicatorOpacity = min(1.0, dx / 60)231 // Haptic when crossing threshold232 if dx > 60 && !hasFiredHaptic {233 UIImpactFeedbackGenerator(style: .soft).impactOccurred()234 hasFiredHaptic = true235 }236 }237 .onEnded { value in238 if value.translation.width > 60 {239 onReply()240 }241 withAnimation(.spring(response: 0.32, dampingFraction: 0.78)) {242 offset = 0; replyIndicatorOpacity = 0243 }244 hasFiredHaptic = false245 }246)247```248249**Reply chip in composer:**250- Once user has tapped reply (or completed the swipe), a chip appears ABOVE the input pill:251 - Vertical accent-color bar (3pt × full height) on the left.252 - Original sender's name (semibold, 13pt, accent color).253 - Truncated original message text (regular, 13pt, secondary label, single line, with `...` truncation).254 - "×" close button on the right.255- The chip animates IN with `.spring(response: 0.32, dampingFraction: 0.85)` + slide-up + opacity.256- When the reply is sent, the original message reference is preserved on the new bubble (inline at the top of the bubble), and TAPPING that inline reference scrolls to the original with a yellow flash highlight (0.6s, `.easeOut`).257258### 5. Voice messages259260This is where second-rate apps fail. Get the WhatsApp pattern right:261262**Recording:**263- The mic icon (right of composer) is the trigger. **Hold to record**, release to send. NOT tap-toggle.264- On press-down:265 - Haptic `.medium`.266 - The composer transforms: the text pill is replaced with a recording indicator (red dot pulsing + timer "0:03"), and a "← slide to cancel" hint appears.267 - The mic icon grows to ~52pt and slides slightly left.268 - A live waveform builds along the bottom of the screen.269- During hold:270 - **Slide LEFT past 80pt**: cancel. Haptic `.warning`, recording discarded, UI restores.271 - **Slide UP past 60pt**: lock. The mic icon snaps into a "lock" position; user can release and continue recording hands-free. Haptic `.success`.272 - **Release in place**: send. Haptic `.light`.273274**Live waveform:**275- Use `AVAudioRecorder` with metering enabled. Poll `averagePower(forChannel: 0)` every 50ms.276- Render 60–80 vertical bars, mirrored vertically (symmetric around the center). Bar width 2pt, gap 2pt.277- Each new sample shifts the bars left and appends a new bar at the right.278- Bar colors: accent color for "speech" (power > threshold), gray for "silence".279280**Playback bubble:**281- 240pt × 56pt rounded bubble.282- Inside: 32pt play/pause button on the left (system play icon), waveform in the middle, duration text on the right.283- The waveform is a STATIC visualization of the recording (pre-computed from the audio samples, downsampled to fit the bubble width).284- Scrubbing: drag finger across the waveform to scrub through playback. Haptic `.soft` every 0.5s of audio crossed.285- Tap-to-play: `UIImpactFeedbackGenerator(.light).impactOccurred()` on tap.286- **Speed control**: small "1×" pill bottom-right; tap to cycle 1× → 1.5× → 2× → 1×.287288**Lift-to-ear playback** (WhatsApp pattern):289- When a voice message is playing and the user lifts the phone to their ear, switch audio output from speaker to earpiece using `AVAudioSession`.290- Detect proximity with `UIDevice.current.isProximityMonitoringEnabled = true` and observe `proximityStateDidChangeNotification`.291- This is incredibly delightful when it works.292293**Transcription** (on-device, iOS 13+):294- After recording, run `SFSpeechRecognizer` on-device (`requiresOnDeviceRecognition = true`).295- Show a small "Aa" button on the voice bubble; tap to reveal the transcription below the waveform.296- Cache transcriptions; don't re-run.297- For new messages: kick off transcription immediately when received so the "Aa" button reveals instantly.298299### 6. Typing indicators300301The three-dot animation is iconic but you have to do it right.302303**Visual:**304- Three dots, 7pt diameter each, 4pt gap between them.305- Inside a mini bubble: same color/shape as a received bubble, smaller (height 22pt), positioned at the receiver's NEXT bubble location.306- Dots animate: each rises by 3pt, with staggered timing. Dot 1 starts at t=0, dot 2 at t=0.15s, dot 3 at t=0.3s. Each cycle is 0.9s (rise → fall).307- Easing: `.easeInOut` for the rise/fall.308309**Behavior:**310- Show after 0.5s of detected typing (don't fire on every keystroke).311- Hide after 2.5s of no typing (or immediately when a message is received).312- Animate IN with `.spring(response: 0.32, dampingFraction: 0.78)` (scale 0 → 1.0 + opacity).313- Animate OUT with `scale 1 → 0` (180ms `.easeIn`).314- Persistence: typing indicators should survive a brief network drop (cache the last "typing" event for 4s).315316```swift317struct TypingDots: View {318 @State private var animating = false319 var body: some View {320 HStack(spacing: 4) {321 ForEach(0..<3) { i in322 Circle()323 .frame(width: 7, height: 7)324 .offset(y: animating ? -3 : 0)325 .animation(326 .easeInOut(duration: 0.45)327 .repeatForever(autoreverses: true)328 .delay(Double(i) * 0.15),329 value: animating330 )331 }332 }333 .onAppear { animating = true }334 }335}336```337338### 7. Read receipts339340This is sociologically loaded UI — design it carefully.341342**Visual options** (pick one and commit):3431. **iMessage**: small "Delivered" or "Read 2:34 PM" beneath the LAST sent bubble.3442. **WhatsApp**: single check (sent) → double gray check (delivered) → double blue check (read), positioned inside the bubble at the bottom-right.3453. **Telegram**: single check (sent), double check (delivered/read combined).3464. **Signal**: outlined check (sent), filled check (delivered), double check (read).347348**Animation**:349- Each state transition animates the check icon with a tiny `.symbolEffect(.bounce)` (iOS 17+) or a custom 200ms scale-up-and-back.350- Haptic on "read" transition (only for sent messages): `UIImpactFeedbackGenerator(.soft).impactOccurred()` ONCE per conversation per session — multiple reads should NOT haptic-spam.351352**Privacy settings** (Telegram's gold standard):353- Default: read receipts OFF for new users (controversial but more humane).354- Three tiers: Everybody, My Contacts, Nobody.355- "Hide Read Time" toggle (subtle: read receipts work but timestamps are hidden).356- Exception list: "Always share with these people".357358### 8. Presence / Online status359360**Online indicator:**361- 8pt green dot, bottom-right corner of avatar, with 2pt white border.362- Inside chat header: "Online" in 13pt regular, secondary label color.363364**Last-seen text:**365- "Last seen at 2:34 PM" (today)366- "Last seen yesterday at 9:12 PM"367- "Last seen recently" (vague, Telegram pattern for partial-privacy users)368- "Last seen within a week" (extremely vague)369- "Last seen a long time ago" (the polite "they ghosted")370371**Self-disclosed status** (Discord pattern):372- Online (green)373- Idle (yellow, half-moon icon)374- Do Not Disturb (red, no-entry icon)375- Invisible (gray)376- Custom Status (any emoji + text, expiring after 1h / 4h / today / never)377378**Active now grouping**: in the chat list, surface a horizontal scroll row of avatars with green dots showing "Active Now". Tap to start a chat.379380### 9. Message effects (iMessage-inspired)381382Visual effects on send: confetti, fireworks, slam, gentle, invisible ink, etc. Optional but DELIGHTFUL.383384**How to implement**:385- Long-press the send button (instead of tapping) to open an effects picker.386- The picker shows: Slam, Loud, Gentle, Invisible Ink, plus full-screen effects (Confetti, Balloons, Fireworks, Lasers, Heart, Spotlight).387- After pick, tap the send arrow to actually send.388- Use `CAEmitterLayer` for particle effects (confetti, fireworks). For more sophisticated effects (lasers, spotlight), use `SCNView` or a custom Metal shader.389- **Reduce Motion**: respect `UIAccessibility.isReduceMotionEnabled` — when true, skip the particle effects but keep the bubble itself (e.g., "Slam" sends as a normal bubble).390391**Performance**: limit emitter cells to ~120 particles. Cap effect duration at 3.5s. Always have an "skip" tap target.392393### 10. Stickers, GIFs, Genmoji394395**Sticker drawer**:396- Surfaced above the keyboard (replacing it) when user taps a sticker icon.397- Top: tab bar with recent stickers, then packs the user owns.398- Each sticker: 88pt × 88pt cell, 8pt gap.399- Tap to send (no preview confirm — fast).400- Long-press to peel the sticker (iOS 17+ peel effect) and drag it onto another part of the conversation (annotation pattern).401402**Genmoji** (iOS 18.2+, on-device on Apple Intelligence devices):403- In the keyboard, surface a "Create Genmoji" button.404- User types a prompt; on-device model generates 4 candidate emoji.405- User picks; the Genmoji is sent as a custom sticker.406- Recipients without Apple Intelligence see a PNG fallback.407408**GIF picker** (Giphy or Tenor):409- Tab in the sticker drawer or a dedicated icon.410- Search field at top; trending row below.411- Each GIF: tap to send, long-press for preview at full size.412413### Trays adopt the environment414415When a sticker picker, GIF browser, emoji panel, or any modal is presented from a themed chat (dark theme, custom wallpaper, brand-tinted), it should INHERIT that environment's color scheme — not snap to the system default. A sticker drawer over a dark chat should be dark. A confirmation over a Telegram custom-themed chat should pick up the theme. The visual environment follows the user across modal layers; sudden theme switches are spatially disorienting.416417```swift418.sheet(isPresented: $showStickers) {419 StickerPickerView()420 .preferredColorScheme(chatTheme.colorScheme)421 .tint(chatTheme.accent)422 .presentationBackground(chatTheme.surfaceColor)423}424```425426This is the design-with-taste "trays adapt to context" rule applied natively.427428### 11. Group chat creation429430The 3-step flow (Luma / Beside / Instagram pattern):431432**Step 1 — Pick people:**433- Top: search field with auto-suggest.434- Below: list of contacts, with a "Suggested" section at the top (frequency-based + recent).435- Selection: tap to add. Selected people appear as chips at the top of the search field (pill, 24pt tall, with avatar + name + × to remove).436- Limit and counter: "3 selected" tracker visible.437438**Step 2 — Customize:**439- Group emoji avatar (huge, ~80pt, in a circle): tap to pick from emoji or generate a Genmoji.440- Theme color picker: 6–8 horizontal swatches (Apple style: red, orange, yellow, green, mint, teal, blue, indigo, purple, pink, brown).441- Group name field (optional — default to comma-list of members).442- Description (optional).443444**Step 3 — Create:**445- "Create Group Chat" button — full-width pill, 56pt tall, accent color, semibold 17pt text.446- Haptic `.success` on creation.447- Animate the transition to the new chat with a smooth push from the right (standard navigation animation).448449### 12. Ephemeral / disappearing messages450451**Auto-delete timer** (Telegram pattern):452- Toggle in chat settings, OR a chat-wide setting "Auto-Delete Messages" with options: 24 hours, 7 days, 31 days, off.453- Once set, all messages in the chat get a small clock icon next to the timestamp.454- After the duration, messages fade out with `opacity 1 → 0` over 600ms and are deleted both locally and remotely.455456**View-once messages** (WhatsApp / Snapchat):457- Toggle a "view once" icon in the composer before sending an image.458- Recipient sees the message as a blurred placeholder; tap to view, opens full-screen.459- Once viewed, the message turns into "Opened" placeholder forever.460- Snapchat allows a 1–10 second view window (or infinity); WhatsApp allows ONE view, period.461462**Screenshot detection** (Snapchat pattern):463- Observe `UIApplication.userDidTakeScreenshotNotification`.464- When detected during a view-once or ephemeral message, send a system message to the other party: "📸 [User] took a screenshot".465- Bonus: also detect screen recording with `UIScreen.main.isCaptured` (KVO).466467### 13. End-to-end encryption indicators468469**Banner**:470- At the start of any new conversation, a centered system message: "🔒 Messages are end-to-end encrypted. No one outside this chat can read or listen to them. Tap to learn more."471- Visual: light background pill, no avatar, no timestamp, center-aligned text (12pt regular, secondary label).472- Tap to open a sheet explaining the encryption + safety number / verification flow.473474**Verification**:475- Safety number / verification code, displayed as a QR code + 60-digit number.476- Both parties can scan each other's QR to verify out-of-band. Animate the QR appearing with a Vision-framework subject-lifting style shimmer.477478**Lock icon in nav**:479- Subtle 12pt lock icon next to the chat title, tinted secondary.480- For unverified contacts: tinted system orange. Tap to see "Verify safety number?" prompt.481482### 14. Call experiences (audio & video)483484**Outgoing call**:485- Full-screen native CallKit UI (use `CXProvider` + `CXCallController`). This is critical — your call appears in the system call log, on the lock screen, with Bluetooth controls.486- Custom in-app pre-call screen: avatar centered (120pt circle), name below (28pt semibold), "Calling..." subtitle, animated wave rings emanating from the avatar (CAReplicatorLayer for the rings).487488**Incoming call**:489- Use CallKit's native incoming UI. Apple won't approve apps that try to override this.490- Pair with PushKit (VoIP push) for instant ring even when the app is suspended/killed.491492**In-call UI** (when user enters the call screen):493- Top: tiny FaceTime/audio waveform indicator.494- Center: large avatar OR self-camera PiP.495- Bottom: mute / video / speaker / end call (large red circle) — standard 4-button row, with 64pt tap targets.496497**Live Activity for ongoing call**:498- When the user backgrounds the app, surface a Live Activity (Dynamic Island on iPhone 14 Pro+):499 - Compact: speaker icon + call duration.500 - Expanded: avatar + name + duration + mute toggle + end call.501502```swift503import ActivityKit504505struct CallAttributes: ActivityAttributes {506 public struct ContentState: Codable, Hashable {507 var duration: TimeInterval508 var isMuted: Bool509 }510 var calleeName: String511 var calleeAvatarURL: URL512}513514let activity = try Activity<CallAttributes>.request(515 attributes: CallAttributes(calleeName: "Sam", calleeAvatarURL: ...),516 content: .init(state: .init(duration: 0, isMuted: false), staleDate: nil),517 pushType: .token518)519```520521### 15. Chat list (the conversation index)522523**Cell layout:**524- 72pt tall (vertical).525- 56pt avatar on the left, 12pt right margin to content.526- Content area: vertically centered.527 - Top: chat title (17pt semibold) + timestamp (right-aligned, 13pt regular, secondary).528 - Bottom: last message preview (15pt regular, secondary label, 1 line truncation) + unread badge (right-aligned, blue circle with white count).529- Right-edge: chevron (`Image(systemName: "chevron.right")`, 12pt, tertiary tint).530531**Avatar**:532- 56pt circle. For group chats: 4-grid mini-avatars OR custom emoji per the group's theme.533- Active-now indicator: 14pt green dot bottom-right with 2pt white border.534535**Sorting**:536- Default: most-recently-active first.537- Pinned chats: pin icon to the right of the title; pinned chats are always at the top of the list with a subtle background tint.538539**Unread state**:540- Bold text for title.541- Blue unread badge with count (system blue, 22pt height, dynamic width).542- For mention/reply: red @ badge instead of blue count.543544**Swipe actions:**545- Swipe-left: Archive, Mute, Delete (red, terminal).546- Swipe-right: Pin, Mark as Read/Unread.547- Use `swipeActions(edge:)` in SwiftUI or `UISwipeActionsConfiguration` in UIKit.548549**Search**:550- Pull down to reveal the search field.551- Recent searches above results.552- Search hits inline-highlight the matched substring in the preview.553554---555556## Animation curves cheat sheet557558| Surface | Curve | Notes |559| --- | --- | --- |560| Bubble send (composer → list) | `.spring(response: 0.42, dampingFraction: 0.78)` | Slight overshoot, settles fast |561| Bubble receive | `.spring(response: 0.4, dampingFraction: 0.85)` | Subtler than send — your message arriving should be calm |562| Composer pill grow | `.linear` | Multi-line growth — NEVER spring, fights the keyboard |563| Mic ↔ send swap | `.spring(response: 0.32, dampingFraction: 0.7)` | Snappy, definite |564| Reaction pill appear | `.spring(response: 0.36, dampingFraction: 0.72)` | Bouncy |565| Reaction emoji stagger | `0.04s delay each` + same spring | Cascading |566| Reaction land on bubble | `.spring(response: 0.32, dampingFraction: 0.65)` | Bouncier — "stuck" |567| Long-press menu open | `.spring(response: 0.36, dampingFraction: 0.78)` | Match iMessage |568| Reply chip appear | `.spring(response: 0.32, dampingFraction: 0.85)` | Calm |569| Typing dots (each cycle) | `.easeInOut(duration: 0.45)` repeat | Standard |570| Chat list cell swipe | `.spring(response: 0.32, dampingFraction: 0.82)` | iOS-native feel |571| Push to chat detail | system push | Don't override |572| Modal sheet (e.g., contact info) | `.spring(response: 0.45, dampingFraction: 0.86)` | Standard sheet |573| Call screen present | `.spring(response: 0.5, dampingFraction: 0.92)` | Slightly slower — gravity |574575**Reduce Motion**: replace springs with `.easeInOut(duration: 0.18)` crossfades, kill stagger, skip message effects.576577---578579## Haptics cheat sheet580581| Action | Generator | Style | Notes |582| --- | --- | --- | --- |583| Tap send | `UIImpactFeedbackGenerator` | `.light` | Prepare in `keyboardWillShow` |584| Message delivered (confirmation) | none | — | Don't haptic on every delivery — too much noise |585| Message read (first time per session) | `UIImpactFeedbackGenerator` | `.soft` | One per conversation per session |586| Receive new message (foreground) | `UIImpactFeedbackGenerator` | `.soft` | Optional — many users find this jarring; opt-in |587| Long-press to open menu | `UIImpactFeedbackGenerator` | `.medium` | Fire on threshold cross |588| Pick reaction | `UIImpactFeedbackGenerator` | `.medium` | On tap |589| Reaction lands on bubble | `UIImpactFeedbackGenerator` | `.soft` | Fire when animation finishes |590| Mic ↔ send swap | `UISelectionFeedbackGenerator` | `.selectionChanged` | Prepare on text changes |591| Voice record start | `UIImpactFeedbackGenerator` | `.medium` | On press-down |592| Voice record cancel (drag past threshold) | `UINotificationFeedbackGenerator` | `.warning` | One-shot |593| Voice record lock (drag up) | `UINotificationFeedbackGenerator` | `.success` | One-shot |594| Voice playback scrub | `UIImpactFeedbackGenerator` | `.soft` | Throttle to 100ms |595| Swipe-to-reply threshold | `UIImpactFeedbackGenerator` | `.soft` | On threshold cross only |596| Chat list swipe-action threshold | `UIImpactFeedbackGenerator` | `.soft` | On threshold |597| Delete confirmation | `UINotificationFeedbackGenerator` | `.warning` | When destructive action confirmed |598| Outgoing call initiated | `UIImpactFeedbackGenerator` | `.heavy` | One-shot |599600**Custom haptic patterns** for delight:601- **Send a "slam" effect message**: use CoreHaptics with a sharp transient + continuous decay (think: a small explosion).602- **Bubble lands after a long send animation**: a tiny "tick-tick" pattern (two `.soft` impacts 80ms apart).603604```swift605let engine = try CHHapticEngine()606try engine.start()607608let pattern = try CHHapticPattern(events: [609 CHHapticEvent(eventType: .hapticTransient, parameters: [610 .init(parameterID: .hapticIntensity, value: 1.0),611 .init(parameterID: .hapticSharpness, value: 0.8)612 ], relativeTime: 0),613 CHHapticEvent(eventType: .hapticContinuous, parameters: [614 .init(parameterID: .hapticIntensity, value: 0.4),615 .init(parameterID: .hapticSharpness, value: 0.2)616 ], relativeTime: 0.05, duration: 0.4)617], parameters: [])618619try engine.makePlayer(with: pattern).start(atTime: 0)620```621622---623624## Typography for chat UIs625626| Surface | Font | Weight | Size | Notes |627| --- | --- | --- | --- | --- |628| Bubble text | SF Pro | `.regular` | 17pt | Use Dynamic Type via `.body` |629| Solo emoji (≤3) | system emoji | — | 48pt | Auto-scale when message is emoji-only |630| Timestamp (inline group) | SF Pro | `.regular` | 11pt | Tracking 0.2, secondary label color |631| Read receipt | SF Pro | `.regular` | 11pt | Secondary label, beneath last sent bubble |632| Chat title (header) | SF Pro | `.semibold` | 17pt | Truncate with middle ellipsis if long |633| Chat subtitle (online status) | SF Pro | `.regular` | 13pt | Secondary label |634| Composer text input | SF Pro | `.regular` | 17pt | NEVER use a different size — matches bubbles for WYSIWYG |635| Chat list title | SF Pro | `.semibold` (unread) / `.regular` (read) | 17pt | |636| Chat list preview | SF Pro | `.regular` | 15pt | Secondary label, 1-line truncation |637| Chat list timestamp | SF Pro | `.regular` | 13pt | Tertiary label |638| Unread badge | SF Pro | `.semibold` | 13pt | White on system blue |639| System message (e.g., "X joined") | SF Pro | `.regular` | 13pt | Center-aligned, secondary label |640| In-call name | SF Pro | `.semibold` | 28pt | White on dark background |641| In-call duration | SF Mono | `.regular` | 17pt | Monospace digits — they don't jitter |642643**Always support Dynamic Type** via `.body`, `.callout`, `.caption`, etc. Test with extra-large accessibility sizes (AX5).644645---646647## Color & material648649- **Sent bubble**: app's accent color. For iMessage parity: `Color(.displayP3, red: 0.0, green: 0.48, blue: 1.0)` (iMessage blue). For green-bubble nostalgia: `Color(.displayP3, red: 0.21, green: 0.78, blue: 0.35)` (SMS green). **Always ship hand-coded colors as Display P3** — see `the-final-5-percent` §5 for the OKLCH-pick / P3-ship workflow that applies to every color in this skill.650- **Received bubble**: `Color(.tertiarySystemGroupedBackground)` (light), `Color(.systemGray5)` (dark).651- **Background of conversation**: `Color(.systemGroupedBackground)` (light), `Color(.systemBackground)` (dark). Telegram and WhatsApp use a subtle pattern/wallpaper — if you do this, make it OFF by default.652653**Push notification copywriting — the Bier rules.** The notification IS the user's first impression of your app, every day. Apps that nail push copy get re-engaged daily; apps that don't get muted, then deleted.654655Every notification must make the user feel something POSITIVE. Dopamine delivered, not nagging delivered:656657| ❌ Bad — nag | ✅ Good — value |658| --- | --- |659| "You haven't opened the app in 3 days" | "Sam just replied to your message" |660| "Don't lose your streak!" | "🔥 You're 7 days in. Keep going?" |661| "New activity" | "Alice tapped ❤️ on your photo" |662| "1 new message" | "Sam: are we still on for tonight?" |663| "Tap to see what you missed" | "Alice and 2 others shared photos in Family Chat" |664| "Update available" | "Voice messages are live. Try them i665666…(truncated)