Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c580c34a35 | ||
|
|
fdf312d9e1 | ||
|
|
44d8b549c4 | ||
|
|
928dd27043 | ||
|
|
4419051347 | ||
|
|
8cf88019e5 | ||
|
|
710971a0cd | ||
|
|
dc306dfcd0 | ||
|
|
e90520a5ec | ||
|
|
7805bd1e45 | ||
|
|
c1c55ca700 | ||
|
|
8e34d2fbbc | ||
|
|
61afb64dd7 | ||
|
|
aa2bc545db | ||
|
|
067f122b05 | ||
|
|
9d9bb68d50 | ||
|
|
af5abae558 | ||
|
|
0ea8705014 | ||
|
|
92409820fb | ||
|
|
98fc6c6adf | ||
|
|
771bc6a14d | ||
|
|
86c36f53e4 | ||
|
|
5c24089f9f | ||
|
|
516c8d79ad | ||
|
|
ff7a8cade1 | ||
|
|
7af4cdffee | ||
|
|
b06838b651 | ||
|
|
b3a4c21c4b | ||
|
|
855881094b | ||
|
|
82d02e923a | ||
|
|
d11d66fa90 | ||
|
|
f5507436f3 | ||
|
|
eeea33c7cb | ||
|
|
7883ca7657 | ||
|
|
8efb8b2f86 | ||
|
|
408a30c25b | ||
|
|
9b67aa537a | ||
|
|
5aabf87898 | ||
|
|
67dbdcd257 | ||
|
|
3d137995d8 | ||
|
|
e424e9328b | ||
|
|
214ecf605b | ||
|
|
7d06d0660d | ||
|
|
c34eddb82a | ||
|
|
9969606432 | ||
|
|
d8abdb7927 | ||
|
|
71a60795cf | ||
|
|
d07ce0b8f4 | ||
|
|
565bc70843 | ||
|
|
7924861810 | ||
|
|
08dd92b726 | ||
|
|
dca5dc4fce | ||
|
|
24f3637199 | ||
|
|
4dd95c1639 |
11
.github/workflows/build.yml
vendored
@@ -32,6 +32,7 @@ jobs:
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
body: ${{ steps.build_changelog.outputs.changelog }}
|
||||
prerelease: true
|
||||
files: |
|
||||
LICENSE
|
||||
fail_on_unmatched_files: true
|
||||
@@ -84,14 +85,14 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
stack build --test
|
||||
echo "::set-output name=LOCAL_INSTALL_ROOT::$(stack path --local-install-root)"
|
||||
echo "::set-output name=local_install_root::$(stack path --local-install-root)"
|
||||
|
||||
- name: Unix upload binary 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.unix_build.outputs.LOCAL_INSTALL_ROOT }}/bin/simplex-chat
|
||||
file: ${{ steps.unix_build.outputs.local_install_root }}/bin/simplex-chat
|
||||
asset_name: ${{ matrix.asset_name }}
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
@@ -110,14 +111,16 @@ jobs:
|
||||
shell: cmd
|
||||
run: |
|
||||
stack build
|
||||
echo "::set-output name=LOCAL_INSTALL_ROOT::$(stack path --local-install-root)"
|
||||
stack path --local-install-root > tmp_file
|
||||
set /p local_install_root= < tmp_file
|
||||
echo ::set-output name=local_install_root::%local_install_root%
|
||||
|
||||
- name: Windows upload binary 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_build.outputs.LOCAL_INSTALL_ROOT }}/bin/simplex-chat.exe
|
||||
file: ${{ steps.windows_build.outputs.local_install_root }}\bin\simplex-chat.exe
|
||||
asset_name: ${{ matrix.asset_name }}
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
|
||||
14
.gitignore
vendored
@@ -5,12 +5,6 @@
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
@@ -42,9 +36,9 @@ cabal.project.local~
|
||||
.ghc.environment.*
|
||||
stack.yaml.lock
|
||||
|
||||
# Idris
|
||||
*.ibc
|
||||
|
||||
# chat database
|
||||
# Chat database
|
||||
*.db
|
||||
*.db.bak
|
||||
|
||||
# Temporary test files
|
||||
tests/tmp
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# SimpleX Chat
|
||||
|
||||
SimpleX - the most private and secure open-source chat and applications platform - now with double-ratchet E2E encryption.
|
||||
SimpleX - private and secure open-source chat and application platform - public beta for iOS now available!
|
||||
|
||||
[](https://github.com/simplex-chat/simplex-chat/actions?query=workflow%3Abuild)
|
||||
[](https://github.com/simplex-chat/simplex-chat/releases)
|
||||
@@ -10,11 +10,11 @@ SimpleX - the most private and secure open-source chat and applications platform
|
||||
[](https://twitter.com/simplexchat)
|
||||
[](https://www.reddit.com/r/SimpleXChat)
|
||||
|
||||
SimpleX Chat is a terminal (command line) UI using [SimpleXMQ](https://github.com/simplex-chat/simplexmq) message broker.
|
||||
SimpleX Chat apps (both terminal UI and [iOS public beta](https://testflight.apple.com/join/DWuT2LQu)) use [SimpleXMQ](https://github.com/simplex-chat/simplexmq) message broker.
|
||||
|
||||
See [SimpleX overview](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information on platform objectives and technical design.
|
||||
|
||||
**v1.0.0 is released: [read announcement here](https://github.com/simplex-chat/simplex-chat/blob/master/blog/20220112-simplex-chat-v1-released.md)**
|
||||
***SimpleX Chat [public beta for iOS 15 is available via TestFlight](https://testflight.apple.com/join/DWuT2LQu)** - it will help us a lot if you test it! [See the announcement here](https://github.com/simplex-chat/simplex-chat/blob/stable/blog/20220214-simplex-chat-ios-public-beta.md).*
|
||||
|
||||
### :zap: Quick installation
|
||||
|
||||
|
||||
2
apps/android/.idea/misc.xml
generated
@@ -5,7 +5,7 @@
|
||||
<map>
|
||||
<entry key="app/src/main/res/drawable-v24/ic_launcher_foreground.xml" value="0.2328125" />
|
||||
<entry key="app/src/main/res/drawable/ic_launcher_background.xml" value="0.2328125" />
|
||||
<entry key="app/src/main/res/layout/activity_main.xml" value="0.22010869565217392" />
|
||||
<entry key="app/src/main/res/layout/activity_main.xml" value="1.0" />
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "1.000",
|
||||
"green" : "0.533",
|
||||
"red" : "0.000"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,91 +1,115 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Icon-App-20x20@2x-1.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-App-20x20@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "1x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-App-29x29@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-App-40x40@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-App-60x60@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-App-60x60@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-App-20x20@1x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-App-29x29@1x-1.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-App-29x29@2x-1.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-App-40x40@1x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-App-40x40@2x-1.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-App-76x76@1x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-App-76x76@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "83.5x83.5"
|
||||
},
|
||||
{
|
||||
"filename" : "Icon-App-1024x1024@1x.png",
|
||||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
|
||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 824 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
23
apps/ios/Shared/Assets.xcassets/github.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "github_1x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "github_2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "github_3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
apps/ios/Shared/Assets.xcassets/github.imageset/github_1x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
apps/ios/Shared/Assets.xcassets/github.imageset/github_2x.png
vendored
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
apps/ios/Shared/Assets.xcassets/github.imageset/github_3x.png
vendored
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
23
apps/ios/Shared/Assets.xcassets/github_light.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "github_light_1x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "github_light_2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "github_light_3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
apps/ios/Shared/Assets.xcassets/github_light.imageset/github_light_1x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
apps/ios/Shared/Assets.xcassets/github_light.imageset/github_light_2x.png
vendored
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
apps/ios/Shared/Assets.xcassets/github_light.imageset/github_light_3x.png
vendored
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
23
apps/ios/Shared/Assets.xcassets/logo.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "logo-2.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "logo-1.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "logo.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
apps/ios/Shared/Assets.xcassets/logo.imageset/logo-1.png
vendored
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
apps/ios/Shared/Assets.xcassets/logo.imageset/logo-2.png
vendored
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
apps/ios/Shared/Assets.xcassets/logo.imageset/logo.png
vendored
Normal file
|
After Width: | Height: | Size: 19 KiB |
@@ -9,33 +9,65 @@ import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
|
||||
@ObservedObject var alertManager = AlertManager.shared
|
||||
@State private var showNotificationAlert = false
|
||||
|
||||
var body: some View {
|
||||
if let user = chatModel.currentUser {
|
||||
ChatListView(user: user)
|
||||
.onAppear {
|
||||
DispatchQueue.global().async {
|
||||
while(true) {
|
||||
do {
|
||||
try processReceivedMsg(chatModel, chatRecvMsg())
|
||||
} catch {
|
||||
print("error receiving message: ", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try apiStartChat()
|
||||
chatModel.chats = try apiGetChats()
|
||||
} catch {
|
||||
print(error)
|
||||
fatalError("Failed to start or load chats: \(error)")
|
||||
}
|
||||
ChatReceiver.shared.start()
|
||||
NtfManager.shared.requestAuthorization(onDeny: {
|
||||
alertManager.showAlert(notificationAlert())
|
||||
})
|
||||
}
|
||||
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
|
||||
} else {
|
||||
WelcomeView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func notificationAlert() -> Alert {
|
||||
Alert(
|
||||
title: Text("Notification are disabled!"),
|
||||
message: Text("Please open settings to enable"),
|
||||
primaryButton: .default(Text("Open Settings")) {
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class AlertManager: ObservableObject {
|
||||
static let shared = AlertManager()
|
||||
@Published var presentAlert = false
|
||||
@Published var alertView: Alert?
|
||||
|
||||
func showAlert(_ alert: Alert) {
|
||||
logger.debug("AlertManager.showAlert")
|
||||
DispatchQueue.main.async {
|
||||
self.alertView = alert
|
||||
self.presentAlert = true
|
||||
}
|
||||
}
|
||||
|
||||
func showAlertMsg(title: String, message: String? = nil) {
|
||||
if let message = message {
|
||||
showAlert(Alert(title: Text(title), message: Text(message)))
|
||||
} else {
|
||||
showAlert(Alert(title: Text(title)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//struct ContentView_Previews: PreviewProvider {
|
||||
// static var previews: some View {
|
||||
|
||||
80
apps/ios/Shared/Model/BGManager.swift
Normal file
@@ -0,0 +1,80 @@
|
||||
//
|
||||
// BGManager.swift
|
||||
// SimpleX
|
||||
//
|
||||
// Created by Evgeny Poberezkin on 08/02/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import BackgroundTasks
|
||||
|
||||
private let receiveTaskId = "chat.simplex.app.receive"
|
||||
|
||||
// TCP timeout + 2 sec
|
||||
private let waitForMessages: TimeInterval = 6
|
||||
|
||||
private let bgRefreshInterval: TimeInterval = 450
|
||||
|
||||
class BGManager {
|
||||
static let shared = BGManager()
|
||||
var chatReceiver: ChatReceiver?
|
||||
var bgTimer: Timer?
|
||||
var completed = false
|
||||
|
||||
func register() {
|
||||
logger.debug("BGManager.register")
|
||||
BGTaskScheduler.shared.register(forTaskWithIdentifier: receiveTaskId, using: nil) { task in
|
||||
self.handleRefresh(task as! BGAppRefreshTask)
|
||||
}
|
||||
}
|
||||
|
||||
func schedule() {
|
||||
logger.debug("BGManager.schedule")
|
||||
let request = BGAppRefreshTaskRequest(identifier: receiveTaskId)
|
||||
request.earliestBeginDate = Date(timeIntervalSinceNow: bgRefreshInterval)
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
} catch {
|
||||
logger.error("BGManager.schedule error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRefresh(_ task: BGAppRefreshTask) {
|
||||
logger.debug("BGManager.handleRefresh")
|
||||
schedule()
|
||||
self.completed = false
|
||||
|
||||
let completeTask: (String) -> Void = { reason in
|
||||
logger.debug("BGManager.handleRefresh completeTask: \(reason)")
|
||||
if !self.completed {
|
||||
self.completed = true
|
||||
self.chatReceiver?.stop()
|
||||
self.chatReceiver = nil
|
||||
self.bgTimer?.invalidate()
|
||||
self.bgTimer = nil
|
||||
task.setTaskCompleted(success: true)
|
||||
}
|
||||
}
|
||||
|
||||
task.expirationHandler = { completeTask("expirationHandler") }
|
||||
DispatchQueue.main.async {
|
||||
initializeChat()
|
||||
if ChatModel.shared.currentUser == nil {
|
||||
completeTask("no current user")
|
||||
return
|
||||
}
|
||||
logger.debug("BGManager.handleRefresh: starting chat")
|
||||
let cr = ChatReceiver()
|
||||
self.chatReceiver = cr
|
||||
cr.start()
|
||||
RunLoop.current.add(Timer(timeInterval: 2, repeats: true) { timer in
|
||||
logger.debug("BGManager.handleRefresh: timer")
|
||||
self.bgTimer = timer
|
||||
if cr.lastMsgTime.distance(to: Date.now) >= waitForMessages {
|
||||
completeTask("timer (no messages after \(waitForMessages) seconds)")
|
||||
}
|
||||
}, forMode: .default)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,12 @@ final class ChatModel: ObservableObject {
|
||||
// current chat
|
||||
@Published var chatId: String?
|
||||
@Published var chatItems: [ChatItem] = []
|
||||
@Published var chatToTop: String?
|
||||
// items in the terminal view
|
||||
@Published var terminalItems: [TerminalItem] = []
|
||||
@Published var userAddress: String?
|
||||
@Published var appOpenUrl: URL?
|
||||
@Published var connectViaUrl = false
|
||||
static let shared = ChatModel()
|
||||
|
||||
func hasChat(_ id: String) -> Bool {
|
||||
chats.first(where: { $0.id == id }) != nil
|
||||
@@ -31,6 +32,10 @@ final class ChatModel: ObservableObject {
|
||||
chats.first(where: { $0.id == id })
|
||||
}
|
||||
|
||||
private func getChatIndex(_ id: String) -> Int? {
|
||||
chats.firstIndex(where: { $0.id == id })
|
||||
}
|
||||
|
||||
func addChat(_ chat: Chat) {
|
||||
withAnimation {
|
||||
chats.insert(chat, at: 0)
|
||||
@@ -38,14 +43,29 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
func updateChatInfo(_ cInfo: ChatInfo) {
|
||||
if let ix = chats.firstIndex(where: { $0.id == cInfo.id }) {
|
||||
chats[ix].chatInfo = cInfo
|
||||
if let i = getChatIndex(cInfo.id) {
|
||||
chats[i].chatInfo = cInfo
|
||||
}
|
||||
}
|
||||
|
||||
func updateContact(_ contact: Contact) {
|
||||
let cInfo = ChatInfo.direct(contact: contact)
|
||||
if hasChat(contact.id) {
|
||||
updateChatInfo(cInfo)
|
||||
} else {
|
||||
addChat(Chat(chatInfo: cInfo, chatItems: []))
|
||||
}
|
||||
}
|
||||
|
||||
func updateNetworkStatus(_ contact: Contact, _ status: Chat.NetworkStatus) {
|
||||
if let ix = getChatIndex(contact.id) {
|
||||
chats[ix].serverInfo.networkStatus = status
|
||||
}
|
||||
}
|
||||
|
||||
func replaceChat(_ id: String, _ chat: Chat) {
|
||||
if let ix = chats.firstIndex(where: { $0.id == id }) {
|
||||
chats[ix] = chat
|
||||
if let i = getChatIndex(id) {
|
||||
chats[i] = chat
|
||||
} else {
|
||||
// invalid state, correcting
|
||||
chats.insert(chat, at: 0)
|
||||
@@ -53,16 +73,102 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
if let ix = chats.firstIndex(where: { $0.id == cInfo.id }) {
|
||||
chats[ix].chatItems = [cItem]
|
||||
if chatId != cInfo.id {
|
||||
let chat = chats.remove(at: ix)
|
||||
chats.insert(chat, at: 0)
|
||||
// update previews
|
||||
if let i = getChatIndex(cInfo.id) {
|
||||
chats[i].chatItems = [cItem]
|
||||
if case .rcvNew = cItem.meta.itemStatus {
|
||||
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1
|
||||
}
|
||||
if i > 0 {
|
||||
if chatId == nil {
|
||||
withAnimation { popChat_(i) }
|
||||
} else if chatId == cInfo.id {
|
||||
chatToTop = cInfo.id
|
||||
} else {
|
||||
popChat_(i)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
|
||||
}
|
||||
// add to current chat
|
||||
if chatId == cInfo.id {
|
||||
withAnimation { chatItems.append(cItem) }
|
||||
if case .rcvNew = cItem.meta.itemStatus {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
if self.chatId == cInfo.id {
|
||||
SimpleX.markChatItemRead(cInfo, cItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if chatId == cInfo.id {
|
||||
chatItems.append(cItem)
|
||||
}
|
||||
|
||||
func upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
|
||||
// update previews
|
||||
var res: Bool
|
||||
if let chat = getChat(cInfo.id) {
|
||||
if let pItem = chat.chatItems.last, pItem.id == cItem.id {
|
||||
chat.chatItems = [cItem]
|
||||
}
|
||||
res = false
|
||||
} else {
|
||||
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
|
||||
res = true
|
||||
}
|
||||
// update current chat
|
||||
if chatId == cInfo.id {
|
||||
if let i = chatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
withAnimation(.default) {
|
||||
self.chatItems[i] = cItem
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
withAnimation { chatItems.append(cItem) }
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
func markChatItemsRead(_ cInfo: ChatInfo) {
|
||||
// update preview
|
||||
if let chat = getChat(cInfo.id) {
|
||||
chat.chatStats = ChatStats()
|
||||
}
|
||||
// update current chat
|
||||
if chatId == cInfo.id {
|
||||
var i = 0
|
||||
while i < chatItems.count {
|
||||
if case .rcvNew = chatItems[i].meta.itemStatus {
|
||||
chatItems[i].meta.itemStatus = .rcvRead
|
||||
}
|
||||
i = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
// update preview
|
||||
if let i = getChatIndex(cInfo.id) {
|
||||
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1
|
||||
}
|
||||
// update current chat
|
||||
if chatId == cInfo.id, let j = chatItems.firstIndex(where: { $0.id == cItem.id }) {
|
||||
chatItems[j].meta.itemStatus = .rcvRead
|
||||
}
|
||||
}
|
||||
|
||||
func popChat(_ id: String) {
|
||||
if let i = getChatIndex(id) {
|
||||
popChat_(i)
|
||||
}
|
||||
}
|
||||
|
||||
private func popChat_(_ i: Int) {
|
||||
let chat = chats.remove(at: i)
|
||||
chats.insert(chat, at: 0)
|
||||
}
|
||||
|
||||
func removeChat(_ id: String) {
|
||||
@@ -72,43 +178,39 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
struct User: Decodable {
|
||||
struct User: Decodable, NamedChat {
|
||||
var userId: Int64
|
||||
var userContactId: Int64
|
||||
var localDisplayName: ContactName
|
||||
var profile: Profile
|
||||
var activeUser: Bool
|
||||
|
||||
// internal init(userId: Int64, userContactId: Int64, localDisplayName: ContactName, profile: Profile, activeUser: Bool) {
|
||||
// self.userId = userId
|
||||
// self.userContactId = userContactId
|
||||
// self.localDisplayName = localDisplayName
|
||||
// self.profile = profile
|
||||
// self.activeUser = activeUser
|
||||
// }
|
||||
}
|
||||
var displayName: String { get { profile.displayName } }
|
||||
|
||||
let sampleUser = User(
|
||||
userId: 1,
|
||||
userContactId: 1,
|
||||
localDisplayName: "alice",
|
||||
profile: sampleProfile,
|
||||
activeUser: true
|
||||
)
|
||||
var fullName: String { get { profile.fullName } }
|
||||
|
||||
static let sampleData = User(
|
||||
userId: 1,
|
||||
userContactId: 1,
|
||||
localDisplayName: "alice",
|
||||
profile: Profile.sampleData,
|
||||
activeUser: true
|
||||
)
|
||||
}
|
||||
|
||||
typealias ContactName = String
|
||||
|
||||
typealias GroupName = String
|
||||
|
||||
struct Profile: Codable {
|
||||
struct Profile: Codable, NamedChat {
|
||||
var displayName: String
|
||||
var fullName: String
|
||||
}
|
||||
|
||||
let sampleProfile = Profile(
|
||||
displayName: "alice",
|
||||
fullName: "Alice"
|
||||
)
|
||||
static let sampleData = Profile(
|
||||
displayName: "alice",
|
||||
fullName: "Alice"
|
||||
)
|
||||
}
|
||||
|
||||
enum ChatType: String {
|
||||
case direct = "@"
|
||||
@@ -116,7 +218,20 @@ enum ChatType: String {
|
||||
case contactRequest = "<@"
|
||||
}
|
||||
|
||||
enum ChatInfo: Identifiable, Decodable {
|
||||
protocol NamedChat {
|
||||
var displayName: String { get }
|
||||
var fullName: String { get }
|
||||
}
|
||||
|
||||
extension NamedChat {
|
||||
var chatViewName: String {
|
||||
get { displayName + (fullName == "" || fullName == displayName ? "" : " / \(fullName)") }
|
||||
}
|
||||
}
|
||||
|
||||
typealias ChatId = String
|
||||
|
||||
enum ChatInfo: Identifiable, Decodable, NamedChat {
|
||||
case direct(contact: Contact)
|
||||
case group(groupInfo: GroupInfo)
|
||||
case contactRequest(contactRequest: UserContactRequest)
|
||||
@@ -124,14 +239,34 @@ enum ChatInfo: Identifiable, Decodable {
|
||||
var localDisplayName: String {
|
||||
get {
|
||||
switch self {
|
||||
case let .direct(contact): return "@\(contact.localDisplayName)"
|
||||
case let .group(groupInfo): return "#\(groupInfo.localDisplayName)"
|
||||
case let .contactRequest(contactRequest): return "< @\(contactRequest.localDisplayName)"
|
||||
case let .direct(contact): return contact.localDisplayName
|
||||
case let .group(groupInfo): return groupInfo.localDisplayName
|
||||
case let .contactRequest(contactRequest): return contactRequest.localDisplayName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var id: String {
|
||||
|
||||
var displayName: String {
|
||||
get {
|
||||
switch self {
|
||||
case let .direct(contact): return contact.displayName
|
||||
case let .group(groupInfo): return groupInfo.displayName
|
||||
case let .contactRequest(contactRequest): return contactRequest.displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var fullName: String {
|
||||
get {
|
||||
switch self {
|
||||
case let .direct(contact): return contact.fullName
|
||||
case let .group(groupInfo): return groupInfo.fullName
|
||||
case let .contactRequest(contactRequest): return contactRequest.fullName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var id: ChatId {
|
||||
get {
|
||||
switch self {
|
||||
case let .direct(contact): return contact.id
|
||||
@@ -160,107 +295,212 @@ enum ChatInfo: Identifiable, Decodable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var ready: Bool {
|
||||
get {
|
||||
switch self {
|
||||
case let .direct(contact): return contact.ready
|
||||
case let .group(groupInfo): return groupInfo.ready
|
||||
case let .contactRequest(contactRequest): return contactRequest.ready
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var createdAt: Date {
|
||||
switch self {
|
||||
case let .direct(contact): return contact.createdAt
|
||||
case let .group(groupInfo): return groupInfo.createdAt
|
||||
case let .contactRequest(contactRequest): return contactRequest.createdAt
|
||||
}
|
||||
}
|
||||
|
||||
struct SampleData {
|
||||
var direct: ChatInfo
|
||||
var group: ChatInfo
|
||||
var contactRequest: ChatInfo
|
||||
}
|
||||
|
||||
static var sampleData: ChatInfo.SampleData = SampleData(
|
||||
direct: ChatInfo.direct(contact: Contact.sampleData),
|
||||
group: ChatInfo.group(groupInfo: GroupInfo.sampleData),
|
||||
contactRequest: ChatInfo.contactRequest(contactRequest: UserContactRequest.sampleData)
|
||||
)
|
||||
}
|
||||
|
||||
let sampleDirectChatInfo = ChatInfo.direct(contact: sampleContact)
|
||||
|
||||
let sampleGroupChatInfo = ChatInfo.group(groupInfo: sampleGroupInfo)
|
||||
|
||||
let sampleContactRequestChatInfo = ChatInfo.contactRequest(contactRequest: sampleContactRequest)
|
||||
|
||||
final class Chat: ObservableObject, Identifiable {
|
||||
@Published var chatInfo: ChatInfo
|
||||
@Published var chatItems: [ChatItem]
|
||||
@Published var chatStats: ChatStats
|
||||
@Published var serverInfo = ServerInfo(networkStatus: .unknown)
|
||||
|
||||
struct ServerInfo: Decodable {
|
||||
var networkStatus: NetworkStatus
|
||||
}
|
||||
|
||||
enum NetworkStatus: Decodable, Equatable {
|
||||
case unknown
|
||||
case connected
|
||||
case disconnected
|
||||
case error(String)
|
||||
|
||||
var statusString: String {
|
||||
get {
|
||||
switch self {
|
||||
case .connected: return "Server connected"
|
||||
case let .error(err): return "Connecting server… (error: \(err))"
|
||||
default: return "Connecting server…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var statusExplanation: String {
|
||||
get {
|
||||
switch self {
|
||||
case .connected: return "You are connected to the server you use to receve messages from this contact."
|
||||
case let .error(err): return "Trying to connect to the server you use to receve messages from this contact (error: \(err))."
|
||||
default: return "Trying to connect to the server you use to receve 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(_ cData: ChatData) {
|
||||
self.chatInfo = cData.chatInfo
|
||||
self.chatItems = cData.chatItems
|
||||
self.chatStats = cData.chatStats
|
||||
}
|
||||
|
||||
init(chatInfo: ChatInfo, chatItems: [ChatItem] = []) {
|
||||
init(chatInfo: ChatInfo, chatItems: [ChatItem] = [], chatStats: ChatStats = ChatStats()) {
|
||||
self.chatInfo = chatInfo
|
||||
self.chatItems = chatItems
|
||||
self.chatStats = chatStats
|
||||
}
|
||||
|
||||
var id: String { get { chatInfo.id } }
|
||||
var id: ChatId { get { chatInfo.id } }
|
||||
}
|
||||
|
||||
struct ChatData: Decodable, Identifiable {
|
||||
var chatInfo: ChatInfo
|
||||
var chatItems: [ChatItem]
|
||||
var chatStats: ChatStats
|
||||
|
||||
var id: String { get { chatInfo.id } }
|
||||
var id: ChatId { get { chatInfo.id } }
|
||||
}
|
||||
|
||||
struct Contact: Identifiable, Decodable {
|
||||
struct ChatStats: Decodable {
|
||||
var unreadCount: Int = 0
|
||||
var minUnreadItemId: Int64 = 0
|
||||
}
|
||||
|
||||
struct Contact: Identifiable, Decodable, NamedChat {
|
||||
var contactId: Int64
|
||||
var localDisplayName: ContactName
|
||||
var profile: Profile
|
||||
var activeConn: Connection
|
||||
var viaGroup: Int64?
|
||||
|
||||
var id: String { get { "@\(contactId)" } }
|
||||
var apiId: Int64 { get { contactId } }
|
||||
var connected: Bool { get { activeConn.connStatus == "ready" || activeConn.connStatus == "snd-ready" } }
|
||||
}
|
||||
var createdAt: Date
|
||||
|
||||
let sampleContact = Contact(
|
||||
contactId: 1,
|
||||
localDisplayName: "alice",
|
||||
profile: sampleProfile,
|
||||
activeConn: sampleConnection
|
||||
)
|
||||
var id: ChatId { get { "@\(contactId)" } }
|
||||
var apiId: Int64 { get { contactId } }
|
||||
var ready: Bool { get { activeConn.connStatus == "ready" || activeConn.connStatus == "snd-ready" } }
|
||||
var displayName: String { get { profile.displayName } }
|
||||
var fullName: String { get { profile.fullName } }
|
||||
|
||||
static let sampleData = Contact(
|
||||
contactId: 1,
|
||||
localDisplayName: "alice",
|
||||
profile: Profile.sampleData,
|
||||
activeConn: Connection.sampleData,
|
||||
createdAt: .now
|
||||
)
|
||||
}
|
||||
|
||||
struct Connection: Decodable {
|
||||
var connStatus: String
|
||||
|
||||
static let sampleData = Connection(connStatus: "ready")
|
||||
}
|
||||
|
||||
let sampleConnection = Connection(connStatus: "ready")
|
||||
|
||||
struct UserContactRequest: Decodable {
|
||||
struct UserContactRequest: Decodable, NamedChat {
|
||||
var contactRequestId: Int64
|
||||
var localDisplayName: ContactName
|
||||
var profile: Profile
|
||||
var createdAt: Date
|
||||
|
||||
var id: String { get { "<@\(contactRequestId)" } }
|
||||
|
||||
var id: ChatId { get { "<@\(contactRequestId)" } }
|
||||
var apiId: Int64 { get { contactRequestId } }
|
||||
var ready: Bool { get { true } }
|
||||
var displayName: String { get { profile.displayName } }
|
||||
var fullName: String { get { profile.fullName } }
|
||||
|
||||
static let sampleData = UserContactRequest(
|
||||
contactRequestId: 1,
|
||||
localDisplayName: "alice",
|
||||
profile: Profile.sampleData,
|
||||
createdAt: .now
|
||||
)
|
||||
}
|
||||
|
||||
let sampleContactRequest = UserContactRequest(
|
||||
contactRequestId: 1,
|
||||
localDisplayName: "alice",
|
||||
profile: sampleProfile
|
||||
)
|
||||
|
||||
struct GroupInfo: Identifiable, Decodable {
|
||||
struct GroupInfo: Identifiable, Decodable, NamedChat {
|
||||
var groupId: Int64
|
||||
var localDisplayName: GroupName
|
||||
var groupProfile: GroupProfile
|
||||
var createdAt: Date
|
||||
|
||||
var id: String { get { "#\(groupId)" } }
|
||||
|
||||
var id: ChatId { get { "#\(groupId)" } }
|
||||
var apiId: Int64 { get { groupId } }
|
||||
var ready: Bool { get { true } }
|
||||
var displayName: String { get { groupProfile.displayName } }
|
||||
var fullName: String { get { groupProfile.fullName } }
|
||||
|
||||
static let sampleData = GroupInfo(
|
||||
groupId: 1,
|
||||
localDisplayName: "team",
|
||||
groupProfile: GroupProfile.sampleData,
|
||||
createdAt: .now
|
||||
)
|
||||
}
|
||||
|
||||
let sampleGroupInfo = GroupInfo(
|
||||
groupId: 1,
|
||||
localDisplayName: "team",
|
||||
groupProfile: sampleGroupProfile
|
||||
)
|
||||
|
||||
struct GroupProfile: Codable {
|
||||
struct GroupProfile: Codable, NamedChat {
|
||||
var displayName: String
|
||||
var fullName: String
|
||||
|
||||
static let sampleData = GroupProfile(
|
||||
displayName: "team",
|
||||
fullName: "My Team"
|
||||
)
|
||||
}
|
||||
|
||||
let sampleGroupProfile = GroupProfile(
|
||||
displayName: "team",
|
||||
fullName: "My Team"
|
||||
)
|
||||
|
||||
struct GroupMember: Decodable {
|
||||
var groupMemberId: Int64
|
||||
var memberId: String
|
||||
// var memberRole: GroupMemberRole
|
||||
// var memberCategory: GroupMemberCategory
|
||||
// var memberStatus: GroupMemberStatus
|
||||
// var invitedBy: InvitedBy
|
||||
var localDisplayName: ContactName
|
||||
var memberProfile: Profile
|
||||
var memberContactId: Int64?
|
||||
// var activeConn: Connection?
|
||||
|
||||
static let sampleData = GroupMember(
|
||||
groupMemberId: 1,
|
||||
memberId: "abcd",
|
||||
localDisplayName: "alice",
|
||||
memberProfile: Profile.sampleData,
|
||||
memberContactId: 1
|
||||
)
|
||||
}
|
||||
|
||||
struct AChatItem: Decodable {
|
||||
@@ -274,21 +514,28 @@ struct ChatItem: Identifiable, Decodable {
|
||||
var content: CIContent
|
||||
|
||||
var id: Int64 { get { meta.itemId } }
|
||||
}
|
||||
|
||||
func chatItemSample(_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String) -> ChatItem {
|
||||
ChatItem(
|
||||
chatDir: dir,
|
||||
meta: ciMetaSample(id, ts, text),
|
||||
content: .sndMsgContent(msgContent: .text(text))
|
||||
)
|
||||
var timestampText: String { get { meta.timestampText } }
|
||||
|
||||
func isRcvNew() -> Bool {
|
||||
if case .rcvNew = meta.itemStatus { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew) -> ChatItem {
|
||||
ChatItem(
|
||||
chatDir: dir,
|
||||
meta: CIMeta.getSample(id, ts, text, status),
|
||||
content: .sndMsgContent(msgContent: .text(text))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum CIDirection: Decodable {
|
||||
case directSnd
|
||||
case directRcv
|
||||
case groupSnd
|
||||
case groupRcv(GroupMember)
|
||||
case groupRcv(groupMember: GroupMember)
|
||||
|
||||
var sent: Bool {
|
||||
get {
|
||||
@@ -306,39 +553,64 @@ struct CIMeta: Decodable {
|
||||
var itemId: Int64
|
||||
var itemTs: Date
|
||||
var itemText: String
|
||||
var itemStatus: CIStatus
|
||||
var createdAt: Date
|
||||
|
||||
var timestampText: String { get { SimpleX.timestampText(itemTs) } }
|
||||
|
||||
static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew) -> CIMeta {
|
||||
CIMeta(
|
||||
itemId: id,
|
||||
itemTs: ts,
|
||||
itemText: text,
|
||||
itemStatus: status,
|
||||
createdAt: ts
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func ciMetaSample(_ id: Int64, _ ts: Date, _ text: String) -> CIMeta {
|
||||
CIMeta(
|
||||
itemId: id,
|
||||
itemTs: ts,
|
||||
itemText: text,
|
||||
createdAt: ts
|
||||
)
|
||||
|
||||
func timestampText(_ date: Date) -> String {
|
||||
date.formatted(date: .omitted, time: .shortened)
|
||||
}
|
||||
|
||||
enum CIStatus: Decodable {
|
||||
case sndNew
|
||||
case sndSent
|
||||
case sndErrorAuth
|
||||
case sndError(agentError: AgentErrorType)
|
||||
case rcvNew
|
||||
case rcvRead
|
||||
}
|
||||
|
||||
enum CIContent: Decodable {
|
||||
case sndMsgContent(msgContent: MsgContent)
|
||||
case rcvMsgContent(msgContent: MsgContent)
|
||||
// files etc.
|
||||
case sndFileInvitation(fileId: Int64, filePath: String)
|
||||
case rcvFileInvitation(rcvFileTransfer: RcvFileTransfer)
|
||||
|
||||
var text: String {
|
||||
get {
|
||||
switch self {
|
||||
case let .sndMsgContent(mc): return mc.string
|
||||
case let .rcvMsgContent(mc): return mc.string
|
||||
case let .sndMsgContent(mc): return mc.text
|
||||
case let .rcvMsgContent(mc): return mc.text
|
||||
case .sndFileInvitation: return "sending files is not supported yet"
|
||||
case .rcvFileInvitation: return "receiving files is not supported yet"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RcvFileTransfer: Decodable {
|
||||
|
||||
}
|
||||
|
||||
enum MsgContent {
|
||||
case text(String)
|
||||
case unknown(type: String, text: String)
|
||||
case invalid(error: String)
|
||||
|
||||
var string: String {
|
||||
var text: String {
|
||||
get {
|
||||
switch self {
|
||||
case let .text(text): return text
|
||||
|
||||
195
apps/ios/Shared/Model/NtfManager.swift
Normal file
@@ -0,0 +1,195 @@
|
||||
//
|
||||
// NtfManager.swift
|
||||
// SimpleX
|
||||
//
|
||||
// Created by Evgeny Poberezkin on 08/02/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
import UIKit
|
||||
|
||||
let ntfActionAccept = "NTF_ACT_ACCEPT"
|
||||
|
||||
let ntfCategoryContactRequest = "NTF_CAT_CONTACT_REQUEST"
|
||||
let ntfCategoryContactConnected = "NTF_CAT_CONTACT_CONNECTED"
|
||||
let ntfCategoryMessageReceived = "NTF_CAT_MESSAGE_RECEIVED"
|
||||
|
||||
let appNotificationId = "chat.simplex.app.notification"
|
||||
|
||||
private let ntfTimeInterval: TimeInterval = 1
|
||||
|
||||
class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
static let shared = NtfManager()
|
||||
|
||||
private var granted = false
|
||||
private var prevNtfTime: Dictionary<ChatId, Date> = [:]
|
||||
|
||||
|
||||
// Handle notification when app is in background
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler handler: () -> Void) {
|
||||
logger.debug("NtfManager.userNotificationCenter: didReceive")
|
||||
let content = response.notification.request.content
|
||||
let chatModel = ChatModel.shared
|
||||
if content.categoryIdentifier == ntfCategoryContactRequest && response.actionIdentifier == ntfActionAccept,
|
||||
let chatId = content.userInfo["chatId"] as? String,
|
||||
case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo {
|
||||
acceptContactRequest(contactRequest)
|
||||
} else {
|
||||
chatModel.chatId = content.targetContentIdentifier
|
||||
}
|
||||
handler()
|
||||
}
|
||||
|
||||
// Handle notification when the app is in foreground
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
withCompletionHandler handler: (UNNotificationPresentationOptions) -> Void) {
|
||||
logger.debug("NtfManager.userNotificationCenter: willPresent")
|
||||
handler(presentationOptions(notification.request.content))
|
||||
}
|
||||
|
||||
private func presentationOptions(_ content: UNNotificationContent) -> UNNotificationPresentationOptions {
|
||||
let model = ChatModel.shared
|
||||
if UIApplication.shared.applicationState == .active {
|
||||
switch content.categoryIdentifier {
|
||||
case ntfCategoryMessageReceived:
|
||||
if model.chatId == nil {
|
||||
// in the chat list
|
||||
return recentInTheSameChat(content) ? [] : [.sound, .list]
|
||||
} else if model.chatId == content.targetContentIdentifier {
|
||||
// in the current chat
|
||||
return recentInTheSameChat(content) ? [] : [.sound, .list]
|
||||
} else {
|
||||
// in another chat
|
||||
return recentInTheSameChat(content) ? [.banner, .list] : [.sound, .banner, .list]
|
||||
}
|
||||
default: return [.sound, .banner, .list]
|
||||
}
|
||||
} else {
|
||||
return [.sound, .banner, .list]
|
||||
}
|
||||
}
|
||||
|
||||
private func recentInTheSameChat(_ content: UNNotificationContent) -> Bool {
|
||||
let now = Date.now
|
||||
if let chatId = content.targetContentIdentifier {
|
||||
var res: Bool = false
|
||||
if let t = prevNtfTime[chatId] { res = t.distance(to: now) < 30 }
|
||||
prevNtfTime[chatId] = now
|
||||
return res
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func registerCategories() {
|
||||
logger.debug("NtfManager.registerCategories")
|
||||
UNUserNotificationCenter.current().setNotificationCategories([
|
||||
UNNotificationCategory(
|
||||
identifier: ntfCategoryContactRequest,
|
||||
actions: [UNNotificationAction(
|
||||
identifier: ntfActionAccept,
|
||||
title: "Accept"
|
||||
)],
|
||||
intentIdentifiers: [],
|
||||
hiddenPreviewsBodyPlaceholder: "New contact request"
|
||||
),
|
||||
UNNotificationCategory(
|
||||
identifier: ntfCategoryContactConnected,
|
||||
actions: [],
|
||||
intentIdentifiers: [],
|
||||
hiddenPreviewsBodyPlaceholder: "Contact is connected"
|
||||
),
|
||||
UNNotificationCategory(
|
||||
identifier: ntfCategoryMessageReceived,
|
||||
actions: [],
|
||||
intentIdentifiers: [],
|
||||
hiddenPreviewsBodyPlaceholder: "New message"
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
func requestAuthorization(onDeny handler: (()-> Void)? = nil) {
|
||||
logger.debug("NtfManager.requestAuthorization")
|
||||
let center = UNUserNotificationCenter.current()
|
||||
center.getNotificationSettings { settings in
|
||||
switch settings.authorizationStatus {
|
||||
case .denied:
|
||||
if let handler = handler { handler() }
|
||||
return
|
||||
case .authorized:
|
||||
self.granted = true
|
||||
default:
|
||||
center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||
if let error = error {
|
||||
logger.error("NtfManager.requestAuthorization error \(error.localizedDescription)")
|
||||
} else {
|
||||
self.granted = granted
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
center.delegate = self
|
||||
}
|
||||
|
||||
func notifyContactRequest(_ contactRequest: UserContactRequest) {
|
||||
logger.debug("NtfManager.notifyContactRequest")
|
||||
addNotification(
|
||||
categoryIdentifier: ntfCategoryContactRequest,
|
||||
title: "\(contactRequest.displayName) wants to connect!",
|
||||
body: "Accept contact request from \(contactRequest.chatViewName)?",
|
||||
targetContentIdentifier: nil,
|
||||
userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId]
|
||||
)
|
||||
}
|
||||
|
||||
func notifyContactConnected(_ contact: Contact) {
|
||||
logger.debug("NtfManager.notifyContactConnected")
|
||||
addNotification(
|
||||
categoryIdentifier: ntfCategoryContactConnected,
|
||||
title: "\(contact.displayName) is connected!",
|
||||
body: "You can now send messages to \(contact.chatViewName)",
|
||||
targetContentIdentifier: contact.id
|
||||
// userInfo: ["chatId": contact.id, "contactId": contact.apiId]
|
||||
)
|
||||
}
|
||||
|
||||
func notifyMessageReceived(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
logger.debug("NtfManager.notifyMessageReceived")
|
||||
addNotification(
|
||||
categoryIdentifier: ntfCategoryMessageReceived,
|
||||
title: "\(cInfo.chatViewName):",
|
||||
body: cItem.content.text,
|
||||
targetContentIdentifier: cInfo.id
|
||||
// userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id]
|
||||
)
|
||||
}
|
||||
|
||||
private func addNotification(categoryIdentifier: String, title: String, subtitle: String? = nil, body: String? = nil,
|
||||
targetContentIdentifier: String? = nil, userInfo: [AnyHashable : Any] = [:]) {
|
||||
if !granted { return }
|
||||
let content = UNMutableNotificationContent()
|
||||
content.categoryIdentifier = categoryIdentifier
|
||||
content.title = title
|
||||
if let s = subtitle { content.subtitle = s }
|
||||
if let s = body { content.body = s }
|
||||
content.targetContentIdentifier = targetContentIdentifier
|
||||
content.userInfo = userInfo
|
||||
content.sound = .default
|
||||
// content.interruptionLevel = .active
|
||||
// content.relevanceScore = 0.5 // 0-1
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: ntfTimeInterval, repeats: false)
|
||||
let request = UNNotificationRequest(identifier: appNotificationId, content: content, trigger: trigger)
|
||||
UNUserNotificationCenter.current().add(request) { error in
|
||||
if let error = error { logger.error("addNotification error: \(error.localizedDescription)") }
|
||||
}
|
||||
}
|
||||
|
||||
func removeNotifications(_ ids : [String]){
|
||||
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ids)
|
||||
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids)
|
||||
}
|
||||
}
|
||||
@@ -8,59 +8,83 @@
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Dispatch
|
||||
import BackgroundTasks
|
||||
|
||||
private var chatStore: chat_store?
|
||||
private var chatController: chat_ctrl?
|
||||
private let jsonDecoder = getJSONDecoder()
|
||||
private let jsonEncoder = getJSONEncoder()
|
||||
|
||||
enum ChatCommand {
|
||||
case showActiveUser
|
||||
case createActiveUser(profile: Profile)
|
||||
case startChat
|
||||
case apiGetChats
|
||||
case apiGetChat(type: ChatType, id: Int64)
|
||||
case apiSendMessage(type: ChatType, id: Int64, msg: MsgContent)
|
||||
case addContact
|
||||
case connect(connReq: String)
|
||||
case apiDeleteChat(type: ChatType, id: Int64)
|
||||
case apiUpdateProfile(profile: Profile)
|
||||
case updateProfile(profile: Profile)
|
||||
case createMyAddress
|
||||
case deleteMyAddress
|
||||
case showMyAddress
|
||||
case apiAcceptContact(contactReqId: Int64)
|
||||
case apiRejectContact(contactReqId: Int64)
|
||||
case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64))
|
||||
case string(String)
|
||||
|
||||
var cmdString: String {
|
||||
get {
|
||||
switch self {
|
||||
case .apiGetChats:
|
||||
return "/_get chats"
|
||||
case let .apiGetChat(type, id):
|
||||
return "/_get chat \(type.rawValue)\(id) count=500"
|
||||
case let .apiSendMessage(type, id, mc):
|
||||
return "/_send \(type.rawValue)\(id) \(mc.cmdString)"
|
||||
case .addContact:
|
||||
return "/connect"
|
||||
case let .connect(connReq):
|
||||
return "/connect \(connReq)"
|
||||
case let .apiDeleteChat(type, id):
|
||||
return "/_delete \(type.rawValue)\(id)"
|
||||
case let .apiUpdateProfile(profile):
|
||||
return "/profile \(profile.displayName) \(profile.fullName)"
|
||||
case .createMyAddress:
|
||||
return "/address"
|
||||
case .deleteMyAddress:
|
||||
return "/delete_address"
|
||||
case .showMyAddress:
|
||||
return "/show_address"
|
||||
case let .apiAcceptContact(contactReqId):
|
||||
return "/_accept \(contactReqId)"
|
||||
case let .apiRejectContact(contactReqId):
|
||||
return "/_reject \(contactReqId)"
|
||||
case let .string(str):
|
||||
return str
|
||||
case .showActiveUser: return "/u"
|
||||
case let .createActiveUser(profile): return "/u \(profile.displayName) \(profile.fullName)"
|
||||
case .startChat: return "/_start"
|
||||
case .apiGetChats: return "/_get chats"
|
||||
case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100"
|
||||
case let .apiSendMessage(type, id, mc): return "/_send \(ref(type, id)) \(mc.cmdString)"
|
||||
case .addContact: return "/connect"
|
||||
case let .connect(connReq): return "/connect \(connReq)"
|
||||
case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))"
|
||||
case let .updateProfile(profile): return "/profile \(profile.displayName) \(profile.fullName)"
|
||||
case .createMyAddress: return "/address"
|
||||
case .deleteMyAddress: return "/delete_address"
|
||||
case .showMyAddress: return "/show_address"
|
||||
case let .apiAcceptContact(contactReqId): return "/_accept \(contactReqId)"
|
||||
case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)"
|
||||
case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)"
|
||||
case let .string(str): return str
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cmdType: String {
|
||||
get {
|
||||
switch self {
|
||||
case .showActiveUser: return "showActiveUser"
|
||||
case .createActiveUser: return "createActiveUser"
|
||||
case .startChat: return "startChat"
|
||||
case .apiGetChats: return "apiGetChats"
|
||||
case .apiGetChat: return "apiGetChat"
|
||||
case .apiSendMessage: return "apiSendMessage"
|
||||
case .addContact: return "addContact"
|
||||
case .connect: return "connect"
|
||||
case .apiDeleteChat: return "apiDeleteChat"
|
||||
case .updateProfile: return "updateProfile"
|
||||
case .createMyAddress: return "createMyAddress"
|
||||
case .deleteMyAddress: return "deleteMyAddress"
|
||||
case .showMyAddress: return "showMyAddress"
|
||||
case .apiAcceptContact: return "apiAcceptContact"
|
||||
case .apiRejectContact: return "apiRejectContact"
|
||||
case .apiChatRead: return "apiChatRead"
|
||||
case .string: return "console command"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ref(_ type: ChatType, _ id: Int64) -> String {
|
||||
"\(type.rawValue)\(id)"
|
||||
}
|
||||
}
|
||||
|
||||
struct APIResponse: Decodable {
|
||||
@@ -69,6 +93,8 @@ struct APIResponse: Decodable {
|
||||
|
||||
enum ChatResponse: Decodable, Error {
|
||||
case response(type: String, json: String)
|
||||
case activeUser(user: User)
|
||||
case chatStarted
|
||||
case apiChats(chats: [ChatData])
|
||||
case apiChat(chat: ChatData)
|
||||
case invitation(connReqInvitation: String)
|
||||
@@ -84,13 +110,25 @@ enum ChatResponse: Decodable, Error {
|
||||
case receivedContactRequest(contactRequest: UserContactRequest)
|
||||
case acceptingContactRequest(contact: Contact)
|
||||
case contactRequestRejected
|
||||
case contactUpdated(toContact: Contact)
|
||||
case contactSubscribed(contact: Contact)
|
||||
case contactDisconnected(contact: Contact)
|
||||
case contactSubError(contact: Contact, chatError: ChatError)
|
||||
case groupSubscribed(groupInfo: GroupInfo)
|
||||
case groupEmpty(groupInfo: GroupInfo)
|
||||
case userContactLinkSubscribed
|
||||
case newChatItem(chatItem: AChatItem)
|
||||
case chatItemUpdated(chatItem: AChatItem)
|
||||
case cmdOk
|
||||
case chatCmdError(chatError: ChatError)
|
||||
case chatError(chatError: ChatError)
|
||||
|
||||
var responseType: String {
|
||||
get {
|
||||
switch self {
|
||||
case let .response(type, _): return "* \(type)"
|
||||
case .activeUser: return "activeUser"
|
||||
case .chatStarted: return "chatStarted"
|
||||
case .apiChats: return "apiChats"
|
||||
case .apiChat: return "apiChat"
|
||||
case .invitation: return "invitation"
|
||||
@@ -106,8 +144,18 @@ enum ChatResponse: Decodable, Error {
|
||||
case .receivedContactRequest: return "receivedContactRequest"
|
||||
case .acceptingContactRequest: return "acceptingContactRequest"
|
||||
case .contactRequestRejected: return "contactRequestRejected"
|
||||
case .contactUpdated: return "contactUpdated"
|
||||
case .contactSubscribed: return "contactSubscribed"
|
||||
case .contactDisconnected: return "contactDisconnected"
|
||||
case .contactSubError: return "contactSubError"
|
||||
case .groupSubscribed: return "groupSubscribed"
|
||||
case .groupEmpty: return "groupEmpty"
|
||||
case .userContactLinkSubscribed: return "userContactLinkSubscribed"
|
||||
case .newChatItem: return "newChatItem"
|
||||
case .chatItemUpdated: return "chatItemUpdated"
|
||||
case .cmdOk: return "cmdOk"
|
||||
case .chatCmdError: return "chatCmdError"
|
||||
case .chatError: return "chatError"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,6 +164,8 @@ enum ChatResponse: Decodable, Error {
|
||||
get {
|
||||
switch self {
|
||||
case let .response(_, json): return json
|
||||
case let .activeUser(user): return String(describing: user)
|
||||
case .chatStarted: return noDetails
|
||||
case let .apiChats(chats): return String(describing: chats)
|
||||
case let .apiChat(chat): return String(describing: chat)
|
||||
case let .invitation(connReqInvitation): return connReqInvitation
|
||||
@@ -131,8 +181,18 @@ enum ChatResponse: Decodable, Error {
|
||||
case let .receivedContactRequest(contactRequest): return String(describing: contactRequest)
|
||||
case let .acceptingContactRequest(contact): return String(describing: contact)
|
||||
case .contactRequestRejected: return noDetails
|
||||
case let .contactUpdated(toContact): return String(describing: toContact)
|
||||
case let .contactSubscribed(contact): return String(describing: contact)
|
||||
case let .contactDisconnected(contact): return String(describing: contact)
|
||||
case let .contactSubError(contact, chatError): return "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))"
|
||||
case let .groupSubscribed(groupInfo): return String(describing: groupInfo)
|
||||
case let .groupEmpty(groupInfo): return String(describing: groupInfo)
|
||||
case .userContactLinkSubscribed: return noDetails
|
||||
case let .newChatItem(chatItem): return String(describing: chatItem)
|
||||
case let .chatItemUpdated(chatItem): return String(describing: chatItem)
|
||||
case .cmdOk: return noDetails
|
||||
case let .chatCmdError(chatError): return String(describing: chatError)
|
||||
case let .chatError(chatError): return String(describing: chatError)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,42 +232,44 @@ enum TerminalItem: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
func chatGetUser() -> User? {
|
||||
let store = getStore()
|
||||
print("chatGetUser")
|
||||
let r: UserResponse? = decodeCJSON(chat_get_user(store))
|
||||
let user = r?.user
|
||||
if user != nil { initChatCtrl(store) }
|
||||
print("user", user as Any)
|
||||
return user
|
||||
}
|
||||
|
||||
func chatCreateUser(_ p: Profile) -> User? {
|
||||
let store = getStore()
|
||||
print("chatCreateUser")
|
||||
var str = encodeCJSON(p)
|
||||
chat_create_user(store, &str)
|
||||
let user = chatGetUser()
|
||||
if user != nil { initChatCtrl(store) }
|
||||
print("user", user as Any)
|
||||
return user
|
||||
}
|
||||
|
||||
func chatSendCmd(_ cmd: ChatCommand) throws -> ChatResponse {
|
||||
var c = cmd.cmdString.cString(using: .utf8)!
|
||||
print("command", cmd.cmdString)
|
||||
// TODO some mechanism to update model without passing it - maybe Publisher / Subscriber?
|
||||
// DispatchQueue.main.async {
|
||||
// termId += 1
|
||||
// chatModel.terminalItems.append(.cmd(termId, cmd))
|
||||
// }
|
||||
return chatResponse(chat_send_cmd(getChatCtrl(), &c)!)
|
||||
logger.debug("chatSendCmd \(cmd.cmdType)")
|
||||
let resp = chatResponse(chat_send_cmd(getChatCtrl(), &c)!)
|
||||
logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)")
|
||||
DispatchQueue.main.async {
|
||||
ChatModel.shared.terminalItems.append(.cmd(.now, cmd))
|
||||
ChatModel.shared.terminalItems.append(.resp(.now, resp))
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func chatRecvMsg() throws -> ChatResponse {
|
||||
chatResponse(chat_recv_msg(getChatCtrl())!)
|
||||
}
|
||||
|
||||
func apiGetActiveUser() throws -> User? {
|
||||
let _ = getChatCtrl()
|
||||
let r = try chatSendCmd(.showActiveUser)
|
||||
switch r {
|
||||
case let .activeUser(user): return user
|
||||
case .chatCmdError(.error(.noActiveUser)): return nil
|
||||
default: throw r
|
||||
}
|
||||
}
|
||||
|
||||
func apiCreateActiveUser(_ p: Profile) throws -> User {
|
||||
let r = try chatSendCmd(.createActiveUser(profile: p))
|
||||
if case let .activeUser(user) = r { return user }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiStartChat() throws {
|
||||
let r = try chatSendCmd(.startChat)
|
||||
if case .chatStarted = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetChats() throws -> [Chat] {
|
||||
let r = try chatSendCmd(.apiGetChats)
|
||||
if case let .apiChats(chats) = r { return chats.map { Chat.init($0) } }
|
||||
@@ -248,7 +310,7 @@ func apiDeleteChat(type: ChatType, id: Int64) throws {
|
||||
}
|
||||
|
||||
func apiUpdateProfile(profile: Profile) throws -> Profile? {
|
||||
let r = try chatSendCmd(.apiUpdateProfile(profile: profile))
|
||||
let r = try chatSendCmd(.updateProfile(profile: profile))
|
||||
switch r {
|
||||
case .userProfileNoChange: return nil
|
||||
case let .userProfileUpdated(_, toProfile): return toProfile
|
||||
@@ -291,26 +353,145 @@ func apiRejectContactRequest(contactReqId: Int64) throws {
|
||||
throw r
|
||||
}
|
||||
|
||||
func processReceivedMsg(_ chatModel: ChatModel, _ res: ChatResponse) {
|
||||
func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) throws {
|
||||
let r = try chatSendCmd(.apiChatRead(type: type, id: id, itemRange: itemRange))
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func acceptContactRequest(_ contactRequest: UserContactRequest) {
|
||||
do {
|
||||
let contact = try apiAcceptContactRequest(contactReqId: contactRequest.apiId)
|
||||
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
|
||||
ChatModel.shared.replaceChat(contactRequest.id, chat)
|
||||
} catch let error {
|
||||
logger.error("acceptContactRequest error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func rejectContactRequest(_ contactRequest: UserContactRequest) {
|
||||
do {
|
||||
try apiRejectContactRequest(contactReqId: contactRequest.apiId)
|
||||
ChatModel.shared.removeChat(contactRequest.id)
|
||||
} catch let error {
|
||||
logger.error("rejectContactRequest: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func markChatRead(_ chat: Chat) {
|
||||
do {
|
||||
let minItemId = chat.chatStats.minUnreadItemId
|
||||
let itemRange = (minItemId, chat.chatItems.last?.id ?? minItemId)
|
||||
let cInfo = chat.chatInfo
|
||||
try apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: itemRange)
|
||||
ChatModel.shared.markChatItemsRead(cInfo)
|
||||
} catch {
|
||||
logger.error("markChatRead apiChatRead error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
do {
|
||||
try apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
|
||||
ChatModel.shared.markChatItemRead(cInfo, cItem)
|
||||
} catch {
|
||||
logger.error("markChatItemRead apiChatRead error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func initializeChat() {
|
||||
do {
|
||||
ChatModel.shared.currentUser = try apiGetActiveUser()
|
||||
} catch {
|
||||
fatalError("Failed to initialize chat controller or database: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
class ChatReceiver {
|
||||
private var receiveLoop: DispatchWorkItem?
|
||||
private var receiveMessages = true
|
||||
private var _lastMsgTime = Date.now
|
||||
|
||||
static let shared = ChatReceiver()
|
||||
|
||||
var lastMsgTime: Date { get { _lastMsgTime } }
|
||||
|
||||
func start() {
|
||||
logger.debug("ChatReceiver.start")
|
||||
receiveMessages = true
|
||||
_lastMsgTime = .now
|
||||
if receiveLoop != nil { return }
|
||||
let loop = DispatchWorkItem(qos: .default, flags: []) {
|
||||
while self.receiveMessages {
|
||||
do {
|
||||
processReceivedMsg(try chatRecvMsg())
|
||||
self._lastMsgTime = .now
|
||||
} catch {
|
||||
logger.error("ChatReceiver.start chatRecvMsg error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
receiveLoop = loop
|
||||
DispatchQueue.global().async(execute: loop)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
logger.debug("ChatReceiver.stop")
|
||||
receiveMessages = false
|
||||
receiveLoop?.cancel()
|
||||
receiveLoop = nil
|
||||
}
|
||||
}
|
||||
|
||||
func processReceivedMsg(_ res: ChatResponse) {
|
||||
let chatModel = ChatModel.shared
|
||||
DispatchQueue.main.async {
|
||||
chatModel.terminalItems.append(.resp(Date.now, res))
|
||||
chatModel.terminalItems.append(.resp(.now, res))
|
||||
logger.debug("processReceivedMsg: \(res.responseType)")
|
||||
switch res {
|
||||
case let .contactConnected(contact):
|
||||
let cInfo = ChatInfo.direct(contact: contact)
|
||||
if chatModel.hasChat(contact.id) {
|
||||
chatModel.updateChatInfo(cInfo)
|
||||
} else {
|
||||
chatModel.addChat(Chat(chatInfo: cInfo, chatItems: []))
|
||||
}
|
||||
chatModel.updateContact(contact)
|
||||
chatModel.updateNetworkStatus(contact, .connected)
|
||||
NtfManager.shared.notifyContactConnected(contact)
|
||||
case let .receivedContactRequest(contactRequest):
|
||||
chatModel.addChat(Chat(
|
||||
chatInfo: ChatInfo.contactRequest(contactRequest: contactRequest),
|
||||
chatItems: []
|
||||
))
|
||||
NtfManager.shared.notifyContactRequest(contactRequest)
|
||||
case let .contactUpdated(toContact):
|
||||
let cInfo = ChatInfo.direct(contact: toContact)
|
||||
if chatModel.hasChat(toContact.id) {
|
||||
chatModel.updateChatInfo(cInfo)
|
||||
}
|
||||
case let .contactSubscribed(contact):
|
||||
chatModel.updateContact(contact)
|
||||
chatModel.updateNetworkStatus(contact, .connected)
|
||||
case let .contactDisconnected(contact):
|
||||
chatModel.updateContact(contact)
|
||||
chatModel.updateNetworkStatus(contact, .disconnected)
|
||||
case let .contactSubError(contact, chatError):
|
||||
chatModel.updateContact(contact)
|
||||
var err: String
|
||||
switch chatError {
|
||||
case .errorAgent(agentError: .BROKER(brokerErr: .NETWORK)): err = "network"
|
||||
case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted"
|
||||
default: err = String(describing: chatError)
|
||||
}
|
||||
chatModel.updateNetworkStatus(contact, .error(err))
|
||||
case let .newChatItem(aChatItem):
|
||||
chatModel.addChatItem(aChatItem.chatInfo, aChatItem.chatItem)
|
||||
let cInfo = aChatItem.chatInfo
|
||||
let cItem = aChatItem.chatItem
|
||||
chatModel.addChatItem(cInfo, cItem)
|
||||
NtfManager.shared.notifyMessageReceived(cInfo, cItem)
|
||||
case let .chatItemUpdated(aChatItem):
|
||||
let cInfo = aChatItem.chatInfo
|
||||
let cItem = aChatItem.chatItem
|
||||
if chatModel.upsertChatItem(cInfo, cItem) {
|
||||
NtfManager.shared.notifyMessageReceived(cInfo, cItem)
|
||||
}
|
||||
default:
|
||||
print("unsupported response: ", res.responseType)
|
||||
logger.debug("unsupported event: \(res.responseType)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -333,7 +514,7 @@ private func chatResponse(_ cjson: UnsafePointer<CChar>) -> ChatResponse {
|
||||
let r = try jsonDecoder.decode(APIResponse.self, from: d)
|
||||
return r.resp
|
||||
} catch {
|
||||
print (error)
|
||||
logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
var type: String?
|
||||
@@ -354,23 +535,12 @@ func prettyJSON(_ obj: NSDictionary) -> String? {
|
||||
return nil
|
||||
}
|
||||
|
||||
private func getStore() -> chat_store {
|
||||
if let store = chatStore { return store }
|
||||
let dataDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.path + "/mobile_v1"
|
||||
var cstr = dataDir.cString(using: .utf8)!
|
||||
chatStore = chat_init_store(&cstr)
|
||||
return chatStore!
|
||||
}
|
||||
|
||||
private func initChatCtrl(_ store: chat_store) {
|
||||
if chatController == nil {
|
||||
chatController = chat_start(store)
|
||||
}
|
||||
}
|
||||
|
||||
private func getChatCtrl() -> chat_ctrl {
|
||||
if let controller = chatController { return controller }
|
||||
fatalError("Chat controller was not started!")
|
||||
let dataDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.path + "/mobile_v1"
|
||||
var cstr = dataDir.cString(using: .utf8)!
|
||||
chatController = chat_init(&cstr)
|
||||
return chatController!
|
||||
}
|
||||
|
||||
private func decodeCJSON<T: Decodable>(_ cjson: UnsafePointer<CChar>) -> T? {
|
||||
@@ -395,15 +565,138 @@ private func encodeCJSON<T: Encodable>(_ value: T) -> [CChar] {
|
||||
|
||||
enum ChatError: Decodable {
|
||||
case error(errorType: ChatErrorType)
|
||||
case errorAgent(agentError: AgentErrorType)
|
||||
case errorStore(storeError: StoreError)
|
||||
// TODO other error cases
|
||||
}
|
||||
|
||||
enum ChatErrorType: Decodable {
|
||||
case noActiveUser
|
||||
case activeUserExists
|
||||
case chatNotStarted
|
||||
case invalidConnReq
|
||||
case invalidChatMessage(message: String)
|
||||
case contactNotReady(contact: Contact)
|
||||
case contactGroups(contact: Contact, groupNames: [GroupName])
|
||||
case groupUserRole
|
||||
case groupContactRole(contactName: ContactName)
|
||||
case groupDuplicateMember(contactName: ContactName)
|
||||
case groupDuplicateMemberId
|
||||
case groupNotJoined(groupInfo: GroupInfo)
|
||||
case groupMemberNotActive
|
||||
case groupMemberUserRemoved
|
||||
case groupMemberNotFound(contactName: ContactName)
|
||||
case groupMemberIntroNotFound(contactName: ContactName)
|
||||
case groupCantResendInvitation(groupInfo: GroupInfo, contactName: ContactName)
|
||||
case groupInternal(message: String)
|
||||
case fileNotFound(message: String)
|
||||
case fileAlreadyReceiving(message: String)
|
||||
case fileAlreadyExists(filePath: String)
|
||||
case fileRead(filePath: String, message: String)
|
||||
case fileWrite(filePath: String, message: String)
|
||||
case fileSend(fileId: Int64, agentError: String)
|
||||
case fileRcvChunk(message: String)
|
||||
case fileInternal(message: String)
|
||||
case agentVersion
|
||||
case commandError(message: String)
|
||||
}
|
||||
|
||||
enum StoreError: Decodable {
|
||||
case duplicateName
|
||||
case contactNotFound(contactId: Int64)
|
||||
case contactNotFoundByName(contactName: ContactName)
|
||||
case contactNotReady(contactName: ContactName)
|
||||
case duplicateContactLink
|
||||
case userContactLinkNotFound
|
||||
// TODO other error cases
|
||||
case contactRequestNotFound(contactRequestId: Int64)
|
||||
case contactRequestNotFoundByName(contactName: ContactName)
|
||||
case groupNotFound(groupId: Int64)
|
||||
case groupNotFoundByName(groupName: GroupName)
|
||||
case groupWithoutUser
|
||||
case duplicateGroupMember
|
||||
case groupAlreadyJoined
|
||||
case groupInvitationNotFound
|
||||
case sndFileNotFound(fileId: Int64)
|
||||
case sndFileInvalid(fileId: Int64)
|
||||
case rcvFileNotFound(fileId: Int64)
|
||||
case fileNotFound(fileId: Int64)
|
||||
case rcvFileInvalid(fileId: Int64)
|
||||
case connectionNotFound(agentConnId: String)
|
||||
case introNotFound
|
||||
case uniqueID
|
||||
case internalError(message: String)
|
||||
case noMsgDelivery(connId: Int64, agentMsgId: String)
|
||||
case badChatItem(itemId: Int64)
|
||||
case chatItemNotFound(itemId: Int64)
|
||||
}
|
||||
|
||||
enum AgentErrorType: Decodable {
|
||||
case CMD(cmdErr: CommandErrorType)
|
||||
case CONN(connErr: ConnectionErrorType)
|
||||
case SMP(smpErr: SMPErrorType)
|
||||
case BROKER(brokerErr: BrokerErrorType)
|
||||
case AGENT(agentErr: SMPAgentError)
|
||||
case INTERNAL(internalErr: String)
|
||||
}
|
||||
|
||||
enum CommandErrorType: Decodable {
|
||||
case PROHIBITED
|
||||
case SYNTAX
|
||||
case NO_CONN
|
||||
case SIZE
|
||||
case LARGE
|
||||
}
|
||||
|
||||
enum ConnectionErrorType: Decodable {
|
||||
case NOT_FOUND
|
||||
case DUPLICATE
|
||||
case SIMPLEX
|
||||
case NOT_ACCEPTED
|
||||
case NOT_AVAILABLE
|
||||
}
|
||||
|
||||
enum BrokerErrorType: Decodable {
|
||||
case RESPONSE(smpErr: SMPErrorType)
|
||||
case UNEXPECTED
|
||||
case NETWORK
|
||||
case TRANSPORT(transportErr: SMPTransportError)
|
||||
case TIMEOUT
|
||||
}
|
||||
|
||||
enum SMPErrorType: Decodable {
|
||||
case BLOCK
|
||||
case SESSION
|
||||
case CMD(cmdErr: SMPCommandError)
|
||||
case AUTH
|
||||
case QUOTA
|
||||
case NO_MSG
|
||||
case LARGE_MSG
|
||||
case INTERNAL
|
||||
}
|
||||
|
||||
enum SMPCommandError: Decodable {
|
||||
case UNKNOWN
|
||||
case SYNTAX
|
||||
case NO_AUTH
|
||||
case HAS_AUTH
|
||||
case NO_QUEUE
|
||||
}
|
||||
|
||||
enum SMPTransportError: Decodable {
|
||||
case TEBadBlock
|
||||
case TELargeMsg
|
||||
case TEBadSession
|
||||
case TEHandshake(handshakeErr: SMPHandshakeError)
|
||||
}
|
||||
|
||||
enum SMPHandshakeError: Decodable {
|
||||
case PARSE
|
||||
case VERSION
|
||||
case IDENTITY
|
||||
}
|
||||
|
||||
enum SMPAgentError: Decodable {
|
||||
case A_MESSAGE
|
||||
case A_PROHIBITED
|
||||
case A_VERSION
|
||||
case A_ENCRYPTION
|
||||
}
|
||||
|
||||
@@ -12,3 +12,21 @@ var a = [1, 2, 3]
|
||||
a.removeAll(where: { $0 == 1} )
|
||||
|
||||
print(a)
|
||||
|
||||
let input = "This is a test with the привет 🙂 URL https://www.hackingwithswift.com to be detected."
|
||||
let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
let matches = detector.matches(in: input, options: [], range: NSRange(location: 0, length: input.count))
|
||||
|
||||
print(matches)
|
||||
|
||||
for match in matches {
|
||||
guard let range = Range(match.range, in: input) else { continue }
|
||||
let url = input[range]
|
||||
print(url)
|
||||
}
|
||||
|
||||
let r = try! NSRegularExpression(pattern: "^\\+?[0-9\\.\\(\\)\\-]{7,20}$")
|
||||
|
||||
print(r.firstMatch(in: "+44(0)7448-736-790", options: [], range: NSRange(location: 0, length: "+44(0)7448-736-790".count)) == nil)
|
||||
|
||||
let action: NtfAction? = NtfAction(rawValue: "NTF_ACT_ACCEPT")
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
version = "3.0">
|
||||
<TimelineItems>
|
||||
<LoggerValueHistoryTimelineItem
|
||||
documentLocation = "file:///Users/evgeny/opensource/simplex-chat/simplex-chat/apps/ios/Shared/MyPlayground.playground#CharacterRangeLen=88&CharacterRangeLoc=91&EndingColumnNumber=0&EndingLineNumber=7&StartingColumnNumber=3&StartingLineNumber=6&Timestamp=665423482.97412"
|
||||
documentLocation = "file:///Users/evgeny/opensource/simplex-chat/simplex-chat/apps/ios/Shared/MyPlayground.playground#CharacterRangeLen=88&CharacterRangeLoc=91&EndingColumnNumber=0&EndingLineNumber=7&StartingColumnNumber=3&StartingLineNumber=6&Timestamp=666087303.155273"
|
||||
selectedRepresentationIndex = "0"
|
||||
shouldTrackSuperviewWidth = "NO">
|
||||
</LoggerValueHistoryTimelineItem>
|
||||
|
||||
@@ -4,12 +4,8 @@
|
||||
|
||||
extern void hs_init(int argc, char **argv[]);
|
||||
|
||||
typedef void* chat_store;
|
||||
typedef void* chat_ctrl;
|
||||
|
||||
extern chat_store chat_init_store(char *path);
|
||||
extern char *chat_get_user(chat_store store);
|
||||
extern char *chat_create_user(chat_store store, char *data);
|
||||
extern chat_ctrl chat_start(chat_store store);
|
||||
extern chat_ctrl chat_init(char *path);
|
||||
extern char *chat_send_cmd(chat_ctrl ctl, char *cmd);
|
||||
extern char *chat_recv_msg(chat_ctrl ctl);
|
||||
|
||||
@@ -2,14 +2,10 @@
|
||||
// Use this file to import your target's public headers that you would like to expose to Swift.
|
||||
//
|
||||
|
||||
extern void hs_init(int argc, char ** argv[]);
|
||||
extern void hs_init(int argc, char **argv[]);
|
||||
|
||||
typedef void* chat_store;
|
||||
typedef void* chat_ctrl;
|
||||
|
||||
extern chat_store chat_init_store(char * path);
|
||||
extern char *chat_get_user(chat_store store);
|
||||
extern char *chat_create_user(chat_store store, char *data);
|
||||
extern chat_ctrl chat_start(chat_store store);
|
||||
extern chat_ctrl chat_init(char *path);
|
||||
extern char *chat_send_cmd(chat_ctrl ctl, char *cmd);
|
||||
extern char *chat_recv_msg(chat_ctrl ctl);
|
||||
|
||||
@@ -6,26 +6,36 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import OSLog
|
||||
|
||||
let logger = Logger()
|
||||
|
||||
@main
|
||||
struct SimpleXApp: App {
|
||||
@StateObject private var chatModel = ChatModel()
|
||||
|
||||
@StateObject private var chatModel = ChatModel.shared
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
|
||||
init() {
|
||||
hs_init(0, nil)
|
||||
BGManager.shared.register()
|
||||
NtfManager.shared.registerCategories()
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
return WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(chatModel)
|
||||
.onOpenURL { url in
|
||||
logger.debug("ContentView.onOpenURL: \(url)")
|
||||
chatModel.appOpenUrl = url
|
||||
chatModel.connectViaUrl = true
|
||||
print(url)
|
||||
}
|
||||
.onAppear() {
|
||||
chatModel.currentUser = chatGetUser()
|
||||
initializeChat()
|
||||
}
|
||||
.onChange(of: scenePhase) { phase in
|
||||
if phase == .background {
|
||||
BGManager.shared.schedule()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
43
apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift
Normal file
@@ -0,0 +1,43 @@
|
||||
//
|
||||
// ChatInfoToolbar.swift
|
||||
// SimpleX
|
||||
//
|
||||
// Created by Evgeny Poberezkin on 11/02/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
private let chatImageColorLight = Color(red: 0.9, green: 0.9, blue: 0.9)
|
||||
private let chatImageColorDark = Color(red: 0.2, green: 0.2, blue: 0.2 )
|
||||
struct ChatInfoToolbar: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@ObservedObject var chat: Chat
|
||||
|
||||
var body: some View {
|
||||
let cInfo = chat.chatInfo
|
||||
return HStack {
|
||||
ChatInfoImage(
|
||||
chat: chat,
|
||||
color: colorScheme == .dark
|
||||
? chatImageColorDark
|
||||
: chatImageColorLight
|
||||
)
|
||||
.frame(width: 32, height: 32)
|
||||
.padding(.trailing, 4)
|
||||
VStack {
|
||||
Text(cInfo.displayName).font(.headline)
|
||||
if cInfo.fullName != "" && cInfo.displayName != cInfo.fullName {
|
||||
Text(cInfo.fullName).font(.subheadline)
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatInfoToolbar_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ChatInfoToolbar(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
|
||||
}
|
||||
}
|
||||
84
apps/ios/Shared/Views/Chat/ChatInfoView.swift
Normal file
@@ -0,0 +1,84 @@
|
||||
//
|
||||
// ChatInfoView.swift
|
||||
// SimpleX
|
||||
//
|
||||
// Created by Evgeny Poberezkin on 05/02/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ChatInfoView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@ObservedObject var alertManager = AlertManager.shared
|
||||
@ObservedObject var chat: Chat
|
||||
@Binding var showChatInfo: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack{
|
||||
ChatInfoImage(chat: chat)
|
||||
.frame(width: 192, height: 192)
|
||||
.padding(.top, 48)
|
||||
.padding()
|
||||
Text(chat.chatInfo.localDisplayName).font(.largeTitle)
|
||||
.padding(.bottom, 2)
|
||||
Text(chat.chatInfo.fullName).font(.title)
|
||||
.padding(.bottom)
|
||||
|
||||
if case let .direct(contact) = chat.chatInfo {
|
||||
VStack {
|
||||
HStack {
|
||||
serverImage()
|
||||
Text(chat.serverInfo.networkStatus.statusString)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
Text(chat.serverInfo.networkStatus.statusExplanation)
|
||||
.font(.subheadline)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 64)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
Spacer()
|
||||
Button(role: .destructive) {
|
||||
alertManager.showAlert(deleteContactAlert(contact))
|
||||
} label: {
|
||||
Label("Delete contact", systemImage: "trash")
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
|
||||
func serverImage() -> some View {
|
||||
let status = chat.serverInfo.networkStatus
|
||||
return Image(systemName: status.imageName)
|
||||
.foregroundColor(status == .connected ? .green : .secondary)
|
||||
}
|
||||
|
||||
private func deleteContactAlert(_ contact: Contact) -> Alert {
|
||||
Alert(
|
||||
title: Text("Delete contact?"),
|
||||
message: Text("Contact and all messages will be deleted"),
|
||||
primaryButton: .destructive(Text("Delete")) {
|
||||
do {
|
||||
try apiDeleteChat(type: .direct, id: contact.apiId)
|
||||
chatModel.removeChat(contact.id)
|
||||
showChatInfo = false
|
||||
} catch let error {
|
||||
logger.error("ChatInfoView.deleteContactAlert apiDeleteChat error: \(error.localizedDescription)")
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatInfoView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
@State var showChatInfo = true
|
||||
return ChatInfoView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []), showChatInfo: $showChatInfo)
|
||||
}
|
||||
}
|
||||
47
apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift
Normal file
@@ -0,0 +1,47 @@
|
||||
//
|
||||
// CIMetaView.swift
|
||||
// SimpleX
|
||||
//
|
||||
// Created by Evgeny Poberezkin on 11/02/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CIMetaView: View {
|
||||
var chatItem: ChatItem
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
switch chatItem.meta.itemStatus {
|
||||
case .sndSent:
|
||||
statusImage("checkmark", .secondary)
|
||||
case .sndErrorAuth:
|
||||
statusImage("multiply", .red)
|
||||
case .sndError:
|
||||
statusImage("exclamationmark.triangle.fill", .yellow)
|
||||
case .rcvNew:
|
||||
statusImage("circlebadge.fill", Color.accentColor)
|
||||
default: EmptyView()
|
||||
}
|
||||
|
||||
Text(chatItem.timestampText)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func statusImage(_ systemName: String, _ color: Color) -> some View {
|
||||
Image(systemName: systemName)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.foregroundColor(color)
|
||||
.frame(maxHeight: 8)
|
||||
}
|
||||
}
|
||||
|
||||
struct CIMetaView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent))
|
||||
}
|
||||
}
|
||||
42
apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift
Normal file
@@ -0,0 +1,42 @@
|
||||
//
|
||||
// EmojiItemView.swift
|
||||
// SimpleX
|
||||
//
|
||||
// Created by Evgeny Poberezkin on 04/02/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct EmojiItemView: View {
|
||||
var chatItem: ChatItem
|
||||
|
||||
var body: some View {
|
||||
let sent = chatItem.chatDir.sent
|
||||
let s = chatItem.content.text.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
VStack(spacing: 1) {
|
||||
Text(s)
|
||||
.font(s.count < 4 ? largeEmojiFont : mediumEmojiFont)
|
||||
.padding(.top, 8)
|
||||
.padding(.horizontal, 6)
|
||||
.frame(maxWidth: .infinity, alignment: sent ? .trailing : .leading)
|
||||
CIMetaView(chatItem: chatItem)
|
||||
.padding(.bottom, 8)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(maxWidth: .infinity, alignment: sent ? .trailing : .leading)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.frame(maxWidth: .infinity, alignment: sent ? .trailing : .leading)
|
||||
}
|
||||
}
|
||||
|
||||
struct EmojiItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group{
|
||||
EmojiItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent))
|
||||
EmojiItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "👍"))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 70))
|
||||
}
|
||||
}
|
||||
149
apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift
Normal file
@@ -0,0 +1,149 @@
|
||||
//
|
||||
// TextItemView.swift
|
||||
// SimpleX
|
||||
//
|
||||
// Created by Evgeny Poberezkin on 04/02/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
private let emailRegex = try! NSRegularExpression(pattern: "^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$", options: .caseInsensitive)
|
||||
|
||||
private let phoneRegex = try! NSRegularExpression(pattern: "^\\+?[0-9\\.\\(\\)\\-]{7,20}$")
|
||||
|
||||
private let sentColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.12)
|
||||
private let sentColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.17)
|
||||
private let linkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
|
||||
|
||||
struct TextItemView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
var chatItem: ChatItem
|
||||
var width: CGFloat
|
||||
private let codeFont = Font.custom("Courier", size: UIFont.preferredFont(forTextStyle: .body).pointSize)
|
||||
|
||||
var body: some View {
|
||||
let sent = chatItem.chatDir.sent
|
||||
let maxWidth = width * 0.78
|
||||
|
||||
return ZStack(alignment: .bottomTrailing) {
|
||||
(messageText(chatItem) + reserveSpaceForMeta(chatItem.timestampText))
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(minWidth: 0, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
|
||||
CIMetaView(chatItem: chatItem)
|
||||
.padding(.trailing, 12)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
.background(
|
||||
sent
|
||||
? (colorScheme == .light ? sentColorLight : sentColorDark)
|
||||
: Color(uiColor: .tertiarySystemGroupedBackground)
|
||||
)
|
||||
.cornerRadius(18)
|
||||
.padding(.horizontal)
|
||||
.frame(
|
||||
maxWidth: maxWidth,
|
||||
maxHeight: .infinity,
|
||||
alignment: sent ? .trailing : .leading
|
||||
)
|
||||
.onTapGesture {
|
||||
switch chatItem.meta.itemStatus {
|
||||
case .sndErrorAuth: msgDeliveryError("Most likely this contact has deleted the connection with you.")
|
||||
case let .sndError(agentError): msgDeliveryError("Unexpected error: \(String(describing: agentError))")
|
||||
default: return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func messageText(_ chatItem: ChatItem) -> Text {
|
||||
let s = chatItem.content.text
|
||||
var res: Text
|
||||
if s == "" {
|
||||
res = Text("")
|
||||
} else {
|
||||
let parts = s.split(separator: " ")
|
||||
res = wordToText(parts[0])
|
||||
var i = 1
|
||||
while i < parts.count {
|
||||
res = res + Text(" ") + wordToText(parts[i])
|
||||
i = i + 1
|
||||
}
|
||||
}
|
||||
if case let .groupRcv(groupMember) = chatItem.chatDir {
|
||||
let member = Text(groupMember.memberProfile.displayName).font(.headline)
|
||||
return member + Text(": ") + res
|
||||
} else {
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
private func reserveSpaceForMeta(_ meta: String) -> Text {
|
||||
Text(" \(meta)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.clear)
|
||||
}
|
||||
|
||||
private func wordToText(_ s: String.SubSequence) -> Text {
|
||||
let str = String(s)
|
||||
switch true {
|
||||
case s.starts(with: "http://") || s.starts(with: "https://"):
|
||||
return linkText(str, prefix: "")
|
||||
case match(str, emailRegex):
|
||||
return linkText(str, prefix: "mailto:")
|
||||
case match(str, phoneRegex):
|
||||
return linkText(str, prefix: "tel:")
|
||||
default:
|
||||
if (s.count > 1) {
|
||||
switch true {
|
||||
case s.first == "*" && s.last == "*": return mdText(s).bold()
|
||||
case s.first == "_" && s.last == "_": return mdText(s).italic()
|
||||
case s.first == "+" && s.last == "+": return mdText(s).underline()
|
||||
case s.first == "~" && s.last == "~": return mdText(s).strikethrough()
|
||||
default: return Text(s)
|
||||
}
|
||||
} else {
|
||||
return Text(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func match(_ s: String, _ regex: NSRegularExpression) -> Bool {
|
||||
regex.firstMatch(in: s, options: [], range: NSRange(location: 0, length: s.count)) != nil
|
||||
}
|
||||
|
||||
private func linkText(_ s: String, prefix: String) -> Text {
|
||||
Text(AttributedString(s, attributes: AttributeContainer([
|
||||
.link: NSURL(string: prefix + s) as Any,
|
||||
.foregroundColor: linkColor as Any
|
||||
]))).underline()
|
||||
}
|
||||
|
||||
private func mdText(_ s: String.SubSequence) -> Text {
|
||||
Text(s[s.index(s.startIndex, offsetBy: 1)..<s.index(s.endIndex, offsetBy: -1)])
|
||||
}
|
||||
|
||||
private func msgDeliveryError(_ err: String) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Message delivery error",
|
||||
message: err
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct TextItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group{
|
||||
TextItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), width: 360)
|
||||
TextItemView(chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello"), width: 360)
|
||||
TextItemView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent), width: 360)
|
||||
TextItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), width: 360)
|
||||
TextItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), width: 360)
|
||||
TextItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), width: 360)
|
||||
TextItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), width: 360)
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 70))
|
||||
}
|
||||
}
|
||||
@@ -12,36 +12,14 @@ private var dateFormatter: DateFormatter?
|
||||
|
||||
struct ChatItemView: View {
|
||||
var chatItem: ChatItem
|
||||
var width: CGFloat
|
||||
|
||||
var body: some View {
|
||||
let sent = chatItem.chatDir.sent
|
||||
|
||||
return VStack {
|
||||
Group {
|
||||
Text(chatItem.content.text)
|
||||
.padding(.top, 8)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(minWidth: 200, maxWidth: 300, alignment: .leading)
|
||||
.foregroundColor(sent ? .white : .primary)
|
||||
.textSelection(.enabled)
|
||||
Text(getDateFormatter().string(from: chatItem.meta.itemTs))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(sent ? .white : .secondary)
|
||||
.padding(.bottom, 8)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(minWidth: 200, maxWidth: 300, alignment: .trailing)
|
||||
}
|
||||
if (isShortEmoji(chatItem.content.text)) {
|
||||
EmojiItemView(chatItem: chatItem)
|
||||
} else {
|
||||
TextItemView(chatItem: chatItem, width: width)
|
||||
}
|
||||
.background(sent ? .blue : Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.cornerRadius(10)
|
||||
.padding(.horizontal)
|
||||
.frame(
|
||||
minWidth: 200,
|
||||
maxWidth: .infinity,
|
||||
minHeight: 0,
|
||||
maxHeight: .infinity,
|
||||
alignment: sent ? .trailing : .leading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,9 +34,12 @@ func getDateFormatter() -> DateFormatter {
|
||||
struct ChatItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group{
|
||||
ChatItemView(chatItem: chatItemSample(1, .directSnd, Date.now, "hello"))
|
||||
ChatItemView(chatItem: chatItemSample(2, .directRcv, Date.now, "hello there too"))
|
||||
ChatItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), width: 360)
|
||||
ChatItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), width: 360)
|
||||
ChatItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), width: 360)
|
||||
ChatItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), width: 360)
|
||||
ChatItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), width: 360)
|
||||
}
|
||||
.previewLayout(.fixed(width: 300, height: 70))
|
||||
.previewLayout(.fixed(width: 360, height: 70))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,53 +10,114 @@ import SwiftUI
|
||||
|
||||
struct ChatView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
var chatInfo: ChatInfo
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@ObservedObject var chat: Chat
|
||||
@State private var inProgress: Bool = false
|
||||
@FocusState private var keyboardVisible: Bool
|
||||
@State private var showChatInfo = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack {
|
||||
ForEach(chatModel.chatItems, id: \.id) {
|
||||
ChatItemView(chatItem: $0)
|
||||
let cInfo = chat.chatInfo
|
||||
|
||||
return VStack {
|
||||
GeometryReader { g in
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(spacing: 5) {
|
||||
ForEach(chatModel.chatItems, id: \.id) {
|
||||
ChatItemView(chatItem: $0, width: g.size.width)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: $0.chatDir.sent ? .trailing : .leading)
|
||||
}
|
||||
.onAppear {
|
||||
DispatchQueue.main.async {
|
||||
scrollToFirstUnread(proxy)
|
||||
}
|
||||
markAllRead()
|
||||
}
|
||||
.onChange(of: chatModel.chatItems.count) { _ in
|
||||
scrollToBottom(proxy)
|
||||
}
|
||||
.onChange(of: keyboardVisible) { _ in
|
||||
if keyboardVisible {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||
scrollToBottom(proxy, animation: .easeInOut(duration: 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear { scrollToBottom(proxy) }
|
||||
.onChange(of: chatModel.chatItems.count) { _ in scrollToBottom(proxy) }
|
||||
}
|
||||
.onTapGesture {
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
SendMessageView(sendMessage: sendMessage, inProgress: inProgress)
|
||||
SendMessageView(
|
||||
sendMessage: sendMessage,
|
||||
inProgress: inProgress,
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
}
|
||||
.navigationTitle(chatInfo.localDisplayName)
|
||||
.navigationTitle(cInfo.chatViewName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button { chatModel.chatId = nil } label: {
|
||||
Image(systemName: "chevron.backward")
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "chevron.backward")
|
||||
Text("Chats")
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .principal) {
|
||||
Button {
|
||||
showChatInfo = true
|
||||
} label: {
|
||||
ChatInfoToolbar(chat: chat)
|
||||
}
|
||||
.sheet(isPresented: $showChatInfo) {
|
||||
ChatInfoView(chat: chat, showChatInfo: $showChatInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarBackButtonHidden(true)
|
||||
|
||||
}
|
||||
|
||||
func scrollToBottom(_ proxy: ScrollViewProxy) {
|
||||
func scrollToBottom(_ proxy: ScrollViewProxy, animation: Animation = .default) {
|
||||
withAnimation(animation) { scrollToBottom_(proxy) }
|
||||
}
|
||||
|
||||
func scrollToBottom_(_ proxy: ScrollViewProxy) {
|
||||
if let id = chatModel.chatItems.last?.id {
|
||||
withAnimation {
|
||||
proxy.scrollTo(id, anchor: .bottom)
|
||||
proxy.scrollTo(id, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
// align first unread with the top or the last unread with bottom
|
||||
func scrollToFirstUnread(_ proxy: ScrollViewProxy) {
|
||||
if let cItem = chatModel.chatItems.first(where: { $0.isRcvNew() }) {
|
||||
proxy.scrollTo(cItem.id)
|
||||
} else {
|
||||
scrollToBottom_(proxy)
|
||||
}
|
||||
}
|
||||
|
||||
func markAllRead() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
if chatModel.chatId == chat.id {
|
||||
markChatRead(chat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendMessage(_ msg: String) {
|
||||
do {
|
||||
let chatItem = try apiSendMessage(type: chatInfo.chatType, id: chatInfo.apiId, msg: .text(msg))
|
||||
chatModel.addChatItem(chatInfo, chatItem)
|
||||
let chatItem = try apiSendMessage(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, msg: .text(msg))
|
||||
chatModel.addChatItem(chat.chatInfo, chatItem)
|
||||
} catch {
|
||||
print(error)
|
||||
logger.error("ChatView.sendMessage apiSendMessage error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,15 +127,16 @@ struct ChatView_Previews: PreviewProvider {
|
||||
let chatModel = ChatModel()
|
||||
chatModel.chatId = "@1"
|
||||
chatModel.chatItems = [
|
||||
chatItemSample(1, .directSnd, Date.now, "hello"),
|
||||
chatItemSample(2, .directRcv, Date.now, "hi"),
|
||||
chatItemSample(3, .directRcv, Date.now, "hi there"),
|
||||
chatItemSample(4, .directRcv, Date.now, "hello again"),
|
||||
chatItemSample(5, .directSnd, Date.now, "hi there!!!"),
|
||||
chatItemSample(6, .directSnd, Date.now, "how are you?"),
|
||||
chatItemSample(7, .directSnd, Date.now, "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.")
|
||||
ChatItem.getSample(1, .directSnd, .now, "hello"),
|
||||
ChatItem.getSample(2, .directRcv, .now, "hi"),
|
||||
ChatItem.getSample(3, .directRcv, .now, "hi there"),
|
||||
ChatItem.getSample(4, .directRcv, .now, "hello again"),
|
||||
ChatItem.getSample(5, .directSnd, .now, "hi there!!!"),
|
||||
ChatItem.getSample(6, .directSnd, .now, "how are you?"),
|
||||
ChatItem.getSample(7, .directSnd, .now, "👍👍👍👍"),
|
||||
ChatItem.getSample(8, .directSnd, .now, "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.")
|
||||
]
|
||||
return ChatView(chatInfo: sampleDirectChatInfo)
|
||||
return ChatView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
|
||||
.environmentObject(chatModel)
|
||||
}
|
||||
}
|
||||
|
||||
31
apps/ios/Shared/Views/Chat/Emoji.swift
Normal file
@@ -0,0 +1,31 @@
|
||||
//
|
||||
// Emoji.swift
|
||||
// SimpleX
|
||||
//
|
||||
// Created by Evgeny Poberezkin on 04/02/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
private func isSimpleEmoji(_ c: Character) -> Bool {
|
||||
guard let firstScalar = c.unicodeScalars.first else { return false }
|
||||
return firstScalar.properties.isEmoji && firstScalar.value > 0x238C
|
||||
}
|
||||
|
||||
private func isCombinedIntoEmoji(_ c: Character) -> Bool {
|
||||
c.unicodeScalars.count > 1 && c.unicodeScalars.first?.properties.isEmoji ?? false
|
||||
}
|
||||
|
||||
func isEmoji(_ c: Character) -> Bool {
|
||||
isSimpleEmoji(c) || isCombinedIntoEmoji(c)
|
||||
}
|
||||
|
||||
func isShortEmoji(_ str: String) -> Bool {
|
||||
let s = str.trimmingCharacters(in: .whitespaces)
|
||||
return s.count > 0 && s.count <= 5 && s.allSatisfy(isEmoji)
|
||||
}
|
||||
|
||||
let largeEmojiFont = Font.custom("Emoji", size: 48, relativeTo: .largeTitle)
|
||||
let mediumEmojiFont = Font.custom("Emoji", size: 36, relativeTo: .largeTitle)
|
||||
@@ -11,36 +11,89 @@ import SwiftUI
|
||||
struct SendMessageView: View {
|
||||
var sendMessage: (String) -> Void
|
||||
var inProgress: Bool = false
|
||||
@State var command: String = ""
|
||||
@State private var message: String = "" //Lorem ipsum dolor sit amet, consectetur" // adipiscing elit, sed do eiusmod tempor incididunt ut labor7 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."
|
||||
@Namespace var namespace
|
||||
@FocusState.Binding var keyboardVisible: Bool
|
||||
@State private var teHeight: CGFloat = 42
|
||||
@State private var teFont: Font = .body
|
||||
var maxHeight: CGFloat = 360
|
||||
var minHeight: CGFloat = 37
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
TextField("Message...", text: $command)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.textInputAutocapitalization(.never)
|
||||
.disableAutocorrection(true)
|
||||
.onSubmit(submit)
|
||||
ZStack {
|
||||
HStack(alignment: .bottom) {
|
||||
ZStack(alignment: .leading) {
|
||||
Text(message)
|
||||
.font(teFont)
|
||||
.foregroundColor(.clear)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.matchedGeometryEffect(id: "te", in: namespace)
|
||||
.background(GeometryReader(content: updateHeight))
|
||||
TextEditor(text: $message)
|
||||
.onSubmit(submit)
|
||||
.focused($keyboardVisible)
|
||||
.font(teFont)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.padding(.horizontal, 5)
|
||||
.allowsTightening(false)
|
||||
.frame(height: teHeight)
|
||||
}
|
||||
|
||||
if (inProgress) {
|
||||
ProgressView()
|
||||
.frame(width: 40, height: 20, alignment: .center)
|
||||
} else {
|
||||
Button("Send", action :submit)
|
||||
.disabled(command.isEmpty)
|
||||
if (inProgress) {
|
||||
ProgressView()
|
||||
.scaleEffect(1.4)
|
||||
.frame(width: 31, height: 31, alignment: .center)
|
||||
.padding([.bottom, .trailing], 3)
|
||||
} else {
|
||||
Button(action: submit) {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.resizable()
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
.disabled(message.isEmpty)
|
||||
.frame(width: 29, height: 29)
|
||||
.padding([.bottom, .trailing], 4)
|
||||
}
|
||||
}
|
||||
|
||||
RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
|
||||
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
|
||||
.frame(height: teHeight)
|
||||
}
|
||||
.frame(minHeight: 30)
|
||||
.padding(12)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
func submit() {
|
||||
sendMessage(command)
|
||||
command = ""
|
||||
sendMessage(message)
|
||||
message = ""
|
||||
}
|
||||
|
||||
func updateHeight(_ g: GeometryProxy) -> Color {
|
||||
DispatchQueue.main.async {
|
||||
teHeight = min(max(g.frame(in: .local).size.height, minHeight), maxHeight)
|
||||
teFont = isShortEmoji(message)
|
||||
? message.count < 4
|
||||
? largeEmojiFont
|
||||
: mediumEmojiFont
|
||||
: .body
|
||||
}
|
||||
return Color.clear
|
||||
}
|
||||
}
|
||||
|
||||
struct SendMessageView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SendMessageView(sendMessage: { print ($0) })
|
||||
@FocusState var keyboardVisible: Bool
|
||||
|
||||
return VStack {
|
||||
Text("")
|
||||
Spacer(minLength: 0)
|
||||
SendMessageView(
|
||||
sendMessage: { print ($0) },
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
66
apps/ios/Shared/Views/ChatList/ChatHelp.swift
Normal file
@@ -0,0 +1,66 @@
|
||||
//
|
||||
// ChatHelp.swift
|
||||
// SimpleX
|
||||
//
|
||||
// Created by Evgeny Poberezkin on 10/02/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ChatHelp: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Binding var showSettings: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Thank you for installing SimpleX Chat!")
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Text("You can")
|
||||
Button("connect to SimpleX team.") {
|
||||
showSettings = false
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.open(simplexTeamURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("To start a new chat")
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text("Tap button ")
|
||||
NewChatButton()
|
||||
Text("above, then:")
|
||||
}
|
||||
|
||||
Text("**Add new contact**: to create your one-time QR Code for your contact.")
|
||||
Text("**Scan QR code**: to connect to your contact who shows QR code to you.")
|
||||
}
|
||||
.padding(.top, 24)
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("To connect via link")
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
|
||||
Text("If you received SimpleX Chat invitation link you can open it in your browser:")
|
||||
|
||||
Text("💻 desktop: scan displayed QR code from the app, via **Scan QR code**.")
|
||||
Text("📱 mobile: tap **Open in mobile app**, then tap **Connect** in the app.")
|
||||
}
|
||||
.padding(.top, 24)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatHelp_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
@State var showSettings = false
|
||||
return ChatHelp(showSettings: $showSettings)
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,7 @@ import SwiftUI
|
||||
struct ChatListNavLink: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@State var chat: Chat
|
||||
|
||||
@State private var showDeleteContactAlert = false
|
||||
@State private var showDeleteGroupAlert = false
|
||||
@State private var showContactRequestAlert = false
|
||||
@State private var showContactRequestDialog = false
|
||||
@State private var alertContact: Contact?
|
||||
@State private var alertGroupInfo: GroupInfo?
|
||||
@State private var alertContactRequest: UserContactRequest?
|
||||
|
||||
var body: some View {
|
||||
switch chat.chatInfo {
|
||||
@@ -32,7 +25,7 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
|
||||
private func chatView() -> some View {
|
||||
ChatView(chatInfo: chat.chatInfo)
|
||||
ChatView(chat: chat)
|
||||
.onAppear {
|
||||
do {
|
||||
let cInfo = chat.chatInfo
|
||||
@@ -40,70 +33,78 @@ struct ChatListNavLink: View {
|
||||
chatModel.updateChatInfo(chat.chatInfo)
|
||||
chatModel.chatItems = chat.chatItems
|
||||
} catch {
|
||||
print("apiGetChatItems", error)
|
||||
logger.error("ChatListNavLink.chatView apiGetChatItems error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func contactNavLink(_ contact: Contact) -> some View {
|
||||
NavigationLink(
|
||||
NavLinkPlain(
|
||||
tag: chat.chatInfo.id,
|
||||
selection: $chatModel.chatId,
|
||||
destination: { chatView() },
|
||||
label: { ChatPreviewView(chat: chat) }
|
||||
label: { ChatPreviewView(chat: chat) },
|
||||
disabled: !contact.ready
|
||||
)
|
||||
.disabled(!contact.connected)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
.swipeActions(edge: .leading) {
|
||||
if chat.chatStats.unreadCount > 0 {
|
||||
markReadButton()
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
alertContact = contact
|
||||
showDeleteContactAlert = true
|
||||
AlertManager.shared.showAlert(deleteContactAlert(contact))
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $showDeleteContactAlert) {
|
||||
deleteContactAlert(alertContact!)
|
||||
}
|
||||
.frame(height: 80)
|
||||
}
|
||||
|
||||
private func groupNavLink(_ groupInfo: GroupInfo) -> some View {
|
||||
NavigationLink(
|
||||
NavLinkPlain(
|
||||
tag: chat.chatInfo.id,
|
||||
selection: $chatModel.chatId,
|
||||
destination: { chatView() },
|
||||
label: { ChatPreviewView(chat: chat) }
|
||||
label: { ChatPreviewView(chat: chat) },
|
||||
disabled: !groupInfo.ready
|
||||
)
|
||||
.swipeActions(edge: .leading) {
|
||||
if chat.chatStats.unreadCount > 0 {
|
||||
markReadButton()
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
alertGroupInfo = groupInfo
|
||||
showDeleteGroupAlert = true
|
||||
AlertManager.shared.showAlert(deleteGroupAlert(groupInfo))
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $showDeleteGroupAlert) {
|
||||
deleteGroupAlert(alertGroupInfo!)
|
||||
}
|
||||
.frame(height: 80)
|
||||
}
|
||||
|
||||
private func markReadButton() -> some View {
|
||||
Button {
|
||||
markChatRead(chat)
|
||||
} label: {
|
||||
Label("Read", systemImage: "checkmark")
|
||||
}
|
||||
.tint(Color.accentColor)
|
||||
}
|
||||
|
||||
private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View {
|
||||
ContactRequestView(contactRequest: contactRequest)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button { acceptContactRequest(contactRequest) }
|
||||
label: { Label("Accept", systemImage: "checkmark") }
|
||||
.tint(.blue)
|
||||
.tint(Color.accentColor)
|
||||
Button(role: .destructive) {
|
||||
alertContactRequest = contactRequest
|
||||
showContactRequestAlert = true
|
||||
AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest))
|
||||
} label: {
|
||||
Label("Reject", systemImage: "multiply")
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $showContactRequestAlert) {
|
||||
contactRequestAlert(alertContactRequest!)
|
||||
}
|
||||
.frame(height: 80)
|
||||
.onTapGesture { showContactRequestDialog = true }
|
||||
.confirmationDialog("Connection request", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
|
||||
@@ -121,12 +122,10 @@ struct ChatListNavLink: View {
|
||||
try apiDeleteChat(type: .direct, id: contact.apiId)
|
||||
chatModel.removeChat(contact.id)
|
||||
} catch let error {
|
||||
print("Error: \(error)")
|
||||
logger.error("ChatListNavLink.deleteContactAlert apiDeleteChat error: \(error.localizedDescription)")
|
||||
}
|
||||
alertContact = nil
|
||||
}, secondaryButton: .cancel() {
|
||||
alertContact = nil
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -137,37 +136,16 @@ struct ChatListNavLink: View {
|
||||
)
|
||||
}
|
||||
|
||||
private func contactRequestAlert(_ contactRequest: UserContactRequest) -> Alert {
|
||||
private func rejectContactRequestAlert(_ contactRequest: UserContactRequest) -> Alert {
|
||||
Alert(
|
||||
title: Text("Reject contact request"),
|
||||
message: Text("The sender will NOT be notified"),
|
||||
primaryButton: .destructive(Text("Reject")) {
|
||||
rejectContactRequest(contactRequest)
|
||||
alertContactRequest = nil
|
||||
}, secondaryButton: .cancel {
|
||||
alertContactRequest = nil
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
private func acceptContactRequest(_ contactRequest: UserContactRequest) {
|
||||
do {
|
||||
let contact = try apiAcceptContactRequest(contactReqId: contactRequest.apiId)
|
||||
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
|
||||
chatModel.replaceChat(contactRequest.id, chat)
|
||||
} catch let error {
|
||||
print("Error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func rejectContactRequest(_ contactRequest: UserContactRequest) {
|
||||
do {
|
||||
try apiRejectContactRequest(contactReqId: contactRequest.apiId)
|
||||
chatModel.removeChat(contactRequest.id)
|
||||
} catch let error {
|
||||
print("Error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatListNavLink_Previews: PreviewProvider {
|
||||
@@ -175,15 +153,15 @@ struct ChatListNavLink_Previews: PreviewProvider {
|
||||
@State var chatId: String? = "@1"
|
||||
return Group {
|
||||
ChatListNavLink(chat: Chat(
|
||||
chatInfo: sampleDirectChatInfo,
|
||||
chatItems: [chatItemSample(1, .directSnd, Date.now, "hello")]
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")]
|
||||
))
|
||||
ChatListNavLink(chat: Chat(
|
||||
chatInfo: sampleDirectChatInfo,
|
||||
chatItems: [chatItemSample(1, .directSnd, Date.now, "hello")]
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")]
|
||||
))
|
||||
ChatListNavLink(chat: Chat(
|
||||
chatInfo: sampleContactRequestChatInfo,
|
||||
chatInfo: ChatInfo.sampleData.contactRequest,
|
||||
chatItems: []
|
||||
))
|
||||
}
|
||||
|
||||
@@ -10,86 +10,98 @@ import SwiftUI
|
||||
|
||||
struct ChatListView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@State private var connectAlert = false
|
||||
@State private var connectError: Error?
|
||||
// not really used in this view
|
||||
@State private var showSettings = false
|
||||
@State private var searchText = ""
|
||||
|
||||
var user: User
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
// if chatModel.chats.isEmpty {
|
||||
// VStack {
|
||||
// Text("Hello chat")
|
||||
// Text("Active user: \(user.localDisplayName) (\(user.profile.fullName))")
|
||||
// }
|
||||
// }
|
||||
|
||||
NavigationView {
|
||||
List {
|
||||
NavigationLink {
|
||||
TerminalView()
|
||||
} label: {
|
||||
Text("Terminal")
|
||||
}
|
||||
|
||||
ForEach(chatModel.chats) { chat in
|
||||
ChatListNavLink(chat: chat)
|
||||
let v = NavigationView {
|
||||
List {
|
||||
if chatModel.chats.isEmpty {
|
||||
VStack(alignment: .leading) {
|
||||
ChatHelp(showSettings: $showSettings)
|
||||
HStack {
|
||||
Text("This text is available in settings")
|
||||
SettingsButton()
|
||||
}
|
||||
.padding(.leading)
|
||||
}
|
||||
}
|
||||
.padding(0)
|
||||
.offset(x: -8)
|
||||
.listStyle(.plain)
|
||||
.navigationTitle("Your chats")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
SettingsButton()
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
NewChatButton()
|
||||
}
|
||||
ForEach(filteredChats()) { chat in
|
||||
ChatListNavLink(chat: chat)
|
||||
.padding(.trailing, -16)
|
||||
}
|
||||
.alert(isPresented: $connectAlert) { connectionErrorAlert() }
|
||||
}
|
||||
.alert(isPresented: $chatModel.connectViaUrl) { connectViaUrlAlert() }
|
||||
.onChange(of: chatModel.chatId) { _ in
|
||||
if chatModel.chatId == nil, let chatId = chatModel.chatToTop {
|
||||
chatModel.chatToTop = nil
|
||||
chatModel.popChat(chatId)
|
||||
}
|
||||
}
|
||||
.onChange(of: chatModel.appOpenUrl) { _ in
|
||||
if let url = chatModel.appOpenUrl {
|
||||
chatModel.appOpenUrl = nil
|
||||
AlertManager.shared.showAlert(connectViaUrlAlert(url))
|
||||
}
|
||||
}
|
||||
.offset(x: -8)
|
||||
.listStyle(.plain)
|
||||
.navigationTitle(chatModel.chats.isEmpty ? "Welcome \(user.displayName)!" : "Your chats")
|
||||
.navigationBarTitleDisplayMode(chatModel.chats.count > 8 ? .inline : .large)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
SettingsButton()
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
NewChatButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
|
||||
if chatModel.chats.count > 8 {
|
||||
v.searchable(text: $searchText)
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
private func connectViaUrlAlert() -> Alert {
|
||||
if let url = chatModel.appOpenUrl {
|
||||
var path = url.path
|
||||
if (path == "/contact" || path == "/invitation") {
|
||||
path.removeFirst()
|
||||
let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
|
||||
return Alert(
|
||||
title: Text("Connect via \(path) link?"),
|
||||
message: Text("Your profile will be sent to the contact that you received this link from: \(link)"),
|
||||
primaryButton: .default(Text("Connect")) {
|
||||
private func filteredChats() -> [Chat] {
|
||||
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||
return s == ""
|
||||
? chatModel.chats
|
||||
: chatModel.chats.filter { $0.chatInfo.chatViewName.localizedLowercase.contains(s) }
|
||||
}
|
||||
|
||||
private func connectViaUrlAlert(_ url: URL) -> Alert {
|
||||
var path = url.path
|
||||
logger.debug("ChatListView.connectViaUrlAlert path: \(path)")
|
||||
if (path == "/contact" || path == "/invitation") {
|
||||
path.removeFirst()
|
||||
let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
|
||||
return Alert(
|
||||
title: Text("Connect via \(path) link?"),
|
||||
message: Text("Your profile will be sent to the contact that you received this link from: \(link)"),
|
||||
primaryButton: .default(Text("Connect")) {
|
||||
DispatchQueue.main.async {
|
||||
do {
|
||||
try apiConnect(connReq: link)
|
||||
connectionReqSentAlert(path == "contact" ? .contact : .invitation)
|
||||
} catch {
|
||||
connectAlert = true
|
||||
connectError = error
|
||||
print(error)
|
||||
let err = error.localizedDescription
|
||||
AlertManager.shared.showAlertMsg(title: "Connection error", message: err)
|
||||
logger.debug("ChatListView.connectViaUrlAlert: apiConnect error: \(err)")
|
||||
}
|
||||
chatModel.appOpenUrl = nil
|
||||
}, secondaryButton: .cancel() {
|
||||
chatModel.appOpenUrl = nil
|
||||
}
|
||||
)
|
||||
} else {
|
||||
return Alert(title: Text("Error: URL not available"))
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
} else {
|
||||
return Alert(title: Text("Error: URL not available"))
|
||||
return Alert(title: Text("Error: URL is invalid"))
|
||||
}
|
||||
}
|
||||
|
||||
private func connectionErrorAlert() -> Alert {
|
||||
Alert(
|
||||
title: Text("Connection error"),
|
||||
message: Text(connectError?.localizedDescription ?? "")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatListView_Previews: PreviewProvider {
|
||||
@@ -97,20 +109,24 @@ struct ChatListView_Previews: PreviewProvider {
|
||||
let chatModel = ChatModel()
|
||||
chatModel.chats = [
|
||||
Chat(
|
||||
chatInfo: sampleDirectChatInfo,
|
||||
chatItems: [chatItemSample(1, .directSnd, Date.now, "hello")]
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")]
|
||||
),
|
||||
Chat(
|
||||
chatInfo: sampleGroupChatInfo,
|
||||
chatItems: [chatItemSample(1, .directSnd, Date.now, "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.")]
|
||||
chatInfo: ChatInfo.sampleData.group,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "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.")]
|
||||
),
|
||||
Chat(
|
||||
chatInfo: sampleContactRequestChatInfo,
|
||||
chatInfo: ChatInfo.sampleData.contactRequest,
|
||||
chatItems: []
|
||||
)
|
||||
|
||||
]
|
||||
return ChatListView(user: sampleUser)
|
||||
.environmentObject(chatModel)
|
||||
return Group {
|
||||
ChatListView(user: User.sampleData)
|
||||
.environmentObject(chatModel)
|
||||
ChatListView(user: User.sampleData)
|
||||
.environmentObject(ChatModel())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,61 +10,114 @@ import SwiftUI
|
||||
|
||||
struct ChatPreviewView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
var darkGreen = Color(red: 0, green: 0.5, blue: 0)
|
||||
|
||||
var body: some View {
|
||||
let cItem = chat.chatItems.last
|
||||
return VStack(spacing: 4) {
|
||||
HStack(alignment: .top) {
|
||||
Text(chat.chatInfo.localDisplayName)
|
||||
.font(.title3)
|
||||
.fontWeight(.bold)
|
||||
.padding(.leading, 8)
|
||||
.padding(.top, 4)
|
||||
.frame(maxHeight: .infinity, alignment: .topLeading)
|
||||
Spacer()
|
||||
if let cItem = cItem {
|
||||
Text(getDateFormatter().string(from: cItem.meta.itemTs))
|
||||
.font(.subheadline)
|
||||
.padding(.trailing, 8)
|
||||
.padding(.top, 4)
|
||||
.frame(minWidth: 60, alignment: .trailing)
|
||||
.foregroundColor(.secondary)
|
||||
let unread = chat.chatStats.unreadCount
|
||||
return HStack(spacing: 8) {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
ChatInfoImage(chat: chat)
|
||||
.frame(width: 63, height: 63)
|
||||
if case .direct = chat.chatInfo,
|
||||
chat.serverInfo.networkStatus == .connected {
|
||||
Image(systemName: "circle.fill")
|
||||
.resizable()
|
||||
.foregroundColor(colorScheme == .dark ? darkGreen : .green)
|
||||
.frame(width: 5, height: 5)
|
||||
.padding([.bottom, .leading], 1)
|
||||
}
|
||||
}
|
||||
if let cItem = cItem {
|
||||
Text(cItem.content.text)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading)
|
||||
.padding([.leading, .trailing], 8)
|
||||
.padding(.bottom, 4)
|
||||
.padding(.top, 1)
|
||||
.padding(.leading, 4)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
Text(chat.chatInfo.chatViewName)
|
||||
.font(.title3)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(chat.chatInfo.ready ? .primary : .secondary)
|
||||
.frame(maxHeight: .infinity, alignment: .topLeading)
|
||||
Spacer()
|
||||
Text(cItem?.timestampText ?? timestampText(chat.chatInfo.createdAt))
|
||||
.font(.subheadline)
|
||||
.frame(minWidth: 60, alignment: .trailing)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 4)
|
||||
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
if let cItem = cItem {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
(itemStatusMark(cItem) + Text(chatItemText(cItem)))
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading)
|
||||
.padding(.leading, 8)
|
||||
.padding(.trailing, 36)
|
||||
.padding(.bottom, 4)
|
||||
if unread > 0 {
|
||||
Text(unread > 999 ? "\(unread / 1000)k" : "\(unread)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 4)
|
||||
.frame(minWidth: 18, minHeight: 18)
|
||||
.background(Color.accentColor)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
else if case let .direct(contact) = chat.chatInfo, !contact.ready {
|
||||
Text("Connecting...")
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading)
|
||||
.padding([.leading, .trailing], 8)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
}
|
||||
// else if case let .direct(contact) = chatPreview.chatInfo, !contact.connected {
|
||||
// Text("Connecting...")
|
||||
// .frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading)
|
||||
// .padding([.leading, .trailing], 8)
|
||||
// .padding(.bottom, 4)
|
||||
// .padding(.top, 1)
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
private func itemStatusMark(_ cItem: ChatItem) -> Text {
|
||||
switch cItem.meta.itemStatus {
|
||||
case .sndErrorAuth:
|
||||
return Text(Image(systemName: "multiply"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.red) + Text(" ")
|
||||
case .sndError:
|
||||
return Text(Image(systemName: "exclamationmark.triangle.fill"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.yellow) + Text(" ")
|
||||
default: return Text("")
|
||||
}
|
||||
}
|
||||
|
||||
private func chatItemText(_ cItem: ChatItem) -> String {
|
||||
let t = cItem.content.text
|
||||
if case let .groupRcv(groupMember) = cItem.chatDir {
|
||||
return groupMember.memberProfile.displayName + ": " + t
|
||||
}
|
||||
return t
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatPreviewView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group{
|
||||
Group {
|
||||
ChatPreviewView(chat: Chat(
|
||||
chatInfo: sampleDirectChatInfo,
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItems: []
|
||||
))
|
||||
ChatPreviewView(chat: Chat(
|
||||
chatInfo: sampleDirectChatInfo,
|
||||
chatItems: [chatItemSample(1, .directSnd, Date.now, "hello")]
|
||||
chatInfo: ChatInfo.sampleData.direct,
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)]
|
||||
))
|
||||
ChatPreviewView(chat: Chat(
|
||||
chatInfo: sampleGroupChatInfo,
|
||||
chatItems: [chatItemSample(1, .directSnd, Date.now, "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.")]
|
||||
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)
|
||||
))
|
||||
}
|
||||
.previewLayout(.fixed(width: 360, height: 80))
|
||||
.previewLayout(.fixed(width: 360, height: 78))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,35 +12,42 @@ struct ContactRequestView: View {
|
||||
var contactRequest: UserContactRequest
|
||||
|
||||
var body: some View {
|
||||
return VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .top) {
|
||||
Text("@\(contactRequest.localDisplayName)")
|
||||
.font(.title3)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.blue)
|
||||
.padding(.leading, 8)
|
||||
.padding(.top, 4)
|
||||
.frame(maxHeight: .infinity, alignment: .topLeading)
|
||||
Spacer()
|
||||
Text("12:34")// getDateFormatter().string(from: cItem.meta.itemTs))
|
||||
.font(.subheadline)
|
||||
.padding(.trailing, 28)
|
||||
.padding(.top, 4)
|
||||
.frame(minWidth: 60, alignment: .trailing)
|
||||
.foregroundColor(.secondary)
|
||||
return HStack(spacing: 8) {
|
||||
Image(systemName: "person.crop.circle.fill")
|
||||
.resizable()
|
||||
.foregroundColor(Color(uiColor: .secondarySystemBackground))
|
||||
.frame(width: 63, height: 63)
|
||||
.padding(.leading, 4)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .top) {
|
||||
Text(ChatInfo.contactRequest(contactRequest: contactRequest).chatViewName)
|
||||
.font(.title3)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.blue)
|
||||
.padding(.leading, 8)
|
||||
.padding(.top, 4)
|
||||
.frame(maxHeight: .infinity, alignment: .topLeading)
|
||||
Spacer()
|
||||
Text(timestampText(contactRequest.createdAt))
|
||||
.font(.subheadline)
|
||||
.padding(.trailing, 8)
|
||||
.padding(.top, 4)
|
||||
.frame(minWidth: 60, alignment: .trailing)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Text("wants to connect to you!")
|
||||
.frame(minHeight: 44, maxHeight: 44, alignment: .topLeading)
|
||||
.padding([.leading, .trailing], 8)
|
||||
.padding(.bottom, 4)
|
||||
.padding(.top, 1)
|
||||
}
|
||||
Text("wants to connect to you!")
|
||||
.frame(minHeight: 44, maxHeight: 44, alignment: .topLeading)
|
||||
.padding([.leading, .trailing], 8)
|
||||
.padding(.bottom, 4)
|
||||
.padding(.top, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContactRequestView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ContactRequestView(contactRequest: sampleContactRequest)
|
||||
ContactRequestView(contactRequest: UserContactRequest.sampleData)
|
||||
.previewLayout(.fixed(width: 360, height: 80))
|
||||
}
|
||||
}
|
||||
|
||||
37
apps/ios/Shared/Views/Helpers/ChatInfoImage.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// ChatInfoImage.swift
|
||||
// SimpleX
|
||||
//
|
||||
// Created by Evgeny Poberezkin on 05/02/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ChatInfoImage: View {
|
||||
@ObservedObject var chat: Chat
|
||||
var color = Color(uiColor: .tertiarySystemGroupedBackground)
|
||||
|
||||
var body: some View {
|
||||
var iconName: String
|
||||
switch chat.chatInfo {
|
||||
case .direct: iconName = "person.crop.circle.fill"
|
||||
case .group: iconName = "person.2.circle.fill"
|
||||
default: iconName = "circle.fill"
|
||||
}
|
||||
|
||||
return Image(systemName: iconName)
|
||||
.resizable()
|
||||
.foregroundColor(color)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatInfoImage_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ChatInfoImage(
|
||||
chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
|
||||
, color: Color(red: 0.9, green: 0.9, blue: 0.9)
|
||||
)
|
||||
.previewLayout(.fixed(width: 63, height: 63))
|
||||
}
|
||||
}
|
||||
35
apps/ios/Shared/Views/Helpers/NavLinkPlain.swift
Normal file
@@ -0,0 +1,35 @@
|
||||
//
|
||||
// NavLinkPlain.swift
|
||||
// SimpleX
|
||||
//
|
||||
// Created by Evgeny Poberezkin on 11/02/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct NavLinkPlain<V: Hashable, Destination: View, Label: View>: View {
|
||||
@State var tag: V
|
||||
@Binding var selection: V?
|
||||
@ViewBuilder var destination: () -> Destination
|
||||
@ViewBuilder var label: () -> Label
|
||||
var disabled = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Button("") { selection = tag }
|
||||
.disabled(disabled)
|
||||
label()
|
||||
}
|
||||
.background {
|
||||
NavigationLink("", tag: tag, selection: $selection, destination: destination)
|
||||
.hidden()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//struct NavLinkPlain_Previews: PreviewProvider {
|
||||
// static var previews: some View {
|
||||
// NavLinkPlain()
|
||||
// }
|
||||
//}
|
||||
18
apps/ios/Shared/Views/Helpers/ShareSheet.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// ShareSheet.swift
|
||||
// SimpleX
|
||||
//
|
||||
// Created by Evgeny Poberezkin on 30/01/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
func showShareSheet(items: [Any]) {
|
||||
let keyWindowScene = UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene
|
||||
if let keyWindow = keyWindowScene?.windows.filter(\.isKeyWindow).first,
|
||||
let presentedViewController = keyWindow.rootViewController?.presentedViewController ?? keyWindow.rootViewController {
|
||||
let activityViewController = UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
presentedViewController.present(activityViewController, animated: true)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import CoreImage.CIFilterBuiltins
|
||||
|
||||
struct AddContactView: View {
|
||||
var connReqInvitation: String
|
||||
@State private var shareInvitation = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
@@ -27,11 +26,12 @@ struct AddContactView: View {
|
||||
.font(.subheadline)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
Button { shareInvitation = true } label: {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
Button {
|
||||
showShareSheet(items: [connReqInvitation])
|
||||
} label: {
|
||||
Label("Share invitation link", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
.padding()
|
||||
.shareSheet(isPresented: $shareInvitation, items: [connReqInvitation])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,11 +37,11 @@ struct ConnectContactView: View {
|
||||
try apiConnect(connReq: r.string)
|
||||
completed(nil)
|
||||
} catch {
|
||||
print(error)
|
||||
logger.error("ConnectContactView.processQRCode apiConnect error: \(error.localizedDescription)")
|
||||
completed(error)
|
||||
}
|
||||
case let .failure(e):
|
||||
print(e)
|
||||
logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)")
|
||||
completed(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,17 +11,13 @@ import SwiftUI
|
||||
struct NewChatButton: View {
|
||||
@State private var showAddChat = false
|
||||
@State private var addContact = false
|
||||
@State private var addContactAlert = false
|
||||
@State private var addContactError: Error?
|
||||
@State private var connReqInvitation: String = ""
|
||||
@State private var connectContact = false
|
||||
@State private var connectAlert = false
|
||||
@State private var connectError: Error?
|
||||
@State private var createGroup = false
|
||||
|
||||
var body: some View {
|
||||
Button { showAddChat = true } label: {
|
||||
Image(systemName: "square.and.pencil")
|
||||
Image(systemName: "person.crop.circle.badge.plus")
|
||||
}
|
||||
.confirmationDialog("Start new chat", isPresented: $showAddChat, titleVisibility: .visible) {
|
||||
Button("Add contact") { addContactAction() }
|
||||
@@ -32,15 +28,9 @@ struct NewChatButton: View {
|
||||
.sheet(isPresented: $addContact, content: {
|
||||
AddContactView(connReqInvitation: connReqInvitation)
|
||||
})
|
||||
.alert(isPresented: $addContactAlert) {
|
||||
connectionError(addContactError)
|
||||
}
|
||||
.sheet(isPresented: $connectContact, content: {
|
||||
connectContactSheet()
|
||||
})
|
||||
.alert(isPresented: $connectAlert) {
|
||||
connectionError(connectError)
|
||||
}
|
||||
.sheet(isPresented: $createGroup, content: { CreateGroupView() })
|
||||
}
|
||||
|
||||
@@ -49,30 +39,46 @@ struct NewChatButton: View {
|
||||
connReqInvitation = try apiAddContact()
|
||||
addContact = true
|
||||
} catch {
|
||||
addContactAlert = true
|
||||
addContactError = error
|
||||
print(error)
|
||||
DispatchQueue.global().async {
|
||||
connectionErrorAlert(error)
|
||||
}
|
||||
logger.error("NewChatButton.addContactAction apiAddContact error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func connectContactSheet() -> some View {
|
||||
ConnectContactView(completed: { err in
|
||||
connectContact = false
|
||||
if err != nil {
|
||||
connectAlert = true
|
||||
connectError = err
|
||||
DispatchQueue.global().async {
|
||||
if let error = err {
|
||||
connectionErrorAlert(error)
|
||||
} else {
|
||||
connectionReqSentAlert(.invitation)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func connectionError(_ error: Error?) -> Alert {
|
||||
Alert(
|
||||
title: Text("Connection error"),
|
||||
message: Text(error?.localizedDescription ?? "")
|
||||
)
|
||||
func connectionErrorAlert(_ error: Error) {
|
||||
AlertManager.shared.showAlertMsg(title: "Connection error", message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
enum ConnReqType: Equatable {
|
||||
case contact
|
||||
case invitation
|
||||
}
|
||||
|
||||
func connectionReqSentAlert(_ type: ConnReqType) {
|
||||
let whenConnected = type == .contact
|
||||
? "your connection request is accepted"
|
||||
: "your contact's device is online"
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Connection request sent!",
|
||||
message: "You will be connected when \(whenConnected), please wait or check later!"
|
||||
)
|
||||
}
|
||||
|
||||
struct NewChatButton_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
NewChatButton()
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
//
|
||||
// ShareSheet.swift
|
||||
// SimpleX
|
||||
//
|
||||
// Created by Evgeny Poberezkin on 30/01/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension UIApplication {
|
||||
static let keyWindow = keyWindowScene?.windows.filter(\.isKeyWindow).first
|
||||
static let keyWindowScene = shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene
|
||||
}
|
||||
|
||||
extension View {
|
||||
func shareSheet(isPresented: Binding<Bool>, items: [Any]) -> some View {
|
||||
guard isPresented.wrappedValue else { return self }
|
||||
let activityViewController = UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
let presentedViewController = UIApplication.keyWindow?.rootViewController?.presentedViewController ?? UIApplication.keyWindow?.rootViewController
|
||||
activityViewController.completionWithItemsHandler = { _, _, _, _ in isPresented.wrappedValue = false }
|
||||
presentedViewController?.present(activityViewController, animated: true)
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
struct ShareSheetTest: View {
|
||||
@State private var isPresentingShareSheet = false
|
||||
|
||||
var body: some View {
|
||||
Button("Show Share Sheet") { isPresentingShareSheet = true }
|
||||
.shareSheet(isPresented: $isPresentingShareSheet, items: ["Share me!"])
|
||||
}
|
||||
}
|
||||
|
||||
struct ShareSheetTest_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ShareSheetTest()
|
||||
}
|
||||
}
|
||||
@@ -8,48 +8,76 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
private let terminalFont = Font.custom("Menlo", size: 16)
|
||||
|
||||
struct TerminalView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@State var inProgress: Bool = false
|
||||
|
||||
@FocusState private var keyboardVisible: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
ScrollView {
|
||||
LazyVStack {
|
||||
ForEach(chatModel.terminalItems) { item in
|
||||
NavigationLink {
|
||||
ScrollView {
|
||||
Text(item.details)
|
||||
.textSelection(.enabled)
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack {
|
||||
ForEach(chatModel.terminalItems) { item in
|
||||
NavigationLink {
|
||||
ScrollView {
|
||||
Text(item.details)
|
||||
.textSelection(.enabled)
|
||||
.padding()
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text(item.id.formatted(date: .omitted, time: .standard))
|
||||
Text(item.label)
|
||||
.frame(maxWidth: .infinity, maxHeight: 30, alignment: .leading)
|
||||
}
|
||||
.font(terminalFont)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
.onAppear { scrollToBottom(proxy) }
|
||||
.onChange(of: chatModel.terminalItems.count) { _ in scrollToBottom(proxy) }
|
||||
.onChange(of: keyboardVisible) { _ in
|
||||
if keyboardVisible {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||
scrollToBottom(proxy, animation: .easeInOut(duration: 1))
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text(item.label)
|
||||
.frame(width: 360, height: 30, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
SendMessageView(
|
||||
sendMessage: sendMessage,
|
||||
inProgress: inProgress,
|
||||
keyboardVisible: $keyboardVisible
|
||||
)
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
.navigationTitle("Chat console")
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
SendMessageView(sendMessage: sendMessage, inProgress: inProgress)
|
||||
func scrollToBottom(_ proxy: ScrollViewProxy, animation: Animation = .default) {
|
||||
if let id = chatModel.terminalItems.last?.id {
|
||||
withAnimation(animation) {
|
||||
proxy.scrollTo(id, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendMessage(_ cmdStr: String) {
|
||||
let cmd = ChatCommand.string(cmdStr)
|
||||
chatModel.terminalItems.append(.cmd(Date.now, cmd))
|
||||
|
||||
DispatchQueue.global().async {
|
||||
inProgress = true
|
||||
do {
|
||||
let r = try chatSendCmd(cmd)
|
||||
DispatchQueue.main.async {
|
||||
chatModel.terminalItems.append(.resp(Date.now, r))
|
||||
}
|
||||
let _ = try chatSendCmd(cmd)
|
||||
} catch {
|
||||
print(error)
|
||||
logger.error("TerminalView.sendMessage chatSendCmd error: \(error.localizedDescription)")
|
||||
}
|
||||
inProgress = false
|
||||
}
|
||||
@@ -60,8 +88,8 @@ struct TerminalView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let chatModel = ChatModel()
|
||||
chatModel.terminalItems = [
|
||||
.resp(Date.now, ChatResponse.response(type: "contactSubscribed", json: "{}")),
|
||||
.resp(Date.now, ChatResponse.response(type: "newChatItem", json: "{}"))
|
||||
.resp(.now, ChatResponse.response(type: "contactSubscribed", json: "{}")),
|
||||
.resp(.now, ChatResponse.response(type: "newChatItem", json: "{}"))
|
||||
]
|
||||
return NavigationView {
|
||||
TerminalView()
|
||||
|
||||
@@ -17,12 +17,12 @@ struct SettingsButton: View {
|
||||
Image(systemName: "gearshape")
|
||||
}
|
||||
.sheet(isPresented: $showSettings, content: {
|
||||
SettingsView()
|
||||
SettingsView(showSettings: $showSettings)
|
||||
.onAppear {
|
||||
do {
|
||||
chatModel.userAddress = try apiGetUserAddress()
|
||||
} catch {
|
||||
print(error)
|
||||
logger.error("SettingsButton apiGetUserAddress error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -8,20 +8,118 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
let simplexTeamURL = URL(string: "simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D")!
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Binding var showSettings: Bool
|
||||
|
||||
var body: some View {
|
||||
UserProfile()
|
||||
UserAddress()
|
||||
let user: User = chatModel.currentUser!
|
||||
|
||||
return NavigationView {
|
||||
List {
|
||||
Section("You") {
|
||||
NavigationLink {
|
||||
UserProfile()
|
||||
.navigationTitle("Your chat profile")
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "person.crop.circle")
|
||||
.padding(.trailing, 8)
|
||||
VStack(alignment: .leading) {
|
||||
Text(user.profile.displayName)
|
||||
.fontWeight(.bold)
|
||||
.font(.title2)
|
||||
Text(user.profile.fullName)
|
||||
}
|
||||
}
|
||||
}
|
||||
NavigationLink {
|
||||
UserAddress()
|
||||
.navigationTitle("Your chat address")
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "qrcode")
|
||||
.padding(.trailing, 8)
|
||||
Text("Your SimpleX contact address")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Help") {
|
||||
NavigationLink {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Welcome \(user.displayName)!")
|
||||
.font(.largeTitle)
|
||||
.padding(.leading)
|
||||
Divider()
|
||||
ChatHelp(showSettings: $showSettings)
|
||||
}
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "questionmark.circle")
|
||||
.padding(.trailing, 8)
|
||||
Text("How to use SimpleX Chat")
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Image(systemName: "number")
|
||||
.padding(.trailing, 8)
|
||||
Button {
|
||||
showSettings = false
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.open(simplexTeamURL)
|
||||
}
|
||||
} label: {
|
||||
Text("Get help & advice via chat")
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Image(systemName: "envelope")
|
||||
.padding(.trailing, 4)
|
||||
Text("[Ask questions via email](mailto:chat@simplex.chat)")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Develop") {
|
||||
NavigationLink {
|
||||
TerminalView()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "terminal")
|
||||
.frame(maxWidth: 24)
|
||||
.padding(.trailing, 8)
|
||||
Text("Chat console")
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Image(colorScheme == .dark ? "github_light" : "github")
|
||||
.resizable()
|
||||
.frame(width: 24, height: 24)
|
||||
.padding(.trailing, 8)
|
||||
Text("Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)")
|
||||
}
|
||||
}
|
||||
|
||||
// Section("Your SimpleX servers") {
|
||||
//
|
||||
// }
|
||||
}
|
||||
.navigationTitle("Your settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let chatModel = ChatModel()
|
||||
chatModel.currentUser = sampleUser
|
||||
return SettingsView()
|
||||
chatModel.currentUser = User.sampleData
|
||||
@State var showSettings = false
|
||||
|
||||
return SettingsView(showSettings: $showSettings)
|
||||
.environmentObject(chatModel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,26 +10,23 @@ import SwiftUI
|
||||
|
||||
struct UserAddress: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@State private var shareAddressLink = false
|
||||
@State private var deleteAddressAlert = false
|
||||
|
||||
var body: some View {
|
||||
VStack (alignment: .leading) {
|
||||
Text("Your chat address")
|
||||
.font(.title)
|
||||
.padding(.bottom)
|
||||
Text("Your can share your address as a link or as a QR code - anybody will be able to connect to you, and if you later delete it - you won't lose your contacts.")
|
||||
Text("You can share your address as a link or as a QR code - anybody will be able to connect to you, and if you later delete it - you won't lose your contacts.")
|
||||
.padding(.bottom)
|
||||
if let userAdress = chatModel.userAddress {
|
||||
QRCode(uri: userAdress)
|
||||
HStack {
|
||||
Button { shareAddressLink = true } label: {
|
||||
Button {
|
||||
showShareSheet(items: [userAdress])
|
||||
} label: {
|
||||
Label("Share link", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
.padding()
|
||||
.shareSheet(isPresented: $shareAddressLink, items: [userAdress])
|
||||
|
||||
Button { deleteAddressAlert = true } label: {
|
||||
Button(role: .destructive) { deleteAddressAlert = true } label: {
|
||||
Label("Delete address", systemImage: "trash")
|
||||
}
|
||||
.padding()
|
||||
@@ -42,12 +39,11 @@ struct UserAddress: View {
|
||||
try apiDeleteUserAddress()
|
||||
chatModel.userAddress = nil
|
||||
} catch let error {
|
||||
print("Error: \(error)")
|
||||
logger.error("UserAddress apiDeleteUserAddress: \(error.localizedDescription)")
|
||||
}
|
||||
}, secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
.shareSheet(isPresented: $shareAddressLink, items: [userAdress])
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
@@ -55,13 +51,14 @@ struct UserAddress: View {
|
||||
do {
|
||||
chatModel.userAddress = try apiCreateUserAddress()
|
||||
} catch let error {
|
||||
print("Error: \(error)")
|
||||
logger.error("UserAddress apiCreateUserAddress: \(error.localizedDescription)")
|
||||
}
|
||||
} label: { Label("Create address", systemImage: "qrcode") }
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,9 +17,6 @@ struct UserProfile: View {
|
||||
let user: User = chatModel.currentUser!
|
||||
|
||||
return VStack(alignment: .leading) {
|
||||
Text("Your chat profile")
|
||||
.font(.title)
|
||||
.padding(.bottom)
|
||||
Text("Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile.")
|
||||
.padding(.bottom)
|
||||
if editProfile {
|
||||
@@ -61,6 +58,7 @@ struct UserProfile: View {
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
|
||||
func saveProfile() {
|
||||
@@ -70,7 +68,7 @@ struct UserProfile: View {
|
||||
profile = newProfile
|
||||
}
|
||||
} catch {
|
||||
print(error)
|
||||
logger.error("UserProfile apiUpdateProfile error: \(error.localizedDescription)")
|
||||
}
|
||||
editProfile = false
|
||||
}
|
||||
@@ -79,7 +77,7 @@ struct UserProfile: View {
|
||||
struct UserProfile_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let chatModel = ChatModel()
|
||||
chatModel.currentUser = sampleUser
|
||||
chatModel.currentUser = User.sampleData
|
||||
return UserProfile()
|
||||
.environmentObject(chatModel)
|
||||
}
|
||||
|
||||
@@ -13,27 +13,44 @@ struct WelcomeView: View {
|
||||
@State var fullName: String = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Create profile")
|
||||
.font(.largeTitle)
|
||||
.padding(.bottom)
|
||||
Text("Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile.")
|
||||
.padding(.bottom)
|
||||
TextField("Display name", text: $displayName)
|
||||
.textInputAutocapitalization(.never)
|
||||
.disableAutocorrection(true)
|
||||
.padding(.bottom)
|
||||
TextField("Full name (optional)", text: $fullName)
|
||||
.textInputAutocapitalization(.never)
|
||||
.disableAutocorrection(true)
|
||||
.padding(.bottom)
|
||||
Button("Create") {
|
||||
let profile = Profile(
|
||||
displayName: displayName,
|
||||
fullName: fullName
|
||||
)
|
||||
if let user = chatCreateUser(profile) {
|
||||
chatModel.currentUser = user
|
||||
GeometryReader { g in
|
||||
VStack(alignment: .leading) {
|
||||
Image("logo")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: g.size.width * 0.7)
|
||||
.padding(.vertical)
|
||||
Text("You control your chat!")
|
||||
.font(.title)
|
||||
.padding(.bottom)
|
||||
Text("The messaging and application platform protecting your privacy and security.")
|
||||
.padding(.bottom, 8)
|
||||
Text("We don't store any of your contacts or messages (once delivered) on the servers.")
|
||||
.padding(.bottom, 24)
|
||||
Text("Create profile")
|
||||
.font(.largeTitle)
|
||||
.padding(.bottom)
|
||||
Text("Your profile is stored on your device and shared only with your contacts.")
|
||||
.padding(.bottom)
|
||||
TextField("Display name", text: $displayName)
|
||||
.textInputAutocapitalization(.never)
|
||||
.disableAutocorrection(true)
|
||||
.padding(.bottom)
|
||||
TextField("Full name (optional)", text: $fullName)
|
||||
.textInputAutocapitalization(.never)
|
||||
.disableAutocorrection(true)
|
||||
.padding(.bottom)
|
||||
Button("Create") {
|
||||
let profile = Profile(
|
||||
displayName: displayName,
|
||||
fullName: fullName
|
||||
)
|
||||
do {
|
||||
let user = try apiCreateActiveUser(profile)
|
||||
chatModel.currentUser = user
|
||||
} catch {
|
||||
fatalError("Failed to create user: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>chat.simplex.app.receive</string>
|
||||
</array>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -15,5 +19,11 @@
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -13,12 +13,6 @@
|
||||
5C116CDD27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; };
|
||||
5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; };
|
||||
5C1A4C1F27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; };
|
||||
5C1AEB86279F4A6400247F08 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB7F279F4A6400247F08 /* libffi.a */; };
|
||||
5C1AEB87279F4A6400247F08 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB7F279F4A6400247F08 /* libffi.a */; };
|
||||
5C1AEB88279F4A6400247F08 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB80279F4A6400247F08 /* libgmp.a */; };
|
||||
5C1AEB89279F4A6400247F08 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB80279F4A6400247F08 /* libgmp.a */; };
|
||||
5C1AEB8A279F4A6400247F08 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB81279F4A6400247F08 /* libgmpxx.a */; };
|
||||
5C1AEB8B279F4A6400247F08 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C1AEB81279F4A6400247F08 /* libgmpxx.a */; };
|
||||
5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */; };
|
||||
5C2E260827A2941F00F70299 /* SimpleXAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */; };
|
||||
5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260A27A30CFA00F70299 /* ChatListView.swift */; };
|
||||
@@ -27,12 +21,25 @@
|
||||
5C2E261027A30FDC00F70299 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260E27A30FDC00F70299 /* ChatView.swift */; };
|
||||
5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E261127A30FEA00F70299 /* TerminalView.swift */; };
|
||||
5C2E261327A30FEA00F70299 /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E261127A30FEA00F70299 /* TerminalView.swift */; };
|
||||
5C44B6A027A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C44B69E27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a */; };
|
||||
5C44B6A127A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C44B69E27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a */; };
|
||||
5C44B6A227A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C44B69F27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a */; };
|
||||
5C44B6A327A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C44B69F27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a */; };
|
||||
5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFC727B2782E00FB6C6D /* BGManager.swift */; };
|
||||
5C35CFC927B2782E00FB6C6D /* BGManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFC727B2782E00FB6C6D /* BGManager.swift */; };
|
||||
5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */; };
|
||||
5C35CFCC27B2E91D00FB6C6D /* NtfManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */; };
|
||||
5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; };
|
||||
5C5346A927B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; };
|
||||
5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; };
|
||||
5C6AD81427A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; };
|
||||
5C75059C27B5CD9300BE3227 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C75059727B5CD9300BE3227 /* libgmp.a */; };
|
||||
5C75059D27B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C75059827B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj-ghc8.10.7.a */; };
|
||||
5C75059E27B5CD9300BE3227 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C75059927B5CD9300BE3227 /* libffi.a */; };
|
||||
5C75059F27B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C75059A27B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj.a */; };
|
||||
5C7505A027B5CD9300BE3227 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C75059B27B5CD9300BE3227 /* libgmpxx.a */; };
|
||||
5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */; };
|
||||
5C7505A327B65FDB00BE3227 /* CIMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */; };
|
||||
5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */; };
|
||||
5C7505A627B679EE00BE3227 /* NavLinkPlain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */; };
|
||||
5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; };
|
||||
5C7505A927B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; };
|
||||
5C764E80279C7276000C6508 /* dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E7F279C7276000C6508 /* dummy.m */; };
|
||||
5C764E81279C7276000C6508 /* dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E7F279C7276000C6508 /* dummy.m */; };
|
||||
5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E7B279C71D4000C6508 /* libiconv.tbd */; };
|
||||
@@ -42,6 +49,10 @@
|
||||
5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; };
|
||||
5C764E8A279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; };
|
||||
5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; };
|
||||
5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; };
|
||||
5C971E1E27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; };
|
||||
5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */; };
|
||||
5C971E2227AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */; };
|
||||
5C9FD96B27A56D4D0075386C /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96A27A56D4D0075386C /* JSON.swift */; };
|
||||
5C9FD96C27A56D4D0075386C /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96A27A56D4D0075386C /* JSON.swift */; };
|
||||
5C9FD96E27A5D6ED0075386C /* SendMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */; };
|
||||
@@ -78,6 +89,12 @@
|
||||
5CCD403827A5F9A200368C90 /* ConnectContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ConnectContactView.swift */; };
|
||||
5CCD403A27A5F9BE00368C90 /* CreateGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */; };
|
||||
5CCD403B27A5F9BE00368C90 /* CreateGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */; };
|
||||
5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407127ADB1D0007B033A /* Emoji.swift */; };
|
||||
5CE4407327ADB1D0007B033A /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407127ADB1D0007B033A /* Emoji.swift */; };
|
||||
5CE4407627ADB66A007B033A /* TextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407527ADB66A007B033A /* TextItemView.swift */; };
|
||||
5CE4407727ADB66A007B033A /* TextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407527ADB66A007B033A /* TextItemView.swift */; };
|
||||
5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; };
|
||||
5CE4407A27ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -101,24 +118,32 @@
|
||||
5C063D2627A4564100AEC577 /* ChatPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreviewView.swift; sourceTree = "<group>"; };
|
||||
5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactRequestView.swift; sourceTree = "<group>"; };
|
||||
5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemView.swift; sourceTree = "<group>"; };
|
||||
5C1AEB7F279F4A6400247F08 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
5C1AEB80279F4A6400247F08 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
5C1AEB81279F4A6400247F08 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
5C2E260627A2941F00F70299 /* SimpleXAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXAPI.swift; sourceTree = "<group>"; };
|
||||
5C2E260927A2C63500F70299 /* MyPlayground.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = MyPlayground.playground; sourceTree = "<group>"; xcLanguageSpecificationIdentifier = xcode.lang.swift; };
|
||||
5C2E260A27A30CFA00F70299 /* ChatListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListView.swift; sourceTree = "<group>"; };
|
||||
5C2E260E27A30FDC00F70299 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
|
||||
5C2E261127A30FEA00F70299 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = "<group>"; };
|
||||
5C35CFC727B2782E00FB6C6D /* BGManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGManager.swift; sourceTree = "<group>"; };
|
||||
5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NtfManager.swift; sourceTree = "<group>"; };
|
||||
5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = "<group>"; };
|
||||
5C44B69E27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a"; sourceTree = "<group>"; };
|
||||
5C44B69F27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a"; sourceTree = "<group>"; };
|
||||
5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = "<group>"; };
|
||||
5C6AD81227A834E300348BD7 /* NewChatButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatButton.swift; sourceTree = "<group>"; };
|
||||
5C75059727B5CD9300BE3227 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
5C75059827B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj-ghc8.10.7.a"; sourceTree = "<group>"; };
|
||||
5C75059927B5CD9300BE3227 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
5C75059A27B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj.a"; sourceTree = "<group>"; };
|
||||
5C75059B27B5CD9300BE3227 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
5C7505A127B65FDB00BE3227 /* CIMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMetaView.swift; sourceTree = "<group>"; };
|
||||
5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = "<group>"; };
|
||||
5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = "<group>"; };
|
||||
5C764E7B279C71D4000C6508 /* libiconv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libiconv.tbd; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.2.sdk/usr/lib/libiconv.tbd; sourceTree = DEVELOPER_DIR; };
|
||||
5C764E7C279C71DB000C6508 /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.2.sdk/usr/lib/libz.tbd; sourceTree = DEVELOPER_DIR; };
|
||||
5C764E7D279C7275000C6508 /* SimpleX (iOS)-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SimpleX (iOS)-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
5C764E7E279C7275000C6508 /* SimpleX (macOS)-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SimpleX (macOS)-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
5C764E7F279C7276000C6508 /* dummy.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = dummy.m; sourceTree = "<group>"; };
|
||||
5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = "<group>"; };
|
||||
5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoView.swift; sourceTree = "<group>"; };
|
||||
5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoImage.swift; sourceTree = "<group>"; };
|
||||
5C9FD96A27A56D4D0075386C /* JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSON.swift; sourceTree = "<group>"; };
|
||||
5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageView.swift; sourceTree = "<group>"; };
|
||||
5CA059C3279559F40002BEB4 /* SimpleXApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXApp.swift; sourceTree = "<group>"; };
|
||||
@@ -143,6 +168,9 @@
|
||||
5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = "<group>"; };
|
||||
5CCD403627A5F9A200368C90 /* ConnectContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectContactView.swift; sourceTree = "<group>"; };
|
||||
5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateGroupView.swift; sourceTree = "<group>"; };
|
||||
5CE4407127ADB1D0007B033A /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = "<group>"; };
|
||||
5CE4407527ADB66A007B033A /* TextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextItemView.swift; sourceTree = "<group>"; };
|
||||
5CE4407827ADB701007B033A /* EmojiItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiItemView.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -151,13 +179,13 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */,
|
||||
5C75059E27B5CD9300BE3227 /* libffi.a in Frameworks */,
|
||||
5C764E83279C748B000C6508 /* libz.tbd in Frameworks */,
|
||||
5C75059F27B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj.a in Frameworks */,
|
||||
5C7505A027B5CD9300BE3227 /* libgmpxx.a in Frameworks */,
|
||||
5C75059C27B5CD9300BE3227 /* libgmp.a in Frameworks */,
|
||||
5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */,
|
||||
5C1AEB86279F4A6400247F08 /* libffi.a in Frameworks */,
|
||||
5C44B6A227A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a in Frameworks */,
|
||||
5C1AEB88279F4A6400247F08 /* libgmp.a in Frameworks */,
|
||||
5C44B6A027A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a in Frameworks */,
|
||||
5C1AEB8A279F4A6400247F08 /* libgmpxx.a in Frameworks */,
|
||||
5C75059D27B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj-ghc8.10.7.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -167,11 +195,6 @@
|
||||
files = (
|
||||
5C764E85279C748C000C6508 /* libz.tbd in Frameworks */,
|
||||
5C764E84279C748C000C6508 /* libiconv.tbd in Frameworks */,
|
||||
5C1AEB87279F4A6400247F08 /* libffi.a in Frameworks */,
|
||||
5C44B6A327A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a in Frameworks */,
|
||||
5C1AEB89279F4A6400247F08 /* libgmp.a in Frameworks */,
|
||||
5C44B6A127A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a in Frameworks */,
|
||||
5C1AEB8B279F4A6400247F08 /* libgmpxx.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -195,6 +218,7 @@
|
||||
5C2E260D27A30E2400F70299 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5C971E1F27AEBF7000C8A3CE /* Helpers */,
|
||||
5C5F4AC227A5E9AF00B51EF1 /* Chat */,
|
||||
5CB9250B27A942F300ACCCDD /* ChatList */,
|
||||
5CB924DD27A8622200ACCCDD /* NewChat */,
|
||||
@@ -208,9 +232,13 @@
|
||||
5C5F4AC227A5E9AF00B51EF1 /* Chat */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5CE4407427ADB657007B033A /* ChatItem */,
|
||||
5C2E260E27A30FDC00F70299 /* ChatView.swift */,
|
||||
5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */,
|
||||
5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */,
|
||||
5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */,
|
||||
5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */,
|
||||
5CE4407127ADB1D0007B033A /* Emoji.swift */,
|
||||
);
|
||||
path = Chat;
|
||||
sourceTree = "<group>";
|
||||
@@ -218,11 +246,11 @@
|
||||
5C764E5C279C70B7000C6508 /* Libraries */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5C1AEB7F279F4A6400247F08 /* libffi.a */,
|
||||
5C1AEB80279F4A6400247F08 /* libgmp.a */,
|
||||
5C1AEB81279F4A6400247F08 /* libgmpxx.a */,
|
||||
5C44B69F27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w-ghc8.10.7.a */,
|
||||
5C44B69E27A5FF22001C3154 /* libHSsimplex-chat-1.0.2-5okwuQXOXC78H7u8DMgS6w.a */,
|
||||
5C75059927B5CD9300BE3227 /* libffi.a */,
|
||||
5C75059727B5CD9300BE3227 /* libgmp.a */,
|
||||
5C75059B27B5CD9300BE3227 /* libgmpxx.a */,
|
||||
5C75059827B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj-ghc8.10.7.a */,
|
||||
5C75059A27B5CD9300BE3227 /* libHSsimplex-chat-1.1.1-GMOrBlCwBvRIONTwKLN6tj.a */,
|
||||
);
|
||||
path = Libraries;
|
||||
sourceTree = "<group>";
|
||||
@@ -242,10 +270,22 @@
|
||||
5C764E88279CBCB3000C6508 /* ChatModel.swift */,
|
||||
5C2E260627A2941F00F70299 /* SimpleXAPI.swift */,
|
||||
5C9FD96A27A56D4D0075386C /* JSON.swift */,
|
||||
5C35CFC727B2782E00FB6C6D /* BGManager.swift */,
|
||||
5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */,
|
||||
);
|
||||
path = Model;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5C971E1F27AEBF7000C8A3CE /* Helpers */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */,
|
||||
5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */,
|
||||
5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */,
|
||||
);
|
||||
path = Helpers;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5CA059BD279559F40002BEB4 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -263,15 +303,15 @@
|
||||
5CA059C2279559F40002BEB4 /* Shared */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5C764E87279CBC8E000C6508 /* Model */,
|
||||
5CA059C3279559F40002BEB4 /* SimpleXApp.swift */,
|
||||
5C2E260927A2C63500F70299 /* MyPlayground.playground */,
|
||||
5C764E7F279C7276000C6508 /* dummy.m */,
|
||||
5CA059C4279559F40002BEB4 /* ContentView.swift */,
|
||||
5C764E87279CBC8E000C6508 /* Model */,
|
||||
5C2E260D27A30E2400F70299 /* Views */,
|
||||
5CA059C5279559F40002BEB4 /* Assets.xcassets */,
|
||||
5C764E7D279C7275000C6508 /* SimpleX (iOS)-Bridging-Header.h */,
|
||||
5C764E7E279C7275000C6508 /* SimpleX (macOS)-Bridging-Header.h */,
|
||||
5C764E7F279C7276000C6508 /* dummy.m */,
|
||||
5C2E260927A2C63500F70299 /* MyPlayground.playground */,
|
||||
);
|
||||
path = Shared;
|
||||
sourceTree = "<group>";
|
||||
@@ -320,7 +360,6 @@
|
||||
5CCD403627A5F9A200368C90 /* ConnectContactView.swift */,
|
||||
5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */,
|
||||
5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */,
|
||||
5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */,
|
||||
);
|
||||
path = NewChat;
|
||||
sourceTree = "<group>";
|
||||
@@ -340,6 +379,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5C2E260A27A30CFA00F70299 /* ChatListView.swift */,
|
||||
5C5346A727B59A6A004DF848 /* ChatHelp.swift */,
|
||||
5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */,
|
||||
5C063D2627A4564100AEC577 /* ChatPreviewView.swift */,
|
||||
5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */,
|
||||
@@ -347,6 +387,16 @@
|
||||
path = ChatList;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5CE4407427ADB657007B033A /* ChatItem */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5CE4407527ADB66A007B033A /* TextItemView.swift */,
|
||||
5C7505A127B65FDB00BE3227 /* CIMetaView.swift */,
|
||||
5CE4407827ADB701007B033A /* EmojiItemView.swift */,
|
||||
);
|
||||
path = ChatItem;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -516,10 +566,15 @@
|
||||
files = (
|
||||
5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */,
|
||||
5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */,
|
||||
5CE4407627ADB66A007B033A /* TextItemView.swift in Sources */,
|
||||
5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */,
|
||||
5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */,
|
||||
5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */,
|
||||
5C764E80279C7276000C6508 /* dummy.m in Sources */,
|
||||
5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */,
|
||||
5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */,
|
||||
5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */,
|
||||
5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */,
|
||||
5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */,
|
||||
5C9FD96B27A56D4D0075386C /* JSON.swift in Sources */,
|
||||
5C9FD96E27A5D6ED0075386C /* SendMessageView.swift in Sources */,
|
||||
@@ -528,16 +583,22 @@
|
||||
5CB9250D27A9432000ACCCDD /* ChatListNavLink.swift in Sources */,
|
||||
5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */,
|
||||
5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */,
|
||||
5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */,
|
||||
5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */,
|
||||
5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */,
|
||||
5CA05A4C27974EB60002BEB4 /* WelcomeView.swift in Sources */,
|
||||
5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */,
|
||||
5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */,
|
||||
5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */,
|
||||
5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */,
|
||||
5CCD403727A5F9A200368C90 /* ConnectContactView.swift in Sources */,
|
||||
5CCD403A27A5F9BE00368C90 /* CreateGroupView.swift in Sources */,
|
||||
5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */,
|
||||
5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */,
|
||||
5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */,
|
||||
5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */,
|
||||
5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */,
|
||||
5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */,
|
||||
5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -548,10 +609,15 @@
|
||||
files = (
|
||||
5C6AD81427A834E300348BD7 /* NewChatButton.swift in Sources */,
|
||||
5CB924D827A8563F00ACCCDD /* SettingsView.swift in Sources */,
|
||||
5CE4407727ADB66A007B033A /* TextItemView.swift in Sources */,
|
||||
5CB924E227A867BA00ACCCDD /* UserProfile.swift in Sources */,
|
||||
5CE4407A27ADB701007B033A /* EmojiItemView.swift in Sources */,
|
||||
5C5346A927B59A6A004DF848 /* ChatHelp.swift in Sources */,
|
||||
5C764E81279C7276000C6508 /* dummy.m in Sources */,
|
||||
5C7505A927B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */,
|
||||
5CB924E527A8683A00ACCCDD /* UserAddress.swift in Sources */,
|
||||
5C063D2827A4564100AEC577 /* ChatPreviewView.swift in Sources */,
|
||||
5C35CFCC27B2E91D00FB6C6D /* NtfManager.swift in Sources */,
|
||||
5C2E261327A30FEA00F70299 /* TerminalView.swift in Sources */,
|
||||
5C9FD96C27A56D4D0075386C /* JSON.swift in Sources */,
|
||||
5C9FD96F27A5D6ED0075386C /* SendMessageView.swift in Sources */,
|
||||
@@ -560,16 +626,22 @@
|
||||
5CB9250E27A9432000ACCCDD /* ChatListNavLink.swift in Sources */,
|
||||
5CA059EE279559F40002BEB4 /* ContentView.swift in Sources */,
|
||||
5CCD403527A5F6DF00368C90 /* AddContactView.swift in Sources */,
|
||||
5C7505A627B679EE00BE3227 /* NavLinkPlain.swift in Sources */,
|
||||
5C7505A327B65FDB00BE3227 /* CIMetaView.swift in Sources */,
|
||||
5C35CFC927B2782E00FB6C6D /* BGManager.swift in Sources */,
|
||||
5CA05A4D27974EB60002BEB4 /* WelcomeView.swift in Sources */,
|
||||
5C2E261027A30FDC00F70299 /* ChatView.swift in Sources */,
|
||||
5C2E260C27A30CFA00F70299 /* ChatListView.swift in Sources */,
|
||||
5C971E2227AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */,
|
||||
5CA059EC279559F40002BEB4 /* SimpleXApp.swift in Sources */,
|
||||
5CCD403827A5F9A200368C90 /* ConnectContactView.swift in Sources */,
|
||||
5CCD403B27A5F9BE00368C90 /* CreateGroupView.swift in Sources */,
|
||||
5C764E8A279CBCB3000C6508 /* ChatModel.swift in Sources */,
|
||||
5C971E1E27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */,
|
||||
5CC1C99627A6CF7F000D9FF6 /* ShareSheet.swift in Sources */,
|
||||
5C2E260827A2941F00F70299 /* SimpleXAPI.swift in Sources */,
|
||||
5CB924D527A853F100ACCCDD /* SettingsButton.swift in Sources */,
|
||||
5CE4407327ADB1D0007B033A /* Emoji.swift in Sources */,
|
||||
5C1A4C1F27A715B700EAD5AD /* ChatItemView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -727,7 +799,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -744,13 +816,10 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
LIBRARY_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Libraries",
|
||||
"$(PROJECT_DIR)/Libraries/ios",
|
||||
"$(PROJECT_DIR)/Libraries/sim",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
LIBRARY_SEARCH_PATHS = "";
|
||||
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios";
|
||||
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim";
|
||||
MARKETING_VERSION = 0.3.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||
PRODUCT_NAME = SimpleX;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -770,7 +839,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -787,13 +856,10 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
LIBRARY_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Libraries",
|
||||
"$(PROJECT_DIR)/Libraries/ios",
|
||||
"$(PROJECT_DIR)/Libraries/sim",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
LIBRARY_SEARCH_PATHS = "";
|
||||
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios";
|
||||
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim";
|
||||
MARKETING_VERSION = 0.3.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||
PRODUCT_NAME = SimpleX;
|
||||
SDKROOT = iphoneos;
|
||||
|
||||
40
blog/20220214-simplex-chat-ios-public-beta.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# SimpleX announces SimpleX Chat public beta for iOS
|
||||
|
||||
**Published:** Feb 14, 2022
|
||||
|
||||
## Private and secure chat and application platform - [public beta is now available](https://testflight.apple.com/join/DWuT2LQu) for iPhones with iOS 15.
|
||||
|
||||
Our new iPhone app is very basic - right now it only supports text messages and emojis.
|
||||
|
||||
Even though the app is new, it uses the same core code as our terminal app, that was used and stabilized over a long time, and it provides the same level of privacy and security that has been available since the release of v1 a month ago:
|
||||
- [double-ratchet](https://www.signal.org/docs/specifications/doubleratchet/) E2E encryption.
|
||||
- separate keys for each contact.
|
||||
- additional layer of E2E encryption in each message queue (to prevent traffic correlation when multiple queues are used in a conversation - something we plan later this year).
|
||||
- additional encryption of messages delivered from servers to recipients (also to prevent traffic correlation).
|
||||
|
||||
You can read more details in our recent [v1 announcement](https://github.com/simplex-chat/simplex-chat/blob/stable/blog/20220112-simplex-chat-v1-released.md).
|
||||
|
||||
## Join our public beta!
|
||||
|
||||
Install the app [via TestFlight](https://testflight.apple.com/join/DWuT2LQu), connect to us (via **Connect to SimpleX team** link in the app) and to a couple of your friends you usually send messages to - and please let us know what you think!
|
||||
|
||||
We would really appreciate any feedback to improve the app and to decide which additional features should be included in our public release in March.
|
||||
|
||||
Should it be:
|
||||
- images,
|
||||
- link previews,
|
||||
- or maybe something else we couldn't think of.
|
||||
|
||||
Please vote on the features you think are the most needed in our [app roadmap](https://app.loopedin.io/simplex).
|
||||
|
||||
## What is SimpleX?
|
||||
|
||||
We are building a new platform for distributed Internet applications where privacy of the messages _and_ the network matter.
|
||||
|
||||
We aim to provide the best possible protection of messages and metadata. Today there is no messaging application that works without global user identities, so we believe we provide better metadata privacy than alternatives. SimpleX is designed to be truly distributed with no central server, and without any global user identities. This allows for high scalability at low cost, and also makes it virtually impossible to snoop on the network graph.
|
||||
|
||||
The first application built on the platform is Simplex Chat, which is available for terminal (command line in Windows/Mac/Linux) and as iOS public beta - with Android app coming in a few weeks. The platform can easily support a private social network feed and a multitude of other services, which can be developed by the Simplex team or third party developers.
|
||||
|
||||
SimpleX also allows people to host their own servers to have control of their chat data. SimpleX servers are exceptionally lightweight and require a single process with the initial memory footprint of under 20 Mb, which grows as the server adds in-memory queues (even with 10,000 queues it uses less than 50Mb, not accounting for messages). It should be considered though that while self-hosting the servers provides more control, it may reduce meta-data privacy, as it is easier to correlate the traffic of servers with small number of messages coming through.
|
||||
|
||||
Further details on platform objectives and technical design are available [in SimpleX platform overview](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md).
|
||||
@@ -1,11 +1,13 @@
|
||||
# Blog
|
||||
|
||||
Jan 12, 2022. [SimpleX Chat v1 released: the most private and secure chat and application platform](https://github.com/simplex-chat/simplex-chat/blob/master/blog/20220112-simplex-chat-v1-released.md)
|
||||
Feb 14, 2022. [SimpleX Chat: join our public beta for iOS!](https://github.com/simplex-chat/simplex-chat/blob/stable/blog/20220214-simplex-chat-ios-public-beta.md)
|
||||
|
||||
Dec 08, 2021. [SimpleX Chat v0.5 released: the first chat platform that is 100% private by design - no access to your connections graph](https://github.com/simplex-chat/simplex-chat/blob/master/blog/20211208-simplex-chat-v0.5-released.md)
|
||||
Jan 12, 2022. [SimpleX Chat v1 released: the most private and secure chat and application platform](https://github.com/simplex-chat/simplex-chat/blob/stable/blog/20220112-simplex-chat-v1-released.md)
|
||||
|
||||
Sep 14, 2021. [SimpleX Chat v0.4 released: open-source chat that uses privacy-preserving message routing protocol](https://github.com/simplex-chat/simplex-chat/blob/master/blog/20210914-simplex-chat-v0.4-released.md)
|
||||
Dec 08, 2021. [SimpleX Chat v0.5 released: the first chat platform that is 100% private by design - no access to your connections graph](https://github.com/simplex-chat/simplex-chat/blob/stable/blog/20211208-simplex-chat-v0.5-released.md)
|
||||
|
||||
May 12, 2021. [SimpleX Chat Prototype!](https://github.com/simplex-chat/simplex-chat/blob/master/blog/20210512-simplex-chat-terminal-ui.md)
|
||||
Sep 14, 2021. [SimpleX Chat v0.4 released: open-source chat that uses privacy-preserving message routing protocol](https://github.com/simplex-chat/simplex-chat/blob/stable/blog/20210914-simplex-chat-v0.4-released.md)
|
||||
|
||||
Oct 22, 2020. [SimpleX Chat](https://github.com/simplex-chat/simplex-chat/blob/master/blog/20201022-simplex-chat)
|
||||
May 12, 2021. [SimpleX Chat Prototype!](https://github.com/simplex-chat/simplex-chat/blob/stable/blog/20210512-simplex-chat-terminal-ui.md)
|
||||
|
||||
Oct 22, 2020. [SimpleX Chat](https://github.com/simplex-chat/simplex-chat/blob/stable/blog/20201022-simplex-chat)
|
||||
|
||||
@@ -3,7 +3,7 @@ packages: .
|
||||
source-repository-package
|
||||
type: git
|
||||
location: git://github.com/simplex-chat/simplexmq.git
|
||||
tag: 137ff7043d49feb3b350f56783c9b64a62bc636a
|
||||
tag: 229e2607d76f3d6baf0d2623b186c084e3908b8f
|
||||
|
||||
source-repository-package
|
||||
type: git
|
||||
|
||||
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 92 KiB |
@@ -1,5 +1,5 @@
|
||||
name: simplex-chat
|
||||
version: 1.1.0
|
||||
version: 1.2.0
|
||||
#synopsis:
|
||||
#description:
|
||||
homepage: https://github.com/simplex-chat/simplex-chat#readme
|
||||
@@ -14,6 +14,7 @@ extra-source-files:
|
||||
dependencies:
|
||||
- aeson == 2.0.*
|
||||
- ansi-terminal >= 0.10 && < 0.12
|
||||
- async == 2.2.*
|
||||
- attoparsec == 0.14.*
|
||||
- base >= 4.7 && < 5
|
||||
- base64-bytestring >= 1.0 && < 1.3
|
||||
|
||||
19
rfcs/2022-02-10-deduplicate-contact-requests.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Deduplicate contact requests
|
||||
|
||||
1. add nullable fields `via_contact_uri_hash` and `xcontact_id` to `connections`
|
||||
2. when joining (Connect -> SCMContact)
|
||||
- generate and save random `xcontact_id`
|
||||
- save hash of `AConnectionRequestUri` when joining via contact uri
|
||||
(AConnectionRequestUri -> ConnectionRequestUri -> CRContactUri)
|
||||
- send random identifier in `XContact` as `Maybe XContactId`
|
||||
- check for repeat join - if connection with such `via_contact_uri_hash` has contact notify user
|
||||
- check for repeat join - check in connections if such contact uri exists, if yes use same identifier; the rest of request can (should) be regenerated, e.g. new server, profile
|
||||
can be required
|
||||
3. add nullable field `xcontact_id` to `contact_requests` and to `contacts` (* for auto-acceptance)
|
||||
4. on contact request (processUserContactRequest)
|
||||
- save identifier
|
||||
- \* check if `xcontact_id` is in `contacts` - then notify this contact already exists
|
||||
- when saving check if contact request with such identifier exists, if yes update `contact_request`
|
||||
(`invId`, new profile)
|
||||
- ? remove old invitation - probably not necessarily, to be done in scope of connection expiration
|
||||
- return from Store whether request is new or updated (Bool?), new chat response for update or same response
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"git://github.com/simplex-chat/simplexmq.git"."137ff7043d49feb3b350f56783c9b64a62bc636a" = "1jlxpmg40qkvisbf03082yrw6k2ah9dsw8pn1jqc0cyz5250qc49";
|
||||
"git://github.com/simplex-chat/simplexmq.git"."229e2607d76f3d6baf0d2623b186c084e3908b8f" = "1w7mrrsyjh2wskqgdp8ml33xivzzqhzig217q3f92nkzc87ziq4g";
|
||||
"git://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp";
|
||||
"git://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj";
|
||||
"git://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97";
|
||||
|
||||
@@ -5,7 +5,7 @@ cabal-version: 1.12
|
||||
-- see: https://github.com/sol/hpack
|
||||
|
||||
name: simplex-chat
|
||||
version: 1.1.0
|
||||
version: 1.2.0
|
||||
category: Web, System, Services, Cryptography
|
||||
homepage: https://github.com/simplex-chat/simplex-chat#readme
|
||||
author: simplex.chat
|
||||
@@ -26,6 +26,8 @@ library
|
||||
Simplex.Chat.Messages
|
||||
Simplex.Chat.Migrations.M20220101_initial
|
||||
Simplex.Chat.Migrations.M20220122_v1_1
|
||||
Simplex.Chat.Migrations.M20220205_chat_item_status
|
||||
Simplex.Chat.Migrations.M20220210_deduplicate_contact_requests
|
||||
Simplex.Chat.Mobile
|
||||
Simplex.Chat.Options
|
||||
Simplex.Chat.Protocol
|
||||
@@ -46,6 +48,7 @@ library
|
||||
build-depends:
|
||||
aeson ==2.0.*
|
||||
, ansi-terminal >=0.10 && <0.12
|
||||
, async ==2.2.*
|
||||
, attoparsec ==0.14.*
|
||||
, base >=4.7 && <5
|
||||
, base64-bytestring >=1.0 && <1.3
|
||||
@@ -80,6 +83,7 @@ executable simplex-chat
|
||||
build-depends:
|
||||
aeson ==2.0.*
|
||||
, ansi-terminal >=0.10 && <0.12
|
||||
, async ==2.2.*
|
||||
, attoparsec ==0.14.*
|
||||
, base >=4.7 && <5
|
||||
, base64-bytestring >=1.0 && <1.3
|
||||
@@ -112,6 +116,7 @@ test-suite simplex-chat-test
|
||||
ChatClient
|
||||
ChatTests
|
||||
MarkdownTests
|
||||
MobileTests
|
||||
ProtocolTests
|
||||
Paths_simplex_chat
|
||||
hs-source-dirs:
|
||||
|
||||
@@ -25,7 +25,6 @@ import Data.Bifunctor (first)
|
||||
import Data.ByteString.Char8 (ByteString)
|
||||
import qualified Data.ByteString.Char8 as B
|
||||
import Data.Char (isSpace)
|
||||
import Data.Foldable (for_)
|
||||
import Data.Functor (($>))
|
||||
import Data.Int (Int64)
|
||||
import Data.List (find)
|
||||
@@ -34,7 +33,6 @@ import qualified Data.Map.Strict as M
|
||||
import Data.Maybe (isJust, mapMaybe)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import Data.Text.Encoding (encodeUtf8)
|
||||
import Data.Time.Clock (UTCTime, getCurrentTime)
|
||||
import Data.Time.LocalTime (getCurrentTimeZone)
|
||||
import Data.Word (Word32)
|
||||
@@ -44,7 +42,7 @@ import Simplex.Chat.Options (ChatOpts (..))
|
||||
import Simplex.Chat.Protocol
|
||||
import Simplex.Chat.Store
|
||||
import Simplex.Chat.Types
|
||||
import Simplex.Chat.Util (ifM, safeDecodeUtf8, unlessM)
|
||||
import Simplex.Chat.Util (ifM, safeDecodeUtf8, unlessM, whenM)
|
||||
import Simplex.Messaging.Agent
|
||||
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), defaultAgentConfig)
|
||||
import Simplex.Messaging.Agent.Protocol
|
||||
@@ -52,14 +50,14 @@ import qualified Simplex.Messaging.Crypto as C
|
||||
import Simplex.Messaging.Encoding
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Parsers (parseAll)
|
||||
import Simplex.Messaging.Protocol (MsgBody)
|
||||
import Simplex.Messaging.Protocol (ErrorType (..), MsgBody)
|
||||
import qualified Simplex.Messaging.Protocol as SMP
|
||||
import Simplex.Messaging.Util (tryError)
|
||||
import System.Exit (exitFailure, exitSuccess)
|
||||
import System.FilePath (combine, splitExtensions, takeFileName)
|
||||
import System.IO (Handle, IOMode (..), SeekMode (..), hFlush, openFile, stdout)
|
||||
import Text.Read (readMaybe)
|
||||
import UnliftIO.Async (race_)
|
||||
import UnliftIO.Async (Async, async, race_)
|
||||
import UnliftIO.Concurrent (forkIO, threadDelay)
|
||||
import UnliftIO.Directory (doesDirectoryExist, doesFileExist, getFileSize, getHomeDirectory, getTemporaryDirectory)
|
||||
import qualified UnliftIO.Exception as E
|
||||
@@ -74,23 +72,27 @@ defaultChatConfig =
|
||||
{ tcpPort = undefined, -- agent does not listen to TCP
|
||||
smpServers = undefined, -- filled in from options
|
||||
dbFile = undefined, -- filled in from options
|
||||
dbPoolSize = 1
|
||||
dbPoolSize = 1,
|
||||
yesToMigrations = False
|
||||
},
|
||||
dbPoolSize = 1,
|
||||
yesToMigrations = False,
|
||||
tbqSize = 16,
|
||||
fileChunkSize = 15780
|
||||
fileChunkSize = 15780,
|
||||
testView = False
|
||||
}
|
||||
|
||||
logCfg :: LogConfig
|
||||
logCfg = LogConfig {lc_file = Nothing, lc_stderr = True}
|
||||
|
||||
newChatController :: SQLiteStore -> User -> ChatConfig -> ChatOpts -> (Notification -> IO ()) -> IO ChatController
|
||||
newChatController :: SQLiteStore -> Maybe User -> ChatConfig -> ChatOpts -> (Notification -> IO ()) -> IO ChatController
|
||||
newChatController chatStore user config@ChatConfig {agentConfig = cfg, tbqSize} ChatOpts {dbFilePrefix, smpServers} sendNotification = do
|
||||
let f = chatStoreFile dbFilePrefix
|
||||
activeTo <- newTVarIO ActiveNone
|
||||
firstTime <- not <$> doesFileExist f
|
||||
currentUser <- newTVarIO user
|
||||
smpAgent <- getSMPAgentClient cfg {dbFile = dbFilePrefix <> "_agent.db", smpServers}
|
||||
agentAsync <- newTVarIO Nothing
|
||||
idsDrg <- newTVarIO =<< drgNew
|
||||
inputQ <- newTBQueueIO tbqSize
|
||||
outputQ <- newTBQueueIO tbqSize
|
||||
@@ -98,10 +100,20 @@ newChatController chatStore user config@ChatConfig {agentConfig = cfg, tbqSize}
|
||||
chatLock <- newTMVarIO ()
|
||||
sndFiles <- newTVarIO M.empty
|
||||
rcvFiles <- newTVarIO M.empty
|
||||
pure ChatController {activeTo, firstTime, currentUser, smpAgent, chatStore, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, config, sendNotification}
|
||||
pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, config, sendNotification}
|
||||
|
||||
runChatController :: (MonadUnliftIO m, MonadReader ChatController m) => m ()
|
||||
runChatController = race_ agentSubscriber notificationSubscriber
|
||||
runChatController :: (MonadUnliftIO m, MonadReader ChatController m) => User -> m ()
|
||||
runChatController = race_ notificationSubscriber . agentSubscriber
|
||||
|
||||
startChatController :: (MonadUnliftIO m, MonadReader ChatController m) => User -> m (Async ())
|
||||
startChatController user = do
|
||||
s <- asks agentAsync
|
||||
readTVarIO s >>= maybe (start s) pure
|
||||
where
|
||||
start s = do
|
||||
a <- async $ runChatController user
|
||||
atomically . writeTVar s $ Just a
|
||||
pure a
|
||||
|
||||
withLock :: MonadUnliftIO m => TMVar () -> m a -> m a
|
||||
withLock lock =
|
||||
@@ -109,28 +121,33 @@ withLock lock =
|
||||
(void . atomically $ takeTMVar lock)
|
||||
(atomically $ putTMVar lock ())
|
||||
|
||||
execChatCommand :: (MonadUnliftIO m, MonadReader ChatController m) => String -> m ChatResponse
|
||||
execChatCommand s = case parseAll chatCommandP . B.dropWhileEnd isSpace . encodeUtf8 $ T.pack s of
|
||||
Left e -> pure . CRChatError . ChatError $ CECommandError e
|
||||
Right cmd -> do
|
||||
ChatController {chatLock = l, smpAgent = a, currentUser} <- ask
|
||||
user <- readTVarIO currentUser
|
||||
withAgentLock a . withLock l $ either CRChatCmdError id <$> runExceptT (processChatCommand user cmd)
|
||||
execChatCommand :: (MonadUnliftIO m, MonadReader ChatController m) => ByteString -> m ChatResponse
|
||||
execChatCommand s = case parseAll chatCommandP $ B.dropWhileEnd isSpace s of
|
||||
Left e -> pure $ chatCmdError e
|
||||
Right cmd -> either CRChatCmdError id <$> runExceptT (processChatCommand cmd)
|
||||
|
||||
toView :: ChatMonad m => ChatResponse -> m ()
|
||||
toView event = do
|
||||
q <- asks outputQ
|
||||
atomically $ writeTBQueue q (Nothing, event)
|
||||
|
||||
processChatCommand :: forall m. ChatMonad m => User -> ChatCommand -> m ChatResponse
|
||||
processChatCommand user@User {userId, profile} = \case
|
||||
APIGetChats -> CRApiChats <$> withStore (`getChatPreviews` user)
|
||||
APIGetChat cType cId pagination -> case cType of
|
||||
processChatCommand :: forall m. ChatMonad m => ChatCommand -> m ChatResponse
|
||||
processChatCommand = \case
|
||||
ShowActiveUser -> withUser' $ pure . CRActiveUser
|
||||
CreateActiveUser p -> do
|
||||
u <- asks currentUser
|
||||
whenM (isJust <$> readTVarIO u) $ throwChatError CEActiveUserExists
|
||||
user <- withStore $ \st -> createUser st p True
|
||||
atomically . writeTVar u $ Just user
|
||||
pure $ CRActiveUser user
|
||||
StartChat -> withUser' $ \user -> startChatController user $> CRChatStarted
|
||||
APIGetChats -> CRApiChats <$> withUser (\user -> withStore (`getChatPreviews` user))
|
||||
APIGetChat cType cId pagination -> withUser $ \user -> case cType of
|
||||
CTDirect -> CRApiChat . AChat SCTDirect <$> withStore (\st -> getDirectChat st user cId pagination)
|
||||
CTGroup -> CRApiChat . AChat SCTGroup <$> withStore (\st -> getGroupChat st user cId pagination)
|
||||
CTContactRequest -> pure $ CRChatError ChatErrorNotImplemented
|
||||
APIGetChatItems _count -> pure $ CRChatError ChatErrorNotImplemented
|
||||
APISendMessage cType chatId mc -> case cType of
|
||||
CTContactRequest -> pure $ chatCmdError "not implemented"
|
||||
APIGetChatItems _pagination -> pure $ chatCmdError "not implemented"
|
||||
APISendMessage cType chatId mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of
|
||||
CTDirect -> do
|
||||
ct@Contact {localDisplayName = c} <- withStore $ \st -> getContact st userId chatId
|
||||
ci <- sendDirectChatItem userId ct (XMsgNew mc) (CISndMsgContent mc)
|
||||
@@ -142,30 +159,30 @@ processChatCommand user@User {userId, profile} = \case
|
||||
ci <- sendGroupChatItem userId group (XMsgNew mc) (CISndMsgContent mc)
|
||||
setActive $ ActiveG gName
|
||||
pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci
|
||||
CTContactRequest -> pure . CRChatError . ChatError $ CECommandError "not supported"
|
||||
APIDeleteChat cType chatId -> case cType of
|
||||
CTContactRequest -> pure $ chatCmdError "not supported"
|
||||
APIChatRead cType chatId fromToIds -> withChatLock $ case cType of
|
||||
CTDirect -> withStore (\st -> updateDirectChatItemsRead st chatId fromToIds) $> CRCmdOk
|
||||
CTGroup -> withStore (\st -> updateGroupChatItemsRead st chatId fromToIds) $> CRCmdOk
|
||||
CTContactRequest -> pure $ chatCmdError "not supported"
|
||||
APIDeleteChat cType chatId -> withUser $ \User {userId} -> case cType of
|
||||
CTDirect -> do
|
||||
ct@Contact {localDisplayName} <- withStore $ \st -> getContact st userId chatId
|
||||
withStore (\st -> getContactGroupNames st userId ct) >>= \case
|
||||
[] -> do
|
||||
conns <- withStore $ \st -> getContactConnections st userId ct
|
||||
procCmd $ do
|
||||
withChatLock . procCmd $ do
|
||||
withAgent $ \a -> forM_ conns $ \conn ->
|
||||
deleteConnection a (aConnId conn) `catchError` \(_ :: AgentErrorType) -> pure ()
|
||||
withStore $ \st -> deleteContact st userId ct
|
||||
unsetActive $ ActiveC localDisplayName
|
||||
pure $ CRContactDeleted ct
|
||||
gs -> throwChatError $ CEContactGroups ct gs
|
||||
CTGroup -> pure $ CRChatCmdError ChatErrorNotImplemented
|
||||
CTContactRequest -> pure . CRChatError . ChatError $ CECommandError "not supported"
|
||||
APIAcceptContact connReqId -> do
|
||||
UserContactRequest {agentInvitationId = AgentInvId invId, localDisplayName = cName, profileId, profile = p} <- withStore $ \st ->
|
||||
getContactRequest st userId connReqId
|
||||
procCmd $ do
|
||||
connId <- withAgent $ \a -> acceptContact a invId . directMessage $ XInfo profile
|
||||
acceptedContact <- withStore $ \st -> createAcceptedContact st userId connId cName profileId p
|
||||
pure $ CRAcceptingContactRequest acceptedContact
|
||||
APIRejectContact connReqId -> do
|
||||
CTGroup -> pure $ chatCmdError "not implemented"
|
||||
CTContactRequest -> pure $ chatCmdError "not supported"
|
||||
APIAcceptContact connReqId -> withUser $ \user@User {userId} -> withChatLock $ do
|
||||
cReq <- withStore $ \st -> getContactRequest st userId connReqId
|
||||
procCmd $ CRAcceptingContactRequest <$> acceptContactRequest user cReq
|
||||
APIRejectContact connReqId -> withUser $ \User {userId} -> withChatLock $ do
|
||||
cReq@UserContactRequest {agentContactConnId = AgentConnId connId, agentInvitationId = AgentInvId invId} <-
|
||||
withStore $ \st ->
|
||||
getContactRequest st userId connReqId
|
||||
@@ -173,51 +190,53 @@ processChatCommand user@User {userId, profile} = \case
|
||||
withAgent $ \a -> rejectContact a connId invId
|
||||
pure $ CRContactRequestRejected cReq
|
||||
ChatHelp section -> pure $ CRChatHelp section
|
||||
Welcome -> pure $ CRWelcome user
|
||||
AddContact -> procCmd $ do
|
||||
Welcome -> withUser $ pure . CRWelcome
|
||||
AddContact -> withUser $ \User {userId} -> withChatLock . procCmd $ do
|
||||
(connId, cReq) <- withAgent (`createConnection` SCMInvitation)
|
||||
withStore $ \st -> createDirectConnection st userId connId
|
||||
pure $ CRInvitation cReq
|
||||
Connect (Just (ACR SCMInvitation cReq)) -> procCmd $ do
|
||||
connect cReq $ XInfo profile
|
||||
Connect (Just (ACR SCMInvitation cReq)) -> withUser $ \User {userId, profile} -> withChatLock . procCmd $ do
|
||||
connId <- withAgent $ \a -> joinConnection a cReq . directMessage $ XInfo profile
|
||||
withStore $ \st -> createDirectConnection st userId connId
|
||||
pure CRSentConfirmation
|
||||
Connect (Just (ACR SCMContact cReq)) -> procCmd $ do
|
||||
connect cReq $ XContact profile Nothing
|
||||
pure CRSentInvitation
|
||||
Connect (Just (ACR SCMContact cReq)) -> withUser $ \User {userId, profile} ->
|
||||
connectViaContact userId cReq profile
|
||||
Connect Nothing -> throwChatError CEInvalidConnReq
|
||||
ConnectAdmin -> procCmd $ do
|
||||
connect adminContactReq $ XContact profile Nothing
|
||||
pure CRSentInvitation
|
||||
DeleteContact cName -> do
|
||||
ConnectAdmin -> withUser $ \User {userId, profile} ->
|
||||
connectViaContact userId adminContactReq profile
|
||||
DeleteContact cName -> withUser $ \User {userId} -> do
|
||||
contactId <- withStore $ \st -> getContactIdByName st userId cName
|
||||
processChatCommand user $ APIDeleteChat CTDirect contactId
|
||||
ListContacts -> CRContactsList <$> withStore (`getUserContacts` user)
|
||||
CreateMyAddress -> procCmd $ do
|
||||
processChatCommand $ APIDeleteChat CTDirect contactId
|
||||
ListContacts -> withUser $ \user -> CRContactsList <$> withStore (`getUserContacts` user)
|
||||
CreateMyAddress -> withUser $ \User {userId} -> withChatLock . procCmd $ do
|
||||
(connId, cReq) <- withAgent (`createConnection` SCMContact)
|
||||
withStore $ \st -> createUserContactLink st userId connId cReq
|
||||
pure $ CRUserContactLinkCreated cReq
|
||||
DeleteMyAddress -> do
|
||||
DeleteMyAddress -> withUser $ \User {userId} -> withChatLock $ do
|
||||
conns <- withStore $ \st -> getUserContactLinkConnections st userId
|
||||
procCmd $ do
|
||||
withAgent $ \a -> forM_ conns $ \conn ->
|
||||
deleteConnection a (aConnId conn) `catchError` \(_ :: AgentErrorType) -> pure ()
|
||||
withStore $ \st -> deleteUserContactLink st userId
|
||||
pure CRUserContactLinkDeleted
|
||||
ShowMyAddress -> CRUserContactLink <$> withStore (`getUserContactLink` userId)
|
||||
AcceptContact cName -> do
|
||||
ShowMyAddress -> withUser $ \User {userId} ->
|
||||
uncurry CRUserContactLink <$> withStore (`getUserContactLink` userId)
|
||||
AddressAutoAccept onOff -> withUser $ \User {userId} -> do
|
||||
uncurry CRUserContactLinkUpdated <$> withStore (\st -> updateUserContactLinkAutoAccept st userId onOff)
|
||||
AcceptContact cName -> withUser $ \User {userId} -> do
|
||||
connReqId <- withStore $ \st -> getContactRequestIdByName st userId cName
|
||||
processChatCommand user $ APIAcceptContact connReqId
|
||||
RejectContact cName -> do
|
||||
processChatCommand $ APIAcceptContact connReqId
|
||||
RejectContact cName -> withUser $ \User {userId} -> do
|
||||
connReqId <- withStore $ \st -> getContactRequestIdByName st userId cName
|
||||
processChatCommand user $ APIRejectContact connReqId
|
||||
SendMessage cName msg -> do
|
||||
processChatCommand $ APIRejectContact connReqId
|
||||
SendMessage cName msg -> withUser $ \User {userId} -> do
|
||||
contactId <- withStore $ \st -> getContactIdByName st userId cName
|
||||
let mc = MCText $ safeDecodeUtf8 msg
|
||||
processChatCommand user $ APISendMessage CTDirect contactId mc
|
||||
NewGroup gProfile -> do
|
||||
processChatCommand $ APISendMessage CTDirect contactId mc
|
||||
NewGroup gProfile -> withUser $ \user -> do
|
||||
gVar <- asks idsDrg
|
||||
CRGroupCreated <$> withStore (\st -> createNewGroup st gVar user gProfile)
|
||||
AddMember gName cName memRole -> do
|
||||
AddMember gName cName memRole -> withUser $ \user@User {userId} -> withChatLock $ do
|
||||
-- TODO for large groups: no need to load all members to determine if contact is a member
|
||||
(group, contact) <- withStore $ \st -> (,) <$> getGroupByName st user gName <*> getContactByName st userId cName
|
||||
let Group gInfo@GroupInfo {groupId, groupProfile, membership} members = group
|
||||
@@ -226,7 +245,7 @@ processChatCommand user@User {userId, profile} = \case
|
||||
when (memberStatus membership == GSMemInvited) $ throwChatError (CEGroupNotJoined gInfo)
|
||||
unless (memberActive membership) $ throwChatError CEGroupMemberNotActive
|
||||
let sendInvitation memberId cReq = do
|
||||
void . sendDirectMessage (contactConn contact) $
|
||||
void . sendDirectContactMessage contact $
|
||||
XGrpInv $ GroupInvitation (MemberIdRole userMemberId userRole) (MemberIdRole memberId memRole) cReq groupProfile
|
||||
setActive $ ActiveG gName
|
||||
pure $ CRSentGroupInvitation gInfo contact
|
||||
@@ -242,9 +261,9 @@ processChatCommand user@User {userId, profile} = \case
|
||||
Just cReq -> sendInvitation memberId cReq
|
||||
Nothing -> throwChatError $ CEGroupCantResendInvitation gInfo cName
|
||||
| otherwise -> throwChatError $ CEGroupDuplicateMember cName
|
||||
JoinGroup gName -> do
|
||||
JoinGroup gName -> withUser $ \user@User {userId} -> do
|
||||
ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g} <- withStore $ \st -> getGroupInvitation st user gName
|
||||
procCmd $ do
|
||||
withChatLock . procCmd $ do
|
||||
agentConnId <- withAgent $ \a -> joinConnection a connRequest . directMessage . XGrpAcpt $ memberId (membership g :: GroupMember)
|
||||
withStore $ \st -> do
|
||||
createMemberConnection st userId fromMember agentConnId
|
||||
@@ -252,44 +271,44 @@ processChatCommand user@User {userId, profile} = \case
|
||||
updateGroupMemberStatus st userId (membership g) GSMemAccepted
|
||||
pure $ CRUserAcceptedGroupSent g
|
||||
MemberRole _gName _cName _mRole -> throwChatError $ CECommandError "unsupported"
|
||||
RemoveMember gName cName -> do
|
||||
RemoveMember gName cName -> withUser $ \user@User {userId} -> do
|
||||
Group gInfo@GroupInfo {membership} members <- withStore $ \st -> getGroupByName st user gName
|
||||
case find ((== cName) . (localDisplayName :: GroupMember -> ContactName)) members of
|
||||
Nothing -> throwChatError $ CEGroupMemberNotFound cName
|
||||
Just m@GroupMember {memberId = mId, memberRole = mRole, memberStatus = mStatus} -> do
|
||||
let userRole = memberRole (membership :: GroupMember)
|
||||
when (userRole < GRAdmin || userRole < mRole) $ throwChatError CEGroupUserRole
|
||||
procCmd $ do
|
||||
withChatLock . procCmd $ do
|
||||
when (mStatus /= GSMemInvited) . void . sendGroupMessage members $ XGrpMemDel mId
|
||||
deleteMemberConnection m
|
||||
withStore $ \st -> updateGroupMemberStatus st userId m GSMemRemoved
|
||||
pure $ CRUserDeletedMember gInfo m
|
||||
LeaveGroup gName -> do
|
||||
LeaveGroup gName -> withUser $ \user@User {userId} -> do
|
||||
Group gInfo@GroupInfo {membership} members <- withStore $ \st -> getGroupByName st user gName
|
||||
procCmd $ do
|
||||
withChatLock . procCmd $ do
|
||||
void $ sendGroupMessage members XGrpLeave
|
||||
mapM_ deleteMemberConnection members
|
||||
withStore $ \st -> updateGroupMemberStatus st userId membership GSMemLeft
|
||||
pure $ CRLeftMemberUser gInfo
|
||||
DeleteGroup gName -> do
|
||||
DeleteGroup gName -> withUser $ \user -> do
|
||||
g@(Group gInfo@GroupInfo {membership} members) <- withStore $ \st -> getGroupByName st user gName
|
||||
let s = memberStatus membership
|
||||
canDelete =
|
||||
memberRole (membership :: GroupMember) == GROwner
|
||||
|| (s == GSMemRemoved || s == GSMemLeft || s == GSMemGroupDeleted || s == GSMemInvited)
|
||||
unless canDelete $ throwChatError CEGroupUserRole
|
||||
procCmd $ do
|
||||
withChatLock . procCmd $ do
|
||||
when (memberActive membership) . void $ sendGroupMessage members XGrpDel
|
||||
mapM_ deleteMemberConnection members
|
||||
withStore $ \st -> deleteGroup st user g
|
||||
pure $ CRGroupDeletedUser gInfo
|
||||
ListMembers gName -> CRGroupMembers <$> withStore (\st -> getGroupByName st user gName)
|
||||
ListGroups -> CRGroupsList <$> withStore (`getUserGroupDetails` user)
|
||||
SendGroupMessage gName msg -> do
|
||||
ListMembers gName -> CRGroupMembers <$> withUser (\user -> withStore (\st -> getGroupByName st user gName))
|
||||
ListGroups -> CRGroupsList <$> withUser (\user -> withStore (`getUserGroupDetails` user))
|
||||
SendGroupMessage gName msg -> withUser $ \user -> do
|
||||
groupId <- withStore $ \st -> getGroupIdByName st user gName
|
||||
let mc = MCText $ safeDecodeUtf8 msg
|
||||
processChatCommand user $ APISendMessage CTGroup groupId mc
|
||||
SendFile cName f -> do
|
||||
processChatCommand $ APISendMessage CTGroup groupId mc
|
||||
SendFile cName f -> withUser $ \User {userId} -> withChatLock $ do
|
||||
(fileSize, chSize) <- checkSndFile f
|
||||
contact <- withStore $ \st -> getContactByName st userId cName
|
||||
(agentConnId, fileConnReq) <- withAgent (`createConnection` SCMInvitation)
|
||||
@@ -297,10 +316,10 @@ processChatCommand user@User {userId, profile} = \case
|
||||
SndFileTransfer {fileId} <- withStore $ \st ->
|
||||
createSndFileTransfer st userId contact f fileInv agentConnId chSize
|
||||
ci <- sendDirectChatItem userId contact (XFile fileInv) (CISndFileInvitation fileId f)
|
||||
withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId ci
|
||||
withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId' ci
|
||||
setActive $ ActiveC cName
|
||||
pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat contact) ci
|
||||
SendGroupFile gName f -> do
|
||||
SendGroupFile gName f -> withUser $ \user@User {userId} -> withChatLock $ do
|
||||
(fileSize, chSize) <- checkSndFile f
|
||||
Group gInfo@GroupInfo {membership} members <- withStore $ \st -> getGroupByName st user gName
|
||||
unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved
|
||||
@@ -320,10 +339,10 @@ processChatCommand user@User {userId, profile} = \case
|
||||
ciMeta@CIMeta {itemId} <- saveChatItem userId (CDGroupSnd gInfo) ci
|
||||
withStore $ \st -> updateFileTransferChatItemId st fileId itemId
|
||||
pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) $ ChatItem CIGroupSnd ciMeta ciContent
|
||||
ReceiveFile fileId filePath_ -> do
|
||||
ReceiveFile fileId filePath_ -> withUser $ \User {userId} -> do
|
||||
ft@RcvFileTransfer {fileInvitation = FileInvitation {fileName, fileConnReq}, fileStatus} <- withStore $ \st -> getRcvFileTransfer st userId fileId
|
||||
unless (fileStatus == RFSNew) . throwChatError $ CEFileAlreadyReceiving fileName
|
||||
procCmd $ do
|
||||
withChatLock . procCmd $ do
|
||||
tryError (withAgent $ \a -> joinConnection a fileConnReq . directMessage $ XFileAcpt fileName) >>= \case
|
||||
Right agentConnId -> do
|
||||
filePath <- getRcvFilePath fileId filePath_ fileName
|
||||
@@ -332,9 +351,9 @@ processChatCommand user@User {userId, profile} = \case
|
||||
Left (ChatErrorAgent (SMP SMP.AUTH)) -> pure $ CRRcvFileAcceptedSndCancelled ft
|
||||
Left (ChatErrorAgent (CONN DUPLICATE)) -> pure $ CRRcvFileAcceptedSndCancelled ft
|
||||
Left e -> throwError e
|
||||
CancelFile fileId -> do
|
||||
CancelFile fileId -> withUser $ \User {userId} -> do
|
||||
ft' <- withStore (\st -> getFileTransfer st userId fileId)
|
||||
procCmd $ case ft' of
|
||||
withChatLock . procCmd $ case ft' of
|
||||
FTSnd fts -> do
|
||||
forM_ fts $ \ft -> cancelSndFileTransfer ft
|
||||
pure $ CRSndGroupFileCancelled fts
|
||||
@@ -342,25 +361,29 @@ processChatCommand user@User {userId, profile} = \case
|
||||
cancelRcvFileTransfer ft
|
||||
pure $ CRRcvFileCancelled ft
|
||||
FileStatus fileId ->
|
||||
CRFileTransferStatus <$> withStore (\st -> getFileTransferProgress st userId fileId)
|
||||
ShowProfile -> pure $ CRUserProfile profile
|
||||
UpdateProfile p@Profile {displayName}
|
||||
| p == profile -> pure CRUserProfileNoChange
|
||||
| otherwise -> do
|
||||
withStore $ \st -> updateUserProfile st user p
|
||||
let user' = (user :: User) {localDisplayName = displayName, profile = p}
|
||||
asks currentUser >>= atomically . (`writeTVar` user')
|
||||
contacts <- withStore (`getUserContacts` user)
|
||||
procCmd $ do
|
||||
forM_ contacts $ \ct -> sendDirectMessage (contactConn ct) $ XInfo p
|
||||
pure $ CRUserProfileUpdated profile p
|
||||
CRFileTransferStatus <$> withUser (\User {userId} -> withStore $ \st -> getFileTransferProgress st userId fileId)
|
||||
ShowProfile -> withUser $ \User {profile} -> pure $ CRUserProfile profile
|
||||
UpdateProfile p@Profile {displayName} -> withUser $ \user@User {profile} ->
|
||||
if p == profile
|
||||
then pure CRUserProfileNoChange
|
||||
else do
|
||||
withStore $ \st -> updateUserProfile st user p
|
||||
let user' = (user :: User) {localDisplayName = displayName, profile = p}
|
||||
asks currentUser >>= atomically . (`writeTVar` Just user')
|
||||
contacts <- withStore (`getUserContacts` user)
|
||||
withChatLock . procCmd $ do
|
||||
forM_ contacts $ \ct -> sendDirectContactMessage ct $ XInfo p
|
||||
pure $ CRUserProfileUpdated profile p
|
||||
QuitChat -> liftIO exitSuccess
|
||||
ShowVersion -> pure CRVersionInfo
|
||||
ShowVersion -> pure $ CRVersionInfo versionNumber
|
||||
where
|
||||
withChatLock action = do
|
||||
ChatController {chatLock = l, smpAgent = a} <- ask
|
||||
withAgentLock a . withLock l $ action
|
||||
-- below code would make command responses asynchronous where they can be slow
|
||||
-- in View.hs `r'` should be defined as `id` in this case
|
||||
procCmd :: m ChatResponse -> m ChatResponse
|
||||
procCmd action = do
|
||||
-- below code would make command responses asynchronous where they can be slow
|
||||
-- in View.hs `r'` should be defined as `id` in this case
|
||||
ChatController {chatLock = l, smpAgent = a, outputQ = q, idsDrg = gVar} <- ask
|
||||
corrId <- liftIO $ SMP.CorrId <$> randomBytes gVar 8
|
||||
void . forkIO $
|
||||
@@ -369,11 +392,18 @@ processChatCommand user@User {userId, profile} = \case
|
||||
pure $ CRCmdAccepted corrId
|
||||
-- use function below to make commands "synchronous"
|
||||
-- procCmd :: m ChatResponse -> m ChatResponse
|
||||
-- procCmd action = action
|
||||
connect :: ConnectionRequestUri c -> ChatMsgEvent -> m ()
|
||||
connect cReq msg = do
|
||||
connId <- withAgent $ \a -> joinConnection a cReq $ directMessage msg
|
||||
withStore $ \st -> createDirectConnection st userId connId
|
||||
-- procCmd = id
|
||||
connectViaContact :: UserId -> ConnectionRequestUri 'CMContact -> Profile -> m ChatResponse
|
||||
connectViaContact userId cReq profile = withChatLock $ do
|
||||
let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq
|
||||
withStore (\st -> getConnReqContactXContactId st userId cReqHash) >>= \case
|
||||
(Just contact, _) -> pure $ CRContactAlreadyExists contact
|
||||
(_, xContactId_) -> procCmd $ do
|
||||
let randomXContactId = XContactId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16))
|
||||
xContactId <- maybe randomXContactId pure xContactId_
|
||||
connId <- withAgent $ \a -> joinConnection a cReq $ directMessage (XContact profile $ Just xContactId)
|
||||
withStore $ \st -> createConnReqConnection st userId connId cReqHash xContactId
|
||||
pure CRSentInvitation
|
||||
contactMember :: Contact -> [GroupMember] -> Maybe GroupMember
|
||||
contactMember Contact {contactId} =
|
||||
find $ \GroupMember {memberContactId = cId, memberStatus = s} ->
|
||||
@@ -414,31 +444,35 @@ processChatCommand user@User {userId, profile} = \case
|
||||
f = filePath `combine` (name <> suffix <> ext)
|
||||
in ifM (doesFileExist f) (tryCombine $ n + 1) (pure f)
|
||||
|
||||
agentSubscriber :: (MonadUnliftIO m, MonadReader ChatController m) => m ()
|
||||
agentSubscriber = do
|
||||
acceptContactRequest :: ChatMonad m => User -> UserContactRequest -> m Contact
|
||||
acceptContactRequest User {userId, profile} UserContactRequest {agentInvitationId = AgentInvId invId, localDisplayName = cName, profileId, profile = p, xContactId} = do
|
||||
connId <- withAgent $ \a -> acceptContact a invId . directMessage $ XInfo profile
|
||||
withStore $ \st -> createAcceptedContact st userId connId cName profileId p xContactId
|
||||
|
||||
agentSubscriber :: (MonadUnliftIO m, MonadReader ChatController m) => User -> m ()
|
||||
agentSubscriber user = do
|
||||
q <- asks $ subQ . smpAgent
|
||||
l <- asks chatLock
|
||||
subscribeUserConnections
|
||||
subscribeUserConnections user
|
||||
forever $ do
|
||||
(_, connId, msg) <- atomically $ readTBQueue q
|
||||
user <- readTVarIO =<< asks currentUser
|
||||
u <- readTVarIO =<< asks currentUser
|
||||
withLock l . void . runExceptT $
|
||||
processAgentMessage user connId msg `catchError` (toView . CRChatError)
|
||||
processAgentMessage u connId msg `catchError` (toView . CRChatError)
|
||||
|
||||
subscribeUserConnections :: forall m. (MonadUnliftIO m, MonadReader ChatController m) => m ()
|
||||
subscribeUserConnections = void . runExceptT $ do
|
||||
user <- readTVarIO =<< asks currentUser
|
||||
subscribeContacts user
|
||||
subscribeGroups user
|
||||
subscribeFiles user
|
||||
subscribePendingConnections user
|
||||
subscribeUserContactLink user
|
||||
subscribeUserConnections :: (MonadUnliftIO m, MonadReader ChatController m) => User -> m ()
|
||||
subscribeUserConnections user@User {userId} = void . runExceptT $ do
|
||||
subscribeContacts
|
||||
subscribeGroups
|
||||
subscribeFiles
|
||||
subscribePendingConnections
|
||||
subscribeUserContactLink
|
||||
where
|
||||
subscribeContacts user = do
|
||||
subscribeContacts = do
|
||||
contacts <- withStore (`getUserContacts` user)
|
||||
forM_ contacts $ \ct ->
|
||||
(subscribe (contactConnId ct) >> toView (CRContactSubscribed ct)) `catchError` (toView . CRContactSubError ct)
|
||||
subscribeGroups user = do
|
||||
subscribeGroups = do
|
||||
groups <- withStore (`getUserGroups` user)
|
||||
forM_ groups $ \(Group g@GroupInfo {membership} members) -> do
|
||||
let connectedMembers = mapMaybe (\m -> (m,) <$> memberConnId m) members
|
||||
@@ -454,7 +488,7 @@ subscribeUserConnections = void . runExceptT $ do
|
||||
forM_ connectedMembers $ \(GroupMember {localDisplayName = c}, cId) ->
|
||||
subscribe cId `catchError` (toView . CRMemberSubError g c)
|
||||
toView $ CRGroupSubscribed g
|
||||
subscribeFiles user = do
|
||||
subscribeFiles = do
|
||||
withStore (`getLiveSndFileTransfers` user) >>= mapM_ subscribeSndFile
|
||||
withStore (`getLiveRcvFileTransfers` user) >>= mapM_ subscribeRcvFile
|
||||
where
|
||||
@@ -475,10 +509,10 @@ subscribeUserConnections = void . runExceptT $ do
|
||||
where
|
||||
resume RcvFileInfo {agentConnId = AgentConnId cId} =
|
||||
subscribe cId `catchError` (toView . CRRcvFileSubError ft)
|
||||
subscribePendingConnections user = do
|
||||
subscribePendingConnections = do
|
||||
cs <- withStore (`getPendingConnections` user)
|
||||
subscribeConns cs `catchError` \_ -> pure ()
|
||||
subscribeUserContactLink User {userId} = do
|
||||
subscribeUserContactLink = do
|
||||
cs <- withStore (`getUserContactLinkConnections` userId)
|
||||
(subscribeConns cs >> toView CRUserContactLinkSubscribed)
|
||||
`catchError` (toView . CRUserContactLinkSubError)
|
||||
@@ -487,8 +521,9 @@ subscribeUserConnections = void . runExceptT $ do
|
||||
withAgent $ \a ->
|
||||
forM_ conns $ subscribeConnection a . aConnId
|
||||
|
||||
processAgentMessage :: forall m. ChatMonad m => User -> ConnId -> ACommand 'Agent -> m ()
|
||||
processAgentMessage user@User {userId, profile} agentConnId agentMessage =
|
||||
processAgentMessage :: forall m. ChatMonad m => Maybe User -> ConnId -> ACommand 'Agent -> m ()
|
||||
processAgentMessage Nothing _ _ = throwChatError CENoActiveUser
|
||||
processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage =
|
||||
(withStore (\st -> getConnectionEntity st user agentConnId) >>= updateConnStatus) >>= \case
|
||||
RcvDirectMsgConnection conn contact_ ->
|
||||
processDirectMessage agentMessage conn contact_
|
||||
@@ -513,12 +548,6 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage =
|
||||
isMember memId GroupInfo {membership} members =
|
||||
sameMemberId memId membership || isJust (find (sameMemberId memId) members)
|
||||
|
||||
contactIsReady :: Contact -> Bool
|
||||
contactIsReady Contact {activeConn} = connStatus activeConn == ConnReady
|
||||
|
||||
memberIsReady :: GroupMember -> Bool
|
||||
memberIsReady GroupMember {activeConn} = maybe False ((== ConnReady) . connStatus) activeConn
|
||||
|
||||
agentMsgConnStatus :: ACommand 'Agent -> Maybe ConnStatus
|
||||
agentMsgConnStatus = \case
|
||||
CONF {} -> Just ConnRequested
|
||||
@@ -527,7 +556,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage =
|
||||
_ -> Nothing
|
||||
|
||||
processDirectMessage :: ACommand 'Agent -> Connection -> Maybe Contact -> m ()
|
||||
processDirectMessage agentMsg conn = \case
|
||||
processDirectMessage agentMsg conn@Connection {connId} = \case
|
||||
Nothing -> case agentMsg of
|
||||
CONF confId connInfo -> do
|
||||
saveConnInfo conn connInfo
|
||||
@@ -539,9 +568,10 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage =
|
||||
withAckMessage agentConnId meta $ pure ()
|
||||
ackMsgDeliveryEvent conn meta
|
||||
SENT msgId ->
|
||||
-- ? updateDirectChatItem
|
||||
sentMsgDeliveryEvent conn msgId
|
||||
-- TODO print errors
|
||||
MERR _ _ -> pure ()
|
||||
MERR _ _ -> pure () -- ? updateDirectChatItem
|
||||
ERR _ -> pure ()
|
||||
-- TODO add debugging output
|
||||
_ -> pure ()
|
||||
@@ -586,12 +616,18 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage =
|
||||
toView $ CRContactConnected ct
|
||||
setActive $ ActiveC c
|
||||
showToast (c <> "> ") "connected"
|
||||
Just (gInfo, m) -> do
|
||||
when (memberIsReady m) $ do
|
||||
Just (gInfo, m@GroupMember {activeConn}) -> do
|
||||
when (maybe False ((== ConnReady) . connStatus) activeConn) $ do
|
||||
notifyMemberConnected gInfo m
|
||||
when (memberCategory m == GCPreMember) $ probeMatchingContacts ct
|
||||
SENT msgId ->
|
||||
SENT msgId -> do
|
||||
sentMsgDeliveryEvent conn msgId
|
||||
chatItemId_ <- withStore $ \st -> getChatItemIdByAgentMsgId st connId msgId
|
||||
case chatItemId_ of
|
||||
Nothing -> pure ()
|
||||
Just chatItemId -> do
|
||||
chatItem <- withStore $ \st -> updateDirectChatItem st chatItemId CISSndSent
|
||||
toView $ CRChatItemUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem)
|
||||
END -> do
|
||||
toView $ CRContactAnotherClient ct
|
||||
showToast (c <> "> ") "connected to another client"
|
||||
@@ -604,7 +640,13 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage =
|
||||
showToast (c <> "> ") "is active"
|
||||
setActive $ ActiveC c
|
||||
-- TODO print errors
|
||||
MERR _ _ -> pure ()
|
||||
MERR msgId err -> do
|
||||
chatItemId_ <- withStore $ \st -> getChatItemIdByAgentMsgId st connId msgId
|
||||
case chatItemId_ of
|
||||
Nothing -> pure ()
|
||||
Just chatItemId -> do
|
||||
chatItem <- withStore $ \st -> updateDirectChatItem st chatItemId (agentErrToItemStatus err)
|
||||
toView $ CRChatItemUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem)
|
||||
ERR _ -> pure ()
|
||||
-- TODO add debugging output
|
||||
_ -> pure ()
|
||||
@@ -669,8 +711,8 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage =
|
||||
Nothing -> do
|
||||
notifyMemberConnected gInfo m
|
||||
messageError "implementation error: connected member does not have contact"
|
||||
Just ct ->
|
||||
when (contactIsReady ct) $ do
|
||||
Just ct@Contact {activeConn = Connection {connStatus}} ->
|
||||
when (connStatus == ConnReady) $ do
|
||||
notifyMemberConnected gInfo m
|
||||
when (memberCategory m == GCPreMember) $ probeMatchingContacts ct
|
||||
MSG msgMeta msgBody -> do
|
||||
@@ -774,8 +816,8 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage =
|
||||
REQ invId connInfo -> do
|
||||
ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage connInfo
|
||||
case chatMsgEvent of
|
||||
XContact p _ -> profileContactRequest invId p
|
||||
XInfo p -> profileContactRequest invId p
|
||||
XContact p xContactId_ -> profileContactRequest invId p xContactId_
|
||||
XInfo p -> profileContactRequest invId p Nothing
|
||||
-- TODO show/log error, other events in contact request
|
||||
_ -> pure ()
|
||||
-- TODO print errors
|
||||
@@ -784,11 +826,17 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage =
|
||||
-- TODO add debugging output
|
||||
_ -> pure ()
|
||||
where
|
||||
profileContactRequest :: InvitationId -> Profile -> m ()
|
||||
profileContactRequest invId p = do
|
||||
cReq@UserContactRequest {localDisplayName} <- withStore $ \st -> createContactRequest st userId userContactLinkId invId p
|
||||
toView $ CRReceivedContactRequest cReq
|
||||
showToast (localDisplayName <> "> ") "wants to connect to you"
|
||||
profileContactRequest :: InvitationId -> Profile -> Maybe XContactId -> m ()
|
||||
profileContactRequest invId p xContactId_ = do
|
||||
withStore (\st -> createOrUpdateContactRequest st userId userContactLinkId invId p xContactId_) >>= \case
|
||||
Left contact -> toView $ CRContactRequestAlreadyAccepted contact
|
||||
Right cReq@UserContactRequest {localDisplayName} -> do
|
||||
(_, autoAccept) <- withStore $ \st -> getUserContactLink st userId
|
||||
if autoAccept
|
||||
then acceptContactRequest user cReq >>= toView . CRAcceptingContactRequest
|
||||
else do
|
||||
toView $ CRReceivedContactRequest cReq
|
||||
showToast (localDisplayName <> "> ") "wants to connect to you"
|
||||
|
||||
withAckMessage :: ConnId -> MsgMeta -> m () -> m ()
|
||||
withAckMessage cId MsgMeta {recipient = (msgId, _)} action =
|
||||
@@ -802,6 +850,10 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage =
|
||||
sentMsgDeliveryEvent Connection {connId} msgId =
|
||||
withStore $ \st -> createSndMsgDeliveryEvent st connId msgId MDSSndSent
|
||||
|
||||
agentErrToItemStatus :: AgentErrorType -> CIStatus 'MDSnd
|
||||
agentErrToItemStatus (SMP AUTH) = CISSndErrorAuth
|
||||
agentErrToItemStatus err = CISSndError err
|
||||
|
||||
badRcvFileChunk :: RcvFileTransfer -> String -> m ()
|
||||
badRcvFileChunk ft@RcvFileTransfer {fileStatus} err =
|
||||
case fileStatus of
|
||||
@@ -821,14 +873,14 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage =
|
||||
probeMatchingContacts ct = do
|
||||
gVar <- asks idsDrg
|
||||
(probe, probeId) <- withStore $ \st -> createSentProbe st gVar userId ct
|
||||
void . sendDirectMessage (contactConn ct) $ XInfoProbe probe
|
||||
void . sendDirectContactMessage ct $ XInfoProbe probe
|
||||
cs <- withStore (\st -> getMatchingContacts st userId ct)
|
||||
let probeHash = ProbeHash $ C.sha256Hash (unProbe probe)
|
||||
forM_ cs $ \c -> sendProbeHash c probeHash probeId `catchError` const (pure ())
|
||||
where
|
||||
sendProbeHash :: Contact -> ProbeHash -> Int64 -> m ()
|
||||
sendProbeHash c probeHash probeId = do
|
||||
void . sendDirectMessage (contactConn c) $ XInfoProbeCheck probeHash
|
||||
void . sendDirectContactMessage c $ XInfoProbeCheck probeHash
|
||||
withStore $ \st -> createSentProbeHash st userId probeId c
|
||||
|
||||
messageWarning :: Text -> m ()
|
||||
@@ -860,7 +912,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage =
|
||||
chSize <- asks $ fileChunkSize . config
|
||||
ft@RcvFileTransfer {fileId} <- withStore $ \st -> createRcvFileTransfer st userId ct fInv chSize
|
||||
ci <- saveRcvDirectChatItem userId ct msgId msgMeta (CIRcvFileInvitation ft)
|
||||
withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId ci
|
||||
withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId' ci
|
||||
toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci
|
||||
checkIntegrity msgMeta $ toView . CRMsgIntegrityError
|
||||
showToast (c <> "> ") "wants to send a file"
|
||||
@@ -871,7 +923,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage =
|
||||
chSize <- asks $ fileChunkSize . config
|
||||
ft@RcvFileTransfer {fileId} <- withStore $ \st -> createRcvGroupFileTransfer st userId m fInv chSize
|
||||
ci <- saveRcvGroupChatItem userId gInfo m msgId msgMeta (CIRcvFileInvitation ft)
|
||||
withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId ci
|
||||
withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId' ci
|
||||
toView . CRNewChatItem $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci
|
||||
checkIntegrity msgMeta $ toView . CRMsgIntegrityError
|
||||
let g = groupName' gInfo
|
||||
@@ -909,7 +961,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage =
|
||||
probeMatch :: Contact -> Contact -> Probe -> m ()
|
||||
probeMatch c1@Contact {profile = p1} c2@Contact {profile = p2} probe =
|
||||
when (p1 == p2) $ do
|
||||
void . sendDirectMessage (contactConn c1) $ XInfoProbeOk probe
|
||||
void . sendDirectContactMessage c1 $ XInfoProbeOk probe
|
||||
mergeContacts c1 c2
|
||||
|
||||
xInfoProbeOk :: Contact -> Probe -> m ()
|
||||
@@ -926,8 +978,9 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage =
|
||||
saveConnInfo activeConn connInfo = do
|
||||
ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage connInfo
|
||||
case chatMsgEvent of
|
||||
XInfo p ->
|
||||
withStore $ \st -> createDirectContact st userId activeConn p
|
||||
XInfo p -> do
|
||||
ct <- withStore $ \st -> createDirectContact st userId activeConn p
|
||||
toView $ CRContactConnecting ct
|
||||
-- TODO show/log error, other events in SMP confirmation
|
||||
_ -> pure ()
|
||||
|
||||
@@ -1024,7 +1077,7 @@ processAgentMessage user@User {userId, profile} agentConnId agentMessage =
|
||||
toView $ CRGroupDeleted gInfo m
|
||||
|
||||
parseChatMessage :: ByteString -> Either ChatError ChatMessage
|
||||
parseChatMessage = first ChatErrorMessage . strDecode
|
||||
parseChatMessage = first (ChatError . CEInvalidChatMessage) . strDecode
|
||||
|
||||
sendFileChunk :: ChatMonad m => SndFileTransfer -> m ()
|
||||
sendFileChunk ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentConnId acId} =
|
||||
@@ -1138,10 +1191,16 @@ throwChatError = throwError . ChatError
|
||||
deleteMemberConnection :: ChatMonad m => GroupMember -> m ()
|
||||
deleteMemberConnection m@GroupMember {activeConn} = do
|
||||
-- User {userId} <- asks currentUser
|
||||
withAgent $ forM_ (memberConnId m) . suspendConnection
|
||||
withAgent (forM_ (memberConnId m) . deleteConnection) `catchError` const (pure ())
|
||||
-- withStore $ \st -> deleteGroupMemberConnection st userId m
|
||||
forM_ activeConn $ \conn -> withStore $ \st -> updateConnectionStatus st conn ConnDeleted
|
||||
|
||||
sendDirectContactMessage :: ChatMonad m => Contact -> ChatMsgEvent -> m MessageId
|
||||
sendDirectContactMessage ct@Contact {activeConn = conn@Connection {connStatus}} chatMsgEvent = do
|
||||
if connStatus == ConnReady || connStatus == ConnSndReady
|
||||
then sendDirectMessage conn chatMsgEvent
|
||||
else throwChatError $ CEContactNotReady ct
|
||||
|
||||
sendDirectMessage :: ChatMonad m => Connection -> ChatMsgEvent -> m MessageId
|
||||
sendDirectMessage conn chatMsgEvent = do
|
||||
(msgId, msgBody) <- createSndMessage chatMsgEvent
|
||||
@@ -1176,17 +1235,21 @@ sendXGrpMemInv reMember chatMsgEvent introId =
|
||||
sendGroupMessage' :: ChatMonad m => [GroupMember] -> ChatMsgEvent -> Maybe Int64 -> m () -> m MessageId
|
||||
sendGroupMessage' members chatMsgEvent introId_ postDeliver = do
|
||||
(msgId, msgBody) <- createSndMessage chatMsgEvent
|
||||
for_ (filter memberCurrent members) $ \m@GroupMember {groupMemberId} ->
|
||||
-- TODO collect failed deliveries into a single error
|
||||
forM_ (filter memberCurrent members) $ \m@GroupMember {groupMemberId} ->
|
||||
case memberConn m of
|
||||
Nothing -> withStore $ \st -> createPendingGroupMessage st groupMemberId msgId introId_
|
||||
Just conn -> deliverMessage conn msgBody msgId >> postDeliver
|
||||
Just conn@Connection {connStatus} ->
|
||||
if not (connStatus == ConnSndReady || connStatus == ConnReady)
|
||||
then unless (connStatus == ConnDeleted) $ withStore (\st -> createPendingGroupMessage st groupMemberId msgId introId_)
|
||||
else (deliverMessage conn msgBody msgId >> postDeliver) `catchError` const (pure ())
|
||||
pure msgId
|
||||
|
||||
sendPendingGroupMessages :: ChatMonad m => GroupMember -> Connection -> m ()
|
||||
sendPendingGroupMessages GroupMember {groupMemberId, localDisplayName} conn = do
|
||||
pendingMessages <- withStore $ \st -> getPendingGroupMessages st groupMemberId
|
||||
-- TODO ensure order - pending messages interleave with user input messages
|
||||
for_ pendingMessages $ \PendingGroupMessage {msgId, cmEventTag, msgBody, introId_} -> do
|
||||
forM_ pendingMessages $ \PendingGroupMessage {msgId, cmEventTag, msgBody, introId_} -> do
|
||||
deliverMessage conn msgBody msgId
|
||||
withStore (\st -> deletePendingGroupMessage st groupMemberId msgId)
|
||||
when (cmEventTag == XGrpMemFwd_) $ case introId_ of
|
||||
@@ -1204,8 +1267,8 @@ saveRcvMSG Connection {connId} agentMsgMeta msgBody = do
|
||||
pure (msgId, chatMsgEvent)
|
||||
|
||||
sendDirectChatItem :: ChatMonad m => UserId -> Contact -> ChatMsgEvent -> CIContent 'MDSnd -> m (ChatItem 'CTDirect 'MDSnd)
|
||||
sendDirectChatItem userId contact@Contact {activeConn} chatMsgEvent ciContent = do
|
||||
msgId <- sendDirectMessage activeConn chatMsgEvent
|
||||
sendDirectChatItem userId contact chatMsgEvent ciContent = do
|
||||
msgId <- sendDirectContactMessage contact chatMsgEvent
|
||||
createdAt <- liftIO getCurrentTime
|
||||
ciMeta <- saveChatItem userId (CDDirectSnd contact) $ mkNewChatItem ciContent msgId createdAt createdAt
|
||||
pure $ ChatItem CIDirectSnd ciMeta ciContent
|
||||
@@ -1229,11 +1292,11 @@ saveRcvGroupChatItem userId g m msgId MsgMeta {broker = (_, brokerTs)} ciContent
|
||||
ciMeta <- saveChatItem userId (CDGroupRcv g m) $ mkNewChatItem ciContent msgId brokerTs createdAt
|
||||
pure $ ChatItem (CIGroupRcv m) ciMeta ciContent
|
||||
|
||||
saveChatItem :: ChatMonad m => UserId -> ChatDirection c d -> NewChatItem d -> m CIMeta
|
||||
saveChatItem :: (ChatMonad m, MsgDirectionI d) => UserId -> ChatDirection c d -> NewChatItem d -> m (CIMeta d)
|
||||
saveChatItem userId cd ci@NewChatItem {itemTs, itemText, createdAt} = do
|
||||
tz <- liftIO getCurrentTimeZone
|
||||
ciId <- withStore $ \st -> createNewChatItem st userId cd ci
|
||||
pure $ mkCIMeta ciId itemText tz itemTs createdAt
|
||||
pure $ mkCIMeta ciId itemText ciStatusNew tz itemTs createdAt
|
||||
|
||||
mkNewChatItem :: forall d. MsgDirectionI d => CIContent d -> MessageId -> UTCTime -> UTCTime -> NewChatItem d
|
||||
mkNewChatItem itemContent msgId itemTs createdAt =
|
||||
@@ -1243,6 +1306,7 @@ mkNewChatItem itemContent msgId itemTs createdAt =
|
||||
itemTs,
|
||||
itemContent,
|
||||
itemText = ciContentToText itemContent,
|
||||
itemStatus = ciStatusNew,
|
||||
createdAt
|
||||
}
|
||||
|
||||
@@ -1317,6 +1381,18 @@ notificationSubscriber = do
|
||||
ChatController {notifyQ, sendNotification} <- ask
|
||||
forever $ atomically (readTBQueue notifyQ) >>= liftIO . sendNotification
|
||||
|
||||
withUser' :: ChatMonad m => (User -> m a) -> m a
|
||||
withUser' action =
|
||||
asks currentUser
|
||||
>>= readTVarIO
|
||||
>>= maybe (throwChatError CENoActiveUser) action
|
||||
|
||||
withUser :: ChatMonad m => (User -> m a) -> m a
|
||||
withUser action = withUser' $ \user ->
|
||||
ifM chatStarted (action user) (throwChatError CEChatNotStarted)
|
||||
where
|
||||
chatStarted = fmap isJust . readTVarIO =<< asks agentAsync
|
||||
|
||||
withAgent :: ChatMonad m => (AgentClient -> ExceptT AgentErrorType m a) -> m a
|
||||
withAgent action =
|
||||
asks smpAgent
|
||||
@@ -1334,10 +1410,14 @@ withStore action =
|
||||
|
||||
chatCommandP :: Parser ChatCommand
|
||||
chatCommandP =
|
||||
"/_get chats" $> APIGetChats
|
||||
("/user " <|> "/u ") *> (CreateActiveUser <$> userProfile)
|
||||
<|> ("/user" <|> "/u") $> ShowActiveUser
|
||||
<|> "/_start" $> StartChat
|
||||
<|> "/_get chats" $> APIGetChats
|
||||
<|> "/_get chat " *> (APIGetChat <$> chatTypeP <*> A.decimal <* A.space <*> chatPaginationP)
|
||||
<|> "/_get items count=" *> (APIGetChatItems <$> A.decimal)
|
||||
<|> "/_send " *> (APISendMessage <$> chatTypeP <*> A.decimal <* A.space <*> msgContentP)
|
||||
<|> "/_read chat " *> (APIChatRead <$> chatTypeP <*> A.decimal <* A.space <*> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal)))
|
||||
<|> "/_delete " *> (APIDeleteChat <$> chatTypeP <*> A.decimal)
|
||||
<|> "/_accept " *> (APIAcceptContact <$> A.decimal)
|
||||
<|> "/_reject " *> (APIRejectContact <$> A.decimal)
|
||||
@@ -1368,6 +1448,7 @@ chatCommandP =
|
||||
<|> ("/address" <|> "/ad") $> CreateMyAddress
|
||||
<|> ("/delete_address" <|> "/da") $> DeleteMyAddress
|
||||
<|> ("/show_address" <|> "/sa") $> ShowMyAddress
|
||||
<|> "/auto_accept " *> (AddressAutoAccept <$> onOffP)
|
||||
<|> ("/accept @" <|> "/accept " <|> "/ac @" <|> "/ac ") *> (AcceptContact <$> displayName)
|
||||
<|> ("/reject @" <|> "/reject " <|> "/rc @" <|> "/rc ") *> (RejectContact <$> displayName)
|
||||
<|> ("/markdown" <|> "/m") $> ChatHelp HSMarkdown
|
||||
@@ -1385,6 +1466,7 @@ chatCommandP =
|
||||
msgContentP = "text " *> (MCText . safeDecodeUtf8 <$> A.takeByteString)
|
||||
displayName = safeDecodeUtf8 <$> (B.cons <$> A.satisfy refChar <*> A.takeTill (== ' '))
|
||||
refChar c = c > ' ' && c /= '#' && c /= '@'
|
||||
onOffP = ("on" $> True) <|> ("off" $> False)
|
||||
userProfile = do
|
||||
cName <- displayName
|
||||
fullName <- fullNameP cName
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
module Simplex.Chat.Controller where
|
||||
|
||||
import Control.Concurrent.Async (Async)
|
||||
import Control.Exception
|
||||
import Control.Monad.Except
|
||||
import Control.Monad.IO.Unlift
|
||||
@@ -35,7 +36,7 @@ import System.IO (Handle)
|
||||
import UnliftIO.STM
|
||||
|
||||
versionNumber :: String
|
||||
versionNumber = "1.1.0"
|
||||
versionNumber = "1.2.0"
|
||||
|
||||
versionStr :: String
|
||||
versionStr = "SimpleX Chat v" <> versionNumber
|
||||
@@ -46,18 +47,21 @@ updateStr = "To update run: curl -o- https://raw.githubusercontent.com/simplex-c
|
||||
data ChatConfig = ChatConfig
|
||||
{ agentConfig :: AgentConfig,
|
||||
dbPoolSize :: Int,
|
||||
yesToMigrations :: Bool,
|
||||
tbqSize :: Natural,
|
||||
fileChunkSize :: Integer
|
||||
fileChunkSize :: Integer,
|
||||
testView :: Bool
|
||||
}
|
||||
|
||||
data ActiveTo = ActiveNone | ActiveC ContactName | ActiveG GroupName
|
||||
deriving (Eq)
|
||||
|
||||
data ChatController = ChatController
|
||||
{ currentUser :: TVar User,
|
||||
{ currentUser :: TVar (Maybe User),
|
||||
activeTo :: TVar ActiveTo,
|
||||
firstTime :: Bool,
|
||||
smpAgent :: AgentClient,
|
||||
agentAsync :: TVar (Maybe (Async ())),
|
||||
chatStore :: SQLiteStore,
|
||||
idsDrg :: TVar ChaChaDRG,
|
||||
inputQ :: TBQueue String,
|
||||
@@ -78,10 +82,14 @@ instance ToJSON HelpSection where
|
||||
toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "HS"
|
||||
|
||||
data ChatCommand
|
||||
= APIGetChats
|
||||
= ShowActiveUser
|
||||
| CreateActiveUser Profile
|
||||
| StartChat
|
||||
| APIGetChats
|
||||
| APIGetChat ChatType Int64 ChatPagination
|
||||
| APIGetChatItems Int
|
||||
| APISendMessage ChatType Int64 MsgContent
|
||||
| APIChatRead ChatType Int64 (ChatItemId, ChatItemId)
|
||||
| APIDeleteChat ChatType Int64
|
||||
| APIAcceptContact Int64
|
||||
| APIRejectContact Int64
|
||||
@@ -95,6 +103,7 @@ data ChatCommand
|
||||
| CreateMyAddress
|
||||
| DeleteMyAddress
|
||||
| ShowMyAddress
|
||||
| AddressAutoAccept Bool
|
||||
| AcceptContact ContactName
|
||||
| RejectContact ContactName
|
||||
| SendMessage ContactName ByteString
|
||||
@@ -120,17 +129,22 @@ data ChatCommand
|
||||
deriving (Show)
|
||||
|
||||
data ChatResponse
|
||||
= CRApiChats {chats :: [AChat]}
|
||||
= CRActiveUser {user :: User}
|
||||
| CRChatStarted
|
||||
| CRApiChats {chats :: [AChat]}
|
||||
| CRApiChat {chat :: AChat}
|
||||
| CRNewChatItem {chatItem :: AChatItem}
|
||||
| CRChatItemUpdated {chatItem :: AChatItem}
|
||||
| CRMsgIntegrityError {msgerror :: MsgErrorType} -- TODO make it chat item to support in mobile
|
||||
| CRCmdAccepted {corr :: CorrId}
|
||||
| CRCmdOk
|
||||
| CRChatHelp {helpSection :: HelpSection}
|
||||
| CRWelcome {user :: User}
|
||||
| CRGroupCreated {groupInfo :: GroupInfo}
|
||||
| CRGroupMembers {group :: Group}
|
||||
| CRContactsList {contacts :: [Contact]}
|
||||
| CRUserContactLink {connReqContact :: ConnReqContact}
|
||||
| CRUserContactLink {connReqContact :: ConnReqContact, autoAccept :: Bool}
|
||||
| CRUserContactLinkUpdated {connReqContact :: ConnReqContact, autoAccept :: Bool}
|
||||
| CRContactRequestRejected {contactRequest :: UserContactRequest}
|
||||
| CRUserAcceptedGroupSent {groupInfo :: GroupInfo}
|
||||
| CRUserDeletedMember {groupInfo :: GroupInfo, member :: GroupMember}
|
||||
@@ -139,7 +153,7 @@ data ChatResponse
|
||||
| CRFileTransferStatus (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus
|
||||
| CRUserProfile {profile :: Profile}
|
||||
| CRUserProfileNoChange
|
||||
| CRVersionInfo
|
||||
| CRVersionInfo {version :: String}
|
||||
| CRInvitation {connReqInvitation :: ConnReqInvitation}
|
||||
| CRSentConfirmation
|
||||
| CRSentInvitation
|
||||
@@ -150,6 +164,8 @@ data ChatResponse
|
||||
| CRUserContactLinkDeleted
|
||||
| CRReceivedContactRequest {contactRequest :: UserContactRequest}
|
||||
| CRAcceptingContactRequest {contact :: Contact}
|
||||
| CRContactAlreadyExists {contact :: Contact}
|
||||
| CRContactRequestAlreadyAccepted {contact :: Contact}
|
||||
| CRLeftMemberUser {groupInfo :: GroupInfo}
|
||||
| CRGroupDeletedUser {groupInfo :: GroupInfo}
|
||||
| CRRcvFileAccepted {fileTransfer :: RcvFileTransfer, filePath :: FilePath}
|
||||
@@ -164,6 +180,7 @@ data ChatResponse
|
||||
| CRSndFileRcvCancelled {sndFileTransfer :: SndFileTransfer}
|
||||
| CRSndGroupFileCancelled {sndFileTransfers :: [SndFileTransfer]}
|
||||
| CRUserProfileUpdated {fromProfile :: Profile, toProfile :: Profile}
|
||||
| CRContactConnecting {contact :: Contact}
|
||||
| CRContactConnected {contact :: Contact}
|
||||
| CRContactAnotherClient {contact :: Contact}
|
||||
| CRContactDisconnected {contact :: Contact}
|
||||
@@ -198,10 +215,8 @@ instance ToJSON ChatResponse where
|
||||
|
||||
data ChatError
|
||||
= ChatError {errorType :: ChatErrorType}
|
||||
| ChatErrorMessage {errorMessage :: String}
|
||||
| ChatErrorAgent {agentError :: AgentErrorType}
|
||||
| ChatErrorStore {storeError :: StoreError}
|
||||
| ChatErrorNotImplemented
|
||||
deriving (Show, Exception, Generic)
|
||||
|
||||
instance ToJSON ChatError where
|
||||
@@ -209,9 +224,14 @@ instance ToJSON ChatError where
|
||||
toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "Chat"
|
||||
|
||||
data ChatErrorType
|
||||
= CEGroupUserRole
|
||||
= CENoActiveUser
|
||||
| CEActiveUserExists
|
||||
| CEChatNotStarted
|
||||
| CEInvalidConnReq
|
||||
| CEInvalidChatMessage {message :: String}
|
||||
| CEContactNotReady {contact :: Contact}
|
||||
| CEContactGroups {contact :: Contact, groupNames :: [GroupName]}
|
||||
| CEGroupUserRole
|
||||
| CEGroupContactRole {contactName :: ContactName}
|
||||
| CEGroupDuplicateMember {contactName :: ContactName}
|
||||
| CEGroupDuplicateMemberId
|
||||
@@ -240,6 +260,9 @@ instance ToJSON ChatErrorType where
|
||||
|
||||
type ChatMonad m = (MonadUnliftIO m, MonadReader ChatController m, MonadError ChatError m)
|
||||
|
||||
chatCmdError :: String -> ChatResponse
|
||||
chatCmdError = CRChatCmdError . ChatError . CECommandError
|
||||
|
||||
setActive :: (MonadUnliftIO m, MonadReader ChatController m) => ActiveTo -> m ()
|
||||
setActive to = asks activeTo >>= atomically . (`writeTVar` to)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ module Simplex.Chat.Messages where
|
||||
|
||||
import Data.Aeson (FromJSON, ToJSON)
|
||||
import qualified Data.Aeson as J
|
||||
import qualified Data.Attoparsec.ByteString.Char8 as A
|
||||
import qualified Data.ByteString.Base64 as B64
|
||||
import qualified Data.ByteString.Lazy.Char8 as LB
|
||||
import Data.Int (Int64)
|
||||
@@ -30,11 +31,13 @@ import Database.SQLite.Simple.ToField (ToField (..))
|
||||
import GHC.Generics (Generic)
|
||||
import Simplex.Chat.Protocol
|
||||
import Simplex.Chat.Types
|
||||
import Simplex.Messaging.Agent.Protocol (AgentMsgId, MsgMeta (..))
|
||||
import Simplex.Chat.Util (eitherToMaybe, safeDecodeUtf8)
|
||||
import Simplex.Messaging.Agent.Protocol (AgentErrorType, AgentMsgId, MsgMeta (..))
|
||||
import Simplex.Messaging.Agent.Store.SQLite (fromTextField_)
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Parsers (dropPrefix, enumJSON, sumTypeJSON)
|
||||
import Simplex.Messaging.Protocol (MsgBody)
|
||||
import Simplex.Messaging.Util ((<$?>))
|
||||
|
||||
data ChatType = CTDirect | CTGroup | CTContactRequest
|
||||
deriving (Show, Generic)
|
||||
@@ -72,7 +75,7 @@ jsonChatInfo = \case
|
||||
|
||||
data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem
|
||||
{ chatDir :: CIDirection c d,
|
||||
meta :: CIMeta,
|
||||
meta :: CIMeta d,
|
||||
content :: CIContent d
|
||||
}
|
||||
deriving (Show, Generic)
|
||||
@@ -114,7 +117,7 @@ jsonCIDirection = \case
|
||||
CIGroupSnd -> JCIGroupSnd
|
||||
CIGroupRcv m -> JCIGroupRcv m
|
||||
|
||||
data CChatItem c = forall d. CChatItem (SMsgDirection d) (ChatItem c d)
|
||||
data CChatItem c = forall d. MsgDirectionI d => CChatItem (SMsgDirection d) (ChatItem c d)
|
||||
|
||||
deriving instance Show (CChatItem c)
|
||||
|
||||
@@ -122,8 +125,8 @@ instance ToJSON (CChatItem c) where
|
||||
toJSON (CChatItem _ ci) = J.toJSON ci
|
||||
toEncoding (CChatItem _ ci) = J.toEncoding ci
|
||||
|
||||
chatItemId :: ChatItem c d -> ChatItemId
|
||||
chatItemId ChatItem {meta = CIMeta {itemId}} = itemId
|
||||
chatItemId' :: ChatItem c d -> ChatItemId
|
||||
chatItemId' ChatItem {meta = CIMeta {itemId}} = itemId
|
||||
|
||||
data ChatDirection (c :: ChatType) (d :: MsgDirection) where
|
||||
CDDirectSnd :: Contact -> ChatDirection 'CTDirect 'MDSnd
|
||||
@@ -137,12 +140,17 @@ data NewChatItem d = NewChatItem
|
||||
itemTs :: ChatItemTs,
|
||||
itemContent :: CIContent d,
|
||||
itemText :: Text,
|
||||
itemStatus :: CIStatus d,
|
||||
createdAt :: UTCTime
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
-- | type to show one chat with messages
|
||||
data Chat c = Chat {chatInfo :: ChatInfo c, chatItems :: [CChatItem c]}
|
||||
data Chat c = Chat
|
||||
{ chatInfo :: ChatInfo c,
|
||||
chatItems :: [CChatItem c],
|
||||
chatStats :: ChatStats
|
||||
}
|
||||
deriving (Show, Generic)
|
||||
|
||||
instance ToJSON (Chat c) where
|
||||
@@ -157,6 +165,16 @@ instance ToJSON AChat where
|
||||
toJSON (AChat _ c) = J.toJSON c
|
||||
toEncoding (AChat _ c) = J.toEncoding c
|
||||
|
||||
data ChatStats = ChatStats
|
||||
{ unreadCount :: Int,
|
||||
minUnreadItemId :: ChatItemId
|
||||
}
|
||||
deriving (Show, Generic)
|
||||
|
||||
instance ToJSON ChatStats where
|
||||
toJSON = J.genericToJSON J.defaultOptions
|
||||
toEncoding = J.genericToEncoding J.defaultOptions
|
||||
|
||||
-- | type to show a mix of messages from multiple chats
|
||||
data AChatItem = forall c d. AChatItem (SChatType c) (SMsgDirection d) (ChatInfo c) (ChatItem c d)
|
||||
|
||||
@@ -173,21 +191,91 @@ instance ToJSON (JSONAnyChatItem c d) where
|
||||
toJSON = J.genericToJSON J.defaultOptions
|
||||
toEncoding = J.genericToEncoding J.defaultOptions
|
||||
|
||||
data CIMeta = CIMeta
|
||||
data CIMeta (d :: MsgDirection) = CIMeta
|
||||
{ itemId :: ChatItemId,
|
||||
itemTs :: ChatItemTs,
|
||||
itemText :: Text,
|
||||
itemStatus :: CIStatus d,
|
||||
localItemTs :: ZonedTime,
|
||||
createdAt :: UTCTime
|
||||
}
|
||||
deriving (Show, Generic, FromJSON)
|
||||
deriving (Show, Generic)
|
||||
|
||||
mkCIMeta :: ChatItemId -> Text -> TimeZone -> ChatItemTs -> UTCTime -> CIMeta
|
||||
mkCIMeta itemId itemText tz itemTs createdAt =
|
||||
mkCIMeta :: ChatItemId -> Text -> CIStatus d -> TimeZone -> ChatItemTs -> UTCTime -> CIMeta d
|
||||
mkCIMeta itemId itemText itemStatus tz itemTs createdAt =
|
||||
let localItemTs = utcToZonedTime tz itemTs
|
||||
in CIMeta {itemId, itemTs, itemText, localItemTs, createdAt}
|
||||
in CIMeta {itemId, itemTs, itemText, itemStatus, localItemTs, createdAt}
|
||||
|
||||
instance ToJSON CIMeta where toEncoding = J.genericToEncoding J.defaultOptions
|
||||
instance ToJSON (CIMeta d) where toEncoding = J.genericToEncoding J.defaultOptions
|
||||
|
||||
data CIStatus (d :: MsgDirection) where
|
||||
CISSndNew :: CIStatus 'MDSnd
|
||||
CISSndSent :: CIStatus 'MDSnd
|
||||
CISSndErrorAuth :: CIStatus 'MDSnd
|
||||
CISSndError :: AgentErrorType -> CIStatus 'MDSnd
|
||||
CISRcvNew :: CIStatus 'MDRcv
|
||||
CISRcvRead :: CIStatus 'MDRcv
|
||||
|
||||
deriving instance Show (CIStatus d)
|
||||
|
||||
ciStatusNew :: forall d. MsgDirectionI d => CIStatus d
|
||||
ciStatusNew = case msgDirection @d of
|
||||
SMDSnd -> CISSndNew
|
||||
SMDRcv -> CISRcvNew
|
||||
|
||||
instance ToJSON (CIStatus d) where
|
||||
toJSON = J.toJSON . jsonCIStatus
|
||||
toEncoding = J.toEncoding . jsonCIStatus
|
||||
|
||||
instance MsgDirectionI d => ToField (CIStatus d) where toField = toField . decodeLatin1 . strEncode
|
||||
|
||||
instance FromField ACIStatus where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8
|
||||
|
||||
data ACIStatus = forall d. MsgDirectionI d => ACIStatus (SMsgDirection d) (CIStatus d)
|
||||
|
||||
instance MsgDirectionI d => StrEncoding (CIStatus d) where
|
||||
strEncode = \case
|
||||
CISSndNew -> "snd_new"
|
||||
CISSndSent -> "snd_sent"
|
||||
CISSndErrorAuth -> "snd_error_auth"
|
||||
CISSndError e -> "snd_error " <> strEncode e
|
||||
CISRcvNew -> "rcv_new"
|
||||
CISRcvRead -> "rcv_read"
|
||||
strP = (\(ACIStatus _ st) -> checkDirection st) <$?> strP
|
||||
|
||||
instance StrEncoding ACIStatus where
|
||||
strEncode (ACIStatus _ s) = strEncode s
|
||||
strP =
|
||||
A.takeTill (== ' ') >>= \case
|
||||
"snd_new" -> pure $ ACIStatus SMDSnd CISSndNew
|
||||
"snd_sent" -> pure $ ACIStatus SMDSnd CISSndSent
|
||||
"snd_error_auth" -> pure $ ACIStatus SMDSnd CISSndErrorAuth
|
||||
"snd_error" -> ACIStatus SMDSnd <$> (A.space *> strP)
|
||||
"rcv_new" -> pure $ ACIStatus SMDRcv CISRcvNew
|
||||
"rcv_read" -> pure $ ACIStatus SMDRcv CISRcvRead
|
||||
_ -> fail "bad status"
|
||||
|
||||
data JSONCIStatus
|
||||
= JCISSndNew
|
||||
| JCISSndSent
|
||||
| JCISSndErrorAuth
|
||||
| JCISSndError {agentError :: AgentErrorType}
|
||||
| JCISRcvNew
|
||||
| JCISRcvRead
|
||||
deriving (Show, Generic)
|
||||
|
||||
instance ToJSON JSONCIStatus where
|
||||
toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCIS"
|
||||
toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCIS"
|
||||
|
||||
jsonCIStatus :: CIStatus d -> JSONCIStatus
|
||||
jsonCIStatus = \case
|
||||
CISSndNew -> JCISSndNew
|
||||
CISSndSent -> JCISSndSent
|
||||
CISSndErrorAuth -> JCISSndErrorAuth
|
||||
CISSndError e -> JCISSndError e
|
||||
CISRcvNew -> JCISRcvNew
|
||||
CISRcvRead -> JCISRcvRead
|
||||
|
||||
type ChatItemId = Int64
|
||||
|
||||
@@ -215,7 +303,7 @@ ciContentToText = \case
|
||||
CIRcvFileInvitation RcvFileTransfer {fileInvitation = FileInvitation {fileName}} -> "file " <> T.pack fileName
|
||||
|
||||
instance ToField (CIContent d) where
|
||||
toField = toField . decodeLatin1 . LB.toStrict . J.encode
|
||||
toField = toField . safeDecodeUtf8 . LB.toStrict . J.encode
|
||||
|
||||
instance ToJSON (CIContent d) where
|
||||
toJSON = J.toJSON . jsonCIContent
|
||||
@@ -419,3 +507,8 @@ msgDeliveryStatusT' s =
|
||||
case testEquality d (msgDirection @d) of
|
||||
Just Refl -> Just st
|
||||
_ -> Nothing
|
||||
|
||||
checkDirection :: forall t d d'. (MsgDirectionI d, MsgDirectionI d') => t d' -> Either String (t d)
|
||||
checkDirection x = case testEquality (msgDirection @d) (msgDirection @d') of
|
||||
Just Refl -> Right x
|
||||
Nothing -> Left "bad direction"
|
||||
|
||||
20
src/Simplex/Chat/Migrations/M20220205_chat_item_status.hs
Normal file
@@ -0,0 +1,20 @@
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
|
||||
module Simplex.Chat.Migrations.M20220205_chat_item_status where
|
||||
|
||||
import Database.SQLite.Simple (Query)
|
||||
import Database.SQLite.Simple.QQ (sql)
|
||||
|
||||
m20220205_chat_item_status :: Query
|
||||
m20220205_chat_item_status =
|
||||
[sql|
|
||||
PRAGMA ignore_check_constraints=ON;
|
||||
|
||||
ALTER TABLE chat_items ADD COLUMN item_status TEXT CHECK (item_status NOT NULL);
|
||||
|
||||
UPDATE chat_items SET item_status = 'rcv_read' WHERE item_sent = 0;
|
||||
|
||||
UPDATE chat_items SET item_status = 'snd_sent' WHERE item_sent = 1;
|
||||
|
||||
PRAGMA ignore_check_constraints=OFF;
|
||||
|]
|
||||
@@ -0,0 +1,25 @@
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
|
||||
module Simplex.Chat.Migrations.M20220210_deduplicate_contact_requests where
|
||||
|
||||
import Database.SQLite.Simple (Query)
|
||||
import Database.SQLite.Simple.QQ (sql)
|
||||
|
||||
m20220210_deduplicate_contact_requests :: Query
|
||||
m20220210_deduplicate_contact_requests =
|
||||
[sql|
|
||||
-- hash of contact address uri used by contact request sender to connect,
|
||||
-- null for contact request recipient and for both parties when using one-off invitation
|
||||
ALTER TABLE connections ADD COLUMN via_contact_uri_hash BLOB;
|
||||
CREATE INDEX idx_connections_via_contact_uri_hash ON connections (via_contact_uri_hash);
|
||||
|
||||
ALTER TABLE connections ADD COLUMN xcontact_id BLOB;
|
||||
|
||||
ALTER TABLE contact_requests ADD COLUMN xcontact_id BLOB;
|
||||
CREATE INDEX idx_contact_requests_xcontact_id ON contact_requests (xcontact_id);
|
||||
|
||||
ALTER TABLE contacts ADD COLUMN xcontact_id BLOB;
|
||||
CREATE INDEX idx_contacts_xcontact_id ON contacts (xcontact_id);
|
||||
|
||||
ALTER TABLE user_contact_links ADD column auto_accept INTEGER DEFAULT 0;
|
||||
|]
|
||||
@@ -6,13 +6,10 @@
|
||||
|
||||
module Simplex.Chat.Mobile where
|
||||
|
||||
import Control.Concurrent (forkIO)
|
||||
import Control.Concurrent.STM
|
||||
import Control.Monad.Except
|
||||
import Control.Monad.Reader
|
||||
import Data.Aeson (ToJSON (..), (.=))
|
||||
import Data.Aeson (ToJSON (..))
|
||||
import qualified Data.Aeson as J
|
||||
import qualified Data.Aeson.Encoding as JE
|
||||
import qualified Data.ByteString.Char8 as B
|
||||
import qualified Data.ByteString.Lazy.Char8 as LB
|
||||
import Data.List (find)
|
||||
@@ -26,47 +23,27 @@ import Simplex.Chat.Store
|
||||
import Simplex.Chat.Types
|
||||
import Simplex.Messaging.Protocol (CorrId (..))
|
||||
|
||||
foreign export ccall "chat_init_store" cChatInitStore :: CString -> IO (StablePtr ChatStore)
|
||||
|
||||
foreign export ccall "chat_get_user" cChatGetUser :: StablePtr ChatStore -> IO CJSONString
|
||||
|
||||
foreign export ccall "chat_create_user" cChatCreateUser :: StablePtr ChatStore -> CJSONString -> IO CJSONString
|
||||
|
||||
foreign export ccall "chat_start" cChatStart :: StablePtr ChatStore -> IO (StablePtr ChatController)
|
||||
foreign export ccall "chat_init" cChatInit :: CString -> IO (StablePtr ChatController)
|
||||
|
||||
foreign export ccall "chat_send_cmd" cChatSendCmd :: StablePtr ChatController -> CString -> IO CJSONString
|
||||
|
||||
foreign export ccall "chat_recv_msg" cChatRecvMsg :: StablePtr ChatController -> IO CJSONString
|
||||
|
||||
-- | creates or connects to chat store
|
||||
cChatInitStore :: CString -> IO (StablePtr ChatStore)
|
||||
cChatInitStore fp = peekCString fp >>= chatInitStore >>= newStablePtr
|
||||
|
||||
-- | returns JSON in the form `{"user": <user object>}` or `{}` in case there is no active user (to show dialog to enter displayName/fullName)
|
||||
cChatGetUser :: StablePtr ChatStore -> IO CJSONString
|
||||
cChatGetUser cc = deRefStablePtr cc >>= chatGetUser >>= newCString
|
||||
|
||||
-- | accepts Profile JSON, returns JSON `{"user": <user object>}` or `{"error": "<error>"}`
|
||||
cChatCreateUser :: StablePtr ChatStore -> CJSONString -> IO CJSONString
|
||||
cChatCreateUser cPtr profileCJson = do
|
||||
c <- deRefStablePtr cPtr
|
||||
p <- peekCString profileCJson
|
||||
newCString =<< chatCreateUser c p
|
||||
|
||||
-- | this function starts chat - it cannot be started during initialization right now, as it cannot work without user (to be fixed later)
|
||||
cChatStart :: StablePtr ChatStore -> IO (StablePtr ChatController)
|
||||
cChatStart st = deRefStablePtr st >>= chatStart >>= newStablePtr
|
||||
-- | initialize chat controller
|
||||
-- The active user has to be created and the chat has to be started before most commands can be used.
|
||||
cChatInit :: CString -> IO (StablePtr ChatController)
|
||||
cChatInit fp = peekCAString fp >>= chatInit >>= newStablePtr
|
||||
|
||||
-- | send command to chat (same syntax as in terminal for now)
|
||||
cChatSendCmd :: StablePtr ChatController -> CString -> IO CJSONString
|
||||
cChatSendCmd cPtr cCmd = do
|
||||
c <- deRefStablePtr cPtr
|
||||
cmd <- peekCString cCmd
|
||||
newCString =<< chatSendCmd c cmd
|
||||
cmd <- peekCAString cCmd
|
||||
newCAString =<< chatSendCmd c cmd
|
||||
|
||||
-- | receive message from chat (blocking)
|
||||
cChatRecvMsg :: StablePtr ChatController -> IO CJSONString
|
||||
cChatRecvMsg cc = deRefStablePtr cc >>= chatRecvMsg >>= newCString
|
||||
cChatRecvMsg cc = deRefStablePtr cc >>= chatRecvMsg >>= newCAString
|
||||
|
||||
mobileChatOpts :: ChatOpts
|
||||
mobileChatOpts =
|
||||
@@ -76,57 +53,33 @@ mobileChatOpts =
|
||||
logging = False
|
||||
}
|
||||
|
||||
defaultMobileConfig :: ChatConfig
|
||||
defaultMobileConfig =
|
||||
defaultChatConfig
|
||||
{ yesToMigrations = True,
|
||||
agentConfig = agentConfig defaultChatConfig {yesToMigrations = True}
|
||||
}
|
||||
|
||||
type CJSONString = CString
|
||||
|
||||
data ChatStore = ChatStore
|
||||
{ dbFilePrefix :: FilePath,
|
||||
chatStore :: SQLiteStore
|
||||
}
|
||||
|
||||
chatInitStore :: String -> IO ChatStore
|
||||
chatInitStore dbFilePrefix = do
|
||||
let f = chatStoreFile dbFilePrefix
|
||||
chatStore <- createStore f $ dbPoolSize defaultChatConfig
|
||||
pure ChatStore {dbFilePrefix, chatStore}
|
||||
|
||||
getActiveUser_ :: SQLiteStore -> IO (Maybe User)
|
||||
getActiveUser_ st = find activeUser <$> getUsers st
|
||||
|
||||
-- | returns JSON in the form `{"user": <user object>}` or `{}`
|
||||
chatGetUser :: ChatStore -> IO JSONString
|
||||
chatGetUser ChatStore {chatStore} =
|
||||
maybe "{}" userObject <$> getActiveUser_ chatStore
|
||||
|
||||
-- | returns JSON in the form `{"user": <user object>}` or `{"error": "<error>"}`
|
||||
chatCreateUser :: ChatStore -> JSONString -> IO JSONString
|
||||
chatCreateUser ChatStore {chatStore} profileJson =
|
||||
case J.eitherDecodeStrict' $ B.pack profileJson of
|
||||
Left e -> pure $ err e
|
||||
Right p -> either err userObject <$> runExceptT (createUser chatStore p True)
|
||||
where
|
||||
err e = jsonObject $ "error" .= show e
|
||||
|
||||
userObject :: User -> JSONString
|
||||
userObject user = jsonObject $ "user" .= user
|
||||
|
||||
chatStart :: ChatStore -> IO ChatController
|
||||
chatStart ChatStore {dbFilePrefix, chatStore} = do
|
||||
Just user <- getActiveUser_ chatStore
|
||||
cc <- newChatController chatStore user defaultChatConfig mobileChatOpts {dbFilePrefix} . const $ pure ()
|
||||
void . forkIO $ runReaderT runChatController cc
|
||||
pure cc
|
||||
chatInit :: String -> IO ChatController
|
||||
chatInit dbFilePrefix = do
|
||||
let f = chatStoreFile dbFilePrefix
|
||||
chatStore <- createStore f (dbPoolSize defaultMobileConfig) (yesToMigrations defaultMobileConfig)
|
||||
user_ <- getActiveUser_ chatStore
|
||||
newChatController chatStore user_ defaultMobileConfig mobileChatOpts {dbFilePrefix} (const $ pure ())
|
||||
|
||||
chatSendCmd :: ChatController -> String -> IO JSONString
|
||||
chatSendCmd cc s = LB.unpack . J.encode . APIResponse Nothing <$> runReaderT (execChatCommand s) cc
|
||||
chatSendCmd cc s = LB.unpack . J.encode . APIResponse Nothing <$> runReaderT (execChatCommand $ B.pack s) cc
|
||||
|
||||
chatRecvMsg :: ChatController -> IO JSONString
|
||||
chatRecvMsg ChatController {outputQ} = json <$> atomically (readTBQueue outputQ)
|
||||
where
|
||||
json (corr, resp) = LB.unpack $ J.encode APIResponse {corr, resp}
|
||||
|
||||
jsonObject :: J.Series -> JSONString
|
||||
jsonObject = LB.unpack . JE.encodingToLazyByteString . J.pairs
|
||||
|
||||
data APIResponse = APIResponse {corr :: Maybe CorrId, resp :: ChatResponse}
|
||||
deriving (Generic)
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ data ChatMsgEvent
|
||||
| XFile FileInvitation
|
||||
| XFileAcpt String
|
||||
| XInfo Profile
|
||||
| XContact Profile (Maybe MsgContent)
|
||||
| XContact Profile (Maybe XContactId)
|
||||
| XGrpInv GroupInvitation
|
||||
| XGrpAcpt MemberId
|
||||
| XGrpMemNew MemberInfo
|
||||
@@ -264,7 +264,7 @@ appToChatMessage AppMessage {event, params} = do
|
||||
XFile_ -> XFile <$> p "file"
|
||||
XFileAcpt_ -> XFileAcpt <$> p "fileName"
|
||||
XInfo_ -> XInfo <$> p "profile"
|
||||
XContact_ -> XContact <$> p "profile" <*> JT.parseEither (.:? "content") params
|
||||
XContact_ -> XContact <$> p "profile" <*> JT.parseEither (.:? "contactReqId") params
|
||||
XGrpInv_ -> XGrpInv <$> p "groupInvitation"
|
||||
XGrpAcpt_ -> XGrpAcpt <$> p "memberId"
|
||||
XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo"
|
||||
@@ -292,8 +292,8 @@ chatToAppMessage ChatMessage {chatMsgEvent} = AppMessage {event, params}
|
||||
XMsgNew content -> o ["content" .= content]
|
||||
XFile fileInv -> o ["file" .= fileInv]
|
||||
XFileAcpt fileName -> o ["fileName" .= fileName]
|
||||
XInfo profile -> o ["profile" .= profile]
|
||||
XContact profile content -> o $ maybe id ((:) . ("content" .=)) content ["profile" .= profile]
|
||||
XInfo profile -> o $ ["profile" .= profile]
|
||||
XContact profile xContactId -> o $ maybe id ((:) . ("contactReqId" .=)) xContactId ["profile" .= profile]
|
||||
XGrpInv groupInv -> o ["groupInvitation" .= groupInv]
|
||||
XGrpAcpt memId -> o ["memberId" .= memId]
|
||||
XGrpMemNew memInfo -> o ["memberInfo" .= memInfo]
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
|
||||
module Simplex.Chat.Terminal where
|
||||
|
||||
import Control.Logger.Simple
|
||||
import Control.Monad.Except
|
||||
import Control.Monad.Reader
|
||||
import Simplex.Chat
|
||||
import Simplex.Chat.Controller
|
||||
@@ -11,11 +15,11 @@ import Simplex.Chat.Terminal.Input
|
||||
import Simplex.Chat.Terminal.Notification
|
||||
import Simplex.Chat.Terminal.Output
|
||||
import Simplex.Chat.Types (User)
|
||||
import Simplex.Chat.Util (whenM)
|
||||
import Simplex.Messaging.Util (raceAny_)
|
||||
import UnliftIO (async, waitEither_)
|
||||
|
||||
simplexChat :: WithTerminal t => ChatConfig -> ChatOpts -> t -> IO ()
|
||||
simplexChat cfg opts t
|
||||
simplexChat cfg@ChatConfig {dbPoolSize, yesToMigrations} opts t
|
||||
| logging opts = do
|
||||
setLogLevel LogInfo -- LogError
|
||||
withGlobalLogging logCfg initRun
|
||||
@@ -24,13 +28,18 @@ simplexChat cfg opts t
|
||||
initRun = do
|
||||
sendNotification' <- initializeNotifications
|
||||
let f = chatStoreFile $ dbFilePrefix opts
|
||||
st <- createStore f $ dbPoolSize cfg
|
||||
st <- createStore f dbPoolSize yesToMigrations
|
||||
u <- getCreateActiveUser st
|
||||
ct <- newChatTerminal t
|
||||
cc <- newChatController st u cfg opts sendNotification'
|
||||
cc <- newChatController st (Just u) cfg opts sendNotification'
|
||||
runSimplexChat u ct cc
|
||||
|
||||
runSimplexChat :: User -> ChatTerminal -> ChatController -> IO ()
|
||||
runSimplexChat u ct = runReaderT $ do
|
||||
whenM (asks firstTime) . liftIO . printToTerminal ct $ chatWelcome u
|
||||
raceAny_ [runTerminalInput ct, runTerminalOutput ct, runInputLoop ct, runChatController]
|
||||
runSimplexChat u ct cc = do
|
||||
when (firstTime cc) . printToTerminal ct $ chatWelcome u
|
||||
a1 <- async $ runChatTerminal ct cc
|
||||
a2 <- runReaderT (startChatController u) cc
|
||||
waitEither_ a1 a2
|
||||
|
||||
runChatTerminal :: ChatTerminal -> ChatController -> IO ()
|
||||
runChatTerminal ct cc = raceAny_ [runTerminalInput ct cc, runTerminalOutput ct cc, runInputLoop ct cc]
|
||||
|
||||
@@ -9,6 +9,7 @@ import Control.Monad.IO.Unlift
|
||||
import Control.Monad.Reader
|
||||
import Data.List (dropWhileEnd)
|
||||
import qualified Data.Text as T
|
||||
import Data.Text.Encoding (encodeUtf8)
|
||||
import Simplex.Chat
|
||||
import Simplex.Chat.Controller
|
||||
import Simplex.Chat.Terminal.Output
|
||||
@@ -24,21 +25,17 @@ getKey =
|
||||
Right (KeyEvent key ms) -> pure (key, ms)
|
||||
_ -> getKey
|
||||
|
||||
runInputLoop :: (MonadUnliftIO m, MonadReader ChatController m) => ChatTerminal -> m ()
|
||||
runInputLoop ct = do
|
||||
q <- asks inputQ
|
||||
forever $ do
|
||||
s <- atomically $ readTBQueue q
|
||||
r <- execChatCommand s
|
||||
liftIO . printToTerminal ct $ responseToView s r
|
||||
runInputLoop :: ChatTerminal -> ChatController -> IO ()
|
||||
runInputLoop ct cc = forever $ do
|
||||
s <- atomically . readTBQueue $ inputQ cc
|
||||
r <- runReaderT (execChatCommand . encodeUtf8 $ T.pack s) cc
|
||||
let testV = testView $ config cc
|
||||
printToTerminal ct $ responseToView s testV r
|
||||
|
||||
runTerminalInput :: (MonadUnliftIO m, MonadReader ChatController m) => ChatTerminal -> m ()
|
||||
runTerminalInput ct = do
|
||||
cc <- ask
|
||||
liftIO $
|
||||
withChatTerm ct $ do
|
||||
updateInput ct
|
||||
receiveFromTTY cc ct
|
||||
runTerminalInput :: ChatTerminal -> ChatController -> IO ()
|
||||
runTerminalInput ct cc = withChatTerm ct $ do
|
||||
updateInput ct
|
||||
receiveFromTTY cc ct
|
||||
|
||||
receiveFromTTY :: MonadTerminal m => ChatController -> ChatTerminal -> m ()
|
||||
receiveFromTTY ChatController {inputQ, activeTo} ct@ChatTerminal {termSize, termState} =
|
||||
|
||||
@@ -72,11 +72,11 @@ withTermLock ChatTerminal {termLock} action = do
|
||||
action
|
||||
atomically $ putTMVar termLock ()
|
||||
|
||||
runTerminalOutput :: (MonadUnliftIO m, MonadReader ChatController m) => ChatTerminal -> m ()
|
||||
runTerminalOutput ct = do
|
||||
ChatController {outputQ} <- ask
|
||||
runTerminalOutput :: ChatTerminal -> ChatController -> IO ()
|
||||
runTerminalOutput ct cc = do
|
||||
let testV = testView $ config cc
|
||||
forever $
|
||||
atomically (readTBQueue outputQ) >>= liftIO . printToTerminal ct . responseToView "" . snd
|
||||
atomically (readTBQueue $ outputQ cc) >>= printToTerminal ct . responseToView "" testV . snd
|
||||
|
||||
printToTerminal :: ChatTerminal -> [StyledString] -> IO ()
|
||||
printToTerminal ct s =
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
{-# LANGUAGE TypeApplications #-}
|
||||
{-# LANGUAGE UndecidableInstances #-}
|
||||
|
||||
module Simplex.Chat.Types where
|
||||
@@ -100,13 +99,52 @@ data UserContactRequest = UserContactRequest
|
||||
localDisplayName :: ContactName,
|
||||
profileId :: Int64,
|
||||
profile :: Profile,
|
||||
createdAt :: UTCTime
|
||||
createdAt :: UTCTime,
|
||||
xContactId :: Maybe XContactId
|
||||
}
|
||||
deriving (Eq, Show, Generic, FromJSON)
|
||||
|
||||
instance ToJSON UserContactRequest where
|
||||
toEncoding = J.genericToEncoding J.defaultOptions
|
||||
|
||||
newtype XContactId = XContactId ByteString
|
||||
deriving (Eq, Show)
|
||||
|
||||
instance FromField XContactId where fromField f = XContactId <$> fromField f
|
||||
|
||||
instance ToField XContactId where toField (XContactId m) = toField m
|
||||
|
||||
instance StrEncoding XContactId where
|
||||
strEncode (XContactId m) = strEncode m
|
||||
strDecode s = XContactId <$> strDecode s
|
||||
strP = XContactId <$> strP
|
||||
|
||||
instance FromJSON XContactId where
|
||||
parseJSON = strParseJSON "XContactId"
|
||||
|
||||
instance ToJSON XContactId where
|
||||
toJSON = strToJSON
|
||||
toEncoding = strToJEncoding
|
||||
|
||||
newtype ConnReqUriHash = ConnReqUriHash {unConnReqUriHash :: ByteString}
|
||||
deriving (Eq, Show)
|
||||
|
||||
instance FromField ConnReqUriHash where fromField f = ConnReqUriHash <$> fromField f
|
||||
|
||||
instance ToField ConnReqUriHash where toField (ConnReqUriHash m) = toField m
|
||||
|
||||
instance StrEncoding ConnReqUriHash where
|
||||
strEncode (ConnReqUriHash m) = strEncode m
|
||||
strDecode s = ConnReqUriHash <$> strDecode s
|
||||
strP = ConnReqUriHash <$> strP
|
||||
|
||||
instance FromJSON ConnReqUriHash where
|
||||
parseJSON = strParseJSON "ConnReqUriHash"
|
||||
|
||||
instance ToJSON ConnReqUriHash where
|
||||
toJSON = strToJSON
|
||||
toEncoding = strToJEncoding
|
||||
|
||||
type ContactName = Text
|
||||
|
||||
type GroupName = Text
|
||||
|
||||
@@ -19,7 +19,7 @@ import Numeric (showFFloat)
|
||||
import Simplex.Chat.Controller
|
||||
import Simplex.Chat.Help
|
||||
import Simplex.Chat.Markdown
|
||||
import Simplex.Chat.Messages
|
||||
import Simplex.Chat.Messages hiding (NewChatItem (..))
|
||||
import Simplex.Chat.Protocol
|
||||
import Simplex.Chat.Store (StoreError (..))
|
||||
import Simplex.Chat.Styled
|
||||
@@ -30,15 +30,19 @@ import qualified Simplex.Messaging.Protocol as SMP
|
||||
import System.Console.ANSI.Types
|
||||
|
||||
serializeChatResponse :: ChatResponse -> String
|
||||
serializeChatResponse = unlines . map unStyle . responseToView ""
|
||||
serializeChatResponse = unlines . map unStyle . responseToView "" False
|
||||
|
||||
responseToView :: String -> ChatResponse -> [StyledString]
|
||||
responseToView cmd = \case
|
||||
CRApiChats chats -> api [sShow chats]
|
||||
CRApiChat chat -> api [sShow chat]
|
||||
responseToView :: String -> Bool -> ChatResponse -> [StyledString]
|
||||
responseToView cmd testView = \case
|
||||
CRActiveUser User {profile} -> r $ viewUserProfile profile
|
||||
CRChatStarted -> r ["chat started"]
|
||||
CRApiChats chats -> r $ if testView then testViewChats chats else [sShow chats]
|
||||
CRApiChat chat -> r $ if testView then testViewChat chat else [sShow chat]
|
||||
CRNewChatItem (AChatItem _ _ chat item) -> viewChatItem chat item
|
||||
CRChatItemUpdated _ -> []
|
||||
CRMsgIntegrityError mErr -> viewMsgIntegrityError mErr
|
||||
CRCmdAccepted _ -> r []
|
||||
CRCmdOk -> r ["ok"]
|
||||
CRChatHelp section -> case section of
|
||||
HSMain -> r chatHelpInfo
|
||||
HSFiles -> r filesHelpInfo
|
||||
@@ -47,7 +51,8 @@ responseToView cmd = \case
|
||||
HSMarkdown -> r markdownInfo
|
||||
CRWelcome user -> r $ chatWelcome user
|
||||
CRContactsList cs -> r $ viewContactsList cs
|
||||
CRUserContactLink cReq -> r $ connReqContact_ "Your chat address:" cReq
|
||||
CRUserContactLink cReqUri _ -> r $ connReqContact_ "Your chat address:" cReqUri
|
||||
CRUserContactLinkUpdated _ autoAccept -> r ["auto_accept " <> if autoAccept then "on" else "off"]
|
||||
CRContactRequestRejected UserContactRequest {localDisplayName = c} -> r [ttyContact c <> ": contact request rejected"]
|
||||
CRGroupCreated g -> r $ viewGroupCreated g
|
||||
CRGroupMembers g -> r $ viewGroupMembers g
|
||||
@@ -56,13 +61,15 @@ responseToView cmd = \case
|
||||
CRFileTransferStatus ftStatus -> r $ viewFileTransferStatus ftStatus
|
||||
CRUserProfile p -> r $ viewUserProfile p
|
||||
CRUserProfileNoChange -> r ["user profile did not change"]
|
||||
CRVersionInfo -> r [plain versionStr, plain updateStr]
|
||||
CRVersionInfo _ -> r [plain versionStr, plain updateStr]
|
||||
CRChatCmdError e -> r $ viewChatError e
|
||||
CRInvitation cReq -> r' $ viewConnReqInvitation cReq
|
||||
CRSentConfirmation -> r' ["confirmation sent!"]
|
||||
CRSentInvitation -> r' ["connection request sent!"]
|
||||
CRContactDeleted Contact {localDisplayName = c} -> r' [ttyContact c <> ": contact is deleted"]
|
||||
CRAcceptingContactRequest Contact {localDisplayName = c} -> r' [ttyContact c <> ": accepting contact request..."]
|
||||
CRContactDeleted c -> r' [ttyContact' c <> ": contact is deleted"]
|
||||
CRAcceptingContactRequest c -> r' [ttyFullContact c <> ": accepting contact request..."]
|
||||
CRContactAlreadyExists c -> r [ttyFullContact c <> ": contact already exists"]
|
||||
CRContactRequestAlreadyAccepted c -> r' [ttyFullContact c <> ": sent you a duplicate contact request, but you are already connected, no action needed"]
|
||||
CRUserContactLinkCreated cReq -> r' $ connReqContact_ "Your new chat address is created!" cReq
|
||||
CRUserContactLinkDeleted -> r' viewUserContactLinkDeleted
|
||||
CRUserAcceptedGroupSent _g -> r' [] -- [ttyGroup' g <> ": joining the group..."]
|
||||
@@ -86,6 +93,7 @@ responseToView cmd = \case
|
||||
CRSndFileCancelled ft -> sendingFile_ "cancelled" ft
|
||||
CRSndFileRcvCancelled ft@SndFileTransfer {recipientDisplayName = c} ->
|
||||
[ttyContact c <> " cancelled receiving " <> sndFile ft]
|
||||
CRContactConnecting _ -> []
|
||||
CRContactConnected ct -> [ttyFullContact ct <> ": contact is connected"]
|
||||
CRContactAnotherClient c -> [ttyContact' c <> ": contact is connected to another client"]
|
||||
CRContactDisconnected c -> [ttyContact' c <> ": disconnected from server (messages will be queued)"]
|
||||
@@ -115,11 +123,25 @@ responseToView cmd = \case
|
||||
CRMessageError prefix err -> [plain prefix <> ": " <> plain err]
|
||||
CRChatError e -> viewChatError e
|
||||
where
|
||||
api = (highlight cmd :)
|
||||
r = (plain cmd :)
|
||||
-- this function should be `r` for "synchronous", `id` for "asynchronous" command responses
|
||||
-- r' = r
|
||||
r' = id
|
||||
testViewChats :: [AChat] -> [StyledString]
|
||||
testViewChats chats = [sShow $ map toChatView chats]
|
||||
where
|
||||
toChatView :: AChat -> (Text, Text)
|
||||
toChatView (AChat _ (Chat (DirectChat Contact {localDisplayName}) items _)) = ("@" <> localDisplayName, toCIPreview items)
|
||||
toChatView (AChat _ (Chat (GroupChat GroupInfo {localDisplayName}) items _)) = ("#" <> localDisplayName, toCIPreview items)
|
||||
toChatView (AChat _ (Chat (ContactRequest UserContactRequest {localDisplayName}) items _)) = ("<@" <> localDisplayName, toCIPreview items)
|
||||
toCIPreview :: [CChatItem c] -> Text
|
||||
toCIPreview ((CChatItem _ ChatItem {meta}) : _) = itemText meta
|
||||
toCIPreview _ = ""
|
||||
testViewChat :: AChat -> [StyledString]
|
||||
testViewChat (AChat _ Chat {chatItems}) = [sShow $ map toChatView chatItems]
|
||||
where
|
||||
toChatView :: CChatItem c -> (Int, Text)
|
||||
toChatView (CChatItem dir ChatItem {meta}) = (msgDirectionInt $ toMsgDirection dir, itemText meta)
|
||||
|
||||
viewChatItem :: ChatInfo c -> ChatItem c d -> [StyledString]
|
||||
viewChatItem chat (ChatItem cd meta content) = case (chat, cd) of
|
||||
@@ -307,10 +329,10 @@ viewContactUpdated
|
||||
where
|
||||
fullNameUpdate = if T.null fullName' || fullName' == n' then " removed full name" else " updated full name: " <> plain fullName'
|
||||
|
||||
viewReceivedMessage :: StyledString -> CIMeta -> MsgContent -> [StyledString]
|
||||
viewReceivedMessage :: StyledString -> CIMeta d -> MsgContent -> [StyledString]
|
||||
viewReceivedMessage from meta mc = receivedWithTime_ from meta (ttyMsgContent mc)
|
||||
|
||||
receivedWithTime_ :: StyledString -> CIMeta -> [StyledString] -> [StyledString]
|
||||
receivedWithTime_ :: StyledString -> CIMeta d -> [StyledString] -> [StyledString]
|
||||
receivedWithTime_ from CIMeta {localItemTs, createdAt} styledMsg = do
|
||||
prependFirst (formattedTime <> " " <> from) styledMsg -- ++ showIntegrity mOk
|
||||
where
|
||||
@@ -325,13 +347,13 @@ receivedWithTime_ from CIMeta {localItemTs, createdAt} styledMsg = do
|
||||
else "%H:%M"
|
||||
in styleTime $ formatTime defaultTimeLocale format localTime
|
||||
|
||||
viewSentMessage :: StyledString -> MsgContent -> CIMeta -> [StyledString]
|
||||
viewSentMessage :: StyledString -> MsgContent -> CIMeta d -> [StyledString]
|
||||
viewSentMessage to = sentWithTime_ . prependFirst to . ttyMsgContent
|
||||
|
||||
viewSentFileInvitation :: StyledString -> FileTransferId -> FilePath -> CIMeta -> [StyledString]
|
||||
viewSentFileInvitation :: StyledString -> FileTransferId -> FilePath -> CIMeta d -> [StyledString]
|
||||
viewSentFileInvitation to fId fPath = sentWithTime_ $ ttySentFile to fId fPath
|
||||
|
||||
sentWithTime_ :: [StyledString] -> CIMeta -> [StyledString]
|
||||
sentWithTime_ :: [StyledString] -> CIMeta d -> [StyledString]
|
||||
sentWithTime_ styledMsg CIMeta {localItemTs} =
|
||||
prependFirst (ttyMsgTime localItemTs <> " ") styledMsg
|
||||
|
||||
@@ -370,7 +392,7 @@ sendingFile_ status ft@SndFileTransfer {recipientDisplayName = c} =
|
||||
sndFile :: SndFileTransfer -> StyledString
|
||||
sndFile SndFileTransfer {fileId, fileName} = fileTransferStr fileId fileName
|
||||
|
||||
viewReceivedFileInvitation :: StyledString -> CIMeta -> RcvFileTransfer -> [StyledString]
|
||||
viewReceivedFileInvitation :: StyledString -> CIMeta d -> RcvFileTransfer -> [StyledString]
|
||||
viewReceivedFileInvitation from meta ft = receivedWithTime_ from meta (receivedFileInvitation_ ft)
|
||||
|
||||
receivedFileInvitation_ :: RcvFileTransfer -> [StyledString]
|
||||
@@ -447,8 +469,13 @@ fileProgress chunksNum chunkSize fileSize =
|
||||
viewChatError :: ChatError -> [StyledString]
|
||||
viewChatError = \case
|
||||
ChatError err -> case err of
|
||||
CENoActiveUser -> ["error: active user is required"]
|
||||
CEActiveUserExists -> ["error: active user already exists"]
|
||||
CEChatNotStarted -> ["error: chat not started"]
|
||||
CEInvalidConnReq -> viewInvalidConnReq
|
||||
CEContactGroups Contact {localDisplayName} gNames -> [ttyContact localDisplayName <> ": contact cannot be deleted, it is a member of the group(s) " <> ttyGroups gNames]
|
||||
CEInvalidChatMessage e -> ["chat message error: " <> sShow e]
|
||||
CEContactNotReady c -> [ttyContact' c <> ": not ready"]
|
||||
CEContactGroups c gNames -> [ttyContact' c <> ": contact cannot be deleted, it is a member of the group(s) " <> ttyGroups gNames]
|
||||
CEGroupDuplicateMember c -> ["contact " <> ttyContact c <> " is already in the group"]
|
||||
CEGroupDuplicateMemberId -> ["cannot add member - duplicate member ID"]
|
||||
CEGroupUserRole -> ["you have insufficient permissions for this group command"]
|
||||
@@ -488,8 +515,6 @@ viewChatError = \case
|
||||
ChatErrorAgent err -> case err of
|
||||
SMP SMP.AUTH -> ["error: this connection is deleted"]
|
||||
e -> ["smp agent error: " <> sShow e]
|
||||
ChatErrorMessage e -> ["chat message error: " <> sShow e]
|
||||
ChatErrorNotImplemented -> ["chat error: not implemented"]
|
||||
where
|
||||
fileNotFound fileId = ["file " <> sShow fileId <> " not found"]
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ extra-deps:
|
||||
# - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561
|
||||
# - ../simplexmq
|
||||
- github: simplex-chat/simplexmq
|
||||
commit: 137ff7043d49feb3b350f56783c9b64a62bc636a
|
||||
commit: 229e2607d76f3d6baf0d2623b186c084e3908b8f
|
||||
# - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977
|
||||
- github: simplex-chat/aeson
|
||||
commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7
|
||||
|
||||
@@ -70,16 +70,17 @@ cfg :: ChatConfig
|
||||
cfg =
|
||||
defaultChatConfig
|
||||
{ agentConfig =
|
||||
aCfg {reconnectInterval = (reconnectInterval aCfg) {initialInterval = 50000}}
|
||||
aCfg {reconnectInterval = (reconnectInterval aCfg) {initialInterval = 50000}},
|
||||
testView = True
|
||||
}
|
||||
|
||||
virtualSimplexChat :: FilePath -> Profile -> IO TestCC
|
||||
virtualSimplexChat dbFilePrefix profile = do
|
||||
st <- createStore (dbFilePrefix <> "_chat.db") 1
|
||||
st <- createStore (dbFilePrefix <> "_chat.db") 1 False
|
||||
Right user <- runExceptT $ createUser st profile True
|
||||
t <- withVirtualTerminal termSettings pure
|
||||
ct <- newChatTerminal t
|
||||
cc <- newChatController st user cfg opts {dbFilePrefix} . const $ pure () -- no notifications
|
||||
cc <- newChatController st (Just user) cfg opts {dbFilePrefix} (const $ pure ()) -- no notifications
|
||||
chatAsync <- async $ runSimplexChat user ct cc
|
||||
termQ <- newTQueueIO
|
||||
termAsync <- async $ readTerminalOutput t termQ
|
||||
@@ -108,16 +109,18 @@ readTerminalOutput t termQ = do
|
||||
then map (dropWhileEnd (== ' ')) diff
|
||||
else getDiff_ (n + 1) len win' win
|
||||
|
||||
testChatN :: [Profile] -> ([TestCC] -> IO ()) -> IO ()
|
||||
testChatN ps test =
|
||||
withTmpFiles :: IO () -> IO ()
|
||||
withTmpFiles =
|
||||
bracket_
|
||||
(createDirectoryIfMissing False "tests/tmp")
|
||||
(removeDirectoryRecursive "tests/tmp")
|
||||
$ do
|
||||
let envs = zip ps $ map ((testDBPrefix <>) . show) [(1 :: Int) ..]
|
||||
tcs <- getTestCCs envs []
|
||||
test tcs
|
||||
concurrentlyN_ $ map (<// 100000) tcs
|
||||
|
||||
testChatN :: [Profile] -> ([TestCC] -> IO ()) -> IO ()
|
||||
testChatN ps test = withTmpFiles $ do
|
||||
let envs = zip ps $ map ((testDBPrefix <>) . show) [(1 :: Int) ..]
|
||||
tcs <- getTestCCs envs []
|
||||
test tcs
|
||||
concurrentlyN_ $ map (<// 100000) tcs
|
||||
where
|
||||
getTestCCs [] tcs = pure tcs
|
||||
getTestCCs ((p, db) : envs') tcs = (:) <$> virtualSimplexChat db p <*> getTestCCs envs' tcs
|
||||
|
||||
@@ -10,8 +10,9 @@ import Control.Concurrent.Async (concurrently_)
|
||||
import Control.Concurrent.STM
|
||||
import qualified Data.ByteString as B
|
||||
import Data.Char (isDigit)
|
||||
import Data.Maybe (fromJust)
|
||||
import qualified Data.Text as T
|
||||
import Simplex.Chat.Controller
|
||||
import Simplex.Chat.Controller (ChatController (..))
|
||||
import Simplex.Chat.Types (Profile (..), User (..))
|
||||
import Simplex.Chat.Util (unlessM)
|
||||
import System.Directory (doesFileExist)
|
||||
@@ -51,6 +52,9 @@ chatTests = do
|
||||
it "send and receive file to group" testGroupFileTransfer
|
||||
describe "user contact link" $ do
|
||||
it "should create and connect via contact link" testUserContactLink
|
||||
it "should auto accept contact requests" testUserContactLinkAutoAccept
|
||||
it "should deduplicate contact requests" testDeduplicateContactRequests
|
||||
it "should deduplicate contact requests with profile change" testDeduplicateContactRequestsProfileChange
|
||||
it "should reject contact and delete contact link" testRejectContactAndDeleteUserContact
|
||||
it "should delete connection requests when contact link deleted" testDeleteConnectionRequests
|
||||
|
||||
@@ -65,10 +69,31 @@ testAddContact =
|
||||
concurrently_
|
||||
(bob <## "alice (Alice): contact is connected")
|
||||
(alice <## "bob (Bob): contact is connected")
|
||||
alice #> "@bob hello"
|
||||
bob <# "alice> hello"
|
||||
-- empty chats
|
||||
alice #$$> ("/_get chats", [("@bob", "")])
|
||||
alice #$> ("/_get chat @2 count=100", chat, [])
|
||||
bob #$$> ("/_get chats", [("@alice", "")])
|
||||
bob #$> ("/_get chat @2 count=100", chat, [])
|
||||
-- one message
|
||||
alice #> "@bob hello 🙂"
|
||||
bob <# "alice> hello 🙂"
|
||||
alice #$$> ("/_get chats", [("@bob", "hello 🙂")])
|
||||
alice #$> ("/_get chat @2 count=100", chat, [(1, "hello 🙂")])
|
||||
bob #$$> ("/_get chats", [("@alice", "hello 🙂")])
|
||||
bob #$> ("/_get chat @2 count=100", chat, [(0, "hello 🙂")])
|
||||
-- many messages
|
||||
bob #> "@alice hi"
|
||||
alice <# "bob> hi"
|
||||
alice #$$> ("/_get chats", [("@bob", "hi")])
|
||||
alice #$> ("/_get chat @2 count=100", chat, [(1, "hello 🙂"), (0, "hi")])
|
||||
bob #$$> ("/_get chats", [("@alice", "hi")])
|
||||
bob #$> ("/_get chat @2 count=100", chat, [(0, "hello 🙂"), (1, "hi")])
|
||||
-- pagination
|
||||
alice #$> ("/_get chat @2 after=1 count=100", chat, [(0, "hi")])
|
||||
alice #$> ("/_get chat @2 before=2 count=100", chat, [(1, "hello 🙂")])
|
||||
-- read messages
|
||||
alice #$> ("/_read chat @2 from=1 to=100", id, "ok")
|
||||
bob #$> ("/_read chat @2 from=1 to=100", id, "ok")
|
||||
-- test adding the same contact one more time - local name will be different
|
||||
alice ##> "/c"
|
||||
inv' <- getInvitation alice
|
||||
@@ -81,11 +106,15 @@ testAddContact =
|
||||
bob <# "alice_1> hello"
|
||||
bob #> "@alice_1 hi"
|
||||
alice <# "bob_1> hi"
|
||||
alice #$$> ("/_get chats", [("@bob_1", "hi"), ("@bob", "hi")])
|
||||
bob #$$> ("/_get chats", [("@alice_1", "hi"), ("@alice", "hi")])
|
||||
-- test deleting contact
|
||||
alice ##> "/d bob_1"
|
||||
alice <## "bob_1: contact is deleted"
|
||||
alice ##> "@bob_1 hey"
|
||||
alice <## "no contact bob_1"
|
||||
alice #$$> ("/_get chats", [("@bob", "hi")])
|
||||
bob #$$> ("/_get chats", [("@alice_1", "hi"), ("@alice", "hi")])
|
||||
|
||||
testGroup :: IO ()
|
||||
testGroup =
|
||||
@@ -132,11 +161,23 @@ testGroup =
|
||||
concurrently_
|
||||
(alice <# "#team bob> hi there")
|
||||
(cath <# "#team bob> hi there")
|
||||
cath #> "#team hey"
|
||||
cath #> "#team hey team"
|
||||
concurrently_
|
||||
(alice <# "#team cath> hey")
|
||||
(bob <# "#team cath> hey")
|
||||
(alice <# "#team cath> hey team")
|
||||
(bob <# "#team cath> hey team")
|
||||
bob <##> cath
|
||||
-- get and read chats
|
||||
alice #$$> ("/_get chats", [("#team", "hey team"), ("@cath", ""), ("@bob", "")])
|
||||
alice #$> ("/_get chat #1 count=100", chat, [(1, "hello"), (0, "hi there"), (0, "hey team")])
|
||||
alice #$> ("/_get chat #1 after=1 count=100", chat, [(0, "hi there"), (0, "hey team")])
|
||||
alice #$> ("/_get chat #1 before=3 count=100", chat, [(1, "hello"), (0, "hi there")])
|
||||
bob #$$> ("/_get chats", [("@cath", "hey"), ("#team", "hey team"), ("@alice", "")])
|
||||
bob #$> ("/_get chat #1 count=100", chat, [(0, "hello"), (1, "hi there"), (0, "hey team")])
|
||||
cath #$$> ("/_get chats", [("@bob", "hey"), ("#team", "hey team"), ("@alice", "")])
|
||||
cath #$> ("/_get chat #1 count=100", chat, [(0, "hello"), (0, "hi there"), (1, "hey team")])
|
||||
alice #$> ("/_read chat #1 from=1 to=100", id, "ok")
|
||||
bob #$> ("/_read chat #1 from=1 to=100", id, "ok")
|
||||
cath #$> ("/_read chat #1 from=1 to=100", id, "ok")
|
||||
-- list groups
|
||||
alice ##> "/gs"
|
||||
alice <## "#team"
|
||||
@@ -660,20 +701,183 @@ testUserContactLink = testChat3 aliceProfile bobProfile cathProfile $
|
||||
cLink <- getContactLink alice True
|
||||
bob ##> ("/c " <> cLink)
|
||||
alice <#? bob
|
||||
alice #$$> ("/_get chats", [("<@bob", "")])
|
||||
alice ##> "/ac bob"
|
||||
alice <## "bob: accepting contact request..."
|
||||
alice <## "bob (Bob): accepting contact request..."
|
||||
concurrently_
|
||||
(bob <## "alice (Alice): contact is connected")
|
||||
(alice <## "bob (Bob): contact is connected")
|
||||
alice #$$> ("/_get chats", [("@bob", "")])
|
||||
alice <##> bob
|
||||
|
||||
cath ##> ("/c " <> cLink)
|
||||
alice <#? cath
|
||||
alice #$$> ("/_get chats", [("<@cath", ""), ("@bob", "hey")])
|
||||
alice ##> "/ac cath"
|
||||
alice <## "cath: accepting contact request..."
|
||||
alice <## "cath (Catherine): accepting contact request..."
|
||||
concurrently_
|
||||
(cath <## "alice (Alice): contact is connected")
|
||||
(alice <## "cath (Catherine): contact is connected")
|
||||
alice #$$> ("/_get chats", [("@cath", ""), ("@bob", "hey")])
|
||||
alice <##> cath
|
||||
|
||||
testUserContactLinkAutoAccept :: IO ()
|
||||
testUserContactLinkAutoAccept =
|
||||
testChat4 aliceProfile bobProfile cathProfile danProfile $
|
||||
\alice bob cath dan -> do
|
||||
alice ##> "/ad"
|
||||
cLink <- getContactLink alice True
|
||||
|
||||
bob ##> ("/c " <> cLink)
|
||||
alice <#? bob
|
||||
alice #$$> ("/_get chats", [("<@bob", "")])
|
||||
alice ##> "/ac bob"
|
||||
alice <## "bob (Bob): accepting contact request..."
|
||||
concurrently_
|
||||
(bob <## "alice (Alice): contact is connected")
|
||||
(alice <## "bob (Bob): contact is connected")
|
||||
alice #$$> ("/_get chats", [("@bob", "")])
|
||||
alice <##> bob
|
||||
|
||||
alice ##> "/auto_accept on"
|
||||
alice <## "auto_accept on"
|
||||
|
||||
cath ##> ("/c " <> cLink)
|
||||
cath <## "connection request sent!"
|
||||
alice <## "cath (Catherine): accepting contact request..."
|
||||
concurrently_
|
||||
(cath <## "alice (Alice): contact is connected")
|
||||
(alice <## "cath (Catherine): contact is connected")
|
||||
alice #$$> ("/_get chats", [("@cath", ""), ("@bob", "hey")])
|
||||
alice <##> cath
|
||||
|
||||
alice ##> "/auto_accept off"
|
||||
alice <## "auto_accept off"
|
||||
|
||||
dan ##> ("/c " <> cLink)
|
||||
alice <#? dan
|
||||
alice #$$> ("/_get chats", [("<@dan", ""), ("@cath", "hey"), ("@bob", "hey")])
|
||||
alice ##> "/ac dan"
|
||||
alice <## "dan (Daniel): accepting contact request..."
|
||||
concurrently_
|
||||
(dan <## "alice (Alice): contact is connected")
|
||||
(alice <## "dan (Daniel): contact is connected")
|
||||
alice #$$> ("/_get chats", [("@dan", ""), ("@cath", "hey"), ("@bob", "hey")])
|
||||
alice <##> dan
|
||||
|
||||
testDeduplicateContactRequests :: IO ()
|
||||
testDeduplicateContactRequests = testChat3 aliceProfile bobProfile cathProfile $
|
||||
\alice bob cath -> do
|
||||
alice ##> "/ad"
|
||||
cLink <- getContactLink alice True
|
||||
|
||||
bob ##> ("/c " <> cLink)
|
||||
alice <#? bob
|
||||
alice #$$> ("/_get chats", [("<@bob", "")])
|
||||
|
||||
bob ##> ("/c " <> cLink)
|
||||
alice <#? bob
|
||||
bob ##> ("/c " <> cLink)
|
||||
alice <#? bob
|
||||
alice #$$> ("/_get chats", [("<@bob", "")])
|
||||
|
||||
alice ##> "/ac bob"
|
||||
alice <## "bob (Bob): accepting contact request..."
|
||||
concurrently_
|
||||
(bob <## "alice (Alice): contact is connected")
|
||||
(alice <## "bob (Bob): contact is connected")
|
||||
|
||||
bob ##> ("/c " <> cLink)
|
||||
bob <## "alice (Alice): contact already exists"
|
||||
alice #$$> ("/_get chats", [("@bob", "")])
|
||||
bob #$$> ("/_get chats", [("@alice", "")])
|
||||
|
||||
alice <##> bob
|
||||
alice #$$> ("/_get chats", [("@bob", "hey")])
|
||||
bob #$$> ("/_get chats", [("@alice", "hey")])
|
||||
|
||||
bob ##> ("/c " <> cLink)
|
||||
bob <## "alice (Alice): contact already exists"
|
||||
|
||||
alice <##> bob
|
||||
alice #$> ("/_get chat @2 count=100", chat, [(1, "hi"), (0, "hey"), (1, "hi"), (0, "hey")])
|
||||
bob #$> ("/_get chat @2 count=100", chat, [(0, "hi"), (1, "hey"), (0, "hi"), (1, "hey")])
|
||||
|
||||
cath ##> ("/c " <> cLink)
|
||||
alice <#? cath
|
||||
alice #$$> ("/_get chats", [("<@cath", ""), ("@bob", "hey")])
|
||||
alice ##> "/ac cath"
|
||||
alice <## "cath (Catherine): accepting contact request..."
|
||||
concurrently_
|
||||
(cath <## "alice (Alice): contact is connected")
|
||||
(alice <## "cath (Catherine): contact is connected")
|
||||
alice #$$> ("/_get chats", [("@cath", ""), ("@bob", "hey")])
|
||||
alice <##> cath
|
||||
|
||||
testDeduplicateContactRequestsProfileChange :: IO ()
|
||||
testDeduplicateContactRequestsProfileChange = testChat3 aliceProfile bobProfile cathProfile $
|
||||
\alice bob cath -> do
|
||||
alice ##> "/ad"
|
||||
cLink <- getContactLink alice True
|
||||
|
||||
bob ##> ("/c " <> cLink)
|
||||
alice <#? bob
|
||||
alice #$$> ("/_get chats", [("<@bob", "")])
|
||||
|
||||
bob ##> "/p bob"
|
||||
bob <## "user full name removed (your contacts are notified)"
|
||||
bob ##> ("/c " <> cLink)
|
||||
bob <## "connection request sent!"
|
||||
alice <## "bob wants to connect to you!"
|
||||
alice <## "to accept: /ac bob"
|
||||
alice <## "to reject: /rc bob (the sender will NOT be notified)"
|
||||
alice #$$> ("/_get chats", [("<@bob", "")])
|
||||
|
||||
bob ##> "/p bob Bob Ross"
|
||||
bob <## "user full name changed to Bob Ross (your contacts are notified)"
|
||||
bob ##> ("/c " <> cLink)
|
||||
alice <#? bob
|
||||
alice #$$> ("/_get chats", [("<@bob", "")])
|
||||
|
||||
bob ##> "/p robert Robert"
|
||||
bob <## "user profile is changed to robert (Robert) (your contacts are notified)"
|
||||
bob ##> ("/c " <> cLink)
|
||||
alice <#? bob
|
||||
alice #$$> ("/_get chats", [("<@robert", "")])
|
||||
|
||||
alice ##> "/ac bob"
|
||||
alice <## "no contact request from bob"
|
||||
alice ##> "/ac robert"
|
||||
alice <## "robert (Robert): accepting contact request..."
|
||||
concurrently_
|
||||
(bob <## "alice (Alice): contact is connected")
|
||||
(alice <## "robert (Robert): contact is connected")
|
||||
|
||||
bob ##> ("/c " <> cLink)
|
||||
bob <## "alice (Alice): contact already exists"
|
||||
alice #$$> ("/_get chats", [("@robert", "")])
|
||||
bob #$$> ("/_get chats", [("@alice", "")])
|
||||
|
||||
alice <##> bob
|
||||
alice #$$> ("/_get chats", [("@robert", "hey")])
|
||||
bob #$$> ("/_get chats", [("@alice", "hey")])
|
||||
|
||||
bob ##> ("/c " <> cLink)
|
||||
bob <## "alice (Alice): contact already exists"
|
||||
|
||||
alice <##> bob
|
||||
alice #$> ("/_get chat @2 count=100", chat, [(1, "hi"), (0, "hey"), (1, "hi"), (0, "hey")])
|
||||
bob #$> ("/_get chat @2 count=100", chat, [(0, "hi"), (1, "hey"), (0, "hi"), (1, "hey")])
|
||||
|
||||
cath ##> ("/c " <> cLink)
|
||||
alice <#? cath
|
||||
alice #$$> ("/_get chats", [("<@cath", ""), ("@robert", "hey")])
|
||||
alice ##> "/ac cath"
|
||||
alice <## "cath (Catherine): accepting contact request..."
|
||||
concurrently_
|
||||
(cath <## "alice (Alice): contact is connected")
|
||||
(alice <## "cath (Catherine): contact is connected")
|
||||
alice #$$> ("/_get chats", [("@cath", ""), ("@robert", "hey")])
|
||||
alice <##> cath
|
||||
|
||||
testRejectContactAndDeleteUserContact :: IO ()
|
||||
@@ -753,7 +957,7 @@ connectUsers cc1 cc2 = do
|
||||
|
||||
showName :: TestCC -> IO String
|
||||
showName (TestCC ChatController {currentUser} _ _ _ _) = do
|
||||
User {localDisplayName, profile = Profile {fullName}} <- readTVarIO currentUser
|
||||
Just User {localDisplayName, profile = Profile {fullName}} <- readTVarIO currentUser
|
||||
pure . T.unpack $ localDisplayName <> " (" <> fullName <> ")"
|
||||
|
||||
createGroup2 :: String -> TestCC -> TestCC -> IO ()
|
||||
@@ -811,7 +1015,7 @@ cc1 <##> cc2 = do
|
||||
cc1 <# (name2 <> "> hey")
|
||||
|
||||
userName :: TestCC -> IO [Char]
|
||||
userName (TestCC ChatController {currentUser} _ _ _ _) = T.unpack . localDisplayName <$> readTVarIO currentUser
|
||||
userName (TestCC ChatController {currentUser} _ _ _ _) = T.unpack . localDisplayName . fromJust <$> readTVarIO currentUser
|
||||
|
||||
(##>) :: TestCC -> String -> IO ()
|
||||
cc ##> cmd = do
|
||||
@@ -823,6 +1027,21 @@ cc #> cmd = do
|
||||
cc `send` cmd
|
||||
cc <# cmd
|
||||
|
||||
(#$>) :: (Eq a, Show a) => TestCC -> (String, String -> a, a) -> Expectation
|
||||
cc #$> (cmd, f, res) = do
|
||||
cc ##> cmd
|
||||
(f <$> getTermLine cc) `shouldReturn` res
|
||||
|
||||
chat :: String -> [(Int, String)]
|
||||
chat = read
|
||||
|
||||
(#$$>) :: TestCC -> (String, [(String, String)]) -> Expectation
|
||||
cc #$$> (cmd, res) = do
|
||||
cc ##> cmd
|
||||
line <- getTermLine cc
|
||||
let chats = read line
|
||||
chats `shouldMatchList` res
|
||||
|
||||
send :: TestCC -> String -> IO ()
|
||||
send TestCC {chatController = cc} cmd = atomically $ writeTBQueue (inputQ cc) cmd
|
||||
|
||||
|
||||