Свайп-реплай: Telegram-parity эффекты и иконка

This commit is contained in:
2026-03-29 16:50:59 +05:00
parent 3b26176875
commit 5e89e97301
10 changed files with 335 additions and 84 deletions

View File

@@ -231,7 +231,6 @@
LA000000C2F8D22220092AD05 /* RosettaLiveActivityWidget */ = {
isa = PBXGroup;
children = (
LA000000D2F8D22220092AD05 /* RosettaLiveActivityWidgetBundle.swift */,
LA000000E2F8D22220092AD05 /* CallLiveActivity.swift */,
LA000000F2F8D22220092AD05 /* CallActivityAttributes.swift */,
);
@@ -442,7 +441,6 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
LA00000102F8D22220092AD05 /* RosettaLiveActivityWidgetBundle.swift in Sources */,
LA00000112F8D22220092AD05 /* CallLiveActivity.swift in Sources */,
LA00000122F8D22220092AD05 /* CallActivityAttributes.swift in Sources */,
);
@@ -771,7 +769,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = RosettaLiveActivityWidget/RosettaLiveActivityWidget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = QN8Z263QGX;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = RosettaLiveActivityWidget/Info.plist;
@@ -781,7 +779,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.2.6;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev.LiveActivityWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -798,7 +796,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = RosettaLiveActivityWidget/RosettaLiveActivityWidget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = QN8Z263QGX;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = RosettaLiveActivityWidget/Info.plist;
@@ -808,7 +806,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.2.6;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev.LiveActivityWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;

View File

@@ -12,12 +12,12 @@
<key>RosettaLiveActivityWidget.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
<integer>1</integer>
</dict>
<key>RosettaNotificationService.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
<integer>2</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>

View File

