diff --git a/Sources/Shared/Models/MessageReplySwipePolicy.swift b/Sources/Shared/Models/MessageReplySwipePolicy.swift index 5ae51c2..e8e23d8 100644 --- a/Sources/Shared/Models/MessageReplySwipePolicy.swift +++ b/Sources/Shared/Models/MessageReplySwipePolicy.swift @@ -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 diff --git a/Sources/Shared/UI/ChatView.swift b/Sources/Shared/UI/ChatView.swift index cc1fa47..fc628bd 100644 --- a/Sources/Shared/UI/ChatView.swift +++ b/Sources/Shared/UI/ChatView.swift @@ -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() +} diff --git a/Sources/Shared/UI/Components/MessageBubble.swift b/Sources/Shared/UI/Components/MessageBubble.swift index 7b135e6..c7d7d80 100644 --- a/Sources/Shared/UI/Components/MessageBubble.swift +++ b/Sources/Shared/UI/Components/MessageBubble.swift @@ -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 diff --git a/Sources/Shared/UI/Components/ReplyThreadOverlay.swift b/Sources/Shared/UI/Components/ReplyThreadOverlay.swift index 6f92683..309f326 100644 --- a/Sources/Shared/UI/Components/ReplyThreadOverlay.swift +++ b/Sources/Shared/UI/Components/ReplyThreadOverlay.swift @@ -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)) diff --git a/Sources/Shared/UI/MainTabView.swift b/Sources/Shared/UI/MainTabView.swift index 31a4c23..ba156c1 100644 --- a/Sources/Shared/UI/MainTabView.swift +++ b/Sources/Shared/UI/MainTabView.swift @@ -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) } diff --git a/Tests/MessageReplySwipeTests.swift b/Tests/MessageReplySwipeTests.swift index 07bf52d..57b0e9a 100644 --- a/Tests/MessageReplySwipeTests.swift +++ b/Tests/MessageReplySwipeTests.swift @@ -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 + ) + } }