Compare commits
16 Commits
group-inte
...
v5.3.1-fdr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a578cfe3c | ||
|
|
dacc075fe8 | ||
|
|
55418e2bc0 | ||
|
|
f2b5c0f3a8 | ||
|
|
5ebdf5dba9 | ||
|
|
8e045764df | ||
|
|
503d3d77e6 | ||
|
|
81bd7d97c5 | ||
|
|
8f57925067 | ||
|
|
9bf99db82e | ||
|
|
5615cdbf1a | ||
|
|
d802ae0058 | ||
|
|
8f2278198c | ||
|
|
10937a5a4e | ||
|
|
6aff6e9804 | ||
|
|
95477cae7e |
49
.github/workflows/build.yml
vendored
49
.github/workflows/build.yml
vendored
@@ -79,10 +79,10 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Haskell
|
||||
uses: haskell-actions/setup@v2
|
||||
uses: haskell/actions/setup@v2
|
||||
with:
|
||||
ghc-version: "9.6.2"
|
||||
cabal-version: "3.10.1.0"
|
||||
ghc-version: "8.10.7"
|
||||
cabal-version: "latest"
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v3
|
||||
@@ -188,7 +188,7 @@ jobs:
|
||||
APPLE_SIMPLEX_NOTARIZATION_APPLE_ID: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_APPLE_ID }}
|
||||
APPLE_SIMPLEX_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_SIMPLEX_NOTARIZATION_PASSWORD }}
|
||||
run: |
|
||||
scripts/ci/build-desktop-mac.sh
|
||||
scripts/build-desktop-mac.sh
|
||||
path=$(echo $PWD/apps/multiplatform/release/main/dmg/SimpleX-*.dmg)
|
||||
echo "package_path=$path" >> $GITHUB_OUTPUT
|
||||
echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||
@@ -259,15 +259,15 @@ jobs:
|
||||
# Unix /
|
||||
|
||||
# / Windows
|
||||
# rm -rf dist-newstyle/src/direct-sq* is here because of the bug in cabal's dependency which prevents second build from finishing
|
||||
|
||||
# * In powershell multiline commands do not fail if individual commands fail - https://github.community/t/multiline-commands-on-windows-do-not-fail-if-individual-commands-fail/16753
|
||||
# * And GitHub Actions does not support parameterizing shell in a matrix job - https://github.community/t/using-matrix-to-specify-shell-is-it-possible/17065
|
||||
|
||||
- name: Windows build
|
||||
id: windows_build
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: bash
|
||||
shell: cmd
|
||||
run: |
|
||||
rm -rf dist-newstyle/src/direct-sq*
|
||||
sed -i "s/, unix /--, unix /" simplex-chat.cabal
|
||||
cabal build --enable-tests
|
||||
rm -rf dist-newstyle/src/direct-sq*
|
||||
path=$(cabal list-bin simplex-chat | tail -n 1)
|
||||
@@ -293,37 +293,4 @@ jobs:
|
||||
body: |
|
||||
${{ steps.windows_build.outputs.bin_hash }}
|
||||
|
||||
- name: Windows build desktop
|
||||
id: windows_desktop_build
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
|
||||
env:
|
||||
SIMPLEX_CI_REPO_URL: ${{ secrets.SIMPLEX_CI_REPO_URL }}
|
||||
shell: bash
|
||||
run: |
|
||||
scripts/desktop/build-lib-windows.sh
|
||||
cd apps/multiplatform
|
||||
./gradlew packageMsi
|
||||
path=$(echo $PWD/release/main/msi/*imple*.msi | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g')
|
||||
echo "package_path=$path" >> $GITHUB_OUTPUT
|
||||
echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Windows upload desktop package to release
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ${{ steps.windows_desktop_build.outputs.package_path }}
|
||||
asset_name: ${{ matrix.desktop_asset_name }}
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
- name: Windows update desktop package hash
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
|
||||
uses: softprops/action-gh-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
append_body: true
|
||||
body: |
|
||||
${{ steps.windows_desktop_build.outputs.package_hash }}
|
||||
|
||||
# Windows /
|
||||
|
||||
1
.github/workflows/web.yml
vendored
1
.github/workflows/web.yml
vendored
@@ -9,7 +9,6 @@ on:
|
||||
- website/**
|
||||
- images/**
|
||||
- blog/**
|
||||
- docs/**
|
||||
- .github/workflows/web.yml
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -8,12 +8,12 @@ RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/
|
||||
chmod +x /usr/bin/ghcup
|
||||
|
||||
# Install ghc
|
||||
RUN ghcup install ghc 9.6.2
|
||||
RUN ghcup install ghc 8.10.7
|
||||
# Install cabal
|
||||
RUN ghcup install cabal 3.10.1.0
|
||||
RUN ghcup install cabal
|
||||
# Set both as default
|
||||
RUN ghcup set ghc 9.6.2 && \
|
||||
ghcup set cabal 3.10.1.0
|
||||
RUN ghcup set ghc 8.10.7 && \
|
||||
ghcup set cabal
|
||||
|
||||
COPY . /project
|
||||
WORKDIR /project
|
||||
|
||||
@@ -55,6 +55,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
didReceiveRemoteNotification userInfo: [AnyHashable : Any],
|
||||
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
||||
logger.debug("AppDelegate: didReceiveRemoteNotification")
|
||||
print("*** userInfo", userInfo)
|
||||
let m = ChatModel.shared
|
||||
if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any],
|
||||
m.notificationMode != .off {
|
||||
|
||||
@@ -31,11 +31,11 @@ struct ContentView: View {
|
||||
@State private var chatListActionSheet: ChatListActionSheet? = nil
|
||||
|
||||
private enum ChatListActionSheet: Identifiable {
|
||||
case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
|
||||
case connectViaUrl(action: ConnReqType, link: String)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .planAndConnectSheet(sheet): return sheet.id
|
||||
case let .connectViaUrl(_, link): return "connectViaUrl \(link)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,7 @@ struct ContentView: View {
|
||||
mainView()
|
||||
.actionSheet(item: $chatListActionSheet) { sheet in
|
||||
switch sheet {
|
||||
case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false)
|
||||
case let .connectViaUrl(action, link): return connectViaUrlSheet(action, link)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -290,16 +290,12 @@ struct ContentView: View {
|
||||
if let url = m.appOpenUrl {
|
||||
m.appOpenUrl = nil
|
||||
var path = url.path
|
||||
logger.debug("ContentView.connectViaUrl path: \(path)")
|
||||
if (path == "/contact" || path == "/invitation") {
|
||||
path.removeFirst()
|
||||
let action: ConnReqType = path == "contact" ? .contact : .invitation
|
||||
let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
|
||||
planAndConnect(
|
||||
link,
|
||||
showAlert: showPlanAndConnectAlert,
|
||||
showActionSheet: { chatListActionSheet = .planAndConnectSheet(sheet: $0) },
|
||||
dismiss: false,
|
||||
incognito: nil
|
||||
)
|
||||
chatListActionSheet = .connectViaUrl(action: action, link: link)
|
||||
} else {
|
||||
AlertManager.shared.showAlert(Alert(title: Text("Error: URL is invalid")))
|
||||
}
|
||||
@@ -307,8 +303,20 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func showPlanAndConnectAlert(_ alert: PlanAndConnectAlert) {
|
||||
AlertManager.shared.showAlert(planAndConnectAlert(alert, dismiss: false))
|
||||
private func connectViaUrlSheet(_ action: ConnReqType, _ link: String) -> ActionSheet {
|
||||
let title: LocalizedStringKey
|
||||
switch action {
|
||||
case .contact: title = "Connect via contact link"
|
||||
case .invitation: title = "Connect via one-time link"
|
||||
}
|
||||
return ActionSheet(
|
||||
title: Text(title),
|
||||
buttons: [
|
||||
.default(Text("Use current profile")) { connectViaLink(link, incognito: false) },
|
||||
.default(Text("Use new incognito profile")) { connectViaLink(link, incognito: true) },
|
||||
.cancel()
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,9 +62,8 @@ final class ChatModel: ObservableObject {
|
||||
// current chat
|
||||
@Published var chatId: String?
|
||||
@Published var reversedChatItems: [ChatItem] = []
|
||||
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
|
||||
@Published var chatToTop: String?
|
||||
@Published var groupMembers: [GMember] = []
|
||||
@Published var groupMembers: [GroupMember] = []
|
||||
// items in the terminal view
|
||||
@Published var showingTerminal = false
|
||||
@Published var terminalItems: [TerminalItem] = []
|
||||
@@ -153,20 +152,6 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func getGroupChat(_ groupId: Int64) -> Chat? {
|
||||
chats.first { chat in
|
||||
if case let .group(groupInfo) = chat.chatInfo {
|
||||
return groupInfo.groupId == groupId
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getGroupMember(_ groupMemberId: Int64) -> GMember? {
|
||||
groupMembers.first { $0.groupMemberId == groupMemberId }
|
||||
}
|
||||
|
||||
private func getChatIndex(_ id: String) -> Int? {
|
||||
chats.firstIndex(where: { $0.id == id })
|
||||
}
|
||||
@@ -180,7 +165,6 @@ final class ChatModel: ObservableObject {
|
||||
func updateChatInfo(_ cInfo: ChatInfo) {
|
||||
if let i = getChatIndex(cInfo.id) {
|
||||
chats[i].chatInfo = cInfo
|
||||
chats[i].created = Date.now
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,11 +296,7 @@ final class ChatModel: ObservableObject {
|
||||
return false
|
||||
} else {
|
||||
withAnimation(itemAnimation()) {
|
||||
var ci = cItem
|
||||
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
|
||||
ci.meta.itemStatus = status
|
||||
}
|
||||
reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
|
||||
reversedChatItems.insert(cItem, at: hasLiveDummy ? 1 : 0)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -329,22 +309,26 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) {
|
||||
func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
|
||||
withAnimation {
|
||||
_updateChatItem(at: i, with: cItem)
|
||||
}
|
||||
} else if let status = status {
|
||||
chatItemStatuses.updateValue(status, forKey: cItem.id)
|
||||
}
|
||||
}
|
||||
|
||||
private func _updateChatItem(at i: Int, with cItem: ChatItem) {
|
||||
let ci = reversedChatItems[i]
|
||||
reversedChatItems[i] = cItem
|
||||
reversedChatItems[i].viewTimestamp = .now
|
||||
// on some occasions the confirmation of message being accepted by the server (tick)
|
||||
// arrives earlier than the response from API, and item remains without tick
|
||||
if case .sndNew = cItem.meta.itemStatus {
|
||||
reversedChatItems[i].meta.itemStatus = ci.meta.itemStatus
|
||||
}
|
||||
}
|
||||
|
||||
func getChatItemIndex(_ cItem: ChatItem) -> Int? {
|
||||
private func getChatItemIndex(_ cItem: ChatItem) -> Int? {
|
||||
reversedChatItems.firstIndex(where: { $0.id == cItem.id })
|
||||
}
|
||||
|
||||
@@ -480,7 +464,6 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
// clear current chat
|
||||
if chatId == cInfo.id {
|
||||
chatItemStatuses = [:]
|
||||
reversedChatItems = []
|
||||
}
|
||||
}
|
||||
@@ -533,62 +516,27 @@ final class ChatModel: ObservableObject {
|
||||
users.filter { !$0.user.activeUser }.reduce(0, { unread, next -> Int in unread + next.unreadCount })
|
||||
}
|
||||
|
||||
// this function analyses "connected" events and assumes that each member will be there only once
|
||||
func getConnectedMemberNames(_ chatItem: ChatItem) -> (Int, [String]) {
|
||||
var count = 0
|
||||
func getConnectedMemberNames(_ ci: ChatItem) -> [String] {
|
||||
guard var i = getChatItemIndex(ci) else { return [] }
|
||||
var ns: [String] = []
|
||||
if let ciCategory = chatItem.mergeCategory,
|
||||
var i = getChatItemIndex(chatItem) {
|
||||
while i < reversedChatItems.count {
|
||||
let ci = reversedChatItems[i]
|
||||
if ci.mergeCategory != ciCategory { break }
|
||||
if let m = ci.memberConnected {
|
||||
ns.append(m.displayName)
|
||||
}
|
||||
count += 1
|
||||
i += 1
|
||||
}
|
||||
while i < reversedChatItems.count, let m = reversedChatItems[i].memberConnected {
|
||||
ns.append(m.displayName)
|
||||
i += 1
|
||||
}
|
||||
return (count, ns)
|
||||
return ns
|
||||
}
|
||||
|
||||
// returns the index of the passed item and the next item (it has smaller index)
|
||||
func getNextChatItem(_ ci: ChatItem) -> (Int?, ChatItem?) {
|
||||
if let i = getChatItemIndex(ci) {
|
||||
(i, i > 0 ? reversedChatItems[i - 1] : nil)
|
||||
func getChatItemNeighbors(_ ci: ChatItem) -> (ChatItem?, ChatItem?) {
|
||||
if let i = getChatItemIndex(ci) {
|
||||
return (
|
||||
i + 1 < reversedChatItems.count ? reversedChatItems[i + 1] : nil,
|
||||
i - 1 >= 0 ? reversedChatItems[i - 1] : nil
|
||||
)
|
||||
} else {
|
||||
(nil, nil)
|
||||
return (nil, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// returns the index of the first item in the same merged group (the first hidden item)
|
||||
// and the previous visible item with another merge category
|
||||
func getPrevShownChatItem(_ ciIndex: Int?, _ ciCategory: CIMergeCategory?) -> (Int?, ChatItem?) {
|
||||
guard var i = ciIndex else { return (nil, nil) }
|
||||
let fst = reversedChatItems.count - 1
|
||||
while i < fst {
|
||||
i = i + 1
|
||||
let ci = reversedChatItems[i]
|
||||
if ciCategory == nil || ciCategory != ci.mergeCategory {
|
||||
return (i - 1, ci)
|
||||
}
|
||||
}
|
||||
return (i, nil)
|
||||
}
|
||||
|
||||
// returns the previous member in the same merge group and the count of members in this group
|
||||
func getPrevHiddenMember(_ member: GroupMember, _ range: ClosedRange<Int>) -> (GroupMember?, Int) {
|
||||
var prevMember: GroupMember? = nil
|
||||
var memberIds: Set<Int64> = []
|
||||
for i in range {
|
||||
if case let .groupRcv(m) = reversedChatItems[i].chatDir {
|
||||
if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m }
|
||||
memberIds.insert(m.groupMemberId)
|
||||
}
|
||||
}
|
||||
return (prevMember, memberIds.count)
|
||||
}
|
||||
|
||||
func popChat(_ id: String) {
|
||||
if let i = getChatIndex(id) {
|
||||
popChat_(i)
|
||||
@@ -623,14 +571,13 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
// update current chat
|
||||
if chatId == groupInfo.id {
|
||||
if let i = groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) {
|
||||
if let i = groupMembers.firstIndex(where: { $0.id == member.id }) {
|
||||
withAnimation(.default) {
|
||||
self.groupMembers[i].wrapped = member
|
||||
self.groupMembers[i].created = Date.now
|
||||
self.groupMembers[i] = member
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
withAnimation { groupMembers.append(GMember(member)) }
|
||||
withAnimation { groupMembers.append(member) }
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
@@ -639,10 +586,11 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
func updateGroupMemberConnectionStats(_ groupInfo: GroupInfo, _ member: GroupMember, _ connectionStats: ConnectionStats) {
|
||||
if var conn = member.activeConn {
|
||||
conn.connectionStats = connectionStats
|
||||
if let conn = member.activeConn {
|
||||
var updatedConn = conn
|
||||
updatedConn.connectionStats = connectionStats
|
||||
var updatedMember = member
|
||||
updatedMember.activeConn = conn
|
||||
updatedMember.activeConn = updatedConn
|
||||
_ = upsertGroupMember(groupInfo, updatedMember)
|
||||
}
|
||||
}
|
||||
@@ -741,18 +689,40 @@ final class Chat: ObservableObject, Identifiable {
|
||||
public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
|
||||
}
|
||||
|
||||
final class GMember: ObservableObject, Identifiable {
|
||||
@Published var wrapped: GroupMember
|
||||
var created = Date.now
|
||||
enum NetworkStatus: Decodable, Equatable {
|
||||
case unknown
|
||||
case connected
|
||||
case disconnected
|
||||
case error(String)
|
||||
|
||||
init(_ member: GroupMember) {
|
||||
self.wrapped = member
|
||||
var statusString: LocalizedStringKey {
|
||||
get {
|
||||
switch self {
|
||||
case .connected: return "connected"
|
||||
case .error: return "error"
|
||||
default: return "connecting"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var id: String { wrapped.id }
|
||||
var groupId: Int64 { wrapped.groupId }
|
||||
var groupMemberId: Int64 { wrapped.groupMemberId }
|
||||
var displayName: String { wrapped.displayName }
|
||||
var viewId: String { get { "\(wrapped.id) \(created.timeIntervalSince1970)" } }
|
||||
static let sampleData = GMember(GroupMember.sampleData)
|
||||
var statusExplanation: LocalizedStringKey {
|
||||
get {
|
||||
switch self {
|
||||
case .connected: return "You are connected to the server used to receive messages from this contact."
|
||||
case let .error(err): return "Trying to connect to the server used to receive messages from this contact (error: \(err))."
|
||||
default: return "Trying to connect to the server used to receive messages from this contact."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var imageName: String {
|
||||
get {
|
||||
switch self {
|
||||
case .unknown: return "circle.dotted"
|
||||
case .connected: return "circle.fill"
|
||||
case .disconnected: return "ellipsis.circle.fill"
|
||||
case .error: return "exclamationmark.circle.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,12 +257,6 @@ func setXFTPConfig(_ cfg: XFTPFileConfig?) throws {
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSetEncryptLocalFiles(_ enable: Bool) throws {
|
||||
let r = chatSendCmdSync(.apiSetEncryptLocalFiles(enable: enable))
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiExportArchive(config: ArchiveConfig) async throws {
|
||||
try await sendCommandOkResp(.apiExportArchive(config: config))
|
||||
}
|
||||
@@ -312,7 +306,6 @@ func loadChat(chat: Chat, search: String = "") {
|
||||
do {
|
||||
let cInfo = chat.chatInfo
|
||||
let m = ChatModel.shared
|
||||
m.chatItemStatuses = [:]
|
||||
m.reversedChatItems = []
|
||||
let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
|
||||
m.updateChatInfo(chat.chatInfo)
|
||||
@@ -502,10 +495,6 @@ func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) a
|
||||
try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings))
|
||||
}
|
||||
|
||||
func apiSetMemberSettings(_ groupId: Int64, _ groupMemberId: Int64, _ memberSettings: GroupMemberSettings) async throws {
|
||||
try await sendCommandOkResp(.apiSetMemberSettings(groupId: groupId, groupMemberId: groupMemberId, memberSettings: memberSettings))
|
||||
}
|
||||
|
||||
func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profile?) {
|
||||
let r = await chatSendCmd(.apiContactInfo(contactId: contactId))
|
||||
if case let .contactInfo(_, _, connStats, customUserProfile) = r { return (connStats, customUserProfile) }
|
||||
@@ -597,14 +586,6 @@ func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> P
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiConnectPlan(connReq: String) async throws -> ConnectionPlan {
|
||||
let userId = try currentUserId("apiConnectPlan")
|
||||
let r = await chatSendCmd(.apiConnectPlan(userId: userId, connReq: connReq))
|
||||
if case let .connectionPlan(_, connectionPlan) = r { return connectionPlan }
|
||||
logger.error("apiConnectPlan error: \(responseError(r))")
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiConnect(incognito: Bool, connReq: String) async -> ConnReqType? {
|
||||
let (connReqType, alert) = await apiConnect_(incognito: incognito, connReq: connReq)
|
||||
if let alert = alert {
|
||||
@@ -629,7 +610,10 @@ func apiConnect_(incognito: Bool, connReq: String) async -> (ConnReqType?, Alert
|
||||
if let c = m.getContactChat(contact.contactId) {
|
||||
await MainActor.run { m.chatId = c.id }
|
||||
}
|
||||
let alert = contactAlreadyExistsAlert(contact)
|
||||
let alert = mkAlert(
|
||||
title: "Contact already exists",
|
||||
message: "You are already connected to \(contact.displayName)."
|
||||
)
|
||||
return (nil, alert)
|
||||
case .chatCmdError(_, .error(.invalidConnReq)):
|
||||
let alert = mkAlert(
|
||||
@@ -657,13 +641,6 @@ func apiConnect_(incognito: Bool, connReq: String) async -> (ConnReqType?, Alert
|
||||
return (nil, alert)
|
||||
}
|
||||
|
||||
func contactAlreadyExistsAlert(_ contact: Contact) -> Alert {
|
||||
mkAlert(
|
||||
title: "Contact already exists",
|
||||
message: "You are already connected to \(contact.displayName)."
|
||||
)
|
||||
}
|
||||
|
||||
private func connectionErrorAlert(_ r: ChatResponse) -> Alert {
|
||||
if let networkErrorAlert = networkErrorAlert(r) {
|
||||
return networkErrorAlert
|
||||
@@ -675,18 +652,18 @@ private func connectionErrorAlert(_ r: ChatResponse) -> Alert {
|
||||
}
|
||||
}
|
||||
|
||||
func apiDeleteChat(type: ChatType, id: Int64, notify: Bool? = nil) async throws {
|
||||
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, notify: notify), bgTask: false)
|
||||
func apiDeleteChat(type: ChatType, id: Int64) async throws {
|
||||
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id), bgTask: false)
|
||||
if case .direct = type, case .contactDeleted = r { return }
|
||||
if case .contactConnection = type, case .contactConnectionDeleted = r { return }
|
||||
if case .group = type, case .groupDeletedUser = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func deleteChat(_ chat: Chat, notify: Bool? = nil) async {
|
||||
func deleteChat(_ chat: Chat) async {
|
||||
do {
|
||||
let cInfo = chat.chatInfo
|
||||
try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, notify: notify)
|
||||
try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId)
|
||||
DispatchQueue.main.async { ChatModel.shared.removeChat(cInfo.id) }
|
||||
} catch let error {
|
||||
logger.error("deleteChat apiDeleteChat error: \(responseError(error))")
|
||||
@@ -724,9 +701,8 @@ func apiUpdateProfile(profile: Profile) async throws -> (Profile, [Contact])? {
|
||||
let userId = try currentUserId("apiUpdateProfile")
|
||||
let r = await chatSendCmd(.apiUpdateProfile(userId: userId, profile: profile))
|
||||
switch r {
|
||||
case .userProfileNoChange: return (profile, [])
|
||||
case .userProfileNoChange: return nil
|
||||
case let .userProfileUpdated(_, _, toProfile, updateSummary): return (toProfile, updateSummary.changedContacts)
|
||||
case .chatCmdError(_, .errorStore(.duplicateName)): return nil;
|
||||
default: throw r
|
||||
}
|
||||
}
|
||||
@@ -968,12 +944,6 @@ func apiCallStatus(_ contact: Contact, _ status: String) async throws {
|
||||
}
|
||||
}
|
||||
|
||||
func apiGetNetworkStatuses() throws -> [ConnNetworkStatus] {
|
||||
let r = chatSendCmdSync(.apiGetNetworkStatuses)
|
||||
if case let .networkStatuses(_, statuses) = r { return statuses }
|
||||
throw r
|
||||
}
|
||||
|
||||
func markChatRead(_ chat: Chat, aboveItem: ChatItem? = nil) async {
|
||||
do {
|
||||
if chat.chatStats.unreadCount > 0 {
|
||||
@@ -1021,9 +991,9 @@ private func sendCommandOkResp(_ cmd: ChatCommand) async throws {
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiNewGroup(incognito: Bool, groupProfile: GroupProfile) throws -> GroupInfo {
|
||||
func apiNewGroup(_ p: GroupProfile) throws -> GroupInfo {
|
||||
let userId = try currentUserId("apiNewGroup")
|
||||
let r = chatSendCmdSync(.apiNewGroup(userId: userId, incognito: incognito, groupProfile: groupProfile))
|
||||
let r = chatSendCmdSync(.apiNewGroup(userId: userId, groupProfile: p))
|
||||
if case let .groupCreated(_, groupInfo) = r { return groupInfo }
|
||||
throw r
|
||||
}
|
||||
@@ -1083,8 +1053,8 @@ func apiListMembers(_ groupId: Int64) async -> [GroupMember] {
|
||||
return []
|
||||
}
|
||||
|
||||
func filterMembersToAdd(_ ms: [GMember]) -> [Contact] {
|
||||
let memberContactIds = ms.compactMap{ m in m.wrapped.memberCurrent ? m.wrapped.memberContactId : nil }
|
||||
func filterMembersToAdd(_ ms: [GroupMember]) -> [Contact] {
|
||||
let memberContactIds = ms.compactMap{ m in m.memberCurrent ? m.memberContactId : nil }
|
||||
return ChatModel.shared.chats
|
||||
.compactMap{ $0.chatInfo.contact }
|
||||
.filter{ !memberContactIds.contains($0.apiId) }
|
||||
@@ -1163,7 +1133,6 @@ func initializeChat(start: Bool, dbKey: String? = nil, refreshInvitations: Bool
|
||||
try apiSetTempFolder(tempFolder: getTempFilesDirectory().path)
|
||||
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
|
||||
try setXFTPConfig(getXFTPCfg())
|
||||
try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get())
|
||||
m.chatInitialized = true
|
||||
m.currentUser = try apiGetActiveUser()
|
||||
if m.currentUser == nil {
|
||||
@@ -1316,12 +1285,6 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
m.removeChat(connection.id)
|
||||
}
|
||||
}
|
||||
case let .contactDeletedByContact(user, contact):
|
||||
if active(user) && contact.directOrUsed {
|
||||
await MainActor.run {
|
||||
m.updateContact(contact)
|
||||
}
|
||||
}
|
||||
case let .contactConnected(user, contact, _):
|
||||
if active(user) && contact.directOrUsed {
|
||||
await MainActor.run {
|
||||
@@ -1366,12 +1329,6 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
m.updateChatInfo(cInfo)
|
||||
}
|
||||
}
|
||||
case let .groupMemberUpdated(user, groupInfo, _, toMember):
|
||||
if active(user) {
|
||||
await MainActor.run {
|
||||
_ = m.upsertGroupMember(groupInfo, toMember)
|
||||
}
|
||||
}
|
||||
case let .contactsMerged(user, intoContact, mergedContact):
|
||||
if active(user) && m.hasChat(mergedContact.id) {
|
||||
await MainActor.run {
|
||||
@@ -1385,6 +1342,13 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
await updateContactsStatus(contactRefs, status: .connected)
|
||||
case let .contactsDisconnected(_, contactRefs):
|
||||
await updateContactsStatus(contactRefs, status: .disconnected)
|
||||
case let .contactSubError(user, contact, chatError):
|
||||
await MainActor.run {
|
||||
if active(user) {
|
||||
m.updateContact(contact)
|
||||
}
|
||||
processContactSubError(contact, chatError)
|
||||
}
|
||||
case let .contactSubSummary(_, contactSubscriptions):
|
||||
await MainActor.run {
|
||||
for sub in contactSubscriptions {
|
||||
@@ -1399,18 +1363,6 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
}
|
||||
}
|
||||
}
|
||||
case let .networkStatus(status, connections):
|
||||
await MainActor.run {
|
||||
for cId in connections {
|
||||
m.networkStatuses[cId] = status
|
||||
}
|
||||
}
|
||||
case let .networkStatuses(_, statuses): ()
|
||||
await MainActor.run {
|
||||
for s in statuses {
|
||||
m.networkStatuses[s.agentConnId] = s.networkStatus
|
||||
}
|
||||
}
|
||||
case let .newChatItem(user, aChatItem):
|
||||
let cInfo = aChatItem.chatInfo
|
||||
let cItem = aChatItem.chatItem
|
||||
@@ -1432,8 +1384,11 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
case let .chatItemStatusUpdated(user, aChatItem):
|
||||
let cInfo = aChatItem.chatInfo
|
||||
let cItem = aChatItem.chatItem
|
||||
if !cItem.isDeletedContent && active(user) {
|
||||
await MainActor.run { m.updateChatItem(cInfo, cItem, status: cItem.meta.itemStatus) }
|
||||
if !cItem.isDeletedContent {
|
||||
let added = active(user) ? await MainActor.run { m.upsertChatItem(cInfo, cItem) } : true
|
||||
if added && cItem.showNotification {
|
||||
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
|
||||
}
|
||||
}
|
||||
if let endTask = m.messageDelivery[cItem.id] {
|
||||
switch cItem.meta.itemStatus {
|
||||
@@ -1485,16 +1440,6 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
m.removeChat(hostContact.activeConn.id)
|
||||
}
|
||||
}
|
||||
case let .groupLinkConnecting(user, groupInfo, hostMember):
|
||||
if !active(user) { return }
|
||||
|
||||
await MainActor.run {
|
||||
m.updateGroup(groupInfo)
|
||||
if let hostConn = hostMember.activeConn {
|
||||
m.dismissConnReqView(hostConn.id)
|
||||
m.removeChat(hostConn.id)
|
||||
}
|
||||
}
|
||||
case let .joinedGroupMemberConnecting(user, groupInfo, _, member):
|
||||
if active(user) {
|
||||
await MainActor.run {
|
||||
@@ -1554,11 +1499,10 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
m.updateGroup(toGroup)
|
||||
}
|
||||
}
|
||||
case let .memberRole(user, groupInfo, byMember: _, member: member, fromRole: _, toRole: _):
|
||||
case let .memberRole(user, groupInfo, _, _, _, _):
|
||||
if active(user) {
|
||||
await MainActor.run {
|
||||
m.updateGroup(groupInfo)
|
||||
_ = m.upsertGroupMember(groupInfo, member)
|
||||
}
|
||||
}
|
||||
case let .newMemberContactReceivedInv(user, contact, _, _):
|
||||
@@ -1699,7 +1643,7 @@ func processContactSubError(_ contact: Contact, _ chatError: ChatError) {
|
||||
case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted"
|
||||
default: err = String(describing: chatError)
|
||||
}
|
||||
m.setContactNetworkStatus(contact, .error(connectionError: err))
|
||||
m.setContactNetworkStatus(contact, .error(err))
|
||||
}
|
||||
|
||||
func refreshCallInvitations() throws {
|
||||
|
||||
@@ -19,11 +19,7 @@ let bgSuspendTimeout: Int = 5 // seconds
|
||||
let terminationTimeout: Int = 3 // seconds
|
||||
|
||||
private func _suspendChat(timeout: Int) {
|
||||
// this is a redundant check to prevent logical errors, like the one fixed in this PR
|
||||
let state = appStateGroupDefault.get()
|
||||
if !state.canSuspend {
|
||||
logger.error("_suspendChat called, current state: \(state.rawValue, privacy: .public)")
|
||||
} else if ChatModel.ok {
|
||||
if ChatModel.ok {
|
||||
appStateGroupDefault.set(.suspending)
|
||||
apiSuspendChat(timeoutMicroseconds: timeout * 1000000)
|
||||
let endTask = beginBGTask(chatSuspended)
|
||||
@@ -35,7 +31,9 @@ private func _suspendChat(timeout: Int) {
|
||||
|
||||
func suspendChat() {
|
||||
suspendLockQueue.sync {
|
||||
_suspendChat(timeout: appSuspendTimeout)
|
||||
if appStateGroupDefault.get() != .stopped {
|
||||
_suspendChat(timeout: appSuspendTimeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,25 +45,15 @@ func suspendBgRefresh() {
|
||||
}
|
||||
}
|
||||
|
||||
private var terminating = false
|
||||
|
||||
func terminateChat() {
|
||||
logger.debug("terminateChat")
|
||||
suspendLockQueue.sync {
|
||||
switch appStateGroupDefault.get() {
|
||||
case .suspending:
|
||||
// suspend instantly if already suspending
|
||||
_chatSuspended()
|
||||
// when apiSuspendChat is called with timeout 0, it won't send any events on suspension
|
||||
if ChatModel.ok { apiSuspendChat(timeoutMicroseconds: 0) }
|
||||
chatCloseStore()
|
||||
case .suspended:
|
||||
chatCloseStore()
|
||||
case .stopped:
|
||||
chatCloseStore()
|
||||
case .stopped: ()
|
||||
default:
|
||||
terminating = true
|
||||
// the store will be closed in _chatSuspended when event is received
|
||||
_suspendChat(timeout: terminationTimeout)
|
||||
}
|
||||
}
|
||||
@@ -85,14 +73,10 @@ private func _chatSuspended() {
|
||||
if ChatModel.shared.chatRunning == true {
|
||||
ChatReceiver.shared.stop()
|
||||
}
|
||||
if terminating {
|
||||
chatCloseStore()
|
||||
}
|
||||
}
|
||||
|
||||
func activateChat(appState: AppState = .active) {
|
||||
logger.debug("DEBUGGING: activateChat")
|
||||
terminating = false
|
||||
suspendLockQueue.sync {
|
||||
appStateGroupDefault.set(appState)
|
||||
if ChatModel.ok { apiActivateChat() }
|
||||
@@ -101,7 +85,6 @@ func activateChat(appState: AppState = .active) {
|
||||
}
|
||||
|
||||
func initChatAndMigrate(refreshInvitations: Bool = true) {
|
||||
terminating = false
|
||||
let m = ChatModel.shared
|
||||
if (!m.chatInitialized) {
|
||||
do {
|
||||
@@ -114,7 +97,6 @@ func initChatAndMigrate(refreshInvitations: Bool = true) {
|
||||
}
|
||||
|
||||
func startChatAndActivate() {
|
||||
terminating = false
|
||||
logger.debug("DEBUGGING: startChatAndActivate")
|
||||
if ChatModel.shared.chatRunning == true {
|
||||
ChatReceiver.shared.start()
|
||||
|
||||
@@ -108,6 +108,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
try audioSession.setActive(true)
|
||||
logger.debug("audioSession activated")
|
||||
} catch {
|
||||
print(error)
|
||||
logger.error("failed activating audio session")
|
||||
}
|
||||
}
|
||||
@@ -120,6 +121,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
try audioSession.setActive(false)
|
||||
logger.debug("audioSession deactivated")
|
||||
} catch {
|
||||
print(error)
|
||||
logger.error("failed deactivating audio session")
|
||||
}
|
||||
suspendOnEndCall()
|
||||
|
||||
@@ -99,12 +99,12 @@ struct ChatInfoView: View {
|
||||
@Binding var connectionCode: String?
|
||||
@FocusState private var aliasTextFieldFocused: Bool
|
||||
@State private var alert: ChatInfoViewAlert? = nil
|
||||
@State private var showDeleteContactActionSheet = false
|
||||
@State private var sendReceipts = SendReceipts.userDefault(true)
|
||||
@State private var sendReceiptsUserDefault = true
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
|
||||
enum ChatInfoViewAlert: Identifiable {
|
||||
case deleteContactAlert
|
||||
case clearChatAlert
|
||||
case networkStatusAlert
|
||||
case switchAddressAlert
|
||||
@@ -114,6 +114,7 @@ struct ChatInfoView: View {
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .deleteContactAlert: return "deleteContactAlert"
|
||||
case .clearChatAlert: return "clearChatAlert"
|
||||
case .networkStatusAlert: return "networkStatusAlert"
|
||||
case .switchAddressAlert: return "switchAddressAlert"
|
||||
@@ -163,13 +164,13 @@ struct ChatInfoView: View {
|
||||
// synchronizeConnectionButtonForce()
|
||||
// }
|
||||
}
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
.disabled(!contact.ready)
|
||||
|
||||
if let contactLink = contact.contactLink {
|
||||
Section {
|
||||
SimpleXLinkQRCode(uri: contactLink)
|
||||
QRCode(uri: contactLink)
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(contactLink)])
|
||||
showShareSheet(items: [contactLink])
|
||||
} label: {
|
||||
Label("Share address", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
@@ -180,7 +181,7 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if contact.ready && contact.active {
|
||||
if contact.ready {
|
||||
Section("Servers") {
|
||||
networkStatusRow()
|
||||
.onTapGesture {
|
||||
@@ -191,7 +192,8 @@ struct ChatInfoView: View {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
.disabled(
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|
||||
!contact.ready
|
||||
|| connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
|
||||
@@ -232,6 +234,7 @@ struct ChatInfoView: View {
|
||||
}
|
||||
.alert(item: $alert) { alertItem in
|
||||
switch(alertItem) {
|
||||
case .deleteContactAlert: return deleteContactAlert()
|
||||
case .clearChatAlert: return clearChatAlert()
|
||||
case .networkStatusAlert: return networkStatusAlert()
|
||||
case .switchAddressAlert: return switchAddressAlert(switchContactAddress)
|
||||
@@ -240,26 +243,6 @@ struct ChatInfoView: View {
|
||||
case let .error(title, error): return mkAlert(title: title, message: error)
|
||||
}
|
||||
}
|
||||
.actionSheet(isPresented: $showDeleteContactActionSheet) {
|
||||
if contact.ready && contact.active {
|
||||
return ActionSheet(
|
||||
title: Text("Delete contact?\nThis cannot be undone!"),
|
||||
buttons: [
|
||||
.destructive(Text("Delete and notify contact")) { deleteContact(notify: true) },
|
||||
.destructive(Text("Delete")) { deleteContact(notify: false) },
|
||||
.cancel()
|
||||
]
|
||||
)
|
||||
} else {
|
||||
return ActionSheet(
|
||||
title: Text("Delete contact?\nThis cannot be undone!"),
|
||||
buttons: [
|
||||
.destructive(Text("Delete")) { deleteContact() },
|
||||
.cancel()
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func contactInfoHeader() -> some View {
|
||||
@@ -432,7 +415,7 @@ struct ChatInfoView: View {
|
||||
|
||||
private func deleteContactButton() -> some View {
|
||||
Button(role: .destructive) {
|
||||
showDeleteContactActionSheet = true
|
||||
alert = .deleteContactAlert
|
||||
} label: {
|
||||
Label("Delete contact", systemImage: "trash")
|
||||
.foregroundColor(Color.red)
|
||||
@@ -448,23 +431,30 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteContact(notify: Bool? = nil) {
|
||||
Task {
|
||||
do {
|
||||
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, notify: notify)
|
||||
await MainActor.run {
|
||||
dismiss()
|
||||
chatModel.chatId = nil
|
||||
chatModel.removeChat(chat.chatInfo.id)
|
||||
private func deleteContactAlert() -> Alert {
|
||||
Alert(
|
||||
title: Text("Delete contact?"),
|
||||
message: Text("Contact and all messages will be deleted - this cannot be undone!"),
|
||||
primaryButton: .destructive(Text("Delete")) {
|
||||
Task {
|
||||
do {
|
||||
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId)
|
||||
await MainActor.run {
|
||||
dismiss()
|
||||
chatModel.chatId = nil
|
||||
chatModel.removeChat(chat.chatInfo.id)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("deleteContactAlert apiDeleteChat error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error deleting contact")
|
||||
await MainActor.run {
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("deleteContactAlert apiDeleteChat error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error deleting contact")
|
||||
await MainActor.run {
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
private func clearChatAlert() -> Alert {
|
||||
|
||||
@@ -11,7 +11,7 @@ import SimpleXChat
|
||||
|
||||
struct CICallItemView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@ObservedObject var chat: Chat
|
||||
var chatInfo: ChatInfo
|
||||
var chatItem: ChatItem
|
||||
var status: CICallStatus
|
||||
var duration: Int
|
||||
@@ -60,7 +60,7 @@ struct CICallItemView: View {
|
||||
|
||||
|
||||
@ViewBuilder private func acceptCallButton() -> some View {
|
||||
if case let .direct(contact) = chat.chatInfo {
|
||||
if case let .direct(contact) = chatInfo {
|
||||
Button {
|
||||
if let invitation = m.callInvitations[contact.id] {
|
||||
CallController.shared.answerCall(invitation: invitation)
|
||||
|
||||
@@ -10,92 +10,20 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct CIChatFeatureView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
var chatItem: ChatItem
|
||||
@Binding var revealed: Bool
|
||||
var feature: Feature
|
||||
var icon: String? = nil
|
||||
var iconColor: Color
|
||||
|
||||
var body: some View {
|
||||
if !revealed, let fs = mergedFeautures() {
|
||||
HStack {
|
||||
ForEach(fs, content: featureIconView)
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 6)
|
||||
} else {
|
||||
fullFeatureView
|
||||
}
|
||||
}
|
||||
|
||||
private struct FeatureInfo: Identifiable {
|
||||
var icon: String
|
||||
var scale: CGFloat
|
||||
var color: Color
|
||||
var param: String?
|
||||
|
||||
init(_ f: Feature, _ color: Color, _ param: Int?) {
|
||||
self.icon = f.iconFilled
|
||||
self.scale = f.iconScale
|
||||
self.color = color
|
||||
self.param = f.hasParam && param != nil ? timeText(param) : nil
|
||||
}
|
||||
|
||||
var id: String {
|
||||
"\(icon) \(color) \(param ?? "")"
|
||||
}
|
||||
}
|
||||
|
||||
private func mergedFeautures() -> [FeatureInfo]? {
|
||||
var fs: [FeatureInfo] = []
|
||||
var icons: Set<String> = []
|
||||
if var i = m.getChatItemIndex(chatItem) {
|
||||
while i < m.reversedChatItems.count,
|
||||
let f = featureInfo(m.reversedChatItems[i]) {
|
||||
if !icons.contains(f.icon) {
|
||||
fs.insert(f, at: 0)
|
||||
icons.insert(f.icon)
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
return fs.count > 1 ? fs : nil
|
||||
}
|
||||
|
||||
private func featureInfo(_ ci: ChatItem) -> FeatureInfo? {
|
||||
switch ci.content {
|
||||
case let .rcvChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param)
|
||||
case let .sndChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param)
|
||||
case let .rcvGroupFeature(feature, preference, param): FeatureInfo(feature, preference.enable.iconColor, param)
|
||||
case let .sndGroupFeature(feature, preference, param): FeatureInfo(feature, preference.enable.iconColor, param)
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func featureIconView(_ f: FeatureInfo) -> some View {
|
||||
let i = Image(systemName: f.icon)
|
||||
.foregroundColor(f.color)
|
||||
.scaleEffect(f.scale)
|
||||
if let param = f.param {
|
||||
HStack {
|
||||
i
|
||||
chatEventText(Text(param)).lineLimit(1)
|
||||
}
|
||||
} else {
|
||||
i
|
||||
}
|
||||
}
|
||||
|
||||
private var fullFeatureView: some View {
|
||||
HStack(alignment: .bottom, spacing: 4) {
|
||||
Image(systemName: icon ?? feature.iconFilled)
|
||||
.foregroundColor(iconColor)
|
||||
.scaleEffect(feature.iconScale)
|
||||
chatEventText(chatItem)
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.leading, 6)
|
||||
.padding(.bottom, 6)
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
}
|
||||
@@ -103,6 +31,6 @@ struct CIChatFeatureView: View {
|
||||
struct CIChatFeatureView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let enabled = FeatureEnabled(forUser: false, forContact: false)
|
||||
CIChatFeatureView(chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor)
|
||||
CIChatFeatureView(chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,11 @@ struct CIEventView: View {
|
||||
var eventText: Text
|
||||
|
||||
var body: some View {
|
||||
eventText
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
HStack(alignment: .bottom, spacing: 0) {
|
||||
eventText
|
||||
}
|
||||
.padding(.leading, 6)
|
||||
.padding(.bottom, 6)
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct CIFeaturePreferenceView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@EnvironmentObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
var feature: ChatFeature
|
||||
var allowed: FeatureAllowed
|
||||
@@ -80,6 +80,7 @@ struct CIFeaturePreferenceView_Previews: PreviewProvider {
|
||||
quotedItem: nil,
|
||||
file: nil
|
||||
)
|
||||
CIFeaturePreferenceView(chat: Chat.sampleData, chatItem: chatItem, feature: ChatFeature.timedMessages, allowed: .yes, param: 30)
|
||||
CIFeaturePreferenceView(chatItem: chatItem, feature: ChatFeature.timedMessages, allowed: .yes, param: 30)
|
||||
.environmentObject(Chat.sampleData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct CIFileView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
let file: CIFile?
|
||||
let edited: Bool
|
||||
@@ -84,7 +83,7 @@ struct CIFileView: View {
|
||||
if fileSizeValid() {
|
||||
Task {
|
||||
logger.debug("CIFileView fileAction - in .rcvInvitation, in Task")
|
||||
if let user = m.currentUser {
|
||||
if let user = ChatModel.shared.currentUser {
|
||||
let encrypted = privacyEncryptLocalFilesGroupDefault.get()
|
||||
await receiveFile(user: user, fileId: file.fileId, encrypted: encrypted)
|
||||
}
|
||||
@@ -235,17 +234,18 @@ struct CIFileView_Previews: PreviewProvider {
|
||||
file: nil
|
||||
)
|
||||
Group {
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: sentFile, revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile, revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentFile, revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: fileChatItemWtFile, revealed: Binding.constant(false))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 360))
|
||||
.environmentObject(Chat.sampleData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,45 +17,34 @@ struct CIGroupInvitationView: View {
|
||||
var memberRole: GroupMemberRole
|
||||
var chatIncognito: Bool = false
|
||||
@State private var frameWidth: CGFloat = 0
|
||||
@State private var inProgress = false
|
||||
@State private var progressByTimeout = false
|
||||
|
||||
var body: some View {
|
||||
let action = !chatItem.chatDir.sent && groupInvitation.status == .pending
|
||||
let v = ZStack(alignment: .bottomTrailing) {
|
||||
ZStack {
|
||||
VStack(alignment: .leading) {
|
||||
groupInfoView(action)
|
||||
.padding(.horizontal, 2)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 6)
|
||||
VStack(alignment: .leading) {
|
||||
groupInfoView(action)
|
||||
.padding(.horizontal, 2)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 6)
|
||||
.overlay(DetermineWidth())
|
||||
|
||||
Divider().frame(width: frameWidth)
|
||||
|
||||
if action {
|
||||
groupInvitationText()
|
||||
.overlay(DetermineWidth())
|
||||
Text(chatIncognito ? "Tap to join incognito" : "Tap to join")
|
||||
.foregroundColor(chatIncognito ? .indigo : .accentColor)
|
||||
.font(.callout)
|
||||
.padding(.trailing, 60)
|
||||
.overlay(DetermineWidth())
|
||||
} else {
|
||||
groupInvitationText()
|
||||
.padding(.trailing, 60)
|
||||
.overlay(DetermineWidth())
|
||||
|
||||
Divider().frame(width: frameWidth)
|
||||
|
||||
if action {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
groupInvitationText()
|
||||
.overlay(DetermineWidth())
|
||||
Text(chatIncognito ? "Tap to join incognito" : "Tap to join")
|
||||
.foregroundColor(inProgress ? .secondary : chatIncognito ? .indigo : .accentColor)
|
||||
.font(.callout)
|
||||
.padding(.trailing, 60)
|
||||
.overlay(DetermineWidth())
|
||||
}
|
||||
} else {
|
||||
groupInvitationText()
|
||||
.padding(.trailing, 60)
|
||||
.overlay(DetermineWidth())
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 2)
|
||||
|
||||
if progressByTimeout {
|
||||
ProgressView().scaleEffect(2)
|
||||
}
|
||||
}
|
||||
|
||||
.padding(.bottom, 2)
|
||||
chatItem.timestampText
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
@@ -66,24 +55,11 @@ struct CIGroupInvitationView: View {
|
||||
.cornerRadius(18)
|
||||
.textSelection(.disabled)
|
||||
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
|
||||
.onChange(of: inProgress) { inProgress in
|
||||
if inProgress {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
progressByTimeout = inProgress
|
||||
}
|
||||
} else {
|
||||
progressByTimeout = false
|
||||
}
|
||||
}
|
||||
|
||||
if action {
|
||||
v.onTapGesture {
|
||||
inProgress = true
|
||||
joinGroup(groupInvitation.groupId) {
|
||||
await MainActor.run { inProgress = false }
|
||||
}
|
||||
joinGroup(groupInvitation.groupId)
|
||||
}
|
||||
.disabled(inProgress)
|
||||
} else {
|
||||
v
|
||||
}
|
||||
@@ -91,7 +67,7 @@ struct CIGroupInvitationView: View {
|
||||
|
||||
private func groupInfoView(_ action: Bool) -> some View {
|
||||
var color: Color
|
||||
if action && !inProgress {
|
||||
if action {
|
||||
color = chatIncognito ? .indigo : .accentColor
|
||||
} else {
|
||||
color = Color(uiColor: .tertiaryLabel)
|
||||
|
||||
@@ -10,7 +10,6 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct CIImageView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
let chatItem: ChatItem
|
||||
let image: String
|
||||
@@ -37,7 +36,7 @@ struct CIImageView: View {
|
||||
switch file.fileStatus {
|
||||
case .rcvInvitation:
|
||||
Task {
|
||||
if let user = m.currentUser {
|
||||
if let user = ChatModel.shared.currentUser {
|
||||
await receiveFile(user: user, fileId: file.fileId, encrypted: chatItem.encryptLocalFile)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct CIMemberCreatedContactView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
var chatItem: ChatItem
|
||||
|
||||
var body: some View {
|
||||
@@ -22,7 +21,7 @@ struct CIMemberCreatedContactView: View {
|
||||
.onTapGesture {
|
||||
dismissAllSheets(animated: true)
|
||||
DispatchQueue.main.async {
|
||||
m.chatId = "@\(contactId)"
|
||||
ChatModel.shared.chatId = "@\(contactId)"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -10,7 +10,7 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct CIMetaView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@EnvironmentObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
var metaColor = Color.secondary
|
||||
var paleMetaColor = Color(UIColor.tertiaryLabel)
|
||||
@@ -95,14 +95,15 @@ private func statusIconText(_ icon: String, _ color: Color) -> Text {
|
||||
struct CIMetaView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete)))
|
||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial)))
|
||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete)))
|
||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial)))
|
||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete)))
|
||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true))
|
||||
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample())
|
||||
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete)))
|
||||
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial)))
|
||||
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete)))
|
||||
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial)))
|
||||
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete)))
|
||||
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true))
|
||||
CIMetaView(chatItem: ChatItem.getDeletedContentSample())
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 100))
|
||||
.environmentObject(Chat.sampleData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@ import SimpleXChat
|
||||
let decryptErrorReason: LocalizedStringKey = "It can happen when you or your connection used the old database backup."
|
||||
|
||||
struct CIRcvDecryptionError: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@ObservedObject var chat: Chat
|
||||
@EnvironmentObject var chat: Chat
|
||||
var msgDecryptError: MsgDecryptError
|
||||
var msgCount: UInt32
|
||||
var chatItem: ChatItem
|
||||
@@ -46,7 +45,7 @@ struct CIRcvDecryptionError: View {
|
||||
do {
|
||||
let (member, stats) = try apiGroupMemberInfo(groupInfo.apiId, groupMember.groupMemberId)
|
||||
if let s = stats {
|
||||
m.updateGroupMemberConnectionStats(groupInfo, member, s)
|
||||
ChatModel.shared.updateGroupMemberConnectionStats(groupInfo, member, s)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiGroupMemberInfo error: \(responseError(error))")
|
||||
@@ -80,8 +79,8 @@ struct CIRcvDecryptionError: View {
|
||||
}
|
||||
} else if case let .group(groupInfo) = chat.chatInfo,
|
||||
case let .groupRcv(groupMember) = chatItem.chatDir,
|
||||
let mem = m.getGroupMember(groupMember.groupMemberId),
|
||||
let memberStats = mem.wrapped.activeConn?.connectionStats {
|
||||
let modelMember = ChatModel.shared.groupMembers.first(where: { $0.id == groupMember.id }),
|
||||
let memberStats = modelMember.activeConn?.connectionStats {
|
||||
if memberStats.ratchetSyncAllowed {
|
||||
decryptionErrorItemFixButton(syncSupported: true) {
|
||||
alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) }
|
||||
@@ -123,7 +122,7 @@ struct CIRcvDecryptionError: View {
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
CIMetaView(chat: chat, chatItem: chatItem)
|
||||
CIMetaView(chatItem: chatItem)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.onTapGesture(perform: { onClick() })
|
||||
@@ -143,7 +142,7 @@ struct CIRcvDecryptionError: View {
|
||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
CIMetaView(chat: chat, chatItem: chatItem)
|
||||
CIMetaView(chatItem: chatItem)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.onTapGesture(perform: { onClick() })
|
||||
@@ -174,7 +173,7 @@ struct CIRcvDecryptionError: View {
|
||||
do {
|
||||
let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, false)
|
||||
await MainActor.run {
|
||||
m.updateGroupMemberConnectionStats(groupInfo, mem, stats)
|
||||
ChatModel.shared.updateGroupMemberConnectionStats(groupInfo, mem, stats)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("syncMemberConnection apiSyncGroupMemberRatchet error: \(responseError(error))")
|
||||
@@ -191,7 +190,7 @@ struct CIRcvDecryptionError: View {
|
||||
do {
|
||||
let stats = try apiSyncContactRatchet(contact.apiId, false)
|
||||
await MainActor.run {
|
||||
m.updateContactConnectionStats(contact, stats)
|
||||
ChatModel.shared.updateContactConnectionStats(contact, stats)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))")
|
||||
|
||||
@@ -11,7 +11,6 @@ import AVKit
|
||||
import SimpleXChat
|
||||
|
||||
struct CIVideoView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
private let chatItem: ChatItem
|
||||
private let image: String
|
||||
@@ -102,7 +101,7 @@ struct CIVideoView: View {
|
||||
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete
|
||||
VideoPlayerView(player: player, url: url, showControls: false)
|
||||
.frame(width: w, height: w * preview.size.height / preview.size.width)
|
||||
.onChange(of: m.stopPreviousRecPlay) { playingUrl in
|
||||
.onChange(of: ChatModel.shared.stopPreviousRecPlay) { playingUrl in
|
||||
if playingUrl != url {
|
||||
player.pause()
|
||||
videoPlaying = false
|
||||
@@ -125,7 +124,7 @@ struct CIVideoView: View {
|
||||
}
|
||||
if !videoPlaying {
|
||||
Button {
|
||||
m.stopPreviousRecPlay = url
|
||||
ChatModel.shared.stopPreviousRecPlay = url
|
||||
player.play()
|
||||
} label: {
|
||||
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
|
||||
@@ -257,7 +256,7 @@ struct CIVideoView: View {
|
||||
// TODO encrypt: where file size is checked?
|
||||
private func receiveFileIfValidSize(file: CIFile, encrypted: Bool, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) {
|
||||
Task {
|
||||
if let user = m.currentUser {
|
||||
if let user = ChatModel.shared.currentUser {
|
||||
await receiveFile(user, file.fileId, encrypted, false)
|
||||
}
|
||||
}
|
||||
@@ -291,7 +290,7 @@ struct CIVideoView: View {
|
||||
)
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now()) {
|
||||
m.stopPreviousRecPlay = url
|
||||
ChatModel.shared.stopPreviousRecPlay = url
|
||||
if let player = fullPlayer {
|
||||
player.play()
|
||||
fullScreenTimeObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in
|
||||
|
||||
@@ -10,7 +10,6 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct CIVoiceView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
let recordingFile: CIFile?
|
||||
let duration: Int
|
||||
@@ -92,7 +91,7 @@ struct CIVoiceView: View {
|
||||
}
|
||||
|
||||
private func metaView() -> some View {
|
||||
CIMetaView(chat: chat, chatItem: chatItem)
|
||||
CIMetaView(chatItem: chatItem)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +219,7 @@ struct VoiceMessagePlayer: View {
|
||||
private func downloadButton(_ recordingFile: CIFile) -> some View {
|
||||
Button {
|
||||
Task {
|
||||
if let user = chatModel.currentUser {
|
||||
if let user = ChatModel.shared.currentUser {
|
||||
await receiveFile(user: user, fileId: recordingFile.fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get())
|
||||
}
|
||||
}
|
||||
@@ -285,7 +284,6 @@ struct CIVoiceView_Previews: PreviewProvider {
|
||||
)
|
||||
Group {
|
||||
CIVoiceView(
|
||||
chat: Chat.sampleData,
|
||||
chatItem: ChatItem.getVoiceMsgContentSample(),
|
||||
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
|
||||
duration: 30,
|
||||
@@ -294,11 +292,12 @@ struct CIVoiceView_Previews: PreviewProvider {
|
||||
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(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 360))
|
||||
.environmentObject(Chat.sampleData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import SimpleXChat
|
||||
|
||||
struct DeletedItemView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@ObservedObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
|
||||
var body: some View {
|
||||
@@ -19,7 +18,7 @@ struct DeletedItemView: View {
|
||||
Text(chatItem.content.text)
|
||||
.foregroundColor(.secondary)
|
||||
.italic()
|
||||
CIMetaView(chat: chat, chatItem: chatItem)
|
||||
CIMetaView(chatItem: chatItem)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.padding(.leading, 12)
|
||||
@@ -33,8 +32,8 @@ struct DeletedItemView: View {
|
||||
struct DeletedItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
DeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample())
|
||||
DeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(dir: .groupRcv(groupMember: GroupMember.sampleData)))
|
||||
DeletedItemView(chatItem: ChatItem.getDeletedContentSample())
|
||||
DeletedItemView(chatItem: ChatItem.getDeletedContentSample(dir: .groupRcv(groupMember: GroupMember.sampleData)))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 200))
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct EmojiItemView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
|
||||
var body: some View {
|
||||
@@ -18,7 +17,7 @@ struct EmojiItemView: View {
|
||||
emojiText(chatItem.content.text)
|
||||
.padding(.top, 8)
|
||||
.padding(.horizontal, 6)
|
||||
CIMetaView(chat: chat, chatItem: chatItem)
|
||||
CIMetaView(chatItem: chatItem)
|
||||
.padding(.bottom, 8)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
@@ -33,8 +32,8 @@ func emojiText(_ text: String) -> Text {
|
||||
struct EmojiItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group{
|
||||
EmojiItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete)))
|
||||
EmojiItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "👍"))
|
||||
EmojiItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete)))
|
||||
EmojiItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "👍"))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 70))
|
||||
}
|
||||
|
||||
@@ -88,12 +88,13 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
|
||||
file: CIFile.getSample(fileStatus: .sndComplete)
|
||||
)
|
||||
Group {
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 360))
|
||||
.environmentObject(Chat.sampleData)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -150,7 +150,7 @@ struct FullScreenMediaView: View {
|
||||
|
||||
private func startPlayerAndNotify() {
|
||||
if let player = player {
|
||||
m.stopPreviousRecPlay = url
|
||||
ChatModel.shared.stopPreviousRecPlay = url
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,11 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct IntegrityErrorItemView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
var msgError: MsgErrorType
|
||||
var chatItem: ChatItem
|
||||
|
||||
var body: some View {
|
||||
CIMsgError(chat: chat, chatItem: chatItem) {
|
||||
CIMsgError(chatItem: chatItem) {
|
||||
switch msgError {
|
||||
case .msgSkipped:
|
||||
AlertManager.shared.showAlertMsg(
|
||||
@@ -53,7 +52,6 @@ struct IntegrityErrorItemView: View {
|
||||
}
|
||||
|
||||
struct CIMsgError: View {
|
||||
@ObservedObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
var onTap: () -> Void
|
||||
|
||||
@@ -62,7 +60,7 @@ struct CIMsgError: View {
|
||||
Text(chatItem.content.text)
|
||||
.foregroundColor(.red)
|
||||
.italic()
|
||||
CIMetaView(chat: chat, chatItem: chatItem)
|
||||
CIMetaView(chatItem: chatItem)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.padding(.leading, 12)
|
||||
@@ -76,6 +74,6 @@ struct CIMsgError: View {
|
||||
|
||||
struct IntegrityErrorItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
IntegrityErrorItemView(chat: Chat.sampleData, msgError: .msgBadHash, chatItem: ChatItem.getIntegrityErrorSample())
|
||||
IntegrityErrorItemView(msgError: .msgBadHash, chatItem: ChatItem.getIntegrityErrorSample())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,70 +10,39 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct MarkedDeletedItemView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@ObservedObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
@Binding var revealed: Bool
|
||||
|
||||
var body: some View {
|
||||
(Text(mergedMarkedDeletedText).italic() + Text(" ") + chatItem.timestampText)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal, 12)
|
||||
HStack(alignment: .bottom, spacing: 0) {
|
||||
if case let .moderated(_, byGroupMember) = chatItem.meta.itemDeleted {
|
||||
markedDeletedText("moderated by \(byGroupMember.chatViewName)")
|
||||
} else {
|
||||
markedDeletedText("marked deleted")
|
||||
}
|
||||
CIMetaView(chatItem: chatItem)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.padding(.leading, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(chatItemFrameColor(chatItem, colorScheme))
|
||||
.cornerRadius(18)
|
||||
.textSelection(.disabled)
|
||||
}
|
||||
|
||||
var mergedMarkedDeletedText: LocalizedStringKey {
|
||||
if !revealed,
|
||||
let ciCategory = chatItem.mergeCategory,
|
||||
var i = m.getChatItemIndex(chatItem) {
|
||||
var moderated = 0
|
||||
var blocked = 0
|
||||
var deleted = 0
|
||||
var moderatedBy: Set<String> = []
|
||||
while i < m.reversedChatItems.count,
|
||||
let ci = .some(m.reversedChatItems[i]),
|
||||
ci.mergeCategory == ciCategory,
|
||||
let itemDeleted = ci.meta.itemDeleted {
|
||||
switch itemDeleted {
|
||||
case let .moderated(_, byGroupMember):
|
||||
moderated += 1
|
||||
moderatedBy.insert(byGroupMember.displayName)
|
||||
case .blocked: blocked += 1
|
||||
case .deleted: deleted += 1
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
let total = moderated + blocked + deleted
|
||||
return total <= 1
|
||||
? markedDeletedText
|
||||
: total == moderated
|
||||
? "\(total) messages moderated by \(moderatedBy.joined(separator: ", "))"
|
||||
: total == blocked
|
||||
? "\(total) messages blocked"
|
||||
: "\(total) messages marked deleted"
|
||||
} else {
|
||||
return markedDeletedText
|
||||
}
|
||||
}
|
||||
|
||||
var markedDeletedText: LocalizedStringKey {
|
||||
switch chatItem.meta.itemDeleted {
|
||||
case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)"
|
||||
case .blocked: "blocked"
|
||||
default: "marked deleted"
|
||||
}
|
||||
func markedDeletedText(_ s: LocalizedStringKey) -> some View {
|
||||
Text(s)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.italic()
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
struct MarkedDeletedItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
MarkedDeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true))
|
||||
MarkedDeletedItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 200))
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ private func typing(_ w: Font.Weight = .light) -> Text {
|
||||
}
|
||||
|
||||
struct MsgContentView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@EnvironmentObject var chat: Chat
|
||||
var text: String
|
||||
var formattedText: [FormattedText]? = nil
|
||||
var sender: String? = nil
|
||||
@@ -121,11 +121,13 @@ private func formatText(_ ft: FormattedText, _ preview: Bool) -> Text {
|
||||
case .secret: return Text(t).foregroundColor(.clear).underline(color: .primary)
|
||||
case let .colored(color): return Text(t).foregroundColor(color.uiColor)
|
||||
case .uri: return linkText(t, t, preview, prefix: "")
|
||||
case let .simplexLink(linkType, simplexUri, smpHosts):
|
||||
case let .simplexLink(linkType, simplexUri, trustedUri, smpHosts):
|
||||
switch privacySimplexLinkModeDefault.get() {
|
||||
case .description: return linkText(simplexLinkText(linkType, smpHosts), simplexUri, preview, prefix: "")
|
||||
case .full: return linkText(t, simplexUri, preview, prefix: "")
|
||||
case .browser: return linkText(t, simplexUri, preview, prefix: "")
|
||||
case .browser: return trustedUri
|
||||
? linkText(t, t, preview, prefix: "")
|
||||
: linkText(t, t, preview, prefix: "", color: .red, uiColor: .red)
|
||||
}
|
||||
case .email: return linkText(t, t, preview, prefix: "mailto:")
|
||||
case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:")
|
||||
@@ -152,7 +154,6 @@ struct MsgContentView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let chatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
|
||||
return MsgContentView(
|
||||
chat: Chat.sampleData,
|
||||
text: chatItem.text,
|
||||
formattedText: chatItem.formattedText,
|
||||
sender: chatItem.memberDisplayName,
|
||||
|
||||
@@ -10,7 +10,6 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct ChatItemInfoView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
var ci: ChatItem
|
||||
@Binding var chatItemInfo: ChatItemInfo?
|
||||
@@ -291,8 +290,8 @@ struct ChatItemInfoView: View {
|
||||
|
||||
private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus)] {
|
||||
memberDeliveryStatuses.compactMap({ mds in
|
||||
if let mem = chatModel.getGroupMember(mds.groupMemberId) {
|
||||
return (mem.wrapped, mds.memberDeliveryStatus)
|
||||
if let mem = ChatModel.shared.groupMembers.first(where: { $0.groupMemberId == mds.groupMemberId }) {
|
||||
return (mem, mds.memberDeliveryStatus)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct ChatItemView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
var chatInfo: ChatInfo
|
||||
var chatItem: ChatItem
|
||||
var maxWidth: CGFloat = .infinity
|
||||
@State var scrollProxy: ScrollViewProxy? = nil
|
||||
@@ -19,19 +19,8 @@ struct ChatItemView: View {
|
||||
@Binding var audioPlayer: AudioPlayer?
|
||||
@Binding var playbackState: VoiceMessagePlaybackState
|
||||
@Binding var playbackTime: TimeInterval?
|
||||
init(
|
||||
chat: Chat,
|
||||
chatItem: ChatItem,
|
||||
showMember: Bool = false,
|
||||
maxWidth: CGFloat = .infinity,
|
||||
scrollProxy: ScrollViewProxy? = nil,
|
||||
revealed: Binding<Bool>,
|
||||
allowMenu: Binding<Bool> = .constant(false),
|
||||
audioPlayer: Binding<AudioPlayer?> = .constant(nil),
|
||||
playbackState: Binding<VoiceMessagePlaybackState> = .constant(.noPlayback),
|
||||
playbackTime: Binding<TimeInterval?> = .constant(nil)
|
||||
) {
|
||||
self.chat = chat
|
||||
init(chatInfo: ChatInfo, chatItem: ChatItem, showMember: Bool = false, maxWidth: CGFloat = .infinity, scrollProxy: ScrollViewProxy? = nil, revealed: Binding<Bool>, allowMenu: Binding<Bool> = .constant(false), audioPlayer: Binding<AudioPlayer?> = .constant(nil), playbackState: Binding<VoiceMessagePlaybackState> = .constant(.noPlayback), playbackTime: Binding<TimeInterval?> = .constant(nil)) {
|
||||
self.chatInfo = chatInfo
|
||||
self.chatItem = chatItem
|
||||
self.maxWidth = maxWidth
|
||||
_scrollProxy = .init(initialValue: scrollProxy)
|
||||
@@ -44,15 +33,15 @@ struct ChatItemView: View {
|
||||
|
||||
var body: some View {
|
||||
let ci = chatItem
|
||||
if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) {
|
||||
MarkedDeletedItemView(chat: chat, chatItem: chatItem, revealed: $revealed)
|
||||
if chatItem.meta.itemDeleted != nil && !revealed {
|
||||
MarkedDeletedItemView(chatItem: chatItem)
|
||||
} else if ci.quotedItem == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive {
|
||||
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
|
||||
EmojiItemView(chat: chat, chatItem: ci)
|
||||
EmojiItemView(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(chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, 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
|
||||
ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
|
||||
} else {
|
||||
framedItemView()
|
||||
}
|
||||
@@ -62,15 +51,14 @@ struct ChatItemView: View {
|
||||
}
|
||||
|
||||
private func framedItemView() -> some View {
|
||||
FramedItemView(chat: chat, chatItem: chatItem, revealed: $revealed, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
|
||||
FramedItemView(chatInfo: chatInfo, chatItem: chatItem, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatItemContentView<Content: View>: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@ObservedObject var chat: Chat
|
||||
var chatInfo: ChatInfo
|
||||
var chatItem: ChatItem
|
||||
@Binding var revealed: Bool
|
||||
var msgContentView: () -> Content
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
|
||||
@@ -84,14 +72,14 @@ struct ChatItemContentView<Content: View>: View {
|
||||
case let .rcvCall(status, duration): callItemView(status, duration)
|
||||
case let .rcvIntegrityError(msgError):
|
||||
if developerTools {
|
||||
IntegrityErrorItemView(chat: chat, msgError: msgError, chatItem: chatItem)
|
||||
IntegrityErrorItemView(msgError: msgError, chatItem: chatItem)
|
||||
} else {
|
||||
ZStack {}
|
||||
}
|
||||
case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(chat: chat, msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem)
|
||||
case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem)
|
||||
case let .rcvGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
|
||||
case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
|
||||
case .rcvDirectEvent: eventItemView()
|
||||
case .rcvGroupEvent(.memberConnected): CIEventView(eventText: membersConnectedItemText)
|
||||
case .rcvGroupEvent(.memberCreatedContact): CIMemberCreatedContactView(chatItem: chatItem)
|
||||
case .rcvGroupEvent: eventItemView()
|
||||
case .sndGroupEvent: eventItemView()
|
||||
@@ -100,9 +88,9 @@ struct ChatItemContentView<Content: View>: View {
|
||||
case let .rcvChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
|
||||
case let .sndChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
|
||||
case let .rcvChatPreference(feature, allowed, param):
|
||||
CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param)
|
||||
CIFeaturePreferenceView(chatItem: chatItem, feature: feature, allowed: allowed, param: param)
|
||||
case let .sndChatPreference(feature, _, _):
|
||||
CIChatFeatureView(chatItem: chatItem, revealed: $revealed, feature: feature, icon: feature.icon, iconColor: .secondary)
|
||||
CIChatFeatureView(chatItem: chatItem, feature: feature, icon: feature.icon, iconColor: .secondary)
|
||||
case let .rcvGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor)
|
||||
case let .sndGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor)
|
||||
case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
|
||||
@@ -114,15 +102,15 @@ struct ChatItemContentView<Content: View>: View {
|
||||
}
|
||||
|
||||
private func deletedItemView() -> some View {
|
||||
DeletedItemView(chat: chat, chatItem: chatItem)
|
||||
DeletedItemView(chatItem: chatItem)
|
||||
}
|
||||
|
||||
private func callItemView(_ status: CICallStatus, _ duration: Int) -> some View {
|
||||
CICallItemView(chat: chat, chatItem: chatItem, status: status, duration: duration)
|
||||
CICallItemView(chatInfo: chatInfo, chatItem: chatItem, status: status, duration: duration)
|
||||
}
|
||||
|
||||
private func groupInvitationItemView(_ groupInvitation: CIGroupInvitation, _ memberRole: GroupMemberRole) -> some View {
|
||||
CIGroupInvitationView(chatItem: chatItem, groupInvitation: groupInvitation, memberRole: memberRole, chatIncognito: chat.chatInfo.incognito)
|
||||
CIGroupInvitationView(chatItem: chatItem, groupInvitation: groupInvitation, memberRole: memberRole, chatIncognito: chatInfo.incognito)
|
||||
}
|
||||
|
||||
private func eventItemView() -> some View {
|
||||
@@ -130,9 +118,7 @@ struct ChatItemContentView<Content: View>: View {
|
||||
}
|
||||
|
||||
private func eventItemViewText() -> Text {
|
||||
if !revealed, let t = mergedGroupEventText {
|
||||
return chatEventText(t + Text(" ") + chatItem.timestampText)
|
||||
} else if let member = chatItem.memberDisplayName {
|
||||
if let member = chatItem.memberDisplayName {
|
||||
return Text(member + " ")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
@@ -144,42 +130,34 @@ struct ChatItemContentView<Content: View>: View {
|
||||
}
|
||||
|
||||
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View {
|
||||
CIChatFeatureView(chatItem: chatItem, revealed: $revealed, feature: feature, iconColor: iconColor)
|
||||
CIChatFeatureView(chatItem: chatItem, feature: feature, iconColor: iconColor)
|
||||
}
|
||||
|
||||
private var mergedGroupEventText: Text? {
|
||||
let (count, ns) = chatModel.getConnectedMemberNames(chatItem)
|
||||
let members: LocalizedStringKey =
|
||||
switch ns.count {
|
||||
case 1: "\(ns[0]) connected"
|
||||
case 2: "\(ns[0]) and \(ns[1]) connected"
|
||||
case 3: "\(ns[0] + ", " + ns[1]) and \(ns[2]) connected"
|
||||
default:
|
||||
ns.count > 3
|
||||
? "\(ns[0]), \(ns[1]) and \(ns.count - 2) other members connected"
|
||||
: ""
|
||||
}
|
||||
return if count <= 1 {
|
||||
nil
|
||||
} else if ns.count == 0 {
|
||||
Text("\(count) group events")
|
||||
} else if count > ns.count {
|
||||
Text(members) + Text(" ") + Text("and \(count - ns.count) other events")
|
||||
private var membersConnectedItemText: Text {
|
||||
if let t = membersConnectedText {
|
||||
return chatEventText(t, chatItem.timestampText)
|
||||
} else {
|
||||
Text(members)
|
||||
return eventItemViewText()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func chatEventText(_ text: Text) -> Text {
|
||||
text
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.fontWeight(.light)
|
||||
private var membersConnectedText: LocalizedStringKey? {
|
||||
let ns = chatModel.getConnectedMemberNames(chatItem)
|
||||
return ns.count > 3
|
||||
? "\(ns[0]), \(ns[1]) and \(ns.count - 2) other members connected"
|
||||
: ns.count == 3
|
||||
? "\(ns[0] + ", " + ns[1]) and \(ns[2]) connected"
|
||||
: ns.count == 2
|
||||
? "\(ns[0]) and \(ns[1]) connected"
|
||||
: nil
|
||||
}
|
||||
}
|
||||
|
||||
func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text) -> Text {
|
||||
chatEventText(Text(eventText) + Text(" ") + ts)
|
||||
(Text(eventText) + Text(" ") + ts)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.fontWeight(.light)
|
||||
}
|
||||
|
||||
func chatEventText(_ ci: ChatItem) -> Text {
|
||||
@@ -189,15 +167,15 @@ func chatEventText(_ ci: ChatItem) -> Text {
|
||||
struct ChatItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group{
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
|
||||
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
|
||||
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 70))
|
||||
.environmentObject(Chat.sampleData)
|
||||
@@ -209,7 +187,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
||||
let ciFeatureContent = CIContent.rcvChatFeature(feature: .fullDelete, enabled: FeatureEnabled(forUser: false, forContact: false), param: nil)
|
||||
Group{
|
||||
ChatItemView(
|
||||
chat: Chat.sampleData,
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItem: ChatItem(
|
||||
chatDir: .directRcv,
|
||||
meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
|
||||
@@ -220,7 +198,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
||||
revealed: Binding.constant(true)
|
||||
)
|
||||
ChatItemView(
|
||||
chat: Chat.sampleData,
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItem: ChatItem(
|
||||
chatDir: .directRcv,
|
||||
meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead),
|
||||
@@ -231,7 +209,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
||||
revealed: Binding.constant(true)
|
||||
)
|
||||
ChatItemView(
|
||||
chat: Chat.sampleData,
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItem: ChatItem(
|
||||
chatDir: .directRcv,
|
||||
meta: CIMeta.getSample(1, .now, "received invitation to join group team as admin", .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
|
||||
@@ -242,7 +220,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
||||
revealed: Binding.constant(true)
|
||||
)
|
||||
ChatItemView(
|
||||
chat: Chat.sampleData,
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItem: ChatItem(
|
||||
chatDir: .directRcv,
|
||||
meta: CIMeta.getSample(1, .now, "group event text", .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
|
||||
@@ -253,7 +231,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
|
||||
revealed: Binding.constant(true)
|
||||
)
|
||||
ChatItemView(
|
||||
chat: Chat.sampleData,
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItem: ChatItem(
|
||||
chatDir: .directRcv,
|
||||
meta: CIMeta.getSample(1, .now, ciFeatureContent.text, .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
|
||||
|
||||
@@ -21,7 +21,9 @@ struct ChatView: View {
|
||||
@State private var showChatInfoSheet: Bool = false
|
||||
@State private var showAddMembersSheet: Bool = false
|
||||
@State private var composeState = ComposeState()
|
||||
@State private var deletingItem: ChatItem? = nil
|
||||
@State private var keyboardVisible = false
|
||||
@State private var showDeleteMessage = false
|
||||
@State private var connectionStats: ConnectionStats?
|
||||
@State private var customUserProfile: Profile?
|
||||
@State private var connectionCode: String?
|
||||
@@ -34,12 +36,7 @@ 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
|
||||
@State private var groupLink: String?
|
||||
@State private var groupLinkMemberRole: GroupMemberRole = .member
|
||||
@State private var selectedMember: GroupMember? = nil
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
@@ -94,10 +91,7 @@ struct ChatView: View {
|
||||
chatModel.chatId = nil
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
if chatModel.chatId == nil {
|
||||
chatModel.chatItemStatuses = [:]
|
||||
chatModel.reversedChatItems = []
|
||||
chatModel.groupMembers = []
|
||||
membersLoaded = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,21 +129,18 @@ struct ChatView: View {
|
||||
}
|
||||
} else if case let .group(groupInfo) = cInfo {
|
||||
Button {
|
||||
Task { await loadGroupMembers(groupInfo) { showChatInfoSheet = true } }
|
||||
Task {
|
||||
let groupMembers = await apiListMembers(groupInfo.groupId)
|
||||
await MainActor.run {
|
||||
ChatModel.shared.groupMembers = groupMembers
|
||||
showChatInfoSheet = true
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
ChatInfoToolbar(chat: chat)
|
||||
}
|
||||
.appSheet(isPresented: $showChatInfoSheet) {
|
||||
GroupChatInfoView(
|
||||
chat: chat,
|
||||
groupInfo: Binding(
|
||||
get: { groupInfo },
|
||||
set: { gInfo in
|
||||
chat.chatInfo = .group(groupInfo: gInfo)
|
||||
chat.created = Date.now
|
||||
}
|
||||
)
|
||||
)
|
||||
GroupChatInfoView(chat: chat, groupInfo: groupInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,7 +150,7 @@ struct ChatView: View {
|
||||
HStack {
|
||||
if contact.allowsFeature(.calls) {
|
||||
callButton(contact, .audio, imageName: "phone")
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
.disabled(!contact.ready)
|
||||
}
|
||||
Menu {
|
||||
if contact.allowsFeature(.calls) {
|
||||
@@ -168,11 +159,11 @@ struct ChatView: View {
|
||||
} label: {
|
||||
Label("Video call", systemImage: "video")
|
||||
}
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
.disabled(!contact.ready)
|
||||
}
|
||||
searchButton()
|
||||
toggleNtfsButton(chat)
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
.disabled(!contact.ready)
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
}
|
||||
@@ -181,16 +172,9 @@ struct ChatView: View {
|
||||
HStack {
|
||||
if groupInfo.canAddMembers {
|
||||
if (chat.chatInfo.incognito) {
|
||||
groupLinkButton()
|
||||
.appSheet(isPresented: $showGroupLinkSheet) {
|
||||
GroupLinkView(
|
||||
groupId: groupInfo.groupId,
|
||||
groupLink: $groupLink,
|
||||
groupLinkMemberRole: $groupLinkMemberRole,
|
||||
showTitle: true,
|
||||
creatingGroup: false
|
||||
)
|
||||
}
|
||||
Image(systemName: "person.crop.circle.badge.plus")
|
||||
.foregroundColor(Color(uiColor: .tertiaryLabel))
|
||||
.onTapGesture { AlertManager.shared.showAlert(cantInviteIncognitoAlert()) }
|
||||
} else {
|
||||
addMembersButton()
|
||||
.appSheet(isPresented: $showAddMembersSheet) {
|
||||
@@ -212,17 +196,6 @@ 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
|
||||
if case let .direct(contact) = cInfo {
|
||||
@@ -348,7 +321,6 @@ struct ChatView: View {
|
||||
@ViewBuilder private func connectingText() -> some View {
|
||||
if case let .direct(contact) = chat.chatInfo,
|
||||
!contact.ready,
|
||||
contact.active,
|
||||
!contact.nextSendGrpInv {
|
||||
Text("connecting…")
|
||||
.font(.caption)
|
||||
@@ -431,32 +403,19 @@ struct ChatView: View {
|
||||
private func addMembersButton() -> some View {
|
||||
Button {
|
||||
if case let .group(gInfo) = chat.chatInfo {
|
||||
Task { await loadGroupMembers(gInfo) { showAddMembersSheet = true } }
|
||||
Task {
|
||||
let groupMembers = await apiListMembers(gInfo.groupId)
|
||||
await MainActor.run {
|
||||
ChatModel.shared.groupMembers = groupMembers
|
||||
showAddMembersSheet = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "person.crop.circle.badge.plus")
|
||||
}
|
||||
}
|
||||
|
||||
private func groupLinkButton() -> some View {
|
||||
Button {
|
||||
if case let .group(gInfo) = chat.chatInfo {
|
||||
Task {
|
||||
do {
|
||||
if let link = try apiGetGroupLink(gInfo.groupId) {
|
||||
(groupLink, groupLinkMemberRole) = link
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("ChatView apiGetGroupLink: \(responseError(error))")
|
||||
}
|
||||
showGroupLinkSheet = true
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "link.badge.plus")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func loadChatItems(_ cInfo: ChatInfo, _ ci: ChatItem, _ proxy: ScrollViewProxy) {
|
||||
if let firstItem = chatModel.reversedChatItems.last, firstItem.id == ci.id {
|
||||
if loadingItems || firstPage { return }
|
||||
@@ -486,30 +445,73 @@ struct ChatView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View {
|
||||
if case let .groupRcv(member) = ci.chatDir,
|
||||
case let .group(groupInfo) = chat.chatInfo {
|
||||
let (prevItem, nextItem) = chatModel.getChatItemNeighbors(ci)
|
||||
if ci.memberConnected != nil && nextItem?.memberConnected != nil {
|
||||
// memberConnected events are aggregated at the last chat item in a row of such events, see ChatItemView
|
||||
ZStack {} // scroll doesn't work if it's EmptyView()
|
||||
} else {
|
||||
if prevItem == nil || showMemberImage(member, prevItem) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if ci.content.showMemberName {
|
||||
Text(member.displayName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, memberImageSize + 14)
|
||||
.padding(.top, 7)
|
||||
}
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
ProfileImage(imageStr: member.memberProfile.image)
|
||||
.frame(width: memberImageSize, height: memberImageSize)
|
||||
.onTapGesture { selectedMember = member }
|
||||
.appSheet(item: $selectedMember) { member in
|
||||
GroupMemberInfoView(groupInfo: groupInfo, member: member, navigation: true)
|
||||
}
|
||||
chatItemWithMenu(ci, maxWidth)
|
||||
}
|
||||
}
|
||||
.padding(.top, 5)
|
||||
.padding(.trailing)
|
||||
.padding(.leading, 12)
|
||||
} else {
|
||||
chatItemWithMenu(ci, maxWidth)
|
||||
.padding(.top, 5)
|
||||
.padding(.trailing)
|
||||
.padding(.leading, memberImageSize + 8 + 12)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
chatItemWithMenu(ci, maxWidth)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 5)
|
||||
}
|
||||
}
|
||||
|
||||
private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View {
|
||||
ChatItemWithMenu(
|
||||
chat: chat,
|
||||
chatItem: ci,
|
||||
ci: ci,
|
||||
maxWidth: maxWidth,
|
||||
scrollProxy: scrollProxy,
|
||||
deleteMessage: deleteMessage,
|
||||
deletingItem: $deletingItem,
|
||||
composeState: $composeState,
|
||||
selectedMember: $selectedMember,
|
||||
chatView: self
|
||||
showDeleteMessage: $showDeleteMessage
|
||||
)
|
||||
.environmentObject(chat)
|
||||
}
|
||||
|
||||
private struct ChatItemWithMenu: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var chat: Chat
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@ObservedObject var chat: Chat
|
||||
var chatItem: ChatItem
|
||||
var ci: ChatItem
|
||||
var maxWidth: CGFloat
|
||||
var scrollProxy: ScrollViewProxy?
|
||||
var deleteMessage: (CIDeleteMode) -> Void
|
||||
@Binding var deletingItem: ChatItem?
|
||||
@Binding var composeState: ComposeState
|
||||
@Binding var selectedMember: GMember?
|
||||
var chatView: ChatView
|
||||
@Binding var showDeleteMessage: Bool
|
||||
|
||||
@State private var deletingItem: ChatItem? = nil
|
||||
@State private var showDeleteMessage = false
|
||||
@State private var deletingItems: [Int64] = []
|
||||
@State private var showDeleteMessages = false
|
||||
@State private var revealed = false
|
||||
@State private var showChatItemInfoSheet: Bool = false
|
||||
@State private var chatItemInfo: ChatItemInfo?
|
||||
@@ -521,114 +523,18 @@ struct ChatView: View {
|
||||
@State private var playbackTime: TimeInterval?
|
||||
|
||||
var body: some View {
|
||||
let (currIndex, nextItem) = m.getNextChatItem(chatItem)
|
||||
let ciCategory = chatItem.mergeCategory
|
||||
if (ciCategory != nil && ciCategory == nextItem?.mergeCategory) {
|
||||
// memberConnected events and deleted items are aggregated at the last chat item in a row, see ChatItemView
|
||||
ZStack {} // scroll doesn't work if it's EmptyView()
|
||||
} else {
|
||||
let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory)
|
||||
let range = itemsRange(currIndex, prevHidden)
|
||||
if revealed, let range = range {
|
||||
let items = Array(zip(Array(range), m.reversedChatItems[range]))
|
||||
ForEach(items, id: \.1.viewId) { (i, ci) in
|
||||
let prev = i == prevHidden ? prevItem : m.reversedChatItems[i + 1]
|
||||
chatItemView(ci, nil, prev)
|
||||
}
|
||||
} else {
|
||||
chatItemView(chatItem, range, prevItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func chatItemView(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ prevItem: ChatItem?) -> some View {
|
||||
if case let .groupRcv(member) = ci.chatDir,
|
||||
case let .group(groupInfo) = chat.chatInfo {
|
||||
let (prevMember, memCount): (GroupMember?, Int) =
|
||||
if let range = range {
|
||||
m.getPrevHiddenMember(member, range)
|
||||
} else {
|
||||
(nil, 1)
|
||||
}
|
||||
if prevItem == nil || showMemberImage(member, prevItem) || prevMember != nil {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if ci.content.showMemberName {
|
||||
Text(memberNames(member, prevMember, memCount))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, memberImageSize + 14)
|
||||
.padding(.top, 7)
|
||||
}
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
ProfileImage(imageStr: member.memberProfile.image)
|
||||
.frame(width: memberImageSize, height: memberImageSize)
|
||||
.onTapGesture {
|
||||
if chatView.membersLoaded {
|
||||
selectedMember = m.getGroupMember(member.groupMemberId)
|
||||
} else {
|
||||
Task {
|
||||
await chatView.loadGroupMembers(groupInfo) {
|
||||
selectedMember = m.getGroupMember(member.groupMemberId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.appSheet(item: $selectedMember) { member in
|
||||
GroupMemberInfoView(groupInfo: groupInfo, groupMember: member, navigation: true)
|
||||
}
|
||||
chatItemWithMenu(ci, range, maxWidth)
|
||||
}
|
||||
}
|
||||
.padding(.top, 5)
|
||||
.padding(.trailing)
|
||||
.padding(.leading, 12)
|
||||
} else {
|
||||
chatItemWithMenu(ci, range, maxWidth)
|
||||
.padding(.top, 5)
|
||||
.padding(.trailing)
|
||||
.padding(.leading, memberImageSize + 8 + 12)
|
||||
}
|
||||
} else {
|
||||
chatItemWithMenu(ci, range, maxWidth)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 5)
|
||||
}
|
||||
}
|
||||
|
||||
private func memberNames(_ member: GroupMember, _ prevMember: GroupMember?, _ memCount: Int) -> LocalizedStringKey {
|
||||
let name = member.displayName
|
||||
return if let prevName = prevMember?.displayName {
|
||||
memCount > 2
|
||||
? "\(name), \(prevName) and \(memCount - 2) members"
|
||||
: "\(name) and \(prevName)"
|
||||
} else {
|
||||
"\(name)"
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ maxWidth: CGFloat) -> some View {
|
||||
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
|
||||
let uiMenu: Binding<UIMenu> = Binding(
|
||||
get: { UIMenu(title: "", children: menu(ci, range, live: composeState.liveMessage != nil)) },
|
||||
get: { UIMenu(title: "", children: menu(live: composeState.liveMessage != nil)) },
|
||||
set: { _ in }
|
||||
)
|
||||
|
||||
VStack(alignment: alignment.horizontal, spacing: 3) {
|
||||
ChatItemView(
|
||||
chat: chat,
|
||||
chatItem: ci,
|
||||
maxWidth: maxWidth,
|
||||
scrollProxy: chatView.scrollProxy,
|
||||
revealed: $revealed,
|
||||
allowMenu: $allowMenu,
|
||||
audioPlayer: $audioPlayer,
|
||||
playbackState: $playbackState,
|
||||
playbackTime: $playbackTime
|
||||
)
|
||||
.uiKitContextMenu(menu: uiMenu, allowMenu: $allowMenu)
|
||||
.accessibilityLabel("")
|
||||
ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
|
||||
.uiKitContextMenu(menu: uiMenu, allowMenu: $allowMenu)
|
||||
.accessibilityLabel("")
|
||||
if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 {
|
||||
chatItemReactions(ci)
|
||||
chatItemReactions()
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
}
|
||||
@@ -642,11 +548,6 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog(deleteMessagesTitle, isPresented: $showDeleteMessages, titleVisibility: .visible) {
|
||||
Button("Delete for me", role: .destructive) {
|
||||
deleteMessages()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
|
||||
.onDisappear {
|
||||
@@ -664,15 +565,7 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool {
|
||||
switch (prevItem?.chatDir) {
|
||||
case .groupSnd: return true
|
||||
case let .groupRcv(prevMember): return prevMember.groupMemberId != member.groupMemberId
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
private func chatItemReactions(_ ci: ChatItem) -> some View {
|
||||
private func chatItemReactions() -> some View {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(ci.reactions, id: \.reaction) { r in
|
||||
let v = HStack(spacing: 4) {
|
||||
@@ -692,7 +585,7 @@ struct ChatView: View {
|
||||
|
||||
if chat.chatInfo.featureEnabled(.reactions) && (ci.allowAddReaction || r.userReacted) {
|
||||
v.onTapGesture {
|
||||
setReaction(ci, add: !r.userReacted, reaction: r.reaction)
|
||||
setReaction(add: !r.userReacted, reaction: r.reaction)
|
||||
}
|
||||
} else {
|
||||
v
|
||||
@@ -701,10 +594,10 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func menu(_ ci: ChatItem, _ range: ClosedRange<Int>?, live: Bool) -> [UIMenuElement] {
|
||||
private func menu(live: Bool) -> [UIMenuElement] {
|
||||
var menu: [UIMenuElement] = []
|
||||
if let mc = ci.content.msgContent, ci.meta.itemDeleted == nil || revealed {
|
||||
let rs = allReactions(ci)
|
||||
let rs = allReactions()
|
||||
if chat.chatInfo.featureEnabled(.reactions) && ci.allowAddReaction,
|
||||
rs.count > 0 {
|
||||
var rm: UIMenu
|
||||
@@ -721,10 +614,10 @@ struct ChatView: View {
|
||||
menu.append(rm)
|
||||
}
|
||||
if ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live {
|
||||
menu.append(replyUIAction(ci))
|
||||
menu.append(replyUIAction())
|
||||
}
|
||||
menu.append(shareUIAction(ci))
|
||||
menu.append(copyUIAction(ci))
|
||||
menu.append(shareUIAction())
|
||||
menu.append(copyUIAction())
|
||||
if let fileSource = getLoadedFileSource(ci.file) {
|
||||
if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) {
|
||||
if image.imageData != nil {
|
||||
@@ -737,9 +630,9 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
if ci.meta.editable && !mc.isVoice && !live {
|
||||
menu.append(editAction(ci))
|
||||
menu.append(editAction())
|
||||
}
|
||||
menu.append(viewInfoUIAction(ci))
|
||||
menu.append(viewInfoUIAction())
|
||||
if revealed {
|
||||
menu.append(hideUIAction())
|
||||
}
|
||||
@@ -749,31 +642,25 @@ struct ChatView: View {
|
||||
menu.append(cancelFileUIAction(file.fileId, cancelAction))
|
||||
}
|
||||
if !live || !ci.meta.isLive {
|
||||
menu.append(deleteUIAction(ci))
|
||||
menu.append(deleteUIAction())
|
||||
}
|
||||
if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) {
|
||||
menu.append(moderateUIAction(ci, groupInfo))
|
||||
menu.append(moderateUIAction(groupInfo))
|
||||
}
|
||||
} else if ci.meta.itemDeleted != nil {
|
||||
if revealed {
|
||||
menu.append(hideUIAction())
|
||||
} else if !ci.isDeletedContent {
|
||||
if !ci.isDeletedContent {
|
||||
menu.append(revealUIAction())
|
||||
} else if range != nil {
|
||||
menu.append(expandUIAction())
|
||||
}
|
||||
menu.append(viewInfoUIAction(ci))
|
||||
menu.append(deleteUIAction(ci))
|
||||
menu.append(viewInfoUIAction())
|
||||
menu.append(deleteUIAction())
|
||||
} else if ci.isDeletedContent {
|
||||
menu.append(viewInfoUIAction(ci))
|
||||
menu.append(deleteUIAction(ci))
|
||||
} else if ci.mergeCategory != nil {
|
||||
menu.append(revealed ? shrinkUIAction() : expandUIAction())
|
||||
menu.append(viewInfoUIAction())
|
||||
menu.append(deleteUIAction())
|
||||
}
|
||||
return menu
|
||||
}
|
||||
|
||||
private func replyUIAction(_ ci: ChatItem) -> UIAction {
|
||||
private func replyUIAction() -> UIAction {
|
||||
UIAction(
|
||||
title: NSLocalizedString("Reply", comment: "chat item action"),
|
||||
image: UIImage(systemName: "arrowshape.turn.up.left")
|
||||
@@ -808,11 +695,11 @@ struct ChatView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private func allReactions(_ ci: ChatItem) -> [UIAction] {
|
||||
private func allReactions() -> [UIAction] {
|
||||
MsgReaction.values.compactMap { r in
|
||||
ci.reactions.contains(where: { $0.userReacted && $0.reaction == r })
|
||||
? nil
|
||||
: UIAction(title: r.text) { _ in setReaction(ci, add: true, reaction: r) }
|
||||
: UIAction(title: r.text) { _ in setReaction(add: true, reaction: r) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -820,7 +707,7 @@ struct ChatView: View {
|
||||
rs.count > 4 ? 3 : 4
|
||||
}
|
||||
|
||||
private func setReaction(_ ci: ChatItem, add: Bool, reaction: MsgReaction) {
|
||||
private func setReaction(add: Bool, reaction: MsgReaction) {
|
||||
Task {
|
||||
do {
|
||||
let cInfo = chat.chatInfo
|
||||
@@ -832,7 +719,7 @@ struct ChatView: View {
|
||||
reaction: reaction
|
||||
)
|
||||
await MainActor.run {
|
||||
m.updateChatItem(chat.chatInfo, chatItem)
|
||||
ChatModel.shared.updateChatItem(chat.chatInfo, chatItem)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiChatItemReaction error: \(responseError(error))")
|
||||
@@ -840,7 +727,7 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func shareUIAction(_ ci: ChatItem) -> UIAction {
|
||||
private func shareUIAction() -> UIAction {
|
||||
UIAction(
|
||||
title: NSLocalizedString("Share", comment: "chat item action"),
|
||||
image: UIImage(systemName: "square.and.arrow.up")
|
||||
@@ -853,7 +740,7 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func copyUIAction(_ ci: ChatItem) -> UIAction {
|
||||
private func copyUIAction() -> UIAction {
|
||||
UIAction(
|
||||
title: NSLocalizedString("Copy", comment: "chat item action"),
|
||||
image: UIImage(systemName: "doc.on.doc")
|
||||
@@ -886,7 +773,7 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func editAction(_ ci: ChatItem) -> UIAction {
|
||||
private func editAction() -> UIAction {
|
||||
UIAction(
|
||||
title: NSLocalizedString("Edit", comment: "chat item action"),
|
||||
image: UIImage(systemName: "square.and.pencil")
|
||||
@@ -897,7 +784,7 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func viewInfoUIAction(_ ci: ChatItem) -> UIAction {
|
||||
private func viewInfoUIAction() -> UIAction {
|
||||
UIAction(
|
||||
title: NSLocalizedString("Info", comment: "chat item action"),
|
||||
image: UIImage(systemName: "info.circle")
|
||||
@@ -910,7 +797,10 @@ struct ChatView: View {
|
||||
chatItemInfo = ciInfo
|
||||
}
|
||||
if case let .group(gInfo) = chat.chatInfo {
|
||||
await chatView.loadGroupMembers(gInfo)
|
||||
let groupMembers = await apiListMembers(gInfo.groupId)
|
||||
await MainActor.run {
|
||||
ChatModel.shared.groupMembers = groupMembers
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiGetChatItemInfo error: \(responseError(error))")
|
||||
@@ -931,7 +821,7 @@ struct ChatView: View {
|
||||
message: Text(cancelAction.alert.message),
|
||||
primaryButton: .destructive(Text(cancelAction.alert.confirm)) {
|
||||
Task {
|
||||
if let user = m.currentUser {
|
||||
if let user = ChatModel.shared.currentUser {
|
||||
await cancelFile(user: user, fileId: fileId)
|
||||
}
|
||||
}
|
||||
@@ -952,45 +842,18 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteUIAction(_ ci: ChatItem) -> UIAction {
|
||||
private func deleteUIAction() -> UIAction {
|
||||
UIAction(
|
||||
title: NSLocalizedString("Delete", comment: "chat item action"),
|
||||
image: UIImage(systemName: "trash"),
|
||||
attributes: [.destructive]
|
||||
) { _ in
|
||||
if !revealed && ci.meta.itemDeleted != nil,
|
||||
let currIndex = m.getChatItemIndex(ci),
|
||||
let ciCategory = ci.mergeCategory {
|
||||
let (prevHidden, _) = m.getPrevShownChatItem(currIndex, ciCategory)
|
||||
if let range = itemsRange(currIndex, prevHidden) {
|
||||
var itemIds: [Int64] = []
|
||||
for i in range {
|
||||
itemIds.append(m.reversedChatItems[i].id)
|
||||
}
|
||||
showDeleteMessages = true
|
||||
deletingItems = itemIds
|
||||
} else {
|
||||
showDeleteMessage = true
|
||||
deletingItem = ci
|
||||
}
|
||||
} else {
|
||||
showDeleteMessage = true
|
||||
deletingItem = ci
|
||||
}
|
||||
showDeleteMessage = true
|
||||
deletingItem = ci
|
||||
}
|
||||
}
|
||||
|
||||
private func itemsRange(_ currIndex: Int?, _ prevHidden: Int?) -> ClosedRange<Int>? {
|
||||
if let currIndex = currIndex,
|
||||
let prevHidden = prevHidden,
|
||||
prevHidden > currIndex {
|
||||
currIndex...prevHidden
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private func moderateUIAction(_ ci: ChatItem, _ groupInfo: GroupInfo) -> UIAction {
|
||||
private func moderateUIAction(_ groupInfo: GroupInfo) -> UIAction {
|
||||
UIAction(
|
||||
title: NSLocalizedString("Moderate", comment: "chat item action"),
|
||||
image: UIImage(systemName: "flag"),
|
||||
@@ -1022,105 +885,20 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func expandUIAction() -> UIAction {
|
||||
UIAction(
|
||||
title: NSLocalizedString("Expand", comment: "chat item action"),
|
||||
image: UIImage(systemName: "arrow.up.and.line.horizontal.and.arrow.down")
|
||||
) { _ in
|
||||
withAnimation {
|
||||
revealed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func shrinkUIAction() -> UIAction {
|
||||
UIAction(
|
||||
title: NSLocalizedString("Hide", comment: "chat item action"),
|
||||
image: UIImage(systemName: "arrow.down.and.line.horizontal.and.arrow.up")
|
||||
) { _ in
|
||||
withAnimation {
|
||||
revealed = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var broadcastDeleteButtonText: LocalizedStringKey {
|
||||
chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone"
|
||||
}
|
||||
|
||||
var deleteMessagesTitle: LocalizedStringKey {
|
||||
let n = deletingItems.count
|
||||
return n == 1 ? "Delete message?" : "Delete \(n) messages?"
|
||||
}
|
||||
|
||||
private func deleteMessages() {
|
||||
let itemIds = deletingItems
|
||||
if itemIds.count > 0 {
|
||||
let chatInfo = chat.chatInfo
|
||||
Task {
|
||||
var deletedItems: [ChatItem] = []
|
||||
for itemId in itemIds {
|
||||
do {
|
||||
let (di, _) = try await apiDeleteChatItem(
|
||||
type: chatInfo.chatType,
|
||||
id: chatInfo.apiId,
|
||||
itemId: itemId,
|
||||
mode: .cidmInternal
|
||||
)
|
||||
deletedItems.append(di)
|
||||
} catch {
|
||||
logger.error("ChatView.deleteMessage error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
await MainActor.run {
|
||||
for di in deletedItems {
|
||||
m.removeChatItem(chatInfo, di)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteMessage(_ mode: CIDeleteMode) {
|
||||
logger.debug("ChatView deleteMessage")
|
||||
Task {
|
||||
logger.debug("ChatView deleteMessage: in Task")
|
||||
do {
|
||||
if let di = deletingItem {
|
||||
var deletedItem: ChatItem
|
||||
var toItem: ChatItem?
|
||||
if case .cidmBroadcast = mode,
|
||||
let (groupInfo, groupMember) = di.memberToModerate(chat.chatInfo) {
|
||||
(deletedItem, toItem) = try await apiDeleteMemberChatItem(
|
||||
groupId: groupInfo.apiId,
|
||||
groupMemberId: groupMember.groupMemberId,
|
||||
itemId: di.id
|
||||
)
|
||||
} else {
|
||||
(deletedItem, toItem) = try await apiDeleteChatItem(
|
||||
type: chat.chatInfo.chatType,
|
||||
id: chat.chatInfo.apiId,
|
||||
itemId: di.id,
|
||||
mode: mode
|
||||
)
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
deletingItem = nil
|
||||
if let toItem = toItem {
|
||||
_ = m.upsertChatItem(chat.chatInfo, toItem)
|
||||
} else {
|
||||
m.removeChatItem(chat.chatInfo, deletedItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.error("ChatView.deleteMessage error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool {
|
||||
switch (prevItem?.chatDir) {
|
||||
case .groupSnd: return true
|
||||
case let .groupRcv(prevMember): return prevMember.groupMemberId != member.groupMemberId
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
private func scrollToBottom(_ proxy: ScrollViewProxy) {
|
||||
if let ci = chatModel.reversedChatItems.first {
|
||||
withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) }
|
||||
@@ -1132,6 +910,44 @@ struct ChatView: View {
|
||||
withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) }
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteMessage(_ mode: CIDeleteMode) {
|
||||
logger.debug("ChatView deleteMessage")
|
||||
Task {
|
||||
logger.debug("ChatView deleteMessage: in Task")
|
||||
do {
|
||||
if let di = deletingItem {
|
||||
var deletedItem: ChatItem
|
||||
var toItem: ChatItem?
|
||||
if case .cidmBroadcast = mode,
|
||||
let (groupInfo, groupMember) = di.memberToModerate(chat.chatInfo) {
|
||||
(deletedItem, toItem) = try await apiDeleteMemberChatItem(
|
||||
groupId: groupInfo.apiId,
|
||||
groupMemberId: groupMember.groupMemberId,
|
||||
itemId: di.id
|
||||
)
|
||||
} else {
|
||||
(deletedItem, toItem) = try await apiDeleteChatItem(
|
||||
type: chat.chatInfo.chatType,
|
||||
id: chat.chatInfo.apiId,
|
||||
itemId: di.id,
|
||||
mode: mode
|
||||
)
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
deletingItem = nil
|
||||
if let toItem = toItem {
|
||||
_ = chatModel.upsertChatItem(chat.chatInfo, toItem)
|
||||
} else {
|
||||
chatModel.removeChatItem(chat.chatInfo, deletedItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.error("ChatView.deleteMessage error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func toggleNtfsButton(_ chat: Chat) -> some View {
|
||||
@@ -1148,7 +964,7 @@ struct ChatView: View {
|
||||
|
||||
func toggleNotifications(_ chat: Chat, enableNtfs: Bool) {
|
||||
var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
|
||||
chatSettings.enableNtfs = enableNtfs ? .all : .none
|
||||
chatSettings.enableNtfs = enableNtfs
|
||||
updateChatSettings(chat, chatSettings: chatSettings)
|
||||
}
|
||||
|
||||
|
||||
@@ -592,14 +592,12 @@ struct ComposeView: View {
|
||||
EmptyView()
|
||||
case let .quotedItem(chatItem: quotedItem):
|
||||
ContextItemView(
|
||||
chat: chat,
|
||||
contextItem: quotedItem,
|
||||
contextIcon: "arrowshape.turn.up.left",
|
||||
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
|
||||
)
|
||||
case let .editingItem(chatItem: editingItem):
|
||||
ContextItemView(
|
||||
chat: chat,
|
||||
contextItem: editingItem,
|
||||
contextIcon: "pencil",
|
||||
cancelContextItem: { clearState() }
|
||||
|
||||
@@ -11,7 +11,6 @@ import SimpleXChat
|
||||
|
||||
struct ContextItemView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@ObservedObject var chat: Chat
|
||||
let contextItem: ChatItem
|
||||
let contextIcon: String
|
||||
let cancelContextItem: () -> Void
|
||||
@@ -49,7 +48,6 @@ struct ContextItemView: View {
|
||||
|
||||
private func msgContentView(lines: Int) -> some View {
|
||||
MsgContentView(
|
||||
chat: chat,
|
||||
text: contextItem.text,
|
||||
formattedText: contextItem.formattedText
|
||||
)
|
||||
@@ -61,6 +59,6 @@ struct ContextItemView: View {
|
||||
struct ContextItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
|
||||
return ContextItemView(chat: Chat.sampleData, contextItem: contextItem, contextIcon: "pencil.circle", cancelContextItem: {})
|
||||
return ContextItemView(contextItem: contextItem, contextIcon: "pencil.circle", cancelContextItem: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,28 +14,20 @@ import PhotosUI
|
||||
struct NativeTextEditor: UIViewRepresentable {
|
||||
@Binding var text: String
|
||||
@Binding var disableEditing: Bool
|
||||
@Binding var height: CGFloat
|
||||
let height: CGFloat
|
||||
let font: UIFont
|
||||
@Binding var focused: Bool
|
||||
let alignment: TextAlignment
|
||||
let onImagesAdded: ([UploadContent]) -> Void
|
||||
|
||||
private let minHeight: CGFloat = 37
|
||||
|
||||
private let defaultHeight: CGFloat = {
|
||||
let field = CustomUITextField(height: Binding.constant(0))
|
||||
field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
|
||||
return min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, 37), 360).rounded(.down)
|
||||
}()
|
||||
|
||||
func makeUIView(context: Context) -> UITextView {
|
||||
let field = CustomUITextField(height: _height)
|
||||
let field = CustomUITextField()
|
||||
field.text = text
|
||||
field.font = font
|
||||
field.textAlignment = alignment == .leading ? .left : .right
|
||||
field.autocapitalizationType = .sentences
|
||||
field.setOnTextChangedListener { newText, images in
|
||||
if !disableEditing {
|
||||
// Speed up the process of updating layout, reduce jumping content on screen
|
||||
if !isShortEmoji(newText) { updateHeight(field) }
|
||||
text = newText
|
||||
} else {
|
||||
field.text = text
|
||||
@@ -47,72 +39,24 @@ struct NativeTextEditor: UIViewRepresentable {
|
||||
field.setOnFocusChangedListener { focused = $0 }
|
||||
field.delegate = field
|
||||
field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
|
||||
updateFont(field)
|
||||
updateHeight(field)
|
||||
return field
|
||||
}
|
||||
|
||||
func updateUIView(_ field: UITextView, context: Context) {
|
||||
field.text = text
|
||||
field.font = font
|
||||
field.textAlignment = alignment == .leading ? .left : .right
|
||||
updateFont(field)
|
||||
updateHeight(field)
|
||||
}
|
||||
|
||||
private func updateHeight(_ field: UITextView) {
|
||||
let maxHeight = min(360, field.font!.lineHeight * 12)
|
||||
// When having emoji in text view and then removing it, sizeThatFits shows previous size (too big for empty text view), so using work around with default size
|
||||
let newHeight = field.text == ""
|
||||
? defaultHeight
|
||||
: min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, minHeight), maxHeight).rounded(.down)
|
||||
|
||||
if field.frame.size.height != newHeight {
|
||||
field.frame.size = CGSizeMake(field.frame.size.width, newHeight)
|
||||
(field as! CustomUITextField).invalidateIntrinsicContentHeight(newHeight)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateFont(_ field: UITextView) {
|
||||
field.font = isShortEmoji(field.text)
|
||||
? (field.text.count < 4 ? largeEmojiUIFont : mediumEmojiUIFont)
|
||||
: UIFont.preferredFont(forTextStyle: .body)
|
||||
}
|
||||
}
|
||||
|
||||
private class CustomUITextField: UITextView, UITextViewDelegate {
|
||||
var height: Binding<CGFloat>
|
||||
var newHeight: CGFloat = 0
|
||||
var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in }
|
||||
var onFocusChanged: (Bool) -> Void = { focused in }
|
||||
|
||||
init(height: Binding<CGFloat>) {
|
||||
self.height = height
|
||||
super.init(frame: .zero, textContainer: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("Not implemented")
|
||||
}
|
||||
|
||||
// This func here needed because using frame.size.height in intrinsicContentSize while loading a screen with text (for example. when you have a draft),
|
||||
// produces incorrect height because at that point intrinsicContentSize has old value of frame.size.height even if it was set to new value right before the call
|
||||
// (who knows why...)
|
||||
func invalidateIntrinsicContentHeight(_ newHeight: CGFloat) {
|
||||
self.newHeight = newHeight
|
||||
invalidateIntrinsicContentSize()
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
if height.wrappedValue != newHeight {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now(), execute: { self.height.wrappedValue = self.newHeight })
|
||||
}
|
||||
return CGSizeMake(0, newHeight)
|
||||
}
|
||||
|
||||
func setOnTextChangedListener(onTextChanged: @escaping (String, [UploadContent]) -> Void) {
|
||||
self.onTextChanged = onTextChanged
|
||||
}
|
||||
|
||||
|
||||
func setOnFocusChangedListener(onFocusChanged: @escaping (Bool) -> Void) {
|
||||
self.onFocusChanged = onFocusChanged
|
||||
}
|
||||
@@ -200,14 +144,14 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
|
||||
|
||||
struct NativeTextEditor_Previews: PreviewProvider{
|
||||
static var previews: some View {
|
||||
NativeTextEditor(
|
||||
return NativeTextEditor(
|
||||
text: Binding.constant("Hello, world!"),
|
||||
disableEditing: Binding.constant(false),
|
||||
height: Binding.constant(100),
|
||||
height: 100,
|
||||
font: UIFont.preferredFont(forTextStyle: .body),
|
||||
focused: Binding.constant(false),
|
||||
alignment: TextAlignment.leading,
|
||||
onImagesAdded: { _ in }
|
||||
)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,12 +32,15 @@ struct SendMessageView: View {
|
||||
var sendButtonColor = Color.accentColor
|
||||
@State private var teHeight: CGFloat = 42
|
||||
@State private var teFont: Font = .body
|
||||
@State private var teUiFont: UIFont = UIFont.preferredFont(forTextStyle: .body)
|
||||
@State private var sendButtonSize: CGFloat = 29
|
||||
@State private var sendButtonOpacity: CGFloat = 1
|
||||
@State private var showCustomDisappearingMessageDialogue = false
|
||||
@State private var showCustomTimePicker = false
|
||||
@State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get()
|
||||
@State private var progressByTimeout = false
|
||||
var maxHeight: CGFloat = 360
|
||||
var minHeight: CGFloat = 37
|
||||
@AppStorage(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
|
||||
|
||||
var body: some View {
|
||||
@@ -54,16 +57,30 @@ struct SendMessageView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
let alignment: TextAlignment = isRightToLeft(composeState.message) ? .trailing : .leading
|
||||
Text(composeState.message)
|
||||
.lineLimit(10)
|
||||
.font(teFont)
|
||||
.multilineTextAlignment(alignment)
|
||||
// put text on top (after NativeTextEditor) and set color to precisely align it on changes
|
||||
// .foregroundColor(.red)
|
||||
.foregroundColor(.clear)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 6)
|
||||
.matchedGeometryEffect(id: "te", in: namespace)
|
||||
.background(GeometryReader(content: updateHeight))
|
||||
|
||||
NativeTextEditor(
|
||||
text: $composeState.message,
|
||||
disableEditing: $composeState.inProgress,
|
||||
height: $teHeight,
|
||||
height: teHeight,
|
||||
font: teUiFont,
|
||||
focused: $keyboardVisible,
|
||||
alignment: alignment,
|
||||
onImagesAdded: onMediaAdded
|
||||
)
|
||||
.allowsTightening(false)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(height: teHeight)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,13 +100,11 @@ struct SendMessageView: View {
|
||||
.frame(height: teHeight, alignment: .bottom)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 1)
|
||||
.overlay(
|
||||
|
||||
RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
|
||||
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
|
||||
)
|
||||
.frame(height: teHeight)
|
||||
}
|
||||
.onChange(of: composeState.message, perform: { text in updateFont(text) })
|
||||
.onChange(of: composeState.inProgress) { inProgress in
|
||||
if inProgress {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||
@@ -400,12 +415,16 @@ struct SendMessageView: View {
|
||||
.padding([.bottom, .trailing], 4)
|
||||
}
|
||||
|
||||
private func updateFont(_ text: String) {
|
||||
private func updateHeight(_ g: GeometryProxy) -> Color {
|
||||
DispatchQueue.main.async {
|
||||
teFont = isShortEmoji(text)
|
||||
? (text.count < 4 ? largeEmojiFont : mediumEmojiFont)
|
||||
: .body
|
||||
teHeight = min(max(g.frame(in: .local).size.height, minHeight), maxHeight)
|
||||
(teFont, teUiFont) = isShortEmoji(composeState.message)
|
||||
? composeState.message.count < 4
|
||||
? (largeEmojiFont, largeEmojiUIFont)
|
||||
: (mediumEmojiFont, mediumEmojiUIFont)
|
||||
: (.body, UIFont.preferredFont(forTextStyle: .body))
|
||||
}
|
||||
return Color.clear
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ struct AddGroupMembersViewCommon: View {
|
||||
do {
|
||||
for contactId in selectedContacts {
|
||||
let member = try await apiAddMember(groupInfo.groupId, contactId, selectedRole)
|
||||
await MainActor.run { _ = chatModel.upsertGroupMember(groupInfo, member) }
|
||||
await MainActor.run { _ = ChatModel.shared.upsertGroupMember(groupInfo, member) }
|
||||
}
|
||||
addedMembersCb(selectedContacts)
|
||||
} catch {
|
||||
|
||||
@@ -15,7 +15,7 @@ struct GroupChatInfoView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@ObservedObject var chat: Chat
|
||||
@Binding var groupInfo: GroupInfo
|
||||
@State var groupInfo: GroupInfo
|
||||
@ObservedObject private var alertManager = AlertManager.shared
|
||||
@State private var alert: GroupChatInfoViewAlert? = nil
|
||||
@State private var groupLink: String?
|
||||
@@ -35,30 +35,14 @@ struct GroupChatInfoView: View {
|
||||
case leaveGroupAlert
|
||||
case cantInviteIncognitoAlert
|
||||
case largeGroupReceiptsDisabled
|
||||
case blockMemberAlert(mem: GroupMember)
|
||||
case unblockMemberAlert(mem: GroupMember)
|
||||
case removeMemberAlert(mem: GroupMember)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .deleteGroupAlert: return "deleteGroupAlert"
|
||||
case .clearChatAlert: return "clearChatAlert"
|
||||
case .leaveGroupAlert: return "leaveGroupAlert"
|
||||
case .cantInviteIncognitoAlert: return "cantInviteIncognitoAlert"
|
||||
case .largeGroupReceiptsDisabled: return "largeGroupReceiptsDisabled"
|
||||
case let .blockMemberAlert(mem): return "blockMemberAlert \(mem.groupMemberId)"
|
||||
case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)"
|
||||
case let .removeMemberAlert(mem): return "removeMemberAlert \(mem.groupMemberId)"
|
||||
case let .error(title, _): return "error \(title)"
|
||||
}
|
||||
}
|
||||
var id: GroupChatInfoViewAlert { get { self } }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
let members = chatModel.groupMembers
|
||||
.filter { m in let status = m.wrapped.memberStatus; return status != .memLeft && status != .memRemoved }
|
||||
.filter { $0.memberStatus != .memLeft && $0.memberStatus != .memRemoved }
|
||||
.sorted { $0.displayName.lowercased() < $1.displayName.lowercased() }
|
||||
|
||||
List {
|
||||
@@ -73,7 +57,7 @@ struct GroupChatInfoView: View {
|
||||
addOrEditWelcomeMessage()
|
||||
}
|
||||
groupPreferencesButton($groupInfo)
|
||||
if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
|
||||
if members.filter({ $0.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
|
||||
sendReceiptsOption()
|
||||
} else {
|
||||
sendReceiptsOptionDisabled()
|
||||
@@ -100,17 +84,17 @@ struct GroupChatInfoView: View {
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||
let filteredMembers = s == "" ? members : members.filter { $0.wrapped.chatViewName.localizedLowercase.contains(s) }
|
||||
MemberRowView(groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert)
|
||||
let filteredMembers = s == "" ? members : members.filter { $0.chatViewName.localizedLowercase.contains(s) }
|
||||
memberView(groupInfo.membership, user: true)
|
||||
ForEach(filteredMembers) { member in
|
||||
ZStack {
|
||||
NavigationLink {
|
||||
memberInfoView(member)
|
||||
memberInfoView(member.groupMemberId)
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.opacity(0)
|
||||
MemberRowView(groupInfo: groupInfo, groupMember: member, alert: $alert)
|
||||
memberView(member)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,10 +126,6 @@ struct GroupChatInfoView: View {
|
||||
case .leaveGroupAlert: return leaveGroupAlert()
|
||||
case .cantInviteIncognitoAlert: return cantInviteIncognitoAlert()
|
||||
case .largeGroupReceiptsDisabled: return largeGroupReceiptsDisabledAlert()
|
||||
case let .blockMemberAlert(mem): return blockMemberAlert(groupInfo, mem)
|
||||
case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem)
|
||||
case let .removeMemberAlert(mem): return removeMemberAlert(mem)
|
||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
@@ -194,7 +174,7 @@ struct GroupChatInfoView: View {
|
||||
Task {
|
||||
let groupMembers = await apiListMembers(groupInfo.groupId)
|
||||
await MainActor.run {
|
||||
chatModel.groupMembers = groupMembers.map { GMember.init($0) }
|
||||
ChatModel.shared.groupMembers = groupMembers
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -203,92 +183,51 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct MemberRowView: View {
|
||||
var groupInfo: GroupInfo
|
||||
@ObservedObject var groupMember: GMember
|
||||
var user: Bool = false
|
||||
@Binding var alert: GroupChatInfoViewAlert?
|
||||
|
||||
var body: some View {
|
||||
let member = groupMember.wrapped
|
||||
let v = HStack{
|
||||
ProfileImage(imageStr: member.image)
|
||||
.frame(width: 38, height: 38)
|
||||
.padding(.trailing, 2)
|
||||
// TODO server connection status
|
||||
VStack(alignment: .leading) {
|
||||
let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : .primary)
|
||||
(member.verified ? memberVerifiedShield + t : t)
|
||||
.lineLimit(1)
|
||||
let s = Text(member.memberStatus.shortText)
|
||||
(user ? Text ("you: ") + s : s)
|
||||
.lineLimit(1)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
let role = member.memberRole
|
||||
if role == .owner || role == .admin {
|
||||
Text(member.memberRole.text)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
private func memberView(_ member: GroupMember, user: Bool = false) -> some View {
|
||||
HStack{
|
||||
ProfileImage(imageStr: member.image)
|
||||
.frame(width: 38, height: 38)
|
||||
.padding(.trailing, 2)
|
||||
// TODO server connection status
|
||||
VStack(alignment: .leading) {
|
||||
let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : .primary)
|
||||
(member.verified ? memberVerifiedShield + t : t)
|
||||
.lineLimit(1)
|
||||
let s = Text(member.memberStatus.shortText)
|
||||
(user ? Text ("you: ") + s : s)
|
||||
.lineLimit(1)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if user {
|
||||
v
|
||||
} else if member.canBeRemoved(groupInfo: groupInfo) {
|
||||
removeSwipe(member, blockSwipe(member, v))
|
||||
} else {
|
||||
blockSwipe(member, v)
|
||||
}
|
||||
}
|
||||
|
||||
private func blockSwipe<V: View>(_ member: GroupMember, _ v: V) -> some View {
|
||||
v.swipeActions(edge: .leading) {
|
||||
if member.memberSettings.showMessages {
|
||||
Button {
|
||||
alert = .blockMemberAlert(mem: member)
|
||||
} label: {
|
||||
Label("Block member", systemImage: "hand.raised").foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
alert = .unblockMemberAlert(mem: member)
|
||||
} label: {
|
||||
Label("Unblock member", systemImage: "hand.raised.slash").foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeSwipe<V: View>(_ member: GroupMember, _ v: V) -> some View {
|
||||
v.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
alert = .removeMemberAlert(mem: member)
|
||||
} label: {
|
||||
Label("Remove member", systemImage: "trash")
|
||||
.foregroundColor(Color.red)
|
||||
}
|
||||
Spacer()
|
||||
let role = member.memberRole
|
||||
if role == .owner || role == .admin {
|
||||
Text(member.memberRole.text)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func memberInfoView(_ groupMember: GMember) -> some View {
|
||||
GroupMemberInfoView(groupInfo: groupInfo, groupMember: groupMember)
|
||||
.navigationBarHidden(false)
|
||||
private var memberVerifiedShield: Text {
|
||||
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
||||
.font(.caption)
|
||||
.baselineOffset(2)
|
||||
.kerning(-2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
@ViewBuilder private func memberInfoView(_ groupMemberId: Int64?) -> some View {
|
||||
if let mId = groupMemberId, let member = chatModel.groupMembers.first(where: { $0.groupMemberId == mId }) {
|
||||
GroupMemberInfoView(groupInfo: groupInfo, member: member)
|
||||
.navigationBarHidden(false)
|
||||
}
|
||||
}
|
||||
|
||||
private func groupLinkButton() -> some View {
|
||||
NavigationLink {
|
||||
GroupLinkView(
|
||||
groupId: groupInfo.groupId,
|
||||
groupLink: $groupLink,
|
||||
groupLinkMemberRole: $groupLinkMemberRole,
|
||||
showTitle: false,
|
||||
creatingGroup: false
|
||||
)
|
||||
.navigationBarTitle("Group link")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink, groupLinkMemberRole: $groupLinkMemberRole)
|
||||
.navigationBarTitle("Group link")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
if groupLink == nil {
|
||||
Label("Create group link", systemImage: "link.badge.plus")
|
||||
@@ -436,28 +375,6 @@ struct GroupChatInfoView: View {
|
||||
alert = .largeGroupReceiptsDisabled
|
||||
}
|
||||
}
|
||||
|
||||
private func removeMemberAlert(_ mem: GroupMember) -> Alert {
|
||||
Alert(
|
||||
title: Text("Remove member?"),
|
||||
message: Text("Member will be removed from group - this cannot be undone!"),
|
||||
primaryButton: .destructive(Text("Remove")) {
|
||||
Task {
|
||||
do {
|
||||
let updatedMember = try await apiRemoveMember(groupInfo.groupId, mem.groupMemberId)
|
||||
await MainActor.run {
|
||||
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiRemoveMember error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error removing member")
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func groupPreferencesButton(_ groupInfo: Binding<GroupInfo>, _ creatingGroup: Bool = false) -> some View {
|
||||
@@ -479,14 +396,6 @@ func groupPreferencesButton(_ groupInfo: Binding<GroupInfo>, _ creatingGroup: Bo
|
||||
}
|
||||
}
|
||||
|
||||
private var memberVerifiedShield: Text {
|
||||
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
||||
.font(.caption)
|
||||
.baselineOffset(2)
|
||||
.kerning(-2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
func cantInviteIncognitoAlert() -> Alert {
|
||||
Alert(
|
||||
title: Text("Can't invite contacts!"),
|
||||
@@ -503,9 +412,6 @@ func largeGroupReceiptsDisabledAlert() -> Alert {
|
||||
|
||||
struct GroupChatInfoView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
GroupChatInfoView(
|
||||
chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []),
|
||||
groupInfo: Binding.constant(GroupInfo.sampleData)
|
||||
)
|
||||
GroupChatInfoView(chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []), groupInfo: GroupInfo.sampleData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,6 @@ struct GroupLinkView: View {
|
||||
var groupId: Int64
|
||||
@Binding var groupLink: String?
|
||||
@Binding var groupLinkMemberRole: GroupMemberRole
|
||||
var showTitle: Bool = false
|
||||
var creatingGroup: Bool = false
|
||||
var linkCreatedCb: (() -> Void)? = nil
|
||||
@State private var creatingLink = false
|
||||
@State private var alert: GroupLinkAlert?
|
||||
|
||||
@@ -32,35 +29,10 @@ struct GroupLinkView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if creatingGroup {
|
||||
NavigationView {
|
||||
groupLinkView()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button ("Continue") { linkCreatedCb?() }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
groupLinkView()
|
||||
}
|
||||
}
|
||||
|
||||
private func groupLinkView() -> some View {
|
||||
List {
|
||||
Group {
|
||||
if showTitle {
|
||||
Text("Group link")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.")
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
|
||||
Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.")
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
Section {
|
||||
if let groupLink = groupLink {
|
||||
Picker("Initial role", selection: $groupLinkMemberRole) {
|
||||
@@ -69,17 +41,15 @@ struct GroupLinkView: View {
|
||||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
SimpleXLinkQRCode(uri: groupLink)
|
||||
QRCode(uri: groupLink)
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(groupLink)])
|
||||
showShareSheet(items: [groupLink])
|
||||
} label: {
|
||||
Label("Share link", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
|
||||
if !creatingGroup {
|
||||
Button(role: .destructive) { alert = .deleteLink } label: {
|
||||
Label("Delete link", systemImage: "trash")
|
||||
}
|
||||
Button(role: .destructive) { alert = .deleteLink } label: {
|
||||
Label("Delete link", systemImage: "trash")
|
||||
}
|
||||
} else {
|
||||
Button(action: createGroupLink) {
|
||||
|
||||
@@ -12,40 +12,38 @@ import SimpleXChat
|
||||
struct GroupMemberInfoView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@State var groupInfo: GroupInfo
|
||||
@ObservedObject var groupMember: GMember
|
||||
var groupInfo: GroupInfo
|
||||
@State var member: GroupMember
|
||||
var navigation: Bool = false
|
||||
@State private var connectionStats: ConnectionStats? = nil
|
||||
@State private var connectionCode: String? = nil
|
||||
@State private var newRole: GroupMemberRole = .member
|
||||
@State private var alert: GroupMemberInfoViewAlert?
|
||||
@State private var sheet: PlanAndConnectActionSheet?
|
||||
@State private var connectToMemberDialog: Bool = false
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@State private var justOpened = true
|
||||
@State private var progressIndicator = false
|
||||
|
||||
enum GroupMemberInfoViewAlert: Identifiable {
|
||||
case blockMemberAlert(mem: GroupMember)
|
||||
case unblockMemberAlert(mem: GroupMember)
|
||||
case removeMemberAlert(mem: GroupMember)
|
||||
case changeMemberRoleAlert(mem: GroupMember, role: GroupMemberRole)
|
||||
case switchAddressAlert
|
||||
case abortSwitchAddressAlert
|
||||
case syncConnectionForceAlert
|
||||
case planAndConnectAlert(alert: PlanAndConnectAlert)
|
||||
case connRequestSentAlert(type: ConnReqType)
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey)
|
||||
case other(alert: Alert)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .blockMemberAlert(mem): return "blockMemberAlert \(mem.groupMemberId)"
|
||||
case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)"
|
||||
case let .removeMemberAlert(mem): return "removeMemberAlert \(mem.groupMemberId)"
|
||||
case let .changeMemberRoleAlert(mem, role): return "changeMemberRoleAlert \(mem.groupMemberId) \(role.rawValue)"
|
||||
case .removeMemberAlert: return "removeMemberAlert"
|
||||
case let .changeMemberRoleAlert(_, role): return "changeMemberRoleAlert \(role.rawValue)"
|
||||
case .switchAddressAlert: return "switchAddressAlert"
|
||||
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
|
||||
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
|
||||
case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)"
|
||||
case .connRequestSentAlert: return "connRequestSentAlert"
|
||||
case let .error(title, _): return "error \(title)"
|
||||
case let .other(alert): return "other \(alert)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,7 +68,6 @@ struct GroupMemberInfoView: View {
|
||||
private func groupMemberInfoView() -> some View {
|
||||
ZStack {
|
||||
VStack {
|
||||
let member = groupMember.wrapped
|
||||
List {
|
||||
groupMemberInfoHeader(member)
|
||||
.listRowBackground(Color.clear)
|
||||
@@ -99,9 +96,9 @@ struct GroupMemberInfoView: View {
|
||||
|
||||
if let contactLink = member.contactLink {
|
||||
Section {
|
||||
SimpleXLinkQRCode(uri: contactLink)
|
||||
QRCode(uri: contactLink)
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(contactLink)])
|
||||
showShareSheet(items: [contactLink])
|
||||
} label: {
|
||||
Label("Share address", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
@@ -164,14 +161,9 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
if member.memberSettings.showMessages {
|
||||
blockMemberButton(member)
|
||||
} else {
|
||||
unblockMemberButton(member)
|
||||
}
|
||||
if member.canBeRemoved(groupInfo: groupInfo) {
|
||||
removeMemberButton(member)
|
||||
if member.canBeRemoved(groupInfo: groupInfo) {
|
||||
Section {
|
||||
removeMemberButton(member)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +184,7 @@ struct GroupMemberInfoView: View {
|
||||
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)
|
||||
member = mem
|
||||
connectionStats = stats
|
||||
connectionCode = code
|
||||
} catch let error {
|
||||
@@ -200,30 +192,25 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
justOpened = false
|
||||
}
|
||||
.onChange(of: newRole) { newRole in
|
||||
.onChange(of: 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) {
|
||||
case let .blockMemberAlert(mem): return blockMemberAlert(groupInfo, mem)
|
||||
case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem)
|
||||
case let .removeMemberAlert(mem): return removeMemberAlert(mem)
|
||||
case let .changeMemberRoleAlert(mem, _): return changeMemberRoleAlert(mem)
|
||||
case .switchAddressAlert: return switchAddressAlert(switchMemberAddress)
|
||||
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress)
|
||||
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
|
||||
case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true)
|
||||
case let .connRequestSentAlert(type): return connReqSentAlert(type)
|
||||
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
|
||||
case let .other(alert): return alert
|
||||
}
|
||||
}
|
||||
.actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
|
||||
|
||||
if progressIndicator {
|
||||
ProgressView().scaleEffect(2)
|
||||
@@ -233,16 +220,25 @@ struct GroupMemberInfoView: View {
|
||||
|
||||
func connectViaAddressButton(_ contactLink: String) -> some View {
|
||||
Button {
|
||||
planAndConnect(
|
||||
contactLink,
|
||||
showAlert: { alert = .planAndConnectAlert(alert: $0) },
|
||||
showActionSheet: { sheet = $0 },
|
||||
dismiss: true,
|
||||
incognito: nil
|
||||
)
|
||||
connectToMemberDialog = true
|
||||
} label: {
|
||||
Label("Connect", systemImage: "link")
|
||||
}
|
||||
.confirmationDialog("Connect directly", isPresented: $connectToMemberDialog, titleVisibility: .visible) {
|
||||
Button("Use current profile") { connectViaAddress(incognito: false, contactLink: contactLink) }
|
||||
Button("Use new incognito profile") { connectViaAddress(incognito: true, contactLink: contactLink) }
|
||||
}
|
||||
}
|
||||
|
||||
func connectViaAddress(incognito: Bool, contactLink: String) {
|
||||
Task {
|
||||
let (connReqType, connectAlert) = await apiConnect_(incognito: incognito, connReq: contactLink)
|
||||
if let connReqType = connReqType {
|
||||
alert = .connRequestSentAlert(type: connReqType)
|
||||
} else if let connectAlert = connectAlert {
|
||||
alert = .other(alert: connectAlert)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func knownDirectChatButton(_ chat: Chat) -> some View {
|
||||
@@ -278,7 +274,7 @@ struct GroupMemberInfoView: View {
|
||||
progressIndicator = true
|
||||
Task {
|
||||
do {
|
||||
let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId)
|
||||
let memberContact = try await apiCreateMemberContact(groupInfo.apiId, member.groupMemberId)
|
||||
await MainActor.run {
|
||||
progressIndicator = false
|
||||
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
|
||||
@@ -336,20 +332,20 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
|
||||
private func verifyCodeButton(_ code: String) -> some View {
|
||||
let member = groupMember.wrapped
|
||||
return NavigationLink {
|
||||
NavigationLink {
|
||||
VerifyCodeView(
|
||||
displayName: member.displayName,
|
||||
connectionCode: code,
|
||||
connectionVerified: member.verified,
|
||||
verify: { code in
|
||||
var member = groupMember.wrapped
|
||||
if let r = apiVerifyGroupMember(member.groupId, member.groupMemberId, connectionCode: code) {
|
||||
let (verified, existingCode) = r
|
||||
let connCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil
|
||||
connectionCode = existingCode
|
||||
member.activeConn?.connectionCode = connCode
|
||||
_ = chatModel.upsertGroupMember(groupInfo, member)
|
||||
if let i = chatModel.groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) {
|
||||
chatModel.groupMembers[i].activeConn?.connectionCode = connCode
|
||||
}
|
||||
return r
|
||||
}
|
||||
return nil
|
||||
@@ -383,29 +379,12 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func blockMemberButton(_ mem: GroupMember) -> some View {
|
||||
Button(role: .destructive) {
|
||||
alert = .blockMemberAlert(mem: mem)
|
||||
} label: {
|
||||
Label("Block member", systemImage: "hand.raised")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
private func unblockMemberButton(_ mem: GroupMember) -> some View {
|
||||
Button {
|
||||
alert = .unblockMemberAlert(mem: mem)
|
||||
} label: {
|
||||
Label("Unblock member", systemImage: "hand.raised.slash")
|
||||
}
|
||||
}
|
||||
|
||||
private func removeMemberButton(_ mem: GroupMember) -> some View {
|
||||
Button(role: .destructive) {
|
||||
alert = .removeMemberAlert(mem: mem)
|
||||
} label: {
|
||||
Label("Remove member", systemImage: "trash")
|
||||
.foregroundColor(.red)
|
||||
.foregroundColor(Color.red)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,6 +420,7 @@ struct GroupMemberInfoView: View {
|
||||
do {
|
||||
let updatedMember = try await apiMemberRole(groupInfo.groupId, mem.groupMemberId, newRole)
|
||||
await MainActor.run {
|
||||
member = updatedMember
|
||||
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
|
||||
}
|
||||
|
||||
@@ -461,10 +441,10 @@ struct GroupMemberInfoView: View {
|
||||
private func switchMemberAddress() {
|
||||
Task {
|
||||
do {
|
||||
let stats = try apiSwitchGroupMember(groupInfo.apiId, groupMember.groupMemberId)
|
||||
let stats = try apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
|
||||
connectionStats = stats
|
||||
await MainActor.run {
|
||||
chatModel.updateGroupMemberConnectionStats(groupInfo, groupMember.wrapped, stats)
|
||||
chatModel.updateGroupMemberConnectionStats(groupInfo, member, stats)
|
||||
dismiss()
|
||||
}
|
||||
} catch let error {
|
||||
@@ -480,10 +460,10 @@ struct GroupMemberInfoView: View {
|
||||
private func abortSwitchMemberAddress() {
|
||||
Task {
|
||||
do {
|
||||
let stats = try apiAbortSwitchGroupMember(groupInfo.apiId, groupMember.groupMemberId)
|
||||
let stats = try apiAbortSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
|
||||
connectionStats = stats
|
||||
await MainActor.run {
|
||||
chatModel.updateGroupMemberConnectionStats(groupInfo, groupMember.wrapped, stats)
|
||||
chatModel.updateGroupMemberConnectionStats(groupInfo, member, stats)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("abortSwitchMemberAddress apiAbortSwitchGroupMember error: \(responseError(error))")
|
||||
@@ -498,7 +478,7 @@ struct GroupMemberInfoView: View {
|
||||
private func syncMemberConnection(force: Bool) {
|
||||
Task {
|
||||
do {
|
||||
let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, groupMember.groupMemberId, force)
|
||||
let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, force)
|
||||
connectionStats = stats
|
||||
await MainActor.run {
|
||||
chatModel.updateGroupMemberConnectionStats(groupInfo, mem, stats)
|
||||
@@ -515,54 +495,11 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func blockMemberAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
|
||||
Alert(
|
||||
title: Text("Block member?"),
|
||||
message: Text("All new messages from \(mem.chatViewName) will be hidden!"),
|
||||
primaryButton: .destructive(Text("Block")) {
|
||||
toggleShowMemberMessages(gInfo, mem, false)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
func unblockMemberAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
|
||||
Alert(
|
||||
title: Text("Unblock member?"),
|
||||
message: Text("Messages from \(mem.chatViewName) will be shown!"),
|
||||
primaryButton: .default(Text("Unblock")) {
|
||||
toggleShowMemberMessages(gInfo, mem, true)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
func toggleShowMemberMessages(_ gInfo: GroupInfo, _ member: GroupMember, _ showMessages: Bool) {
|
||||
var memberSettings = member.memberSettings
|
||||
memberSettings.showMessages = showMessages
|
||||
updateMemberSettings(gInfo, member, memberSettings)
|
||||
}
|
||||
|
||||
func updateMemberSettings(_ gInfo: GroupInfo, _ member: GroupMember, _ memberSettings: GroupMemberSettings) {
|
||||
Task {
|
||||
do {
|
||||
try await apiSetMemberSettings(gInfo.groupId, member.groupMemberId, memberSettings)
|
||||
await MainActor.run {
|
||||
var mem = member
|
||||
mem.memberSettings = memberSettings
|
||||
_ = ChatModel.shared.upsertGroupMember(gInfo, mem)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiSetMemberSettings error \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GroupMemberInfoView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
GroupMemberInfoView(
|
||||
groupInfo: GroupInfo.sampleData,
|
||||
groupMember: GMember.sampleData
|
||||
member: GroupMember.sampleData
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ struct GroupPreferencesView: View {
|
||||
featureSection(.directMessages, $preferences.directMessages.enable)
|
||||
featureSection(.reactions, $preferences.reactions.enable)
|
||||
featureSection(.voice, $preferences.voice.enable)
|
||||
featureSection(.files, $preferences.files.enable)
|
||||
// TODO uncomment in 5.3
|
||||
// featureSection(.files, $preferences.files.enable)
|
||||
|
||||
if groupInfo.canEdit {
|
||||
Section {
|
||||
|
||||
@@ -9,18 +9,6 @@
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
enum GroupProfileAlert: Identifiable {
|
||||
case saveError(err: String)
|
||||
case invalidName(validName: String)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .saveError(err): return "saveError \(err)"
|
||||
case let .invalidName(validName): return "invalidName \(validName)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GroupProfileView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@@ -30,7 +18,8 @@ struct GroupProfileView: View {
|
||||
@State private var showImagePicker = false
|
||||
@State private var showTakePhoto = false
|
||||
@State private var chosenImage: UIImage? = nil
|
||||
@State private var alert: GroupProfileAlert?
|
||||
@State private var showSaveErrorAlert = false
|
||||
@State private var saveGroupError: String? = nil
|
||||
@FocusState private var focusDisplayName
|
||||
|
||||
var body: some View {
|
||||
@@ -58,29 +47,20 @@ struct GroupProfileView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if !validNewProfileName() {
|
||||
Button {
|
||||
alert = .invalidName(validName: mkValidName(groupProfile.displayName))
|
||||
} label: {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.clear)
|
||||
ZStack(alignment: .leading) {
|
||||
if !validDisplayName(groupProfile.displayName) {
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.foregroundColor(.red)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
profileNameTextEdit("Group display name", $groupProfile.displayName)
|
||||
.focused($focusDisplayName)
|
||||
}
|
||||
.padding(.bottom)
|
||||
let fullName = groupInfo.groupProfile.fullName
|
||||
if fullName != "" && fullName != groupProfile.displayName {
|
||||
profileNameTextEdit("Group full name (optional)", $groupProfile.fullName)
|
||||
.padding(.bottom)
|
||||
}
|
||||
profileNameTextEdit("Group full name (optional)", $groupProfile.fullName)
|
||||
HStack(spacing: 20) {
|
||||
Button("Cancel") { dismiss() }
|
||||
Button("Save group profile") { saveProfile() }
|
||||
.disabled(!canUpdateProfile())
|
||||
.disabled(groupProfile.displayName == "" || !validDisplayName(groupProfile.displayName))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 120, alignment: .leading)
|
||||
@@ -119,39 +99,27 @@ struct GroupProfileView: View {
|
||||
focusDisplayName = true
|
||||
}
|
||||
}
|
||||
.alert(item: $alert) { a in
|
||||
switch a {
|
||||
case let .saveError(err):
|
||||
return Alert(
|
||||
title: Text("Error saving group profile"),
|
||||
message: Text(err)
|
||||
)
|
||||
case let .invalidName(name):
|
||||
return createInvalidNameAlert(name, $groupProfile.displayName)
|
||||
}
|
||||
.alert(isPresented: $showSaveErrorAlert) {
|
||||
Alert(
|
||||
title: Text("Error saving group profile"),
|
||||
message: Text("\(saveGroupError ?? "Unexpected error")")
|
||||
)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { hideKeyboard() }
|
||||
}
|
||||
|
||||
private func canUpdateProfile() -> Bool {
|
||||
groupProfile.displayName.trimmingCharacters(in: .whitespaces) != "" && validNewProfileName()
|
||||
}
|
||||
|
||||
private func validNewProfileName() -> Bool {
|
||||
groupProfile.displayName == groupInfo.groupProfile.displayName
|
||||
|| validDisplayName(groupProfile.displayName.trimmingCharacters(in: .whitespaces))
|
||||
}
|
||||
|
||||
func profileNameTextEdit(_ label: LocalizedStringKey, _ name: Binding<String>) -> some View {
|
||||
TextField(label, text: name)
|
||||
.padding(.leading, 32)
|
||||
.textInputAutocapitalization(.never)
|
||||
.disableAutocorrection(true)
|
||||
.padding(.bottom)
|
||||
.padding(.leading, 28)
|
||||
}
|
||||
|
||||
func saveProfile() {
|
||||
Task {
|
||||
do {
|
||||
groupProfile.displayName = groupProfile.displayName.trimmingCharacters(in: .whitespaces)
|
||||
let gInfo = try await apiUpdateGroup(groupInfo.groupId, groupProfile)
|
||||
await MainActor.run {
|
||||
groupInfo = gInfo
|
||||
@@ -160,7 +128,8 @@ struct GroupProfileView: View {
|
||||
}
|
||||
} catch let error {
|
||||
let err = responseError(error)
|
||||
alert = .saveError(err: err)
|
||||
saveGroupError = err
|
||||
showSaveErrorAlert = true
|
||||
logger.error("GroupProfile apiUpdateGroup error: \(err)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,33 +32,19 @@ struct ChatListNavLink: View {
|
||||
@State private var showJoinGroupDialog = false
|
||||
@State private var showContactConnectionInfo = false
|
||||
@State private var showInvalidJSON = false
|
||||
@State private var showDeleteContactActionSheet = false
|
||||
@State private var inProgress = false
|
||||
@State private var progressByTimeout = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch chat.chatInfo {
|
||||
case let .direct(contact):
|
||||
contactNavLink(contact)
|
||||
case let .group(groupInfo):
|
||||
groupNavLink(groupInfo)
|
||||
case let .contactRequest(cReq):
|
||||
contactRequestNavLink(cReq)
|
||||
case let .contactConnection(cConn):
|
||||
contactConnectionNavLink(cConn)
|
||||
case let .invalidJSON(json):
|
||||
invalidJSONPreview(json)
|
||||
}
|
||||
}
|
||||
.onChange(of: inProgress) { inProgress in
|
||||
if inProgress {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
progressByTimeout = inProgress
|
||||
}
|
||||
} else {
|
||||
progressByTimeout = false
|
||||
}
|
||||
switch chat.chatInfo {
|
||||
case let .direct(contact):
|
||||
contactNavLink(contact)
|
||||
case let .group(groupInfo):
|
||||
groupNavLink(groupInfo)
|
||||
case let .contactRequest(cReq):
|
||||
contactRequestNavLink(cReq)
|
||||
case let .contactConnection(cConn):
|
||||
contactConnectionNavLink(cConn)
|
||||
case let .invalidJSON(json):
|
||||
invalidJSONPreview(json)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +52,7 @@ struct ChatListNavLink: View {
|
||||
NavLinkPlain(
|
||||
tag: chat.chatInfo.id,
|
||||
selection: $chatModel.chatId,
|
||||
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }
|
||||
label: { ChatPreviewView(chat: chat) }
|
||||
)
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
markReadButton()
|
||||
@@ -78,43 +64,23 @@ struct ChatListNavLink: View {
|
||||
clearChatButton()
|
||||
}
|
||||
Button {
|
||||
if contact.ready || !contact.active {
|
||||
showDeleteContactActionSheet = true
|
||||
} else {
|
||||
AlertManager.shared.showAlert(deletePendingContactAlert(chat, contact))
|
||||
}
|
||||
AlertManager.shared.showAlert(
|
||||
contact.ready
|
||||
? deleteContactAlert(chat.chatInfo)
|
||||
: deletePendingContactAlert(chat, contact)
|
||||
)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
.actionSheet(isPresented: $showDeleteContactActionSheet) {
|
||||
if contact.ready && contact.active {
|
||||
return ActionSheet(
|
||||
title: Text("Delete contact?\nThis cannot be undone!"),
|
||||
buttons: [
|
||||
.destructive(Text("Delete and notify contact")) { Task { await deleteChat(chat, notify: true) } },
|
||||
.destructive(Text("Delete")) { Task { await deleteChat(chat, notify: false) } },
|
||||
.cancel()
|
||||
]
|
||||
)
|
||||
} else {
|
||||
return ActionSheet(
|
||||
title: Text("Delete contact?\nThis cannot be undone!"),
|
||||
buttons: [
|
||||
.destructive(Text("Delete")) { Task { await deleteChat(chat) } },
|
||||
.cancel()
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func groupNavLink(_ groupInfo: GroupInfo) -> some View {
|
||||
switch (groupInfo.membership.memberStatus) {
|
||||
case .memInvited:
|
||||
ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout)
|
||||
ChatPreviewView(chat: chat)
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
joinGroupButton()
|
||||
@@ -125,16 +91,12 @@ struct ChatListNavLink: View {
|
||||
.onTapGesture { showJoinGroupDialog = true }
|
||||
.confirmationDialog("Group invitation", isPresented: $showJoinGroupDialog, titleVisibility: .visible) {
|
||||
Button(chat.chatInfo.incognito ? "Join incognito" : "Join group") {
|
||||
inProgress = true
|
||||
joinGroup(groupInfo.groupId) {
|
||||
await MainActor.run { inProgress = false }
|
||||
}
|
||||
joinGroup(groupInfo.groupId)
|
||||
}
|
||||
Button("Delete invitation", role: .destructive) { Task { await deleteChat(chat) } }
|
||||
}
|
||||
.disabled(inProgress)
|
||||
case .memAccepted:
|
||||
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
|
||||
ChatPreviewView(chat: chat)
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
.onTapGesture {
|
||||
AlertManager.shared.showAlert(groupInvitationAcceptedAlert())
|
||||
@@ -151,7 +113,7 @@ struct ChatListNavLink: View {
|
||||
NavLinkPlain(
|
||||
tag: chat.chatInfo.id,
|
||||
selection: $chatModel.chatId,
|
||||
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
|
||||
label: { ChatPreviewView(chat: chat) },
|
||||
disabled: !groupInfo.ready
|
||||
)
|
||||
.frame(height: rowHeights[dynamicTypeSize])
|
||||
@@ -176,10 +138,7 @@ struct ChatListNavLink: View {
|
||||
|
||||
private func joinGroupButton() -> some View {
|
||||
Button {
|
||||
inProgress = true
|
||||
joinGroup(chat.chatInfo.apiId) {
|
||||
await MainActor.run { inProgress = false }
|
||||
}
|
||||
joinGroup(chat.chatInfo.apiId)
|
||||
} label: {
|
||||
Label("Join", systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward")
|
||||
}
|
||||
@@ -310,6 +269,17 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteContactAlert(_ chatInfo: ChatInfo) -> Alert {
|
||||
Alert(
|
||||
title: Text("Delete contact?"),
|
||||
message: Text("Contact and all messages will be deleted - this cannot be undone!"),
|
||||
primaryButton: .destructive(Text("Delete")) {
|
||||
Task { await deleteChat(chat) }
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert {
|
||||
Alert(
|
||||
title: Text("Delete group?"),
|
||||
@@ -439,7 +409,7 @@ func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection,
|
||||
)
|
||||
}
|
||||
|
||||
func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) {
|
||||
func joinGroup(_ groupId: Int64) {
|
||||
Task {
|
||||
logger.debug("joinGroup")
|
||||
do {
|
||||
@@ -454,9 +424,7 @@ func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) {
|
||||
AlertManager.shared.showAlertMsg(title: "No group!", message: "This group no longer exists.")
|
||||
await deleteGroup()
|
||||
}
|
||||
await onComplete()
|
||||
} catch let error {
|
||||
await onComplete()
|
||||
let a = getErrorAlert(error, "Error joining group")
|
||||
AlertManager.shared.showAlertMsg(title: a.title, message: a.message)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ struct ChatListView: View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
NavStackCompat(
|
||||
isActive: Binding(
|
||||
get: { chatModel.chatId != nil },
|
||||
get: { ChatModel.shared.chatId != nil },
|
||||
set: { _ in }
|
||||
),
|
||||
destination: chatView
|
||||
|
||||
@@ -12,7 +12,6 @@ import SimpleXChat
|
||||
struct ChatPreviewView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@ObservedObject var chat: Chat
|
||||
@Binding var progressByTimeout: Bool
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
var darkGreen = Color(red: 0, green: 0.5, blue: 0)
|
||||
|
||||
@@ -58,26 +57,19 @@ struct ChatPreviewView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder private func chatPreviewImageOverlayIcon() -> some View {
|
||||
switch chat.chatInfo {
|
||||
case let .direct(contact):
|
||||
if !contact.active {
|
||||
inactiveIcon()
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
case let .group(groupInfo):
|
||||
if case let .group(groupInfo) = chat.chatInfo {
|
||||
switch (groupInfo.membership.memberStatus) {
|
||||
case .memLeft: inactiveIcon()
|
||||
case .memRemoved: inactiveIcon()
|
||||
case .memGroupDeleted: inactiveIcon()
|
||||
case .memLeft: groupInactiveIcon()
|
||||
case .memRemoved: groupInactiveIcon()
|
||||
case .memGroupDeleted: groupInactiveIcon()
|
||||
default: EmptyView()
|
||||
}
|
||||
default:
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func inactiveIcon() -> some View {
|
||||
@ViewBuilder private func groupInactiveIcon() -> some View {
|
||||
Image(systemName: "multiply.circle.fill")
|
||||
.foregroundColor(.secondary.opacity(0.65))
|
||||
.background(Circle().foregroundColor(Color(uiColor: .systemBackground)))
|
||||
@@ -88,6 +80,7 @@ struct ChatPreviewView: View {
|
||||
switch chat.chatInfo {
|
||||
case let .direct(contact):
|
||||
previewTitle(contact.verified == true ? verifiedIcon + t : t)
|
||||
.foregroundColor(chat.chatInfo.ready ? .primary : .secondary)
|
||||
case let .group(groupInfo):
|
||||
let v = previewTitle(t)
|
||||
switch (groupInfo.membership.memberStatus) {
|
||||
@@ -112,17 +105,14 @@ struct ChatPreviewView: View {
|
||||
|
||||
private func chatPreviewLayout(_ text: Text, draft: Bool = false) -> some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
let t = text
|
||||
text
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.padding(.leading, 8)
|
||||
.padding(.trailing, 36)
|
||||
if !showChatPreviews && !draft {
|
||||
t.privacySensitive(true).redacted(reason: .privacy)
|
||||
} else {
|
||||
t
|
||||
}
|
||||
.privacySensitive(!showChatPreviews && !draft)
|
||||
.redacted(reason: .privacy)
|
||||
let s = chat.chatStats
|
||||
if s.unreadCount > 0 || s.unreadChat {
|
||||
unreadCountText(s.unreadCount)
|
||||
@@ -193,7 +183,7 @@ struct ChatPreviewView: View {
|
||||
if !contact.ready {
|
||||
if contact.nextSendGrpInv {
|
||||
chatPreviewInfoText("send direct message")
|
||||
} else if contact.active {
|
||||
} else {
|
||||
chatPreviewInfoText("connecting…")
|
||||
}
|
||||
}
|
||||
@@ -238,26 +228,16 @@ struct ChatPreviewView: View {
|
||||
@ViewBuilder private func chatStatusImage() -> some View {
|
||||
switch chat.chatInfo {
|
||||
case let .direct(contact):
|
||||
if contact.active {
|
||||
switch (chatModel.contactNetworkStatus(contact)) {
|
||||
case .connected: incognitoIcon(chat.chatInfo.incognito)
|
||||
case .error:
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 17, height: 17)
|
||||
.foregroundColor(.secondary)
|
||||
default:
|
||||
ProgressView()
|
||||
}
|
||||
} else {
|
||||
incognitoIcon(chat.chatInfo.incognito)
|
||||
}
|
||||
case .group:
|
||||
if progressByTimeout {
|
||||
switch (chatModel.contactNetworkStatus(contact)) {
|
||||
case .connected: incognitoIcon(chat.chatInfo.incognito)
|
||||
case .error:
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 17, height: 17)
|
||||
.foregroundColor(.secondary)
|
||||
default:
|
||||
ProgressView()
|
||||
} else {
|
||||
incognitoIcon(chat.chatInfo.incognito)
|
||||
}
|
||||
default:
|
||||
incognitoIcon(chat.chatInfo.incognito)
|
||||
@@ -287,30 +267,30 @@ struct ChatPreviewView_Previews: PreviewProvider {
|
||||
ChatPreviewView(chat: Chat(
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItems: []
|
||||
), progressByTimeout: Binding.constant(false))
|
||||
))
|
||||
ChatPreviewView(chat: Chat(
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))]
|
||||
), progressByTimeout: Binding.constant(false))
|
||||
))
|
||||
ChatPreviewView(chat: Chat(
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))],
|
||||
chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0)
|
||||
), progressByTimeout: Binding.constant(false))
|
||||
))
|
||||
ChatPreviewView(chat: Chat(
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))]
|
||||
), progressByTimeout: Binding.constant(false))
|
||||
))
|
||||
ChatPreviewView(chat: Chat(
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))],
|
||||
chatStats: ChatStats(unreadCount: 3, minUnreadItemId: 0)
|
||||
), progressByTimeout: Binding.constant(false))
|
||||
))
|
||||
ChatPreviewView(chat: Chat(
|
||||
chatInfo: ChatInfo.sampleData.group,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "Lorem ipsum dolor sit amet, d. consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")],
|
||||
chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0)
|
||||
), progressByTimeout: Binding.constant(false))
|
||||
))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 78))
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ struct ContactConnectionInfo: View {
|
||||
|
||||
if contactConnection.initiated,
|
||||
let connReqInv = contactConnection.connReqInv {
|
||||
SimpleXLinkQRCode(uri: simplexChatLink(connReqInv))
|
||||
QRCode(uri: connReqInv)
|
||||
incognitoEnabled()
|
||||
shareLinkButton(connReqInv)
|
||||
oneTimeLinkLearnMoreButton()
|
||||
@@ -119,7 +119,7 @@ struct ContactConnectionInfo: View {
|
||||
if let conn = try await apiSetConnectionAlias(connId: contactConnection.pccConnId, localAlias: localAlias) {
|
||||
await MainActor.run {
|
||||
contactConnection = conn
|
||||
m.updateContactConnection(conn)
|
||||
ChatModel.shared.updateContactConnection(conn)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ struct VideoPlayerView: UIViewRepresentable {
|
||||
var timeObserver: Any? = nil
|
||||
|
||||
deinit {
|
||||
print("deinit coordinator of VideoPlayer")
|
||||
if let timeObserver = timeObserver {
|
||||
NotificationCenter.default.removeObserver(timeObserver)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ struct AddContactView: View {
|
||||
List {
|
||||
Section {
|
||||
if connReqInvitation != "" {
|
||||
SimpleXLinkQRCode(uri: connReqInvitation)
|
||||
QRCode(uri: connReqInvitation)
|
||||
} else {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
@@ -48,7 +48,7 @@ struct AddContactView: View {
|
||||
let conn = try await apiSetConnectionIncognito(connId: contactConn.pccConnId, incognito: incognito) {
|
||||
await MainActor.run {
|
||||
contactConnection = conn
|
||||
chatModel.updateContactConnection(conn)
|
||||
ChatModel.shared.updateContactConnection(conn)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -99,7 +99,7 @@ func sharedProfileInfo(_ incognito: Bool) -> Text {
|
||||
|
||||
func shareLinkButton(_ connReqInvitation: String) -> some View {
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(connReqInvitation)])
|
||||
showShareSheet(items: [connReqInvitation])
|
||||
} label: {
|
||||
settingsRow("square.and.arrow.up") {
|
||||
Text("Share 1-time link")
|
||||
|
||||
@@ -12,45 +12,27 @@ import SimpleXChat
|
||||
struct AddGroupView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
|
||||
@State private var chat: Chat?
|
||||
@State private var groupInfo: GroupInfo?
|
||||
@State private var profile = GroupProfile(displayName: "", fullName: "")
|
||||
@FocusState private var focusDisplayName
|
||||
@FocusState private var focusFullName
|
||||
@State private var showChooseSource = false
|
||||
@State private var showImagePicker = false
|
||||
@State private var showTakePhoto = false
|
||||
@State private var chosenImage: UIImage? = nil
|
||||
@State private var showInvalidNameAlert = false
|
||||
@State private var groupLink: String?
|
||||
@State private var groupLinkMemberRole: GroupMemberRole = .member
|
||||
|
||||
var body: some View {
|
||||
if let chat = chat, let groupInfo = groupInfo {
|
||||
if !groupInfo.membership.memberIncognito {
|
||||
AddGroupMembersViewCommon(
|
||||
chat: chat,
|
||||
groupInfo: groupInfo,
|
||||
creatingGroup: true,
|
||||
showFooterCounter: false
|
||||
) { _ in
|
||||
dismiss()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
m.chatId = groupInfo.id
|
||||
}
|
||||
}
|
||||
} else {
|
||||
GroupLinkView(
|
||||
groupId: groupInfo.groupId,
|
||||
groupLink: $groupLink,
|
||||
groupLinkMemberRole: $groupLinkMemberRole,
|
||||
showTitle: true,
|
||||
creatingGroup: true
|
||||
) {
|
||||
dismiss()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
m.chatId = groupInfo.id
|
||||
}
|
||||
AddGroupMembersViewCommon(
|
||||
chat: chat,
|
||||
groupInfo: groupInfo,
|
||||
creatingGroup: true,
|
||||
showFooterCounter: false
|
||||
) { _ in
|
||||
dismiss()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
m.chatId = groupInfo.id
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -59,62 +41,79 @@ struct AddGroupView: View {
|
||||
}
|
||||
|
||||
func createGroupView() -> some View {
|
||||
List {
|
||||
Group {
|
||||
Text("Create secret group")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.bottom, 24)
|
||||
.onTapGesture(perform: hideKeyboard)
|
||||
VStack(alignment: .leading) {
|
||||
Text("Create secret group")
|
||||
.font(.largeTitle)
|
||||
.padding(.vertical, 4)
|
||||
Text("The group is fully decentralized – it is visible only to the members.")
|
||||
.padding(.bottom, 4)
|
||||
|
||||
ZStack(alignment: .center) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
ProfileImage(imageStr: profile.image, color: Color(uiColor: .secondarySystemGroupedBackground))
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.frame(maxWidth: 128, maxHeight: 128)
|
||||
if profile.image != nil {
|
||||
Button {
|
||||
profile.image = nil
|
||||
} label: {
|
||||
Image(systemName: "multiply")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 12)
|
||||
}
|
||||
HStack {
|
||||
Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote)
|
||||
Spacer().frame(width: 8)
|
||||
Text("Your chat profile will be sent to group members").font(.footnote)
|
||||
}
|
||||
.padding(.bottom)
|
||||
|
||||
ZStack(alignment: .center) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
profileImageView(profile.image)
|
||||
if profile.image != nil {
|
||||
Button {
|
||||
profile.image = nil
|
||||
} label: {
|
||||
Image(systemName: "multiply")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
editImageButton { showChooseSource = true }
|
||||
.buttonStyle(BorderlessButtonStyle()) // otherwise whole "list row" is clickable
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
editImageButton { showChooseSource = true }
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
Section {
|
||||
groupNameTextField()
|
||||
Button(action: createGroup) {
|
||||
settingsRow("checkmark", color: .accentColor) { Text("Create group") }
|
||||
ZStack(alignment: .topLeading) {
|
||||
if !validDisplayName(profile.displayName) {
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.foregroundColor(.red)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.disabled(!canCreateProfile())
|
||||
IncognitoToggle(incognitoEnabled: $incognitoDefault)
|
||||
} footer: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
sharedGroupProfileInfo(incognitoDefault)
|
||||
Text("Fully decentralized – visible only to members.")
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.onTapGesture(perform: hideKeyboard)
|
||||
textField("Group display name", text: $profile.displayName)
|
||||
.focused($focusDisplayName)
|
||||
.submitLabel(.next)
|
||||
.onSubmit {
|
||||
if canCreateProfile() { focusFullName = true }
|
||||
else { focusDisplayName = true }
|
||||
}
|
||||
}
|
||||
textField("Group full name (optional)", text: $profile.fullName)
|
||||
.focused($focusFullName)
|
||||
.submitLabel(.go)
|
||||
.onSubmit {
|
||||
if canCreateProfile() { createGroup() }
|
||||
else { focusFullName = true }
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
createGroup()
|
||||
} label: {
|
||||
Text("Create")
|
||||
Image(systemName: "greaterthan")
|
||||
}
|
||||
.disabled(!canCreateProfile())
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.onAppear() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
focusDisplayName = true
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.confirmationDialog("Group image", isPresented: $showChooseSource, titleVisibility: .visible) {
|
||||
Button("Take picture") {
|
||||
showTakePhoto = true
|
||||
@@ -134,9 +133,6 @@ struct AddGroupView: View {
|
||||
didSelectItem in showImagePicker = false
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $showInvalidNameAlert) {
|
||||
createInvalidNameAlert(mkValidName(profile.displayName), $profile.displayName)
|
||||
}
|
||||
.onChange(of: chosenImage) { image in
|
||||
if let image = image {
|
||||
profile.image = resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
|
||||
@@ -144,52 +140,26 @@ struct AddGroupView: View {
|
||||
profile.image = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func groupNameTextField() -> some View {
|
||||
ZStack(alignment: .leading) {
|
||||
let name = profile.displayName.trimmingCharacters(in: .whitespaces)
|
||||
if name != mkValidName(name) {
|
||||
Button {
|
||||
showInvalidNameAlert = true
|
||||
} label: {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "pencil").foregroundColor(.secondary)
|
||||
}
|
||||
textField("Enter group name…", text: $profile.displayName)
|
||||
.focused($focusDisplayName)
|
||||
.submitLabel(.continue)
|
||||
.onSubmit {
|
||||
if canCreateProfile() { createGroup() }
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { hideKeyboard() }
|
||||
}
|
||||
|
||||
func textField(_ placeholder: LocalizedStringKey, text: Binding<String>) -> some View {
|
||||
TextField(placeholder, text: text)
|
||||
.padding(.leading, 36)
|
||||
}
|
||||
|
||||
func sharedGroupProfileInfo(_ incognito: Bool) -> Text {
|
||||
let name = ChatModel.shared.currentUser?.displayName ?? ""
|
||||
return Text(
|
||||
incognito
|
||||
? "A new random profile will be shared."
|
||||
: "Your profile **\(name)** will be shared."
|
||||
)
|
||||
.textInputAutocapitalization(.never)
|
||||
.disableAutocorrection(true)
|
||||
.padding(.leading, 28)
|
||||
.padding(.bottom)
|
||||
}
|
||||
|
||||
func createGroup() {
|
||||
hideKeyboard()
|
||||
do {
|
||||
profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces)
|
||||
let gInfo = try apiNewGroup(incognito: incognitoDefault, groupProfile: profile)
|
||||
let gInfo = try apiNewGroup(profile)
|
||||
Task {
|
||||
let groupMembers = await apiListMembers(gInfo.groupId)
|
||||
await MainActor.run {
|
||||
m.groupMembers = groupMembers.map { GMember.init($0) }
|
||||
ChatModel.shared.groupMembers = groupMembers
|
||||
}
|
||||
}
|
||||
let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: [])
|
||||
@@ -210,8 +180,7 @@ struct AddGroupView: View {
|
||||
}
|
||||
|
||||
func canCreateProfile() -> Bool {
|
||||
let name = profile.displayName.trimmingCharacters(in: .whitespaces)
|
||||
return name != "" && validDisplayName(name)
|
||||
profile.displayName != "" && validDisplayName(profile.displayName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,369 +58,65 @@ struct NewChatButton: View {
|
||||
}
|
||||
}
|
||||
|
||||
enum PlanAndConnectAlert: Identifiable {
|
||||
case ownInvitationLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
|
||||
case invitationLinkConnecting(connectionLink: String)
|
||||
case ownContactAddressConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
|
||||
case contactAddressConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
|
||||
case groupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
|
||||
case groupLinkConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
|
||||
case groupLinkConnecting(connectionLink: String, groupInfo: GroupInfo?)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .ownInvitationLinkConfirmConnect(connectionLink, _, _): return "ownInvitationLinkConfirmConnect \(connectionLink)"
|
||||
case let .invitationLinkConnecting(connectionLink): return "invitationLinkConnecting \(connectionLink)"
|
||||
case let .ownContactAddressConfirmConnect(connectionLink, _, _): return "ownContactAddressConfirmConnect \(connectionLink)"
|
||||
case let .contactAddressConnectingConfirmReconnect(connectionLink, _, _): return "contactAddressConnectingConfirmReconnect \(connectionLink)"
|
||||
case let .groupLinkConfirmConnect(connectionLink, _, _): return "groupLinkConfirmConnect \(connectionLink)"
|
||||
case let .groupLinkConnectingConfirmReconnect(connectionLink, _, _): return "groupLinkConnectingConfirmReconnect \(connectionLink)"
|
||||
case let .groupLinkConnecting(connectionLink, _): return "groupLinkConnecting \(connectionLink)"
|
||||
}
|
||||
}
|
||||
enum ConnReqType: Equatable {
|
||||
case contact
|
||||
case invitation
|
||||
}
|
||||
|
||||
func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool) -> Alert {
|
||||
switch alert {
|
||||
case let .ownInvitationLinkConfirmConnect(connectionLink, connectionPlan, incognito):
|
||||
return Alert(
|
||||
title: Text("Connect to yourself?"),
|
||||
message: Text("This is your own one-time link!"),
|
||||
primaryButton: .destructive(
|
||||
Text(incognito ? "Connect incognito" : "Connect"),
|
||||
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }
|
||||
),
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
case .invitationLinkConnecting:
|
||||
return Alert(
|
||||
title: Text("Already connecting!"),
|
||||
message: Text("You are already connecting via this one-time link!")
|
||||
)
|
||||
case let .ownContactAddressConfirmConnect(connectionLink, connectionPlan, incognito):
|
||||
return Alert(
|
||||
title: Text("Connect to yourself?"),
|
||||
message: Text("This is your own SimpleX address!"),
|
||||
primaryButton: .destructive(
|
||||
Text(incognito ? "Connect incognito" : "Connect"),
|
||||
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }
|
||||
),
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
case let .contactAddressConnectingConfirmReconnect(connectionLink, connectionPlan, incognito):
|
||||
return Alert(
|
||||
title: Text("Repeat connection request?"),
|
||||
message: Text("You have already requested connection via this address!"),
|
||||
primaryButton: .destructive(
|
||||
Text(incognito ? "Connect incognito" : "Connect"),
|
||||
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }
|
||||
),
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
case let .groupLinkConfirmConnect(connectionLink, connectionPlan, incognito):
|
||||
return Alert(
|
||||
title: Text("Join group?"),
|
||||
message: Text("You will connect to all group members."),
|
||||
primaryButton: .default(
|
||||
Text(incognito ? "Join incognito" : "Join"),
|
||||
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }
|
||||
),
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
case let .groupLinkConnectingConfirmReconnect(connectionLink, connectionPlan, incognito):
|
||||
return Alert(
|
||||
title: Text("Repeat join request?"),
|
||||
message: Text("You are already joining the group via this link!"),
|
||||
primaryButton: .destructive(
|
||||
Text(incognito ? "Join incognito" : "Join"),
|
||||
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }
|
||||
),
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
case let .groupLinkConnecting(_, groupInfo):
|
||||
if let groupInfo = groupInfo {
|
||||
return Alert(
|
||||
title: Text("Group already exists!"),
|
||||
message: Text("You are already joining the group \(groupInfo.displayName).")
|
||||
)
|
||||
} else {
|
||||
return Alert(
|
||||
title: Text("Already joining the group!"),
|
||||
message: Text("You are already joining the group via this link.")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PlanAndConnectActionSheet: Identifiable {
|
||||
case askCurrentOrIncognitoProfile(connectionLink: String, connectionPlan: ConnectionPlan?, title: LocalizedStringKey)
|
||||
case askCurrentOrIncognitoProfileDestructive(connectionLink: String, connectionPlan: ConnectionPlan, title: LocalizedStringKey)
|
||||
case ownGroupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool?, groupInfo: GroupInfo)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .askCurrentOrIncognitoProfile(connectionLink, _, _): return "askCurrentOrIncognitoProfile \(connectionLink)"
|
||||
case let .askCurrentOrIncognitoProfileDestructive(connectionLink, _, _): return "askCurrentOrIncognitoProfileDestructive \(connectionLink)"
|
||||
case let .ownGroupLinkConfirmConnect(connectionLink, _, _, _): return "ownGroupLinkConfirmConnect \(connectionLink)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func planAndConnectActionSheet(_ sheet: PlanAndConnectActionSheet, dismiss: Bool) -> ActionSheet {
|
||||
switch sheet {
|
||||
case let .askCurrentOrIncognitoProfile(connectionLink, connectionPlan, title):
|
||||
return ActionSheet(
|
||||
title: Text(title),
|
||||
buttons: [
|
||||
.default(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) },
|
||||
.default(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) },
|
||||
.cancel()
|
||||
]
|
||||
)
|
||||
case let .askCurrentOrIncognitoProfileDestructive(connectionLink, connectionPlan, title):
|
||||
return ActionSheet(
|
||||
title: Text(title),
|
||||
buttons: [
|
||||
.destructive(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) },
|
||||
.destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) },
|
||||
.cancel()
|
||||
]
|
||||
)
|
||||
case let .ownGroupLinkConfirmConnect(connectionLink, connectionPlan, incognito, groupInfo):
|
||||
if let incognito = incognito {
|
||||
return ActionSheet(
|
||||
title: Text("Join your group?\nThis is your link for group \(groupInfo.displayName)!"),
|
||||
buttons: [
|
||||
.default(Text("Open group")) { openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) },
|
||||
.destructive(Text(incognito ? "Join incognito" : "Join with current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) },
|
||||
.cancel()
|
||||
]
|
||||
)
|
||||
} else {
|
||||
return ActionSheet(
|
||||
title: Text("Join your group?\nThis is your link for group \(groupInfo.displayName)!"),
|
||||
buttons: [
|
||||
.default(Text("Open group")) { openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) },
|
||||
.destructive(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) },
|
||||
.destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) },
|
||||
.cancel()
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func planAndConnect(
|
||||
_ connectionLink: String,
|
||||
showAlert: @escaping (PlanAndConnectAlert) -> Void,
|
||||
showActionSheet: @escaping (PlanAndConnectActionSheet) -> Void,
|
||||
dismiss: Bool,
|
||||
incognito: Bool?
|
||||
) {
|
||||
Task {
|
||||
do {
|
||||
let connectionPlan = try await apiConnectPlan(connReq: connectionLink)
|
||||
switch connectionPlan {
|
||||
case let .invitationLink(ilp):
|
||||
switch ilp {
|
||||
case .ok:
|
||||
logger.debug("planAndConnect, .invitationLink, .ok, incognito=\(incognito?.description ?? "nil")")
|
||||
if let incognito = incognito {
|
||||
connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito)
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via one-time link"))
|
||||
}
|
||||
case .ownLink:
|
||||
logger.debug("planAndConnect, .invitationLink, .ownLink, incognito=\(incognito?.description ?? "nil")")
|
||||
if let incognito = incognito {
|
||||
showAlert(.ownInvitationLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own one-time link!"))
|
||||
}
|
||||
case let .connecting(contact_):
|
||||
logger.debug("planAndConnect, .invitationLink, .connecting, incognito=\(incognito?.description ?? "nil")")
|
||||
if let contact = contact_ {
|
||||
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) }
|
||||
} else {
|
||||
showAlert(.invitationLinkConnecting(connectionLink: connectionLink))
|
||||
}
|
||||
case let .known(contact):
|
||||
logger.debug("planAndConnect, .invitationLink, .known, incognito=\(incognito?.description ?? "nil")")
|
||||
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) }
|
||||
}
|
||||
case let .contactAddress(cap):
|
||||
switch cap {
|
||||
case .ok:
|
||||
logger.debug("planAndConnect, .contactAddress, .ok, incognito=\(incognito?.description ?? "nil")")
|
||||
if let incognito = incognito {
|
||||
connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito)
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via contact address"))
|
||||
}
|
||||
case .ownLink:
|
||||
logger.debug("planAndConnect, .contactAddress, .ownLink, incognito=\(incognito?.description ?? "nil")")
|
||||
if let incognito = incognito {
|
||||
showAlert(.ownContactAddressConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own SimpleX address!"))
|
||||
}
|
||||
case .connectingConfirmReconnect:
|
||||
logger.debug("planAndConnect, .contactAddress, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")")
|
||||
if let incognito = incognito {
|
||||
showAlert(.contactAddressConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You have already requested connection!\nRepeat connection request?"))
|
||||
}
|
||||
case let .connectingProhibit(contact):
|
||||
logger.debug("planAndConnect, .contactAddress, .connectingProhibit, incognito=\(incognito?.description ?? "nil")")
|
||||
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) }
|
||||
case let .known(contact):
|
||||
logger.debug("planAndConnect, .contactAddress, .known, incognito=\(incognito?.description ?? "nil")")
|
||||
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) }
|
||||
}
|
||||
case let .groupLink(glp):
|
||||
switch glp {
|
||||
case .ok:
|
||||
if let incognito = incognito {
|
||||
showAlert(.groupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Join group"))
|
||||
}
|
||||
case let .ownLink(groupInfo):
|
||||
logger.debug("planAndConnect, .groupLink, .ownLink, incognito=\(incognito?.description ?? "nil")")
|
||||
showActionSheet(.ownGroupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito, groupInfo: groupInfo))
|
||||
case .connectingConfirmReconnect:
|
||||
logger.debug("planAndConnect, .groupLink, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")")
|
||||
if let incognito = incognito {
|
||||
showAlert(.groupLinkConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You are already joining the group!\nRepeat join request?"))
|
||||
}
|
||||
case let .connectingProhibit(groupInfo_):
|
||||
logger.debug("planAndConnect, .groupLink, .connectingProhibit, incognito=\(incognito?.description ?? "nil")")
|
||||
showAlert(.groupLinkConnecting(connectionLink: connectionLink, groupInfo: groupInfo_))
|
||||
case let .known(groupInfo):
|
||||
logger.debug("planAndConnect, .groupLink, .known, incognito=\(incognito?.description ?? "nil")")
|
||||
openKnownGroup(groupInfo, dismiss: dismiss) { AlertManager.shared.showAlert(groupAlreadyExistsAlert(groupInfo)) }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.debug("planAndConnect, plan error")
|
||||
if let incognito = incognito {
|
||||
connectViaLink(connectionLink, connectionPlan: nil, dismiss: dismiss, incognito: incognito)
|
||||
} else {
|
||||
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: nil, title: "Connect via link"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func connectViaLink(_ connectionLink: String, connectionPlan: ConnectionPlan?, dismiss: Bool, incognito: Bool) {
|
||||
func connectViaLink(_ connectionLink: String, dismiss: DismissAction? = nil, incognito: Bool) {
|
||||
Task {
|
||||
if let connReqType = await apiConnect(incognito: incognito, connReq: connectionLink) {
|
||||
let crt: ConnReqType
|
||||
if let plan = connectionPlan {
|
||||
crt = planToConnReqType(plan)
|
||||
} else {
|
||||
crt = connReqType
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
if dismiss {
|
||||
dismissAllSheets(animated: true) {
|
||||
AlertManager.shared.showAlert(connReqSentAlert(crt))
|
||||
}
|
||||
} else {
|
||||
AlertManager.shared.showAlert(connReqSentAlert(crt))
|
||||
}
|
||||
dismiss?()
|
||||
AlertManager.shared.showAlert(connReqSentAlert(connReqType))
|
||||
}
|
||||
} else {
|
||||
if dismiss {
|
||||
DispatchQueue.main.async {
|
||||
dismissAllSheets(animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func openKnownContact(_ contact: Contact, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) {
|
||||
Task {
|
||||
let m = ChatModel.shared
|
||||
if let c = m.getContactChat(contact.contactId) {
|
||||
DispatchQueue.main.async {
|
||||
if dismiss {
|
||||
dismissAllSheets(animated: true) {
|
||||
m.chatId = c.id
|
||||
showAlreadyExistsAlert?()
|
||||
}
|
||||
} else {
|
||||
m.chatId = c.id
|
||||
showAlreadyExistsAlert?()
|
||||
}
|
||||
dismiss?()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) {
|
||||
Task {
|
||||
let m = ChatModel.shared
|
||||
if let g = m.getGroupChat(groupInfo.groupId) {
|
||||
DispatchQueue.main.async {
|
||||
if dismiss {
|
||||
dismissAllSheets(animated: true) {
|
||||
m.chatId = g.id
|
||||
showAlreadyExistsAlert?()
|
||||
}
|
||||
} else {
|
||||
m.chatId = g.id
|
||||
showAlreadyExistsAlert?()
|
||||
}
|
||||
}
|
||||
}
|
||||
struct CReqClientData: Decodable {
|
||||
var type: String
|
||||
var groupLinkId: String?
|
||||
}
|
||||
|
||||
func parseLinkQueryData(_ connectionLink: String) -> CReqClientData? {
|
||||
if let hashIndex = connectionLink.firstIndex(of: "#"),
|
||||
let urlQuery = URL(string: String(connectionLink[connectionLink.index(after: hashIndex)...])),
|
||||
let components = URLComponents(url: urlQuery, resolvingAgainstBaseURL: false),
|
||||
let data = components.queryItems?.first(where: { $0.name == "data" })?.value,
|
||||
let d = data.data(using: .utf8),
|
||||
let crData = try? getJSONDecoder().decode(CReqClientData.self, from: d) {
|
||||
return crData
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func contactAlreadyConnectingAlert(_ contact: Contact) -> Alert {
|
||||
mkAlert(
|
||||
title: "Contact already exists",
|
||||
message: "You are already connecting to \(contact.displayName)."
|
||||
func checkCRDataGroup(_ crData: CReqClientData) -> Bool {
|
||||
return crData.type == "group" && crData.groupLinkId != nil
|
||||
}
|
||||
|
||||
func groupLinkAlert(_ connectionLink: String, incognito: Bool) -> Alert {
|
||||
return Alert(
|
||||
title: Text("Connect via group link?"),
|
||||
message: Text("You will join a group this link refers to and connect to its group members."),
|
||||
primaryButton: .default(Text(incognito ? "Connect incognito" : "Connect")) {
|
||||
connectViaLink(connectionLink, incognito: incognito)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
func groupAlreadyExistsAlert(_ groupInfo: GroupInfo) -> Alert {
|
||||
mkAlert(
|
||||
title: "Group already exists",
|
||||
message: "You are already in group \(groupInfo.displayName)."
|
||||
)
|
||||
}
|
||||
|
||||
enum ConnReqType: Equatable {
|
||||
case invitation
|
||||
case contact
|
||||
case groupLink
|
||||
|
||||
var connReqSentText: LocalizedStringKey {
|
||||
switch self {
|
||||
case .invitation: return "You will be connected when your contact's device is online, please wait or check later!"
|
||||
case .contact: return "You will be connected when your connection request is accepted, please wait or check later!"
|
||||
case .groupLink: return "You will be connected when group link host's device is online, please wait or check later!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func planToConnReqType(_ connectionPlan: ConnectionPlan) -> ConnReqType {
|
||||
switch connectionPlan {
|
||||
case .invitationLink: return .invitation
|
||||
case .contactAddress: return .contact
|
||||
case .groupLink: return .groupLink
|
||||
}
|
||||
}
|
||||
|
||||
func connReqSentAlert(_ type: ConnReqType) -> Alert {
|
||||
return mkAlert(
|
||||
title: "Connection request sent!",
|
||||
message: type.connReqSentText
|
||||
message: type == .contact
|
||||
? "You will be connected when your connection request is accepted, please wait or check later!"
|
||||
: "You will be connected when your contact's device is online, please wait or check later!"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ struct PasteToConnectView: View {
|
||||
@State private var connectionLink: String = ""
|
||||
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
|
||||
@FocusState private var linkEditorFocused: Bool
|
||||
@State private var alert: PlanAndConnectAlert?
|
||||
@State private var sheet: PlanAndConnectActionSheet?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@@ -54,15 +52,11 @@ struct PasteToConnectView: View {
|
||||
|
||||
IncognitoToggle(incognitoEnabled: $incognitoDefault)
|
||||
} footer: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
sharedProfileInfo(incognitoDefault)
|
||||
Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.")
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
sharedProfileInfo(incognitoDefault)
|
||||
+ Text(String("\n\n"))
|
||||
+ Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.")
|
||||
}
|
||||
}
|
||||
.alert(item: $alert) { a in planAndConnectAlert(a, dismiss: true) }
|
||||
.actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
|
||||
}
|
||||
|
||||
private func linkEditor() -> some View {
|
||||
@@ -89,13 +83,13 @@ struct PasteToConnectView: View {
|
||||
|
||||
private func connect() {
|
||||
let link = connectionLink.trimmingCharacters(in: .whitespaces)
|
||||
planAndConnect(
|
||||
link,
|
||||
showAlert: { alert = $0 },
|
||||
showActionSheet: { sheet = $0 },
|
||||
dismiss: true,
|
||||
incognito: incognitoDefault
|
||||
)
|
||||
if let crData = parseLinkQueryData(link),
|
||||
checkCRDataGroup(crData) {
|
||||
dismiss()
|
||||
AlertManager.shared.showAlert(groupLinkAlert(link, incognito: incognitoDefault))
|
||||
} else {
|
||||
connectViaLink(link, dismiss: dismiss, incognito: incognitoDefault)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,22 +28,6 @@ struct MutableQRCode: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct SimpleXLinkQRCode: View {
|
||||
let uri: String
|
||||
var withLogo: Bool = true
|
||||
var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1)
|
||||
|
||||
var body: some View {
|
||||
QRCode(uri: simplexChatLink(uri), withLogo: withLogo, tintColor: tintColor)
|
||||
}
|
||||
}
|
||||
|
||||
func simplexChatLink(_ uri: String) -> String {
|
||||
uri.starts(with: "simplex:/")
|
||||
? uri.replacingOccurrences(of: "simplex:/", with: "https://simplex.chat/")
|
||||
: uri
|
||||
}
|
||||
|
||||
struct QRCode: View {
|
||||
let uri: String
|
||||
var withLogo: Bool = true
|
||||
|
||||
@@ -13,8 +13,6 @@ import CodeScanner
|
||||
struct ScanToConnectView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
|
||||
@State private var alert: PlanAndConnectAlert?
|
||||
@State private var sheet: PlanAndConnectActionSheet?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@@ -38,11 +36,11 @@ struct ScanToConnectView: View {
|
||||
)
|
||||
.padding(.top)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Group {
|
||||
sharedProfileInfo(incognitoDefault)
|
||||
Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.")
|
||||
+ Text(String("\n\n"))
|
||||
+ Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.")
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal)
|
||||
@@ -51,20 +49,18 @@ struct ScanToConnectView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.alert(item: $alert) { a in planAndConnectAlert(a, dismiss: true) }
|
||||
.actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
|
||||
}
|
||||
|
||||
func processQRCode(_ resp: Result<ScanResult, ScanError>) {
|
||||
switch resp {
|
||||
case let .success(r):
|
||||
planAndConnect(
|
||||
r.string,
|
||||
showAlert: { alert = $0 },
|
||||
showActionSheet: { sheet = $0 },
|
||||
dismiss: true,
|
||||
incognito: incognitoDefault
|
||||
)
|
||||
if let crData = parseLinkQueryData(r.string),
|
||||
checkCRDataGroup(crData) {
|
||||
dismiss()
|
||||
AlertManager.shared.showAlert(groupLinkAlert(r.string, incognito: incognitoDefault))
|
||||
} else {
|
||||
Task { connectViaLink(r.string, dismiss: dismiss, incognito: incognitoDefault) }
|
||||
}
|
||||
case let .failure(e):
|
||||
logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)")
|
||||
dismiss()
|
||||
|
||||
@@ -9,244 +9,175 @@
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
enum UserProfileAlert: Identifiable {
|
||||
case duplicateUserError
|
||||
case createUserError(error: LocalizedStringKey)
|
||||
case invalidNameError(validName: String)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .duplicateUserError: return "duplicateUserError"
|
||||
case .createUserError: return "createUserError"
|
||||
case let .invalidNameError(validName): return "invalidNameError \(validName)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CreateProfile: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var displayName: String = ""
|
||||
@FocusState private var focusDisplayName
|
||||
@State private var alert: UserProfileAlert?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
TextField("Enter your name…", text: $displayName)
|
||||
.focused($focusDisplayName)
|
||||
Button {
|
||||
createProfile(displayName, showAlert: { alert = $0 }, dismiss: dismiss)
|
||||
} label: {
|
||||
Label("Create profile", systemImage: "checkmark")
|
||||
}
|
||||
.disabled(!canCreateProfile(displayName))
|
||||
} header: {
|
||||
HStack {
|
||||
Text("Your profile")
|
||||
let name = displayName.trimmingCharacters(in: .whitespaces)
|
||||
let validName = mkValidName(name)
|
||||
if name != validName {
|
||||
Spacer()
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.foregroundColor(.red)
|
||||
.onTapGesture {
|
||||
alert = .invalidNameError(validName: validName)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 20)
|
||||
} footer: {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Your profile, contacts and delivered messages are stored on your device.")
|
||||
Text("The profile is only shared with your contacts.")
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Create your profile")
|
||||
.alert(item: $alert) { a in userProfileAlert(a, $displayName) }
|
||||
.onAppear() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
focusDisplayName = true
|
||||
}
|
||||
}
|
||||
.keyboardPadding()
|
||||
}
|
||||
}
|
||||
|
||||
struct CreateFirstProfile: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var displayName: String = ""
|
||||
@State private var fullName: String = ""
|
||||
@FocusState private var focusDisplayName
|
||||
@FocusState private var focusFullName
|
||||
@State private var alert: CreateProfileAlert?
|
||||
|
||||
private enum CreateProfileAlert: Identifiable {
|
||||
case duplicateUserError
|
||||
case createUserError(error: LocalizedStringKey)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .duplicateUserError: return "duplicateUserError"
|
||||
case .createUserError: return "createUserError"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Group {
|
||||
Text("Create your profile")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
Text("Your profile, contacts and delivered messages are stored on your device.")
|
||||
.foregroundColor(.secondary)
|
||||
Text("The profile is only shared with your contacts.")
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.bottom)
|
||||
}
|
||||
.padding(.bottom)
|
||||
|
||||
Text("Create your profile")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.bottom, 4)
|
||||
.frame(maxWidth: .infinity)
|
||||
Text("Your profile, contacts and delivered messages are stored on your device.")
|
||||
.padding(.bottom, 4)
|
||||
Text("The profile is only shared with your contacts.")
|
||||
.padding(.bottom)
|
||||
ZStack(alignment: .topLeading) {
|
||||
let name = displayName.trimmingCharacters(in: .whitespaces)
|
||||
let validName = mkValidName(name)
|
||||
if name != validName {
|
||||
Button {
|
||||
showAlert(.invalidNameError(validName: validName))
|
||||
} label: {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.clear)
|
||||
if !validDisplayName(displayName) {
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.foregroundColor(.red)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
TextField("Enter your name…", text: $displayName)
|
||||
textField("Display name", text: $displayName)
|
||||
.focused($focusDisplayName)
|
||||
.padding(.leading, 32)
|
||||
.submitLabel(.next)
|
||||
.onSubmit {
|
||||
if canCreateProfile() { focusFullName = true }
|
||||
else { focusDisplayName = true }
|
||||
}
|
||||
}
|
||||
.padding(.bottom)
|
||||
textField("Full name (optional)", text: $fullName)
|
||||
.focused($focusFullName)
|
||||
.submitLabel(.go)
|
||||
.onSubmit {
|
||||
if canCreateProfile() { createProfile() }
|
||||
else { focusFullName = true }
|
||||
}
|
||||
|
||||
Spacer()
|
||||
onboardingButtons()
|
||||
|
||||
HStack {
|
||||
if m.users.isEmpty {
|
||||
Button {
|
||||
hideKeyboard()
|
||||
withAnimation {
|
||||
m.onboardingStage = .step1_SimpleXInfo
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "lessthan")
|
||||
Text("About SimpleX")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack {
|
||||
Button {
|
||||
createProfile()
|
||||
} label: {
|
||||
Text("Create")
|
||||
Image(systemName: "greaterthan")
|
||||
}
|
||||
.disabled(!canCreateProfile())
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear() {
|
||||
focusDisplayName = true
|
||||
setLastVersionDefault()
|
||||
}
|
||||
.alert(item: $alert) { a in
|
||||
switch a {
|
||||
case .duplicateUserError: return duplicateUserAlert
|
||||
case let .createUserError(err): return creatUserErrorAlert(err)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.keyboardPadding()
|
||||
}
|
||||
|
||||
func onboardingButtons() -> some View {
|
||||
HStack {
|
||||
Button {
|
||||
hideKeyboard()
|
||||
func textField(_ placeholder: LocalizedStringKey, text: Binding<String>) -> some View {
|
||||
TextField(placeholder, text: text)
|
||||
.textInputAutocapitalization(.never)
|
||||
.disableAutocorrection(true)
|
||||
.padding(.leading, 28)
|
||||
.padding(.bottom)
|
||||
}
|
||||
|
||||
func createProfile() {
|
||||
hideKeyboard()
|
||||
let profile = Profile(
|
||||
displayName: displayName,
|
||||
fullName: fullName
|
||||
)
|
||||
do {
|
||||
m.currentUser = try apiCreateActiveUser(profile)
|
||||
if m.users.isEmpty {
|
||||
try startChat()
|
||||
withAnimation {
|
||||
m.onboardingStage = .step1_SimpleXInfo
|
||||
onboardingStageDefault.set(.step3_CreateSimpleXAddress)
|
||||
m.onboardingStage = .step3_CreateSimpleXAddress
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "lessthan")
|
||||
Text("About SimpleX")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
createProfile(displayName, showAlert: showAlert, dismiss: dismiss)
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Create")
|
||||
Image(systemName: "greaterthan")
|
||||
}
|
||||
}
|
||||
.disabled(!canCreateProfile(displayName))
|
||||
}
|
||||
}
|
||||
|
||||
private func showAlert(_ alert: UserProfileAlert) {
|
||||
AlertManager.shared.showAlert(userProfileAlert(alert, $displayName))
|
||||
}
|
||||
}
|
||||
|
||||
private func createProfile(_ displayName: String, showAlert: (UserProfileAlert) -> Void, dismiss: DismissAction) {
|
||||
hideKeyboard()
|
||||
let profile = Profile(
|
||||
displayName: displayName.trimmingCharacters(in: .whitespaces),
|
||||
fullName: ""
|
||||
)
|
||||
let m = ChatModel.shared
|
||||
do {
|
||||
m.currentUser = try apiCreateActiveUser(profile)
|
||||
if m.users.isEmpty {
|
||||
try startChat()
|
||||
withAnimation {
|
||||
onboardingStageDefault.set(.step3_CreateSimpleXAddress)
|
||||
m.onboardingStage = .step3_CreateSimpleXAddress
|
||||
}
|
||||
} else {
|
||||
onboardingStageDefault.set(.onboardingComplete)
|
||||
m.onboardingStage = .onboardingComplete
|
||||
dismiss()
|
||||
m.users = try listUsers()
|
||||
try getUserChatData()
|
||||
}
|
||||
} catch let error {
|
||||
switch error as? ChatResponse {
|
||||
case .chatCmdError(_, .errorStore(.duplicateName)),
|
||||
.chatCmdError(_, .error(.userExists)):
|
||||
if m.currentUser == nil {
|
||||
AlertManager.shared.showAlert(duplicateUserAlert)
|
||||
} else {
|
||||
showAlert(.duplicateUserError)
|
||||
onboardingStageDefault.set(.onboardingComplete)
|
||||
m.onboardingStage = .onboardingComplete
|
||||
dismiss()
|
||||
m.users = try listUsers()
|
||||
try getUserChatData()
|
||||
}
|
||||
default:
|
||||
let err: LocalizedStringKey = "Error: \(responseError(error))"
|
||||
if m.currentUser == nil {
|
||||
AlertManager.shared.showAlert(creatUserErrorAlert(err))
|
||||
} else {
|
||||
showAlert(.createUserError(error: err))
|
||||
} catch let error {
|
||||
switch error as? ChatResponse {
|
||||
case .chatCmdError(_, .errorStore(.duplicateName)),
|
||||
.chatCmdError(_, .error(.userExists)):
|
||||
if m.currentUser == nil {
|
||||
AlertManager.shared.showAlert(duplicateUserAlert)
|
||||
} else {
|
||||
alert = .duplicateUserError
|
||||
}
|
||||
default:
|
||||
let err: LocalizedStringKey = "Error: \(responseError(error))"
|
||||
if m.currentUser == nil {
|
||||
AlertManager.shared.showAlert(creatUserErrorAlert(err))
|
||||
} else {
|
||||
alert = .createUserError(error: err)
|
||||
}
|
||||
}
|
||||
logger.error("Failed to create user or start chat: \(responseError(error))")
|
||||
}
|
||||
logger.error("Failed to create user or start chat: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
|
||||
private func canCreateProfile(_ displayName: String) -> Bool {
|
||||
let name = displayName.trimmingCharacters(in: .whitespaces)
|
||||
return name != "" && mkValidName(name) == name
|
||||
}
|
||||
|
||||
func userProfileAlert(_ alert: UserProfileAlert, _ displayName: Binding<String>) -> Alert {
|
||||
switch alert {
|
||||
case .duplicateUserError: return duplicateUserAlert
|
||||
case let .createUserError(err): return creatUserErrorAlert(err)
|
||||
case let .invalidNameError(name): return createInvalidNameAlert(name, displayName)
|
||||
func canCreateProfile() -> Bool {
|
||||
displayName != "" && validDisplayName(displayName)
|
||||
}
|
||||
}
|
||||
|
||||
private var duplicateUserAlert: Alert {
|
||||
Alert(
|
||||
title: Text("Duplicate display name!"),
|
||||
message: Text("You already have a chat profile with the same display name. Please choose another name.")
|
||||
)
|
||||
}
|
||||
private var duplicateUserAlert: Alert {
|
||||
Alert(
|
||||
title: Text("Duplicate display name!"),
|
||||
message: Text("You already have a chat profile with the same display name. Please choose another name.")
|
||||
)
|
||||
}
|
||||
|
||||
private func creatUserErrorAlert(_ err: LocalizedStringKey) -> Alert {
|
||||
Alert(
|
||||
title: Text("Error creating profile!"),
|
||||
message: Text(err)
|
||||
)
|
||||
}
|
||||
|
||||
func createInvalidNameAlert(_ name: String, _ displayName: Binding<String>) -> Alert {
|
||||
name == ""
|
||||
? Alert(title: Text("Invalid name!"))
|
||||
: Alert(
|
||||
title: Text("Invalid name!"),
|
||||
message: Text("Correct name to \(name)?"),
|
||||
primaryButton: .default(
|
||||
Text("Ok"),
|
||||
action: { displayName.wrappedValue = name }
|
||||
),
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
private func creatUserErrorAlert(_ err: LocalizedStringKey) -> Alert {
|
||||
Alert(
|
||||
title: Text("Error creating profile!"),
|
||||
message: Text(err)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func validDisplayName(_ name: String) -> Bool {
|
||||
mkValidName(name.trimmingCharacters(in: .whitespaces)) == name
|
||||
}
|
||||
|
||||
func mkValidName(_ s: String) -> String {
|
||||
var c = s.cString(using: .utf8)!
|
||||
return fromCString(chat_valid_name(&c)!)
|
||||
name.firstIndex(of: " ") == nil && name.first != "@" && name.first != "#"
|
||||
}
|
||||
|
||||
struct CreateProfile_Previews: PreviewProvider {
|
||||
|
||||
@@ -31,7 +31,7 @@ struct CreateSimpleXAddress: View {
|
||||
Spacer()
|
||||
|
||||
if let userAddress = m.userAddress {
|
||||
SimpleXLinkQRCode(uri: userAddress.connReqContact)
|
||||
QRCode(uri: userAddress.connReqContact)
|
||||
.frame(maxHeight: g.size.width)
|
||||
shareQRCodeButton(userAddress)
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -126,7 +126,7 @@ struct CreateSimpleXAddress: View {
|
||||
|
||||
private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View {
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(userAddress.connReqContact)])
|
||||
showShareSheet(items: [userAddress.connReqContact])
|
||||
} label: {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
@@ -194,7 +194,7 @@ struct SendAddressMailView: View {
|
||||
let messageBody = String(format: NSLocalizedString("""
|
||||
<p>Hi!</p>
|
||||
<p><a href="%@">Connect to me via SimpleX Chat</a></p>
|
||||
""", comment: "email text"), simplexChatLink(userAddress.connReqContact))
|
||||
""", comment: "email text"), userAddress.connReqContact)
|
||||
MailView(
|
||||
isShowing: self.$showMailView,
|
||||
result: $mailViewResult,
|
||||
|
||||
@@ -14,7 +14,7 @@ struct OnboardingView: View {
|
||||
var body: some View {
|
||||
switch onboarding {
|
||||
case .step1_SimpleXInfo: SimpleXInfo(onboarding: true)
|
||||
case .step2_CreateProfile: CreateFirstProfile()
|
||||
case .step2_CreateProfile: CreateProfile()
|
||||
case .step3_CreateSimpleXAddress: CreateSimpleXAddress()
|
||||
case .step4_SetNotificationsMode: SetNotificationsMode()
|
||||
case .onboardingComplete: EmptyView()
|
||||
|
||||
@@ -66,9 +66,6 @@ struct PrivacySettings: View {
|
||||
Section {
|
||||
settingsRow("lock.doc") {
|
||||
Toggle("Encrypt local files", isOn: $encryptLocalFiles)
|
||||
.onChange(of: encryptLocalFiles) {
|
||||
setEncryptLocalFiles($0)
|
||||
}
|
||||
}
|
||||
settingsRow("photo") {
|
||||
Toggle("Auto-accept images", isOn: $autoAcceptImages)
|
||||
@@ -93,9 +90,7 @@ struct PrivacySettings: View {
|
||||
}
|
||||
settingsRow("link") {
|
||||
Picker("SimpleX links", selection: $simplexLinkMode) {
|
||||
ForEach(
|
||||
SimpleXLinkMode.values + (SimpleXLinkMode.values.contains(simplexLinkMode) ? [] : [simplexLinkMode])
|
||||
) { mode in
|
||||
ForEach(SimpleXLinkMode.values) { mode in
|
||||
Text(mode.text)
|
||||
}
|
||||
}
|
||||
@@ -106,6 +101,10 @@ struct PrivacySettings: View {
|
||||
}
|
||||
} header: {
|
||||
Text("Chats")
|
||||
} footer: {
|
||||
if case .browser = simplexLinkMode {
|
||||
Text("Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red.")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
@@ -119,7 +118,7 @@ struct PrivacySettings: View {
|
||||
Text("Send delivery receipts to")
|
||||
} footer: {
|
||||
VStack(alignment: .leading) {
|
||||
Text("These settings are for your current profile **\(m.currentUser?.displayName ?? "")**.")
|
||||
Text("These settings are for your current profile **\(ChatModel.shared.currentUser?.displayName ?? "")**.")
|
||||
Text("They can be overridden in contact and group settings.")
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -184,16 +183,6 @@ struct PrivacySettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func setEncryptLocalFiles(_ enable: Bool) {
|
||||
do {
|
||||
try apiSetEncryptLocalFiles(enable)
|
||||
} catch let error {
|
||||
let err = responseError(error)
|
||||
logger.error("apiSetEncryptLocalFiles \(err)")
|
||||
alert = .error(title: "Error", error: "\(err)")
|
||||
}
|
||||
}
|
||||
|
||||
private func setOrAskSendReceiptsContacts(_ enable: Bool) {
|
||||
contactReceiptsOverrides = m.chats.reduce(0) { count, chat in
|
||||
let sendRcpts = chat.chatInfo.contact?.chatSettings.sendRcpts
|
||||
@@ -356,7 +345,7 @@ struct SimplexLockView: View {
|
||||
var id: Self { self }
|
||||
}
|
||||
|
||||
let laDelays: [Int] = [10, 30, 60, 180, 600, 0]
|
||||
let laDelays: [Int] = [10, 30, 60, 180, 0]
|
||||
|
||||
func laDelayText(_ t: Int) -> LocalizedStringKey {
|
||||
let m = t / 60
|
||||
@@ -378,7 +367,6 @@ struct SimplexLockView: View {
|
||||
Text(mode.text)
|
||||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
if performLA {
|
||||
Picker("Lock after", selection: $laLockDelay) {
|
||||
let delays = laDelays.contains(laLockDelay) ? laDelays : [laLockDelay] + laDelays
|
||||
@@ -386,7 +374,6 @@ struct SimplexLockView: View {
|
||||
Text(laDelayText(t))
|
||||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
if showChangePassword && laMode == .passcode {
|
||||
Button("Change passcode") {
|
||||
changeLAPassword()
|
||||
|
||||
@@ -93,7 +93,7 @@ enum SimpleXLinkMode: String, Identifiable {
|
||||
case full
|
||||
case browser
|
||||
|
||||
static var values: [SimpleXLinkMode] = [.description, .full]
|
||||
static var values: [SimpleXLinkMode] = [.description, .full, .browser]
|
||||
|
||||
public var id: Self { self }
|
||||
|
||||
@@ -381,9 +381,7 @@ struct ProfilePreview: View {
|
||||
Text(profileOf.displayName)
|
||||
.fontWeight(.bold)
|
||||
.font(.title2)
|
||||
if profileOf.fullName != "" && profileOf.fullName != profileOf.displayName {
|
||||
Text(profileOf.fullName)
|
||||
}
|
||||
Text(profileOf.fullName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ struct UserAddressView: View {
|
||||
|
||||
@ViewBuilder private func existingAddressView(_ userAddress: UserContactLink) -> some View {
|
||||
Section {
|
||||
MutableQRCode(uri: Binding.constant(simplexChatLink(userAddress.connReqContact)))
|
||||
MutableQRCode(uri: Binding.constant(userAddress.connReqContact))
|
||||
shareQRCodeButton(userAddress)
|
||||
if MFMailComposeViewController.canSendMail() {
|
||||
shareViaEmailButton(userAddress)
|
||||
@@ -248,7 +248,7 @@ struct UserAddressView: View {
|
||||
|
||||
private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View {
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(userAddress.connReqContact)])
|
||||
showShareSheet(items: [userAddress.connReqContact])
|
||||
} label: {
|
||||
settingsRow("square.and.arrow.up") {
|
||||
Text("Share address")
|
||||
|
||||
@@ -17,8 +17,6 @@ struct UserProfile: View {
|
||||
@State private var showImagePicker = false
|
||||
@State private var showTakePhoto = false
|
||||
@State private var chosenImage: UIImage? = nil
|
||||
@State private var alert: UserProfileAlert?
|
||||
@FocusState private var focusDisplayName
|
||||
|
||||
var body: some View {
|
||||
let user: User = chatModel.currentUser!
|
||||
@@ -49,27 +47,18 @@ struct UserProfile: View {
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
ZStack(alignment: .leading) {
|
||||
if !validNewProfileName(user) {
|
||||
Button {
|
||||
alert = .invalidNameError(validName: mkValidName(profile.displayName))
|
||||
} label: {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.clear)
|
||||
if !validDisplayName(profile.displayName) {
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.foregroundColor(.red)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
profileNameTextEdit("Profile name", $profile.displayName)
|
||||
.focused($focusDisplayName)
|
||||
}
|
||||
.padding(.bottom)
|
||||
if showFullName(user) {
|
||||
profileNameTextEdit("Full name (optional)", $profile.fullName)
|
||||
.padding(.bottom)
|
||||
profileNameTextEdit("Display name", $profile.displayName)
|
||||
}
|
||||
profileNameTextEdit("Full name (optional)", $profile.fullName)
|
||||
HStack(spacing: 20) {
|
||||
Button("Cancel") { editProfile = false }
|
||||
Button("Save (and notify contacts)") { saveProfile() }
|
||||
.disabled(!canSaveProfile(user))
|
||||
.disabled(profile.displayName == "" || !validDisplayName(profile.displayName))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 120, alignment: .leading)
|
||||
@@ -85,14 +74,11 @@ struct UserProfile: View {
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
profileNameView("Profile name:", user.profile.displayName)
|
||||
if showFullName(user) {
|
||||
profileNameView("Full name:", user.profile.fullName)
|
||||
}
|
||||
profileNameView("Display name:", user.profile.displayName)
|
||||
profileNameView("Full name:", user.profile.fullName)
|
||||
Button("Edit") {
|
||||
profile = fromLocalProfile(user.profile)
|
||||
editProfile = true
|
||||
focusDisplayName = true
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 120, alignment: .leading)
|
||||
@@ -131,12 +117,14 @@ struct UserProfile: View {
|
||||
profile.image = nil
|
||||
}
|
||||
}
|
||||
.alert(item: $alert) { a in userProfileAlert(a, $profile.displayName) }
|
||||
}
|
||||
|
||||
func profileNameTextEdit(_ label: LocalizedStringKey, _ name: Binding<String>) -> some View {
|
||||
TextField(label, text: name)
|
||||
.padding(.leading, 32)
|
||||
.textInputAutocapitalization(.never)
|
||||
.disableAutocorrection(true)
|
||||
.padding(.bottom)
|
||||
.padding(.leading, 28)
|
||||
}
|
||||
|
||||
func profileNameView(_ label: LocalizedStringKey, _ name: String) -> some View {
|
||||
@@ -153,34 +141,19 @@ struct UserProfile: View {
|
||||
showChooseSource = true
|
||||
}
|
||||
|
||||
private func validNewProfileName(_ user: User) -> Bool {
|
||||
profile.displayName == user.profile.displayName || validDisplayName(profile.displayName.trimmingCharacters(in: .whitespaces))
|
||||
}
|
||||
|
||||
private func showFullName(_ user: User) -> Bool {
|
||||
user.profile.fullName != "" && user.profile.fullName != user.profile.displayName
|
||||
}
|
||||
|
||||
private func canSaveProfile(_ user: User) -> Bool {
|
||||
profile.displayName.trimmingCharacters(in: .whitespaces) != "" && validNewProfileName(user)
|
||||
}
|
||||
|
||||
func saveProfile() {
|
||||
Task {
|
||||
do {
|
||||
profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces)
|
||||
if let (newProfile, _) = try await apiUpdateProfile(profile: profile) {
|
||||
DispatchQueue.main.async {
|
||||
chatModel.updateCurrentUser(newProfile)
|
||||
profile = newProfile
|
||||
}
|
||||
editProfile = false
|
||||
} else {
|
||||
alert = .duplicateUserError
|
||||
}
|
||||
} catch {
|
||||
logger.error("UserProfile apiUpdateProfile error: \(responseError(error))")
|
||||
}
|
||||
editProfile = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +216,6 @@ func startChat() -> DBMigrationResult? {
|
||||
try apiSetTempFolder(tempFolder: getTempFilesDirectory().path)
|
||||
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
|
||||
try setXFTPConfig(xftpConfig)
|
||||
try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get())
|
||||
let justStarted = try apiStartChat()
|
||||
chatStarted = true
|
||||
if justStarted {
|
||||
@@ -352,12 +351,6 @@ func setXFTPConfig(_ cfg: XFTPFileConfig?) throws {
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSetEncryptLocalFiles(_ enable: Bool) throws {
|
||||
let r = sendSimpleXCmd(.apiSetEncryptLocalFiles(enable: enable))
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? {
|
||||
guard apiGetActiveUser() != nil else {
|
||||
logger.debug("no active user")
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
18415C6C56DBCEC2CBBD2F11 /* WebRTCClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18415323A4082FC92887F906 /* WebRTCClient.swift */; };
|
||||
18415F9A2D551F9757DA4654 /* CIVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18415FD2E36F13F596A45BB4 /* CIVideoView.swift */; };
|
||||
18415FEFE153C5920BFB7828 /* GroupWelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1841516F0CE5992B0EDFB377 /* GroupWelcomeView.swift */; };
|
||||
3C71477A281C0F6800CB4D4B /* www in Resources */ = {isa = PBXBuildFile; fileRef = 3C714779281C0F6800CB4D4B /* www */; };
|
||||
3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */; };
|
||||
3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; };
|
||||
3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* CILinkView.swift */; };
|
||||
@@ -47,6 +48,11 @@
|
||||
5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */; };
|
||||
5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A922283CEDE600C4E99E /* SoundPlayer.swift */; };
|
||||
5C55A92E283D0FDE00C4E99E /* sounds in Resources */ = {isa = PBXBuildFile; fileRef = 5C55A92D283D0FDE00C4E99E /* sounds */; };
|
||||
5C56251A2AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5625152AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7-ghc8.10.7.a */; };
|
||||
5C56251B2AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5625162AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7.a */; };
|
||||
5C56251C2AC1DE5900A21210 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5625172AC1DE5900A21210 /* libgmpxx.a */; };
|
||||
5C56251D2AC1DE5900A21210 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5625182AC1DE5900A21210 /* libgmp.a */; };
|
||||
5C56251E2AC1DE5900A21210 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5625192AC1DE5900A21210 /* libffi.a */; };
|
||||
5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; };
|
||||
5C58BCD6292BEBE600AF9E4F /* CIChatFeatureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C58BCD5292BEBE600AF9E4F /* CIChatFeatureView.swift */; };
|
||||
5C5DB70E289ABDD200730FFF /* AppearanceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5DB70D289ABDD200730FFF /* AppearanceSettings.swift */; };
|
||||
@@ -117,11 +123,6 @@
|
||||
5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */; };
|
||||
5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; };
|
||||
5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */; };
|
||||
5CD089312AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD0892C2AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a */; };
|
||||
5CD089322AE59CB300669208 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD0892D2AE59CB300669208 /* libffi.a */; };
|
||||
5CD089332AE59CB300669208 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD0892E2AE59CB300669208 /* libgmpxx.a */; };
|
||||
5CD089342AE59CB300669208 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD0892F2AE59CB300669208 /* libgmp.a */; };
|
||||
5CD089352AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD089302AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a */; };
|
||||
5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD472818589900503DA2 /* NotificationService.swift */; };
|
||||
5CE2BA702845308900EC33A6 /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; };
|
||||
5CE2BA712845308900EC33A6 /* SimpleXChat.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
||||
@@ -256,6 +257,7 @@
|
||||
18415B08031E8FB0F7FC27F9 /* CallViewRenderers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallViewRenderers.swift; sourceTree = "<group>"; };
|
||||
18415DAAAD1ADBEDB0EDA852 /* VideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = "<group>"; };
|
||||
18415FD2E36F13F596A45BB4 /* CIVideoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CIVideoView.swift; sourceTree = "<group>"; };
|
||||
3C714779281C0F6800CB4D4B /* www */ = {isa = PBXFileReference; lastKnownFileType = folder; name = www; path = ../multiplatform/android/src/main/assets/www; sourceTree = "<group>"; };
|
||||
3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteToConnectView.swift; sourceTree = "<group>"; };
|
||||
3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = "<group>"; };
|
||||
3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = "<group>"; };
|
||||
@@ -291,6 +293,11 @@
|
||||
5C55A920283CCCB700C4E99E /* IncomingCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallView.swift; sourceTree = "<group>"; };
|
||||
5C55A922283CEDE600C4E99E /* SoundPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundPlayer.swift; sourceTree = "<group>"; };
|
||||
5C55A92D283D0FDE00C4E99E /* sounds */ = {isa = PBXFileReference; lastKnownFileType = folder; path = sounds; sourceTree = "<group>"; };
|
||||
5C5625152AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7-ghc8.10.7.a"; sourceTree = "<group>"; };
|
||||
5C5625162AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7.a"; sourceTree = "<group>"; };
|
||||
5C5625172AC1DE5900A21210 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
5C5625182AC1DE5900A21210 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
5C5625192AC1DE5900A21210 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = "<group>"; };
|
||||
5C58BCD5292BEBE600AF9E4F /* CIChatFeatureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIChatFeatureView.swift; sourceTree = "<group>"; };
|
||||
5C5B67912ABAF4B500DA9412 /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
@@ -397,11 +404,6 @@
|
||||
5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = "<group>"; };
|
||||
5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = "<group>"; };
|
||||
5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToConnectView.swift; sourceTree = "<group>"; };
|
||||
5CD0892C2AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a"; sourceTree = "<group>"; };
|
||||
5CD0892D2AE59CB300669208 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
5CD0892E2AE59CB300669208 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
5CD0892F2AE59CB300669208 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
5CD089302AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a"; sourceTree = "<group>"; };
|
||||
5CDCAD452818589900503DA2 /* SimpleX NSE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "SimpleX NSE.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5CDCAD472818589900503DA2 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||
5CDCAD492818589900503DA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
@@ -505,13 +507,13 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5CD089352AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a in Frameworks */,
|
||||
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
|
||||
5CD089332AE59CB300669208 /* libgmpxx.a in Frameworks */,
|
||||
5C56251C2AC1DE5900A21210 /* libgmpxx.a in Frameworks */,
|
||||
5C56251B2AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7.a in Frameworks */,
|
||||
5C56251A2AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7-ghc8.10.7.a in Frameworks */,
|
||||
5C56251E2AC1DE5900A21210 /* libffi.a in Frameworks */,
|
||||
5C56251D2AC1DE5900A21210 /* libgmp.a in Frameworks */,
|
||||
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
|
||||
5CD089312AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a in Frameworks */,
|
||||
5CD089342AE59CB300669208 /* libgmp.a in Frameworks */,
|
||||
5CD089322AE59CB300669208 /* libffi.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -572,11 +574,11 @@
|
||||
5C764E5C279C70B7000C6508 /* Libraries */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5CD0892D2AE59CB300669208 /* libffi.a */,
|
||||
5CD0892F2AE59CB300669208 /* libgmp.a */,
|
||||
5CD0892E2AE59CB300669208 /* libgmpxx.a */,
|
||||
5CD0892C2AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a */,
|
||||
5CD089302AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a */,
|
||||
5C5625192AC1DE5900A21210 /* libffi.a */,
|
||||
5C5625182AC1DE5900A21210 /* libgmp.a */,
|
||||
5C5625172AC1DE5900A21210 /* libgmpxx.a */,
|
||||
5C5625152AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7-ghc8.10.7.a */,
|
||||
5C5625162AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7.a */,
|
||||
);
|
||||
path = Libraries;
|
||||
sourceTree = "<group>";
|
||||
@@ -636,6 +638,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5C55A92D283D0FDE00C4E99E /* sounds */,
|
||||
3C714779281C0F6800CB4D4B /* www */,
|
||||
5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */,
|
||||
5CC2C0FA2809BF11000C35E3 /* Localizable.strings */,
|
||||
5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */,
|
||||
@@ -1047,6 +1050,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5C55A92E283D0FDE00C4E99E /* sounds in Resources */,
|
||||
3C71477A281C0F6800CB4D4B /* www in Resources */,
|
||||
5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */,
|
||||
5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */,
|
||||
5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */,
|
||||
@@ -1482,7 +1486,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 180;
|
||||
CURRENT_PROJECT_VERSION = 174;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -1503,7 +1507,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 5.4;
|
||||
MARKETING_VERSION = 5.3.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||
PRODUCT_NAME = SimpleX;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1524,7 +1528,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 180;
|
||||
CURRENT_PROJECT_VERSION = 174;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -1545,7 +1549,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 5.4;
|
||||
MARKETING_VERSION = 5.3.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||
PRODUCT_NAME = SimpleX;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1604,7 +1608,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 180;
|
||||
CURRENT_PROJECT_VERSION = 174;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1617,7 +1621,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 5.4;
|
||||
MARKETING_VERSION = 5.3.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -1636,7 +1640,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 180;
|
||||
CURRENT_PROJECT_VERSION = 174;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1649,7 +1653,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 5.4;
|
||||
MARKETING_VERSION = 5.3.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -1668,7 +1672,7 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 180;
|
||||
CURRENT_PROJECT_VERSION = 174;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
@@ -1692,7 +1696,7 @@
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Libraries/sim",
|
||||
);
|
||||
MARKETING_VERSION = 5.4;
|
||||
MARKETING_VERSION = 5.3.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1714,7 +1718,7 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 180;
|
||||
CURRENT_PROJECT_VERSION = 174;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
@@ -1738,7 +1742,7 @@
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Libraries/sim",
|
||||
);
|
||||
MARKETING_VERSION = 5.4;
|
||||
MARKETING_VERSION = 5.3.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
SDKROOT = iphoneos;
|
||||
|
||||
@@ -50,13 +50,6 @@ public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: Migratio
|
||||
return result
|
||||
}
|
||||
|
||||
public func chatCloseStore() {
|
||||
let err = fromCString(chat_close_store(getChatCtrl()))
|
||||
if err != "" {
|
||||
logger.error("chatCloseStore error: \(err)")
|
||||
}
|
||||
}
|
||||
|
||||
public func resetChatCtrl() {
|
||||
chatController = nil
|
||||
migrationResult = nil
|
||||
@@ -139,11 +132,8 @@ public func chatResponse(_ s: String) -> ChatResponse {
|
||||
var type: String?
|
||||
var json: String?
|
||||
if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary {
|
||||
if let jResp = j["resp"] as? NSDictionary, jResp.count == 1 || jResp.count == 2 {
|
||||
if let jResp = j["resp"] as? NSDictionary, jResp.count == 1 {
|
||||
type = jResp.allKeys[0] as? String
|
||||
if jResp.count == 2 && type == "_owsf" {
|
||||
type = jResp.allKeys[1] as? String
|
||||
}
|
||||
if type == "apiChats" {
|
||||
if let jApiChats = jResp["apiChats"] as? NSDictionary,
|
||||
let user: UserRef = try? decodeObject(jApiChats["user"] as Any),
|
||||
|
||||
@@ -32,7 +32,6 @@ public enum ChatCommand {
|
||||
case setTempFolder(tempFolder: String)
|
||||
case setFilesFolder(filesFolder: String)
|
||||
case apiSetXFTPConfig(config: XFTPFileConfig?)
|
||||
case apiSetEncryptLocalFiles(enable: Bool)
|
||||
case apiExportArchive(config: ArchiveConfig)
|
||||
case apiImportArchive(config: ArchiveConfig)
|
||||
case apiDeleteStorage
|
||||
@@ -50,7 +49,7 @@ public enum ChatCommand {
|
||||
case apiVerifyToken(token: DeviceToken, nonce: String, code: String)
|
||||
case apiDeleteToken(token: DeviceToken)
|
||||
case apiGetNtfMessage(nonce: String, encNtfInfo: String)
|
||||
case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile)
|
||||
case apiNewGroup(userId: Int64, groupProfile: GroupProfile)
|
||||
case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole)
|
||||
case apiJoinGroup(groupId: Int64)
|
||||
case apiMemberRole(groupId: Int64, memberId: Int64, memberRole: GroupMemberRole)
|
||||
@@ -73,7 +72,6 @@ public enum ChatCommand {
|
||||
case apiGetNetworkConfig
|
||||
case reconnectAllServers
|
||||
case apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings)
|
||||
case apiSetMemberSettings(groupId: Int64, groupMemberId: Int64, memberSettings: GroupMemberSettings)
|
||||
case apiContactInfo(contactId: Int64)
|
||||
case apiGroupMemberInfo(groupId: Int64, groupMemberId: Int64)
|
||||
case apiSwitchContact(contactId: Int64)
|
||||
@@ -88,9 +86,8 @@ public enum ChatCommand {
|
||||
case apiVerifyGroupMember(groupId: Int64, groupMemberId: Int64, connectionCode: String?)
|
||||
case apiAddContact(userId: Int64, incognito: Bool)
|
||||
case apiSetConnectionIncognito(connId: Int64, incognito: Bool)
|
||||
case apiConnectPlan(userId: Int64, connReq: String)
|
||||
case apiConnect(userId: Int64, incognito: Bool, connReq: String)
|
||||
case apiDeleteChat(type: ChatType, id: Int64, notify: Bool?)
|
||||
case apiDeleteChat(type: ChatType, id: Int64)
|
||||
case apiClearChat(type: ChatType, id: Int64)
|
||||
case apiListContacts(userId: Int64)
|
||||
case apiUpdateProfile(userId: Int64, profile: Profile)
|
||||
@@ -113,11 +110,10 @@ public enum ChatCommand {
|
||||
case apiEndCall(contact: Contact)
|
||||
case apiGetCallInvitations
|
||||
case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus)
|
||||
case apiGetNetworkStatuses
|
||||
case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64))
|
||||
case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool)
|
||||
case receiveFile(fileId: Int64, encrypted: Bool?, inline: Bool?)
|
||||
case setFileToReceive(fileId: Int64, encrypted: Bool?)
|
||||
case receiveFile(fileId: Int64, encrypted: Bool, inline: Bool?)
|
||||
case setFileToReceive(fileId: Int64, encrypted: Bool)
|
||||
case cancelFile(fileId: Int64)
|
||||
case showVersion
|
||||
case string(String)
|
||||
@@ -154,7 +150,6 @@ public enum ChatCommand {
|
||||
} else {
|
||||
return "/_xftp off"
|
||||
}
|
||||
case let .apiSetEncryptLocalFiles(enable): return "/_files_encrypt \(onOff(enable))"
|
||||
case let .apiExportArchive(cfg): return "/_db export \(encodeJSON(cfg))"
|
||||
case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))"
|
||||
case .apiDeleteStorage: return "/_db delete"
|
||||
@@ -176,7 +171,7 @@ public enum ChatCommand {
|
||||
case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)"
|
||||
case let .apiDeleteToken(token): return "/_ntf delete \(token.cmdString)"
|
||||
case let .apiGetNtfMessage(nonce, encNtfInfo): return "/_ntf message \(nonce) \(encNtfInfo)"
|
||||
case let .apiNewGroup(userId, incognito, groupProfile): return "/_group \(userId) incognito=\(onOff(incognito)) \(encodeJSON(groupProfile))"
|
||||
case let .apiNewGroup(userId, groupProfile): return "/_group \(userId) \(encodeJSON(groupProfile))"
|
||||
case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)"
|
||||
case let .apiJoinGroup(groupId): return "/_join #\(groupId)"
|
||||
case let .apiMemberRole(groupId, memberId, memberRole): return "/_member role #\(groupId) \(memberId) \(memberRole.rawValue)"
|
||||
@@ -199,7 +194,6 @@ public enum ChatCommand {
|
||||
case .apiGetNetworkConfig: return "/network"
|
||||
case .reconnectAllServers: return "/reconnect"
|
||||
case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id)) \(encodeJSON(chatSettings))"
|
||||
case let .apiSetMemberSettings(groupId, groupMemberId, memberSettings): return "/_member settings #\(groupId) \(groupMemberId) \(encodeJSON(memberSettings))"
|
||||
case let .apiContactInfo(contactId): return "/_info @\(contactId)"
|
||||
case let .apiGroupMemberInfo(groupId, groupMemberId): return "/_info #\(groupId) \(groupMemberId)"
|
||||
case let .apiSwitchContact(contactId): return "/_switch @\(contactId)"
|
||||
@@ -224,13 +218,8 @@ public enum ChatCommand {
|
||||
case let .apiVerifyGroupMember(groupId, groupMemberId, .none): return "/_verify code #\(groupId) \(groupMemberId)"
|
||||
case let .apiAddContact(userId, incognito): return "/_connect \(userId) incognito=\(onOff(incognito))"
|
||||
case let .apiSetConnectionIncognito(connId, incognito): return "/_set incognito :\(connId) \(onOff(incognito))"
|
||||
case let .apiConnectPlan(userId, connReq): return "/_connect plan \(userId) \(connReq)"
|
||||
case let .apiConnect(userId, incognito, connReq): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connReq)"
|
||||
case let .apiDeleteChat(type, id, notify): if let notify = notify {
|
||||
return "/_delete \(ref(type, id)) notify=\(onOff(notify))"
|
||||
} else {
|
||||
return "/_delete \(ref(type, id))"
|
||||
}
|
||||
case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))"
|
||||
case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))"
|
||||
case let .apiListContacts(userId): return "/_contacts \(userId)"
|
||||
case let .apiUpdateProfile(userId, profile): return "/_profile \(userId) \(encodeJSON(profile))"
|
||||
@@ -252,11 +241,15 @@ public enum ChatCommand {
|
||||
case let .apiEndCall(contact): return "/_call end @\(contact.apiId)"
|
||||
case .apiGetCallInvitations: return "/_call get"
|
||||
case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)"
|
||||
case .apiGetNetworkStatuses: return "/_network_statuses"
|
||||
case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)"
|
||||
case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id)) \(onOff(unreadChat))"
|
||||
case let .receiveFile(fileId, encrypt, inline): return "/freceive \(fileId)\(onOffParam("encrypt", encrypt))\(onOffParam("inline", inline))"
|
||||
case let .setFileToReceive(fileId, encrypt): return "/_set_file_to_receive \(fileId)\(onOffParam("encrypt", encrypt))"
|
||||
case let .receiveFile(fileId, encrypted, inline):
|
||||
let s = "/freceive \(fileId) encrypt=\(onOff(encrypted))"
|
||||
if let inline = inline {
|
||||
return s + " inline=\(onOff(inline))"
|
||||
}
|
||||
return s
|
||||
case let .setFileToReceive(fileId, encrypted): return "/_set_file_to_receive \(fileId) encrypt=\(onOff(encrypted))"
|
||||
case let .cancelFile(fileId): return "/fcancel \(fileId)"
|
||||
case .showVersion: return "/version"
|
||||
case let .string(str): return str
|
||||
@@ -286,7 +279,6 @@ public enum ChatCommand {
|
||||
case .setTempFolder: return "setTempFolder"
|
||||
case .setFilesFolder: return "setFilesFolder"
|
||||
case .apiSetXFTPConfig: return "apiSetXFTPConfig"
|
||||
case .apiSetEncryptLocalFiles: return "apiSetEncryptLocalFiles"
|
||||
case .apiExportArchive: return "apiExportArchive"
|
||||
case .apiImportArchive: return "apiImportArchive"
|
||||
case .apiDeleteStorage: return "apiDeleteStorage"
|
||||
@@ -327,7 +319,6 @@ public enum ChatCommand {
|
||||
case .apiGetNetworkConfig: return "apiGetNetworkConfig"
|
||||
case .reconnectAllServers: return "reconnectAllServers"
|
||||
case .apiSetChatSettings: return "apiSetChatSettings"
|
||||
case .apiSetMemberSettings: return "apiSetMemberSettings"
|
||||
case .apiContactInfo: return "apiContactInfo"
|
||||
case .apiGroupMemberInfo: return "apiGroupMemberInfo"
|
||||
case .apiSwitchContact: return "apiSwitchContact"
|
||||
@@ -342,7 +333,6 @@ public enum ChatCommand {
|
||||
case .apiVerifyGroupMember: return "apiVerifyGroupMember"
|
||||
case .apiAddContact: return "apiAddContact"
|
||||
case .apiSetConnectionIncognito: return "apiSetConnectionIncognito"
|
||||
case .apiConnectPlan: return "apiConnectPlan"
|
||||
case .apiConnect: return "apiConnect"
|
||||
case .apiDeleteChat: return "apiDeleteChat"
|
||||
case .apiClearChat: return "apiClearChat"
|
||||
@@ -366,7 +356,6 @@ public enum ChatCommand {
|
||||
case .apiEndCall: return "apiEndCall"
|
||||
case .apiGetCallInvitations: return "apiGetCallInvitations"
|
||||
case .apiCallStatus: return "apiCallStatus"
|
||||
case .apiGetNetworkStatuses: return "apiGetNetworkStatuses"
|
||||
case .apiChatRead: return "apiChatRead"
|
||||
case .apiChatUnread: return "apiChatUnread"
|
||||
case .receiveFile: return "receiveFile"
|
||||
@@ -425,13 +414,6 @@ public enum ChatCommand {
|
||||
b ? "on" : "off"
|
||||
}
|
||||
|
||||
private func onOffParam(_ param: String, _ b: Bool?) -> String {
|
||||
if let b = b {
|
||||
return " \(param)=\(onOff(b))"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
private func maybePwd(_ pwd: String?) -> String {
|
||||
pwd == "" || pwd == nil ? "" : " " + encodeJSON(pwd)
|
||||
}
|
||||
@@ -475,13 +457,11 @@ public enum ChatResponse: Decodable, Error {
|
||||
case connectionVerified(user: UserRef, verified: Bool, expectedCode: String)
|
||||
case invitation(user: UserRef, connReqInvitation: String, connection: PendingContactConnection)
|
||||
case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection)
|
||||
case connectionPlan(user: UserRef, connectionPlan: ConnectionPlan)
|
||||
case sentConfirmation(user: UserRef)
|
||||
case sentInvitation(user: UserRef)
|
||||
case contactAlreadyExists(user: UserRef, contact: Contact)
|
||||
case contactRequestAlreadyAccepted(user: UserRef, contact: Contact)
|
||||
case contactDeleted(user: UserRef, contact: Contact)
|
||||
case contactDeletedByContact(user: UserRef, contact: Contact)
|
||||
case chatCleared(user: UserRef, chatInfo: ChatInfo)
|
||||
case userProfileNoChange(user: User)
|
||||
case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile, updateSummary: UserProfileUpdateSummary)
|
||||
@@ -499,15 +479,11 @@ public enum ChatResponse: Decodable, Error {
|
||||
case acceptingContactRequest(user: UserRef, contact: Contact)
|
||||
case contactRequestRejected(user: UserRef)
|
||||
case contactUpdated(user: UserRef, toContact: Contact)
|
||||
case groupMemberUpdated(user: UserRef, groupInfo: GroupInfo, fromMember: GroupMember, toMember: GroupMember)
|
||||
// TODO remove events below
|
||||
case contactsSubscribed(server: String, contactRefs: [ContactRef])
|
||||
case contactsDisconnected(server: String, contactRefs: [ContactRef])
|
||||
case contactSubError(user: UserRef, contact: Contact, chatError: ChatError)
|
||||
case contactSubSummary(user: UserRef, contactSubscriptions: [ContactSubStatus])
|
||||
// TODO remove events above
|
||||
case networkStatus(networkStatus: NetworkStatus, connections: [String])
|
||||
case networkStatuses(user_: UserRef?, networkStatuses: [ConnNetworkStatus])
|
||||
case groupSubscribed(user: UserRef, groupInfo: GroupRef)
|
||||
case groupSubscribed(user: UserRef, groupInfo: GroupInfo)
|
||||
case memberSubErrors(user: UserRef, memberSubErrors: [MemberSubError])
|
||||
case groupEmpty(user: UserRef, groupInfo: GroupInfo)
|
||||
case userContactLinkSubscribed
|
||||
@@ -522,7 +498,6 @@ public enum ChatResponse: Decodable, Error {
|
||||
case groupCreated(user: UserRef, groupInfo: GroupInfo)
|
||||
case sentGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, member: GroupMember)
|
||||
case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?)
|
||||
case groupLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember)
|
||||
case userDeletedMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember)
|
||||
case leftMemberUser(user: UserRef, groupInfo: GroupInfo)
|
||||
case groupMembers(user: UserRef, group: Group)
|
||||
@@ -619,13 +594,11 @@ public enum ChatResponse: Decodable, Error {
|
||||
case .connectionVerified: return "connectionVerified"
|
||||
case .invitation: return "invitation"
|
||||
case .connectionIncognitoUpdated: return "connectionIncognitoUpdated"
|
||||
case .connectionPlan: return "connectionPlan"
|
||||
case .sentConfirmation: return "sentConfirmation"
|
||||
case .sentInvitation: return "sentInvitation"
|
||||
case .contactAlreadyExists: return "contactAlreadyExists"
|
||||
case .contactRequestAlreadyAccepted: return "contactRequestAlreadyAccepted"
|
||||
case .contactDeleted: return "contactDeleted"
|
||||
case .contactDeletedByContact: return "contactDeletedByContact"
|
||||
case .chatCleared: return "chatCleared"
|
||||
case .userProfileNoChange: return "userProfileNoChange"
|
||||
case .userProfileUpdated: return "userProfileUpdated"
|
||||
@@ -643,12 +616,10 @@ public enum ChatResponse: Decodable, Error {
|
||||
case .acceptingContactRequest: return "acceptingContactRequest"
|
||||
case .contactRequestRejected: return "contactRequestRejected"
|
||||
case .contactUpdated: return "contactUpdated"
|
||||
case .groupMemberUpdated: return "groupMemberUpdated"
|
||||
case .contactsSubscribed: return "contactsSubscribed"
|
||||
case .contactsDisconnected: return "contactsDisconnected"
|
||||
case .contactSubError: return "contactSubError"
|
||||
case .contactSubSummary: return "contactSubSummary"
|
||||
case .networkStatus: return "networkStatus"
|
||||
case .networkStatuses: return "networkStatuses"
|
||||
case .groupSubscribed: return "groupSubscribed"
|
||||
case .memberSubErrors: return "memberSubErrors"
|
||||
case .groupEmpty: return "groupEmpty"
|
||||
@@ -663,7 +634,6 @@ public enum ChatResponse: Decodable, Error {
|
||||
case .groupCreated: return "groupCreated"
|
||||
case .sentGroupInvitation: return "sentGroupInvitation"
|
||||
case .userAcceptedGroupSent: return "userAcceptedGroupSent"
|
||||
case .groupLinkConnecting: return "groupLinkConnecting"
|
||||
case .userDeletedMember: return "userDeletedMember"
|
||||
case .leftMemberUser: return "leftMemberUser"
|
||||
case .groupMembers: return "groupMembers"
|
||||
@@ -760,13 +730,11 @@ public enum ChatResponse: Decodable, Error {
|
||||
case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)")
|
||||
case let .invitation(u, connReqInvitation, _): return withUser(u, connReqInvitation)
|
||||
case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection))
|
||||
case let .connectionPlan(u, connectionPlan): return withUser(u, String(describing: connectionPlan))
|
||||
case .sentConfirmation: return noDetails
|
||||
case .sentInvitation: return noDetails
|
||||
case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact))
|
||||
case let .contactRequestAlreadyAccepted(u, contact): return withUser(u, String(describing: contact))
|
||||
case let .contactDeleted(u, contact): return withUser(u, String(describing: contact))
|
||||
case let .contactDeletedByContact(u, contact): return withUser(u, String(describing: contact))
|
||||
case let .chatCleared(u, chatInfo): return withUser(u, String(describing: chatInfo))
|
||||
case .userProfileNoChange: return noDetails
|
||||
case let .userProfileUpdated(u, _, toProfile, _): return withUser(u, String(describing: toProfile))
|
||||
@@ -784,12 +752,10 @@ public enum ChatResponse: Decodable, Error {
|
||||
case let .acceptingContactRequest(u, contact): return withUser(u, String(describing: contact))
|
||||
case .contactRequestRejected: return noDetails
|
||||
case let .contactUpdated(u, toContact): return withUser(u, String(describing: toContact))
|
||||
case let .groupMemberUpdated(u, groupInfo, fromMember, toMember): return withUser(u, "groupInfo: \(groupInfo)\nfromMember: \(fromMember)\ntoMember: \(toMember)")
|
||||
case let .contactsSubscribed(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))"
|
||||
case let .contactsDisconnected(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))"
|
||||
case let .contactSubError(u, contact, chatError): return withUser(u, "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))")
|
||||
case let .contactSubSummary(u, contactSubscriptions): return withUser(u, String(describing: contactSubscriptions))
|
||||
case let .networkStatus(status, conns): return "networkStatus: \(String(describing: status))\nconnections: \(String(describing: conns))"
|
||||
case let .networkStatuses(u, statuses): return withUser(u, String(describing: statuses))
|
||||
case let .groupSubscribed(u, groupInfo): return withUser(u, String(describing: groupInfo))
|
||||
case let .memberSubErrors(u, memberSubErrors): return withUser(u, String(describing: memberSubErrors))
|
||||
case let .groupEmpty(u, groupInfo): return withUser(u, String(describing: groupInfo))
|
||||
@@ -804,7 +770,6 @@ public enum ChatResponse: Decodable, Error {
|
||||
case let .groupCreated(u, groupInfo): return withUser(u, String(describing: groupInfo))
|
||||
case let .sentGroupInvitation(u, groupInfo, contact, member): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)")
|
||||
case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))")
|
||||
case let .groupLinkConnecting(u, groupInfo, hostMember): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))")
|
||||
case let .userDeletedMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)")
|
||||
case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo))
|
||||
case let .groupMembers(u, group): return withUser(u, String(describing: group))
|
||||
@@ -883,35 +848,6 @@ public func chatError(_ chatResponse: ChatResponse) -> ChatErrorType? {
|
||||
}
|
||||
}
|
||||
|
||||
public enum ConnectionPlan: Decodable {
|
||||
case invitationLink(invitationLinkPlan: InvitationLinkPlan)
|
||||
case contactAddress(contactAddressPlan: ContactAddressPlan)
|
||||
case groupLink(groupLinkPlan: GroupLinkPlan)
|
||||
}
|
||||
|
||||
public enum InvitationLinkPlan: Decodable {
|
||||
case ok
|
||||
case ownLink
|
||||
case connecting(contact_: Contact?)
|
||||
case known(contact: Contact)
|
||||
}
|
||||
|
||||
public enum ContactAddressPlan: Decodable {
|
||||
case ok
|
||||
case ownLink
|
||||
case connectingConfirmReconnect
|
||||
case connectingProhibit(contact: Contact)
|
||||
case known(contact: Contact)
|
||||
}
|
||||
|
||||
public enum GroupLinkPlan: Decodable {
|
||||
case ok
|
||||
case ownLink(groupInfo: GroupInfo)
|
||||
case connectingConfirmReconnect
|
||||
case connectingProhibit(groupInfo_: GroupInfo?)
|
||||
case known(groupInfo: GroupInfo)
|
||||
}
|
||||
|
||||
struct NewUser: Encodable {
|
||||
var profile: Profile?
|
||||
var sameServers: Bool
|
||||
@@ -1242,67 +1178,18 @@ public struct KeepAliveOpts: Codable, Equatable {
|
||||
public static let defaults: KeepAliveOpts = KeepAliveOpts(keepIdle: 30, keepIntvl: 15, keepCnt: 4)
|
||||
}
|
||||
|
||||
public enum NetworkStatus: Decodable, Equatable {
|
||||
case unknown
|
||||
case connected
|
||||
case disconnected
|
||||
case error(connectionError: String)
|
||||
|
||||
public var statusString: LocalizedStringKey {
|
||||
get {
|
||||
switch self {
|
||||
case .connected: return "connected"
|
||||
case .error: return "error"
|
||||
default: return "connecting"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var statusExplanation: LocalizedStringKey {
|
||||
get {
|
||||
switch self {
|
||||
case .connected: return "You are connected to the server used to receive messages from this contact."
|
||||
case let .error(err): return "Trying to connect to the server used to receive messages from this contact (error: \(err))."
|
||||
default: return "Trying to connect to the server used to receive messages from this contact."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var imageName: String {
|
||||
get {
|
||||
switch self {
|
||||
case .unknown: return "circle.dotted"
|
||||
case .connected: return "circle.fill"
|
||||
case .disconnected: return "ellipsis.circle.fill"
|
||||
case .error: return "exclamationmark.circle.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ConnNetworkStatus: Decodable {
|
||||
public var agentConnId: String
|
||||
public var networkStatus: NetworkStatus
|
||||
}
|
||||
|
||||
public struct ChatSettings: Codable {
|
||||
public var enableNtfs: MsgFilter
|
||||
public var enableNtfs: Bool
|
||||
public var sendRcpts: Bool?
|
||||
public var favorite: Bool
|
||||
|
||||
public init(enableNtfs: MsgFilter, sendRcpts: Bool?, favorite: Bool) {
|
||||
public init(enableNtfs: Bool, sendRcpts: Bool?, favorite: Bool) {
|
||||
self.enableNtfs = enableNtfs
|
||||
self.sendRcpts = sendRcpts
|
||||
self.favorite = favorite
|
||||
}
|
||||
|
||||
public static let defaults: ChatSettings = ChatSettings(enableNtfs: .all, sendRcpts: nil, favorite: false)
|
||||
}
|
||||
|
||||
public enum MsgFilter: String, Codable {
|
||||
case none
|
||||
case all
|
||||
case mentions
|
||||
public static let defaults: ChatSettings = ChatSettings(enableNtfs: true, sendRcpts: nil, favorite: false)
|
||||
}
|
||||
|
||||
public struct UserMsgReceiptSettings: Codable {
|
||||
@@ -1530,11 +1417,9 @@ public enum ChatErrorType: Decodable {
|
||||
case chatNotStarted
|
||||
case chatNotStopped
|
||||
case chatStoreChanged
|
||||
case connectionPlan(connectionPlan: ConnectionPlan)
|
||||
case invalidConnReq
|
||||
case invalidChatMessage(connection: Connection, message: String)
|
||||
case contactNotReady(contact: Contact)
|
||||
case contactNotActive(contact: Contact)
|
||||
case contactDisabled(contact: Contact)
|
||||
case connectionDisabled(connection: Connection)
|
||||
case groupUserRole(groupInfo: GroupInfo, requiredRole: GroupMemberRole)
|
||||
|
||||
@@ -80,14 +80,6 @@ public enum AppState: String {
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
public var canSuspend: Bool {
|
||||
switch self {
|
||||
case .active: return true
|
||||
case .bgRefresh: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum DBContainer: String {
|
||||
|
||||
@@ -422,8 +422,8 @@ public enum CustomTimeUnit {
|
||||
|
||||
|
||||
public func timeText(_ seconds: Int?) -> String {
|
||||
guard let seconds = seconds else { return NSLocalizedString("off", comment: "time to disappear") }
|
||||
if seconds == 0 { return NSLocalizedString("0 sec", comment: "time to disappear") }
|
||||
guard let seconds = seconds else { return "off" }
|
||||
if seconds == 0 { return "0 sec" }
|
||||
return CustomTimeUnit.toText(seconds: seconds)
|
||||
}
|
||||
|
||||
@@ -1292,7 +1292,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat {
|
||||
}
|
||||
|
||||
public var ntfsEnabled: Bool {
|
||||
self.chatSettings?.enableNtfs == .all
|
||||
self.chatSettings?.enableNtfs ?? false
|
||||
}
|
||||
|
||||
public var chatSettings: ChatSettings? {
|
||||
@@ -1373,7 +1373,6 @@ public struct Contact: Identifiable, Decodable, NamedChat {
|
||||
public var activeConn: Connection
|
||||
public var viaGroup: Int64?
|
||||
public var contactUsed: Bool
|
||||
public var contactStatus: ContactStatus
|
||||
public var chatSettings: ChatSettings
|
||||
public var userPreferences: Preferences
|
||||
public var mergedPreferences: ContactUserPreferences
|
||||
@@ -1385,9 +1384,8 @@ public struct Contact: Identifiable, Decodable, NamedChat {
|
||||
public var id: ChatId { get { "@\(contactId)" } }
|
||||
public var apiId: Int64 { get { contactId } }
|
||||
public var ready: Bool { get { activeConn.connStatus == .ready } }
|
||||
public var active: Bool { get { contactStatus == .active } }
|
||||
public var sendMsgEnabled: Bool { get {
|
||||
(ready && active && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?? false))
|
||||
(ready && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?? false))
|
||||
|| nextSendGrpInv
|
||||
} }
|
||||
public var nextSendGrpInv: Bool { get { contactGroupMemberId != nil && !contactGrpInvSent } }
|
||||
@@ -1432,7 +1430,6 @@ public struct Contact: Identifiable, Decodable, NamedChat {
|
||||
profile: LocalProfile.sampleData,
|
||||
activeConn: Connection.sampleData,
|
||||
contactUsed: true,
|
||||
contactStatus: .active,
|
||||
chatSettings: ChatSettings.defaults,
|
||||
userPreferences: Preferences.sampleData,
|
||||
mergedPreferences: ContactUserPreferences.sampleData,
|
||||
@@ -1442,11 +1439,6 @@ public struct Contact: Identifiable, Decodable, NamedChat {
|
||||
)
|
||||
}
|
||||
|
||||
public enum ContactStatus: String, Decodable {
|
||||
case active = "active"
|
||||
case deleted = "deleted"
|
||||
}
|
||||
|
||||
public struct ContactRef: Decodable, Equatable {
|
||||
var contactId: Int64
|
||||
public var agentConnId: String
|
||||
@@ -1729,11 +1721,6 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat {
|
||||
)
|
||||
}
|
||||
|
||||
public struct GroupRef: Decodable {
|
||||
public var groupId: Int64
|
||||
var localDisplayName: GroupName
|
||||
}
|
||||
|
||||
public struct GroupProfile: Codable, NamedChat {
|
||||
public init(displayName: String, fullName: String, description: String? = nil, image: String? = nil, groupPreferences: GroupPreferences? = nil) {
|
||||
self.displayName = displayName
|
||||
@@ -1763,7 +1750,6 @@ public struct GroupMember: Identifiable, Decodable {
|
||||
public var memberRole: GroupMemberRole
|
||||
public var memberCategory: GroupMemberCategory
|
||||
public var memberStatus: GroupMemberStatus
|
||||
public var memberSettings: GroupMemberSettings
|
||||
public var invitedBy: InvitedBy
|
||||
public var localDisplayName: ContactName
|
||||
public var memberProfile: LocalProfile
|
||||
@@ -1857,7 +1843,6 @@ public struct GroupMember: Identifiable, Decodable {
|
||||
memberRole: .admin,
|
||||
memberCategory: .inviteeMember,
|
||||
memberStatus: .memComplete,
|
||||
memberSettings: GroupMemberSettings(showMessages: true),
|
||||
invitedBy: .user,
|
||||
localDisplayName: "alice",
|
||||
memberProfile: LocalProfile.sampleData,
|
||||
@@ -1867,20 +1852,11 @@ public struct GroupMember: Identifiable, Decodable {
|
||||
)
|
||||
}
|
||||
|
||||
public struct GroupMemberSettings: Codable {
|
||||
public var showMessages: Bool
|
||||
}
|
||||
|
||||
public struct GroupMemberRef: Decodable {
|
||||
var groupMemberId: Int64
|
||||
var profile: Profile
|
||||
}
|
||||
|
||||
public struct GroupMemberIds: Decodable {
|
||||
var groupMemberId: Int64
|
||||
var groupId: Int64
|
||||
}
|
||||
|
||||
public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Decodable {
|
||||
case observer = "observer"
|
||||
case member = "member"
|
||||
@@ -1973,7 +1949,7 @@ public enum InvitedBy: Decodable {
|
||||
}
|
||||
|
||||
public struct MemberSubError: Decodable {
|
||||
var member: GroupMemberIds
|
||||
var member: GroupMember
|
||||
var memberError: ChatError
|
||||
}
|
||||
|
||||
@@ -1999,8 +1975,8 @@ public enum ConnectionEntity: Decodable {
|
||||
|
||||
public var ntfsEnabled: Bool {
|
||||
switch self {
|
||||
case let .rcvDirectMsgConnection(contact): return contact?.chatSettings.enableNtfs == .all
|
||||
case let .rcvGroupMsgConnection(groupInfo, _): return groupInfo.chatSettings.enableNtfs == .all
|
||||
case let .rcvDirectMsgConnection(contact): return contact?.chatSettings.enableNtfs ?? false
|
||||
case let .rcvGroupMsgConnection(groupInfo, _): return groupInfo.chatSettings.enableNtfs
|
||||
case .sndFileConnection: return false
|
||||
case .rcvFileConnection: return false
|
||||
case let .userContactConnection(userContact): return userContact.groupId == nil
|
||||
@@ -2090,7 +2066,7 @@ public struct ChatItem: Identifiable, Decodable {
|
||||
|
||||
public var memberConnected: GroupMember? {
|
||||
switch chatDir {
|
||||
case let .groupRcv(groupMember):
|
||||
case .groupRcv(let groupMember):
|
||||
switch content {
|
||||
case .rcvGroupEvent(rcvGroupEvent: .memberConnected): return groupMember
|
||||
default: return nil
|
||||
@@ -2099,35 +2075,6 @@ public struct ChatItem: Identifiable, Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public var mergeCategory: CIMergeCategory? {
|
||||
switch content {
|
||||
case .rcvChatFeature: .chatFeature
|
||||
case .sndChatFeature: .chatFeature
|
||||
case .rcvGroupFeature: .chatFeature
|
||||
case .sndGroupFeature: .chatFeature
|
||||
case let.rcvGroupEvent(event):
|
||||
switch event {
|
||||
case .userRole: nil
|
||||
case .userDeleted: nil
|
||||
case .groupDeleted: nil
|
||||
case .memberCreatedContact: nil
|
||||
default: .rcvGroupEvent
|
||||
}
|
||||
case let .sndGroupEvent(event):
|
||||
switch event {
|
||||
case .userRole: nil
|
||||
case .userLeft: nil
|
||||
default: .sndGroupEvent
|
||||
}
|
||||
default:
|
||||
if meta.itemDeleted == nil {
|
||||
nil
|
||||
} else {
|
||||
chatDir.sent ? .sndItemDeleted : .rcvItemDeleted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var showNtfDir: Bool {
|
||||
return !chatDir.sent
|
||||
}
|
||||
@@ -2144,7 +2091,6 @@ public struct ChatItem: Identifiable, Decodable {
|
||||
case .rcvDecryptionError: return showNtfDir
|
||||
case .rcvGroupInvitation: return showNtfDir
|
||||
case .sndGroupInvitation: return showNtfDir
|
||||
case .rcvDirectEvent: return false
|
||||
case .rcvGroupEvent(rcvGroupEvent: let rcvGroupEvent):
|
||||
switch rcvGroupEvent {
|
||||
case .groupUpdated: return false
|
||||
@@ -2205,7 +2151,7 @@ public struct ChatItem: Identifiable, Decodable {
|
||||
public var memberDisplayName: String? {
|
||||
get {
|
||||
if case let .groupRcv(groupMember) = chatDir {
|
||||
return groupMember.chatViewName
|
||||
return groupMember.displayName
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
@@ -2359,15 +2305,6 @@ public struct ChatItem: Identifiable, Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum CIMergeCategory {
|
||||
case memberConnected
|
||||
case rcvGroupEvent
|
||||
case sndGroupEvent
|
||||
case sndItemDeleted
|
||||
case rcvItemDeleted
|
||||
case chatFeature
|
||||
}
|
||||
|
||||
public enum CIDirection: Decodable {
|
||||
case directSnd
|
||||
case directRcv
|
||||
@@ -2546,13 +2483,11 @@ public enum SndCIStatusProgress: String, Decodable {
|
||||
|
||||
public enum CIDeleted: Decodable {
|
||||
case deleted(deletedTs: Date?)
|
||||
case blocked(deletedTs: Date?)
|
||||
case moderated(deletedTs: Date?, byGroupMember: GroupMember)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .deleted: return "deleted"
|
||||
case .blocked: return "blocked"
|
||||
case .moderated: return "moderated"
|
||||
}
|
||||
}
|
||||
@@ -2570,15 +2505,14 @@ protocol ItemContent {
|
||||
public enum CIContent: Decodable, ItemContent {
|
||||
case sndMsgContent(msgContent: MsgContent)
|
||||
case rcvMsgContent(msgContent: MsgContent)
|
||||
case sndDeleted(deleteMode: CIDeleteMode) // legacy - since v4.3.0 itemDeleted field is used
|
||||
case rcvDeleted(deleteMode: CIDeleteMode) // legacy - since v4.3.0 itemDeleted field is used
|
||||
case sndDeleted(deleteMode: CIDeleteMode)
|
||||
case rcvDeleted(deleteMode: CIDeleteMode)
|
||||
case sndCall(status: CICallStatus, duration: Int)
|
||||
case rcvCall(status: CICallStatus, duration: Int)
|
||||
case rcvIntegrityError(msgError: MsgErrorType)
|
||||
case rcvDecryptionError(msgDecryptError: MsgDecryptError, msgCount: UInt32)
|
||||
case rcvGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole)
|
||||
case sndGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole)
|
||||
case rcvDirectEvent(rcvDirectEvent: RcvDirectEvent)
|
||||
case rcvGroupEvent(rcvGroupEvent: RcvGroupEvent)
|
||||
case sndGroupEvent(sndGroupEvent: SndGroupEvent)
|
||||
case rcvConnEvent(rcvConnEvent: RcvConnEvent)
|
||||
@@ -2608,7 +2542,6 @@ public enum CIContent: Decodable, ItemContent {
|
||||
case let .rcvDecryptionError(msgDecryptError, _): return msgDecryptError.text
|
||||
case let .rcvGroupInvitation(groupInvitation, _): return groupInvitation.text
|
||||
case let .sndGroupInvitation(groupInvitation, _): return groupInvitation.text
|
||||
case let .rcvDirectEvent(rcvDirectEvent): return rcvDirectEvent.text
|
||||
case let .rcvGroupEvent(rcvGroupEvent): return rcvGroupEvent.text
|
||||
case let .sndGroupEvent(sndGroupEvent): return sndGroupEvent.text
|
||||
case let .rcvConnEvent(rcvConnEvent): return rcvConnEvent.text
|
||||
@@ -3108,7 +3041,7 @@ public enum Format: Decodable, Equatable {
|
||||
case secret
|
||||
case colored(color: FormatColor)
|
||||
case uri
|
||||
case simplexLink(linkType: SimplexLinkType, simplexUri: String, smpHosts: [String])
|
||||
case simplexLink(linkType: SimplexLinkType, simplexUri: String, trustedUri: Bool, smpHosts: [String])
|
||||
case email
|
||||
case phone
|
||||
}
|
||||
@@ -3262,16 +3195,6 @@ public enum CIGroupInvitationStatus: String, Decodable {
|
||||
case expired
|
||||
}
|
||||
|
||||
public enum RcvDirectEvent: Decodable {
|
||||
case contactDeleted
|
||||
|
||||
var text: String {
|
||||
switch self {
|
||||
case .contactDeleted: return NSLocalizedString("deleted contact", comment: "rcv direct event chat item")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum RcvGroupEvent: Decodable {
|
||||
case memberAdded(groupMemberId: Int64, profile: Profile)
|
||||
case memberConnected
|
||||
|
||||
@@ -17,14 +17,12 @@ typedef void* chat_ctrl;
|
||||
|
||||
// the last parameter is used to return the pointer to chat controller
|
||||
extern char *chat_migrate_init(char *path, char *key, char *confirm, chat_ctrl *ctrl);
|
||||
extern char *chat_close_store(chat_ctrl ctl);
|
||||
extern char *chat_send_cmd(chat_ctrl ctl, char *cmd);
|
||||
extern char *chat_recv_msg(chat_ctrl ctl);
|
||||
extern char *chat_recv_msg_wait(chat_ctrl ctl, int wait);
|
||||
extern char *chat_parse_markdown(char *str);
|
||||
extern char *chat_parse_server(char *str);
|
||||
extern char *chat_password_hash(char *pwd, char *salt);
|
||||
extern char *chat_valid_name(char *name);
|
||||
extern char *chat_encrypt_media(char *key, char *frame, int len);
|
||||
extern char *chat_decrypt_media(char *key, char *frame, int len);
|
||||
|
||||
|
||||
1
apps/multiplatform/.gitignore
vendored
1
apps/multiplatform/.gitignore
vendored
@@ -11,6 +11,7 @@
|
||||
local.properties
|
||||
common/src/commonMain/cpp/android/libs/
|
||||
common/src/commonMain/cpp/desktop/libs/
|
||||
desktop/src/jvmMain/resources/libs/
|
||||
android/build
|
||||
android/release
|
||||
common/build
|
||||
|
||||
@@ -77,7 +77,6 @@ android {
|
||||
}
|
||||
jniLibs.useLegacyPackaging = rootProject.extra["compression.level"] as Int != 0
|
||||
}
|
||||
android.sourceSets["main"].assets.setSrcDirs(listOf("../common/src/commonMain/resources/assets"))
|
||||
val isRelease = gradle.startParameter.taskNames.find { it.toLowerCase().contains("release") } != null
|
||||
val isBundle = gradle.startParameter.taskNames.find { it.toLowerCase().contains("bundle") } != null
|
||||
// if (isRelease) {
|
||||
|
||||
@@ -23,9 +23,6 @@ var TransformOperation;
|
||||
})(TransformOperation || (TransformOperation = {}));
|
||||
let activeCall;
|
||||
let answerTimeout = 30000;
|
||||
var useWorker = false;
|
||||
var localizedState = "";
|
||||
var localizedDescription = "";
|
||||
const processCommand = (function () {
|
||||
const defaultIceServers = [
|
||||
{ urls: ["stun:stun.simplex.im:443"] },
|
||||
@@ -41,9 +38,9 @@ const processCommand = (function () {
|
||||
iceTransportPolicy: relay ? "relay" : "all",
|
||||
},
|
||||
iceCandidates: {
|
||||
delay: 750,
|
||||
extrasInterval: 1500,
|
||||
extrasTimeout: 12000,
|
||||
delay: 3000,
|
||||
extrasInterval: 2000,
|
||||
extrasTimeout: 8000,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -84,8 +81,6 @@ const processCommand = (function () {
|
||||
if (delay)
|
||||
clearTimeout(delay);
|
||||
resolved = true;
|
||||
// console.log("resolveIceCandidates", JSON.stringify(candidates))
|
||||
console.log("resolveIceCandidates");
|
||||
const iceCandidates = serialize(candidates);
|
||||
candidates = [];
|
||||
resolve(iceCandidates);
|
||||
@@ -93,21 +88,19 @@ const processCommand = (function () {
|
||||
function sendIceCandidates() {
|
||||
if (candidates.length === 0)
|
||||
return;
|
||||
// console.log("sendIceCandidates", JSON.stringify(candidates))
|
||||
console.log("sendIceCandidates");
|
||||
const iceCandidates = serialize(candidates);
|
||||
candidates = [];
|
||||
sendMessageToNative({ resp: { type: "ice", iceCandidates } });
|
||||
}
|
||||
});
|
||||
}
|
||||
async function initializeCall(config, mediaType, aesKey) {
|
||||
async function initializeCall(config, mediaType, aesKey, useWorker) {
|
||||
const pc = new RTCPeerConnection(config.peerConnectionConfig);
|
||||
const remoteStream = new MediaStream();
|
||||
const localCamera = VideoCamera.User;
|
||||
const localStream = await getLocalMediaStream(mediaType, localCamera);
|
||||
const iceCandidates = getIceCandidates(pc, config);
|
||||
const call = { connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey };
|
||||
const call = { connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey, useWorker };
|
||||
await setupMediaStreams(call);
|
||||
let connectionTimeout = setTimeout(connectionHandler, answerTimeout);
|
||||
pc.addEventListener("connectionstatechange", connectionStateChange);
|
||||
@@ -185,17 +178,17 @@ const processCommand = (function () {
|
||||
// This request for local media stream is made to prompt for camera/mic permissions on call start
|
||||
if (command.media)
|
||||
await getLocalMediaStream(command.media, VideoCamera.User);
|
||||
const encryption = supportsInsertableStreams(useWorker);
|
||||
const encryption = supportsInsertableStreams(command.useWorker);
|
||||
resp = { type: "capabilities", capabilities: { encryption } };
|
||||
break;
|
||||
case "start": {
|
||||
console.log("starting incoming call - create webrtc session");
|
||||
if (activeCall)
|
||||
endCall();
|
||||
const { media, iceServers, relay } = command;
|
||||
const { media, useWorker, iceServers, relay } = command;
|
||||
const encryption = supportsInsertableStreams(useWorker);
|
||||
const aesKey = encryption ? command.aesKey : undefined;
|
||||
activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey);
|
||||
activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey, useWorker);
|
||||
const pc = activeCall.connection;
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
@@ -209,6 +202,7 @@ const processCommand = (function () {
|
||||
// iceServers,
|
||||
// relay,
|
||||
// aesKey,
|
||||
// useWorker,
|
||||
// }
|
||||
resp = {
|
||||
type: "offer",
|
||||
@@ -216,23 +210,21 @@ const processCommand = (function () {
|
||||
iceCandidates: await activeCall.iceCandidates,
|
||||
capabilities: { encryption },
|
||||
};
|
||||
// console.log("offer response", JSON.stringify(resp))
|
||||
break;
|
||||
}
|
||||
case "offer":
|
||||
if (activeCall) {
|
||||
resp = { type: "error", message: "accept: call already started" };
|
||||
}
|
||||
else if (!supportsInsertableStreams(useWorker) && command.aesKey) {
|
||||
else if (!supportsInsertableStreams(command.useWorker) && command.aesKey) {
|
||||
resp = { type: "error", message: "accept: encryption is not supported" };
|
||||
}
|
||||
else {
|
||||
const offer = parse(command.offer);
|
||||
const remoteIceCandidates = parse(command.iceCandidates);
|
||||
const { media, aesKey, iceServers, relay } = command;
|
||||
activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey);
|
||||
const { media, aesKey, useWorker, iceServers, relay } = command;
|
||||
activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey, useWorker);
|
||||
const pc = activeCall.connection;
|
||||
// console.log("offer remoteIceCandidates", JSON.stringify(remoteIceCandidates))
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(offer));
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
@@ -244,7 +236,6 @@ const processCommand = (function () {
|
||||
iceCandidates: await activeCall.iceCandidates,
|
||||
};
|
||||
}
|
||||
// console.log("answer response", JSON.stringify(resp))
|
||||
break;
|
||||
case "answer":
|
||||
if (!pc) {
|
||||
@@ -259,7 +250,6 @@ const processCommand = (function () {
|
||||
else {
|
||||
const answer = parse(command.answer);
|
||||
const remoteIceCandidates = parse(command.iceCandidates);
|
||||
// console.log("answer remoteIceCandidates", JSON.stringify(remoteIceCandidates))
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(answer));
|
||||
addIceCandidates(pc, remoteIceCandidates);
|
||||
resp = { type: "ok" };
|
||||
@@ -296,11 +286,6 @@ const processCommand = (function () {
|
||||
resp = { type: "ok" };
|
||||
}
|
||||
break;
|
||||
case "description":
|
||||
localizedState = command.state;
|
||||
localizedDescription = command.description;
|
||||
resp = { type: "ok" };
|
||||
break;
|
||||
case "end":
|
||||
endCall();
|
||||
resp = { type: "ok" };
|
||||
@@ -325,14 +310,12 @@ const processCommand = (function () {
|
||||
catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
shutdownCameraAndMic();
|
||||
activeCall = undefined;
|
||||
resetVideoElements();
|
||||
}
|
||||
function addIceCandidates(conn, iceCandidates) {
|
||||
for (const c of iceCandidates) {
|
||||
conn.addIceCandidate(new RTCIceCandidate(c));
|
||||
// console.log("addIceCandidates", JSON.stringify(c))
|
||||
}
|
||||
}
|
||||
async function setupMediaStreams(call) {
|
||||
@@ -352,11 +335,11 @@ const processCommand = (function () {
|
||||
if (call.aesKey) {
|
||||
if (!call.key)
|
||||
call.key = await callCrypto.decodeAesKey(call.aesKey);
|
||||
if (useWorker && !call.worker) {
|
||||
if (call.useWorker && !call.worker) {
|
||||
const workerCode = `const callCrypto = (${callCryptoFunction.toString()})(); (${workerFunction.toString()})()`;
|
||||
call.worker = new Worker(URL.createObjectURL(new Blob([workerCode], { type: "text/javascript" })));
|
||||
call.worker.onerror = ({ error, filename, lineno, message }) => console.log({ error, filename, lineno, message });
|
||||
// call.worker.onmessage = ({data}) => console.log(JSON.stringify({message: data}))
|
||||
call.worker.onerror = ({ error, filename, lineno, message }) => console.log(JSON.stringify({ error, filename, lineno, message }));
|
||||
call.worker.onmessage = ({ data }) => console.log(JSON.stringify({ message: data }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -496,11 +479,6 @@ const processCommand = (function () {
|
||||
return (("createEncodedStreams" in RTCRtpSender.prototype && "createEncodedStreams" in RTCRtpReceiver.prototype) ||
|
||||
(!!useWorker && "RTCRtpScriptTransform" in window));
|
||||
}
|
||||
function shutdownCameraAndMic() {
|
||||
if (activeCall === null || activeCall === void 0 ? void 0 : activeCall.localStream) {
|
||||
activeCall.localStream.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
}
|
||||
function resetVideoElements() {
|
||||
const videos = getVideoElements();
|
||||
if (!videos)
|
||||
@@ -529,15 +507,6 @@ const processCommand = (function () {
|
||||
}
|
||||
return processCommand;
|
||||
})();
|
||||
function toggleMedia(s, media) {
|
||||
let res = false;
|
||||
const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks();
|
||||
for (const t of tracks) {
|
||||
t.enabled = !t.enabled;
|
||||
res = t.enabled;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
// Cryptography function - it is loaded both in the main window and in worker context (if the worker is used)
|
||||
function callCryptoFunction() {
|
||||
const initialPlainTextRequired = {
|
||||
@@ -91,11 +91,11 @@ class MainActivity: FragmentActivity() {
|
||||
// When pressed Back and there is no one wants to process the back event, clear auth state to force re-auth on launch
|
||||
AppLock.clearAuthState()
|
||||
AppLock.laFailed.value = true
|
||||
AppLock.destroyedAfterBackPress.value = true
|
||||
}
|
||||
if (!onBackPressedDispatcher.hasEnabledCallbacks()) {
|
||||
// Drop shared content
|
||||
SimplexApp.context.chatModel.sharedContent.value = null
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,7 +143,6 @@ fun processExternalIntent(intent: Intent?) {
|
||||
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
val uri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri
|
||||
if (uri != null) {
|
||||
if (uri.scheme != "content") return showNonContentUriAlert()
|
||||
// Shared file that contains plain text, like `*.log` file
|
||||
chatModel.sharedContent.value = SharedContent.File(text ?: "", uri.toURI())
|
||||
} else if (text != null) {
|
||||
@@ -154,14 +153,12 @@ fun processExternalIntent(intent: Intent?) {
|
||||
isMediaIntent(intent) -> {
|
||||
val uri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri
|
||||
if (uri != null) {
|
||||
if (uri.scheme != "content") return showNonContentUriAlert()
|
||||
chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", listOf(uri.toURI()))
|
||||
} // All other mime types
|
||||
}
|
||||
else -> {
|
||||
val uri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri
|
||||
if (uri != null) {
|
||||
if (uri.scheme != "content") return showNonContentUriAlert()
|
||||
chatModel.sharedContent.value = SharedContent.File(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uri.toURI())
|
||||
}
|
||||
}
|
||||
@@ -176,7 +173,6 @@ fun processExternalIntent(intent: Intent?) {
|
||||
isMediaIntent(intent) -> {
|
||||
val uris = intent.getParcelableArrayListExtra<Parcelable>(Intent.EXTRA_STREAM) as? List<Uri>
|
||||
if (uris != null) {
|
||||
if (uris.any { it.scheme != "content" }) return showNonContentUriAlert()
|
||||
chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uris.map { it.toURI() })
|
||||
} // All other mime types
|
||||
}
|
||||
@@ -189,13 +185,6 @@ fun processExternalIntent(intent: Intent?) {
|
||||
fun isMediaIntent(intent: Intent): Boolean =
|
||||
intent.type?.startsWith("image/") == true || intent.type?.startsWith("video/") == true
|
||||
|
||||
private fun showNonContentUriAlert() {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.non_content_uri_alert_title),
|
||||
text = generalGetString(MR.strings.non_content_uri_alert_text)
|
||||
)
|
||||
}
|
||||
|
||||
//fun testJson() {
|
||||
// val str: String = """
|
||||
// """.trimIndent()
|
||||
|
||||
@@ -9,14 +9,12 @@ import chat.simplex.common.helpers.APPLICATION_ID
|
||||
import chat.simplex.common.helpers.requiresIgnoringBattery
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.model.ChatModel.updatingChatsMutex
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.call.RcvCallInvitation
|
||||
import com.jakewharton.processphoenix.ProcessPhoenix
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.io.*
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -54,23 +52,21 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
Lifecycle.Event.ON_START -> {
|
||||
isAppOnForeground = true
|
||||
if (chatModel.chatRunning.value == true) {
|
||||
updatingChatsMutex.withLock {
|
||||
kotlin.runCatching {
|
||||
val currentUserId = chatModel.currentUser.value?.userId
|
||||
val chats = ArrayList(chatController.apiGetChats())
|
||||
/** Active user can be changed in background while [ChatController.apiGetChats] is executing */
|
||||
if (chatModel.currentUser.value?.userId == currentUserId) {
|
||||
val currentChatId = chatModel.chatId.value
|
||||
val oldStats = if (currentChatId != null) chatModel.getChat(currentChatId)?.chatStats else null
|
||||
if (oldStats != null) {
|
||||
val indexOfCurrentChat = chats.indexOfFirst { it.id == currentChatId }
|
||||
/** Pass old chatStats because unreadCounter can be changed already while [ChatController.apiGetChats] is executing */
|
||||
if (indexOfCurrentChat >= 0) chats[indexOfCurrentChat] = chats[indexOfCurrentChat].copy(chatStats = oldStats)
|
||||
}
|
||||
chatModel.updateChats(chats)
|
||||
kotlin.runCatching {
|
||||
val currentUserId = chatModel.currentUser.value?.userId
|
||||
val chats = ArrayList(chatController.apiGetChats())
|
||||
/** Active user can be changed in background while [ChatController.apiGetChats] is executing */
|
||||
if (chatModel.currentUser.value?.userId == currentUserId) {
|
||||
val currentChatId = chatModel.chatId.value
|
||||
val oldStats = if (currentChatId != null) chatModel.getChat(currentChatId)?.chatStats else null
|
||||
if (oldStats != null) {
|
||||
val indexOfCurrentChat = chats.indexOfFirst { it.id == currentChatId }
|
||||
/** Pass old chatStats because unreadCounter can be changed already while [ChatController.apiGetChats] is executing */
|
||||
if (indexOfCurrentChat >= 0) chats[indexOfCurrentChat] = chats[indexOfCurrentChat].copy(chatStats = oldStats)
|
||||
}
|
||||
}.onFailure { Log.e(TAG, it.stackTraceToString()) }
|
||||
}
|
||||
chatModel.updateChats(chats)
|
||||
}
|
||||
}.onFailure { Log.e(TAG, it.stackTraceToString()) }
|
||||
}
|
||||
}
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
|
||||
@@ -97,9 +97,7 @@ kotlin {
|
||||
implementation("com.github.Dansoftowner:jSystemThemeDetector:3.6")
|
||||
implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT")
|
||||
implementation("org.slf4j:slf4j-simple:2.0.7")
|
||||
implementation("uk.co.caprica:vlcj:4.7.3")
|
||||
implementation("com.github.NanoHttpd.nanohttpd:nanohttpd:efb2ebf85a")
|
||||
implementation("com.github.NanoHttpd.nanohttpd:nanohttpd-websocket:efb2ebf85a")
|
||||
implementation("uk.co.caprica:vlcj:4.7.0")
|
||||
}
|
||||
}
|
||||
val desktopTest by getting
|
||||
|
||||
@@ -50,7 +50,6 @@ actual fun PlatformTextField(
|
||||
userIsObserver: Boolean,
|
||||
onMessageChange: (String) -> Unit,
|
||||
onUpArrow: () -> Unit,
|
||||
onFilesPasted: (List<URI>) -> Unit,
|
||||
onDone: () -> Unit,
|
||||
) {
|
||||
val cs = composeState.value
|
||||
|
||||
@@ -69,5 +69,3 @@ actual fun hideKeyboard(view: Any?) {
|
||||
(androidAppContext.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(view.windowToken, 0)
|
||||
}
|
||||
}
|
||||
|
||||
actual fun androidIsFinishingMainActivity(): Boolean = (mainActivity.get()?.isFinishing == true)
|
||||
|
||||
@@ -18,7 +18,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -44,9 +43,6 @@ import chat.simplex.res.MR
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
||||
@@ -56,7 +52,7 @@ actual fun ActiveCallView() {
|
||||
val chatModel = ChatModel
|
||||
BackHandler(onBack = {
|
||||
val call = chatModel.activeCall.value
|
||||
if (call != null) withBGApi { chatModel.callManager.endCall(call) }
|
||||
if (call != null) withApi { chatModel.callManager.endCall(call) }
|
||||
})
|
||||
val audioViaBluetooth = rememberSaveable { mutableStateOf(false) }
|
||||
val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE }
|
||||
@@ -116,30 +112,30 @@ actual fun ActiveCallView() {
|
||||
if (call != null) {
|
||||
Log.d(TAG, "has active call $call")
|
||||
when (val r = apiMsg.resp) {
|
||||
is WCallResponse.Capabilities -> withBGApi {
|
||||
is WCallResponse.Capabilities -> withApi {
|
||||
val callType = CallType(call.localMedia, r.capabilities)
|
||||
chatModel.controller.apiSendCallInvitation(call.contact, callType)
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities)
|
||||
}
|
||||
is WCallResponse.Offer -> withBGApi {
|
||||
is WCallResponse.Offer -> withApi {
|
||||
chatModel.controller.apiSendCallOffer(call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities)
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities)
|
||||
}
|
||||
is WCallResponse.Answer -> withBGApi {
|
||||
is WCallResponse.Answer -> withApi {
|
||||
chatModel.controller.apiSendCallAnswer(call.contact, r.answer, r.iceCandidates)
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
|
||||
}
|
||||
is WCallResponse.Ice -> withBGApi {
|
||||
is WCallResponse.Ice -> withApi {
|
||||
chatModel.controller.apiSendCallExtraInfo(call.contact, r.iceCandidates)
|
||||
}
|
||||
is WCallResponse.Connection ->
|
||||
try {
|
||||
val callStatus = json.decodeFromString<WebRTCCallStatus>("\"${r.state.connectionState}\"")
|
||||
if (callStatus == WebRTCCallStatus.Connected) {
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now())
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Connected)
|
||||
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
||||
}
|
||||
withBGApi { chatModel.controller.apiCallStatus(call.contact, callStatus) }
|
||||
withApi { chatModel.controller.apiCallStatus(call.contact, callStatus) }
|
||||
} catch (e: Error) {
|
||||
Log.d(TAG,"call status ${r.state.connectionState} not used")
|
||||
}
|
||||
@@ -149,12 +145,9 @@ actual fun ActiveCallView() {
|
||||
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
||||
}
|
||||
}
|
||||
is WCallResponse.End -> {
|
||||
withBGApi { chatModel.callManager.endCall(call) }
|
||||
}
|
||||
is WCallResponse.Ended -> {
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Ended)
|
||||
withBGApi { chatModel.callManager.endCall(call) }
|
||||
withApi { chatModel.callManager.endCall(call) }
|
||||
chatModel.showCallView.value = false
|
||||
}
|
||||
is WCallResponse.Ok -> when (val cmd = apiMsg.command) {
|
||||
@@ -169,7 +162,7 @@ actual fun ActiveCallView() {
|
||||
is WCallCommand.Camera -> {
|
||||
chatModel.activeCall.value = call.copy(localCamera = cmd.camera)
|
||||
if (!call.audioEnabled) {
|
||||
chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = false))
|
||||
chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = false)
|
||||
}
|
||||
}
|
||||
is WCallCommand.End ->
|
||||
@@ -194,14 +187,11 @@ actual fun ActiveCallView() {
|
||||
// Lock orientation to portrait in order to have good experience with calls
|
||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
chatModel.activeCallViewIsVisible.value = true
|
||||
// After the first call, End command gets added to the list which prevents making another calls
|
||||
chatModel.callCommand.removeAll { it is WCallCommand.End }
|
||||
onDispose {
|
||||
activity.volumeControlStream = prevVolumeControlStream
|
||||
// Unlock orientation
|
||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
chatModel.activeCallViewIsVisible.value = false
|
||||
chatModel.callCommand.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,9 +201,9 @@ private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetoot
|
||||
ActiveCallOverlayLayout(
|
||||
call = call,
|
||||
speakerCanBeEnabled = !audioViaBluetooth.value,
|
||||
dismiss = { withBGApi { chatModel.callManager.endCall(call) } },
|
||||
toggleAudio = { chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled)) },
|
||||
toggleVideo = { chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled)) },
|
||||
dismiss = { withApi { chatModel.callManager.endCall(call) } },
|
||||
toggleAudio = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled) },
|
||||
toggleVideo = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled) },
|
||||
toggleSound = {
|
||||
var call = chatModel.activeCall.value
|
||||
if (call != null) {
|
||||
@@ -222,7 +212,7 @@ private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetoot
|
||||
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
||||
}
|
||||
},
|
||||
flipCamera = { chatModel.callCommand.add(WCallCommand.Camera(call.localCamera.flipped)) }
|
||||
flipCamera = { chatModel.callCommand.value = WCallCommand.Camera(call.localCamera.flipped) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -449,7 +439,7 @@ private fun DisabledBackgroundCallsButton() {
|
||||
//}
|
||||
|
||||
@Composable
|
||||
fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIMessage) -> Unit) {
|
||||
fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessage) -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val webView = remember { mutableStateOf<WebView?>(null) }
|
||||
val permissionsState = rememberMultiplePermissionsState(
|
||||
@@ -480,19 +470,13 @@ fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIM
|
||||
webView.value = null
|
||||
}
|
||||
}
|
||||
val wv = webView.value
|
||||
if (wv != null) {
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { callCommand.firstOrNull() }
|
||||
.distinctUntilChanged()
|
||||
.filterNotNull()
|
||||
.collect {
|
||||
while (callCommand.isNotEmpty()) {
|
||||
val cmd = callCommand.removeFirst()
|
||||
Log.d(TAG, "WebRTCView LaunchedEffect executing $cmd")
|
||||
processCommand(wv, cmd)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(callCommand.value, webView.value) {
|
||||
val cmd = callCommand.value
|
||||
val wv = webView.value
|
||||
if (cmd != null && wv != null) {
|
||||
Log.d(TAG, "WebRTCView LaunchedEffect executing $cmd")
|
||||
processCommand(wv, cmd)
|
||||
callCommand.value = null
|
||||
}
|
||||
}
|
||||
val assetLoader = WebViewAssetLoader.Builder()
|
||||
@@ -518,7 +502,7 @@ fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIM
|
||||
}
|
||||
}
|
||||
}
|
||||
this.webViewClient = LocalContentWebViewClient(webView, assetLoader)
|
||||
this.webViewClient = LocalContentWebViewClient(assetLoader)
|
||||
this.clearHistory()
|
||||
this.clearCache(true)
|
||||
this.addJavascriptInterface(WebRTCInterface(onResponse), "WebRTCInterface")
|
||||
@@ -528,10 +512,19 @@ fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIM
|
||||
webViewSettings.javaScriptEnabled = true
|
||||
webViewSettings.mediaPlaybackRequiresUserGesture = false
|
||||
webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE
|
||||
this.loadUrl("file:android_asset/www/android/call.html")
|
||||
this.loadUrl("file:android_asset/www/call.html")
|
||||
}
|
||||
}
|
||||
) { /* WebView */ }
|
||||
) { wv ->
|
||||
Log.d(TAG, "WebRTCView: webview ready")
|
||||
// for debugging
|
||||
// wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
|
||||
scope.launch {
|
||||
delay(2000L)
|
||||
wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
|
||||
webView.value = wv
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -546,28 +539,19 @@ class WebRTCInterface(private val onResponse: (WVAPIMessage) -> Unit) {
|
||||
// for debugging
|
||||
// onResponse(message)
|
||||
onResponse(json.decodeFromString(message))
|
||||
} catch (e: Exception) {
|
||||
} catch (e: Error) {
|
||||
Log.e(TAG, "failed parsing WebView message: $message")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class LocalContentWebViewClient(val webView: MutableState<WebView?>, private val assetLoader: WebViewAssetLoader) : WebViewClientCompat() {
|
||||
private class LocalContentWebViewClient(private val assetLoader: WebViewAssetLoader) : WebViewClientCompat() {
|
||||
override fun shouldInterceptRequest(
|
||||
view: WebView,
|
||||
request: WebResourceRequest
|
||||
): WebResourceResponse? {
|
||||
return assetLoader.shouldInterceptRequest(request.url)
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
super.onPageFinished(view, url)
|
||||
view.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
|
||||
webView.value = view
|
||||
Log.d(TAG, "WebRTCView: webview ready")
|
||||
// for debugging
|
||||
// view.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
|
||||
@@ -5,8 +5,6 @@ import android.os.Build
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.platform.ClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.ChatItem
|
||||
@@ -43,7 +41,3 @@ actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserL
|
||||
showMenu.value = false
|
||||
})
|
||||
}
|
||||
|
||||
actual fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) {
|
||||
clipboard.setText(AnnotatedString(cItem.content.text))
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
package chat.simplex.common.views.chatlist
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.views.helpers.*
|
||||
|
||||
@Composable
|
||||
actual fun ChatListNavLinkLayout(
|
||||
chatLinkPreview: @Composable () -> Unit,
|
||||
click: () -> Unit,
|
||||
dropdownMenuItems: (@Composable () -> Unit)?,
|
||||
showMenu: MutableState<Boolean>,
|
||||
stopped: Boolean,
|
||||
selectedChat: State<Boolean>
|
||||
) {
|
||||
var modifier = Modifier.fillMaxWidth()
|
||||
if (!stopped) modifier = modifier
|
||||
.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
|
||||
.onRightClick { showMenu.value = true }
|
||||
Box(modifier) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp, top = 8.dp, end = 12.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
chatLinkPreview()
|
||||
}
|
||||
if (dropdownMenuItems != null) {
|
||||
DefaultDropdownMenu(showMenu, dropdownMenuItems = dropdownMenuItems)
|
||||
}
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package chat.simplex.common.views.chatlist
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
@Composable
|
||||
actual fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow<AnimatedViewState>) {}
|
||||
@@ -46,7 +46,6 @@ extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
|
||||
extern char *chat_parse_markdown(const char *str);
|
||||
extern char *chat_parse_server(const char *str);
|
||||
extern char *chat_password_hash(const char *pwd, const char *salt);
|
||||
extern char *chat_valid_name(const char *name);
|
||||
extern char *chat_write_file(const char *path, char *ptr, int length);
|
||||
extern char *chat_read_file(const char *path, const char *key, const char *nonce);
|
||||
extern char *chat_encrypt_file(const char *from_path, const char *to_path);
|
||||
@@ -122,14 +121,6 @@ Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, __unused
|
||||
return res;
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz, jstring name) {
|
||||
const char *_name = (*env)->GetStringUTFChars(env, name, JNI_FALSE);
|
||||
jstring res = (*env)->NewStringUTF(env, chat_valid_name(_name));
|
||||
(*env)->ReleaseStringUTFChars(env, name, _name);
|
||||
return res;
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jstring path, jobject buffer) {
|
||||
const char *_path = (*env)->GetStringUTFChars(env, path, JNI_FALSE);
|
||||
|
||||
@@ -31,9 +31,9 @@ else()
|
||||
set(CMAKE_BUILD_RPATH "@loader_path")
|
||||
endif()
|
||||
|
||||
if(${CMAKE_SYSTEM_PROCESSOR} MATCHES "amd64" OR ${CMAKE_SYSTEM_PROCESSOR} MATCHES "AMD64")
|
||||
if(${CMAKE_SYSTEM_PROCESSOR} MATCHES "amd64")
|
||||
set(OS_LIB_ARCH "x86_64")
|
||||
elseif(${CMAKE_SYSTEM_PROCESSOR} MATCHES "arm64" OR ${CMAKE_SYSTEM_PROCESSOR} MATCHES "ARM64")
|
||||
elseif(${CMAKE_SYSTEM_PROCESSOR} MATCHES "arm64")
|
||||
set(OS_LIB_ARCH "aarch64")
|
||||
else()
|
||||
set(OS_LIB_ARCH "${CMAKE_SYSTEM_PROCESSOR}")
|
||||
@@ -54,13 +54,9 @@ add_library( # Sets the name of the library.
|
||||
simplex-api.c)
|
||||
|
||||
add_library( simplex SHARED IMPORTED )
|
||||
if(WIN32)
|
||||
FILE(GLOB SIMPLEXLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/lib*simplex*.${OS_LIB_EXT})
|
||||
set_target_properties( simplex PROPERTIES IMPORTED_IMPLIB ${SIMPLEXLIB})
|
||||
else()
|
||||
FILE(GLOB SIMPLEXLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/lib*simplex-chat*.${OS_LIB_EXT})
|
||||
set_target_properties( simplex PROPERTIES IMPORTED_LOCATION ${SIMPLEXLIB})
|
||||
endif()
|
||||
# Lib has different name because of version, find it
|
||||
FILE(GLOB SIMPLEXLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/libHSsimplex-chat-*.${OS_LIB_EXT})
|
||||
set_target_properties( simplex PROPERTIES IMPORTED_LOCATION ${SIMPLEXLIB})
|
||||
|
||||
|
||||
# Specifies libraries CMake should link to your target library. You
|
||||
@@ -71,17 +67,12 @@ if(NOT APPLE)
|
||||
else()
|
||||
# Without direct linking it can't find hs_init in linking step
|
||||
add_library( rts SHARED IMPORTED )
|
||||
FILE(GLOB RTSLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/libHSrts*_thr-*.${OS_LIB_EXT})
|
||||
FILE(GLOB RTSLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/deps/libHSrts_thr-*.${OS_LIB_EXT})
|
||||
set_target_properties( rts PROPERTIES IMPORTED_LOCATION ${RTSLIB})
|
||||
|
||||
target_link_libraries(app-lib rts simplex)
|
||||
endif()
|
||||
|
||||
if(APPLE)
|
||||
add_custom_command(TARGET app-lib POST_BUILD
|
||||
COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/patch-libapp-mac.sh
|
||||
)
|
||||
endif()
|
||||
|
||||
|
||||
# Trying to copy resulting files into needed directory, but none of these work for some reason. This could allow to
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
lib=libapp-lib.dylib
|
||||
RPATHS=$(otool -l $lib | grep -E '/Users|/opt/|/usr/local' | cut -d' ' -f11)
|
||||
for RPATH in $RPATHS; do
|
||||
install_name_tool -delete_rpath $RPATH $lib
|
||||
done
|
||||
@@ -21,7 +21,6 @@ extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
|
||||
extern char *chat_parse_markdown(const char *str);
|
||||
extern char *chat_parse_server(const char *str);
|
||||
extern char *chat_password_hash(const char *pwd, const char *salt);
|
||||
extern char *chat_valid_name(const char *name);
|
||||
extern char *chat_write_file(const char *path, char *ptr, int length);
|
||||
extern char *chat_read_file(const char *path, const char *key, const char *nonce);
|
||||
extern char *chat_encrypt_file(const char *from_path, const char *to_path);
|
||||
@@ -76,7 +75,7 @@ Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, jclass cla
|
||||
jstring res = decode_to_utf8_string(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl));
|
||||
(*env)->ReleaseStringUTFChars(env, dbPath, _dbPath);
|
||||
(*env)->ReleaseStringUTFChars(env, dbKey, _dbKey);
|
||||
(*env)->ReleaseStringUTFChars(env, confirm, _confirm);
|
||||
(*env)->ReleaseStringUTFChars(env, dbKey, _confirm);
|
||||
|
||||
// Creating array of Object's (boxed values can be passed, eg. Long instead of long)
|
||||
jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL);
|
||||
@@ -134,14 +133,6 @@ Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, jclass cl
|
||||
return res;
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz, jstring name) {
|
||||
const char *_name = encode_to_utf8_chars(env, name);
|
||||
jstring res = decode_to_utf8_string(env, chat_valid_name(_name));
|
||||
(*env)->ReleaseStringUTFChars(env, name, _name);
|
||||
return res;
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jstring path, jobject buffer) {
|
||||
const char *_path = encode_to_utf8_chars(env, path);
|
||||
|
||||
@@ -17,7 +17,6 @@ import chat.simplex.common.views.usersettings.SetDeliveryReceiptsView
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.CreateFirstProfile
|
||||
import chat.simplex.common.views.helpers.SimpleButton
|
||||
import chat.simplex.common.views.SplashView
|
||||
import chat.simplex.common.views.call.ActiveCallView
|
||||
@@ -136,7 +135,7 @@ fun MainScreen() {
|
||||
ModalManager.fullscreen.showInView()
|
||||
}
|
||||
}
|
||||
onboarding == OnboardingStage.Step2_CreateProfile -> CreateFirstProfile(chatModel) {}
|
||||
onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel) {}
|
||||
onboarding == OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel)
|
||||
onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel)
|
||||
onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel)
|
||||
@@ -150,7 +149,7 @@ fun MainScreen() {
|
||||
LaunchedEffect(Unit) {
|
||||
// With these constrains when user presses back button while on ChatList, activity destroys and shows auth request
|
||||
// while the screen moves to a launcher. Detect it and prevent showing the auth
|
||||
if (!(androidIsFinishingMainActivity() && chatModel.controller.appPrefs.laMode.get() == LAMode.SYSTEM)) {
|
||||
if (!(AppLock.destroyedAfterBackPress.value && chatModel.controller.appPrefs.laMode.get() == LAMode.SYSTEM)) {
|
||||
AppLock.runAuthenticate()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ object AppLock {
|
||||
|
||||
// Remember result and show it after orientation change
|
||||
val laFailed = mutableStateOf(false)
|
||||
val destroyedAfterBackPress = mutableStateOf(false)
|
||||
|
||||
fun clearAuthState() {
|
||||
userAuthorized.value = null
|
||||
|
||||
@@ -6,17 +6,17 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.*
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.call.*
|
||||
import chat.simplex.common.views.chat.ComposeState
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.ImageResource
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.datetime.*
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.serialization.*
|
||||
@@ -53,7 +53,6 @@ object ChatModel {
|
||||
// current chat
|
||||
val chatId = mutableStateOf<String?>(null)
|
||||
val chatItems = mutableStateListOf<ChatItem>()
|
||||
val chatItemStatuses = mutableMapOf<Long, CIStatus>()
|
||||
val groupMembers = mutableStateListOf<GroupMember>()
|
||||
|
||||
val terminalItems = mutableStateListOf<TerminalItem>()
|
||||
@@ -88,7 +87,7 @@ object ChatModel {
|
||||
val activeCallInvitation = mutableStateOf<RcvCallInvitation?>(null)
|
||||
val activeCall = mutableStateOf<Call?>(null)
|
||||
val activeCallViewIsVisible = mutableStateOf<Boolean>(false)
|
||||
val callCommand = mutableStateListOf<WCallCommand>()
|
||||
val callCommand = mutableStateOf<WCallCommand?>(null)
|
||||
val showCallView = mutableStateOf(false)
|
||||
val switchingCall = mutableStateOf(false)
|
||||
|
||||
@@ -104,8 +103,6 @@ object ChatModel {
|
||||
val filesToDelete = mutableSetOf<File>()
|
||||
val simplexLinkMode by lazy { mutableStateOf(ChatController.appPrefs.simplexLinkMode.get()) }
|
||||
|
||||
var updatingChatsMutex: Mutex = Mutex()
|
||||
|
||||
fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) {
|
||||
currentUser.value
|
||||
} else {
|
||||
@@ -136,7 +133,6 @@ object ChatModel {
|
||||
fun hasChat(id: String): Boolean = chats.toList().firstOrNull { it.id == id } != null
|
||||
fun getChat(id: String): Chat? = chats.toList().firstOrNull { it.id == id }
|
||||
fun getContactChat(contactId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId }
|
||||
fun getGroupChat(groupId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Group && it.chatInfo.apiId == groupId }
|
||||
private fun getChatIndex(id: String): Int = chats.toList().indexOfFirst { it.id == id }
|
||||
fun addChat(chat: Chat) = chats.add(index = 0, chat)
|
||||
|
||||
@@ -203,7 +199,7 @@ object ChatModel {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addChatItem(cInfo: ChatInfo, cItem: ChatItem) = updatingChatsMutex.withLock {
|
||||
suspend fun addChatItem(cInfo: ChatInfo, cItem: ChatItem) {
|
||||
// update previews
|
||||
val i = getChatIndex(cInfo.id)
|
||||
val chat: Chat
|
||||
@@ -226,25 +222,22 @@ object ChatModel {
|
||||
} else {
|
||||
addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf(cItem)))
|
||||
}
|
||||
Log.d(TAG, "TODOCHAT: addChatItem: adding to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
|
||||
withContext(Dispatchers.Main) {
|
||||
// add to current chat
|
||||
if (chatId.value == cInfo.id) {
|
||||
Log.d(TAG, "TODOCHAT: addChatItem: chatIds are equal, size ${chatItems.size}")
|
||||
// add to current chat
|
||||
if (chatId.value == cInfo.id) {
|
||||
withContext(Dispatchers.Main) {
|
||||
// Prevent situation when chat item already in the list received from backend
|
||||
if (chatItems.none { it.id == cItem.id }) {
|
||||
if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
|
||||
chatItems.add(kotlin.math.max(0, chatItems.lastIndex), cItem)
|
||||
} else {
|
||||
chatItems.add(cItem)
|
||||
Log.d(TAG, "TODOCHAT: addChatItem: added to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun upsertChatItem(cInfo: ChatInfo, cItem: ChatItem): Boolean = updatingChatsMutex.withLock {
|
||||
suspend fun upsertChatItem(cInfo: ChatInfo, cItem: ChatItem): Boolean {
|
||||
// update previews
|
||||
val i = getChatIndex(cInfo.id)
|
||||
val chat: Chat
|
||||
@@ -264,41 +257,30 @@ object ChatModel {
|
||||
addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf(cItem)))
|
||||
res = true
|
||||
}
|
||||
Log.d(TAG, "TODOCHAT: upsertChatItem: upserting to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
|
||||
return withContext(Dispatchers.Main) {
|
||||
// update current chat
|
||||
if (chatId.value == cInfo.id) {
|
||||
// update current chat
|
||||
return if (chatId.value == cInfo.id) {
|
||||
withContext(Dispatchers.Main) {
|
||||
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
|
||||
if (itemIndex >= 0) {
|
||||
chatItems[itemIndex] = cItem
|
||||
Log.d(TAG, "TODOCHAT: upsertChatItem: updated in chat $chatId from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
|
||||
false
|
||||
} else {
|
||||
val status = chatItemStatuses.remove(cItem.id)
|
||||
val ci = if (status != null && cItem.meta.itemStatus is CIStatus.SndNew) {
|
||||
cItem.copy(meta = cItem.meta.copy(itemStatus = status))
|
||||
} else {
|
||||
cItem
|
||||
}
|
||||
chatItems.add(ci)
|
||||
Log.d(TAG, "TODOCHAT: upsertChatItem: added to chat $chatId from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
|
||||
chatItems.add(cItem)
|
||||
true
|
||||
}
|
||||
} else {
|
||||
res
|
||||
}
|
||||
} else {
|
||||
res
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem, status: CIStatus? = null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (chatId.value == cInfo.id) {
|
||||
suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem) {
|
||||
if (chatId.value == cInfo.id) {
|
||||
withContext(Dispatchers.Main) {
|
||||
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
|
||||
if (itemIndex >= 0) {
|
||||
chatItems[itemIndex] = cItem
|
||||
}
|
||||
} else if (status != null) {
|
||||
chatItemStatuses[cItem.id] = status
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -336,7 +318,6 @@ object ChatModel {
|
||||
}
|
||||
// clear current chat
|
||||
if (chatId.value == cInfo.id) {
|
||||
chatItemStatuses.clear()
|
||||
chatItems.clear()
|
||||
}
|
||||
}
|
||||
@@ -393,7 +374,6 @@ object ChatModel {
|
||||
var markedRead = 0
|
||||
if (chatId.value == cInfo.id) {
|
||||
var i = 0
|
||||
Log.d(TAG, "TODOCHAT: markItemsReadInCurrentChat: marking read ${cInfo.id}, current chatId ${chatId.value}, size was ${chatItems.size}")
|
||||
while (i < chatItems.count()) {
|
||||
val item = chatItems[i]
|
||||
if (item.meta.itemStatus is CIStatus.RcvNew && (range == null || (range.from <= item.id && item.id <= range.to))) {
|
||||
@@ -408,7 +388,6 @@ object ChatModel {
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
Log.d(TAG, "TODOCHAT: markItemsReadInCurrentChat: marked read ${cInfo.id}, current chatId ${chatId.value}, size now ${chatItems.size}")
|
||||
}
|
||||
return markedRead
|
||||
}
|
||||
@@ -740,7 +719,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
||||
override val apiId get() = contactConnection.apiId
|
||||
override val ready get() = contactConnection.ready
|
||||
override val sendMsgEnabled get() = contactConnection.sendMsgEnabled
|
||||
override val ntfsEnabled get() = false
|
||||
override val ntfsEnabled get() = contactConnection.incognito
|
||||
override val incognito get() = contactConnection.incognito
|
||||
override fun featureEnabled(feature: ChatFeature) = contactConnection.featureEnabled(feature)
|
||||
override val timedMessagesTTL: Int? get() = contactConnection.timedMessagesTTL
|
||||
@@ -800,19 +779,16 @@ sealed class NetworkStatus {
|
||||
val statusExplanation: String get() =
|
||||
when (this) {
|
||||
is Connected -> generalGetString(MR.strings.connected_to_server_to_receive_messages_from_contact)
|
||||
is Error -> String.format(generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages_with_error), connectionError)
|
||||
is Error -> String.format(generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages_with_error), error)
|
||||
else -> generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages)
|
||||
}
|
||||
|
||||
@Serializable @SerialName("unknown") class Unknown: NetworkStatus()
|
||||
@Serializable @SerialName("connected") class Connected: NetworkStatus()
|
||||
@Serializable @SerialName("disconnected") class Disconnected: NetworkStatus()
|
||||
@Serializable @SerialName("error") class Error(val connectionError: String): NetworkStatus()
|
||||
@Serializable @SerialName("error") class Error(val error: String): NetworkStatus()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ConnNetworkStatus(val agentConnId: String, val networkStatus: NetworkStatus)
|
||||
|
||||
@Serializable
|
||||
data class Contact(
|
||||
val contactId: Long,
|
||||
@@ -821,7 +797,6 @@ data class Contact(
|
||||
val activeConn: Connection,
|
||||
val viaGroup: Long? = null,
|
||||
val contactUsed: Boolean,
|
||||
val contactStatus: ContactStatus,
|
||||
val chatSettings: ChatSettings,
|
||||
val userPreferences: ChatPreferences,
|
||||
val mergedPreferences: ContactUserPreferences,
|
||||
@@ -834,12 +809,11 @@ data class Contact(
|
||||
override val id get() = "@$contactId"
|
||||
override val apiId get() = contactId
|
||||
override val ready get() = activeConn.connStatus == ConnStatus.Ready
|
||||
val active get() = contactStatus == ContactStatus.Active
|
||||
override val sendMsgEnabled get() =
|
||||
(ready && active && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?: false))
|
||||
(ready && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?: false))
|
||||
|| nextSendGrpInv
|
||||
val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent
|
||||
override val ntfsEnabled get() = chatSettings.enableNtfs == MsgFilter.All
|
||||
override val ntfsEnabled get() = chatSettings.enableNtfs
|
||||
override val incognito get() = contactConnIncognito
|
||||
override fun featureEnabled(feature: ChatFeature) = when (feature) {
|
||||
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.enabled.forUser
|
||||
@@ -885,8 +859,7 @@ data class Contact(
|
||||
profile = LocalProfile.sampleData,
|
||||
activeConn = Connection.sampleData,
|
||||
contactUsed = true,
|
||||
contactStatus = ContactStatus.Active,
|
||||
chatSettings = ChatSettings(enableNtfs = MsgFilter.All, sendRcpts = null, favorite = false),
|
||||
chatSettings = ChatSettings(enableNtfs = true, sendRcpts = null, favorite = false),
|
||||
userPreferences = ChatPreferences.sampleData,
|
||||
mergedPreferences = ContactUserPreferences.sampleData,
|
||||
createdAt = Clock.System.now(),
|
||||
@@ -896,12 +869,6 @@ data class Contact(
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class ContactStatus {
|
||||
@SerialName("active") Active,
|
||||
@SerialName("deleted") Deleted;
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class ContactRef(
|
||||
val contactId: Long,
|
||||
@@ -1026,7 +993,7 @@ data class GroupInfo (
|
||||
override val apiId get() = groupId
|
||||
override val ready get() = membership.memberActive
|
||||
override val sendMsgEnabled get() = membership.memberActive
|
||||
override val ntfsEnabled get() = chatSettings.enableNtfs == MsgFilter.All
|
||||
override val ntfsEnabled get() = chatSettings.enableNtfs
|
||||
override val incognito get() = membership.memberIncognito
|
||||
override fun featureEnabled(feature: ChatFeature) = when (feature) {
|
||||
ChatFeature.TimedMessages -> fullGroupPreferences.timedMessages.on
|
||||
@@ -1058,16 +1025,13 @@ data class GroupInfo (
|
||||
fullGroupPreferences = FullGroupPreferences.sampleData,
|
||||
membership = GroupMember.sampleData,
|
||||
hostConnCustomUserProfileId = null,
|
||||
chatSettings = ChatSettings(enableNtfs = MsgFilter.All, sendRcpts = null, favorite = false),
|
||||
chatSettings = ChatSettings(enableNtfs = true, sendRcpts = null, favorite = false),
|
||||
createdAt = Clock.System.now(),
|
||||
updatedAt = Clock.System.now()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class GroupRef(val groupId: Long, val localDisplayName: String)
|
||||
|
||||
@Serializable
|
||||
data class GroupProfile (
|
||||
override val displayName: String,
|
||||
@@ -1093,7 +1057,6 @@ data class GroupMember (
|
||||
var memberRole: GroupMemberRole,
|
||||
var memberCategory: GroupMemberCategory,
|
||||
var memberStatus: GroupMemberStatus,
|
||||
var memberSettings: GroupMemberSettings,
|
||||
var invitedBy: InvitedBy,
|
||||
val localDisplayName: String,
|
||||
val memberProfile: LocalProfile,
|
||||
@@ -1161,7 +1124,6 @@ data class GroupMember (
|
||||
memberRole = GroupMemberRole.Member,
|
||||
memberCategory = GroupMemberCategory.InviteeMember,
|
||||
memberStatus = GroupMemberStatus.MemComplete,
|
||||
memberSettings = GroupMemberSettings(showMessages = true),
|
||||
invitedBy = InvitedBy.IBUser(),
|
||||
localDisplayName = "alice",
|
||||
memberProfile = LocalProfile.sampleData,
|
||||
@@ -1173,20 +1135,11 @@ data class GroupMember (
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class GroupMemberSettings(val showMessages: Boolean) {}
|
||||
|
||||
@Serializable
|
||||
data class GroupMemberRef(
|
||||
class GroupMemberRef(
|
||||
val groupMemberId: Long,
|
||||
val profile: Profile
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GroupMemberIds(
|
||||
val groupMemberId: Long,
|
||||
val groupId: Long
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class GroupMemberRole(val memberRole: String) {
|
||||
@SerialName("observer") Observer("observer"), // order matters in comparisons
|
||||
@@ -1280,7 +1233,7 @@ class LinkPreview (
|
||||
|
||||
@Serializable
|
||||
class MemberSubError (
|
||||
val member: GroupMemberIds,
|
||||
val member: GroupMember,
|
||||
val memberError: ChatError
|
||||
)
|
||||
|
||||
@@ -1518,7 +1471,6 @@ data class ChatItem (
|
||||
is CIContent.RcvDecryptionError -> showNtfDir
|
||||
is CIContent.RcvGroupInvitation -> showNtfDir
|
||||
is CIContent.SndGroupInvitation -> showNtfDir
|
||||
is CIContent.RcvDirectEventContent -> false
|
||||
is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) {
|
||||
is RcvGroupEvent.MemberAdded -> false
|
||||
is RcvGroupEvent.MemberConnected -> false
|
||||
@@ -1875,7 +1827,6 @@ enum class SndCIStatusProgress {
|
||||
@Serializable
|
||||
sealed class CIDeleted {
|
||||
@Serializable @SerialName("deleted") class Deleted(val deletedTs: Instant?): CIDeleted()
|
||||
@Serializable @SerialName("blocked") class Blocked(val deletedTs: Instant?): CIDeleted()
|
||||
@Serializable @SerialName("moderated") class Moderated(val deletedTs: Instant?, val byGroupMember: GroupMember): CIDeleted()
|
||||
}
|
||||
|
||||
@@ -1903,7 +1854,6 @@ sealed class CIContent: ItemContent {
|
||||
@Serializable @SerialName("rcvDecryptionError") class RcvDecryptionError(val msgDecryptError: MsgDecryptError, val msgCount: UInt): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvGroupInvitation") class RcvGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("sndGroupInvitation") class SndGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvDirectEvent") class RcvDirectEventContent(val rcvDirectEvent: RcvDirectEvent): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvGroupEvent") class RcvGroupEventContent(val rcvGroupEvent: RcvGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("sndGroupEvent") class SndGroupEventContent(val sndGroupEvent: SndGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@Serializable @SerialName("rcvConnEvent") class RcvConnEventContent(val rcvConnEvent: RcvConnEvent): CIContent() { override val msgContent: MsgContent? get() = null }
|
||||
@@ -1931,7 +1881,6 @@ sealed class CIContent: ItemContent {
|
||||
is RcvDecryptionError -> msgDecryptError.text
|
||||
is RcvGroupInvitation -> groupInvitation.text
|
||||
is SndGroupInvitation -> groupInvitation.text
|
||||
is RcvDirectEventContent -> rcvDirectEvent.text
|
||||
is RcvGroupEventContent -> rcvGroupEvent.text
|
||||
is SndGroupEventContent -> sndGroupEvent.text
|
||||
is RcvConnEventContent -> rcvConnEvent.text
|
||||
@@ -2427,7 +2376,7 @@ sealed class Format {
|
||||
@Serializable @SerialName("secret") class Secret: Format()
|
||||
@Serializable @SerialName("colored") class Colored(val color: FormatColor): Format()
|
||||
@Serializable @SerialName("uri") class Uri: Format()
|
||||
@Serializable @SerialName("simplexLink") class SimplexLink(val linkType: SimplexLinkType, val simplexUri: String, val smpHosts: List<String>): Format()
|
||||
@Serializable @SerialName("simplexLink") class SimplexLink(val linkType: SimplexLinkType, val simplexUri: String, val trustedUri: Boolean, val smpHosts: List<String>): Format()
|
||||
@Serializable @SerialName("email") class Email: Format()
|
||||
@Serializable @SerialName("phone") class Phone: Format()
|
||||
|
||||
@@ -2538,15 +2487,6 @@ sealed class MsgErrorType() {
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class RcvDirectEvent() {
|
||||
@Serializable @SerialName("contactDeleted") class ContactDeleted(): RcvDirectEvent()
|
||||
|
||||
val text: String get() = when (this) {
|
||||
is ContactDeleted -> generalGetString(MR.strings.rcv_direct_event_contact_deleted)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class RcvGroupEvent() {
|
||||
@Serializable @SerialName("memberAdded") class MemberAdded(val groupMemberId: Long, val profile: Profile): RcvGroupEvent()
|
||||
|
||||
@@ -4,7 +4,6 @@ import chat.simplex.common.views.helpers.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import chat.simplex.common.model.ChatModel.updatingChatsMutex
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
@@ -17,7 +16,6 @@ import com.charleskorn.kaml.YamlConfiguration
|
||||
import chat.simplex.res.MR
|
||||
import com.russhwolf.settings.Settings
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.*
|
||||
@@ -108,7 +106,6 @@ class AppPreferences {
|
||||
val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null)
|
||||
val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null)
|
||||
val developerTools = mkBoolPreference(SHARED_PREFS_DEVELOPER_TOOLS, false)
|
||||
val terminalAlwaysVisible = mkBoolPreference(SHARED_PREFS_TERMINAL_ALWAYS_VISIBLE, false)
|
||||
val networkUseSocksProxy = mkBoolPreference(SHARED_PREFS_NETWORK_USE_SOCKS_PROXY, false)
|
||||
val networkProxyHostPort = mkStrPreference(SHARED_PREFS_NETWORK_PROXY_HOST_PORT, "localhost:9050")
|
||||
private val _networkSessionMode = mkStrPreference(SHARED_PREFS_NETWORK_SESSION_MODE, TransportSessionMode.default.name)
|
||||
@@ -268,7 +265,6 @@ class AppPreferences {
|
||||
private const val SHARED_PREFS_ONBOARDING_STAGE = "OnboardingStage"
|
||||
private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart"
|
||||
private const val SHARED_PREFS_DEVELOPER_TOOLS = "DeveloperTools"
|
||||
private const val SHARED_PREFS_TERMINAL_ALWAYS_VISIBLE = "TerminalAlwaysVisible"
|
||||
private const val SHARED_PREFS_NETWORK_USE_SOCKS_PROXY = "NetworkUseSocksProxy"
|
||||
private const val SHARED_PREFS_NETWORK_PROXY_HOST_PORT = "NetworkProxyHostPort"
|
||||
private const val SHARED_PREFS_NETWORK_SESSION_MODE = "NetworkSessionMode"
|
||||
@@ -338,7 +334,6 @@ object ChatController {
|
||||
apiSetTempFolder(coreTmpDir.absolutePath)
|
||||
apiSetFilesFolder(appFilesDir.absolutePath)
|
||||
apiSetXFTPConfig(getXFTPCfg())
|
||||
apiSetEncryptLocalFiles(appPrefs.privacyEncryptLocalFiles.get())
|
||||
val justStarted = apiStartChat()
|
||||
val users = listUsers()
|
||||
chatModel.users.clear()
|
||||
@@ -352,10 +347,8 @@ object ChatController {
|
||||
startReceiver()
|
||||
Log.d(TAG, "startChat: started")
|
||||
} else {
|
||||
updatingChatsMutex.withLock {
|
||||
val chats = apiGetChats()
|
||||
chatModel.updateChats(chats)
|
||||
}
|
||||
val chats = apiGetChats()
|
||||
chatModel.updateChats(chats)
|
||||
Log.d(TAG, "startChat: running")
|
||||
}
|
||||
} catch (e: Error) {
|
||||
@@ -389,10 +382,8 @@ object ChatController {
|
||||
suspend fun getUserChatData() {
|
||||
chatModel.userAddress.value = apiGetUserAddress()
|
||||
chatModel.chatItemTTL.value = getChatItemTTL()
|
||||
updatingChatsMutex.withLock {
|
||||
val chats = apiGetChats()
|
||||
chatModel.updateChats(chats)
|
||||
}
|
||||
val chats = apiGetChats()
|
||||
chatModel.updateChats(chats)
|
||||
}
|
||||
|
||||
private fun startReceiver() {
|
||||
@@ -560,8 +551,6 @@ object ChatController {
|
||||
throw Error("apiSetXFTPConfig bad response: ${r.responseType} ${r.details}")
|
||||
}
|
||||
|
||||
suspend fun apiSetEncryptLocalFiles(enable: Boolean) = sendCommandOkResp(CC.ApiSetEncryptLocalFiles(enable))
|
||||
|
||||
suspend fun apiExportArchive(config: ArchiveConfig) {
|
||||
val r = sendCmd(CC.ApiExportArchive(config))
|
||||
if (r is CR.CmdOk) return
|
||||
@@ -855,14 +844,6 @@ object ChatController {
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiConnectPlan(connReq: String): ConnectionPlan? {
|
||||
val userId = kotlin.runCatching { currentUserId("apiConnectPlan") }.getOrElse { return null }
|
||||
val r = sendCmd(CC.APIConnectPlan(userId, connReq))
|
||||
if (r is CR.CRConnectionPlan) return r.connectionPlan
|
||||
Log.e(TAG, "apiConnectPlan bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiConnect(incognito: Boolean, connReq: String): Boolean {
|
||||
val userId = chatModel.currentUser.value?.userId ?: run {
|
||||
Log.e(TAG, "apiConnect: no current user")
|
||||
@@ -904,8 +885,8 @@ object ChatController {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiDeleteChat(type: ChatType, id: Long, notify: Boolean? = null): Boolean {
|
||||
val r = sendCmd(CC.ApiDeleteChat(type, id, notify))
|
||||
suspend fun apiDeleteChat(type: ChatType, id: Long): Boolean {
|
||||
val r = sendCmd(CC.ApiDeleteChat(type, id))
|
||||
when {
|
||||
r is CR.ContactDeleted && type == ChatType.Direct -> return true
|
||||
r is CR.ContactConnectionDeleted && type == ChatType.ContactConnection -> return true
|
||||
@@ -943,9 +924,6 @@ object ChatController {
|
||||
val r = sendCmd(CC.ApiUpdateProfile(userId, profile))
|
||||
if (r is CR.UserProfileNoChange) return profile to emptyList()
|
||||
if (r is CR.UserProfileUpdated) return r.toProfile to r.updateSummary.changedContacts
|
||||
if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.DuplicateName) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_create_user_duplicate_title), generalGetString(MR.strings.failed_to_create_user_duplicate_desc))
|
||||
}
|
||||
Log.e(TAG, "apiUpdateProfile bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
@@ -1096,13 +1074,6 @@ object ChatController {
|
||||
return r is CR.CmdOk
|
||||
}
|
||||
|
||||
suspend fun apiGetNetworkStatuses(): List<ConnNetworkStatus>? {
|
||||
val r = sendCmd(CC.ApiGetNetworkStatuses())
|
||||
if (r is CR.NetworkStatuses) return r.networkStatuses
|
||||
Log.e(TAG, "apiGetNetworkStatuses bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiChatRead(type: ChatType, id: Long, range: CC.ItemRange): Boolean {
|
||||
val r = sendCmd(CC.ApiChatRead(type, id, range))
|
||||
if (r is CR.CmdOk) return true
|
||||
@@ -1166,9 +1137,9 @@ object ChatController {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiNewGroup(incognito: Boolean, groupProfile: GroupProfile): GroupInfo? {
|
||||
suspend fun apiNewGroup(p: GroupProfile): GroupInfo? {
|
||||
val userId = kotlin.runCatching { currentUserId("apiNewGroup") }.getOrElse { return null }
|
||||
val r = sendCmd(CC.ApiNewGroup(userId, incognito, groupProfile))
|
||||
val r = sendCmd(CC.ApiNewGroup(userId, p))
|
||||
if (r is CR.GroupCreated) return r.groupInfo
|
||||
Log.e(TAG, "apiNewGroup bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
@@ -1341,13 +1312,6 @@ object ChatController {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendCommandOkResp(cmd: CC): Boolean {
|
||||
val r = sendCmd(cmd)
|
||||
val ok = r is CR.CmdOk
|
||||
if (!ok) apiErrorAlert(cmd.cmdType, generalGetString(MR.strings.error_alert_title), r)
|
||||
return ok
|
||||
}
|
||||
|
||||
suspend fun apiGetVersion(): CoreVersionInfo? {
|
||||
val r = sendCmd(CC.ShowVersion())
|
||||
return if (r is CR.VersionInfo) {
|
||||
@@ -1402,11 +1366,6 @@ object ChatController {
|
||||
chatModel.removeChat(r.connection.id)
|
||||
}
|
||||
}
|
||||
is CR.ContactDeletedByContact -> {
|
||||
if (active(r.user) && r.contact.directOrUsed) {
|
||||
chatModel.updateContact(r.contact)
|
||||
}
|
||||
}
|
||||
is CR.ContactConnected -> {
|
||||
if (active(r.user) && r.contact.directOrUsed) {
|
||||
chatModel.updateContact(r.contact)
|
||||
@@ -1443,11 +1402,6 @@ object ChatController {
|
||||
chatModel.updateChatInfo(cInfo)
|
||||
}
|
||||
}
|
||||
is CR.GroupMemberUpdated -> {
|
||||
if (active(r.user)) {
|
||||
chatModel.upsertGroupMember(r.groupInfo, r.toMember)
|
||||
}
|
||||
}
|
||||
is CR.ContactsMerged -> {
|
||||
if (active(r.user) && chatModel.hasChat(r.mergedContact.id)) {
|
||||
if (chatModel.chatId.value == r.mergedContact.id) {
|
||||
@@ -1458,6 +1412,12 @@ object ChatController {
|
||||
}
|
||||
is CR.ContactsSubscribed -> updateContactsStatus(r.contactRefs, NetworkStatus.Connected())
|
||||
is CR.ContactsDisconnected -> updateContactsStatus(r.contactRefs, NetworkStatus.Disconnected())
|
||||
is CR.ContactSubError -> {
|
||||
if (active(r.user)) {
|
||||
chatModel.updateContact(r.contact)
|
||||
}
|
||||
processContactSubError(r.contact, r.chatError)
|
||||
}
|
||||
is CR.ContactSubSummary -> {
|
||||
for (sub in r.contactSubscriptions) {
|
||||
if (active(r.user)) {
|
||||
@@ -1471,16 +1431,6 @@ object ChatController {
|
||||
}
|
||||
}
|
||||
}
|
||||
is CR.NetworkStatusResp -> {
|
||||
for (cId in r.connections) {
|
||||
chatModel.networkStatuses[cId] = r.networkStatus
|
||||
}
|
||||
}
|
||||
is CR.NetworkStatuses -> {
|
||||
for (s in r.networkStatuses) {
|
||||
chatModel.networkStatuses[s.agentConnId] = s.networkStatus
|
||||
}
|
||||
}
|
||||
is CR.NewChatItem -> {
|
||||
val cInfo = r.chatItem.chatInfo
|
||||
val cItem = r.chatItem.chatItem
|
||||
@@ -1505,8 +1455,11 @@ object ChatController {
|
||||
is CR.ChatItemStatusUpdated -> {
|
||||
val cInfo = r.chatItem.chatInfo
|
||||
val cItem = r.chatItem.chatItem
|
||||
if (!cItem.isDeletedContent && active(r.user)) {
|
||||
chatModel.updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus)
|
||||
if (!cItem.isDeletedContent) {
|
||||
val added = if (active(r.user)) chatModel.upsertChatItem(cInfo, cItem) else true
|
||||
if (added && cItem.showNotification) {
|
||||
ntfManager.notifyMessageReceived(r.user, cInfo, cItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
is CR.ChatItemUpdated ->
|
||||
@@ -1558,16 +1511,6 @@ object ChatController {
|
||||
chatModel.removeChat(r.hostContact.activeConn.id)
|
||||
}
|
||||
}
|
||||
is CR.GroupLinkConnecting -> {
|
||||
if (!active(r.user)) return
|
||||
|
||||
chatModel.updateGroup(r.groupInfo)
|
||||
val hostConn = r.hostMember.activeConn
|
||||
if (hostConn != null) {
|
||||
chatModel.dismissConnReqView(hostConn.id)
|
||||
chatModel.removeChat(hostConn.id)
|
||||
}
|
||||
}
|
||||
is CR.JoinedGroupMemberConnecting ->
|
||||
if (active(r.user)) {
|
||||
chatModel.upsertGroupMember(r.groupInfo, r.member)
|
||||
@@ -1665,25 +1608,25 @@ object ChatController {
|
||||
val useRelay = appPrefs.webrtcPolicyRelay.get()
|
||||
val iceServers = getIceServers()
|
||||
Log.d(TAG, ".callOffer iceServers $iceServers")
|
||||
chatModel.callCommand.add(WCallCommand.Offer(
|
||||
chatModel.callCommand.value = WCallCommand.Offer(
|
||||
offer = r.offer.rtcSession,
|
||||
iceCandidates = r.offer.rtcIceCandidates,
|
||||
media = r.callType.media,
|
||||
aesKey = r.sharedKey,
|
||||
iceServers = iceServers,
|
||||
relay = useRelay
|
||||
))
|
||||
)
|
||||
}
|
||||
}
|
||||
is CR.CallAnswer -> {
|
||||
withCall(r, r.contact) { call ->
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.AnswerReceived)
|
||||
chatModel.callCommand.add(WCallCommand.Answer(answer = r.answer.rtcSession, iceCandidates = r.answer.rtcIceCandidates))
|
||||
chatModel.callCommand.value = WCallCommand.Answer(answer = r.answer.rtcSession, iceCandidates = r.answer.rtcIceCandidates)
|
||||
}
|
||||
}
|
||||
is CR.CallExtraInfo -> {
|
||||
withCall(r, r.contact) { _ ->
|
||||
chatModel.callCommand.add(WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates))
|
||||
chatModel.callCommand.value = WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates)
|
||||
}
|
||||
}
|
||||
is CR.CallEnded -> {
|
||||
@@ -1692,7 +1635,7 @@ object ChatController {
|
||||
chatModel.callManager.reportCallRemoteEnded(invitation = invitation)
|
||||
}
|
||||
withCall(r, r.contact) { _ ->
|
||||
chatModel.callCommand.add(WCallCommand.End)
|
||||
chatModel.callCommand.value = WCallCommand.End
|
||||
withApi {
|
||||
chatModel.activeCall.value = null
|
||||
chatModel.showCallView.value = false
|
||||
@@ -1891,7 +1834,6 @@ sealed class CC {
|
||||
class SetTempFolder(val tempFolder: String): CC()
|
||||
class SetFilesFolder(val filesFolder: String): CC()
|
||||
class ApiSetXFTPConfig(val config: XFTPFileConfig?): CC()
|
||||
class ApiSetEncryptLocalFiles(val enable: Boolean): CC()
|
||||
class ApiExportArchive(val config: ArchiveConfig): CC()
|
||||
class ApiImportArchive(val config: ArchiveConfig): CC()
|
||||
class ApiDeleteStorage: CC()
|
||||
@@ -1904,7 +1846,7 @@ sealed class CC {
|
||||
class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC()
|
||||
class ApiDeleteMemberChatItem(val groupId: Long, val groupMemberId: Long, val itemId: Long): CC()
|
||||
class ApiChatItemReaction(val type: ChatType, val id: Long, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC()
|
||||
class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC()
|
||||
class ApiNewGroup(val userId: Long, val groupProfile: GroupProfile): CC()
|
||||
class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC()
|
||||
class ApiJoinGroup(val groupId: Long): CC()
|
||||
class ApiMemberRole(val groupId: Long, val memberId: Long, val memberRole: GroupMemberRole): CC()
|
||||
@@ -1940,9 +1882,8 @@ sealed class CC {
|
||||
class APIVerifyGroupMember(val groupId: Long, val groupMemberId: Long, val connectionCode: String?): CC()
|
||||
class APIAddContact(val userId: Long, val incognito: Boolean): CC()
|
||||
class ApiSetConnectionIncognito(val connId: Long, val incognito: Boolean): CC()
|
||||
class APIConnectPlan(val userId: Long, val connReq: String): CC()
|
||||
class APIConnect(val userId: Long, val incognito: Boolean, val connReq: String): CC()
|
||||
class ApiDeleteChat(val type: ChatType, val id: Long, val notify: Boolean?): CC()
|
||||
class ApiDeleteChat(val type: ChatType, val id: Long): CC()
|
||||
class ApiClearChat(val type: ChatType, val id: Long): CC()
|
||||
class ApiListContacts(val userId: Long): CC()
|
||||
class ApiUpdateProfile(val userId: Long, val profile: Profile): CC()
|
||||
@@ -1961,12 +1902,11 @@ sealed class CC {
|
||||
class ApiSendCallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CC()
|
||||
class ApiEndCall(val contact: Contact): CC()
|
||||
class ApiCallStatus(val contact: Contact, val callStatus: WebRTCCallStatus): CC()
|
||||
class ApiGetNetworkStatuses(): CC()
|
||||
class ApiAcceptContact(val incognito: Boolean, val contactReqId: Long): CC()
|
||||
class ApiRejectContact(val contactReqId: Long): CC()
|
||||
class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC()
|
||||
class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC()
|
||||
class ReceiveFile(val fileId: Long, val encrypt: Boolean, val inline: Boolean?): CC()
|
||||
class ReceiveFile(val fileId: Long, val encrypted: Boolean, val inline: Boolean?): CC()
|
||||
class CancelFile(val fileId: Long): CC()
|
||||
class ShowVersion(): CC()
|
||||
|
||||
@@ -1998,7 +1938,6 @@ sealed class CC {
|
||||
is SetTempFolder -> "/_temp_folder $tempFolder"
|
||||
is SetFilesFolder -> "/_files_folder $filesFolder"
|
||||
is ApiSetXFTPConfig -> if (config != null) "/_xftp on ${json.encodeToString(config)}" else "/_xftp off"
|
||||
is ApiSetEncryptLocalFiles -> "/_files_encrypt ${onOff(enable)}"
|
||||
is ApiExportArchive -> "/_db export ${json.encodeToString(config)}"
|
||||
is ApiImportArchive -> "/_db import ${json.encodeToString(config)}"
|
||||
is ApiDeleteStorage -> "/_db delete"
|
||||
@@ -2014,7 +1953,7 @@ sealed class CC {
|
||||
is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}"
|
||||
is ApiDeleteMemberChatItem -> "/_delete member item #$groupId $groupMemberId $itemId"
|
||||
is ApiChatItemReaction -> "/_reaction ${chatRef(type, id)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}"
|
||||
is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}"
|
||||
is ApiNewGroup -> "/_group $userId ${json.encodeToString(groupProfile)}"
|
||||
is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}"
|
||||
is ApiJoinGroup -> "/_join #$groupId"
|
||||
is ApiMemberRole -> "/_member role #$groupId $memberId ${memberRole.memberRole}"
|
||||
@@ -2050,13 +1989,8 @@ sealed class CC {
|
||||
is APIVerifyGroupMember -> "/_verify code #$groupId $groupMemberId" + if (connectionCode != null) " $connectionCode" else ""
|
||||
is APIAddContact -> "/_connect $userId incognito=${onOff(incognito)}"
|
||||
is ApiSetConnectionIncognito -> "/_set incognito :$connId ${onOff(incognito)}"
|
||||
is APIConnectPlan -> "/_connect plan $userId $connReq"
|
||||
is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} $connReq"
|
||||
is ApiDeleteChat -> if (notify != null) {
|
||||
"/_delete ${chatRef(type, id)} notify=${onOff(notify)}"
|
||||
} else {
|
||||
"/_delete ${chatRef(type, id)}"
|
||||
}
|
||||
is ApiDeleteChat -> "/_delete ${chatRef(type, id)}"
|
||||
is ApiClearChat -> "/_clear chat ${chatRef(type, id)}"
|
||||
is ApiListContacts -> "/_contacts $userId"
|
||||
is ApiUpdateProfile -> "/_profile $userId ${json.encodeToString(profile)}"
|
||||
@@ -2077,13 +2011,9 @@ sealed class CC {
|
||||
is ApiSendCallExtraInfo -> "/_call extra @${contact.apiId} ${json.encodeToString(extraInfo)}"
|
||||
is ApiEndCall -> "/_call end @${contact.apiId}"
|
||||
is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}"
|
||||
is ApiGetNetworkStatuses -> "/_network_statuses"
|
||||
is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}"
|
||||
is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}"
|
||||
is ReceiveFile ->
|
||||
"/freceive $fileId" +
|
||||
(if (encrypt == null) "" else " encrypt=${onOff(encrypt)}") +
|
||||
(if (inline == null) "" else " inline=${onOff(inline)}")
|
||||
is ReceiveFile -> "/freceive $fileId encrypt=${onOff(encrypted)}" + (if (inline == null) "" else " inline=${onOff(inline)}")
|
||||
is CancelFile -> "/fcancel $fileId"
|
||||
is ShowVersion -> "/version"
|
||||
}
|
||||
@@ -2107,7 +2037,6 @@ sealed class CC {
|
||||
is SetTempFolder -> "setTempFolder"
|
||||
is SetFilesFolder -> "setFilesFolder"
|
||||
is ApiSetXFTPConfig -> "apiSetXFTPConfig"
|
||||
is ApiSetEncryptLocalFiles -> "apiSetEncryptLocalFiles"
|
||||
is ApiExportArchive -> "apiExportArchive"
|
||||
is ApiImportArchive -> "apiImportArchive"
|
||||
is ApiDeleteStorage -> "apiDeleteStorage"
|
||||
@@ -2156,7 +2085,6 @@ sealed class CC {
|
||||
is APIVerifyGroupMember -> "apiVerifyGroupMember"
|
||||
is APIAddContact -> "apiAddContact"
|
||||
is ApiSetConnectionIncognito -> "apiSetConnectionIncognito"
|
||||
is APIConnectPlan -> "apiConnectPlan"
|
||||
is APIConnect -> "apiConnect"
|
||||
is ApiDeleteChat -> "apiDeleteChat"
|
||||
is ApiClearChat -> "apiClearChat"
|
||||
@@ -2179,7 +2107,6 @@ sealed class CC {
|
||||
is ApiSendCallExtraInfo -> "apiSendCallExtraInfo"
|
||||
is ApiEndCall -> "apiEndCall"
|
||||
is ApiCallStatus -> "apiCallStatus"
|
||||
is ApiGetNetworkStatuses -> "apiGetNetworkStatuses"
|
||||
is ApiChatRead -> "apiChatRead"
|
||||
is ApiChatUnread -> "apiChatUnread"
|
||||
is ReceiveFile -> "receiveFile"
|
||||
@@ -2538,22 +2465,15 @@ data class KeepAliveOpts(
|
||||
|
||||
@Serializable
|
||||
data class ChatSettings(
|
||||
val enableNtfs: MsgFilter,
|
||||
val enableNtfs: Boolean,
|
||||
val sendRcpts: Boolean?,
|
||||
val favorite: Boolean
|
||||
) {
|
||||
companion object {
|
||||
val defaults: ChatSettings = ChatSettings(enableNtfs = MsgFilter.All, sendRcpts = null, favorite = false)
|
||||
val defaults: ChatSettings = ChatSettings(enableNtfs = true, sendRcpts = null, favorite = false)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class MsgFilter {
|
||||
@SerialName("all") All,
|
||||
@SerialName("none") None,
|
||||
@SerialName("mentions") Mentions,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class UserMsgReceiptSettings(val enable: Boolean, val clearOverrides: Boolean)
|
||||
|
||||
@@ -3370,13 +3290,11 @@ sealed class CR {
|
||||
@Serializable @SerialName("connectionVerified") class ConnectionVerified(val user: UserRef, val verified: Boolean, val expectedCode: String): CR()
|
||||
@Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connReqInvitation: String, val connection: PendingContactConnection): CR()
|
||||
@Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR()
|
||||
@Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connectionPlan: ConnectionPlan): CR()
|
||||
@Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef): CR()
|
||||
@Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef): CR()
|
||||
@Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: UserRef, val contact: Contact): CR()
|
||||
@Serializable @SerialName("contactRequestAlreadyAccepted") class ContactRequestAlreadyAccepted(val user: UserRef, val contact: Contact): CR()
|
||||
@Serializable @SerialName("contactDeleted") class ContactDeleted(val user: UserRef, val contact: Contact): CR()
|
||||
@Serializable @SerialName("contactDeletedByContact") class ContactDeletedByContact(val user: UserRef, val contact: Contact): CR()
|
||||
@Serializable @SerialName("chatCleared") class ChatCleared(val user: UserRef, val chatInfo: ChatInfo): CR()
|
||||
@Serializable @SerialName("userProfileNoChange") class UserProfileNoChange(val user: User): CR()
|
||||
@Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val user: User, val fromProfile: Profile, val toProfile: Profile, val updateSummary: UserProfileUpdateSummary): CR()
|
||||
@@ -3394,15 +3312,11 @@ sealed class CR {
|
||||
@Serializable @SerialName("acceptingContactRequest") class AcceptingContactRequest(val user: UserRef, val contact: Contact): CR()
|
||||
@Serializable @SerialName("contactRequestRejected") class ContactRequestRejected(val user: UserRef): CR()
|
||||
@Serializable @SerialName("contactUpdated") class ContactUpdated(val user: UserRef, val toContact: Contact): CR()
|
||||
@Serializable @SerialName("groupMemberUpdated") class GroupMemberUpdated(val user: UserRef, val groupInfo: GroupInfo, val fromMember: GroupMember, val toMember: GroupMember): CR()
|
||||
// TODO remove below
|
||||
@Serializable @SerialName("contactsSubscribed") class ContactsSubscribed(val server: String, val contactRefs: List<ContactRef>): CR()
|
||||
@Serializable @SerialName("contactsDisconnected") class ContactsDisconnected(val server: String, val contactRefs: List<ContactRef>): CR()
|
||||
@Serializable @SerialName("contactSubError") class ContactSubError(val user: UserRef, val contact: Contact, val chatError: ChatError): CR()
|
||||
@Serializable @SerialName("contactSubSummary") class ContactSubSummary(val user: UserRef, val contactSubscriptions: List<ContactSubStatus>): CR()
|
||||
// TODO remove above
|
||||
@Serializable @SerialName("networkStatus") class NetworkStatusResp(val networkStatus: NetworkStatus, val connections: List<String>): CR()
|
||||
@Serializable @SerialName("networkStatuses") class NetworkStatuses(val user_: UserRef?, val networkStatuses: List<ConnNetworkStatus>): CR()
|
||||
@Serializable @SerialName("groupSubscribed") class GroupSubscribed(val user: UserRef, val group: GroupRef): CR()
|
||||
@Serializable @SerialName("groupSubscribed") class GroupSubscribed(val user: UserRef, val group: GroupInfo): CR()
|
||||
@Serializable @SerialName("memberSubErrors") class MemberSubErrors(val user: UserRef, val memberSubErrors: List<MemberSubError>): CR()
|
||||
@Serializable @SerialName("groupEmpty") class GroupEmpty(val user: UserRef, val group: GroupInfo): CR()
|
||||
@Serializable @SerialName("userContactLinkSubscribed") class UserContactLinkSubscribed: CR()
|
||||
@@ -3417,7 +3331,6 @@ sealed class CR {
|
||||
@Serializable @SerialName("groupCreated") class GroupCreated(val user: UserRef, val groupInfo: GroupInfo): CR()
|
||||
@Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR()
|
||||
@Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val user: UserRef, val groupInfo: GroupInfo, val hostContact: Contact? = null): CR()
|
||||
@Serializable @SerialName("groupLinkConnecting") class GroupLinkConnecting (val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember): CR()
|
||||
@Serializable @SerialName("userDeletedMember") class UserDeletedMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR()
|
||||
@Serializable @SerialName("leftMemberUser") class LeftMemberUser(val user: UserRef, val groupInfo: GroupInfo): CR()
|
||||
@Serializable @SerialName("groupMembers") class GroupMembers(val user: UserRef, val group: Group): CR()
|
||||
@@ -3508,13 +3421,11 @@ sealed class CR {
|
||||
is ConnectionVerified -> "connectionVerified"
|
||||
is Invitation -> "invitation"
|
||||
is ConnectionIncognitoUpdated -> "connectionIncognitoUpdated"
|
||||
is CRConnectionPlan -> "connectionPlan"
|
||||
is SentConfirmation -> "sentConfirmation"
|
||||
is SentInvitation -> "sentInvitation"
|
||||
is ContactAlreadyExists -> "contactAlreadyExists"
|
||||
is ContactRequestAlreadyAccepted -> "contactRequestAlreadyAccepted"
|
||||
is ContactDeleted -> "contactDeleted"
|
||||
is ContactDeletedByContact -> "contactDeletedByContact"
|
||||
is ChatCleared -> "chatCleared"
|
||||
is UserProfileNoChange -> "userProfileNoChange"
|
||||
is UserProfileUpdated -> "userProfileUpdated"
|
||||
@@ -3532,12 +3443,10 @@ sealed class CR {
|
||||
is AcceptingContactRequest -> "acceptingContactRequest"
|
||||
is ContactRequestRejected -> "contactRequestRejected"
|
||||
is ContactUpdated -> "contactUpdated"
|
||||
is GroupMemberUpdated -> "groupMemberUpdated"
|
||||
is ContactsSubscribed -> "contactsSubscribed"
|
||||
is ContactsDisconnected -> "contactsDisconnected"
|
||||
is ContactSubError -> "contactSubError"
|
||||
is ContactSubSummary -> "contactSubSummary"
|
||||
is NetworkStatusResp -> "networkStatus"
|
||||
is NetworkStatuses -> "networkStatuses"
|
||||
is GroupSubscribed -> "groupSubscribed"
|
||||
is MemberSubErrors -> "memberSubErrors"
|
||||
is GroupEmpty -> "groupEmpty"
|
||||
@@ -3552,7 +3461,6 @@ sealed class CR {
|
||||
is GroupCreated -> "groupCreated"
|
||||
is SentGroupInvitation -> "sentGroupInvitation"
|
||||
is UserAcceptedGroupSent -> "userAcceptedGroupSent"
|
||||
is GroupLinkConnecting -> "groupLinkConnecting"
|
||||
is UserDeletedMember -> "userDeletedMember"
|
||||
is LeftMemberUser -> "leftMemberUser"
|
||||
is GroupMembers -> "groupMembers"
|
||||
@@ -3641,13 +3549,11 @@ sealed class CR {
|
||||
is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode")
|
||||
is Invitation -> withUser(user, connReqInvitation)
|
||||
is ConnectionIncognitoUpdated -> withUser(user, json.encodeToString(toConnection))
|
||||
is CRConnectionPlan -> withUser(user, json.encodeToString(connectionPlan))
|
||||
is SentConfirmation -> withUser(user, noDetails())
|
||||
is SentInvitation -> withUser(user, noDetails())
|
||||
is ContactAlreadyExists -> withUser(user, json.encodeToString(contact))
|
||||
is ContactRequestAlreadyAccepted -> withUser(user, json.encodeToString(contact))
|
||||
is ContactDeleted -> withUser(user, json.encodeToString(contact))
|
||||
is ContactDeletedByContact -> withUser(user, json.encodeToString(contact))
|
||||
is ChatCleared -> withUser(user, json.encodeToString(chatInfo))
|
||||
is UserProfileNoChange -> withUser(user, noDetails())
|
||||
is UserProfileUpdated -> withUser(user, json.encodeToString(toProfile))
|
||||
@@ -3665,12 +3571,10 @@ sealed class CR {
|
||||
is AcceptingContactRequest -> withUser(user, json.encodeToString(contact))
|
||||
is ContactRequestRejected -> withUser(user, noDetails())
|
||||
is ContactUpdated -> withUser(user, json.encodeToString(toContact))
|
||||
is GroupMemberUpdated -> withUser(user, "groupInfo: $groupInfo\nfromMember: $fromMember\ntoMember: $toMember")
|
||||
is ContactsSubscribed -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}"
|
||||
is ContactsDisconnected -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}"
|
||||
is ContactSubError -> withUser(user, "error:\n${chatError.string}\ncontact:\n${json.encodeToString(contact)}")
|
||||
is ContactSubSummary -> withUser(user, json.encodeToString(contactSubscriptions))
|
||||
is NetworkStatusResp -> "networkStatus $networkStatus\nconnections: $connections"
|
||||
is NetworkStatuses -> withUser(user_, json.encodeToString(networkStatuses))
|
||||
is GroupSubscribed -> withUser(user, json.encodeToString(group))
|
||||
is MemberSubErrors -> withUser(user, json.encodeToString(memberSubErrors))
|
||||
is GroupEmpty -> withUser(user, json.encodeToString(group))
|
||||
@@ -3685,7 +3589,6 @@ sealed class CR {
|
||||
is GroupCreated -> withUser(user, json.encodeToString(groupInfo))
|
||||
is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member")
|
||||
is UserAcceptedGroupSent -> json.encodeToString(groupInfo)
|
||||
is GroupLinkConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember")
|
||||
is UserDeletedMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member")
|
||||
is LeftMemberUser -> withUser(user, json.encodeToString(groupInfo))
|
||||
is GroupMembers -> withUser(user, json.encodeToString(group))
|
||||
@@ -3757,39 +3660,6 @@ fun chatError(r: CR): ChatErrorType? {
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class ConnectionPlan {
|
||||
@Serializable @SerialName("invitationLink") class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan()
|
||||
@Serializable @SerialName("contactAddress") class ContactAddress(val contactAddressPlan: ContactAddressPlan): ConnectionPlan()
|
||||
@Serializable @SerialName("groupLink") class GroupLink(val groupLinkPlan: GroupLinkPlan): ConnectionPlan()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class InvitationLinkPlan {
|
||||
@Serializable @SerialName("ok") object Ok: InvitationLinkPlan()
|
||||
@Serializable @SerialName("ownLink") object OwnLink: InvitationLinkPlan()
|
||||
@Serializable @SerialName("connecting") class Connecting(val contact_: Contact? = null): InvitationLinkPlan()
|
||||
@Serializable @SerialName("known") class Known(val contact: Contact): InvitationLinkPlan()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class ContactAddressPlan {
|
||||
@Serializable @SerialName("ok") object Ok: ContactAddressPlan()
|
||||
@Serializable @SerialName("ownLink") object OwnLink: ContactAddressPlan()
|
||||
@Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: ContactAddressPlan()
|
||||
@Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val contact: Contact): ContactAddressPlan()
|
||||
@Serializable @SerialName("known") class Known(val contact: Contact): ContactAddressPlan()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class GroupLinkPlan {
|
||||
@Serializable @SerialName("ok") object Ok: GroupLinkPlan()
|
||||
@Serializable @SerialName("ownLink") class OwnLink(val groupInfo: GroupInfo): GroupLinkPlan()
|
||||
@Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: GroupLinkPlan()
|
||||
@Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val groupInfo_: GroupInfo? = null): GroupLinkPlan()
|
||||
@Serializable @SerialName("known") class Known(val groupInfo: GroupInfo): GroupLinkPlan()
|
||||
}
|
||||
|
||||
abstract class TerminalItem {
|
||||
abstract val id: Long
|
||||
val date: Instant = Clock.System.now()
|
||||
@@ -3949,11 +3819,9 @@ sealed class ChatErrorType {
|
||||
is ChatNotStarted -> "chatNotStarted"
|
||||
is ChatNotStopped -> "chatNotStopped"
|
||||
is ChatStoreChanged -> "chatStoreChanged"
|
||||
is ConnectionPlanChatError -> "connectionPlan"
|
||||
is InvalidConnReq -> "invalidConnReq"
|
||||
is InvalidChatMessage -> "invalidChatMessage"
|
||||
is ContactNotReady -> "contactNotReady"
|
||||
is ContactNotActive -> "contactNotActive"
|
||||
is ContactDisabled -> "contactDisabled"
|
||||
is ConnectionDisabled -> "connectionDisabled"
|
||||
is GroupUserRole -> "groupUserRole"
|
||||
@@ -4026,11 +3894,9 @@ sealed class ChatErrorType {
|
||||
@Serializable @SerialName("chatNotStarted") object ChatNotStarted: ChatErrorType()
|
||||
@Serializable @SerialName("chatNotStopped") object ChatNotStopped: ChatErrorType()
|
||||
@Serializable @SerialName("chatStoreChanged") object ChatStoreChanged: ChatErrorType()
|
||||
@Serializable @SerialName("connectionPlan") class ConnectionPlanChatError(val connectionPlan: ConnectionPlan): ChatErrorType()
|
||||
@Serializable @SerialName("invalidConnReq") object InvalidConnReq: ChatErrorType()
|
||||
@Serializable @SerialName("invalidChatMessage") class InvalidChatMessage(val connection: Connection, val message: String): ChatErrorType()
|
||||
@Serializable @SerialName("contactNotReady") class ContactNotReady(val contact: Contact): ChatErrorType()
|
||||
@Serializable @SerialName("contactNotActive") class ContactNotActive(val contact: Contact): ChatErrorType()
|
||||
@Serializable @SerialName("contactDisabled") class ContactDisabled(val contact: Contact): ChatErrorType()
|
||||
@Serializable @SerialName("connectionDisabled") class ConnectionDisabled(val connection: Connection): ChatErrorType()
|
||||
@Serializable @SerialName("groupUserRole") class GroupUserRole(val groupInfo: GroupInfo, val requiredRole: GroupMemberRole): ChatErrorType()
|
||||
|
||||
@@ -20,7 +20,6 @@ external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String
|
||||
external fun chatParseMarkdown(str: String): String
|
||||
external fun chatParseServer(str: String): String
|
||||
external fun chatPasswordHash(pwd: String, salt: String): String
|
||||
external fun chatValidName(name: String): String
|
||||
external fun chatWriteFile(path: String, buffer: ByteBuffer): String
|
||||
external fun chatReadFile(path: String, key: String, nonce: String): Array<Any>
|
||||
external fun chatEncryptFile(fromPath: String, toPath: String): String
|
||||
|
||||
@@ -4,8 +4,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import chat.simplex.common.views.chat.ComposeState
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
|
||||
@Composable
|
||||
expect fun PlatformTextField(
|
||||
@@ -16,6 +14,5 @@ expect fun PlatformTextField(
|
||||
userIsObserver: Boolean,
|
||||
onMessageChange: (String) -> Unit,
|
||||
onUpArrow: () -> Unit,
|
||||
onFilesPasted: (List<URI>) -> Unit,
|
||||
onDone: () -> Unit,
|
||||
)
|
||||
|
||||
@@ -14,5 +14,3 @@ expect fun LocalMultiplatformView(): Any?
|
||||
@Composable
|
||||
expect fun getKeyboardState(): State<KeyboardState>
|
||||
expect fun hideKeyboard(view: Any?)
|
||||
|
||||
expect fun androidIsFinishingMainActivity(): Boolean
|
||||
|
||||
@@ -97,7 +97,6 @@ fun TerminalLayout(
|
||||
updateLiveMessage = null,
|
||||
editPrevMessage = {},
|
||||
onMessageChange = ::onMessageChange,
|
||||
onFilesPasted = {},
|
||||
textStyle = textStyle
|
||||
)
|
||||
}
|
||||
@@ -124,18 +123,7 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
|
||||
}
|
||||
val reversedTerminalItems by remember {
|
||||
derivedStateOf {
|
||||
// Such logic prevents concurrent modification
|
||||
val res = ArrayList<TerminalItem>()
|
||||
var i = 0
|
||||
while (i < terminalItems.size) {
|
||||
res.add(terminalItems[i])
|
||||
i++
|
||||
}
|
||||
res.asReversed()
|
||||
}
|
||||
}
|
||||
val reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed().toList() } }
|
||||
val clipboard = LocalClipboardManager.current
|
||||
LazyColumn(state = listState, reverseLayout = true) {
|
||||
items(reversedTerminalItems) { item ->
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package chat.simplex.common.views
|
||||
|
||||
import SectionTextFooter
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.MaterialTheme.colors
|
||||
import androidx.compose.runtime.*
|
||||
@@ -18,160 +18,115 @@ import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.model.Profile
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.platform.appPlatform
|
||||
import chat.simplex.common.platform.navigationBarsWithImePadding
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.onboarding.*
|
||||
import chat.simplex.common.views.usersettings.SettingsActionItem
|
||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import chat.simplex.common.views.onboarding.ReadableText
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
fun isValidDisplayName(name: String) : Boolean {
|
||||
return (name.firstOrNull { it.isWhitespace() }) == null && !name.startsWith("@") && !name.startsWith("#")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateProfile(chatModel: ChatModel, close: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
val keyboardState by getKeyboardState()
|
||||
var savedKeyboardState by remember { mutableStateOf(keyboardState) }
|
||||
fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) {
|
||||
val displayName = rememberSaveable { mutableStateOf("") }
|
||||
val fullName = rememberSaveable { mutableStateOf("") }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = 20.dp)
|
||||
) {
|
||||
val displayName = rememberSaveable { mutableStateOf("") }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = DEFAULT_PADDING)) {
|
||||
AppBarTitle(stringResource(MR.strings.create_profile), bottomPadding = DEFAULT_PADDING)
|
||||
Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
stringResource(MR.strings.display_name),
|
||||
fontSize = 16.sp
|
||||
)
|
||||
val name = displayName.value.trim()
|
||||
val validName = mkValidName(name)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
if (name != validName) {
|
||||
IconButton({ showInvalidNameAlert(mkValidName(displayName.value), displayName) }, Modifier.size(20.dp)) {
|
||||
Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
ProfileNameField(displayName, "", { it.trim() == mkValidName(it) }, focusRequester)
|
||||
}
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_check),
|
||||
stringResource(MR.strings.create_another_profile_button),
|
||||
disabled = !canCreateProfile(displayName.value),
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
click = { createProfileInProfiles(chatModel, displayName.value, close) },
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||
) {
|
||||
/*CloseSheetBar(close = {
|
||||
if (chatModel.users.isEmpty()) {
|
||||
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
})*/
|
||||
Column(Modifier.padding(horizontal = DEFAULT_PADDING)) {
|
||||
AppBarTitle(stringResource(MR.strings.create_profile), bottomPadding = DEFAULT_PADDING)
|
||||
ReadableText(MR.strings.your_profile_is_stored_on_your_device, TextAlign.Center, padding = PaddingValues(), style = MaterialTheme.typography.body1)
|
||||
ReadableText(MR.strings.profile_is_only_shared_with_your_contacts, TextAlign.Center, style = MaterialTheme.typography.body1)
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
stringResource(MR.strings.display_name),
|
||||
fontSize = 16.sp
|
||||
)
|
||||
SectionTextFooter(generalGetString(MR.strings.your_profile_is_stored_on_your_device))
|
||||
SectionTextFooter(generalGetString(MR.strings.profile_is_only_shared_with_your_contacts))
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(300)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
if (savedKeyboardState != keyboardState) {
|
||||
LaunchedEffect(keyboardState) {
|
||||
scope.launch {
|
||||
savedKeyboardState = keyboardState
|
||||
scrollState.animateScrollTo(scrollState.maxValue)
|
||||
}
|
||||
if (!isValidDisplayName(displayName.value)) {
|
||||
Text(
|
||||
stringResource(MR.strings.no_spaces),
|
||||
fontSize = 16.sp,
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
ProfileNameField(displayName, "", ::isValidDisplayName, focusRequester)
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
Text(
|
||||
stringResource(MR.strings.full_name_optional__prompt),
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF)
|
||||
)
|
||||
ProfileNameField(fullName, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateFirstProfile(chatModel: ChatModel, close: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
val keyboardState by getKeyboardState()
|
||||
var savedKeyboardState by remember { mutableStateOf(keyboardState) }
|
||||
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = 20.dp)
|
||||
) {
|
||||
val displayName = rememberSaveable { mutableStateOf("") }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||
) {
|
||||
/*CloseSheetBar(close = {
|
||||
if (chatModel.users.isEmpty()) {
|
||||
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Row {
|
||||
if (chatModel.users.isEmpty()) {
|
||||
SimpleButtonDecorated(
|
||||
text = stringResource(MR.strings.about_simplex),
|
||||
icon = painterResource(MR.images.ic_arrow_back_ios_new),
|
||||
textDecoration = TextDecoration.None,
|
||||
fontWeight = FontWeight.Medium
|
||||
) { chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) }
|
||||
}
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
|
||||
val createModifier: Modifier
|
||||
val createColor: Color
|
||||
if (enabled) {
|
||||
createModifier = Modifier.clickable {
|
||||
if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) {
|
||||
createProfileInProfiles(chatModel, displayName.value, fullName.value, close)
|
||||
} else {
|
||||
close()
|
||||
createProfileOnboarding(chatModel, displayName.value, fullName.value, close)
|
||||
}
|
||||
})*/
|
||||
Column(Modifier.padding(horizontal = DEFAULT_PADDING)) {
|
||||
AppBarTitle(stringResource(MR.strings.create_profile), bottomPadding = DEFAULT_PADDING)
|
||||
ReadableText(MR.strings.your_profile_is_stored_on_your_device, TextAlign.Center, padding = PaddingValues(), style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary))
|
||||
ReadableText(MR.strings.profile_is_only_shared_with_your_contacts, TextAlign.Center, style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary))
|
||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||
Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
stringResource(MR.strings.display_name),
|
||||
fontSize = 16.sp
|
||||
)
|
||||
val name = displayName.value.trim()
|
||||
val validName = mkValidName(name)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
if (name != validName) {
|
||||
IconButton({ showInvalidNameAlert(mkValidName(displayName.value), displayName) }, Modifier.size(20.dp)) {
|
||||
Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
ProfileNameField(displayName, "", { it.trim() == mkValidName(it) }, focusRequester)
|
||||
}.padding(8.dp)
|
||||
createColor = MaterialTheme.colors.primary
|
||||
} else {
|
||||
createModifier = Modifier.padding(8.dp)
|
||||
createColor = MaterialTheme.colors.secondary
|
||||
}
|
||||
Surface(shape = RoundedCornerShape(20.dp), color = Color.Transparent) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = createModifier) {
|
||||
Text(stringResource(MR.strings.create_profile_button), style = MaterialTheme.typography.caption, color = createColor, fontWeight = FontWeight.Medium)
|
||||
Icon(painterResource(MR.images.ic_arrow_forward_ios), stringResource(MR.strings.create_profile_button), tint = createColor)
|
||||
}
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
OnboardingButtons(displayName, close)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(300)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
setLastVersionDefault(chatModel)
|
||||
}
|
||||
if (savedKeyboardState != keyboardState) {
|
||||
LaunchedEffect(keyboardState) {
|
||||
scope.launch {
|
||||
savedKeyboardState = keyboardState
|
||||
scrollState.animateScrollTo(scrollState.maxValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
delay(300)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createProfileInProfiles(chatModel: ChatModel, displayName: String, close: () -> Unit) {
|
||||
fun createProfileInProfiles(chatModel: ChatModel, displayName: String, fullName: String, close: () -> Unit) {
|
||||
withApi {
|
||||
val user = chatModel.controller.apiCreateActiveUser(
|
||||
Profile(displayName.trim(), "", null)
|
||||
Profile(displayName, fullName, null)
|
||||
) ?: return@withApi
|
||||
chatModel.currentUser.value = user
|
||||
if (chatModel.users.isEmpty()) {
|
||||
@@ -187,10 +142,10 @@ fun createProfileInProfiles(chatModel: ChatModel, displayName: String, close: ()
|
||||
}
|
||||
}
|
||||
|
||||
fun createProfileOnboarding(chatModel: ChatModel, displayName: String, close: () -> Unit) {
|
||||
fun createProfileOnboarding(chatModel: ChatModel, displayName: String, fullName: String, close: () -> Unit) {
|
||||
withApi {
|
||||
chatModel.controller.apiCreateActiveUser(
|
||||
Profile(displayName.trim(), "", null)
|
||||
Profile(displayName, fullName, null)
|
||||
) ?: return@withApi
|
||||
val onboardingStage = chatModel.controller.appPrefs.onboardingStage
|
||||
if (chatModel.users.isEmpty()) {
|
||||
@@ -208,28 +163,6 @@ fun createProfileOnboarding(chatModel: ChatModel, displayName: String, close: ()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OnboardingButtons(displayName: MutableState<String>, close: () -> Unit) {
|
||||
Row {
|
||||
SimpleButtonDecorated(
|
||||
text = stringResource(MR.strings.about_simplex),
|
||||
icon = painterResource(MR.images.ic_arrow_back_ios_new),
|
||||
textDecoration = TextDecoration.None,
|
||||
fontWeight = FontWeight.Medium
|
||||
) { chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) }
|
||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||
val enabled = canCreateProfile(displayName.value)
|
||||
val createModifier: Modifier = Modifier.clickable(enabled) { createProfileOnboarding(chatModel, displayName.value, close) }.padding(8.dp)
|
||||
val createColor: Color = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
|
||||
Surface(shape = RoundedCornerShape(20.dp), color = Color.Transparent) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = createModifier) {
|
||||
Text(stringResource(MR.strings.create_profile_button), style = MaterialTheme.typography.caption, color = createColor, fontWeight = FontWeight.Medium)
|
||||
Icon(painterResource(MR.images.ic_arrow_forward_ios), stringResource(MR.strings.create_profile_button), tint = createColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileNameField(name: MutableState<String>, placeholder: String = "", isValid: (String) -> Boolean = { true }, focusRequester: FocusRequester? = null) {
|
||||
var valid by rememberSaveable { mutableStateOf(true) }
|
||||
@@ -262,6 +195,10 @@ fun ProfileNameField(name: MutableState<String>, placeholder: String = "", isVal
|
||||
onValueChange = { name.value = it },
|
||||
modifier = if (focusRequester == null) modifier else modifier.focusRequester(focusRequester),
|
||||
textStyle = TextStyle(fontSize = 18.sp, color = colors.onBackground),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrect = false
|
||||
),
|
||||
singleLine = true,
|
||||
cursorBrush = SolidColor(MaterialTheme.colors.secondary)
|
||||
)
|
||||
@@ -274,28 +211,3 @@ fun ProfileNameField(name: MutableState<String>, placeholder: String = "", isVal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun canCreateProfile(displayName: String): Boolean {
|
||||
val name = displayName.trim()
|
||||
return name.isNotEmpty() && mkValidName(name) == name
|
||||
}
|
||||
|
||||
fun showInvalidNameAlert(name: String, displayName: MutableState<String>) {
|
||||
if (name.isEmpty()) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.invalid_name),
|
||||
)
|
||||
} else {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.invalid_name),
|
||||
text = generalGetString(MR.strings.correct_name_to).format(name),
|
||||
onConfirm = {
|
||||
displayName.value = name
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun isValidDisplayName(name: String) : Boolean = mkValidName(name.trim()) == name
|
||||
|
||||
fun mkValidName(s: String): String = chatValidName(s)
|
||||
|
||||
@@ -3,6 +3,8 @@ package chat.simplex.common.views.call
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.helpers.withApi
|
||||
import chat.simplex.common.views.helpers.withBGApi
|
||||
import chat.simplex.common.views.usersettings.showInDevelopingAlert
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
@@ -24,6 +26,10 @@ class CallManager(val chatModel: ChatModel) {
|
||||
}
|
||||
|
||||
fun acceptIncomingCall(invitation: RcvCallInvitation) {
|
||||
if (appPlatform.isDesktop) {
|
||||
return showInDevelopingAlert()
|
||||
}
|
||||
|
||||
val call = chatModel.activeCall.value
|
||||
if (call == null) {
|
||||
justAcceptIncomingCall(invitation = invitation)
|
||||
@@ -52,12 +58,12 @@ class CallManager(val chatModel: ChatModel) {
|
||||
val useRelay = controller.appPrefs.webrtcPolicyRelay.get()
|
||||
val iceServers = getIceServers()
|
||||
Log.d(TAG, "answerIncomingCall iceServers: $iceServers")
|
||||
callCommand.add(WCallCommand.Start(
|
||||
callCommand.value = WCallCommand.Start(
|
||||
media = invitation.callType.media,
|
||||
aesKey = invitation.sharedKey,
|
||||
iceServers = iceServers,
|
||||
relay = useRelay
|
||||
))
|
||||
)
|
||||
callInvitations.remove(invitation.contact.id)
|
||||
if (invitation.contact.id == activeCallInvitation.value?.contact?.id) {
|
||||
activeCallInvitation.value = null
|
||||
@@ -74,7 +80,7 @@ class CallManager(val chatModel: ChatModel) {
|
||||
showCallView.value = false
|
||||
} else {
|
||||
Log.d(TAG, "CallManager.endCall: ending call...")
|
||||
callCommand.add(WCallCommand.End)
|
||||
callCommand.value = WCallCommand.End
|
||||
showCallView.value = false
|
||||
controller.apiEndCall(call.contact)
|
||||
activeCall.value = null
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package chat.simplex.common.views.call
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import chat.simplex.common.views.helpers.generalGetString
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.res.MR
|
||||
@@ -21,17 +23,16 @@ data class Call(
|
||||
val videoEnabled: Boolean = localMedia == CallMediaType.Video,
|
||||
val soundSpeaker: Boolean = localMedia == CallMediaType.Video,
|
||||
var localCamera: VideoCamera = VideoCamera.User,
|
||||
val connectionInfo: ConnectionInfo? = null,
|
||||
var connectedAt: Instant? = null
|
||||
val connectionInfo: ConnectionInfo? = null
|
||||
) {
|
||||
val encrypted: Boolean get() = localEncrypted && sharedKey != null
|
||||
val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false
|
||||
|
||||
val encryptionStatus: String get() = when(callState) {
|
||||
val encryptionStatus: String @Composable get() = when(callState) {
|
||||
CallState.WaitCapabilities -> ""
|
||||
CallState.InvitationSent -> generalGetString(if (localEncrypted) MR.strings.status_e2e_encrypted else MR.strings.status_no_e2e_encryption)
|
||||
CallState.InvitationAccepted -> generalGetString(if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_contact_has_e2e_encryption)
|
||||
else -> generalGetString(if (!localEncrypted) MR.strings.status_no_e2e_encryption else if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_e2e_encrypted)
|
||||
CallState.InvitationSent -> stringResource(if (localEncrypted) MR.strings.status_e2e_encrypted else MR.strings.status_no_e2e_encryption)
|
||||
CallState.InvitationAccepted -> stringResource(if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_contact_has_e2e_encryption)
|
||||
else -> stringResource(if (!localEncrypted) MR.strings.status_no_e2e_encryption else if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_e2e_encrypted)
|
||||
}
|
||||
|
||||
val hasMedia: Boolean get() = callState == CallState.OfferSent || callState == CallState.Negotiated || callState == CallState.Connected
|
||||
@@ -48,16 +49,16 @@ enum class CallState {
|
||||
Connected,
|
||||
Ended;
|
||||
|
||||
val text: String get() = when(this) {
|
||||
WaitCapabilities -> generalGetString(MR.strings.callstate_starting)
|
||||
InvitationSent -> generalGetString(MR.strings.callstate_waiting_for_answer)
|
||||
InvitationAccepted -> generalGetString(MR.strings.callstate_starting)
|
||||
OfferSent -> generalGetString(MR.strings.callstate_waiting_for_confirmation)
|
||||
OfferReceived -> generalGetString(MR.strings.callstate_received_answer)
|
||||
AnswerReceived -> generalGetString(MR.strings.callstate_received_confirmation)
|
||||
Negotiated -> generalGetString(MR.strings.callstate_connecting)
|
||||
Connected -> generalGetString(MR.strings.callstate_connected)
|
||||
Ended -> generalGetString(MR.strings.callstate_ended)
|
||||
val text: String @Composable get() = when(this) {
|
||||
WaitCapabilities -> stringResource(MR.strings.callstate_starting)
|
||||
InvitationSent -> stringResource(MR.strings.callstate_waiting_for_answer)
|
||||
InvitationAccepted -> stringResource(MR.strings.callstate_starting)
|
||||
OfferSent -> stringResource(MR.strings.callstate_waiting_for_confirmation)
|
||||
OfferReceived -> stringResource(MR.strings.callstate_received_answer)
|
||||
AnswerReceived -> stringResource(MR.strings.callstate_received_confirmation)
|
||||
Negotiated -> stringResource(MR.strings.callstate_connecting)
|
||||
Connected -> stringResource(MR.strings.callstate_connected)
|
||||
Ended -> stringResource(MR.strings.callstate_ended)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,14 +67,13 @@ enum class CallState {
|
||||
|
||||
@Serializable
|
||||
sealed class WCallCommand {
|
||||
@Serializable @SerialName("capabilities") data class Capabilities(val media: CallMediaType): WCallCommand()
|
||||
@Serializable @SerialName("capabilities") object Capabilities: WCallCommand()
|
||||
@Serializable @SerialName("start") data class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
|
||||
@Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
|
||||
@Serializable @SerialName("answer") data class Answer (val answer: String, val iceCandidates: String): WCallCommand()
|
||||
@Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallCommand()
|
||||
@Serializable @SerialName("media") data class Media(val media: CallMediaType, val enable: Boolean): WCallCommand()
|
||||
@Serializable @SerialName("camera") data class Camera(val camera: VideoCamera): WCallCommand()
|
||||
@Serializable @SerialName("description") data class Description(val state: String, val description: String): WCallCommand()
|
||||
@Serializable @SerialName("end") object End: WCallCommand()
|
||||
}
|
||||
|
||||
@@ -85,7 +85,6 @@ sealed class WCallResponse {
|
||||
@Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallResponse()
|
||||
@Serializable @SerialName("connection") data class Connection(val state: ConnectionState): WCallResponse()
|
||||
@Serializable @SerialName("connected") data class Connected(val connectionInfo: ConnectionInfo): WCallResponse()
|
||||
@Serializable @SerialName("end") object End: WCallResponse()
|
||||
@Serializable @SerialName("ended") object Ended: WCallResponse()
|
||||
@Serializable @SerialName("ok") object Ok: WCallResponse()
|
||||
@Serializable @SerialName("error") data class Error(val message: String): WCallResponse()
|
||||
@@ -107,14 +106,14 @@ sealed class WCallResponse {
|
||||
}
|
||||
@Serializable data class CallCapabilities(val encryption: Boolean)
|
||||
@Serializable data class ConnectionInfo(private val localCandidate: RTCIceCandidate?, private val remoteCandidate: RTCIceCandidate?) {
|
||||
val text: String get() {
|
||||
val text: String @Composable get() {
|
||||
val local = localCandidate?.candidateType
|
||||
val remote = remoteCandidate?.candidateType
|
||||
return when {
|
||||
local == RTCIceCandidateType.Host && remote == RTCIceCandidateType.Host ->
|
||||
generalGetString(MR.strings.call_connection_peer_to_peer)
|
||||
stringResource(MR.strings.call_connection_peer_to_peer)
|
||||
local == RTCIceCandidateType.Relay && remote == RTCIceCandidateType.Relay ->
|
||||
generalGetString(MR.strings.call_connection_via_relay)
|
||||
stringResource(MR.strings.call_connection_via_relay)
|
||||
else ->
|
||||
"${local?.value ?: "unknown"} / ${remote?.value ?: "unknown"}"
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -31,10 +32,10 @@ import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.newchat.QRCode
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.chatlist.updateChatSettings
|
||||
import chat.simplex.common.views.newchat.*
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
@@ -196,67 +197,28 @@ sealed class SendReceipts {
|
||||
}
|
||||
|
||||
fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
AlertManager.shared.showAlertDialogButtonsColumn(
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.delete_contact_question),
|
||||
text = AnnotatedString(generalGetString(MR.strings.delete_contact_all_messages_deleted_cannot_undo_warning)),
|
||||
buttons = {
|
||||
Column {
|
||||
if (chatInfo is ChatInfo.Direct && chatInfo.contact.ready && chatInfo.contact.active) {
|
||||
// Delete and notify contact
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
withApi {
|
||||
deleteContact(chatInfo, chatModel, close, notify = true)
|
||||
}
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.delete_and_notify_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
|
||||
text = generalGetString(MR.strings.delete_contact_all_messages_deleted_cannot_undo_warning),
|
||||
confirmText = generalGetString(MR.strings.delete_verb),
|
||||
onConfirm = {
|
||||
withApi {
|
||||
val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId)
|
||||
if (r) {
|
||||
chatModel.removeChat(chatInfo.id)
|
||||
if (chatModel.chatId.value == chatInfo.id) {
|
||||
chatModel.chatId.value = null
|
||||
ModalManager.end.closeModals()
|
||||
}
|
||||
// Delete
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
withApi {
|
||||
deleteContact(chatInfo, chatModel, close, notify = false)
|
||||
}
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
|
||||
}
|
||||
} else {
|
||||
// Delete
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
withApi {
|
||||
deleteContact(chatInfo, chatModel, close)
|
||||
}
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
|
||||
}
|
||||
}
|
||||
// Cancel
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
}) {
|
||||
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
ntfManager.cancelNotificationsForChat(chatInfo.id)
|
||||
close?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
destructive = true,
|
||||
)
|
||||
}
|
||||
|
||||
fun deleteContact(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)?, notify: Boolean? = null) {
|
||||
withApi {
|
||||
val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId, notify)
|
||||
if (r) {
|
||||
chatModel.removeChat(chatInfo.id)
|
||||
if (chatModel.chatId.value == chatInfo.id) {
|
||||
chatModel.chatId.value = null
|
||||
ModalManager.end.closeModals()
|
||||
}
|
||||
ntfManager.cancelNotificationsForChat(chatInfo.id)
|
||||
close?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.clear_chat_question),
|
||||
@@ -329,7 +291,7 @@ fun ChatInfoLayout(
|
||||
SectionDividerSpaced()
|
||||
}
|
||||
|
||||
if (contact.ready && contact.active) {
|
||||
if (contact.ready) {
|
||||
SectionView {
|
||||
if (connectionCode != null) {
|
||||
VerifyCodeButton(contact.verified, verifyClicked)
|
||||
@@ -348,15 +310,15 @@ fun ChatInfoLayout(
|
||||
|
||||
if (contact.contactLink != null) {
|
||||
SectionView(stringResource(MR.strings.address_section_title).uppercase()) {
|
||||
SimpleXLinkQRCode(contact.contactLink, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f))
|
||||
QRCode(contact.contactLink, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f))
|
||||
val clipboard = LocalClipboardManager.current
|
||||
ShareAddressButton { clipboard.shareText(simplexChatLink(contact.contactLink)) }
|
||||
ShareAddressButton { clipboard.shareText(contact.contactLink) }
|
||||
SectionTextFooter(stringResource(MR.strings.you_can_share_this_address_with_your_contacts).format(contact.displayName))
|
||||
}
|
||||
SectionDividerSpaced()
|
||||
}
|
||||
|
||||
if (contact.ready && contact.active) {
|
||||
if (contact.ready) {
|
||||
SectionView(title = stringResource(MR.strings.conn_stats_section_title_servers)) {
|
||||
SectionItemView({
|
||||
AlertManager.shared.showAlertMsg(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user