@@ -2,11 +2,11 @@ import ActivityKit
import Foundation
struct CallActivityAttributes: ActivityAttributes {
/// Fixed data set when the activity starts.
let peerName: String
let peerPublicKey: String
let avatarData: Data?
let colorIndex: Int
/// Dynamic state updated during the call.
struct ContentState: Codable, Hashable {
let durationSec: Int
let isActive: Bool

View File

@@ -325,15 +325,27 @@ final class CallManager: NSObject, ObservableObject {
// MARK: - Live Activity
func startLiveActivity() {
// End any stale activities from previous calls / schema changes
for activity in Activity<CallActivityAttributes>.activities {
Task { await activity.end(nil, dismissalPolicy: .immediate) }
}
let authInfo = ActivityAuthorizationInfo()
print("[Call] LiveActivity: areActivitiesEnabled=\(authInfo.areActivitiesEnabled), frequentPushesEnabled=\(authInfo.frequentPushesEnabled)")
guard authInfo.areActivitiesEnabled else {
print("[Call] LiveActivity DISABLED by user settings")
return
}
// Load peer avatar thumbnail for Live Activity
var avatarJpeg: Data?
if let avatar = AvatarRepository.shared.loadAvatar(publicKey: uiState.peerPublicKey) {
avatarJpeg = avatar.jpegData(compressionQuality: 0.5)
}
let attributes = CallActivityAttributes(
peerName: uiState.displayName,
peerPublicKey: uiState.peerPublicKey
peerPublicKey: uiState.peerPublicKey,
avatarData: avatarJpeg,
colorIndex: RosettaColors.avatarColorIndex(for: uiState.peerTitle, publicKey: uiState.peerPublicKey)
)
let state = CallActivityAttributes.ContentState(
durationSec: uiState.durationSec,

View File

@@ -15,18 +15,11 @@ struct ActiveCallOverlayView: View {
}
private var peerInitials: String {
let name = state.peerTitle.isEmpty ? state.peerUsername : state.peerTitle
guard !name.isEmpty else { return "?" }
let parts = name.split(separator: " ")
if parts.count >= 2 {
return "\(parts[0].prefix(1))\(parts[1].prefix(1))".uppercased()
}
return String(name.prefix(2)).uppercased()
RosettaColors.initials(name: state.peerTitle.isEmpty ? state.peerUsername : state.peerTitle, publicKey: state.peerPublicKey)
}
private var peerColorIndex: Int {
guard !state.peerPublicKey.isEmpty else { return 0 }
return abs(state.peerPublicKey.hashValue) % 7
RosettaColors.avatarColorIndex(for: state.peerTitle, publicKey: state.peerPublicKey)
}
var body: some View {

View File

@@ -58,7 +58,8 @@ struct MessageCellView: View, Equatable {
}
}
.modifier(ConditionalSwipeToReply(
enabled: !isSavedMessages && !isSystemAccount,
enabled: !isSavedMessages && !isSystemAccount
&& !message.attachments.contains(where: { $0.type == .avatar || $0.type == .call || $0.type == .messages }),
onReply: { actions.onReply(message) }
))
.overlay {
@@ -544,11 +545,16 @@ struct MessageCellView: View, Equatable {
private func bubbleActions(for message: ChatMessage) -> [BubbleContextAction] {
var result: [BubbleContextAction] = []
// Avatars, calls, and forwarded messages cannot be replied to or forwarded
let isAvatarOrForwarded = message.attachments.contains(where: { $0.type == .avatar || $0.type == .call || $0.type == .messages })
if !isAvatarOrForwarded {
result.append(BubbleContextAction(
title: "Reply",
image: UIImage(systemName: "arrowshape.turn.up.left"),
role: []
) { actions.onReply(message) })
}
result.append(BubbleContextAction(
title: "Copy",
@@ -556,7 +562,7 @@ struct MessageCellView: View, Equatable {
role: []
) { actions.onCopy(message.text) })
if !message.attachments.contains(where: { $0.type == .avatar }) {
if !isAvatarOrForwarded {
result.append(BubbleContextAction(
title: "Forward",
image: UIImage(systemName: "arrowshape.turn.up.right"),

View File

@@ -41,6 +41,29 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private static let mediaClockMinImage = StatusIconRenderer.makeClockMinImage(color: mediaMetaColor)
private static let errorIcon = StatusIconRenderer.makeErrorIcon(color: .systemRed)
private static let maxVisiblePhotoTiles = 5
// Telegram-exact reply arrow rendered from SVG path (matches SwiftUI TelegramIconPath.replyArrow).
// Rendered at DISPLAY size (20×20) so UIImageView never upscales the raster.
private static let telegramReplyArrowImage: UIImage = {
let viewBox = CGSize(width: 16, height: 13)
let canvasSize = CGSize(width: 20, height: 20) // match replyIconView frame
let scale = UIScreen.main.scale
UIGraphicsBeginImageContextWithOptions(canvasSize, false, scale)
guard let ctx = UIGraphicsGetCurrentContext() else { return UIImage() }
var parser = SVGPathParser(pathData: TelegramIconPath.replyArrow)
let cgPath = parser.parse()
// Aspect-fit path into canvas, centered
let fitScale = min(canvasSize.width / viewBox.width, canvasSize.height / viewBox.height)
let scaledW = viewBox.width * fitScale
let scaledH = viewBox.height * fitScale
ctx.translateBy(x: (canvasSize.width - scaledW) / 2, y: (canvasSize.height - scaledH) / 2)
ctx.scaleBy(x: fitScale, y: fitScale)
ctx.addPath(cgPath)
ctx.setFillColor(UIColor.white.cgColor)
ctx.fillPath()
let image = UIGraphicsGetImageFromCurrentImageContext() ?? UIImage()
UIGraphicsEndImageContext()
return image.withRenderingMode(.alwaysOriginal)
}()
// Telegram-exact stretchable bubble images (raster, not vector only way to get exact tail)
private static let bubbleImages = BubbleImageFactory.generate(
outgoingColor: outgoingColor,
@@ -111,7 +134,12 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private let forwardNameLabel = UILabel()
// Swipe-to-reply
private let replyCircleView = UIView()
private let replyIconView = UIImageView()
private var hasTriggeredSwipeHaptic = false
private let swipeHaptic = UIImpactFeedbackGenerator(style: .heavy)
/// Global X of the first touch reject if near left screen edge (back gesture zone).
private var swipeStartX: CGFloat?
private let deliveryFailedButton = UIButton(type: .custom)
// MARK: - State
@@ -145,6 +173,9 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private func setupViews() {
contentView.backgroundColor = .clear
backgroundColor = .clear
// Allow reply swipe icon to extend beyond cell bounds
contentView.clipsToBounds = false
clipsToBounds = false
contentView.transform = CGAffineTransform(scaleX: 1, y: -1) // inverted scroll flip
// Bubble CAShapeLayer for shadow (index 0), then outline, then raster image on top
@@ -348,10 +379,14 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
forwardNameLabel.textColor = .white
bubbleView.addSubview(forwardNameLabel)
// Swipe reply icon
replyIconView.image = UIImage(systemName: "arrowshape.turn.up.left.fill")?
.withRenderingMode(.alwaysTemplate)
replyIconView.tintColor = UIColor.white.withAlphaComponent(0.5)
// Swipe reply icon circle + Telegram-exact arrow (same vector as SwiftUI SwipeToReplyModifier)
replyCircleView.backgroundColor = UIColor.white.withAlphaComponent(0.12)
replyCircleView.layer.cornerRadius = 17 // 34pt / 2
replyCircleView.alpha = 0
contentView.addSubview(replyCircleView)
replyIconView.image = Self.telegramReplyArrowImage
replyIconView.contentMode = .scaleAspectFit
replyIconView.alpha = 0
contentView.addSubview(replyIconView)
@@ -633,7 +668,6 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
let cellW = contentView.bounds.width
let tailProtrusion = Self.bubbleMetrics.tailProtrusion
let tailW: CGFloat = layout.hasTail ? tailProtrusion : 0
// Rule 2: Tail reserve (6pt) + margin (2pt) strict vertical body alignment
let bubbleX: CGFloat
@@ -838,14 +872,14 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
deliveryFailedButton.alpha = 0
}
// Reply icon (for swipe gesture) use actual bubbleView frame
replyIconView.frame = CGRect(
x: layout.isOutgoing
? bubbleView.frame.minX - 30
: bubbleView.frame.maxX + tailW + 8,
y: bubbleView.frame.midY - 10,
width: 20, height: 20
)
// Reply icon (for swipe gesture) positioned behind bubble's trailing edge.
// Starts hidden (alpha=0, scale=0). As bubble slides left via transform,
// the icon is revealed in the gap between shifted bubble and original position.
let replyIconDiameter: CGFloat = 34
let replyIconX = bubbleView.frame.maxX - replyIconDiameter
let replyIconY = bubbleView.frame.midY - replyIconDiameter / 2
replyCircleView.frame = CGRect(x: replyIconX, y: replyIconY, width: replyIconDiameter, height: replyIconDiameter)
replyIconView.frame = CGRect(x: replyIconX + 7, y: replyIconY + 7, width: 20, height: 20)
}
private static func formattedDuration(seconds: Int) -> String {
@@ -951,12 +985,16 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
actions.onCopy(message.text)
})
}
// Avatars, calls, and forwarded messages cannot be replied to or forwarded
let isAvatarOrForwarded = message.attachments.contains(where: { $0.type == .avatar || $0.type == .call || $0.type == .messages })
if !isAvatarOrForwarded {
items.append(UIAction(title: "Reply", image: UIImage(systemName: "arrowshape.turn.up.left")) { _ in
actions.onReply(message)
})
items.append(UIAction(title: "Forward", image: UIImage(systemName: "arrowshape.turn.up.right")) { _ in
actions.onForward(message)
})
}
items.append(UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { _ in
actions.onDelete(message)
})
@@ -967,28 +1005,81 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
// MARK: - Swipe to Reply
@objc private func handleSwipe(_ gesture: UIPanGestureRecognizer) {
// Block swipe on avatar, call, and forwarded-message attachments
let isReplyBlocked = message?.attachments.contains(where: { $0.type == .avatar || $0.type == .call || $0.type == .messages }) ?? false
if isReplyBlocked { return }
let translation = gesture.translation(in: contentView)
let isOutgoing = currentLayout?.isOutgoing ?? false
let threshold: CGFloat = 55
let elasticCap: CGFloat = 85 // match SwiftUI SwipeToReplyModifier
let backGestureEdge: CGFloat = 40
switch gesture.state {
case .began:
// Record start position reject if near left screen edge (iOS back gesture zone)
let startPoint = gesture.location(in: contentView.window)
swipeStartX = startPoint.x
// Pre-warm haptic engine for instant response at threshold
swipeHaptic.prepare()
case .changed:
let dx = isOutgoing ? min(translation.x, 0) : max(translation.x, 0)
let clamped = isOutgoing ? max(dx, -60) : min(dx, 60)
// Reject gestures from back gesture zone (left 40pt)
if let startX = swipeStartX, startX < backGestureEdge { return }
// Telegram: ALL messages swipe LEFT
let raw = min(translation.x, 0)
guard raw < 0 else { return }
// Elastic resistance past cap (Telegram rubber-band)
let absRaw = abs(raw)
let clamped: CGFloat
if absRaw > elasticCap {
clamped = -(elasticCap + (absRaw - elasticCap) * 0.15)
} else {
clamped = raw
}
bubbleView.transform = CGAffineTransform(translationX: clamped, y: 0)
let progress = min(abs(clamped) / 50, 1)
// Icon progress: fade in from 4pt to threshold
let absClamped = abs(clamped)
let progress: CGFloat = absClamped > 4 ? min((absClamped - 4) / (threshold - 4), 1) : 0
replyCircleView.alpha = progress
replyCircleView.transform = CGAffineTransform(scaleX: progress, y: progress)
replyIconView.alpha = progress
replyIconView.transform = CGAffineTransform(scaleX: progress, y: progress)
// Haptic at threshold crossing (once per gesture, pre-prepared)
if absClamped >= threshold, !hasTriggeredSwipeHaptic {
hasTriggeredSwipeHaptic = true
swipeHaptic.impactOccurred()
}
case .ended, .cancelled:
if abs(translation.x) > 50, let message, let actions {
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
let shouldReply = abs(translation.x) >= threshold
if shouldReply, let message, let actions {
actions.onReply(message)
}
UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseOut) {
hasTriggeredSwipeHaptic = false
swipeStartX = nil
// Velocity-aware spring (Telegram passes swipe velocity for natural spring-back)
let velocity = gesture.velocity(in: contentView)
let currentOffset = bubbleView.transform.tx
let relativeVx: CGFloat = currentOffset != 0 ? velocity.x / abs(currentOffset) : 0
let initialVelocity = CGVector(dx: relativeVx, dy: 0)
let timing = UISpringTimingParameters(mass: 1, stiffness: 386, damping: 33.4, initialVelocity: initialVelocity)
let animator = UIViewPropertyAnimator(duration: 0, timingParameters: timing)
animator.addAnimations {
self.bubbleView.transform = .identity
self.replyCircleView.alpha = 0
self.replyCircleView.transform = .identity
self.replyIconView.alpha = 0
self.replyIconView.transform = .identity
}
animator.startAnimation()
default:
break
}
@@ -1788,7 +1879,12 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
forwardNameLabel.isHidden = true
photoContainer.isHidden = true
bubbleView.transform = .identity
replyCircleView.alpha = 0
replyCircleView.transform = .identity
replyIconView.alpha = 0
replyIconView.transform = .identity
hasTriggeredSwipeHaptic = false
swipeStartX = nil
deliveryFailedButton.isHidden = true
deliveryFailedButton.alpha = 0
isDeliveryFailedVisible = false
@@ -1801,7 +1897,8 @@ extension NativeMessageCell: UIGestureRecognizerDelegate {
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true }
let velocity = pan.velocity(in: contentView)
return abs(velocity.x) > abs(velocity.y) * 1.5
// Telegram: only left swipe (negative velocity.x), clear horizontal dominance
return velocity.x < 0 && abs(velocity.x) > abs(velocity.y) * 2.0
}
}

View File

@@ -2,11 +2,11 @@ import ActivityKit
import Foundation
struct CallActivityAttributes: ActivityAttributes {
/// Fixed data set when the activity starts.
let peerName: String
let peerPublicKey: String
let avatarData: Data?
let colorIndex: Int
/// Dynamic state updated during the call.
struct ContentState: Codable, Hashable {
let durationSec: Int
let isActive: Bool

View File

@@ -2,36 +2,191 @@ import ActivityKit
import SwiftUI
import WidgetKit
// Mantine v8 avatar palette (11 colors) desktop parity
private let mantineAvatarTints: [Color] = [
Color(red: 0.133, green: 0.545, blue: 0.902), // blue #228be6
Color(red: 0.082, green: 0.667, blue: 0.749), // cyan #15aabf
Color(red: 0.745, green: 0.294, blue: 0.859), // grape #be4bdb
Color(red: 0.251, green: 0.753, blue: 0.341), // green #40c057
Color(red: 0.298, green: 0.431, blue: 0.961), // indigo #4c6ef5
Color(red: 0.510, green: 0.788, blue: 0.118), // lime #82c91e
Color(red: 0.992, green: 0.494, blue: 0.078), // orange #fd7e14
Color(red: 0.902, green: 0.286, blue: 0.502), // pink #e64980
Color(red: 0.980, green: 0.322, blue: 0.322), // red #fa5252
Color(red: 0.071, green: 0.722, blue: 0.525), // teal #12b886
Color(red: 0.475, green: 0.314, blue: 0.949), // violet #7950f2
]
@main
struct CallLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: CallActivityAttributes.self) { context in
// Lock Screen
Text("Rosetta Call: \(context.attributes.peerName)")
// MARK: - Lock Screen Banner
HStack(spacing: 14) {
avatarView(context: context, size: 48, fontSize: 18)
VStack(alignment: .leading, spacing: 3) {
Text(context.attributes.peerName)
.font(.system(size: 17, weight: .semibold))
.foregroundColor(.white)
.padding()
.activityBackgroundTint(.black)
.lineLimit(1)
HStack(spacing: 6) {
Image(systemName: "lock.fill")
.font(.system(size: 10))
.foregroundColor(Color(white: 0.45))
if context.state.isActive {
Text(duration(context.state.durationSec))
.font(.system(size: 14, weight: .medium).monospacedDigit())
.foregroundColor(Color(white: 0.55))
} else {
Text("Connecting...")
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color(white: 0.55))
}
if context.state.isMuted {
Image(systemName: "mic.slash.fill")
.font(.system(size: 11))
.foregroundColor(.red)
}
}
}
Spacer()
ZStack {
Circle().fill(Color.red)
Image(systemName: "phone.down.fill")
.font(.system(size: 15, weight: .semibold))
.foregroundColor(.white)
}
.frame(width: 40, height: 40)
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
.activityBackgroundTint(Color(white: 0.08))
} dynamicIsland: { context in
DynamicIsland {
// MARK: - Expanded
DynamicIslandExpandedRegion(.leading) {
avatarView(context: context, size: 44, fontSize: 15)
.padding(.leading, 6)
.padding(.top, 4)
}
DynamicIslandExpandedRegion(.trailing) {
VStack(spacing: 2) {
if context.state.isActive {
Text(duration(context.state.durationSec))
.font(.system(size: 16, weight: .bold).monospacedDigit())
.foregroundColor(.green)
} else {
Text("...")
.font(.system(size: 16, weight: .bold))
.foregroundColor(.orange)
}
if context.state.isMuted {
Image(systemName: "mic.slash.fill")
.font(.system(size: 11))
.foregroundColor(.red)
}
}
.padding(.trailing, 6)
.padding(.top, 4)
}
DynamicIslandExpandedRegion(.center) {
Text(context.attributes.peerName)
.font(.system(size: 16, weight: .semibold))
.foregroundColor(.white)
.lineLimit(1)
.padding(.top, 4)
}
DynamicIslandExpandedRegion(.bottom) {
Text("Encrypted call")
.font(.caption2)
.foregroundColor(.gray)
HStack(spacing: 5) {
Circle()
.fill(context.state.isActive ? Color.green : Color.orange)
.frame(width: 6, height: 6)
Image(systemName: "lock.fill")
.font(.system(size: 9))
.foregroundColor(Color(white: 0.4))
Text("Rosetta · E2E encrypted")
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color(white: 0.4))
}
.padding(.top, 4)
}
} compactLeading: {
Image(systemName: "phone.fill")
.foregroundColor(.green)
avatarView(context: context, size: 26, fontSize: 10)
} compactTrailing: {
Text("Call")
.font(.caption2)
if context.state.isActive {
Text(duration(context.state.durationSec))
.font(.system(size: 13, weight: .semibold).monospacedDigit())
.foregroundColor(.green)
} else {
Image(systemName: "phone.fill")
.font(.system(size: 12))
.foregroundColor(.green)
}
} minimal: {
Image(systemName: "phone.fill")
.font(.system(size: 11))
.foregroundColor(.green)
}
}
}
// MARK: - Avatar
@ViewBuilder
private func avatarView(context: ActivityViewContext<CallActivityAttributes>, size: CGFloat, fontSize: CGFloat) -> some View {
if let data = context.attributes.avatarData,
let uiImage = UIImage(data: data) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.frame(width: size, height: size)
.clipShape(Circle())
} else {
let idx = context.attributes.colorIndex
let color = mantineAvatarTints[idx < mantineAvatarTints.count ? idx : 0]
ZStack {
Circle().fill(color)
Text(initials(from: context.attributes.peerName, publicKey: context.attributes.peerPublicKey))
.font(.system(size: fontSize, weight: .bold))
.foregroundColor(.white)
}
.frame(width: size, height: size)
}
}
// MARK: - Helpers
private func duration(_ sec: Int) -> String {
String(format: "%d:%02d", sec / 60, sec % 60)
}
private func initials(from name: String, publicKey: String) -> String {
let words = name.trimmingCharacters(in: .whitespaces)
.split(whereSeparator: { $0.isWhitespace })
.filter { !$0.isEmpty }
switch words.count {
case 0:
return publicKey.isEmpty ? "?" : String(publicKey.prefix(2)).uppercased()
case 1:
return String(words[0].prefix(2)).uppercased()
default:
let first = words[0].first.map(String.init) ?? ""
let second = words[1].first.map(String.init) ?? ""
return (first + second).uppercased()
}
}
}

View File

@@ -1,10 +0,0 @@
import ActivityKit
import SwiftUI
import WidgetKit
@main
struct RosettaLiveActivityWidgetBundle: WidgetBundle {
var body: some Widget {
CallLiveActivity()
}
}