mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ceecaf6592 | |||
| fb831439e9 | |||
| 018d9a1064 | |||
| 4b382841a1 | |||
| 6834138270 | |||
| bf83ba40a3 | |||
| ca166d7f96 | |||
| 42669454b0 | |||
| 243f3a7516 | |||
| aadba41e66 | |||
| ce1b66cef2 | |||
| 3e18fdc34d | |||
| c09075d71e | |||
| 41b4d7851a | |||
| 92bec5eabb | |||
| 24587ecc92 | |||
| 6865515f43 | |||
| 5ee6f40e75 | |||
| 637189cc2d | |||
| 13bfc9e92b | |||
| 032c5d3a5b | |||
| a53333be20 | |||
| a966f6b19d | |||
| 70d577260b | |||
| ea12982788 | |||
| 6fca6c22c5 | |||
| e343dd017b | |||
| 70a94d772a | |||
| 2689d1e27b | |||
| 4a9b54fbaf | |||
| f10a0ce58e | |||
| 6d488ba489 | |||
| 95776e0951 | |||
| bf9cdf3053 | |||
| fa73e63a79 | |||
| bfab76ed90 | |||
| 905295ee5f | |||
| a1e707ac1b | |||
| fea51b75e9 | |||
| ff8bbf11e7 | |||
| 391e9d57f2 | |||
| dae9f8575d | |||
| 065d9be614 | |||
| 4251762553 | |||
| c7017b3efc | |||
| b4ae3b9431 | |||
| 7138b09bae | |||
| bf59402856 | |||
| c6e5d6e2b8 | |||
| 0847b725b3 | |||
| 0d6f43a5ac | |||
| 5a014ba410 | |||
| 99888f9d5f | |||
| 670bf34ff5 | |||
| 3e873fcb32 | |||
| 23f24b1677 | |||
| 17526fa385 | |||
| 70204e071d | |||
| b348979b32 | |||
| 71ad8f2fd1 | |||
| 2dff94cbb4 | |||
| a73abfe642 | |||
| 859fa0bc22 | |||
| 41c4f13939 | |||
| f84ac713d7 | |||
| f41c04735b | |||
| a8da9b9cd9 | |||
| 64a0f509f7 | |||
| c4f8a50f0d | |||
| 93a4c0854e | |||
| 49c29c74df | |||
| e6ee5df158 | |||
| f4be0278b6 | |||
| a9d2535292 | |||
| 3e623684bc | |||
| fd90b47194 | |||
| acd3467d10 | |||
| 71ce598355 | |||
| 63393eaf0b | |||
| f90de83215 | |||
| 1c10209a31 | |||
| 5d7abf31ce | |||
| 44c0861fe4 | |||
| 1e6dc8002c |
@@ -73,6 +73,8 @@ final class ChatModel: ObservableObject {
|
||||
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
|
||||
@Published var chatToTop: String?
|
||||
@Published var groupMembers: [GMember] = []
|
||||
@Published var groupMembersIndexes: Dictionary<Int64, Int> = [:] // groupMemberId to index in groupMembers list
|
||||
@Published var membersLoaded = false
|
||||
// items in the terminal view
|
||||
@Published var showingTerminal = false
|
||||
@Published var terminalItems: [TerminalItem] = []
|
||||
@@ -180,8 +182,30 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func populateGroupMembersIndexes() {
|
||||
groupMembersIndexes.removeAll()
|
||||
for (i, member) in groupMembers.enumerated() {
|
||||
groupMembersIndexes[member.groupMemberId] = i
|
||||
}
|
||||
}
|
||||
|
||||
func getGroupMember(_ groupMemberId: Int64) -> GMember? {
|
||||
groupMembers.first { $0.groupMemberId == groupMemberId }
|
||||
if let i = groupMembersIndexes[groupMemberId] {
|
||||
return groupMembers[i]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadGroupMembers(_ groupInfo: GroupInfo, updateView: @escaping () -> Void = {}) async {
|
||||
let groupMembers = await apiListMembers(groupInfo.groupId)
|
||||
await MainActor.run {
|
||||
if chatId == groupInfo.id {
|
||||
self.groupMembers = groupMembers.map { GMember.init($0) }
|
||||
self.populateGroupMembersIndexes()
|
||||
self.membersLoaded = true
|
||||
updateView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getChatIndex(_ id: String) -> Int? {
|
||||
@@ -330,6 +354,9 @@ final class ChatModel: ObservableObject {
|
||||
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
|
||||
res = true
|
||||
}
|
||||
if cItem.isDeletedContent || cItem.meta.itemDeleted != nil {
|
||||
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
|
||||
}
|
||||
// update current chat
|
||||
return chatId == cInfo.id ? _upsertChatItem(cInfo, cItem) : res
|
||||
}
|
||||
@@ -379,8 +406,8 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
if cItem.isRcvNew {
|
||||
decreaseUnreadCounter(cInfo)
|
||||
if cItem.isRcvNew, let chatIndex = getChatIndex(cInfo.id) {
|
||||
decreaseUnreadCounter(chatIndex)
|
||||
}
|
||||
// update previews
|
||||
if let chat = getChat(cInfo.id) {
|
||||
@@ -396,6 +423,7 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
|
||||
}
|
||||
|
||||
func nextChatItemData<T>(_ chatItemId: Int64, previous: Bool, map: @escaping (ChatItem) -> T?) -> T? {
|
||||
@@ -525,13 +553,18 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
||||
if reversedChatItems[i].isRcvNew {
|
||||
// update current chat
|
||||
markChatItemRead_(i)
|
||||
// update preview
|
||||
decreaseUnreadCounter(cInfo)
|
||||
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
|
||||
if chatId == cInfo.id,
|
||||
let itemIndex = getChatItemIndex(cItem),
|
||||
let chatIndex = getChatIndex(cInfo.id),
|
||||
reversedChatItems[itemIndex].isRcvNew {
|
||||
await MainActor.run {
|
||||
withTransaction(Transaction()) {
|
||||
// update current chat
|
||||
markChatItemRead_(itemIndex)
|
||||
// update preview
|
||||
decreaseUnreadCounter(chatIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -547,11 +580,9 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func decreaseUnreadCounter(_ cInfo: ChatInfo) {
|
||||
if let i = getChatIndex(cInfo.id) {
|
||||
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1
|
||||
decreaseUnreadCounter(user: currentUser!)
|
||||
}
|
||||
func decreaseUnreadCounter(_ chatIndex: Int) {
|
||||
chats[chatIndex].chatStats.unreadCount = chats[chatIndex].chatStats.unreadCount - 1
|
||||
decreaseUnreadCounter(user: currentUser!)
|
||||
}
|
||||
|
||||
func increaseUnreadCounter(user: any UserLike) {
|
||||
@@ -667,14 +698,17 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
// update current chat
|
||||
if chatId == groupInfo.id {
|
||||
if let i = groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) {
|
||||
if let i = groupMembersIndexes[member.groupMemberId] {
|
||||
withAnimation(.default) {
|
||||
self.groupMembers[i].wrapped = member
|
||||
self.groupMembers[i].created = Date.now
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
withAnimation { groupMembers.append(GMember(member)) }
|
||||
withAnimation {
|
||||
groupMembers.append(GMember(member))
|
||||
groupMembersIndexes[member.groupMemberId] = groupMembers.count - 1
|
||||
}
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
@@ -702,7 +736,7 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return UnreadChatItemCounts(totalBelow: totalBelow, unreadBelow: unreadBelow)
|
||||
return UnreadChatItemCounts(isNearBottom: totalBelow < 16, unreadBelow: unreadBelow)
|
||||
}
|
||||
|
||||
func topItemInView(itemsInView: Set<String>) -> ChatItem? {
|
||||
@@ -740,11 +774,11 @@ struct NTFContactRequest {
|
||||
}
|
||||
|
||||
struct UnreadChatItemCounts: Equatable {
|
||||
var totalBelow: Int
|
||||
var isNearBottom: Bool
|
||||
var unreadBelow: Int
|
||||
}
|
||||
|
||||
final class Chat: ObservableObject, Identifiable {
|
||||
final class Chat: ObservableObject, Identifiable, ChatLike {
|
||||
@Published var chatInfo: ChatInfo
|
||||
@Published var chatItems: [ChatItem]
|
||||
@Published var chatStats: ChatStats
|
||||
|
||||
@@ -217,7 +217,7 @@ func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool, viewPwd: String?) asyn
|
||||
}
|
||||
|
||||
func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool {
|
||||
let r = chatSendCmdSync(.startChat(mainApp: true), ctrl)
|
||||
let r = chatSendCmdSync(.startChat(mainApp: true, enableSndFiles: true), ctrl)
|
||||
switch r {
|
||||
case .chatStarted: return true
|
||||
case .chatRunning: return false
|
||||
@@ -225,6 +225,15 @@ func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool {
|
||||
}
|
||||
}
|
||||
|
||||
func apiCheckChatRunning() throws -> Bool {
|
||||
let r = chatSendCmdSync(.checkChatRunning)
|
||||
switch r {
|
||||
case .chatRunning: return true
|
||||
case .chatStopped: return false
|
||||
default: throw r
|
||||
}
|
||||
}
|
||||
|
||||
func apiStopChat() async throws {
|
||||
let r = await chatSendCmd(.apiStopChat)
|
||||
switch r {
|
||||
@@ -397,7 +406,7 @@ private func sendMessageErrorAlert(_ r: ChatResponse) {
|
||||
logger.error("send message error: \(String(describing: r))")
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Error sending message",
|
||||
message: "Error: \(String(describing: r))"
|
||||
message: "Error: \(responseError(r))"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -405,7 +414,7 @@ private func createChatItemErrorAlert(_ r: ChatResponse) {
|
||||
logger.error("apiCreateChatItem error: \(String(describing: r))")
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Error creating message",
|
||||
message: "Error: \(String(describing: r))"
|
||||
message: "Error: \(responseError(r))"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -699,7 +708,7 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi
|
||||
message: "Please check that you used the correct link or ask your contact to send you another one."
|
||||
)
|
||||
return (nil, alert)
|
||||
case .chatCmdError(_, .errorAgent(.SMP(.AUTH))):
|
||||
case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))):
|
||||
let alert = mkAlert(
|
||||
title: "Connection error (AUTH)",
|
||||
message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection."
|
||||
@@ -732,7 +741,7 @@ private func connectionErrorAlert(_ r: ChatResponse) -> Alert {
|
||||
} else {
|
||||
return mkAlert(
|
||||
title: "Connection error",
|
||||
message: "Error: \(String(describing: r))"
|
||||
message: "Error: \(responseError(r))"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -898,7 +907,7 @@ func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Cont
|
||||
let am = AlertManager.shared
|
||||
|
||||
if case let .acceptingContactRequest(_, contact) = r { return contact }
|
||||
if case .chatCmdError(_, .errorAgent(.SMP(.AUTH))) = r {
|
||||
if case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))) = r {
|
||||
am.showAlertMsg(
|
||||
title: "Connection error (AUTH)",
|
||||
message: "Sender may have deleted the connection request."
|
||||
@@ -909,7 +918,7 @@ func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Cont
|
||||
logger.error("apiAcceptContactRequest error: \(String(describing: r))")
|
||||
am.showAlertMsg(
|
||||
title: "Error accepting contact request",
|
||||
message: "Error: \(String(describing: r))"
|
||||
message: "Error: \(responseError(r))"
|
||||
)
|
||||
}
|
||||
return nil
|
||||
@@ -935,7 +944,7 @@ func uploadStandaloneFile(user: any UserLike, file: CryptoFile, ctrl: chat_ctrl?
|
||||
return (fileTransferMeta, nil)
|
||||
} else {
|
||||
logger.error("uploadStandaloneFile error: \(String(describing: r))")
|
||||
return (nil, String(describing: r))
|
||||
return (nil, responseError(r))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -945,7 +954,7 @@ func downloadStandaloneFile(user: any UserLike, url: String, file: CryptoFile, c
|
||||
return (rcvFileTransfer, nil)
|
||||
} else {
|
||||
logger.error("downloadStandaloneFile error: \(String(describing: r))")
|
||||
return (nil, String(describing: r))
|
||||
return (nil, responseError(r))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1025,7 +1034,7 @@ func apiReceiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool, in
|
||||
if !auto {
|
||||
am.showAlertMsg(
|
||||
title: "Error receiving file",
|
||||
message: "Error: \(String(describing: r))"
|
||||
message: "Error: \(responseError(r))"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1092,18 +1101,9 @@ func deleteRemoteCtrl(_ rcId: Int64) async throws {
|
||||
}
|
||||
|
||||
func networkErrorAlert(_ r: ChatResponse) -> Alert? {
|
||||
switch r {
|
||||
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))):
|
||||
return mkAlert(
|
||||
title: "Connection timeout",
|
||||
message: "Please check your network connection with \(serverHostname(addr)) and try again."
|
||||
)
|
||||
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))):
|
||||
return mkAlert(
|
||||
title: "Connection error",
|
||||
message: "Please check your network connection with \(serverHostname(addr)) and try again."
|
||||
)
|
||||
default:
|
||||
if let alert = getNetworkErrorAlert(r) {
|
||||
return mkAlert(title: alert.title, message: alert.message)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1111,7 +1111,10 @@ func networkErrorAlert(_ r: ChatResponse) -> Alert? {
|
||||
func acceptContactRequest(incognito: Bool, contactRequest: UserContactRequest) async {
|
||||
if let contact = await apiAcceptContactRequest(incognito: incognito, contactReqId: contactRequest.apiId) {
|
||||
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
|
||||
DispatchQueue.main.async { ChatModel.shared.replaceChat(contactRequest.id, chat) }
|
||||
DispatchQueue.main.async {
|
||||
ChatModel.shared.replaceChat(contactRequest.id, chat)
|
||||
ChatModel.shared.setContactNetworkStatus(contact, .connected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1207,7 +1210,7 @@ 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))
|
||||
await MainActor.run { ChatModel.shared.markChatItemRead(cInfo, cItem) }
|
||||
await ChatModel.shared.markChatItemRead(cInfo, cItem)
|
||||
} catch {
|
||||
logger.error("apiMarkChatItemRead apiChatRead error: \(responseError(error))")
|
||||
}
|
||||
@@ -1248,7 +1251,7 @@ func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult {
|
||||
let r = await chatSendCmd(.apiJoinGroup(groupId: groupId))
|
||||
switch r {
|
||||
case let .userAcceptedGroupSent(_, groupInfo, _): return .joined(groupInfo: groupInfo)
|
||||
case .chatCmdError(_, .errorAgent(.SMP(.AUTH))): return .invitationRemoved
|
||||
case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))): return .invitationRemoved
|
||||
case .chatCmdError(_, .errorStore(.groupNotFound)): return .groupNotFound
|
||||
default: throw r
|
||||
}
|
||||
@@ -1354,6 +1357,14 @@ func apiGetVersion() throws -> CoreVersionInfo {
|
||||
throw r
|
||||
}
|
||||
|
||||
func getAgentSubsTotal() throws -> (SMPServerSubs, Bool) {
|
||||
let userId = try currentUserId("getAgentSubsTotal")
|
||||
let r = chatSendCmdSync(.getAgentSubsTotal(userId: userId), log: false)
|
||||
if case let .agentSubsTotal(_, subsTotal, hasSession) = r { return (subsTotal, hasSession) }
|
||||
logger.error("getAgentSubsTotal error: \(String(describing: r))")
|
||||
throw r
|
||||
}
|
||||
|
||||
func getAgentServersSummary() throws -> PresentedServersSummary {
|
||||
let userId = try currentUserId("getAgentServersSummary")
|
||||
let r = chatSendCmdSync(.getAgentServersSummary(userId: userId), log: false)
|
||||
@@ -1437,15 +1448,16 @@ func startChat(refreshInvitations: Bool = true) throws {
|
||||
logger.debug("startChat")
|
||||
let m = ChatModel.shared
|
||||
try setNetworkConfig(getNetCfg())
|
||||
let justStarted = try apiStartChat()
|
||||
let chatRunning = try apiCheckChatRunning()
|
||||
m.users = try listUsers()
|
||||
if justStarted {
|
||||
if !chatRunning {
|
||||
try getUserChatData()
|
||||
NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers())
|
||||
if (refreshInvitations) {
|
||||
try refreshCallInvitations()
|
||||
}
|
||||
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
|
||||
_ = try apiStartChat()
|
||||
// deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called,
|
||||
// when it is called before startChat
|
||||
if let token = m.deviceToken {
|
||||
@@ -1617,6 +1629,19 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
}
|
||||
}
|
||||
}
|
||||
case let .contactSndReady(user, contact):
|
||||
if active(user) && contact.directOrUsed {
|
||||
await MainActor.run {
|
||||
m.updateContact(contact)
|
||||
if let conn = contact.activeConn {
|
||||
m.dismissConnReqView(conn.id)
|
||||
m.removeChat(conn.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
await MainActor.run {
|
||||
m.setContactNetworkStatus(contact, .connected)
|
||||
}
|
||||
case let .receivedContactRequest(user, contactRequest):
|
||||
if active(user) {
|
||||
let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
|
||||
|
||||
@@ -36,6 +36,18 @@ private func _suspendChat(timeout: Int) {
|
||||
}
|
||||
}
|
||||
|
||||
let seSubscriber = seMessageSubscriber {
|
||||
switch $0 {
|
||||
case let .state(state):
|
||||
switch state {
|
||||
case .inactive:
|
||||
if AppChatState.shared.value.inactive { activateChat() }
|
||||
case .sendingMessage:
|
||||
if AppChatState.shared.value.canSuspend { suspendChat() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func suspendChat() {
|
||||
suspendLockQueue.sync {
|
||||
_suspendChat(timeout: appSuspendTimeout)
|
||||
|
||||
@@ -102,8 +102,7 @@ extension ThemeWallpaper {
|
||||
public func importFromString() -> ThemeWallpaper {
|
||||
if preset == nil, let image {
|
||||
// Need to save image from string and to save its path
|
||||
if let data = Data(base64Encoded: dropImagePrefix(image)),
|
||||
let parsed = UIImage(data: data),
|
||||
if let parsed = UIImage(base64Encoded: image),
|
||||
let filename = saveWallpaperFile(image: parsed) {
|
||||
var copy = self
|
||||
copy.image = nil
|
||||
|
||||
@@ -28,15 +28,17 @@ struct ChatInfoToolbar: View {
|
||||
color: Color(uiColor: .tertiaryLabel)
|
||||
)
|
||||
.padding(.trailing, 4)
|
||||
VStack {
|
||||
let t = Text(cInfo.displayName).font(.headline)
|
||||
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
|
||||
.lineLimit(1)
|
||||
if cInfo.fullName != "" && cInfo.displayName != cInfo.fullName {
|
||||
Text(cInfo.fullName).font(.subheadline)
|
||||
.lineLimit(1)
|
||||
let t = Text(cInfo.displayName).font(.headline)
|
||||
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
|
||||
.lineLimit(1)
|
||||
.if (cInfo.fullName != "" && cInfo.displayName != cInfo.fullName) { v in
|
||||
VStack(spacing: 0) {
|
||||
v
|
||||
Text(cInfo.fullName).font(.subheadline)
|
||||
.lineLimit(1)
|
||||
.padding(.top, -2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundColor(theme.colors.onBackground)
|
||||
.frame(width: 220)
|
||||
|
||||
@@ -112,7 +112,7 @@ struct ChatInfoView: View {
|
||||
case abortSwitchAddressAlert
|
||||
case syncConnectionForceAlert
|
||||
case queueInfo(info: String)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
@@ -155,23 +155,25 @@ struct ChatInfoView: View {
|
||||
}
|
||||
|
||||
Section {
|
||||
if let code = connectionCode { verifyCodeButton(code) }
|
||||
contactPreferencesButton()
|
||||
sendReceiptsOption()
|
||||
if let connStats = connectionStats,
|
||||
connStats.ratchetSyncAllowed {
|
||||
synchronizeConnectionButton()
|
||||
Group {
|
||||
if let code = connectionCode { verifyCodeButton(code) }
|
||||
contactPreferencesButton()
|
||||
sendReceiptsOption()
|
||||
if let connStats = connectionStats,
|
||||
connStats.ratchetSyncAllowed {
|
||||
synchronizeConnectionButton()
|
||||
}
|
||||
// } else if developerTools {
|
||||
// synchronizeConnectionButtonForce()
|
||||
// }
|
||||
}
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
NavigationLink {
|
||||
ChatWallpaperEditorSheet(chat: chat)
|
||||
} label: {
|
||||
Label("Chat theme", systemImage: "photo")
|
||||
}
|
||||
// } else if developerTools {
|
||||
// synchronizeConnectionButtonForce()
|
||||
// }
|
||||
}
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
|
||||
if let conn = contact.activeConn {
|
||||
Section {
|
||||
@@ -271,7 +273,7 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
.actionSheet(isPresented: $showDeleteContactActionSheet) {
|
||||
if contact.ready && contact.active {
|
||||
if contact.sndReady && contact.active {
|
||||
return ActionSheet(
|
||||
title: Text("Delete contact?\nThis cannot be undone!"),
|
||||
buttons: [
|
||||
|
||||
@@ -9,6 +9,7 @@ import SwiftUI
|
||||
class AnimatedImageView: UIView {
|
||||
var image: UIImage? = nil
|
||||
var imageView: UIImageView? = nil
|
||||
var cMode: UIView.ContentMode = .scaleAspectFit
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
@@ -18,11 +19,12 @@ class AnimatedImageView: UIView {
|
||||
fatalError("Not implemented")
|
||||
}
|
||||
|
||||
convenience init(image: UIImage) {
|
||||
convenience init(image: UIImage, contentMode: UIView.ContentMode) {
|
||||
self.init()
|
||||
self.image = image
|
||||
self.cMode = contentMode
|
||||
imageView = UIImageView(gifImage: image)
|
||||
imageView!.contentMode = .scaleAspectFit
|
||||
imageView!.contentMode = contentMode
|
||||
self.addSubview(imageView!)
|
||||
}
|
||||
|
||||
@@ -35,7 +37,7 @@ class AnimatedImageView: UIView {
|
||||
if let subview = self.subviews.first as? UIImageView {
|
||||
if image.imageData != subview.gifImage?.imageData {
|
||||
imageView = UIImageView(gifImage: image)
|
||||
imageView!.contentMode = .scaleAspectFit
|
||||
imageView!.contentMode = contentMode
|
||||
self.addSubview(imageView!)
|
||||
subview.removeFromSuperview()
|
||||
}
|
||||
@@ -47,13 +49,15 @@ class AnimatedImageView: UIView {
|
||||
|
||||
struct SwiftyGif: UIViewRepresentable {
|
||||
private let image: UIImage
|
||||
private let contentMode: UIView.ContentMode
|
||||
|
||||
init(image: UIImage) {
|
||||
init(image: UIImage, contentMode: UIView.ContentMode = .scaleAspectFit) {
|
||||
self.image = image
|
||||
self.contentMode = contentMode
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> AnimatedImageView {
|
||||
AnimatedImageView(image: image)
|
||||
AnimatedImageView(image: image, contentMode: contentMode)
|
||||
}
|
||||
|
||||
func updateUIView(_ imageView: AnimatedImageView, context: Context) {
|
||||
|
||||
@@ -14,39 +14,45 @@ struct CIFileView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
let file: CIFile?
|
||||
let edited: Bool
|
||||
var smallViewSize: CGFloat?
|
||||
|
||||
var body: some View {
|
||||
let metaReserve = edited
|
||||
? " "
|
||||
: " "
|
||||
Button(action: fileAction) {
|
||||
HStack(alignment: .bottom, spacing: 6) {
|
||||
fileIndicator()
|
||||
.padding(.top, 5)
|
||||
.padding(.bottom, 3)
|
||||
if let file = file {
|
||||
let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(file.fileName)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(theme.colors.onBackground)
|
||||
Text(prettyFileSize + metaReserve)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
if smallViewSize != nil {
|
||||
fileIndicator()
|
||||
.onTapGesture(perform: fileAction)
|
||||
} else {
|
||||
let metaReserve = edited
|
||||
? " "
|
||||
: " "
|
||||
Button(action: fileAction) {
|
||||
HStack(alignment: .bottom, spacing: 6) {
|
||||
fileIndicator()
|
||||
.padding(.top, 5)
|
||||
.padding(.bottom, 3)
|
||||
if let file = file {
|
||||
let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(file.fileName)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(theme.colors.onBackground)
|
||||
Text(prettyFileSize + metaReserve)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
} else {
|
||||
Text(metaReserve)
|
||||
}
|
||||
} else {
|
||||
Text(metaReserve)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 6)
|
||||
.padding(.leading, 10)
|
||||
.padding(.trailing, 12)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 6)
|
||||
.padding(.leading, 10)
|
||||
.padding(.trailing, 12)
|
||||
.disabled(!itemInteractive)
|
||||
}
|
||||
.disabled(!itemInteractive)
|
||||
}
|
||||
|
||||
private var itemInteractive: Bool {
|
||||
@@ -195,21 +201,22 @@ struct CIFileView: View {
|
||||
}
|
||||
|
||||
private func fileIcon(_ icon: String, color: Color = Color(uiColor: .tertiaryLabel), innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View {
|
||||
ZStack(alignment: .center) {
|
||||
let size = smallViewSize ?? 30
|
||||
return ZStack(alignment: .center) {
|
||||
Image(systemName: icon)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 30, height: 30)
|
||||
.frame(width: size, height: size)
|
||||
.foregroundColor(color)
|
||||
if let innerIcon = innerIcon,
|
||||
let innerIconSize = innerIconSize {
|
||||
let innerIconSize = innerIconSize, (smallViewSize == nil || file?.showStatusIconInSmallView == true) {
|
||||
Image(systemName: innerIcon)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(maxHeight: 16)
|
||||
.frame(width: innerIconSize, height: innerIconSize)
|
||||
.foregroundColor(.white)
|
||||
.padding(.top, 12)
|
||||
.padding(.top, size / 2.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,6 @@ struct CIGroupInvitationView: View {
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(chatItemFrameColor(chatItem, theme))
|
||||
.cornerRadius(18)
|
||||
.textSelection(.disabled)
|
||||
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
|
||||
.onChange(of: inProgress) { inProgress in
|
||||
|
||||
@@ -15,22 +15,33 @@ struct CIImageView: View {
|
||||
var preview: UIImage?
|
||||
let maxWidth: CGFloat
|
||||
var imgWidth: CGFloat?
|
||||
@State private var showFullScreenImage = false
|
||||
var smallView: Bool = false
|
||||
@Binding var showFullScreenImage: Bool
|
||||
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
|
||||
|
||||
var body: some View {
|
||||
let file = chatItem.file
|
||||
VStack(alignment: .center, spacing: 6) {
|
||||
if let uiImage = getLoadedImage(file) {
|
||||
imageView(uiImage)
|
||||
Group { if smallView { smallViewImageView(uiImage) } else { imageView(uiImage) } }
|
||||
.fullScreenCover(isPresented: $showFullScreenImage) {
|
||||
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage)
|
||||
}
|
||||
.if(!smallView) { view in
|
||||
view.modifier(PrivacyBlur(blurred: $blurred))
|
||||
}
|
||||
.onTapGesture { showFullScreenImage = true }
|
||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||
showFullScreenImage = false
|
||||
}
|
||||
} else if let preview {
|
||||
imageView(preview)
|
||||
Group {
|
||||
if smallView {
|
||||
smallViewImageView(preview)
|
||||
} else {
|
||||
imageView(preview).modifier(PrivacyBlur(blurred: $blurred))
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
if let file = file {
|
||||
switch file.fileStatus {
|
||||
@@ -83,6 +94,9 @@ struct CIImageView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
showFullScreenImage = false
|
||||
}
|
||||
}
|
||||
|
||||
private func imageView(_ img: UIImage) -> some View {
|
||||
@@ -98,7 +112,26 @@ struct CIImageView: View {
|
||||
.frame(width: w, height: w * img.size.height / img.size.width)
|
||||
.scaledToFit()
|
||||
}
|
||||
loadingIndicator()
|
||||
if !blurred || !showDownloadButton(chatItem.file?.fileStatus) {
|
||||
loadingIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func smallViewImageView(_ img: UIImage) -> some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
if img.imageData == nil {
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: maxWidth, height: maxWidth)
|
||||
} else {
|
||||
SwiftyGif(image: img, contentMode: .scaleAspectFill)
|
||||
.frame(width: maxWidth, height: maxWidth)
|
||||
}
|
||||
if chatItem.file?.showStatusIconInSmallView == true {
|
||||
loadingIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,4 +178,12 @@ struct CIImageView: View {
|
||||
.tint(.white)
|
||||
.padding(8)
|
||||
}
|
||||
|
||||
private func showDownloadButton(_ fileStatus: CIFileStatus?) -> Bool {
|
||||
switch fileStatus {
|
||||
case .rcvInvitation: true
|
||||
case .rcvAborted: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ struct CIInvalidJSONView: View {
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.cornerRadius(18)
|
||||
.textSelection(.disabled)
|
||||
.onTapGesture { showJSON = true }
|
||||
.appSheet(isPresented: $showJSON) {
|
||||
|
||||
@@ -12,14 +12,15 @@ import SimpleXChat
|
||||
struct CILinkView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
let linkPreview: LinkPreview
|
||||
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .center, spacing: 6) {
|
||||
if let data = Data(base64Encoded: dropImagePrefix(linkPreview.image)),
|
||||
let uiImage = UIImage(data: data) {
|
||||
if let uiImage = UIImage(base64Encoded: linkPreview.image) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.modifier(PrivacyBlur(blurred: $blurred))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(linkPreview.title)
|
||||
|
||||
@@ -27,7 +27,7 @@ struct CIRcvDecryptionError: View {
|
||||
case syncNotSupportedContactAlert
|
||||
case syncNotSupportedMemberAlert
|
||||
case decryptionErrorAlert
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
@@ -62,7 +62,7 @@ struct CIRcvDecryptionError: View {
|
||||
case .syncNotSupportedContactAlert: return Alert(title: Text("Fix not supported by contact"), message: message())
|
||||
case .syncNotSupportedMemberAlert: return Alert(title: Text("Fix not supported by group member"), message: message())
|
||||
case .decryptionErrorAlert: return Alert(title: Text("Decryption error"), message: message())
|
||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
||||
case let .error(title, error): return mkAlert(title: title, message: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,6 @@ struct CIRcvDecryptionError: View {
|
||||
.onTapGesture(perform: { onClick() })
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.cornerRadius(18)
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
|
||||
@@ -152,7 +151,6 @@ struct CIRcvDecryptionError: View {
|
||||
.onTapGesture(perform: { onClick() })
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.cornerRadius(18)
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,45 +20,44 @@ struct CIVideoView: View {
|
||||
@State private var videoPlaying: Bool = false
|
||||
private let maxWidth: CGFloat
|
||||
private var videoWidth: CGFloat?
|
||||
private let smallView: Bool
|
||||
@State private var player: AVPlayer?
|
||||
@State private var fullPlayer: AVPlayer?
|
||||
@State private var url: URL?
|
||||
@State private var urlDecrypted: URL?
|
||||
@State private var decryptionInProgress: Bool = false
|
||||
@State private var showFullScreenPlayer = false
|
||||
@Binding private var showFullScreenPlayer: Bool
|
||||
@State private var timeObserver: Any? = nil
|
||||
@State private var fullScreenTimeObserver: Any? = nil
|
||||
@State private var publisher: AnyCancellable? = nil
|
||||
private var sizeMultiplier: CGFloat { smallView ? 0.38 : 1 }
|
||||
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
|
||||
|
||||
init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?) {
|
||||
init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding<Bool>) {
|
||||
self.chatItem = chatItem
|
||||
self.preview = preview
|
||||
self._duration = State(initialValue: duration)
|
||||
self.maxWidth = maxWidth
|
||||
self.videoWidth = videoWidth
|
||||
if let url = getLoadedVideo(chatItem.file) {
|
||||
let decrypted = chatItem.file?.fileSource?.cryptoArgs == nil ? url : chatItem.file?.fileSource?.decryptedGet()
|
||||
self._urlDecrypted = State(initialValue: decrypted)
|
||||
if let decrypted = decrypted {
|
||||
self._player = State(initialValue: VideoPlayerView.getOrCreatePlayer(decrypted, false))
|
||||
self._fullPlayer = State(initialValue: AVPlayer(url: decrypted))
|
||||
}
|
||||
self._url = State(initialValue: url)
|
||||
}
|
||||
self.smallView = smallView
|
||||
self._showFullScreenPlayer = showFullscreenPlayer
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let file = chatItem.file
|
||||
ZStack {
|
||||
ZStack(alignment: smallView ? .topLeading : .center) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted {
|
||||
if let file = file, let preview = preview, let decrypted = urlDecrypted, smallView {
|
||||
smallVideoView(decrypted, file, preview)
|
||||
} else if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted {
|
||||
videoView(player, decrypted, file, preview, duration)
|
||||
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil, smallView {
|
||||
smallVideoViewEncrypted(file, defaultPreview)
|
||||
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil {
|
||||
videoViewEncrypted(file, defaultPreview, duration)
|
||||
} else if let preview {
|
||||
imageView(preview)
|
||||
.onTapGesture {
|
||||
if let file = file {
|
||||
} else if let preview, let file {
|
||||
Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } }
|
||||
.onTapGesture {
|
||||
switch file.fileStatus {
|
||||
case .rcvInvitation, .rcvAborted:
|
||||
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||
@@ -82,21 +81,64 @@ struct CIVideoView: View {
|
||||
default: ()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
durationProgress()
|
||||
if !smallView {
|
||||
durationProgress()
|
||||
}
|
||||
}
|
||||
if let file = file, showDownloadButton(file.fileStatus) {
|
||||
Button {
|
||||
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||
} label: {
|
||||
if !blurred, let file, showDownloadButton(file.fileStatus) {
|
||||
if !smallView {
|
||||
Button {
|
||||
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||
} label: {
|
||||
playPauseIcon("play.fill")
|
||||
}
|
||||
} else if !file.showStatusIconInSmallView {
|
||||
playPauseIcon("play.fill")
|
||||
.onTapGesture {
|
||||
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $showFullScreenPlayer) {
|
||||
if let decrypted = urlDecrypted {
|
||||
fullScreenPlayer(decrypted)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
setupPlayer(chatItem.file)
|
||||
}
|
||||
.onChange(of: chatItem.file) { file in
|
||||
// ChatItem can be changed in small view on chat list screen
|
||||
setupPlayer(file)
|
||||
}
|
||||
.onDisappear {
|
||||
showFullScreenPlayer = false
|
||||
}
|
||||
}
|
||||
|
||||
private func showDownloadButton(_ fileStatus: CIFileStatus) -> Bool {
|
||||
private func setupPlayer(_ file: CIFile?) {
|
||||
let newUrl = getLoadedVideo(file)
|
||||
if newUrl == url {
|
||||
return
|
||||
}
|
||||
url = nil
|
||||
urlDecrypted = nil
|
||||
player = nil
|
||||
fullPlayer = nil
|
||||
if let newUrl {
|
||||
let decrypted = file?.fileSource?.cryptoArgs == nil ? newUrl : file?.fileSource?.decryptedGet()
|
||||
urlDecrypted = decrypted
|
||||
if let decrypted = decrypted {
|
||||
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
|
||||
fullPlayer = AVPlayer(url: decrypted)
|
||||
}
|
||||
url = newUrl
|
||||
}
|
||||
}
|
||||
|
||||
private func showDownloadButton(_ fileStatus: CIFileStatus?) -> Bool {
|
||||
switch fileStatus {
|
||||
case .rcvInvitation: true
|
||||
case .rcvAborted: true
|
||||
@@ -109,11 +151,6 @@ struct CIVideoView: View {
|
||||
ZStack(alignment: .center) {
|
||||
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
|
||||
imageView(defaultPreview)
|
||||
.fullScreenCover(isPresented: $showFullScreenPlayer) {
|
||||
if let decrypted = urlDecrypted {
|
||||
fullScreenPlayer(decrypted)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
decrypt(file: file) {
|
||||
showFullScreenPlayer = urlDecrypted != nil
|
||||
@@ -122,20 +159,22 @@ struct CIVideoView: View {
|
||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||
showFullScreenPlayer = false
|
||||
}
|
||||
if !decryptionInProgress {
|
||||
Button {
|
||||
decrypt(file: file) {
|
||||
if urlDecrypted != nil {
|
||||
videoPlaying = true
|
||||
player?.play()
|
||||
if !blurred {
|
||||
if !decryptionInProgress {
|
||||
Button {
|
||||
decrypt(file: file) {
|
||||
if urlDecrypted != nil {
|
||||
videoPlaying = true
|
||||
player?.play()
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
|
||||
}
|
||||
} label: {
|
||||
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
|
||||
.disabled(!canBePlayed)
|
||||
} else {
|
||||
videoDecryptionProgress()
|
||||
}
|
||||
.disabled(!canBePlayed)
|
||||
} else {
|
||||
videoDecryptionProgress()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,9 +193,7 @@ struct CIVideoView: View {
|
||||
videoPlaying = false
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $showFullScreenPlayer) {
|
||||
fullScreenPlayer(url)
|
||||
}
|
||||
.modifier(PrivacyBlur(enabled: !videoPlaying, blurred: $blurred))
|
||||
.onTapGesture {
|
||||
switch player.timeControlStatus {
|
||||
case .playing:
|
||||
@@ -172,7 +209,7 @@ struct CIVideoView: View {
|
||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||
showFullScreenPlayer = false
|
||||
}
|
||||
if !videoPlaying {
|
||||
if !videoPlaying && !blurred {
|
||||
Button {
|
||||
m.stopPreviousRecPlay = url
|
||||
player.play()
|
||||
@@ -194,14 +231,53 @@ struct CIVideoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func smallVideoViewEncrypted(_ file: CIFile, _ preview: UIImage) -> some View {
|
||||
return ZStack(alignment: .topLeading) {
|
||||
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
|
||||
smallViewImageView(preview, file)
|
||||
.onTapGesture {
|
||||
decrypt(file: file) {
|
||||
showFullScreenPlayer = urlDecrypted != nil
|
||||
}
|
||||
}
|
||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||
showFullScreenPlayer = false
|
||||
}
|
||||
if file.showStatusIconInSmallView {
|
||||
// Show nothing
|
||||
} else if !decryptionInProgress {
|
||||
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
|
||||
} else {
|
||||
videoDecryptionProgress()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func smallVideoView(_ url: URL, _ file: CIFile, _ preview: UIImage) -> some View {
|
||||
return ZStack(alignment: .topLeading) {
|
||||
smallViewImageView(preview, file)
|
||||
.onTapGesture {
|
||||
showFullScreenPlayer = true
|
||||
}
|
||||
.onChange(of: m.activeCallViewIsCollapsed) { _ in
|
||||
showFullScreenPlayer = false
|
||||
}
|
||||
|
||||
if !file.showStatusIconInSmallView {
|
||||
playPauseIcon("play.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func playPauseIcon(_ image: String, _ color: Color = .white) -> some View {
|
||||
Image(systemName: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 12, height: 12)
|
||||
.frame(width: smallView ? 12 * sizeMultiplier * 1.6 : 12, height: smallView ? 12 * sizeMultiplier * 1.6 : 12)
|
||||
.foregroundColor(color)
|
||||
.padding(.leading, 4)
|
||||
.frame(width: 40, height: 40)
|
||||
.padding(.leading, smallView ? 0 : 4)
|
||||
.frame(width: 40 * sizeMultiplier, height: 40 * sizeMultiplier)
|
||||
.background(Color.black.opacity(0.35))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
@@ -209,9 +285,9 @@ struct CIVideoView: View {
|
||||
private func videoDecryptionProgress(_ color: Color = .white) -> some View {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.frame(width: 12, height: 12)
|
||||
.frame(width: smallView ? 12 * sizeMultiplier : 12, height: smallView ? 12 * sizeMultiplier : 12)
|
||||
.tint(color)
|
||||
.frame(width: 40, height: 40)
|
||||
.frame(width: smallView ? 40 * sizeMultiplier * 0.9 : 40, height: smallView ? 40 * sizeMultiplier * 0.9 : 40)
|
||||
.background(Color.black.opacity(0.35))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
@@ -247,7 +323,23 @@ struct CIVideoView: View {
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: w)
|
||||
fileStatusIcon()
|
||||
.modifier(PrivacyBlur(blurred: $blurred))
|
||||
if !blurred || !showDownloadButton(chatItem.file?.fileStatus) {
|
||||
fileStatusIcon()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func smallViewImageView(_ img: UIImage, _ file: CIFile) -> some View {
|
||||
ZStack(alignment: .center) {
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: maxWidth, height: maxWidth)
|
||||
if file.showStatusIconInSmallView {
|
||||
fileStatusIcon()
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,7 +414,7 @@ struct CIVideoView: View {
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: size, height: size)
|
||||
.foregroundColor(.white)
|
||||
.padding(padding)
|
||||
.padding(smallView ? 0 : padding)
|
||||
}
|
||||
|
||||
private func progressView() -> some View {
|
||||
@@ -330,7 +422,7 @@ struct CIVideoView: View {
|
||||
.progressViewStyle(.circular)
|
||||
.frame(width: 16, height: 16)
|
||||
.tint(.white)
|
||||
.padding(11)
|
||||
.padding(smallView ? 0 : 11)
|
||||
}
|
||||
|
||||
private func progressCircle(_ progress: Int64, _ total: Int64) -> some View {
|
||||
@@ -342,7 +434,7 @@ struct CIVideoView: View {
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.frame(width: 16, height: 16)
|
||||
.padding([.trailing, .top], 11)
|
||||
.padding([.trailing, .top], smallView ? 0 : 11)
|
||||
}
|
||||
|
||||
// TODO encrypt: where file size is checked?
|
||||
@@ -382,7 +474,8 @@ struct CIVideoView: View {
|
||||
)
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now()) {
|
||||
m.stopPreviousRecPlay = url
|
||||
// Prevent feedback loop - setting `ChatModel`s property causes `onAppear` to be called on iOS17+
|
||||
if m.stopPreviousRecPlay != url { m.stopPreviousRecPlay = url }
|
||||
if let player = fullPlayer {
|
||||
player.play()
|
||||
var played = false
|
||||
@@ -419,10 +512,12 @@ struct CIVideoView: View {
|
||||
urlDecrypted = await file.fileSource?.decryptedGetOrCreate(&ChatModel.shared.filesToDelete)
|
||||
await MainActor.run {
|
||||
if let decrypted = urlDecrypted {
|
||||
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
|
||||
if !smallView {
|
||||
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
|
||||
}
|
||||
fullPlayer = AVPlayer(url: decrypted)
|
||||
}
|
||||
decryptionInProgress = true
|
||||
decryptionInProgress = false
|
||||
completed?()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,15 +15,26 @@ struct CIVoiceView: View {
|
||||
var chatItem: ChatItem
|
||||
let recordingFile: CIFile?
|
||||
let duration: Int
|
||||
@Binding var audioPlayer: AudioPlayer?
|
||||
@Binding var playbackState: VoiceMessagePlaybackState
|
||||
@Binding var playbackTime: TimeInterval?
|
||||
@State var audioPlayer: AudioPlayer? = nil
|
||||
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
|
||||
@State var playbackTime: TimeInterval? = nil
|
||||
|
||||
@Binding var allowMenu: Bool
|
||||
var smallViewSize: CGFloat?
|
||||
@State private var seek: (TimeInterval) -> Void = { _ in }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if chatItem.chatDir.sent {
|
||||
if smallViewSize != nil {
|
||||
HStack(spacing: 10) {
|
||||
player()
|
||||
playerTime()
|
||||
.allowsHitTesting(false)
|
||||
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
|
||||
playbackSlider()
|
||||
}
|
||||
}
|
||||
} else if chatItem.chatDir.sent {
|
||||
VStack (alignment: .trailing, spacing: 6) {
|
||||
HStack {
|
||||
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
|
||||
@@ -54,7 +65,13 @@ struct CIVoiceView: View {
|
||||
}
|
||||
|
||||
private func player() -> some View {
|
||||
VoiceMessagePlayer(
|
||||
let sizeMultiplier: CGFloat = if let sz = smallViewSize {
|
||||
voiceMessageSizeBasedOnSquareSize(sz) / 56
|
||||
} else {
|
||||
1
|
||||
}
|
||||
return VoiceMessagePlayer(
|
||||
chat: chat,
|
||||
chatItem: chatItem,
|
||||
recordingFile: recordingFile,
|
||||
recordingTime: TimeInterval(duration),
|
||||
@@ -63,7 +80,8 @@ struct CIVoiceView: View {
|
||||
audioPlayer: $audioPlayer,
|
||||
playbackState: $playbackState,
|
||||
playbackTime: $playbackTime,
|
||||
allowMenu: $allowMenu
|
||||
allowMenu: $allowMenu,
|
||||
sizeMultiplier: sizeMultiplier
|
||||
)
|
||||
}
|
||||
|
||||
@@ -119,6 +137,7 @@ struct VoiceMessagePlayerTime: View {
|
||||
}
|
||||
|
||||
struct VoiceMessagePlayer: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var chatItem: ChatItem
|
||||
@@ -130,7 +149,9 @@ struct VoiceMessagePlayer: View {
|
||||
@Binding var audioPlayer: AudioPlayer?
|
||||
@Binding var playbackState: VoiceMessagePlaybackState
|
||||
@Binding var playbackTime: TimeInterval?
|
||||
|
||||
@Binding var allowMenu: Bool
|
||||
var sizeMultiplier: CGFloat
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -190,49 +211,113 @@ struct VoiceMessagePlayer: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if audioPlayer == nil {
|
||||
let small = sizeMultiplier != 1
|
||||
audioPlayer = small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.audioPlayer : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.audioPlayer
|
||||
playbackState = (small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.playbackState : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.playbackState) ?? .noPlayback
|
||||
playbackTime = small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.playbackTime : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.playbackTime
|
||||
}
|
||||
seek = { to in audioPlayer?.seek(to) }
|
||||
audioPlayer?.onTimer = { playbackTime = $0 }
|
||||
let audioPath: URL? = if let recordingSource = getLoadedFileSource(recordingFile) {
|
||||
getAppFilePath(recordingSource.filePath)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
let chatId = chatModel.chatId
|
||||
let userId = chatModel.currentUser?.userId
|
||||
audioPlayer?.onTimer = {
|
||||
playbackTime = $0
|
||||
notifyStateChange()
|
||||
// Manual check here is needed because when this view is not visible, SwiftUI don't react on stopPreviousRecPlay, chatId and current user changes and audio keeps playing when it should stop
|
||||
if (audioPath != nil && chatModel.stopPreviousRecPlay != audioPath) || chatModel.chatId != chatId || chatModel.currentUser?.userId != userId {
|
||||
stopPlayback()
|
||||
}
|
||||
}
|
||||
audioPlayer?.onFinishPlayback = {
|
||||
playbackState = .noPlayback
|
||||
playbackTime = TimeInterval(0)
|
||||
notifyStateChange()
|
||||
}
|
||||
// One voice message was paused, then scrolled far from it, started to play another one, drop to stopped state
|
||||
if let audioPath, chatModel.stopPreviousRecPlay != audioPath {
|
||||
stopPlayback()
|
||||
}
|
||||
}
|
||||
.onChange(of: chatModel.stopPreviousRecPlay) { it in
|
||||
if let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
|
||||
chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) {
|
||||
audioPlayer?.stop()
|
||||
playbackState = .noPlayback
|
||||
playbackTime = TimeInterval(0)
|
||||
stopPlayback()
|
||||
}
|
||||
}
|
||||
.onChange(of: playbackState) { state in
|
||||
allowMenu = state == .paused || state == .noPlayback
|
||||
// Notify activeContentPreview in ChatPreviewView that playback is finished
|
||||
if state == .noPlayback, let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
|
||||
chatModel.stopPreviousRecPlay == getAppFilePath(recordingFileName) {
|
||||
chatModel.stopPreviousRecPlay = nil
|
||||
}
|
||||
}
|
||||
.onChange(of: chatModel.chatId) { _ in
|
||||
stopPlayback()
|
||||
}
|
||||
.onDisappear {
|
||||
if sizeMultiplier == 1 && chatModel.chatId == nil {
|
||||
stopPlayback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func playbackButton() -> some View {
|
||||
switch playbackState {
|
||||
case .noPlayback:
|
||||
Button {
|
||||
if let recordingSource = getLoadedFileSource(recordingFile) {
|
||||
startPlayback(recordingSource)
|
||||
}
|
||||
} label: {
|
||||
if sizeMultiplier != 1 {
|
||||
switch playbackState {
|
||||
case .noPlayback:
|
||||
playPauseIcon("play.fill", theme.colors.primary)
|
||||
}
|
||||
case .playing:
|
||||
Button {
|
||||
audioPlayer?.pause()
|
||||
playbackState = .paused
|
||||
} label: {
|
||||
.onTapGesture {
|
||||
if let recordingSource = getLoadedFileSource(recordingFile) {
|
||||
startPlayback(recordingSource)
|
||||
}
|
||||
}
|
||||
case .playing:
|
||||
playPauseIcon("pause.fill", theme.colors.primary)
|
||||
}
|
||||
case .paused:
|
||||
Button {
|
||||
audioPlayer?.play()
|
||||
playbackState = .playing
|
||||
} label: {
|
||||
.onTapGesture {
|
||||
audioPlayer?.pause()
|
||||
playbackState = .paused
|
||||
notifyStateChange()
|
||||
}
|
||||
case .paused:
|
||||
playPauseIcon("play.fill", theme.colors.primary)
|
||||
.onTapGesture {
|
||||
audioPlayer?.play()
|
||||
playbackState = .playing
|
||||
notifyStateChange()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
switch playbackState {
|
||||
case .noPlayback:
|
||||
Button {
|
||||
if let recordingSource = getLoadedFileSource(recordingFile) {
|
||||
startPlayback(recordingSource)
|
||||
}
|
||||
} label: {
|
||||
playPauseIcon("play.fill", theme.colors.primary)
|
||||
}
|
||||
case .playing:
|
||||
Button {
|
||||
audioPlayer?.pause()
|
||||
playbackState = .paused
|
||||
notifyStateChange()
|
||||
} label: {
|
||||
playPauseIcon("pause.fill", theme.colors.primary)
|
||||
}
|
||||
case .paused:
|
||||
Button {
|
||||
audioPlayer?.play()
|
||||
playbackState = .playing
|
||||
notifyStateChange()
|
||||
} label: {
|
||||
playPauseIcon("play.fill", theme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -242,28 +327,49 @@ struct VoiceMessagePlayer: View {
|
||||
Image(systemName: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 20, height: 20)
|
||||
.frame(width: 20 * sizeMultiplier, height: 20 * sizeMultiplier)
|
||||
.foregroundColor(color)
|
||||
.padding(.leading, image == "play.fill" ? 4 : 0)
|
||||
.frame(width: 56, height: 56)
|
||||
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
|
||||
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
|
||||
.clipShape(Circle())
|
||||
if recordingTime > 0 {
|
||||
ProgressCircle(length: recordingTime, progress: $playbackTime)
|
||||
.frame(width: 53, height: 53) // this + ProgressCircle lineWidth = background circle diameter
|
||||
.frame(width: 53 * sizeMultiplier, height: 53 * sizeMultiplier) // this + ProgressCircle lineWidth = background circle diameter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View {
|
||||
Button {
|
||||
Task {
|
||||
if let user = chatModel.currentUser {
|
||||
await receiveFile(user: user, fileId: recordingFile.fileId)
|
||||
Group {
|
||||
if sizeMultiplier != 1 {
|
||||
playPauseIcon(icon, theme.colors.primary)
|
||||
.onTapGesture {
|
||||
Task {
|
||||
if let user = chatModel.currentUser {
|
||||
await receiveFile(user: user, fileId: recordingFile.fileId)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
Task {
|
||||
if let user = chatModel.currentUser {
|
||||
await receiveFile(user: user, fileId: recordingFile.fileId)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
playPauseIcon(icon, theme.colors.primary)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
playPauseIcon(icon, theme.colors.primary)
|
||||
}
|
||||
}
|
||||
|
||||
func notifyStateChange() {
|
||||
if sizeMultiplier != 1 {
|
||||
VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)] = VoiceItemState(audioPlayer: audioPlayer, playbackState: playbackState, playbackTime: playbackTime)
|
||||
} else {
|
||||
VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)] = VoiceItemState(audioPlayer: audioPlayer, playbackState: playbackState, playbackTime: playbackTime)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,33 +394,96 @@ struct VoiceMessagePlayer: View {
|
||||
Image(systemName: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: size, height: size)
|
||||
.frame(width: size * sizeMultiplier, height: size * sizeMultiplier)
|
||||
.foregroundColor(Color(uiColor: .tertiaryLabel))
|
||||
.frame(width: 56, height: 56)
|
||||
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
|
||||
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
private func loadingIcon() -> some View {
|
||||
ProgressView()
|
||||
.frame(width: 30, height: 30)
|
||||
.frame(width: 56, height: 56)
|
||||
.frame(width: 30 * sizeMultiplier, height: 30 * sizeMultiplier)
|
||||
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
|
||||
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
private func startPlayback(_ recordingSource: CryptoFile) {
|
||||
chatModel.stopPreviousRecPlay = getAppFilePath(recordingSource.filePath)
|
||||
let audioPath = getAppFilePath(recordingSource.filePath)
|
||||
let chatId = chatModel.chatId
|
||||
let userId = chatModel.currentUser?.userId
|
||||
chatModel.stopPreviousRecPlay = audioPath
|
||||
audioPlayer = AudioPlayer(
|
||||
onTimer: { playbackTime = $0 },
|
||||
onTimer: {
|
||||
playbackTime = $0
|
||||
notifyStateChange()
|
||||
// Manual check here is needed because when this view is not visible, SwiftUI don't react on stopPreviousRecPlay, chatId and current user changes and audio keeps playing when it should stop
|
||||
if chatModel.stopPreviousRecPlay != audioPath || chatModel.chatId != chatId || chatModel.currentUser?.userId != userId {
|
||||
stopPlayback()
|
||||
}
|
||||
},
|
||||
onFinishPlayback: {
|
||||
playbackState = .noPlayback
|
||||
playbackTime = TimeInterval(0)
|
||||
notifyStateChange()
|
||||
}
|
||||
)
|
||||
audioPlayer?.start(fileSource: recordingSource, at: playbackTime)
|
||||
playbackState = .playing
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private func stopPlayback() {
|
||||
audioPlayer?.stop()
|
||||
playbackState = .noPlayback
|
||||
playbackTime = TimeInterval(0)
|
||||
notifyStateChange()
|
||||
}
|
||||
}
|
||||
|
||||
func voiceMessageSizeBasedOnSquareSize(_ squareSize: CGFloat) -> CGFloat {
|
||||
let squareToCircleRatio = 0.935
|
||||
return squareSize + squareSize * (1 - squareToCircleRatio)
|
||||
}
|
||||
|
||||
class VoiceItemState {
|
||||
var audioPlayer: AudioPlayer?
|
||||
var playbackState: VoiceMessagePlaybackState
|
||||
var playbackTime: TimeInterval?
|
||||
|
||||
init(audioPlayer: AudioPlayer? = nil, playbackState: VoiceMessagePlaybackState, playbackTime: TimeInterval? = nil) {
|
||||
self.audioPlayer = audioPlayer
|
||||
self.playbackState = playbackState
|
||||
self.playbackTime = playbackTime
|
||||
}
|
||||
|
||||
static func id(_ chat: Chat, _ chatItem: ChatItem) -> String {
|
||||
"\(chat.id) \(chatItem.id)"
|
||||
}
|
||||
|
||||
static func id(_ chatInfo: ChatInfo, _ chatItem: ChatItem) -> String {
|
||||
"\(chatInfo.id) \(chatItem.id)"
|
||||
}
|
||||
|
||||
static func stopVoiceInSmallView(_ chatInfo: ChatInfo, _ chatItem: ChatItem) {
|
||||
let id = id(chatInfo, chatItem)
|
||||
if let item = smallView[id] {
|
||||
item.audioPlayer?.stop()
|
||||
ChatModel.shared.stopPreviousRecPlay = nil
|
||||
}
|
||||
}
|
||||
|
||||
static func stopVoiceInChatView(_ chatInfo: ChatInfo, _ chatItem: ChatItem) {
|
||||
let id = id(chatInfo, chatItem)
|
||||
if let item = chatView[id] {
|
||||
item.audioPlayer?.stop()
|
||||
ChatModel.shared.stopPreviousRecPlay = nil
|
||||
}
|
||||
}
|
||||
|
||||
static var smallView: [String: VoiceItemState] = [:]
|
||||
static var chatView: [String: VoiceItemState] = [:]
|
||||
}
|
||||
|
||||
struct CIVoiceView_Previews: PreviewProvider {
|
||||
@@ -339,15 +508,12 @@ struct CIVoiceView_Previews: PreviewProvider {
|
||||
chatItem: ChatItem.getVoiceMsgContentSample(),
|
||||
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
|
||||
duration: 30,
|
||||
audioPlayer: .constant(nil),
|
||||
playbackState: .constant(.playing),
|
||||
playbackTime: .constant(TimeInterval(20)),
|
||||
allowMenu: Binding.constant(true)
|
||||
)
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 360))
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ struct DeletedItemView: View {
|
||||
.padding(.leading, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(chatItemFrameColor(chatItem, theme))
|
||||
.cornerRadius(18)
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,21 +13,23 @@ import SimpleXChat
|
||||
|
||||
struct FramedCIVoiceView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@ObservedObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
let recordingFile: CIFile?
|
||||
let duration: Int
|
||||
|
||||
|
||||
@State var audioPlayer: AudioPlayer? = nil
|
||||
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
|
||||
@State var playbackTime: TimeInterval? = nil
|
||||
|
||||
@Binding var allowMenu: Bool
|
||||
|
||||
@Binding var audioPlayer: AudioPlayer?
|
||||
@Binding var playbackState: VoiceMessagePlaybackState
|
||||
@Binding var playbackTime: TimeInterval?
|
||||
|
||||
|
||||
@State private var seek: (TimeInterval) -> Void = { _ in }
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VoiceMessagePlayer(
|
||||
chat: chat,
|
||||
chatItem: chatItem,
|
||||
recordingFile: recordingFile,
|
||||
recordingTime: TimeInterval(duration),
|
||||
@@ -36,7 +38,8 @@ struct FramedCIVoiceView: View {
|
||||
audioPlayer: $audioPlayer,
|
||||
playbackState: $playbackState,
|
||||
playbackTime: $playbackTime,
|
||||
allowMenu: $allowMenu
|
||||
allowMenu: $allowMenu,
|
||||
sizeMultiplier: 1
|
||||
)
|
||||
VoiceMessagePlayerTime(
|
||||
recordingTime: TimeInterval(duration),
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -70,7 +70,6 @@ struct CIMsgError: View {
|
||||
.padding(.leading, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.cornerRadius(18)
|
||||
.textSelection(.disabled)
|
||||
.onTapGesture(perform: onTap)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ struct MarkedDeletedItemView: View {
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(chatItemFrameColor(chatItem, theme))
|
||||
.cornerRadius(18)
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ struct ChatItemForwardingView: View {
|
||||
@FocusState private var searchFocused
|
||||
@State private var alert: SomeAlert?
|
||||
@State private var hasSimplexLink_: Bool?
|
||||
private let chatsToForwardTo = filterChatsToForwardTo()
|
||||
private let chatsToForwardTo = filterChatsToForwardTo(chats: ChatModel.shared.chats)
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
@@ -67,22 +67,6 @@ struct ChatItemForwardingView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func foundChat(_ chat: Chat, _ searchStr: String) -> Bool {
|
||||
let cInfo = chat.chatInfo
|
||||
return switch cInfo {
|
||||
case let .direct(contact):
|
||||
viewNameContains(cInfo, searchStr) ||
|
||||
contact.profile.displayName.localizedLowercase.contains(searchStr) ||
|
||||
contact.fullName.localizedLowercase.contains(searchStr)
|
||||
default:
|
||||
viewNameContains(cInfo, searchStr)
|
||||
}
|
||||
|
||||
func viewNameContains(_ cInfo: ChatInfo, _ s: String) -> Bool {
|
||||
cInfo.chatViewName.localizedLowercase.contains(s)
|
||||
}
|
||||
}
|
||||
|
||||
private func prohibitedByPref(_ chat: Chat) -> Bool {
|
||||
// preference checks should match checks in compose view
|
||||
let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
|
||||
@@ -162,27 +146,6 @@ struct ChatItemForwardingView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func filterChatsToForwardTo() -> [Chat] {
|
||||
var filteredChats = ChatModel.shared.chats.filter { c in
|
||||
c.chatInfo.chatType != .local && canForwardToChat(c)
|
||||
}
|
||||
if let privateNotes = ChatModel.shared.chats.first(where: { $0.chatInfo.chatType == .local }) {
|
||||
filteredChats.insert(privateNotes, at: 0)
|
||||
}
|
||||
return filteredChats
|
||||
}
|
||||
|
||||
private func canForwardToChat(_ chat: Chat) -> Bool {
|
||||
switch chat.chatInfo {
|
||||
case let .direct(contact): contact.sendMsgEnabled && !contact.nextSendGrpInv
|
||||
case let .group(groupInfo): groupInfo.sendMsgEnabled
|
||||
case let .local(noteFolder): noteFolder.sendMsgEnabled
|
||||
case .contactRequest: false
|
||||
case .contactConnection: false
|
||||
case .invalidJSON: false
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ChatItemForwardingView(
|
||||
ci: ChatItem.getSample(1, .directSnd, .now, "hello"),
|
||||
@@ -190,3 +153,4 @@ private func canForwardToChat(_ chat: Chat) -> Bool {
|
||||
composeState: Binding.constant(ComposeState(message: "hello"))
|
||||
).environmentObject(CurrentColors.toAppTheme())
|
||||
}
|
||||
|
||||
|
||||
@@ -232,7 +232,7 @@ struct ChatItemInfoView: View {
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(chatItemFrameColor(ci, theme))
|
||||
.cornerRadius(18)
|
||||
.modifier(ChatItemClipped())
|
||||
.contextMenu {
|
||||
if itemVersion.msgContent.text != "" {
|
||||
Button {
|
||||
@@ -302,7 +302,7 @@ struct ChatItemInfoView: View {
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(quotedMsgFrameColor(qi, theme))
|
||||
.cornerRadius(18)
|
||||
.modifier(ChatItemClipped())
|
||||
.contextMenu {
|
||||
if qi.text != "" {
|
||||
Button {
|
||||
@@ -415,7 +415,7 @@ struct ChatItemInfoView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
LazyVStack(alignment: .leading, spacing: 12) {
|
||||
let mss = membersStatuses(memberDeliveryStatuses)
|
||||
if !mss.isEmpty {
|
||||
ForEach(mss, id: \.0.groupMemberId) { memberStatus in
|
||||
@@ -428,7 +428,7 @@ struct ChatItemInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus, Bool?)] {
|
||||
private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, GroupSndStatus, Bool?)] {
|
||||
memberDeliveryStatuses.compactMap({ mds in
|
||||
if let mem = chatModel.getGroupMember(mds.groupMemberId) {
|
||||
return (mem.wrapped, mds.memberDeliveryStatus, mds.sentViaProxy)
|
||||
@@ -438,7 +438,7 @@ struct ChatItemInfoView: View {
|
||||
})
|
||||
}
|
||||
|
||||
private func memberDeliveryStatusView(_ member: GroupMember, _ status: CIStatus, _ sentViaProxy: Bool?) -> some View {
|
||||
private func memberDeliveryStatusView(_ member: GroupMember, _ status: GroupSndStatus, _ sentViaProxy: Bool?) -> some View {
|
||||
HStack{
|
||||
ProfileImage(imageStr: member.image, size: 30)
|
||||
.padding(.trailing, 2)
|
||||
@@ -450,23 +450,19 @@ struct ChatItemInfoView: View {
|
||||
.foregroundColor(theme.colors.secondary).opacity(0.67)
|
||||
}
|
||||
let v = Group {
|
||||
if let (icon, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary) {
|
||||
switch status {
|
||||
case .sndRcvd:
|
||||
ZStack(alignment: .trailing) {
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(statusColor.opacity(0.67))
|
||||
.padding(.trailing, 6)
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(statusColor.opacity(0.67))
|
||||
}
|
||||
default:
|
||||
let (icon, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary)
|
||||
switch status {
|
||||
case .rcvd:
|
||||
ZStack(alignment: .trailing) {
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(statusColor)
|
||||
.foregroundColor(statusColor.opacity(0.67))
|
||||
.padding(.trailing, 6)
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(statusColor.opacity(0.67))
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "ellipsis")
|
||||
.foregroundColor(Color.secondary)
|
||||
default:
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(statusColor)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,28 +16,20 @@ struct ChatItemView: View {
|
||||
var maxWidth: CGFloat = .infinity
|
||||
@Binding var revealed: Bool
|
||||
@Binding var allowMenu: Bool
|
||||
@Binding var audioPlayer: AudioPlayer?
|
||||
@Binding var playbackState: VoiceMessagePlaybackState
|
||||
@Binding var playbackTime: TimeInterval?
|
||||
|
||||
init(
|
||||
chat: Chat,
|
||||
chatItem: ChatItem,
|
||||
showMember: Bool = false,
|
||||
maxWidth: CGFloat = .infinity,
|
||||
revealed: Binding<Bool>,
|
||||
allowMenu: Binding<Bool> = .constant(false),
|
||||
audioPlayer: Binding<AudioPlayer?> = .constant(nil),
|
||||
playbackState: Binding<VoiceMessagePlaybackState> = .constant(.noPlayback),
|
||||
playbackTime: Binding<TimeInterval?> = .constant(nil)
|
||||
allowMenu: Binding<Bool> = .constant(false)
|
||||
) {
|
||||
self.chat = chat
|
||||
self.chatItem = chatItem
|
||||
self.maxWidth = maxWidth
|
||||
_revealed = revealed
|
||||
_allowMenu = allowMenu
|
||||
_audioPlayer = audioPlayer
|
||||
_playbackState = playbackState
|
||||
_playbackTime = playbackTime
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -48,7 +40,7 @@ struct ChatItemView: View {
|
||||
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
|
||||
EmojiItemView(chat: chat, chatItem: ci)
|
||||
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
|
||||
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu)
|
||||
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, allowMenu: $allowMenu)
|
||||
} else if ci.content.msgContent == nil {
|
||||
ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
|
||||
} else {
|
||||
@@ -68,9 +60,7 @@ struct ChatItemView: View {
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
.map { dropImagePrefix($0) }
|
||||
.flatMap { Data(base64Encoded: $0) }
|
||||
.flatMap { UIImage(data: $0) }
|
||||
.flatMap { UIImage(base64Encoded: $0) }
|
||||
let adjustedMaxWidth = {
|
||||
if let preview, preview.size.width <= preview.size.height {
|
||||
maxWidth * 0.75
|
||||
@@ -86,10 +76,7 @@ struct ChatItemView: View {
|
||||
maxWidth: maxWidth,
|
||||
imgWidth: adjustedMaxWidth,
|
||||
videoWidth: adjustedMaxWidth,
|
||||
allowMenu: $allowMenu,
|
||||
audioPlayer: $audioPlayer,
|
||||
playbackState: $playbackState,
|
||||
playbackTime: $playbackTime
|
||||
allowMenu: $allowMenu
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ struct ChatView: View {
|
||||
@State private var searchText: String = ""
|
||||
@FocusState private var searchFocussed
|
||||
// opening GroupMemberInfoView on member icon
|
||||
@State private var membersLoaded = false
|
||||
@State private var selectedMember: GMember? = nil
|
||||
// opening GroupLinkView on link button (incognito)
|
||||
@State private var showGroupLinkSheet: Bool = false
|
||||
@@ -46,9 +45,14 @@ struct ChatView: View {
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
viewBody
|
||||
let v = viewBody
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
.keyboardPadding()
|
||||
if (searchMode) {
|
||||
v.toolbarBackground(.thinMaterial, for: .navigationBar)
|
||||
} else {
|
||||
v.toolbarBackground(.visible, for: .navigationBar)
|
||||
}
|
||||
} else {
|
||||
viewBody
|
||||
}
|
||||
@@ -82,7 +86,6 @@ struct ChatView: View {
|
||||
)
|
||||
.disabled(!cInfo.sendMsgEnabled)
|
||||
}
|
||||
.padding(.top, 1)
|
||||
.navigationTitle(cInfo.chatViewName)
|
||||
.background(theme.colors.background)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -93,6 +96,7 @@ struct ChatView: View {
|
||||
}
|
||||
.onChange(of: chatModel.chatId) { cId in
|
||||
showChatInfoSheet = false
|
||||
stopAudioPlayer()
|
||||
if let cId {
|
||||
if let c = chatModel.getChat(cId) {
|
||||
chat = c
|
||||
@@ -114,6 +118,7 @@ struct ChatView: View {
|
||||
.environmentObject(scrollModel)
|
||||
.onDisappear {
|
||||
VideoPlayerView.players.removeAll()
|
||||
stopAudioPlayer()
|
||||
if chatModel.chatId == cInfo.id && !presentationMode.wrappedValue.isPresented {
|
||||
chatModel.chatId = nil
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
@@ -121,7 +126,8 @@ struct ChatView: View {
|
||||
chatModel.chatItemStatuses = [:]
|
||||
chatModel.reversedChatItems = []
|
||||
chatModel.groupMembers = []
|
||||
membersLoaded = false
|
||||
chatModel.groupMembersIndexes.removeAll()
|
||||
chatModel.membersLoaded = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,7 +169,7 @@ struct ChatView: View {
|
||||
}
|
||||
} else if case let .group(groupInfo) = cInfo {
|
||||
Button {
|
||||
Task { await loadGroupMembers(groupInfo) { showChatInfoSheet = true } }
|
||||
Task { await chatModel.loadGroupMembers(groupInfo) { showChatInfoSheet = true } }
|
||||
} label: {
|
||||
ChatInfoToolbar(chat: chat)
|
||||
.tint(theme.colors.primary)
|
||||
@@ -249,18 +255,7 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadGroupMembers(_ groupInfo: GroupInfo, updateView: @escaping () -> Void = {}) async {
|
||||
let groupMembers = await apiListMembers(groupInfo.groupId)
|
||||
await MainActor.run {
|
||||
if chatModel.chatId == groupInfo.id {
|
||||
chatModel.groupMembers = groupMembers.map { GMember.init($0) }
|
||||
membersLoaded = true
|
||||
updateView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func initChatView() {
|
||||
let cInfo = chat.chatInfo
|
||||
// This check prevents the call to apiContactInfo after the app is suspended, and the database is closed.
|
||||
@@ -321,8 +316,9 @@ struct ChatView: View {
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
.background(.thinMaterial)
|
||||
}
|
||||
|
||||
|
||||
private func voiceWithoutFrame(_ ci: ChatItem) -> Bool {
|
||||
ci.content.msgContent?.isVoice == true && ci.content.text.count == 0 && ci.quotedItem == nil && ci.meta.itemForwarded == nil
|
||||
}
|
||||
@@ -384,7 +380,7 @@ struct ChatView: View {
|
||||
|
||||
@ViewBuilder private func connectingText() -> some View {
|
||||
if case let .direct(contact) = chat.chatInfo,
|
||||
!contact.ready,
|
||||
!contact.sndReady,
|
||||
contact.active,
|
||||
!contact.nextSendGrpInv {
|
||||
Text("connecting…")
|
||||
@@ -410,7 +406,7 @@ struct ChatView: View {
|
||||
|
||||
init() {
|
||||
unreadChatItemCounts = UnreadChatItemCounts(
|
||||
totalBelow: .zero,
|
||||
isNearBottom: true,
|
||||
unreadBelow: .zero
|
||||
)
|
||||
events
|
||||
@@ -425,9 +421,9 @@ struct ChatView: View {
|
||||
itemsInView
|
||||
}
|
||||
}
|
||||
.throttle(for: .seconds(0.2), scheduler: DispatchQueue.main, latest: true)
|
||||
.map { ChatModel.shared.unreadChatItemCounts(itemsInView: $0) }
|
||||
.removeDuplicates()
|
||||
.throttle(for: .seconds(0.2), scheduler: DispatchQueue.main, latest: true)
|
||||
.assign(to: \.unreadChatItemCounts, on: self)
|
||||
.store(in: &bag)
|
||||
}
|
||||
@@ -475,11 +471,9 @@ struct ChatView: View {
|
||||
.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
.onTapGesture {
|
||||
if let latestUnreadItem = filtered(chatModel.reversedChatItems).last(where: { $0.isRcvNew }) {
|
||||
scrollModel.scrollToItem(id: latestUnreadItem.id)
|
||||
}
|
||||
scrollModel.scrollToBottom()
|
||||
}
|
||||
} else if counts.totalBelow > 16 {
|
||||
} else if !counts.isNearBottom {
|
||||
circleButton {
|
||||
Image(systemName: "chevron.down")
|
||||
.foregroundColor(theme.colors.primary)
|
||||
@@ -532,7 +526,7 @@ struct ChatView: View {
|
||||
private func addMembersButton() -> some View {
|
||||
Button {
|
||||
if case let .group(gInfo) = chat.chatInfo {
|
||||
Task { await loadGroupMembers(gInfo) { showAddMembersSheet = true } }
|
||||
Task { await chatModel.loadGroupMembers(gInfo) { showAddMembersSheet = true } }
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "person.crop.circle.badge.plus")
|
||||
@@ -597,16 +591,19 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func stopAudioPlayer() {
|
||||
VoiceItemState.chatView.values.forEach { $0.audioPlayer?.stop() }
|
||||
VoiceItemState.chatView = [:]
|
||||
}
|
||||
|
||||
@ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View {
|
||||
ChatItemWithMenu(
|
||||
chat: chat,
|
||||
chatItem: ci,
|
||||
maxWidth: maxWidth,
|
||||
itemWidth: maxWidth,
|
||||
composeState: $composeState,
|
||||
selectedMember: $selectedMember,
|
||||
revealedChatItem: $revealedChatItem,
|
||||
chatView: self
|
||||
revealedChatItem: $revealedChatItem
|
||||
)
|
||||
}
|
||||
|
||||
@@ -614,13 +611,11 @@ struct ChatView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@ObservedObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
var maxWidth: CGFloat
|
||||
@State var itemWidth: CGFloat
|
||||
let chatItem: ChatItem
|
||||
let maxWidth: CGFloat
|
||||
@Binding var composeState: ComposeState
|
||||
@Binding var selectedMember: GMember?
|
||||
@Binding var revealedChatItem: ChatItem?
|
||||
var chatView: ChatView
|
||||
|
||||
@State private var deletingItem: ChatItem? = nil
|
||||
@State private var showDeleteMessage = false
|
||||
@@ -632,10 +627,6 @@ struct ChatView: View {
|
||||
|
||||
@State private var allowMenu: Bool = true
|
||||
|
||||
@State private var audioPlayer: AudioPlayer?
|
||||
@State private var playbackState: VoiceMessagePlaybackState = .noPlayback
|
||||
@State private var playbackTime: TimeInterval?
|
||||
|
||||
var revealed: Bool { chatItem == revealedChatItem }
|
||||
|
||||
var body: some View {
|
||||
@@ -655,23 +646,38 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
markRead(
|
||||
chatItems: range.flatMap { m.reversedChatItems[$0] }
|
||||
?? [chatItem]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func markRead(chatItems: Array<ChatItem>.SubSequence) {
|
||||
let unreadItems = chatItems.filter { $0.isRcvNew }
|
||||
if unreadItems.isEmpty { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
||||
if m.chatId == chat.chatInfo.id {
|
||||
Task {
|
||||
for unreadItem in unreadItems {
|
||||
await apiMarkChatItemRead(chat.chatInfo, unreadItem)
|
||||
if let range {
|
||||
if let items = unreadItems(range) {
|
||||
waitToMarkRead {
|
||||
for ci in items {
|
||||
await apiMarkChatItemRead(chat.chatInfo, ci)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if chatItem.isRcvNew {
|
||||
waitToMarkRead {
|
||||
await apiMarkChatItemRead(chat.chatInfo, chatItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func unreadItems(_ range: ClosedRange<Int>) -> [ChatItem]? {
|
||||
let items = range.compactMap { i in
|
||||
if i >= 0 && i < m.reversedChatItems.count {
|
||||
let ci = m.reversedChatItems[i]
|
||||
return if ci.isRcvNew { ci } else { nil }
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return if items.isEmpty { nil } else { items }
|
||||
}
|
||||
|
||||
private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
||||
if m.chatId == chat.chatInfo.id {
|
||||
Task(operation: op)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -688,7 +694,12 @@ struct ChatView: View {
|
||||
if prevItem == nil || showMemberImage(member, prevItem) || prevMember != nil {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if ci.content.showMemberName {
|
||||
Text(memberNames(member, prevMember, memCount))
|
||||
let t = if memCount == 1 && member.memberRole > .member {
|
||||
Text(member.memberRole.text + " ").fontWeight(.semibold) + Text(member.displayName)
|
||||
} else {
|
||||
Text(memberNames(member, prevMember, memCount))
|
||||
}
|
||||
t
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
@@ -698,11 +709,11 @@ struct ChatView: View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
ProfileImage(imageStr: member.memberProfile.image, size: memberImageSize, backgroundColor: theme.colors.background)
|
||||
.onTapGesture {
|
||||
if chatView.membersLoaded {
|
||||
if m.membersLoaded {
|
||||
selectedMember = m.getGroupMember(member.groupMemberId)
|
||||
} else {
|
||||
Task {
|
||||
await chatView.loadGroupMembers(groupInfo) {
|
||||
await m.loadGroupMembers(groupInfo) {
|
||||
selectedMember = m.getGroupMember(member.groupMemberId)
|
||||
}
|
||||
}
|
||||
@@ -714,19 +725,19 @@ struct ChatView: View {
|
||||
chatItemWithMenu(ci, range, maxWidth)
|
||||
}
|
||||
}
|
||||
.padding(.top, 5)
|
||||
.padding(.bottom, 5)
|
||||
.padding(.trailing)
|
||||
.padding(.leading, 12)
|
||||
} else {
|
||||
chatItemWithMenu(ci, range, maxWidth)
|
||||
.padding(.top, 5)
|
||||
.padding(.bottom, 5)
|
||||
.padding(.trailing)
|
||||
.padding(.leading, memberImageSize + 8 + 12)
|
||||
}
|
||||
} else {
|
||||
chatItemWithMenu(ci, range, maxWidth)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 5)
|
||||
.padding(.bottom, 5)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -749,11 +760,9 @@ struct ChatView: View {
|
||||
chatItem: ci,
|
||||
maxWidth: maxWidth,
|
||||
revealed: .constant(revealed),
|
||||
allowMenu: $allowMenu,
|
||||
audioPlayer: $audioPlayer,
|
||||
playbackState: $playbackState,
|
||||
playbackTime: $playbackTime
|
||||
allowMenu: $allowMenu
|
||||
)
|
||||
.modifier(ChatItemClipped(ci))
|
||||
.contextMenu { menu(ci, range, live: composeState.liveMessage != nil) }
|
||||
.accessibilityLabel("")
|
||||
if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 {
|
||||
@@ -778,14 +787,6 @@ struct ChatView: View {
|
||||
}
|
||||
.frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
|
||||
.onDisappear {
|
||||
if ci.content.msgContent?.isVoice == true {
|
||||
allowMenu = true
|
||||
audioPlayer?.stop()
|
||||
playbackState = .noPlayback
|
||||
playbackTime = TimeInterval(0)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showChatItemInfoSheet, onDismiss: {
|
||||
chatItemInfo = nil
|
||||
}) {
|
||||
@@ -1096,7 +1097,7 @@ struct ChatView: View {
|
||||
chatItemInfo = ciInfo
|
||||
}
|
||||
if case let .group(gInfo) = chat.chatInfo {
|
||||
await chatView.loadGroupMembers(gInfo)
|
||||
await m.loadGroupMembers(gInfo)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiGetChatItemInfo error: \(responseError(error))")
|
||||
|
||||
@@ -32,9 +32,8 @@ struct ComposeFileView: View {
|
||||
}
|
||||
.padding(.vertical, 1)
|
||||
.padding(.trailing, 12)
|
||||
.frame(height: 50)
|
||||
.frame(height: 54)
|
||||
.background(theme.appColors.sentMessage)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,7 @@ struct ComposeImageView: View {
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
let imgs: [UIImage] = images.compactMap { image in
|
||||
if let data = Data(base64Encoded: dropImagePrefix(image)) {
|
||||
return UIImage(data: data)
|
||||
}
|
||||
return nil
|
||||
UIImage(base64Encoded: image)
|
||||
}
|
||||
if imgs.count == 0 {
|
||||
ProgressView()
|
||||
@@ -49,8 +46,8 @@ struct ComposeImageView: View {
|
||||
.padding(.vertical, 1)
|
||||
.padding(.trailing, 12)
|
||||
.background(theme.appColors.sentMessage)
|
||||
.frame(minHeight: 54)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,35 +10,6 @@ import SwiftUI
|
||||
import LinkPresentation
|
||||
import SimpleXChat
|
||||
|
||||
func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) {
|
||||
logger.debug("getLinkMetadata: fetching URL preview")
|
||||
LPMetadataProvider().startFetchingMetadata(for: url){ metadata, error in
|
||||
if let e = error {
|
||||
logger.error("Error retrieving link metadata: \(e.localizedDescription)")
|
||||
}
|
||||
if let metadata = metadata,
|
||||
let imageProvider = metadata.imageProvider,
|
||||
imageProvider.canLoadObject(ofClass: UIImage.self) {
|
||||
imageProvider.loadObject(ofClass: UIImage.self){ object, error in
|
||||
var linkPreview: LinkPreview? = nil
|
||||
if let error = error {
|
||||
logger.error("Couldn't load image preview from link metadata with error: \(error.localizedDescription)")
|
||||
} else {
|
||||
if let image = object as? UIImage,
|
||||
let resized = resizeImageToStrSize(image, maxDataSize: 14000),
|
||||
let title = metadata.title,
|
||||
let uri = metadata.originalURL {
|
||||
linkPreview = LinkPreview(uri: uri, title: title, image: resized)
|
||||
}
|
||||
}
|
||||
cb(linkPreview)
|
||||
}
|
||||
} else {
|
||||
cb(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ComposeLinkView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
let linkPreview: LinkPreview?
|
||||
@@ -63,14 +34,13 @@ struct ComposeLinkView: View {
|
||||
.padding(.vertical, 1)
|
||||
.padding(.trailing, 12)
|
||||
.background(theme.appColors.sentMessage)
|
||||
.frame(minHeight: 54)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
private func linkPreviewView(_ linkPreview: LinkPreview) -> some View {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
if let data = Data(base64Encoded: dropImagePrefix(linkPreview.image)),
|
||||
let uiImage = UIImage(data: data) {
|
||||
if let uiImage = UIImage(base64Encoded: linkPreview.image) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
|
||||
@@ -284,8 +284,10 @@ struct ComposeView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
if chat.chatInfo.contact?.nextSendGrpInv ?? false {
|
||||
ContextInvitingContactMemberView()
|
||||
Divider()
|
||||
}
|
||||
// preference checks should match checks in forwarding list
|
||||
let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
|
||||
@@ -293,10 +295,13 @@ struct ComposeView: View {
|
||||
let voiceProhibited = composeState.voicePreview && !chat.chatInfo.featureEnabled(.voice)
|
||||
if simplexLinkProhibited {
|
||||
msgNotAllowedView("SimpleX links not allowed", icon: "link")
|
||||
Divider()
|
||||
} else if fileProhibited {
|
||||
msgNotAllowedView("Files and media not allowed", icon: "doc")
|
||||
Divider()
|
||||
} else if voiceProhibited {
|
||||
msgNotAllowedView("Voice messages not allowed", icon: "mic")
|
||||
Divider()
|
||||
}
|
||||
contextItemView()
|
||||
switch (composeState.editing, composeState.preview) {
|
||||
@@ -359,7 +364,6 @@ struct ComposeView: View {
|
||||
: theme.colors.primary
|
||||
)
|
||||
.padding(.trailing, 12)
|
||||
.background(theme.colors.background)
|
||||
.disabled(!chat.userCanSend)
|
||||
|
||||
if chat.userIsObserver {
|
||||
@@ -377,6 +381,7 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(.thinMaterial)
|
||||
.onChange(of: composeState.message) { msg in
|
||||
if composeState.linkPreviewAllowed {
|
||||
if msg.count > 0 {
|
||||
@@ -625,6 +630,7 @@ struct ComposeView: View {
|
||||
cancelPreview: cancelLinkPreview,
|
||||
cancelEnabled: !composeState.inProgress
|
||||
)
|
||||
Divider()
|
||||
case let .mediaPreviews(mediaPreviews: media):
|
||||
ComposeImageView(
|
||||
images: media.map { (img, _) in img },
|
||||
@@ -633,6 +639,7 @@ struct ComposeView: View {
|
||||
chosenMedia = []
|
||||
},
|
||||
cancelEnabled: !composeState.editing && !composeState.inProgress)
|
||||
Divider()
|
||||
case let .voicePreview(recordingFileName, _):
|
||||
ComposeVoiceView(
|
||||
recordingFileName: recordingFileName,
|
||||
@@ -645,6 +652,7 @@ struct ComposeView: View {
|
||||
cancelEnabled: !composeState.editing && !composeState.inProgress,
|
||||
stopPlayback: $stopPlayback
|
||||
)
|
||||
Divider()
|
||||
case let .filePreview(fileName, _):
|
||||
ComposeFileView(
|
||||
fileName: fileName,
|
||||
@@ -652,6 +660,7 @@ struct ComposeView: View {
|
||||
composeState = composeState.copy(preview: .noPreview)
|
||||
},
|
||||
cancelEnabled: !composeState.editing && !composeState.inProgress)
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -661,10 +670,9 @@ struct ComposeView: View {
|
||||
Text(reason).italic()
|
||||
}
|
||||
.padding(12)
|
||||
.frame(minHeight: 50)
|
||||
.frame(minHeight: 54)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.padding(.top, 8)
|
||||
.background(.thinMaterial)
|
||||
}
|
||||
|
||||
@ViewBuilder private func contextItemView() -> some View {
|
||||
@@ -678,6 +686,7 @@ struct ComposeView: View {
|
||||
contextIcon: "arrowshape.turn.up.left",
|
||||
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
|
||||
)
|
||||
Divider()
|
||||
case let .editingItem(chatItem: editingItem):
|
||||
ContextItemView(
|
||||
chat: chat,
|
||||
@@ -685,6 +694,7 @@ struct ComposeView: View {
|
||||
contextIcon: "pencil",
|
||||
cancelContextItem: { clearState() }
|
||||
)
|
||||
Divider()
|
||||
case let .forwardingItem(chatItem: forwardedItem, _):
|
||||
ContextItemView(
|
||||
chat: chat,
|
||||
@@ -693,6 +703,7 @@ struct ComposeView: View {
|
||||
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) },
|
||||
showSender: false
|
||||
)
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -849,6 +860,7 @@ struct ComposeView: View {
|
||||
func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
|
||||
let (image, data) = imageData
|
||||
if case let .video(_, url, duration) = data, let savedFile = moveTempFileFromURL(url) {
|
||||
ChatModel.shared.filesToDelete.remove(url)
|
||||
return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live, ttl: ttl)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -51,8 +51,8 @@ struct ComposeVoiceView: View {
|
||||
.padding(.vertical, 1)
|
||||
.frame(height: ComposeVoiceView.previewHeight)
|
||||
.background(theme.appColors.sentMessage)
|
||||
.frame(minHeight: 54)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
private func recordingMode() -> some View {
|
||||
|
||||
@@ -18,10 +18,9 @@ struct ContextInvitingContactMemberView: View {
|
||||
Text("Send direct message to connect")
|
||||
}
|
||||
.padding(12)
|
||||
.frame(minHeight: 50)
|
||||
.frame(minHeight: 54)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(theme.appColors.sentMessage)
|
||||
.padding(.top, 8)
|
||||
.background(.thinMaterial)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,10 +43,9 @@ struct ContextItemView: View {
|
||||
.tint(theme.colors.primary)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(minHeight: 50)
|
||||
.frame(minHeight: 54)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(chatItemFrameColor(contextItem, theme))
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
private func msgContentView(lines: Int) -> some View {
|
||||
|
||||
@@ -44,6 +44,7 @@ struct SendMessageView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
let composeShape = RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
|
||||
HStack(alignment: .bottom) {
|
||||
ZStack(alignment: .leading) {
|
||||
if case .voicePreview = composeState.preview {
|
||||
@@ -66,7 +67,6 @@ struct SendMessageView: View {
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
if progressByTimeout {
|
||||
ProgressView()
|
||||
.scaleEffect(1.4)
|
||||
@@ -84,10 +84,9 @@ struct SendMessageView: View {
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 1)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
|
||||
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
|
||||
)
|
||||
.background(theme.colors.background)
|
||||
.clipShape(composeShape)
|
||||
.overlay(composeShape.strokeBorder(.secondary, lineWidth: 0.5).opacity(0.7))
|
||||
}
|
||||
.onChange(of: composeState.message, perform: { text in updateFont(text) })
|
||||
.onChange(of: composeState.inProgress) { inProgress in
|
||||
|
||||
@@ -35,7 +35,7 @@ struct AddGroupMembersViewCommon: View {
|
||||
|
||||
private enum AddGroupMembersAlert: Identifiable {
|
||||
case prohibitedToInviteIncognito
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
@@ -122,7 +122,7 @@ struct AddGroupMembersViewCommon: View {
|
||||
message: Text("You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile")
|
||||
)
|
||||
case let .error(title, error):
|
||||
return Alert(title: Text(title), message: Text(error))
|
||||
return mkAlert(title: title, message: error)
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedContacts) { _ in
|
||||
|
||||
@@ -40,7 +40,7 @@ struct GroupChatInfoView: View {
|
||||
case blockForAllAlert(mem: GroupMember)
|
||||
case unblockForAllAlert(mem: GroupMember)
|
||||
case removeMemberAlert(mem: GroupMember)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
@@ -158,7 +158,7 @@ struct GroupChatInfoView: View {
|
||||
case let .blockForAllAlert(mem): return blockForAllAlert(groupInfo, mem)
|
||||
case let .unblockForAllAlert(mem): return unblockForAllAlert(groupInfo, mem)
|
||||
case let .removeMemberAlert(mem): return removeMemberAlert(mem)
|
||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
||||
case let .error(title, error): return mkAlert(title: title, message: error)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
@@ -207,6 +207,7 @@ struct GroupChatInfoView: View {
|
||||
let groupMembers = await apiListMembers(groupInfo.groupId)
|
||||
await MainActor.run {
|
||||
chatModel.groupMembers = groupMembers.map { GMember.init($0) }
|
||||
chatModel.populateGroupMembersIndexes()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -232,8 +233,7 @@ struct GroupChatInfoView: View {
|
||||
let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground)
|
||||
(member.verified ? memberVerifiedShield + t : t)
|
||||
.lineLimit(1)
|
||||
let s = Text(member.memberStatus.shortText)
|
||||
(user ? Text ("you: ") + s : s)
|
||||
(user ? Text ("you: ") + Text(member.memberStatus.shortText) : Text(memberConnStatus(member)))
|
||||
.lineLimit(1)
|
||||
.font(.caption)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
@@ -266,6 +266,16 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey {
|
||||
if member.activeConn?.connDisabled ?? false {
|
||||
return "disabled"
|
||||
} else if member.activeConn?.connInactive ?? false {
|
||||
return "inactive"
|
||||
} else {
|
||||
return member.memberStatus.shortText
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func memberInfo(_ member: GroupMember) -> some View {
|
||||
if member.blocked {
|
||||
Text("blocked")
|
||||
|
||||
@@ -22,7 +22,7 @@ struct GroupLinkView: View {
|
||||
|
||||
private enum GroupLinkAlert: Identifiable {
|
||||
case deleteLink
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
@@ -113,7 +113,7 @@ struct GroupLinkView: View {
|
||||
}, secondaryButton: .cancel()
|
||||
)
|
||||
case let .error(title, error):
|
||||
return Alert(title: Text(title), message: Text(error))
|
||||
return mkAlert(title: title, message: error)
|
||||
}
|
||||
}
|
||||
.onChange(of: groupLinkMemberRole) { _ in
|
||||
|
||||
@@ -37,7 +37,7 @@ struct GroupMemberInfoView: View {
|
||||
case syncConnectionForceAlert
|
||||
case planAndConnectAlert(alert: PlanAndConnectAlert)
|
||||
case queueInfo(info: String)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
@@ -76,157 +76,153 @@ struct GroupMemberInfoView: View {
|
||||
|
||||
private func groupMemberInfoView() -> some View {
|
||||
ZStack {
|
||||
VStack {
|
||||
let member = groupMember.wrapped
|
||||
List {
|
||||
groupMemberInfoHeader(member)
|
||||
.listRowBackground(Color.clear)
|
||||
let member = groupMember.wrapped
|
||||
List {
|
||||
groupMemberInfoHeader(member)
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
if member.memberActive {
|
||||
Section {
|
||||
if let contactId = member.memberContactId, let chat = knownDirectChat(contactId) {
|
||||
knownDirectChatButton(chat)
|
||||
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
|
||||
if let contactId = member.memberContactId {
|
||||
newDirectChatButton(contactId)
|
||||
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
|
||||
createMemberContactButton()
|
||||
}
|
||||
}
|
||||
if let code = connectionCode { verifyCodeButton(code) }
|
||||
if let connStats = connectionStats,
|
||||
connStats.ratchetSyncAllowed {
|
||||
synchronizeConnectionButton()
|
||||
}
|
||||
// } else if developerTools {
|
||||
// synchronizeConnectionButtonForce()
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
if let contactLink = member.contactLink {
|
||||
Section {
|
||||
SimpleXLinkQRCode(uri: contactLink)
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(contactLink)])
|
||||
} label: {
|
||||
Label("Share address", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
if member.memberActive {
|
||||
Section {
|
||||
if let contactId = member.memberContactId, let chat = knownDirectChat(contactId) {
|
||||
knownDirectChatButton(chat)
|
||||
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
|
||||
if let contactId = member.memberContactId {
|
||||
if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
|
||||
connectViaAddressButton(contactLink)
|
||||
}
|
||||
} else {
|
||||
newDirectChatButton(contactId)
|
||||
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
|
||||
createMemberContactButton()
|
||||
}
|
||||
}
|
||||
if let code = connectionCode { verifyCodeButton(code) }
|
||||
if let connStats = connectionStats,
|
||||
connStats.ratchetSyncAllowed {
|
||||
synchronizeConnectionButton()
|
||||
}
|
||||
// } else if developerTools {
|
||||
// synchronizeConnectionButtonForce()
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
if let contactLink = member.contactLink {
|
||||
Section {
|
||||
SimpleXLinkQRCode(uri: contactLink)
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(contactLink)])
|
||||
} label: {
|
||||
Label("Share address", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
if let contactId = member.memberContactId {
|
||||
if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
|
||||
connectViaAddressButton(contactLink)
|
||||
}
|
||||
} header: {
|
||||
Text("Address")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Member").foregroundColor(theme.colors.secondary)) {
|
||||
infoRow("Group", groupInfo.displayName)
|
||||
|
||||
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
|
||||
Picker("Change role", selection: $newRole) {
|
||||
ForEach(roles) { role in
|
||||
Text(role.text)
|
||||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
} else {
|
||||
infoRow("Role", member.memberRole.text)
|
||||
connectViaAddressButton(contactLink)
|
||||
}
|
||||
} header: {
|
||||
Text("Address")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
Text("You can share this address with your contacts to let them connect with **\(member.displayName)**.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO invited by - need to get contact by contact id
|
||||
Section(header: Text("Member").foregroundColor(theme.colors.secondary)) {
|
||||
infoRow("Group", groupInfo.displayName)
|
||||
|
||||
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
|
||||
Picker("Change role", selection: $newRole) {
|
||||
ForEach(roles) { role in
|
||||
Text(role.text)
|
||||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
} else {
|
||||
infoRow("Role", member.memberRole.text)
|
||||
}
|
||||
}
|
||||
|
||||
if let connStats = connectionStats {
|
||||
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
|
||||
// TODO network connection status
|
||||
Button("Change receiving address") {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
.disabled(
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
|
||||
Button("Abort changing address") {
|
||||
alert = .abortSwitchAddressAlert
|
||||
}
|
||||
.disabled(
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
}
|
||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
|
||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if groupInfo.membership.memberRole >= .admin {
|
||||
adminDestructiveSection(member)
|
||||
} else {
|
||||
nonAdminBlockSection(member)
|
||||
}
|
||||
|
||||
if developerTools {
|
||||
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
|
||||
infoRow("Local name", member.localDisplayName)
|
||||
infoRow("Database ID", "\(member.groupMemberId)")
|
||||
if let conn = member.activeConn {
|
||||
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
|
||||
infoRow("Connection", connLevelDesc)
|
||||
}
|
||||
}
|
||||
|
||||
if let connStats = connectionStats {
|
||||
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
|
||||
// TODO network connection status
|
||||
Button("Change receiving address") {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
.disabled(
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
|
||||
Button("Abort changing address") {
|
||||
alert = .abortSwitchAddressAlert
|
||||
}
|
||||
.disabled(
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
}
|
||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
|
||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if groupInfo.membership.memberRole >= .admin {
|
||||
adminDestructiveSection(member)
|
||||
} else {
|
||||
nonAdminBlockSection(member)
|
||||
}
|
||||
|
||||
if developerTools {
|
||||
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
|
||||
infoRow("Local name", member.localDisplayName)
|
||||
infoRow("Database ID", "\(member.groupMemberId)")
|
||||
Button ("Debug delivery") {
|
||||
Task {
|
||||
do {
|
||||
let info = queueInfoText(try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId))
|
||||
await MainActor.run { alert = .queueInfo(info: info) }
|
||||
} catch let e {
|
||||
logger.error("apiContactQueueInfo error: \(responseError(e))")
|
||||
let a = getErrorAlert(e, "Error")
|
||||
await MainActor.run { alert = .error(title: a.title, error: a.message) }
|
||||
}
|
||||
Button ("Debug delivery") {
|
||||
Task {
|
||||
do {
|
||||
let info = queueInfoText(try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId))
|
||||
await MainActor.run { alert = .queueInfo(info: info) }
|
||||
} catch let e {
|
||||
logger.error("apiContactQueueInfo error: \(responseError(e))")
|
||||
let a = getErrorAlert(e, "Error")
|
||||
await MainActor.run { alert = .error(title: a.title, error: a.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.onAppear {
|
||||
if #unavailable(iOS 16) {
|
||||
// this condition prevents re-setting picker
|
||||
if !justOpened { return }
|
||||
}
|
||||
justOpened = false
|
||||
DispatchQueue.main.async {
|
||||
newRole = member.memberRole
|
||||
do {
|
||||
let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
|
||||
let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
|
||||
_ = chatModel.upsertGroupMember(groupInfo, mem)
|
||||
connectionStats = stats
|
||||
connectionCode = code
|
||||
} catch let error {
|
||||
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.onAppear {
|
||||
if #unavailable(iOS 16) {
|
||||
// this condition prevents re-setting picker
|
||||
if !justOpened { return }
|
||||
}
|
||||
justOpened = false
|
||||
DispatchQueue.main.async {
|
||||
newRole = member.memberRole
|
||||
do {
|
||||
let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
|
||||
let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
|
||||
_ = chatModel.upsertGroupMember(groupInfo, mem)
|
||||
connectionStats = stats
|
||||
connectionCode = code
|
||||
} catch let error {
|
||||
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
.onChange(of: newRole) { newRole in
|
||||
if newRole != member.memberRole {
|
||||
alert = .changeMemberRoleAlert(mem: member, role: newRole)
|
||||
}
|
||||
}
|
||||
.onChange(of: member.memberRole) { role in
|
||||
newRole = role
|
||||
}
|
||||
.onChange(of: newRole) { newRole in
|
||||
if newRole != member.memberRole {
|
||||
alert = .changeMemberRoleAlert(mem: member, role: newRole)
|
||||
}
|
||||
}
|
||||
.onChange(of: member.memberRole) { role in
|
||||
newRole = role
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.alert(item: $alert) { alertItem in
|
||||
switch(alertItem) {
|
||||
@@ -241,7 +237,7 @@ struct GroupMemberInfoView: View {
|
||||
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
|
||||
case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true)
|
||||
case let .queueInfo(info): return queueInfoAlert(info)
|
||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
||||
case let .error(title, error): return mkAlert(title: title, message: error)
|
||||
}
|
||||
}
|
||||
.actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
|
||||
|
||||
@@ -55,6 +55,7 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
|
||||
// 1. Style
|
||||
tableView.separatorStyle = .none
|
||||
tableView.transform = .verticalFlip
|
||||
tableView.backgroundColor = .clear
|
||||
|
||||
// 2. Register cells
|
||||
if #available(iOS 16.0, *) {
|
||||
@@ -128,8 +129,9 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
|
||||
from: nil,
|
||||
for: nil
|
||||
)
|
||||
NotificationCenter.default.post(name: .chatViewWillBeginScrolling, object: nil)
|
||||
}
|
||||
|
||||
|
||||
/// Scrolls up
|
||||
func scrollToNextPage() {
|
||||
tableView.setContentOffset(
|
||||
@@ -180,6 +182,7 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
|
||||
/// Updates content of the cell
|
||||
/// For reference: https://noahgilmore.com/blog/swiftui-self-sizing-cells/
|
||||
func set(content: Hosted, parent: UIViewController) {
|
||||
hostingController.view.backgroundColor = .clear
|
||||
hostingController.rootView = content
|
||||
if let hostingView = hostingController.view {
|
||||
hostingView.invalidateIntrinsicContentSize()
|
||||
|
||||
@@ -9,25 +9,34 @@
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
private let rowHeights: [DynamicTypeSize: CGFloat] = [
|
||||
.xSmall: 68,
|
||||
.small: 72,
|
||||
.medium: 76,
|
||||
.large: 80,
|
||||
.xLarge: 88,
|
||||
.xxLarge: 94,
|
||||
.xxxLarge: 104,
|
||||
.accessibility1: 90,
|
||||
.accessibility2: 100,
|
||||
.accessibility3: 120,
|
||||
.accessibility4: 130,
|
||||
.accessibility5: 140
|
||||
let dynamicSizes: [
|
||||
DynamicTypeSize: (
|
||||
rowHeight: CGFloat,
|
||||
profileImageSize: CGFloat,
|
||||
mediaSize: CGFloat,
|
||||
chatInfoSize: CGFloat,
|
||||
unreadCorner: CGFloat,
|
||||
unreadPadding: CGFloat
|
||||
)
|
||||
] = [
|
||||
.xSmall: (68, 55, 33, 18, 9, 3),
|
||||
.small: (72, 57, 34, 18, 9, 3),
|
||||
.medium: (76, 60, 36, 18, 10, 4),
|
||||
.large: (80, 63, 38, 20, 10, 4),
|
||||
.xLarge: (88, 67, 41, 20, 10, 4),
|
||||
.xxLarge: (94, 71, 44, 22, 11, 4),
|
||||
.xxxLarge: (104, 75, 48, 24, 12, 5),
|
||||
.accessibility1: (104, 75, 48, 24, 12, 5),
|
||||
.accessibility2: (114, 75, 48, 24, 12, 5),
|
||||
.accessibility3: (124, 75, 48, 24, 12, 5),
|
||||
.accessibility4: (134, 75, 48, 24, 12, 5),
|
||||
.accessibility5: (144, 75, 48, 24, 12, 5)
|
||||
]
|
||||
|
||||
struct ChatListNavLink: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
|
||||
@ObservedObject var chat: Chat
|
||||
@State private var showContactRequestDialog = false
|
||||
@State private var showJoinGroupDialog = false
|
||||
@@ -38,6 +47,8 @@ struct ChatListNavLink: View {
|
||||
@State private var inProgress = false
|
||||
@State private var progressByTimeout = false
|
||||
|
||||
var dynamicRowHeight: CGFloat { dynamicSizes[userFont]?.rowHeight ?? 80 }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch chat.chatInfo {
|
||||
@@ -70,7 +81,7 @@ struct ChatListNavLink: View {
|
||||
Group {
|
||||
if contact.activeConn == nil && contact.profile.contactLink != nil {
|
||||
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
.frame(height: dynamicRowHeight)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button {
|
||||
showDeleteContactActionSheet = true
|
||||
@@ -100,7 +111,7 @@ struct ChatListNavLink: View {
|
||||
clearChatButton()
|
||||
}
|
||||
Button {
|
||||
if contact.ready || !contact.active {
|
||||
if contact.sndReady || !contact.active {
|
||||
showDeleteContactActionSheet = true
|
||||
} else {
|
||||
AlertManager.shared.showAlert(deletePendingContactAlert(chat, contact))
|
||||
@@ -110,11 +121,11 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
.frame(height: dynamicRowHeight)
|
||||
}
|
||||
}
|
||||
.actionSheet(isPresented: $showDeleteContactActionSheet) {
|
||||
if contact.ready && contact.active {
|
||||
if contact.sndReady && contact.active {
|
||||
return ActionSheet(
|
||||
title: Text("Delete contact?\nThis cannot be undone!"),
|
||||
buttons: [
|
||||
@@ -139,7 +150,7 @@ struct ChatListNavLink: View {
|
||||
switch (groupInfo.membership.memberStatus) {
|
||||
case .memInvited:
|
||||
ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout)
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
.frame(height: dynamicRowHeight)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
joinGroupButton()
|
||||
if groupInfo.canDelete {
|
||||
@@ -159,7 +170,7 @@ struct ChatListNavLink: View {
|
||||
.disabled(inProgress)
|
||||
case .memAccepted:
|
||||
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
.frame(height: dynamicRowHeight)
|
||||
.onTapGesture {
|
||||
AlertManager.shared.showAlert(groupInvitationAcceptedAlert())
|
||||
}
|
||||
@@ -178,7 +189,7 @@ struct ChatListNavLink: View {
|
||||
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
|
||||
disabled: !groupInfo.ready
|
||||
)
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
.frame(height: dynamicRowHeight)
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
markReadButton()
|
||||
toggleFavoriteButton()
|
||||
@@ -205,7 +216,7 @@ struct ChatListNavLink: View {
|
||||
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
|
||||
disabled: !noteFolder.ready
|
||||
)
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
.frame(height: dynamicRowHeight)
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
markReadButton()
|
||||
}
|
||||
@@ -321,7 +332,7 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
.frame(height: dynamicRowHeight)
|
||||
.onTapGesture { showContactRequestDialog = true }
|
||||
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
|
||||
Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } }
|
||||
@@ -349,7 +360,7 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
.tint(theme.colors.primary)
|
||||
}
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
.frame(height: dynamicRowHeight)
|
||||
.appSheet(isPresented: $showContactConnectionInfo) {
|
||||
Group {
|
||||
if case let .contactConnection(contactConnection) = chat.chatInfo {
|
||||
@@ -469,7 +480,7 @@ struct ChatListNavLink: View {
|
||||
Text("invalid chat data")
|
||||
.foregroundColor(.red)
|
||||
.padding(4)
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
.frame(height: dynamicRowHeight)
|
||||
.onTapGesture { showInvalidJSON = true }
|
||||
.appSheet(isPresented: $showInvalidJSON) {
|
||||
invalidJSONView(json)
|
||||
@@ -564,18 +575,11 @@ func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) {
|
||||
}
|
||||
}
|
||||
|
||||
struct ErrorAlert {
|
||||
var title: LocalizedStringKey
|
||||
var message: LocalizedStringKey
|
||||
}
|
||||
|
||||
func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert {
|
||||
switch error as? ChatResponse {
|
||||
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))):
|
||||
return ErrorAlert(title: "Connection timeout", message: "Please check your network connection with \(serverHostname(addr)) and try again.")
|
||||
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))):
|
||||
return ErrorAlert(title: "Connection error", message: "Please check your network connection with \(serverHostname(addr)) and try again.")
|
||||
default:
|
||||
if let r = error as? ChatResponse,
|
||||
let alert = getNetworkErrorAlert(r) {
|
||||
return alert
|
||||
} else {
|
||||
return ErrorAlert(title: title, message: "Error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ struct ChatListView: View {
|
||||
@State private var newChatMenuOption: NewChatMenuOption? = nil
|
||||
@State private var userPickerVisible = false
|
||||
@State private var showConnectDesktop = false
|
||||
|
||||
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
|
||||
|
||||
var body: some View {
|
||||
@@ -162,6 +163,10 @@ struct ChatListView: View {
|
||||
chatModel.chatToTop = nil
|
||||
chatModel.popChat(chatId)
|
||||
}
|
||||
stopAudioPlayer()
|
||||
}
|
||||
.onChange(of: chatModel.currentUser?.userId) { _ in
|
||||
stopAudioPlayer()
|
||||
}
|
||||
if cs.isEmpty && !chatModel.chats.isEmpty {
|
||||
Text("No filtered chats").foregroundColor(theme.colors.secondary)
|
||||
@@ -217,6 +222,11 @@ struct ChatListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func stopAudioPlayer() {
|
||||
VoiceItemState.smallView.values.forEach { $0.audioPlayer?.stop() }
|
||||
VoiceItemState.smallView = [:]
|
||||
}
|
||||
|
||||
private func filteredChats() -> [Chat] {
|
||||
if let linkChatId = searchChatFilteredBySimplexLink {
|
||||
return chatModel.chats.filter { $0.id == linkChatId }
|
||||
@@ -267,31 +277,25 @@ struct ChatListView: View {
|
||||
|
||||
struct SubsStatusIndicator: View {
|
||||
@State private var subs: SMPServerSubs = SMPServerSubs.newSMPServerSubs
|
||||
@State private var sess: ServerSessions = ServerSessions.newServerSessions
|
||||
@State private var hasSess: Bool = false
|
||||
@State private var timer: Timer? = nil
|
||||
@State private var timerCounter = 0
|
||||
@State private var showServersSummary = false
|
||||
|
||||
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
|
||||
|
||||
// Constants for the intervals
|
||||
let initialInterval: TimeInterval = 1.0
|
||||
let regularInterval: TimeInterval = 3.0
|
||||
let initialPhaseDuration: TimeInterval = 10.0 // Duration for initial phase in seconds
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
showServersSummary = true
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
SubscriptionStatusIndicatorView(subs: subs, sess: sess)
|
||||
SubscriptionStatusIndicatorView(subs: subs, hasSess: hasSess)
|
||||
if showSubscriptionPercentage {
|
||||
SubscriptionStatusPercentageView(subs: subs, sess: sess)
|
||||
SubscriptionStatusPercentageView(subs: subs, hasSess: hasSess)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
startInitialTimer()
|
||||
startTimer()
|
||||
}
|
||||
.onDisappear {
|
||||
stopTimer()
|
||||
@@ -301,35 +305,24 @@ struct SubsStatusIndicator: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func startInitialTimer() {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: initialInterval, repeats: true) { _ in
|
||||
getServersSummary()
|
||||
timerCounter += 1
|
||||
// Switch to the regular timer after the initial phase
|
||||
if timerCounter * Int(initialInterval) >= Int(initialPhaseDuration) {
|
||||
switchToRegularTimer()
|
||||
private func startTimer() {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
|
||||
if AppChatState.shared.value == .active {
|
||||
getSubsTotal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func switchToRegularTimer() {
|
||||
timer?.invalidate()
|
||||
timer = Timer.scheduledTimer(withTimeInterval: regularInterval, repeats: true) { _ in
|
||||
getServersSummary()
|
||||
}
|
||||
}
|
||||
|
||||
func stopTimer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
private func getServersSummary() {
|
||||
private func getSubsTotal() {
|
||||
do {
|
||||
let summ = try getAgentServersSummary()
|
||||
(subs, sess) = (summ.allUsersSMP.smpTotals.subs, summ.allUsersSMP.smpTotals.sessions)
|
||||
(subs, hasSess) = try getAgentSubsTotal()
|
||||
} catch let error {
|
||||
logger.error("getAgentServersSummary error: \(responseError(error))")
|
||||
logger.error("getSubsTotal error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,18 +12,24 @@ import SimpleXChat
|
||||
struct ChatPreviewView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
|
||||
@ObservedObject var chat: Chat
|
||||
@Binding var progressByTimeout: Bool
|
||||
@State var deleting: Bool = false
|
||||
var darkGreen = Color(red: 0, green: 0.5, blue: 0)
|
||||
@State private var activeContentPreview: ActiveContentPreview? = nil
|
||||
@State private var showFullscreenGallery: Bool = false
|
||||
|
||||
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
|
||||
|
||||
var dynamicMediaSize: CGFloat { dynamicSizes[userFont]?.mediaSize ?? 36 }
|
||||
var dynamicChatInfoSize: CGFloat { dynamicSizes[userFont]?.chatInfoSize ?? 18 }
|
||||
|
||||
var body: some View {
|
||||
let cItem = chat.chatItems.last
|
||||
return HStack(spacing: 8) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
ChatInfoImage(chat: chat, size: 63)
|
||||
ChatInfoImage(chat: chat, size: dynamicSizes[userFont]?.profileImageSize ?? 63)
|
||||
chatPreviewImageOverlayIcon()
|
||||
.padding([.bottom, .trailing], 1)
|
||||
}
|
||||
@@ -43,11 +49,38 @@ struct ChatPreviewView: View {
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
ZStack(alignment: .topTrailing) {
|
||||
chatMessagePreview(cItem)
|
||||
let chat = activeContentPreview?.chat ?? chat
|
||||
let ci = activeContentPreview?.ci ?? chat.chatItems.last
|
||||
let mc = ci?.content.msgContent
|
||||
HStack(alignment: .top) {
|
||||
let deleted = ci?.isDeletedContent == true || ci?.meta.itemDeleted != nil
|
||||
let showContentPreview = (showChatPreviews && chatModel.draftChatId != chat.id && !deleted) || activeContentPreview != nil
|
||||
if let ci, showContentPreview {
|
||||
chatItemContentPreview(chat, ci)
|
||||
}
|
||||
let mcIsVoice = switch mc { case .voice: true; default: false }
|
||||
if !mcIsVoice || !showContentPreview || mc?.text != "" || chatModel.draftChatId == chat.id {
|
||||
let hasFilePreview = if case .file = mc { true } else { false }
|
||||
chatMessagePreview(cItem, hasFilePreview)
|
||||
} else {
|
||||
Spacer()
|
||||
chatInfoIcon(chat).frame(minWidth: 37, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
.onChange(of: chatModel.stopPreviousRecPlay?.path) { _ in
|
||||
checkActiveContentPreview(chat, ci, mc)
|
||||
}
|
||||
.onChange(of: activeContentPreview) { _ in
|
||||
checkActiveContentPreview(chat, ci, mc)
|
||||
}
|
||||
.onChange(of: showFullscreenGallery) { _ in
|
||||
checkActiveContentPreview(chat, ci, mc)
|
||||
}
|
||||
chatStatusImage()
|
||||
.padding(.top, 26)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.trailing, 8)
|
||||
|
||||
Spacer()
|
||||
@@ -57,6 +90,33 @@ struct ChatPreviewView: View {
|
||||
.padding(.bottom, -8)
|
||||
.onChange(of: chatModel.deletedChats.contains(chat.chatInfo.id)) { contains in
|
||||
deleting = contains
|
||||
// Stop voice when deleting the chat
|
||||
if contains, let ci = activeContentPreview?.ci {
|
||||
VoiceItemState.stopVoiceInSmallView(chat.chatInfo, ci)
|
||||
}
|
||||
}
|
||||
|
||||
func checkActiveContentPreview(_ chat: Chat, _ ci: ChatItem?, _ mc: MsgContent?) {
|
||||
let playing = chatModel.stopPreviousRecPlay
|
||||
if case .voice = activeContentPreview?.mc, playing == nil {
|
||||
activeContentPreview = nil
|
||||
} else if activeContentPreview == nil {
|
||||
if case .image = mc, let ci, let mc, showFullscreenGallery {
|
||||
activeContentPreview = ActiveContentPreview(chat: chat, ci: ci, mc: mc)
|
||||
}
|
||||
if case .video = mc, let ci, let mc, showFullscreenGallery {
|
||||
activeContentPreview = ActiveContentPreview(chat: chat, ci: ci, mc: mc)
|
||||
}
|
||||
if case .voice = mc, let ci, let mc, let fileSource = ci.file?.fileSource, playing?.path.hasSuffix(fileSource.filePath) == true {
|
||||
activeContentPreview = ActiveContentPreview(chat: chat, ci: ci, mc: mc)
|
||||
}
|
||||
} else if case .voice = activeContentPreview?.mc {
|
||||
if let playing, let fileSource = ci?.file?.fileSource, !playing.path.hasSuffix(fileSource.filePath) {
|
||||
activeContentPreview = nil
|
||||
}
|
||||
} else if !showFullscreenGallery {
|
||||
activeContentPreview = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,39 +173,50 @@ struct ChatPreviewView: View {
|
||||
.kerning(-2)
|
||||
}
|
||||
|
||||
private func chatPreviewLayout(_ text: Text, draft: Bool = false) -> some View {
|
||||
private func chatPreviewLayout(_ text: Text?, draft: Bool = false, _ hasFilePreview: Bool = false) -> some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
let t = text
|
||||
.lineLimit(2)
|
||||
.lineLimit(userFont <= .xxxLarge ? 2 : 1)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.padding(.leading, 8)
|
||||
.padding(.trailing, 36)
|
||||
.padding(.leading, hasFilePreview ? 0 : 8)
|
||||
.padding(.trailing, hasFilePreview ? 38 : 36)
|
||||
.offset(x: hasFilePreview ? -2 : 0)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if !showChatPreviews && !draft {
|
||||
t.privacySensitive(true).redacted(reason: .privacy)
|
||||
} else {
|
||||
t
|
||||
}
|
||||
let s = chat.chatStats
|
||||
if s.unreadCount > 0 || s.unreadChat {
|
||||
unreadCountText(s.unreadCount)
|
||||
.font(.caption)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 4)
|
||||
.frame(minWidth: 18, minHeight: 18)
|
||||
.background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary)
|
||||
.cornerRadius(10)
|
||||
} else if !chat.chatInfo.ntfsEnabled && chat.chatInfo.chatType != .local {
|
||||
Image(systemName: "speaker.slash.fill")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} else if chat.chatInfo.chatSettings?.favorite ?? false {
|
||||
Image(systemName: "star.fill")
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 18, height: 18)
|
||||
.padding(.trailing, 1)
|
||||
.foregroundColor(.secondary.opacity(0.65))
|
||||
}
|
||||
chatInfoIcon(chat).frame(minWidth: 37, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func chatInfoIcon(_ chat: Chat) -> some View {
|
||||
let s = chat.chatStats
|
||||
if s.unreadCount > 0 || s.unreadChat {
|
||||
unreadCountText(s.unreadCount)
|
||||
.font(userFont <= .xxxLarge ? .caption : .caption2)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, dynamicSizes[userFont]?.unreadPadding ?? 4)
|
||||
.frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize)
|
||||
.background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary)
|
||||
.cornerRadius(dynamicSizes[userFont]?.unreadCorner ?? 10)
|
||||
} else if !chat.chatInfo.ntfsEnabled && chat.chatInfo.chatType != .local {
|
||||
Image(systemName: "speaker.slash.fill")
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} else if chat.chatInfo.chatSettings?.favorite ?? false {
|
||||
Image(systemName: "star.fill")
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize)
|
||||
.padding(.trailing, 1)
|
||||
.foregroundColor(theme.colors.secondary.opacity(0.65))
|
||||
} else {
|
||||
Color.clear.frame(width: 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +243,7 @@ struct ChatPreviewView: View {
|
||||
func chatItemPreview(_ cItem: ChatItem) -> Text {
|
||||
let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText()
|
||||
let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil
|
||||
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: attachment(), preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
|
||||
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
|
||||
|
||||
// same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey;
|
||||
// can be refactored into a single function if functions calling these are changed to return same type
|
||||
@@ -196,18 +267,18 @@ struct ChatPreviewView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?) -> some View {
|
||||
@ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?, _ hasFilePreview: Bool = false) -> some View {
|
||||
if chatModel.draftChatId == chat.id, let draft = chatModel.draft {
|
||||
chatPreviewLayout(messageDraft(draft), draft: true)
|
||||
chatPreviewLayout(messageDraft(draft), draft: true, hasFilePreview)
|
||||
} else if let cItem = cItem {
|
||||
chatPreviewLayout(itemStatusMark(cItem) + chatItemPreview(cItem))
|
||||
chatPreviewLayout(itemStatusMark(cItem) + chatItemPreview(cItem), hasFilePreview)
|
||||
} else {
|
||||
switch (chat.chatInfo) {
|
||||
case let .direct(contact):
|
||||
if contact.activeConn == nil && contact.profile.contactLink != nil {
|
||||
chatPreviewInfoText("Tap to Connect")
|
||||
.foregroundColor(theme.colors.primary)
|
||||
} else if !contact.ready && contact.activeConn != nil {
|
||||
} else if !contact.sndReady && contact.activeConn != nil {
|
||||
if contact.nextSendGrpInv {
|
||||
chatPreviewInfoText("send direct message")
|
||||
} else if contact.active {
|
||||
@@ -225,6 +296,54 @@ struct ChatPreviewView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func chatItemContentPreview(_ chat: Chat, _ ci: ChatItem) -> some View {
|
||||
let mc = ci.content.msgContent
|
||||
switch mc {
|
||||
case let .link(_, preview):
|
||||
smallContentPreview(size: dynamicMediaSize) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(uiImage: UIImage(base64Encoded: preview.image) ?? UIImage(systemName: "arrow.up.right")!)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: dynamicMediaSize, height: dynamicMediaSize)
|
||||
ZStack {
|
||||
Image(systemName: "arrow.up.right")
|
||||
.resizable()
|
||||
.foregroundColor(Color.white)
|
||||
.font(.system(size: 15, weight: .black))
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
.frame(width: 16, height: 16)
|
||||
.background(Color.black.opacity(0.25))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.onTapGesture {
|
||||
UIApplication.shared.open(preview.uri)
|
||||
}
|
||||
}
|
||||
case let .image(_, image):
|
||||
smallContentPreview(size: dynamicMediaSize) {
|
||||
CIImageView(chatItem: ci, preview: UIImage(base64Encoded: image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery)
|
||||
.environmentObject(ReverseListScrollModel<ChatItem>())
|
||||
}
|
||||
case let .video(_,image, duration):
|
||||
smallContentPreview(size: dynamicMediaSize) {
|
||||
CIVideoView(chatItem: ci, preview: UIImage(base64Encoded: image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery)
|
||||
.environmentObject(ReverseListScrollModel<ChatItem>())
|
||||
}
|
||||
case let .voice(_, duration):
|
||||
smallContentPreviewVoice(size: dynamicMediaSize) {
|
||||
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, allowMenu: Binding.constant(true), smallViewSize: dynamicMediaSize)
|
||||
}
|
||||
case .file:
|
||||
smallContentPreviewFile(size: dynamicMediaSize) {
|
||||
CIFileView(file: ci.file, edited: ci.meta.itemEdited, smallViewSize: dynamicMediaSize)
|
||||
}
|
||||
default: EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder private func groupInvitationPreviewText(_ groupInfo: GroupInfo) -> some View {
|
||||
groupInfo.membership.memberIncognito
|
||||
? chatPreviewInfoText("join as \(groupInfo.membership.memberProfile.displayName)")
|
||||
@@ -294,10 +413,45 @@ struct ChatPreviewView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func smallContentPreview(size: CGFloat, _ view: @escaping () -> some View) -> some View {
|
||||
view()
|
||||
.frame(width: size, height: size)
|
||||
.cornerRadius(8)
|
||||
.overlay(RoundedRectangle(cornerSize: CGSize(width: 8, height: 8))
|
||||
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true))
|
||||
.padding(.vertical, size / 6)
|
||||
.padding(.leading, 3)
|
||||
.offset(x: 6)
|
||||
}
|
||||
|
||||
func smallContentPreviewVoice(size: CGFloat, _ view: @escaping () -> some View) -> some View {
|
||||
view()
|
||||
.frame(height: voiceMessageSizeBasedOnSquareSize(size))
|
||||
.padding(.vertical, size / 6)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
|
||||
func smallContentPreviewFile(size: CGFloat, _ view: @escaping () -> some View) -> some View {
|
||||
view()
|
||||
.frame(width: size, height: size)
|
||||
.padding(.vertical, size / 7)
|
||||
.padding(.leading, 5)
|
||||
}
|
||||
|
||||
func unreadCountText(_ n: Int) -> Text {
|
||||
Text(n > 999 ? "\(n / 1000)k" : n > 0 ? "\(n)" : "")
|
||||
}
|
||||
|
||||
private struct ActiveContentPreview: Equatable {
|
||||
var chat: Chat
|
||||
var ci: ChatItem
|
||||
var mc: MsgContent
|
||||
|
||||
static func == (lhs: ActiveContentPreview, rhs: ActiveContentPreview) -> Bool {
|
||||
lhs.chat.id == rhs.chat.id && lhs.ci.id == rhs.ci.id && lhs.mc == rhs.mc
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatPreviewView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
|
||||
@@ -21,7 +21,7 @@ struct ContactConnectionInfo: View {
|
||||
|
||||
enum CCInfoAlert: Identifiable {
|
||||
case deleteInvitationAlert
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
@@ -102,7 +102,7 @@ struct ContactConnectionInfo: View {
|
||||
} success: {
|
||||
dismiss()
|
||||
}
|
||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
||||
case let .error(title, error): return mkAlert(title: title, message: error)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
|
||||
@@ -12,12 +12,13 @@ import SimpleXChat
|
||||
struct ContactRequestView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
|
||||
var contactRequest: UserContactRequest
|
||||
@ObservedObject var chat: Chat
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
ChatInfoImage(chat: chat, size: 63)
|
||||
ChatInfoImage(chat: chat, size: dynamicSizes[userFont]?.profileImageSize ?? 63)
|
||||
.padding(.leading, 4)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
||||
|
||||
struct ServersSummaryView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@State private var serversSummary: PresentedServersSummary? = nil
|
||||
@State private var selectedUserCategory: PresentedUserCategory = .allUsers
|
||||
@State private var selectedServerType: PresentedServerType = .smp
|
||||
@@ -36,6 +37,7 @@ struct ServersSummaryView: View {
|
||||
viewBody()
|
||||
.navigationTitle("Servers info")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
shareButton()
|
||||
@@ -57,11 +59,21 @@ struct ServersSummaryView: View {
|
||||
|
||||
private func startTimer() {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
|
||||
getServersSummary()
|
||||
if AppChatState.shared.value == .active {
|
||||
getServersSummary()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopTimer() {
|
||||
private func getServersSummary() {
|
||||
do {
|
||||
serversSummary = try getAgentServersSummary()
|
||||
} catch let error {
|
||||
logger.error("getAgentServersSummary error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
|
||||
private func stopTimer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
@@ -90,8 +102,8 @@ struct ServersSummaryView: View {
|
||||
Group {
|
||||
if m.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
|
||||
Picker("User selection", selection: $selectedUserCategory) {
|
||||
Text("All users").tag(PresentedUserCategory.allUsers)
|
||||
Text("Current user").tag(PresentedUserCategory.currentUser)
|
||||
Text("All profiles").tag(PresentedUserCategory.allUsers)
|
||||
Text("Current profile").tag(PresentedUserCategory.currentUser)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
@@ -182,19 +194,22 @@ struct ServersSummaryView: View {
|
||||
}
|
||||
} else {
|
||||
Text("No info, try to reload")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.background(theme.colors.background)
|
||||
}
|
||||
}
|
||||
|
||||
private func smpSubsSection(_ totals: SMPTotals) -> some View {
|
||||
Section {
|
||||
infoRow("Connections subscribed", numOrDash(totals.subs.ssActive))
|
||||
infoRow("Active connections", numOrDash(totals.subs.ssActive))
|
||||
infoRow("Total", numOrDash(totals.subs.total))
|
||||
Toggle("Show percentage", isOn: $showSubscriptionPercentage)
|
||||
} header: {
|
||||
HStack {
|
||||
Text("Message subscriptions")
|
||||
SubscriptionStatusIndicatorView(subs: totals.subs, sess: totals.sessions)
|
||||
Text("Message reception")
|
||||
SubscriptionStatusIndicatorView(subs: totals.subs, hasSess: totals.sessions.hasSess)
|
||||
if showSubscriptionPercentage {
|
||||
SubscriptionStatusPercentageView(subs: totals.subs, sess: totals.sessions)
|
||||
SubscriptionStatusPercentageView(subs: totals.subs, hasSess: totals.sessions.hasSess)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,6 +279,7 @@ struct ServersSummaryView: View {
|
||||
)
|
||||
.navigationBarTitle("SMP server")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
HStack {
|
||||
Text(serverAddress(srvSumm.smpServer))
|
||||
@@ -271,9 +287,9 @@ struct ServersSummaryView: View {
|
||||
if let subs = srvSumm.subs {
|
||||
Spacer()
|
||||
if showSubscriptionPercentage {
|
||||
SubscriptionStatusPercentageView(subs: subs, sess: srvSumm.sessionsOrNew)
|
||||
SubscriptionStatusPercentageView(subs: subs, hasSess: srvSumm.sessionsOrNew.hasSess)
|
||||
}
|
||||
SubscriptionStatusIndicatorView(subs: subs, sess: srvSumm.sessionsOrNew)
|
||||
SubscriptionStatusIndicatorView(subs: subs, hasSess: srvSumm.sessionsOrNew.hasSess)
|
||||
} else if let sess = srvSumm.sessions {
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.circle")
|
||||
@@ -332,6 +348,7 @@ struct ServersSummaryView: View {
|
||||
)
|
||||
.navigationBarTitle("XFTP server")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
HStack {
|
||||
Text(serverAddress(srvSumm.xftpServer))
|
||||
@@ -360,7 +377,7 @@ struct ServersSummaryView: View {
|
||||
Button {
|
||||
alert = SomeAlert(
|
||||
alert: Alert(
|
||||
title: Text("Reset all servers statistics?"),
|
||||
title: Text("Reset all statistics?"),
|
||||
message: Text("Servers statistics will be reset - this cannot be undone!"),
|
||||
primaryButton: .destructive(Text("Reset")) {
|
||||
Task {
|
||||
@@ -386,24 +403,16 @@ struct ServersSummaryView: View {
|
||||
Text("Reset all statistics")
|
||||
}
|
||||
}
|
||||
|
||||
private func getServersSummary() {
|
||||
do {
|
||||
serversSummary = try getAgentServersSummary()
|
||||
} catch let error {
|
||||
logger.error("getAgentServersSummary error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SubscriptionStatusIndicatorView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
var subs: SMPServerSubs
|
||||
var sess: ServerSessions
|
||||
var hasSess: Bool
|
||||
|
||||
var body: some View {
|
||||
let onionHosts = networkUseOnionHostsGroupDefault.get()
|
||||
let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, sess)
|
||||
let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, hasSess)
|
||||
if #available(iOS 16.0, *) {
|
||||
Image(systemName: "dot.radiowaves.up.forward", variableValue: variableValue)
|
||||
.foregroundColor(color)
|
||||
@@ -417,18 +426,18 @@ struct SubscriptionStatusIndicatorView: View {
|
||||
struct SubscriptionStatusPercentageView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
var subs: SMPServerSubs
|
||||
var sess: ServerSessions
|
||||
var hasSess: Bool
|
||||
|
||||
var body: some View {
|
||||
let onionHosts = networkUseOnionHostsGroupDefault.get()
|
||||
let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, sess)
|
||||
Text("\(Int(floor(statusPercent * 100)))%")
|
||||
let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, hasSess)
|
||||
Text(verbatim: "\(Int(floor(statusPercent * 100)))%")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
func subscriptionStatusColorAndPercentage(_ online: Bool, _ onionHosts: OnionHosts, _ subs: SMPServerSubs, _ sess: ServerSessions) -> (Color, Double, Double, Double) {
|
||||
func subscriptionStatusColorAndPercentage(_ online: Bool, _ onionHosts: OnionHosts, _ subs: SMPServerSubs, _ hasSess: Bool) -> (Color, Double, Double, Double) {
|
||||
func roundedToQuarter(_ n: Double) -> Double {
|
||||
n >= 1 ? 1
|
||||
: n <= 0 ? 0
|
||||
@@ -443,12 +452,12 @@ func subscriptionStatusColorAndPercentage(_ online: Bool, _ onionHosts: OnionHos
|
||||
? (
|
||||
subs.ssActive == 0
|
||||
? (
|
||||
sess.ssConnected == 0 ? noConnColorAndPercent : (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
|
||||
hasSess ? (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) : noConnColorAndPercent
|
||||
)
|
||||
: ( // ssActive > 0
|
||||
sess.ssConnected == 0
|
||||
? (.orange, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) // This would mean implementation error
|
||||
: (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
|
||||
hasSess
|
||||
? (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
|
||||
: (.orange, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) // This would mean implementation error
|
||||
)
|
||||
)
|
||||
: noConnColorAndPercent
|
||||
@@ -470,6 +479,7 @@ struct SMPServerSummaryView: View {
|
||||
NavigationLink {
|
||||
ProtocolServersView(serverProtocol: .smp)
|
||||
.navigationTitle("Your SMP servers")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
Text("Open server settings")
|
||||
}
|
||||
@@ -493,16 +503,16 @@ struct SMPServerSummaryView: View {
|
||||
|
||||
private func smpSubsSection(_ subs: SMPServerSubs) -> some View {
|
||||
Section {
|
||||
infoRow("Connections subscribed", numOrDash(subs.ssActive))
|
||||
infoRow("Active connections", numOrDash(subs.ssActive))
|
||||
infoRow("Pending", numOrDash(subs.ssPending))
|
||||
infoRow("Total", numOrDash(subs.total))
|
||||
reconnectButton()
|
||||
} header: {
|
||||
HStack {
|
||||
Text("Message subscriptions")
|
||||
SubscriptionStatusIndicatorView(subs: subs, sess: summary.sessionsOrNew)
|
||||
Text("Message reception")
|
||||
SubscriptionStatusIndicatorView(subs: subs, hasSess: summary.sessionsOrNew.hasSess)
|
||||
if showSubscriptionPercentage {
|
||||
SubscriptionStatusPercentageView(subs: subs, sess: summary.sessionsOrNew)
|
||||
SubscriptionStatusPercentageView(subs: subs, hasSess: summary.sessionsOrNew.hasSess)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -563,6 +573,7 @@ struct SMPStatsView: View {
|
||||
DetailedSMPStatsView(stats: stats, statsStartedAt: statsStartedAt)
|
||||
.navigationTitle("Detailed statistics")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
Text("Details")
|
||||
}
|
||||
@@ -590,33 +601,37 @@ struct DetailedSMPStatsView: View {
|
||||
infoRowTwoValues("Sent via proxy", "attempts", stats._sentViaProxy, stats._sentViaProxyAttempts)
|
||||
infoRowTwoValues("Proxied", "attempts", stats._sentProxied, stats._sentProxiedAttempts)
|
||||
Text("Send errors")
|
||||
indentedInfoRow("AUTH", numOrDash(stats._sentAuthErrs))
|
||||
indentedInfoRow("QUOTA", numOrDash(stats._sentQuotaErrs))
|
||||
indentedInfoRow("expired", numOrDash(stats._sentExpiredErrs))
|
||||
indentedInfoRow("other", numOrDash(stats._sentOtherErrs))
|
||||
infoRow(Text(verbatim: "AUTH"), numOrDash(stats._sentAuthErrs)).padding(.leading, 24)
|
||||
infoRow(Text(verbatim: "QUOTA"), numOrDash(stats._sentQuotaErrs)).padding(.leading, 24)
|
||||
infoRow("expired", numOrDash(stats._sentExpiredErrs)).padding(.leading, 24)
|
||||
infoRow("other", numOrDash(stats._sentOtherErrs)).padding(.leading, 24)
|
||||
}
|
||||
Section("Received messages") {
|
||||
infoRow("Received total", numOrDash(stats._recvMsgs))
|
||||
Text("Receive errors")
|
||||
indentedInfoRow("duplicates", numOrDash(stats._recvDuplicates))
|
||||
indentedInfoRow("decryption errors", numOrDash(stats._recvCryptoErrs))
|
||||
indentedInfoRow("other errors", numOrDash(stats._recvErrs))
|
||||
infoRow("duplicates", numOrDash(stats._recvDuplicates)).padding(.leading, 24)
|
||||
infoRow("decryption errors", numOrDash(stats._recvCryptoErrs)).padding(.leading, 24)
|
||||
infoRow("other errors", numOrDash(stats._recvErrs)).padding(.leading, 24)
|
||||
infoRowTwoValues("Acknowledged", "attempts", stats._ackMsgs, stats._ackAttempts)
|
||||
Text("Acknowledgement errors")
|
||||
indentedInfoRow("NO_MSG errors", numOrDash(stats._ackNoMsgErrs))
|
||||
indentedInfoRow("other errors", numOrDash(stats._ackOtherErrs))
|
||||
infoRow(Text(verbatim: "NO_MSG errors"), numOrDash(stats._ackNoMsgErrs)).padding(.leading, 24)
|
||||
infoRow("other errors", numOrDash(stats._ackOtherErrs)).padding(.leading, 24)
|
||||
}
|
||||
Section {
|
||||
Section("Connections") {
|
||||
infoRow("Created", numOrDash(stats._connCreated))
|
||||
infoRow("Secured", numOrDash(stats._connCreated))
|
||||
infoRow("Completed", numOrDash(stats._connCompleted))
|
||||
infoRowTwoValues("Deleted", "attempts", stats._connDeleted, stats._connDelAttempts)
|
||||
infoRow("Deletion errors", numOrDash(stats._connDelErrs))
|
||||
infoRowTwoValues("Subscribed", "attempts", stats._connSubscribed, stats._connSubAttempts)
|
||||
infoRow("Subscription results ignored", numOrDash(stats._connSubIgnored))
|
||||
infoRow("Subscriptions ignored", numOrDash(stats._connSubIgnored))
|
||||
infoRow("Subscription errors", numOrDash(stats._connSubErrs))
|
||||
}
|
||||
Section {
|
||||
infoRowTwoValues("Enabled", "attempts", stats._ntfKey, stats._ntfKeyAttempts)
|
||||
infoRowTwoValues("Disabled", "attempts", stats._ntfKeyDeleted, stats._ntfKeyDeleteAttempts)
|
||||
} header: {
|
||||
Text("Connections")
|
||||
Text("Connection notifications")
|
||||
} footer: {
|
||||
Text("Starting from \(localTimestamp(statsStartedAt)).")
|
||||
}
|
||||
@@ -626,29 +641,19 @@ struct DetailedSMPStatsView: View {
|
||||
|
||||
private func infoRowTwoValues(_ title: LocalizedStringKey, _ title2: LocalizedStringKey, _ value: Int, _ value2: Int) -> some View {
|
||||
HStack {
|
||||
Text(title) + Text(" / ").font(.caption2) + Text(title2).font(.caption2)
|
||||
Text(title) + Text(verbatim: " / ").font(.caption2) + Text(title2).font(.caption2)
|
||||
Spacer()
|
||||
Group {
|
||||
if value == 0 && value2 == 0 {
|
||||
Text("-")
|
||||
Text(verbatim: "-")
|
||||
} else {
|
||||
Text(numOrDash(value)) + Text(" / ").font(.caption2) + Text(numOrDash(value2)).font(.caption2)
|
||||
Text(numOrDash(value)) + Text(verbatim: " / ").font(.caption2) + Text(numOrDash(value2)).font(.caption2)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func indentedInfoRow(_ title: LocalizedStringKey, _ value: String) -> some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
.padding(.leading, 24)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
struct XFTPServerSummaryView: View {
|
||||
var summary: XFTPServerSummary
|
||||
var statsStartedAt: Date
|
||||
@@ -662,6 +667,7 @@ struct XFTPServerSummaryView: View {
|
||||
NavigationLink {
|
||||
ProtocolServersView(serverProtocol: .xftp)
|
||||
.navigationTitle("Your XFTP servers")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
Text("Open server settings")
|
||||
}
|
||||
@@ -692,6 +698,7 @@ struct XFTPStatsView: View {
|
||||
DetailedXFTPStatsView(stats: stats, statsStartedAt: statsStartedAt)
|
||||
.navigationTitle("Detailed statistics")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
Text("Details")
|
||||
}
|
||||
@@ -725,8 +732,8 @@ struct DetailedXFTPStatsView: View {
|
||||
infoRow("Size", prettySize(stats._downloadsSize))
|
||||
infoRowTwoValues("Chunks downloaded", "attempts", stats._downloads, stats._downloadAttempts)
|
||||
Text("Download errors")
|
||||
indentedInfoRow("AUTH", numOrDash(stats._downloadAuthErrs))
|
||||
indentedInfoRow("other", numOrDash(stats._downloadErrs))
|
||||
infoRow(Text(verbatim: "AUTH"), numOrDash(stats._downloadAuthErrs)).padding(.leading, 24)
|
||||
infoRow("other", numOrDash(stats._downloadErrs)).padding(.leading, 24)
|
||||
} header: {
|
||||
Text("Downloaded files")
|
||||
} footer: {
|
||||
|
||||
@@ -64,7 +64,7 @@ struct DatabaseErrorView: View {
|
||||
case let .migrationError(mtrError):
|
||||
titleText("Incompatible database version")
|
||||
fileNameText(dbFile)
|
||||
Text("Error: ") + Text(DatabaseErrorView.mtrErrorDescription(mtrError))
|
||||
Text("Error: ") + Text(mtrErrorDescription(mtrError))
|
||||
}
|
||||
case let .errorSQL(dbFile, migrationSQLError):
|
||||
titleText("Database error")
|
||||
@@ -105,15 +105,6 @@ struct DatabaseErrorView: View {
|
||||
Text("Migrations: \(ms.joined(separator: ", "))")
|
||||
}
|
||||
|
||||
static func mtrErrorDescription(_ err: MTRError) -> LocalizedStringKey {
|
||||
switch err {
|
||||
case let .noDown(dbMigrations):
|
||||
return "database version is newer than the app, but no down migration for: \(dbMigrations.joined(separator: ", "))"
|
||||
case let .different(appMigration, dbMigration):
|
||||
return "different migration in the app/database: \(appMigration) / \(dbMigration)"
|
||||
}
|
||||
}
|
||||
|
||||
private func databaseKeyField(onSubmit: @escaping () -> Void) -> some View {
|
||||
PassphraseField(key: $dbKey, placeholder: "Enter passphrase…", valid: validKey(dbKey), onSubmit: onSubmit)
|
||||
}
|
||||
|
||||
@@ -16,18 +16,10 @@ struct ChatInfoImage: View {
|
||||
var color = Color(uiColor: .tertiarySystemGroupedBackground)
|
||||
|
||||
var body: some View {
|
||||
var iconName: String
|
||||
switch chat.chatInfo {
|
||||
case .direct: iconName = "person.crop.circle.fill"
|
||||
case .group: iconName = "person.2.circle.fill"
|
||||
case .local: iconName = "folder.circle.fill"
|
||||
case .contactRequest: iconName = "person.crop.circle.fill"
|
||||
default: iconName = "circle.fill"
|
||||
}
|
||||
let iconColor = if case .local = chat.chatInfo { theme.appColors.primaryVariant2 } else { color }
|
||||
return ProfileImage(
|
||||
imageStr: chat.chatInfo.image,
|
||||
iconName: iconName,
|
||||
iconName: chatIconName(chat.chatInfo),
|
||||
size: size,
|
||||
color: iconColor
|
||||
)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//
|
||||
// ChatItemClipShape.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Levitating Pineapple on 04/07/2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
/// Modifier, which provides clipping mask for ``ChatItemWithMenu`` view
|
||||
/// and it's previews: (drag interaction, context menu, etc.)
|
||||
/// Supports [Dynamic Type](https://developer.apple.com/documentation/uikit/uifont/scaling_fonts_automatically)
|
||||
/// by retaining pill shape, even when ``ChatItem``'s height is less that twice its corner radius
|
||||
struct ChatItemClipped: ViewModifier {
|
||||
struct ClipShape: Shape {
|
||||
let maxCornerRadius: Double
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
Path(
|
||||
roundedRect: rect,
|
||||
cornerRadius: min((rect.height / 2), maxCornerRadius),
|
||||
style: .circular
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
clipShape = ClipShape(
|
||||
maxCornerRadius: 18
|
||||
)
|
||||
}
|
||||
|
||||
init(_ chatItem: ChatItem) {
|
||||
clipShape = ClipShape(
|
||||
maxCornerRadius: {
|
||||
switch chatItem.content {
|
||||
case
|
||||
.sndMsgContent,
|
||||
.rcvMsgContent,
|
||||
.rcvDecryptionError,
|
||||
.rcvGroupInvitation,
|
||||
.sndGroupInvitation,
|
||||
.sndDeleted,
|
||||
.rcvDeleted,
|
||||
.rcvIntegrityError,
|
||||
.sndModerated,
|
||||
.rcvModerated,
|
||||
.rcvBlocked,
|
||||
.invalidJSON: 18
|
||||
default: 8
|
||||
}
|
||||
}()
|
||||
)
|
||||
}
|
||||
|
||||
private let clipShape: ClipShape
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.contentShape(.dragPreview, clipShape)
|
||||
.contentShape(.contextMenuPreview, clipShape)
|
||||
.clipShape(clipShape)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,10 @@ struct ChatViewBackground: ViewModifier {
|
||||
var image = context.resolve(image)
|
||||
let rect = CGRectMake(0, 0, size.width, size.height)
|
||||
func repeatDraw(_ imageScale: CGFloat) {
|
||||
// Prevent range bounds crash and dividing by zero
|
||||
if size.height == 0 || size.width == 0 || image.size.height == 0 || image.size.width == 0 { return }
|
||||
image.shading = .color(tint)
|
||||
let scale = imageScale * 1.57 // for some reason a wallpaper on iOS looks smaller than on Android
|
||||
let scale = imageScale * 2.5 // scale wallpaper for iOS
|
||||
for h in 0 ... Int(size.height / image.size.height / scale) {
|
||||
for w in 0 ... Int(size.width / image.size.width / scale) {
|
||||
let rect = CGRectMake(CGFloat(w) * image.size.width * scale, CGFloat(h) * image.size.height * scale, image.size.width * scale, image.size.height * scale)
|
||||
|
||||
@@ -21,9 +21,7 @@ struct ProfileImage: View {
|
||||
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner
|
||||
|
||||
var body: some View {
|
||||
if let image = imageStr,
|
||||
let data = Data(base64Encoded: dropImagePrefix(image)),
|
||||
let uiImage = UIImage(data: data) {
|
||||
if let uiImage = UIImage(base64Encoded: imageStr) {
|
||||
clipProfileImage(Image(uiImage: uiImage), size: size, radius: radius)
|
||||
} else {
|
||||
let c = color.asAnotherColorFromSecondaryVariant(theme)
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
//
|
||||
// VideoUtils.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Avently on 25.12.2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import SimpleXChat
|
||||
|
||||
func makeVideoQualityLower(_ input: URL, outputUrl: URL) async -> Bool {
|
||||
let asset: AVURLAsset = AVURLAsset(url: input, options: nil)
|
||||
if let s = AVAssetExportSession(asset: asset, presetName: AVAssetExportPreset640x480) {
|
||||
s.outputURL = outputUrl
|
||||
s.outputFileType = .mp4
|
||||
s.metadataItemFilter = AVMetadataItemFilter.forSharing()
|
||||
await s.export()
|
||||
if let err = s.error {
|
||||
logger.error("Failed to export video with error: \(err)")
|
||||
}
|
||||
return s.status == .completed
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -17,3 +17,37 @@ extension View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let chatViewWillBeginScrolling = Notification.Name("chatWillBeginScrolling")
|
||||
}
|
||||
|
||||
struct PrivacyBlur: ViewModifier {
|
||||
var enabled: Bool = true
|
||||
@Binding var blurred: Bool
|
||||
@AppStorage(DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) private var blurRadius: Int = 0
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if blurRadius > 0 {
|
||||
// parallel ifs are necessary here because otherwise some views flicker,
|
||||
// e.g. when playing video
|
||||
content
|
||||
.blur(radius: blurred && enabled ? CGFloat(blurRadius) * 0.5 : 0)
|
||||
.overlay {
|
||||
if (blurred && enabled) {
|
||||
Color.clear.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
blurred = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .chatViewWillBeginScrolling)) { _ in
|
||||
if !blurred {
|
||||
blurred = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,7 +662,7 @@ private struct PassphraseConfirmationView: View {
|
||||
if case .chatCmdError(_, .errorDatabase(.errorOpen(.errorNotADatabase))) = error as? ChatResponse {
|
||||
showErrorOnMigrationIfNeeded(.errorNotADatabase(dbFile: ""), $alert)
|
||||
} else {
|
||||
alert = .error(title: "Error", error: NSLocalizedString("Error verifying passphrase:", comment: "") + " " + String(String(describing: error)))
|
||||
alert = .error(title: "Error", error: NSLocalizedString("Error verifying passphrase:", comment: "") + " " + String(responseError(error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,7 +331,7 @@ struct MigrateToDevice: View {
|
||||
case let .migrationError(mtrError):
|
||||
("Incompatible database version",
|
||||
nil,
|
||||
"\(NSLocalizedString("Error: ", comment: "")) \(DatabaseErrorView.mtrErrorDescription(mtrError))",
|
||||
"\(NSLocalizedString("Error: ", comment: "")) \(mtrErrorDescription(mtrError))",
|
||||
nil)
|
||||
}
|
||||
default: ("Error", nil, "Unknown error", nil)
|
||||
|
||||
@@ -194,6 +194,7 @@ struct AddGroupView: View {
|
||||
let groupMembers = await apiListMembers(gInfo.groupId)
|
||||
await MainActor.run {
|
||||
m.groupMembers = groupMembers.map { GMember.init($0) }
|
||||
m.populateGroupMembersIndexes()
|
||||
}
|
||||
}
|
||||
let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: [])
|
||||
|
||||
@@ -37,7 +37,7 @@ struct ConnectDesktopView: View {
|
||||
case badInvitationError
|
||||
case badVersionError(version: String?)
|
||||
case desktopDisconnectedError
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
@@ -160,7 +160,7 @@ struct ConnectDesktopView: View {
|
||||
case .desktopDisconnectedError:
|
||||
Alert(title: Text("Connection terminated"))
|
||||
case let .error(title, error):
|
||||
Alert(title: Text(title), message: Text(error))
|
||||
mkAlert(title: title, message: error)
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled(m.activeRemoteCtrl)
|
||||
|
||||
@@ -32,6 +32,7 @@ extension AppSettings {
|
||||
if let val = privacyShowChatPreviews { def.setValue(val, forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) }
|
||||
if let val = privacySaveLastDraft { def.setValue(val, forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT) }
|
||||
if let val = privacyProtectScreen { def.setValue(val, forKey: DEFAULT_PRIVACY_PROTECT_SCREEN) }
|
||||
if let val = privacyMediaBlurRadius { def.setValue(val, forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) }
|
||||
if let val = notificationMode { ChatModel.shared.notificationMode = val.toNotificationsMode() }
|
||||
if let val = notificationPreviewMode { ntfPreviewModeGroupDefault.set(val) }
|
||||
if let val = webrtcPolicyRelay { def.setValue(val, forKey: DEFAULT_WEBRTC_POLICY_RELAY) }
|
||||
@@ -62,6 +63,7 @@ extension AppSettings {
|
||||
c.privacyShowChatPreviews = def.bool(forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS)
|
||||
c.privacySaveLastDraft = def.bool(forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT)
|
||||
c.privacyProtectScreen = def.bool(forKey: DEFAULT_PRIVACY_PROTECT_SCREEN)
|
||||
c.privacyMediaBlurRadius = def.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS)
|
||||
c.notificationMode = AppSettingsNotificationMode.from(ChatModel.shared.notificationMode)
|
||||
c.notificationPreviewMode = ntfPreviewModeGroupDefault.get()
|
||||
c.webrtcPolicyRelay = def.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
|
||||
|
||||
@@ -305,13 +305,16 @@ struct ChatThemePreview: View {
|
||||
let view = VStack {
|
||||
if withMessages {
|
||||
let alice = ChatItem.getSample(1, CIDirection.directRcv, Date.now, NSLocalizedString("Good afternoon!", comment: "message preview"))
|
||||
let bob = ChatItem.getSample(2, CIDirection.directSnd, Date.now, NSLocalizedString("Good morning!", comment: "message preview"), quotedItem: CIQuote.getSample(alice.id, alice.meta.itemTs, alice.content.text, chatDir: alice.chatDir))
|
||||
HStack {
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: alice, revealed: Binding.constant(false))
|
||||
.modifier(ChatItemClipped())
|
||||
Spacer()
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, CIDirection.directSnd, Date.now, NSLocalizedString("Good morning!", comment: "message preview"), quotedItem: CIQuote.getSample(alice.id, alice.meta.itemTs, alice.content.text, chatDir: alice.chatDir)), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: bob, revealed: Binding.constant(false))
|
||||
.modifier(ChatItemClipped())
|
||||
.frame(alignment: .trailing)
|
||||
}
|
||||
} else {
|
||||
@@ -747,7 +750,7 @@ struct ThemeDestinationPicker: View {
|
||||
let values = [(nil, "All profiles")] + m.users.filter { $0.user.activeUser }.map { ($0.user.userId, $0.user.chatViewName)}
|
||||
|
||||
if values.contains(where: { (userId, text) in userId == themeUserDestination?.0 }) {
|
||||
Picker("Apply to mode", selection: $themeUserDest) {
|
||||
Picker("Apply to", selection: $themeUserDest) {
|
||||
ForEach(values, id: \.0) { (_, text) in
|
||||
Text(text)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ struct NetworkAndServers: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
|
||||
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
|
||||
@State private var cfgLoaded = false
|
||||
@State private var currentNetCfg = NetCfg.defaults
|
||||
@State private var netCfg = NetCfg.defaults
|
||||
@@ -62,8 +61,6 @@ struct NetworkAndServers: View {
|
||||
Text("XFTP servers")
|
||||
}
|
||||
|
||||
Toggle("Subscription percentage", isOn: $showSubscriptionPercentage)
|
||||
|
||||
Picker("Use .onion hosts", selection: $onionHosts) {
|
||||
ForEach(OnionHosts.values, id: \.self) { Text($0.text) }
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ struct PrivacySettings: View {
|
||||
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
|
||||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
||||
@State private var currentLAMode = privacyLocalAuthModeDefault.get()
|
||||
@AppStorage(DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) private var privacyMediaBlurRadius: Int = 0
|
||||
@State private var contactReceipts = false
|
||||
@State private var contactReceiptsReset = false
|
||||
@State private var contactReceiptsOverrides = 0
|
||||
@@ -113,6 +114,22 @@ struct PrivacySettings: View {
|
||||
privacyAcceptImagesGroupDefault.set($0)
|
||||
}
|
||||
}
|
||||
settingsRow("circle.rectangle.filled.pattern.diagonalline", color: theme.colors.secondary) {
|
||||
Picker("Blur media", selection: $privacyMediaBlurRadius) {
|
||||
let values = [0, 12, 24, 48] + ([0, 12, 24, 48].contains(privacyMediaBlurRadius) ? [] : [privacyMediaBlurRadius])
|
||||
ForEach(values, id: \.self) { radius in
|
||||
let text: String = switch radius {
|
||||
case 0: NSLocalizedString("Off", comment: "blur media")
|
||||
case 12: NSLocalizedString("Soft", comment: "blur media")
|
||||
case 24: NSLocalizedString("Medium", comment: "blur media")
|
||||
case 48: NSLocalizedString("Strong", comment: "blur media")
|
||||
default: "\(radius)"
|
||||
}
|
||||
Text(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
settingsRow("network.badge.shield.half.filled", color: theme.colors.secondary) {
|
||||
Toggle("Protect IP address", isOn: $askToApproveRelays)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ struct ProtocolServerView: View {
|
||||
let serverProtocol: ServerProtocol
|
||||
@Binding var server: ServerCfg
|
||||
@State var serverToEdit: ServerCfg
|
||||
@State var serverEnabled: Bool
|
||||
@State private var showTestFailure = false
|
||||
@State private var testing = false
|
||||
@State private var testFailure: ProtocolTestFailure?
|
||||
@@ -113,10 +112,10 @@ struct ProtocolServerView: View {
|
||||
Spacer()
|
||||
showTestStatus(server: serverToEdit)
|
||||
}
|
||||
Toggle("Use for new connections", isOn: $serverEnabled)
|
||||
.onChange(of: serverEnabled) { enabled in
|
||||
serverToEdit.enabled = enabled ? .enabled : .disabled
|
||||
}
|
||||
let useForNewDisabled = serverToEdit.tested != true && !serverToEdit.preset
|
||||
Toggle("Use for new connections", isOn: $serverToEdit.enabled)
|
||||
.disabled(useForNewDisabled)
|
||||
.foregroundColor(useForNewDisabled ? theme.colors.secondary : theme.colors.onBackground)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,17 +175,12 @@ func testServerConnection(server: Binding<ServerCfg>) async -> ProtocolTestFailu
|
||||
}
|
||||
}
|
||||
|
||||
func serverHostname(_ srv: String) -> String {
|
||||
parseServerAddress(srv)?.hostnames.first ?? srv
|
||||
}
|
||||
|
||||
struct ProtocolServerView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ProtocolServerView(
|
||||
serverProtocol: .smp,
|
||||
server: Binding.constant(ServerCfg.sampleData.custom),
|
||||
serverToEdit: ServerCfg.sampleData.custom,
|
||||
serverEnabled: true
|
||||
serverToEdit: ServerCfg.sampleData.custom
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,9 @@ struct ProtocolServersView: View {
|
||||
@Environment(\.editMode) private var editMode
|
||||
let serverProtocol: ServerProtocol
|
||||
@State private var currServers: [ServerCfg] = []
|
||||
@State private var presetServers: [String] = []
|
||||
@State private var servers: [ServerCfg] = []
|
||||
@State private var presetServers: [ServerCfg] = []
|
||||
@State private var configuredServers: [ServerCfg] = []
|
||||
@State private var otherServers: [ServerCfg] = []
|
||||
@State private var selectedServer: String? = nil
|
||||
@State private var showAddServer = false
|
||||
@State private var showScanProtoServer = false
|
||||
@@ -53,31 +54,53 @@ struct ProtocolServersView: View {
|
||||
|
||||
private func protocolServersView() -> some View {
|
||||
List {
|
||||
Section {
|
||||
ForEach($servers) { srv in
|
||||
protocolServerView(srv)
|
||||
if !configuredServers.isEmpty {
|
||||
Section {
|
||||
ForEach($configuredServers) { srv in
|
||||
protocolServerView(srv)
|
||||
}
|
||||
.onMove { indexSet, offset in
|
||||
configuredServers.move(fromOffsets: indexSet, toOffset: offset)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
configuredServers.remove(atOffsets: indexSet)
|
||||
}
|
||||
} header: {
|
||||
Text("Configured \(proto) servers")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
Text("The servers for new connections of your current chat profile **\(m.currentUser?.displayName ?? "")**.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.lineLimit(10)
|
||||
}
|
||||
.onMove { indexSet, offset in
|
||||
servers.move(fromOffsets: indexSet, toOffset: offset)
|
||||
}
|
||||
|
||||
if !otherServers.isEmpty {
|
||||
Section {
|
||||
ForEach($otherServers) { srv in
|
||||
protocolServerView(srv)
|
||||
}
|
||||
.onMove { indexSet, offset in
|
||||
otherServers.move(fromOffsets: indexSet, toOffset: offset)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
otherServers.remove(atOffsets: indexSet)
|
||||
}
|
||||
} header: {
|
||||
Text("Other \(proto) servers")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
servers.remove(atOffsets: indexSet)
|
||||
}
|
||||
Button("Add server…") {
|
||||
showAddServer = true
|
||||
}
|
||||
} header: {
|
||||
Text("\(proto) servers")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
Text("The servers for new connections of your current chat profile **\(m.currentUser?.displayName ?? "")**.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.lineLimit(10)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Reset") { servers = currServers }
|
||||
.disabled(servers == currServers || testing)
|
||||
Button("Add server") {
|
||||
showAddServer = true
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Reset") { partitionServers(currServers) }
|
||||
.disabled(Set(allServers) == Set(currServers) || testing)
|
||||
Button("Test servers", action: testServers)
|
||||
.disabled(testing || allServersDisabled)
|
||||
Button("Save servers", action: saveServers)
|
||||
@@ -86,17 +109,17 @@ struct ProtocolServersView: View {
|
||||
}
|
||||
}
|
||||
.toolbar { EditButton() }
|
||||
.confirmationDialog("Add server…", isPresented: $showAddServer, titleVisibility: .hidden) {
|
||||
.confirmationDialog("Add server", isPresented: $showAddServer, titleVisibility: .hidden) {
|
||||
Button("Enter server manually") {
|
||||
servers.append(ServerCfg.empty)
|
||||
selectedServer = servers.last?.id
|
||||
otherServers.append(ServerCfg.empty)
|
||||
selectedServer = allServers.last?.id
|
||||
}
|
||||
Button("Scan server QR code") { showScanProtoServer = true }
|
||||
Button("Add preset servers", action: addAllPresets)
|
||||
.disabled(hasAllPresets())
|
||||
}
|
||||
.sheet(isPresented: $showScanProtoServer) {
|
||||
ScanProtocolServer(servers: $servers)
|
||||
ScanProtocolServer(servers: $otherServers)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
.modifier(BackButton(disabled: Binding.constant(false)) {
|
||||
@@ -133,27 +156,39 @@ struct ProtocolServersView: View {
|
||||
}
|
||||
.onAppear {
|
||||
// this condition is needed to prevent re-setting the servers when exiting single server view
|
||||
if !justOpened { return }
|
||||
do {
|
||||
let r = try getUserProtoServers(serverProtocol)
|
||||
currServers = r.protoServers
|
||||
presetServers = r.presetServers
|
||||
servers = currServers
|
||||
} catch let error {
|
||||
alert = .error(
|
||||
title: "Error loading \(proto) servers",
|
||||
error: "Error: \(responseError(error))"
|
||||
)
|
||||
if justOpened {
|
||||
do {
|
||||
let r = try getUserProtoServers(serverProtocol)
|
||||
currServers = r.protoServers
|
||||
presetServers = r.presetServers
|
||||
partitionServers(currServers)
|
||||
} catch let error {
|
||||
alert = .error(
|
||||
title: "Error loading \(proto) servers",
|
||||
error: "Error: \(responseError(error))"
|
||||
)
|
||||
}
|
||||
justOpened = false
|
||||
} else {
|
||||
partitionServers(allServers)
|
||||
}
|
||||
justOpened = false
|
||||
}
|
||||
}
|
||||
|
||||
private func partitionServers(_ servers: [ServerCfg]) {
|
||||
configuredServers = servers.filter { $0.preset || $0.enabled }
|
||||
otherServers = servers.filter { !($0.preset || $0.enabled) }
|
||||
}
|
||||
|
||||
private var allServers: [ServerCfg] {
|
||||
configuredServers + otherServers
|
||||
}
|
||||
|
||||
private var saveDisabled: Bool {
|
||||
servers.isEmpty ||
|
||||
servers == currServers ||
|
||||
allServers.isEmpty ||
|
||||
Set(allServers) == Set(currServers) ||
|
||||
testing ||
|
||||
!servers.allSatisfy { srv in
|
||||
!allServers.allSatisfy { srv in
|
||||
if let address = parseServerAddress(srv.server) {
|
||||
return uniqueAddress(srv, address)
|
||||
}
|
||||
@@ -163,7 +198,7 @@ struct ProtocolServersView: View {
|
||||
}
|
||||
|
||||
private var allServersDisabled: Bool {
|
||||
servers.allSatisfy { $0.enabled != .enabled }
|
||||
allServers.allSatisfy { !$0.enabled }
|
||||
}
|
||||
|
||||
private func protocolServerView(_ server: Binding<ServerCfg>) -> some View {
|
||||
@@ -172,8 +207,7 @@ struct ProtocolServersView: View {
|
||||
ProtocolServerView(
|
||||
serverProtocol: serverProtocol,
|
||||
server: server,
|
||||
serverToEdit: srv,
|
||||
serverEnabled: srv.enabled == .enabled
|
||||
serverToEdit: srv
|
||||
)
|
||||
.navigationBarTitle(srv.preset ? "Preset server" : "Your server")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
@@ -187,7 +221,7 @@ struct ProtocolServersView: View {
|
||||
invalidServer()
|
||||
} else if !uniqueAddress(srv, address) {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
||||
} else if srv.enabled != .enabled {
|
||||
} else if !srv.enabled {
|
||||
Image(systemName: "slash.circle").foregroundColor(theme.colors.secondary)
|
||||
} else {
|
||||
showTestStatus(server: srv)
|
||||
@@ -200,7 +234,7 @@ struct ProtocolServersView: View {
|
||||
.padding(.trailing, 4)
|
||||
|
||||
let v = Text(address?.hostnames.first ?? srv.server).lineLimit(1)
|
||||
if srv.enabled == .enabled {
|
||||
if srv.enabled {
|
||||
v
|
||||
} else {
|
||||
v.foregroundColor(theme.colors.secondary)
|
||||
@@ -227,7 +261,7 @@ struct ProtocolServersView: View {
|
||||
}
|
||||
|
||||
private func uniqueAddress(_ s: ServerCfg, _ address: ServerAddress) -> Bool {
|
||||
servers.allSatisfy { srv in
|
||||
allServers.allSatisfy { srv in
|
||||
address.hostnames.allSatisfy { host in
|
||||
srv.id == s.id || !srv.server.contains(host)
|
||||
}
|
||||
@@ -241,13 +275,13 @@ struct ProtocolServersView: View {
|
||||
private func addAllPresets() {
|
||||
for srv in presetServers {
|
||||
if !hasPreset(srv) {
|
||||
servers.append(ServerCfg(server: srv, preset: true, tested: nil, enabled: .enabled))
|
||||
configuredServers.append(srv)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func hasPreset(_ srv: String) -> Bool {
|
||||
servers.contains(where: { $0.server == srv })
|
||||
private func hasPreset(_ srv: ServerCfg) -> Bool {
|
||||
allServers.contains(where: { $0.server == srv.server })
|
||||
}
|
||||
|
||||
private func testServers() {
|
||||
@@ -265,19 +299,31 @@ struct ProtocolServersView: View {
|
||||
}
|
||||
|
||||
private func resetTestStatus() {
|
||||
for i in 0..<servers.count {
|
||||
if servers[i].enabled == .enabled {
|
||||
servers[i].tested = nil
|
||||
for i in 0..<configuredServers.count {
|
||||
if configuredServers[i].enabled {
|
||||
configuredServers[i].tested = nil
|
||||
}
|
||||
}
|
||||
for i in 0..<otherServers.count {
|
||||
if otherServers[i].enabled {
|
||||
otherServers[i].tested = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func runServersTest() async -> [String: ProtocolTestFailure] {
|
||||
var fs: [String: ProtocolTestFailure] = [:]
|
||||
for i in 0..<servers.count {
|
||||
if servers[i].enabled == .enabled {
|
||||
if let f = await testServerConnection(server: $servers[i]) {
|
||||
fs[serverHostname(servers[i].server)] = f
|
||||
for i in 0..<configuredServers.count {
|
||||
if configuredServers[i].enabled {
|
||||
if let f = await testServerConnection(server: $configuredServers[i]) {
|
||||
fs[serverHostname(configuredServers[i].server)] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in 0..<otherServers.count {
|
||||
if otherServers[i].enabled {
|
||||
if let f = await testServerConnection(server: $otherServers[i]) {
|
||||
fs[serverHostname(otherServers[i].server)] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,9 +333,9 @@ struct ProtocolServersView: View {
|
||||
func saveServers() {
|
||||
Task {
|
||||
do {
|
||||
try await setUserProtoServers(serverProtocol, servers: servers)
|
||||
try await setUserProtoServers(serverProtocol, servers: allServers)
|
||||
await MainActor.run {
|
||||
currServers = servers
|
||||
currServers = allServers
|
||||
editMode?.wrappedValue = .inactive
|
||||
}
|
||||
} catch let error {
|
||||
|
||||
@@ -40,7 +40,7 @@ struct ScanProtocolServer: View {
|
||||
switch resp {
|
||||
case let .success(r):
|
||||
if parseServerAddress(r.string) != nil {
|
||||
servers.append(ServerCfg(server: r.string, preset: false, tested: nil, enabled: .enabled))
|
||||
servers.append(ServerCfg(server: r.string, preset: false, tested: nil, enabled: false))
|
||||
dismiss()
|
||||
} else {
|
||||
showAddressError = true
|
||||
|
||||
@@ -34,6 +34,7 @@ let DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS = "privacyShowChatPreviews"
|
||||
let DEFAULT_PRIVACY_SAVE_LAST_DRAFT = "privacySaveLastDraft"
|
||||
let DEFAULT_PRIVACY_PROTECT_SCREEN = "privacyProtectScreen"
|
||||
let DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET = "privacyDeliveryReceiptsSet"
|
||||
let DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS = "privacyMediaBlurRadius"
|
||||
let DEFAULT_EXPERIMENTAL_CALLS = "experimentalCalls"
|
||||
let DEFAULT_CHAT_ARCHIVE_NAME = "chatArchiveName"
|
||||
let DEFAULT_CHAT_ARCHIVE_TIME = "chatArchiveTime"
|
||||
@@ -87,6 +88,7 @@ let appDefaults: [String: Any] = [
|
||||
DEFAULT_PRIVACY_SAVE_LAST_DRAFT: true,
|
||||
DEFAULT_PRIVACY_PROTECT_SCREEN: false,
|
||||
DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET: false,
|
||||
DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS: 0,
|
||||
DEFAULT_EXPERIMENTAL_CALLS: false,
|
||||
DEFAULT_CHAT_V3_DB_MIGRATION: V3DBMigrationState.offer.rawValue,
|
||||
DEFAULT_DEVELOPER_TOOLS: false,
|
||||
|
||||
@@ -30,7 +30,7 @@ struct UserAddressView: View {
|
||||
case deleteAddress
|
||||
case profileAddress(on: Bool)
|
||||
case shareOnCreate
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
@@ -185,7 +185,7 @@ struct UserAddressView: View {
|
||||
}, secondaryButton: .cancel()
|
||||
)
|
||||
case let .error(title, error):
|
||||
return Alert(title: Text(title), message: Text(error))
|
||||
return mkAlert(title: title, message: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ struct UserProfilesView: View {
|
||||
case hiddenProfilesNotice
|
||||
case muteProfileAlert
|
||||
case activateUserError(error: String)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
@@ -172,7 +172,7 @@ struct UserProfilesView: View {
|
||||
message: Text(err)
|
||||
)
|
||||
case let .error(title, error):
|
||||
return Alert(title: Text(title), message: Text(error))
|
||||
return mkAlert(title: title, message: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,8 +367,8 @@
|
||||
<source>Add servers by scanning QR codes.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add server…" xml:space="preserve">
|
||||
<source>Add server…</source>
|
||||
<trans-unit id="Add server" xml:space="preserve">
|
||||
<source>Add server</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add to another device" xml:space="preserve">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,10 @@
|
||||
"project" : "SimpleX.xcodeproj",
|
||||
"targetLocale" : "bg",
|
||||
"toolInfo" : {
|
||||
"toolBuildNumber" : "15A240d",
|
||||
"toolBuildNumber" : "15F31d",
|
||||
"toolID" : "com.apple.dt.xcode",
|
||||
"toolName" : "Xcode",
|
||||
"toolVersion" : "15.0"
|
||||
"toolVersion" : "15.4"
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
@@ -386,8 +386,8 @@
|
||||
<source>Add servers by scanning QR codes.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add server…" xml:space="preserve">
|
||||
<source>Add server…</source>
|
||||
<trans-unit id="Add server" xml:space="preserve">
|
||||
<source>Add server</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add to another device" xml:space="preserve">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,10 @@
|
||||
"project" : "SimpleX.xcodeproj",
|
||||
"targetLocale" : "cs",
|
||||
"toolInfo" : {
|
||||
"toolBuildNumber" : "15A240d",
|
||||
"toolBuildNumber" : "15F31d",
|
||||
"toolID" : "com.apple.dt.xcode",
|
||||
"toolName" : "Xcode",
|
||||
"toolVersion" : "15.0"
|
||||
"toolVersion" : "15.4"
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,10 @@
|
||||
"project" : "SimpleX.xcodeproj",
|
||||
"targetLocale" : "de",
|
||||
"toolInfo" : {
|
||||
"toolBuildNumber" : "15A240d",
|
||||
"toolBuildNumber" : "15F31d",
|
||||
"toolID" : "com.apple.dt.xcode",
|
||||
"toolName" : "Xcode",
|
||||
"toolVersion" : "15.0"
|
||||
"toolVersion" : "15.4"
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
@@ -336,8 +336,8 @@ Available in v5.1</source>
|
||||
<source>Add servers by scanning QR codes.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add server…" xml:space="preserve">
|
||||
<source>Add server…</source>
|
||||
<trans-unit id="Add server" xml:space="preserve">
|
||||
<source>Add server</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add to another device" xml:space="preserve">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,10 @@
|
||||
"project" : "SimpleX.xcodeproj",
|
||||
"targetLocale" : "en",
|
||||
"toolInfo" : {
|
||||
"toolBuildNumber" : "15A240d",
|
||||
"toolBuildNumber" : "15F31d",
|
||||
"toolID" : "com.apple.dt.xcode",
|
||||
"toolName" : "Xcode",
|
||||
"toolVersion" : "15.0"
|
||||
"toolVersion" : "15.4"
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,10 @@
|
||||
"project" : "SimpleX.xcodeproj",
|
||||
"targetLocale" : "es",
|
||||
"toolInfo" : {
|
||||
"toolBuildNumber" : "15A240d",
|
||||
"toolBuildNumber" : "15F31d",
|
||||
"toolID" : "com.apple.dt.xcode",
|
||||
"toolName" : "Xcode",
|
||||
"toolVersion" : "15.0"
|
||||
"toolVersion" : "15.4"
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,10 @@
|
||||
"project" : "SimpleX.xcodeproj",
|
||||
"targetLocale" : "fi",
|
||||
"toolInfo" : {
|
||||
"toolBuildNumber" : "15A240d",
|
||||
"toolBuildNumber" : "15F31d",
|
||||
"toolID" : "com.apple.dt.xcode",
|
||||
"toolName" : "Xcode",
|
||||
"toolVersion" : "15.0"
|
||||
"toolVersion" : "15.4"
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,10 @@
|
||||
"project" : "SimpleX.xcodeproj",
|
||||
"targetLocale" : "fr",
|
||||
"toolInfo" : {
|
||||
"toolBuildNumber" : "15A240d",
|
||||
"toolBuildNumber" : "15F31d",
|
||||
"toolID" : "com.apple.dt.xcode",
|
||||
"toolName" : "Xcode",
|
||||
"toolVersion" : "15.0"
|
||||
"toolVersion" : "15.4"
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
@@ -403,9 +403,9 @@ Available in v5.1</source>
|
||||
<target state="translated">הוספת שרתים על ידי סריקת קוד QR.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add server…" xml:space="preserve" approved="no">
|
||||
<source>Add server…</source>
|
||||
<target state="translated">הוסף שרת…</target>
|
||||
<trans-unit id="Add server" xml:space="preserve" approved="no">
|
||||
<source>Add server</source>
|
||||
<target state="translated">הוסף שרת</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add to another device" xml:space="preserve" approved="no">
|
||||
|
||||
@@ -300,8 +300,8 @@
|
||||
<source>Add servers by scanning QR codes.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add server…" xml:space="preserve">
|
||||
<source>Add server…</source>
|
||||
<trans-unit id="Add server" xml:space="preserve">
|
||||
<source>Add server</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add to another device" xml:space="preserve">
|
||||
|
||||
@@ -367,8 +367,8 @@
|
||||
<source>Add servers by scanning QR codes.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add server…" xml:space="preserve">
|
||||
<source>Add server…</source>
|
||||
<trans-unit id="Add server" xml:space="preserve">
|
||||
<source>Add server</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add to another device" xml:space="preserve">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,10 @@
|
||||
"project" : "SimpleX.xcodeproj",
|
||||
"targetLocale" : "hu",
|
||||
"toolInfo" : {
|
||||
"toolBuildNumber" : "15A240d",
|
||||
"toolBuildNumber" : "15F31d",
|
||||
"toolID" : "com.apple.dt.xcode",
|
||||
"toolName" : "Xcode",
|
||||
"toolVersion" : "15.0"
|
||||
"toolVersion" : "15.4"
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,10 @@
|
||||
"project" : "SimpleX.xcodeproj",
|
||||
"targetLocale" : "it",
|
||||
"toolInfo" : {
|
||||
"toolBuildNumber" : "15A240d",
|
||||
"toolBuildNumber" : "15F31d",
|
||||
"toolID" : "com.apple.dt.xcode",
|
||||
"toolName" : "Xcode",
|
||||
"toolVersion" : "15.0"
|
||||
"toolVersion" : "15.4"
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,10 @@
|
||||
"project" : "SimpleX.xcodeproj",
|
||||
"targetLocale" : "ja",
|
||||
"toolInfo" : {
|
||||
"toolBuildNumber" : "15A240d",
|
||||
"toolBuildNumber" : "15F31d",
|
||||
"toolID" : "com.apple.dt.xcode",
|
||||
"toolName" : "Xcode",
|
||||
"toolVersion" : "15.0"
|
||||
"toolVersion" : "15.4"
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
@@ -5,9 +5,11 @@
|
||||
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="14.2" build-num="14C18"/>
|
||||
</header>
|
||||
<body>
|
||||
<trans-unit id=" " xml:space="preserve">
|
||||
<trans-unit id=" " xml:space="preserve" approved="no">
|
||||
<source>
|
||||
</source>
|
||||
<target state="translated">
|
||||
</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id=" " xml:space="preserve">
|
||||
@@ -300,8 +302,8 @@
|
||||
<source>Add servers by scanning QR codes.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add server…" xml:space="preserve">
|
||||
<source>Add server…</source>
|
||||
<trans-unit id="Add server" xml:space="preserve">
|
||||
<source>Add server</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add to another device" xml:space="preserve">
|
||||
@@ -484,53 +486,62 @@
|
||||
<source>Can't delete user profile!</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Can't invite contact!" xml:space="preserve">
|
||||
<trans-unit id="Can't invite contact!" xml:space="preserve" approved="no">
|
||||
<source>Can't invite contact!</source>
|
||||
<target state="translated">주소를 초대할 수 없습니다.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Can't invite contacts!" xml:space="preserve">
|
||||
<source>Can't invite contacts!</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Cancel" xml:space="preserve">
|
||||
<trans-unit id="Cancel" xml:space="preserve" approved="no">
|
||||
<source>Cancel</source>
|
||||
<target state="translated">취소</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Cannot access keychain to save database password" xml:space="preserve">
|
||||
<trans-unit id="Cannot access keychain to save database password" xml:space="preserve" approved="no">
|
||||
<source>Cannot access keychain to save database password</source>
|
||||
<target state="translated">데이터베이스 암호를 저장하는 키체인에 접근 할 수 없습니다</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Cannot receive file" xml:space="preserve">
|
||||
<trans-unit id="Cannot receive file" xml:space="preserve" approved="no">
|
||||
<source>Cannot receive file</source>
|
||||
<target state="translated">파일을 받을 수 없습니다</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Change" xml:space="preserve">
|
||||
<trans-unit id="Change" xml:space="preserve" approved="no">
|
||||
<source>Change</source>
|
||||
<target state="translated">변경</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Change database passphrase?" xml:space="preserve">
|
||||
<source>Change database passphrase?</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Change member role?" xml:space="preserve">
|
||||
<trans-unit id="Change member role?" xml:space="preserve" approved="no">
|
||||
<source>Change member role?</source>
|
||||
<target state="translated">멤버 역할을 변경하시겠습니까?</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Change receiving address" xml:space="preserve">
|
||||
<trans-unit id="Change receiving address" xml:space="preserve" approved="no">
|
||||
<source>Change receiving address</source>
|
||||
<target state="translated">수신 주소 변경</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Change receiving address?" xml:space="preserve" approved="no">
|
||||
<source>Change receiving address?</source>
|
||||
<target state="translated">修改接收地址?</target>
|
||||
<target state="translated">수신 주소를 변경하시겠습니까?</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Change role" xml:space="preserve">
|
||||
<trans-unit id="Change role" xml:space="preserve" approved="no">
|
||||
<source>Change role</source>
|
||||
<target state="translated">역할 변경</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Chat archive" xml:space="preserve">
|
||||
<trans-unit id="Chat archive" xml:space="preserve" approved="no">
|
||||
<source>Chat archive</source>
|
||||
<target state="translated">채팅 기록 보관함</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Chat console" xml:space="preserve">
|
||||
@@ -545,8 +556,9 @@
|
||||
<source>Chat database deleted</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Chat database imported" xml:space="preserve">
|
||||
<trans-unit id="Chat database imported" xml:space="preserve" approved="no">
|
||||
<source>Chat database imported</source>
|
||||
<target state="translated">채팅 데이터베이스를 가져옴</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Chat is running" xml:space="preserve">
|
||||
@@ -2397,24 +2409,29 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<source>Send live message</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Send notifications" xml:space="preserve">
|
||||
<trans-unit id="Send notifications" xml:space="preserve" approved="no">
|
||||
<source>Send notifications</source>
|
||||
<target state="translated">알림 전송</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Send notifications:" xml:space="preserve">
|
||||
<trans-unit id="Send notifications:" xml:space="preserve" approved="no">
|
||||
<source>Send notifications:</source>
|
||||
<target state="translated">알림 전송:</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Send questions and ideas" xml:space="preserve">
|
||||
<trans-unit id="Send questions and ideas" xml:space="preserve" approved="no">
|
||||
<source>Send questions and ideas</source>
|
||||
<target state="translated">질문이나 아이디어 보내기</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Send them from gallery or custom keyboards." xml:space="preserve">
|
||||
<trans-unit id="Send them from gallery or custom keyboards." xml:space="preserve" approved="no">
|
||||
<source>Send them from gallery or custom keyboards.</source>
|
||||
<target state="needs-translation">갤러리 또는 사용자 정의 키보드에서 그들을 보내십시오.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Sender cancelled file transfer." xml:space="preserve">
|
||||
<trans-unit id="Sender cancelled file transfer." xml:space="preserve" approved="no">
|
||||
<source>Sender cancelled file transfer.</source>
|
||||
<target state="translated">상대방이 파일 전송을 취소했습니다.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Sender may have deleted the connection request." xml:space="preserve">
|
||||
@@ -3755,6 +3772,26 @@ SimpleX servers cannot see your profile.</source>
|
||||
<source>\~strike~</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Change passcode" xml:space="preserve" approved="no">
|
||||
<source>Change passcode</source>
|
||||
<target state="translated">패스코드 변경</target>
|
||||
<note>authentication reason</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Cellular" xml:space="preserve" approved="no">
|
||||
<source>Cellular</source>
|
||||
<target state="translated">셀룰러</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Send messages directly when your or destination server does not support private routing." xml:space="preserve" approved="no">
|
||||
<source>Send messages directly when your or destination server does not support private routing.</source>
|
||||
<target state="needs-translation">이 서버 또는 도착 서버가 비밀 라우팅을 지원하지 않을 때 직통 메시지 보내기.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Send up to 100 last messages to new members." xml:space="preserve" approved="no">
|
||||
<source>Send up to 100 last messages to new members.</source>
|
||||
<target state="translated">새로운 멤버에게 최대 100개의 마지막 메시지 보내기.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
<file original="en.lproj/SimpleX--iOS--InfoPlist.strings" source-language="en" target-language="ko" datatype="plaintext">
|
||||
@@ -3778,8 +3815,9 @@ SimpleX servers cannot see your profile.</source>
|
||||
<source>SimpleX needs microphone access for audio and video calls, and to record voice messages.</source>
|
||||
<note>Privacy - Microphone Usage Description</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="NSPhotoLibraryAddUsageDescription" xml:space="preserve">
|
||||
<trans-unit id="NSPhotoLibraryAddUsageDescription" xml:space="preserve" approved="no">
|
||||
<source>SimpleX needs access to Photo Library for saving captured and received media</source>
|
||||
<target state="needs-translation">SimpleX는 캡처 및 수신 된 미디어를 저장하기 위해 사진 라이브러리에 접근이 필요합니다</target>
|
||||
<note>Privacy - Photo Library Additions Usage Description</note>
|
||||
</trans-unit>
|
||||
</body>
|
||||
@@ -3793,8 +3831,9 @@ SimpleX servers cannot see your profile.</source>
|
||||
<source>SimpleX NSE</source>
|
||||
<note>Bundle display name</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="CFBundleName" xml:space="preserve">
|
||||
<trans-unit id="CFBundleName" xml:space="preserve" approved="no">
|
||||
<source>SimpleX NSE</source>
|
||||
<target state="translated">SimpleX NSE</target>
|
||||
<note>Bundle name</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="NSHumanReadableCopyright" xml:space="preserve">
|
||||
|
||||
@@ -329,9 +329,9 @@
|
||||
<target state="translated">Pridėti serverius skenuojant QR kodus.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add server…" xml:space="preserve" approved="no">
|
||||
<source>Add server…</source>
|
||||
<target state="translated">Pridėti serverį…</target>
|
||||
<trans-unit id="Add server" xml:space="preserve" approved="no">
|
||||
<source>Add server</source>
|
||||
<target state="translated">Pridėti serverį</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add to another device" xml:space="preserve">
|
||||
|
||||
@@ -367,8 +367,8 @@
|
||||
<source>Add servers by scanning QR codes.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add server…" xml:space="preserve">
|
||||
<source>Add server…</source>
|
||||
<trans-unit id="Add server" xml:space="preserve">
|
||||
<source>Add server</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add to another device" xml:space="preserve">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,10 @@
|
||||
"project" : "SimpleX.xcodeproj",
|
||||
"targetLocale" : "nl",
|
||||
"toolInfo" : {
|
||||
"toolBuildNumber" : "15A240d",
|
||||
"toolBuildNumber" : "15F31d",
|
||||
"toolID" : "com.apple.dt.xcode",
|
||||
"toolName" : "Xcode",
|
||||
"toolVersion" : "15.0"
|
||||
"toolVersion" : "15.4"
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,10 @@
|
||||
"project" : "SimpleX.xcodeproj",
|
||||
"targetLocale" : "pl",
|
||||
"toolInfo" : {
|
||||
"toolBuildNumber" : "15A240d",
|
||||
"toolBuildNumber" : "15F31d",
|
||||
"toolID" : "com.apple.dt.xcode",
|
||||
"toolName" : "Xcode",
|
||||
"toolVersion" : "15.0"
|
||||
"toolVersion" : "15.4"
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
@@ -374,9 +374,9 @@
|
||||
<target state="translated">Adicione servidores escaneando o QR code.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add server…" xml:space="preserve" approved="no">
|
||||
<source>Add server…</source>
|
||||
<target state="translated">Adicionar servidor…</target>
|
||||
<trans-unit id="Add server" xml:space="preserve" approved="no">
|
||||
<source>Add server</source>
|
||||
<target state="translated">Adicionar servidor</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add to another device" xml:space="preserve" approved="no">
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="14.2" build-num="14C18"/>
|
||||
</header>
|
||||
<body>
|
||||
<trans-unit id=" " xml:space="preserve">
|
||||
<trans-unit id=" " xml:space="preserve" approved="no">
|
||||
<source>
|
||||
</source>
|
||||
<target state="needs-translation">
|
||||
</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id=" Available in v5.1" xml:space="preserve">
|
||||
@@ -50,16 +52,19 @@ Available in v5.1</source>
|
||||
<target state="translated">#secreto#</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@" xml:space="preserve">
|
||||
<trans-unit id="%@" xml:space="preserve" approved="no">
|
||||
<source>%@</source>
|
||||
<target state="needs-translation">%@</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ %@" xml:space="preserve">
|
||||
<trans-unit id="%@ %@" xml:space="preserve" approved="no">
|
||||
<source>%@ %@</source>
|
||||
<target state="needs-translation">%@ %@</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ / %@" xml:space="preserve">
|
||||
<trans-unit id="%@ / %@" xml:space="preserve" approved="no">
|
||||
<source>%@ / %@</source>
|
||||
<target state="needs-translation">%@ / %@</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ is connected!" xml:space="preserve" approved="no">
|
||||
@@ -117,12 +122,14 @@ Available in v5.1</source>
|
||||
<target state="translated">%d mensagem(s) ignorada(s)</target>
|
||||
<note>integrity error chat item</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lld" xml:space="preserve">
|
||||
<trans-unit id="%lld" xml:space="preserve" approved="no">
|
||||
<source>%lld</source>
|
||||
<target state="needs-translation">%lld</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lld %@" xml:space="preserve">
|
||||
<trans-unit id="%lld %@" xml:space="preserve" approved="no">
|
||||
<source>%lld %@</source>
|
||||
<target state="needs-translation">%lld %@</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lld contact(s) selected" xml:space="preserve" approved="no">
|
||||
@@ -155,24 +162,29 @@ Available in v5.1</source>
|
||||
<target state="translated">%lld segundos</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lldd" xml:space="preserve">
|
||||
<trans-unit id="%lldd" xml:space="preserve" approved="no">
|
||||
<source>%lldd</source>
|
||||
<target state="needs-translation">%lldd</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lldh" xml:space="preserve">
|
||||
<trans-unit id="%lldh" xml:space="preserve" approved="no">
|
||||
<source>%lldh</source>
|
||||
<target state="needs-translation">%lldh</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lldk" xml:space="preserve">
|
||||
<trans-unit id="%lldk" xml:space="preserve" approved="no">
|
||||
<source>%lldk</source>
|
||||
<target state="needs-translation">%lldk</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lldm" xml:space="preserve">
|
||||
<trans-unit id="%lldm" xml:space="preserve" approved="no">
|
||||
<source>%lldm</source>
|
||||
<target state="needs-translation">%lldm</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%lldmth" xml:space="preserve">
|
||||
<trans-unit id="%lldmth" xml:space="preserve" approved="no">
|
||||
<source>%lldmth</source>
|
||||
<target state="needs-translation">%lldmth</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%llds" xml:space="preserve">
|
||||
@@ -193,8 +205,9 @@ Available in v5.1</source>
|
||||
<target state="translated">%u mensagens ignoradas.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="(" xml:space="preserve">
|
||||
<trans-unit id="(" xml:space="preserve" approved="no">
|
||||
<source>(</source>
|
||||
<target state="needs-translation">(</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id=")" xml:space="preserve">
|
||||
@@ -359,8 +372,8 @@ Available in v5.1</source>
|
||||
<source>Add servers by scanning QR codes.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add server…" xml:space="preserve">
|
||||
<source>Add server…</source>
|
||||
<trans-unit id="Add server" xml:space="preserve">
|
||||
<source>Add server</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Add to another device" xml:space="preserve">
|
||||
@@ -4540,6 +4553,31 @@ SimpleX servers cannot see your profile.</source>
|
||||
<target state="translated">Confirmar envio</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ downloaded" xml:space="preserve" approved="no">
|
||||
<source>%@ downloaded</source>
|
||||
<target state="translated">%@ baixado</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="# %@" xml:space="preserve" approved="no">
|
||||
<source># %@</source>
|
||||
<target state="needs-translation"># %@</target>
|
||||
<note>copied message info title, # <title></note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@:" xml:space="preserve" approved="no">
|
||||
<source>%@:</source>
|
||||
<target state="needs-translation">%@:</target>
|
||||
<note>copied message info</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ (current)" xml:space="preserve" approved="no">
|
||||
<source>%@ (current)</source>
|
||||
<target state="translated">%@(atual)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="%@ (current):" xml:space="preserve" approved="no">
|
||||
<source>%@ (current):</source>
|
||||
<target state="translated">%@ (atual):</target>
|
||||
<note>copied message info</note>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
<file original="en.lproj/SimpleX--iOS--InfoPlist.strings" source-language="en" target-language="pt" datatype="plaintext">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user