updated up
This commit is contained in:
1 parent
1e2670903f
commit
cf2bda9c54
6 files changed
+173
-87
No files matched your search
@@ -14,13 +14,12 @@ struct MessageReplySwipePolicy {
|
|||||||
static let followDominance: CGFloat = 1.05
|
static let followDominance: CGFloat = 1.05
|
||||||
|
|
||||||
/// Whether this translation can lock the gesture as a horizontal swipe.
|
/// Whether this translation can lock the gesture as a horizontal swipe.
|
||||||
/// Only right-to-left (swipe left, negative width) is accepted, and the
|
/// Both directions are accepted, and the lock is only available early:
|
||||||
/// lock is only available early: once the finger has travelled mostly
|
/// once the finger has travelled mostly vertically the scroll view owns
|
||||||
/// vertically the scroll view owns the touch and the swipe must never
|
/// the touch and the swipe must never activate for the rest of that
|
||||||
/// activate for the rest of that gesture.
|
/// gesture.
|
||||||
static func canLock(_ translation: CGSize) -> Bool {
|
static func canLock(_ translation: CGSize) -> Bool {
|
||||||
translation.width < 0
|
abs(translation.height) <= 24
|
||||||
&& abs(translation.height) <= 24
|
|
||||||
&& isHorizontal(translation, dominance: lockDominance)
|
&& isHorizontal(translation, dominance: lockDominance)
|
||||||
&& abs(translation.width) >= activationDistance * 0.5
|
&& abs(translation.width) >= activationDistance * 0.5
|
||||||
}
|
}
|
||||||
@@ -29,14 +28,15 @@ struct MessageReplySwipePolicy {
|
|||||||
isHorizontal(translation, dominance: lockDominance)
|
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 {
|
static func offset(locked: Bool, translation: CGSize) -> CGFloat {
|
||||||
guard translation.width < 0 else { return 0 }
|
|
||||||
let dominance = locked ? followDominance : lockDominance
|
let dominance = locked ? followDominance : lockDominance
|
||||||
guard isHorizontal(translation, dominance: dominance) else { return 0 }
|
guard isHorizontal(translation, dominance: dominance) else { return 0 }
|
||||||
let width = abs(translation.width)
|
let width = abs(translation.width)
|
||||||
guard width >= activationDistance else { return 0 }
|
guard width >= activationDistance else { return 0 }
|
||||||
let distance = min((width - activationDistance) * 0.72 + 12, maximumOffset)
|
let distance = min((width - activationDistance) * 0.72 + 12, maximumOffset)
|
||||||
return -distance
|
return translation.width < 0 ? -distance : distance
|
||||||
}
|
}
|
||||||
|
|
||||||
static func shouldReply(
|
static func shouldReply(
|
||||||
@@ -47,7 +47,6 @@ struct MessageReplySwipePolicy {
|
|||||||
let candidate = abs(predictedEndTranslation.width) > abs(translation.width)
|
let candidate = abs(predictedEndTranslation.width) > abs(translation.width)
|
||||||
? predictedEndTranslation
|
? predictedEndTranslation
|
||||||
: translation
|
: translation
|
||||||
guard candidate.width < 0 else { return false }
|
|
||||||
let dominance = locked ? followDominance : lockDominance
|
let dominance = locked ? followDominance : lockDominance
|
||||||
return isHorizontal(candidate, dominance: dominance)
|
return isHorizontal(candidate, dominance: dominance)
|
||||||
&& abs(candidate.width) >= triggerDistance
|
&& abs(candidate.width) >= triggerDistance
|
||||||
|
|||||||
@@ -170,53 +170,47 @@ struct ChatView: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
HStack {
|
if !conversation.isGroup {
|
||||||
AvatarView(
|
Menu {
|
||||||
conversation: liveConversation,
|
Button {
|
||||||
imageData: model.avatarData(for: conversation.jid),
|
Task {
|
||||||
size: 28
|
await model.startCall(
|
||||||
).padding(4)
|
to: conversation.jid,
|
||||||
if !conversation.isGroup {
|
withVideo: false
|
||||||
Menu {
|
)
|
||||||
Button {
|
|
||||||
Task {
|
|
||||||
await model.startCall(
|
|
||||||
to: conversation.jid,
|
|
||||||
withVideo: false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("Аудиозвонок", systemImage: "phone")
|
|
||||||
}
|
}
|
||||||
.disabled(!canStartCall)
|
|
||||||
.help("Аудиозвонок")
|
|
||||||
|
|
||||||
Button {
|
|
||||||
Task {
|
|
||||||
await model.startCall(
|
|
||||||
to: conversation.jid,
|
|
||||||
withVideo: true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("Видеозвонок", systemImage: "video")
|
|
||||||
}
|
|
||||||
.disabled(!canStartCall)
|
|
||||||
.help("Видеозвонок")
|
|
||||||
encryptionMenu
|
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "ellipsis.circle")
|
Label("Аудиозвонок", systemImage: "phone")
|
||||||
}
|
}
|
||||||
.disabled(!canStartCall)
|
.disabled(!canStartCall)
|
||||||
}
|
.help("Аудиозвонок")
|
||||||
if conversation.isGroup {
|
|
||||||
Button {
|
Button {
|
||||||
showingGroupInfo = true
|
Task {
|
||||||
|
await model.startCall(
|
||||||
|
to: conversation.jid,
|
||||||
|
withVideo: true
|
||||||
|
)
|
||||||
|
}
|
||||||
} label: {
|
} 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
|
canUseComposer ? Color.accentColor : Color.secondary
|
||||||
)
|
)
|
||||||
.frame(width: 42, height: 42)
|
.frame(width: 42, height: 42)
|
||||||
// .background(.ultraThinMaterial, in: Circle())
|
// .background(.ultraThinMaterial, in: Circle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.disabled(!canUseComposer)
|
.disabled(!canUseComposer)
|
||||||
@@ -995,24 +989,7 @@ struct ChatView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var chatNavigationTitle: some View {
|
private var chatNavigationTitle: some View {
|
||||||
VStack(spacing: 0) {
|
ChatNavigationTitle(model: model, conversation: conversation)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var canSendToConversation: Bool {
|
private var canSendToConversation: Bool {
|
||||||
@@ -2653,3 +2630,69 @@ private func writePickedImageData(_ data: Data) throws -> URL {
|
|||||||
#Preview {
|
#Preview {
|
||||||
PreviewSupport.chatPreview()
|
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)
|
.offset(x: replySwipeOffset)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
|
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.accessibilityIdentifier("bubble-\(message.clientID)")
|
.accessibilityIdentifier("bubble-\(message.clientID)")
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
@@ -48,10 +47,6 @@ struct MessageBubble: View {
|
|||||||
.vertical,
|
.vertical,
|
||||||
message.kind == .photo || message.kind == .video ? 7 : 2
|
message.kind == .photo || message.kind == .video ? 7 : 2
|
||||||
)
|
)
|
||||||
.simultaneousGesture(
|
|
||||||
replySwipeGesture,
|
|
||||||
including: !isSelectionMode && message.canBeRepliedTo ? .gesture : .none
|
|
||||||
)
|
|
||||||
.onChange(of: isSelectionMode) { _, selecting in
|
.onChange(of: isSelectionMode) { _, selecting in
|
||||||
guard selecting else { return }
|
guard selecting else { return }
|
||||||
replySwipeOffset = 0
|
replySwipeOffset = 0
|
||||||
@@ -175,6 +170,7 @@ struct MessageBubble: View {
|
|||||||
)
|
)
|
||||||
.font(.system(size: 19, weight: .semibold))
|
.font(.system(size: 19, weight: .semibold))
|
||||||
.foregroundStyle(Color.accentColor)
|
.foregroundStyle(Color.accentColor)
|
||||||
|
.scaleEffect(x: replySwipeOffset > 0 ? -1 : 1, y: 1)
|
||||||
.scaleEffect(
|
.scaleEffect(
|
||||||
CGFloat(0.8) + CGFloat(0.2) * MessageReplySwipePolicy.progress(for: replySwipeOffset)
|
CGFloat(0.8) + CGFloat(0.2) * MessageReplySwipePolicy.progress(for: replySwipeOffset)
|
||||||
)
|
)
|
||||||
@@ -232,7 +228,7 @@ struct MessageBubble: View {
|
|||||||
.onEnded { value in
|
.onEnded { value in
|
||||||
// The lock is a hint, not a requirement: a fast flick may
|
// The lock is a hint, not a requirement: a fast flick may
|
||||||
// deliver no intermediate frame that passes canLock, yet its
|
// 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.
|
// alone protects the timeline from reply activation.
|
||||||
let shouldReply =
|
let shouldReply =
|
||||||
!scrollOwnsGesture
|
!scrollOwnsGesture
|
||||||
@@ -277,7 +273,15 @@ struct MessageBubble: View {
|
|||||||
.foregroundStyle(senderColor)
|
.foregroundStyle(senderColor)
|
||||||
.padding(.horizontal, 8)
|
.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
|
messageContent
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.simultaneousGesture(
|
||||||
|
replySwipeGesture,
|
||||||
|
including: !isSelectionMode && message.canBeRepliedTo ? .gesture : .none
|
||||||
|
)
|
||||||
|
|
||||||
if !reactionSummaries.isEmpty {
|
if !reactionSummaries.isEmpty {
|
||||||
reactionStrip
|
reactionStrip
|
||||||
|
|||||||
@@ -71,12 +71,13 @@ struct ReplyThreadOverlay: View {
|
|||||||
|
|
||||||
Button(action: onDismiss) {
|
Button(action: onDismiss) {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.system(size: 13, weight: .bold))
|
.font(.system(size: 17, weight: .semibold))
|
||||||
.foregroundStyle(.white.opacity(0.82))
|
.foregroundStyle(.white.opacity(0.82))
|
||||||
.frame(width: 32, height: 32)
|
.padding(7)
|
||||||
.background(.white.opacity(0.12), in: Circle())
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.glass)
|
||||||
|
.buttonBorderShape(.circle)
|
||||||
|
.background(.clear)
|
||||||
.keyboardShortcut(.cancelAction)
|
.keyboardShortcut(.cancelAction)
|
||||||
.accessibilityLabel("Закрыть ветку ответов")
|
.accessibilityLabel("Закрыть ветку ответов")
|
||||||
.padding(.top, max(12, geometry.safeAreaInsets.top + 6))
|
.padding(.top, max(12, geometry.safeAreaInsets.top + 6))
|
||||||
|
|||||||
@@ -54,10 +54,6 @@ private struct SearchField: View {
|
|||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 7)
|
.padding(.vertical, 7)
|
||||||
.glassEffect()
|
.glassEffect()
|
||||||
// .background(
|
|
||||||
// Color.secondary.opacity(0.14),
|
|
||||||
// in: RoundedRectangle(cornerRadius: 10)
|
|
||||||
// )
|
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,17 +15,17 @@ final class MessageReplySwipeTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testLeftToRightSwipeNeverReplies() {
|
func testLeftToRightSwipeReplies() {
|
||||||
XCTAssertFalse(MessageReplySwipePolicy.canLock(CGSize(width: 24, height: 10)))
|
XCTAssertTrue(MessageReplySwipePolicy.canLock(CGSize(width: 24, height: 10)))
|
||||||
XCTAssertEqual(
|
XCTAssertGreaterThan(
|
||||||
MessageReplySwipePolicy.offset(locked: false, translation: CGSize(width: 70, height: 8)),
|
MessageReplySwipePolicy.offset(locked: false, translation: CGSize(width: 70, height: 8)),
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
XCTAssertGreaterThan(
|
||||||
MessageReplySwipePolicy.offset(locked: true, translation: CGSize(width: 70, height: 8)),
|
MessageReplySwipePolicy.offset(locked: true, translation: CGSize(width: 70, height: 8)),
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
XCTAssertFalse(MessageReplySwipePolicy.shouldReply(
|
XCTAssertTrue(MessageReplySwipePolicy.shouldReply(
|
||||||
locked: false,
|
locked: false,
|
||||||
translation: CGSize(width: 70, height: 8),
|
translation: CGSize(width: 70, height: 8),
|
||||||
predictedEndTranslation: CGSize(width: 72, height: 9)
|
predictedEndTranslation: CGSize(width: 72, height: 9)
|
||||||
@@ -102,9 +102,10 @@ final class MessageReplySwipeTests: XCTestCase {
|
|||||||
|
|
||||||
func testCanLockRequiresClearHorizontalStart() {
|
func testCanLockRequiresClearHorizontalStart() {
|
||||||
XCTAssertTrue(MessageReplySwipePolicy.canLock(CGSize(width: -24, height: 10)))
|
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: -30, height: 20)))
|
||||||
XCTAssertFalse(MessageReplySwipePolicy.canLock(CGSize(width: -8, height: 5)))
|
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() {
|
func testCanLockIsUnavailableOnceTheGestureTurnsVertical() {
|
||||||
@@ -127,5 +128,47 @@ final class MessageReplySwipeTests: XCTestCase {
|
|||||||
0
|
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