добавил кнопку Копировать тект в контекстном меню сообщения #13
This commit is contained in:
1 parent
888501485a
commit
fe2b14ecee
1 file changed
+96
-60
@@ -1,9 +1,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
import UIKit
|
import UIKit
|
||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -71,6 +71,18 @@ struct MessageBubble: View {
|
|||||||
Label("Переслать", systemImage: "arrowshape.turn.up.right")
|
Label("Переслать", systemImage: "arrowshape.turn.up.right")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if message.hasText {
|
||||||
|
Button(action: {
|
||||||
|
#if os(macOS)
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(message.text, forType: .string)
|
||||||
|
#else
|
||||||
|
UIPasteboard.general.string = message.text
|
||||||
|
#endif
|
||||||
|
}) {
|
||||||
|
Label("Копировать текст", systemImage: "doc.on.doc")
|
||||||
|
}
|
||||||
|
}
|
||||||
if model.canRetryMediaMessage(message) {
|
if model.canRetryMediaMessage(message) {
|
||||||
Button(action: onRetry) {
|
Button(action: onRetry) {
|
||||||
Label("Повторить отправку", systemImage: "arrow.clockwise")
|
Label("Повторить отправку", systemImage: "arrow.clockwise")
|
||||||
@@ -142,16 +154,20 @@ struct MessageBubble: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var replySwipeIcon: some View {
|
private var replySwipeIcon: some View {
|
||||||
Image(systemName: replySwipeArmed
|
Image(
|
||||||
? "arrowshape.turn.up.left.fill"
|
systemName: replySwipeArmed
|
||||||
: "arrowshape.turn.up.left")
|
? "arrowshape.turn.up.left.fill"
|
||||||
.font(.system(size: 19, weight: .semibold))
|
: "arrowshape.turn.up.left"
|
||||||
.foregroundStyle(Color.accentColor)
|
)
|
||||||
.scaleEffect(
|
.font(.system(size: 19, weight: .semibold))
|
||||||
CGFloat(0.8) + CGFloat(0.2) * MessageReplySwipePolicy.progress(for: replySwipeOffset)
|
.foregroundStyle(Color.accentColor)
|
||||||
)
|
.scaleEffect(
|
||||||
.opacity(Double(
|
CGFloat(0.8) + CGFloat(0.2) * MessageReplySwipePolicy.progress(for: replySwipeOffset)
|
||||||
CGFloat(0.35) + CGFloat(0.65) * MessageReplySwipePolicy.progress(for: replySwipeOffset)
|
)
|
||||||
|
.opacity(
|
||||||
|
Double(
|
||||||
|
CGFloat(0.35) + CGFloat(0.65)
|
||||||
|
* MessageReplySwipePolicy.progress(for: replySwipeOffset)
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,9 +208,9 @@ struct MessageBubble: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func replySwipeFeedback() {
|
private func replySwipeFeedback() {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private var selectionAccessibilityLabel: String {
|
private var selectionAccessibilityLabel: String {
|
||||||
@@ -232,7 +248,8 @@ struct MessageBubble: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
guard let targetID = message.replyToID,
|
guard let targetID = message.replyToID,
|
||||||
message.kind == .text || message.kind == .system else { return }
|
message.kind == .text || message.kind == .system
|
||||||
|
else { return }
|
||||||
onReplyTap(targetID)
|
onReplyTap(targetID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,8 +263,11 @@ struct MessageBubble: View {
|
|||||||
if message.security != .omemo || message.delivery == .failed {
|
if message.security != .omemo || message.delivery == .failed {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
if message.security != .omemo {
|
if message.security != .omemo {
|
||||||
Image(systemName: message.security == .plaintext ? "lock.open" : "exclamationmark.shield")
|
Image(
|
||||||
.foregroundStyle(message.security == .plaintext ? .orange : .red)
|
systemName: message.security == .plaintext
|
||||||
|
? "lock.open" : "exclamationmark.shield"
|
||||||
|
)
|
||||||
|
.foregroundStyle(message.security == .plaintext ? .orange : .red)
|
||||||
}
|
}
|
||||||
if message.delivery == .failed {
|
if message.delivery == .failed {
|
||||||
Image(systemName: "exclamationmark.circle.fill")
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
@@ -262,8 +282,11 @@ struct MessageBubble: View {
|
|||||||
private var standardMetadata: some View {
|
private var standardMetadata: some View {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
if message.callHistory == nil, message.security != .omemo {
|
if message.callHistory == nil, message.security != .omemo {
|
||||||
Image(systemName: message.security == .plaintext ? "lock.open" : "exclamationmark.shield")
|
Image(
|
||||||
.foregroundStyle(message.security == .plaintext ? .orange : .red)
|
systemName: message.security == .plaintext
|
||||||
|
? "lock.open" : "exclamationmark.shield"
|
||||||
|
)
|
||||||
|
.foregroundStyle(message.security == .plaintext ? .orange : .red)
|
||||||
}
|
}
|
||||||
if message.editedAt != nil {
|
if message.editedAt != nil {
|
||||||
Text("изм.")
|
Text("изм.")
|
||||||
@@ -290,7 +313,9 @@ struct MessageBubble: View {
|
|||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.padding(.horizontal, 13)
|
.padding(.horizontal, 13)
|
||||||
.padding(.vertical, 9)
|
.padding(.vertical, 9)
|
||||||
.foregroundStyle(message.direction == .outgoing ? Color.white.opacity(0.86) : Color.secondary)
|
.foregroundStyle(
|
||||||
|
message.direction == .outgoing ? Color.white.opacity(0.86) : Color.secondary
|
||||||
|
)
|
||||||
.background(bubbleBackground)
|
.background(bubbleBackground)
|
||||||
.clipShape(messageBubbleShape)
|
.clipShape(messageBubbleShape)
|
||||||
} else {
|
} else {
|
||||||
@@ -370,16 +395,20 @@ struct MessageBubble: View {
|
|||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(callAccentColor(for: history).opacity(
|
.fill(
|
||||||
message.direction == .outgoing ? 0.28 : 0.14
|
callAccentColor(for: history).opacity(
|
||||||
))
|
message.direction == .outgoing ? 0.28 : 0.14
|
||||||
|
))
|
||||||
Image(systemName: history.isVideo ? "video.fill" : "phone.fill")
|
Image(systemName: history.isVideo ? "video.fill" : "phone.fill")
|
||||||
.font(.system(size: 17, weight: .semibold))
|
.font(.system(size: 17, weight: .semibold))
|
||||||
Image(systemName: message.direction == .outgoing ? "arrow.up.right" : "arrow.down.left")
|
Image(
|
||||||
.font(.system(size: 8, weight: .heavy))
|
systemName: message.direction == .outgoing
|
||||||
.padding(3)
|
? "arrow.up.right" : "arrow.down.left"
|
||||||
.background(Circle().fill(callDirectionBadgeBackground))
|
)
|
||||||
.offset(x: 14, y: 14)
|
.font(.system(size: 8, weight: .heavy))
|
||||||
|
.padding(3)
|
||||||
|
.background(Circle().fill(callDirectionBadgeBackground))
|
||||||
|
.offset(x: 14, y: 14)
|
||||||
}
|
}
|
||||||
.frame(width: 42, height: 42)
|
.frame(width: 42, height: 42)
|
||||||
.foregroundStyle(callIconForeground(for: history))
|
.foregroundStyle(callIconForeground(for: history))
|
||||||
@@ -488,9 +517,10 @@ struct MessageBubble: View {
|
|||||||
.padding(.bottom, -2)
|
.padding(.bottom, -2)
|
||||||
.task(id: replyTarget?.remoteAttachmentURL) {
|
.task(id: replyTarget?.remoteAttachmentURL) {
|
||||||
if let replyTarget,
|
if let replyTarget,
|
||||||
replyTarget.kind == .photo
|
replyTarget.kind == .photo
|
||||||
|| replyTarget.kind == .video
|
|| replyTarget.kind == .video
|
||||||
|| replyTarget.kind == .videoNote {
|
|| replyTarget.kind == .videoNote
|
||||||
|
{
|
||||||
await model.prepareMediaPreview(replyTarget)
|
await model.prepareMediaPreview(replyTarget)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -561,25 +591,25 @@ struct MessageBubble: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func replyThumbnailImage(for target: ChatMessage) -> some View {
|
private func replyThumbnailImage(for target: ChatMessage) -> some View {
|
||||||
if let data = model.mediaThumbnail(for: target) {
|
if let data = model.mediaThumbnail(for: target) {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
if let image = ChatMediaImageCache.image(for: target, data: data) {
|
if let image = ChatMediaImageCache.image(for: target, data: data) {
|
||||||
Image(uiImage: image)
|
Image(uiImage: image)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFill()
|
.scaledToFill()
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: replyMediaIcon(for: target.kind))
|
Image(systemName: replyMediaIcon(for: target.kind))
|
||||||
.foregroundStyle(compactReplyColor)
|
.foregroundStyle(compactReplyColor)
|
||||||
}
|
}
|
||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
if let image = ChatMediaImageCache.image(for: target, data: data) {
|
if let image = ChatMediaImageCache.image(for: target, data: data) {
|
||||||
Image(nsImage: image)
|
Image(nsImage: image)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFill()
|
.scaledToFill()
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: replyMediaIcon(for: target.kind))
|
Image(systemName: replyMediaIcon(for: target.kind))
|
||||||
.foregroundStyle(compactReplyColor)
|
.foregroundStyle(compactReplyColor)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: replyMediaIcon(for: target.kind))
|
Image(systemName: replyMediaIcon(for: target.kind))
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.system(size: 12, weight: .semibold))
|
||||||
@@ -611,7 +641,9 @@ struct MessageBubble: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(.white.opacity(message.direction == .outgoing ? 0.22 : 0.12))
|
.fill(.white.opacity(message.direction == .outgoing ? 0.22 : 0.12))
|
||||||
.frame(width: message.kind == .videoNote ? 58 : 38, height: message.kind == .videoNote ? 58 : 38)
|
.frame(
|
||||||
|
width: message.kind == .videoNote ? 58 : 38,
|
||||||
|
height: message.kind == .videoNote ? 58 : 38)
|
||||||
Image(systemName: mediaIcon)
|
Image(systemName: mediaIcon)
|
||||||
.font(.system(size: message.kind == .videoNote ? 24 : 17))
|
.font(.system(size: message.kind == .videoNote ? 24 : 17))
|
||||||
}
|
}
|
||||||
@@ -649,7 +681,8 @@ struct MessageBubble: View {
|
|||||||
parts.append(String(format: "%d:%02d", seconds / 60, seconds % 60))
|
parts.append(String(format: "%d:%02d", seconds / 60, seconds % 60))
|
||||||
}
|
}
|
||||||
if let byteCount = message.byteCount {
|
if let byteCount = message.byteCount {
|
||||||
parts.append(ByteCountFormatter.string(fromByteCount: Int64(byteCount), countStyle: .file))
|
parts.append(
|
||||||
|
ByteCountFormatter.string(fromByteCount: Int64(byteCount), countStyle: .file))
|
||||||
}
|
}
|
||||||
return parts.isEmpty ? "Нажмите для просмотра" : parts.joined(separator: " · ")
|
return parts.isEmpty ? "Нажмите для просмотра" : parts.joined(separator: " · ")
|
||||||
}
|
}
|
||||||
@@ -684,7 +717,10 @@ struct MessageBubble: View {
|
|||||||
private var bubbleBackground: some View {
|
private var bubbleBackground: some View {
|
||||||
if message.direction == .outgoing {
|
if message.direction == .outgoing {
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [Color(red: 0.13, green: 0.51, blue: 0.94), Color(red: 0.12, green: 0.63, blue: 0.94)],
|
colors: [
|
||||||
|
Color(red: 0.13, green: 0.51, blue: 0.94),
|
||||||
|
Color(red: 0.12, green: 0.63, blue: 0.94),
|
||||||
|
],
|
||||||
startPoint: .topLeading,
|
startPoint: .topLeading,
|
||||||
endPoint: .bottomTrailing
|
endPoint: .bottomTrailing
|
||||||
)
|
)
|
||||||
@@ -722,13 +758,13 @@ struct MessageBubble: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension View {
|
extension View {
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func lumaTextSelection() -> some View {
|
fileprivate func lumaTextSelection() -> some View {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
textSelection(.enabled)
|
textSelection(.enabled)
|
||||||
#else
|
#else
|
||||||
self
|
self
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user