Compare commits

..

54 Commits

Author SHA1 Message Date
Efim Poberezkin
c580c34a35 1.2.0 2022-02-14 21:55:39 +04:00
Efim Poberezkin
fdf312d9e1 ios: add contactNotReady error type (#304) 2022-02-14 21:52:01 +04:00
Evgeny Poberezkin
44d8b549c4 return version number to mobile (#303) 2022-02-14 21:51:50 +04:00
Efim Poberezkin
928dd27043 prepare v1.2.0 (#302) 2022-02-14 21:21:16 +04:00
Efim Poberezkin
4419051347 connection precedence logic in getContact_ (fixes asynchronous establishment of connection) (#300) 2022-02-14 18:49:42 +04:00
Evgeny Poberezkin
8cf88019e5 ios public beta announcement (#298)
* ios public beta announcement

* update post

* corrections

* corrections

* update blog links

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2022-02-14 13:48:21 +00:00
Evgeny Poberezkin
710971a0cd show confirmation alert after the connection (#299)
* show confirmation alert after the connection

* update build number
2022-02-14 11:53:44 +00:00
Efim Poberezkin
dc306dfcd0 option to auto-accept contact requests (#296) 2022-02-14 14:59:11 +04:00
Mark Aleksander Hil
e90520a5ec update banner (#297) 2022-02-14 10:29:16 +00:00
Evgeny Poberezkin
7805bd1e45 show large unread numbers 2022-02-13 10:09:09 +00:00
Efim Poberezkin
c1c55ca700 deduplicate contact requests (#287)
* deprecate XContact

* XInfoId

* xInfoId tests

* merging

* saving on connection

* connectByAddress

* remove old connect

* deduplicate contact requests

* check on contact acceptance

* test

* rename response

* reuse CRContactRequestAlreadyAccepted

* Update src/Simplex/Chat.hs

* createConnReqConnection

* simplify controller logic

* store methods + profile change

* index

* more indices

* unXInfoId

* simplify

* XInfo with ID -> XContact

* sync reply to Connect when contact already exists

* update view for sync CRContactAlreadyExists command response

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-02-13 09:19:24 +00:00
Evgeny Poberezkin
8e34d2fbbc fix swift 2022-02-13 09:13:06 +00:00
Evgeny Poberezkin
61afb64dd7 search chats, longer emojis (#295)
* search chats, longer emojis

* simplify
2022-02-13 08:45:08 +00:00
Evgeny Poberezkin
aa2bc545db update build number (8) 2022-02-12 18:02:52 +00:00
Evgeny Poberezkin
067f122b05 iOS app version 0.3.1 2022-02-12 17:28:37 +00:00
Evgeny Poberezkin
9d9bb68d50 iOS: show message sent/unread status (#293)
* light github image for dark mode

* show message received status, remove chevrons in chat list

* show unread message status

* add message send error mark

* refactor alerts to use AlertManager

* show alert message on tapping undelivered message, simplify text-only alerts
2022-02-12 15:59:43 +00:00
Efim Poberezkin
af5abae558 fix group leave (#294)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-02-12 13:17:11 +04:00
Efim Poberezkin
0ea8705014 1.1.1 2022-02-11 12:05:22 +04:00
Efim Poberezkin
92409820fb enable async commands (#290)
* enable async

* fix async command error response

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-02-11 12:03:34 +04:00
Evgeny Poberezkin
98fc6c6adf chat usage help and minor UI fixes (#291)
* chat usage help and minor UI fixes

* update version, build and binary
2022-02-11 07:42:00 +00:00
Efim Poberezkin
771bc6a14d prepare v1.1.1 (#289) 2022-02-10 20:08:29 +04:00
Evgeny Poberezkin
86c36f53e4 simplify and fix background loading (#288)
* simplify and fix background loading

* start receive loop in the main chat
2022-02-10 15:52:11 +00:00
Efim Poberezkin
5c24089f9f check group member connection status before delivery; best effort delivery per group member (#286) 2022-02-10 17:03:36 +04:00
Evgeny Poberezkin
516c8d79ad receiving messages in the background and sending local notifications (#284)
* receiving messages in the background and sending local notifications

* show notifications in foreground and background

* presentation logic for notification options when app is in the foreground

* background refresh works

* remove async dispatch
2022-02-09 22:53:06 +00:00
Efim Poberezkin
ff7a8cade1 test chat items (#285) 2022-02-09 20:58:02 +04:00
Efim Poberezkin
7af4cdffee add unreadCount and minUnreadItemId stats to Chat type (#283) 2022-02-08 20:38:57 +04:00
Efim Poberezkin
b06838b651 add APIChatRead chat command (#282) 2022-02-08 17:27:43 +04:00
Evgeny Poberezkin
b3a4c21c4b updated text items (#278)
* updated text items

* update version

* fix JSON parsing in CIDirection, refactor data samples

* show group member in received messages and chat preview

* use profile displayName instead of localDisplayName, do not show fullName if it is the same as displayName
2022-02-08 09:19:25 +00:00
Efim Poberezkin
855881094b add CRContactConnecting api response (#281) 2022-02-08 13:04:17 +04:00
Efim Poberezkin
82d02e923a ios: add CIStatus type (#280) 2022-02-08 11:20:41 +04:00
Efim Poberezkin
d11d66fa90 connection precedence logic in getDirectChatPreviews_; update item status in object (#279) 2022-02-07 18:34:54 +04:00
Efim Poberezkin
f5507436f3 chat item status, CRChatItemUpdated api response (#269) 2022-02-07 15:19:34 +04:00
Evgeny Poberezkin
eeea33c7cb fix loading chat, contact connection status info (#277) 2022-02-07 10:36:11 +00:00
Evgeny Poberezkin
7883ca7657 improve text message view (#276)
* show text and time on the same line

* convert emails and phones to links
2022-02-06 21:06:02 +00:00
Evgeny Poberezkin
8efb8b2f86 use simplified chat controller, fix keyboard removing on tap (#275) 2022-02-06 18:26:22 +00:00
Evgeny Poberezkin
408a30c25b simplify mobile API to have single controller (#274)
* simplify mobile API to have single controller

* update chat response in swift

* add async to stack
2022-02-06 16:18:01 +00:00
Evgeny Poberezkin
9b67aa537a each command takes lock if it needs it (#273) 2022-02-06 08:21:40 +00:00
Evgeny Poberezkin
5aabf87898 ios: highlight URLs in texts (#272)
* ios: highlight URLs in texts

* Apply suggestions from code review
2022-02-06 07:44:41 +00:00
Evgeny Poberezkin
67dbdcd257 contact and server connection info (#271) 2022-02-05 20:10:47 +00:00
Evgeny Poberezkin
3d137995d8 multiline message entry field (#270) 2022-02-05 14:24:23 +00:00
Evgeny Poberezkin
e424e9328b large emojis, full contact names, contact createdAt, process profile updates, etc. (#268) 2022-02-04 22:13:52 +00:00
Evgeny Poberezkin
214ecf605b minor UI improvements (#267) 2022-02-04 16:31:08 +00:00
Evgeny Poberezkin
7d06d0660d Merge pull request #266 from simplex-chat/ep/fix-utf8-api
fix utf8 encoding for C API requests
2022-02-04 12:46:45 +00:00
Evgeny Poberezkin
c34eddb82a fix utf8 encoding for C API requests 2022-02-04 12:41:43 +00:00
Efim Poberezkin
9969606432 fix utf8 encoding when writing to database 2022-02-04 14:30:00 +04:00
Evgeny Poberezkin
d8abdb7927 Merge pull request #265 from simplex-chat/ep/sync-cmd
fix C string UTF8 encoding, revert to sync commands
2022-02-04 08:50:52 +00:00
Evgeny Poberezkin
71a60795cf Merge pull request #263 from simplex-chat/ep/ios-fixes
configure build for device/simulator
2022-02-04 08:17:18 +00:00
Evgeny Poberezkin
d07ce0b8f4 use 8 byte characters, as encoding is handled elsewhere 2022-02-04 08:15:25 +00:00
Evgeny Poberezkin
565bc70843 sync commands 2022-02-04 08:02:48 +00:00
Efim Poberezkin
7924861810 sort chat items by id (#264) 2022-02-04 11:12:12 +04:00
Evgeny Poberezkin
08dd92b726 configure build for device/simulator 2022-02-03 18:22:05 +00:00
Evgeny Poberezkin
dca5dc4fce iOS version 1.0.1 2022-02-03 07:18:17 +00:00
Evgeny Poberezkin
24f3637199 add animations (#260)
* add animations

* improve settings screen

* app icons
2022-02-03 07:16:29 +00:00
Efim Poberezkin
4dd95c1639 create release as prerelease; fix windows build (#261) 2022-02-03 10:15:38 +04:00
103 changed files with 4002 additions and 1187 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -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!
[![GitHub build](https://github.com/simplex-chat/simplex-chat/workflows/build/badge.svg)](https://github.com/simplex-chat/simplex-chat/actions?query=workflow%3Abuild)
[![GitHub downloads](https://img.shields.io/github/downloads/simplex-chat/simplex-chat/total)](https://github.com/simplex-chat/simplex-chat/releases)
@@ -10,11 +10,11 @@ SimpleX - the most private and secure open-source chat and applications platform
[![Follow on Twitter](https://img.shields.io/twitter/follow/SimpleXChat?style=social)](https://twitter.com/simplexchat)
[![Join on Reddit](https://img.shields.io/reddit/subreddit-subscribers/SimpleXChat?style=social)](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

View File

@@ -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>

View File

@@ -1,6 +1,15 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "0.533",
"red" : "0.000"
}
},
"idiom" : "universal"
}
],

View File

@@ -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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 824 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -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 {

View 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)
}
}
}

View File

@@ -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

View 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)
}
}

View File

@@ -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
}

View File

@@ -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")

View File

@@ -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&amp;CharacterRangeLoc=91&amp;EndingColumnNumber=0&amp;EndingLineNumber=7&amp;StartingColumnNumber=3&amp;StartingLineNumber=6&amp;Timestamp=665423482.97412"
documentLocation = "file:///Users/evgeny/opensource/simplex-chat/simplex-chat/apps/ios/Shared/MyPlayground.playground#CharacterRangeLen=88&amp;CharacterRangeLoc=91&amp;EndingColumnNumber=0&amp;EndingLineNumber=7&amp;StartingColumnNumber=3&amp;StartingLineNumber=6&amp;Timestamp=666087303.155273"
selectedRepresentationIndex = "0"
shouldTrackSuperviewWidth = "NO">
</LoggerValueHistoryTimelineItem>

View File

@@ -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);

View File

@@ -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);

View File

@@ -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()
}
}
}
}

View 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: []))
}
}

View 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)
}
}

View 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))
}
}

View 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))
}
}

View 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))
}
}

View File

@@ -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))
}
}

View File

@@ -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)
}
}

View 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)

View File

@@ -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
)
}
}
}

View 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)
}
}

View File

@@ -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: []
))
}

View File

@@ -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())
}
}
}

View File

@@ -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))
}
}

View File

@@ -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))
}
}

View 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))
}
}

View 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()
// }
//}

View 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)
}
}

View File

@@ -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])
}
}
}

View File

@@ -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)
}
}

View File

@@ -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()

View File

@@ -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()
}
}

View File

@@ -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()

View File

@@ -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)")
}
}
})

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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)")
}
}
}
}

View File

@@ -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>

View File

@@ -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;

View 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).

View File

@@ -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)

View File

@@ -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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 92 KiB

View File

@@ -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

View 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

View File

@@ -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";

View File

@@ -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:

View File

@@ -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

View File

@@ -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)

View File

@@ -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"

View 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;
|]

View File

@@ -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;
|]

View File

@@ -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)

View File

@@ -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]

File diff suppressed because it is too large Load Diff

View File

@@ -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]

View File

@@ -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} =

View File

@@ -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 =

View File

@@ -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

View File

@@ -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"]

View File

@@ -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

View File

@@ -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

View File

@@ -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

Some files were not shown because too many files have changed in this diff Show More