mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-04-29 19:06:29 +00:00
ios: chat pagination (#910)
* ios: chat pagination * pagination hack * rotationEffect * more rotation * the least broken context menu * custom contect menu * add context item menus * fix context menu preview size * fix content menu targeted previews * subclass context menu view * remove UIView subclass * move coordinator class inside view * context menu and clicks work * reverse model * update item view based on viewId * hide underlying swiftui item * cover swiftui item with solid color * remove overlay * move hostview to async block * background overlay * remove async hostview * clear chat items on back buttom * update viewId on status changes
This commit is contained in:
committed by
GitHub
parent
2e4ffb7fe9
commit
3776e1c29c
@@ -22,7 +22,7 @@ final class ChatModel: ObservableObject {
|
||||
@Published var chats: [Chat] = []
|
||||
// current chat
|
||||
@Published var chatId: String?
|
||||
@Published var chatItems: [ChatItem] = []
|
||||
@Published var reversedChatItems: [ChatItem] = []
|
||||
@Published var chatToTop: String?
|
||||
@Published var groupMembers: [GroupMember] = []
|
||||
// items in the terminal view
|
||||
@@ -159,7 +159,7 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
// add to current chat
|
||||
if chatId == cInfo.id {
|
||||
withAnimation { chatItems.append(cItem) }
|
||||
withAnimation { reversedChatItems.insert(cItem, at: 0) }
|
||||
if case .rcvNew = cItem.meta.itemStatus {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
if self.chatId == cInfo.id {
|
||||
@@ -187,13 +187,14 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
// update current chat
|
||||
if chatId == cInfo.id {
|
||||
if let i = chatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
withAnimation(.default) {
|
||||
self.chatItems[i] = cItem
|
||||
self.reversedChatItems[i] = cItem
|
||||
self.reversedChatItems[i].viewTimestamp = .now
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
withAnimation { chatItems.append(cItem) }
|
||||
withAnimation { reversedChatItems.insert(cItem, at: 0) }
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
@@ -210,12 +211,12 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
// remove from current chat
|
||||
if chatId == cInfo.id {
|
||||
if let i = chatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
if chatItems[i].isRcvNew() == true {
|
||||
if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
if reversedChatItems[i].isRcvNew() == true {
|
||||
NtfManager.shared.decNtfBadgeCount()
|
||||
}
|
||||
_ = withAnimation {
|
||||
self.chatItems.remove(at: i)
|
||||
self.reversedChatItems.remove(at: i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -230,9 +231,10 @@ final class ChatModel: ObservableObject {
|
||||
// update current chat
|
||||
if chatId == cInfo.id {
|
||||
var i = 0
|
||||
while i < chatItems.count {
|
||||
if case .rcvNew = chatItems[i].meta.itemStatus {
|
||||
chatItems[i].meta.itemStatus = .rcvRead
|
||||
while i < reversedChatItems.count {
|
||||
if case .rcvNew = reversedChatItems[i].meta.itemStatus {
|
||||
reversedChatItems[i].meta.itemStatus = .rcvRead
|
||||
reversedChatItems[i].viewTimestamp = .now
|
||||
}
|
||||
i = i + 1
|
||||
}
|
||||
@@ -249,7 +251,7 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
// clear current chat
|
||||
if chatId == cInfo.id {
|
||||
chatItems = []
|
||||
reversedChatItems = []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,8 +261,9 @@ final class ChatModel: ObservableObject {
|
||||
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1
|
||||
}
|
||||
// update current chat
|
||||
if chatId == cInfo.id, let j = chatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
chatItems[j].meta.itemStatus = .rcvRead
|
||||
if chatId == cInfo.id, let j = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
reversedChatItems[j].meta.itemStatus = .rcvRead
|
||||
reversedChatItems[j].viewTimestamp = .now
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,8 +272,8 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
func getPrevChatItem(_ ci: ChatItem) -> ChatItem? {
|
||||
if let i = chatItems.firstIndex(where: { $0.id == ci.id }), i > 0 {
|
||||
return chatItems[i - 1]
|
||||
if let i = reversedChatItems.firstIndex(where: { $0.id == ci.id }), i < reversedChatItems.count - 1 {
|
||||
return reversedChatItems[i + 1]
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -185,19 +185,25 @@ func apiGetChats() throws -> [ChatData] {
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination = .last(count: 100)) throws -> Chat {
|
||||
let r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: pagination))
|
||||
func apiGetChat(type: ChatType, id: Int64) throws -> Chat {
|
||||
let r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: .last(count: 50)))
|
||||
if case let .apiChat(chat) = r { return Chat.init(chat) }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination) async throws -> [ChatItem] {
|
||||
let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: pagination))
|
||||
if case let .apiChat(chat) = r { return chat.chatItems }
|
||||
throw r
|
||||
}
|
||||
|
||||
func loadChat(chat: Chat) {
|
||||
do {
|
||||
let cInfo = chat.chatInfo
|
||||
let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId)
|
||||
let m = ChatModel.shared
|
||||
m.updateChatInfo(chat.chatInfo)
|
||||
m.chatItems = chat.chatItems
|
||||
m.reversedChatItems = chat.chatItems.reversed()
|
||||
} catch let error {
|
||||
logger.error("loadChat error: \(responseError(error))")
|
||||
}
|
||||
@@ -548,10 +554,11 @@ func markChatRead(_ chat: Chat) async {
|
||||
|
||||
func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
|
||||
do {
|
||||
logger.debug("apiMarkChatItemRead: \(cItem.id)")
|
||||
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
|
||||
DispatchQueue.main.async { ChatModel.shared.markChatItemRead(cInfo, cItem) }
|
||||
await MainActor.run { ChatModel.shared.markChatItemRead(cInfo, cItem) }
|
||||
} catch {
|
||||
logger.error("markChatItemRead apiChatRead error: \(responseError(error))")
|
||||
logger.error("apiMarkChatItemRead apiChatRead error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
import Introspect
|
||||
|
||||
private let memberImageSize: CGFloat = 34
|
||||
|
||||
@@ -22,6 +23,10 @@ struct ChatView: View {
|
||||
@FocusState private var keyboardVisible: Bool
|
||||
@State private var showDeleteMessage = false
|
||||
@State private var connectionStats: ConnectionStats?
|
||||
@State private var tableView: UITableView?
|
||||
@State private var loadingItems = false
|
||||
@State private var firstPage = false
|
||||
@State private var scrolledToUnread = false
|
||||
|
||||
var body: some View {
|
||||
let cInfo = chat.chatInfo
|
||||
@@ -35,47 +40,34 @@ struct ChatView: View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 5) {
|
||||
ForEach(chatModel.chatItems) { ci in
|
||||
if case let .groupRcv(member) = ci.chatDir {
|
||||
let prevItem = chatModel.getPrevChatItem(ci)
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
let showMember = prevItem == nil || showMemberImage(member, prevItem)
|
||||
if showMember {
|
||||
ProfileImage(imageStr: member.memberProfile.image)
|
||||
.frame(width: memberImageSize, height: memberImageSize)
|
||||
} else {
|
||||
Rectangle().fill(.clear)
|
||||
.frame(width: memberImageSize, height: memberImageSize)
|
||||
}
|
||||
chatItemWithMenu(ci, maxWidth, showMember: showMember).padding(.leading, 8)
|
||||
}
|
||||
.padding(.trailing)
|
||||
.padding(.leading, 12)
|
||||
} else {
|
||||
chatItemWithMenu(ci, maxWidth).padding(.horizontal)
|
||||
}
|
||||
ForEach(chatModel.reversedChatItems, id: \.viewId) { ci in
|
||||
chatItemView(ci, maxWidth)
|
||||
.scaleEffect(x: 1, y: -1, anchor: .center)
|
||||
.onAppear { loadChatItems(cInfo, ci, proxy) }
|
||||
}
|
||||
.onAppear {
|
||||
DispatchQueue.main.async {
|
||||
scrollToFirstUnread(proxy)
|
||||
}
|
||||
markAllRead()
|
||||
}
|
||||
.onChange(of: chatModel.chatItems.last?.id) { _ in
|
||||
scrollToBottom(proxy)
|
||||
}
|
||||
.onChange(of: keyboardVisible) { _ in
|
||||
if keyboardVisible {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||
scrollToBottom(proxy, animation: .easeInOut(duration: 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
DispatchQueue.main.async {
|
||||
scrollToFirstUnread(proxy)
|
||||
scrolledToUnread = true
|
||||
}
|
||||
markAllRead()
|
||||
}
|
||||
.onChange(of: chatModel.reversedChatItems.first?.id) { _ in
|
||||
scrollToBottom(proxy)
|
||||
}
|
||||
.onChange(of: keyboardVisible) { _ in
|
||||
if keyboardVisible {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||
scrollToBottom(proxy, animation: .easeInOut(duration: 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
.onTapGesture { hideKeyboard() }
|
||||
}
|
||||
}
|
||||
.scaleEffect(x: 1, y: -1, anchor: .center)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
@@ -86,11 +78,19 @@ struct ChatView: View {
|
||||
)
|
||||
.disabled(!chat.chatInfo.sendMsgEnabled)
|
||||
}
|
||||
.padding(.top, 1)
|
||||
.navigationTitle(cInfo.chatViewName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button { chatModel.chatId = nil } label: {
|
||||
Button {
|
||||
chatModel.chatId = nil
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
if chatModel.chatId == nil {
|
||||
chatModel.reversedChatItems = []
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "chevron.backward")
|
||||
Text("Chats", comment: "back button to return to chats list")
|
||||
@@ -178,76 +178,156 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat, showMember: Bool = false) -> some View {
|
||||
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
|
||||
return ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth)
|
||||
.contextMenu {
|
||||
if ci.isMsgContent() {
|
||||
Button {
|
||||
withAnimation {
|
||||
if composeState.editing() {
|
||||
composeState = ComposeState(contextItem: .quotedItem(chatItem: ci))
|
||||
} else {
|
||||
composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci))
|
||||
}
|
||||
}
|
||||
} label: { Label("Reply", systemImage: "arrowshape.turn.up.left") }
|
||||
Button {
|
||||
var shareItems: [Any] = [ci.content.text]
|
||||
if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) {
|
||||
shareItems.append(image)
|
||||
}
|
||||
showShareSheet(items: shareItems)
|
||||
} label: { Label("Share", systemImage: "square.and.arrow.up") }
|
||||
Button {
|
||||
if case let .image(text, _) = ci.content.msgContent,
|
||||
text == "",
|
||||
let image = getLoadedImage(ci.file) {
|
||||
UIPasteboard.general.image = image
|
||||
private func loadChatItems(_ cInfo: ChatInfo, _ ci: ChatItem, _ proxy: ScrollViewProxy) {
|
||||
if let firstItem = chatModel.reversedChatItems.last, firstItem.id == ci.id {
|
||||
if loadingItems || firstPage || !scrolledToUnread { return }
|
||||
loadingItems = true
|
||||
Task {
|
||||
do {
|
||||
let items = try await apiGetChatItems(
|
||||
type: cInfo.chatType,
|
||||
id: cInfo.apiId,
|
||||
pagination: .before(chatItemId: firstItem.id, count: 50)
|
||||
)
|
||||
await MainActor.run {
|
||||
if items.count == 0 {
|
||||
firstPage = true
|
||||
} else {
|
||||
UIPasteboard.general.string = ci.content.text
|
||||
chatModel.reversedChatItems.append(contentsOf: items.reversed())
|
||||
}
|
||||
} label: { Label("Copy", systemImage: "doc.on.doc") }
|
||||
if case .image = ci.content.msgContent,
|
||||
let image = getLoadedImage(ci.file) {
|
||||
Button {
|
||||
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
|
||||
} label: { Label("Save", systemImage: "square.and.arrow.down") }
|
||||
loadingItems = false
|
||||
}
|
||||
if ci.meta.editable {
|
||||
Button {
|
||||
withAnimation {
|
||||
composeState = ComposeState(editingItem: ci)
|
||||
}
|
||||
} label: { Label("Edit", systemImage: "square.and.pencil") }
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
showDeleteMessage = true
|
||||
deletingItem = ci
|
||||
} label: { Label("Delete", systemImage: "trash") }
|
||||
} else if ci.isDeletedContent() {
|
||||
Button(role: .destructive) {
|
||||
showDeleteMessage = true
|
||||
deletingItem = ci
|
||||
} label: { Label("Delete", systemImage: "trash") }
|
||||
} catch let error {
|
||||
logger.error("apiGetChat error: \(responseError(error))")
|
||||
await MainActor.run { loadingItems = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View {
|
||||
if case let .groupRcv(member) = ci.chatDir {
|
||||
let prevItem = chatModel.getPrevChatItem(ci)
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
let showMember = prevItem == nil || showMemberImage(member, prevItem)
|
||||
if showMember {
|
||||
ProfileImage(imageStr: member.memberProfile.image)
|
||||
.frame(width: memberImageSize, height: memberImageSize)
|
||||
} else {
|
||||
Rectangle().fill(.clear)
|
||||
.frame(width: memberImageSize, height: memberImageSize)
|
||||
}
|
||||
chatItemWithMenu(ci, maxWidth, showMember: showMember).padding(.leading, 8)
|
||||
}
|
||||
.padding(.trailing)
|
||||
.padding(.leading, 12)
|
||||
} else {
|
||||
chatItemWithMenu(ci, maxWidth).padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat, showMember: Bool = false) -> some View {
|
||||
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
|
||||
var menu: [UIAction] = []
|
||||
if ci.isMsgContent() {
|
||||
menu.append(contentsOf: [
|
||||
UIAction(
|
||||
title: NSLocalizedString("Reply", comment: "chat item action"),
|
||||
image: UIImage(systemName: "arrowshape.turn.up.left")
|
||||
) { _ in
|
||||
withAnimation {
|
||||
if composeState.editing() {
|
||||
composeState = ComposeState(contextItem: .quotedItem(chatItem: ci))
|
||||
} else {
|
||||
composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci))
|
||||
}
|
||||
}
|
||||
},
|
||||
UIAction(
|
||||
title: NSLocalizedString("Share", comment: "chat item action"),
|
||||
image: UIImage(systemName: "square.and.arrow.up")
|
||||
) { _ in
|
||||
var shareItems: [Any] = [ci.content.text]
|
||||
if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) {
|
||||
shareItems.append(image)
|
||||
}
|
||||
showShareSheet(items: shareItems)
|
||||
},
|
||||
UIAction(
|
||||
title: NSLocalizedString("Copy", comment: "chat item action"),
|
||||
image: UIImage(systemName: "doc.on.doc")
|
||||
) { _ in
|
||||
if case let .image(text, _) = ci.content.msgContent,
|
||||
text == "",
|
||||
let image = getLoadedImage(ci.file) {
|
||||
UIPasteboard.general.image = image
|
||||
} else {
|
||||
UIPasteboard.general.string = ci.content.text
|
||||
}
|
||||
}
|
||||
])
|
||||
if case .image = ci.content.msgContent,
|
||||
let image = getLoadedImage(ci.file) {
|
||||
menu.append(
|
||||
UIAction(
|
||||
title: NSLocalizedString("Save", comment: "chat item action"),
|
||||
image: UIImage(systemName: "square.and.arrow.down")
|
||||
) { _ in
|
||||
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
|
||||
}
|
||||
)
|
||||
}
|
||||
if ci.meta.editable {
|
||||
menu.append(
|
||||
UIAction(
|
||||
title: NSLocalizedString("Edit", comment: "chat item action"),
|
||||
image: UIImage(systemName: "square.and.pencil")
|
||||
) { _ in
|
||||
withAnimation {
|
||||
composeState = ComposeState(editingItem: ci)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
menu.append(
|
||||
UIAction(
|
||||
title: NSLocalizedString("Delete", comment: "chat item action"),
|
||||
image: UIImage(systemName: "trash"),
|
||||
attributes: [.destructive]
|
||||
) { _ in
|
||||
showDeleteMessage = true
|
||||
deletingItem = ci
|
||||
}
|
||||
)
|
||||
} else if ci.isDeletedContent() {
|
||||
menu.append(
|
||||
UIAction(
|
||||
title: NSLocalizedString("Delete", comment: "chat item action"),
|
||||
image: UIImage(systemName: "trash"),
|
||||
attributes: [.destructive]
|
||||
) { _ in
|
||||
showDeleteMessage = true
|
||||
deletingItem = ci
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth)
|
||||
.uiKitContextMenu(actions: menu)
|
||||
.confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) {
|
||||
Button("Delete for me", role: .destructive) {
|
||||
deleteMessage(.cidmInternal)
|
||||
}
|
||||
if let di = deletingItem {
|
||||
if di.meta.editable {
|
||||
Button("Delete for everyone",role: .destructive) {
|
||||
deleteMessage(.cidmBroadcast)
|
||||
}
|
||||
if let di = deletingItem, di.meta.editable {
|
||||
Button("Delete for everyone",role: .destructive) {
|
||||
deleteMessage(.cidmBroadcast)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
|
||||
}
|
||||
|
||||
|
||||
private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool {
|
||||
switch (prevItem?.chatDir) {
|
||||
case .groupSnd: return true
|
||||
@@ -261,15 +341,15 @@ struct ChatView: View {
|
||||
}
|
||||
|
||||
func scrollToBottom_(_ proxy: ScrollViewProxy) {
|
||||
if let id = chatModel.chatItems.last?.id {
|
||||
proxy.scrollTo(id, anchor: .bottom)
|
||||
if let id = chatModel.reversedChatItems.first?.id {
|
||||
proxy.scrollTo(id, anchor: .top)
|
||||
}
|
||||
}
|
||||
|
||||
// align first unread with the top or the last unread with bottom
|
||||
func scrollToFirstUnread(_ proxy: ScrollViewProxy) {
|
||||
if let cItem = chatModel.chatItems.first(where: { $0.isRcvNew() }) {
|
||||
proxy.scrollTo(cItem.id)
|
||||
if let cItem = chatModel.reversedChatItems.last(where: { $0.isRcvNew() }) {
|
||||
proxy.scrollTo(cItem.id, anchor: .bottom)
|
||||
} else {
|
||||
scrollToBottom_(proxy)
|
||||
}
|
||||
@@ -311,7 +391,7 @@ struct ChatView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let chatModel = ChatModel()
|
||||
chatModel.chatId = "@1"
|
||||
chatModel.chatItems = [
|
||||
chatModel.reversedChatItems = [
|
||||
ChatItem.getSample(1, .directSnd, .now, "hello"),
|
||||
ChatItem.getSample(2, .directRcv, .now, "hi"),
|
||||
ChatItem.getSample(3, .directRcv, .now, "hi there"),
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//
|
||||
// ContextMenu2.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 09/08/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
func uiKitContextMenu(title: String = "", actions: [UIAction]) -> some View {
|
||||
self.overlay(Color(uiColor: .systemBackground))
|
||||
.overlay(
|
||||
InteractionView(content: self, menu: UIMenu(title: title, children: actions))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct InteractionConfig<Content: View> {
|
||||
let content: Content
|
||||
let menu: UIMenu
|
||||
}
|
||||
|
||||
private struct InteractionView<Content: View>: UIViewRepresentable {
|
||||
let content: Content
|
||||
let menu: UIMenu
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
let view = UIView()
|
||||
view.backgroundColor = .clear
|
||||
let hostView = UIHostingController(rootView: content)
|
||||
hostView.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
let constraints = [
|
||||
hostView.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
hostView.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
hostView.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
hostView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
hostView.view.widthAnchor.constraint(equalTo: view.widthAnchor),
|
||||
hostView.view.heightAnchor.constraint(equalTo: view.heightAnchor)
|
||||
]
|
||||
view.addSubview(hostView.view)
|
||||
view.addConstraints(constraints)
|
||||
let menuInteraction = UIContextMenuInteraction(delegate: context.coordinator)
|
||||
view.addInteraction(menuInteraction)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIView, context: Context) {}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UIContextMenuInteractionDelegate {
|
||||
let parent: InteractionView<Content>
|
||||
|
||||
init(_ parent: InteractionView<Content>) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func contextMenuInteraction(
|
||||
_ interaction: UIContextMenuInteraction,
|
||||
configurationForMenuAtLocation location: CGPoint
|
||||
) -> UIContextMenuConfiguration? {
|
||||
UIContextMenuConfiguration(
|
||||
identifier: nil,
|
||||
previewProvider: nil,
|
||||
actionProvider: { [weak self] _ in
|
||||
guard let self = self else { return nil }
|
||||
return self.parent.menu
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// func contextMenuInteraction(
|
||||
// _ interaction: UIContextMenuInteraction,
|
||||
// willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration,
|
||||
// animator: UIContextMenuInteractionCommitAnimating
|
||||
// ) {
|
||||
// animator.addCompletion {
|
||||
// print("user tapped")
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@
|
||||
3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */; };
|
||||
3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; };
|
||||
3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* CILinkView.swift */; };
|
||||
5C00164028A1B87B0094D739 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 5C00163F28A1B87B0094D739 /* Introspect */; };
|
||||
5C00164428A26FBC0094D739 /* ContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C00164328A26FBC0094D739 /* ContextMenu.swift */; };
|
||||
5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA72837DBB3004A9677 /* CICallItemView.swift */; };
|
||||
5C029EAA283942EA004A9677 /* CallController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA9283942EA004A9677 /* CallController.swift */; };
|
||||
5C05DF532840AA1D00C683F9 /* CallSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C05DF522840AA1D00C683F9 /* CallSettings.swift */; };
|
||||
@@ -194,6 +196,7 @@
|
||||
3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteToConnectView.swift; sourceTree = "<group>"; };
|
||||
3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = "<group>"; };
|
||||
3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = "<group>"; };
|
||||
5C00164328A26FBC0094D739 /* ContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenu.swift; sourceTree = "<group>"; };
|
||||
5C029EA72837DBB3004A9677 /* CICallItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CICallItemView.swift; sourceTree = "<group>"; };
|
||||
5C029EA9283942EA004A9677 /* CallController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallController.swift; sourceTree = "<group>"; };
|
||||
5C05DF522840AA1D00C683F9 /* CallSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSettings.swift; sourceTree = "<group>"; };
|
||||
@@ -323,6 +326,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5CE2BA702845308900EC33A6 /* SimpleXChat.framework in Frameworks */,
|
||||
5C00164028A1B87B0094D739 /* Introspect in Frameworks */,
|
||||
646BB38C283BEEB9001CE359 /* LocalAuthentication.framework in Frameworks */,
|
||||
5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */,
|
||||
);
|
||||
@@ -452,6 +456,7 @@
|
||||
5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */,
|
||||
646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */,
|
||||
5C6BA666289BD954009B8ECC /* DismissSheets.swift */,
|
||||
5C00164328A26FBC0094D739 /* ContextMenu.swift */,
|
||||
);
|
||||
path = Helpers;
|
||||
sourceTree = "<group>";
|
||||
@@ -685,6 +690,7 @@
|
||||
name = "SimpleX (iOS)";
|
||||
packageProductDependencies = (
|
||||
5C8F01CC27A6F0D8007D2C8D /* CodeScanner */,
|
||||
5C00163F28A1B87B0094D739 /* Introspect */,
|
||||
);
|
||||
productName = "SimpleX (iOS)";
|
||||
productReference = 5CA059CA279559F40002BEB4 /* SimpleX.app */;
|
||||
@@ -785,6 +791,7 @@
|
||||
mainGroup = 5CA059BD279559F40002BEB4;
|
||||
packageReferences = (
|
||||
5C8F01CB27A6F0D8007D2C8D /* XCRemoteSwiftPackageReference "CodeScanner" */,
|
||||
5C00163E28A1B87B0094D739 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */,
|
||||
);
|
||||
productRefGroup = 5CA059CB279559F40002BEB4 /* Products */;
|
||||
projectDirPath = "";
|
||||
@@ -861,6 +868,7 @@
|
||||
3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */,
|
||||
3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */,
|
||||
5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */,
|
||||
5C00164428A26FBC0094D739 /* ContextMenu.swift in Sources */,
|
||||
5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */,
|
||||
5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */,
|
||||
640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */,
|
||||
@@ -1475,6 +1483,14 @@
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
5C00163E28A1B87B0094D739 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/siteline/SwiftUI-Introspect";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 0.1.4;
|
||||
};
|
||||
};
|
||||
5C8F01CB27A6F0D8007D2C8D /* XCRemoteSwiftPackageReference "CodeScanner" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/twostraws/CodeScanner";
|
||||
@@ -1486,6 +1502,11 @@
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
5C00163F28A1B87B0094D739 /* Introspect */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 5C00163E28A1B87B0094D739 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */;
|
||||
productName = Introspect;
|
||||
};
|
||||
5C8F01CC27A6F0D8007D2C8D /* CodeScanner */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 5C8F01CB27A6F0D8007D2C8D /* XCRemoteSwiftPackageReference "CodeScanner" */;
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
"revision" : "c27a66149b7483fe42e2ec6aad61d5c3fffe522d",
|
||||
"version" : "2.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftui-introspect",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/siteline/SwiftUI-Introspect",
|
||||
"state" : {
|
||||
"revision" : "f2616860a41f9d9932da412a8978fec79c06fe24",
|
||||
"version" : "0.1.4"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
|
||||
@@ -710,7 +710,7 @@ public struct ChatItem: Identifiable, Decodable {
|
||||
self.quotedItem = quotedItem
|
||||
self.file = file
|
||||
}
|
||||
|
||||
|
||||
public var chatDir: CIDirection
|
||||
public var meta: CIMeta
|
||||
public var content: CIContent
|
||||
@@ -718,9 +718,17 @@ public struct ChatItem: Identifiable, Decodable {
|
||||
public var quotedItem: CIQuote?
|
||||
public var file: CIFile?
|
||||
|
||||
public var id: Int64 { get { meta.itemId } }
|
||||
public var viewTimestamp = Date.now
|
||||
|
||||
public var timestampText: Text { get { meta.timestampText } }
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case chatDir, meta, content, formattedText, quotedItem, file
|
||||
}
|
||||
|
||||
public var id: Int64 { meta.itemId }
|
||||
|
||||
public var viewId: String { "\(meta.itemId) \(viewTimestamp.timeIntervalSince1970)" }
|
||||
|
||||
public var timestampText: Text { meta.timestampText }
|
||||
|
||||
public var text: String {
|
||||
get {
|
||||
@@ -855,6 +863,7 @@ public struct CIMeta: Decodable {
|
||||
var itemText: String
|
||||
public var itemStatus: CIStatus
|
||||
var createdAt: Date
|
||||
var updatedAt: Date
|
||||
public var itemDeleted: Bool
|
||||
public var itemEdited: Bool
|
||||
public var editable: Bool
|
||||
@@ -868,6 +877,7 @@ public struct CIMeta: Decodable {
|
||||
itemText: text,
|
||||
itemStatus: status,
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
itemDeleted: itemDeleted,
|
||||
itemEdited: itemEdited,
|
||||
editable: editable
|
||||
@@ -892,6 +902,17 @@ public enum CIStatus: Decodable {
|
||||
case sndError(agentError: AgentErrorType)
|
||||
case rcvNew
|
||||
case rcvRead
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .sndNew: return "sndNew"
|
||||
case .sndSent: return "sndSent"
|
||||
case .sndErrorAuth: return "sndErrorAuth"
|
||||
case .sndError: return "sndError"
|
||||
case .rcvNew: return "rcvNew"
|
||||
case .rcvRead: return "rcvRead"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum CIDeleteMode: String, Decodable {
|
||||
|
||||
Reference in New Issue
Block a user