updated up
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user