updated up
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s

This commit is contained in:
wt
2026-09-06 01:23:27 +07:00
parent 1e2670903f
commit cf2bda9c54
6 changed files with 173 additions and 87 deletions
@@ -14,13 +14,12 @@ struct MessageReplySwipePolicy {
static let followDominance: CGFloat = 1.05
/// Whether this translation can lock the gesture as a horizontal swipe.
/// Only right-to-left (swipe left, negative width) is accepted, and the
/// lock is only available early: once the finger has travelled mostly
/// vertically the scroll view owns the touch and the swipe must never
/// activate for the rest of that gesture.
/// Both directions are accepted, and the lock is only available early:
/// once the finger has travelled mostly vertically the scroll view owns
/// the touch and the swipe must never activate for the rest of that
/// gesture.
static func canLock(_ translation: CGSize) -> Bool {
translation.width < 0
&& abs(translation.height) <= 24
abs(translation.height) <= 24
&& isHorizontal(translation, dominance: lockDominance)
&& abs(translation.width) >= activationDistance * 0.5
}
@@ -29,14 +28,15 @@ struct MessageReplySwipePolicy {
isHorizontal(translation, dominance: lockDominance)
}
/// Visual offset of the bubble: negative for a left swipe, positive for
/// a right swipe, zero while the movement is not clearly horizontal.
static func offset(locked: Bool, translation: CGSize) -> CGFloat {
guard translation.width < 0 else { return 0 }
let dominance = locked ? followDominance : lockDominance
guard isHorizontal(translation, dominance: dominance) else { return 0 }
let width = abs(translation.width)
guard width >= activationDistance else { return 0 }
let distance = min((width - activationDistance) * 0.72 + 12, maximumOffset)
return -distance
return translation.width < 0 ? -distance : distance
}
static func shouldReply(
@@ -47,7 +47,6 @@ struct MessageReplySwipePolicy {
let candidate = abs(predictedEndTranslation.width) > abs(translation.width)
? predictedEndTranslation
: translation
guard candidate.width < 0 else { return false }
let dominance = locked ? followDominance : lockDominance
return isHorizontal(candidate, dominance: dominance)
&& abs(candidate.width) >= triggerDistance
+101 -58
View File
@@ -170,53 +170,47 @@ struct ChatView: View {
}
} else {
ToolbarItem(placement: .primaryAction) {
HStack {
AvatarView(
conversation: liveConversation,
imageData: model.avatarData(for: conversation.jid),
size: 28
).padding(4)
if !conversation.isGroup {
Menu {
Button {
Task {
await model.startCall(
to: conversation.jid,
withVideo: false
)
}
} label: {
Label("Аудиозвонок", systemImage: "phone")
if !conversation.isGroup {
Menu {
Button {
Task {
await model.startCall(
to: conversation.jid,
withVideo: false
)
}
.disabled(!canStartCall)
.help("Аудиозвонок")
Button {
Task {
await model.startCall(
to: conversation.jid,
withVideo: true
)
}
} label: {
Label("Видеозвонок", systemImage: "video")
}
.disabled(!canStartCall)
.help("Видеозвонок")
encryptionMenu
} label: {
Image(systemName: "ellipsis.circle")
Label("Аудиозвонок", systemImage: "phone")
}
.disabled(!canStartCall)
}
if conversation.isGroup {
.help("Аудиозвонок")
Button {
showingGroupInfo = true
Task {
await model.startCall(
to: conversation.jid,
withVideo: true
)
}
} label: {
Image(systemName: "person.3")
Label("Видеозвонок", systemImage: "video")
}
.help("Информация о группе")
.disabled(!canStartCall)
.help("Видеозвонок")
encryptionMenu
} label: {
Image(systemName: "ellipsis.circle")
}
.menuIndicator(.hidden)
.disabled(!canStartCall)
}
if conversation.isGroup {
Button {
showingGroupInfo = true
} label: {
Image(systemName: "person.3")
}
.help("Информация о группе")
}
}
}
@@ -856,7 +850,7 @@ struct ChatView: View {
canUseComposer ? Color.accentColor : Color.secondary
)
.frame(width: 42, height: 42)
// .background(.ultraThinMaterial, in: Circle())
// .background(.ultraThinMaterial, in: Circle())
}
.buttonStyle(.plain)
.disabled(!canUseComposer)
@@ -995,24 +989,7 @@ struct ChatView: View {
}
private var chatNavigationTitle: some View {
VStack(spacing: 0) {
Text(liveConversation.displayName)
.font(.headline)
.lineLimit(1)
if let typingText = model.typingText(for: liveConversation) {
Text(typingText)
.font(.caption2.weight(.medium))
.foregroundStyle(.tint)
.lineLimit(1)
.transition(.opacity)
}
}
.animation(
.easeInOut(duration: 0.16),
value: model.typingText(for: liveConversation)
)
.accessibilityElement(children: .combine)
.padding()
ChatNavigationTitle(model: model, conversation: conversation)
}
private var canSendToConversation: Bool {
@@ -2653,3 +2630,69 @@ private func writePickedImageData(_ data: Data) throws -> URL {
#Preview {
PreviewSupport.chatPreview()
}
/// Extracted chat navigation title: avatar, display name and the transient
/// «печатает…» line. Kept as its own view so its layout can be previewed on
/// its own without opening a full chat.
private struct ChatNavigationTitle: View {
@ObservedObject var model: AppModel
let conversation: Conversation
private var liveConversation: Conversation {
model.conversations.first(where: { $0.jid == conversation.jid })
?? conversation
}
var body: some View {
Button {
} label: {
titleContent
}
.buttonStyle(.plain)
}
private var titleContent: some View {
HStack {
AvatarView(
conversation: liveConversation,
imageData: model.avatarData(for: conversation.jid),
size: 25
)
.padding(4)
VStack(spacing: 0) {
Text(liveConversation.displayName)
.font(.headline)
.lineLimit(1)
if let typingText = model.typingText(for: liveConversation) {
Text(typingText)
.font(.caption2.weight(.medium))
.foregroundStyle(.tint)
.lineLimit(1)
.transition(.opacity)
}
}
.padding(.top, 5)
.padding(.bottom, 5)
.padding(.trailing, 15)
.animation(
.easeInOut(duration: 0.16),
value: model.typingText(for: liveConversation)
)
.accessibilityElement(children: .combine)
}
#if os(iOS)
.glassEffect()
#endif
}
}
#Preview("Заголовок чата") {
ChatNavigationTitle(
model: PreviewSupport.model,
conversation: PreviewSupport.conversation(
displayName: "Алиса",
isOnline: true
)
)
.padding()
}
@@ -37,7 +37,6 @@ struct MessageBubble: View {
.offset(x: replySwipeOffset)
}
.frame(maxWidth: .infinity)
.background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
.contentShape(Rectangle())
.accessibilityIdentifier("bubble-\(message.clientID)")
.onTapGesture {
@@ -48,10 +47,6 @@ struct MessageBubble: View {
.vertical,
message.kind == .photo || message.kind == .video ? 7 : 2
)
.simultaneousGesture(
replySwipeGesture,
including: !isSelectionMode && message.canBeRepliedTo ? .gesture : .none
)
.onChange(of: isSelectionMode) { _, selecting in
guard selecting else { return }
replySwipeOffset = 0
@@ -175,6 +170,7 @@ struct MessageBubble: View {
)
.font(.system(size: 19, weight: .semibold))
.foregroundStyle(Color.accentColor)
.scaleEffect(x: replySwipeOffset > 0 ? -1 : 1, y: 1)
.scaleEffect(
CGFloat(0.8) + CGFloat(0.2) * MessageReplySwipePolicy.progress(for: replySwipeOffset)
)
@@ -232,7 +228,7 @@ struct MessageBubble: View {
.onEnded { value in
// The lock is a hint, not a requirement: a fast flick may
// deliver no intermediate frame that passes canLock, yet its
// end state is still a clear leftward swipe. scrollOwnsGesture
// end state is still a clear horizontal swipe. scrollOwnsGesture
// alone protects the timeline from reply activation.
let shouldReply =
!scrollOwnsGesture
@@ -277,7 +273,15 @@ struct MessageBubble: View {
.foregroundStyle(senderColor)
.padding(.horizontal, 8)
}
// The drag lives on the bubble itself so a swipe can only start
// a reply when it begins on the bubble — not on the empty
// space beside it, the sender name, or the metadata row.
messageContent
.contentShape(Rectangle())
.simultaneousGesture(
replySwipeGesture,
including: !isSelectionMode && message.canBeRepliedTo ? .gesture : .none
)
if !reactionSummaries.isEmpty {
reactionStrip
@@ -71,12 +71,13 @@ struct ReplyThreadOverlay: View {
Button(action: onDismiss) {
Image(systemName: "xmark")
.font(.system(size: 13, weight: .bold))
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white.opacity(0.82))
.frame(width: 32, height: 32)
.background(.white.opacity(0.12), in: Circle())
.padding(7)
}
.buttonStyle(.plain)
.buttonStyle(.glass)
.buttonBorderShape(.circle)
.background(.clear)
.keyboardShortcut(.cancelAction)
.accessibilityLabel("Закрыть ветку ответов")
.padding(.top, max(12, geometry.safeAreaInsets.top + 6))
-4
View File
@@ -54,10 +54,6 @@ private struct SearchField: View {
.padding(.horizontal, 10)
.padding(.vertical, 7)
.glassEffect()
// .background(
// Color.secondary.opacity(0.14),
// in: RoundedRectangle(cornerRadius: 10)
// )
.padding(.horizontal, 12)
.padding(.vertical, 6)
}
+49 -6
View File
@@ -15,17 +15,17 @@ final class MessageReplySwipeTests: XCTestCase {
)
}
func testLeftToRightSwipeNeverReplies() {
XCTAssertFalse(MessageReplySwipePolicy.canLock(CGSize(width: 24, height: 10)))
XCTAssertEqual(
func testLeftToRightSwipeReplies() {
XCTAssertTrue(MessageReplySwipePolicy.canLock(CGSize(width: 24, height: 10)))
XCTAssertGreaterThan(
MessageReplySwipePolicy.offset(locked: false, translation: CGSize(width: 70, height: 8)),
0
)
XCTAssertEqual(
XCTAssertGreaterThan(
MessageReplySwipePolicy.offset(locked: true, translation: CGSize(width: 70, height: 8)),
0
)
XCTAssertFalse(MessageReplySwipePolicy.shouldReply(
XCTAssertTrue(MessageReplySwipePolicy.shouldReply(
locked: false,
translation: CGSize(width: 70, height: 8),
predictedEndTranslation: CGSize(width: 72, height: 9)
@@ -102,9 +102,10 @@ final class MessageReplySwipeTests: XCTestCase {
func testCanLockRequiresClearHorizontalStart() {
XCTAssertTrue(MessageReplySwipePolicy.canLock(CGSize(width: -24, height: 10)))
XCTAssertTrue(MessageReplySwipePolicy.canLock(CGSize(width: 24, height: 10)))
XCTAssertFalse(MessageReplySwipePolicy.canLock(CGSize(width: -30, height: 20)))
XCTAssertFalse(MessageReplySwipePolicy.canLock(CGSize(width: -8, height: 5)))
XCTAssertFalse(MessageReplySwipePolicy.canLock(CGSize(width: 40, height: 4)))
XCTAssertTrue(MessageReplySwipePolicy.canLock(CGSize(width: 40, height: 4)))
}
func testCanLockIsUnavailableOnceTheGestureTurnsVertical() {
@@ -127,5 +128,47 @@ final class MessageReplySwipeTests: XCTestCase {
0
)
}
func testRightwardDiagonalScrollDoesNotReply() {
XCTAssertFalse(MessageReplySwipePolicy.shouldReply(
locked: false,
translation: CGSize(width: 54, height: 44),
predictedEndTranslation: CGSize(width: 76, height: 58)
))
XCTAssertEqual(
MessageReplySwipePolicy.offset(locked: false, translation: CGSize(width: 54, height: 44)),
0
)
}
func testRightwardVisualOffsetIsCapped() {
XCTAssertEqual(
MessageReplySwipePolicy.offset(locked: true, translation: CGSize(width: 500, height: 0)),
MessageReplySwipePolicy.maximumOffset
)
}
func testRightwardIndicatorStaysHiddenUntilActivationDistance() {
XCTAssertEqual(
MessageReplySwipePolicy.offset(locked: false, translation: CGSize(width: 20, height: 0)),
0
)
XCTAssertGreaterThan(
MessageReplySwipePolicy.offset(locked: false, translation: CGSize(width: 24, height: 0)),
0
)
}
func testRightwardLockedSwipeToleratesVerticalDrift() {
XCTAssertTrue(MessageReplySwipePolicy.shouldReply(
locked: true,
translation: CGSize(width: 70, height: 60),
predictedEndTranslation: CGSize(width: 70, height: 60)
))
XCTAssertGreaterThan(
MessageReplySwipePolicy.offset(locked: true, translation: CGSize(width: 70, height: 60)),
0
)
}
}